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 created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None updated_at: Optional[datetime] = None
image_url: Optional[str] = None image_url: Optional[str] = None
include_albums: bool = True
include_eps: bool = True
include_singles: bool = True
@dataclass @dataclass
class SimilarArtist: class SimilarArtist:
@ -262,6 +265,9 @@ class MusicDatabase:
# Add image_url column to watchlist_artists (migration) # Add image_url column to watchlist_artists (migration)
self._add_watchlist_artist_image_column(cursor) 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() conn.commit()
logger.info("Database initialized successfully") logger.info("Database initialized successfully")
@ -591,6 +597,27 @@ class MusicDatabase:
logger.error(f"Error adding image_url column to watchlist_artists: {e}") logger.error(f"Error adding image_url column 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_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): def close(self):
"""Close database connection (no-op since we create connections per operation)""" """Close database connection (no-op since we create connections per operation)"""
# Each operation creates and closes its own connection, so nothing to do here # Each operation creates and closes its own connection, so nothing to do here

View file

@ -12,6 +12,7 @@ import shutil
import glob import glob
import uuid import uuid
import re import re
import sqlite3
from pathlib import Path from pathlib import Path
from urllib.parse import urljoin from urllib.parse import urljoin
@ -14915,6 +14916,116 @@ def get_similar_artists_update_status():
print(f"Error getting similar artists status: {e}") print(f"Error getting similar artists status: {e}")
return jsonify({"success": False, "error": str(e)}), 500 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(): def _update_similar_artists_worker():
"""Background worker to update similar artists for all watchlist artists""" """Background worker to update similar artists for all watchlist artists"""
global similar_artists_update_state global similar_artists_update_state

View file

@ -2790,6 +2790,73 @@
</div> </div>
</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 --> <!-- Genre Browser Modal -->
<div class="genre-browser-modal-overlay" id="genre-browser-modal"> <div class="genre-browser-modal-overlay" id="genre-browser-modal">
<div class="genre-browser-modal-container"> <div class="genre-browser-modal-container">

View file

@ -19822,7 +19822,10 @@ async function showWatchlistModal() {
<div class="watchlist-artists-list" id="watchlist-artists-list"> <div class="watchlist-artists-list" id="watchlist-artists-list">
${artistsData.artists.map(artist => ` ${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 ? ` ${artist.image_url ? `
<img src="${artist.image_url}" <img src="${artist.image_url}"
alt="${escapeHtml(artist.artist_name)}" alt="${escapeHtml(artist.artist_name)}"
@ -19842,7 +19845,8 @@ async function showWatchlistModal() {
</div> </div>
<button class="playlist-modal-btn playlist-modal-btn-secondary watchlist-remove-btn" <button class="playlist-modal-btn playlist-modal-btn-secondary watchlist-remove-btn"
data-artist-id="${artist.spotify_artist_id}" 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 Remove
</button> </button>
</div> </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 // Show modal
modal.style.display = 'flex'; 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 * Filter watchlist artists based on search input
*/ */

View file

@ -7960,6 +7960,313 @@ body {
border-bottom: 1px solid rgba(255, 255, 255, 0.08); 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 */ /* Add these styles to the end of style.css */
.sync-progress-indicator { .sync-progress-indicator {