tagger: modulo mutagen per ID3 tag + cover art da URL
This commit is contained in:
1 parent
f1437e8e24
commit
0f7cd24703
2 files changed
+166
No files matched your search
+109
@@ -0,0 +1,109 @@
|
|||||||
|
"""Scrittura tag ID3 su file MP3 via mutagen, con cover art da URL."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from mutagen.easyid3 import EasyID3
|
||||||
|
from mutagen.id3 import ID3, APIC
|
||||||
|
from mutagen.mp3 import MP3
|
||||||
|
|
||||||
|
|
||||||
|
SAFE_FILENAME_RE = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_filename_stem(stem: str, max_len: int = 180) -> str:
|
||||||
|
"""Rimuove caratteri non validi per un filename e limita lunghezza."""
|
||||||
|
s = SAFE_FILENAME_RE.sub("_", stem or "").strip()
|
||||||
|
s = re.sub(r"\s+", " ", s)
|
||||||
|
if len(s) > max_len:
|
||||||
|
s = s[:max_len].rstrip()
|
||||||
|
return s or "untitled"
|
||||||
|
|
||||||
|
|
||||||
|
def build_filename_stem(artist: str, title: str) -> str:
|
||||||
|
"""Compone 'Artista - Titolo' sanitizzato."""
|
||||||
|
artist = (artist or "").strip()
|
||||||
|
title = (title or "").strip()
|
||||||
|
if artist and title:
|
||||||
|
return sanitize_filename_stem(f"{artist} - {title}")
|
||||||
|
return sanitize_filename_stem(title or artist or "untitled")
|
||||||
|
|
||||||
|
|
||||||
|
def _download_cover(url: str) -> Optional[bytes]:
|
||||||
|
"""Scarica bytes cover, ritorna None su qualsiasi errore."""
|
||||||
|
try:
|
||||||
|
resp = requests.get(url, timeout=15)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
return resp.content
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _cover_mime(url: str, content: bytes) -> str:
|
||||||
|
"""Deduci MIME dell'immagine da URL o magic bytes."""
|
||||||
|
u = url.lower()
|
||||||
|
if u.endswith(".png"):
|
||||||
|
return "image/png"
|
||||||
|
if u.endswith((".jpg", ".jpeg")):
|
||||||
|
return "image/jpeg"
|
||||||
|
# magic bytes
|
||||||
|
if content[:8] == b"\x89PNG\r\n\x1a\n":
|
||||||
|
return "image/png"
|
||||||
|
if content[:3] == b"\xff\xd8\xff":
|
||||||
|
return "image/jpeg"
|
||||||
|
return "image/jpeg" # default
|
||||||
|
|
||||||
|
|
||||||
|
def write_tags(filepath: str, metadata: dict) -> None:
|
||||||
|
"""Scrive ID3 tag su file MP3 esistente.
|
||||||
|
|
||||||
|
metadata: dict con chiavi opzionali:
|
||||||
|
title, artist, album, date (YYYY o YYYY-MM-DD), genre,
|
||||||
|
tracknumber, cover_url
|
||||||
|
|
||||||
|
Errori non fatali (file mancante, tag non scrivibili): silenziosi
|
||||||
|
per non bloccare il download principale.
|
||||||
|
"""
|
||||||
|
p = Path(filepath)
|
||||||
|
if not p.exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
# 1. Tag testuali via EasyID3
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
audio = EasyID3(str(p))
|
||||||
|
except Exception:
|
||||||
|
# Se il file non ha header ID3, aggiungine uno vuoto
|
||||||
|
mp3 = MP3(str(p))
|
||||||
|
if mp3.tags is None:
|
||||||
|
mp3.add_tags()
|
||||||
|
mp3.save()
|
||||||
|
audio = EasyID3(str(p))
|
||||||
|
for key in ("title", "artist", "album", "date", "genre", "tracknumber"):
|
||||||
|
val = metadata.get(key)
|
||||||
|
if val:
|
||||||
|
audio[key] = str(val)
|
||||||
|
audio.save(str(p))
|
||||||
|
except Exception:
|
||||||
|
return # ID3 broken, non provo la cover
|
||||||
|
|
||||||
|
# 2. Cover art via ID3 APIC
|
||||||
|
cover_url = (metadata.get("cover_url") or "").strip()
|
||||||
|
if not cover_url:
|
||||||
|
return
|
||||||
|
cover_bytes = _download_cover(cover_url)
|
||||||
|
if not cover_bytes:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
mime = _cover_mime(cover_url, cover_bytes)
|
||||||
|
tags = ID3(str(p))
|
||||||
|
tags.delall("APIC")
|
||||||
|
tags.add(APIC(encoding=3, mime=mime, type=3, desc="Cover", data=cover_bytes))
|
||||||
|
tags.save(str(p))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"""Test per core.tagger — filename sanitization + write_tags safety net."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from core import tagger
|
||||||
|
|
||||||
|
|
||||||
|
class TestSanitizeFilenameStem:
|
||||||
|
def test_removes_bad_chars(self):
|
||||||
|
# Tutti i char vietati su Windows/macOS: < > : " / \ | ? *
|
||||||
|
got = tagger.sanitize_filename_stem('bad<name>:"/\\|?*test')
|
||||||
|
# Nessuno dei char proibiti sopravvive
|
||||||
|
for ch in '<>:"/\\|?*':
|
||||||
|
assert ch not in got
|
||||||
|
# E ho ottenuto qualcosa di non vuoto
|
||||||
|
assert got
|
||||||
|
|
||||||
|
def test_collapses_whitespace(self):
|
||||||
|
assert tagger.sanitize_filename_stem("a b c") == "a b c"
|
||||||
|
|
||||||
|
def test_truncates_long_stem(self):
|
||||||
|
s = "x" * 500
|
||||||
|
got = tagger.sanitize_filename_stem(s, max_len=180)
|
||||||
|
assert len(got) <= 180
|
||||||
|
|
||||||
|
def test_empty_returns_untitled(self):
|
||||||
|
assert tagger.sanitize_filename_stem("") == "untitled"
|
||||||
|
assert tagger.sanitize_filename_stem(" ") == "untitled"
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildFilenameStem:
|
||||||
|
def test_format_artist_dash_title(self):
|
||||||
|
assert tagger.build_filename_stem("Kapuchon", "Hot Sauce") == "Kapuchon - Hot Sauce"
|
||||||
|
|
||||||
|
def test_multiple_artists_ok(self):
|
||||||
|
got = tagger.build_filename_stem("Kapuchon, Miss Monique & GLZ", "Hot Sauce (Extended)")
|
||||||
|
assert got == "Kapuchon, Miss Monique & GLZ - Hot Sauce (Extended)"
|
||||||
|
|
||||||
|
def test_missing_artist_uses_title_only(self):
|
||||||
|
assert tagger.build_filename_stem("", "Only Title") == "Only Title"
|
||||||
|
|
||||||
|
def test_missing_title_uses_artist_only(self):
|
||||||
|
assert tagger.build_filename_stem("Only Artist", "") == "Only Artist"
|
||||||
|
|
||||||
|
def test_sanitizes_bad_chars(self):
|
||||||
|
got = tagger.build_filename_stem("A/B", "C:D")
|
||||||
|
# Nessuno slash o colon residuo
|
||||||
|
assert "/" not in got
|
||||||
|
assert ":" not in got
|
||||||
|
|
||||||
|
|
||||||
|
class TestWriteTagsMissingFile:
|
||||||
|
def test_missing_file_is_noop(self):
|
||||||
|
# Non deve sollevare alcuna eccezione se il file non esiste
|
||||||
|
tagger.write_tags("/nonexistent/path/to/file.mp3", {
|
||||||
|
"title": "x", "artist": "y", "cover_url": "http://example.com/x.jpg",
|
||||||
|
})
|
||||||
Reference in new issue
Block a user