From 7e19e66ef3e82fbc64940fa050691661f1a37a70 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sat, 14 Mar 2026 17:09:08 -0700 Subject: [PATCH] Add quality enhance button to upgrade existing library tracks & Add iTunes fallback to Quality Enhance endpoint for full metadata source parity --- database/music_database.py | 13 ++ web_server.py | 401 ++++++++++++++++++++++++++++++++++++- webui/index.html | 6 + webui/static/script.js | 291 +++++++++++++++++++++++++++ webui/static/style.css | 395 ++++++++++++++++++++++++++++++++++++ 5 files changed, 1105 insertions(+), 1 deletion(-) diff --git a/database/music_database.py b/database/music_database.py index e7ba9fee..60636210 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -4960,6 +4960,19 @@ class MusicDatabase: # Case-insensitive comparison of track name and primary artist if (existing_name.lower() == track_name.lower() and existing_artist.lower() == artist_name.lower()): + # Enhance mode: upsert existing entry with enhance bypass context + if source_type == 'enhance': + source_json = json.dumps(source_info or {}) + cursor.execute(""" + UPDATE wishlist_tracks + SET source_type = ?, source_info = ?, failure_reason = ?, + spotify_data = ?, spotify_track_id = ? + WHERE id = ? + """, (source_type, source_json, failure_reason, + json.dumps(spotify_track_data), track_id, existing['id'])) + conn.commit() + logger.info(f"Upserted wishlist entry to enhance mode: '{track_name}' by {artist_name}") + return True logger.info(f"Skipping duplicate wishlist entry: '{track_name}' by {artist_name} (already exists as ID: {existing['id']})") return False # Already exists, don't add duplicate except Exception as parse_error: diff --git a/web_server.py b/web_server.py index 52b7c3e6..3f8ecd90 100644 --- a/web_server.py +++ b/web_server.py @@ -9291,6 +9291,353 @@ def get_artist_enhanced_detail(artist_id): except Exception as e: return jsonify({"success": False, "error": str(e)}), 500 +@app.route('/api/library/artist//quality-analysis') +def get_artist_quality_analysis(artist_id): + """Analyze track quality for an artist — returns tier classification for each track.""" + try: + database = get_database() + result = database.get_artist_full_detail(artist_id) + if not result.get('success'): + return jsonify(result), 404 + + artist = result.get('artist', {}) + albums = result.get('albums', []) + + # Get user's quality profile to determine min acceptable tier + quality_profile = database.get_quality_profile() + preferred_qualities = quality_profile.get('qualities', {}) + min_acceptable_tier = 999 + tier_map = {'flac': 'lossless', 'mp3_320': 'low_lossy', 'mp3_256': 'low_lossy', 'mp3_192': 'low_lossy'} + for qname, qconfig in preferred_qualities.items(): + if qconfig.get('enabled', False): + tname = tier_map.get(qname) + if tname and tname in QUALITY_TIERS: + min_acceptable_tier = min(min_acceptable_tier, QUALITY_TIERS[tname]['tier']) + + tracks = [] + summary = {'total': 0, 'lossless': 0, 'high_lossy': 0, 'standard_lossy': 0, 'low_lossy': 0, 'unknown': 0} + + for album in albums: + album_title = album.get('title', 'Unknown Album') + album_id = album.get('id') + for track in album.get('tracks', []): + file_path = track.get('file_path') + if not file_path: + continue + + tier_name, tier_num = _get_quality_tier_from_extension(file_path) + ext = os.path.splitext(file_path)[1].lstrip('.').upper() + + # Use filename as fallback when title is empty + title = track.get('title', '') or '' + if not title.strip(): + title = os.path.splitext(os.path.basename(file_path))[0] + + tracks.append({ + 'track_id': track.get('id'), + 'title': title, + 'album_title': album_title, + 'album_id': album_id, + 'file_path': file_path, + 'format': ext or 'Unknown', + 'tier_name': tier_name, + 'tier_num': tier_num, + 'bitrate': track.get('bitrate'), + 'spotify_track_id': track.get('spotify_track_id'), + 'track_number': track.get('track_number'), + 'disc_number': track.get('disc_number'), + }) + summary['total'] += 1 + if tier_name in summary: + summary[tier_name] += 1 + else: + summary['unknown'] += 1 + + return jsonify({ + 'success': True, + 'artist_name': artist.get('name', 'Unknown Artist'), + 'artist_id': artist_id, + 'tracks': tracks, + 'quality_summary': summary, + 'min_acceptable_tier': min_acceptable_tier + }) + except Exception as e: + return jsonify({"success": False, "error": str(e)}), 500 + +@app.route('/api/library/artist//enhance', methods=['POST']) +def enhance_artist_quality(artist_id): + """Add selected tracks to wishlist for quality enhancement re-download.""" + try: + data = request.get_json() or {} + track_ids = data.get('track_ids', []) + if not track_ids: + return jsonify({"success": False, "error": "No track IDs provided"}), 400 + + database = get_database() + from core.wishlist_service import get_wishlist_service + wishlist_service = get_wishlist_service() + profile_id = get_current_profile_id() + + # Get artist info + artist_result = database.get_artist_full_detail(artist_id) + if not artist_result.get('success'): + return jsonify({"success": False, "error": "Artist not found"}), 404 + + artist_name = artist_result.get('artist', {}).get('name', 'Unknown Artist') + + # Build lookup of all tracks for this artist + track_lookup = {} + for album in artist_result.get('albums', []): + album_title = album.get('title', '') + for track in album.get('tracks', []): + tid = str(track.get('id', '')) + track['_album_title'] = album_title + track['_album_id'] = album.get('id') + track_lookup[tid] = track + + enhanced_count = 0 + failed_count = 0 + failed_tracks = [] + + for track_id in track_ids: + track_id_str = str(track_id) + track = track_lookup.get(track_id_str) + if not track: + failed_count += 1 + failed_tracks.append({'track_id': track_id, 'reason': 'Track not found'}) + continue + + file_path = track.get('file_path') + if not file_path: + failed_count += 1 + failed_tracks.append({'track_id': track_id, 'reason': 'No file path'}) + continue + + tier_name, tier_num = _get_quality_tier_from_extension(file_path) + title = track.get('title', '') or '' + if not title.strip(): + title = os.path.splitext(os.path.basename(file_path))[0] + spotify_tid = track.get('spotify_track_id') + + # Build Spotify track data for wishlist + matched_track_data = None + + if spotify_tid and spotify_client: + # Direct lookup via stored Spotify ID — raw_data has full Spotify API format + try: + track_details = spotify_client.get_track_details(spotify_tid) + if track_details and track_details.get('raw_data'): + matched_track_data = track_details['raw_data'] + elif track_details: + # Enhanced format — rebuild with images for wishlist compatibility + album_data = track_details.get('album', {}) + album_images = [] + # Try to get album art from a full album lookup + if album_data.get('id'): + try: + full_album = spotify_client.get_album(album_data['id']) + if full_album and full_album.get('images'): + album_images = full_album['images'] + except Exception: + pass + matched_track_data = { + 'id': spotify_tid, + 'name': track_details.get('name', title), + 'artists': [{'name': a} for a in track_details.get('artists', [artist_name])], + 'album': { + 'id': album_data.get('id', ''), + 'name': album_data.get('name', track.get('_album_title', '')), + 'album_type': album_data.get('album_type', 'album'), + 'release_date': album_data.get('release_date', ''), + 'total_tracks': album_data.get('total_tracks', 1), + 'artists': [{'name': a} for a in album_data.get('artists', [artist_name])], + 'images': album_images, + }, + 'duration_ms': track_details.get('duration_ms', track.get('duration', 0)), + 'track_number': track_details.get('track_number', track.get('track_number', 1)), + 'disc_number': track_details.get('disc_number', 1), + 'popularity': 0, + 'preview_url': None, + 'external_urls': {}, + } + except Exception as e: + print(f"⚠️ [Enhance] Spotify lookup failed for {spotify_tid}: {e}") + + if not matched_track_data and spotify_client: + # Fallback: Spotify search matching — need full track data for wishlist + try: + temp_track = type('TempTrack', (), { + 'name': title, 'artists': [artist_name], + 'album': track.get('_album_title', '') + })() + search_queries = matching_engine.generate_download_queries(temp_track) + best_match = None + best_match_raw = None + best_confidence = 0.0 + + for search_query in search_queries[:3]: # Limit queries + try: + results = spotify_client.search_tracks(search_query, limit=5) + if not results: + continue + for sp_track in results: + artist_conf = max( + (matching_engine.similarity_score( + matching_engine.normalize_string(artist_name), + matching_engine.normalize_string(a) + ) for a in (sp_track.artists or [artist_name])), + default=0 + ) + title_conf = matching_engine.similarity_score( + matching_engine.normalize_string(title), + matching_engine.normalize_string(sp_track.name) + ) + combined = artist_conf * 0.5 + title_conf * 0.5 + if combined > best_confidence and combined >= 0.7: + best_confidence = combined + best_match = sp_track + if best_confidence >= 0.9: + break + except Exception: + continue + + if best_match: + # Fetch full track data from Spotify for proper wishlist format + try: + full_details = spotify_client.get_track_details(best_match.id) + if full_details and full_details.get('raw_data'): + matched_track_data = full_details['raw_data'] + else: + raise ValueError("No raw_data from get_track_details") + except Exception: + # Build from Track dataclass with image + album_images = [{'url': best_match.image_url}] if best_match.image_url else [] + matched_track_data = { + 'id': best_match.id, + 'name': best_match.name, + 'artists': [{'name': a} for a in best_match.artists], + 'album': { + 'name': best_match.album, + 'artists': [{'name': a} for a in best_match.artists], + 'album_type': 'album', + 'images': album_images, + }, + 'duration_ms': best_match.duration_ms, + 'popularity': best_match.popularity or 0, + 'preview_url': best_match.preview_url, + 'external_urls': best_match.external_urls or {}, + } + except Exception as e: + print(f"⚠️ [Enhance] Search match failed for {title}: {e}") + + # iTunes fallback when Spotify unavailable or no match found + if not matched_track_data: + try: + from core.itunes_client import iTunesClient + itunes_client = iTunesClient() + itunes_best = None + itunes_best_conf = 0.0 + + itunes_queries = matching_engine.generate_download_queries( + type('TempTrack', (), { + 'name': title, 'artists': [artist_name], + 'album': track.get('_album_title', '') + })() + ) + + for search_query in itunes_queries[:3]: + try: + itunes_results = itunes_client.search_tracks(search_query, limit=5) + if not itunes_results: + continue + for it_track in itunes_results: + artist_conf = max( + (matching_engine.similarity_score( + matching_engine.normalize_string(artist_name), + matching_engine.normalize_string(a) + ) for a in (it_track.artists or [artist_name])), + default=0 + ) + title_conf = matching_engine.similarity_score( + matching_engine.normalize_string(title), + matching_engine.normalize_string(it_track.name) + ) + combined = artist_conf * 0.5 + title_conf * 0.5 + if combined > itunes_best_conf and combined >= 0.7: + itunes_best_conf = combined + itunes_best = it_track + if itunes_best_conf >= 0.9: + break + except Exception: + continue + + if itunes_best: + album_images = [{'url': itunes_best.image_url, 'height': 600, 'width': 600}] if itunes_best.image_url else [] + matched_track_data = { + 'id': itunes_best.id, + 'name': itunes_best.name, + 'artists': [{'name': a} for a in itunes_best.artists], + 'album': { + 'name': itunes_best.album, + 'artists': [{'name': a} for a in itunes_best.artists], + 'album_type': 'album', + 'images': album_images, + 'release_date': itunes_best.release_date or '', + 'total_tracks': 1, + }, + 'duration_ms': itunes_best.duration_ms, + 'track_number': itunes_best.track_number or 1, + 'disc_number': itunes_best.disc_number or 1, + 'popularity': itunes_best.popularity or 0, + 'preview_url': itunes_best.preview_url, + 'external_urls': itunes_best.external_urls or {}, + } + print(f"🍎 [Enhance] iTunes fallback match for {title}: {itunes_best.artists[0]} - {itunes_best.name} (conf: {itunes_best_conf:.3f})") + except Exception as e: + print(f"⚠️ [Enhance] iTunes fallback failed for {title}: {e}") + + if not matched_track_data: + failed_count += 1 + failed_tracks.append({'track_id': track_id, 'title': title, 'reason': 'No Spotify or iTunes match'}) + continue + + # Add to wishlist with enhance source + source_context = { + 'enhance': True, + 'original_file_path': file_path, + 'original_format': tier_name, + 'original_bitrate': track.get('bitrate'), + 'original_tier': tier_num, + 'artist_name': artist_name, + } + + success = wishlist_service.add_spotify_track_to_wishlist( + spotify_track_data=matched_track_data, + failure_reason=f"Quality enhance - upgrading from {tier_name.replace('_', ' ').title()}", + source_type='enhance', + source_context=source_context, + profile_id=profile_id + ) + + if success: + enhanced_count += 1 + print(f"✅ [Enhance] Queued for upgrade: {artist_name} - {title} ({tier_name})") + else: + failed_count += 1 + failed_tracks.append({'track_id': track_id, 'title': title, 'reason': 'Wishlist add failed'}) + + return jsonify({ + 'success': True, + 'enhanced_count': enhanced_count, + 'failed_count': failed_count, + 'failed_tracks': failed_tracks + }) + except Exception as e: + print(f"❌ [Enhance] Error: {e}") + import traceback + traceback.print_exc() + return jsonify({"success": False, "error": str(e)}), 500 + @app.route('/api/library/artist/', methods=['PUT']) def update_library_artist(artist_id): """Update artist metadata fields.""" @@ -13223,6 +13570,22 @@ def _build_final_path_for_track(context, spotify_artist, album_info, file_ext): original_search = context.get("original_search_result", {}) playlist_folder_mode = track_info.get("_playlist_folder_mode", False) + # ENHANCE BYPASS: Place file in same location as original (different extension OK) + _source_info = track_info.get('source_info') or {} + if isinstance(_source_info, str): + try: + _source_info = json.loads(_source_info) + except (json.JSONDecodeError, TypeError): + _source_info = {} + if _source_info.get('enhance') and _source_info.get('original_file_path'): + original_path = _source_info['original_file_path'] + original_dir = os.path.dirname(original_path) + original_stem = os.path.splitext(os.path.basename(original_path))[0] + final_path = os.path.join(original_dir, original_stem + file_ext) + os.makedirs(original_dir, exist_ok=True) + print(f"🔄 [Enhance] Using original file location: {final_path}") + return final_path, True + # Extract year and album_type from spotify_album for template use (safe for all modes) year = '' # Empty string instead of 'Unknown' to avoid "Unknown albumName" spotify_album = context.get("spotify_album", {}) @@ -15440,6 +15803,15 @@ def _post_process_matched_download(context_key, context, file_path): pp_logger.info(f"[inner] Metadata enhancement FAILED for {context_key}: {meta_err}\n{traceback.format_exc()}") # Continue anyway - file can still be moved + # Detect enhance mode from track context + _enhance_source_info = context.get('track_info', {}).get('source_info') or {} + if isinstance(_enhance_source_info, str): + try: + _enhance_source_info = json.loads(_enhance_source_info) + except (json.JSONDecodeError, TypeError): + _enhance_source_info = {} + is_enhance_download = _enhance_source_info.get('enhance', False) + print(f"🚚 Moving '{os.path.basename(file_path)}' to '{final_path}'") if os.path.exists(final_path): # PROTECTION: If destination already exists, check before overwriting @@ -15452,7 +15824,7 @@ def _post_process_matched_download(context_key, context, file_path): existing_file = MutagenFile(final_path) has_metadata = existing_file is not None and len(existing_file.tags or {}) > 2 # More than basic tags - if has_metadata: + if has_metadata and not is_enhance_download: print(f"⚠️ [Protection] Existing file already has metadata enhancement - skipping overwrite: {os.path.basename(final_path)}") print(f"🗑️ [Protection] Removing redundant download file: {os.path.basename(file_path)}") try: @@ -15462,6 +15834,13 @@ def _post_process_matched_download(context_key, context, file_path): except Exception as e: print(f"⚠️ [Protection] Error removing redundant file: {e}") return # Don't overwrite the good file + elif is_enhance_download: + # ENHANCE BYPASS: Allow overwrite — backup original, then remove to allow move + print(f"🔄 [Enhance] Quality enhance mode — replacing existing file: {os.path.basename(final_path)}") + try: + os.remove(final_path) + except Exception as e: + print(f"⚠️ [Enhance] Could not remove existing file for replacement: {e}") else: print(f"🔄 [Protection] Existing file lacks metadata - safe to overwrite: {os.path.basename(final_path)}") try: @@ -15519,6 +15898,22 @@ def _post_process_matched_download(context_key, context, file_path): _safe_move_file(file_path, final_path) + # ENHANCE CLEANUP: If format changed (e.g. MP3→FLAC), remove old-format file + if is_enhance_download and _enhance_source_info.get('original_file_path'): + original_enhance_path = _enhance_source_info['original_file_path'] + if os.path.normpath(original_enhance_path) != os.path.normpath(final_path) and os.path.exists(original_enhance_path): + try: + os.remove(original_enhance_path) + old_fmt = os.path.splitext(original_enhance_path)[1] + new_fmt = os.path.splitext(final_path)[1] + print(f"✅ [Enhance] Upgraded {old_fmt} → {new_fmt}: {os.path.basename(final_path)}") + except Exception as e: + print(f"⚠️ [Enhance] Could not remove old-format file: {e}") + elif is_enhance_download: + old_fmt = _enhance_source_info.get('original_format', 'unknown') + new_fmt = os.path.splitext(final_path)[1] + print(f"✅ [Enhance] Replaced in-place ({old_fmt} → {new_fmt}): {os.path.basename(final_path)}") + _download_cover_art(album_info, os.path.dirname(final_path)) # 4. Generate LRC lyrics file at final location (elegant addition) @@ -17616,6 +18011,10 @@ def start_wishlist_missing_downloads(): cleanup_removed = 0 for track in cleanup_tracks: + # BYPASS: Don't remove enhance tracks — they intentionally re-download existing library tracks + if track.get('source_type') == 'enhance': + continue + track_name = track.get('name', '') artists = track.get('artists', []) spotify_track_id = track.get('spotify_track_id') or track.get('id') diff --git a/webui/index.html b/webui/index.html index ca0d5c7c..9455ccf1 100644 --- a/webui/index.html +++ b/webui/index.html @@ -2435,6 +2435,12 @@ 👁️ Add to Watchlist + + diff --git a/webui/static/script.js b/webui/static/script.js index af4aed3f..859c3434 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -34972,6 +34972,9 @@ async function loadArtistDetailData(artistId, artistName) { } } + // Check if artist has tracks eligible for quality enhancement + checkArtistEnhanceEligibility(artistId); + } catch (error) { console.error(`❌ Error loading artist detail data:`, error); @@ -54198,3 +54201,291 @@ function _escAttr(str) { if (!str) return ''; return String(str).replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, ''').replace(//g, '>'); } + +// ===== ENHANCE QUALITY MODAL ===== + +let _enhanceQualityData = null; +let _enhanceArtistId = null; + +const ENHANCE_TIER_MAP = { + 'lossless': { num: 1, label: 'Lossless', cssClass: 'lossless' }, + 'high_lossy': { num: 2, label: 'High Lossy', cssClass: 'high-lossy' }, + 'standard_lossy': { num: 3, label: 'Standard Lossy', cssClass: 'standard-lossy' }, + 'low_lossy': { num: 4, label: 'Low Lossy', cssClass: 'low-lossy' }, + 'unknown': { num: 999, label: 'Unknown', cssClass: 'unknown' }, +}; + +async function checkArtistEnhanceEligibility(artistId) { + const btn = document.getElementById('library-artist-enhance-btn'); + if (!btn) return; + btn.classList.add('hidden'); + _enhanceArtistId = artistId; + + try { + const resp = await fetch(`/api/library/artist/${artistId}/quality-analysis`); + if (!resp.ok) return; + const data = await resp.json(); + if (!data.success || !data.tracks || data.tracks.length === 0) return; + + _enhanceQualityData = data; + + // Show button if any tracks are below the user's min acceptable tier + const minTier = data.min_acceptable_tier || 1; + const belowCount = data.tracks.filter(t => t.tier_num > minTier).length; + if (belowCount > 0) { + btn.classList.remove('hidden'); + btn.querySelector('.enhance-text').textContent = `Enhance Quality (${belowCount})`; + } + } catch (e) { + console.debug('Enhance eligibility check failed:', e); + } +} + +function openEnhanceQualityModal() { + if (!_enhanceQualityData) return; + const data = _enhanceQualityData; + + // Remove existing modal if any + const existing = document.getElementById('enhance-quality-overlay'); + if (existing) existing.remove(); + + const overlay = document.createElement('div'); + overlay.id = 'enhance-quality-overlay'; + overlay.className = 'enhance-modal-overlay'; + overlay.onclick = (e) => { if (e.target === overlay) closeEnhanceQualityModal(); }; + + const minTier = data.min_acceptable_tier || 1; + const summary = data.quality_summary || {}; + + overlay.innerHTML = ` +
+
+

