diff --git a/web_server.py b/web_server.py index 40731b12..60e61784 100644 --- a/web_server.py +++ b/web_server.py @@ -18181,15 +18181,33 @@ def _download_cover_art(album_info: dict, target_dir: str, context: dict = None) return try: cover_path = os.path.join(target_dir, "cover.jpg") + release_mbid = album_info.get('musicbrainz_release_id') if album_info else None + prefer_caa = config_manager.get('metadata_enhancement.prefer_caa_art', False) + + # If cover.jpg exists but we now have a CAA MBID, check if we should upgrade if os.path.exists(cover_path): - return + if release_mbid and prefer_caa: + try: + existing_size = os.path.getsize(cover_path) + # Typical Spotify/iTunes cover is ~50-150KB at 640x640 + # CAA covers are usually 300KB+ at 1200x1200 + if existing_size > 200_000: + return # Already high-res, skip + # Low-res cover exists — try to upgrade from CAA + is_upgrade = True + print(f"🔄 Existing cover.jpg is {existing_size // 1024}KB — attempting CAA upgrade...") + except Exception: + return + else: + return + else: + is_upgrade = False image_data = None # Try Cover Art Archive first (often 1200x1200+, original quality) — opt-in # The MBID is stored in album_info by _enhance_file_metadata before this is called - release_mbid = album_info.get('musicbrainz_release_id') - if release_mbid and config_manager.get('metadata_enhancement.prefer_caa_art', False): + if release_mbid and prefer_caa: try: caa_url = f"https://coverartarchive.org/release/{release_mbid}/front" req = urllib.request.Request(caa_url, headers={'Accept': 'image/*'}) @@ -18202,6 +18220,11 @@ def _download_cover_art(album_info: dict, target_dir: str, context: dict = None) except Exception: image_data = None + # If upgrading and CAA failed, keep existing cover — don't overwrite with same low-res + if is_upgrade and not image_data: + print(f"📷 CAA upgrade failed — keeping existing cover.jpg") + return + # Fallback to Spotify/iTunes/Deezer URL (typically 640x640) if not image_data: art_url = album_info.get('album_image_url') @@ -36702,13 +36725,16 @@ def reset_pin_via_credential(): if not matched: return jsonify({'success': False, 'error': 'Credential does not match any configured service'}), 401 - # Credential verified — clear admin PIN and disable launch lock + # Credential verified — clear PIN for the requested profile (default: admin) database = get_database() - database.update_profile(1, pin_hash=None) - config_manager.set('security.require_pin_on_launch', False) + target_profile = data.get('profile_id', 1) + database.update_profile(target_profile, pin_hash=None) + # If clearing admin PIN, also disable launch lock + if target_profile == 1: + config_manager.set('security.require_pin_on_launch', False) session['launch_pin_verified'] = True - return jsonify({'success': True, 'message': 'PIN cleared and lock screen disabled. You can set a new PIN in Settings.'}) + return jsonify({'success': True, 'message': 'PIN cleared. You can set a new PIN in Settings.'}) except Exception as e: return jsonify({'success': False, 'error': str(e)}), 500 diff --git a/webui/index.html b/webui/index.html index eae61d20..b3c05bcf 100644 --- a/webui/index.html +++ b/webui/index.html @@ -57,6 +57,7 @@ + Forgot PIN? diff --git a/webui/static/script.js b/webui/static/script.js index 2a897fa6..e104190a 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -1210,6 +1210,75 @@ async function submitRecoveryCredential() { btn.textContent = 'Verify & Reset PIN'; } +// ── Profile PIN Forgot Recovery ──────────────────────────────────────── +function showProfileForgotPin() { + const dialog = document.getElementById('profile-pin-dialog'); + const content = dialog.querySelector('.profile-pin-content'); + + // Store the profile ID we're recovering for + const profileName = document.getElementById('profile-pin-name').textContent; + + // Replace dialog content with recovery form + content.dataset.prevHtml = content.innerHTML; + content.innerHTML = ` +

Reset PIN for ${profileName}

+

Enter any configured API credential
(Spotify secret, Plex token, etc.)

+ +
+ + +
+ + `; + setTimeout(() => document.getElementById('profile-recovery-input').focus(), 100); + + document.getElementById('profile-recovery-cancel').onclick = () => { + content.innerHTML = content.dataset.prevHtml; + }; + + document.getElementById('profile-recovery-submit').onclick = async () => { + const input = document.getElementById('profile-recovery-input'); + const error = document.getElementById('profile-recovery-error'); + const credential = input.value.trim(); + if (!credential) return; + + const btn = document.getElementById('profile-recovery-submit'); + btn.disabled = true; + btn.textContent = 'Verifying...'; + error.style.display = 'none'; + + try { + const res = await fetch('/api/profiles/reset-pin-via-credential', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ credential, profile_id: dialog._profileId || 1 }) + }); + const data = await res.json(); + if (data.success) { + dialog.style.display = 'none'; + content.innerHTML = content.dataset.prevHtml; + showToast('PIN cleared. You can set a new one in Settings.', 'success'); + // Re-try selecting the profile (now PIN-free) + if (dialog._profileId) selectProfile(dialog._profileId); + } else { + error.textContent = data.error || 'Credential not recognized'; + error.style.display = 'block'; + input.value = ''; + input.focus(); + } + } catch (e) { + error.textContent = 'Connection error'; + error.style.display = 'block'; + } + btn.disabled = false; + btn.textContent = 'Verify & Reset'; + }; + + document.getElementById('profile-recovery-input').onkeydown = (e) => { + if (e.key === 'Enter') document.getElementById('profile-recovery-submit').click(); + }; +} + function showProfilePicker(profiles, canCancel = false) { const overlay = document.getElementById('profile-picker-overlay'); const grid = document.getElementById('profile-picker-grid'); @@ -1309,6 +1378,7 @@ function showPinDialog(profile) { nameEl.textContent = profile.name; input.value = ''; errorEl.style.display = 'none'; + dialog._profileId = profile.id; dialog.style.display = 'flex'; setTimeout(() => input.focus(), 100); diff --git a/webui/static/style.css b/webui/static/style.css index e41ec092..31c9e703 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -39716,6 +39716,19 @@ body.helper-mode-active #dashboard-activity-feed:hover { margin-top: 12px; } +.profile-pin-forgot { + display: block; + color: rgba(255, 255, 255, 0.35); + font-size: 12px; + margin-top: 10px; + text-decoration: none; + cursor: pointer; +} + +.profile-pin-forgot:hover { + color: rgba(138, 43, 226, 0.8); +} + /* Profile Management Panel */ .profile-manage-panel { position: fixed;