"""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"