252 lines
8.2 KiB
Python
252 lines
8.2 KiB
Python
|
|
import io
|
||
|
|
import requests
|
||
|
|
from pathlib import Path
|
||
|
|
from PIL import Image
|
||
|
|
|
||
|
|
from mutagen.id3 import ID3, APIC, TIT2, TPE1, TALB, TRCK, TDRC
|
||
|
|
from mutagen.id3 import ID3NoHeaderError
|
||
|
|
from mutagen.flac import FLAC, Picture
|
||
|
|
from mutagen.mp4 import MP4, MP4Cover
|
||
|
|
from mutagen.oggvorbis import OggVorbis
|
||
|
|
from mutagen.oggopus import OggOpus
|
||
|
|
|
||
|
|
SUPPORTED_EXTS = {".mp3", ".flac", ".m4a", ".mp4", ".wav", ".ogg", ".opus"}
|
||
|
|
|
||
|
|
|
||
|
|
def _sanitize(value) -> str:
|
||
|
|
return str(value) if value is not None else ""
|
||
|
|
|
||
|
|
|
||
|
|
def _fetch_cover(url: str) -> bytes | None:
|
||
|
|
if not url:
|
||
|
|
return None
|
||
|
|
try:
|
||
|
|
r = requests.get(url, timeout=15)
|
||
|
|
r.raise_for_status()
|
||
|
|
img = Image.open(io.BytesIO(r.content)).convert("RGB")
|
||
|
|
buf = io.BytesIO()
|
||
|
|
img.save(buf, format="JPEG", quality=95)
|
||
|
|
return buf.getvalue()
|
||
|
|
except Exception:
|
||
|
|
return None
|
||
|
|
|
||
|
|
|
||
|
|
def _cover_bytes_from_upload(data: bytes) -> bytes:
|
||
|
|
img = Image.open(io.BytesIO(data)).convert("RGB")
|
||
|
|
buf = io.BytesIO()
|
||
|
|
img.save(buf, format="JPEG", quality=95)
|
||
|
|
return buf.getvalue()
|
||
|
|
|
||
|
|
|
||
|
|
def _extract_cover_bytes(path: Path) -> bytes | None:
|
||
|
|
ext = path.suffix.lower()
|
||
|
|
try:
|
||
|
|
if ext in (".mp3", ".wav"):
|
||
|
|
tags = ID3(path)
|
||
|
|
for tag in tags.values():
|
||
|
|
if tag.FrameID == "APIC":
|
||
|
|
return tag.data
|
||
|
|
elif ext == ".flac":
|
||
|
|
audio = FLAC(path)
|
||
|
|
if audio.pictures:
|
||
|
|
return audio.pictures[0].data
|
||
|
|
elif ext in (".m4a", ".mp4"):
|
||
|
|
audio = MP4(path)
|
||
|
|
covr = audio.get("covr")
|
||
|
|
if covr:
|
||
|
|
return bytes(covr[0])
|
||
|
|
elif ext == ".ogg":
|
||
|
|
audio = OggVorbis(path)
|
||
|
|
pics = audio.get("metadata_block_picture")
|
||
|
|
if pics:
|
||
|
|
import base64
|
||
|
|
pic = Picture(base64.b64decode(pics[0]))
|
||
|
|
return pic.data
|
||
|
|
elif ext == ".opus":
|
||
|
|
audio = OggOpus(path)
|
||
|
|
pics = audio.get("metadata_block_picture")
|
||
|
|
if pics:
|
||
|
|
import base64
|
||
|
|
pic = Picture(base64.b64decode(pics[0]))
|
||
|
|
return pic.data
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
return None
|
||
|
|
|
||
|
|
|
||
|
|
def read_tags(path: Path) -> dict:
|
||
|
|
ext = path.suffix.lower()
|
||
|
|
tags = {"title": "", "artist": "", "album": "", "year": "", "track_number": "", "has_cover": False}
|
||
|
|
|
||
|
|
try:
|
||
|
|
if ext in (".mp3", ".wav"):
|
||
|
|
id3 = ID3(path)
|
||
|
|
tags["title"] = str(id3.get("TIT2", ""))
|
||
|
|
tags["artist"] = str(id3.get("TPE1", ""))
|
||
|
|
tags["album"] = str(id3.get("TALB", ""))
|
||
|
|
tags["year"] = str(id3.get("TDRC", ""))
|
||
|
|
tags["track_number"] = str(id3.get("TRCK", ""))
|
||
|
|
elif ext == ".flac":
|
||
|
|
audio = FLAC(path)
|
||
|
|
tags["title"] = audio.get("title", [""])[0]
|
||
|
|
tags["artist"] = audio.get("artist", [""])[0]
|
||
|
|
tags["album"] = audio.get("album", [""])[0]
|
||
|
|
tags["year"] = audio.get("date", [""])[0]
|
||
|
|
tags["track_number"] = audio.get("tracknumber", [""])[0]
|
||
|
|
elif ext in (".m4a", ".mp4"):
|
||
|
|
audio = MP4(path)
|
||
|
|
tags["title"] = (audio.get("\xa9nam") or [""])[0]
|
||
|
|
tags["artist"] = (audio.get("\xa9ART") or [""])[0]
|
||
|
|
tags["album"] = (audio.get("\xa9alb") or [""])[0]
|
||
|
|
tags["year"] = (audio.get("\xa9day") or [""])[0]
|
||
|
|
elif ext == ".ogg":
|
||
|
|
audio = OggVorbis(path)
|
||
|
|
tags["title"] = audio.get("title", [""])[0]
|
||
|
|
tags["artist"] = audio.get("artist", [""])[0]
|
||
|
|
tags["album"] = audio.get("album", [""])[0]
|
||
|
|
tags["year"] = audio.get("date", [""])[0]
|
||
|
|
elif ext == ".opus":
|
||
|
|
audio = OggOpus(path)
|
||
|
|
tags["title"] = audio.get("title", [""])[0]
|
||
|
|
tags["artist"] = audio.get("artist", [""])[0]
|
||
|
|
tags["album"] = audio.get("album", [""])[0]
|
||
|
|
tags["year"] = audio.get("date", [""])[0]
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
|
||
|
|
tags["has_cover"] = _extract_cover_bytes(path) is not None
|
||
|
|
return tags
|
||
|
|
|
||
|
|
|
||
|
|
def _write_mp3_like(path: Path, meta: dict, cover: bytes | None):
|
||
|
|
try:
|
||
|
|
tags = ID3(path)
|
||
|
|
except ID3NoHeaderError:
|
||
|
|
tags = ID3()
|
||
|
|
|
||
|
|
if meta.get("title") is not None:
|
||
|
|
tags["TIT2"] = TIT2(encoding=3, text=_sanitize(meta["title"]))
|
||
|
|
if meta.get("artist") is not None:
|
||
|
|
tags["TPE1"] = TPE1(encoding=3, text=_sanitize(meta["artist"]))
|
||
|
|
if meta.get("album") is not None:
|
||
|
|
tags["TALB"] = TALB(encoding=3, text=_sanitize(meta["album"]))
|
||
|
|
if meta.get("track_number") is not None:
|
||
|
|
tags["TRCK"] = TRCK(encoding=3, text=_sanitize(meta["track_number"]))
|
||
|
|
if meta.get("year") is not None:
|
||
|
|
tags["TDRC"] = TDRC(encoding=3, text=_sanitize(meta["year"]))
|
||
|
|
if cover:
|
||
|
|
tags.delall("APIC")
|
||
|
|
tags["APIC"] = APIC(encoding=3, mime="image/jpeg", type=3, desc="Cover", data=cover)
|
||
|
|
|
||
|
|
tags.save(path)
|
||
|
|
|
||
|
|
|
||
|
|
def _write_flac(path: Path, meta: dict, cover: bytes | None):
|
||
|
|
audio = FLAC(path)
|
||
|
|
|
||
|
|
if meta.get("title") is not None:
|
||
|
|
audio["title"] = _sanitize(meta["title"])
|
||
|
|
if meta.get("artist") is not None:
|
||
|
|
audio["artist"] = _sanitize(meta["artist"])
|
||
|
|
if meta.get("album") is not None:
|
||
|
|
audio["album"] = _sanitize(meta["album"])
|
||
|
|
if meta.get("track_number") is not None:
|
||
|
|
audio["tracknumber"] = _sanitize(meta["track_number"])
|
||
|
|
if meta.get("year") is not None:
|
||
|
|
audio["date"] = _sanitize(meta["year"])
|
||
|
|
|
||
|
|
if cover:
|
||
|
|
audio.clear_pictures()
|
||
|
|
pic = Picture()
|
||
|
|
pic.type = 3
|
||
|
|
pic.mime = "image/jpeg"
|
||
|
|
pic.data = cover
|
||
|
|
audio.add_picture(pic)
|
||
|
|
|
||
|
|
audio.save()
|
||
|
|
|
||
|
|
|
||
|
|
def _write_m4a(path: Path, meta: dict, cover: bytes | None):
|
||
|
|
audio = MP4(path)
|
||
|
|
|
||
|
|
if meta.get("title") is not None:
|
||
|
|
audio["\xa9nam"] = [_sanitize(meta["title"])]
|
||
|
|
if meta.get("artist") is not None:
|
||
|
|
audio["\xa9ART"] = [_sanitize(meta["artist"])]
|
||
|
|
if meta.get("album") is not None:
|
||
|
|
audio["\xa9alb"] = [_sanitize(meta["album"])]
|
||
|
|
if meta.get("year") is not None:
|
||
|
|
audio["\xa9day"] = [_sanitize(meta["year"])]
|
||
|
|
if meta.get("track_number"):
|
||
|
|
try:
|
||
|
|
audio["trkn"] = [(int(meta["track_number"]), 0)]
|
||
|
|
except (TypeError, ValueError):
|
||
|
|
pass
|
||
|
|
if cover:
|
||
|
|
audio["covr"] = [MP4Cover(cover, imageformat=MP4Cover.FORMAT_JPEG)]
|
||
|
|
|
||
|
|
audio.save()
|
||
|
|
|
||
|
|
|
||
|
|
def _write_ogg_like(path: Path, meta: dict, cover: bytes | None, cls):
|
||
|
|
audio = cls(path)
|
||
|
|
|
||
|
|
if meta.get("title") is not None:
|
||
|
|
audio["title"] = _sanitize(meta["title"])
|
||
|
|
if meta.get("artist") is not None:
|
||
|
|
audio["artist"] = _sanitize(meta["artist"])
|
||
|
|
if meta.get("album") is not None:
|
||
|
|
audio["album"] = _sanitize(meta["album"])
|
||
|
|
if meta.get("year") is not None:
|
||
|
|
audio["date"] = _sanitize(meta["year"])
|
||
|
|
|
||
|
|
if cover:
|
||
|
|
import base64
|
||
|
|
pic = Picture()
|
||
|
|
pic.type = 3
|
||
|
|
pic.mime = "image/jpeg"
|
||
|
|
pic.data = cover
|
||
|
|
audio["metadata_block_picture"] = [base64.b64encode(pic.write()).decode("ascii")]
|
||
|
|
|
||
|
|
audio.save()
|
||
|
|
|
||
|
|
|
||
|
|
def write_tags(path: Path, meta: dict, cover_bytes: bytes | None = None):
|
||
|
|
ext = path.suffix.lower()
|
||
|
|
if ext not in SUPPORTED_EXTS:
|
||
|
|
raise ValueError(f"Unsupported format: {ext}")
|
||
|
|
|
||
|
|
if ext in (".mp3", ".wav"):
|
||
|
|
_write_mp3_like(path, meta, cover_bytes)
|
||
|
|
elif ext == ".flac":
|
||
|
|
_write_flac(path, meta, cover_bytes)
|
||
|
|
elif ext in (".m4a", ".mp4"):
|
||
|
|
_write_m4a(path, meta, cover_bytes)
|
||
|
|
elif ext == ".ogg":
|
||
|
|
_write_ogg_like(path, meta, cover_bytes, OggVorbis)
|
||
|
|
elif ext == ".opus":
|
||
|
|
_write_ogg_like(path, meta, cover_bytes, OggOpus)
|
||
|
|
|
||
|
|
|
||
|
|
def embed_cover_and_metadata(file_path: str, meta: dict):
|
||
|
|
"""Used by the downloader right after a track finishes downloading."""
|
||
|
|
path = Path(file_path)
|
||
|
|
if not path.exists():
|
||
|
|
return
|
||
|
|
|
||
|
|
cover_data = _fetch_cover(meta.get("cover_url"))
|
||
|
|
try:
|
||
|
|
write_tags(
|
||
|
|
path,
|
||
|
|
{
|
||
|
|
"title": meta.get("title"),
|
||
|
|
"artist": meta.get("artist"),
|
||
|
|
"album": meta.get("album"),
|
||
|
|
"track_number": meta.get("track_number"),
|
||
|
|
"year": meta.get("year"),
|
||
|
|
},
|
||
|
|
cover_data,
|
||
|
|
)
|
||
|
|
except Exception:
|
||
|
|
pass
|