From 651b904e92f0a2b0ce0fc3a426b9966075fc5658 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Sat, 13 Jun 2026 08:07:20 -0700 Subject: [PATCH] =?UTF-8?q?Watchlist:=20per-artist=20'auto-download'=20tog?= =?UTF-8?q?gle=20(follow-only)=20=E2=80=94=20off=20=3D=20discover/surface?= =?UTF-8?q?=20releases=20but=20skip=20the=20wishlist=20add;=20default=20on?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/watchlist_scanner.py | 15 ++++++--- database/music_database.py | 30 ++++++++++++++++- tests/test_watchlist_auto_download.py | 42 ++++++++++++++++++++++++ tests/test_watchlist_scanner_scan.py | 46 +++++++++++++++++++++++++++ web_server.py | 11 +++++-- webui/index.html | 18 +++++++++++ webui/static/api-monitor.js | 6 ++++ 7 files changed, 161 insertions(+), 7 deletions(-) create mode 100644 tests/test_watchlist_auto_download.py diff --git a/core/watchlist_scanner.py b/core/watchlist_scanner.py index c99841eb..d4584519 100644 --- a/core/watchlist_scanner.py +++ b/core/watchlist_scanner.py @@ -1376,10 +1376,17 @@ class WatchlistScanner: if scan_state is not None: scan_state['tracks_found_this_scan'] += 1 - added = self.add_track_to_wishlist( - track, album_data, artist, - scan_run_id=(scan_state or {}).get('scan_run_id', ''), - ) + # "Follow only" artists (auto_download off): discover and + # surface the release exactly like normal, but skip the one + # call that adds it to the wishlist — so nothing auto-downloads + # and the user can pick what to grab (corruption's request). + if getattr(artist, 'auto_download', True): + added = self.add_track_to_wishlist( + track, album_data, artist, + scan_run_id=(scan_state or {}).get('scan_run_id', ''), + ) + else: + added = False track_artists = track.get('artists', []) track_artist_name = track_artists[0].get('name', 'Unknown Artist') if track_artists else 'Unknown Artist' diff --git a/database/music_database.py b/database/music_database.py index 518ac116..24a16a41 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -102,6 +102,10 @@ class WatchlistArtist: include_instrumentals: bool = False lookback_days: Optional[int] = None # Per-artist override; None = use global setting preferred_metadata_source: Optional[str] = None # Per-artist override; None = use global setting + # When False ("follow only"), the watchlist scan still discovers + surfaces new + # releases for this artist but does NOT auto-add them to the wishlist (so they + # don't auto-download). Default True = current behaviour. + auto_download: bool = True profile_id: int = 1 @dataclass @@ -467,6 +471,12 @@ class MusicDatabase: self._add_soul_id_columns(cursor) self._add_listening_history_table(cursor) + # Per-artist auto_download ("follow only") column. MUST run after the + # profile-support migrations above — those recreate watchlist_artists + # from an explicit column list, so any column added before them gets + # dropped. Adding it here (after the last recreate) makes it stick. + self._add_watchlist_auto_download_column(cursor) + # Spotify library cache self._add_spotify_library_cache_table(cursor) @@ -2158,6 +2168,21 @@ class MusicDatabase: logger.error(f"Error adding content type filter columns to watchlist_artists: {e}") # Don't raise - this is a migration, database can still function + def _add_watchlist_auto_download_column(self, cursor): + """Add per-artist auto_download column ("follow only" toggle). + + Default 1 (auto-download) = existing behaviour. When 0, the watchlist scan + still finds + surfaces new releases for the artist but skips adding them to + the wishlist.""" + try: + cursor.execute("PRAGMA table_info(watchlist_artists)") + columns = [column[1] for column in cursor.fetchall()] + if 'auto_download' not in columns: + cursor.execute("ALTER TABLE watchlist_artists ADD COLUMN auto_download INTEGER NOT NULL DEFAULT 1") + logger.info("Added auto_download column to watchlist_artists table") + except Exception as e: + logger.error(f"Error adding auto_download column to watchlist_artists: {e}") + def _add_watchlist_lookback_days_column(self, cursor): """Add per-artist lookback_days column to watchlist_artists table""" try: @@ -9293,7 +9318,8 @@ class MusicDatabase: 'last_scan_timestamp', 'created_at', 'updated_at'] optional_columns = ['image_url', 'itunes_artist_id', 'deezer_artist_id', 'discogs_artist_id', 'musicbrainz_artist_id', 'include_albums', 'include_eps', 'include_singles', 'include_live', 'include_remixes', 'include_acoustic', 'include_compilations', - 'include_instrumentals', 'lookback_days', 'preferred_metadata_source'] + 'include_instrumentals', 'lookback_days', 'preferred_metadata_source', + 'auto_download'] columns_to_select = base_columns + [col for col in optional_columns if col in existing_columns] @@ -9331,6 +9357,7 @@ class MusicDatabase: include_instrumentals = bool(row['include_instrumentals']) if 'include_instrumentals' in existing_columns else False lookback_days = row['lookback_days'] if 'lookback_days' in existing_columns else None preferred_metadata_source = row['preferred_metadata_source'] if 'preferred_metadata_source' in existing_columns else None + auto_download = bool(row['auto_download']) if 'auto_download' in existing_columns else True watchlist_artists.append(WatchlistArtist( id=row['id'], @@ -9355,6 +9382,7 @@ class MusicDatabase: include_instrumentals=include_instrumentals, lookback_days=lookback_days, preferred_metadata_source=preferred_metadata_source, + auto_download=auto_download, profile_id=profile_id )) diff --git a/tests/test_watchlist_auto_download.py b/tests/test_watchlist_auto_download.py new file mode 100644 index 00000000..a75a867d --- /dev/null +++ b/tests/test_watchlist_auto_download.py @@ -0,0 +1,42 @@ +"""DB-level coverage for the per-artist watchlist auto_download ("follow only") +toggle — the column migrates in, defaults to auto-download, and round-trips +through get_watchlist_artists onto the WatchlistArtist dataclass.""" + +from __future__ import annotations + +from database.music_database import MusicDatabase + + +def _get(db, profile_id=1): + return {a.spotify_artist_id: a for a in db.get_watchlist_artists(profile_id=profile_id)} + + +def test_auto_download_defaults_true(tmp_path): + db = MusicDatabase(str(tmp_path / 'm.db')) + assert db.add_artist_to_watchlist('sp1', 'Artist One', profile_id=1) + artist = _get(db)['sp1'] + # New watchlist artists keep the existing behaviour: auto-download on. + assert artist.auto_download is True + + +def test_auto_download_persists_when_disabled(tmp_path): + db = MusicDatabase(str(tmp_path / 'm.db')) + db.add_artist_to_watchlist('sp1', 'Artist One', profile_id=1) + + # Flip it off the same way the config endpoint's UPDATE does. + with db._get_connection() as conn: + conn.execute( + "UPDATE watchlist_artists SET auto_download = 0 WHERE spotify_artist_id = ?", + ('sp1',), + ) + conn.commit() + + artist = _get(db)['sp1'] + assert artist.auto_download is False + + +def test_auto_download_column_exists_after_migration(tmp_path): + db = MusicDatabase(str(tmp_path / 'm.db')) + with db._get_connection() as conn: + cols = [c[1] for c in conn.execute("PRAGMA table_info(watchlist_artists)").fetchall()] + assert 'auto_download' in cols diff --git a/tests/test_watchlist_scanner_scan.py b/tests/test_watchlist_scanner_scan.py index f02eab45..9eda637f 100644 --- a/tests/test_watchlist_scanner_scan.py +++ b/tests/test_watchlist_scanner_scan.py @@ -446,6 +446,52 @@ def test_scan_watchlist_artists_scans_tracks_and_updates_state(monkeypatch): assert scan_state["recent_wishlist_additions"][0]["track_name"] == "Track One" +def test_follow_only_artist_discovers_but_skips_wishlist_add(monkeypatch): + """auto_download=False ("follow only"): the scan still finds the new track + (counts toward new_tracks_found + the scan summary) but must NOT call + add_track_to_wishlist — so nothing auto-downloads (corruption's request).""" + monkeypatch.setattr(watchlist_scanner_module, "DELAY_BETWEEN_ARTISTS", 0) + monkeypatch.setattr(watchlist_scanner_module, "DELAY_BETWEEN_ALBUMS", 0) + + artist = _build_artist() + artist.auto_download = False # follow only + album = types.SimpleNamespace(id="album-1", name="Album One") + album_data = { + "name": "Album One", + "images": [{"url": "https://example.com/album.jpg"}], + "tracks": {"items": [{ + "id": "track-1", "name": "Track One", "track_number": 1, + "disc_number": 1, "artists": [{"name": "Artist One"}], + }]}, + } + scanner = _build_scanner(album_data, [artist]) + scanner._database.has_fresh_similar_artists = lambda *a, **k: False + + add_calls = [] + monkeypatch.setattr(scanner, "_backfill_missing_ids", lambda *a, **k: None) + monkeypatch.setattr(scanner, "get_artist_image_url", lambda *a, **k: "https://example.com/artist.jpg") + monkeypatch.setattr(scanner, "get_artist_discography_for_watchlist", lambda *a, **k: [album]) + monkeypatch.setattr(scanner, "_get_lookback_period_setting", lambda: "30") + monkeypatch.setattr(scanner, "_get_rescan_cutoff", lambda: None) + monkeypatch.setattr(scanner, "_should_include_release", lambda *a, **k: True) + monkeypatch.setattr(scanner, "_should_include_track", lambda *a, **k: True) + monkeypatch.setattr(scanner, "is_track_missing_from_library", lambda *a, **k: True) + monkeypatch.setattr(scanner, "add_track_to_wishlist", lambda *a, **k: add_calls.append(a) or True) + monkeypatch.setattr(scanner, "update_artist_scan_timestamp", lambda *a, **k: True) + monkeypatch.setattr(scanner, "update_similar_artists", lambda *a, **k: True) + monkeypatch.setattr(scanner, "_backfill_similar_artists_fallback_ids", lambda *a, **k: 0) + + scan_state = {} + results = scanner.scan_watchlist_artists([artist], scan_state=scan_state) + + assert results[0].success is True + assert results[0].new_tracks_found == 1 # still discovered/surfaced + assert results[0].tracks_added_to_wishlist == 0 # but not auto-added + assert add_calls == [] # the one gated call never fired + assert scan_state["summary"]["new_tracks_found"] == 1 + assert scan_state["summary"]["tracks_added_to_wishlist"] == 0 + + def test_scan_watchlist_artists_skips_placeholder_tracklists(monkeypatch): monkeypatch.setattr(watchlist_scanner_module, "DELAY_BETWEEN_ARTISTS", 0) monkeypatch.setattr(watchlist_scanner_module, "DELAY_BETWEEN_ALBUMS", 0) diff --git a/web_server.py b/web_server.py index 4b83cb3b..f206427a 100644 --- a/web_server.py +++ b/web_server.py @@ -27829,7 +27829,7 @@ def watchlist_artist_config(artist_id): artist_name, image_url, spotify_artist_id, itunes_artist_id, last_scan_timestamp, date_added, include_instrumentals, deezer_artist_id, lookback_days, discogs_artist_id, preferred_metadata_source, - amazon_artist_id, musicbrainz_artist_id + amazon_artist_id, musicbrainz_artist_id, auto_download FROM watchlist_artists WHERE spotify_artist_id = ? OR itunes_artist_id = ? OR deezer_artist_id = ? OR discogs_artist_id = ? OR amazon_artist_id = ? OR musicbrainz_artist_id = ? @@ -27958,6 +27958,8 @@ def watchlist_artist_config(artist_id): 'date_added': result[12], 'lookback_days': result[15] if len(result) > 15 else None, 'preferred_metadata_source': result[17] if len(result) > 17 else None, + # follow-only toggle (default True/auto-download when column absent) + 'auto_download': bool(result[20]) if len(result) > 20 and result[20] is not None else True, } from core.metadata.registry import get_primary_source @@ -27997,6 +27999,9 @@ def watchlist_artist_config(artist_id): # Validate — only accept known sources, empty string means clear override if preferred_metadata_source == '' or preferred_metadata_source not in ('spotify', 'deezer', 'itunes', 'discogs', 'musicbrainz'): preferred_metadata_source = None + # Follow-only toggle: default True so an older client that omits the + # field keeps auto-downloading. + auto_download = bool(data.get('auto_download', True)) # Validate at least one release type is selected if not (include_albums or include_eps or include_singles): @@ -28021,13 +28026,14 @@ def watchlist_artist_config(artist_id): SET include_albums = ?, include_eps = ?, include_singles = ?, include_live = ?, include_remixes = ?, include_acoustic = ?, include_compilations = ?, include_instrumentals = ?, lookback_days = ?, preferred_metadata_source = ?, + auto_download = ?, last_scan_timestamp = CASE WHEN ? THEN NULL ELSE last_scan_timestamp END, updated_at = CURRENT_TIMESTAMP WHERE spotify_artist_id = ? OR itunes_artist_id = ? OR deezer_artist_id = ? OR discogs_artist_id = ? OR musicbrainz_artist_id = ? """, (int(include_albums), int(include_eps), int(include_singles), int(include_live), int(include_remixes), int(include_acoustic), int(include_compilations), - int(include_instrumentals), lookback_days, preferred_metadata_source, lookback_changed, + int(include_instrumentals), lookback_days, preferred_metadata_source, int(auto_download), lookback_changed, artist_id, artist_id, artist_id, artist_id, artist_id)) conn.commit() @@ -28051,6 +28057,7 @@ def watchlist_artist_config(artist_id): 'include_acoustic': include_acoustic, 'include_compilations': include_compilations, 'include_instrumentals': include_instrumentals, + 'auto_download': auto_download, } }) diff --git a/webui/index.html b/webui/index.html index 8518816d..b353f10f 100644 --- a/webui/index.html +++ b/webui/index.html @@ -7756,6 +7756,24 @@
+
+

