stashify-cli 1.3.0__tar.gz → 1.4.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.
- {stashify_cli-1.3.0 → stashify_cli-1.4.0}/PKG-INFO +13 -4
- {stashify_cli-1.3.0 → stashify_cli-1.4.0}/README.md +12 -3
- {stashify_cli-1.3.0 → stashify_cli-1.4.0}/pyproject.toml +1 -1
- {stashify_cli-1.3.0 → stashify_cli-1.4.0}/src/stashify/__init__.py +1 -1
- {stashify_cli-1.3.0 → stashify_cli-1.4.0}/src/stashify/cli.py +48 -21
- stashify_cli-1.4.0/src/stashify/doctor.py +139 -0
- stashify_cli-1.4.0/src/stashify/lyrics.py +79 -0
- {stashify_cli-1.3.0 → stashify_cli-1.4.0}/src/stashify_cli.egg-info/PKG-INFO +13 -4
- {stashify_cli-1.3.0 → stashify_cli-1.4.0}/src/stashify_cli.egg-info/SOURCES.txt +3 -0
- {stashify_cli-1.3.0 → stashify_cli-1.4.0}/tests/test_cli.py +35 -0
- stashify_cli-1.4.0/tests/test_doctor.py +46 -0
- stashify_cli-1.4.0/tests/test_lyrics.py +84 -0
- stashify_cli-1.3.0/src/stashify/lyrics.py +0 -36
- {stashify_cli-1.3.0 → stashify_cli-1.4.0}/LICENSE +0 -0
- {stashify_cli-1.3.0 → stashify_cli-1.4.0}/setup.cfg +0 -0
- {stashify_cli-1.3.0 → stashify_cli-1.4.0}/src/stashify/__main__.py +0 -0
- {stashify_cli-1.3.0 → stashify_cli-1.4.0}/src/stashify/config.py +0 -0
- {stashify_cli-1.3.0 → stashify_cli-1.4.0}/src/stashify/download.py +0 -0
- {stashify_cli-1.3.0 → stashify_cli-1.4.0}/src/stashify/embed.py +0 -0
- {stashify_cli-1.3.0 → stashify_cli-1.4.0}/src/stashify/errors.py +0 -0
- {stashify_cli-1.3.0 → stashify_cli-1.4.0}/src/stashify/models.py +0 -0
- {stashify_cli-1.3.0 → stashify_cli-1.4.0}/src/stashify/output.py +0 -0
- {stashify_cli-1.3.0 → stashify_cli-1.4.0}/src/stashify/pipeline.py +0 -0
- {stashify_cli-1.3.0 → stashify_cli-1.4.0}/src/stashify/sources.py +0 -0
- {stashify_cli-1.3.0 → stashify_cli-1.4.0}/src/stashify/spotify.py +0 -0
- {stashify_cli-1.3.0 → stashify_cli-1.4.0}/src/stashify/sync.py +0 -0
- {stashify_cli-1.3.0 → stashify_cli-1.4.0}/src/stashify/tagger.py +0 -0
- {stashify_cli-1.3.0 → stashify_cli-1.4.0}/src/stashify_cli.egg-info/dependency_links.txt +0 -0
- {stashify_cli-1.3.0 → stashify_cli-1.4.0}/src/stashify_cli.egg-info/entry_points.txt +0 -0
- {stashify_cli-1.3.0 → stashify_cli-1.4.0}/src/stashify_cli.egg-info/requires.txt +0 -0
- {stashify_cli-1.3.0 → stashify_cli-1.4.0}/src/stashify_cli.egg-info/top_level.txt +0 -0
- {stashify_cli-1.3.0 → stashify_cli-1.4.0}/tests/test_config.py +0 -0
- {stashify_cli-1.3.0 → stashify_cli-1.4.0}/tests/test_download.py +0 -0
- {stashify_cli-1.3.0 → stashify_cli-1.4.0}/tests/test_embed.py +0 -0
- {stashify_cli-1.3.0 → stashify_cli-1.4.0}/tests/test_models.py +0 -0
- {stashify_cli-1.3.0 → stashify_cli-1.4.0}/tests/test_output.py +0 -0
- {stashify_cli-1.3.0 → stashify_cli-1.4.0}/tests/test_pipeline.py +0 -0
- {stashify_cli-1.3.0 → stashify_cli-1.4.0}/tests/test_spotify.py +0 -0
- {stashify_cli-1.3.0 → stashify_cli-1.4.0}/tests/test_sync.py +0 -0
- {stashify_cli-1.3.0 → stashify_cli-1.4.0}/tests/test_tagger.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: stashify-cli
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.4.0
|
|
4
4
|
Summary: Find music from your Spotify library on YouTube and save it locally with cover art, lyrics and full metadata.
|
|
5
5
|
Author: Nicolas Dwi Dharma
|
|
6
6
|
License-Expression: MIT
|
|
@@ -258,6 +258,7 @@ stashify download "https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M"
|
|
|
258
258
|
- Change the output folder with `-o "D:\Music"` and the naming scheme with `-t "{album}/{track_number} {title}"`
|
|
259
259
|
- Download up to 8 tracks at once with `--jobs 4` (default 2)
|
|
260
260
|
- Skip cover art or lyrics with `--no-cover` / `--no-lyrics`, or re-fetch everything with `--overwrite`
|
|
261
|
+
- Check the whole stack with `stashify doctor` when a download fails; it reports Python, FFmpeg, network access to Spotify, YouTube and the lyrics API
|
|
261
262
|
- Preview the match without downloading with `--dry-run`
|
|
262
263
|
- Keep a folder in sync with a playlist: `stashify sync "PLAYLIST_URL" -o "D:\Music\MyList"`
|
|
263
264
|
- Retag files you already have: `stashify meta file1.mp3 file2.mp3 --url "ALBUM_URL"`
|
|
@@ -303,7 +304,7 @@ Stashify is online only when it resolves metadata, searches for a source or fetc
|
|
|
303
304
|
| Track, album, playlist and artist metadata | Spotify's public embed pages (default) or the Spotify Web API (`--provider api`) |
|
|
304
305
|
| Cover art | Spotify image CDN (embedded into files) |
|
|
305
306
|
| Audio source | YouTube, through yt-dlp |
|
|
306
|
-
| Lyrics |
|
|
307
|
+
| Lyrics | lrclib.net (no key required), lyrics.ovh as fallback |
|
|
307
308
|
|
|
308
309
|
> [!NOTE]
|
|
309
310
|
> Some tracks have no usable match on YouTube. Stashify reports the best candidate it found and skips tracks whose score is too low instead of downloading the wrong song.
|
|
@@ -319,6 +320,7 @@ stashify/
|
|
|
319
320
|
│ ├── __init__.py # package version
|
|
320
321
|
│ ├── __main__.py # python -m stashify entry point
|
|
321
322
|
│ ├── cli.py # argument parsing and commands
|
|
323
|
+
│ ├── doctor.py # end-to-end stack health check
|
|
322
324
|
│ ├── pipeline.py # shared download pipeline
|
|
323
325
|
│ ├── spotify.py # Spotify Web API client (optional, --provider api)
|
|
324
326
|
│ ├── embed.py # public embed page resolver (default provider)
|
|
@@ -326,7 +328,7 @@ stashify/
|
|
|
326
328
|
│ ├── sources.py # YouTube search and scoring
|
|
327
329
|
│ ├── download.py # yt-dlp download and FFmpeg conversion
|
|
328
330
|
│ ├── tagger.py # MP3, M4A, FLAC, Opus and WAV tagging
|
|
329
|
-
│ ├── lyrics.py # lyrics.ovh
|
|
331
|
+
│ ├── lyrics.py # lrclib.net client (lyrics.ovh fallback)
|
|
330
332
|
│ ├── output.py # path templates and file naming
|
|
331
333
|
│ ├── sync.py # playlist sync state handling
|
|
332
334
|
│ ├── config.py # config file loading
|
|
@@ -413,6 +415,13 @@ Lint rules are enforced through the `[tool.ruff]` section in `pyproject.toml`.
|
|
|
413
415
|
|
|
414
416
|
## FAQ
|
|
415
417
|
|
|
418
|
+
<details>
|
|
419
|
+
<summary><b>Something fails; how do I diagnose it?</b></summary>
|
|
420
|
+
|
|
421
|
+
Run `stashify doctor`. It checks Python, FFmpeg, the downloads folder, Spotify's embed pages, YouTube search and the lyrics API, and tells you exactly which part works and which does not.
|
|
422
|
+
|
|
423
|
+
</details>
|
|
424
|
+
|
|
416
425
|
<details>
|
|
417
426
|
<summary><b>Do I need a Spotify account, login or developer app?</b></summary>
|
|
418
427
|
|
|
@@ -479,7 +488,7 @@ Yes. `stashify sync "PLAYLIST_URL" -o "D:\Music\MyList"` downloads new tracks an
|
|
|
479
488
|
|
|
480
489
|
## Disclaimer
|
|
481
490
|
|
|
482
|
-
Stashify is an independent open-source project and is not affiliated with or endorsed by Spotify, Google, YouTube or the operators of lyrics.ovh. The default provider parses Spotify's public embed pages; this is not a documented API and can change or break at any time. Downloading music may violate the terms of service of those platforms and may infringe copyright in your jurisdiction. Stashify is provided for personal, lawful use only; you are responsible for how you use it.
|
|
491
|
+
Stashify is an independent open-source project and is not affiliated with or endorsed by Spotify, Google, YouTube, lrclib or the operators of lyrics.ovh. The default provider parses Spotify's public embed pages; this is not a documented API and can change or break at any time. Downloading music may violate the terms of service of those platforms and may infringe copyright in your jurisdiction. Stashify is provided for personal, lawful use only; you are responsible for how you use it.
|
|
483
492
|
|
|
484
493
|
---
|
|
485
494
|
|
|
@@ -224,6 +224,7 @@ stashify download "https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M"
|
|
|
224
224
|
- Change the output folder with `-o "D:\Music"` and the naming scheme with `-t "{album}/{track_number} {title}"`
|
|
225
225
|
- Download up to 8 tracks at once with `--jobs 4` (default 2)
|
|
226
226
|
- Skip cover art or lyrics with `--no-cover` / `--no-lyrics`, or re-fetch everything with `--overwrite`
|
|
227
|
+
- Check the whole stack with `stashify doctor` when a download fails; it reports Python, FFmpeg, network access to Spotify, YouTube and the lyrics API
|
|
227
228
|
- Preview the match without downloading with `--dry-run`
|
|
228
229
|
- Keep a folder in sync with a playlist: `stashify sync "PLAYLIST_URL" -o "D:\Music\MyList"`
|
|
229
230
|
- Retag files you already have: `stashify meta file1.mp3 file2.mp3 --url "ALBUM_URL"`
|
|
@@ -269,7 +270,7 @@ Stashify is online only when it resolves metadata, searches for a source or fetc
|
|
|
269
270
|
| Track, album, playlist and artist metadata | Spotify's public embed pages (default) or the Spotify Web API (`--provider api`) |
|
|
270
271
|
| Cover art | Spotify image CDN (embedded into files) |
|
|
271
272
|
| Audio source | YouTube, through yt-dlp |
|
|
272
|
-
| Lyrics |
|
|
273
|
+
| Lyrics | lrclib.net (no key required), lyrics.ovh as fallback |
|
|
273
274
|
|
|
274
275
|
> [!NOTE]
|
|
275
276
|
> Some tracks have no usable match on YouTube. Stashify reports the best candidate it found and skips tracks whose score is too low instead of downloading the wrong song.
|
|
@@ -285,6 +286,7 @@ stashify/
|
|
|
285
286
|
│ ├── __init__.py # package version
|
|
286
287
|
│ ├── __main__.py # python -m stashify entry point
|
|
287
288
|
│ ├── cli.py # argument parsing and commands
|
|
289
|
+
│ ├── doctor.py # end-to-end stack health check
|
|
288
290
|
│ ├── pipeline.py # shared download pipeline
|
|
289
291
|
│ ├── spotify.py # Spotify Web API client (optional, --provider api)
|
|
290
292
|
│ ├── embed.py # public embed page resolver (default provider)
|
|
@@ -292,7 +294,7 @@ stashify/
|
|
|
292
294
|
│ ├── sources.py # YouTube search and scoring
|
|
293
295
|
│ ├── download.py # yt-dlp download and FFmpeg conversion
|
|
294
296
|
│ ├── tagger.py # MP3, M4A, FLAC, Opus and WAV tagging
|
|
295
|
-
│ ├── lyrics.py # lyrics.ovh
|
|
297
|
+
│ ├── lyrics.py # lrclib.net client (lyrics.ovh fallback)
|
|
296
298
|
│ ├── output.py # path templates and file naming
|
|
297
299
|
│ ├── sync.py # playlist sync state handling
|
|
298
300
|
│ ├── config.py # config file loading
|
|
@@ -379,6 +381,13 @@ Lint rules are enforced through the `[tool.ruff]` section in `pyproject.toml`.
|
|
|
379
381
|
|
|
380
382
|
## FAQ
|
|
381
383
|
|
|
384
|
+
<details>
|
|
385
|
+
<summary><b>Something fails; how do I diagnose it?</b></summary>
|
|
386
|
+
|
|
387
|
+
Run `stashify doctor`. It checks Python, FFmpeg, the downloads folder, Spotify's embed pages, YouTube search and the lyrics API, and tells you exactly which part works and which does not.
|
|
388
|
+
|
|
389
|
+
</details>
|
|
390
|
+
|
|
382
391
|
<details>
|
|
383
392
|
<summary><b>Do I need a Spotify account, login or developer app?</b></summary>
|
|
384
393
|
|
|
@@ -445,7 +454,7 @@ Yes. `stashify sync "PLAYLIST_URL" -o "D:\Music\MyList"` downloads new tracks an
|
|
|
445
454
|
|
|
446
455
|
## Disclaimer
|
|
447
456
|
|
|
448
|
-
Stashify is an independent open-source project and is not affiliated with or endorsed by Spotify, Google, YouTube or the operators of lyrics.ovh. The default provider parses Spotify's public embed pages; this is not a documented API and can change or break at any time. Downloading music may violate the terms of service of those platforms and may infringe copyright in your jurisdiction. Stashify is provided for personal, lawful use only; you are responsible for how you use it.
|
|
457
|
+
Stashify is an independent open-source project and is not affiliated with or endorsed by Spotify, Google, YouTube, lrclib or the operators of lyrics.ovh. The default provider parses Spotify's public embed pages; this is not a documented API and can change or break at any time. Downloading music may violate the terms of service of those platforms and may infringe copyright in your jurisdiction. Stashify is provided for personal, lawful use only; you are responsible for how you use it.
|
|
449
458
|
|
|
450
459
|
---
|
|
451
460
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "stashify-cli"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.4.0"
|
|
8
8
|
description = "Find music from your Spotify library on YouTube and save it locally with cover art, lyrics and full metadata."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -19,6 +19,7 @@ from .config import (
|
|
|
19
19
|
default_download_dir,
|
|
20
20
|
load_config,
|
|
21
21
|
)
|
|
22
|
+
from .doctor import run_doctor
|
|
22
23
|
from .download import AUDIO_FORMATS
|
|
23
24
|
from .embed import EmbedResolver
|
|
24
25
|
from .errors import StashifyError
|
|
@@ -55,7 +56,17 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
55
56
|
help="two-letter market code (default: US)",
|
|
56
57
|
)
|
|
57
58
|
|
|
58
|
-
|
|
59
|
+
resolver = argparse.ArgumentParser(add_help=False)
|
|
60
|
+
resolver.add_argument(
|
|
61
|
+
"--provider", choices=["embed", "api"], default=None,
|
|
62
|
+
help="metadata provider (default: embed, no account needed)",
|
|
63
|
+
)
|
|
64
|
+
resolver.add_argument(
|
|
65
|
+
"--no-details", action="store_true", default=None,
|
|
66
|
+
help="skip per-track detail lookup (faster, fewer cover art files)",
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
output = argparse.ArgumentParser(add_help=False, parents=[resolver])
|
|
59
70
|
output.add_argument(
|
|
60
71
|
"-o", "--output", default=None,
|
|
61
72
|
help="output directory (default: Downloads folder)"
|
|
@@ -77,25 +88,17 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
77
88
|
help="re-download files that already exist",
|
|
78
89
|
)
|
|
79
90
|
output.add_argument(
|
|
80
|
-
"--
|
|
81
|
-
help="
|
|
91
|
+
"--cover", dest="cover", default=None, action=argparse.BooleanOptionalAction,
|
|
92
|
+
help="embed cover art (default: config or on)",
|
|
82
93
|
)
|
|
83
94
|
output.add_argument(
|
|
84
|
-
"--
|
|
85
|
-
help="
|
|
95
|
+
"--lyrics", dest="lyrics", default=None, action=argparse.BooleanOptionalAction,
|
|
96
|
+
help="embed lyrics (default: config or on)",
|
|
86
97
|
)
|
|
87
98
|
output.add_argument(
|
|
88
99
|
"--dry-run", action="store_true", default=None,
|
|
89
100
|
help="print matches without downloading",
|
|
90
101
|
)
|
|
91
|
-
output.add_argument(
|
|
92
|
-
"--provider", choices=["embed", "api"], default=None,
|
|
93
|
-
help="metadata provider (default: embed, no account needed)",
|
|
94
|
-
)
|
|
95
|
-
output.add_argument(
|
|
96
|
-
"--no-details", action="store_true", default=None,
|
|
97
|
-
help="skip per-track detail lookup (faster, fewer cover art files)",
|
|
98
|
-
)
|
|
99
102
|
output.add_argument(
|
|
100
103
|
"--jobs", type=int, default=None, metavar="N",
|
|
101
104
|
help=f"download up to N tracks in parallel (default: {DEFAULT_JOBS}, max 8)",
|
|
@@ -122,7 +125,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
122
125
|
)
|
|
123
126
|
|
|
124
127
|
p_url = sub.add_parser(
|
|
125
|
-
"url", parents=[credentials],
|
|
128
|
+
"url", parents=[credentials, resolver],
|
|
126
129
|
help="print the matched YouTube source URL per track",
|
|
127
130
|
)
|
|
128
131
|
p_url.add_argument("urls", nargs="+", help="Spotify links")
|
|
@@ -136,6 +139,8 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
136
139
|
"--url", required=True, help="Spotify link whose tracks are applied"
|
|
137
140
|
)
|
|
138
141
|
|
|
142
|
+
sub.add_parser("doctor", help="check Python, FFmpeg, network and APIs")
|
|
143
|
+
|
|
139
144
|
p_sync = sub.add_parser(
|
|
140
145
|
"sync", parents=[credentials, output],
|
|
141
146
|
help="sync a directory with a playlist",
|
|
@@ -171,13 +176,25 @@ def _resolver(args: argparse.Namespace):
|
|
|
171
176
|
provider = getattr(args, "provider", None) or config.get("provider", "embed")
|
|
172
177
|
if provider == "embed":
|
|
173
178
|
no_details = bool(getattr(args, "no_details", False))
|
|
174
|
-
|
|
179
|
+
dry_run = bool(getattr(args, "dry_run", False))
|
|
180
|
+
url_only = getattr(args, "command", None) == "url"
|
|
181
|
+
skip = no_details or dry_run or url_only
|
|
182
|
+
detailed = False if skip else bool(config.get("detailed", True))
|
|
175
183
|
return EmbedResolver(detailed=detailed)
|
|
176
184
|
if provider == "api":
|
|
177
185
|
return _client(args)
|
|
178
186
|
raise StashifyError(f"Unknown provider {provider!r}. Choose from: embed, api.")
|
|
179
187
|
|
|
180
188
|
|
|
189
|
+
def _pick_flag(cli: bool | None, config_value: bool | None, default: bool) -> bool:
|
|
190
|
+
"""CLI flag wins when given, else config value, else default."""
|
|
191
|
+
if cli is not None:
|
|
192
|
+
return cli
|
|
193
|
+
if config_value is not None:
|
|
194
|
+
return bool(config_value)
|
|
195
|
+
return default
|
|
196
|
+
|
|
197
|
+
|
|
181
198
|
def _options(args: argparse.Namespace) -> TrackOptions:
|
|
182
199
|
config = load_config()
|
|
183
200
|
|
|
@@ -186,8 +203,12 @@ def _options(args: argparse.Namespace) -> TrackOptions:
|
|
|
186
203
|
return cli
|
|
187
204
|
return config.get(key, default)
|
|
188
205
|
|
|
189
|
-
|
|
190
|
-
|
|
206
|
+
cli_cover = getattr(args, "cover", None)
|
|
207
|
+
cover = _pick_flag(cli_cover, not bool(config.get("no_cover", False)), default=True)
|
|
208
|
+
|
|
209
|
+
cli_lyrics = getattr(args, "lyrics", None)
|
|
210
|
+
lyrics = _pick_flag(cli_lyrics, not bool(config.get("no_lyrics", False)), default=True)
|
|
211
|
+
|
|
191
212
|
return TrackOptions(
|
|
192
213
|
out_dir=Path(
|
|
193
214
|
pick(getattr(args, "output", None), "output_dir", str(default_download_dir()))
|
|
@@ -197,8 +218,8 @@ def _options(args: argparse.Namespace) -> TrackOptions:
|
|
|
197
218
|
bitrate=pick(getattr(args, "bitrate", None), "bitrate", DEFAULT_BITRATE),
|
|
198
219
|
overwrite=bool(pick(getattr(args, "overwrite", None), "overwrite", False)),
|
|
199
220
|
dry_run=bool(pick(getattr(args, "dry_run", None), "dry_run", False)),
|
|
200
|
-
embed_cover=
|
|
201
|
-
embed_lyrics=
|
|
221
|
+
embed_cover=cover,
|
|
222
|
+
embed_lyrics=lyrics,
|
|
202
223
|
jobs=clamp_jobs(pick(getattr(args, "jobs", None), "jobs", DEFAULT_JOBS)),
|
|
203
224
|
)
|
|
204
225
|
|
|
@@ -270,6 +291,11 @@ def _cmd_url(args: argparse.Namespace) -> int:
|
|
|
270
291
|
|
|
271
292
|
def _cmd_meta(args: argparse.Namespace) -> int:
|
|
272
293
|
config = load_config()
|
|
294
|
+
cli_cover = getattr(args, "cover", None)
|
|
295
|
+
cover = _pick_flag(cli_cover, not bool(config.get("no_cover", False)), default=True)
|
|
296
|
+
cli_lyrics = getattr(args, "lyrics", None)
|
|
297
|
+
lyrics = _pick_flag(cli_lyrics, not bool(config.get("no_lyrics", False)), default=True)
|
|
298
|
+
|
|
273
299
|
tracks = _fetch(args, [args.url])
|
|
274
300
|
files = [Path(f) for f in args.files]
|
|
275
301
|
missing = [f for f in files if not f.is_file()]
|
|
@@ -286,8 +312,8 @@ def _cmd_meta(args: argparse.Namespace) -> int:
|
|
|
286
312
|
failures = 0
|
|
287
313
|
for path, track in zip(files, tracks, strict=False):
|
|
288
314
|
try:
|
|
289
|
-
cover = fetch_cover(track) if
|
|
290
|
-
lyrics = fetch_lyrics(track) if
|
|
315
|
+
cover = fetch_cover(track) if cover else None
|
|
316
|
+
lyrics = fetch_lyrics(track) if lyrics else None
|
|
291
317
|
tag_file(path, track, cover, lyrics)
|
|
292
318
|
print(f"Tagged {path} -> {track.display()}")
|
|
293
319
|
except Exception as exc:
|
|
@@ -351,6 +377,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
351
377
|
"url": _cmd_url,
|
|
352
378
|
"meta": _cmd_meta,
|
|
353
379
|
"sync": _cmd_sync,
|
|
380
|
+
"doctor": lambda args: run_doctor(),
|
|
354
381
|
}
|
|
355
382
|
handler = handlers[args.command]
|
|
356
383
|
try:
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Check that the whole Stashify stack works end to end.
|
|
2
|
+
|
|
3
|
+
`stashify doctor` runs quick checks against Python, FFmpeg, the downloads
|
|
4
|
+
folder, Spotify's embed pages, YouTube search and the lyrics API, and prints
|
|
5
|
+
a pass/fail report. Use when a download fails and you want to know exactly
|
|
6
|
+
which part of the stack is broken, without running a full download.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import shutil
|
|
13
|
+
|
|
14
|
+
import requests
|
|
15
|
+
|
|
16
|
+
from .config import default_download_dir
|
|
17
|
+
from .embed import EmbedResolver
|
|
18
|
+
from .lyrics import LRCLIB_URL
|
|
19
|
+
from .lyrics import USER_AGENT as LYRICS_UA
|
|
20
|
+
from .models import Track
|
|
21
|
+
from .sources import find_source
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def check_python() -> tuple[str, bool, str]:
|
|
25
|
+
try:
|
|
26
|
+
import sys
|
|
27
|
+
|
|
28
|
+
ok = sys.version_info >= (3, 10)
|
|
29
|
+
label = "Python 3.10 or newer"
|
|
30
|
+
return (label, ok, f"Python {sys.version_info.major}.{sys.version_info.minor}")
|
|
31
|
+
except Exception as exc:
|
|
32
|
+
return ("Python 3.10 or newer", False, f"failed: {exc}")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def check_ffmpeg() -> tuple[str, bool, str]:
|
|
36
|
+
try:
|
|
37
|
+
ffmpeg = shutil.which("ffmpeg")
|
|
38
|
+
path = "FFmpeg on PATH"
|
|
39
|
+
return (path, bool(ffmpeg), ffmpeg or "not found (https://ffmpeg.org/download.html)")
|
|
40
|
+
except Exception as exc:
|
|
41
|
+
return ("FFmpeg on PATH", False, f"failed: {exc}")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def check_ffprobe() -> tuple[str, bool, str]:
|
|
45
|
+
try:
|
|
46
|
+
ffprobe = shutil.which("ffprobe")
|
|
47
|
+
path = "ffprobe on PATH"
|
|
48
|
+
return (path, bool(ffprobe), ffprobe or "not found (comes with FFmpeg)")
|
|
49
|
+
except Exception as exc:
|
|
50
|
+
return ("ffprobe on PATH", False, f"failed: {exc}")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def check_download_dir() -> tuple[str, bool, str]:
|
|
54
|
+
try:
|
|
55
|
+
folder = default_download_dir()
|
|
56
|
+
except Exception as exc:
|
|
57
|
+
return ("Downloads folder found", False, f"failed: {exc}")
|
|
58
|
+
if not folder.exists():
|
|
59
|
+
return ("Downloads folder exists", False, f"{folder} does not exist")
|
|
60
|
+
if not folder.is_dir():
|
|
61
|
+
return ("Downloads folder exists", False, f"{folder} exists but is not a directory")
|
|
62
|
+
w_ok = os.access(folder, os.W_OK)
|
|
63
|
+
return (
|
|
64
|
+
"Downloads folder writable",
|
|
65
|
+
w_ok,
|
|
66
|
+
str(folder) if w_ok else f"{folder}: not writable",
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def check_spotify_embed() -> tuple[str, bool, str]:
|
|
71
|
+
try:
|
|
72
|
+
resolver = EmbedResolver(detailed=False)
|
|
73
|
+
tracks = resolver.fetch_tracks(
|
|
74
|
+
"https://open.spotify.com/track/4uLU6hMCjMI75M1A2tKUQC"
|
|
75
|
+
)
|
|
76
|
+
if not tracks:
|
|
77
|
+
return ("Spotify embed pages", False, "embed resolved to no tracks")
|
|
78
|
+
summary = tracks[0].display() if tracks else ""
|
|
79
|
+
return ("Spotify embed pages", True, f"resolved: {summary}")
|
|
80
|
+
except Exception as exc:
|
|
81
|
+
return ("Spotify embed pages", False, f"failed: {exc}")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def check_youtube() -> tuple[str, bool, str]:
|
|
85
|
+
try:
|
|
86
|
+
source = find_source(
|
|
87
|
+
Track(
|
|
88
|
+
title="Never Gonna Give You Up",
|
|
89
|
+
artists=["Rick Astley"],
|
|
90
|
+
duration_ms=213000,
|
|
91
|
+
)
|
|
92
|
+
)
|
|
93
|
+
return ("YouTube search", True, f"matched: {source.title or source.url}")
|
|
94
|
+
except Exception as exc:
|
|
95
|
+
return ("YouTube search", False, f"failed: {exc}")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def check_lyrics_api() -> tuple[str, bool, str]:
|
|
99
|
+
try:
|
|
100
|
+
response = requests.get(
|
|
101
|
+
LRCLIB_URL,
|
|
102
|
+
params={"artist_name": "Rick Astley", "track_name": "Never Gonna Give You Up"},
|
|
103
|
+
timeout=10,
|
|
104
|
+
headers={"User-Agent": LYRICS_UA},
|
|
105
|
+
)
|
|
106
|
+
except requests.RequestException as exc:
|
|
107
|
+
return ("Lyrics API (lrclib.net)", False, f"failed: {exc}")
|
|
108
|
+
# 200 = match found, 404 = reachable but requested track not in lrclib.
|
|
109
|
+
if response.status_code in (200, 404):
|
|
110
|
+
detail = "reachable" if response.status_code == 200 else "reachable (track not found)"
|
|
111
|
+
return ("Lyrics API (lrclib.net)", True, detail)
|
|
112
|
+
return ("Lyrics API (lrclib.net)", False, f"HTTP {response.status_code}")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def run_doctor() -> int:
|
|
116
|
+
checks = [
|
|
117
|
+
check_python,
|
|
118
|
+
check_ffmpeg,
|
|
119
|
+
check_ffprobe,
|
|
120
|
+
check_download_dir,
|
|
121
|
+
check_spotify_embed,
|
|
122
|
+
check_youtube,
|
|
123
|
+
check_lyrics_api,
|
|
124
|
+
]
|
|
125
|
+
results = [check() for check in checks]
|
|
126
|
+
|
|
127
|
+
width = max(len(label) for label, _, _ in results)
|
|
128
|
+
failures = 0
|
|
129
|
+
for label, ok, detail in results:
|
|
130
|
+
failures += 0 if ok else 1
|
|
131
|
+
mark = "OK " if ok else "FAIL"
|
|
132
|
+
print(f"{mark} {label:<{width}} {detail}")
|
|
133
|
+
|
|
134
|
+
print()
|
|
135
|
+
if failures:
|
|
136
|
+
print(f"{failures} of {len(results)} checks failed. Fix the items above and try again.")
|
|
137
|
+
return 1
|
|
138
|
+
print(f"All {len(results)} checks passed. Stashify is ready to run.")
|
|
139
|
+
return 0
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Fetch plain-text lyrics, lrclib first, community fallback.
|
|
2
|
+
|
|
3
|
+
Returns None on any failure; lyrics are always optional and must never block
|
|
4
|
+
a download.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import requests
|
|
10
|
+
|
|
11
|
+
from .models import Track
|
|
12
|
+
|
|
13
|
+
LRCLIB_URL = "https://lrclib.net/api/get"
|
|
14
|
+
OVH_URL = "https://api.lyrics.ovh/v1"
|
|
15
|
+
TIMEOUT = 10
|
|
16
|
+
MAX_LYRICS_BYTES = 256 * 1024
|
|
17
|
+
USER_AGENT = "stashify/1.4 (https://github.com/nekonaru/stashify)"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class _Instrumental(Exception):
|
|
21
|
+
"""lrclib confirmed the track is instrumental; use no lyrics at all."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _fetch_lrclib(track: Track) -> str | None:
|
|
25
|
+
params = {
|
|
26
|
+
"artist_name": track.artist,
|
|
27
|
+
"track_name": track.title,
|
|
28
|
+
}
|
|
29
|
+
if track.album:
|
|
30
|
+
params["album_name"] = track.album
|
|
31
|
+
if track.duration_ms:
|
|
32
|
+
params["duration"] = round(track.duration_ms / 1000)
|
|
33
|
+
try:
|
|
34
|
+
response = requests.get(
|
|
35
|
+
LRCLIB_URL, params=params, timeout=TIMEOUT,
|
|
36
|
+
headers={"User-Agent": USER_AGENT},
|
|
37
|
+
)
|
|
38
|
+
except requests.RequestException:
|
|
39
|
+
return None
|
|
40
|
+
if response.status_code != 200:
|
|
41
|
+
return None
|
|
42
|
+
if len(response.content) > MAX_LYRICS_BYTES:
|
|
43
|
+
return None
|
|
44
|
+
try:
|
|
45
|
+
payload = response.json()
|
|
46
|
+
except ValueError:
|
|
47
|
+
return None
|
|
48
|
+
if isinstance(payload, dict) and payload.get("instrumental"):
|
|
49
|
+
raise _Instrumental()
|
|
50
|
+
lyrics = (payload or {}).get("plainLyrics") or ""
|
|
51
|
+
return lyrics.strip() or None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _fetch_ovh(track: Track) -> str | None:
|
|
55
|
+
artist = requests.utils.quote(track.artist, safe="")
|
|
56
|
+
title = requests.utils.quote(track.title, safe="")
|
|
57
|
+
try:
|
|
58
|
+
response = requests.get(f"{OVH_URL}/{artist}/{title}", timeout=TIMEOUT)
|
|
59
|
+
except requests.RequestException:
|
|
60
|
+
return None
|
|
61
|
+
if response.status_code != 200:
|
|
62
|
+
return None
|
|
63
|
+
if len(response.content) > MAX_LYRICS_BYTES:
|
|
64
|
+
return None
|
|
65
|
+
try:
|
|
66
|
+
lyrics = (response.json() or {}).get("lyrics") or ""
|
|
67
|
+
except ValueError:
|
|
68
|
+
return None
|
|
69
|
+
return lyrics.strip() or None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def fetch_lyrics(track: Track) -> str | None:
|
|
73
|
+
try:
|
|
74
|
+
lyrics = _fetch_lrclib(track)
|
|
75
|
+
except _Instrumental:
|
|
76
|
+
return None
|
|
77
|
+
if lyrics:
|
|
78
|
+
return lyrics
|
|
79
|
+
return _fetch_ovh(track)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: stashify-cli
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.4.0
|
|
4
4
|
Summary: Find music from your Spotify library on YouTube and save it locally with cover art, lyrics and full metadata.
|
|
5
5
|
Author: Nicolas Dwi Dharma
|
|
6
6
|
License-Expression: MIT
|
|
@@ -258,6 +258,7 @@ stashify download "https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M"
|
|
|
258
258
|
- Change the output folder with `-o "D:\Music"` and the naming scheme with `-t "{album}/{track_number} {title}"`
|
|
259
259
|
- Download up to 8 tracks at once with `--jobs 4` (default 2)
|
|
260
260
|
- Skip cover art or lyrics with `--no-cover` / `--no-lyrics`, or re-fetch everything with `--overwrite`
|
|
261
|
+
- Check the whole stack with `stashify doctor` when a download fails; it reports Python, FFmpeg, network access to Spotify, YouTube and the lyrics API
|
|
261
262
|
- Preview the match without downloading with `--dry-run`
|
|
262
263
|
- Keep a folder in sync with a playlist: `stashify sync "PLAYLIST_URL" -o "D:\Music\MyList"`
|
|
263
264
|
- Retag files you already have: `stashify meta file1.mp3 file2.mp3 --url "ALBUM_URL"`
|
|
@@ -303,7 +304,7 @@ Stashify is online only when it resolves metadata, searches for a source or fetc
|
|
|
303
304
|
| Track, album, playlist and artist metadata | Spotify's public embed pages (default) or the Spotify Web API (`--provider api`) |
|
|
304
305
|
| Cover art | Spotify image CDN (embedded into files) |
|
|
305
306
|
| Audio source | YouTube, through yt-dlp |
|
|
306
|
-
| Lyrics |
|
|
307
|
+
| Lyrics | lrclib.net (no key required), lyrics.ovh as fallback |
|
|
307
308
|
|
|
308
309
|
> [!NOTE]
|
|
309
310
|
> Some tracks have no usable match on YouTube. Stashify reports the best candidate it found and skips tracks whose score is too low instead of downloading the wrong song.
|
|
@@ -319,6 +320,7 @@ stashify/
|
|
|
319
320
|
│ ├── __init__.py # package version
|
|
320
321
|
│ ├── __main__.py # python -m stashify entry point
|
|
321
322
|
│ ├── cli.py # argument parsing and commands
|
|
323
|
+
│ ├── doctor.py # end-to-end stack health check
|
|
322
324
|
│ ├── pipeline.py # shared download pipeline
|
|
323
325
|
│ ├── spotify.py # Spotify Web API client (optional, --provider api)
|
|
324
326
|
│ ├── embed.py # public embed page resolver (default provider)
|
|
@@ -326,7 +328,7 @@ stashify/
|
|
|
326
328
|
│ ├── sources.py # YouTube search and scoring
|
|
327
329
|
│ ├── download.py # yt-dlp download and FFmpeg conversion
|
|
328
330
|
│ ├── tagger.py # MP3, M4A, FLAC, Opus and WAV tagging
|
|
329
|
-
│ ├── lyrics.py # lyrics.ovh
|
|
331
|
+
│ ├── lyrics.py # lrclib.net client (lyrics.ovh fallback)
|
|
330
332
|
│ ├── output.py # path templates and file naming
|
|
331
333
|
│ ├── sync.py # playlist sync state handling
|
|
332
334
|
│ ├── config.py # config file loading
|
|
@@ -413,6 +415,13 @@ Lint rules are enforced through the `[tool.ruff]` section in `pyproject.toml`.
|
|
|
413
415
|
|
|
414
416
|
## FAQ
|
|
415
417
|
|
|
418
|
+
<details>
|
|
419
|
+
<summary><b>Something fails; how do I diagnose it?</b></summary>
|
|
420
|
+
|
|
421
|
+
Run `stashify doctor`. It checks Python, FFmpeg, the downloads folder, Spotify's embed pages, YouTube search and the lyrics API, and tells you exactly which part works and which does not.
|
|
422
|
+
|
|
423
|
+
</details>
|
|
424
|
+
|
|
416
425
|
<details>
|
|
417
426
|
<summary><b>Do I need a Spotify account, login or developer app?</b></summary>
|
|
418
427
|
|
|
@@ -479,7 +488,7 @@ Yes. `stashify sync "PLAYLIST_URL" -o "D:\Music\MyList"` downloads new tracks an
|
|
|
479
488
|
|
|
480
489
|
## Disclaimer
|
|
481
490
|
|
|
482
|
-
Stashify is an independent open-source project and is not affiliated with or endorsed by Spotify, Google, YouTube or the operators of lyrics.ovh. The default provider parses Spotify's public embed pages; this is not a documented API and can change or break at any time. Downloading music may violate the terms of service of those platforms and may infringe copyright in your jurisdiction. Stashify is provided for personal, lawful use only; you are responsible for how you use it.
|
|
491
|
+
Stashify is an independent open-source project and is not affiliated with or endorsed by Spotify, Google, YouTube, lrclib or the operators of lyrics.ovh. The default provider parses Spotify's public embed pages; this is not a documented API and can change or break at any time. Downloading music may violate the terms of service of those platforms and may infringe copyright in your jurisdiction. Stashify is provided for personal, lawful use only; you are responsible for how you use it.
|
|
483
492
|
|
|
484
493
|
---
|
|
485
494
|
|
|
@@ -5,6 +5,7 @@ src/stashify/__init__.py
|
|
|
5
5
|
src/stashify/__main__.py
|
|
6
6
|
src/stashify/cli.py
|
|
7
7
|
src/stashify/config.py
|
|
8
|
+
src/stashify/doctor.py
|
|
8
9
|
src/stashify/download.py
|
|
9
10
|
src/stashify/embed.py
|
|
10
11
|
src/stashify/errors.py
|
|
@@ -24,8 +25,10 @@ src/stashify_cli.egg-info/requires.txt
|
|
|
24
25
|
src/stashify_cli.egg-info/top_level.txt
|
|
25
26
|
tests/test_cli.py
|
|
26
27
|
tests/test_config.py
|
|
28
|
+
tests/test_doctor.py
|
|
27
29
|
tests/test_download.py
|
|
28
30
|
tests/test_embed.py
|
|
31
|
+
tests/test_lyrics.py
|
|
29
32
|
tests/test_models.py
|
|
30
33
|
tests/test_output.py
|
|
31
34
|
tests/test_pipeline.py
|
|
@@ -49,6 +49,41 @@ def test_meta_api_without_credentials(monkeypatch, capsys):
|
|
|
49
49
|
assert code == 1
|
|
50
50
|
|
|
51
51
|
|
|
52
|
+
def test_dry_run_skips_detail_lookups(monkeypatch, capsys):
|
|
53
|
+
recorded = []
|
|
54
|
+
|
|
55
|
+
class FakeResolver:
|
|
56
|
+
def __init__(self, detailed=True, timeout=20):
|
|
57
|
+
recorded.append(detailed)
|
|
58
|
+
|
|
59
|
+
def fetch_tracks(self, url):
|
|
60
|
+
return []
|
|
61
|
+
|
|
62
|
+
monkeypatch.setattr("stashify.cli.EmbedResolver", FakeResolver)
|
|
63
|
+
main(["url", "https://open.spotify.com/track/4uLU6hMCjMI75M1A2tKUQC"])
|
|
64
|
+
main(["download", "https://open.spotify.com/track/4uLU6hMCjMI75M1A2tKUQC", "--dry-run"])
|
|
65
|
+
main(["save", "https://open.spotify.com/track/4uLU6hMCjMI75M1A2tKUQC"])
|
|
66
|
+
assert recorded == [False, False, True]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_no_cover_flag_forces_off(monkeypatch, tmp_path, capsys):
|
|
70
|
+
"""The --no-cover CLI flag is available and maps to --cover=false."""
|
|
71
|
+
import argparse
|
|
72
|
+
|
|
73
|
+
from stashify.cli import _options
|
|
74
|
+
|
|
75
|
+
monkeypatch.setenv("STASHIFY_DOWNLOAD_DIR", str(tmp_path))
|
|
76
|
+
monkeypatch.setattr("stashify.cli.load_config", lambda *a, **k: {})
|
|
77
|
+
base = dict(
|
|
78
|
+
output=None, template=None, audio_format=None, bitrate=None,
|
|
79
|
+
overwrite=None, dry_run=None, cover=False, lyrics=None, jobs=None,
|
|
80
|
+
provider=None, detailed=None,
|
|
81
|
+
)
|
|
82
|
+
options = _options(argparse.Namespace(**base))
|
|
83
|
+
assert options.embed_cover is False
|
|
84
|
+
assert options.embed_lyrics is True
|
|
85
|
+
|
|
86
|
+
|
|
52
87
|
def test_url_output_order_preserved(monkeypatch, capsys):
|
|
53
88
|
from stashify.models import Track
|
|
54
89
|
from stashify.sources import Source
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from stashify import doctor
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_doctor_all_pass(monkeypatch, capsys):
|
|
5
|
+
monkeypatch.setattr(doctor, "check_python", lambda: ("py", True, "ok"))
|
|
6
|
+
monkeypatch.setattr(doctor, "check_ffmpeg", lambda: ("ffmpeg", True, "ok"))
|
|
7
|
+
monkeypatch.setattr(doctor, "check_ffprobe", lambda: ("ffprobe", True, "ok"))
|
|
8
|
+
monkeypatch.setattr(doctor, "check_download_dir", lambda: ("dir", True, "ok"))
|
|
9
|
+
monkeypatch.setattr(doctor, "check_spotify_embed", lambda: ("embed", True, "ok"))
|
|
10
|
+
monkeypatch.setattr(doctor, "check_youtube", lambda: ("yt", True, "ok"))
|
|
11
|
+
monkeypatch.setattr(doctor, "check_lyrics_api", lambda: ("lyrics", True, "ok"))
|
|
12
|
+
assert doctor.run_doctor() == 0
|
|
13
|
+
out = capsys.readouterr().out
|
|
14
|
+
assert "All 7 checks passed" in out
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_download_dir_missing(monkeypatch, tmp_path):
|
|
18
|
+
monkeypatch.setattr(
|
|
19
|
+
"stashify.doctor.default_download_dir", lambda: tmp_path / "nope"
|
|
20
|
+
)
|
|
21
|
+
_, ok, detail = doctor.check_download_dir()
|
|
22
|
+
assert ok is False
|
|
23
|
+
assert "does not exist" in detail
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_lyrics_api_404_is_up(monkeypatch):
|
|
27
|
+
class Resp:
|
|
28
|
+
status_code = 404
|
|
29
|
+
|
|
30
|
+
monkeypatch.setattr(doctor.requests, "get", lambda *a, **k: Resp())
|
|
31
|
+
_, ok, detail = doctor.check_lyrics_api()
|
|
32
|
+
assert ok is True
|
|
33
|
+
assert "not found" in detail
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_doctor_reports_failures(monkeypatch, capsys):
|
|
37
|
+
monkeypatch.setattr(doctor, "check_python", lambda: ("py", True, "ok"))
|
|
38
|
+
monkeypatch.setattr(doctor, "check_ffmpeg", lambda: ("ffmpeg", False, "missing"))
|
|
39
|
+
monkeypatch.setattr(doctor, "check_ffprobe", lambda: ("ffprobe", True, "ok"))
|
|
40
|
+
monkeypatch.setattr(doctor, "check_download_dir", lambda: ("dir", False, "read-only"))
|
|
41
|
+
monkeypatch.setattr(doctor, "check_spotify_embed", lambda: ("embed", True, "ok"))
|
|
42
|
+
monkeypatch.setattr(doctor, "check_youtube", lambda: ("yt", False, "bot check"))
|
|
43
|
+
monkeypatch.setattr(doctor, "check_lyrics_api", lambda: ("lyrics", True, "ok"))
|
|
44
|
+
assert doctor.run_doctor() == 1
|
|
45
|
+
out = capsys.readouterr().out
|
|
46
|
+
assert "3 of 7 checks failed" in out
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import requests # noqa: F401 (requests.exceptions used via monkeypatch)
|
|
2
|
+
|
|
3
|
+
from stashify import lyrics
|
|
4
|
+
from stashify.models import Track
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _track():
|
|
8
|
+
return Track(
|
|
9
|
+
title="Never Gonna Give You Up",
|
|
10
|
+
artists=["Rick Astley"],
|
|
11
|
+
album="Whenever You Need Somebody",
|
|
12
|
+
duration_ms=213573,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_lrclib_success(monkeypatch):
|
|
17
|
+
def fake_get(url, params=None, timeout=None, headers=None):
|
|
18
|
+
class Resp:
|
|
19
|
+
status_code = 200
|
|
20
|
+
content = b'{"plainLyrics": "Never gonna give you up"}'
|
|
21
|
+
def json(self):
|
|
22
|
+
return {"plainLyrics": "We're no strangers to love"}
|
|
23
|
+
|
|
24
|
+
assert "lrclib.net" in url
|
|
25
|
+
assert params["artist_name"] == "Rick Astley"
|
|
26
|
+
return Resp()
|
|
27
|
+
|
|
28
|
+
monkeypatch.setattr(lyrics.requests, "get", fake_get)
|
|
29
|
+
assert lyrics.fetch_lyrics(_track()) == "We're no strangers to love"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_lrclib_404_falls_back_to_ovh(monkeypatch):
|
|
33
|
+
calls = {}
|
|
34
|
+
|
|
35
|
+
def fake_get(url, params=None, timeout=None, headers=None):
|
|
36
|
+
class Resp:
|
|
37
|
+
def __init__(self, status, body):
|
|
38
|
+
self.status_code = status
|
|
39
|
+
self.content = body
|
|
40
|
+
def json(self):
|
|
41
|
+
return {"lyrics": "fallback lyrics"}
|
|
42
|
+
|
|
43
|
+
calls["count"] = calls.get("count", 0) + 1
|
|
44
|
+
if "lrclib.net" in url:
|
|
45
|
+
return Resp(404, b"Not Found")
|
|
46
|
+
calls["ovh"] = True
|
|
47
|
+
return Resp(200, b'{"lyrics": "fallback lyrics"}')
|
|
48
|
+
|
|
49
|
+
monkeypatch.setattr(lyrics.requests, "get", fake_get)
|
|
50
|
+
assert lyrics.fetch_lyrics(_track()) == "fallback lyrics"
|
|
51
|
+
assert calls["ovh"] is True
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_lrclib_error_returns_fallback_none(monkeypatch):
|
|
55
|
+
def fake_get(url, params=None, timeout=None, headers=None):
|
|
56
|
+
class Resp:
|
|
57
|
+
status_code = 500
|
|
58
|
+
content = b"server error"
|
|
59
|
+
def json(self):
|
|
60
|
+
raise ValueError("bad json")
|
|
61
|
+
|
|
62
|
+
return Resp()
|
|
63
|
+
|
|
64
|
+
monkeypatch.setattr(lyrics.requests, "get", fake_get)
|
|
65
|
+
assert lyrics.fetch_lyrics(_track()) is None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_lrclib_instrumental_does_not_fall_back(monkeypatch):
|
|
69
|
+
calls = {}
|
|
70
|
+
|
|
71
|
+
def fake_get(url, params=None, timeout=None, headers=None):
|
|
72
|
+
class Resp:
|
|
73
|
+
status_code = 200
|
|
74
|
+
content = b'{"instrumental": true}'
|
|
75
|
+
|
|
76
|
+
def json(self):
|
|
77
|
+
return {"instrumental": True}
|
|
78
|
+
|
|
79
|
+
calls["count"] = calls.get("count", 0) + 1
|
|
80
|
+
return Resp()
|
|
81
|
+
|
|
82
|
+
monkeypatch.setattr(lyrics.requests, "get", fake_get)
|
|
83
|
+
assert lyrics.fetch_lyrics(_track()) is None
|
|
84
|
+
assert calls["count"] == 1
|
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
"""Fetch plain-text lyrics from a free community lyrics API.
|
|
2
|
-
|
|
3
|
-
Returns None on any failure; lyrics are always optional and must never block
|
|
4
|
-
a download.
|
|
5
|
-
"""
|
|
6
|
-
|
|
7
|
-
from __future__ import annotations
|
|
8
|
-
|
|
9
|
-
import requests
|
|
10
|
-
|
|
11
|
-
from .models import Track
|
|
12
|
-
|
|
13
|
-
API_URL = "https://api.lyrics.ovh/v1"
|
|
14
|
-
TIMEOUT = 10
|
|
15
|
-
MAX_LYRICS_BYTES = 64 * 1024
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
def fetch_lyrics(track: Track) -> str | None:
|
|
19
|
-
artist = requests.utils.quote(track.artist, safe="")
|
|
20
|
-
title = requests.utils.quote(track.title, safe="")
|
|
21
|
-
try:
|
|
22
|
-
response = requests.get(
|
|
23
|
-
f"{API_URL}/{artist}/{title}", timeout=TIMEOUT
|
|
24
|
-
)
|
|
25
|
-
except requests.RequestException:
|
|
26
|
-
return None
|
|
27
|
-
if response.status_code != 200:
|
|
28
|
-
return None
|
|
29
|
-
if len(response.content) > MAX_LYRICS_BYTES:
|
|
30
|
-
return None
|
|
31
|
-
try:
|
|
32
|
-
lyrics = (response.json() or {}).get("lyrics") or ""
|
|
33
|
-
except ValueError:
|
|
34
|
-
return None
|
|
35
|
-
lyrics = lyrics.strip()
|
|
36
|
-
return lyrics or None
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|