Merge remote-tracking branch 'upstream/dev' into fix/album-completeness-fragmented-rows

This commit is contained in:
ragnarlotus 2026-06-27 13:38:10 +02:00
commit 866c8a6400
15 changed files with 382 additions and 65 deletions

View file

@ -13,9 +13,31 @@ Pure + import-safe: parsing only, no network.
from __future__ import annotations from __future__ import annotations
import re import re
from typing import Any, Optional, Tuple from typing import Any, List, Optional, Tuple
from urllib.parse import urlparse from urllib.parse import urlparse
def linked_track_id(track: Any) -> str:
"""The source track id stamped on a search result, read from
``_source_metadata['track_id']`` the field every ID-downloadable source
(Tidal, Qobuz) records. Empty string when absent. ``TrackResult`` has no
top-level ``id``, so callers must NOT use ``getattr(t, 'id')`` (that always
missed and left the pasted-link bubble a silent no-op #932)."""
meta = getattr(track, '_source_metadata', None)
if not isinstance(meta, dict):
return ''
return str(meta.get('track_id') or '')
def bubble_linked_track_first(tracks: List[Any], link_track_id: str) -> List[Any]:
"""Float the result whose source id matches a pasted link to the top so the
user sees the EXACT track they linked, not a fuzzy text-search lookalike
(#813/#932). Stable + a graceful no-op when no result carries the id."""
if not link_track_id or not tracks:
return tracks
target = str(link_track_id)
return sorted(tracks, key=lambda t: linked_track_id(t) != target)
# host substring → download source id. Only ID-downloadable streaming sources. # host substring → download source id. Only ID-downloadable streaming sources.
_HOSTS = ( _HOSTS = (
('tidal.com', 'tidal'), ('tidal.com', 'tidal'),

View file

@ -100,6 +100,7 @@ _ALBUM_ID_COLUMNS = {
'deezer': 'deezer_id', 'deezer': 'deezer_id',
'discogs': 'discogs_id', 'discogs': 'discogs_id',
'hydrabase': 'soul_id', 'hydrabase': 'soul_id',
'musicbrainz': 'musicbrainz_release_id',
} }
# Human-facing label for each source. # Human-facing label for each source.

View file

@ -211,7 +211,7 @@ def pick_canonical_release(
# core.library_reorganize._ALBUM_ID_COLUMNS — a test pins them in sync). A manual # core.library_reorganize._ALBUM_ID_COLUMNS — a test pins them in sync). A manual
# match on any of these should pin/lock the canonical version (#758); a match on # match on any of these should pin/lock the canonical version (#758); a match on
# a source the canonical tools don't read (e.g. lastfm) has no version to pin. # a source the canonical tools don't read (e.g. lastfm) has no version to pin.
CANONICAL_ALBUM_SOURCES = frozenset({'spotify', 'itunes', 'deezer', 'discogs', 'hydrabase'}) CANONICAL_ALBUM_SOURCES = frozenset({'spotify', 'itunes', 'deezer', 'discogs', 'hydrabase', 'musicbrainz'})
def should_pin_manual_canonical(entity_type: str, source: str) -> bool: def should_pin_manual_canonical(entity_type: str, source: str) -> bool:

View file

@ -1074,6 +1074,10 @@ class QobuzClient(DownloadSourcePlugin):
title=title, title=title,
album=album_name, album=album_name,
track_number=track.get('track_number'), track_number=track.get('track_number'),
# Stamp the Qobuz track id so a pasted-link manual search can float the
# exact track to the top (#932). Without this the bubble never matched
# and the linked track stayed buried among fuzzy lookalikes.
_source_metadata={'source': 'qobuz', 'track_id': str(track.get('id') or '')},
) )
# Stamp real API quality so the global ranker sees actual # Stamp real API quality so the global ranker sees actual

View file

