Discord (Shdjfgatdif): a downloaded .flac sat right there in the download folder but the import flow reported "File not found on disk after 5 search attempts" and failed it. root cause: slskd REPORTS the name as "[34 - You & Me (Flume Remix).flac" but SAVES it as "34 - You & Me (Flume Remix).flac" (it strips the leading '['). The finder's fuzzy-match normaliser used one combined bracket-strip — r'[\[\(].*?[\]\)]' — which allows MISMATCHED delimiters, so the lone '[' matched all the way to the next ')', ate the whole title, and collapsed the search target to just "flac". That scored 0.40 against the real filename (below the 0.85 floor) → "not found", despite the file being on disk. Confirmed by running the real code on his exact filename. fix: strip only BALANCED pairs (\[...\] and (...) separately). A stray unbalanced bracket now survives to the alphanumeric strip instead of devouring the title. '[34 - You & Me (Flume Remix)' → matches at 1.00. Balanced tags like "[FLAC]" / "(Remastered 2016)" are still stripped (no regression). Only used internally by the finder's fuzzy scorer — contained blast radius. 3 tests: his exact unbalanced-'[' filename, a stray-']' variant, and a balanced-tag no-regression guard. 1311 imports/downloads/quality tests green, ruff clean.
295 lines
12 KiB
Python
295 lines
12 KiB
Python
"""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)
|
|
# Strip ONLY balanced bracket pairs (tags like "[FLAC]", "(Remastered 2016)").
|
|
# The old combined pattern r'[\[\(].*?[\]\)]' allowed MISMATCHED delimiters, so a
|
|
# lone unbalanced '[' — slskd reports "[34 - You & Me (Flume Remix)" but saves the
|
|
# file as "34 - You & Me (Flume Remix)" — matched from that '[' all the way to the
|
|
# next ')', eating the entire title and collapsing the search target to "flac". The
|
|
# file then scored 0.40 against the real on-disk name and was reported "not found"
|
|
# despite sitting right there. Per-delimiter pairs can't over-consume; a stray
|
|
# unbalanced bracket simply survives to the alphanumeric strip below.
|
|
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']
|