mpup 0.1.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.
- mpup/__init__.py +3 -0
- mpup/__main__.py +5 -0
- mpup/background.py +122 -0
- mpup/cli.py +159 -0
- mpup/config.py +187 -0
- mpup/cover.py +244 -0
- mpup/discovery.py +33 -0
- mpup/enriched_audio.py +147 -0
- mpup/itunes.py +112 -0
- mpup/lyrics.py +190 -0
- mpup/lyrics_from_qq.py +46 -0
- mpup/metadata.py +159 -0
- mpup/pipeline.py +305 -0
- mpup/remotion_bridge.py +130 -0
- mpup/render_job.py +118 -0
- mpup/video.py +160 -0
- mpup/workspace.py +92 -0
- mpup-0.1.0.dist-info/METADATA +135 -0
- mpup-0.1.0.dist-info/RECORD +21 -0
- mpup-0.1.0.dist-info/WHEEL +4 -0
- mpup-0.1.0.dist-info/entry_points.txt +2 -0
mpup/cover.py
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""Extract and normalize cover artwork for deterministic browser rendering."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import io
|
|
6
|
+
import os
|
|
7
|
+
import subprocess
|
|
8
|
+
import tempfile
|
|
9
|
+
import unicodedata
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Literal
|
|
14
|
+
from urllib.request import Request, urlopen
|
|
15
|
+
|
|
16
|
+
from PIL import Image, UnidentifiedImageError
|
|
17
|
+
|
|
18
|
+
from mpup.itunes import DEFAULT_USER_AGENT, ItunesTrack, search_track
|
|
19
|
+
from mpup.metadata import AudioMetadata
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
MAX_IMAGE_BYTES = 10 * 1024 * 1024
|
|
23
|
+
Runner = Callable[..., subprocess.CompletedProcess[str]]
|
|
24
|
+
CoverSource = Literal["embedded", "itunes", "default"]
|
|
25
|
+
SearchFn = Callable[[str, str], ItunesTrack | None]
|
|
26
|
+
Downloader = Callable[[str], bytes]
|
|
27
|
+
Extractor = Callable[..., tuple[int, int]]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class CoverResolution:
|
|
32
|
+
cover_path: Path | None
|
|
33
|
+
source: CoverSource
|
|
34
|
+
warnings: tuple[str, ...] = ()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def normalize_image_bytes(
|
|
38
|
+
payload: bytes,
|
|
39
|
+
destination: Path,
|
|
40
|
+
*,
|
|
41
|
+
max_bytes: int = MAX_IMAGE_BYTES,
|
|
42
|
+
) -> tuple[int, int]:
|
|
43
|
+
"""Fully decode an image and atomically publish a browser-safe PNG."""
|
|
44
|
+
if not payload or len(payload) > max_bytes:
|
|
45
|
+
raise RuntimeError(f"封面图片大小必须在 1 字节到 {max_bytes} 字节之间")
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
with Image.open(io.BytesIO(payload)) as source:
|
|
49
|
+
source.load()
|
|
50
|
+
dimensions = source.size
|
|
51
|
+
mode = "RGBA" if "A" in source.getbands() else "RGB"
|
|
52
|
+
normalized = source.convert(mode)
|
|
53
|
+
except (OSError, ValueError, UnidentifiedImageError) as error:
|
|
54
|
+
raise RuntimeError("封面不是可完整解码的图片") from error
|
|
55
|
+
|
|
56
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
57
|
+
temp_path: Path | None = None
|
|
58
|
+
try:
|
|
59
|
+
with tempfile.NamedTemporaryFile(
|
|
60
|
+
dir=destination.parent,
|
|
61
|
+
prefix=f".{destination.name}.",
|
|
62
|
+
suffix=".png",
|
|
63
|
+
delete=False,
|
|
64
|
+
) as temporary:
|
|
65
|
+
temp_path = Path(temporary.name)
|
|
66
|
+
normalized.save(temp_path, format="PNG")
|
|
67
|
+
os.replace(temp_path, destination)
|
|
68
|
+
temp_path = None
|
|
69
|
+
finally:
|
|
70
|
+
if temp_path is not None:
|
|
71
|
+
temp_path.unlink(missing_ok=True)
|
|
72
|
+
normalized.close()
|
|
73
|
+
return dimensions
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def extract_embedded_cover(
|
|
77
|
+
audio_path: Path,
|
|
78
|
+
stream_index: int,
|
|
79
|
+
destination: Path,
|
|
80
|
+
*,
|
|
81
|
+
ffmpeg: str = "ffmpeg",
|
|
82
|
+
runner: Runner = subprocess.run,
|
|
83
|
+
temp_root: Path | None = None,
|
|
84
|
+
) -> tuple[int, int]:
|
|
85
|
+
"""Extract one FFmpeg attached-picture stream and normalize it to PNG."""
|
|
86
|
+
if stream_index < 0:
|
|
87
|
+
raise ValueError("封面流索引不能小于 0")
|
|
88
|
+
|
|
89
|
+
with tempfile.TemporaryDirectory(dir=temp_root) as directory:
|
|
90
|
+
extracted = Path(directory) / "cover.img"
|
|
91
|
+
command = [
|
|
92
|
+
ffmpeg,
|
|
93
|
+
"-v",
|
|
94
|
+
"error",
|
|
95
|
+
"-i",
|
|
96
|
+
str(audio_path),
|
|
97
|
+
"-map",
|
|
98
|
+
f"0:{stream_index}",
|
|
99
|
+
"-frames:v",
|
|
100
|
+
"1",
|
|
101
|
+
"-c:v",
|
|
102
|
+
"copy",
|
|
103
|
+
"-f",
|
|
104
|
+
"image2",
|
|
105
|
+
str(extracted),
|
|
106
|
+
]
|
|
107
|
+
result = runner(command, capture_output=True, text=True, check=False)
|
|
108
|
+
if result.returncode != 0:
|
|
109
|
+
detail = result.stderr.strip() or "未知错误"
|
|
110
|
+
raise RuntimeError(f"FFmpeg 提取内嵌封面失败:{detail[-2000:]}")
|
|
111
|
+
if not extracted.is_file():
|
|
112
|
+
raise RuntimeError("FFmpeg 未生成内嵌封面")
|
|
113
|
+
if extracted.stat().st_size > MAX_IMAGE_BYTES:
|
|
114
|
+
raise RuntimeError("内嵌封面超过 10 MiB 限制")
|
|
115
|
+
return normalize_image_bytes(extracted.read_bytes(), destination)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def download_artwork(
|
|
119
|
+
url: str,
|
|
120
|
+
*,
|
|
121
|
+
opener: Callable[..., Any] = urlopen,
|
|
122
|
+
max_bytes: int = MAX_IMAGE_BYTES,
|
|
123
|
+
) -> bytes:
|
|
124
|
+
"""Download at most ``max_bytes`` of artwork with a bounded request."""
|
|
125
|
+
request = Request(url, headers={"User-Agent": DEFAULT_USER_AGENT, "Accept": "image/*"})
|
|
126
|
+
with opener(request, timeout=10) as response:
|
|
127
|
+
payload = response.read(max_bytes + 1)
|
|
128
|
+
if not payload or len(payload) > max_bytes:
|
|
129
|
+
raise RuntimeError("远程封面为空或超过 10 MiB 限制")
|
|
130
|
+
return payload
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _warning(prefix: str, error: Exception) -> str:
|
|
134
|
+
detail = str(error).strip() or error.__class__.__name__
|
|
135
|
+
return f"{prefix}:{detail[-1000:]}"
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def resolve_cover(
|
|
139
|
+
audio_path: Path,
|
|
140
|
+
metadata: AudioMetadata,
|
|
141
|
+
destination: Path,
|
|
142
|
+
*,
|
|
143
|
+
ffmpeg: str = "ffmpeg",
|
|
144
|
+
search: SearchFn = search_track,
|
|
145
|
+
downloader: Downloader = download_artwork,
|
|
146
|
+
extractor: Extractor = extract_embedded_cover,
|
|
147
|
+
) -> CoverResolution:
|
|
148
|
+
"""Resolve embedded artwork, then iTunes artwork, with a safe default fallback."""
|
|
149
|
+
warnings: list[str] = []
|
|
150
|
+
if metadata.cover_stream_index is not None:
|
|
151
|
+
try:
|
|
152
|
+
extractor(
|
|
153
|
+
audio_path,
|
|
154
|
+
metadata.cover_stream_index,
|
|
155
|
+
destination,
|
|
156
|
+
ffmpeg=ffmpeg,
|
|
157
|
+
)
|
|
158
|
+
return CoverResolution(destination, "embedded")
|
|
159
|
+
except Exception as error:
|
|
160
|
+
destination.unlink(missing_ok=True)
|
|
161
|
+
warnings.append(_warning("内嵌封面不可用", error))
|
|
162
|
+
|
|
163
|
+
if not metadata.title or not metadata.artist:
|
|
164
|
+
return CoverResolution(None, "default", tuple(warnings))
|
|
165
|
+
|
|
166
|
+
try:
|
|
167
|
+
track = search(metadata.title, metadata.artist)
|
|
168
|
+
except Exception as error:
|
|
169
|
+
warnings.append(_warning("iTunes 封面查询失败", error))
|
|
170
|
+
return CoverResolution(None, "default", tuple(warnings))
|
|
171
|
+
|
|
172
|
+
if track is None or track.artwork_url is None:
|
|
173
|
+
return CoverResolution(None, "default", tuple(warnings))
|
|
174
|
+
|
|
175
|
+
try:
|
|
176
|
+
normalize_image_bytes(downloader(track.artwork_url), destination)
|
|
177
|
+
return CoverResolution(destination, "itunes", tuple(warnings))
|
|
178
|
+
except Exception as error:
|
|
179
|
+
destination.unlink(missing_ok=True)
|
|
180
|
+
warnings.append(_warning("iTunes 封面下载或解码失败", error))
|
|
181
|
+
return CoverResolution(None, "default", tuple(warnings))
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _cache_text(value: str) -> str:
|
|
185
|
+
normalized = unicodedata.normalize("NFKC", value)
|
|
186
|
+
return " ".join(normalized.split()).casefold()
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class BatchCoverResolver:
|
|
190
|
+
"""Resolve covers while caching iTunes searches and artwork bytes per batch."""
|
|
191
|
+
|
|
192
|
+
def __init__(
|
|
193
|
+
self,
|
|
194
|
+
*,
|
|
195
|
+
search: SearchFn = search_track,
|
|
196
|
+
downloader: Downloader = download_artwork,
|
|
197
|
+
extractor: Extractor = extract_embedded_cover,
|
|
198
|
+
) -> None:
|
|
199
|
+
self._search = search
|
|
200
|
+
self._downloader = downloader
|
|
201
|
+
self._extractor = extractor
|
|
202
|
+
self._search_cache: dict[tuple[str, str], ItunesTrack | None | Exception] = {}
|
|
203
|
+
self._download_cache: dict[str, bytes | Exception] = {}
|
|
204
|
+
|
|
205
|
+
def _cached_search(self, title: str, artist: str) -> ItunesTrack | None:
|
|
206
|
+
key = (_cache_text(title), _cache_text(artist))
|
|
207
|
+
if key not in self._search_cache:
|
|
208
|
+
try:
|
|
209
|
+
self._search_cache[key] = self._search(title, artist)
|
|
210
|
+
except Exception as error:
|
|
211
|
+
self._search_cache[key] = error
|
|
212
|
+
result = self._search_cache[key]
|
|
213
|
+
if isinstance(result, Exception):
|
|
214
|
+
raise result
|
|
215
|
+
return result
|
|
216
|
+
|
|
217
|
+
def _cached_download(self, url: str) -> bytes:
|
|
218
|
+
if url not in self._download_cache:
|
|
219
|
+
try:
|
|
220
|
+
self._download_cache[url] = self._downloader(url)
|
|
221
|
+
except Exception as error:
|
|
222
|
+
self._download_cache[url] = error
|
|
223
|
+
result = self._download_cache[url]
|
|
224
|
+
if isinstance(result, Exception):
|
|
225
|
+
raise result
|
|
226
|
+
return result
|
|
227
|
+
|
|
228
|
+
def resolve(
|
|
229
|
+
self,
|
|
230
|
+
audio_path: Path,
|
|
231
|
+
metadata: AudioMetadata,
|
|
232
|
+
destination: Path,
|
|
233
|
+
*,
|
|
234
|
+
ffmpeg: str = "ffmpeg",
|
|
235
|
+
) -> CoverResolution:
|
|
236
|
+
return resolve_cover(
|
|
237
|
+
audio_path,
|
|
238
|
+
metadata,
|
|
239
|
+
destination,
|
|
240
|
+
ffmpeg=ffmpeg,
|
|
241
|
+
search=self._cached_search,
|
|
242
|
+
downloader=self._cached_download,
|
|
243
|
+
extractor=self._extractor,
|
|
244
|
+
)
|
mpup/discovery.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Discover supported audio inputs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
SUPPORTED_SUFFIXES = frozenset({".mp3", ".m4a"})
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _is_supported(path: Path) -> bool:
|
|
12
|
+
return path.is_file() and path.suffix.casefold() in SUPPORTED_SUFFIXES
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def discover_audio(input_path: Path) -> list[Path]:
|
|
16
|
+
"""Resolve one audio file or discover immediate audio children."""
|
|
17
|
+
path = input_path.expanduser()
|
|
18
|
+
if not path.exists():
|
|
19
|
+
raise ValueError(f"输入路径不存在:{input_path}")
|
|
20
|
+
if path.is_file():
|
|
21
|
+
if not _is_supported(path):
|
|
22
|
+
raise ValueError(f"不支持的音频格式:{path.suffix or '(无扩展名)'}")
|
|
23
|
+
return [path.resolve()]
|
|
24
|
+
if not path.is_dir():
|
|
25
|
+
raise ValueError(f"输入路径既不是文件也不是目录:{input_path}")
|
|
26
|
+
|
|
27
|
+
audio_paths = sorted(
|
|
28
|
+
(child.resolve() for child in path.iterdir() if _is_supported(child)),
|
|
29
|
+
key=lambda child: (child.name.casefold(), child.name),
|
|
30
|
+
)
|
|
31
|
+
if not audio_paths:
|
|
32
|
+
raise ValueError(f"目录中没有支持的音频文件:{input_path}")
|
|
33
|
+
return audio_paths
|
mpup/enriched_audio.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Write a playable MP3 copy with resolved artwork and synchronized lyrics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import subprocess
|
|
8
|
+
import tempfile
|
|
9
|
+
from collections.abc import Callable, Sequence
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from mpup.lyrics import LyricCue
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
Runner = Callable[..., subprocess.CompletedProcess[str]]
|
|
16
|
+
_UNSAFE_FILENAME_CHARACTER = re.compile(r"[\\\\/:\x00]")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _filename_component(value: str) -> str:
|
|
20
|
+
"""Keep the requested name readable without letting metadata escape its folder."""
|
|
21
|
+
cleaned = _UNSAFE_FILENAME_CHARACTER.sub("-", value.strip())
|
|
22
|
+
if not cleaned:
|
|
23
|
+
raise ValueError("歌名和歌手不能为空")
|
|
24
|
+
return cleaned
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def enriched_mp3_path(source_path: Path, title: str, artist: str) -> Path:
|
|
28
|
+
"""Return the sibling MP3 path requested for a resolved song."""
|
|
29
|
+
return source_path.parent / f"{_filename_component(title)}-{_filename_component(artist)}.mp3"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def lrc_from_cues(cues: Sequence[LyricCue] | None) -> str | None:
|
|
33
|
+
"""Serialize renderer cues as deterministic, timestamped LRC tag text."""
|
|
34
|
+
if not cues:
|
|
35
|
+
return None
|
|
36
|
+
lines: list[str] = []
|
|
37
|
+
for cue in cues:
|
|
38
|
+
total_seconds, milliseconds = divmod(cue.start_ms, 1000)
|
|
39
|
+
minutes, seconds = divmod(total_seconds, 60)
|
|
40
|
+
lines.append(f"[{minutes:02d}:{seconds:02d}.{milliseconds:03d}]{cue.text}")
|
|
41
|
+
return "\n".join(lines)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def build_enriched_mp3_command(
|
|
45
|
+
source_path: Path,
|
|
46
|
+
destination: Path,
|
|
47
|
+
title: str,
|
|
48
|
+
artist: str,
|
|
49
|
+
cover_path: Path | None,
|
|
50
|
+
lyrics: Sequence[LyricCue] | None,
|
|
51
|
+
*,
|
|
52
|
+
ffmpeg: str = "ffmpeg",
|
|
53
|
+
) -> list[str]:
|
|
54
|
+
"""Build a shell-free transcode that maps source audio plus optional cover."""
|
|
55
|
+
command = [
|
|
56
|
+
ffmpeg,
|
|
57
|
+
"-hide_banner",
|
|
58
|
+
"-loglevel",
|
|
59
|
+
"error",
|
|
60
|
+
"-y",
|
|
61
|
+
"-i",
|
|
62
|
+
str(source_path),
|
|
63
|
+
]
|
|
64
|
+
if cover_path is not None:
|
|
65
|
+
command.extend(["-i", str(cover_path), "-map", "0:a:0", "-map", "1:v:0"])
|
|
66
|
+
else:
|
|
67
|
+
command.extend(["-map", "0:a:0"])
|
|
68
|
+
command.extend(
|
|
69
|
+
[
|
|
70
|
+
"-map_metadata",
|
|
71
|
+
"-1",
|
|
72
|
+
"-c:a",
|
|
73
|
+
"libmp3lame",
|
|
74
|
+
"-q:a",
|
|
75
|
+
"0",
|
|
76
|
+
"-id3v2_version",
|
|
77
|
+
"3",
|
|
78
|
+
"-metadata",
|
|
79
|
+
f"title={title}",
|
|
80
|
+
"-metadata",
|
|
81
|
+
f"artist={artist}",
|
|
82
|
+
]
|
|
83
|
+
)
|
|
84
|
+
lyrics_text = lrc_from_cues(lyrics)
|
|
85
|
+
if lyrics_text is not None:
|
|
86
|
+
command.extend(["-metadata", f"syncedlyrics={lyrics_text}"])
|
|
87
|
+
if cover_path is not None:
|
|
88
|
+
command.extend(
|
|
89
|
+
[
|
|
90
|
+
"-c:v",
|
|
91
|
+
"mjpeg",
|
|
92
|
+
"-disposition:v:0",
|
|
93
|
+
"attached_pic",
|
|
94
|
+
"-metadata:s:v:0",
|
|
95
|
+
"title=Album cover",
|
|
96
|
+
"-metadata:s:v:0",
|
|
97
|
+
"comment=Cover (front)",
|
|
98
|
+
]
|
|
99
|
+
)
|
|
100
|
+
command.append(str(destination))
|
|
101
|
+
return command
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def write_enriched_mp3(
|
|
105
|
+
source_path: Path,
|
|
106
|
+
title: str,
|
|
107
|
+
artist: str,
|
|
108
|
+
cover_path: Path | None,
|
|
109
|
+
lyrics: Sequence[LyricCue] | None,
|
|
110
|
+
*,
|
|
111
|
+
ffmpeg: str = "ffmpeg",
|
|
112
|
+
overwrite: bool = False,
|
|
113
|
+
runner: Runner = subprocess.run,
|
|
114
|
+
) -> Path:
|
|
115
|
+
"""Atomically create the sibling ``<title>-<artist>.mp3`` metadata copy."""
|
|
116
|
+
destination = enriched_mp3_path(source_path, title, artist)
|
|
117
|
+
if destination.exists() and destination != source_path and not overwrite:
|
|
118
|
+
raise FileExistsError(f"元数据 MP3 已存在:{destination}")
|
|
119
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
120
|
+
temporary = tempfile.NamedTemporaryFile(
|
|
121
|
+
prefix=f".{destination.stem}.",
|
|
122
|
+
suffix=".tmp.mp3",
|
|
123
|
+
dir=destination.parent,
|
|
124
|
+
delete=False,
|
|
125
|
+
)
|
|
126
|
+
temporary_path = Path(temporary.name)
|
|
127
|
+
temporary.close()
|
|
128
|
+
try:
|
|
129
|
+
command = build_enriched_mp3_command(
|
|
130
|
+
source_path,
|
|
131
|
+
temporary_path,
|
|
132
|
+
title,
|
|
133
|
+
artist,
|
|
134
|
+
cover_path,
|
|
135
|
+
lyrics,
|
|
136
|
+
ffmpeg=ffmpeg,
|
|
137
|
+
)
|
|
138
|
+
result = runner(command, capture_output=True, text=True, check=False)
|
|
139
|
+
if result.returncode != 0:
|
|
140
|
+
detail = result.stderr.strip() or "未知错误"
|
|
141
|
+
raise RuntimeError(f"FFmpeg 写入 MP3 元数据失败:{detail[-2000:]}")
|
|
142
|
+
if not temporary_path.is_file() or temporary_path.stat().st_size == 0:
|
|
143
|
+
raise RuntimeError("FFmpeg 未生成元数据 MP3")
|
|
144
|
+
os.replace(temporary_path, destination)
|
|
145
|
+
finally:
|
|
146
|
+
temporary_path.unlink(missing_ok=True)
|
|
147
|
+
return destination
|
mpup/itunes.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Look up music metadata through the public iTunes Search API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Any
|
|
10
|
+
from urllib.error import HTTPError
|
|
11
|
+
from urllib.parse import urlencode
|
|
12
|
+
from urllib.request import Request, urlopen
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
ITUNES_SEARCH_URL = "https://itunes.apple.com/search"
|
|
16
|
+
DEFAULT_USER_AGENT = "MPUp/0.1.0 (https://github.com/)"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class ItunesTrack:
|
|
21
|
+
"""A song match returned by iTunes, with a downloadable artwork URL."""
|
|
22
|
+
|
|
23
|
+
title: str
|
|
24
|
+
artist: str
|
|
25
|
+
album: str | None
|
|
26
|
+
artwork_url: str | None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
Opener = Callable[..., Any]
|
|
30
|
+
Sleeper = Callable[[float], None]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _clean_string(value: object) -> str | None:
|
|
34
|
+
if not isinstance(value, str):
|
|
35
|
+
return None
|
|
36
|
+
return value.strip() or None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _high_resolution_artwork(url: str | None) -> str | None:
|
|
40
|
+
if url is None:
|
|
41
|
+
return None
|
|
42
|
+
return url.replace("100x100bb", "600x600bb")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _track_from_payload(payload: object) -> ItunesTrack | None:
|
|
46
|
+
if not isinstance(payload, dict):
|
|
47
|
+
raise RuntimeError("iTunes 返回了无效 JSON 对象")
|
|
48
|
+
results = payload.get("results")
|
|
49
|
+
if not isinstance(results, list):
|
|
50
|
+
raise RuntimeError("iTunes 返回了无效搜索结果")
|
|
51
|
+
if not results:
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
match = results[0]
|
|
55
|
+
if not isinstance(match, dict):
|
|
56
|
+
raise RuntimeError("iTunes 返回了无效歌曲结果")
|
|
57
|
+
title = _clean_string(match.get("trackName"))
|
|
58
|
+
artist = _clean_string(match.get("artistName"))
|
|
59
|
+
if title is None or artist is None:
|
|
60
|
+
raise RuntimeError("iTunes 返回的歌曲缺少歌名或歌手")
|
|
61
|
+
return ItunesTrack(
|
|
62
|
+
title=title,
|
|
63
|
+
artist=artist,
|
|
64
|
+
album=_clean_string(match.get("collectionName")),
|
|
65
|
+
artwork_url=_high_resolution_artwork(_clean_string(match.get("artworkUrl100"))),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def search_track(
|
|
70
|
+
title: str,
|
|
71
|
+
artist: str,
|
|
72
|
+
*,
|
|
73
|
+
opener: Opener = urlopen,
|
|
74
|
+
sleeper: Sleeper = time.sleep,
|
|
75
|
+
max_rate_limit_retries: int = 3,
|
|
76
|
+
) -> ItunesTrack | None:
|
|
77
|
+
"""Return iTunes' best song match, retrying 429 responses with backoff."""
|
|
78
|
+
cleaned_title = title.strip()
|
|
79
|
+
cleaned_artist = artist.strip()
|
|
80
|
+
if not cleaned_title or not cleaned_artist:
|
|
81
|
+
raise ValueError("歌名和歌手不能为空")
|
|
82
|
+
if max_rate_limit_retries < 0:
|
|
83
|
+
raise ValueError("限流重试次数不能小于 0")
|
|
84
|
+
|
|
85
|
+
query = urlencode(
|
|
86
|
+
{
|
|
87
|
+
"term": f"{cleaned_artist} {cleaned_title}",
|
|
88
|
+
"media": "music",
|
|
89
|
+
"entity": "song",
|
|
90
|
+
"limit": "1",
|
|
91
|
+
}
|
|
92
|
+
)
|
|
93
|
+
request = Request(
|
|
94
|
+
f"{ITUNES_SEARCH_URL}?{query}",
|
|
95
|
+
headers={"User-Agent": DEFAULT_USER_AGENT, "Accept": "application/json"},
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
for attempt in range(max_rate_limit_retries + 1):
|
|
99
|
+
try:
|
|
100
|
+
with opener(request, timeout=10) as response:
|
|
101
|
+
body = response.read()
|
|
102
|
+
try:
|
|
103
|
+
payload = json.loads(body.decode("utf-8"))
|
|
104
|
+
except (AttributeError, UnicodeDecodeError, json.JSONDecodeError) as error:
|
|
105
|
+
raise RuntimeError("iTunes 返回了无效 JSON") from error
|
|
106
|
+
return _track_from_payload(payload)
|
|
107
|
+
except HTTPError as error:
|
|
108
|
+
if error.code != 429 or attempt == max_rate_limit_retries:
|
|
109
|
+
raise
|
|
110
|
+
sleeper(float(2**attempt))
|
|
111
|
+
|
|
112
|
+
raise AssertionError("unreachable")
|