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/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
|