# How SpotifySaver's YouTube Music Search Algorithm Matches Spotify Metadata

> Discover how SpotifySaver's algorithm matches Spotify tracks to YouTube Music. Learn about metadata normalization, fallback searches, and weighted scoring for precise results.

- Repository: [Gabriel Baute/spotify-saver](https://github.com/gabrielbaute/spotify-saver)
- Tags: internals
- Published: 2026-03-02

---

**SpotifySaver matches Spotify tracks to YouTube Music using a multi-stage algorithm that normalizes metadata, executes hierarchical fallback searches, and applies a weighted scoring system to select the best candidate.**

SpotifySaver, an open-source tool maintained in the `gabrielbaute/spotify-saver` repository, bridges the gap between Spotify's streaming catalog and YouTube Music's library through sophisticated metadata matching. The algorithm orchestrates three core Python modules—`YoutubeMusicSearcher`, `ScoreMatchCalculator`, and the `Track` model—to translate Spotify URI metadata into precise YouTube Music URLs with high accuracy.

## Metadata Normalization Pipeline

Before executing any search, the algorithm sanitizes both query and candidate strings through a custom `_normalize` helper function implemented in [`spotifysaver/services/youtube_api.py`](https://github.com/gabrielbaute/spotify-saver/blob/main/spotifysaver/services/youtube_api.py) and [`spotifysaver/services/score_match_calculator.py`](https://github.com/gabrielbaute/spotify-saver/blob/main/spotifysaver/services/score_match_calculator.py). This function lowercases text, strips common noise words like *official*, *video*, *lyrics*, and *audio*, removes punctuation including parentheses and brackets, and collapses whitespace.

The normalization code at lines 56-74 in [`youtube_api.py`](https://github.com/gabrielbaute/spotify-saver/blob/main/youtube_api.py) ensures that "Blinding Lights (Official Video)" and "blinding lights" are treated as identical strings during comparison. This preprocessing is critical for the fuzzy matching stages that follow, as it eliminates formatting discrepancies that would otherwise break exact string comparisons.

## Hierarchical Search Strategy with Fallbacks

The `YoutubeMusicSearcher` class in [`spotifysaver/services/youtube_api.py`](https://github.com/gabrielbaute/spotify-saver/blob/main/spotifysaver/services/youtube_api.py) implements a resilient three-tier fallback system within its `_search_with_fallback` method (lines 75-99). Each strategy progressively relaxes constraints until a viable candidate emerges:

**1. Exact Match Search (`_search_exact_match`)**
- Constructs a query from normalized artist, track, and album fields
- Searches YouTube Music's song catalog with `ignore_spelling=True`
- Limits results to 5 candidates for efficiency

**2. Album Context Search (`_search_album_context`)**
- Activates when direct song searches fail
- Retrieves the full track list from the Spotify album on YouTube Music
- Scores each track in the album context to find the correct position

**3. Fuzzy Match Search (`_search_fuzzy_match`)**
- Broadens the search to 10 results with spelling corrections disabled
- Tolerates greater deviation in track naming conventions
- Serves as the final safety net before failure

Each strategy returns candidate results to `_process_results`, which hands them to the scoring module for evaluation. The first strategy to yield a candidate meeting the score threshold wins, and the system immediately returns the corresponding YouTube Music URL.

## Weighted Scoring Algorithm

The `ScoreMatchCalculator` class defined in [`spotifysaver/services/score_match_calculator.py`](https://github.com/gabrielbaute/spotify-saver/blob/main/spotifysaver/services/score_match_calculator.py) evaluates candidates through `_calculate_match_score` (lines 45-64), aggregating four weighted components into a final confidence score between 0.0 and 1.0:

**Duration Matching (`_score_duration`)** — Weight: 0.3
- Calculates absolute difference between Spotify and YouTube Music track lengths
- Awards perfect score if difference ≤ 2 seconds
- Applies linear degradation for larger discrepancies

**Artist Overlap (`_score_artist_overlap`)** — Weight: 0.3 + 0.1 bonus
- Computes Jaccard-like set intersection of normalized artist names
- Adds 0.1 bonus when the primary album artist matches exactly

**Title Similarity (`_score_title_similarity`)** — Weight: 0.3
- Uses `SequenceMatcher` for normalized Levenshtein-style ratio calculation
- Applies penalty if token overlap falls below 30%
- Implements safety cap: if title similarity < 0.1, total score cannot exceed 0.5 regardless of other factors

**Album Bonus (`_score_album_bonus`)** — Weight: +0.1
- Adds flat boost when YouTube result's album field contains the Spotify album name

The candidate passes if the `total_score` meets the threshold: **0.7** for strict mode or **0.6** for standard mode.

## Error Handling and Caching

The `search_track` method implements defensive programming with `self.max_retries = 3` attempts for transient failures. Specific exceptions—`AlbumNotFoundError` and `InvalidResultError`—trigger retry logic, while unexpected errors are logged before returning `None` (lines 38-57 in [`youtube_api.py`](https://github.com/gabrielbaute/spotify-saver/blob/main/youtube_api.py)).

Performance optimization comes through an `@lru_cache(maxsize=100)` decorator applied to `search_track` (lines 26-28). This LRU cache stores results for identical `Track` objects, eliminating redundant API calls when processing playlist duplicates or handling repeated downloads.

## Implementation Example

The following demonstrates how to invoke the search algorithm programmatically using the actual classes from the repository:

```python
from spotifysaver.services.youtube_api import YoutubeMusicSearcher
from spotifysaver.models.track import Track

# Initialize with Spotify metadata

track = Track(
    number=1,
    total_tracks=10,
    name="Blinding Lights",
    duration=200,
    uri="spotify:track:7ouMYWpwJ422jRcDASZB7P",
    artists=["The Weeknd"],
    album_artist=["The Weeknd"],
    release_date="2020-03-20",
    disc_number=1,
    source_type="album",
    album_name="After Hours",
)

searcher = YoutubeMusicSearcher()
yt_url = searcher.search_track(track)

print(f"YouTube Music URL: {yt_url}")

```

To inspect why a particular candidate was selected or rejected during debugging, access the scorer's explanation method:

```python
raw_results = searcher.search_raw(track)
for candidate in raw_results:
    explanation = searcher.scorer.explain_score(candidate, track, strict=False)
    print(explanation)

```

This outputs the component scores (duration, artist overlap, title similarity, and album bonus) alongside the final threshold decision.

## Summary

- **Three-module architecture**: `YoutubeMusicSearcher` orchestrates the flow, `ScoreMatchCalculator` evaluates candidates, and the `Track` model provides immutable Spotify metadata
- **Noise-resistant matching**: The `_normalize` function strips non-semantic words and punctuation to enable fuzzy comparisons
- **Tiered fallback logic**: Exact match → Album context → Fuzzy search ensures high precision without sacrificing recall
- **Weighted scoring**: Duration, artist overlap, and title similarity each contribute 30% to the final score, with a 10% album name bonus
- **Threshold enforcement**: Strict mode requires 0.7 confidence; standard mode accepts 0.6, with title similarity caps preventing false positives
- **Performance features**: LRU caching (size 100) and 3-attempt retry logic with specific exception handling optimize API usage

## Frequently Asked Questions

### How does SpotifySaver handle spelling variations in artist or track names?

The algorithm tolerates spelling variations through multiple mechanisms. The `_normalize` function in [`spotifysaver/services/youtube_api.py`](https://github.com/gabrielbaute/spotify-saver/blob/main/spotifysaver/services/youtube_api.py) preprocesses strings to remove case sensitivity and punctuation. During scoring, `_score_title_similarity` uses `SequenceMatcher` to calculate Levenshtein-style ratios, allowing minor character differences. If exact searches fail, the fuzzy match strategy disables spelling corrections entirely (`ignore_spelling=False` in YouTube Music's API), letting the weighted scoring algorithm select the closest candidate regardless of minor spelling mismatches.

### What happens when no YouTube Music match meets the scoring threshold?

When all three fallback strategies fail to produce a candidate scoring above the threshold (0.6 for standard mode, 0.7 for strict), `YoutubeMusicSearcher.search_track` returns `None` after logging a warning. The calling code in `spotifysaver/spotsaver/spotsaver` handles this null return by skipping the track or reporting it as unavailable, depending on the CLI command context. The retry logic (3 attempts) only triggers for specific exceptions like `AlbumNotFoundError`, not for threshold failures, ensuring the system fails fast when metadata simply does not match.

### Can I adjust the strictness of the metadata matching algorithm?

Yes. The `search_track` method accepts a `strict` boolean parameter that adjusts the acceptance threshold from 0.6 (standard) to 0.7 (strict). When `strict=True`, the `ScoreMatchCalculator` requires higher confidence in duration, artist overlap, and title similarity before returning a match. Additionally, you can instantiate `YoutubeMusicSearcher` with custom scoring weights by modifying the `ScoreMatchCalculator` constants, though this requires subclassing since the weights are defined as class attributes in [`spotifysaver/services/score_match_calculator.py`](https://github.com/gabrielbaute/spotify-saver/blob/main/spotifysaver/services/score_match_calculator.py).

### How does the caching mechanism affect repeated searches?

The `@lru_cache(maxsize=100)` decorator on `search_track` caches results based on the `Track` object's hash equality. Identical Spotify tracks—common when processing playlists containing duplicates or when re-running the tool—return instantly without hitting the YouTube Music API. The cache stores up to 100 unique `Track` objects in memory; least recently used entries are evicted when this limit is exceeded. Note that the cache is process-bound and does not persist between CLI invocations, only within a single Python session.