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:
parent
db104ec155
commit
a33f891fa6
5 changed files with 77 additions and 15 deletions
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
))
|
))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue