Files
SaraTune/Metadata.py
T
2026-10-09 15:33:43 +02:00

260 lines
9.6 KiB
Python

"""Reading audio files with mutagen: the tags Music mode sorts by, the full
details Audio mode shows, and cover art (embedded, or a cover image next to
the file). Never writes to the files."""
import base64
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Optional
import mutagen
from mutagen.flac import Picture
# Images next to a file that count as its cover, best first.
CoverNames = ["cover", "folder", "front", "album", "albumart", "albumartsmall"]
CoverExtensions = [".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp"]
@dataclass
class Track:
Path: str
Title: str = ""
Artist: str = ""
Album: str = ""
AlbumArtist: str = ""
Genre: str = ""
Year: str = ""
TrackNumber: int = 0
DiscNumber: int = 0
Duration: int = 0 # milliseconds
Modified: float = 0 # the file's mtime when it was read, so a rescan can skip it
@property
def DisplayTitle(self) -> str:
return self.Title or Path(self.Path).stem
@property
def DisplayArtist(self) -> str:
return self.Artist or self.AlbumArtist or "Unknown artist"
@property
def DisplayAlbum(self) -> str:
return self.Album or "Unknown album"
@property
def AlbumKey(self) -> tuple[str, str]:
"""Albums are told apart by album artist too, so two "Greatest Hits" stay two."""
return (self.AlbumArtist or self.Artist or "").lower(), (self.Album or "").lower()
def ToDict(self) -> dict:
return {Key: Value for Key, Value in asdict(self).items() if Value not in ("", 0) or Key == "Path"}
@classmethod
def FromDict(cls, Data: dict) -> "Track":
Known = cls.__dataclass_fields__
return cls(**{Key: Value for Key, Value in Data.items() if Key in Known})
def _First(Tags, *Keys: str) -> str:
for Key in Keys:
try:
Value = Tags.get(Key)
except (KeyError, ValueError):
continue
if Value:
if isinstance(Value, list):
Value = Value[0]
return str(Value).strip()
return ""
def _Number(Text: str) -> int:
""""3/12" -> 3."""
try:
return int(Text.split("/")[0].strip() or 0)
except ValueError:
return 0
def _Open(FilePath: str, Easy: bool):
try:
return mutagen.File(FilePath, easy=Easy)
except Exception: # mutagen raises many kinds of errors for broken files
return None
def ReadTrack(FilePath: str) -> Track:
"""The tags Music mode uses. A file mutagen can't read still becomes a
Track (titled after its file name), so it can be played."""
Result = Track(Path=FilePath)
try:
Result.Modified = Path(FilePath).stat().st_mtime
except OSError:
pass
File = _Open(FilePath, Easy=True)
if File is None:
return Result
if File.info is not None and getattr(File.info, "length", None):
Result.Duration = int(File.info.length * 1000)
Tags = File.tags or {}
Result.Title = _First(Tags, "title")
Result.Artist = _First(Tags, "artist")
Result.Album = _First(Tags, "album")
Result.AlbumArtist = _First(Tags, "albumartist", "album artist", "performer")
Result.Genre = _First(Tags, "genre")
Result.Year = _First(Tags, "date", "year", "originaldate")[:4]
Result.TrackNumber = _Number(_First(Tags, "tracknumber", "track"))
Result.DiscNumber = _Number(_First(Tags, "discnumber", "disc"))
return Result
def ReadDetails(FilePath: str) -> tuple[list[tuple[str, str]], list[tuple[str, str]]]:
"""(technical info, every tag) as label/value rows for Audio mode."""
PathValue = Path(FilePath)
Info: list[tuple[str, str]] = []
try:
Size = PathValue.stat().st_size
Info.append(("Size", _FormatSize(Size)))
except OSError:
pass
Info.append(("Format", PathValue.suffix.lstrip(".").upper() or "?"))
Tags: list[tuple[str, str]] = []
File = _Open(FilePath, Easy=False)
if File is None:
Info.append(("Note", "mutagen couldn't read this file's details"))
return Info, Tags
Stream = File.info
Codec = getattr(Stream, "codec_description", "") or getattr(Stream, "codec", "")
if Codec:
Info.append(("Codec", str(Codec)))
if getattr(Stream, "length", None):
from Core import FormatTime
Info.append(("Length", FormatTime(Stream.length * 1000)))
if getattr(Stream, "bitrate", None):
Mode = getattr(Stream, "bitrate_mode", None)
ModeText = {1: " CBR", 2: " VBR", 3: " ABR"}.get(int(Mode), "") if Mode is not None else ""
Info.append(("Bitrate", f"{round(Stream.bitrate / 1000)} kbps{ModeText}"))
if getattr(Stream, "sample_rate", None):
Info.append(("Sample rate", f"{Stream.sample_rate / 1000:g} kHz"))
if getattr(Stream, "bits_per_sample", None):
Info.append(("Bit depth", f"{Stream.bits_per_sample} bit"))
if getattr(Stream, "channels", None):
Info.append(("Channels", {1: "Mono", 2: "Stereo"}.get(Stream.channels, str(Stream.channels))))
for Key, Value in _AllTags(File):
Tags.append((Key, Value))
return Info, Tags
# Readable names for ID3 frames and MP4 atoms; Vorbis comments (FLAC, Ogg,
# Opus) already have names, and only get title-cased.
TagNames = {
"TIT2": "Title", "TPE1": "Artist", "TPE2": "Album artist", "TALB": "Album", "TRCK": "Track", "TPOS": "Disc",
"TDRC": "Date", "TYER": "Year", "TCON": "Genre", "TCOM": "Composer", "TBPM": "BPM", "TSSE": "Encoder",
"TENC": "Encoded by", "TCOP": "Copyright", "TPUB": "Publisher", "TLAN": "Language", "COMM": "Comment",
"USLT": "Lyrics", "TIT1": "Grouping", "TIT3": "Subtitle", "TKEY": "Key", "TMOO": "Mood", "TSRC": "ISRC",
"TDOR": "Original date", "TOPE": "Original artist", "TEXT": "Lyricist", "TLEN": "Length (ms)",
"\xa9nam": "Title", "\xa9ART": "Artist", "aART": "Album artist", "\xa9alb": "Album", "trkn": "Track",
"disk": "Disc", "\xa9day": "Date", "\xa9gen": "Genre", "\xa9wrt": "Composer", "\xa9too": "Encoder",
"\xa9cmt": "Comment", "\xa9lyr": "Lyrics", "cprt": "Copyright", "tmpo": "BPM", "\xa9grp": "Grouping",
"tracknumber": "Track", "discnumber": "Disc", "albumartist": "Album artist", "tracktotal": "Total tracks",
"totaltracks": "Total tracks", "disctotal": "Total discs", "totaldiscs": "Total discs",
}
def _TagName(Key: str) -> str:
if Key.startswith("TXXX:"): # ID3 user text frames read "TXXX:Name"
return Key[5:]
if Key.startswith("----:"): # MP4 freeform atoms read "----:com.apple.iTunes:Name"
return Key.rsplit(":", 1)[-1]
Base = Key.split(":")[0] # COMM frames read "COMM::eng"
if Base in TagNames:
return TagNames[Base]
return Base.replace("_", " ").capitalize() if Base.islower() else Base
def _AllTags(File) -> list[tuple[str, str]]:
"""Readable key/value pairs from any tag format, without the binary ones."""
Rows: list[tuple[str, str]] = []
if not File.tags:
return Rows
try:
Items = list(File.tags.items())
except (AttributeError, TypeError):
return Rows
for Key, Value in Items:
KeyText = str(Key)
if KeyText.startswith(("APIC", "covr", "metadata_block_picture", "PRIV", "GEOB", "MCDI")):
continue
if hasattr(Value, "text"): # ID3 frames
Value = Value.text
if isinstance(Value, list):
# MP4 track/disc numbers are (number, total) pairs.
Value = ", ".join("/".join(str(N) for N in Part if N) if isinstance(Part, tuple) else
Part.decode("utf-8", "replace") if isinstance(Part, bytes) else str(Part) for Part in Value)
Text = str(Value)
if not Text or len(Text) > 400 or "\x00" in Text:
continue
Rows.append((_TagName(KeyText), Text))
return Rows
def ReadCover(FilePath: str) -> Optional[bytes]:
"""The embedded front cover (or the first picture), else an image file
named like cover.jpg/folder.png in the same folder."""
File = _Open(FilePath, Easy=False)
Embedded = _EmbeddedCover(File) if File is not None else None
if Embedded:
return Embedded
return FolderCover(Path(FilePath).parent)
def _EmbeddedCover(File) -> Optional[bytes]:
try:
Pictures = getattr(File, "pictures", None) # FLAC
if Pictures:
Front = [P for P in Pictures if P.type == 3]
return (Front or Pictures)[0].data
Tags = File.tags
if Tags is None:
return None
if hasattr(Tags, "getall"): # ID3
Frames = Tags.getall("APIC")
if Frames:
Front = [F for F in Frames if F.type == 3]
return (Front or Frames)[0].data
if "covr" in Tags: # MP4
return bytes(Tags["covr"][0])
if "metadata_block_picture" in Tags: # Ogg Vorbis/Opus
for Encoded in Tags["metadata_block_picture"]:
return Picture(base64.b64decode(Encoded)).data
except Exception:
return None
return None
def FolderCover(Folder: Path) -> Optional[bytes]:
try:
Files = {Child.name.lower(): Child for Child in Folder.iterdir() if Child.is_file()}
except OSError:
return None
for Name in CoverNames:
for Extension in CoverExtensions:
Found = Files.get(Name + Extension)
if Found:
try:
return Found.read_bytes()
except OSError:
return None
return None
def _FormatSize(Size: int) -> str:
Value = float(Size)
for Unit in ("B", "KB", "MB", "GB"):
if Value < 1024 or Unit == "GB":
return f"{Value:.0f} {Unit}" if Unit == "B" else f"{Value:.1f} {Unit}"
Value /= 1024
return f"{Size} B"