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