Add per-artist watchlist lookback period override

New "Scan Lookback" dropdown in the watchlist artist config modal.
Each artist can override the global lookback period (7d to entire
discography). Default is "Use Global Setting" (NULL in DB).

- Database: lookback_days INTEGER DEFAULT NULL on watchlist_artists,
  auto-migrated on startup
- Scanner: checks per-artist lookback_days first, falls back to
  global discovery_lookback_period if NULL
- Backend: GET/POST /api/watchlist/artist/<id>/config includes
  lookback_days. Changing lookback clears last_scan_timestamp to
  force a rescan with the new window
- Frontend: dropdown with 8 options in artist config modal
- Fully backwards compatible — existing artists unchanged
This commit is contained in:
Broque Thomas 2026-03-27 08:17:02 -07:00
parent db104ec155
commit a33f891fa6
5 changed files with 77 additions and 15 deletions

View file

@ -502,7 +502,7 @@ class WatchlistScanner:
logger.warning(f"No valid client/ID for {watchlist_artist.artist_name}") logger.warning(f"No valid client/ID for {watchlist_artist.artist_name}")
return None return None
albums = self._get_artist_discography_with_client(client, artist_id, last_scan_timestamp) albums = self._get_artist_discography_with_client(client, artist_id, last_scan_timestamp, lookback_days=watchlist_artist.lookback_days)
# If primary provider returned nothing, try the other provider as fallback # If primary provider returned nothing, try the other provider as fallback
if not albums: if not albums:
@ -533,7 +533,7 @@ class WatchlistScanner:
if fallback_client and fallback_id: if fallback_client and fallback_id:
logger.info(f"{provider.capitalize()} returned no albums for {watchlist_artist.artist_name}, falling back to {'iTunes' if provider == 'spotify' else 'Spotify'}") logger.info(f"{provider.capitalize()} returned no albums for {watchlist_artist.artist_name}, falling back to {'iTunes' if provider == 'spotify' else 'Spotify'}")
albums = self._get_artist_discography_with_client(fallback_client, fallback_id, last_scan_timestamp) albums = self._get_artist_discography_with_client(fallback_client, fallback_id, last_scan_timestamp, lookback_days=watchlist_artist.lookback_days)
return albums return albums
@ -725,7 +725,7 @@ class WatchlistScanner:
logger.warning(f"Could not update artist image for {watchlist_artist.artist_name}: {img_error}") logger.warning(f"Could not update artist image for {watchlist_artist.artist_name}: {img_error}")
# Get artist discography using active provider # Get artist discography using active provider
albums = self._get_artist_discography_with_client(client, artist_id, watchlist_artist.last_scan_timestamp) albums = self._get_artist_discography_with_client(client, artist_id, watchlist_artist.last_scan_timestamp, lookback_days=watchlist_artist.lookback_days)
if albums is None: if albums is None:
return ScanResult( return ScanResult(
@ -863,14 +863,19 @@ class WatchlistScanner:
# Determine cutoff date for filtering # Determine cutoff date for filtering
cutoff_timestamp = last_scan_timestamp cutoff_timestamp = last_scan_timestamp
# If no last scan timestamp, use lookback period setting # If no last scan timestamp, use per-artist lookback or global setting
if cutoff_timestamp is None: if cutoff_timestamp is None:
lookback_period = self._get_lookback_period_setting() if lookback_days is not None:
if lookback_period != 'all': # Per-artist override
# Convert period to days and create cutoff date (use UTC) cutoff_timestamp = datetime.now(timezone.utc) - timedelta(days=lookback_days)
days = int(lookback_period) logger.info(f"Using per-artist lookback: {lookback_days} days (cutoff: {cutoff_timestamp})")
cutoff_timestamp = datetime.now(timezone.utc) - timedelta(days=days) else:
logger.info(f"Using lookback period: {lookback_period} days (cutoff: {cutoff_timestamp})") # Global setting
lookback_period = self._get_lookback_period_setting()
if lookback_period != 'all':
days = int(lookback_period)
cutoff_timestamp = datetime.now(timezone.utc) - timedelta(days=days)
logger.info(f"Using global lookback period: {lookback_period} days (cutoff: {cutoff_timestamp})")
# Filter by release date if we have a cutoff timestamp # Filter by release date if we have a cutoff timestamp
if cutoff_timestamp: if cutoff_timestamp:
@ -889,7 +894,7 @@ class WatchlistScanner:
logger.error(f"Error getting discography for artist {spotify_artist_id}: {e}") logger.error(f"Error getting discography for artist {spotify_artist_id}: {e}")
return None return None
def _get_artist_discography_with_client(self, client, artist_id: str, last_scan_timestamp: Optional[datetime] = None) -> Optional[List]: def _get_artist_discography_with_client(self, client, artist_id: str, last_scan_timestamp: Optional[datetime] = None, lookback_days: Optional[int] = None) -> Optional[List]:
""" """
Get artist's discography using the specified client, optionally filtered by release date. Get artist's discography using the specified client, optionally filtered by release date.
@ -898,6 +903,7 @@ class WatchlistScanner:
artist_id: Artist ID for the given client artist_id: Artist ID for the given client
last_scan_timestamp: Only return releases after this date (for incremental scans) last_scan_timestamp: Only return releases after this date (for incremental scans)
If None, uses lookback period setting from database If None, uses lookback period setting from database
lookback_days: Per-artist override for lookback period (None = use global setting)
""" """
try: try:
# Get all artist albums (albums + singles) # Get all artist albums (albums + singles)

View file

@ -96,6 +96,7 @@ class WatchlistArtist:
include_acoustic: bool = False include_acoustic: bool = False
include_compilations: bool = False include_compilations: bool = False
include_instrumentals: bool = False include_instrumentals: bool = False
lookback_days: Optional[int] = None # Per-artist override; None = use global setting
profile_id: int = 1 profile_id: int = 1
@dataclass @dataclass
@ -305,6 +306,9 @@ class MusicDatabase:
# Add content type filter columns to watchlist_artists (migration) # Add content type filter columns to watchlist_artists (migration)
self._add_watchlist_content_type_filters(cursor) self._add_watchlist_content_type_filters(cursor)
# Add per-artist lookback_days column to watchlist_artists (migration)
self._add_watchlist_lookback_days_column(cursor)
# Add iTunes artist ID column to watchlist_artists (migration) # Add iTunes artist ID column to watchlist_artists (migration)
self._add_watchlist_itunes_id_column(cursor) self._add_watchlist_itunes_id_column(cursor)
@ -1168,6 +1172,17 @@ class MusicDatabase:
logger.error(f"Error adding content type filter columns to watchlist_artists: {e}") logger.error(f"Error adding content type filter columns to watchlist_artists: {e}")
# Don't raise - this is a migration, database can still function # Don't raise - this is a migration, database can still function
def _add_watchlist_lookback_days_column(self, cursor):
"""Add per-artist lookback_days column to watchlist_artists table"""
try:
cursor.execute("PRAGMA table_info(watchlist_artists)")
columns = [column[1] for column in cursor.fetchall()]
if 'lookback_days' not in columns:
cursor.execute("ALTER TABLE watchlist_artists ADD COLUMN lookback_days INTEGER DEFAULT NULL")
logger.info("Added lookback_days column to watchlist_artists table")
except Exception as e:
logger.error(f"Error adding lookback_days column to watchlist_artists: {e}")
def _add_watchlist_itunes_id_column(self, cursor): def _add_watchlist_itunes_id_column(self, cursor):
"""Add iTunes artist ID column to watchlist_artists table for cross-provider support""" """Add iTunes artist ID column to watchlist_artists table for cross-provider support"""
try: try:
@ -6318,7 +6333,7 @@ class MusicDatabase:
'last_scan_timestamp', 'created_at', 'updated_at'] 'last_scan_timestamp', 'created_at', 'updated_at']
optional_columns = ['image_url', 'itunes_artist_id', 'deezer_artist_id', 'include_albums', 'include_eps', 'include_singles', optional_columns = ['image_url', 'itunes_artist_id', 'deezer_artist_id', 'include_albums', 'include_eps', 'include_singles',
'include_live', 'include_remixes', 'include_acoustic', 'include_compilations', 'include_live', 'include_remixes', 'include_acoustic', 'include_compilations',
'include_instrumentals'] 'include_instrumentals', 'lookback_days']
columns_to_select = base_columns + [col for col in optional_columns if col in existing_columns] columns_to_select = base_columns + [col for col in optional_columns if col in existing_columns]
@ -6352,6 +6367,7 @@ class MusicDatabase:
include_acoustic = bool(row['include_acoustic']) if 'include_acoustic' in existing_columns else False include_acoustic = bool(row['include_acoustic']) if 'include_acoustic' in existing_columns else False
include_compilations = bool(row['include_compilations']) if 'include_compilations' in existing_columns else False include_compilations = bool(row['include_compilations']) if 'include_compilations' in existing_columns else False
include_instrumentals = bool(row['include_instrumentals']) if 'include_instrumentals' in existing_columns else False 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
watchlist_artists.append(WatchlistArtist( watchlist_artists.append(WatchlistArtist(
id=row['id'], id=row['id'],
@ -6372,6 +6388,7 @@ class MusicDatabase:
include_acoustic=include_acoustic, include_acoustic=include_acoustic,
include_compilations=include_compilations, include_compilations=include_compilations,
include_instrumentals=include_instrumentals, include_instrumentals=include_instrumentals,
lookback_days=lookback_days,
profile_id=profile_id profile_id=profile_id
)) ))

