stashify-cli 1.3.0__py3-none-any.whl

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/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Stashify: find music from your Spotify library on YouTube and save it locally."""
2
+
3
+ __version__ = "1.3.0"
stashify/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())
stashify/cli.py ADDED
@@ -0,0 +1,372 @@
1
+ """Stashify command line interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import logging
8
+ import sys
9
+ from concurrent.futures import ThreadPoolExecutor
10
+ from pathlib import Path
11
+
12
+ from . import __version__
13
+ from .config import (
14
+ DEFAULT_BITRATE,
15
+ DEFAULT_FORMAT,
16
+ DEFAULT_JOBS,
17
+ DEFAULT_TEMPLATE,
18
+ clamp_jobs,
19
+ default_download_dir,
20
+ load_config,
21
+ )
22
+ from .download import AUDIO_FORMATS
23
+ from .embed import EmbedResolver
24
+ from .errors import StashifyError
25
+ from .lyrics import fetch_lyrics
26
+ from .models import Track
27
+ from .pipeline import TrackOptions, process_tracks, summarize
28
+ from .sources import find_source
29
+ from .spotify import SpotifyClient, client_credentials_from_env
30
+ from .sync import load_state, plan_sync, remove_entries, save_state, state_entry
31
+ from .tagger import fetch_cover, tag_file
32
+
33
+ LOG = logging.getLogger("stashify")
34
+
35
+
36
+ def build_parser() -> argparse.ArgumentParser:
37
+ parser = argparse.ArgumentParser(
38
+ prog="stashify",
39
+ description="Find music from your Spotify library on YouTube and save it locally.",
40
+ )
41
+ parser.add_argument("--version", action="version", version=f"stashify {__version__}")
42
+ parser.add_argument("-v", "--verbose", action="store_true", help="enable debug output")
43
+
44
+ credentials = argparse.ArgumentParser(add_help=False)
45
+ credentials.add_argument(
46
+ "--client-id", default=None,
47
+ help="Spotify app client id (env: SPOTIFY_CLIENT_ID)",
48
+ )
49
+ credentials.add_argument(
50
+ "--client-secret", default=None,
51
+ help="Spotify app client secret (env: SPOTIFY_CLIENT_SECRET)",
52
+ )
53
+ credentials.add_argument(
54
+ "--market", default=None,
55
+ help="two-letter market code (default: US)",
56
+ )
57
+
58
+ output = argparse.ArgumentParser(add_help=False)
59
+ output.add_argument(
60
+ "-o", "--output", default=None,
61
+ help="output directory (default: Downloads folder)"
62
+ )
63
+ output.add_argument(
64
+ "-t", "--template", default=None,
65
+ help=f"path template (default: {DEFAULT_TEMPLATE!r})",
66
+ )
67
+ output.add_argument(
68
+ "-f", "--format", dest="audio_format", choices=sorted(AUDIO_FORMATS),
69
+ default=None, help=f"audio format (default: {DEFAULT_FORMAT})",
70
+ )
71
+ output.add_argument(
72
+ "--bitrate", default=None,
73
+ help=f"bitrate for lossy formats (default: {DEFAULT_BITRATE})",
74
+ )
75
+ output.add_argument(
76
+ "--overwrite", action="store_true", default=None,
77
+ help="re-download files that already exist",
78
+ )
79
+ output.add_argument(
80
+ "--no-cover", action="store_true", default=None,
81
+ help="do not embed cover art",
82
+ )
83
+ output.add_argument(
84
+ "--no-lyrics", action="store_true", default=None,
85
+ help="do not embed lyrics",
86
+ )
87
+ output.add_argument(
88
+ "--dry-run", action="store_true", default=None,
89
+ help="print matches without downloading",
90
+ )
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
+ output.add_argument(
100
+ "--jobs", type=int, default=None, metavar="N",
101
+ help=f"download up to N tracks in parallel (default: {DEFAULT_JOBS}, max 8)",
102
+ )
103
+
104
+ sub = parser.add_subparsers(dest="command", metavar="command", required=True)
105
+
106
+ p_download = sub.add_parser(
107
+ "download", parents=[credentials, output],
108
+ help="download tracks, albums or playlists",
109
+ )
110
+ p_download.add_argument(
111
+ "urls", nargs="+", help="Spotify track, album, playlist or artist links"
112
+ )
113
+
114
+ p_save = sub.add_parser(
115
+ "save", parents=[credentials, output],
116
+ help="save track metadata as JSON without downloading",
117
+ )
118
+ p_save.add_argument("urls", nargs="+", help="Spotify links")
119
+ p_save.add_argument(
120
+ "--save-file", default=None,
121
+ help="where to write the JSON (default: stdout)",
122
+ )
123
+
124
+ p_url = sub.add_parser(
125
+ "url", parents=[credentials],
126
+ help="print the matched YouTube source URL per track",
127
+ )
128
+ p_url.add_argument("urls", nargs="+", help="Spotify links")
129
+
130
+ p_meta = sub.add_parser(
131
+ "meta", parents=[credentials, output],
132
+ help="write metadata into existing audio files",
133
+ )
134
+ p_meta.add_argument("files", nargs="+", help="audio files, in track order")
135
+ p_meta.add_argument(
136
+ "--url", required=True, help="Spotify link whose tracks are applied"
137
+ )
138
+
139
+ p_sync = sub.add_parser(
140
+ "sync", parents=[credentials, output],
141
+ help="sync a directory with a playlist",
142
+ )
143
+ p_sync.add_argument("url", help="playlist, album or artist link")
144
+ p_sync.add_argument(
145
+ "--state", default=None,
146
+ help="state file (default: <output>/stashify.sync.json)",
147
+ )
148
+
149
+ return parser
150
+
151
+
152
+ def _client(args: argparse.Namespace) -> SpotifyClient:
153
+ config = load_config()
154
+ market = args.market or config.get("market") or "US"
155
+ client_id_flag = getattr(args, "client_id", None)
156
+ client_secret_flag = getattr(args, "client_secret", None)
157
+ if client_id_flag or client_secret_flag:
158
+ if not client_id_flag or not client_secret_flag:
159
+ raise StashifyError(
160
+ "Pass both --client-id and --client-secret, "
161
+ "or neither to read them from the environment."
162
+ )
163
+ client_id, client_secret = client_id_flag, client_secret_flag
164
+ else:
165
+ client_id, client_secret = client_credentials_from_env()
166
+ return SpotifyClient(client_id=client_id, client_secret=client_secret, market=market)
167
+
168
+
169
+ def _resolver(args: argparse.Namespace):
170
+ config = load_config()
171
+ provider = getattr(args, "provider", None) or config.get("provider", "embed")
172
+ if provider == "embed":
173
+ no_details = bool(getattr(args, "no_details", False))
174
+ detailed = False if no_details else bool(config.get("detailed", True))
175
+ return EmbedResolver(detailed=detailed)
176
+ if provider == "api":
177
+ return _client(args)
178
+ raise StashifyError(f"Unknown provider {provider!r}. Choose from: embed, api.")
179
+
180
+
181
+ def _options(args: argparse.Namespace) -> TrackOptions:
182
+ config = load_config()
183
+
184
+ def pick(cli, key, default):
185
+ if cli is not None:
186
+ return cli
187
+ return config.get(key, default)
188
+
189
+ no_cover = pick(getattr(args, "no_cover", None), "no_cover", False)
190
+ no_lyrics = pick(getattr(args, "no_lyrics", None), "no_lyrics", False)
191
+ return TrackOptions(
192
+ out_dir=Path(
193
+ pick(getattr(args, "output", None), "output_dir", str(default_download_dir()))
194
+ ),
195
+ template=pick(getattr(args, "template", None), "template", DEFAULT_TEMPLATE),
196
+ audio_format=pick(getattr(args, "audio_format", None), "audio_format", DEFAULT_FORMAT),
197
+ bitrate=pick(getattr(args, "bitrate", None), "bitrate", DEFAULT_BITRATE),
198
+ overwrite=bool(pick(getattr(args, "overwrite", None), "overwrite", False)),
199
+ dry_run=bool(pick(getattr(args, "dry_run", None), "dry_run", False)),
200
+ embed_cover=not no_cover,
201
+ embed_lyrics=not no_lyrics,
202
+ jobs=clamp_jobs(pick(getattr(args, "jobs", None), "jobs", DEFAULT_JOBS)),
203
+ )
204
+
205
+
206
+ def _fetch(args: argparse.Namespace, urls: list[str]) -> list[Track]:
207
+ resolver = _resolver(args)
208
+ tracks: list[Track] = []
209
+ for url in urls:
210
+ tracks.extend(resolver.fetch_tracks(url))
211
+ return tracks
212
+
213
+
214
+ def _cmd_download(args: argparse.Namespace) -> int:
215
+ tracks = _fetch(args, args.urls)
216
+ if not tracks:
217
+ print("No tracks found for the given links.")
218
+ return 1
219
+ options = _options(args)
220
+ print(f"Saving to: {options.out_dir}")
221
+ results = process_tracks(tracks, options)
222
+ counts = summarize(results)
223
+ print(
224
+ f"Done: {counts['downloaded']} downloaded, {counts['skipped']} skipped, "
225
+ f"{counts['failed']} failed."
226
+ )
227
+ return 1 if counts["failed"] else 0
228
+
229
+
230
+ def _cmd_save(args: argparse.Namespace) -> int:
231
+ tracks = _fetch(args, args.urls)
232
+ payload = json.dumps([t.to_dict() for t in tracks], indent=2, ensure_ascii=False)
233
+ if args.save_file:
234
+ save_path = Path(args.save_file)
235
+ try:
236
+ save_path.parent.mkdir(parents=True, exist_ok=True)
237
+ save_path.write_text(payload + "\n", encoding="utf-8")
238
+ except OSError as exc:
239
+ raise StashifyError(f"Could not write {save_path}: {exc}") from exc
240
+ print(f"Saved {len(tracks)} track(s) to {args.save_file}")
241
+ else:
242
+ print(payload)
243
+ return 0
244
+
245
+
246
+ def _cmd_url(args: argparse.Namespace) -> int:
247
+ tracks = _fetch(args, args.urls)
248
+
249
+ def work(index: int, track: Track) -> tuple[int, str, bool]:
250
+ try:
251
+ source = find_source(track)
252
+ except StashifyError as exc:
253
+ return index, f"{track.display()} - not found: {exc}", False
254
+ return index, f"{track.display()} - {source.url}", True
255
+
256
+ total = len(tracks)
257
+ rows: list[tuple[str, bool] | None] = [None] * total
258
+ with ThreadPoolExecutor(max_workers=4) as pool:
259
+ for index, line, ok in pool.map(work, range(total), tracks):
260
+ rows[index] = (line, ok)
261
+ failures = 0
262
+ for row in rows:
263
+ assert row is not None
264
+ line, ok = row
265
+ print(line)
266
+ if not ok:
267
+ failures += 1
268
+ return 1 if failures else 0
269
+
270
+
271
+ def _cmd_meta(args: argparse.Namespace) -> int:
272
+ config = load_config()
273
+ tracks = _fetch(args, [args.url])
274
+ files = [Path(f) for f in args.files]
275
+ missing = [f for f in files if not f.is_file()]
276
+ if missing:
277
+ for f in missing:
278
+ print(f"File not found: {f}")
279
+ return 1
280
+ limit = min(len(files), len(tracks))
281
+ if len(files) != len(tracks):
282
+ print(
283
+ f"Warning: {len(files)} file(s) given but {len(tracks)} track(s) "
284
+ f"found; tagging the first {limit}."
285
+ )
286
+ failures = 0
287
+ for path, track in zip(files, tracks, strict=False):
288
+ 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
291
+ tag_file(path, track, cover, lyrics)
292
+ print(f"Tagged {path} -> {track.display()}")
293
+ except Exception as exc:
294
+ failures += 1
295
+ print(f"Could not tag {path}: {exc}")
296
+ return 1 if failures else 0
297
+
298
+
299
+ def _cmd_sync(args: argparse.Namespace) -> int:
300
+ options = _options(args)
301
+ out_dir = options.out_dir
302
+ state_path = Path(args.state) if args.state else out_dir / "stashify.sync.json"
303
+ tracks = _fetch(args, [args.url])
304
+ state = load_state(state_path)
305
+ state["url"] = args.url
306
+ missing, removed = plan_sync(state, tracks)
307
+ print(f"Syncing: {out_dir}")
308
+
309
+ deleted = remove_entries(removed, out_dir)
310
+ for path in deleted:
311
+ print(f"Removed (no longer in playlist): {path}")
312
+
313
+ if missing:
314
+ results = process_tracks(missing, options)
315
+ for result in results:
316
+ if result.status == "downloaded" and result.path:
317
+ key = state_entry(result.track, result.path, out_dir)["key"]
318
+ state["entries"] = [
319
+ e for e in state["entries"] if e.get("key") != key
320
+ ]
321
+ state["entries"].append(
322
+ state_entry(result.track, result.path, out_dir)
323
+ )
324
+ else:
325
+ print("Directory is already in sync.")
326
+
327
+ removed_keys = {e.get("key") for e in removed}
328
+ state["entries"] = [e for e in state["entries"] if e.get("key") not in removed_keys]
329
+ save_state(state_path, state)
330
+
331
+ counts_failed = 0
332
+ if missing:
333
+ counts_failed = sum(1 for r in results if r.status == "failed")
334
+ print(
335
+ f"Sync complete: {len(missing)} to download, {len(removed)} removed, "
336
+ f"{len(tracks)} track(s) now in the playlist."
337
+ )
338
+ return 1 if counts_failed else 0
339
+
340
+
341
+ def main(argv: list[str] | None = None) -> int:
342
+ parser = build_parser()
343
+ args = parser.parse_args(argv)
344
+ logging.basicConfig(
345
+ level=logging.DEBUG if args.verbose else logging.WARNING,
346
+ format="%(levelname)s %(name)s: %(message)s",
347
+ )
348
+ handlers = {
349
+ "download": _cmd_download,
350
+ "save": _cmd_save,
351
+ "url": _cmd_url,
352
+ "meta": _cmd_meta,
353
+ "sync": _cmd_sync,
354
+ }
355
+ handler = handlers[args.command]
356
+ try:
357
+ return handler(args)
358
+ except StashifyError as exc:
359
+ print(f"Error: {exc}", file=sys.stderr)
360
+ return 1
361
+ except KeyboardInterrupt:
362
+ print("Interrupted.", file=sys.stderr)
363
+ return 130
364
+ except Exception as exc: # Never leak a traceback unless asked for one.
365
+ if args.verbose:
366
+ raise
367
+ print(f"Unexpected error: {exc}", file=sys.stderr)
368
+ return 1
369
+
370
+
371
+ if __name__ == "__main__":
372
+ sys.exit(main())
stashify/config.py ADDED
@@ -0,0 +1,108 @@
1
+ """Load non-secret user configuration.
2
+
3
+ Secrets (Spotify client id and secret) are read from the environment or from
4
+ command line flags, never from the config file. The config file only holds
5
+ preferences such as the output directory and default audio format.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ from pathlib import Path
13
+
14
+ from .errors import StashifyError
15
+
16
+ DEFAULT_TEMPLATE = "{artist} - {title}"
17
+ DEFAULT_FORMAT = "mp3"
18
+ DEFAULT_BITRATE = "192k"
19
+ DEFAULT_JOBS = 2
20
+
21
+ _KNOWN_KEYS = {
22
+ "output_dir": str,
23
+ "audio_format": str,
24
+ "bitrate": str,
25
+ "template": str,
26
+ "market": str,
27
+ "overwrite": bool,
28
+ "no_cover": bool,
29
+ "no_lyrics": bool,
30
+ "provider": str,
31
+ "detailed": bool,
32
+ "jobs": int,
33
+ }
34
+
35
+
36
+ def config_dir() -> Path:
37
+ if os.name == "nt":
38
+ base = os.environ.get("APPDATA") or str(Path.home() / "AppData" / "Roaming")
39
+ return Path(base) / "stashify"
40
+ xdg = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
41
+ return Path(xdg) / "stashify"
42
+
43
+
44
+ def _is_windows() -> bool:
45
+ return os.name == "nt"
46
+
47
+
48
+ def default_download_dir() -> Path:
49
+ """The user's downloads folder, resolved the same way other apps do it."""
50
+ override = os.environ.get("STASHIFY_DOWNLOAD_DIR")
51
+ if override:
52
+ return Path(override).expanduser()
53
+ if _is_windows():
54
+ profile = os.environ.get("USERPROFILE") or str(Path.home())
55
+ return Path(profile) / "Downloads"
56
+ xdg = os.environ.get("XDG_DOWNLOAD_DIR")
57
+ if xdg:
58
+ return Path(xdg).expanduser()
59
+ user_dirs = Path.home() / ".config" / "user-dirs.dirs"
60
+ try:
61
+ for line in user_dirs.read_text(encoding="utf-8").splitlines():
62
+ line = line.strip()
63
+ if line.startswith("XDG_DOWNLOAD_DIR="):
64
+ raw = line.split("=", 1)[1].strip().strip('"')
65
+ raw = raw.replace("$HOME", str(Path.home()))
66
+ return Path(raw)
67
+ except OSError:
68
+ pass
69
+ return Path.home() / "Downloads"
70
+
71
+
72
+ def clamp_jobs(value) -> int:
73
+ try:
74
+ jobs = int(value)
75
+ except (TypeError, ValueError):
76
+ raise StashifyError(f"jobs must be a number between 1 and 8, got {value!r}.") from None
77
+ if not 1 <= jobs <= 8:
78
+ raise StashifyError(f"jobs must be between 1 and 8, got {jobs}.")
79
+ return jobs
80
+
81
+
82
+ def default_config_path() -> Path:
83
+ return config_dir() / "config.json"
84
+
85
+
86
+ def load_config(path: Path | None = None) -> dict:
87
+ path = Path(path) if path else default_config_path()
88
+ if not path.exists():
89
+ return {}
90
+ try:
91
+ data = json.loads(path.read_text(encoding="utf-8"))
92
+ except (OSError, json.JSONDecodeError) as exc:
93
+ raise StashifyError(f"Could not read config file {path}: {exc}") from exc
94
+ if not isinstance(data, dict):
95
+ raise StashifyError(f"Config file {path} must contain a JSON object.")
96
+ cleaned: dict = {}
97
+ for key, expected in _KNOWN_KEYS.items():
98
+ value = data.get(key)
99
+ if value is None:
100
+ continue
101
+ if expected is bool and not isinstance(value, bool):
102
+ raise StashifyError(f"Config key {key!r} must be a boolean.")
103
+ if expected is str and not isinstance(value, str):
104
+ raise StashifyError(f"Config key {key!r} must be a string.")
105
+ if expected is int and not isinstance(value, int):
106
+ raise StashifyError(f"Config key {key!r} must be an integer.")
107
+ cleaned[key] = value
108
+ return cleaned
stashify/download.py ADDED
@@ -0,0 +1,104 @@
1
+ """Download audio with yt-dlp and convert it with FFmpeg."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import shutil
7
+ from pathlib import Path
8
+
9
+ import yt_dlp
10
+
11
+ from .errors import DownloadError
12
+ from .sources import Source
13
+
14
+ LOSSLESS_FORMATS = {"flac", "wav"}
15
+ AUDIO_FORMATS = {"mp3", "m4a", "opus", "flac", "wav"}
16
+
17
+ _CODEC_MAP = {
18
+ "mp3": "mp3",
19
+ "m4a": "m4a",
20
+ "opus": "opus",
21
+ "flac": "flac",
22
+ "wav": "wav",
23
+ }
24
+
25
+ _BITRATE_RE = re.compile(r"^\d{2,4}k?$", re.IGNORECASE)
26
+
27
+
28
+ def validate_bitrate(bitrate: str) -> str:
29
+ """Validate a bitrate like 128k, 192k, 320k. Raises DownloadError if bad."""
30
+ text = (bitrate or "").strip().lower()
31
+ if not _BITRATE_RE.match(text):
32
+ raise DownloadError(
33
+ f"Invalid bitrate {bitrate!r}. Use something like 128k, 192k or 320k."
34
+ )
35
+ return text
36
+
37
+
38
+ def ffmpeg_available() -> bool:
39
+ return shutil.which("ffmpeg") is not None
40
+
41
+
42
+ def download_audio(
43
+ source: Source,
44
+ output_base: Path,
45
+ audio_format: str = "mp3",
46
+ bitrate: str = "192k",
47
+ ) -> Path:
48
+ """Download source audio and convert to audio_format.
49
+
50
+ output_base is the target path without extension. Returns the final path.
51
+ """
52
+ audio_format = audio_format.lower()
53
+ if audio_format not in AUDIO_FORMATS:
54
+ raise DownloadError(
55
+ f"Unsupported audio format {audio_format!r}. "
56
+ f"Choose from: {', '.join(sorted(AUDIO_FORMATS))}."
57
+ )
58
+ bitrate = validate_bitrate(bitrate)
59
+ if not ffmpeg_available():
60
+ raise DownloadError(
61
+ "FFmpeg was not found on PATH. Install FFmpeg first: "
62
+ "https://ffmpeg.org/download.html"
63
+ )
64
+
65
+ output_base = Path(output_base)
66
+ output_base.parent.mkdir(parents=True, exist_ok=True)
67
+
68
+ options = {
69
+ "quiet": True,
70
+ "no_warnings": True,
71
+ "format": "bestaudio/best",
72
+ "outtmpl": str(output_base) + ".%(ext)s",
73
+ "noplaylist": True,
74
+ "postprocessors": [
75
+ {
76
+ "key": "FFmpegExtractAudio",
77
+ "preferredcodec": _CODEC_MAP[audio_format],
78
+ "preferredquality": (
79
+ "0" if audio_format in LOSSLESS_FORMATS
80
+ else bitrate.rstrip("k")
81
+ ),
82
+ }
83
+ ],
84
+ }
85
+ try:
86
+ with yt_dlp.YoutubeDL(options) as ydl:
87
+ ydl.download([source.url])
88
+ except Exception as exc: # yt-dlp raises many error types; surface one clear message.
89
+ raise DownloadError(
90
+ f"Could not download {source.url}: {exc}"
91
+ ) from exc
92
+
93
+ expected = output_base.with_suffix("." + audio_format)
94
+ if expected.exists():
95
+ return expected
96
+ # Fall back: pick up whatever the postprocessor produced.
97
+ candidates = sorted(
98
+ output_base.parent.glob(output_base.name + ".*"),
99
+ key=lambda p: p.stat().st_mtime,
100
+ reverse=True,
101
+ )
102
+ if candidates:
103
+ return candidates[0]
104
+ raise DownloadError(f"Download finished but no output file was produced for {output_base}")