diff --git a/web_server.py b/web_server.py index d888fda3..36139198 100644 --- a/web_server.py +++ b/web_server.py @@ -64,6 +64,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 @@ -175,6 +176,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(): @@ -7261,6 +7266,117 @@ 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/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 6c5a279b..8287bc5d 100644 --- a/webui/index.html +++ b/webui/index.html @@ -4343,10 +4343,27 @@
-
- +
+ +
+
diff --git a/webui/static/script.js b/webui/static/script.js index 4035b1b4..64a96155 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -6028,12 +6028,20 @@ async function loadSettingsData() { document.getElementById('plex-token').value = settings.plex?.token || ''; const hasPlexConfig = Boolean(settings.plex?.base_url || settings.plex?.token); const plexViewConfigButton = document.getElementById('plex-view-config-button'); - const plexConfigureButton = document.getElementById('plex-configure-button'); + const plexLinkToPlexButton = document.getElementById('plex-link-to-plex-button'); + const plexManualConfigButton = document.getElementById('plex-manual-config-button'); + const plexPinAuthFlow = document.getElementById('plex-pin-auth-flow'); if (plexViewConfigButton) { plexViewConfigButton.style.display = hasPlexConfig ? '' : 'none'; } - if (plexConfigureButton) { - plexConfigureButton.style.display = hasPlexConfig ? 'none' : ''; + if (plexLinkToPlexButton) { + plexLinkToPlexButton.style.display = hasPlexConfig ? 'none' : ''; + } + if (plexManualConfigButton) { + plexManualConfigButton.style.display = hasPlexConfig ? 'none' : ''; + } + if (plexPinAuthFlow) { + plexPinAuthFlow.style.display = 'none'; } // Populate Jellyfin settings @@ -6425,27 +6433,146 @@ function updateMediaServerFields() { } } +let _plexPinAuthRequestId = null; +let _plexPinAuthPollInterval = null; + function showPlexConfiguration() { + stopPlexPinAuthPolling(); const plexConfig = document.getElementById('plex-configuration'); const plexSetup = document.getElementById('plex-setup'); + const plexPinAuthFlow = document.getElementById('plex-pin-auth-flow'); if (plexConfig) plexConfig.style.display = ''; if (plexSetup) plexSetup.style.display = 'none'; + if (plexPinAuthFlow) plexPinAuthFlow.style.display = 'none'; +} + +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') { + saveSettings(true); + } + showToast('Plex successfully linked', 'success'); + showPlexConfiguration(); + 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(); } 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 plexConfigureButton = document.getElementById('plex-configure-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 (plexConfigureButton) plexConfigureButton.style.display = ''; + if (plexLinkToPlexButton) plexLinkToPlexButton.style.display = ''; + if (plexManualConfigButton) plexManualConfigButton.style.display = ''; if (typeof saveSettings === 'function') { saveSettings(true); @@ -19805,6 +19932,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;