mirror of
https://git.srhtnin.garden/sarah/SaraTune.git
synced 2026-10-15 02:23:17 +02:00
Initial commit
This commit is contained in:
commit
0eeff23589
25 files changed
+3328
No files matched your search
+259
@@ -0,0 +1,259 @@
|
||||
"""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"
|
||||
Reference in new issue
Block a user