⚡ Enhance Quality — ${_esc(data.artist_name)}

+ +
+
+ ${_buildEnhanceSummaryChips(summary)} +
+
+
+ + +
+
+ + + 0 selected +
+
+
+ + + + + + + + + + + + + +
#TitleAlbumFormatBitrate
+
+ +
+ `; + + document.body.appendChild(overlay); + renderEnhanceTrackRows(minTier); +} + +function _buildEnhanceSummaryChips(summary) { + const chips = [ + { key: 'lossless', label: 'FLAC', cssClass: 'lossless' }, + { key: 'high_lossy', label: 'OGG/Opus', cssClass: 'high-lossy' }, + { key: 'standard_lossy', label: 'M4A/AAC', cssClass: 'standard-lossy' }, + { key: 'low_lossy', label: 'MP3/WMA', cssClass: 'low-lossy' }, + ]; + return chips + .filter(c => (summary[c.key] || 0) > 0) + .map(c => ` +
+ ${summary[c.key]} + ${c.label} +
+ `).join(''); +} + +function renderEnhanceTrackRows(thresholdTier) { + const tbody = document.getElementById('enhance-track-tbody'); + if (!tbody || !_enhanceQualityData) return; + + const tracks = _enhanceQualityData.tracks; + // Sort: below-threshold first, then by album + track number + const sorted = [...tracks].sort((a, b) => { + const aBt = a.tier_num > thresholdTier ? 0 : 1; + const bBt = b.tier_num > thresholdTier ? 0 : 1; + if (aBt !== bBt) return aBt - bBt; + const albumCmp = (a.album_title || '').localeCompare(b.album_title || ''); + if (albumCmp !== 0) return albumCmp; + return (a.disc_number || 1) * 1000 + (a.track_number || 0) - ((b.disc_number || 1) * 1000 + (b.track_number || 0)); + }); + + tbody.innerHTML = sorted.map(track => { + const isBelow = track.tier_num > thresholdTier; + const tierInfo = ENHANCE_TIER_MAP[track.tier_name] || ENHANCE_TIER_MAP['unknown']; + const bitrateStr = track.bitrate ? `${track.bitrate} kbps` : '-'; + return ` + + + ${track.track_number || '-'} + ${_esc(track.title)} + ${_esc(track.album_title)} + ${_esc(track.format)} + ${bitrateStr} + + `; + }).join(''); + + updateEnhanceSelectedCount(); +} + +function updateEnhanceThreshold(tierNum) { + const rows = document.querySelectorAll('.enhance-track-row'); + rows.forEach(row => { + const trackTier = parseInt(row.dataset.tier); + const isBelow = trackTier > tierNum; + const cb = row.querySelector('.enhance-track-check'); + + row.classList.toggle('below-threshold', isBelow); + row.classList.toggle('above-threshold', !isBelow); + if (cb) cb.checked = isBelow; + }); + updateEnhanceSelectedCount(); +} + +function enhanceSelectAll(select) { + const thresholdTier = parseInt(document.getElementById('enhance-tier-dropdown')?.value || '1'); + const checks = document.querySelectorAll('.enhance-track-check'); + checks.forEach(cb => { + const row = cb.closest('.enhance-track-row'); + const trackTier = parseInt(row?.dataset.tier || '999'); + if (select) { + cb.checked = trackTier > thresholdTier; + } else { + cb.checked = false; + } + }); + updateEnhanceSelectedCount(); +} + +function updateEnhanceSelectedCount() { + const checks = document.querySelectorAll('.enhance-track-check:checked'); + const count = checks.length; + const countEl = document.getElementById('enhance-selected-count'); + const submitBtn = document.getElementById('enhance-submit-btn'); + + if (countEl) countEl.textContent = `${count} selected`; + if (submitBtn) { + submitBtn.textContent = `⚡ Enhance ${count} Track${count !== 1 ? 's' : ''}`; + submitBtn.disabled = count === 0; + } +} + +async function submitEnhanceQuality() { + const checks = document.querySelectorAll('.enhance-track-check:checked'); + const trackIds = []; + checks.forEach(cb => { + const row = cb.closest('.enhance-track-row'); + if (row?.dataset.trackId) trackIds.push(row.dataset.trackId); + }); + + if (trackIds.length === 0) return; + + const submitBtn = document.getElementById('enhance-submit-btn'); + const footerInfo = document.getElementById('enhance-footer-info'); + if (submitBtn) { + submitBtn.disabled = true; + submitBtn.innerHTML = 'Processing...'; + } + if (footerInfo) footerInfo.textContent = 'Matching tracks to Spotify and adding to wishlist...'; + + try { + const resp = await fetch(`/api/library/artist/${_enhanceArtistId}/enhance`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ track_ids: trackIds }) + }); + + const result = await resp.json(); + + if (result.success) { + const msg = `${result.enhanced_count} track${result.enhanced_count !== 1 ? 's' : ''} queued for enhancement`; + if (footerInfo) footerInfo.textContent = msg; + + showToast(msg + (result.failed_count > 0 ? ` (${result.failed_count} failed)` : ''), 'success'); + + // Update button count + const enhBtn = document.getElementById('library-artist-enhance-btn'); + if (enhBtn && result.enhanced_count > 0) { + const remaining = trackIds.length - result.enhanced_count; + if (remaining <= 0) { + enhBtn.classList.add('hidden'); + } + } + + if (submitBtn) { + submitBtn.textContent = '✅ Done'; + submitBtn.disabled = true; + } + + // Auto-close after short delay + setTimeout(() => closeEnhanceQualityModal(), 1500); + } else { + throw new Error(result.error || 'Enhancement failed'); + } + } catch (e) { + console.error('Enhance quality error:', e); + showToast(`Enhancement failed: ${e.message}`, 'error'); + if (submitBtn) { + submitBtn.textContent = `⚡ Enhance ${trackIds.length} Tracks`; + submitBtn.disabled = false; + } + if (footerInfo) footerInfo.textContent = ''; + } +} + +function closeEnhanceQualityModal() { + const overlay = document.getElementById('enhance-quality-overlay'); + if (overlay) { + overlay.classList.add('hidden'); + setTimeout(() => overlay.remove(), 300); + } +} + +// Global exports +window.openEnhanceQualityModal = openEnhanceQualityModal; +window.closeEnhanceQualityModal = closeEnhanceQualityModal; +window.updateEnhanceThreshold = updateEnhanceThreshold; +window.enhanceSelectAll = enhanceSelectAll; +window.updateEnhanceSelectedCount = updateEnhanceSelectedCount; +window.submitEnhanceQuality = submitEnhanceQuality; + +// ===== END ENHANCE QUALITY MODAL ===== diff --git a/webui/static/style.css b/webui/static/style.css index 3748a4f9..8c54b972 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -15818,6 +15818,401 @@ body { transform: none; } +/* ─── Enhance Quality Button ─── */ + +.library-artist-enhance-btn { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 18px; + font-size: 14px; + font-weight: 600; + color: #4fc3f7; + background: rgba(79, 195, 247, 0.08); + border: 1px solid rgba(79, 195, 247, 0.2); + border-radius: 16px; + cursor: pointer; + transition: all 0.2s ease; + outline: none; + font-family: 'SF Pro Text', -apple-system, BlinkMacSystemFont, sans-serif; +} + +.library-artist-enhance-btn:hover { + background: rgba(79, 195, 247, 0.18); + color: #ffffff; + border-color: rgba(79, 195, 247, 0.4); + transform: translateY(-1px); +} + +.library-artist-enhance-btn .enhance-icon { + font-size: 16px; +} + +.library-artist-enhance-btn.hidden { + display: none; +} + +/* ─── Enhance Quality Modal ─── */ + +.enhance-modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.75); + z-index: 10000; + display: flex; + align-items: center; + justify-content: center; + opacity: 1; + transition: opacity 0.3s ease; +} + +.enhance-modal-overlay.hidden { + opacity: 0; + pointer-events: none; +} + +.enhance-modal { + background: linear-gradient(135deg, #1a1a1a 0%, #121212 100%); + border: 1px solid rgba(79, 195, 247, 0.2); + border-radius: 16px; + width: 90%; + max-width: 820px; + max-height: 85vh; + display: flex; + flex-direction: column; + box-shadow: 0 24px 80px rgba(0, 0, 0, 0.6); + animation: enhanceModalIn 0.3s ease-out; +} + +@keyframes enhanceModalIn { + from { opacity: 0; transform: scale(0.92) translateY(10px); } + to { opacity: 1; transform: scale(1) translateY(0); } +} + +.enhance-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 20px 24px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); +} + +.enhance-modal-header h3 { + margin: 0; + font-size: 18px; + font-weight: 700; + color: #ffffff; + display: flex; + align-items: center; + gap: 10px; +} + +.enhance-modal-close { + background: none; + border: none; + color: rgba(255, 255, 255, 0.5); + font-size: 24px; + cursor: pointer; + padding: 4px 8px; + border-radius: 8px; + transition: all 0.2s; +} + +.enhance-modal-close:hover { + color: #ffffff; + background: rgba(255, 255, 255, 0.1); +} + +.enhance-summary-bar { + display: flex; + gap: 10px; + padding: 16px 24px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + flex-wrap: wrap; +} + +.enhance-summary-chip { + display: flex; + flex-direction: column; + align-items: center; + padding: 10px 16px; + border-radius: 10px; + min-width: 70px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.06); +} + +.enhance-summary-chip .chip-count { + font-size: 20px; + font-weight: 700; + color: #ffffff; +} + +.enhance-summary-chip .chip-label { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + margin-top: 2px; +} + +.enhance-summary-chip.lossless .chip-label { color: #ffb84d; } +.enhance-summary-chip.high-lossy .chip-label { color: #81c784; } +.enhance-summary-chip.standard-lossy .chip-label { color: rgb(var(--accent-light-rgb)); } +.enhance-summary-chip.low-lossy .chip-label { color: #ef9a9a; } + +.enhance-controls { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 24px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + gap: 12px; +} + +.enhance-tier-selector { + display: flex; + align-items: center; + gap: 8px; +} + +.enhance-tier-selector label { + font-size: 13px; + color: rgba(255, 255, 255, 0.7); + white-space: nowrap; +} + +.enhance-tier-selector select { + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 8px; + color: #ffffff; + padding: 6px 12px; + font-size: 13px; + outline: none; + cursor: pointer; +} + +.enhance-tier-selector select:focus { + border-color: rgba(79, 195, 247, 0.4); +} + +.enhance-select-controls { + display: flex; + gap: 8px; + align-items: center; +} + +.enhance-select-btn { + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.1); + color: rgba(255, 255, 255, 0.7); + padding: 5px 12px; + border-radius: 6px; + font-size: 12px; + cursor: pointer; + transition: all 0.2s; +} + +.enhance-select-btn:hover { + background: rgba(255, 255, 255, 0.12); + color: #ffffff; +} + +.enhance-selected-count { + font-size: 12px; + color: rgba(79, 195, 247, 0.9); + font-weight: 600; +} + +.enhance-modal-body { + flex: 1; + overflow-y: auto; + padding: 0; +} + +.enhance-track-table { + width: 100%; + border-collapse: collapse; +} + +.enhance-track-table thead th { + position: sticky; + top: 0; + background: #1a1a1a; + padding: 10px 12px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: rgba(255, 255, 255, 0.4); + text-align: left; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + z-index: 1; +} + +.enhance-track-table thead th:first-child { + padding-left: 24px; +} + +.enhance-track-table tbody tr { + border-bottom: 1px solid rgba(255, 255, 255, 0.03); + transition: background 0.15s; +} + +.enhance-track-table tbody tr:hover { + background: rgba(255, 255, 255, 0.03); +} + +.enhance-track-table tbody td { + padding: 10px 12px; + font-size: 13px; + color: rgba(255, 255, 255, 0.85); +} + +.enhance-track-table tbody td:first-child { + padding-left: 24px; +} + +.enhance-track-row.above-threshold { + opacity: 0.35; +} + +.enhance-track-row.above-threshold td { + color: rgba(255, 255, 255, 0.4); +} + +.enhance-track-row input[type="checkbox"] { + accent-color: rgb(var(--accent-rgb)); + width: 16px; + height: 16px; + cursor: pointer; +} + +.enhance-format-badge { + display: inline-block; + padding: 2px 8px; + border-radius: 4px; + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.enhance-format-badge.lossless { + color: #ffb84d; + background: rgba(255, 184, 77, 0.12); +} + +.enhance-format-badge.high-lossy { + color: #81c784; + background: rgba(129, 199, 132, 0.12); +} + +.enhance-format-badge.standard-lossy { + color: rgb(var(--accent-light-rgb)); + background: rgba(var(--accent-rgb), 0.12); +} + +.enhance-format-badge.low-lossy { + color: #ef9a9a; + background: rgba(239, 154, 154, 0.12); +} + +.enhance-format-badge.unknown { + color: rgba(255, 255, 255, 0.4); + background: rgba(255, 255, 255, 0.05); +} + +.enhance-bitrate { + font-size: 12px; + color: rgba(255, 255, 255, 0.5); +} + +.enhance-modal-footer { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 24px; + border-top: 1px solid rgba(255, 255, 255, 0.06); +} + +.enhance-footer-info { + font-size: 13px; + color: rgba(255, 255, 255, 0.5); +} + +.enhance-footer-actions { + display: flex; + gap: 10px; +} + +.enhance-btn { + padding: 10px 20px; + border-radius: 10px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + border: none; + font-family: 'SF Pro Text', -apple-system, BlinkMacSystemFont, sans-serif; +} + +.enhance-btn.secondary { + background: rgba(255, 255, 255, 0.08); + color: rgba(255, 255, 255, 0.7); + border: 1px solid rgba(255, 255, 255, 0.1); +} + +.enhance-btn.secondary:hover { + background: rgba(255, 255, 255, 0.14); + color: #ffffff; +} + +.enhance-btn.primary { + background: linear-gradient(135deg, rgba(79, 195, 247, 0.9), rgba(41, 182, 246, 0.9)); + color: #ffffff; +} + +.enhance-btn.primary:hover { + background: linear-gradient(135deg, rgba(79, 195, 247, 1), rgba(41, 182, 246, 1)); + transform: translateY(-1px); + box-shadow: 0 4px 16px rgba(79, 195, 247, 0.3); +} + +.enhance-btn.primary:disabled { + opacity: 0.4; + cursor: not-allowed; + transform: none; + box-shadow: none; +} + +.enhance-btn .enhance-spinner { + display: inline-block; + width: 14px; + height: 14px; + border: 2px solid rgba(255, 255, 255, 0.3); + border-top-color: #ffffff; + border-radius: 50%; + animation: spin 0.8s linear infinite; + margin-right: 8px; + vertical-align: middle; +} + +/* Wishlist enhance badge */ +.wishlist-enhance-badge { + display: inline-block; + padding: 1px 6px; + border-radius: 4px; + font-size: 9px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + color: #4fc3f7; + background: rgba(79, 195, 247, 0.12); + margin-left: 6px; +} + .artist-detail-info { display: flex; align-items: center;