"""Shared low-level helpers for metadata enrichment.""" from __future__ import annotations import os import shutil import threading import weakref from types import SimpleNamespace from typing import Any from utils.logging_config import get_logger as _create_logger logger = _create_logger("metadata.common") __all__ = [ "get_logger", "get_config_manager", "get_mutagen_symbols", "get_file_lock", "is_ogg_opus", "is_vorbis_like", "save_audio_file", "get_image_dimensions", "strip_all_non_audio_tags", "verify_metadata_written", "wipe_source_tags", ] _FILE_LOCKS: "weakref.WeakValueDictionary[str, threading.Lock]" = weakref.WeakValueDictionary() _FILE_LOCKS_LOCK = threading.Lock() class _NullConfigManager: def get(self, _key: str, default: Any = None) -> Any: return default def get_logger(): return logger def get_config_manager(): try: from config.settings import config_manager as settings_config_manager return settings_config_manager except Exception: return _NullConfigManager() def get_mutagen_symbols(): """Lazy mutagen import so tests can monkeypatch this without the package installed.""" try: from mutagen import File as MutagenFile from mutagen.apev2 import APEv2, APENoHeaderError from mutagen.flac import FLAC, Picture from mutagen.id3 import ( APIC, ID3, TBPM, TCOP, TDOR, TDRC, TCON, TIT2, TALB, TPE1, TPE2, TPOS, TPUB, TRCK, TSRC, TXXX, UFID, TMED, ) from mutagen.mp4 import MP4, MP4Cover, MP4FreeForm from mutagen.oggvorbis import OggVorbis try: from mutagen.oggopus import OggOpus except Exception: OggOpus = None except Exception as exc: logger.debug("Mutagen unavailable for metadata enrichment: %s", exc) return None return SimpleNamespace( File=MutagenFile, APEv2=APEv2, APENoHeaderError=APENoHeaderError, FLAC=FLAC, Picture=Picture, ID3=ID3, APIC=APIC, TBPM=TBPM, TCOP=TCOP, TDOR=TDOR, TDRC=TDRC, TCON=TCON, TIT2=TIT2, TALB=TALB, TPE1=TPE1, TPE2=TPE2, TPOS=TPOS, TPUB=TPUB, TRCK=TRCK, TSRC=TSRC, TXXX=TXXX, UFID=UFID, TMED=TMED, MP4=MP4, MP4Cover=MP4Cover, MP4FreeForm=MP4FreeForm, OggVorbis=OggVorbis, OggOpus=OggOpus, ) def get_file_lock(file_path: str) -> threading.Lock: # Keep a per-path lock while it is actively referenced, but let it # fall out of the cache once nobody is using it anymore. with _FILE_LOCKS_LOCK: lock = _FILE_LOCKS.get(file_path) if lock is None: lock = threading.Lock() _FILE_LOCKS[file_path] = lock return lock def is_ogg_opus(audio_file: Any) -> bool: return type(audio_file).__name__ == "OggOpus" def is_vorbis_like(audio_file: Any, symbols: Any) -> bool: vorbis_classes = tuple( cls for cls in ( getattr(symbols, "FLAC", None), getattr(symbols, "OggVorbis", None), ) if cls is not None ) return bool(vorbis_classes) and isinstance(audio_file, vorbis_classes) or is_ogg_opus(audio_file) def _raw_audio_save(audio_file: Any, symbols: Any, target: Any = None) -> None: """The plain mutagen save with the format-specific kwargs. ``target`` None → save in place (the exact call used before #819, byte-for-byte unchanged); a path → save into that file (the atomic temp copy).""" if isinstance(audio_file.tags, symbols.ID3): audio_file.save(v1=0, v2_version=4) if target is None else audio_file.save(target, v1=0, v2_version=4) elif isinstance(audio_file, symbols.FLAC): audio_file.save(deleteid3=True) if target is None else audio_file.save(target, deleteid3=True) else: audio_file.save() if target is None else audio_file.save(target) def save_audio_file(audio_file: Any, symbols: Any) -> None: """Persist mutagen tag changes ATOMICALLY where possible (#819). mutagen's in-place ``save()`` rewrites the file; if the process is interrupted or OOM-killed mid-write, the file is left truncated — audio AND tags gone (CubeComming's large hi-res FLACs imported to an empty shell). Instead: copy the original to a temp in the same directory, write the modified tags into that copy, verify it's still a valid audio file, then ``os.replace`` it in atomically. The original is never touched until that final swap, so a crash can only ever orphan the temp — never destroy the user's file. Falls back to the plain in-place save if the atomic path can't run (no filename, copy fails, or a format mutagen can't save-to-path) so the file is never left worse off than it is today. """ path = getattr(audio_file, "filename", None) try: path = os.fspath(path) if path else None except TypeError: path = None if not path or not os.path.isfile(path): _raw_audio_save(audio_file, symbols) return tmp = f"{path}.sstmp" try: shutil.copy2(path, tmp) # snapshot original (audio + tags) _raw_audio_save(audio_file, symbols, target=tmp) # write new tags into the copy check = symbols.File(tmp) # verify it's still real audio if check is None or getattr(getattr(check, "info", None), "length", 0) <= 0: raise ValueError("atomic save produced a file with no audio") os.replace(tmp, path) # atomic swap — original safe until here except Exception as atomic_err: # Original untouched (only tmp was written). Clean up + fall back to the # original in-place save so any format/edge the atomic path can't handle # behaves exactly as before. try: if os.path.exists(tmp): os.remove(tmp) except OSError: pass logger.warning("[Atomic Save] atomic path failed (%s) — in-place fallback for %s", atomic_err, os.path.basename(path)) _raw_audio_save(audio_file, symbols) def get_image_dimensions(data: bytes): try: if data[:8] == b"\x89PNG\r\n\x1a\n": import struct w, h = struct.unpack(">II", data[16:24]) return w, h if data[:2] == b"\xff\xd8": import struct i = 2 while i < len(data) - 9: if data[i] != 0xFF: break marker = data[i + 1] if marker in (0xC0, 0xC2): h, w = struct.unpack(">HH", data[i + 5 : i + 9]) return w, h length = struct.unpack(">H", data[i + 2 : i + 4])[0] i += 2 + length except Exception as e: logger.debug("parse JPEG dimensions failed: %s", e) return None, None def strip_all_non_audio_tags(file_path: str) -> dict: summary = {"apev2_stripped": False, "apev2_tag_count": 0} if os.path.splitext(file_path)[1].lower() != ".mp3": return summary symbols = get_mutagen_symbols() if not symbols: return summary try: apev2_tags = symbols.APEv2(file_path) tag_count = len(apev2_tags) tag_keys = list(apev2_tags.keys()) apev2_tags.delete(file_path) summary["apev2_stripped"] = True summary["apev2_tag_count"] = tag_count logger.info("Stripped %s APEv2 tags: %s", tag_count, ", ".join(tag_keys[:10])) except symbols.APENoHeaderError: pass except Exception as exc: logger.error("Could not strip APEv2 tags (non-fatal): %s", exc) return summary def verify_metadata_written(file_path: str) -> bool: symbols = get_mutagen_symbols() if not symbols: return False try: check = symbols.File(file_path) if check is None or check.tags is None: logger.info("[VERIFY] Tags are None after save: %s", file_path) return False title_found = False artist_found = False if isinstance(check.tags, symbols.ID3): title_found = bool(check.tags.getall("TIT2")) artist_found = bool(check.tags.getall("TPE1")) try: symbols.APEv2(file_path) logger.info("[VERIFY] APEv2 tags still present after processing!") return False except symbols.APENoHeaderError: pass elif is_vorbis_like(check, symbols): title_found = bool(check.get("title")) artist_found = bool(check.get("artist")) elif isinstance(check, symbols.MP4): title_found = bool(check.get("\xa9nam")) artist_found = bool(check.get("\xa9ART")) if not title_found or not artist_found: logger.warning("[VERIFY] Missing metadata - title:%s artist:%s", title_found, artist_found) return False logger.info("[VERIFY] Metadata verified OK") return True except Exception as exc: logger.error("[VERIFY] Verification error (non-fatal): %s", exc) return False def wipe_source_tags(file_path: str) -> bool: try: strip_all_non_audio_tags(file_path) symbols = get_mutagen_symbols() if not symbols: return False audio = symbols.File(file_path) if audio is None: return False if hasattr(audio, "clear_pictures"): audio.clear_pictures() if audio.tags is not None: tag_count = len(audio.tags) audio.tags.clear() else: audio.add_tags() tag_count = 0 save_audio_file(audio, symbols) if tag_count > 0: logger.info("[Tag Wipe] Stripped %s source tags from: %s", tag_count, os.path.basename(file_path)) return True except Exception as exc: logger.error("[Tag Wipe] Failed (non-fatal): %s", exc) return False