Source code for tidalpy.tags

"""Audio metadata writing (best-effort, never fatal).

Tagging uses :mod:`mutagen` and is *best-effort by contract*: a failed
tag write logs and returns ``False`` but never fails the download —
the audio bytes are the product, the tags are the garnish. The opened
mutagen file object is what gets mutated, which keeps the mapping
unit-testable with fakes.

Supported containers: MP3 (ID3v2), AAC/MP4 (``.m4a``), FLAC.
ReplayGain from Tidal's OpenAPI loudness data is written for all
three (FLAC ``REPLAYGAIN_*`` tags, MP3 ``RVA2`` frames, MP4 Apple
freeform atoms).
"""

from __future__ import annotations

import logging
from pathlib import Path
from typing import Any

from .models import Album, Track

logger = logging.getLogger("tidalpy.tags")


def _year(album: Album | None) -> str:
    if album is None or not album.release_date:
        return ""
    return str(album.release_date)[:4]


def _artist_names(artists: Any) -> str:
    """Every artist name joined with ``", "`` (primary first).

    Collaborations carry several artists; a music library should see
    all of them (reference-fork parity), while file names keep the
    primary-artist convention for stability.
    """

    return ", ".join(a.name for a in (artists or []) if a.name)


def _album_artist(album: Album | None, track: Track) -> str:
    if album is not None:
        names = _artist_names(album.artists)
        if names:
            return names
    return _artist_names(track.artists) or track.artist_name


def _cover_frame(
    audio: Any,
    cover_bytes: bytes | None,
) -> None:
    """Attach cover art in the container-appropriate form."""

    if not cover_bytes:
        return
    kind = _tag_kind(audio)
    try:
        if kind == "id3":
            from mutagen.id3 import APIC

            picture = APIC(
                encoding=3,
                mime="image/jpeg",
                type=3,
                desc="",
                data=cover_bytes,
            )
            audio.add_picture(picture)
        elif kind == "mp4":
            from mutagen.mp4 import MP4Cover

            # mutagen >= 1.47: the keyword is ``imageformat`` (older
            # releases used ``format``; both spellings break the other).
            audio["covr"] = [MP4Cover(cover_bytes, imageformat=MP4Cover.FORMAT_JPEG)]
        elif kind == "flac":
            from mutagen.flac import Picture

            # Modern mutagen (>=1.47) API: attribute-based constructor.
            flac_picture = Picture()
            flac_picture.type = 3
            flac_picture.mime = "image/jpeg"
            flac_picture.desc = ""
            flac_picture.data = cover_bytes
            audio.add_picture(flac_picture)
    except Exception:  # noqa: BLE001 - best-effort
        logger.debug("failed to attach cover art", exc_info=True)


def _tag_kind(audio: Any) -> str:
    name = type(audio).__name__.lower()
    if "easyid3" in name or "id3" in name:
        return "id3"
    if "mp4" in name:
        return "mp4"
    if "flac" in name or "vorbis" in name:
        return "flac"
    return "other"


