Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions core/download_plugins/album_bundle.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,51 @@ def quality_score(title: str, quality_guess) -> int:
return _QUALITY_SCORE.get(quality_guess(title) or '', 0)


def album_search_queries(album_name: str, artist_name: str) -> list[str]:
"""Return deduped Prowlarr query variants for album-bundle search.

Some indexers (notably RuTracker through Prowlarr) match release titles
better when the query mirrors tracker naming (``Artist - Album``), while
compilations are often listed under ``VA`` instead of ``Various Artists``.
Keep the ladder small: broad enough to find tracker-shaped titles, but not
so broad that we spam indexers or invite unrelated albums.
"""
album = (album_name or '').strip()
artist = (artist_name or '').strip()
if not album and not artist:
return []

candidates: list[str] = []
if artist and album:
is_va = artist.lower() in {'various artists', 'various', 'va'}
candidates.extend([
f"{artist} {album}",
f"{artist} - {album}",
])
if is_va:
candidates.extend([
f"VA {album}",
f"VA - {album}",
f"Various Artists - {album}",
])
candidates.extend([
f"{album} {artist}",
album,
])
else:
candidates.append(album or artist)

out: list[str] = []
seen: set[str] = set()
for candidate in candidates:
query = re.sub(r"\s+", " ", candidate).strip()
key = query.lower()
if query and key not in seen:
out.append(query)
seen.add(key)
return out


def _normalize_release_text(text: str) -> str:
"""Lowercase, fold accents (Björk -> bjork), strip punctuation to spaces.

Expand Down
50 changes: 32 additions & 18 deletions core/download_plugins/torrent.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
from core.archive_pipeline import collect_audio_after_extraction
from core.download_plugins.album_bundle import (
TransientMissCounter,
album_search_queries,
copy_audio_files_atomically,
get_poll_interval,
get_poll_timeout,
Expand Down Expand Up @@ -542,34 +543,47 @@ def _emit(state: str, **extra) -> None:
except Exception as cb_exc:
logger.debug("[Torrent album] progress callback failed: %s", cb_exc)

# Phase 1: search Prowlarr for the album.
query = f"{artist_name} {album_name}".strip()
_emit('searching', query=query)
try:
search_results = run_async(self._prowlarr.search(
query, categories=DEFAULT_MUSIC_CATEGORIES,
indexer_ids=_parse_indexer_id_filter(),
))
except Exception as e:
result['error'] = f'Prowlarr search failed: {e}'
return result
# Phase 1: search Prowlarr for the album. Try a small query ladder so
# tracker-shaped titles like RuTracker's ``Artist - Album`` and
# compilation ``VA - Album`` releases can be discovered.
picked = None
saw_candidates = False
queries = album_search_queries(album_name, artist_name)
last_query = queries[-1] if queries else f"{artist_name} {album_name}".strip()
for query in queries:
last_query = query
_emit('searching', query=query)
try:
search_results = run_async(self._prowlarr.search(
query, categories=DEFAULT_MUSIC_CATEGORIES,
indexer_ids=_parse_indexer_id_filter(),
))
except Exception as e:
result['error'] = f'Prowlarr search failed: {e}'
return result

candidates = [r for r in search_results
if r.protocol == 'torrent' and (r.magnet_uri or r.download_url)]
if not candidates:
candidates = [r for r in search_results
if r.protocol == 'torrent' and (r.magnet_uri or r.download_url)]
if not candidates:
continue
saw_candidates = True
picked = pick_best_album_release(
candidates, _guess_quality_from_title, album_name=album_name,
)
if picked is not None:
break

if not saw_candidates:
# Album isn't available on this source. Mark the failure as
# fallback-eligible so the dispatch returns to the per-track flow
# instead of hard-failing the batch — in hybrid mode that lets the
# next configured source take over. Without this flag a torrent-first
# hybrid would get stuck at "searching" forever when Prowlarr
# returns nothing, never trying the other sources.
result['error'] = f'No torrent results found for "{query}"'
result['error'] = f'No torrent results found for "{last_query}"'
result['fallback'] = True
return result

picked = pick_best_album_release(
candidates, _guess_quality_from_title, album_name=album_name,
)
if picked is None:
# No candidate matched the requested album (or none passed filtering).
# Fall back to the per-track flow rather than downloading a wrong
Expand Down
47 changes: 29 additions & 18 deletions core/download_plugins/usenet.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from core.archive_pipeline import collect_audio_after_extraction
from core.download_plugins.album_bundle import (
TransientMissCounter,
album_search_queries,
copy_audio_files_atomically,
get_completed_no_path_window_seconds,
pick_best_album_release,
Expand Down Expand Up @@ -451,31 +452,42 @@ def _emit(state: str, **extra) -> None:
except Exception as cb_exc:
logger.debug("[Usenet album] progress callback failed: %s", cb_exc)

query = f"{artist_name} {album_name}".strip()
_emit('searching', query=query)
try:
search_results = run_async(self._prowlarr.search(
query, categories=DEFAULT_MUSIC_CATEGORIES,
indexer_ids=_parse_indexer_id_filter(),
))
except Exception as e:
result['error'] = f'Prowlarr search failed: {e}'
return result
picked = None
saw_candidates = False
queries = album_search_queries(album_name, artist_name)
last_query = queries[-1] if queries else f"{artist_name} {album_name}".strip()
for query in queries:
last_query = query
_emit('searching', query=query)
try:
search_results = run_async(self._prowlarr.search(
query, categories=DEFAULT_MUSIC_CATEGORIES,
indexer_ids=_parse_indexer_id_filter(),
))
except Exception as e:
result['error'] = f'Prowlarr search failed: {e}'
return result

candidates = [r for r in search_results
if r.protocol == 'usenet' and r.download_url]
if not candidates:
candidates = [r for r in search_results
if r.protocol == 'usenet' and r.download_url]
if not candidates:
continue
saw_candidates = True
picked = pick_best_album_release(
candidates, _guess_quality_from_title, album_name=album_name,
)
if picked is not None:
break

if not saw_candidates:
# Album isn't available on this source — fall back to the per-track
# flow (next configured source in hybrid mode) rather than hard-
# failing the whole batch. Mirrors the torrent plugin + soulseek's
# default fallback contract.
result['error'] = f'No usenet results found for "{query}"'
result['error'] = f'No usenet results found for "{last_query}"'
result['fallback'] = True
return result

picked = pick_best_album_release(
candidates, _guess_quality_from_title, album_name=album_name,
)
if picked is None:
# No candidate matched the requested album (or none passed filtering).
# Fall back to per-track rather than grabbing a wrong album (#730).
Expand Down Expand Up @@ -540,4 +552,3 @@ def _emit(state: str, **extra) -> None:
result['success'] = True
result['files'] = copied
return result

121 changes: 116 additions & 5 deletions core/downloads/staging.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,50 @@ def _extract_release_filename_title(stem: str) -> str:
return ''


@dataclass(frozen=True)
class _ReleaseFilenameParts:
track_number: int = 0
artist: str = ''
title: str = ''


def _parse_release_filename_parts(stem: str) -> _ReleaseFilenameParts:
"""Parse common release filename stems into track / artist / title parts.

