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;