fix(downloads): pre-fill artist/title so search UI doesn't show download URL

Real-world test surfaced the bug — torrent results displayed
'by download?apikey=c15d6f69...&link=...' as the uploader / artist
in the basic search UI. The cause is TrackResult.__post_init__:
when artist is None it runs parse_filename_metadata on the bare
filename, and our filename starts with the indexer's download URL
(needed so download() can recover the URL later). The auto-parser
treats the URL as 'artist' and ships it to the UI.

Fix:
- core/download_plugins/torrent.py: new _parse_release_title()
  splits 'Artist - Title' / 'Artist - Album' out of the release
  title and strips trailing [FLAC] / (2016) tags. Falls back to
  ('', cleaned_title) when no dash is found, and explicitly
  rejects URL-looking strings as an extra defence. The projection
  pre-fills both artist and title on TrackResult, so __post_init__
  skips the auto-parse entirely. When the release title has no
  dash, artist defaults to the indexer name so the UI shows
  'by Indexer' instead of a URL.
- core/download_plugins/usenet.py: imports the new helper and
  applies the same fix.
- tests/test_torrent_usenet_plugins.py: 5 tests for the new
  helper (dash split, trailing-tag stripping, no-dash fallback,
  multiple-dash preservation, URL-prefix rejection). Existing
  projection tests updated to assert artist + title come through
  parsed correctly, plus a new test pinning the indexer-name
  fallback for titles without a dash so the URL-leak regression
  can't return.
This commit is contained in:
Broque Thomas 2026-05-20 18:06:23 -07:00
parent 43f3121abd
commit 478fd25dd6
3 changed files with 113 additions and 15 deletions

View file

@ -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,

View file

@ -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],

View file

@ -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: