mirror of
https://git.srhtnin.garden/sarah/SaraTune.git
synced 2026-10-13 18:18:11 +02:00
260 lines
9.6 KiB
Python
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"
|