vncmd 0.1.7__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.
- function/__init__.py +0 -0
- function/_defaults.py +45 -0
- function/api.py +273 -0
- function/audio.py +47 -0
- function/cache.py +60 -0
- function/checkpoint.py +92 -0
- function/commands.py +232 -0
- function/config.py +175 -0
- function/download.py +85 -0
- function/downloader.py +390 -0
- function/image.py +52 -0
- function/lyrics.py +128 -0
- function/metadata.py +76 -0
- function/output.py +142 -0
- function/tracker.py +519 -0
- vncmd-0.1.7.dist-info/METADATA +127 -0
- vncmd-0.1.7.dist-info/RECORD +22 -0
- vncmd-0.1.7.dist-info/WHEEL +5 -0
- vncmd-0.1.7.dist-info/entry_points.txt +2 -0
- vncmd-0.1.7.dist-info/licenses/LICENSE +661 -0
- vncmd-0.1.7.dist-info/top_level.txt +2 -0
- vncmd.py +139 -0
function/__init__.py
ADDED
|
File without changes
|
function/_defaults.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
CONFIG_TOML = """\
|
|
2
|
+
[download]
|
|
3
|
+
# Absolute path, relative to VNCMD_HOME, or empty for ~/Downloads
|
|
4
|
+
dir = ""
|
|
5
|
+
# Filename format: 10=Title-Artist, 01=Artist-Title, 1=Title only
|
|
6
|
+
filename_format = "10"
|
|
7
|
+
# Download content: 0=song, 1=lyrics, 2=cover (e.g. 012=all, 12=lyrics&cover)
|
|
8
|
+
content = "012"
|
|
9
|
+
|
|
10
|
+
[download.song]
|
|
11
|
+
# Audio quality: 128, 192, 320, 999 (highest)
|
|
12
|
+
quality = "999"
|
|
13
|
+
|
|
14
|
+
[download.lyric]
|
|
15
|
+
# Embedded lyrics mode: 0=interleaved, 1=merged, 2=original only
|
|
16
|
+
embed_mode = "2"
|
|
17
|
+
# Standalone lyrics file mode: 0=interleaved, 1=merged, 2=separate files
|
|
18
|
+
save_mode = "0"
|
|
19
|
+
|
|
20
|
+
[download.cover]
|
|
21
|
+
# Embedded cover quality: 0=original, 1=resize to max 500x500 JPEG
|
|
22
|
+
embed_quality = "1"
|
|
23
|
+
# Standalone cover file quality: 0=original, 1=resize to max 500x500 JPEG
|
|
24
|
+
save_quality = "0"
|
|
25
|
+
|
|
26
|
+
[cache]
|
|
27
|
+
# Enable local cache for song details and lyrics
|
|
28
|
+
enabled = true
|
|
29
|
+
# Absolute path, or relative to VNCMD_HOME
|
|
30
|
+
dir = "cache"
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
TRACKER_SETTINGS_TOML = """\
|
|
34
|
+
[tracker]
|
|
35
|
+
description = "Describe this tracker here"
|
|
36
|
+
|
|
37
|
+
[sources.song]
|
|
38
|
+
ids = []
|
|
39
|
+
|
|
40
|
+
[sources.playlist]
|
|
41
|
+
ids = []
|
|
42
|
+
|
|
43
|
+
[sources.album]
|
|
44
|
+
ids = []
|
|
45
|
+
"""
|
function/api.py
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import requests
|
|
3
|
+
from requests.adapters import HTTPAdapter
|
|
4
|
+
from urllib3.util.retry import Retry
|
|
5
|
+
|
|
6
|
+
from function.config import get_cookie
|
|
7
|
+
from function.cache import get_song as cache_get_song, put_song as cache_put_song
|
|
8
|
+
from function.cache import (
|
|
9
|
+
get_lyrics as cache_get_lyrics,
|
|
10
|
+
put_lyrics as cache_put_lyrics,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
BASE_URL = "https://music.163.com"
|
|
14
|
+
HEADERS = {
|
|
15
|
+
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
|
16
|
+
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
17
|
+
"Referer": "https://music.163.com/",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
_session = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _get_session():
|
|
24
|
+
global _session
|
|
25
|
+
if _session is None:
|
|
26
|
+
_session = requests.Session()
|
|
27
|
+
_session.headers.update(HEADERS)
|
|
28
|
+
|
|
29
|
+
retry = Retry(
|
|
30
|
+
total=3,
|
|
31
|
+
backoff_factor=0.5,
|
|
32
|
+
status_forcelist=[500, 502, 503, 504],
|
|
33
|
+
allowed_methods={"GET"},
|
|
34
|
+
)
|
|
35
|
+
adapter = HTTPAdapter(max_retries=retry)
|
|
36
|
+
_session.mount("http://", adapter)
|
|
37
|
+
_session.mount("https://", adapter)
|
|
38
|
+
|
|
39
|
+
cookie = get_cookie()
|
|
40
|
+
if cookie:
|
|
41
|
+
for item in cookie.split(";"):
|
|
42
|
+
item = item.strip()
|
|
43
|
+
if "=" not in item:
|
|
44
|
+
continue
|
|
45
|
+
name, value = item.split("=", 1)
|
|
46
|
+
name = name.strip()
|
|
47
|
+
value = value.strip()
|
|
48
|
+
try:
|
|
49
|
+
value.encode("latin-1")
|
|
50
|
+
except UnicodeEncodeError:
|
|
51
|
+
value = value.encode("latin-1", errors="ignore").decode("latin-1")
|
|
52
|
+
if name and value:
|
|
53
|
+
_session.cookies.set(name, value, domain="music.163.com")
|
|
54
|
+
return _session
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def format_timestamp(ts: int | None) -> str:
|
|
58
|
+
if ts is None:
|
|
59
|
+
return ""
|
|
60
|
+
t = time.localtime(ts / 1000)
|
|
61
|
+
return time.strftime("%Y-%m-%d %H:%M:%S", t)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _fmt_duration(ms: int | None) -> str:
|
|
65
|
+
if not ms:
|
|
66
|
+
return ""
|
|
67
|
+
total_sec = ms // 1000
|
|
68
|
+
m, s = divmod(total_sec, 60)
|
|
69
|
+
return f"{m}:{s:02d}"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def get_song_details(song_id: int) -> dict:
|
|
73
|
+
cached = cache_get_song(song_id)
|
|
74
|
+
if cached:
|
|
75
|
+
return cached
|
|
76
|
+
|
|
77
|
+
url = f"{BASE_URL}/api/song/detail/?id={song_id}&ids=%5B{song_id}%5D"
|
|
78
|
+
resp = _get_session().get(url, timeout=15)
|
|
79
|
+
data = resp.json()
|
|
80
|
+
if not data.get("songs"):
|
|
81
|
+
raise ValueError(f"Song {song_id} not found")
|
|
82
|
+
|
|
83
|
+
song = data["songs"][0]
|
|
84
|
+
result = {
|
|
85
|
+
"id": song["id"],
|
|
86
|
+
"title": song["name"],
|
|
87
|
+
"artist": ",".join(a["name"] for a in song.get("artists", [])),
|
|
88
|
+
"album": song.get("album", {}).get("name", ""),
|
|
89
|
+
"album_id": song.get("album", {}).get("id"),
|
|
90
|
+
"cover": song.get("album", {}).get("picUrl", ""),
|
|
91
|
+
"publish_time": format_timestamp(song.get("album", {}).get("publishTime")),
|
|
92
|
+
"duration": _fmt_duration(song.get("duration")),
|
|
93
|
+
}
|
|
94
|
+
cache_put_song(song_id, result)
|
|
95
|
+
return result
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def get_playlist_details(playlist_id: int, limit: int | None = None) -> dict:
|
|
99
|
+
url = f"{BASE_URL}/api/v6/playlist/detail/?id={playlist_id}"
|
|
100
|
+
resp = _get_session().get(url, timeout=15)
|
|
101
|
+
data = resp.json()
|
|
102
|
+
if not data.get("playlist"):
|
|
103
|
+
raise ValueError(f"Playlist {playlist_id} not found")
|
|
104
|
+
|
|
105
|
+
pl = data["playlist"]
|
|
106
|
+
tracks = []
|
|
107
|
+
|
|
108
|
+
full_tracks = pl.get("tracks") or []
|
|
109
|
+
track_ids = pl.get("trackIds") or []
|
|
110
|
+
ft_map = {t["id"]: t for t in full_tracks}
|
|
111
|
+
|
|
112
|
+
# Limit resolution to what's needed
|
|
113
|
+
resolve_count = min(limit, len(track_ids)) if limit else len(track_ids)
|
|
114
|
+
|
|
115
|
+
if track_ids and resolve_count > 0:
|
|
116
|
+
if resolve_count > len(full_tracks):
|
|
117
|
+
print(f"Resolving {resolve_count} tracks...")
|
|
118
|
+
for i, tid in enumerate(track_ids[:resolve_count]):
|
|
119
|
+
sid = tid if isinstance(tid, int) else tid["id"]
|
|
120
|
+
ft = ft_map.get(sid)
|
|
121
|
+
if ft:
|
|
122
|
+
tracks.append(
|
|
123
|
+
{
|
|
124
|
+
"id": ft["id"],
|
|
125
|
+
"title": ft["name"],
|
|
126
|
+
"artist": ", ".join(a["name"] for a in ft.get("ar", [])),
|
|
127
|
+
"album": ft.get("al", {}).get("name", ""),
|
|
128
|
+
"cover": ft.get("al", {}).get("picUrl", ""),
|
|
129
|
+
"publish_time": format_timestamp(
|
|
130
|
+
ft.get("publishTime") or ft.get("al", {}).get("publishTime")
|
|
131
|
+
),
|
|
132
|
+
"duration": _fmt_duration(ft.get("dt")),
|
|
133
|
+
}
|
|
134
|
+
)
|
|
135
|
+
else:
|
|
136
|
+
try:
|
|
137
|
+
song = get_song_details(sid)
|
|
138
|
+
tracks.append(
|
|
139
|
+
{
|
|
140
|
+
"id": song["id"],
|
|
141
|
+
"title": song["title"],
|
|
142
|
+
"artist": song["artist"],
|
|
143
|
+
"album": song["album"],
|
|
144
|
+
"cover": song["cover"],
|
|
145
|
+
"publish_time": song["publish_time"],
|
|
146
|
+
"duration": song["duration"],
|
|
147
|
+
}
|
|
148
|
+
)
|
|
149
|
+
except Exception:
|
|
150
|
+
pass
|
|
151
|
+
if (i + 1) % 20 == 0:
|
|
152
|
+
print(f" ... {i + 1}/{len(track_ids)}")
|
|
153
|
+
time.sleep(0.1)
|
|
154
|
+
elif full_tracks:
|
|
155
|
+
for t in full_tracks:
|
|
156
|
+
tracks.append(
|
|
157
|
+
{
|
|
158
|
+
"id": t["id"],
|
|
159
|
+
"title": t["name"],
|
|
160
|
+
"artist": ", ".join(a["name"] for a in t.get("ar", [])),
|
|
161
|
+
"album": t.get("al", {}).get("name", ""),
|
|
162
|
+
"cover": t.get("al", {}).get("picUrl", ""),
|
|
163
|
+
"publish_time": format_timestamp(
|
|
164
|
+
t.get("publishTime") or t.get("al", {}).get("publishTime")
|
|
165
|
+
),
|
|
166
|
+
"duration": _fmt_duration(t.get("dt")),
|
|
167
|
+
}
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
result = {
|
|
171
|
+
"id": pl["id"],
|
|
172
|
+
"name": pl["name"],
|
|
173
|
+
"creator": pl.get("creator", {}).get("nickname", ""),
|
|
174
|
+
"cover": pl.get("coverImgUrl", ""),
|
|
175
|
+
"track_count": pl.get("trackCount", len(tracks)),
|
|
176
|
+
"tracks": tracks,
|
|
177
|
+
}
|
|
178
|
+
return result
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def get_album_details(album_id: int) -> dict:
|
|
182
|
+
url = f"{BASE_URL}/api/v1/album/{album_id}"
|
|
183
|
+
resp = _get_session().get(url, timeout=15)
|
|
184
|
+
data = resp.json()
|
|
185
|
+
album = data.get("album")
|
|
186
|
+
if not album:
|
|
187
|
+
raise ValueError(f"Album {album_id} not found")
|
|
188
|
+
|
|
189
|
+
tracks = []
|
|
190
|
+
for s in data.get("songs", []):
|
|
191
|
+
tracks.append(
|
|
192
|
+
{
|
|
193
|
+
"id": s["id"],
|
|
194
|
+
"title": s["name"],
|
|
195
|
+
"artist": ", ".join(a["name"] for a in s.get("ar", [])),
|
|
196
|
+
"album": album.get("name", ""),
|
|
197
|
+
"cover": album.get("picUrl", ""),
|
|
198
|
+
"publish_time": format_timestamp(
|
|
199
|
+
s.get("publishTime") or album.get("publishTime")
|
|
200
|
+
),
|
|
201
|
+
"duration": _fmt_duration(s.get("dt")),
|
|
202
|
+
}
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
"id": album["id"],
|
|
207
|
+
"name": album["name"],
|
|
208
|
+
"artist": album.get("artist", {}).get("name", ""),
|
|
209
|
+
"cover": album.get("picUrl", ""),
|
|
210
|
+
"track_count": album.get("size", len(tracks)),
|
|
211
|
+
"tracks": tracks,
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def search(q: str, limit: int = 30, offset: int = 0) -> dict:
|
|
216
|
+
url = f"{BASE_URL}/api/cloudsearch/pc?type=1&s={q}&limit={limit}&offset={offset}"
|
|
217
|
+
resp = _get_session().get(url, timeout=15)
|
|
218
|
+
data = resp.json()
|
|
219
|
+
if data.get("code") != 200:
|
|
220
|
+
raise ValueError(f"Search failed: {data}")
|
|
221
|
+
|
|
222
|
+
songs = []
|
|
223
|
+
for s in data.get("result", {}).get("songs", []):
|
|
224
|
+
songs.append(
|
|
225
|
+
{
|
|
226
|
+
"id": s["id"],
|
|
227
|
+
"title": s["name"],
|
|
228
|
+
"artist": ",".join(a["name"] for a in s.get("ar", [])),
|
|
229
|
+
"album": s.get("al", {}).get("name", ""),
|
|
230
|
+
"cover": s.get("al", {}).get("picUrl", ""),
|
|
231
|
+
"publish_time": format_timestamp(s.get("publishTime")),
|
|
232
|
+
"duration": _fmt_duration(s.get("dt")),
|
|
233
|
+
}
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
"total": data.get("result", {}).get("songCount", 0),
|
|
238
|
+
"songs": songs,
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def get_lyrics_url(song_id: int) -> str:
|
|
243
|
+
return f"{BASE_URL}/api/song/lyric?os=pc&id={song_id}&lv=-1&tv=1"
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def get_lyrics(song_id: int) -> dict:
|
|
247
|
+
cached = cache_get_lyrics(song_id)
|
|
248
|
+
if cached:
|
|
249
|
+
return cached
|
|
250
|
+
|
|
251
|
+
url = get_lyrics_url(song_id)
|
|
252
|
+
try:
|
|
253
|
+
resp = _get_session().get(url, timeout=15)
|
|
254
|
+
data = resp.json()
|
|
255
|
+
except Exception:
|
|
256
|
+
return {"lrc": {"lyric": ""}, "tlyric": {"lyric": ""}}
|
|
257
|
+
|
|
258
|
+
if data.get("code") != 200:
|
|
259
|
+
return {"lrc": {"lyric": ""}, "tlyric": {"lyric": ""}}
|
|
260
|
+
|
|
261
|
+
result = {"lrc": data.get("lrc", {}), "tlyric": data.get("tlyric", {})}
|
|
262
|
+
cache_put_lyrics(song_id, result)
|
|
263
|
+
return result
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def get_song_url(song_id: int, quality: int | None = None) -> str | None:
|
|
267
|
+
br = quality if quality else 2147483647
|
|
268
|
+
url = f"{BASE_URL}/api/song/enhance/player/url?ids=[{song_id}]&br={br}"
|
|
269
|
+
resp = _get_session().get(url, timeout=15)
|
|
270
|
+
data = resp.json()
|
|
271
|
+
if data.get("data") and data["data"][0].get("url"):
|
|
272
|
+
return data["data"][0]["url"]
|
|
273
|
+
return None
|
function/audio.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from function.config import get_filename_format
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def check_filename(name: str) -> str:
|
|
8
|
+
return re.sub(r'[\\/:*?"<>|]', "-", name)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get_type_from_url(url: str) -> str:
|
|
12
|
+
pattern = r"((?!.*\.))([^?]+)"
|
|
13
|
+
matches = re.search(pattern, url)
|
|
14
|
+
if matches:
|
|
15
|
+
ext = matches.group(0)
|
|
16
|
+
if ext in ("mp3", "flac", "m4a", "wav", "ogg"):
|
|
17
|
+
return ext
|
|
18
|
+
return "mp3"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def cover_ext(cover_url: str | None) -> str:
|
|
22
|
+
if not cover_url:
|
|
23
|
+
return "jpg"
|
|
24
|
+
ext = cover_url.rsplit("?", 1)[0].rsplit(".", 1)[-1].lower()
|
|
25
|
+
return ext if ext in ("jpg", "jpeg", "png") else "jpg"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def build_filename(title: str, artist: str) -> str:
|
|
29
|
+
fmt = get_filename_format()
|
|
30
|
+
safe_title = check_filename(title)
|
|
31
|
+
safe_artist = check_filename(artist)
|
|
32
|
+
if fmt == "10":
|
|
33
|
+
return f"{safe_title} - {safe_artist}"
|
|
34
|
+
elif fmt == "01":
|
|
35
|
+
return f"{safe_artist} - {safe_title}"
|
|
36
|
+
else:
|
|
37
|
+
return safe_title
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def resolve_path(base: str, ext: str, directory: str) -> str:
|
|
41
|
+
counter = 0
|
|
42
|
+
while True:
|
|
43
|
+
suffix = f"({counter})" if counter > 0 else ""
|
|
44
|
+
path = Path(directory) / f"{base}{suffix}.{ext}"
|
|
45
|
+
if not path.exists():
|
|
46
|
+
return str(path)
|
|
47
|
+
counter += 1
|
function/cache.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from function.config import get_cache_dir, is_cache_enabled
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _song_dir(song_id: int) -> Path:
|
|
8
|
+
path = Path(get_cache_dir()) / "song" / str(song_id)
|
|
9
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
10
|
+
return path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def get_song_cache_dir(song_id: int) -> Path:
|
|
14
|
+
return _song_dir(song_id)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_song(song_id: int) -> dict | None:
|
|
18
|
+
if not is_cache_enabled():
|
|
19
|
+
return None
|
|
20
|
+
path = _song_dir(song_id) / "info.json"
|
|
21
|
+
if not path.exists():
|
|
22
|
+
return None
|
|
23
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def put_song(song_id: int, data: dict) -> None:
|
|
27
|
+
if not is_cache_enabled():
|
|
28
|
+
return
|
|
29
|
+
path = _song_dir(song_id) / "info.json"
|
|
30
|
+
path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def get_lyrics(song_id: int) -> dict | None:
|
|
34
|
+
"""Read lyrics from cache. Returns dict {lrc, tlyric} or None."""
|
|
35
|
+
if not is_cache_enabled():
|
|
36
|
+
return None
|
|
37
|
+
d = _song_dir(song_id)
|
|
38
|
+
lrc_path = d / "lyric.lrc"
|
|
39
|
+
if not lrc_path.exists():
|
|
40
|
+
return None
|
|
41
|
+
result = {"lrc": {"lyric": lrc_path.read_text(encoding="utf-8")}}
|
|
42
|
+
tlyric_path = d / "tlyric.lrc"
|
|
43
|
+
if tlyric_path.exists():
|
|
44
|
+
result["tlyric"] = {"lyric": tlyric_path.read_text(encoding="utf-8")}
|
|
45
|
+
else:
|
|
46
|
+
result["tlyric"] = {"lyric": ""}
|
|
47
|
+
return result
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def put_lyrics(song_id: int, data: dict) -> None:
|
|
51
|
+
"""Save lyrics to cache. data is {lrc: {lyric: str}, tlyric: {lyric: str}}."""
|
|
52
|
+
if not is_cache_enabled():
|
|
53
|
+
return
|
|
54
|
+
d = _song_dir(song_id)
|
|
55
|
+
lrc_text = data.get("lrc", {}).get("lyric", "")
|
|
56
|
+
(d / "lyric.lrc").write_text(lrc_text, encoding="utf-8")
|
|
57
|
+
|
|
58
|
+
tlyric_text = data.get("tlyric", {}).get("lyric", "")
|
|
59
|
+
if tlyric_text:
|
|
60
|
+
(d / "tlyric.lrc").write_text(tlyric_text, encoding="utf-8")
|
function/checkpoint.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from function.config import get_cache_dir
|
|
5
|
+
from function.output import info
|
|
6
|
+
|
|
7
|
+
_CHECKPOINT_DIR = None
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _get_checkpoint_dir():
|
|
11
|
+
global _CHECKPOINT_DIR
|
|
12
|
+
if _CHECKPOINT_DIR is None:
|
|
13
|
+
_CHECKPOINT_DIR = Path(get_cache_dir()) / "download"
|
|
14
|
+
_CHECKPOINT_DIR.mkdir(parents=True, exist_ok=True)
|
|
15
|
+
return _CHECKPOINT_DIR
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_checkpoint_path(dl_type: str, dl_id: str) -> Path:
|
|
19
|
+
return _get_checkpoint_dir() / f"{dl_type}_{dl_id}.json"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def load_checkpoint(dl_type: str, dl_id: str) -> dict | None:
|
|
23
|
+
path = get_checkpoint_path(dl_type, dl_id)
|
|
24
|
+
if not path.exists():
|
|
25
|
+
return None
|
|
26
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def save_checkpoint(dl_type: str, dl_id: str, data: dict) -> None:
|
|
30
|
+
path = get_checkpoint_path(dl_type, dl_id)
|
|
31
|
+
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def create_checkpoint(dl_type: str, dl_id: str, download_dir: str, track_ids: list[int]) -> dict:
|
|
35
|
+
data = {
|
|
36
|
+
"type": dl_type,
|
|
37
|
+
"id": dl_id,
|
|
38
|
+
"download_dir": download_dir,
|
|
39
|
+
"tracks": {str(tid): False for tid in track_ids},
|
|
40
|
+
}
|
|
41
|
+
save_checkpoint(dl_type, dl_id, data)
|
|
42
|
+
return data
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def mark_downloaded(dl_type: str, dl_id: str, song_id: int) -> None:
|
|
46
|
+
cp = load_checkpoint(dl_type, dl_id)
|
|
47
|
+
if cp is None:
|
|
48
|
+
return
|
|
49
|
+
cp["tracks"][str(song_id)] = True
|
|
50
|
+
save_checkpoint(dl_type, dl_id, cp)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def sync_checkpoint_tracks(dl_type: str, dl_id: str, current_tracks: dict[str, str]) -> tuple[list[str], bool]:
|
|
54
|
+
"""Sync checkpoint tracks with current API list.
|
|
55
|
+
|
|
56
|
+
``current_tracks`` is a dict of ``{id: title}``.
|
|
57
|
+
Returns ``(pending_ids, has_changes)``.
|
|
58
|
+
For album/playlist: notifies about added/removed tracks with titles.
|
|
59
|
+
For tracker: silently updates.
|
|
60
|
+
"""
|
|
61
|
+
cp = load_checkpoint(dl_type, dl_id)
|
|
62
|
+
if cp is None:
|
|
63
|
+
return list(current_tracks.keys()), False
|
|
64
|
+
|
|
65
|
+
current_ids = {str(tid) for tid in current_tracks}
|
|
66
|
+
cp_ids = set(cp["tracks"].keys())
|
|
67
|
+
new_ids = current_ids - cp_ids
|
|
68
|
+
removed_ids = cp_ids - current_ids
|
|
69
|
+
|
|
70
|
+
# Update checkpoint: add new tracks, remove gone tracks
|
|
71
|
+
for tid in new_ids:
|
|
72
|
+
cp["tracks"][tid] = False
|
|
73
|
+
has_changes = bool(new_ids or removed_ids)
|
|
74
|
+
|
|
75
|
+
# Notify for album/playlist
|
|
76
|
+
if dl_type in ("album", "playlist") and has_changes:
|
|
77
|
+
for tid in sorted(new_ids):
|
|
78
|
+
title = current_tracks.get(tid, current_tracks.get(int(tid), "?"))
|
|
79
|
+
info(f" New track in {dl_type}: {title} (ID {tid})")
|
|
80
|
+
for tid in sorted(removed_ids):
|
|
81
|
+
info(f" Track removed from {dl_type}: ID {tid}")
|
|
82
|
+
info(
|
|
83
|
+
" Tip: if this list changes often, consider using the tracker feature"
|
|
84
|
+
" (vncmd tracker --help)"
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
for tid in removed_ids:
|
|
88
|
+
del cp["tracks"][tid]
|
|
89
|
+
save_checkpoint(dl_type, dl_id, cp)
|
|
90
|
+
|
|
91
|
+
pending = [tid for tid, done in cp["tracks"].items() if not done]
|
|
92
|
+
return pending, has_changes
|