diff --git a/core/download_plugins/torrent.py b/core/download_plugins/torrent.py index a5487422..414b31d5 100644 --- a/core/download_plugins/torrent.py +++ b/core/download_plugins/torrent.py @@ -164,6 +164,7 @@ class TorrentDownloadPlugin(DownloadSourcePlugin): continue filename = f"{download_url}{_FILENAME_SEP}{result.title}" quality = _guess_quality_from_title(result.title) + parsed_artist, parsed_title = _parse_release_title(result.title) tr = TrackResult( username='torrent', filename=filename, @@ -177,9 +178,13 @@ class TorrentDownloadPlugin(DownloadSourcePlugin): free_upload_slots=max(1, result.seeders or 0), upload_speed=0, queue_length=0, - artist=None, - title=result.title, - album=None, + # Pre-fill artist + title so TrackResult.__post_init__ + # doesn't auto-parse the filename — our filename starts + # with the indexer download URL, which would otherwise + # show up as "by download?apikey=..." in the UI. + artist=parsed_artist or result.indexer_name or 'Torrent', + title=parsed_title or result.title, + album=parsed_title or None, track_number=None, _source_metadata={ 'indexer': result.indexer_name, @@ -194,8 +199,8 @@ class TorrentDownloadPlugin(DownloadSourcePlugin): albums.append(AlbumResult( username='torrent', album_path=f"torrent/{result.guid}", - album_title=result.title, - artist=None, + album_title=parsed_title or result.title, + artist=parsed_artist or None, track_count=1, # unknown until download finishes total_size=result.size, tracks=[tr], @@ -405,6 +410,37 @@ def _decode_filename(filename: str) -> Tuple[Optional[str], str]: return (url, display) +def _parse_release_title(title: str) -> Tuple[str, str]: + """Split a release title into ``(artist, title)`` using the + ``Artist - Title`` / ``Artist - Album`` convention almost every + indexer follows. Returns ``('', title)`` when no dash is found. + + Without this, ``TrackResult.__post_init__`` runs the bare + filename through ``parse_filename_metadata`` — and our filename + starts with the indexer's download URL, so the auto-parser + extracts garbage like ``download?apikey=...`` as the artist + and shows it in the search-result UI's "by" line. Pre-filling + the artist field short-circuits the auto-parse. + """ + if not title: + return ('', '') + # Strip common quality / format tags so the dash split doesn't + # eat them — "Artist - Album [FLAC] (2020)" → "Artist", "Album". + cleaned = re.sub(r'\s*[\[\(][^\]\)]*[\]\)]\s*$', '', title.strip()) + # Look for the FIRST " - " (or "-" surrounded by content). Some + # release titles have multiple dashes (subtitle dashes); the + # first split is the artist/work boundary. + parts = re.split(r'\s+-\s+|\s+-(?=\S)|(?<=\S)-\s+', cleaned, maxsplit=1) + if len(parts) == 2: + artist = parts[0].strip() + rest = parts[1].strip() + # Reject obvious non-artist prefixes (URLs, hashes, single + # punctuation) so we don't propagate garbage. + if artist and not re.match(r'^https?:|^[a-f0-9]{32,}$', artist): + return (artist, rest or cleaned) + return ('', cleaned) + + def _guess_quality_from_title(title: str) -> str: """Read the quality hint from a release title — most music torrents put the encoding right in the name (FLAC, MP3 320, diff --git a/core/download_plugins/usenet.py b/core/download_plugins/usenet.py index fca9b01d..8442db43 100644 --- a/core/download_plugins/usenet.py +++ b/core/download_plugins/usenet.py @@ -27,6 +27,7 @@ from core.download_plugins.torrent import ( _decode_filename, _guess_quality_from_title, _parse_indexer_id_filter, + _parse_release_title, _row_to_status, _COMPLETE_STATES, _FILENAME_SEP, @@ -114,6 +115,7 @@ class UsenetDownloadPlugin(DownloadSourcePlugin): continue filename = f"{result.download_url}{_FILENAME_SEP}{result.title}" quality = _guess_quality_from_title(result.title) + parsed_artist, parsed_title = _parse_release_title(result.title) tr = TrackResult( username='usenet', filename=filename, @@ -126,9 +128,12 @@ class UsenetDownloadPlugin(DownloadSourcePlugin): free_upload_slots=1, upload_speed=0, queue_length=0, - artist=None, - title=result.title, - album=None, + # Pre-fill artist + title so TrackResult.__post_init__ + # doesn't auto-parse the filename — same URL-in-filename + # gotcha as the torrent plugin. + artist=parsed_artist or result.indexer_name or 'Usenet', + title=parsed_title or result.title, + album=parsed_title or None, track_number=None, _source_metadata={ 'indexer': result.indexer_name, @@ -141,8 +146,8 @@ class UsenetDownloadPlugin(DownloadSourcePlugin): albums.append(AlbumResult( username='usenet', album_path=f"usenet/{result.guid}", - album_title=result.title, - artist=None, + album_title=parsed_title or result.title, + artist=parsed_artist or None, track_count=1, total_size=result.size, tracks=[tr], diff --git a/tests/test_torrent_usenet_plugins.py b/tests/test_torrent_usenet_plugins.py index 4a9818a2..bf2f856b 100644 --- a/tests/test_torrent_usenet_plugins.py +++ b/tests/test_torrent_usenet_plugins.py @@ -22,6 +22,7 @@ from core.download_plugins.torrent import ( _decode_filename, _FILENAME_SEP, _guess_quality_from_title, + _parse_release_title, ) from core.download_plugins.usenet import UsenetDownloadPlugin from core.prowlarr_client import ProwlarrSearchResult @@ -71,6 +72,45 @@ def test_guess_quality_from_title() -> None: assert _guess_quality_from_title('') == 'mp3' +def test_parse_release_title_splits_artist_dash_title() -> None: + """Most release titles follow 'Artist - Title' / 'Artist - Album'.""" + assert _parse_release_title('Danny Brown - Atrocity Exhibition') == ('Danny Brown', 'Atrocity Exhibition') + assert _parse_release_title('Kendrick Lamar - DAMN.') == ('Kendrick Lamar', 'DAMN.') + + +def test_parse_release_title_strips_trailing_tags() -> None: + """Quality / year tags at the end shouldn't pollute the title.""" + artist, title = _parse_release_title('Danny Brown - Atrocity Exhibition [FLAC]') + assert artist == 'Danny Brown' + assert title == 'Atrocity Exhibition' + artist, title = _parse_release_title('Danny Brown - Atrocity Exhibition (2016)') + assert artist == 'Danny Brown' + assert title == 'Atrocity Exhibition' + + +def test_parse_release_title_handles_no_dash() -> None: + """Some indexers post bare titles. Caller should fall back to + the indexer name as the 'artist' field.""" + artist, title = _parse_release_title('JustATitle') + assert artist == '' + assert title == 'JustATitle' + + +def test_parse_release_title_handles_dashes_in_title() -> None: + """Track titles can themselves contain dashes — only split on + the FIRST one so subtitles survive.""" + artist, title = _parse_release_title('Artist - Title - Live Version') + assert artist == 'Artist' + assert title == 'Title - Live Version' + + +def test_parse_release_title_rejects_url_prefix() -> None: + """Defensive: if a URL somehow lands in the title field, refuse + to call it an artist.""" + artist, title = _parse_release_title('https://example.com/x - Album') + assert artist == '' + + def test_adapter_state_mapping_covers_complete_states() -> None: assert _adapter_state_to_display('downloading') == 'InProgress, Downloading' assert _adapter_state_to_display('seeding') == 'Completed, Succeeded' @@ -88,7 +128,7 @@ def test_adapter_state_mapping_covers_complete_states() -> None: def _make_torrent_result(**overrides) -> ProwlarrSearchResult: base = dict( - guid='guid-1', title='Some Album FLAC', indexer_id=3, + guid='guid-1', title='Danny Brown - Atrocity Exhibition [FLAC]', indexer_id=3, indexer_name='Indexer', protocol='torrent', download_url='https://x/y.torrent', magnet_uri=None, info_url=None, size=500_000_000, seeders=12, leechers=3, @@ -107,7 +147,8 @@ def test_torrent_project_results_drops_non_torrent_protocol() -> None: ] tracks, albums = plugin._project_results(results) assert len(tracks) == 1 - assert tracks[0].title == 'Some Album FLAC' + assert tracks[0].title == 'Atrocity Exhibition' + assert tracks[0].artist == 'Danny Brown' assert len(albums) == 1 @@ -133,7 +174,20 @@ def test_torrent_project_results_encodes_url_and_title_in_filename() -> None: tracks, _ = plugin._project_results([_make_torrent_result()]) url, display = _decode_filename(tracks[0].filename) assert url == 'https://x/y.torrent' - assert display == 'Some Album FLAC' + assert display == 'Danny Brown - Atrocity Exhibition [FLAC]' + + +def test_torrent_project_falls_back_to_indexer_name_when_title_lacks_dash() -> None: + """When the title has no 'Artist -' prefix we'd auto-parse the + filename (which starts with the indexer download URL) and end + up showing the URL in the UI's 'by' field. Pre-filling artist + with the indexer name avoids that.""" + plugin = TorrentDownloadPlugin() + tracks, _ = plugin._project_results([_make_torrent_result(title='JustATitle')]) + assert tracks[0].artist == 'Indexer' + # And the URL is definitely not the artist. + assert 'http' not in tracks[0].artist + assert '||' not in tracks[0].artist def test_torrent_project_results_neutralizes_soulseek_specific_fields() -> None: @@ -252,7 +306,7 @@ def test_torrent_get_all_returns_status_objects() -> None: def _make_usenet_result(**overrides) -> ProwlarrSearchResult: base = dict( - guid='guid-u', title='Some Album', indexer_id=5, + guid='guid-u', title='Some Artist - Some Album', indexer_id=5, indexer_name='UsenetIndexer', protocol='usenet', download_url='https://x/y.nzb', magnet_uri=None, info_url=None, size=400_000_000, seeders=None, leechers=None, @@ -285,7 +339,10 @@ def test_usenet_project_encodes_url_in_filename() -> None: tracks, _ = plugin._project_results([_make_usenet_result()]) url, display = _decode_filename(tracks[0].filename) assert url == 'https://x/y.nzb' - assert display == 'Some Album' + assert display == 'Some Artist - Some Album' + # Artist + title should be parsed out, not auto-extracted from filename. + assert tracks[0].artist == 'Some Artist' + assert tracks[0].title == 'Some Album' def test_usenet_finalize_picks_first_audio_file(tmp_path: Path) -> None: