foundation for allowing user to decide which releases to grab per watchlist artist

This commit is contained in:
Broque Thomas 2025-11-24 17:16:12 -08:00
parent fd206f552d
commit 1749bb62a5
5 changed files with 712 additions and 2 deletions

View file

@ -86,6 +86,9 @@ class WatchlistArtist:
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
image_url: Optional[str] = None
include_albums: bool = True
include_eps: bool = True
include_singles: bool = True
@dataclass
class SimilarArtist:
@ -262,6 +265,9 @@ class MusicDatabase:
# Add image_url column to watchlist_artists (migration)
self._add_watchlist_artist_image_column(cursor)
# Add album type filter columns to watchlist_artists (migration)
self._add_watchlist_album_type_filters(cursor)
conn.commit()
logger.info("Database initialized successfully")
@ -591,6 +597,27 @@ class MusicDatabase:
logger.error(f"Error adding image_url column to watchlist_artists: {e}")
# Don't raise - this is a migration, database can still function
def _add_watchlist_album_type_filters(self, cursor):
"""Add album type filter columns to watchlist_artists table"""
try:
cursor.execute("PRAGMA table_info(watchlist_artists)")
columns = [column[1] for column in cursor.fetchall()]
columns_to_add = {
'include_albums': ('INTEGER', '1'), # 1 = True (include albums)
'include_eps': ('INTEGER', '1'), # 1 = True (include EPs)
'include_singles': ('INTEGER', '1') # 1 = True (include singles)
}
for column_name, (column_type, default_value) in columns_to_add.items():
if column_name not in columns:
cursor.execute(f"ALTER TABLE watchlist_artists ADD COLUMN {column_name} {column_type} DEFAULT {default_value}")
logger.info(f"Added {column_name} column to watchlist_artists table")
except Exception as e:
logger.error(f"Error adding album type filter columns to watchlist_artists: {e}")
# Don't raise - this is a migration, database can still function
def close(self):
"""Close database connection (no-op since we create connections per operation)"""
# Each operation creates and closes its own connection, so nothing to do here

View file

@ -12,6 +12,7 @@ import shutil
import glob
import uuid
import re
import sqlite3
from pathlib import Path
from urllib.parse import urljoin
@ -14915,6 +14916,116 @@ def get_similar_artists_update_status():
print(f"Error getting similar artists status: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/watchlist/artist/<artist_id>/config', methods=['GET', 'POST'])
def watchlist_artist_config(artist_id):
"""Get or update watchlist artist configuration"""
try:
from database.music_database import get_database
database = get_database()
if request.method == 'GET':
# Get current config from database
conn = sqlite3.connect(str(database.database_path))
cursor = conn.cursor()
cursor.execute("""
SELECT include_albums, include_eps, include_singles, artist_name, image_url
FROM watchlist_artists
WHERE spotify_artist_id = ?
""", (artist_id,))
result = cursor.fetchone()
conn.close()
if not result:
return jsonify({"success": False, "error": "Artist not found in watchlist"}), 404
# Get artist info from Spotify
artist_info = None
if spotify_client and spotify_client.is_authenticated():
try:
artist_data = spotify_client.sp.artist(artist_id)
if artist_data:
artist_info = {
'id': artist_data['id'],
'name': artist_data['name'],
'image_url': artist_data['images'][0]['url'] if artist_data.get('images') else None,
'followers': artist_data.get('followers', {}).get('total', 0),
'popularity': artist_data.get('popularity', 0),
'genres': artist_data.get('genres', [])
}
except Exception as e:
print(f"Warning: Could not fetch artist info from Spotify: {e}")
# Fallback to database info if Spotify fetch failed
if not artist_info:
artist_info = {
'id': artist_id,
'name': result[3], # artist_name
'image_url': result[4], # image_url
'followers': 0,
'popularity': 0,
'genres': []
}
config = {
'include_albums': bool(result[0]), # Convert INTEGER to boolean
'include_eps': bool(result[1]),
'include_singles': bool(result[2])
}
return jsonify({
"success": True,
"config": config,
"artist": artist_info
})
else: # POST
data = request.get_json()
if not data:
return jsonify({"success": False, "error": "No data provided"}), 400
include_albums = data.get('include_albums', True)
include_eps = data.get('include_eps', True)
include_singles = data.get('include_singles', True)
# Validate at least one is selected
if not (include_albums or include_eps or include_singles):
return jsonify({"success": False, "error": "At least one release type must be selected"}), 400
# Update database
conn = sqlite3.connect(str(database.database_path))
cursor = conn.cursor()
cursor.execute("""
UPDATE watchlist_artists
SET include_albums = ?, include_eps = ?, include_singles = ?, updated_at = CURRENT_TIMESTAMP
WHERE spotify_artist_id = ?
""", (int(include_albums), int(include_eps), int(include_singles), artist_id))
conn.commit()
if cursor.rowcount == 0:
conn.close()
return jsonify({"success": False, "error": "Artist not found in watchlist"}), 404
conn.close()
print(f"✅ Updated watchlist config for artist {artist_id}: albums={include_albums}, eps={include_eps}, singles={include_singles}")
return jsonify({
"success": True,
"message": "Artist configuration updated successfully",
"config": {
'include_albums': include_albums,
'include_eps': include_eps,
'include_singles': include_singles
}
})
except Exception as e:
print(f"Error in watchlist artist config: {e}")
import traceback
traceback.print_exc()
return jsonify({"success": False, "error": str(e)}), 500
def _update_similar_artists_worker():
"""Background worker to update similar artists for all watchlist artists"""
global similar_artists_update_state

View file

@ -2790,6 +2790,73 @@
</div>
</div>
<!-- Watchlist Artist Config Modal -->
<div class="modal-overlay hidden" id="watchlist-artist-config-modal-overlay">
<div class="watchlist-artist-config-modal" id="watchlist-artist-config-modal">
<div class="watchlist-artist-config-header">
<div class="watchlist-artist-config-hero" id="watchlist-artist-config-hero">
<!-- Hero content will be dynamically populated -->
</div>
<span class="watchlist-artist-config-close" onclick="closeWatchlistArtistConfigModal()">&times;</span>
</div>
<div class="watchlist-artist-config-content">
<div class="watchlist-artist-config-body">
<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>
<div class="config-options">
<label class="config-option">
<input type="checkbox" id="config-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="config-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="config-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>
</div>
<div class="watchlist-artist-config-footer">
<div class="config-modal-actions">
<button class="config-modal-btn config-modal-btn-secondary" onclick="closeWatchlistArtistConfigModal()">
Cancel
</button>
<button class="config-modal-btn config-modal-btn-primary" id="save-artist-config-btn">
Save Preferences
</button>
</div>
</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

@ -19822,7 +19822,10 @@ async function showWatchlistModal() {
<div class="watchlist-artists-list" id="watchlist-artists-list">
${artistsData.artists.map(artist => `
<div class="watchlist-artist-item" data-artist-name="${artist.artist_name.toLowerCase().replace(/"/g, '&quot;')}">
<div class="watchlist-artist-item"
data-artist-name="${artist.artist_name.toLowerCase().replace(/"/g, '&quot;')}"
data-artist-id="${artist.spotify_artist_id}"
style="cursor: pointer;">
${artist.image_url ? `
<img src="${artist.image_url}"
alt="${escapeHtml(artist.artist_name)}"
@ -19842,7 +19845,8 @@ async function showWatchlistModal() {
</div>
<button class="playlist-modal-btn playlist-modal-btn-secondary watchlist-remove-btn"
data-artist-id="${artist.spotify_artist_id}"
data-artist-name="${escapeHtml(artist.artist_name)}">
data-artist-name="${escapeHtml(artist.artist_name)}"
onclick="event.stopPropagation();">
Remove
</button>
</div>
@ -19867,6 +19871,22 @@ async function showWatchlistModal() {
});
});
// Add click handlers to artist items (except for remove button)
modal.querySelectorAll('.watchlist-artist-item').forEach(item => {
item.addEventListener('click', (e) => {
// Don't trigger if clicking the remove button
if (e.target.closest('.watchlist-remove-btn')) {
return;
}
const artistId = item.getAttribute('data-artist-id');
const artistName = item.querySelector('.watchlist-artist-name').textContent;
console.log(`🎵 Artist card clicked: ${artistName} (${artistId})`);
openWatchlistArtistConfigModal(artistId, artistName);
});
});
// Show modal
modal.style.display = 'flex';
@ -19890,6 +19910,184 @@ function closeWatchlistModal() {
}
}
/**
* Open watchlist artist configuration modal
* @param {string} artistId - Spotify artist ID
* @param {string} artistName - Artist name
*/
async function openWatchlistArtistConfigModal(artistId, artistName) {
try {
console.log(`🎨 Opening config modal for artist: ${artistName} (${artistId})`);
// Fetch artist config and info
const response = await fetch(`/api/watchlist/artist/${artistId}/config`);
const data = await response.json();
if (!data.success) {
console.error('Error loading artist config:', data.error);
showToast(`Error loading artist configuration: ${data.error}`, 'error');
return;
}
const { config, artist } = data;
// Generate hero section
const heroHTML = `
${artist.image_url ? `
<img src="${artist.image_url}"
alt="${escapeHtml(artist.name)}"
class="watchlist-artist-config-hero-image">
` : ''}
<div class="watchlist-artist-config-hero-info">
<h2 class="watchlist-artist-config-hero-name">${escapeHtml(artist.name)}</h2>
<div class="watchlist-artist-config-hero-stats">
<div class="watchlist-artist-config-stat">
<span class="watchlist-artist-config-stat-value">${formatNumber(artist.followers)}</span>
<span class="watchlist-artist-config-stat-label">Followers</span>
</div>
<div class="watchlist-artist-config-stat">
<span class="watchlist-artist-config-stat-value">${artist.popularity}/100</span>
<span class="watchlist-artist-config-stat-label">Popularity</span>
</div>
</div>
${artist.genres && artist.genres.length > 0 ? `
<div class="watchlist-artist-config-hero-genres">
${artist.genres.slice(0, 3).map(genre =>
`<span class="watchlist-artist-config-genre-tag">${escapeHtml(genre)}</span>`
).join('')}
</div>
` : ''}
</div>
`;
// Populate hero section
const heroContainer = document.getElementById('watchlist-artist-config-hero');
if (heroContainer) {
heroContainer.innerHTML = heroHTML;
}
// Set checkbox states
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;
// Store artist ID for saving
const modal = document.getElementById('watchlist-artist-config-modal');
if (modal) {
modal.setAttribute('data-artist-id', artistId);
}
// Show modal
const overlay = document.getElementById('watchlist-artist-config-modal-overlay');
if (overlay) {
overlay.classList.remove('hidden');
}
// Add save button handler
const saveBtn = document.getElementById('save-artist-config-btn');
if (saveBtn) {
// Remove old listeners
const newSaveBtn = saveBtn.cloneNode(true);
saveBtn.parentNode.replaceChild(newSaveBtn, saveBtn);
// Add new listener
newSaveBtn.addEventListener('click', () => saveWatchlistArtistConfig(artistId));
}
} catch (error) {
console.error('Error opening watchlist artist config modal:', error);
showToast(`Error: ${error.message}`, 'error');
}
}
/**
* Close watchlist artist configuration modal
*/
function closeWatchlistArtistConfigModal() {
const overlay = document.getElementById('watchlist-artist-config-modal-overlay');
if (overlay) {
overlay.classList.add('hidden');
}
// Clear hero content
const heroContainer = document.getElementById('watchlist-artist-config-hero');
if (heroContainer) {
heroContainer.innerHTML = '';
}
}
/**
* Save watchlist artist configuration
* @param {string} artistId - Spotify artist ID
*/
async function saveWatchlistArtistConfig(artistId) {
try {
const includeAlbums = document.getElementById('config-include-albums').checked;
const includeEps = document.getElementById('config-include-eps').checked;
const includeSingles = document.getElementById('config-include-singles').checked;
// Validate at least one is selected
if (!includeAlbums && !includeEps && !includeSingles) {
showToast('Please select at least one release type', 'error');
return;
}
// Disable save button
const saveBtn = document.getElementById('save-artist-config-btn');
if (saveBtn) {
saveBtn.disabled = true;
saveBtn.textContent = 'Saving...';
}
// Send update to backend
const response = await fetch(`/api/watchlist/artist/${artistId}/config`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
include_albums: includeAlbums,
include_eps: includeEps,
include_singles: includeSingles
})
});
const data = await response.json();
if (data.success) {
showToast('Artist preferences saved successfully', 'success');
closeWatchlistArtistConfigModal();
// Refresh watchlist modal if it's open
const watchlistModal = document.getElementById('watchlist-modal');
if (watchlistModal && watchlistModal.style.display === 'flex') {
await showWatchlistModal();
}
} else {
showToast(`Error saving preferences: ${data.error}`, 'error');
}
} catch (error) {
console.error('Error saving watchlist artist config:', error);
showToast(`Error: ${error.message}`, 'error');
} finally {
// Re-enable save button
const saveBtn = document.getElementById('save-artist-config-btn');
if (saveBtn) {
saveBtn.disabled = false;
saveBtn.textContent = 'Save Preferences';
}
}
}
/**
* Format large numbers with commas
* @param {number} num - Number to format
* @returns {string} Formatted number
*/
function formatNumber(num) {
if (!num) return '0';
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
}
/**
* Filter watchlist artists based on search input
*/

View file

@ -7960,6 +7960,313 @@ body {
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
/* ===== WATCHLIST ARTIST CONFIG MODAL STYLES ===== */
#watchlist-artist-config-modal-overlay {
z-index: 11000; /* Higher than default modal-overlay to appear on top of watchlist modal */
}
.watchlist-artist-config-modal {
background: linear-gradient(135deg,
rgba(26, 26, 26, 0.98) 0%,
rgba(18, 18, 18, 0.98) 100%);
border-radius: 24px;
box-shadow: 0 25px 80px rgba(0, 0, 0, 0.6);
max-width: 580px;
width: 90%;
max-height: 90vh;
overflow: hidden;
display: flex;
flex-direction: column;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.watchlist-artist-config-header {
position: relative;
background: linear-gradient(180deg,
rgba(29, 185, 84, 0.15) 0%,
rgba(29, 185, 84, 0.05) 50%,
transparent 100%);
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.watchlist-artist-config-hero {
padding: 32px 28px;
display: flex;
align-items: center;
gap: 24px;
}
.watchlist-artist-config-hero-image {
width: 100px;
height: 100px;
border-radius: 50%;
object-fit: cover;
border: 3px solid rgba(29, 185, 84, 0.3);
box-shadow: 0 8px 24px rgba(29, 185, 84, 0.2);
flex-shrink: 0;
}
.watchlist-artist-config-hero-info {
flex: 1;
min-width: 0;
}
.watchlist-artist-config-hero-name {
font-size: 28px;
font-weight: 700;
color: #ffffff;
margin-bottom: 8px;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
.watchlist-artist-config-hero-stats {
display: flex;
gap: 20px;
margin-top: 12px;
flex-wrap: wrap;
}
.watchlist-artist-config-stat {
display: flex;
flex-direction: column;
gap: 2px;
}
.watchlist-artist-config-stat-value {
font-size: 18px;
font-weight: 600;
color: rgba(29, 185, 84, 1);
}
.watchlist-artist-config-stat-label {
font-size: 12px;
color: rgba(255, 255, 255, 0.5);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.watchlist-artist-config-hero-genres {
margin-top: 12px;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.watchlist-artist-config-genre-tag {
padding: 4px 12px;
background: rgba(29, 185, 84, 0.15);
border: 1px solid rgba(29, 185, 84, 0.3);
border-radius: 12px;
font-size: 12px;
color: rgba(29, 185, 84, 1);
font-weight: 500;
}
.watchlist-artist-config-close {
position: absolute;
top: 20px;
right: 20px;
font-size: 32px;
color: rgba(255, 255, 255, 0.6);
cursor: pointer;
transition: all 0.2s ease;
line-height: 1;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
background: rgba(0, 0, 0, 0.3);
}
.watchlist-artist-config-close:hover {
color: #ffffff;
background: rgba(255, 59, 48, 0.2);
transform: scale(1.1);
}
.watchlist-artist-config-content {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.watchlist-artist-config-body {
padding: 28px;
flex: 1;
}
.config-section {
margin-bottom: 24px;
}
.config-section-title {
font-size: 20px;
font-weight: 600;
color: #ffffff;
margin-bottom: 8px;
}
.config-section-subtitle {
font-size: 14px;
color: rgba(255, 255, 255, 0.6);
margin-bottom: 24px;
}
.config-options {
display: flex;
flex-direction: column;
gap: 16px;
}
.config-option {
display: flex;
align-items: center;
padding: 20px;
background: rgba(255, 255, 255, 0.03);
border: 2px solid rgba(255, 255, 255, 0.08);
border-radius: 16px;
cursor: pointer;
transition: all 0.3s ease;
position: relative;
}
.config-option:hover {
background: rgba(255, 255, 255, 0.05);
border-color: rgba(29, 185, 84, 0.3);
transform: translateY(-2px);
}
.config-option input[type="checkbox"] {
width: 24px;
height: 24px;
margin-right: 16px;
cursor: pointer;
accent-color: #1db954;
flex-shrink: 0;
}
.config-option-content {
display: flex;
align-items: center;
flex: 1;
gap: 16px;
}
.config-option-icon {
font-size: 32px;
flex-shrink: 0;
}
.config-option-text {
display: flex;
flex-direction: column;
gap: 4px;
}
.config-option-title {
font-size: 16px;
font-weight: 600;
color: #ffffff;
}
.config-option-description {
font-size: 13px;
color: rgba(255, 255, 255, 0.5);
}
.config-option input[type="checkbox"]:checked + .config-option-content {
opacity: 1;
}
.config-option input[type="checkbox"]:not(:checked) + .config-option-content {
opacity: 0.5;
}
.watchlist-artist-config-footer {
padding: 20px 28px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(0, 0, 0, 0.2);
}
.config-modal-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
}
.config-modal-btn {
padding: 12px 28px;
border-radius: 12px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
border: none;
outline: none;
}
.config-modal-btn-primary {
background: linear-gradient(135deg, #1ed760, #1db954);
color: #000;
box-shadow: 0 4px 16px rgba(29, 185, 84, 0.3);
}
.config-modal-btn-primary:hover {
background: linear-gradient(135deg, #1fdf64, #1ed760);
box-shadow: 0 6px 20px rgba(29, 185, 84, 0.4);
transform: translateY(-1px);
}
.config-modal-btn-primary:active {
transform: translateY(0);
}
.config-modal-btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.config-modal-btn-secondary {
background: rgba(255, 255, 255, 0.08);
color: #ffffff;
border: 1px solid rgba(255, 255, 255, 0.15);
}
.config-modal-btn-secondary:hover {
background: rgba(255, 255, 255, 0.12);
border-color: rgba(255, 255, 255, 0.25);
}
/* Mobile Responsive */
@media (max-width: 640px) {
.watchlist-artist-config-modal {
width: 95%;
max-width: none;
}
.watchlist-artist-config-hero {
flex-direction: column;
text-align: center;
padding: 24px 20px;
}
.watchlist-artist-config-hero-stats {
justify-content: center;
}
.config-modal-actions {
flex-direction: column-reverse;
}
.config-modal-btn {
width: 100%;
}
}
/* Add these styles to the end of style.css */
.sync-progress-indicator {