Files
musicgrabber/audio_probe.py
g33kphr33k a43cda6bee v4.0.3 dev: quarantine, honest upgrades, and four more album links
Track Upgrades got a proper talking-to this round:
- Upgrading a track no longer stamps "data" over your Artist tag and a temp
  folder name over your Album. The scratch staging dir was being read as an
  Artist/Album path, which is certainly one way to rename U2, just not a
  good one. Spotted by serguma88.
- New Quarantine panel: restore a swapped-out file (whatever replaced it
  goes to quarantine in turn, so the undo is itself undoable) or bin it for
  good, instead of rummaging through a hidden folder by hand.
- An upgrade that lands better but still below your target now stays on the
  list and says so, rather than vanishing and sheepishly reappearing later
  looking like it never happened.
- "Upgrade All" reports progress as it goes and refreshes the list itself,
  instead of a toast politely asking you to keep hitting reload.
- Swap-in now pokes Jellyfin and Plex too, not just Navidrome.

Album URLs from YouTube Music (the OLAK5uy_ ones), Beatport /release/,
Tidal and Monochrome now reach the proper Albums pipeline instead of being
flattened into a track list or refused outright. All four verified against
the real services. Amazon resisted arrest: its album page is an empty app
shell and the existing Playwright scraper finds precisely nothing there, so
it stays on the backlog rather than shipping half-built.

Also in the box: Plex library scans after downloads, bulk imports that
manage two tracks at once, Soulseek checking the library before bothering a
peer for bytes it already has, emptying your own Trash without needing an
admin (it is your bin), and a test fixture that had been reading the wrong
config flag and quietly skipping every multi-user path it was meant to test.

Built with a human and a grumpy-AI combo.
2026-08-20 15:23:49 +01:00

170 lines
4.9 KiB
Python

"""Read-only audio inspection shared by upgrades and provenance auditing.
This module only opens files through Mutagen. It never writes tags or calls
``save()``, which is an important (if rather unglamorous) part of the audit's
contract.
"""
import re
from pathlib import Path
import mutagen
AUDIO_EXTENSIONS = {
".aac",
".aif",
".aiff",
".ape",
".flac",
".m4a",
".mp3",
".mp4",
".oga",
".ogg",
".opus",
".wav",
".wave",
".wma",
".wv",
}
def _digits_to_int(value: str) -> int:
numbers = [int(number) for number in re.findall(r"\d+", value or "")]
return max(numbers) if numbers else 0
def _decode_tag_value(value):
if value is None:
return None
if isinstance(value, list):
if not value:
return None
value = value[0]
if isinstance(value, (bytes, bytearray)):
return value.decode("utf-8", "ignore")
return str(value)
def _read_tag(tags, vorbis_key: str, mp4_key: str, id3: str | None = None):
"""Read one tag across Vorbis, ID3/TXXX, and MP4 freeform layouts."""
for key in (vorbis_key, vorbis_key.lower(), mp4_key):
try:
value = tags.get(key)
except Exception:
value = None
if value:
return _decode_tag_value(value)
try:
if id3:
frame = tags.get(id3)
if frame is not None:
return _decode_tag_value(getattr(frame, "text", None))
frames = tags.getall(f"TXXX:{vorbis_key}")
if frames:
return _decode_tag_value(getattr(frames[0], "text", None))
except Exception:
pass
return None
def _container_for_path(path: Path) -> str:
suffix = path.suffix.lower().lstrip(".")
return {
"m4a": "mp4",
"oga": "ogg",
"wave": "wav",
"aif": "aiff",
"wv": "wavpack",
}.get(suffix, suffix or "unknown")
def probe_file(path: Path) -> dict | None:
"""Inspect an audio file without modifying it.
``None`` means Mutagen could not recognise/read the file. A successful
result deliberately reports observed properties and recorded tags only; it
makes no claim that a lossless-looking container contains lossless-origin
audio.
"""
try:
audio = mutagen.File(str(path))
except Exception:
return None
if audio is None:
return None
info = getattr(audio, "info", None)
tags = audio.tags
cls = type(audio).__name__.lower()
codec = None
if "flac" in cls:
codec = "flac"
elif "mp3" in cls or "easymp3" in cls:
codec = "mp3"
elif "opus" in cls:
codec = "opus"
elif "vorbis" in cls or "oggvorbis" in cls:
codec = "vorbis"
elif "wave" in cls or cls == "wav":
codec = "wav"
elif "aiff" in cls:
codec = "aiff"
elif "monkey" in cls:
codec = "ape"
elif "wavpack" in cls:
codec = "wavpack"
elif "mp4" in cls or "m4a" in cls:
mp4_codec = (getattr(info, "codec", "") or "").lower()
codec = "alac" if "alac" in mp4_codec else "aac"
else:
codec = cls or None
source = source_quality = title = artist = album = album_artist = file_id = source_codec = source_bitrate = None
if tags is not None:
source = _read_tag(tags, "SOURCE", "----:com.musicgrabber:SOURCE")
source_quality = _read_tag(
tags, "SOURCE_QUALITY", "----:com.musicgrabber:SOURCE_QUALITY"
)
title = _read_tag(tags, "TITLE", "\xa9nam", id3="TIT2")
artist = _read_tag(tags, "ARTIST", "\xa9ART", id3="TPE1")
album = _read_tag(tags, "ALBUM", "\xa9alb", id3="TALB")
album_artist = _read_tag(tags, "ALBUMARTIST", "aART", id3="TPE2")
file_id = _read_tag(
tags, "MUSICGRABBER_FILE_ID", "----:com.musicgrabber:FILE_ID"
)
source_codec = _read_tag(
tags, "SOURCE_CODEC", "----:com.musicgrabber:SOURCE_CODEC"
)
source_bitrate = _read_tag(
tags, "SOURCE_BITRATE", "----:com.musicgrabber:SOURCE_BITRATE"
)
return {
"container": _container_for_path(path),
"codec": codec,
"bitrate_kbps": int((getattr(info, "bitrate", 0) or 0) / 1000),
"duration": float(getattr(info, "length", 0) or 0),
"sample_rate_hz": int(getattr(info, "sample_rate", 0) or 0),
"bits_per_sample": int(
(
getattr(info, "bits_per_sample", 0)
or getattr(info, "bit_depth", 0)
or 0
)
),
"channels": int(getattr(info, "channels", 0) or 0),
"source": source,
"source_quality": source_quality,
"artist": artist,
"title": title,
"album": album,
"album_artist": album_artist,
"file_id": file_id,
"source_codec": source_codec,
"source_bitrate_kbps": _digits_to_int(source_bitrate or ""),
}