YouTube/Tidal/Qobuz results encode the name as ``id||title``. When the title
itself contains a '/' (e.g. the Sawano AoT track "YouSeeBIGGIRL/T:T"), two places
wrongly basename-split it on the slash and kept only the last segment ("T:T"):
- core/downloads/file_finder.py — the completed-download finder truncated its
search target to "T:T", so the real on-disk file (slash sanitised by the
writer) never matched → "not found after processing" → the download got
QUARANTINED. Now an encoded ``id||title`` keeps the whole title as the target
and contributes no remote-directory components; real Soulseek PATHS still get
basename + dir extraction unchanged.
- webui/static/downloads.js — the manual-search FILE column showed only "T:T".
Added a ``||``-aware short-label helper (mirrors the correct handling already
used elsewhere in the file); real file paths still show their basename.
Tests: the finder locates "YouSeeBIGGIRL∕T: T.mp3" from the encoded title
"…||YouSeeBIGGIRL/T:T" (the screenshot case), doesn't match an unrelated file,
and a genuine Soulseek path still resolves to its last segment. 21 finder tests
+ 64 script-split integrity tests pass.
286 lines
11 KiB
Python
286 lines
11 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)
|
|
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']
|