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/sources.py ADDED
@@ -0,0 +1,130 @@
1
+ """Find the best YouTube source for a Spotify track.
2
+
3
+ Search results are scored with a transparent heuristic: duration closeness
4
+ and word overlap between the Spotify metadata and the video title. The
5
+ highest scoring candidate wins. Everything here is plain Python, no code is
6
+ fetched or executed from anywhere.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from dataclasses import dataclass
13
+
14
+ import yt_dlp
15
+
16
+ from .models import Track
17
+
18
+ SEARCH_COUNT = 8
19
+ MIN_ACCEPT_SCORE = 30
20
+
21
+ _WORD_RE = re.compile(r"[a-z0-9]{3,}")
22
+ _PENALTY_TERMS = (
23
+ "karaoke", "cover", "reaction", "remix",
24
+ "live", "instrumental", "sped up", "slowed",
25
+ )
26
+
27
+
28
+ @dataclass
29
+ class Source:
30
+ url: str
31
+ title: str = ""
32
+ channel: str = ""
33
+ duration_seconds: float | None = None
34
+ score: float = 0.0
35
+
36
+
37
+ def _words(text: str) -> set[str]:
38
+ return set(_WORD_RE.findall((text or "").lower()))
39
+
40
+
41
+ def score_candidate(track: Track, candidate_title: str, duration_seconds: float | None) -> float:
42
+ score = 100.0
43
+
44
+ if duration_seconds is not None and track.duration_ms > 0:
45
+ diff = abs(duration_seconds - track.duration_ms / 1000.0)
46
+ if diff <= 2:
47
+ score += 50
48
+ elif diff <= 10:
49
+ score += 20
50
+ elif diff <= 30:
51
+ score += 0
52
+ else:
53
+ score -= 60
54
+
55
+ candidate_words = _words(candidate_title)
56
+ wanted = _words(track.search_query())
57
+ overlap = len(wanted & candidate_words)
58
+ score += min(5 * overlap, 25)
59
+
60
+ lowered = (candidate_title or "").lower()
61
+ for term in _PENALTY_TERMS:
62
+ # Keep the penalty off when the track itself asks for that kind of version.
63
+ if term in lowered and term not in _words(track.search_query()):
64
+ score -= 40
65
+ if "official" in lowered:
66
+ score += 10
67
+
68
+ return score
69
+
70
+
71
+ def _search_entries(query: str, search_count: int) -> list[dict]:
72
+ options = {
73
+ "quiet": True,
74
+ "no_warnings": True,
75
+ "skip_download": True,
76
+ "extract_flat": True,
77
+ "default_search": "ytsearch",
78
+ }
79
+ try:
80
+ with yt_dlp.YoutubeDL(options) as ydl:
81
+ result = ydl.extract_info(f"ytsearch{search_count}:{query}", download=False)
82
+ except Exception as exc: # yt-dlp raises many error types; surface one clear message.
83
+ from .errors import SourceNotFoundError
84
+
85
+ raise SourceNotFoundError(f"Could not search YouTube: {exc}") from exc
86
+ return (result or {}).get("entries") or []
87
+
88
+
89
+ def find_source(track: Track, search_count: int = SEARCH_COUNT) -> Source:
90
+ query = track.search_query()
91
+ entries = _search_entries(query, search_count)
92
+ if not entries:
93
+ # YouTube sometimes returns nothing for the plain "artist - title" query.
94
+ entries = _search_entries(f"{track.artists_text} {track.title}", search_count)
95
+ if not entries:
96
+ entries = _search_entries(
97
+ f"{track.artists_text} {track.title} audio", search_count
98
+ )
99
+
100
+ best: Source | None = None
101
+ for entry in entries:
102
+ if not entry:
103
+ continue
104
+ url = entry.get("url") or entry.get("id")
105
+ if not url:
106
+ continue
107
+ if not str(url).startswith("http"):
108
+ url = f"https://www.youtube.com/watch?v={url}"
109
+ duration = entry.get("duration")
110
+ title = entry.get("title") or ""
111
+ candidate = Source(
112
+ url=str(url),
113
+ title=title,
114
+ channel=(entry.get("channel") or entry.get("uploader") or ""),
115
+ duration_seconds=float(duration) if duration is not None else None,
116
+ )
117
+ candidate.score = score_candidate(track, candidate.title, candidate.duration_seconds)
118
+ if best is None or candidate.score > best.score:
119
+ best = candidate
120
+
121
+ from .errors import SourceNotFoundError
122
+
123
+ if best is None:
124
+ raise SourceNotFoundError(f"No YouTube results for {track.display()!r}.")
125
+ if best.score < MIN_ACCEPT_SCORE:
126
+ raise SourceNotFoundError(
127
+ f"Best YouTube match for {track.display()!r} looks unreliable "
128
+ f"(score {best.score:.0f}). Try the full URL instead."
129
+ )
130
+ return best
stashify/spotify.py ADDED
@@ -0,0 +1,256 @@
1
+ """Client for the official Spotify Web API.
2
+
3
+ Uses the client credentials flow. The user supplies their own client id and
4
+ secret, created in a Spotify developer app. No internal or undocumented
5
+ endpoints are used.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import re
12
+ import time
13
+ from dataclasses import dataclass
14
+
15
+ import requests
16
+
17
+ from .errors import SpotifyApiError, SpotifyAuthError, StashifyError
18
+ from .models import Track
19
+
20
+ TOKEN_URL = "https://accounts.spotify.com/api/token"
21
+ API_BASE = "https://api.spotify.com/v1"
22
+ DEFAULT_TIMEOUT = 15
23
+ MAX_RETRIES = 3
24
+ _API_UNAVAILABLE_MESSAGE = (
25
+ "Official Web API access to playlists and artist top tracks was removed by "
26
+ "Spotify in February 2026 (Development Mode apps). Use the default embed "
27
+ "provider instead, no credentials needed."
28
+ )
29
+
30
+ _ID_RE = re.compile(r"^[A-Za-z0-9]{22}$")
31
+ _KINDS = ("track", "album", "playlist", "artist")
32
+
33
+ _URL_RE = re.compile(
34
+ r"^(?:(?:https?://)?(?:www\.)?open\.spotify\.com/)?(?P<kind>track|album|playlist|artist)/(?P<id>[A-Za-z0-9]{22})(?:[/?#].*)?$"
35
+ )
36
+ _URI_RE = re.compile(
37
+ r"^spotify:(?P<kind>track|album|playlist|artist):(?P<id>[A-Za-z0-9]{22})$"
38
+ )
39
+
40
+
41
+ def parse_spotify_url(text: str) -> tuple[str, str]:
42
+ """Extract (kind, id) from a Spotify URL or URI.
43
+
44
+ Accepts open.spotify.com URLs, spotify: URIs, and bare "kind:id" pairs.
45
+ Rejects anything else so user input can never reach a path unchecked.
46
+ """
47
+ text = (text or "").strip()
48
+ for pattern in (_URL_RE, _URI_RE):
49
+ match = pattern.match(text)
50
+ if match:
51
+ return match.group("kind"), match.group("id")
52
+ match = re.match(r"^(?P<kind>track|album|playlist|artist):(?P<id>[A-Za-z0-9]{22})$", text)
53
+ if match:
54
+ return match.group("kind"), match.group("id")
55
+ raise StashifyError(
56
+ f"Not a recognized Spotify link: {text!r}. "
57
+ "Expected a track, album, playlist or artist URL."
58
+ )
59
+
60
+
61
+ def client_credentials_from_env() -> tuple[str, str]:
62
+ client_id = os.environ.get("SPOTIFY_CLIENT_ID", "").strip()
63
+ client_secret = os.environ.get("SPOTIFY_CLIENT_SECRET", "").strip()
64
+ if not client_id or not client_secret:
65
+ raise StashifyError(
66
+ "Spotify credentials are not set. Create an app at "
67
+ "https://developer.spotify.com/dashboard and set SPOTIFY_CLIENT_ID "
68
+ "and SPOTIFY_CLIENT_SECRET, or pass --client-id and --client-secret."
69
+ )
70
+ return client_id, client_secret
71
+
72
+
73
+ @dataclass
74
+ class SpotifyClient:
75
+ client_id: str
76
+ client_secret: str
77
+ timeout: int = DEFAULT_TIMEOUT
78
+ market: str = "US"
79
+
80
+ def __post_init__(self) -> None:
81
+ self._session = requests.Session()
82
+ self._token: str | None = None
83
+ self._token_expires_at: float = 0.0
84
+
85
+ def _token_now(self) -> str:
86
+ if self._token and time.time() < self._token_expires_at - 30:
87
+ return self._token
88
+ response = self._session.post(
89
+ TOKEN_URL,
90
+ data={"grant_type": "client_credentials"},
91
+ auth=(self.client_id, self.client_secret),
92
+ timeout=self.timeout,
93
+ )
94
+ if response.status_code != 200:
95
+ raise SpotifyAuthError(
96
+ "Could not get a Spotify access token. Check that SPOTIFY_CLIENT_ID "
97
+ "and SPOTIFY_CLIENT_SECRET are correct and that the app is enabled."
98
+ )
99
+ payload = response.json()
100
+ token = payload.get("access_token")
101
+ if not token:
102
+ raise SpotifyAuthError("Spotify returned an empty access token.")
103
+ self._token = token
104
+ self._token_expires_at = time.time() + float(payload.get("expires_in", 3600))
105
+ return token
106
+
107
+ def _get(self, path: str, params: dict | None = None) -> dict:
108
+ url = f"{API_BASE}{path}"
109
+ headers = {"Authorization": f"Bearer {self._token_now()}"}
110
+ attempts = 0
111
+ while True:
112
+ attempts += 1
113
+ try:
114
+ response = self._session.get(
115
+ url, params=params, headers=headers, timeout=self.timeout
116
+ )
117
+ except requests.RequestException as exc:
118
+ raise SpotifyApiError(f"Could not reach Spotify: {exc}") from exc
119
+
120
+ if response.status_code == 401 and attempts <= MAX_RETRIES:
121
+ self._token = None
122
+ headers["Authorization"] = f"Bearer {self._token_now()}"
123
+ continue
124
+ if response.status_code == 429 and attempts <= MAX_RETRIES:
125
+ retry_after = response.headers.get("Retry-After")
126
+ try:
127
+ delay = min(float(retry_after), 10.0) if retry_after else 1.0
128
+ except ValueError:
129
+ delay = 1.0
130
+ time.sleep(delay)
131
+ continue
132
+ if response.status_code >= 500 and attempts <= MAX_RETRIES:
133
+ time.sleep(0.5 * attempts)
134
+ continue
135
+ if response.status_code != 200:
136
+ message = _error_message(response)
137
+ raise SpotifyApiError(
138
+ f"Spotify request failed ({response.status_code}): {message}"
139
+ )
140
+ try:
141
+ return response.json()
142
+ except ValueError as exc:
143
+ raise SpotifyApiError("Spotify returned an unreadable response.") from exc
144
+
145
+ def _get_paged(self, path: str, params: dict | None = None) -> list[dict]:
146
+ params = dict(params or {})
147
+ params.setdefault("limit", 100)
148
+ params.setdefault("offset", 0)
149
+ items: list[dict] = []
150
+ url = path
151
+ page = 0
152
+ while url:
153
+ page += 1
154
+ if page > 100:
155
+ raise SpotifyApiError("Spotify pagination exceeded the safety limit.")
156
+ data = self._get(url, params)
157
+ items.extend(data.get("items", []))
158
+ next_url = data.get("next")
159
+ if next_url:
160
+ # next is absolute; _get only accepts paths, so strip the base.
161
+ url = next_url.replace(API_BASE, "", 1)
162
+ params = {}
163
+ else:
164
+ url = ""
165
+ return items
166
+
167
+ # Public API -----------------------------------------------------------
168
+
169
+ def fetch_tracks(self, url: str) -> list[Track]:
170
+ kind, spotify_id = parse_spotify_url(url)
171
+ if kind == "track":
172
+ return [self.track_from_api(self._get(f"/tracks/{spotify_id}"))]
173
+ if kind == "album":
174
+ return self._album_tracks(spotify_id)
175
+ raise StashifyError(_API_UNAVAILABLE_MESSAGE)
176
+
177
+ def track_from_api(self, data: dict) -> Track:
178
+ album = data.get("album") or {}
179
+ external_ids = data.get("external_ids") or {}
180
+ artists = [a.get("name", "") for a in data.get("artists") or [] if a.get("name")]
181
+ album_artists = [
182
+ a.get("name", "") for a in album.get("artists") or [] if a.get("name")
183
+ ]
184
+ images = album.get("images") or []
185
+ cover = _largest_image(images)
186
+ return Track(
187
+ title=data.get("name", ""),
188
+ artists=artists,
189
+ album=album.get("name", ""),
190
+ album_artist=album_artists[0] if album_artists else "",
191
+ track_number=int(data.get("track_number") or 0),
192
+ disc_number=int(data.get("disc_number") or 1),
193
+ total_tracks=int(album.get("total_tracks") or 0),
194
+ duration_ms=int(data.get("duration_ms") or 0),
195
+ release_date=str(album.get("release_date") or ""),
196
+ isrc=str(external_ids.get("isrc") or ""),
197
+ cover_url=cover,
198
+ spotify_url=(data.get("external_urls") or {}).get("spotify", ""),
199
+ explicit=bool(data.get("explicit") or False),
200
+ )
201
+
202
+ def _album_tracks(self, album_id: str) -> list[Track]:
203
+ album = self._get(f"/albums/{album_id}")
204
+ tracks_data = self._get_paged(f"/albums/{album_id}/tracks")
205
+ images = (album or {}).get("images") or []
206
+ cover = _largest_image(images)
207
+ album_artists = [
208
+ a.get("name", "") for a in album.get("artists") or [] if a.get("name")
209
+ ]
210
+ album_artist = album_artists[0] if album_artists else ""
211
+ total_tracks = int(album.get("total_tracks") or len(tracks_data))
212
+ release_date = str(album.get("release_date") or "")
213
+ tracks: list[Track] = []
214
+ for item in tracks_data:
215
+ track = self.track_from_api(
216
+ {
217
+ **item,
218
+ "album": {
219
+ "name": album.get("name", ""),
220
+ "artists": album.get("artists") or [],
221
+ "images": images,
222
+ "total_tracks": total_tracks,
223
+ "release_date": release_date,
224
+ },
225
+ }
226
+ )
227
+ track.cover_url = cover
228
+ track.album_artist = album_artist
229
+ tracks.append(track)
230
+ return tracks
231
+
232
+
233
+ def _largest_image(images: list[dict]) -> str:
234
+ if not images:
235
+ return ""
236
+
237
+ def area(image: dict) -> int:
238
+ return int(image.get("width") or 0) * int(image.get("height") or 0)
239
+
240
+ return str(max(images, key=area).get("url") or "")
241
+
242
+
243
+ def _error_message(response: requests.Response) -> str:
244
+ try:
245
+ payload = response.json()
246
+ except ValueError:
247
+ text = (response.text or "").strip().replace("\n", " ")
248
+ return text[:200] if text else "no details"
249
+ error = payload.get("error") if isinstance(payload, dict) else None
250
+ if isinstance(error, dict):
251
+ message = error.get("message") or error.get("error_description")
252
+ if message:
253
+ return str(message)
254
+ if isinstance(error, str):
255
+ return error
256
+ return "no details"
stashify/sync.py ADDED
@@ -0,0 +1,93 @@
1
+ """State files for keeping a local directory in sync with a playlist.
2
+
3
+ The state file is plain JSON. It records which tracks were downloaded into
4
+ which paths, so a later `stashify sync` can add new songs and remove files
5
+ that disappeared from the playlist, without touching anything else the user
6
+ put in the folder.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from pathlib import Path
13
+
14
+ from .errors import StashifyError
15
+ from .models import Track
16
+
17
+ STATE_VERSION = 1
18
+
19
+
20
+ def track_key(track: Track) -> str:
21
+ if track.spotify_url:
22
+ return track.spotify_url
23
+ return f"{track.artist}-{track.album}-{track.title}-{track.track_number}"
24
+
25
+
26
+ def load_state(path: Path) -> dict:
27
+ path = Path(path)
28
+ if not path.exists():
29
+ return {"version": STATE_VERSION, "url": "", "entries": []}
30
+ try:
31
+ data = json.loads(path.read_text(encoding="utf-8"))
32
+ except (OSError, json.JSONDecodeError) as exc:
33
+ raise StashifyError(f"Could not read state file {path}: {exc}") from exc
34
+ if not isinstance(data, dict) or not isinstance(data.get("entries", []), list):
35
+ raise StashifyError(f"State file {path} is not a Stashify state file.")
36
+ data.setdefault("version", STATE_VERSION)
37
+ data.setdefault("url", "")
38
+ data.setdefault("entries", [])
39
+ return data
40
+
41
+
42
+ def save_state(path: Path, state: dict) -> None:
43
+ path = Path(path)
44
+ path.parent.mkdir(parents=True, exist_ok=True)
45
+ path.write_text(
46
+ json.dumps(state, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
47
+ )
48
+
49
+
50
+ def plan_sync(state: dict, current: list[Track]) -> tuple[list[Track], list[dict]]:
51
+ """Return (tracks to download, entries to remove)."""
52
+ current_keys = {track_key(t) for t in current}
53
+ existing = {entry.get("key"): entry for entry in state.get("entries", [])}
54
+ missing = [t for t in current if track_key(t) not in existing]
55
+ removed = [entry for key, entry in existing.items() if key not in current_keys]
56
+ return missing, removed
57
+
58
+
59
+ def remove_entries(removed: list[dict], out_dir: Path) -> list[str]:
60
+ """Delete files referenced by removed entries, confined to out_dir."""
61
+ out_dir = Path(out_dir).resolve()
62
+ deleted: list[str] = []
63
+ for entry in removed:
64
+ rel = entry.get("path")
65
+ if not rel:
66
+ continue
67
+ # Older state files may contain OS-native separators; normalize both.
68
+ rel = str(rel).replace("\\", "/")
69
+ candidate = (out_dir / rel).resolve()
70
+ try:
71
+ candidate.relative_to(out_dir)
72
+ except ValueError:
73
+ continue # Never delete outside the output directory.
74
+ try:
75
+ candidate.unlink()
76
+ deleted.append(str(candidate))
77
+ except OSError:
78
+ continue
79
+ return deleted
80
+
81
+
82
+ def state_entry(track: Track, path: Path, out_dir: Path) -> dict:
83
+ try:
84
+ rel = Path(path).resolve().relative_to(Path(out_dir).resolve())
85
+ except ValueError:
86
+ rel = Path(path)
87
+ # Always store forward slashes so the file can be moved across OSes.
88
+ return {
89
+ "key": track_key(track),
90
+ "path": rel.as_posix(),
91
+ "title": track.title,
92
+ "artist": track.artist,
93
+ }