Torrent album bundles often arrive without tags, so the staging cache falls
back to filename stems. Keep this conservative and data-only: callers decide
whether to trust the parsed artist/title based on context.

Supported examples:
- ``01 - Artist - Title`` -> track=1, artist=Artist, title=Title
- ``01 - Title`` -> track=1, title=Title
- ``Artist_-_Title`` -> artist=Artist, title=Title
"""
compacted = re.sub(r'[_]+', ' ', str(stem or '').strip())
compacted = re.sub(r'\s+', ' ', compacted).strip()
if not compacted:
return _ReleaseFilenameParts()

parts = [p.strip() for p in compacted.split(' - ') if p.strip()]
if len(parts) < 2:
return _ReleaseFilenameParts()

first_track = _coerce_positive_int(parts[0], 0)
if first_track:
if len(parts) >= 3:
return _ReleaseFilenameParts(
track_number=first_track,
artist=parts[1],
title=' - '.join(parts[2:]).strip(),
)
return _ReleaseFilenameParts(track_number=first_track, title=parts[1])

if len(parts) == 2:
return _ReleaseFilenameParts(artist=parts[0], title=parts[1])

return _ReleaseFilenameParts()


def _staging_title_variants(title: Any, normalize: Callable[[str], str]) -> list[str]:
"""Return conservative title variants for release-file matching.

