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/__init__.py +3 -0
- stashify/__main__.py +6 -0
- stashify/cli.py +372 -0
- stashify/config.py +108 -0
- stashify/download.py +104 -0
- stashify/embed.py +271 -0
- stashify/errors.py +21 -0
- stashify/lyrics.py +36 -0
- stashify/models.py +62 -0
- stashify/output.py +59 -0
- stashify/pipeline.py +179 -0
- stashify/sources.py +130 -0
- stashify/spotify.py +256 -0
- stashify/sync.py +93 -0
- stashify/tagger.py +243 -0
- stashify_cli-1.3.0.dist-info/METADATA +488 -0
- stashify_cli-1.3.0.dist-info/RECORD +21 -0
- stashify_cli-1.3.0.dist-info/WHEEL +5 -0
- stashify_cli-1.3.0.dist-info/entry_points.txt +2 -0
- stashify_cli-1.3.0.dist-info/licenses/LICENSE +21 -0
- stashify_cli-1.3.0.dist-info/top_level.txt +1 -0
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 ""
|