"""Robust completed-download file finder. Walks a download directory (and optional transfer directory) to find the local file matching an API-reported remote filename. Handles: - Arbitrary subdirectory layouts (slskd flat / username-prefixed / remote-tree-preserved). Pre-extract callers in the Soulseek album-bundle path tried three hard-coded candidate paths and silently failed on layouts that didn't match — see issue #715 (Billy Ocean album task fails after slskd finishes downloading release). The per-track flow already used a recursive walk and worked; the bundle path didn't, so users on common slskd configs with username-prefixed downloads saw bundles time out 22 minutes after slskd reported every transfer Completed. - slskd dedup suffix ``_<10-or-more-digit-timestamp>`` appended when a file with the same basename already exists. - YouTube / Tidal encoded filename format ``id||title`` — the ``||`` half is the human title and used for matching. - Multiple files sharing a basename — disambiguates by counting how many remote-path directory components appear in the local path. This was lifted verbatim from ``web_server._find_completed_file_robust`` so both the per-track download poll AND the Soulseek album-bundle poll go through one finder. Pre-extract the bundle path probed three hard-coded candidates only, which is why bundle downloads on slskd setups with username-prefixed download dirs silently timed out (#715). """ from __future__ import annotations import logging import os import re from difflib import SequenceMatcher from typing import Optional, Tuple from unidecode import unidecode logger = logging.getLogger(__name__) AUDIO_EXTENSIONS = frozenset({ '.mp3', '.flac', '.m4a', '.aac', '.ogg', '.opus', '.wav', '.wma', '.alac', '.aiff', '.aif', '.dsf', '.dff', '.ape', }) # slskd appends a 10+ digit timestamp suffix when a file with the # same basename already exists. Match-strip those so we still # resolve the transfer. _SLSKD_DEDUP_SUFFIX = re.compile(r'_\d{10,}$') # AcoustID-quarantined files live under this dirname and must be # skipped — they are known-wrong matches the verifier rejected. _QUARANTINE_DIRNAME = 'ss_quarantine' # Confidence floor for accepting a fuzzy basename match. Anything # below this is treated as "no match" so we don't drag in unrelated # files. _FUZZY_THRESHOLD = 0.85 def _is_audio_candidate(path: str) -> bool: return os.path.splitext(str(path or ''))[1].lower() in AUDIO_EXTENSIONS def _normalize_for_finding(text: str) -> str: """Match-engine-style normalisation for fuzzy filename comparison. Lowercases, transliterates unicode, drops bracketed content ("(Remastered 2016)", "[FLAC]"), strips punctuation, collapses whitespace. Mirrors ``matching_engine.py``'s text normaliser so finder + matcher agree on equivalence. """ if not text: return "" text = unidecode(text).lower() text = re.sub(r'[._/]', ' ', text) text = re.sub(r'[\[\(].*?[\]\)]', '', text) text = re.sub(r'[^a-z0-9\s-]', '', text) return ' '.join(text.split()).strip() def _extract_basename(api_filename: str) -> str: """Cross-platform rightmost-separator split for a real remote PATH. A YouTube/Tidal/Qobuz ``id||title`` encoded filename is handled by returning the title VERBATIM: the title is not a filesystem path, so a '/' in it (e.g. the Sawano track ``YouSeeBIGGIRL/T:T``) is part of the name and must NOT be split on (issue #835).""" if not api_filename: return "" if '||' in api_filename: _id, title = api_filename.split('||', 1) return title last_slash = max(api_filename.rfind('/'), api_filename.rfind('\\')) return api_filename[last_slash + 1:] if last_slash != -1 else api_filename def _api_dir_parts(api_filename: str) -> list[str]: """Lowercased remote-path directory components, sans the filename itself. Used to disambiguate when several local files share the same basename — the one whose path mirrors the remote folder structure wins.""" if not api_filename: return [] normalized = api_filename.replace('\\', '/') return [p.lower() for p in normalized.split('/')[:-1] if p] def _path_matches_api_dirs(file_path: str, api_dirs: list[str]) -> bool: """``True`` iff every remote directory component appears as a path part of the local file. Cheap "is this file on a sibling tree" check.""" if not api_dirs: return False path_parts = set(p.lower() for p in file_path.replace('\\', '/').split('/')) return all(d in path_parts for d in api_dirs) def _search_in_directory( search_dir: str, location_name: str, target_basename: str, normalized_target: str, api_dirs: list[str], ) -> Tuple[Optional[str], float]: """Walk ``search_dir`` once, return (best_match, similarity). Priority order, highest first: 1. Exact basename match with directory-structure confirmation 2. Exact basename match without disambiguation (when no api_dirs) 3. slskd-dedup-suffix basename match (same two tiers as exact) 4. Best fuzzy basename match above ``_FUZZY_THRESHOLD`` """ best_fuzzy_path: Optional[str] = None highest_fuzzy_similarity = 0.0 exact_matches: list[str] = [] for root, dirs, files in os.walk(search_dir): # Strip quarantine subdir from the walk in place — these # files are known-bad and matching them would re-poison the # post-process pipeline. dirs[:] = [d for d in dirs if d != _QUARANTINE_DIRNAME] for filename in files: file_path = os.path.join(root, filename) if not _is_audio_candidate(file_path): continue # Tier 1 + 2: exact basename match. if filename == target_basename: if api_dirs and _path_matches_api_dirs(file_path, api_dirs): logger.info( "Found path-confirmed match in %s: %s", location_name, file_path, ) return file_path, 1.0 if not api_dirs: logger.info( "Found exact match in %s: %s", location_name, file_path, ) return file_path, 1.0 exact_matches.append(file_path) continue # Tier 3: slskd dedup suffix. stem, ext = os.path.splitext(filename) stripped_stem = _SLSKD_DEDUP_SUFFIX.sub('', stem) if stripped_stem != stem and stripped_stem + ext == target_basename: if api_dirs and _path_matches_api_dirs(file_path, api_dirs): logger.info( "Found path-confirmed dedup match in %s: %s", location_name, file_path, ) return file_path, 1.0 if not api_dirs: logger.info( "Found dedup-suffix match in %s: %s", location_name, file_path, ) return file_path, 1.0 exact_matches.append(file_path) continue # Tier 4: fuzzy basename match. Cheaper than path-walking # the whole tree a second time, so always compute and # keep the best one as a fallback. normalized_file = _normalize_for_finding(filename) similarity = SequenceMatcher( None, normalized_target, normalized_file, ).ratio() if similarity > highest_fuzzy_similarity: highest_fuzzy_similarity = similarity best_fuzzy_path = file_path if exact_matches: if len(exact_matches) == 1: logger.info( "Found exact match in %s: %s", location_name, exact_matches[0], ) return exact_matches[0], 1.0 # Multiple basename collisions — pick the one whose path # carries the most of the remote directory tree (album # folder etc.). Breaks ties deterministically. best = exact_matches[0] best_score = -1 for m in exact_matches: m_parts = set(p.lower() for p in m.replace('\\', '/').split('/')) score = sum(1 for d in api_dirs if d in m_parts) if score > best_score: best_score = score best = m logger.info( "Found %d files named '%s' in %s, picked best path match: %s", len(exact_matches), target_basename, location_name, best, ) return best, 1.0 return best_fuzzy_path, highest_fuzzy_similarity def find_completed_audio_file( download_dir: str, api_filename: str, transfer_dir: Optional[str] = None, ) -> Tuple[Optional[str], Optional[str]]: """Locate a completed download's local file via recursive walk. Tries the downloads tree first; if nothing above the fuzzy threshold lands AND a transfer_dir was passed, tries that too. Returns ``(file_path, location)`` where ``location`` is ``'downloads'`` / ``'transfer'`` / ``None``. Both elements are ``None`` when the file isn't found anywhere — callers should treat that as "not yet" (still mid-write) or "lost". """ # YouTube / Tidal / Qobuz encoded filenames carry the id ahead of ``||``. # The title half is NOT a filesystem path: a '/' in it (e.g. the Sawano # track ``YouSeeBIGGIRL/T:T``) is part of the title, so it must NOT be # basename-split or read as a remote directory component — doing so # truncated the search target to ``T:T`` and the real file was never found, # quarantining valid downloads (issue #835). Real remote paths (Soulseek) # still get basename + dir-component extraction. encoded_title = None if api_filename and '||' in api_filename: _id, encoded_title = api_filename.split('||', 1) if encoded_title is not None: target_basename = encoded_title api_dirs = [] else: target_basename = _extract_basename(api_filename) api_dirs = _api_dir_parts(api_filename) normalized_target = _normalize_for_finding(target_basename) best_dl_path, dl_sim = _search_in_directory( download_dir, 'downloads', target_basename, normalized_target, api_dirs, ) if dl_sim > _FUZZY_THRESHOLD: if dl_sim < 1.0: logger.info( "Found fuzzy match in downloads (%.2f): %s", dl_sim, best_dl_path, ) return (best_dl_path, 'downloads') if transfer_dir and os.path.exists(transfer_dir): best_tx_path, tx_sim = _search_in_directory( transfer_dir, 'transfer', target_basename, normalized_target, api_dirs, ) if tx_sim > _FUZZY_THRESHOLD: if tx_sim < 1.0: logger.info( "Found fuzzy match in transfer (%.2f): %s", tx_sim, best_tx_path, ) return (best_tx_path, 'transfer') return (None, None) __all__ = ['find_completed_audio_file', 'AUDIO_EXTENSIONS']