playlist-bridge 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. playlist_bridge-0.2.0/LICENSE +21 -0
  2. playlist_bridge-0.2.0/PKG-INFO +212 -0
  3. playlist_bridge-0.2.0/README.md +183 -0
  4. playlist_bridge-0.2.0/playlist_bridge/__init__.py +1 -0
  5. playlist_bridge-0.2.0/playlist_bridge/cli.py +160 -0
  6. playlist_bridge-0.2.0/playlist_bridge/matcher/__init__.py +1 -0
  7. playlist_bridge-0.2.0/playlist_bridge/matcher/track_matcher.py +52 -0
  8. playlist_bridge-0.2.0/playlist_bridge/models.py +62 -0
  9. playlist_bridge-0.2.0/playlist_bridge/parser/__init__.py +1 -0
  10. playlist_bridge-0.2.0/playlist_bridge/parser/ytmusic_parser.py +133 -0
  11. playlist_bridge-0.2.0/playlist_bridge/parser/ytmusic_playlist.py +63 -0
  12. playlist_bridge-0.2.0/playlist_bridge/spotify_client/__init__.py +1 -0
  13. playlist_bridge-0.2.0/playlist_bridge/spotify_client/client.py +93 -0
  14. playlist_bridge-0.2.0/playlist_bridge/state/__init__.py +1 -0
  15. playlist_bridge-0.2.0/playlist_bridge/state/checkpoint.py +72 -0
  16. playlist_bridge-0.2.0/playlist_bridge.egg-info/PKG-INFO +212 -0
  17. playlist_bridge-0.2.0/playlist_bridge.egg-info/SOURCES.txt +28 -0
  18. playlist_bridge-0.2.0/playlist_bridge.egg-info/dependency_links.txt +1 -0
  19. playlist_bridge-0.2.0/playlist_bridge.egg-info/entry_points.txt +2 -0
  20. playlist_bridge-0.2.0/playlist_bridge.egg-info/requires.txt +10 -0
  21. playlist_bridge-0.2.0/playlist_bridge.egg-info/top_level.txt +1 -0
  22. playlist_bridge-0.2.0/pyproject.toml +52 -0
  23. playlist_bridge-0.2.0/setup.cfg +4 -0
  24. playlist_bridge-0.2.0/tests/test_checkpoint.py +84 -0
  25. playlist_bridge-0.2.0/tests/test_cli.py +189 -0
  26. playlist_bridge-0.2.0/tests/test_matcher.py +90 -0
  27. playlist_bridge-0.2.0/tests/test_models.py +22 -0
  28. playlist_bridge-0.2.0/tests/test_parser.py +154 -0
  29. playlist_bridge-0.2.0/tests/test_spotify_client.py +119 -0
  30. playlist_bridge-0.2.0/tests/test_ytmusic_playlist.py +62 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Basar Tuncay
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,212 @@
1
+ Metadata-Version: 2.4
2
+ Name: playlist-bridge
3
+ Version: 0.2.0
4
+ Summary: Migrate YouTube Music playlists into Spotify Liked Songs, with fuzzy matching and resumable runs.
5
+ Author: Basar Tuncay
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/basartuncay/PlaylistBridge
8
+ Project-URL: Issues, https://github.com/basartuncay/PlaylistBridge/issues
9
+ Keywords: spotify,youtube-music,playlist,migration,cli
10
+ Classifier: Environment :: Console
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Topic :: Multimedia :: Sound/Audio
16
+ Requires-Python: >=3.10
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ Requires-Dist: spotipy>=2.23
20
+ Requires-Dist: rapidfuzz>=3.0
21
+ Requires-Dist: python-dotenv>=1.0
22
+ Requires-Dist: ytmusicapi>=1.8
23
+ Provides-Extra: dev
24
+ Requires-Dist: pytest>=7.0; extra == "dev"
25
+ Requires-Dist: ruff>=0.6; extra == "dev"
26
+ Requires-Dist: mypy>=1.11; extra == "dev"
27
+ Requires-Dist: build>=1.2; extra == "dev"
28
+ Dynamic: license-file
29
+
30
+ # PlaylistBridge
31
+
32
+ [![CI](https://github.com/basartuncay/PlaylistBridge/actions/workflows/ci.yml/badge.svg)](https://github.com/basartuncay/PlaylistBridge/actions/workflows/ci.yml)
33
+
34
+ A command-line tool that reads a YouTube Music playlist — straight from its public
35
+ URL, or from a copy-pasted page dump — and adds the matching tracks to Spotify Liked Songs. Built with an
36
+ extensible architecture so that other source/target platform pairs can be
37
+ added later without touching the core matching or checkpointing logic.
38
+
39
+ ## Architecture
40
+
41
+ The domain model is deliberately kept platform-agnostic (`src/models.py`):
42
+ `SourceTrack`, `SearchCandidate`, `MatchResult`, and `MigrationSummary` don't
43
+ know anything about YouTube Music or Spotify specifically. Every other module
44
+ only talks to these shared types, not to each other's internals.
45
+
46
+ - **`parser/ytmusic_playlist.py`** reads a public playlist by URL via
47
+ `ytmusicapi` (no login) and produces the same `SourceTrack` entries.
48
+ - **`parser/ytmusic_parser.py`** turns the raw page dump into `SourceTrack`
49
+ entries and builds an ordered list of `SearchCandidate` guesses per entry.
50
+ - **`spotify_client/client.py`** wraps `spotipy`: OAuth setup, rate-limit
51
+ backoff, search, and adding to Liked Songs.
52
+ - **`matcher/track_matcher.py`** scores a Spotify search hit against the
53
+ source entry and flags low-confidence matches.
54
+ - **`state/checkpoint.py`** tracks which entries have already been migrated
55
+ so re-running the tool is safe and cheap.
56
+ - **`cli.py`** wires all of the above together and prints an end-of-run
57
+ summary.
58
+
59
+ ## Design Decisions
60
+
61
+ **Platform-agnostic shared models.** `SourceTrack` and `SearchCandidate` don't
62
+ reference YouTube Music by name, and `MatchResult` doesn't reference Spotify.
63
+ The parser is the only thing that needs to know about YouTube Music's page
64
+ format, and the client is the only thing that needs to know about Spotify's
65
+ API shape. Adding, say, an Apple Music target later means writing a new
66
+ client module that consumes `SearchCandidate` and produces `MatchResult` — no
67
+ changes to the matching or checkpointing logic.
68
+
69
+ **Incremental checkpoint with atomic writes.** A migration run can touch
70
+ hundreds of tracks and take a long time once rate-limit backoff kicks in.
71
+ `CheckpointStore` flushes to disk every few entries (not just once at the
72
+ end), and each flush writes to a temp file and `os.replace()`s it over the
73
+ real file, so a crash or Ctrl+C mid-write never corrupts the checkpoint —
74
+ worst case you lose the last few *unflushed* entries, not the whole file.
75
+
76
+ **Fuzzy matching with suspicious-match flagging.** Spotify's search endpoint
77
+ returns its own idea of the best match, which is frequently wrong for
78
+ non-official uploads, remixes, or channels named after the artist rather than
79
+ the artist's own page. Instead of trusting the top result blindly, every
80
+ match is scored against the original entry (title-weighted, since artist
81
+ data is sometimes just a channel name) and anything below
82
+ `SUSPICIOUS_SCORE_THRESHOLD` is flagged for manual review rather than
83
+ silently trusted.
84
+
85
+ **Dry-run mode.** Because the tool mutates a real Spotify library, `--dry-run`
86
+ lets you see exactly what would be searched for — without creating a Spotify
87
+ client, without any network calls, and without touching checkpoint state —
88
+ before committing to a real run.
89
+
90
+ ## Testing & Lessons Learned
91
+
92
+ 65/65 tests pass (`pytest -v`). Two real bugs were caught during development
93
+ and are worth calling out because they weren't obvious from the code alone:
94
+
95
+ 1. **Empty `source.artist` silently tanked otherwise-perfect matches.**
96
+ `score_match` already handled an empty *candidate* artist (the
97
+ title-only search fallback) by scoring on title alone. But when the
98
+ *source* entry's own artist field was empty — which happens whenever
99
+ `build_candidates`'s title-only fallback is what actually finds a hit —
100
+ the fuzzy ratio between `""` and a real artist name scores `0`, dragging
101
+ a 100%-title-match down to ~60% overall. Fixed by falling back to
102
+ title-only scoring whenever *either* side's artist is empty, with a
103
+ regression test (`test_score_match_uses_title_only_when_source_artist_is_empty`).
104
+
105
+ 2. **`suspicious_tracks.txt` was opened in append mode.** Since the
106
+ suspicious-duration check runs before the checkpoint lookup, a track
107
+ that's suspiciously long gets logged on every single run, whether or not
108
+ it was already migrated. Opening the log in `"a"` mode meant every run
109
+ added another copy of the same entries on top of the last run's. Fixed
110
+ by overwriting the file every run instead
111
+ (`test_run_migration_overwrites_suspicious_log_instead_of_appending`
112
+ calls `run_migration` twice and asserts the log contains exactly one
113
+ copy of the entry, not two).
114
+
115
+ ## Setup & Usage
116
+
117
+ ### Install
118
+
119
+ ```bash
120
+ pipx install playlist-bridge # or: pip install playlist-bridge
121
+ ```
122
+
123
+ From source (for development): `pip install -e ".[dev]"`.
124
+
125
+ ### Configure Spotify credentials
126
+
127
+ 1. Go to the [Spotify Developer Dashboard](https://developer.spotify.com/dashboard)
128
+ and create an app.
129
+ 2. Copy its **Client ID** and **Client Secret**.
130
+ 3. Add a Redirect URI in the app settings that matches what you'll use
131
+ locally, e.g. `http://127.0.0.1:8888/callback`.
132
+ 4. Create a `.env` file in the folder you'll run the command from (or export the
133
+ variables in your shell) with the three values:
134
+
135
+ ```
136
+ SPOTIPY_CLIENT_ID=your-client-id
137
+ SPOTIPY_CLIENT_SECRET=your-client-secret
138
+ SPOTIPY_REDIRECT_URI=http://127.0.0.1:8888/callback
139
+ ```
140
+
141
+ Each user brings their own Spotify app. That keeps every run inside your own app's
142
+ development-mode quota instead of a shared one.
143
+
144
+ ### Pick a source
145
+
146
+ **Option A: a public playlist URL (recommended).** Copy the playlist's share link from
147
+ YouTube Music or YouTube — no YouTube login or copy-paste needed. Private playlists and
148
+ your Liked Music aren't reachable this way; use option B for those.
149
+
150
+ **Option B: a page dump.** Open your YouTube Music playlist or library page in a browser, select all
151
+ (Ctrl+A / Cmd+A), copy, and paste the result into `ytmusic_paste.txt` in the
152
+ project root (or any path you pass to `--input`).
153
+
154
+ > **Copy the whole page, not just the track list.** Include the header/menu
155
+ > text above the tracks — the parser locates the line mentioning both
156
+ > "views" and "tracks" and skips everything up to and including it, so any
157
+ > nav/menu text above the tracks is discarded cleanly. If you paste only the
158
+ > track list itself, that header line won't be there, and the parser has no
159
+ > reliable anchor for where the real entries start.
160
+
161
+ ### Run
162
+
163
+ ```bash
164
+ # Report what would happen, without touching Spotify or checkpoint state
165
+ playlist-bridge --playlist "https://music.youtube.com/playlist?list=PL..." --dry-run
166
+
167
+ # Actually migrate
168
+ playlist-bridge --playlist "https://music.youtube.com/playlist?list=PL..."
169
+
170
+ # Or from a page dump
171
+ playlist-bridge --input ytmusic_paste.txt
172
+ ```
173
+
174
+ The first real run opens a browser window for Spotify OAuth consent. Progress
175
+ is checkpointed to `processed_tracks.json`, so an interrupted run can simply
176
+ be re-run — already-migrated tracks are skipped. Suspiciously long entries
177
+ (over 10 minutes) are written to `suspicious_tracks.txt` for manual review.
178
+
179
+ ### Run the checks
180
+
181
+ ```bash
182
+ ruff check . && mypy playlist_bridge && pytest -v
183
+ ```
184
+
185
+ ### Releasing
186
+
187
+ Bump `version` in `pyproject.toml`, then push a matching tag (`git tag v0.2.0 && git push --tags`).
188
+ `.github/workflows/publish.yml` builds and uploads to PyPI via trusted publishing (no token
189
+ stored in the repo); it refuses to publish if the tag and the package version differ.
190
+
191
+ ## Tech Stack
192
+
193
+ - **Python** 3.10+
194
+ - **[spotipy](https://spotipy.readthedocs.io/)** — Spotify Web API client and OAuth
195
+ - **[rapidfuzz](https://github.com/rapidfuzz/RapidFuzz)** — fuzzy string matching for track scoring
196
+ - **[ytmusicapi](https://github.com/sigma67/ytmusicapi)** — reading public YouTube Music playlists (unofficial API)
197
+ - **[python-dotenv](https://github.com/theskumar/python-dotenv)** — loading Spotify credentials from `.env`
198
+ - **[pytest](https://docs.pytest.org/)** — test suite
199
+
200
+ ## Future Work
201
+
202
+ The platform-agnostic model design leaves room to grow in a few directions:
203
+
204
+ * **Private playlists and Liked Music.** Public playlists are read via
205
+ `ytmusicapi` without login; private ones still need the page-dump path.
206
+ `ytmusicapi`'s browser-auth mode could cover them.
207
+ * **Reverse migration (Spotify → YouTube Music).** The shared model types
208
+ were designed with this in mind — it would mainly require a YouTube
209
+ Music *write* client alongside the existing Spotify one.
210
+ * **Other platforms.** Apple Music, Tidal, and others could be added as
211
+ either a source or a target without touching the core matching or
212
+ checkpointing logic.
@@ -0,0 +1,183 @@
1
+ # PlaylistBridge
2
+
3
+ [![CI](https://github.com/basartuncay/PlaylistBridge/actions/workflows/ci.yml/badge.svg)](https://github.com/basartuncay/PlaylistBridge/actions/workflows/ci.yml)
4
+
5
+ A command-line tool that reads a YouTube Music playlist — straight from its public
6
+ URL, or from a copy-pasted page dump — and adds the matching tracks to Spotify Liked Songs. Built with an
7
+ extensible architecture so that other source/target platform pairs can be
8
+ added later without touching the core matching or checkpointing logic.
9
+
10
+ ## Architecture
11
+
12
+ The domain model is deliberately kept platform-agnostic (`src/models.py`):
13
+ `SourceTrack`, `SearchCandidate`, `MatchResult`, and `MigrationSummary` don't
14
+ know anything about YouTube Music or Spotify specifically. Every other module
15
+ only talks to these shared types, not to each other's internals.
16
+
17
+ - **`parser/ytmusic_playlist.py`** reads a public playlist by URL via
18
+ `ytmusicapi` (no login) and produces the same `SourceTrack` entries.
19
+ - **`parser/ytmusic_parser.py`** turns the raw page dump into `SourceTrack`
20
+ entries and builds an ordered list of `SearchCandidate` guesses per entry.
21
+ - **`spotify_client/client.py`** wraps `spotipy`: OAuth setup, rate-limit
22
+ backoff, search, and adding to Liked Songs.
23
+ - **`matcher/track_matcher.py`** scores a Spotify search hit against the
24
+ source entry and flags low-confidence matches.
25
+ - **`state/checkpoint.py`** tracks which entries have already been migrated
26
+ so re-running the tool is safe and cheap.
27
+ - **`cli.py`** wires all of the above together and prints an end-of-run
28
+ summary.
29
+
30
+ ## Design Decisions
31
+
32
+ **Platform-agnostic shared models.** `SourceTrack` and `SearchCandidate` don't
33
+ reference YouTube Music by name, and `MatchResult` doesn't reference Spotify.
34
+ The parser is the only thing that needs to know about YouTube Music's page
35
+ format, and the client is the only thing that needs to know about Spotify's
36
+ API shape. Adding, say, an Apple Music target later means writing a new
37
+ client module that consumes `SearchCandidate` and produces `MatchResult` — no
38
+ changes to the matching or checkpointing logic.
39
+
40
+ **Incremental checkpoint with atomic writes.** A migration run can touch
41
+ hundreds of tracks and take a long time once rate-limit backoff kicks in.
42
+ `CheckpointStore` flushes to disk every few entries (not just once at the
43
+ end), and each flush writes to a temp file and `os.replace()`s it over the
44
+ real file, so a crash or Ctrl+C mid-write never corrupts the checkpoint —
45
+ worst case you lose the last few *unflushed* entries, not the whole file.
46
+
47
+ **Fuzzy matching with suspicious-match flagging.** Spotify's search endpoint
48
+ returns its own idea of the best match, which is frequently wrong for
49
+ non-official uploads, remixes, or channels named after the artist rather than
50
+ the artist's own page. Instead of trusting the top result blindly, every
51
+ match is scored against the original entry (title-weighted, since artist
52
+ data is sometimes just a channel name) and anything below
53
+ `SUSPICIOUS_SCORE_THRESHOLD` is flagged for manual review rather than
54
+ silently trusted.
55
+
56
+ **Dry-run mode.** Because the tool mutates a real Spotify library, `--dry-run`
57
+ lets you see exactly what would be searched for — without creating a Spotify
58
+ client, without any network calls, and without touching checkpoint state —
59
+ before committing to a real run.
60
+
61
+ ## Testing & Lessons Learned
62
+
63
+ 65/65 tests pass (`pytest -v`). Two real bugs were caught during development
64
+ and are worth calling out because they weren't obvious from the code alone:
65
+
66
+ 1. **Empty `source.artist` silently tanked otherwise-perfect matches.**
67
+ `score_match` already handled an empty *candidate* artist (the
68
+ title-only search fallback) by scoring on title alone. But when the
69
+ *source* entry's own artist field was empty — which happens whenever
70
+ `build_candidates`'s title-only fallback is what actually finds a hit —
71
+ the fuzzy ratio between `""` and a real artist name scores `0`, dragging
72
+ a 100%-title-match down to ~60% overall. Fixed by falling back to
73
+ title-only scoring whenever *either* side's artist is empty, with a
74
+ regression test (`test_score_match_uses_title_only_when_source_artist_is_empty`).
75
+
76
+ 2. **`suspicious_tracks.txt` was opened in append mode.** Since the
77
+ suspicious-duration check runs before the checkpoint lookup, a track
78
+ that's suspiciously long gets logged on every single run, whether or not
79
+ it was already migrated. Opening the log in `"a"` mode meant every run
80
+ added another copy of the same entries on top of the last run's. Fixed
81
+ by overwriting the file every run instead
82
+ (`test_run_migration_overwrites_suspicious_log_instead_of_appending`
83
+ calls `run_migration` twice and asserts the log contains exactly one
84
+ copy of the entry, not two).
85
+
86
+ ## Setup & Usage
87
+
88
+ ### Install
89
+
90
+ ```bash
91
+ pipx install playlist-bridge # or: pip install playlist-bridge
92
+ ```
93
+
94
+ From source (for development): `pip install -e ".[dev]"`.
95
+
96
+ ### Configure Spotify credentials
97
+
98
+ 1. Go to the [Spotify Developer Dashboard](https://developer.spotify.com/dashboard)
99
+ and create an app.
100
+ 2. Copy its **Client ID** and **Client Secret**.
101
+ 3. Add a Redirect URI in the app settings that matches what you'll use
102
+ locally, e.g. `http://127.0.0.1:8888/callback`.
103
+ 4. Create a `.env` file in the folder you'll run the command from (or export the
104
+ variables in your shell) with the three values:
105
+
106
+ ```
107
+ SPOTIPY_CLIENT_ID=your-client-id
108
+ SPOTIPY_CLIENT_SECRET=your-client-secret
109
+ SPOTIPY_REDIRECT_URI=http://127.0.0.1:8888/callback
110
+ ```
111
+
112
+ Each user brings their own Spotify app. That keeps every run inside your own app's
113
+ development-mode quota instead of a shared one.
114
+
115
+ ### Pick a source
116
+
117
+ **Option A: a public playlist URL (recommended).** Copy the playlist's share link from
118
+ YouTube Music or YouTube — no YouTube login or copy-paste needed. Private playlists and
119
+ your Liked Music aren't reachable this way; use option B for those.
120
+
121
+ **Option B: a page dump.** Open your YouTube Music playlist or library page in a browser, select all
122
+ (Ctrl+A / Cmd+A), copy, and paste the result into `ytmusic_paste.txt` in the
123
+ project root (or any path you pass to `--input`).
124
+
125
+ > **Copy the whole page, not just the track list.** Include the header/menu
126
+ > text above the tracks — the parser locates the line mentioning both
127
+ > "views" and "tracks" and skips everything up to and including it, so any
128
+ > nav/menu text above the tracks is discarded cleanly. If you paste only the
129
+ > track list itself, that header line won't be there, and the parser has no
130
+ > reliable anchor for where the real entries start.
131
+
132
+ ### Run
133
+
134
+ ```bash
135
+ # Report what would happen, without touching Spotify or checkpoint state
136
+ playlist-bridge --playlist "https://music.youtube.com/playlist?list=PL..." --dry-run
137
+
138
+ # Actually migrate
139
+ playlist-bridge --playlist "https://music.youtube.com/playlist?list=PL..."
140
+
141
+ # Or from a page dump
142
+ playlist-bridge --input ytmusic_paste.txt
143
+ ```
144
+
145
+ The first real run opens a browser window for Spotify OAuth consent. Progress
146
+ is checkpointed to `processed_tracks.json`, so an interrupted run can simply
147
+ be re-run — already-migrated tracks are skipped. Suspiciously long entries
148
+ (over 10 minutes) are written to `suspicious_tracks.txt` for manual review.
149
+
150
+ ### Run the checks
151
+
152
+ ```bash
153
+ ruff check . && mypy playlist_bridge && pytest -v
154
+ ```
155
+
156
+ ### Releasing
157
+
158
+ Bump `version` in `pyproject.toml`, then push a matching tag (`git tag v0.2.0 && git push --tags`).
159
+ `.github/workflows/publish.yml` builds and uploads to PyPI via trusted publishing (no token
160
+ stored in the repo); it refuses to publish if the tag and the package version differ.
161
+
162
+ ## Tech Stack
163
+
164
+ - **Python** 3.10+
165
+ - **[spotipy](https://spotipy.readthedocs.io/)** — Spotify Web API client and OAuth
166
+ - **[rapidfuzz](https://github.com/rapidfuzz/RapidFuzz)** — fuzzy string matching for track scoring
167
+ - **[ytmusicapi](https://github.com/sigma67/ytmusicapi)** — reading public YouTube Music playlists (unofficial API)
168
+ - **[python-dotenv](https://github.com/theskumar/python-dotenv)** — loading Spotify credentials from `.env`
169
+ - **[pytest](https://docs.pytest.org/)** — test suite
170
+
171
+ ## Future Work
172
+
173
+ The platform-agnostic model design leaves room to grow in a few directions:
174
+
175
+ * **Private playlists and Liked Music.** Public playlists are read via
176
+ `ytmusicapi` without login; private ones still need the page-dump path.
177
+ `ytmusicapi`'s browser-auth mode could cover them.
178
+ * **Reverse migration (Spotify → YouTube Music).** The shared model types
179
+ were designed with this in mind — it would mainly require a YouTube
180
+ Music *write* client alongside the existing Spotify one.
181
+ * **Other platforms.** Apple Music, Tidal, and others could be added as
182
+ either a source or a target without touching the core matching or
183
+ checkpointing logic.
@@ -0,0 +1 @@
1
+ """PlaylistBridge: migrate playlists between music platforms."""
@@ -0,0 +1,160 @@
1
+ """Command-line entry point for PlaylistBridge.
2
+
3
+ Wires together the source (a public playlist URL or a YouTube Music page dump), matcher,
4
+ Spotify client, and checkpoint store to migrate tracks into Spotify Liked Songs, and prints
5
+ an end-of-run summary.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ from pathlib import Path
11
+
12
+ from playlist_bridge.matcher.track_matcher import build_match_result
13
+ from playlist_bridge.models import MigrationSummary
14
+ from playlist_bridge.parser.ytmusic_parser import build_candidates, parse_ytmusic_page
15
+ from playlist_bridge.parser.ytmusic_playlist import fetch_playlist_tracks
16
+ from playlist_bridge.spotify_client.client import SpotifyClient
17
+ from playlist_bridge.state.checkpoint import CheckpointStore
18
+
19
+ DEFAULT_INPUT_PATH = Path("ytmusic_paste.txt")
20
+ DEFAULT_STATE_PATH = Path("processed_tracks.json")
21
+ DEFAULT_SUSPICIOUS_LOG_PATH = Path("suspicious_tracks.txt")
22
+
23
+ # A track longer than this is more likely to be a full album/mix mislabeled as a
24
+ # single entry than a genuinely long song, and is worth a human's second look.
25
+ SUSPICIOUS_DURATION_THRESHOLD_SECONDS = 600
26
+
27
+
28
+ def build_arg_parser() -> argparse.ArgumentParser:
29
+ """Build the CLI argument parser, including --input and --dry-run."""
30
+ parser = argparse.ArgumentParser(
31
+ description="Migrate a YouTube Music playlist into Spotify Liked Songs."
32
+ )
33
+ source = parser.add_mutually_exclusive_group()
34
+ source.add_argument(
35
+ "--playlist",
36
+ metavar="URL_OR_ID",
37
+ help="Public YouTube Music / YouTube playlist URL or id (no YouTube login needed).",
38
+ )
39
+ source.add_argument(
40
+ "--input",
41
+ type=Path,
42
+ default=DEFAULT_INPUT_PATH,
43
+ help="Path to the YouTube Music page dump text file (default: %(default)s).",
44
+ )
45
+ parser.add_argument(
46
+ "--dry-run",
47
+ action="store_true",
48
+ help=(
49
+ "Report what would happen without actually adding anything to Spotify "
50
+ "or writing checkpoint state."
51
+ ),
52
+ )
53
+ return parser
54
+
55
+
56
+ def run_migration(
57
+ input_path: Path, dry_run: bool, playlist: str | None = None
58
+ ) -> MigrationSummary:
59
+ """Run the full YouTube Music -> Spotify migration and return a summary of the results.
60
+
61
+ In --dry-run mode, only the input is parsed: no Spotify client or checkpoint store
62
+ is created, nothing is searched for on Spotify, and nothing is added to Liked Songs
63
+ or written to checkpoint state. Instead, the searches that would have been tried
64
+ for each entry are printed.
65
+ """
66
+ if playlist:
67
+ entries = fetch_playlist_tracks(playlist)
68
+ else:
69
+ entries = parse_ytmusic_page(input_path.read_text(encoding="utf-8"))
70
+
71
+ client: SpotifyClient | None = None
72
+ checkpoint: CheckpointStore | None = None
73
+ if not dry_run:
74
+ client = SpotifyClient.from_env()
75
+ checkpoint = CheckpointStore(DEFAULT_STATE_PATH)
76
+
77
+ summary = MigrationSummary()
78
+ suspicious_lines: list[str] = []
79
+
80
+ for entry in entries:
81
+ if entry.duration_seconds > SUSPICIOUS_DURATION_THRESHOLD_SECONDS:
82
+ summary.suspicious_duration_count += 1
83
+ suspicious_lines.append(
84
+ f"{entry.artist} - {entry.title} ({entry.duration_seconds}s)"
85
+ )
86
+
87
+ if checkpoint is not None and checkpoint.is_processed(entry.key()):
88
+ summary.skipped_already_processed += 1
89
+ continue
90
+
91
+ found_track = None
92
+ for candidate in build_candidates(entry):
93
+ if dry_run:
94
+ label = (
95
+ f"{candidate.artist} - {candidate.title}"
96
+ if candidate.artist
97
+ else candidate.title
98
+ )
99
+ print(f"Would search for: {label}")
100
+ continue
101
+
102
+ assert client is not None
103
+ found_track = client.search_track(candidate.artist, candidate.title)
104
+ if found_track:
105
+ break
106
+
107
+ if client is None or checkpoint is None: # dry run
108
+ continue
109
+
110
+ if found_track is None:
111
+ summary.not_found += 1
112
+ continue
113
+
114
+ match_result = build_match_result(entry, found_track)
115
+ client.add_to_liked_songs(match_result.track_id)
116
+ checkpoint.mark_processed(entry.key())
117
+
118
+ summary.added += 1
119
+ summary.match_scores.append(match_result.score)
120
+ if match_result.is_suspicious:
121
+ summary.suspicious_match_count += 1
122
+
123
+ if checkpoint is not None:
124
+ checkpoint.flush()
125
+
126
+ # Overwritten (not appended) on every run: a track that's no longer suspicious,
127
+ # or was already migrated, would otherwise linger here as a stale duplicate.
128
+ with DEFAULT_SUSPICIOUS_LOG_PATH.open("w", encoding="utf-8") as f:
129
+ if suspicious_lines:
130
+ f.write("\n".join(suspicious_lines) + "\n")
131
+
132
+ return summary
133
+
134
+
135
+ def print_summary(summary: MigrationSummary) -> None:
136
+ """Print the end-of-run report: added / not-found / suspicious counts and the
137
+ average fuzzy match score.
138
+ """
139
+ print("Migration summary")
140
+ print(f" Added: {summary.added}")
141
+ print(f" Not found: {summary.not_found}")
142
+ print(f" Skipped (already processed): {summary.skipped_already_processed}")
143
+ print(f" Suspicious duration: {summary.suspicious_duration_count}")
144
+ print(f" Suspicious match: {summary.suspicious_match_count}")
145
+ print(f" Average match score: {summary.average_match_score:.1f}")
146
+
147
+
148
+ def main() -> None:
149
+ """CLI entry point: parse arguments and run the migration."""
150
+ parser = build_arg_parser()
151
+ args = parser.parse_args()
152
+ try:
153
+ summary = run_migration(args.input, args.dry_run, playlist=args.playlist)
154
+ except (ValueError, RuntimeError, FileNotFoundError) as exc:
155
+ parser.exit(2, f"playlist-bridge: error: {exc}\n")
156
+ print_summary(summary)
157
+
158
+
159
+ if __name__ == "__main__":
160
+ main()
@@ -0,0 +1 @@
1
+ """Fuzzy matching between parsed source tracks and target-platform search results."""
@@ -0,0 +1,52 @@
1
+ """Fuzzy matching between a parsed source track and a candidate Spotify search result.
2
+
3
+ Uses rapidfuzz to score how well a Spotify search hit actually matches the original
4
+ source entry, and flags low-confidence matches as suspicious instead of blindly
5
+ trusting Spotify's top search result.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+ from rapidfuzz import fuzz
12
+
13
+ from playlist_bridge.models import MatchResult, SourceTrack
14
+
15
+ SUSPICIOUS_SCORE_THRESHOLD = 60.0
16
+
17
+ # Title carries more signal than artist: artist is sometimes empty or just a channel
18
+ # name (see build_candidates' fallbacks), while the title is usually intact.
19
+ TITLE_WEIGHT = 0.6
20
+ ARTIST_WEIGHT = 0.4
21
+
22
+
23
+ def score_match(source: SourceTrack, candidate_artist: str, candidate_title: str) -> float:
24
+ """Compute a 0-100 similarity score between the source entry and a Spotify candidate,
25
+ combining fuzzy ratios on both artist and title (not title alone).
26
+ """
27
+ title_score = fuzz.token_sort_ratio(source.title, candidate_title)
28
+
29
+ if not candidate_artist.strip() or not source.artist.strip():
30
+ return title_score
31
+
32
+ artist_score = fuzz.token_sort_ratio(source.artist, candidate_artist)
33
+ return TITLE_WEIGHT * title_score + ARTIST_WEIGHT * artist_score
34
+
35
+
36
+ def build_match_result(source: SourceTrack, spotify_track: dict[str, Any]) -> MatchResult:
37
+ """Score a raw Spotify track object against the source entry and package the result,
38
+ flagging it as suspicious when the combined similarity score is below
39
+ SUSPICIOUS_SCORE_THRESHOLD.
40
+ """
41
+ matched_title = spotify_track["name"]
42
+ matched_artist = spotify_track["artists"][0]["name"]
43
+
44
+ score = score_match(source, matched_artist, matched_title)
45
+
46
+ return MatchResult(
47
+ track_id=spotify_track["id"],
48
+ matched_artist=matched_artist,
49
+ matched_title=matched_title,
50
+ score=score,
51
+ is_suspicious=score < SUSPICIOUS_SCORE_THRESHOLD,
52
+ )