soulsync/core/quality/lossless.py
BoulderBadgeDad 4af7600fd5 lossy copy: support all lossless formats, not just FLAC (#941)
radoslav-orlov: "create lossy copies of lossless tracks" only recognized FLAC, even though ALAC/
WAV/AIFF/DSD are now quality-profile formats. the FLAC knowledge was hardcoded in 3 separate
places (the import path, the Lossy Converter scan, and the fix executor) — exactly how a format
gets added in one spot but not another.

kettui-style fix — one canonical seam both sites route through, instead of 3 more string edits:

- new core/quality/lossless.py: is_lossless_format / is_lossless_audio_path (pure; injects a
  codec probe for the ambiguous .m4a/.mp4 — ALAC vs AAC — so the decision stays testable with no
  I/O), LOSSLESS_FORMATS (single source of truth, derived-consistent with model.tier_score), and
  the lossy_output_would_overwrite_source safety invariant.
- create_lossy_copy + the Lossy Converter scan + repair_worker._fix_missing_lossy_copy all route
  through it. SQL pre-filters by candidate extensions, then each file is confirmed (probing .m4a).
- SAFETY: a lossy copy must never be written over its own source — an .m4a ALAC source + AAC
  target lands on the same .m4a path, and ffmpeg runs with -y. all three sites now bail on the
  overwrite case BEFORE ffmpeg (the existing delete-original guard was too late — the source was
  already clobbered). dropped a vestigial mutagen FLAC import; updated FLAC-only UI strings.

19 tests: full seam coverage (formats, the .m4a ALAC/AAC probe branch, candidate extensions, the
overwrite guard), a tier-model consistency test that fails if the lossless set drifts, and import-
site wiring tests — WAV now converts (was rejected), and the .m4a-ALAC+AAC overwrite case proves
ffmpeg NEVER runs. 286 quality/import/repair tests green, ruff clean.
2026-06-28 12:13:05 -07:00

103 lines
4.3 KiB
Python

"""Single source of truth for "is this audio lossless?" + the lossy-copy
overwrite invariant.
The "create a lossy copy of lossless tracks" feature lives in two places (the
import post-processing path and the Lossy Converter repair job). Both used to
hardcode ``.flac``, which is exactly how ALAC/WAV/DSD ended up being quality-
profile options but NOT lossy-copy sources (#941). The knowledge of which
formats are lossless now lives HERE, derived from the same format names the
quality model ranks, so adding a format lights it up in both sites at once and
they can never drift.
Two seams, both pure (no I/O) so they're unit-testable without real files:
* :func:`is_lossless_format` / :func:`is_lossless_audio_path` — eligibility.
``.m4a``/``.mp4`` are ambiguous (ALAC=lossless, AAC=lossy) and can only be told
apart by codec, so the path check delegates them to an *injected* ``probe_codec``
callable — the file I/O stays at the edge, the decision stays pure.
* :func:`lossy_output_would_overwrite_source` — the safety invariant: a lossy copy
must NEVER be written over its own source (which becomes possible once ``.m4a``
is eligible and the target codec is AAC → same ``.m4a`` path).
"""
from __future__ import annotations
import os
from typing import Any, Callable, Optional
from core.quality.source_map import AUDIO_EXTENSIONS, format_from_extension
# Canonical lossless format set. MUST stay in sync with the lossless tiers in
# core.quality.model.tier_score and the frontend RT_LOSSLESS_FORMATS — the
# consistency test in tests/quality/test_lossless.py pins the tier agreement.
LOSSLESS_FORMATS = frozenset({'flac', 'alac', 'wav', 'dsf'})
# Container extensions that may hold EITHER lossless (ALAC) or lossy (AAC) audio —
# only a codec probe can decide, so they're never lossless on extension alone.
_AMBIGUOUS_EXTS = frozenset({'m4a', 'mp4'})
def is_lossless_format(fmt: Any) -> bool:
"""True when a unified format name (as returned by ``format_from_extension``)
is lossless. ``'aac'`` is False here — an ALAC-in-m4a file reports format
``'aac'`` by extension and must be resolved by codec, not by name."""
return str(fmt or '').lower() in LOSSLESS_FORMATS
# Extensions worth checking as possibly-lossless (a caller can pre-filter SQL by
# these, then confirm each via is_lossless_audio_path). Derived from the format
# map so it can't drift: every extension whose format is lossless, plus the
# ambiguous ALAC containers. Leading dot included (e.g. '.flac', '.m4a').
LOSSLESS_CANDIDATE_EXTENSIONS = frozenset(
e for e in AUDIO_EXTENSIONS
if is_lossless_format(format_from_extension(e)) or e.lstrip('.') in _AMBIGUOUS_EXTS
)
def _ext(path: Any) -> str:
return os.path.splitext(str(path or ''))[1].lower().lstrip('.')
def is_lossless_audio_path(
path: Any,
*,
probe_codec: Optional[Callable[[str], Optional[str]]] = None,
) -> bool:
"""True when the file at ``path`` is lossless.
Unambiguous extensions (flac/wav/aiff/dsf/dff/alac) are decided by extension.
``.m4a``/``.mp4`` are decided by ``probe_codec(path)`` (returns the codec, e.g.
``'alac'`` or ``'aac'``) — without a probe they're treated as NOT lossless, so
a missing probe can never misclassify an AAC file as lossless.
"""
ext = _ext(path)
if is_lossless_format(format_from_extension(ext)):
return True
if ext in _AMBIGUOUS_EXTS and probe_codec is not None:
try:
codec = (probe_codec(str(path)) or '').lower()
except Exception:
return False
return 'alac' in codec
return False
def lossy_output_would_overwrite_source(source_path: Any, output_path: Any) -> bool:
"""True when the computed lossy-copy output path is the source file itself.
Safety invariant: the converter must skip (never run ffmpeg with ``-y``) when
this is True, or it would destroy the original. Happens when an ``.m4a`` ALAC
source is converted with the AAC codec (output is also ``.m4a``)."""
if not source_path or not output_path:
return False
a = os.path.normcase(os.path.normpath(str(source_path)))
b = os.path.normcase(os.path.normpath(str(output_path)))
return a == b
__all__ = [
'LOSSLESS_FORMATS',
'is_lossless_format',
'is_lossless_audio_path',
'lossy_output_would_overwrite_source',
]