diff --git a/core/discovery/quality_scanner.py b/core/discovery/quality_scanner.py index ebde647c..5a1e424b 100644 --- a/core/discovery/quality_scanner.py +++ b/core/discovery/quality_scanner.py @@ -77,6 +77,10 @@ class QualityScannerDeps: # so the scan checks against the SAME ranked-target core as the download # quality guard — not just the file extension. Injected for testability. probe_audio_quality: Callable = None + # Resolve a DB-stored (often RELATIVE, e.g. "Artist/Album/Track.flac") path + # to an absolute on-disk file by checking the transfer/download/library + # dirs. Without this the probe opens a relative path and fails. + resolve_library_file_path: Callable = None def _extract_lookup_value(value: Any, *names: str, default: Any = None) -> Any: @@ -438,14 +442,21 @@ def run_quality_scanner(scope='watchlist', profile_id=1, deps: QualityScannerDep # Probe the REAL audio quality (bit depth / sample rate / bitrate) # and check it against the ranked targets — same core as the - # download guard. The library path may be a Windows/host path that - # needs Docker resolution before mutagen can open it. - resolved_path = file_path - try: - from core.imports.paths import docker_resolve_path - resolved_path = docker_resolve_path(file_path) if file_path else file_path - except Exception: - resolved_path = file_path + # download guard. The DB stores paths RELATIVE to the library root + # (e.g. "Artist/Album/Track.flac"), so resolve to an absolute + # on-disk path first or mutagen can't open the file. + resolved_path = None + if deps.resolve_library_file_path: + try: + resolved_path = deps.resolve_library_file_path(file_path) + except Exception: + resolved_path = None + if not resolved_path: + try: + from core.imports.paths import docker_resolve_path + resolved_path = docker_resolve_path(file_path) if file_path else file_path + except Exception: + resolved_path = file_path aq = deps.probe_audio_quality(resolved_path) if deps.probe_audio_quality else None if aq is not None: diff --git a/tests/discovery/test_discovery_quality_scanner.py b/tests/discovery/test_discovery_quality_scanner.py index bec7fa49..0841fec8 100644 --- a/tests/discovery/test_discovery_quality_scanner.py +++ b/tests/discovery/test_discovery_quality_scanner.py @@ -175,6 +175,7 @@ def _build_deps( get_quality_tier_from_extension=lambda fp: quality_tier_result, add_activity_item=lambda *a, **kw: None, probe_audio_quality=probe_fn or _default_probe, + resolve_library_file_path=lambda fp: fp, # identity — tests use direct paths ) return deps @@ -263,6 +264,30 @@ def test_high_quality_tracks_skipped(mock_db_and_wishlist): assert state['low_quality'] == 0 +def test_scanner_resolves_relative_path_before_probing(mock_db_and_wishlist): + """DB stores RELATIVE paths (Artist/Album/Track.flac); the scanner must + resolve them to absolute via resolve_library_file_path before probing, + otherwise mutagen can't open the file and the whole library passes.""" + from core.quality.model import AudioQuality + + db, _ = mock_db_and_wishlist + db._watchlist_artists = [_WatchlistArtist('A')] + db._tracks = [_track_row(file_path='Artist/Album/Track.flac')] + state = {} + probed = [] + + def _probe(fp): + probed.append(fp) + return AudioQuality('flac', bit_depth=16, sample_rate=44100) + + deps = _build_deps(state=state, probe_fn=_probe) + deps.resolve_library_file_path = lambda fp: '/music/' + fp + + qs.run_quality_scanner('watchlist', 1, deps) + + assert probed == ['/music/Artist/Album/Track.flac'] + + def test_low_quality_tracks_attempted(mock_db_and_wishlist): """Low-quality tracks (tier_num > min) trigger a metadata search.""" db, _ = mock_db_and_wishlist diff --git a/web_server.py b/web_server.py index 620114a8..f68277ee 100644 --- a/web_server.py +++ b/web_server.py @@ -17662,6 +17662,7 @@ def _build_quality_scanner_deps(): get_quality_tier_from_extension=_get_quality_tier_from_extension, add_activity_item=add_activity_item, probe_audio_quality=_probe_audio_quality, + resolve_library_file_path=_resolve_library_file_path, )