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
pull/253/head
Broque Thomas 3 months ago
parent db104ec155
commit a33f891fa6

@ -502,7 +502,7 @@ class WatchlistScanner:
logger.warning(f"No valid client/ID for {watchlist_artist.artist_name}")
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 not albums:
@ -533,7 +533,7 @@ class WatchlistScanner:
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'}")
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
@ -725,7 +725,7 @@ class WatchlistScanner:
logger.warning(f"Could not update artist image for {watchlist_artist.artist_name}: {img_error}")
# 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:
return ScanResult(
@ -863,14 +863,19 @@ class WatchlistScanner:
# Determine cutoff date for filtering
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:
lookback_period = self._get_lookback_period_setting()
if lookback_period != 'all':
# Convert period to days and create cutoff date (use UTC)
days = int(lookback_period)
cutoff_timestamp = datetime.now(timezone.utc) - timedelta(days=days)
logger.info(f"Using lookback period: {lookback_period} days (cutoff: {cutoff_timestamp})")
if lookback_days is not None:
# Per-artist override
cutoff_timestamp = datetime.now(timezone.utc) - timedelta(days=lookback_days)
logger.info(f"Using per-artist lookback: {lookback_days} days (cutoff: {cutoff_timestamp})")
else:
# 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
if cutoff_timestamp:
@ -889,7 +894,7 @@ class WatchlistScanner:
logger.error(f"Error getting discography for artist {spotify_artist_id}: {e}")
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.
@ -898,6 +903,7 @@ class WatchlistScanner:
artist_id: Artist ID for the given client
last_scan_timestamp: Only return releases after this date (for incremental scans)
If None, uses lookback period setting from database
lookback_days: Per-artist override for lookback period (None = use global setting)
"""
try:
# Get all artist albums (albums + singles)

@ -96,6 +96,7 @@ class WatchlistArtist:
include_acoustic: bool = False
include_compilations: bool = False
include_instrumentals: bool = False
lookback_days: Optional[int] = None # Per-artist override; None = use global setting
profile_id: int = 1
@dataclass
@ -305,6 +306,9 @@ class MusicDatabase:
# Add content type filter columns to watchlist_artists (migration)
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)
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}")
# 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):
"""Add iTunes artist ID column to watchlist_artists table for cross-provider support"""
try:
@ -6318,7 +6333,7 @@ class MusicDatabase:
'last_scan_timestamp', 'created_at', 'updated_at']
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_instrumentals']
'include_instrumentals', 'lookback_days']
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_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
lookback_days = row['lookback_days'] if 'lookback_days' in existing_columns else None
watchlist_artists.append(WatchlistArtist(
id=row['id'],
@ -6372,6 +6388,7 @@ class MusicDatabase:
include_acoustic=include_acoustic,
include_compilations=include_compilations,
include_instrumentals=include_instrumentals,
lookback_days=lookback_days,
profile_id=profile_id
))

@ -34538,7 +34538,8 @@ def watchlist_artist_config(artist_id):
SELECT include_albums, include_eps, include_singles,
include_live, include_remixes, include_acoustic, include_compilations,
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
WHERE spotify_artist_id = ? OR itunes_artist_id = ? OR deezer_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,
'last_scan_timestamp': result[11],
'date_added': result[12],
'lookback_days': result[15] if len(result) > 15 else None,
}
return jsonify({
@ -34666,6 +34668,10 @@ def watchlist_artist_config(artist_id):
include_acoustic = data.get('include_acoustic', False)
include_compilations = data.get('include_compilations', 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
if not (include_albums or include_eps or include_singles):
@ -34674,16 +34680,27 @@ def watchlist_artist_config(artist_id):
# Update database
conn = sqlite3.connect(str(database.database_path))
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("""
UPDATE watchlist_artists
SET include_albums = ?, include_eps = ?, include_singles = ?,
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
WHERE spotify_artist_id = ? OR itunes_artist_id = ? OR deezer_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),
int(include_instrumentals), lookback_days, lookback_changed,
artist_id, artist_id, artist_id))
conn.commit()

@ -5925,6 +5925,24 @@
</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 -->
<div class="config-section" id="watchlist-linked-provider-section" style="display:none">
<h3 class="config-section-title">Linked Artist</h3>

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

Loading…
Cancel
Save