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.
- playlist_bridge-0.2.0/LICENSE +21 -0
- playlist_bridge-0.2.0/PKG-INFO +212 -0
- playlist_bridge-0.2.0/README.md +183 -0
- playlist_bridge-0.2.0/playlist_bridge/__init__.py +1 -0
- playlist_bridge-0.2.0/playlist_bridge/cli.py +160 -0
- playlist_bridge-0.2.0/playlist_bridge/matcher/__init__.py +1 -0
- playlist_bridge-0.2.0/playlist_bridge/matcher/track_matcher.py +52 -0
- playlist_bridge-0.2.0/playlist_bridge/models.py +62 -0
- playlist_bridge-0.2.0/playlist_bridge/parser/__init__.py +1 -0
- playlist_bridge-0.2.0/playlist_bridge/parser/ytmusic_parser.py +133 -0
- playlist_bridge-0.2.0/playlist_bridge/parser/ytmusic_playlist.py +63 -0
- playlist_bridge-0.2.0/playlist_bridge/spotify_client/__init__.py +1 -0
- playlist_bridge-0.2.0/playlist_bridge/spotify_client/client.py +93 -0
- playlist_bridge-0.2.0/playlist_bridge/state/__init__.py +1 -0
- playlist_bridge-0.2.0/playlist_bridge/state/checkpoint.py +72 -0
- playlist_bridge-0.2.0/playlist_bridge.egg-info/PKG-INFO +212 -0
- playlist_bridge-0.2.0/playlist_bridge.egg-info/SOURCES.txt +28 -0
- playlist_bridge-0.2.0/playlist_bridge.egg-info/dependency_links.txt +1 -0
- playlist_bridge-0.2.0/playlist_bridge.egg-info/entry_points.txt +2 -0
- playlist_bridge-0.2.0/playlist_bridge.egg-info/requires.txt +10 -0
- playlist_bridge-0.2.0/playlist_bridge.egg-info/top_level.txt +1 -0
- playlist_bridge-0.2.0/pyproject.toml +52 -0
- playlist_bridge-0.2.0/setup.cfg +4 -0
- playlist_bridge-0.2.0/tests/test_checkpoint.py +84 -0
- playlist_bridge-0.2.0/tests/test_cli.py +189 -0
- playlist_bridge-0.2.0/tests/test_matcher.py +90 -0
- playlist_bridge-0.2.0/tests/test_models.py +22 -0
- playlist_bridge-0.2.0/tests/test_parser.py +154 -0
- playlist_bridge-0.2.0/tests/test_spotify_client.py +119 -0
- 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
|
+
[](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
|
+
[](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
|
+
)
|