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/embed.py ADDED
@@ -0,0 +1,271 @@
1
+ """Metadata from Spotify's public embed pages.
2
+
3
+ The embed pages served at open.spotify.com/embed/... are public HTML pages
4
+ meant to be loaded by any visitor's browser. Each page embeds a JSON document
5
+ with the entity metadata (title, artists, duration, cover art, the track list
6
+ of an album, playlist or artist). Stashify parses that document instead of the
7
+ official Web API, so no account, login or developer app is required.
8
+
9
+ Limitations, honestly:
10
+ - Private or unavailable entities cannot be resolved (embed shows a not-found page).
11
+ - Fields like ISRC, album name (for playlist items), track numbers and disc
12
+ numbers are not available. Album track numbers are derived from the order.
13
+ - The embed format is not a documented public contract and can change.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import re
20
+ import threading
21
+ from concurrent.futures import ThreadPoolExecutor
22
+ from dataclasses import dataclass
23
+
24
+ import requests
25
+
26
+ from .errors import StashifyError
27
+ from .models import Track
28
+ from .spotify import parse_spotify_url
29
+
30
+ EMBED_BASE = "https://open.spotify.com/embed"
31
+ EMBED_TIMEOUT = 20
32
+ MAX_EMBED_BYTES = 8 * 1024 * 1024
33
+ USER_AGENT = (
34
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
35
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
36
+ "Chrome/131.0.0.0 Safari/537.36"
37
+ )
38
+
39
+ _NEXT_DATA_RE = re.compile(
40
+ r'<script id="__NEXT_DATA__" type="application/json">(.*?)</script>',
41
+ re.S,
42
+ )
43
+
44
+
45
+ @dataclass
46
+ class EmbedResolver:
47
+ """Resolve public Spotify links into Track objects via embed pages."""
48
+
49
+ detailed: bool = True
50
+ timeout: int = EMBED_TIMEOUT
51
+
52
+ def __post_init__(self) -> None:
53
+ self._local = threading.local()
54
+
55
+ def _session(self) -> requests.Session:
56
+ session = getattr(self._local, "session", None)
57
+ if session is None:
58
+ session = requests.Session()
59
+ self._local.session = session
60
+ return session
61
+
62
+ # Public interface ------------------------------------------------------
63
+
64
+ def fetch_tracks(self, url: str) -> list[Track]:
65
+ kind, spotify_id = parse_spotify_url(url)
66
+ entity = self._entity(kind, spotify_id)
67
+ if kind == "track":
68
+ return [self._track_from_entity(entity)]
69
+ if kind == "album":
70
+ entity_title = _clean_str(entity.get("name"))
71
+ album_artist = _clean_str(entity.get("subtitle"))
72
+ cover = _largest_image(entity)
73
+ entries = _track_list(entity)
74
+ tracks = []
75
+ for index, entry in enumerate(entries, start=1):
76
+ track = self._track_from_entry(entry)
77
+ track.album = entity_title
78
+ track.album_artist = album_artist or track.artist
79
+ track.track_number = index
80
+ track.disc_number = 1
81
+ track.total_tracks = len(entries)
82
+ track.cover_url = cover or track.cover_url
83
+ tracks.append(track)
84
+ if self.detailed:
85
+ tracks = self._enrich(tracks)
86
+ return tracks
87
+ if kind in ("playlist", "artist"):
88
+ cover = _largest_image(entity) if kind == "playlist" else ""
89
+ entries = _track_list(entity)
90
+ tracks = [self._track_from_entry(entry) for entry in entries]
91
+ if self.detailed:
92
+ tracks = self._enrich(tracks)
93
+ if kind == "playlist":
94
+ for track in tracks:
95
+ if not track.cover_url:
96
+ track.cover_url = cover
97
+ return tracks
98
+ raise StashifyError(f"Unsupported Spotify link kind: {kind!r}")
99
+
100
+ # Internals ------------------------------------------------------------
101
+
102
+ def _entity(self, kind: str, spotify_id: str) -> dict:
103
+ try:
104
+ response = self._session().get(
105
+ f"{EMBED_BASE}/{kind}/{spotify_id}",
106
+ headers={"User-Agent": USER_AGENT},
107
+ timeout=self.timeout,
108
+ stream=True,
109
+ )
110
+ except requests.RequestException as exc:
111
+ raise StashifyError(f"Could not reach Spotify embed pages: {exc}") from exc
112
+
113
+ content = b""
114
+ limit = MAX_EMBED_BYTES
115
+ for chunk in response.iter_content(chunk_size=64 * 1024):
116
+ limit -= len(chunk)
117
+ if limit < 0:
118
+ raise StashifyError("Spotify embed page is unexpectedly large.")
119
+ content += chunk
120
+ if response.status_code != 200:
121
+ raise StashifyError(
122
+ f"Spotify embed request failed ({response.status_code}). "
123
+ "The link is probably private or does not exist."
124
+ )
125
+ match = _NEXT_DATA_RE.search(content.decode("utf-8", errors="replace"))
126
+ if not match:
127
+ raise StashifyError(
128
+ "Spotify embed page has an unexpected format. "
129
+ "The embed format may have changed."
130
+ )
131
+ try:
132
+ payload = json.loads(match.group(1))
133
+ except json.JSONDecodeError as exc:
134
+ raise StashifyError("Spotify embed page contains invalid JSON.") from exc
135
+ except RecursionError as exc:
136
+ raise StashifyError("Spotify embed page JSON is too deeply nested.") from exc
137
+ entity = (
138
+ ((payload.get("props") or {}).get("pageProps") or {})
139
+ .get("state", {})
140
+ .get("data", {})
141
+ .get("entity")
142
+ )
143
+ if not isinstance(entity, dict):
144
+ status = (payload.get("props") or {}).get("pageProps", {}).get("status")
145
+ detail = f" (status: {status})" if status else ""
146
+ raise StashifyError(
147
+ f"This {kind} link could not be resolved{detail}. "
148
+ "It may be private, unavailable or removed."
149
+ )
150
+ return entity
151
+
152
+ def _track_from_entity(self, entity: dict) -> Track:
153
+ artists = [
154
+ _clean_str(a.get("name"))
155
+ for a in entity.get("artists") or []
156
+ if _clean_str(a.get("name"))
157
+ ]
158
+ if not artists:
159
+ subtitle = _clean_str(entity.get("subtitle"))
160
+ artists = _split_artists(subtitle)
161
+ release = ""
162
+ raw_date = entity.get("releaseDate") or {}
163
+ if isinstance(raw_date, dict):
164
+ release = str(raw_date.get("isoString") or "")[:10]
165
+ cover = _largest_image(entity)
166
+ return Track(
167
+ title=_clean_str(entity.get("name") or entity.get("title")),
168
+ artists=artists,
169
+ duration_ms=int(entity.get("duration") or 0),
170
+ release_date=release,
171
+ cover_url=cover,
172
+ spotify_url=_spotify_url(entity),
173
+ explicit=bool(entity.get("isExplicit") or False),
174
+ )
175
+
176
+ def _track_from_entry(self, entry: dict) -> Track:
177
+ uri = _clean_str(entry.get("uri"))
178
+ artists = _split_artists(_clean_str(entry.get("subtitle")))
179
+ explicit = bool(entry.get("isExplicit") or False)
180
+ return Track(
181
+ title=_clean_str(entry.get("title")),
182
+ artists=artists,
183
+ duration_ms=int(entry.get("duration") or 0),
184
+ spotify_url=_spotify_url_from_uri(uri),
185
+ explicit=explicit,
186
+ )
187
+
188
+ def _enrich(self, tracks: list[Track]) -> list[Track]:
189
+ """Fill cover art, release year and proper artists via per-track embeds.
190
+
191
+ Runs four lookups in parallel; on any failure the basic entry data is
192
+ kept.
193
+ """
194
+
195
+ def fetch(index: int, track: Track) -> tuple[int, Track]:
196
+ track_id = _id_from_spotify_url(track.spotify_url)
197
+ if not track_id:
198
+ return index, track
199
+ try:
200
+ entity = self._entity("track", track_id)
201
+ except StashifyError:
202
+ return index, track
203
+ full = self._track_from_entity(entity)
204
+ if full.artists:
205
+ track.artists = full.artists
206
+ if full.release_date and not track.release_date:
207
+ track.release_date = full.release_date
208
+ if full.cover_url:
209
+ track.cover_url = full.cover_url
210
+ return index, track
211
+
212
+ if len(tracks) > 1:
213
+ with ThreadPoolExecutor(max_workers=4) as pool:
214
+ return [track for _, track in sorted(pool.map(fetch, range(len(tracks)), tracks))]
215
+ return [fetch(0, tracks[0])[1]]
216
+
217
+
218
+ def _clean_str(value) -> str:
219
+ if not isinstance(value, str):
220
+ return ""
221
+ return value.strip()
222
+
223
+
224
+ def _split_artists(subtitle: str) -> list[str]:
225
+ parts = [part.strip() for part in (subtitle or "").split(",")]
226
+ parts = [part for part in parts if part]
227
+ return parts or ["Unknown Artist"]
228
+
229
+
230
+ def _track_list(entity: dict) -> list[dict]:
231
+ entries = entity.get("trackList") or []
232
+ result = []
233
+ for entry in entries:
234
+ if not isinstance(entry, dict):
235
+ continue
236
+ if entry.get("isPlayable") is False:
237
+ continue
238
+ if _clean_str(entry.get("uri")) and _clean_str(entry.get("title")):
239
+ result.append(entry)
240
+ return result
241
+
242
+
243
+ def _largest_image(entity: dict) -> str:
244
+ images = (entity.get("visualIdentity") or {}).get("image") or []
245
+ if not images:
246
+ return ""
247
+
248
+ def area(image: dict) -> int:
249
+ return int(image.get("maxWidth") or 0) * int(image.get("maxHeight") or 0)
250
+
251
+ return _clean_str(max(images, key=area).get("url"))
252
+
253
+
254
+ def _spotify_url(entity: dict) -> str:
255
+ kind = entity.get("type") or "track"
256
+ entity_id = _clean_str(entity.get("id"))
257
+ if entity_id:
258
+ return f"https://open.spotify.com/{kind}/{entity_id}"
259
+ return _clean_str(entity.get("uri"))
260
+
261
+
262
+ def _spotify_url_from_uri(uri: str) -> str:
263
+ match = re.match(r"^spotify:(?P<kind>[a-z]+):(?P<id>[A-Za-z0-9]{22})$", uri or "")
264
+ if match:
265
+ return f"https://open.spotify.com/{match.group('kind')}/{match.group('id')}"
266
+ return uri or ""
267
+
268
+
269
+ def _id_from_spotify_url(url: str) -> str:
270
+ match = re.search(r"(?:track/|spotify:track:)([A-Za-z0-9]{22})", url or "")
271
+ return match.group(1) if match else ""
stashify/errors.py ADDED
@@ -0,0 +1,21 @@
1
+ """Error types used across Stashify."""
2
+
3
+
4
+ class StashifyError(Exception):
5
+ """A user-facing failure with a clear message."""
6
+
7
+
8
+ class SpotifyAuthError(StashifyError):
9
+ """Authentication or authorization with Spotify failed."""
10
+
11
+
12
+ class SpotifyApiError(StashifyError):
13
+ """A call to the Spotify Web API failed."""
14
+
15
+
16
+ class SourceNotFoundError(StashifyError):
17
+ """No suitable audio source could be found for a track."""
18
+
19
+
20
+ class DownloadError(StashifyError):
21
+ """Downloading or converting a track failed."""
stashify/lyrics.py ADDED
@@ -0,0 +1,36 @@
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
stashify/models.py ADDED
@@ -0,0 +1,62 @@
1
+ """Data model for a single track of music.
2
+
3
+ A Track is the unit everything else in Stashify works on: metadata is
4
+ collected from Spotify, audio is matched and downloaded for each track,
5
+ and the file is tagged with the same information.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import asdict, dataclass, field
11
+
12
+
13
+ @dataclass
14
+ class Track:
15
+ title: str
16
+ artists: list[str] = field(default_factory=list)
17
+ album: str = ""
18
+ album_artist: str = ""
19
+ track_number: int = 0
20
+ disc_number: int = 1
21
+ total_tracks: int = 0
22
+ duration_ms: int = 0
23
+ release_date: str = ""
24
+ isrc: str = ""
25
+ cover_url: str = ""
26
+ genres: list[str] = field(default_factory=list)
27
+ spotify_url: str = ""
28
+ explicit: bool = False
29
+
30
+ @property
31
+ def artist(self) -> str:
32
+ """Primary artist, used for display and file naming."""
33
+ return self.artists[0] if self.artists else "Unknown Artist"
34
+
35
+ @property
36
+ def artists_text(self) -> str:
37
+ return ", ".join(self.artists) if self.artists else "Unknown Artist"
38
+
39
+ @property
40
+ def duration_text(self) -> str:
41
+ minutes, seconds = divmod(int(self.duration_ms) // 1000, 60)
42
+ return f"{minutes}:{seconds:02d}"
43
+
44
+ def display(self) -> str:
45
+ return f"{self.artists_text} - {self.title}"
46
+
47
+ def search_query(self) -> str:
48
+ """Query string used to look the track up on YouTube."""
49
+ return f"{self.artists_text} - {self.title}"
50
+
51
+ def to_dict(self) -> dict:
52
+ return asdict(self)
53
+
54
+ @classmethod
55
+ def from_dict(cls, data: dict) -> Track:
56
+ if not isinstance(data, dict):
57
+ raise ValueError("Track data must be a mapping")
58
+ known = {f for f in cls.__dataclass_fields__} # type: ignore[attr-defined]
59
+ values = {k: v for k, v in data.items() if k in known}
60
+ if not values.get("title"):
61
+ raise ValueError("Track data is missing a title")
62
+ return cls(**values)
stashify/output.py ADDED
@@ -0,0 +1,59 @@
1
+ """Output path rendering and safe file naming."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ from .models import Track
8
+
9
+ DEFAULT_TEMPLATE = "{artist} - {title}"
10
+ MAX_SEGMENT = 120
11
+
12
+ _ILLEGAL = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
13
+ _SPACES = re.compile(r"\s+")
14
+ _TOKENS = re.compile(r"\{([a-z_]+)\}")
15
+
16
+
17
+ def release_year(track: Track) -> str:
18
+ match = re.match(r"^(\d{4})", track.release_date or "")
19
+ return match.group(1) if match else ""
20
+
21
+
22
+ def safe_segment(text: str) -> str:
23
+ text = _ILLEGAL.sub("", text or "")
24
+ text = _SPACES.sub(" ", text).strip().strip(".")
25
+ text = text[:MAX_SEGMENT].rstrip(" .")
26
+ return text
27
+
28
+
29
+ def _token_value(name: str, track: Track) -> str:
30
+ values = {
31
+ "artist": track.artist,
32
+ "artists": track.artists_text,
33
+ "title": track.title,
34
+ "album": track.album,
35
+ "album_artist": track.album_artist or track.artist,
36
+ "track_number": str(track.track_number) if track.track_number else "",
37
+ "disc_number": str(track.disc_number) if track.disc_number else "",
38
+ "release_year": release_year(track),
39
+ }
40
+ return values.get(name, "")
41
+
42
+
43
+ def render_template(template: str, track: Track) -> str:
44
+ """Render a path template into a relative, safe path string."""
45
+ template = template or DEFAULT_TEMPLATE
46
+
47
+ def replace(match: re.Match) -> str:
48
+ value = _token_value(match.group(1), track)
49
+ return value.replace("/", "-").replace("\\", "-")
50
+
51
+ rendered = _TOKENS.sub(replace, template)
52
+ parts = []
53
+ for segment in rendered.split("/"):
54
+ cleaned = safe_segment(segment)
55
+ if cleaned:
56
+ parts.append(cleaned)
57
+ if not parts:
58
+ parts = [safe_segment(f"{track.artist} - {track.title}")]
59
+ return "/".join(parts)
stashify/pipeline.py ADDED
@@ -0,0 +1,179 @@
1
+ """Shared pipeline: given tracks, find sources, download, tag, write files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ from collections.abc import Callable
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+
10
+ from .download import download_audio
11
+ from .errors import StashifyError
12
+ from .lyrics import fetch_lyrics
13
+ from .models import Track
14
+ from .output import render_template
15
+ from .sources import find_source
16
+ from .tagger import fetch_cover, tag_file
17
+
18
+
19
+ @dataclass
20
+ class TrackOptions:
21
+ out_dir: Path
22
+ template: str
23
+ audio_format: str
24
+ bitrate: str
25
+ overwrite: bool = False
26
+ dry_run: bool = False
27
+ embed_cover: bool = True
28
+ embed_lyrics: bool = True
29
+ jobs: int = 1
30
+
31
+
32
+ @dataclass
33
+ class TrackResult:
34
+ track: Track
35
+ status: str # downloaded | skipped | dry-run | failed
36
+ path: Path | None = None
37
+ source_url: str = ""
38
+ detail: str = ""
39
+
40
+
41
+ @dataclass
42
+ class _State:
43
+ claimed: set[Path]
44
+ cover_cache: dict[str, tuple[bytes, str] | None]
45
+ claim_lock: threading.Lock
46
+ cover_lock: threading.Lock
47
+ print_lock: threading.Lock
48
+
49
+ @classmethod
50
+ def new(cls) -> _State:
51
+ return cls(
52
+ claimed=set(),
53
+ cover_cache={},
54
+ claim_lock=threading.Lock(),
55
+ cover_lock=threading.Lock(),
56
+ print_lock=threading.Lock(),
57
+ )
58
+
59
+
60
+ def target_path(options: TrackOptions, track: Track) -> Path:
61
+ relative = render_template(options.template, track)
62
+ extension = options.audio_format.lower().lstrip(".")
63
+ return Path(options.out_dir).joinpath(*relative.split("/")).with_suffix("." + extension)
64
+
65
+
66
+ def _emit(state: _State, progress: Callable[[str], None], message: str) -> None:
67
+ with state.print_lock:
68
+ progress(message)
69
+
70
+
71
+ def _cover_for(state: _State, track: Track, options: TrackOptions):
72
+ if not options.embed_cover:
73
+ return None
74
+ with state.cover_lock:
75
+ if track.cover_url in state.cover_cache:
76
+ return state.cover_cache[track.cover_url]
77
+ cover = fetch_cover(track)
78
+ with state.cover_lock:
79
+ state.cover_cache.setdefault(track.cover_url, cover)
80
+ return state.cover_cache[track.cover_url]
81
+
82
+
83
+ def _process_one(
84
+ index: int,
85
+ track: Track,
86
+ total: int,
87
+ options: TrackOptions,
88
+ state: _State,
89
+ progress: Callable[[str], None],
90
+ ) -> TrackResult:
91
+ label = track.display()
92
+ prefix = f"[{index}/{total}] {label}"
93
+ target = target_path(options, track)
94
+
95
+ with state.claim_lock:
96
+ is_duplicate = target in state.claimed
97
+ state.claimed.add(target)
98
+
99
+ if is_duplicate:
100
+ result = TrackResult(track, "skipped", target, detail="duplicate target in this run")
101
+ _emit(state, progress, f"{prefix} - skipped (duplicate name)")
102
+ return result
103
+ if target.exists() and not options.overwrite:
104
+ result = TrackResult(track, "skipped", target, detail="already exists")
105
+ _emit(state, progress, f"{prefix} - skipped (exists)")
106
+ return result
107
+
108
+ try:
109
+ source = find_source(track)
110
+ except StashifyError as exc:
111
+ result = TrackResult(track, "failed", detail=str(exc))
112
+ _emit(state, progress, f"{prefix} - source not found: {exc}")
113
+ return result
114
+
115
+ if options.dry_run:
116
+ result = TrackResult(
117
+ track, "dry-run", target, source_url=source.url, detail=source.title
118
+ )
119
+ _emit(state, progress, f"{prefix} - would download: {source.url}")
120
+ return result
121
+
122
+ try:
123
+ path = download_audio(
124
+ source,
125
+ target.with_suffix(""),
126
+ audio_format=options.audio_format,
127
+ bitrate=options.bitrate,
128
+ )
129
+ except StashifyError as exc:
130
+ result = TrackResult(track, "failed", source_url=source.url, detail=str(exc))
131
+ _emit(state, progress, f"{prefix} - download failed: {exc}")
132
+ return result
133
+
134
+ cover = _cover_for(state, track, options)
135
+ lyrics = fetch_lyrics(track) if options.embed_lyrics else None
136
+ tag_note = ""
137
+ try:
138
+ tag_file(path, track, cover, lyrics)
139
+ except Exception as exc: # Keep the audio file; only the tags failed.
140
+ tag_note = f" (tags not written: {exc})"
141
+
142
+ result = TrackResult(track, "downloaded", path, source_url=source.url, detail=tag_note)
143
+ _emit(state, progress, f"{prefix} - saved: {path}{tag_note}")
144
+ return result
145
+
146
+
147
+ def process_tracks(
148
+ tracks: list[Track],
149
+ options: TrackOptions,
150
+ progress: Callable[[str], None] = print,
151
+ ) -> list[TrackResult]:
152
+ total = len(tracks)
153
+ state = _State.new()
154
+ jobs = max(1, options.jobs)
155
+
156
+ if jobs <= 1:
157
+ return [
158
+ _process_one(index + 1, track, total, options, state, progress)
159
+ for index, track in enumerate(tracks)
160
+ ]
161
+
162
+ from concurrent.futures import ThreadPoolExecutor
163
+
164
+ results: list[TrackResult | None] = [None] * total
165
+ with ThreadPoolExecutor(max_workers=jobs) as pool:
166
+ futures = {
167
+ pool.submit(_process_one, index + 1, track, total, options, state, progress): index
168
+ for index, track in enumerate(tracks)
169
+ }
170
+ for future, index in futures.items():
171
+ results[index] = future.result()
172
+ return [result for result in results if result is not None]
173
+
174
+
175
+ def summarize(results: list[TrackResult]) -> dict[str, int]:
176
+ counts = {"downloaded": 0, "skipped": 0, "dry-run": 0, "failed": 0}
177
+ for result in results:
178
+ counts[result.status] = counts.get(result.status, 0) + 1
179
+ return counts