Watchlist: per-artist 'auto-download' toggle (follow-only) — off = discover/surface releases but skip the wishlist add; default on
This commit is contained in:
parent
2428df1144
commit
651b904e92
7 changed files with 161 additions and 7 deletions
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
))
|
||||
|
||||
|
|
|
|||
42
tests/test_watchlist_auto_download.py
Normal file
42
tests/test_watchlist_auto_download.py
Normal 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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue