All checks were successful
continuous-integration/drone/push Build is passing
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>
172 lines
6.1 KiB
Python
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
|