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/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")