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/tagger.py ADDED
@@ -0,0 +1,243 @@
1
+ """Write Spotify metadata into audio files.
2
+
3
+ Supported containers: MP3, M4A, FLAC, OGG/Opus and WAV (ID3). Cover art and
4
+ plain-text lyrics are embedded when available.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import base64
10
+ import re
11
+ from pathlib import Path
12
+
13
+ import requests
14
+ from mutagen import File as MutagenFile
15
+ from mutagen.flac import FLAC, Picture
16
+ from mutagen.mp4 import MP4, MP4Cover
17
+ from mutagen.oggopus import OggOpus
18
+
19
+ from .errors import StashifyError
20
+ from .models import Track
21
+
22
+ COVER_TIMEOUT = 15
23
+ MAX_COVER_BYTES = 20 * 1024 * 1024
24
+ _MIME_BY_SUFFIX = {"jpg": "image/jpeg", "jpeg": "image/jpeg", "png": "image/png"}
25
+
26
+
27
+ def fetch_cover(track: Track) -> tuple[bytes, str] | None:
28
+ if not track.cover_url:
29
+ return None
30
+ try:
31
+ response = requests.get(track.cover_url, timeout=COVER_TIMEOUT, stream=True)
32
+ except requests.RequestException:
33
+ return None
34
+ if response.status_code != 200:
35
+ return None
36
+ content_type = (response.headers.get("Content-Type") or "").split(";")[0].strip().lower()
37
+ if content_type and not content_type.startswith("image/"):
38
+ return None
39
+ chunks: list[bytes] = []
40
+ size = 0
41
+ for chunk in response.iter_content(chunk_size=64 * 1024):
42
+ size += len(chunk)
43
+ if size > MAX_COVER_BYTES:
44
+ return None
45
+ chunks.append(chunk)
46
+ data = b"".join(chunks)
47
+ if not data:
48
+ return None
49
+ mime = content_type if content_type.startswith("image/") else _mime_guess(data)
50
+ return data, mime
51
+
52
+
53
+ def _mime_guess(data: bytes) -> str:
54
+ if data.startswith(b"\x89PNG"):
55
+ return "image/png"
56
+ return "image/jpeg"
57
+
58
+
59
+ def tag_file(path: Path, track: Track, cover: tuple[bytes, str] | None, lyrics: str | None) -> None:
60
+ path = Path(path)
61
+ suffix = path.suffix.lower()
62
+ if suffix == ".mp3":
63
+ _tag_id3(path, track, cover, lyrics)
64
+ elif suffix == ".m4a":
65
+ _tag_mp4(path, track, cover, lyrics)
66
+ elif suffix == ".flac":
67
+ _tag_flac(path, track, cover, lyrics)
68
+ elif suffix == ".opus":
69
+ _tag_opus(path, track, cover, lyrics)
70
+ elif suffix == ".wav":
71
+ _tag_id3(path, track, cover, lyrics)
72
+ else:
73
+ # Fall back to whatever mutagen detects, and fail clearly if nothing does.
74
+ audio = MutagenFile(path)
75
+ if audio is None:
76
+ raise StashifyError(f"Unsupported audio container: {path.suffix}")
77
+ _assign_simple(audio, track, lyrics)
78
+
79
+
80
+ # ID3 (MP3, WAV) ----------------------------------------------------------
81
+
82
+ def _id3_frames(track: Track, lyrics: str | None) -> dict:
83
+ frames: dict = {}
84
+ frames["TIT2"] = track.title
85
+ frames["TPE1"] = track.artists_text
86
+ frames["TPE2"] = track.album_artist or track.artist
87
+ frames["TALB"] = track.album
88
+ if track.track_number:
89
+ frames["TRCK"] = (
90
+ f"{track.track_number}/{track.total_tracks}"
91
+ if track.total_tracks
92
+ else str(track.track_number)
93
+ )
94
+ if track.disc_number:
95
+ frames["TPOS"] = str(track.disc_number)
96
+ year = _release_year(track.release_date)
97
+ if year:
98
+ frames["TDRC"] = year
99
+ if track.isrc:
100
+ frames["TSRC"] = track.isrc
101
+ if lyrics:
102
+ frames["USLT"] = lyrics
103
+ return frames
104
+
105
+
106
+ def _tag_id3(path: Path, track: Track, cover: tuple[bytes, str] | None, lyrics: str | None) -> None:
107
+ from mutagen.id3 import APIC, TALB, TDRC, TIT2, TPE1, TPE2, TPOS, TRCK, TSRC, USLT
108
+
109
+ audio = MP3_or_WAVE(path)
110
+ try:
111
+ audio.add_tags()
112
+ except Exception:
113
+ pass # Tags already exist.
114
+
115
+ tags = audio.tags
116
+ frames = _id3_frames(track, lyrics)
117
+ frame_classes = {
118
+ "TIT2": TIT2, "TPE1": TPE1, "TPE2": TPE2, "TALB": TALB,
119
+ "TRCK": TRCK, "TPOS": TPOS, "TDRC": TDRC, "TSRC": TSRC,
120
+ }
121
+ for key, value in frames.items():
122
+ if key == "USLT":
123
+ tags.delall("USLT")
124
+ tags.add(USLT(encoding=3, lang="eng", desc="", text=value))
125
+ continue
126
+ frame_class = frame_classes.get(key)
127
+ if frame_class is None:
128
+ continue
129
+ tags.delall(key)
130
+ tags.add(frame_class(encoding=3, text=value))
131
+
132
+ if cover is not None:
133
+ data, mime = cover
134
+ tags.delall("APIC")
135
+ tags.add(APIC(encoding=3, mime=mime, type=3, desc="Cover", data=data))
136
+ audio.save()
137
+
138
+
139
+ def MP3_or_WAVE(path: Path):
140
+ from mutagen.id3 import ID3
141
+ from mutagen.mp3 import MP3
142
+ from mutagen.wave import WAVE
143
+
144
+ if path.suffix.lower() == ".wav":
145
+ return WAVE(str(path))
146
+ return MP3(str(path), ID3=ID3)
147
+
148
+
149
+ # M4A ---------------------------------------------------------------------
150
+
151
+ def _tag_mp4(path: Path, track: Track, cover: tuple[bytes, str] | None, lyrics: str | None) -> None:
152
+ audio = MP4(str(path))
153
+ audio["\xa9nam"] = [track.title]
154
+ audio["\xa9ART"] = list(track.artists) or [track.artist]
155
+ audio["\xa9alb"] = [track.album]
156
+ audio["aART"] = [track.album_artist or track.artist]
157
+ if track.track_number:
158
+ audio["trkn"] = [(track.track_number, track.total_tracks or 0)]
159
+ if track.disc_number:
160
+ audio["disk"] = [(track.disc_number, 0)]
161
+ year = _release_year(track.release_date)
162
+ if year:
163
+ audio["\xa9day"] = [year]
164
+ if track.isrc:
165
+ audio["----:com.apple.iTunes:ISRC"] = [track.isrc.encode("utf-8")]
166
+ if lyrics:
167
+ audio["\xa9lyr"] = [lyrics]
168
+ if cover is not None:
169
+ data, mime = cover
170
+ fmt = MP4Cover.FORMAT_PNG if mime == "image/png" else MP4Cover.FORMAT_JPEG
171
+ audio["covr"] = [MP4Cover(data, imageformat=fmt)]
172
+ audio.save()
173
+
174
+
175
+ # FLAC --------------------------------------------------------------------
176
+
177
+ def _vorbis_common(audio, track: Track, lyrics: str | None) -> None:
178
+ audio["TITLE"] = [track.title]
179
+ audio["ARTIST"] = list(track.artists) or [track.artist]
180
+ audio["ALBUM"] = [track.album]
181
+ audio["ALBUMARTIST"] = [track.album_artist or track.artist]
182
+ if track.track_number:
183
+ audio["TRACKNUMBER"] = [str(track.track_number)]
184
+ if track.total_tracks:
185
+ audio["TRACKTOTAL"] = [str(track.total_tracks)]
186
+ audio["DISCNUMBER"] = [str(track.disc_number or 1)]
187
+ year = _release_year(track.release_date)
188
+ if year:
189
+ audio["DATE"] = [year]
190
+ if track.isrc:
191
+ audio["ISRC"] = [track.isrc]
192
+ if lyrics:
193
+ audio["LYRICS"] = [lyrics]
194
+
195
+
196
+ def _make_picture(cover: tuple[bytes, str]) -> Picture:
197
+ data, mime = cover
198
+ picture = Picture()
199
+ picture.type = 3
200
+ picture.mime = mime
201
+ picture.desc = "Cover"
202
+ picture.data = data
203
+ return picture
204
+
205
+
206
+ def _tag_flac(
207
+ path: Path, track: Track, cover: tuple[bytes, str] | None, lyrics: str | None
208
+ ) -> None:
209
+ audio = FLAC(str(path))
210
+ _vorbis_common(audio, track, lyrics)
211
+ if cover is not None:
212
+ audio.clear_pictures()
213
+ audio.add_picture(_make_picture(cover))
214
+ audio.save()
215
+
216
+
217
+ # OGG / Opus ----------------------------------------------------------------
218
+
219
+ def _tag_opus(
220
+ path: Path, track: Track, cover: tuple[bytes, str] | None, lyrics: str | None
221
+ ) -> None:
222
+ audio = OggOpus(str(path))
223
+ _vorbis_common(audio, track, lyrics)
224
+ if cover is not None:
225
+ encoded = base64.b64encode(_make_picture(cover).write()).decode("ascii")
226
+ audio["METADATA_BLOCK_PICTURE"] = [encoded]
227
+ audio.save()
228
+
229
+
230
+ # Helpers -------------------------------------------------------------------
231
+
232
+ def _assign_simple(audio, track: Track, lyrics: str | None) -> None:
233
+ """Best-effort tagging for containers without a dedicated path."""
234
+ try:
235
+ audio["TIT2"] = track.title
236
+ except Exception:
237
+ pass
238
+ audio.save()
239
+
240
+
241
+ def _release_year(release_date: str) -> str:
242
+ match = re.match(r"^(\d{4})", release_date or "")
243
+ return match.group(1) if match else ""