@ -185,7 +185,11 @@ def process_watchlist_scan_automatically(automation_id=None, profile_id=None, de
'results': [], 'results': [],
'summary': {}, 'summary': {},
'error': None, 'error': None,
'cancel_requested': False 'cancel_requested': False,
# #933: stamp these so this scan lands in the History modal too —
# the scanner fills scan_track_events; persist_scan_run reads both.
'scan_run_id': datetime.now().strftime('%Y%m%d-%H%M%S'),
'scan_track_events': [],
} }
scan_results = [] scan_results = []
@ -294,6 +298,17 @@ def process_watchlist_scan_automatically(automation_id=None, profile_id=None, de
total_added_to_wishlist = deps.watchlist_scan_state.get('summary', {}).get('tracks_added_to_wishlist', 0) total_added_to_wishlist = deps.watchlist_scan_state.get('summary', {}).get('tracks_added_to_wishlist', 0)
logger.warning("Automatic watchlist scan cancelled — skipping post-scan steps") logger.warning("Automatic watchlist scan cancelled — skipping post-scan steps")
# #933: record this run in the History ledger — same helper the manual
# scan uses, so scheduled scans show up alongside manual ones.
try:
from core.watchlist.scan_history import persist_scan_run
persist_scan_run(
database, deps.watchlist_scan_state,
profile_id=profile_id, was_cancelled=was_cancelled,
)
except Exception as _hist_err:
logger.error(f"Failed to persist watchlist scan run: {_hist_err}")
# Post-scan steps — skip if cancelled # Post-scan steps — skip if cancelled
if not was_cancelled: if not was_cancelled:
# Populate discovery pool from similar artists (per-profile) # Populate discovery pool from similar artists (per-profile)

View file

@ -0,0 +1,51 @@
"""Persist a finished watchlist scan to the History ledger (#831 / #933).
Both the manual scan (``web_server.start_watchlist_scan``) and the automatic
scan (``core.watchlist.auto_scan.process_watchlist_scan_automatically``) finish
with the same ``watchlist_scan_state`` shape, but only the manual path used to
record a history row so scheduled/nightly scans never showed up in the
History modal (#933). This single helper is the shared seam: both paths call it,
so they can't drift apart again.
Pure except for the one ``database.save_watchlist_scan_run`` call the field
extraction is unit-testable with a fake database.
"""
from __future__ import annotations
from datetime import datetime
from typing import Any, Dict, Optional
def _iso(value: Any) -> Optional[str]:
"""ISO-format a datetime; pass through an already-stringified timestamp."""
if value is None:
return None
return value.isoformat() if hasattr(value, 'isoformat') else str(value)
def persist_scan_run(database: Any, state: Dict[str, Any], *,
profile_id: Any, was_cancelled: bool) -> bool:
"""Record one watchlist scan run + its track ledger from ``state``.
Reads the counts/timestamps/ledger off the live ``watchlist_scan_state`` the
scanner just finished writing, and writes a single history row. ``run_id``
comes from ``state['scan_run_id']`` (both paths stamp it); a timestamp
fallback keeps it from ever colliding if that's somehow missing. Returns the
DB call's truthiness; callers wrap in their own try/except so a history-write
failure never breaks the scan.
"""
summary = state.get('summary') or {}
run_id = state.get('scan_run_id') or datetime.now().strftime('%Y%m%d-%H%M%S')
return database.save_watchlist_scan_run(
run_id=run_id,
profile_id=profile_id if profile_id else 1,
status='cancelled' if was_cancelled else 'completed',
started_at=_iso(state.get('started_at')),
completed_at=_iso(state.get('completed_at')) or datetime.now().isoformat(),
total_artists=summary.get('total_artists', state.get('total_artists', 0)),
artists_scanned=summary.get('successful_scans', 0),
tracks_found=state.get('tracks_found_this_scan', 0),
tracks_added=state.get('tracks_added_this_scan', 0),
track_events=state.get('scan_track_events') or [],
)

View file

@ -328,6 +328,22 @@ _ALBUM_QUALIFIER_RE = re.compile(
re.IGNORECASE, re.IGNORECASE,
) )
# A trailing "- ..." clause is stripped ONLY when EVERY token in it is an edition/format
# qualifier (+ connectors / a year-ordinal). So "- Single", "- Acoustic Version", "- 2011
# Remaster" collapse to the base name, but a real distinguishing subtitle ("- Nos vies en
# Lumière", "- Live in Berlin") is kept — the bug was a blanket "- anything$" strip that
# erased subtitles and fused different editions (Sokhi: Expedition 33 OST vs Bonus Edition).
_DASH_QUALIFIER_WORD = (
r'live|acoustic|electric|instrumental|unplugged|mono|stereo|demos?|reissue|'
r'remix(?:es)?|edit(?:ed)?|radio|single|ep|lp|version|mix(?:es)?|sessions?|bootleg|'
r'covers?|original|redux|deluxe|expanded|remaster(?:ed)?|anniversary|special|'
r'edition|bonus|extended|explicit|clean|soundtrack|ost|score'
)
_TRAILING_DASH_QUALIFIER_RE = re.compile(
r'\s*-\s*(?:(?:' + _DASH_QUALIFIER_WORD + r'|the|a|and|&|\+|\d+(?:st|nd|rd|th)?)\b[\s\-]*)+$',
re.IGNORECASE,
)
def _normalize_album_for_match(name: str) -> str: def _normalize_album_for_match(name: str) -> str:
"""Return a canonical form of an album name suitable for fuzzy comparison. """Return a canonical form of an album name suitable for fuzzy comparison.
@ -347,8 +363,9 @@ def _normalize_album_for_match(name: str) -> str:
# they're almost always edition or commentary noise, not part of the # they're almost always edition or commentary noise, not part of the
# album's identifying name. # album's identifying name.
cleaned = re.sub(r'\s*[\(\[][^\)\]]*[\)\]]\s*', ' ', cleaned) cleaned = re.sub(r'\s*[\(\[][^\)\]]*[\)\]]\s*', ' ', cleaned)
# Trailing dash-clauses ("Album - Remastered", "Album - Live") # Trailing dash-clause, but ONLY when it's entirely edition/format qualifiers — a real
cleaned = re.sub(r'\s*-\s*[^-]+$', '', cleaned) # subtitle is preserved (see _TRAILING_DASH_QUALIFIER_RE).
cleaned = _TRAILING_DASH_QUALIFIER_RE.sub(' ', cleaned)
cleaned = re.sub(r'[^a-z0-9 ]+', ' ', cleaned.lower()) cleaned = re.sub(r'[^a-z0-9 ]+', ' ', cleaned.lower())
cleaned = re.sub(r'\s+', ' ', cleaned).strip() cleaned = re.sub(r'\s+', ' ', cleaned).strip()
return cleaned return cleaned
@ -382,7 +399,7 @@ def _extract_volume_marker(normalized_name: str):
return last.group(1) or last.group(2) return last.group(1) or last.group(2)
def _albums_likely_match(spotify_album: str, lib_album: str, threshold: float = 0.6) -> bool: def _albums_likely_match(spotify_album: str, lib_album: str, threshold: float = 0.85) -> bool:
"""Return True when two album names plausibly identify the same release. """Return True when two album names plausibly identify the same release.
Designed to swallow naming drift between metadata sources and the Designed to swallow naming drift between metadata sources and the
@ -406,11 +423,11 @@ def _albums_likely_match(spotify_album: str, lib_album: str, threshold: float =
return False return False
if norm_a == norm_b: if norm_a == norm_b:
return True return True
# After normalization the shorter name often becomes a prefix / # No loose substring shortcut: after qualifier-stripping, a short name being a
# substring of the longer one ("napoleon dynamite" ⊂ "napoleon # prefix of a longer one is usually a DIFFERENT edition carrying a real subtitle
# dynamite music from the motion picture" before stripping). # ("clair obscur expedition 33" ⊂ "clair obscur expedition 33 nos vies en lumiere"),
if norm_a in norm_b or norm_b in norm_a: # not naming drift. Genuine drift collapses to an EXACT match above; everything else
return True # must clear a high overall-similarity bar.
return SequenceMatcher(None, norm_a, norm_b).ratio() >= threshold return SequenceMatcher(None, norm_a, norm_b).ratio() >= threshold

View file

@ -78,3 +78,47 @@ def test_payload_non_dict_or_empty():
assert q('tidal', None) is None assert q('tidal', None) is None
assert q('tidal', {}) is None assert q('tidal', {}) is None
assert q('qobuz', 'garbage') is None assert q('qobuz', 'garbage') is None
# ── bubble the pasted-link track to the top (#932) ──
from types import SimpleNamespace
from core.downloads.track_link import linked_track_id, bubble_linked_track_first
def _result(track_id=None):
"""Mimic a TrackResult: NO top-level `id`, the source id lives in
_source_metadata['track_id'] (or absent entirely)."""
meta = {'source': 'qobuz', 'track_id': track_id} if track_id is not None else None
return SimpleNamespace(_source_metadata=meta, title='t')
def test_linked_track_id_reads_source_metadata():
assert linked_track_id(_result('296427754')) == '296427754'
def test_linked_track_id_empty_when_absent():
# the exact #932 trap: there is no top-level `id`, so getattr(t,'id') would miss.
r = _result()
assert not hasattr(r, 'id')
assert linked_track_id(r) == ''
def test_bubble_floats_exact_track_to_top():
fuzzy_a, exact, fuzzy_b = _result('111'), _result('296427754'), _result('222')
out = bubble_linked_track_first([fuzzy_a, exact, fuzzy_b], '296427754')
assert out[0] is exact # exact track surfaced first
assert out[1:] == [fuzzy_a, fuzzy_b] # stable order for the rest
def test_bubble_handles_int_vs_str_id():
out = bubble_linked_track_first([_result('999'), _result('296427754')], 296427754)
assert linked_track_id(out[0]) == '296427754'
def test_bubble_noop_when_nothing_matches_or_empty():
a, b = _result('1'), _result('2')
assert bubble_linked_track_first([a, b], '296427754') == [a, b] # unchanged
assert bubble_linked_track_first([], '296427754') == []
assert bubble_linked_track_first([a, b], '') == [a, b]

View file

@ -1,4 +1,3 @@
import sys
import types import types
@ -10,35 +9,10 @@ class _DummyConfigManager:
return "plex" return "plex"
if "spotipy" not in sys.modules: # NOTE: deliberately no sys.modules stubbing of spotipy / config.settings here. Both import
spotipy = types.ModuleType("spotipy") # fine in the test env, and faking them globally (with no teardown) leaked into other files —
# it left a config.settings with no ConfigManager, intermittently breaking
class _DummySpotify: # tests/test_config_save_retry depending on collection order.
def __init__(self, *args, **kwargs):
pass
oauth2 = types.ModuleType("spotipy.oauth2")
class _DummyOAuth:
def __init__(self, *args, **kwargs):
pass
spotipy.Spotify = _DummySpotify
oauth2.SpotifyOAuth = _DummyOAuth
oauth2.SpotifyClientCredentials = _DummyOAuth
spotipy.oauth2 = oauth2
sys.modules["spotipy"] = spotipy
sys.modules["spotipy.oauth2"] = oauth2
if "config.settings" not in sys.modules:
config_pkg = types.ModuleType("config")
settings_mod = types.ModuleType("config.settings")
settings_mod.config_manager = _DummyConfigManager()
config_pkg.settings = settings_mod
sys.modules["config"] = config_pkg
sys.modules["config.settings"] = settings_mod
from core.repair_jobs.album_completeness import AlbumCompletenessJob from core.repair_jobs.album_completeness import AlbumCompletenessJob
import core.repair_jobs.album_completeness as album_completeness_module import core.repair_jobs.album_completeness as album_completeness_module

View file

@ -24,7 +24,7 @@ from database.music_database import MusicDatabase
# should_pin_manual_canonical — pure # should_pin_manual_canonical — pure
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@pytest.mark.parametrize('source', ['spotify', 'itunes', 'deezer', 'discogs', 'hydrabase']) @pytest.mark.parametrize('source', ['spotify', 'itunes', 'deezer', 'discogs', 'hydrabase', 'musicbrainz'])
def test_pins_album_on_recognised_source(source): def test_pins_album_on_recognised_source(source):
assert should_pin_manual_canonical('album', source) is True assert should_pin_manual_canonical('album', source) is True
@ -34,7 +34,7 @@ def test_does_not_pin_non_album(entity):
assert should_pin_manual_canonical(entity, 'spotify') is False assert should_pin_manual_canonical(entity, 'spotify') is False
@pytest.mark.parametrize('source', ['lastfm', 'genius', 'musicbrainz', 'audiodb', 'tidal']) @pytest.mark.parametrize('source', ['lastfm', 'genius', 'audiodb', 'tidal'])
def test_does_not_pin_source_canonical_cant_read(source): def test_does_not_pin_source_canonical_cant_read(source):
# No album-version data the canonical tools read → nothing to pin. # No album-version data the canonical tools read → nothing to pin.
assert should_pin_manual_canonical('album', source) is False assert should_pin_manual_canonical('album', source) is False

View file

@ -7,7 +7,11 @@ canonical_source/canonical_album_id, and an explicit user source pick
from __future__ import annotations from __future__ import annotations
from unittest.mock import MagicMock
import core.library_reorganize as lr import core.library_reorganize as lr
import core.metadata.registry as metadata_registry
from core.musicbrainz_search import MusicBrainzSearchClient
def _patch_fetch(monkeypatch, tracklists): def _patch_fetch(monkeypatch, tracklists):
@ -73,3 +77,82 @@ def test_no_canonical_unchanged(monkeypatch):
album_data = {"spotify_album_id": "sp1"} album_data = {"spotify_album_id": "sp1"}
source, _, _ = lr._resolve_source(album_data, "spotify") source, _, _ = lr._resolve_source(album_data, "spotify")
assert source == "spotify" assert source == "spotify"
def test_musicbrainz_release_id_is_used_by_priority_walk(monkeypatch):
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.get_release_group.return_value = None
client._client.get_release.return_value = {
"id": "mb-release-1",
"title": "Test Album",
"date": "2024-01-01",
"artist-credit": [{"name": "Test Artist"}],
"release-group": {
"id": "mb-group-1",
"primary-type": "Album",
"secondary-types": [],
},
"media": [
{
"position": 1,
"tracks": [
{
"id": "track-1",
"number": "1",
"position": 1,
"length": 180000,
"recording": {
"id": "recording-1",
"title": "Test Track",
"artist-credit": [{"name": "Test Artist"}],
"length": 180000,
},
},
],
},
],
}
monkeypatch.setattr(
metadata_registry,
"get_musicbrainz_client",
lambda *args, **kwargs: client,
)
monkeypatch.setattr(
lr,
"get_source_priority",
lambda primary: ["musicbrainz", "spotify"],
)
album_data = {
"musicbrainz_release_id": "mb-release-1",
}
source, api_album, items = lr._resolve_source(
album_data,
"musicbrainz",
)
assert source == "musicbrainz"
assert api_album["id"] == "mb-release-1"
assert api_album["name"] == "Test Album"
assert items == api_album["tracks"]
assert items == [
{
"id": "recording-1",
"name": "Test Track",
"artists": [{"name": "Test Artist"}],
"duration_ms": 180000,
"track_number": 1,
"disc_number": 1,
},
]
client._client.get_release_group.assert_any_call(
"mb-release-1",
includes=["releases", "artist-credits"],
)
client._client.get_release.assert_any_call(
"mb-release-1",
includes=["recordings", "artist-credits", "release-groups"],
)

View file

@ -156,6 +156,13 @@ def test_compilation_score_explanation() -> None:
("Inception (Music From The Motion Picture)", "Inception Soundtrack"), ("Inception (Music From The Motion Picture)", "Inception Soundtrack"),
# Substring containment # Substring containment
("Random Access Memories", "Random Access Memories (Bonus Edition)"), ("Random Access Memories", "Random Access Memories (Bonus Edition)"),
# Dash-suffixed qualifiers must still collapse — these are the SAME album, so
# treating them as different would re-wishlist/redownload forever (the failure the
# original blanket strip guarded against; the narrowed strip must keep covering it).
("Album Name", "Album Name - Single"),
("Album Name", "Album Name - Acoustic Version"),
("Hotel California", "Hotel California - 2013 Remaster"),
("Album", "Album - The Remixes"),
], ],
) )
def test_likely_match_positive(spotify_name, lib_name) -> None: def test_likely_match_positive(spotify_name, lib_name) -> None:
@ -176,12 +183,29 @@ def test_likely_match_positive(spotify_name, lib_name) -> None:
("Abbey Road", "Sgt. Pepper's Lonely Hearts Club Band"), ("Abbey Road", "Sgt. Pepper's Lonely Hearts Club Band"),
# Same word in title but different album # Same word in title but different album
("Greatest Hits Volume 1", "Greatest Hits Volume 2"), ("Greatest Hits Volume 1", "Greatest Hits Volume 2"),
# Sokhi: distinct editions of the same franchise — the OST vs a bonus edition
# with a real subtitle. USED to collapse to the same normalized name (the blanket
# trailing-dash strip removed "- Nos vies en Lumière"), so the watchlist marked
# unowned OST tracks as owned via the bonus edition. Must be DIFFERENT albums.
("Clair Obscur: Expedition 33: Original Soundtrack",
"Clair Obscur: Expedition 33 - Nos vies en Lumière (Bonus Edition)"),
# A real subtitle after a dash must not be stripped down to the base name.
("The Album", "The Album - A Whole Different Subtitle"),
], ],
) )
def test_likely_match_negative(spotify_name, lib_name) -> None: def test_likely_match_negative(spotify_name, lib_name) -> None:
assert not _albums_likely_match(spotify_name, lib_name) assert not _albums_likely_match(spotify_name, lib_name)
def test_real_subtitle_after_dash_is_preserved() -> None:
# the regression's root cause: a meaningful subtitle must survive normalization,
# while a recognized qualifier after a dash ("- Live", "- 2011") still collapses.
assert _normalize_album_for_match("Clair Obscur: Expedition 33 - Nos vies en Lumière") \
!= _normalize_album_for_match("Clair Obscur: Expedition 33")
assert _normalize_album_for_match("Some Album - Live") == _normalize_album_for_match("Some Album")
assert _normalize_album_for_match("Some Album - 2011") == _normalize_album_for_match("Some Album")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# _albums_likely_match — defensive cases # _albums_likely_match — defensive cases
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View file

