Adds torrent/usenet as release-oriented download sources with album-bundle staging, live progress reporting, and post-processing that selects the requested audio file from completed releases instead of blindly importing the first file. Keeps album-bundle behavior gated to single-source torrent/usenet album downloads, excludes release sources from hybrid album per-track searches, and allows hybrid non-album tracks to use release results safely. Improves staged-release matching for featured/bonus track filenames while preserving version mismatches, records torrent/usenet provenance in library history, and updates service/status UI labels. Covers the flow with focused lifecycle, status, staging, validation, task worker, post-processing, and import side-effect tests.
169 lines
6.2 KiB
Python
169 lines
6.2 KiB
Python
"""Tests for core/downloads/validation.py — SoundCloud preview filter.
|
|
|
|
The SoundCloud anonymous tier serves a ~30s preview clip for tracks
|
|
gated behind Go+ / login. ``filter_soundcloud_previews`` drops these
|
|
candidates before they reach the matcher, the modal cache, or the
|
|
manual-pick download path.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from typing import Optional
|
|
|
|
from core.downloads import validation
|
|
from core.downloads.validation import filter_soundcloud_previews, get_valid_candidates
|
|
|
|
|
|
@dataclass
|
|
class _Track:
|
|
duration_ms: int
|
|
name: str = 'Song'
|
|
artists: tuple[str, ...] = ('Artist',)
|
|
|
|
|
|
@dataclass
|
|
class _Candidate:
|
|
username: str
|
|
duration: Optional[int] # milliseconds
|
|
title: str = 'Song'
|
|
artist: str = 'Artist'
|
|
filename: str = 'candidate'
|
|
|
|
|
|
class _MatchingEngine:
|
|
def score_track_match(self, **kwargs):
|
|
return 0.99, 'core_title_match'
|
|
|
|
def normalize_string(self, text):
|
|
return (text or '').lower()
|
|
|
|
|
|
def test_drops_soundcloud_30s_preview_when_expected_long():
|
|
"""A 30s SC candidate against a 5-minute expected track is the
|
|
canonical preview-snippet case — must be dropped."""
|
|
expected = _Track(duration_ms=338_000) # ~5:38
|
|
cands = [
|
|
_Candidate(username='soundcloud', duration=30_000, title='Preview'),
|
|
_Candidate(username='soundcloud', duration=338_000, title='Real'),
|
|
]
|
|
result = filter_soundcloud_previews(cands, expected)
|
|
assert len(result) == 1
|
|
assert result[0].title == 'Real'
|
|
|
|
|
|
def test_drops_under_half_expected_duration():
|
|
"""SC candidate at 100s against 300s expected = clearly truncated /
|
|
wrong content. Must be dropped even if not at the 30s boundary."""
|
|
expected = _Track(duration_ms=300_000)
|
|
cand = _Candidate(username='soundcloud', duration=100_000)
|
|
assert filter_soundcloud_previews([cand], expected) == []
|
|
|
|
|
|
def test_keeps_soundcloud_when_expected_track_is_short():
|
|
"""Genuinely short SC tracks (intros, sound effects, sub-minute
|
|
songs) must pass through when the expected track is also short.
|
|
Filter only kicks in when expected > 60s."""
|
|
expected = _Track(duration_ms=45_000) # 45s expected
|
|
cand = _Candidate(username='soundcloud', duration=30_000)
|
|
result = filter_soundcloud_previews([cand], expected)
|
|
assert result == [cand]
|
|
|
|
|
|
def test_does_not_filter_non_soundcloud_sources():
|
|
"""A 30s candidate from another streaming source isn't a SoundCloud
|
|
preview — leave it for the generic matching engine to score."""
|
|
expected = _Track(duration_ms=338_000)
|
|
yt = _Candidate(username='youtube', duration=30_000)
|
|
tidal = _Candidate(username='tidal', duration=30_000)
|
|
assert filter_soundcloud_previews([yt, tidal], expected) == [yt, tidal]
|
|
|
|
|
|
def test_returns_input_unchanged_without_expected_duration():
|
|
"""Without a Spotify-track / expected duration we can't reason
|
|
about previews — pass everything through."""
|
|
cands = [
|
|
_Candidate(username='soundcloud', duration=30_000),
|
|
_Candidate(username='soundcloud', duration=300_000),
|
|
]
|
|
assert filter_soundcloud_previews(cands, None) == cands
|
|
assert filter_soundcloud_previews(cands, _Track(duration_ms=0)) == cands
|
|
|
|
|
|
def test_empty_input_returns_empty_list():
|
|
assert filter_soundcloud_previews([], _Track(duration_ms=200_000)) == []
|
|
|
|
|
|
def test_keeps_soundcloud_candidate_at_threshold():
|
|
"""Boundary check: 35s candidate against 200s expected — exactly
|
|
at the 35s preview boundary, but 35s is also above
|
|
expected*0.5 (100s) check (35 < 100, so still drops). Use a
|
|
higher value to confirm the just-above threshold passes."""
|
|
expected = _Track(duration_ms=200_000) # 200s
|
|
# 110s passes both checks: > 35s AND > 100s (half of 200s)
|
|
cand = _Candidate(username='soundcloud', duration=110_000)
|
|
assert filter_soundcloud_previews([cand], expected) == [cand]
|
|
|
|
|
|
def test_rejects_tidal_candidate_that_would_fail_integrity_duration(monkeypatch):
|
|
"""Structured sources should not download candidates that post-processing
|
|
will immediately quarantine for the same duration mismatch."""
|
|
monkeypatch.setattr(validation, 'matching_engine', _MatchingEngine())
|
|
expected = _Track(duration_ms=338_000)
|
|
wrong_tidal = _Candidate(username='tidal', duration=30_000)
|
|
|
|
assert get_valid_candidates([wrong_tidal], expected, 'Artist Song') == []
|
|
|
|
|
|
def test_keeps_tidal_candidate_inside_integrity_duration_tolerance(monkeypatch):
|
|
monkeypatch.setattr(validation, 'matching_engine', _MatchingEngine())
|
|
expected = _Track(duration_ms=338_000)
|
|
tidal = _Candidate(username='tidal', duration=340_000)
|
|
|
|
result = get_valid_candidates([tidal], expected, 'Artist Song')
|
|
|
|
assert result == [tidal]
|
|
|
|
|
|
def test_rejects_torrent_title_match_from_wrong_artist(monkeypatch):
|
|
monkeypatch.setattr(validation, 'matching_engine', _MatchingEngine())
|
|
expected = _Track(duration_ms=180_000, name='The Man I Need', artists=('Olivia Dean',))
|
|
wrong_artist = _Candidate(
|
|
username='torrent',
|
|
duration=None,
|
|
title='The Man I Need',
|
|
artist='Tinkabelle',
|
|
)
|
|
|
|
assert get_valid_candidates([wrong_artist], expected, 'Olivia Dean The Man I Need') == []
|
|
|
|
|
|
def test_keeps_torrent_title_match_from_expected_artist(monkeypatch):
|
|
monkeypatch.setattr(validation, 'matching_engine', _MatchingEngine())
|
|
expected = _Track(duration_ms=180_000, name='The Man I Need', artists=('Olivia Dean',))
|
|
correct_artist = _Candidate(
|
|
username='torrent',
|
|
duration=None,
|
|
title='The Man I Need',
|
|
artist='Olivia Dean',
|
|
)
|
|
|
|
result = get_valid_candidates([correct_artist], expected, 'Olivia Dean The Man I Need')
|
|
|
|
assert result == [correct_artist]
|
|
|
|
|
|
def test_keeps_torrent_title_match_when_artist_is_indexer_fallback(monkeypatch):
|
|
monkeypatch.setattr(validation, 'matching_engine', _MatchingEngine())
|
|
expected = _Track(duration_ms=180_000, name='The Man I Need', artists=('Olivia Dean',))
|
|
candidate = _Candidate(
|
|
username='torrent',
|
|
duration=None,
|
|
title='The Man I Need',
|
|
artist='Indexer',
|
|
)
|
|
candidate._source_metadata = {'indexer': 'Indexer'}
|
|
|
|
result = get_valid_candidates([candidate], expected, 'Olivia Dean The Man I Need')
|
|
|
|
assert result == [candidate]
|