[docs] def apply_tags( audio: Any, track: Track, album: Album | None, cover_bytes: bytes | None = None, replaygain: dict[str, str] | None = None, composer: list[str] | None = None, lyrics: str | None = None, ) -> bool: """Write standard tags (plus cover/ReplayGain) into an open mutagen file. The tag set mirrors the reference client: title (with version), artist/album artist, album, date, track/disc numbers, composer credit, ISRC, copyright, ReplayGain (all containers), and optionally embedded lyrics. MP4 has no standard ISRC atom, so ISRC is written for ID3/FLAC only. Returns ``False`` when the container type is unknown. """ kind = _tag_kind(audio) title = track.title or "" if track.version: title = f"{title} ({track.version})" # Every artist, not just the primary: a collaboration's featured # artists belong in the library's artist field (reference-fork # parity). File names keep the primary-artist convention. artist = _artist_names(track.artists) or track.artist_name album_artist = _album_artist(album, track) album_title = album.title if album is not None else "" year = _year(album) composer_text = ", ".join(composer) if composer else "" try: if kind == "id3": if title: audio["TIT2"] = title if artist: audio["TPE1"] = artist if album_artist: audio["TPE2"] = album_artist if album_title: audio["TALB"] = album_title if track.track_number: audio["TRCK"] = str(track.track_number) if track.volume_number: audio["TPOS"] = str(track.volume_number) if year: audio["TDRC"] = year if composer_text: audio["TCOM"] = composer_text if track.isrc: audio["TSRC"] = track.isrc if track.copyright_text: audio["TCOP"] = track.copyright_text if lyrics: from mutagen.id3 import USLT audio["USLT"] = USLT(encoding=3, lang="eng", desc="", text=lyrics) _id3_tempo_key(audio, track) _id3_replaygain(audio, replaygain) elif kind == "mp4": if title: audio["\xa9nam"] = title if artist: audio["\xa9ART"] = artist if album_artist: audio["aART"] = album_artist if album_title: audio["\xa9alb"] = album_title if track.track_number: total = album.number_of_tracks if album is not None else 0 audio["trkn"] = [(int(track.track_number), int(total))] if track.volume_number: total_disks = album.number_of_volumes if album is not None else 0 audio["disk"] = [(int(track.volume_number), int(total_disks))] if year: audio["\xa9day"] = year if composer_text: audio["\xa9wrt"] = composer_text if track.copyright_text: audio["cprt"] = track.copyright_text if lyrics: audio["\xa9lyr"] = lyrics _mp4_tempo_key(audio, track) _mp4_replaygain(audio, replaygain) elif kind == "flac": if title: audio["TITLE"] = title if artist: audio["ARTIST"] = artist if album_artist: audio["ALBUMARTIST"] = album_artist if album_title: audio["ALBUM"] = album_title if track.track_number: audio["TRACKNUMBER"] = str(track.track_number) # Totals (reference-client parity): players show # "3/12" from TRACKTOTAL, not just TRACKNUMBER. if album is not None and album.number_of_tracks: audio["TRACKTOTAL"] = str(album.number_of_tracks) if track.volume_number: audio["DISCNUMBER"] = str(track.volume_number) if album is not None and album.number_of_volumes: audio["DISCTOTAL"] = str(album.number_of_volumes) if year: audio["DATE"] = year if composer_text: audio["COMPOSER"] = composer_text if track.isrc: audio["ISRC"] = track.isrc if track.copyright_text: audio["COPYRIGHT"] = track.copyright_text if lyrics: audio["LYRICS"] = lyrics _flac_tempo_key(audio, track) for key, value in (replaygain or {}).items(): tag = _flac_rg_tag(key) if tag: audio[tag] = value else: return False _cover_frame(audio, cover_bytes) return True except Exception: # noqa: BLE001 - best-effort logger.debug("failed to write tags", exc_info=True) return False
def _flac_rg_tag(key: str) -> str | None: mapping = { "trackReplayGain": "REPLAYGAIN_TRACK_GAIN", "trackPeakAmplitude": "REPLAYGAIN_TRACK_PEAK", "albumReplayGain": "REPLAYGAIN_ALBUM_GAIN", "albumPeakAmplitude": "REPLAYGAIN_ALBUM_PEAK", } return mapping.get(key) def _rg_gain(raw: str | None) -> float | None: """``"+1.23 dB"`` -> ``1.23`` (``None`` when absent/unparseable).""" if raw is None: return None try: return float(str(raw).removesuffix(" dB").strip()) except ValueError: return None def _id3_replaygain(audio: Any, replaygain: dict[str, str] | None) -> None: """RVA2 frames, as the reference client writes them for MP3.""" if not replaygain: return from mutagen.id3 import RVA2 for desc, gain_key, peak_key in ( ("REPLAYGAIN_TRACK", "trackReplayGain", "trackPeakAmplitude"), ("REPLAYGAIN_ALBUM", "albumReplayGain", "albumPeakAmplitude"), ): gain = _rg_gain(replaygain.get(gain_key)) if gain is None: continue peak = _rg_gain(replaygain.get(peak_key)) if peak is None: peak = 1.0 audio.add(RVA2(encoding=0, desc=desc, gain=gain, peak=peak)) def _mp4_replaygain(audio: Any, replaygain: dict[str, str] | None) -> None: """Apple freeform ReplayGain atoms, as the reference client writes them for MP4 (the gain strings arrive pre-formatted ``+X.XX dB``).""" if not replaygain: return atoms = ( ("----:com.apple.replaygain_track_gain", "trackReplayGain"), ("----:com.apple.replaygain_track_peak", "trackPeakAmplitude"), ("----:com.apple.replaygain_album_gain", "albumReplayGain"), ("----:com.apple.replaygain_album_peak", "albumPeakAmplitude"), ) for atom, key in atoms: raw = replaygain.get(key) if raw is None: continue if "peak" in atom: try: audio[atom] = float(raw) except ValueError: continue else: audio[atom] = raw def _bpm_label(bpm: int | float | None) -> str | None: """The BPM as players expect it: whole when integral (``"185"``), otherwise as reported (``"185.5"``).""" if bpm is None: return None return str(int(bpm)) if float(bpm).is_integer() else str(bpm) def _key_label(track: Track) -> str | None: """The musical key as a displayable tag value (``"F# MAJOR"``). Tidal spells the pitch class in words (``FSharp`` / ``BFlat``); the conventional notation is ``#`` / ``b``. The scale word (``MAJOR`` / ``MINOR``) is kept verbatim — DJ software (rekordbox, foobar2000, iTunes) accepts the word form, and inventing Camelot or a shorter symbol would lose information the catalog carried. """ note = track.key if not note: return None note = note.replace("Sharp", "#").replace("Flat", "b") if track.key_scale: return f"{note} {track.key_scale}" return note def _id3_tempo_key(audio: Any, track: Track) -> None: """TBPM (isocel BPM) and TKEY (key of song) ID3 frames.""" bpm = _bpm_label(track.bpm) if bpm is not None: audio["TBPM"] = bpm key = _key_label(track) if key is not None: audio["TKEY"] = key def _mp4_tempo_key(audio: Any, track: Track) -> None: """The standard ``tmpo`` integer atom; the key via the Apple freeform (there is no standard key atom).""" if track.bpm is not None: audio["tmpo"] = int(track.bpm) if float(track.bpm).is_integer() else track.bpm key = _key_label(track) if key is not None: audio["----:com.apple.iTunes:KEY"] = key def _flac_tempo_key(audio: Any, track: Track) -> None: """Vorbis ``BPM`` / ``KEY`` comments.""" bpm = _bpm_label(track.bpm) if bpm is not None: audio["BPM"] = bpm key = _key_label(track) if key is not None: audio["KEY"] = key
[docs] def write_tags( path: str | Path, track: Track, album: Album | None, cover_bytes: bytes | None = None, replaygain: dict[str, str] | None = None, composer: list[str] | None = None, lyrics: str | None = None, ) -> bool: """Open ``path``, write tags, save. Never raises.""" try: import mutagen audio = mutagen.File(str(path)) except Exception: # noqa: BLE001 - best-effort logger.debug("could not open %s for tagging", path, exc_info=True) return False if audio is None: logger.debug("no tag support for container %s", path) return False if not apply_tags(audio, track, album, cover_bytes, replaygain, composer, lyrics): return False try: audio.save() return True except Exception: # noqa: BLE001 - best-effort logger.debug("failed to save tags for %s", path, exc_info=True) return False