From 9fd4f19f60988df1011d0d2c47d411529be5b0b0 Mon Sep 17 00:00:00 2001 From: ramonskie Date: Thu, 2 Jul 2026 12:53:07 +0200 Subject: [PATCH 1/2] fix: match RuTracker album bundle files --- core/downloads/staging.py | 121 +++++++- ...test_album_bundle_rutracker_integration.py | 281 ++++++++++++++++++ 2 files changed, 397 insertions(+), 5 deletions(-) create mode 100644 tests/downloads/test_album_bundle_rutracker_integration.py diff --git a/core/downloads/staging.py b/core/downloads/staging.py index 6a06091a9..bb61eca95 100644 --- a/core/downloads/staging.py +++ b/core/downloads/staging.py @@ -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. @@ -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 @@ -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( @@ -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) @@ -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)) diff --git a/tests/downloads/test_album_bundle_rutracker_integration.py b/tests/downloads/test_album_bundle_rutracker_integration.py new file mode 100644 index 000000000..f86adf8a5 --- /dev/null +++ b/tests/downloads/test_album_bundle_rutracker_integration.py @@ -0,0 +1,281 @@ +"""Integration tests for RuTracker-shaped torrent album-bundle downloads. + +These tests keep the real torrent album-bundle code in the loop and fake only +external boundaries (Prowlarr, torrent client, tagging). +They encode two real RuTracker naming patterns reported by users: + +- artist-prefixed, unnumbered files: ``Nosferatu_-_Beaver_Cleaver.flac`` +- compilation files: ``01 - Artist - Title.flac`` + +Both are marked strict-xfail while they document the current matcher gap. When +the album-bundle matcher learns those shapes, the XPASS will fail and remind us +to remove the marker. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Any +from unittest.mock import AsyncMock, patch + +import pytest + +from core.downloads import album_bundle_dispatch as dispatch +from core.downloads import staging as ds +from core.download_plugins.torrent import TorrentDownloadPlugin +from core.prowlarr_client import ProwlarrSearchResult +from core.runtime_state import download_tasks, matched_downloads_context +from core.torrent_clients.base import TorrentStatus + + +@pytest.fixture(autouse=True) +def reset_runtime_state(): + download_tasks.clear() + matched_downloads_context.clear() + yield + download_tasks.clear() + matched_downloads_context.clear() + + +@dataclass +class _Track: + name: str + artists: list[str] + album: str + track_number: int + disc_number: int = 1 + + +class _FakeMatchingEngine: + @staticmethod + def normalize_string(value: str) -> str: + import re + + value = (value or '').replace('_', ' ').lower().strip() + value = re.sub(r'[^a-z0-9]+', ' ', value) + return re.sub(r'\s+', ' ', value).strip() + + +class _FakeConfig: + def __init__(self, transfer_path: Path): + self.transfer_path = str(transfer_path) + + def get(self, key: str, default: Any = None) -> Any: + if key == 'soulseek.transfer_path': + return self.transfer_path + return default + + +class _BatchState: + def __init__(self): + self.rows: dict[str, dict[str, Any]] = {} + + def update_fields(self, batch_id: str, fields: dict) -> None: + self.rows.setdefault(batch_id, {}).update(fields) + + + def mark_failed(self, batch_id: str, error: str) -> None: + self.update_fields(batch_id, { + 'phase': 'failed', + 'error': error, + 'album_bundle_state': 'failed', + }) + + +class _FakeTorrentAdapter: + def __init__(self, save_path: Path): + self.save_path = str(save_path) + self.added_urls: list[str] = [] + + def is_configured(self) -> bool: + return True + + async def add_torrent(self, url_or_magnet: str, category: str = "soulsync", save_path: str | None = None) -> str: + self.added_urls.append(url_or_magnet) + return 'FAKEHASH' + + async def get_status(self, torrent_id: str) -> TorrentStatus: + return TorrentStatus( + id=torrent_id, + name='rutracker-release', + state='seeding', + progress=1.0, + size=123_456_789, + downloaded=123_456_789, + download_speed=0, + upload_speed=0, + seeders=10, + save_path=self.save_path, + ) + + +def _make_rutracker_result(title: str, *, size: int = 500_000_000) -> ProwlarrSearchResult: + return ProwlarrSearchResult( + guid=f'https://rutracker.org/forum/viewtopic.php?t={abs(hash(title))}', + title=title, + indexer_id=350, + indexer_name='RuTracker', + protocol='torrent', + download_url='https://prowlarr.example/download.torrent', + magnet_uri='magnet:?xt=urn:btih:FAKEHASH', + info_url='https://rutracker.org/forum/viewtopic.php?t=3503447', + size=size, + seeders=25, + leechers=1, + grabs=100, + categories=[3040], + raw={}, + ) + + +def _write_downloaded_files(download_dir: Path, filenames: list[str]) -> None: + download_dir.mkdir(parents=True, exist_ok=True) + for name in filenames: + (download_dir / name).write_bytes(b'fake flac bytes') + + +def _seed_task(task_id: str, track: _Track, album_name: str, artist_name: str) -> None: + download_tasks[task_id] = { + 'status': 'searching', + 'track_info': { + '_is_explicit_album_download': True, + '_explicit_album_context': { + 'id': 'album-id', + 'name': album_name, + 'total_tracks': 0, + 'total_discs': 1, + 'artists': [{'name': artist_name}], + }, + '_explicit_artist_context': {'id': 'artist-id', 'name': artist_name}, + 'track_number': track.track_number, + 'disc_number': track.disc_number, + }, + 'used_sources': set(), + 'download_id': None, + } + + +def _staging_cache_from_dir(staging_dir: str) -> list[dict[str, Any]]: + files = sorted(Path(staging_dir).glob('*.flac')) + return [ + { + 'full_path': str(path), + # Simulate the real weak fallback for untagged release files: title + # is the filename stem, not clean track metadata. + 'title': path.stem, + 'artist': '', + } + for path in files + ] + + +def _run_album_bundle_then_claim_tracks( + *, + tmp_path: Path, + album_name: str, + artist_name: str, + release_title: str, + filenames: list[str], + tracks: list[_Track], +) -> list[tuple[str, tuple, dict]]: + state = _BatchState() + batch_id = 'rutracker_batch' + post_process_calls: list[tuple[str, tuple, dict]] = [] + download_dir = tmp_path / 'torrent_download' + _write_downloaded_files(download_dir, filenames) + plugin = TorrentDownloadPlugin() + adapter = _FakeTorrentAdapter(download_dir) + search = AsyncMock(return_value=[_make_rutracker_result(release_title)]) + + with patch.object(plugin, 'is_configured', return_value=True), \ + patch.object(plugin._prowlarr, 'search', new=search), \ + patch('core.download_plugins.torrent.get_active_torrent_adapter', return_value=adapter): + engaged = dispatch.try_dispatch( + batch_id=batch_id, + is_album=True, + album_context={'name': album_name}, + artist_context={'name': artist_name}, + config_get=lambda key, default=None: str(tmp_path / 'bundle_staging') if key == 'download_source.album_bundle_staging_path' else default, + plugin_resolver=lambda mode: plugin if mode == 'torrent' else None, + state=state, + source_override='torrent', + ) + + # Success returns False by design: dispatch staged files, then falls through + # so per-track workers can claim them from private staging. + assert engaged is False + assert state.rows[batch_id]['album_bundle_state'] == 'staged' + assert adapter.added_urls == ['magnet:?xt=urn:btih:FAKEHASH'] + search.assert_awaited_once() + staging_dir = state.rows[batch_id]['album_bundle_staging_path'] + + deps = ds.StagingDeps( + config_manager=_FakeConfig(tmp_path / 'transfer'), + matching_engine=_FakeMatchingEngine(), + get_staging_file_cache=lambda _batch_id: _staging_cache_from_dir(staging_dir), + docker_resolve_path=lambda path: path, + post_process_matched_download_with_verification=lambda *args, **kwargs: post_process_calls.append((args[0], args, kwargs)), + get_batch_field=lambda _batch_id, field: state.rows[batch_id].get(field), + ) + + for index, track in enumerate(tracks, start=1): + task_id = f'track_{index:02d}' + _seed_task(task_id, track, album_name, artist_name) + ds.try_staging_match(task_id, batch_id, track, deps) + + return post_process_calls + + +def test_rutracker_artist_prefixed_unnumbered_album_files_claim_all_tracks(tmp_path): + # Real-world source: https://rutracker.org/forum/viewtopic.php?t=3503447 + # Title: (Hardcore, Gabber) Nosferatu - Never Met Equals [WEB] - 2006, FLAC (tracks) lossless + calls = _run_album_bundle_then_claim_tracks( + tmp_path=tmp_path, + album_name='Never Met Equals', + artist_name='Nosferatu', + release_title='(Hardcore, Gabber) Nosferatu - Never Met Equals [WEB] - 2006, FLAC (tracks) lossless', + filenames=[ + 'Nosferatu_-_Beaver_Cleaver.flac', + 'Nosferatu_-_Disorder_Of_The_Mind.flac', + 'Nosferatu_-_Knock_Out.flac', + 'Nosferatu_-_Underground_Stream_(Dione_remix).flac', + ], + tracks=[ + _Track('Beaver Cleaver', ['Nosferatu'], 'Never Met Equals', 1), + _Track('Knock Out', ['Nosferatu'], 'Never Met Equals', 2), + _Track('Disorder Of The Mind', ['Nosferatu'], 'Never Met Equals', 3), + _Track('The Underground Stream (Dione Remix)', ['Nosferatu'], 'Never Met Equals', 4), + ], + ) + + assert len(calls) == 4 + assert [matched_downloads_context[f'staging_track_{i:02d}']['track_info']['track_number'] for i in range(1, 5)] == [1, 2, 3, 4] + + +def test_rutracker_compilation_album_files_claim_duplicate_title_tracks_by_number(tmp_path): + # Real-world source: https://rutracker.org/forum/viewtopic.php?t=1599889 + # Title: (Happy Hardcore) VA - Happy Hardcore vol.1 - 1997, FLAC (tracks+.cue), lossless + calls = _run_album_bundle_then_claim_tracks( + tmp_path=tmp_path, + album_name='Happy Hardcore vol.1', + artist_name='Various Artists', + release_title='(Happy Hardcore) VA - Happy Hardcore vol.1 - 1997, FLAC (tracks+.cue), lossless', + filenames=[ + '01 - 4 Tune Fairytales - Take Me 2 Wonderland (Extended Mix).flac', + '02 - Mindtrust - The Key To Your Heart (Extended Mix).flac', + '03 - Critical Mass - Happy Generation (Trimix).flac', + '14 - Mindtrust - The Key To Your Heart (Extended Mix).flac', + '16 - Critical Mass - Happy Generation (Deaz. D. Remix).flac', + ], + tracks=[ + _Track('Take Me 2 Wonderland (Extended Mix)', ['4 Tune Fairytales'], 'Happy Hardcore vol.1', 1), + _Track('The Key To Your Heart (Extended Mix)', ['Mindtrust'], 'Happy Hardcore vol.1', 2), + _Track('Happy Generation (Trimix)', ['Critical Mass'], 'Happy Hardcore vol.1', 3), + _Track('The Key To Your Heart (Extended Mix)', ['Mindtrust'], 'Happy Hardcore vol.1', 14), + _Track('Happy Generation (Deaz. D. Remix)', ['Critical Mass'], 'Happy Hardcore vol.1', 16), + ], + ) + + assert len(calls) == 5 + assert [matched_downloads_context[f'staging_track_{i:02d}']['track_info']['track_number'] for i in range(1, 6)] == [1, 2, 3, 14, 16] From cf63a328afd987a6a05636406f068893444e8414 Mon Sep 17 00:00:00 2001 From: ramonskie Date: Thu, 2 Jul 2026 13:15:18 +0200 Subject: [PATCH 2/2] fix: retry tracker album searches --- core/download_plugins/album_bundle.py | 45 ++++++++++++++ core/download_plugins/torrent.py | 50 +++++++++------ core/download_plugins/usenet.py | 47 ++++++++------ ...test_album_bundle_rutracker_integration.py | 62 +++++++++++++++++-- 4 files changed, 163 insertions(+), 41 deletions(-) diff --git a/core/download_plugins/album_bundle.py b/core/download_plugins/album_bundle.py index 2c584a610..d1e36fdd1 100644 --- a/core/download_plugins/album_bundle.py +++ b/core/download_plugins/album_bundle.py @@ -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. diff --git a/core/download_plugins/torrent.py b/core/download_plugins/torrent.py index 54dfeb566..5c95b9b2b 100644 --- a/core/download_plugins/torrent.py +++ b/core/download_plugins/torrent.py @@ -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, @@ -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 diff --git a/core/download_plugins/usenet.py b/core/download_plugins/usenet.py index 2bf14512a..7b1bc57a9 100644 --- a/core/download_plugins/usenet.py +++ b/core/download_plugins/usenet.py @@ -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, @@ -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). @@ -540,4 +552,3 @@ def _emit(state: str, **extra) -> None: result['success'] = True result['files'] = copied return result - diff --git a/tests/downloads/test_album_bundle_rutracker_integration.py b/tests/downloads/test_album_bundle_rutracker_integration.py index f86adf8a5..38fc62455 100644 --- a/tests/downloads/test_album_bundle_rutracker_integration.py +++ b/tests/downloads/test_album_bundle_rutracker_integration.py @@ -7,9 +7,8 @@ - artist-prefixed, unnumbered files: ``Nosferatu_-_Beaver_Cleaver.flac`` - compilation files: ``01 - Artist - Title.flac`` -Both are marked strict-xfail while they document the current matcher gap. When -the album-bundle matcher learns those shapes, the XPASS will fail and remind us -to remove the marker. +They exercise real torrent album-bundle search/download/staging code while +mocking only network-facing services. """ from __future__ import annotations @@ -178,6 +177,8 @@ def _run_album_bundle_then_claim_tracks( release_title: str, filenames: list[str], tracks: list[_Track], + result_query: str | None = None, + query_log: list[str] | None = None, ) -> list[tuple[str, tuple, dict]]: state = _BatchState() batch_id = 'rutracker_batch' @@ -186,7 +187,15 @@ def _run_album_bundle_then_claim_tracks( _write_downloaded_files(download_dir, filenames) plugin = TorrentDownloadPlugin() adapter = _FakeTorrentAdapter(download_dir) - search = AsyncMock(return_value=[_make_rutracker_result(release_title)]) + + async def _search(query, *args, **kwargs): + if query_log is not None: + query_log.append(query) + if result_query is not None and query != result_query: + return [] + return [_make_rutracker_result(release_title)] + + search = AsyncMock(side_effect=_search) with patch.object(plugin, 'is_configured', return_value=True), \ patch.object(plugin._prowlarr, 'search', new=search), \ @@ -207,7 +216,7 @@ def _run_album_bundle_then_claim_tracks( assert engaged is False assert state.rows[batch_id]['album_bundle_state'] == 'staged' assert adapter.added_urls == ['magnet:?xt=urn:btih:FAKEHASH'] - search.assert_awaited_once() + assert search.await_count >= 1 staging_dir = state.rows[batch_id]['album_bundle_staging_path'] deps = ds.StagingDeps( @@ -279,3 +288,46 @@ def test_rutracker_compilation_album_files_claim_duplicate_title_tracks_by_numbe assert len(calls) == 5 assert [matched_downloads_context[f'staging_track_{i:02d}']['track_info']['track_number'] for i in range(1, 6)] == [1, 2, 3, 14, 16] + + +def test_rutracker_album_search_tries_tracker_title_shape_when_plain_query_misses(tmp_path): + query_log: list[str] = [] + + calls = _run_album_bundle_then_claim_tracks( + tmp_path=tmp_path, + album_name='Never Met Equals', + artist_name='Nosferatu', + release_title='(Hardcore, Gabber) Nosferatu - Never Met Equals [WEB] - 2006, FLAC (tracks) lossless', + result_query='Nosferatu - Never Met Equals', + query_log=query_log, + filenames=['Nosferatu_-_Beaver_Cleaver.flac'], + tracks=[_Track('Beaver Cleaver', ['Nosferatu'], 'Never Met Equals', 1)], + ) + + assert calls + assert query_log[:2] == [ + 'Nosferatu Never Met Equals', + 'Nosferatu - Never Met Equals', + ] + + +def test_rutracker_compilation_search_tries_va_alias_when_various_artists_misses(tmp_path): + query_log: list[str] = [] + + calls = _run_album_bundle_then_claim_tracks( + tmp_path=tmp_path, + album_name='Happy Hardcore vol.1', + artist_name='Various Artists', + release_title='(Happy Hardcore) VA - Happy Hardcore vol.1 - 1997, FLAC (tracks+.cue), lossless', + result_query='VA Happy Hardcore vol.1', + query_log=query_log, + filenames=['01 - 4 Tune Fairytales - Take Me 2 Wonderland (Extended Mix).flac'], + tracks=[_Track('Take Me 2 Wonderland (Extended Mix)', ['4 Tune Fairytales'], 'Happy Hardcore vol.1', 1)], + ) + + assert calls + assert query_log[:3] == [ + 'Various Artists Happy Hardcore vol.1', + 'Various Artists - Happy Hardcore vol.1', + 'VA Happy Hardcore vol.1', + ]