soulsync/core/library/path_resolver.py
dev 973d28f61d fix(path-resolve): CWD-independent base dirs + quality-scan resolve diagnostic
The quality job still resolved 0/18 because the shared resolver kept relative
config paths ("./Transfer") as-is and gated them behind os.path.isdir("./Transfer"),
which only holds when the calling thread's CWD is the app root. The repair
worker thread's CWD isn't guaranteed to be /app, so base_dirs came back empty
and every track was "unresolved".

- _collect_base_dirs now also adds os.path.abspath() of every relative
  candidate, so "./Transfer" → "/app/Transfer" regardless of CWD.
- quality_upgrade_scanner logs a one-shot [QualityResolve] diagnostic on the
  first unresolved track (cwd, transfer_folder + abspath + isdir, base dirs
  tried, abs-join existence) so any remaining mount mismatch is pinpointable
  instead of a silent "all skipped".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 14:39:18 +02:00

269 lines
11 KiB
Python

"""Resolve database-stored file paths to actual files on disk.
Database track rows store file paths as the media server reported them
(`/music/Artist/Album/track.flac`, `H:\\Music\\Artist\\...`, etc). When
SoulSync runs in Docker, those paths don't exist as-is inside the
container — the user's library is bind-mounted at a container path
(commonly `/music`) that has nothing to do with what Plex/Jellyfin
recorded. Same problem for native installs that point at a NAS via SMB:
the path the media server scanned isn't the path SoulSync reads.
The resolver tries the raw path first (cheap happy-path), then walks
progressively shorter suffixes against every configured base directory:
the transfer folder, the slskd download folder, every configured Plex
library location, and every entry in the user's `library.music_paths`
config. The first existing match wins.
This module replaces four duplicated copies of the same function (each
with the same incomplete logic) that lived in
`core/repair_worker.py` and three modules under `core/repair_jobs/`.
The duplicates only checked the transfer + download folders and
silently returned None for files in the actual media-server library —
which is why, for example, the Album Completeness "Auto-Fill" button
returned ``Could not determine album folder from existing tracks`` for
every Docker user (issue #476).
The web server has its own near-duplicate at
``web_server.py:_resolve_library_file_path`` which already covers the
full search space; this module is the lifted, shared version usable
from any background worker.
"""
from __future__ import annotations
import os
from dataclasses import dataclass, field
from typing import Any, Iterable, List, Optional, Tuple
from utils.logging_config import get_logger
logger = get_logger("library.path_resolver")
@dataclass
class ResolveAttempt:
"""Diagnostic record for a single `resolve_library_file_path` call.
Returned by `resolve_library_file_path_with_diagnostic` so callers
that need to surface a useful error message (instead of just a
silent None) can describe what was tried. Pure data — no side
effects, no rendering opinions.
Fields:
raw_path_existed: True if `os.path.exists(file_path)` returned
True at the start of the resolver. When this is True the
resolver short-circuits and `base_dirs_tried` will be empty.
base_dirs_tried: The ordered list of base directories the
resolver suffix-walked against (already filtered by
`os.path.isdir`).
had_config_manager: Whether a config_manager was supplied. Useful
for distinguishing "no candidates discovered" from "couldn't
even read config to discover".
had_plex_client: Same, for the Plex API probe.
"""
raw_path_existed: bool = False
base_dirs_tried: List[str] = field(default_factory=list)
had_config_manager: bool = False
had_plex_client: bool = False
def _docker_resolve_path(path_str: Any) -> Optional[str]:
"""Translate Windows-style paths to the Docker container layout.
Mirrors ``core/imports/paths.docker_resolve_path`` but kept local to
avoid a cross-package import in case this module is consumed early
in a job startup. Returns the input unchanged outside Docker.
"""
if not isinstance(path_str, str):
return None
if (
os.path.exists("/.dockerenv")
and len(path_str) >= 3
and path_str[1] == ":"
and path_str[0].isalpha()
):
drive_letter = path_str[0].lower()
rest = path_str[2:].replace("\\", "/")
return f"/host/mnt/{drive_letter}{rest}"
return path_str
def _collect_base_dirs(
transfer_folder: Optional[str],
download_folder: Optional[str],
config_manager: Any,
plex_client: Any,
) -> list[str]:
"""Build the ordered list of base directories to probe."""
candidates: list[Optional[str]] = []
if transfer_folder:
candidates.append(_docker_resolve_path(transfer_folder))
if download_folder:
candidates.append(_docker_resolve_path(download_folder))
if config_manager is not None:
try:
transfer_cfg = config_manager.get("soulseek.transfer_path", "") or ""
download_cfg = config_manager.get("soulseek.download_path", "") or ""
if transfer_cfg:
candidates.append(_docker_resolve_path(transfer_cfg))
if download_cfg:
candidates.append(_docker_resolve_path(download_cfg))
except Exception as e:
logger.debug("soulseek paths read failed: %s", e)
# Plex-reported library locations (handles "Plex scanned at /music but
# SoulSync mounts at /library" cases).
if plex_client is not None:
try:
server = getattr(plex_client, "server", None)
music_library = getattr(plex_client, "music_library", None)
if server is not None and music_library is not None:
for loc in getattr(music_library, "locations", []) or []:
if loc:
candidates.append(loc)
except Exception as e:
logger.debug("plex locations read failed: %s", e)
# User-configured library music paths (Settings → Library → Music Paths).
if config_manager is not None:
try:
music_paths = config_manager.get("library.music_paths", []) or []
if isinstance(music_paths, Iterable):
for p in music_paths:
if isinstance(p, str) and p.strip():
candidates.append(_docker_resolve_path(p.strip()))
except Exception as e:
logger.debug("music paths read failed: %s", e)
# Normalize to absolute forms so resolution does NOT depend on the calling
# thread's CWD. A relative config like "./Transfer" otherwise only resolves
# when os.path.isdir("./Transfer") happens to be true from the current CWD —
# which fails in background workers whose CWD isn't the app root, leaving
# base_dirs empty and every track "unresolved". For each candidate we try
# the raw form first (cheap, preserves an already-absolute path), then its
# os.path.abspath() form so "./Transfer" → "/app/Transfer".
expanded: list[str] = []
for c in candidates:
if not c:
continue
expanded.append(c)
if not os.path.isabs(c):
expanded.append(os.path.abspath(c))
# De-duplicate while preserving order, drop empties / non-existent dirs.
seen: set[str] = set()
out: list[str] = []
for c in expanded:
if not c or c in seen:
continue
seen.add(c)
if os.path.isdir(c):
out.append(c)
return out
def resolve_library_file_path(
file_path: Any,
*,
transfer_folder: Optional[str] = None,
download_folder: Optional[str] = None,
config_manager: Any = None,
plex_client: Any = None,
) -> Optional[str]:
"""Resolve a stored DB path to an actual file on disk.
Args:
file_path: The path as recorded in the database (may not exist
as-is in the current process's filesystem view).
transfer_folder: Optional explicit transfer-folder override
(bypasses the config_manager lookup). Useful when the caller
already cached one.
download_folder: Optional explicit download-folder override.
config_manager: When provided, the resolver also pulls
``soulseek.transfer_path``, ``soulseek.download_path``, and
``library.music_paths`` from config to expand the search.
plex_client: When provided, every Plex-reported music-library
location is added to the search.
Returns:
The first existing path on disk, or None when no match is found.
Never raises — failure is the None return.
"""
resolved, _ = resolve_library_file_path_with_diagnostic(
file_path,
transfer_folder=transfer_folder,
download_folder=download_folder,
config_manager=config_manager,
plex_client=plex_client,
)
return resolved
def resolve_library_file_path_with_diagnostic(
file_path: Any,
*,
transfer_folder: Optional[str] = None,
download_folder: Optional[str] = None,
config_manager: Any = None,
plex_client: Any = None,
) -> Tuple[Optional[str], ResolveAttempt]:
"""Same as ``resolve_library_file_path`` but also returns a
``ResolveAttempt`` describing what the resolver tried.
Use this when you need to surface a useful "we tried X, Y, Z" error
to the user instead of a silent None. Issue #558 (gabistek, Navidrome
on Docker): the resolver was returning None because Navidrome doesn't
expose library filesystem paths via API (unlike Plex), and the user
hadn't configured ``library.music_paths``. The Album Completeness
fix endpoint surfaced a generic "Could not determine album folder"
error with no diagnostic — user had no way to know what to configure.
"""
attempt = ResolveAttempt(
had_config_manager=config_manager is not None,
had_plex_client=plex_client is not None,
)
if not isinstance(file_path, str) or not file_path:
return None, attempt
if os.path.exists(file_path):
attempt.raw_path_existed = True
return file_path, attempt
path_parts = file_path.replace("\\", "/").split("/")
base_dirs = _collect_base_dirs(transfer_folder, download_folder, config_manager, plex_client)
attempt.base_dirs_tried = list(base_dirs)
if not base_dirs:
return None, attempt
# Try progressively shorter path suffixes against each base dir.
#
# Start at index 0 so a clean RELATIVE library path is tried in FULL first.
# SoulSync's own library scanner stores paths like
# "Asketa/Another Side/01 - Track.flac" (no leading slash) — index 0 is the
# artist folder and dropping it (the old range(1, ...)) meant the artist
# segment was never joined, so nothing under transfer/ ever resolved and
# every track looked unreadable to the quality scanner.
#
# For ABSOLUTE media-server paths ("/music/Artist/Album/track.flac") index 0
# is the empty leading segment and i=0 yields os.path.join(base, "", ...) ==
# base/Artist/... which simply won't exist and harmlessly falls through to
# i=1 ("music/...") etc. A Windows drive part ("E:") at i=0 likewise just
# fails on POSIX and falls through. So starting at 0 is safe for every form
# and only ADDS the relative-full-path match that was missing.
for base in base_dirs:
for i in range(0, len(path_parts)):
candidate = os.path.join(base, *path_parts[i:])
if os.path.exists(candidate):
return candidate, attempt
return None, attempt
__all__ = [
"ResolveAttempt",
"resolve_library_file_path",
"resolve_library_file_path_with_diagnostic",
]