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:
parent
60261f2e91
commit
7e19e66ef3
5 changed files with 1105 additions and 1 deletions
|
|
@ -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:
|
||||
|
|
|
|||
401
web_server.py
401
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/<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')
|
||||
|
|
|
|||
|
|
@ -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 -->
|
||||
|
|
|
|||
|
|
@ -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, '<').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 = `
|
||||
<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()">×</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 =====
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue