Add quality enhance button to upgrade existing library tracks & Add iTunes fallback to Quality Enhance endpoint for full metadata source parity

This commit is contained in:
Broque Thomas 2026-03-14 17:09:08 -07:00
parent 60261f2e91
commit 7e19e66ef3
5 changed files with 1105 additions and 1 deletions

View file

@ -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:

View file

@ -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/<artist_id>/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/<artist_id>/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/<artist_id>', 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')

View file

@ -2435,6 +2435,12 @@
<span class="watchlist-icon">👁️</span>
<span class="watchlist-text">Add to Watchlist</span>
</button>
<button class="library-artist-enhance-btn hidden" id="library-artist-enhance-btn"
onclick="openEnhanceQualityModal()">
<span class="enhance-icon"></span>
<span class="enhance-text">Enhance Quality</span>
</button>
</div>
<!-- Artist Hero Section -->

View file

@ -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, '&amp;').replace(/"/g, '&quot;').replace(/'/g, '&#39;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
// ===== 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 = `
<div class="enhance-modal" onclick="event.stopPropagation()">
<div class="enhance-modal-header">
<h3> Enhance Quality ${_esc(data.artist_name)}</h3>
<button class="enhance-modal-close" onclick="closeEnhanceQualityModal()">&times;</button>
</div>
<div class="enhance-summary-bar">
${_buildEnhanceSummaryChips(summary)}
</div>
<div class="enhance-controls">
<div class="enhance-tier-selector">
<label>Upgrade tracks below:</label>
<select id="enhance-tier-dropdown" onchange="updateEnhanceThreshold(parseInt(this.value))">
<option value="1" ${minTier <= 1 ? 'selected' : ''}>Lossless (FLAC/WAV)</option>
<option value="2" ${minTier === 2 ? 'selected' : ''}>High Lossy (OGG/Opus)</option>
<option value="3" ${minTier === 3 ? 'selected' : ''}>Standard Lossy (M4A/AAC)</option>
<option value="4" ${minTier >= 4 ? 'selected' : ''}>Low Lossy (MP3/WMA)</option>
</select>
</div>
<div class="enhance-select-controls">
<button class="enhance-select-btn" onclick="enhanceSelectAll(true)">Select All Below</button>
<button class="enhance-select-btn" onclick="enhanceSelectAll(false)">Deselect All</button>
<span class="enhance-selected-count" id="enhance-selected-count">0 selected</span>
</div>
</div>
<div class="enhance-modal-body">
<table class="enhance-track-table">
<thead>
<tr>
<th></th>
<th>#</th>
<th>Title</th>
<th>Album</th>
<th>Format</th>
<th>Bitrate</th>
</tr>
</thead>
<tbody id="enhance-track-tbody">
</tbody>
</table>
</div>
<div class="enhance-modal-footer">
<div class="enhance-footer-info" id="enhance-footer-info"></div>
<div class="enhance-footer-actions">
<button class="enhance-btn secondary" onclick="closeEnhanceQualityModal()">Cancel</button>
<button class="enhance-btn primary" id="enhance-submit-btn" onclick="submitEnhanceQuality()" disabled>
Enhance 0 Tracks
</button>
</div>
</div>
</div>
`;
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 => `
<div class="enhance-summary-chip ${c.cssClass}">
<span class="chip-count">${summary[c.key]}</span>
<span class="chip-label">${c.label}</span>
</div>
`).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 `
<tr class="enhance-track-row ${isBelow ? 'below-threshold' : 'above-threshold'}"
data-track-id="${_escAttr(track.track_id)}" data-tier="${track.tier_num}">
<td><input type="checkbox" class="enhance-track-check"
${isBelow ? 'checked' : ''} onchange="updateEnhanceSelectedCount()"></td>
<td>${track.track_number || '-'}</td>
<td>${_esc(track.title)}</td>
<td>${_esc(track.album_title)}</td>
<td><span class="enhance-format-badge ${tierInfo.cssClass}">${_esc(track.format)}</span></td>
<td><span class="enhance-bitrate">${bitrateStr}</span></td>
</tr>
`;
}).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 = '<span class="enhance-spinner"></span>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 =====

View file

@ -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;