@ -47,6 +47,62 @@ def test_resave_is_idempotent_on_run_id(db):
assert len(runs) == 1 and runs[0]['tracks_added'] == 11 assert len(runs) == 1 and runs[0]['tracks_added'] == 11
# ── persist_scan_run: the shared seam both scan paths use (#933) ──
from datetime import datetime
from core.watchlist.scan_history import persist_scan_run
def _state(**over):
"""A watchlist_scan_state as the scanner leaves it when a scan finishes."""
s = {
'scan_run_id': 'auto-run-1',
'started_at': datetime(2026, 6, 26, 2, 0, 0),
'completed_at': datetime(2026, 6, 26, 2, 4, 0),
'total_artists': 40,
'tracks_found_this_scan': 7,
'tracks_added_this_scan': 3,
'scan_track_events': _events(n_added=3, n_skipped=1),
'summary': {'total_artists': 40, 'successful_scans': 40,
'new_tracks_found': 7, 'tracks_added_to_wishlist': 3},
}
s.update(over)
return s
def test_persist_scan_run_records_a_history_row(db):
# the #933 fix: an automatic (all-profiles → profile_id=None) scan must land in History.
assert persist_scan_run(db, _state(), profile_id=None, was_cancelled=False) is True
runs = db.get_watchlist_scan_runs()
assert len(runs) == 1
r = runs[0]
assert (r['run_id'], r['status'], r['tracks_found'], r['tracks_added']) == \
('auto-run-1', 'completed', 7, 3)
assert r['profile_id'] == 1 # None coerced to a concrete profile, never NULL
# the per-run ledger came through too
assert [e['status'] for e in db.get_watchlist_scan_run_events('auto-run-1')] == \
['added', 'added', 'added', 'skipped']
def test_persist_scan_run_cancelled_status(db):
persist_scan_run(db, _state(scan_run_id='c1'), profile_id=2, was_cancelled=True)
assert db.get_watchlist_scan_runs()[0]['status'] == 'cancelled'
def test_persist_scan_run_accepts_datetime_or_iso_string(db):
# state timestamps may be datetime (auto path) or already-iso strings — both must persist.
persist_scan_run(db, _state(scan_run_id='dt', completed_at='2026-06-26T02:09:00'),
profile_id=1, was_cancelled=False)
assert db.get_watchlist_scan_runs()[0]['run_id'] == 'dt'
def test_persist_scan_run_tolerates_sparse_state(db):
# a bare/early-finished state must not raise — history-write must never break a scan.
assert persist_scan_run(db, {'scan_run_id': 'sparse'}, profile_id=None, was_cancelled=False)
assert db.get_watchlist_scan_runs()[0]['tracks_added'] == 0
def test_prune_keeps_most_recent(db): def test_prune_keeps_most_recent(db):
for i in range(1, 8): for i in range(1, 8):
db.save_watchlist_scan_run( db.save_watchlist_scan_run(

View file

@ -81,6 +81,11 @@ class _FakeDB:
self._watchlist_artists = watchlist_artists or [] self._watchlist_artists = watchlist_artists or []
self._lb_profiles = lb_profiles or [] self._lb_profiles = lb_profiles or []
self.database_path = '/tmp/test.db' self.database_path = '/tmp/test.db'
self.scan_runs_saved = []
def save_watchlist_scan_run(self, **kwargs):
self.scan_runs_saved.append(kwargs)
return True
def get_all_profiles(self): def get_all_profiles(self):
return self._profiles return self._profiles
@ -237,6 +242,39 @@ def test_successful_scan_runs_post_steps(patched_modules):
assert deps._state_ref[0]['summary']['new_tracks_found'] == 2 assert deps._state_ref[0]['summary']['new_tracks_found'] == 2
def test_successful_scan_records_history_run(patched_modules):
"""#933: the automatic scan must persist a History row (it used to skip this,
so only manual scans appeared in History)."""
scanner, db = patched_modules
deps = _build_deps()
autosc.process_watchlist_scan_automatically(automation_id='a1', deps=deps)
assert len(db.scan_runs_saved) == 1 # exactly one row, no double-record
saved = db.scan_runs_saved[0]
assert saved['status'] == 'completed'
assert saved['artists_scanned'] == 1 # one successful _ScanResult
assert saved['run_id'] # a run id was stamped
def test_cancelled_scan_still_records_history_run(patched_modules, monkeypatch):
"""A cancelled scan is recorded too, with status='cancelled'."""
scanner, db = patched_modules
deps = _build_deps()
# Make the scan observe a cancel request mid-run.
orig = scanner.scan_watchlist_artists
def _cancel_during(artists, *, scan_state, progress_callback, cancel_check):
scan_state['cancel_requested'] = True
return orig(artists, scan_state=scan_state, progress_callback=progress_callback,
cancel_check=cancel_check)
monkeypatch.setattr(scanner, 'scan_watchlist_artists', _cancel_during)
autosc.process_watchlist_scan_automatically(automation_id='a1', deps=deps)
assert len(db.scan_runs_saved) == 1
assert db.scan_runs_saved[0]['status'] == 'cancelled'
def test_completion_emits_automation_event(patched_modules): def test_completion_emits_automation_event(patched_modules):
"""Successful scan emits 'watchlist_scan_completed' on automation_engine.""" """Successful scan emits 'watchlist_scan_completed' on automation_engine."""
scanner, _ = patched_modules scanner, _ = patched_modules

View file

@ -7533,13 +7533,13 @@ def manual_search_for_task(task_id):
"error": error, "error": error,
}) + "\n" }) + "\n"
continue continue
# Pasted-link exact match: bubble the track whose id matches # Pasted-link exact match: bubble the track whose source id
# the link to the top so the user sees the exact version # matches the link to the top so the user sees the exact version
# first (graceful no-op if ids don't line up). # first. Reads _source_metadata['track_id'] (TrackResult has no
# top-level id) — the old getattr(t,'id') always missed (#932).
if src_name == link_source and link_track_id and tracks: if src_name == link_source and link_track_id and tracks:
tracks = sorted( from core.downloads.track_link import bubble_linked_track_first
tracks, tracks = bubble_linked_track_first(tracks, link_track_id)
key=lambda t: str(getattr(t, 'id', '')) != str(link_track_id))
serialized = [] serialized = []
for t in tracks: for t in tracks:
s = _serialize_candidate(t, source_override=src_name) s = _serialize_candidate(t, source_override=src_name)
@ -28223,22 +28223,10 @@ def start_watchlist_scan():
# #831 round 2: persist this run + its track ledger so the # #831 round 2: persist this run + its track ledger so the
# Watchlist History modal can show what every past scan did. # Watchlist History modal can show what every past scan did.
try: try:
_state = watchlist_scan_state from core.watchlist.scan_history import persist_scan_run
get_database().save_watchlist_scan_run( persist_scan_run(
run_id=_state.get('scan_run_id') or datetime.now().strftime('%Y%m%d-%H%M%S'), get_database(), watchlist_scan_state,
profile_id=scan_profile_id, profile_id=scan_profile_id, was_cancelled=was_cancelled,
status='cancelled' if was_cancelled else 'completed',
started_at=(_state.get('started_at').isoformat()
if _state.get('started_at') else None),
completed_at=(_state.get('completed_at') or datetime.now()).isoformat()
if not isinstance(_state.get('completed_at'), str)
else _state.get('completed_at'),
total_artists=(_state.get('summary') or {}).get('total_artists',
_state.get('total_artists', 0)),
artists_scanned=(_state.get('summary') or {}).get('successful_scans', 0),
tracks_found=_state.get('tracks_found_this_scan', 0),
tracks_added=_state.get('tracks_added_this_scan', 0),
track_events=_state.get('scan_track_events') or [],
) )
except Exception as _hist_err: except Exception as _hist_err:
logger.error(f"Failed to persist watchlist scan run: {_hist_err}") logger.error(f"Failed to persist watchlist scan run: {_hist_err}")