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:
parent
43f3121abd
commit
478fd25dd6
3 changed files with 113 additions and 15 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in a new issue