Expand Down Expand Up @@ -201,6 +245,27 @@ def try_staging_match(task_id, batch_id, track, deps: StagingDeps):
norm_artist = normalize(track_artist)
title_variants = _staging_title_variants(track_title, normalize) or [norm_title]

_private_album_bundle_staging = False
if batch_id and deps.get_batch_field is not None:
try:
_private_album_bundle_staging = bool(
deps.get_batch_field(batch_id, 'album_bundle_private_staging')
)
except Exception as _exc:
logger.debug("get_batch_field failed: %s", _exc)

with tasks_lock:
_task_track_info = download_tasks.get(task_id, {}).get('track_info', {})
if not isinstance(_task_track_info, dict):
_task_track_info = {}
# The task's stored track_info can carry weak defaults from earlier album
# setup paths. Only use the live track object's explicit number as a hard
# filename-matching guard; context building below can still let selected
# files override weak defaults when no live number exists.
expected_match_track_number = _coerce_positive_int(
getattr(track, 'track_number', 0), 0,
)

best_match = None
best_score = 0.0
# Track per-candidate scoring so the rejection log can show the
Expand All @@ -209,8 +274,45 @@ def try_staging_match(task_id, batch_id, track, deps: StagingDeps):
candidate_scores: list = []

for sf in staging_files:
full_path = sf.get('full_path', '')
if full_path and not os.path.exists(full_path):
logger.debug(
"[Staging] Skip candidate %s — file no longer exists",
os.path.basename(full_path),
)
continue

filename_stem = os.path.splitext(os.path.basename(str(full_path or '')))[0]
parsed_filename = _parse_release_filename_parts(filename_stem or sf.get('title', ''))
parsed_track_number = (
_coerce_positive_int(sf.get('track_number'), 0) or
parsed_filename.track_number or
_extract_explicit_track_number(full_path)
)
if (
_private_album_bundle_staging and
expected_match_track_number and
parsed_track_number and
parsed_track_number != expected_match_track_number
):
logger.debug(
"[Staging] Skip candidate %s — track_number=%s expected=%s",
os.path.basename(full_path or '?'), parsed_track_number,
expected_match_track_number,
)
continue

sf_title_variants = _staging_title_variants(sf['title'], normalize)
if _private_album_bundle_staging and parsed_filename.title:
parsed_title = normalize(parsed_filename.title)
if parsed_title and parsed_title not in sf_title_variants:
sf_title_variants.append(parsed_title)
sf_norm_artist = normalize(sf['artist'])
sf_artist_variants = [sf_norm_artist] if sf_norm_artist else []
if _private_album_bundle_staging and parsed_filename.artist:
parsed_artist = normalize(parsed_filename.artist)
if parsed_artist and parsed_artist not in sf_artist_variants:
sf_artist_variants.append(parsed_artist)

if not sf_title_variants:
logger.debug(
Expand All @@ -236,13 +338,16 @@ def try_staging_match(task_id, batch_id, track, deps: StagingDeps):

# Artist similarity (secondary)
artist_sim = 0.0
if norm_artist and sf_norm_artist:
artist_sim = SequenceMatcher(None, norm_artist, sf_norm_artist).ratio()
elif not norm_artist and not sf_norm_artist:
if norm_artist and sf_artist_variants:
artist_sim = max(
SequenceMatcher(None, norm_artist, candidate).ratio()
for candidate in sf_artist_variants
)
elif not norm_artist and not sf_artist_variants:
artist_sim = 0.5 # Both unknown — neutral
elif norm_artist and not sf_norm_artist:
elif norm_artist and not sf_artist_variants:
artist_sim = 0.3 # Staging file lacks artist — partial credit if title is strong
elif sf_norm_artist and not norm_artist:
elif sf_artist_variants and not norm_artist:
artist_sim = 0.3 # Track lacks artist — same partial credit

# Combined score: title-weighted (these are user-curated staging files)
Expand All @@ -251,6 +356,12 @@ def try_staging_match(task_id, batch_id, track, deps: StagingDeps):
combined = (title_sim * 0.55) + (artist_sim * 0.45)
else:
combined = (title_sim * 0.80) + (artist_sim * 0.20)
if (
_private_album_bundle_staging and
expected_match_track_number and
parsed_track_number == expected_match_track_number
):
combined = min(1.0, combined + 0.10)

candidate_scores.append((sf, title_sim, artist_sim, combined))

Expand Down
Loading