radieo/ingest/radieo/tagging.py
Pierre-Olivier Mercier 75629c829a
All checks were successful
continuous-integration/drone/push Build is passing
ingest: resolve bandcamp label sources down to individual tracks
A yt-dlp source pointing at a label/artist page flat-extracts to a mix of
/track/ and /album/ URLs. The provider used each verbatim as a locator, so an
/album/ URL was handed to the fetcher as if it were a track: yt-dlp then
(mis)downloaded the whole album into one file and tagged it from the
playlist-level info, which carries the album title and no artist — surfacing as
"Unknown artist" on the stream.

Drill picked container entries down to a single track before emitting a
locator, bounded by a small depth so nested containers (label -> album ->
track) resolve while a real track (which flat-extracts to just itself) is the
base case. Locators are now always downloadable tracks, so the existing
tag/fetch path and anti-repeat keying work as intended.

Also make guess_metadata trust the explicit artist tag over the "Artist -
Title" title split: some label uploads double the artist into the title
("Artist - Artist - Title"), which the blind last-" - " split mis-parsed. When
that artist prefixes the title we peel it off (repeatedly), falling back to the
split only when there is no artist tag.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 12:48:45 +08:00

172 lines
6.1 KiB
Python

"""Guess and write ID3 tags for freshly downloaded bandcamp tracks.
The yt-dlp *flat* extraction the provider uses to pick a track is thin: on a
bandcamp **label** account the uploader is the label rather than the performing
artist, and many bandcamp mp3s ship with no ID3 tags at all. The full metadata
yt-dlp gathers when it actually downloads is richer, so this module re-derives
``(artist, title, album)`` from it — trusting the ``"Artist - Title"`` shape of
the track title the way the user's ``id3tag`` one-liner trusts the filename —
and writes the result into the file's tags.
Everything here is best-effort: the artist is *guessed*, then the caller cross-
checks it against MusicBrainz (:meth:`Canonicalizer.identify`) to adopt a
canonical spelling and MBID when one is found confidently. A failure to open or
tag the file just leaves it untagged; playback is unaffected.
"""
import logging
import re
from dataclasses import dataclass
from pathlib import Path
log = logging.getLogger("radieo.tagging")
@dataclass
class TagMeta:
artist: str
title: str
album: str | None = None
mbid: str | None = None
def is_bandcamp(info: dict, locator: str) -> bool:
"""True when the download came from bandcamp (extractor key or host)."""
key = (info.get("extractor_key") or info.get("extractor") or "").lower()
if "bandcamp" in key:
return True
return "bandcamp.com" in (locator or "")
def guess_metadata(info: dict, source_url: str | None = None) -> TagMeta:
"""Derive tags from a yt-dlp *download* info dict.
On a bandcamp download the reliable signal is the explicit ``artist`` tag
(the performing artist, *not* the label the way ``uploader`` is). Label
uploads name the ``title`` ``"Artist - Title"`` — sometimes with the artist
doubled (``"Artist - Artist - Title"``) — so when that artist tag prefixes
the title we peel it off rather than guessing where to split. Only when
there is no such artist tag do we fall back to splitting the title on the
last ``" - "`` (and then to the uploader/label as a last resort).
"""
raw_title = (info.get("title") or "").strip()
reliable = (info.get("artist") or info.get("creator") or "").strip()
prefix = f"{reliable} - "
if reliable and raw_title.startswith(prefix):
artist = reliable
title = raw_title
while title.startswith(prefix): # peel a doubled "Artist - " prefix too
title = title[len(prefix):].strip()
else:
artist, title = _split_artist_title(raw_title)
if not artist:
artist = reliable or (
info.get("uploader") or info.get("channel") or ""
).strip()
if not title:
title = raw_title or (info.get("track") or "").strip()
album = (info.get("album") or "").strip() or _album_from_url(
source_url or info.get("webpage_url")
)
return TagMeta(
artist=artist or "Unknown artist",
title=title or "Unknown title",
album=album or None,
)
def read_tags(path: Path) -> TagMeta | None:
"""Return the file's existing tags, or None when it has no usable ones.
"Usable" means both an artist and a title are present — that is the signal
that bandcamp already tagged the file properly, in which case we leave it
alone rather than second-guessing it with MusicBrainz.
"""
from mutagen import File as MutagenFile
try:
audio = MutagenFile(str(path), easy=True)
except Exception:
return None
if audio is None or not audio.tags:
return None
artist = _first(audio, "artist")
title = _first(audio, "title")
if not (artist and title):
return None
return TagMeta(
artist=artist,
title=title,
album=_first(audio, "album") or None,
mbid=_first(audio, "musicbrainz_trackid") or None,
)
def write_tags(path: Path, meta: TagMeta) -> bool:
"""Write ``meta`` into the file's tags, overwriting existing ones.
Returns True on success. Any failure (unsupported container, unwritable
file) is logged and swallowed — tagging never breaks a download.
"""
from mutagen import File as MutagenFile
try:
audio = MutagenFile(str(path), easy=True)
except Exception as exc: # corrupt/unknown file: leave it alone
log.warning("cannot open %s for tagging: %s", Path(path).name, exc)
return False
if audio is None:
log.info("no mutagen handler for %s; skipping tags", Path(path).name)
return False
try:
if audio.tags is None:
audio.add_tags()
audio["artist"] = meta.artist
audio["title"] = meta.title
if meta.album:
audio["album"] = meta.album
if meta.mbid:
try:
audio["musicbrainz_trackid"] = meta.mbid
except (KeyError, ValueError):
pass # container's easy interface doesn't map this key
audio.save()
return True
except Exception as exc:
log.warning("could not write tags to %s: %s", Path(path).name, exc)
return False
# --- helpers --------------------------------------------------------------
def _first(audio, key: str) -> str:
"""First value of an easy-tag key, stripped; "" when absent."""
values = audio.tags.get(key) or []
return values[0].strip() if values and values[0] else ""
def _split_artist_title(text: str) -> tuple[str | None, str]:
"""Split ``"Artist - Title"`` on the last ``" - "`` (like the shell one-liner).
Returns ``(None, text)`` when there is no separator to split on.
"""
idx = text.rfind(" - ")
if idx == -1:
return None, text.strip()
return text[:idx].strip() or None, text[idx + 3 :].strip()
def _album_from_url(url: str | None) -> str | None:
"""Deslugify a bandcamp ``/album/<slug>`` path into a readable album name.
Only a fallback for when the info dict carries no album; ``/track/`` URLs
yield nothing.
"""
if not url:
return None
m = re.search(r"/album/([^/?#]+)", url)
if not m:
return None
return m.group(1).replace("-", " ").strip().title() or None