diff --git a/.gitignore b/.gitignore index c442cc3a..8266b83b 100644 --- a/.gitignore +++ b/.gitignore @@ -23,5 +23,7 @@ logs/*.log.* # Auto-downloaded binaries bin/ +# Development compose/config files +*.dev.yml # Any hidden folders **/.*/ diff --git a/web_server.py b/web_server.py index 82caa7f8..5366e503 100644 --- a/web_server.py +++ b/web_server.py @@ -81,6 +81,7 @@ if not pp_logger.handlers: pp_logger.propagate = False from core.spotify_client import SpotifyClient, Playlist as SpotifyPlaylist, Track as SpotifyTrack, _is_globally_rate_limited as _spotify_rate_limited from core.plex_client import PlexClient +from plexapi.myplex import MyPlexAccount, MyPlexPinLogin from core.jellyfin_client import JellyfinClient from core.navidrome_client import NavidromeClient from core.soulseek_client import SoulseekClient @@ -189,6 +190,10 @@ app.secret_key = _init_flask_secret_key() # --- WebSocket (Socket.IO) Setup --- socketio = SocketIO(app, async_mode='threading', cors_allowed_origins='*') +# Plex PIN auth requests stored in memory for polling +_plex_pin_requests = {} +_plex_pin_requests_lock = threading.Lock() + # --- Profile Context (before_request hook) --- @app.before_request def _set_profile_context(): @@ -7275,6 +7280,131 @@ def detect_media_server_endpoint(): add_activity_item("", "Auto-Detect Failed", f"No {server_type} server found", "Now") return jsonify({"success": False, "error": f"No {server_type} server found on common local addresses."}) +@app.route('/api/plex/pin/start', methods=['POST']) +def start_plex_pin_auth(): + try: + pinlogin = MyPlexPinLogin(oauth=False) + except Exception as e: + logger.error(f'Failed to start Plex PIN auth: {e}') + return jsonify({"success": False, "error": str(e)}), 500 + + pin_code = getattr(pinlogin, 'pin', None) + if not pin_code: + return jsonify({"success": False, "error": 'Failed to generate Plex PIN code.'}), 500 + + request_id = str(uuid.uuid4()) + with _plex_pin_requests_lock: + _plex_pin_requests[request_id] = { + 'pinlogin': pinlogin, + 'created_at': time.time(), + 'expires_at': getattr(pinlogin, 'expires_at', None) + } + + expires_in = None + expires_at = getattr(pinlogin, 'expires_at', None) + if expires_at: + try: + expires_in = int((expires_at - datetime.now(timezone.utc)).total_seconds()) + except Exception: + expires_in = None + + return jsonify({ + "success": True, + "request_id": request_id, + "code": str(pin_code), + "auth_url": "https://plex.tv/link", + "expires_in": expires_in + }) + + +@app.route('/api/plex/pin/status', methods=['GET']) +def get_plex_pin_status(): + request_id = request.args.get('request_id') + if not request_id: + return jsonify({"success": False, "error": 'request_id is required'}), 400 + + with _plex_pin_requests_lock: + entry = _plex_pin_requests.get(request_id) + + if not entry: + return jsonify({"success": False, "error": 'Invalid or expired PIN request id.'}), 400 + + pinlogin = entry.get('pinlogin') + if not pinlogin: + return jsonify({"success": False, "error": 'Invalid PIN login state.'}), 500 + + try: + if getattr(pinlogin, 'expired', False): + with _plex_pin_requests_lock: + _plex_pin_requests.pop(request_id, None) + return jsonify({"success": False, "expired": True, "error": 'PIN code expired.'}) + + if pinlogin.checkLogin(): + token = getattr(pinlogin, 'token', None) + if not token: + raise ValueError('Plex token was not returned after authorization.') + + try: + account = MyPlexAccount(token=token) + resources = account.resources() + except Exception as e: + logger.error(f'Failed to fetch Plex account resources: {e}') + return jsonify({"success": False, "error": f'Plex authorization succeeded but failed to resolve server resources: {e}'}), 500 + + server_resources = [r for r in resources if 'server' in (getattr(r, 'provides', '') or '').lower()] + if not server_resources: + return jsonify({"success": False, "error": 'No Plex server resources found for this account.'}), 500 + + local_conn = None + relay_conn = None + for resource in server_resources: + for conn in getattr(resource, 'connections', []) or []: + if getattr(conn, 'local', False): + local_conn = conn + break + if getattr(conn, 'relay', False) and relay_conn is None: + relay_conn = conn + if local_conn: + break + + chosen_conn = local_conn or relay_conn + if not chosen_conn: + chosen_conn = getattr(server_resources[0], 'connections', [None])[0] + + found_url = getattr(chosen_conn, 'uri', None) if chosen_conn else None + with _plex_pin_requests_lock: + _plex_pin_requests.pop(request_id, None) + + if not found_url: + return jsonify({"success": False, "error": 'Plex authorized, but no usable server connection URI was found.'}), 500 + + return jsonify({ + "success": True, + "found_url": found_url, + "token": token, + "status": 'Plex authorization complete.' + }) + + return jsonify({"success": False, "status": 'Waiting for Plex authorization. Enter the PIN on plex.tv/link.'}) + except Exception as e: + logger.error(f'Error checking Plex PIN status: {e}') + return jsonify({"success": False, "error": str(e)}), 500 + + +@app.route('/api/plex/clear-library', methods=['POST']) +def clear_plex_library_preference(): + try: + from database.music_database import MusicDatabase + db = MusicDatabase() + db.set_preference('plex_music_library', '') + if plex_client: + plex_client.music_library = None + return jsonify({"success": True, "message": "Plex library preference cleared."}) + except Exception as e: + logger.error(f"Error clearing Plex library preference: {e}") + return jsonify({"success": False, "error": str(e)}), 500 + + @app.route('/api/plex/music-libraries', methods=['GET']) def get_plex_music_libraries(): """Get list of all available music libraries from Plex""" diff --git a/webui/index.html b/webui/index.html index 9fa2bc49..6961bf86 100644 --- a/webui/index.html +++ b/webui/index.html @@ -4318,26 +4318,56 @@
-
- - +
+
+ + +
+ +
+
+
+ + +
+ +
+
+ +
+ +
-
- - -
- -
- - +
+
+ + + +
+
diff --git a/webui/static/script.js b/webui/static/script.js index 5b997db3..a49a7809 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -5991,6 +5991,25 @@ function updateLossyBitrateOptions() { } } +function updatePlexConfigurationButtons() { + const plexUrl = document.getElementById('plex-url'); + const plexToken = document.getElementById('plex-token'); + const hasPlexConfig = Boolean((plexUrl?.value || '').trim() || (plexToken?.value || '').trim()); + const plexViewConfigButton = document.getElementById('plex-view-config-button'); + const plexLinkToPlexButton = document.getElementById('plex-link-to-plex-button'); + const plexManualConfigButton = document.getElementById('plex-manual-config-button'); + const plexUrlActions = document.getElementById('plex-url-actions'); + const plexTokenActions = document.getElementById('plex-token-actions'); + const plexPinAuthFlow = document.getElementById('plex-pin-auth-flow'); + + if (plexViewConfigButton) plexViewConfigButton.style.display = hasPlexConfig ? '' : 'none'; + if (plexLinkToPlexButton) plexLinkToPlexButton.style.display = hasPlexConfig ? 'none' : ''; + if (plexManualConfigButton) plexManualConfigButton.style.display = hasPlexConfig ? 'none' : ''; + if (plexUrlActions) plexUrlActions.style.display = hasPlexConfig ? 'none' : 'flex'; + if (plexTokenActions) plexTokenActions.style.display = hasPlexConfig ? 'none' : 'flex'; + if (plexPinAuthFlow) plexPinAuthFlow.style.display = 'none'; +} + async function loadSettingsData() { try { const response = await fetch(API.settings); @@ -6028,8 +6047,13 @@ async function loadSettingsData() { }); // Populate Plex settings - document.getElementById('plex-url').value = settings.plex?.base_url || ''; - document.getElementById('plex-token').value = settings.plex?.token || ''; + const plexUrlInput = document.getElementById('plex-url'); + const plexTokenInput = document.getElementById('plex-token'); + if (plexUrlInput) plexUrlInput.value = settings.plex?.base_url || ''; + if (plexTokenInput) plexTokenInput.value = settings.plex?.token || ''; + if (plexUrlInput) plexUrlInput.addEventListener('input', updatePlexConfigurationButtons); + if (plexTokenInput) plexTokenInput.addEventListener('input', updatePlexConfigurationButtons); + updatePlexConfigurationButtons(); // Populate Jellyfin settings document.getElementById('jellyfin-url').value = settings.jellyfin?.base_url || ''; @@ -6423,6 +6447,212 @@ function updateMediaServerFields() { } } +let _plexPinAuthRequestId = null; +let _plexPinAuthPollInterval = null; + +function showPlexConfiguration(disableFields = false, isManualConfig = false) { + stopPlexPinAuthPolling(); + const plexConfig = document.getElementById('plex-configuration'); + const plexSetup = document.getElementById('plex-setup'); + const plexPinAuthFlow = document.getElementById('plex-pin-auth-flow'); + const plexUrl = document.getElementById('plex-url'); + const plexToken = document.getElementById('plex-token'); + const plexLibraryContainer = document.getElementById('plex-library-selector-container'); + + if (plexConfig) plexConfig.style.display = ''; + if (plexSetup) plexSetup.style.display = 'none'; + if (plexPinAuthFlow) plexPinAuthFlow.style.display = 'none'; + if (plexUrl) plexUrl.disabled = disableFields; + if (plexToken) plexToken.disabled = disableFields; + if (plexLibraryContainer && isManualConfig) { + plexLibraryContainer.style.display = 'none'; + } + setPlexConfigActionButton(isManualConfig); + updatePlexConfigurationButtons(); +} + +function showPlexSetup() { + const plexConfig = document.getElementById('plex-configuration'); + const plexSetup = document.getElementById('plex-setup'); + const plexPinAuthFlow = document.getElementById('plex-pin-auth-flow'); + const plexLibraryContainer = document.getElementById('plex-library-selector-container'); + + if (plexConfig) plexConfig.style.display = 'none'; + if (plexSetup) plexSetup.style.display = ''; + if (plexPinAuthFlow) plexPinAuthFlow.style.display = 'none'; + if (plexLibraryContainer) plexLibraryContainer.style.display = 'none'; + setPlexConfigActionButton(false); +} + +function setPlexConfigActionButton(isManualConfig) { + const actionButton = document.getElementById('plex-config-action-button'); + if (!actionButton) return; + + if (isManualConfig) { + actionButton.textContent = 'Cancel'; + actionButton.onclick = showPlexSetup; + actionButton.title = 'Cancel manual Plex configuration'; + } else { + actionButton.textContent = 'Clear Configuration'; + actionButton.onclick = clearPlexConfiguration; + actionButton.title = 'Clear saved Plex configuration'; + } +} + +async function startPlexPinAuth() { + const setupButtons = document.getElementById('plex-setup-buttons'); + const authFlow = document.getElementById('plex-pin-auth-flow'); + const statusEl = document.getElementById('plex-pin-status'); + if (setupButtons) setupButtons.style.display = 'none'; + if (authFlow) authFlow.style.display = ''; + if (statusEl) statusEl.textContent = 'Starting Plex authorization...'; + + try { + showLoadingOverlay('Starting Plex authorization...'); + const response = await fetch('/api/plex/pin/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' } + }); + const result = await response.json(); + if (!result.success) { + throw new Error(result.error || 'Failed to start Plex PIN flow'); + } + + _plexPinAuthRequestId = result.request_id; + const pinCodeEl = document.getElementById('plex-pin-code'); + if (pinCodeEl) pinCodeEl.textContent = result.code || ''; + if (statusEl) { + statusEl.textContent = result.expires_in + ? `Enter this code at plex.tv/link. Code expires in ${result.expires_in} seconds.` + : 'Enter this code at plex.tv/link. Waiting for authorization...'; + } + + startPlexPinAuthPolling(); + } catch (error) { + console.error('Plex PIN auth start failed:', error); + showToast(error.message || 'Failed to start Plex authorization', 'error'); + cancelPlexPinAuth(); + } finally { + hideLoadingOverlay(); + } +} + +function startPlexPinAuthPolling() { + stopPlexPinAuthPolling(); + if (!_plexPinAuthRequestId) return; + _plexPinAuthPollInterval = setInterval(pollPlexPinAuthStatus, 5000); + pollPlexPinAuthStatus(); +} + +function stopPlexPinAuthPolling() { + if (_plexPinAuthPollInterval) { + clearInterval(_plexPinAuthPollInterval); + _plexPinAuthPollInterval = null; + } +} + +async function pollPlexPinAuthStatus() { + if (!_plexPinAuthRequestId) return; + try { + const response = await fetch(`/api/plex/pin/status?request_id=${encodeURIComponent(_plexPinAuthRequestId)}`); + const result = await response.json(); + const statusEl = document.getElementById('plex-pin-status'); + + if (!result.success && result.expired) { + if (statusEl) statusEl.textContent = 'PIN code expired. Generate a new code to continue.'; + stopPlexPinAuthPolling(); + return; + } + + if (result.success) { + stopPlexPinAuthPolling(); + if (statusEl) statusEl.textContent = 'Authorization complete! Saving Plex configuration...'; + document.getElementById('plex-url').value = result.found_url || ''; + document.getElementById('plex-token').value = result.token || ''; + if (typeof saveSettings === 'function') { + await saveSettings(true); + } + showToast('Plex successfully linked', 'success'); + showPlexConfiguration(true); + await testConnection('plex'); + return; + } + + if (result.status) { + if (statusEl) statusEl.textContent = result.status; + return; + } + + if (result.error) { + if (statusEl) statusEl.textContent = result.error; + return; + } + } catch (error) { + console.error('Error polling Plex PIN status:', error); + const statusEl = document.getElementById('plex-pin-status'); + if (statusEl) statusEl.textContent = 'Unable to contact Plex auth status. Retrying...'; + } +} + +function cancelPlexPinAuth() { + stopPlexPinAuthPolling(); + _plexPinAuthRequestId = null; + const setupButtons = document.getElementById('plex-setup-buttons'); + const authFlow = document.getElementById('plex-pin-auth-flow'); + if (setupButtons) setupButtons.style.display = ''; + if (authFlow) authFlow.style.display = 'none'; +} + +function restartPlexPinAuth() { + cancelPlexPinAuth(); + startPlexPinAuth(); +} + +async function clearPlexConfiguration() { + cancelPlexPinAuth(); + const plexUrl = document.getElementById('plex-url'); + const plexToken = document.getElementById('plex-token'); + const plexConfig = document.getElementById('plex-configuration'); + const plexSetup = document.getElementById('plex-setup'); + const plexSetupButtons = document.getElementById('plex-setup-buttons'); + const plexViewConfigButton = document.getElementById('plex-view-config-button'); + const plexLinkToPlexButton = document.getElementById('plex-link-to-plex-button'); + const plexManualConfigButton = document.getElementById('plex-manual-config-button'); + + if (plexUrl) plexUrl.value = ''; + if (plexToken) plexToken.value = ''; + if (plexConfig) plexConfig.style.display = 'none'; + if (plexSetup) plexSetup.style.display = ''; + if (plexSetupButtons) plexSetupButtons.style.display = ''; + if (plexViewConfigButton) plexViewConfigButton.style.display = 'none'; + if (plexLinkToPlexButton) plexLinkToPlexButton.style.display = ''; + if (plexManualConfigButton) plexManualConfigButton.style.display = ''; + + const plexLibraryContainer = document.getElementById('plex-library-selector-container'); + const plexLibrarySelect = document.getElementById('plex-music-library'); + if (plexLibrarySelect) { + plexLibrarySelect.innerHTML = ''; + } + if (plexLibraryContainer) { + plexLibraryContainer.style.display = 'none'; + } + + updatePlexConfigurationButtons(); + + try { + await fetch('/api/plex/clear-library', { method: 'POST' }); + } catch (e) { + console.warn('Failed to clear Plex library preference:', e); + } + + if (typeof saveSettings === 'function') { + saveSettings(true); + } + if (typeof showToast === 'function') { + showToast('Plex configuration cleared', 'success'); + } +} + function toggleServer(serverType) { // Update toggle buttons document.getElementById('plex-toggle').classList.remove('active'); @@ -6437,6 +6667,12 @@ function toggleServer(serverType) { document.getElementById('navidrome-container').classList.toggle('hidden', serverType !== 'navidrome'); document.getElementById('soulsync-container')?.classList.toggle('hidden', serverType !== 'soulsync'); + // Show Plex setup when Plex is selected; otherwise hide both Plex panels + const plexConfig = document.getElementById('plex-configuration'); + const plexSetup = document.getElementById('plex-setup'); + if (plexConfig) plexConfig.style.display = serverType === 'plex' ? 'none' : ''; + if (plexSetup) plexSetup.style.display = serverType === 'plex' ? '' : 'none'; + // Load Plex music libraries when switching to Plex if (serverType === 'plex') { loadPlexMusicLibraries(); @@ -19790,6 +20026,10 @@ window.testConnection = testConnection; window.autoDetectPlex = autoDetectPlex; window.autoDetectJellyfin = autoDetectJellyfin; window.autoDetectSlskd = autoDetectSlskd; +window.startPlexPinAuth = startPlexPinAuth; +window.cancelPlexPinAuth = cancelPlexPinAuth; +window.restartPlexPinAuth = restartPlexPinAuth; +window.showPlexConfiguration = showPlexConfiguration; window.toggleServer = toggleServer; window.authenticateSpotify = authenticateSpotify; window.authenticateTidal = authenticateTidal;