Watchlist: per-artist 'auto-download' toggle (follow-only) — off = discover/surface releases but skip the wishlist add; default on

This commit is contained in:
BoulderBadgeDad 2026-06-13 08:07:20 -07:00
parent 2428df1144
commit 651b904e92
7 changed files with 161 additions and 7 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -7756,6 +7756,24 @@
<div class="watchlist-artist-config-content">
<div class="watchlist-artist-config-body">
<div class="config-section">
<h3 class="config-section-title">Auto-Download</h3>
<p class="config-section-subtitle">When on, new releases are added to the wishlist and downloaded automatically. Turn off to <strong>follow only</strong> — still see new releases in scans, but pick what to download yourself.</p>
<div class="config-options">
<label class="config-option">
<input type="checkbox" id="config-auto-download" checked>
<div class="config-option-content">
<div class="config-option-icon">⬇️</div>
<div class="config-option-text">
<span class="config-option-title">Auto-download new releases</span>
<span class="config-option-description">Off = follow only (discover but don't auto-add to wishlist)</span>
</div>
</div>
</label>
</div>
</div>
<div class="config-section">
<h3 class="config-section-title">Download Preferences</h3>
<p class="config-section-subtitle">Select which types of releases to monitor for this artist</p>

View file

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