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