From 4af7600fd5bac18e1717152cce9b4dd2eaa7d0d7 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Sun, 28 Jun 2026 12:13:05 -0700 Subject: [PATCH] lossy copy: support all lossless formats, not just FLAC (#941) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- core/imports/file_ops.py | 31 ++++++- core/quality/lossless.py | 103 ++++++++++++++++++++++ core/repair_jobs/lossy_converter.py | 49 ++++++++--- core/repair_worker.py | 8 ++ tests/imports/test_import_file_ops.py | 58 ++++++++++++ tests/quality/test_lossless.py | 121 ++++++++++++++++++++++++++ 6 files changed, 355 insertions(+), 15 deletions(-) create mode 100644 core/quality/lossless.py create mode 100644 tests/quality/test_lossless.py diff --git a/core/imports/file_ops.py b/core/imports/file_ops.py index 2f9f245a..89903108 100644 --- a/core/imports/file_ops.py +++ b/core/imports/file_ops.py @@ -524,14 +524,29 @@ def downsample_hires_flac(final_path, context): return None +def m4a_codec(path): + """Codec of an .m4a/.mp4 container ('alac', 'aac', …) or None — lets the + lossless check tell ALAC (lossless) from AAC (lossy), since both are .m4a.""" + try: + from mutagen.mp4 import MP4 + return (getattr(MP4(path).info, 'codec', '') or '').lower() or None + except Exception: + return None + + def create_lossy_copy(final_path): - """Convert a FLAC file to a lossy copy using the configured codec.""" - from mutagen.flac import FLAC + """Convert a lossless file (FLAC / ALAC / WAV / AIFF / DSD) to a lossy copy + using the configured codec. Non-lossless inputs are skipped (#941).""" + from core.quality.lossless import ( + is_lossless_audio_path, + lossy_output_would_overwrite_source, + ) if not config_manager.get("lossy_copy.enabled", False): return None - if os.path.splitext(final_path)[1].lower() != ".flac": + # Was FLAC-only; now any lossless source. .m4a is probed (ALAC vs AAC). + if not is_lossless_audio_path(final_path, probe_codec=m4a_codec): return None codec = config_manager.get("lossy_copy.codec", "mp3").lower() @@ -560,6 +575,16 @@ def create_lossy_copy(final_path): out_basename = out_basename.replace(original_quality, quality_label) out_path = os.path.join(os.path.dirname(out_path), out_basename) + # Safety invariant: never write the lossy copy over its own source (an .m4a + # ALAC source + AAC target lands on the same .m4a path). ffmpeg runs with -y, + # so this guard MUST precede it — the later delete-original guard is too late. + if lossy_output_would_overwrite_source(final_path, out_path): + logger.info( + f"[Lossy Copy] Skipping — {codec.upper()} output would overwrite the " + f"source: {os.path.basename(final_path)}" + ) + return None + ffmpeg_bin = shutil.which("ffmpeg") if not ffmpeg_bin: local = os.path.join(os.path.dirname(__file__), "tools", "ffmpeg") diff --git a/core/quality/lossless.py b/core/quality/lossless.py new file mode 100644 index 00000000..df84dcab --- /dev/null +++ b/core/quality/lossless.py @@ -0,0 +1,103 @@ +"""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', +] diff --git a/core/repair_jobs/lossy_converter.py b/core/repair_jobs/lossy_converter.py index da1f1646..7e14adbd 100644 --- a/core/repair_jobs/lossy_converter.py +++ b/core/repair_jobs/lossy_converter.py @@ -1,13 +1,19 @@ -"""Lossy Converter Job — finds FLAC files that don't have a lossy copy. +"""Lossy Converter Job — finds lossless files that don't have a lossy copy. -Scans the library for FLAC files without a corresponding lossy copy alongside +Scans the library for lossless files without a corresponding lossy copy alongside them, and creates a finding for each. The fix action converts the file using ffmpeg with the user's configured codec/bitrate settings. """ import os +from core.imports.file_ops import m4a_codec from core.library.path_resolver import resolve_library_file_path +from core.quality.lossless import ( + LOSSLESS_CANDIDATE_EXTENSIONS, + is_lossless_audio_path, + lossy_output_would_overwrite_source, +) from core.repair_jobs import register_job from core.repair_jobs.base import JobContext, JobResult, RepairJob from utils.logging_config import get_logger @@ -21,6 +27,16 @@ CODEC_MAP = { } +def _lossless_ext_where(col: str) -> str: + """SQL pre-filter matching files whose extension *might* be lossless. The + final decision (including ALAC-in-.m4a, which needs a codec probe) is made + per-file by is_lossless_audio_path. Extensions are trusted constants from the + quality model, never user input — safe to interpolate.""" + return '(' + ' OR '.join( + f"LOWER({col}) LIKE '%{ext}'" for ext in sorted(LOSSLESS_CANDIDATE_EXTENSIONS) + ) + ')' + + def _resolve_file_path(file_path, transfer_folder, download_folder=None, config_manager=None): """Backwards-compat wrapper. Use ``resolve_library_file_path`` directly.""" return resolve_library_file_path( @@ -35,15 +51,15 @@ def _resolve_file_path(file_path, transfer_folder, download_folder=None, config_ class LossyConverterJob(RepairJob): job_id = 'lossy_converter' display_name = 'Lossy Converter' - description = 'Finds FLAC files without a lossy copy' + description = 'Finds lossless files without a lossy copy' help_text = ( - 'Scans your library for FLAC files that don\'t already have a lossy copy ' + 'Scans your library for lossless files (FLAC/ALAC/WAV/AIFF/DSD) that don\'t already have a lossy copy ' '(MP3, Opus, or AAC) alongside them.\n\n' 'Uses the codec setting from your Lossy Copy configuration on the Settings ' 'page. Enable Lossy Copy in Settings first, then run this job to find FLAC ' 'files missing a lossy copy.\n\n' 'Each finding can be fixed individually or in bulk — the fix action converts ' - 'the FLAC file using ffmpeg at your configured bitrate.\n\n' + 'the lossless file using ffmpeg at your configured bitrate.\n\n' 'Requires ffmpeg to be installed.' ) icon = 'repair-icon-lossy' @@ -81,14 +97,14 @@ class LossyConverterJob(RepairJob): try: conn = context.db._get_connection() cursor = conn.cursor() - cursor.execute(""" + cursor.execute(f""" SELECT t.id, t.title, ar.name, al.title, t.file_path, al.thumb_url, ar.thumb_url FROM tracks t LEFT JOIN artists ar ON ar.id = t.artist_id LEFT JOIN albums al ON al.id = t.album_id WHERE t.file_path IS NOT NULL AND t.file_path != '' - AND LOWER(t.file_path) LIKE '%.flac' + AND {_lossless_ext_where('t.file_path')} """) tracks = cursor.fetchall() except Exception as e: @@ -104,7 +120,7 @@ class LossyConverterJob(RepairJob): context.update_progress(0, total) if context.report_progress: context.report_progress( - phase=f'Scanning {total} FLAC files for missing {quality_label} copies...', + phase=f'Scanning {total} lossless files for missing {quality_label} copies...', total=total ) @@ -135,8 +151,17 @@ class LossyConverterJob(RepairJob): if not resolved or not os.path.exists(resolved): continue + # Confirm it's actually lossless — the SQL pre-filter lets .m4a through, + # which is ALAC (lossless) OR AAC (lossy); only a codec probe decides. + if not is_lossless_audio_path(resolved, probe_codec=m4a_codec): + continue + # Check if lossy copy already exists out_path = os.path.splitext(resolved)[0] + out_ext + # Never offer to convert a file onto itself (e.g. .m4a ALAC + AAC target + # lands on the same path) — that conversion would destroy the original. + if lossy_output_would_overwrite_source(resolved, out_path): + continue if os.path.exists(out_path): continue @@ -159,7 +184,7 @@ class LossyConverterJob(RepairJob): file_path=file_path, title=f'No {quality_label} copy: {title or "Unknown"}', description=( - f'FLAC file "{title}" by {artist_name or "Unknown"} does not have ' + f'Lossless file "{title}" by {artist_name or "Unknown"} does not have ' f'a {quality_label} copy alongside it' ), details={ @@ -195,7 +220,7 @@ class LossyConverterJob(RepairJob): context.report_progress( scanned=total, total=total, phase='Complete', - log_line=f'Found {result.findings_created} FLAC files without {quality_label} copies', + log_line=f'Found {result.findings_created} lossless files without {quality_label} copies', log_type='success' if result.findings_created == 0 else 'info' ) @@ -208,10 +233,10 @@ class LossyConverterJob(RepairJob): try: conn = context.db._get_connection() cursor = conn.cursor() - cursor.execute(""" + cursor.execute(f""" SELECT COUNT(*) FROM tracks WHERE file_path IS NOT NULL AND file_path != '' - AND LOWER(file_path) LIKE '%.flac' + AND {_lossless_ext_where('file_path')} """) row = cursor.fetchone() return row[0] if row else 0 diff --git a/core/repair_worker.py b/core/repair_worker.py index 55b682f7..e1ff10c7 100644 --- a/core/repair_worker.py +++ b/core/repair_worker.py @@ -3373,6 +3373,14 @@ class RepairWorker: return {'success': False, 'error': f'Source file not found: {file_path}'} out_path = os.path.splitext(resolved)[0] + out_ext + # Safety invariant: ffmpeg runs with -y, so refuse to convert a file onto + # itself (an .m4a ALAC source + AAC target shares the .m4a path) — that + # would destroy the original lossless file (#941). + from core.quality.lossless import lossy_output_would_overwrite_source + if lossy_output_would_overwrite_source(resolved, out_path): + return {'success': False, + 'error': f'{codec.upper()} output would overwrite the source file; ' + f'choose a different lossy codec'} if os.path.exists(out_path): return {'success': True, 'action': 'already_exists', 'message': f'{quality_label} copy already exists'} diff --git a/tests/imports/test_import_file_ops.py b/tests/imports/test_import_file_ops.py index a23df527..a5734e6c 100644 --- a/tests/imports/test_import_file_ops.py +++ b/tests/imports/test_import_file_ops.py @@ -244,3 +244,61 @@ def test_atomic_helper_cleans_temp_and_keeps_source_on_failure(tmp_path, monkeyp assert src.exists() # source preserved on failure assert not dst.exists() # no partial final file assert not list(dstdir.glob(".*ssync-tmp")) # temp cleaned up + + +# ── #941: create_lossy_copy now accepts all lossless sources + never overwrites them ── + +import core.imports.file_ops as _fo + + +def _enable_lossy(monkeypatch, codec="mp3", bitrate="320"): + cfg = {"lossy_copy.enabled": True, "lossy_copy.codec": codec, + "lossy_copy.bitrate": bitrate, "lossy_copy.delete_original": False} + monkeypatch.setattr(_fo.config_manager, "get", lambda k, d=None: cfg.get(k, d)) + monkeypatch.setattr(_fo, "get_audio_quality_string", lambda _p: None) + + +def test_create_lossy_copy_rejects_non_lossless(monkeypatch, tmp_path): + _enable_lossy(monkeypatch) + src = tmp_path / "song.mp3" + src.write_bytes(b"id3") + assert _fo.create_lossy_copy(str(src)) is None # lossy input → nothing to do + + +def test_create_lossy_copy_now_accepts_wav(monkeypatch, tmp_path): + """Was FLAC-only; a WAV must now pass the gate and convert (#941).""" + _enable_lossy(monkeypatch, codec="mp3") + monkeypatch.setattr(_fo.shutil, "which", lambda _name: "/usr/bin/ffmpeg") + monkeypatch.setattr("mutagen.File", lambda *_a, **_k: None) # skip tag write + + seen = {} + + def _fake_run(cmd, **_kw): + seen["cmd"] = cmd + open(cmd[-1], "wb").write(b"fake-mp3") # ffmpeg "writes" the output + return types.SimpleNamespace(returncode=0, stderr="") + + monkeypatch.setattr(_fo.subprocess, "run", _fake_run) + + src = tmp_path / "song.wav" + src.write_bytes(b"RIFF....WAVE") + out = _fo.create_lossy_copy(str(src)) + assert out and out.endswith(".mp3") # WAV passed the gate + converted + assert str(src) in seen["cmd"] # ffmpeg got the .wav input + + +def test_create_lossy_copy_skips_when_output_would_overwrite_source(monkeypatch, tmp_path): + """REGRESSION: .m4a ALAC source + AAC codec → output is the same .m4a path. + ffmpeg (-y) must NEVER run, or it would destroy the original lossless file.""" + _enable_lossy(monkeypatch, codec="aac", bitrate="256") + monkeypatch.setattr(_fo, "m4a_codec", lambda _p: "alac") # source IS ALAC (lossless) + + ran = {"called": False} + monkeypatch.setattr(_fo.subprocess, "run", + lambda *_a, **_k: ran.__setitem__("called", True)) + + src = tmp_path / "track.m4a" + src.write_bytes(b"....ALAC....") + out = _fo.create_lossy_copy(str(src)) + assert out is None # skipped — output would overwrite source + assert ran["called"] is False # the original was never touched diff --git a/tests/quality/test_lossless.py b/tests/quality/test_lossless.py new file mode 100644 index 00000000..acb274c9 --- /dev/null +++ b/tests/quality/test_lossless.py @@ -0,0 +1,121 @@ +"""The canonical "is this lossless?" seam + the lossy-copy overwrite invariant +(#941). All pure — no files, no ffmpeg — so the decision and the safety guard are +unit-testable in isolation. Both the import path (create_lossy_copy) and the Lossy +Converter repair job route through these, so the same rules drive both.""" + +from core.quality.lossless import ( + LOSSLESS_FORMATS, + LOSSLESS_CANDIDATE_EXTENSIONS, + is_lossless_format, + is_lossless_audio_path, + lossy_output_would_overwrite_source, +) +from core.quality.model import AudioQuality +from core.repair_jobs.lossy_converter import _lossless_ext_where + + +# ── is_lossless_format ── + +def test_lossless_formats_recognized(): + for fmt in ('flac', 'alac', 'wav', 'dsf', 'FLAC', 'Dsf'): + assert is_lossless_format(fmt) is True + + +def test_lossy_formats_not_lossless(): + for fmt in ('mp3', 'aac', 'ogg', 'opus', 'wma', 'unknown', '', None): + assert is_lossless_format(fmt) is False + + +# ── is_lossless_audio_path (the ambiguity is the whole point) ── + +def test_unambiguous_extensions_decided_by_extension(): + for path in ('/m/a.flac', '/m/a.wav', '/m/a.wave', '/m/a.aiff', '/m/a.aif', + '/m/a.dsf', '/m/a.dff', '/m/a.alac', '/m/A.FLAC'): + assert is_lossless_audio_path(path) is True + + +def test_lossy_extensions_are_not_lossless(): + for path in ('/m/a.mp3', '/m/a.ogg', '/m/a.opus', '/m/a.wma', '/m/a.aac'): + assert is_lossless_audio_path(path) is False + + +def test_m4a_without_probe_is_not_lossless(): + # The safe default: with no codec probe, an .m4a can't be proven lossless, so + # an AAC file is never misclassified as lossless and converted/deleted. + assert is_lossless_audio_path('/m/a.m4a') is False + assert is_lossless_audio_path('/m/a.mp4') is False + + +def test_m4a_alac_is_lossless_via_probe(): + assert is_lossless_audio_path('/m/a.m4a', probe_codec=lambda _p: 'alac') is True + assert is_lossless_audio_path('/m/a.mp4', probe_codec=lambda _p: 'ALAC') is True + + +def test_m4a_aac_is_not_lossless_via_probe(): + assert is_lossless_audio_path('/m/a.m4a', probe_codec=lambda _p: 'mp4a.40.2') is False + + +def test_probe_exception_is_not_lossless(): + def _boom(_p): + raise RuntimeError("probe failed") + assert is_lossless_audio_path('/m/a.m4a', probe_codec=_boom) is False + + +# ── LOSSLESS_CANDIDATE_EXTENSIONS (the SQL pre-filter set) ── + +def test_candidate_extensions_cover_lossless_plus_ambiguous(): + for e in ('.flac', '.wav', '.aiff', '.dsf', '.dff', '.alac', '.m4a', '.mp4'): + assert e in LOSSLESS_CANDIDATE_EXTENSIONS + # raw lossy extensions must NOT be candidates + for e in ('.mp3', '.aac', '.ogg', '.opus', '.wma'): + assert e not in LOSSLESS_CANDIDATE_EXTENSIONS + + +def test_sql_where_clause_matches_candidates_only(): + where = _lossless_ext_where('t.file_path') + assert "LIKE '%.flac'" in where and "LIKE '%.dsf'" in where and "LIKE '%.m4a'" in where + assert "LIKE '%.mp3'" not in where and "LIKE '%.aac'" not in where + + +# ── lossy_output_would_overwrite_source (the safety invariant) ── + +def test_overwrite_detected_when_paths_equal(): + assert lossy_output_would_overwrite_source('/m/Album/01.m4a', '/m/Album/01.m4a') is True + + +def test_overwrite_detected_after_normalization(): + assert lossy_output_would_overwrite_source('/m/Album/../Album/01.m4a', '/m/Album/01.m4a') is True + + +def test_no_overwrite_for_different_extension(): + assert lossy_output_would_overwrite_source('/m/Album/01.flac', '/m/Album/01.mp3') is False + assert lossy_output_would_overwrite_source('/m/Album/01.m4a', '/m/Album/01.mp3') is False + + +def test_overwrite_guard_handles_empty(): + assert lossy_output_would_overwrite_source('', '/x.mp3') is False + assert lossy_output_would_overwrite_source('/x.flac', '') is False + + +# ── anti-drift: the seam must agree with the quality tier model ── + +def test_lossless_set_consistent_with_tier_model(): + """If a format is in LOSSLESS_FORMATS it must out-rank every lossy format in + tier_score — guards against the two lists drifting apart (the whole reason + this seam exists).""" + lossy = ('mp3', 'aac', 'ogg', 'opus', 'wma') + worst_lossless = min(AudioQuality(f, bitrate=11290, sample_rate=44100, bit_depth=16).tier_score() + for f in LOSSLESS_FORMATS) + best_lossy = max(AudioQuality(f, bitrate=320).tier_score() for f in lossy) + assert worst_lossless > best_lossy + + +# ── regression: the exact bug class this guards (overwrite the original) ── + +def test_regression_m4a_alac_to_aac_would_overwrite_and_is_blocked(): + """An .m4a ALAC source converted with the AAC codec lands on the SAME .m4a + path. The guard must catch it so ffmpeg -y never destroys the original.""" + src = '/library/Sade/Diamond Life/01. Smooth Operator.m4a' # ALAC + out = src # AAC target → .m4a + assert is_lossless_audio_path(src, probe_codec=lambda _p: 'alac') is True + assert lossy_output_would_overwrite_source(src, out) is True # → callers skip