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