PlaylistBridge
A command-line tool that reads a YouTube Music playlist — straight from its public URL, or from a copy-pasted page dump — and adds the matching tracks to Spotify Liked Songs. Built with an extensible architecture so that other source/target platform pairs can be added later without touching the core matching or checkpointing logic.
Architecture
The domain model is deliberately kept platform-agnostic (src/models.py):
SourceTrack, SearchCandidate, MatchResult, and MigrationSummary don't
know anything about YouTube Music or Spotify specifically. Every other module
only talks to these shared types, not to each other's internals.
parser/ytmusic_playlist.pyreads a public playlist by URL viaytmusicapi(no login) and produces the sameSourceTrackentries.parser/ytmusic_parser.pyturns the raw page dump intoSourceTrackentries and builds an ordered list ofSearchCandidateguesses per entry.spotify_client/client.pywrapsspotipy: OAuth setup, rate-limit backoff, search, and adding to Liked Songs.matcher/track_matcher.pyscores a Spotify search hit against the source entry and flags low-confidence matches.state/checkpoint.pytracks which entries have already been migrated so re-running the tool is safe and cheap.cli.pywires all of the above together and prints an end-of-run summary.
Design Decisions
Platform-agnostic shared models. SourceTrack and SearchCandidate don't
reference YouTube Music by name, and MatchResult doesn't reference Spotify.
The parser is the only thing that needs to know about YouTube Music's page
format, and the client is the only thing that needs to know about Spotify's
API shape. Adding, say, an Apple Music target later means writing a new
client module that consumes SearchCandidate and produces MatchResult — no
changes to the matching or checkpointing logic.
Incremental checkpoint with atomic writes. A migration run can touch
hundreds of tracks and take a long time once rate-limit backoff kicks in.
CheckpointStore flushes to disk every few entries (not just once at the
end), and each flush writes to a temp file and os.replace()s it over the
real file, so a crash or Ctrl+C mid-write never corrupts the checkpoint —
worst case you lose the last few unflushed entries, not the whole file.
Fuzzy matching with suspicious-match flagging. Spotify's search endpoint
returns its own idea of the best match, which is frequently wrong for
non-official uploads, remixes, or channels named after the artist rather than
the artist's own page. Instead of trusting the top result blindly, every
match is scored against the original entry (title-weighted, since artist
data is sometimes just a channel name) and anything below
SUSPICIOUS_SCORE_THRESHOLD is flagged for manual review rather than
silently trusted.
Dry-run mode. Because the tool mutates a real Spotify library, --dry-run
lets you see exactly what would be searched for — without creating a Spotify
client, without any network calls, and without touching checkpoint state —
before committing to a real run.
Testing & Lessons Learned
65/65 tests pass (pytest -v). Two real bugs were caught during development
and are worth calling out because they weren't obvious from the code alone:
-
Empty
source.artistsilently tanked otherwise-perfect matches.score_matchalready handled an empty candidate artist (the title-only search fallback) by scoring on title alone. But when the source entry's own artist field was empty — which happens wheneverbuild_candidates's title-only fallback is what actually finds a hit — the fuzzy ratio between""and a real artist name scores0, dragging a 100%-title-match down to ~60% overall. Fixed by falling back to title-only scoring whenever either side's artist is empty, with a regression test (test_score_match_uses_title_only_when_source_artist_is_empty). -
suspicious_tracks.txtwas opened in append mode. Since the suspicious-duration check runs before the checkpoint lookup, a track that's suspiciously long gets logged on every single run, whether or not it was already migrated. Opening the log in"a"mode meant every run added another copy of the same entries on top of the last run's. Fixed by overwriting the file every run instead (test_run_migration_overwrites_suspicious_log_instead_of_appendingcallsrun_migrationtwice and asserts the log contains exactly one copy of the entry, not two).
Setup & Usage
Install
pipx install playlist-bridge # or: pip install playlist-bridge
From source (for development): pip install -e ".[dev]".
Configure Spotify credentials
-
Go to the Spotify Developer Dashboard and create an app.
-
Copy its Client ID and Client Secret.
-
Add a Redirect URI in the app settings that matches what you'll use locally, e.g.
http://127.0.0.1:8888/callback. -
Create a
.envfile in the folder you'll run the command from (or export the variables in your shell) with the three values:SPOTIPY_CLIENT_ID=your-client-id SPOTIPY_CLIENT_SECRET=your-client-secret SPOTIPY_REDIRECT_URI=http://127.0.0.1:8888/callback
Each user brings their own Spotify app. That keeps every run inside your own app's development-mode quota instead of a shared one.
Pick a source
Option A: a public playlist URL (recommended). Copy the playlist's share link from YouTube Music or YouTube — no YouTube login or copy-paste needed. Private playlists and your Liked Music aren't reachable this way; use option B for those.
Option B: a page dump. Open your YouTube Music playlist or library page in a browser, select all
(Ctrl+A / Cmd+A), copy, and paste the result into ytmusic_paste.txt in the
project root (or any path you pass to --input).
Copy the whole page, not just the track list. Include the header/menu text above the tracks — the parser locates the line mentioning both "views" and "tracks" and skips everything up to and including it, so any nav/menu text above the tracks is discarded cleanly. If you paste only the track list itself, that header line won't be there, and the parser has no reliable anchor for where the real entries start.
Run
# Report what would happen, without touching Spotify or checkpoint state
playlist-bridge --playlist "https://music.youtube.com/playlist?list=PL..." --dry-run
# Actually migrate
playlist-bridge --playlist "https://music.youtube.com/playlist?list=PL..."
# Or from a page dump
playlist-bridge --input ytmusic_paste.txt
The first real run opens a browser window for Spotify OAuth consent. Progress
is checkpointed to processed_tracks.json, so an interrupted run can simply
be re-run — already-migrated tracks are skipped. Suspiciously long entries
(over 10 minutes) are written to suspicious_tracks.txt for manual review.
Run the checks
ruff check . && mypy playlist_bridge && pytest -v
Releasing
Bump version in pyproject.toml, then push a matching tag (git tag v0.2.0 && git push --tags).
.github/workflows/publish.yml builds and uploads to PyPI via trusted publishing (no token
stored in the repo); it refuses to publish if the tag and the package version differ.
Tech Stack
- Python 3.10+
- spotipy — Spotify Web API client and OAuth
- rapidfuzz — fuzzy string matching for track scoring
- ytmusicapi — reading public YouTube Music playlists (unofficial API)
- python-dotenv — loading Spotify credentials from
.env - pytest — test suite
Future Work
The platform-agnostic model design leaves room to grow in a few directions:
- Private playlists and Liked Music. Public playlists are read via
ytmusicapiwithout login; private ones still need the page-dump path.ytmusicapi's browser-auth mode could cover them. - Reverse migration (Spotify → YouTube Music). The shared model types were designed with this in mind — it would mainly require a YouTube Music write client alongside the existing Spotify one.
- Other platforms. Apple Music, Tidal, and others could be added as either a source or a target without touching the core matching or checkpointing logic.
Metadata
Release files for playlist-bridge 0.2.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| playlist_bridge-0.2.0.tar.gz | 24.4 kB | With provenance | Details for playlist_bridge-0.2.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| playlist_bridge-0.2.0-py3-none-any.whl | Python 3 | none | any | Details for playlist_bridge-0.2.0-py3-none-any.whl |
Total release size: 42.9 kB
Release files / playlist_bridge-0.2.0.tar.gz
| Download URL | playlist_bridge-0.2.0.tar.gz |
|---|---|
| Size | 24.4 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
e7fce94dede54b5fd66baac856ff614cb77f9333e5784106c352e1dd3559b152
|
|
BLAKE2b-256 checksum How to use checksums |
2abd516df62ac9a1be44776895c45dfd61b944040b404e398025457dba594ac4
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 9, 2026.
Transparency logRelease files / playlist_bridge-0.2.0-py3-none-any.whl
| Download URL | playlist_bridge-0.2.0-py3-none-any.whl |
|---|---|
| Size | 18.5 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
9c15e6b954482428b7eda32e248f5a45b8d6345761af07c95ca03b7ceb886327
|
|
BLAKE2b-256 checksum How to use checksums |
b4143a81ebd7acc555e65db477a5644049138951e4982f671b66ffc65778c4e9
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 9, 2026.
Transparency logSecurity for playlist-bridge 0.2.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
Both files in this release have been uploaded with provenance metadata.
Attestation for playlist_bridge-0.2.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 9, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.