Add global watchlist override and UI

Introduce a global watchlist override feature and UI to control release/content filters across all watchlist artists. Backend: add /api/watchlist/global-config (GET/POST) for reading/updating global settings, validation to require at least one release type when override is enabled, and _apply_watchlist_global_overrides() to apply settings to WatchlistArtist objects. Scanners (manual and automatic) now call _apply_watchlist_global_overrides() and perform additional checks (_should_include_release, _should_include_track) to skip releases/tracks according to config. Frontend: add a Global Watchlist Settings modal, controls (release types, content filters, include-all shortcut), save/validation logic, banners/notices when global override is active, and integration into the watchlist modal and per-artist config. Styles: add supporting CSS for the modal and banners. Small cleanup/whitespace adjustments included.
This commit is contained in:
Broque Thomas 2026-02-24 22:09:08 -08:00
parent 63624a4f6e
commit 35a03d6839
4 changed files with 535 additions and 3 deletions

View file

@ -20850,6 +20850,9 @@ def start_watchlist_scan():
database = get_database()
watchlist_artists = database.get_watchlist_artists()
# Apply global overrides if enabled
_apply_watchlist_global_overrides(watchlist_artists)
if not watchlist_artists:
watchlist_scan_state['status'] = 'completed'
watchlist_scan_state['summary'] = {
@ -20993,6 +20996,10 @@ def start_watchlist_scan():
tracks = album_data['tracks']['items']
# Check release type filter (album/EP/single)
if not scanner._should_include_release(len(tracks), artist):
continue
# Get album image
album_image_url = ''
if 'images' in album_data and album_data['images']:
@ -21007,6 +21014,10 @@ def start_watchlist_scan():
# Check each track
for track in tracks:
# Check content type filter (live/remix/acoustic/compilation)
if not scanner._should_include_track(track, album_data, artist):
continue
# Update current track being processed
track_name = track.get('name', 'Unknown Track')
watchlist_scan_state['current_track_name'] = track_name
@ -21033,7 +21044,7 @@ def start_watchlist_scan():
# Keep only last 10
if len(watchlist_scan_state['recent_wishlist_additions']) > 10:
watchlist_scan_state['recent_wishlist_additions'].pop()
# Rate-limited delay between albums
import time
time.sleep(album_delay)
@ -21440,6 +21451,100 @@ def watchlist_artist_config(artist_id):
traceback.print_exc()
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/watchlist/global-config', methods=['GET', 'POST'])
def watchlist_global_config():
"""Get or update global watchlist configuration (overrides per-artist settings)"""
try:
if request.method == 'GET':
config = {
'global_override_enabled': config_manager.get('watchlist.global_override_enabled', False),
'include_albums': config_manager.get('watchlist.global_include_albums', True),
'include_eps': config_manager.get('watchlist.global_include_eps', True),
'include_singles': config_manager.get('watchlist.global_include_singles', True),
'include_live': config_manager.get('watchlist.global_include_live', False),
'include_remixes': config_manager.get('watchlist.global_include_remixes', False),
'include_acoustic': config_manager.get('watchlist.global_include_acoustic', False),
'include_compilations': config_manager.get('watchlist.global_include_compilations', False),
}
return jsonify({"success": True, "config": config})
else: # POST
data = request.get_json()
if not data:
return jsonify({"success": False, "error": "No data provided"}), 400
global_override_enabled = data.get('global_override_enabled', False)
include_albums = data.get('include_albums', True)
include_eps = data.get('include_eps', True)
include_singles = data.get('include_singles', True)
include_live = data.get('include_live', False)
include_remixes = data.get('include_remixes', False)
include_acoustic = data.get('include_acoustic', False)
include_compilations = data.get('include_compilations', False)
# When override is enabled, validate at least one release type
if global_override_enabled and not (include_albums or include_eps or include_singles):
return jsonify({"success": False, "error": "At least one release type must be selected"}), 400
config_manager.set('watchlist.global_override_enabled', global_override_enabled)
config_manager.set('watchlist.global_include_albums', include_albums)
config_manager.set('watchlist.global_include_eps', include_eps)
config_manager.set('watchlist.global_include_singles', include_singles)
config_manager.set('watchlist.global_include_live', include_live)
config_manager.set('watchlist.global_include_remixes', include_remixes)
config_manager.set('watchlist.global_include_acoustic', include_acoustic)
config_manager.set('watchlist.global_include_compilations', include_compilations)
print(f"✅ Updated global watchlist config: override={global_override_enabled}, "
f"albums={include_albums}, eps={include_eps}, singles={include_singles}, "
f"live={include_live}, remixes={include_remixes}, acoustic={include_acoustic}, "
f"compilations={include_compilations}")
return jsonify({
"success": True,
"message": "Global watchlist configuration updated",
"config": {
'global_override_enabled': global_override_enabled,
'include_albums': include_albums,
'include_eps': include_eps,
'include_singles': include_singles,
'include_live': include_live,
'include_remixes': include_remixes,
'include_acoustic': include_acoustic,
'include_compilations': include_compilations,
}
})
except Exception as e:
print(f"Error in watchlist global config: {e}")
import traceback
traceback.print_exc()
return jsonify({"success": False, "error": str(e)}), 500
def _apply_watchlist_global_overrides(watchlist_artists):
"""If global override is enabled, overwrite per-artist settings on WatchlistArtist objects."""
if not config_manager.get('watchlist.global_override_enabled', False):
return
# Read global settings once
g_albums = config_manager.get('watchlist.global_include_albums', True)
g_eps = config_manager.get('watchlist.global_include_eps', True)
g_singles = config_manager.get('watchlist.global_include_singles', True)
g_live = config_manager.get('watchlist.global_include_live', False)
g_remixes = config_manager.get('watchlist.global_include_remixes', False)
g_acoustic = config_manager.get('watchlist.global_include_acoustic', False)
g_compilations = config_manager.get('watchlist.global_include_compilations', False)
print(f"🌐 [Watchlist] Global override is ACTIVE — applying to {len(watchlist_artists)} artists "
f"(albums={g_albums}, eps={g_eps}, singles={g_singles}, live={g_live}, "
f"remixes={g_remixes}, acoustic={g_acoustic}, compilations={g_compilations})")
for artist in watchlist_artists:
artist.include_albums = g_albums
artist.include_eps = g_eps
artist.include_singles = g_singles
artist.include_live = g_live
artist.include_remixes = g_remixes
artist.include_acoustic = g_acoustic
artist.include_compilations = g_compilations
def _update_similar_artists_worker():
"""Background worker to update similar artists for all watchlist artists"""
global similar_artists_update_state
@ -21661,6 +21766,9 @@ def _process_watchlist_scan_automatically():
watchlist_artists = database.get_watchlist_artists()
scanner = get_watchlist_scanner(spotify_client)
# Apply global overrides if enabled
_apply_watchlist_global_overrides(watchlist_artists)
# Initialize detailed progress tracking (same as manual scan)
watchlist_scan_state = {
'status': 'scanning',
@ -21780,6 +21888,10 @@ def _process_watchlist_scan_automatically():
tracks = album_data['tracks']['items']
# Check release type filter (album/EP/single)
if not scanner._should_include_release(len(tracks), artist):
continue
# Get album image
album_image_url = ''
if 'images' in album_data and album_data['images']:
@ -21794,6 +21906,10 @@ def _process_watchlist_scan_automatically():
# Check each track
for track in tracks:
# Check content type filter (live/remix/acoustic/compilation)
if not scanner._should_include_track(track, album_data, artist):
continue
# Update current track being processed
track_name = track.get('name', 'Unknown Track')
watchlist_scan_state['current_track_name'] = track_name

View file

@ -3950,6 +3950,168 @@
</div>
</div>
<!-- Watchlist Global Config Modal -->
<div class="modal-overlay hidden" id="watchlist-global-config-modal-overlay">
<div class="watchlist-artist-config-modal" id="watchlist-global-config-modal">
<div class="watchlist-artist-config-header" style="padding: 24px 28px;">
<div style="display: flex; align-items: center; gap: 12px;">
<span style="font-size: 28px;">⚙️</span>
<div>
<h2 style="color: #fff; margin: 0; font-size: 22px;">Global Watchlist Settings</h2>
<p style="color: #b3b3b3; margin: 4px 0 0; font-size: 13px;">
Override per-artist settings for all watchlist scans
</p>
</div>
</div>
<span class="watchlist-artist-config-close" onclick="closeWatchlistGlobalSettingsModal()">&times;</span>
</div>
<div class="watchlist-artist-config-content">
<div class="watchlist-artist-config-body">
<!-- Global Override Toggle -->
<div class="config-section">
<label class="config-option global-override-toggle" id="global-override-toggle-label">
<input type="checkbox" id="global-override-enabled" onchange="toggleGlobalOverrideOptions()">
<div class="config-option-content">
<div class="config-option-icon">🌐</div>
<div class="config-option-text">
<span class="config-option-title">Enable Global Override</span>
<span class="config-option-description">When enabled, the settings below apply to ALL
artists, ignoring individual artist configs</span>
</div>
</div>
</label>
</div>
<div id="global-override-options" style="opacity: 0.4; pointer-events: none; transition: opacity 0.3s;">
<!-- Release Types -->
<div class="config-section">
<h3 class="config-section-title">Release Types</h3>
<p class="config-section-subtitle">Select which release types to download for all artists</p>
<div class="config-options">
<label class="config-option">
<input type="checkbox" id="global-include-albums" checked>
<div class="config-option-content">
<div class="config-option-icon">💿</div>
<div class="config-option-text">
<span class="config-option-title">Albums</span>
<span class="config-option-description">Full-length studio albums</span>
</div>
</div>
</label>
<label class="config-option">
<input type="checkbox" id="global-include-eps" checked>
<div class="config-option-content">
<div class="config-option-icon">🎵</div>
<div class="config-option-text">
<span class="config-option-title">EPs</span>
<span class="config-option-description">Extended plays (4-6 tracks)</span>
</div>
</div>
</label>
<label class="config-option">
<input type="checkbox" id="global-include-singles" checked>
<div class="config-option-content">
<div class="config-option-icon">🎶</div>
<div class="config-option-text">
<span class="config-option-title">Singles</span>
<span class="config-option-description">Single tracks and 2-3 track
releases</span>
</div>
</div>
</label>
</div>
</div>
<!-- Content Filters -->
<div class="config-section">
<h3 class="config-section-title">Content Filters</h3>
<p class="config-section-subtitle">Check to INCLUDE, leave unchecked to EXCLUDE (default: all
excluded)</p>
<div class="config-options">
<label class="config-option">
<input type="checkbox" id="global-include-live">
<div class="config-option-content">
<div class="config-option-icon">🎤</div>
<div class="config-option-text">
<span class="config-option-title">Include Live Versions</span>
<span class="config-option-description">Live performances and concerts</span>
</div>
</div>
</label>
<label class="config-option">
<input type="checkbox" id="global-include-remixes">
<div class="config-option-content">
<div class="config-option-icon">🎧</div>
<div class="config-option-text">
<span class="config-option-title">Include Remixes</span>
<span class="config-option-description">Remix versions and edits</span>
</div>
</div>
</label>
<label class="config-option">
<input type="checkbox" id="global-include-acoustic">
<div class="config-option-content">
<div class="config-option-icon">🎸</div>
<div class="config-option-text">
<span class="config-option-title">Include Acoustic Versions</span>
<span class="config-option-description">Acoustic and stripped versions</span>
</div>
</div>
</label>
<label class="config-option">
<input type="checkbox" id="global-include-compilations">
<div class="config-option-content">
<div class="config-option-icon">📀</div>
<div class="config-option-text">
<span class="config-option-title">Include Compilations</span>
<span class="config-option-description">Greatest hits and collections</span>
</div>
</div>
</label>
</div>
</div>
<!-- Include Everything shortcut -->
<div class="config-section">
<label class="config-option include-everything-option">
<input type="checkbox" id="global-include-all" onchange="toggleGlobalIncludeAll()">
<div class="config-option-content">
<div class="config-option-icon"></div>
<div class="config-option-text">
<span class="config-option-title">Include Everything</span>
<span class="config-option-description">Enable all release types AND content filters
(live, remixes, acoustic, compilations)</span>
</div>
</div>
</label>
</div>
</div>
</div>
</div>
<div class="watchlist-artist-config-footer">
<div class="config-modal-actions">
<button class="config-modal-btn config-modal-btn-secondary"
onclick="closeWatchlistGlobalSettingsModal()">
Cancel
</button>
<button class="config-modal-btn config-modal-btn-primary" id="save-global-config-btn"
onclick="saveWatchlistGlobalConfig()">
Save Global Settings
</button>
</div>
</div>
</div>
</div>
<!-- Genre Browser Modal -->
<div class="genre-browser-modal-overlay" id="genre-browser-modal">
<div class="genre-browser-modal-container">

View file

@ -25126,11 +25126,20 @@ async function showWatchlistModal() {
document.body.appendChild(modal);
}
// Get scan status
// Get scan status and global config
const statusResponse = await fetch('/api/watchlist/scan/status');
const statusData = await statusResponse.json();
const scanStatus = statusData.success ? statusData.status : 'idle';
let globalOverrideActive = false;
try {
const globalConfigResponse = await fetch('/api/watchlist/global-config');
const globalConfigData = await globalConfigResponse.json();
globalOverrideActive = globalConfigData.success && globalConfigData.config.global_override_enabled;
} catch (e) {
console.debug('Could not fetch global config:', e);
}
// Format countdown timer
const nextRunSeconds = countData.next_run_in_seconds || 0;
const countdownText = formatCountdownTime(nextRunSeconds);
@ -25187,7 +25196,7 @@ async function showWatchlistModal() {
</div>
<div class="playlist-modal-body">
<div class="watchlist-actions" style="margin-bottom: 20px; display: flex; gap: 12px;">
<div class="watchlist-actions" style="margin-bottom: 20px; display: flex; gap: 12px; align-items: center;">
<button class="playlist-modal-btn playlist-modal-btn-primary"
id="scan-watchlist-btn"
onclick="startWatchlistScan()"
@ -25200,8 +25209,21 @@ async function showWatchlistModal() {
${scanStatus === 'scanning' ? 'disabled' : ''}>
Update Similar Artists
</button>
<button class="playlist-modal-btn playlist-modal-btn-secondary ${globalOverrideActive ? 'watchlist-global-settings-active' : ''}"
id="watchlist-global-settings-btn"
onclick="openWatchlistGlobalSettingsModal()"
>
${globalOverrideActive ? '⚙️ Global Override ON' : '⚙️ Global Settings'}
</button>
</div>
${globalOverrideActive ? `
<div class="watchlist-global-override-banner">
<span></span>
<span>Global override is active per-artist settings are being ignored during scans.</span>
</div>
` : ''}
<!-- Search Bar -->
<div class="watchlist-search-container" style="margin-bottom: 16px;">
<input type="text"
@ -25386,6 +25408,16 @@ async function openWatchlistArtistConfigModal(artistId, artistName) {
const { config, artist } = data;
// Check if global override is active
let globalOverrideActive = false;
try {
const globalResponse = await fetch('/api/watchlist/global-config');
const globalData = await globalResponse.json();
globalOverrideActive = globalData.success && globalData.config.global_override_enabled;
} catch (e) {
console.debug('Could not check global config:', e);
}
// Generate hero section
const heroHTML = `
${artist.image_url ? `
@ -25431,6 +25463,18 @@ 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;
// Show global override notice if active
const existingNotice = document.querySelector('.global-override-notice');
if (existingNotice) existingNotice.remove();
if (globalOverrideActive) {
const notice = document.createElement('div');
notice.className = 'global-override-notice watchlist-global-override-banner';
notice.innerHTML = '<span>⚠️</span><span>Global override is active — these per-artist settings are currently ignored during scans.</span>';
const configBody = document.querySelector('.watchlist-artist-config-body');
if (configBody) configBody.insertBefore(notice, configBody.firstChild);
}
// Store artist ID for saving
const modal = document.getElementById('watchlist-artist-config-modal');
if (modal) {
@ -25476,6 +25520,178 @@ function closeWatchlistArtistConfigModal() {
}
}
/**
* Open global watchlist settings modal
*/
async function openWatchlistGlobalSettingsModal() {
try {
const response = await fetch('/api/watchlist/global-config');
const data = await response.json();
if (!data.success) {
showToast(`Error loading global settings: ${data.error}`, 'error');
return;
}
const config = data.config;
// Populate checkboxes
document.getElementById('global-override-enabled').checked = config.global_override_enabled;
document.getElementById('global-include-albums').checked = config.include_albums;
document.getElementById('global-include-eps').checked = config.include_eps;
document.getElementById('global-include-singles').checked = config.include_singles;
document.getElementById('global-include-live').checked = config.include_live;
document.getElementById('global-include-remixes').checked = config.include_remixes;
document.getElementById('global-include-acoustic').checked = config.include_acoustic;
document.getElementById('global-include-compilations').checked = config.include_compilations;
// Sync "Include Everything" checkbox
syncGlobalIncludeAllCheckbox();
// Update options visibility based on toggle state
toggleGlobalOverrideOptions();
// Update toggle label border
const toggleLabel = document.getElementById('global-override-toggle-label');
if (toggleLabel) {
toggleLabel.style.border = config.global_override_enabled
? '2px solid rgba(29, 185, 84, 0.5)'
: '2px solid rgba(255, 255, 255, 0.1)';
}
// Show modal
const overlay = document.getElementById('watchlist-global-config-modal-overlay');
if (overlay) overlay.classList.remove('hidden');
} catch (error) {
console.error('Error opening global watchlist settings:', error);
showToast(`Error: ${error.message}`, 'error');
}
}
/**
* Close global watchlist settings modal
*/
function closeWatchlistGlobalSettingsModal() {
const overlay = document.getElementById('watchlist-global-config-modal-overlay');
if (overlay) overlay.classList.add('hidden');
}
/**
* Toggle global override options visibility
*/
function toggleGlobalOverrideOptions() {
const enabled = document.getElementById('global-override-enabled').checked;
const options = document.getElementById('global-override-options');
if (options) {
options.style.opacity = enabled ? '1' : '0.4';
options.style.pointerEvents = enabled ? 'auto' : 'none';
}
// Update toggle label border
const toggleLabel = document.getElementById('global-override-toggle-label');
if (toggleLabel) {
toggleLabel.style.border = enabled
? '2px solid rgba(29, 185, 84, 0.5)'
: '2px solid rgba(255, 255, 255, 0.1)';
}
}
/**
* Toggle all global include checkboxes
*/
function toggleGlobalIncludeAll() {
const checked = document.getElementById('global-include-all').checked;
['global-include-albums', 'global-include-eps', 'global-include-singles',
'global-include-live', 'global-include-remixes', 'global-include-acoustic',
'global-include-compilations'].forEach(id => {
const el = document.getElementById(id);
if (el) el.checked = checked;
});
}
/**
* Sync the "Include Everything" checkbox based on individual checkbox states
*/
function syncGlobalIncludeAllCheckbox() {
const allIds = ['global-include-albums', 'global-include-eps', 'global-include-singles',
'global-include-live', 'global-include-remixes', 'global-include-acoustic',
'global-include-compilations'];
const allChecked = allIds.every(id => {
const el = document.getElementById(id);
return el && el.checked;
});
const includeAllEl = document.getElementById('global-include-all');
if (includeAllEl) includeAllEl.checked = allChecked;
}
/**
* Save global watchlist configuration
*/
async function saveWatchlistGlobalConfig() {
try {
const globalOverrideEnabled = document.getElementById('global-override-enabled').checked;
const includeAlbums = document.getElementById('global-include-albums').checked;
const includeEps = document.getElementById('global-include-eps').checked;
const includeSingles = document.getElementById('global-include-singles').checked;
const includeLive = document.getElementById('global-include-live').checked;
const includeRemixes = document.getElementById('global-include-remixes').checked;
const includeAcoustic = document.getElementById('global-include-acoustic').checked;
const includeCompilations = document.getElementById('global-include-compilations').checked;
if (globalOverrideEnabled && !includeAlbums && !includeEps && !includeSingles) {
showToast('Please select at least one release type', 'error');
return;
}
const saveBtn = document.getElementById('save-global-config-btn');
if (saveBtn) {
saveBtn.disabled = true;
saveBtn.textContent = 'Saving...';
}
const response = await fetch('/api/watchlist/global-config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
global_override_enabled: globalOverrideEnabled,
include_albums: includeAlbums,
include_eps: includeEps,
include_singles: includeSingles,
include_live: includeLive,
include_remixes: includeRemixes,
include_acoustic: includeAcoustic,
include_compilations: includeCompilations,
})
});
const data = await response.json();
if (data.success) {
showToast('Global watchlist settings saved', 'success');
closeWatchlistGlobalSettingsModal();
// Refresh the watchlist modal to update button and banner
const watchlistModal = document.getElementById('watchlist-modal');
if (watchlistModal && watchlistModal.style.display === 'flex') {
await showWatchlistModal();
}
} else {
showToast(`Error: ${data.error}`, 'error');
}
} catch (error) {
console.error('Error saving global config:', error);
showToast(`Error: ${error.message}`, 'error');
} finally {
const saveBtn = document.getElementById('save-global-config-btn');
if (saveBtn) {
saveBtn.disabled = false;
saveBtn.textContent = 'Save Global Settings';
}
}
}
/**
* Save watchlist artist configuration
* @param {string} artistId - Spotify artist ID

View file

@ -9517,6 +9517,44 @@ body {
}
}
/* Watchlist Global Settings */
#watchlist-global-config-modal-overlay {
z-index: 11000;
}
.global-override-toggle {
border: 2px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
padding: 16px;
transition: border-color 0.3s;
}
.include-everything-option {
border: 1px dashed rgba(255, 152, 0, 0.4);
border-radius: 12px;
padding: 12px;
}
.watchlist-global-settings-active {
background: rgba(255, 152, 0, 0.2) !important;
border-color: rgba(255, 152, 0, 0.4) !important;
color: #ff9800 !important;
}
.watchlist-global-override-banner {
background: rgba(255, 152, 0, 0.15);
border: 1px solid rgba(255, 152, 0, 0.3);
border-radius: 8px;
padding: 10px 16px;
margin-bottom: 16px;
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: #ff9800;
animation: fadeIn 0.3s ease;
}
/* Add these styles to the end of style.css */
.sync-progress-indicator {