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 +3 -0
- stashify/__main__.py +6 -0
- stashify/cli.py +372 -0
- stashify/config.py +108 -0
- stashify/download.py +104 -0
- stashify/embed.py +271 -0
- stashify/errors.py +21 -0
- stashify/lyrics.py +36 -0
- stashify/models.py +62 -0
- stashify/output.py +59 -0
- stashify/pipeline.py +179 -0
- stashify/sources.py +130 -0
- stashify/spotify.py +256 -0
- stashify/sync.py +93 -0
- stashify/tagger.py +243 -0
- stashify_cli-1.3.0.dist-info/METADATA +488 -0
- stashify_cli-1.3.0.dist-info/RECORD +21 -0
- stashify_cli-1.3.0.dist-info/WHEEL +5 -0
- stashify_cli-1.3.0.dist-info/entry_points.txt +2 -0
- stashify_cli-1.3.0.dist-info/licenses/LICENSE +21 -0
- stashify_cli-1.3.0.dist-info/top_level.txt +1 -0
stashify/__init__.py
ADDED
stashify/__main__.py
ADDED
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}")
|