Auto-Download

+

When on, new releases are added to the wishlist and downloaded automatically. Turn off to follow only — still see new releases in scans, but pick what to download yourself.

+ +
+ +
+
+

Download Preferences

Select which types of releases to monitor for this artist

diff --git a/webui/static/api-monitor.js b/webui/static/api-monitor.js index 07641bd8..36e09242 100644 --- a/webui/static/api-monitor.js +++ b/webui/static/api-monitor.js @@ -2498,6 +2498,9 @@ async function openWatchlistArtistConfigModal(artistId, artistName) { } // Set checkbox states + // Default to true (auto-download) when absent (older configs). + const _autoDlToggle = document.getElementById('config-auto-download'); + if (_autoDlToggle) _autoDlToggle.checked = config.auto_download !== false; document.getElementById('config-include-albums').checked = config.include_albums; document.getElementById('config-include-eps').checked = config.include_eps; document.getElementById('config-include-singles').checked = config.include_singles; @@ -2985,6 +2988,8 @@ async function saveWatchlistArtistConfig(artistId) { const includeAcoustic = document.getElementById('config-include-acoustic').checked; const includeCompilations = document.getElementById('config-include-compilations').checked; const includeInstrumentals = document.getElementById('config-include-instrumentals').checked; + const autoDownloadEl = document.getElementById('config-auto-download'); + const autoDownload = autoDownloadEl ? autoDownloadEl.checked : true; const lookbackDaysVal = document.getElementById('config-lookback-days').value; const lookbackDays = lookbackDaysVal !== '' ? parseInt(lookbackDaysVal) : null; const activeSourceBtn = document.querySelector('#config-metadata-source-selector .config-msrc-btn.active'); @@ -3016,6 +3021,7 @@ async function saveWatchlistArtistConfig(artistId) { include_acoustic: includeAcoustic, include_compilations: includeCompilations, include_instrumentals: includeInstrumentals, + auto_download: autoDownload, lookback_days: lookbackDays, preferred_metadata_source: preferredMetadataSource, })