View file

@ -34538,7 +34538,8 @@ def watchlist_artist_config(artist_id):
SELECT include_albums, include_eps, include_singles, SELECT include_albums, include_eps, include_singles,
include_live, include_remixes, include_acoustic, include_compilations, include_live, include_remixes, include_acoustic, include_compilations,
artist_name, image_url, spotify_artist_id, itunes_artist_id, artist_name, image_url, spotify_artist_id, itunes_artist_id,
last_scan_timestamp, date_added, include_instrumentals, deezer_artist_id last_scan_timestamp, date_added, include_instrumentals, deezer_artist_id,
lookback_days
FROM watchlist_artists FROM watchlist_artists
WHERE spotify_artist_id = ? OR itunes_artist_id = ? OR deezer_artist_id = ? WHERE spotify_artist_id = ? OR itunes_artist_id = ? OR deezer_artist_id = ?
""", (artist_id, artist_id, artist_id)) """, (artist_id, artist_id, artist_id))
@ -34640,6 +34641,7 @@ def watchlist_artist_config(artist_id):
'include_instrumentals': bool(result[13]) if result[13] is not None else False, 'include_instrumentals': bool(result[13]) if result[13] is not None else False,
'last_scan_timestamp': result[11], 'last_scan_timestamp': result[11],
'date_added': result[12], 'date_added': result[12],
'lookback_days': result[15] if len(result) > 15 else None,
} }
return jsonify({ return jsonify({
@ -34666,6 +34668,10 @@ def watchlist_artist_config(artist_id):
include_acoustic = data.get('include_acoustic', False) include_acoustic = data.get('include_acoustic', False)
include_compilations = data.get('include_compilations', False) include_compilations = data.get('include_compilations', False)
include_instrumentals = data.get('include_instrumentals', False) include_instrumentals = data.get('include_instrumentals', False)
lookback_days = data.get('lookback_days', None) # None = use global setting
# Validate lookback_days if provided
if lookback_days is not None:
lookback_days = int(lookback_days) if lookback_days != '' else None
# Validate at least one release type is selected # Validate at least one release type is selected
if not (include_albums or include_eps or include_singles): if not (include_albums or include_eps or include_singles):
@ -34674,16 +34680,27 @@ def watchlist_artist_config(artist_id):
# Update database # Update database
conn = sqlite3.connect(str(database.database_path)) conn = sqlite3.connect(str(database.database_path))
cursor = conn.cursor() cursor = conn.cursor()
# Check if lookback_days changed — if so, clear last_scan_timestamp to force rescan
cursor.execute("""
SELECT lookback_days FROM watchlist_artists
WHERE spotify_artist_id = ? OR itunes_artist_id = ? OR deezer_artist_id = ?
""", (artist_id, artist_id, artist_id))
old_row = cursor.fetchone()
old_lookback = old_row[0] if old_row else None
lookback_changed = old_lookback != lookback_days
cursor.execute(""" cursor.execute("""
UPDATE watchlist_artists UPDATE watchlist_artists
SET include_albums = ?, include_eps = ?, include_singles = ?, SET include_albums = ?, include_eps = ?, include_singles = ?,
include_live = ?, include_remixes = ?, include_acoustic = ?, include_compilations = ?, include_live = ?, include_remixes = ?, include_acoustic = ?, include_compilations = ?,
include_instrumentals = ?, include_instrumentals = ?, lookback_days = ?,
last_scan_timestamp = CASE WHEN ? THEN NULL ELSE last_scan_timestamp END,
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE spotify_artist_id = ? OR itunes_artist_id = ? OR deezer_artist_id = ? WHERE spotify_artist_id = ? OR itunes_artist_id = ? OR deezer_artist_id = ?
""", (int(include_albums), int(include_eps), int(include_singles), """, (int(include_albums), int(include_eps), int(include_singles),
int(include_live), int(include_remixes), int(include_acoustic), int(include_compilations), int(include_live), int(include_remixes), int(include_acoustic), int(include_compilations),
int(include_instrumentals), int(include_instrumentals), lookback_days, lookback_changed,
artist_id, artist_id, artist_id)) artist_id, artist_id, artist_id))
conn.commit() conn.commit()

View file

@ -5925,6 +5925,24 @@
</div> </div>
</div> </div>
<div class="config-section">
<h3 class="config-section-title">Scan Lookback</h3>
<p class="config-section-subtitle">How far back to look for releases on first scan of this artist</p>
<div class="form-group" style="margin: 12px 0 0;">
<select id="config-lookback-days" class="form-select">
<option value="">Use Global Setting</option>
<option value="7">Last 7 days</option>
<option value="30">Last 30 days</option>
<option value="90">Last 90 days</option>
<option value="180">Last 6 months</option>
<option value="365">Last year</option>
<option value="730">Last 2 years</option>
<option value="1825">Last 5 years</option>
<option value="36500">Entire discography</option>
</select>
</div>
</div>
<!-- Linked Provider Section --> <!-- Linked Provider Section -->
<div class="config-section" id="watchlist-linked-provider-section" style="display:none"> <div class="config-section" id="watchlist-linked-provider-section" style="display:none">
<h3 class="config-section-title">Linked Artist</h3> <h3 class="config-section-title">Linked Artist</h3>

View file

@ -36986,6 +36986,7 @@ async function openWatchlistArtistConfigModal(artistId, artistName) {
document.getElementById('config-include-acoustic').checked = config.include_acoustic || false; document.getElementById('config-include-acoustic').checked = config.include_acoustic || false;
document.getElementById('config-include-compilations').checked = config.include_compilations || false; document.getElementById('config-include-compilations').checked = config.include_compilations || false;
document.getElementById('config-include-instrumentals').checked = config.include_instrumentals || false; document.getElementById('config-include-instrumentals').checked = config.include_instrumentals || false;
document.getElementById('config-lookback-days').value = config.lookback_days != null ? String(config.lookback_days) : '';
// Show global override notice if active // Show global override notice if active
const existingNotice = document.querySelector('.global-override-notice'); const existingNotice = document.querySelector('.global-override-notice');
@ -37443,6 +37444,8 @@ async function saveWatchlistArtistConfig(artistId) {
const includeAcoustic = document.getElementById('config-include-acoustic').checked; const includeAcoustic = document.getElementById('config-include-acoustic').checked;
const includeCompilations = document.getElementById('config-include-compilations').checked; const includeCompilations = document.getElementById('config-include-compilations').checked;
const includeInstrumentals = document.getElementById('config-include-instrumentals').checked; const includeInstrumentals = document.getElementById('config-include-instrumentals').checked;
const lookbackDaysVal = document.getElementById('config-lookback-days').value;
const lookbackDays = lookbackDaysVal !== '' ? parseInt(lookbackDaysVal) : null;
// Validate at least one release type is selected // Validate at least one release type is selected
if (!includeAlbums && !includeEps && !includeSingles) { if (!includeAlbums && !includeEps && !includeSingles) {
@ -37470,6 +37473,7 @@ async function saveWatchlistArtistConfig(artistId) {
include_acoustic: includeAcoustic, include_acoustic: includeAcoustic,
include_compilations: includeCompilations, include_compilations: includeCompilations,
include_instrumentals: includeInstrumentals, include_instrumentals: includeInstrumentals,
lookback_days: lookbackDays,
}) })
}); });