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.
103 lines
4.3 KiB
Python
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',
|
|
]
|