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.
Files changed (40) hide show
  1. {stashify_cli-1.3.0 → stashify_cli-1.4.0}/PKG-INFO +13 -4
  2. {stashify_cli-1.3.0 → stashify_cli-1.4.0}/README.md +12 -3
  3. {stashify_cli-1.3.0 → stashify_cli-1.4.0}/pyproject.toml +1 -1
  4. {stashify_cli-1.3.0 → stashify_cli-1.4.0}/src/stashify/__init__.py +1 -1
  5. {stashify_cli-1.3.0 → stashify_cli-1.4.0}/src/stashify/cli.py +48 -21
  6. stashify_cli-1.4.0/src/stashify/doctor.py +139 -0
  7. stashify_cli-1.4.0/src/stashify/lyrics.py +79 -0
  8. {stashify_cli-1.3.0 → stashify_cli-1.4.0}/src/stashify_cli.egg-info/PKG-INFO +13 -4
  9. {stashify_cli-1.3.0 → stashify_cli-1.4.0}/src/stashify_cli.egg-info/SOURCES.txt +3 -0
  10. {stashify_cli-1.3.0 → stashify_cli-1.4.0}/tests/test_cli.py +35 -0
  11. stashify_cli-1.4.0/tests/test_doctor.py +46 -0
  12. stashify_cli-1.4.0/tests/test_lyrics.py +84 -0
  13. stashify_cli-1.3.0/src/stashify/lyrics.py +0 -36
  14. {stashify_cli-1.3.0 → stashify_cli-1.4.0}/LICENSE +0 -0
  15. {stashify_cli-1.3.0 → stashify_cli-1.4.0}/setup.cfg +0 -0
  16. {stashify_cli-1.3.0 → stashify_cli-1.4.0}/src/stashify/__main__.py +0 -0
  17. {stashify_cli-1.3.0 → stashify_cli-1.4.0}/src/stashify/config.py +0 -0
  18. {stashify_cli-1.3.0 → stashify_cli-1.4.0}/src/stashify/download.py +0 -0
  19. {stashify_cli-1.3.0 → stashify_cli-1.4.0}/src/stashify/embed.py +0 -0
  20. {stashify_cli-1.3.0 → stashify_cli-1.4.0}/src/stashify/errors.py +0 -0
  21. {stashify_cli-1.3.0 → stashify_cli-1.4.0}/src/stashify/models.py +0 -0
  22. {stashify_cli-1.3.0 → stashify_cli-1.4.0}/src/stashify/output.py +0 -0
  23. {stashify_cli-1.3.0 → stashify_cli-1.4.0}/src/stashify/pipeline.py +0 -0
  24. {stashify_cli-1.3.0 → stashify_cli-1.4.0}/src/stashify/sources.py +0 -0
  25. {stashify_cli-1.3.0 → stashify_cli-1.4.0}/src/stashify/spotify.py +0 -0
  26. {stashify_cli-1.3.0 → stashify_cli-1.4.0}/src/stashify/sync.py +0 -0
  27. {stashify_cli-1.3.0 → stashify_cli-1.4.0}/src/stashify/tagger.py +0 -0
  28. {stashify_cli-1.3.0 → stashify_cli-1.4.0}/src/stashify_cli.egg-info/dependency_links.txt +0 -0
  29. {stashify_cli-1.3.0 → stashify_cli-1.4.0}/src/stashify_cli.egg-info/entry_points.txt +0 -0
  30. {stashify_cli-1.3.0 → stashify_cli-1.4.0}/src/stashify_cli.egg-info/requires.txt +0 -0
  31. {stashify_cli-1.3.0 → stashify_cli-1.4.0}/src/stashify_cli.egg-info/top_level.txt +0 -0
  32. {stashify_cli-1.3.0 → stashify_cli-1.4.0}/tests/test_config.py +0 -0
  33. {stashify_cli-1.3.0 → stashify_cli-1.4.0}/tests/test_download.py +0 -0
  34. {stashify_cli-1.3.0 → stashify_cli-1.4.0}/tests/test_embed.py +0 -0
  35. {stashify_cli-1.3.0 → stashify_cli-1.4.0}/tests/test_models.py +0 -0
  36. {stashify_cli-1.3.0 → stashify_cli-1.4.0}/tests/test_output.py +0 -0
  37. {stashify_cli-1.3.0 → stashify_cli-1.4.0}/tests/test_pipeline.py +0 -0
  38. {stashify_cli-1.3.0 → stashify_cli-1.4.0}/tests/test_spotify.py +0 -0
  39. {stashify_cli-1.3.0 → stashify_cli-1.4.0}/tests/test_sync.py +0 -0
  40. {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.0
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 | lyrics.ovh (no key required) |
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 client
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 | lyrics.ovh (no key required) |
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 client
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.3.0"
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"
@@ -1,3 +1,3 @@
1
1
  """Stashify: find music from your Spotify library on YouTube and save it locally."""
2
2
 
3
- __version__ = "1.3.0"
3
+ __version__ = "1.4.0"
@@ -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
- output = argparse.ArgumentParser(add_help=False)
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
- "--no-cover", action="store_true", default=None,
81
- help="do not embed cover art",
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
- "--no-lyrics", action="store_true", default=None,
85
- help="do not embed lyrics",
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
- detailed = False if no_details else bool(config.get("detailed", True))
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
- no_cover = pick(getattr(args, "no_cover", None), "no_cover", False)
190
- no_lyrics = pick(getattr(args, "no_lyrics", None), "no_lyrics", False)
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=not no_cover,
201
- embed_lyrics=not no_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 not config.get("no_cover") else None
290
- lyrics = fetch_lyrics(track) if not config.get("no_lyrics") else None
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.0
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 | lyrics.ovh (no key required) |
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 client
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