From c6a6d497b2d9935d67f6c66d767300888be251fe Mon Sep 17 00:00:00 2001 From: dylfrancis Date: Tue, 2 Jun 2026 19:30:02 -0400 Subject: [PATCH 1/9] feat(plex): initial plex integration draft --- backend/config/constants.js | 1 + backend/config/encryption.js | 1 + backend/routes/settings.js | 147 ++++++++ backend/services/plex.js | 293 ++++++++++++++++ backend/services/weeklyFlowPlaylistManager.js | 168 ++++++++- .../components/SettingsIntegrationsTab.jsx | 326 ++++++++++++++++++ .../pages/Settings/hooks/useSettingsData.js | 1 + frontend/src/pages/Settings/utils.js | 7 + frontend/src/utils/api.js | 28 ++ 9 files changed, 970 insertions(+), 2 deletions(-) create mode 100644 backend/services/plex.js diff --git a/backend/config/constants.js b/backend/config/constants.js index a2c28d09..aa9340c9 100644 --- a/backend/config/constants.js +++ b/backend/config/constants.js @@ -176,6 +176,7 @@ export const defaultData = { ], integrations: { navidrome: { url: "", username: "", password: "" }, + plex: { url: "", token: "", clientId: "", machineIdentifier: "" }, lastfm: { apiKey: "", username: "", diff --git a/backend/config/encryption.js b/backend/config/encryption.js index 7c0e64b3..aef334cb 100644 --- a/backend/config/encryption.js +++ b/backend/config/encryption.js @@ -42,6 +42,7 @@ function decryptWithKey(text, key) { const SENSITIVE_PATHS = [ ["navidrome", "password"], + ["plex", "token"], ["soulseek", "password"], ["general", "authPassword"], ["lidarr", "apiKey"], diff --git a/backend/routes/settings.js b/backend/routes/settings.js index 39c4111e..e92c646a 100644 --- a/backend/routes/settings.js +++ b/backend/routes/settings.js @@ -128,6 +128,12 @@ router.post("/", async (req, res) => { ...integrations.navidrome, } : mergedIntegrations.navidrome, + plex: integrations.plex + ? { + ...(mergedIntegrations.plex || {}), + ...integrations.plex, + } + : mergedIntegrations.plex, slskd: integrations.slskd ? { ...(mergedIntegrations.slskd || {}), @@ -1116,6 +1122,147 @@ router.post("/lidarr/apply-community-guide", async (req, res) => { } }); +// --- Plex integration ------------------------------------------------------ + +function getPlexConfig() { + return dbOps.getSettings()?.integrations?.plex || {}; +} + +// Start the PIN OAuth flow: returns the PIN id + the app.plex.tv URL to visit. +router.post("/plex/auth/pin", async (req, res) => { + try { + const { PlexClient } = await import("../services/plex.js"); + const settings = dbOps.getSettings(); + const plex = settings.integrations?.plex || {}; + let clientId = plex.clientId; + if (!clientId) { + clientId = PlexClient.generateClientId(); + dbOps.updateSettings({ + ...settings, + integrations: { + ...settings.integrations, + plex: { ...plex, clientId }, + }, + }); + } + const { id, code } = await PlexClient.generatePin(clientId); + const forwardUrl = req.body?.forwardUrl; + res.json({ + pinId: id, + code, + clientId, + authUrl: PlexClient.buildAuthUrl(clientId, code, forwardUrl), + }); + } catch (error) { + console.error("[Settings] Plex PIN generation failed:", error.message); + res.status(500).json({ + error: "Failed to start Plex authentication", + message: error.message, + }); + } +}); + +// Poll a PIN. Returns { token } once the user authorizes, else { pending: true }. +router.post("/plex/auth/check", async (req, res) => { + try { + const { PlexClient } = await import("../services/plex.js"); + const { pinId, code } = req.body || {}; + if (!pinId || !code) { + return res.status(400).json({ error: "pinId and code are required" }); + } + const clientId = getPlexConfig().clientId; + if (!clientId) { + return res.status(400).json({ error: "Plex client not initialized" }); + } + const token = await PlexClient.checkPin(pinId, code, clientId); + if (!token) return res.json({ pending: true }); + res.json({ token }); + } catch (error) { + console.error("[Settings] Plex PIN check failed:", error.message); + res.status(500).json({ + error: "Failed to check Plex authentication", + message: error.message, + }); + } +}); + +// List Plex servers available to the authenticated account. +router.post("/plex/resources", async (req, res) => { + try { + const { PlexClient } = await import("../services/plex.js"); + const clientId = getPlexConfig().clientId; + const token = req.body?.token || getPlexConfig().token; + if (!token || !clientId) { + return res.status(400).json({ error: "Plex authentication required" }); + } + const servers = await PlexClient.getResources(token, clientId); + res.json({ servers }); + } catch (error) { + console.error("[Settings] Plex resources failed:", error.message); + res.status(500).json({ + error: "Failed to list Plex servers", + message: error.message, + }); + } +}); + +// Test connectivity to a Plex Media Server and capture its machineIdentifier. +router.post("/plex/test", async (req, res) => { + try { + const { PlexClient } = await import("../services/plex.js"); + const stored = getPlexConfig(); + let url = (req.body?.url || stored.url || "").trim().replace(/\/+$/, ""); + const token = req.body?.token || stored.token; + const clientId = stored.clientId; + if (!url || !token) { + return res.status(400).json({ error: "Server URL and token are required" }); + } + const urlValidation = validateExternalUrl(url); + if (!urlValidation.valid) { + return res.status(400).json({ error: urlValidation.error }); + } + url = urlValidation.url; + const client = new PlexClient(url, token, clientId); + const identity = await client.ping(); + res.json({ + success: true, + message: "Connection successful", + machineIdentifier: identity.machineIdentifier, + version: identity.version, + }); + } catch (error) { + res.status(400).json({ + error: "Connection failed", + message: error.response?.data || error.message, + }); + } +}); + +// Build the Aurral library + flow playlists on the configured Plex server now. +router.post("/plex/sync", async (req, res) => { + try { + const plex = getPlexConfig(); + if (!plex.url || !plex.token) { + return res.status(400).json({ + error: "Plex not configured", + message: "Connect Plex and save settings before syncing", + }); + } + const { playlistManager } = await import( + "../services/weeklyFlowPlaylistManager.js" + ); + playlistManager.updateConfig(false); + const result = await playlistManager.syncPlexNow(); + res.json({ success: true, ...result }); + } catch (error) { + console.error("[Settings] Plex sync failed:", error.message); + res.status(500).json({ + error: "Plex sync failed", + message: error.response?.data || error.message, + }); + } +}); + router.post("/logs/level", async (req, res) => { try { const { logger } = await import("../services/logger.js"); diff --git a/backend/services/plex.js b/backend/services/plex.js new file mode 100644 index 00000000..881cbfd9 --- /dev/null +++ b/backend/services/plex.js @@ -0,0 +1,293 @@ +import axios from "axios"; +import crypto from "crypto"; + +const PLEX_TV = "https://plex.tv"; +const PLEX_AUTH_APP = "https://app.plex.tv"; +const PLEX_PRODUCT = "Aurral"; +// Plex music libraries use the "artist" section type with these defaults. +const MUSIC_SECTION_TYPE = "artist"; +const MUSIC_AGENT = "tv.plex.agents.music"; +const MUSIC_SCANNER = "Plex Music"; +const TRACK_TYPE = 10; // Plex metadata type for audio tracks + +/** + * Client for the Plex Media Server + plex.tv APIs. + * + * Authentication uses the PIN-based OAuth flow (see the static auth helpers). + * Once a token is obtained it is used as `X-Plex-Token` against the user's + * local Plex Media Server for library and playlist management. + */ +export class PlexClient { + constructor(url, token, clientId) { + this.url = url ? url.replace(/\/+$/, "") : null; + this.token = token || null; + this.clientId = clientId || null; + this._machineIdentifier = null; + } + + isConfigured() { + return !!(this.url && this.token); + } + + // --- plex.tv OAuth (PIN) flow ------------------------------------------- + + static plexHeaders(clientId, { token } = {}) { + const headers = { + Accept: "application/json", + "X-Plex-Product": PLEX_PRODUCT, + "X-Plex-Client-Identifier": clientId, + }; + if (token) headers["X-Plex-Token"] = token; + return headers; + } + + /** Generate a fresh, stable client identifier for this Aurral install. */ + static generateClientId() { + return crypto.randomUUID(); + } + + /** + * Request a strong PIN from plex.tv. Returns { id, code }. + * The user authorizes the PIN at the URL from `buildAuthUrl`. + */ + static async generatePin(clientId) { + const { data } = await axios.post( + `${PLEX_TV}/api/v2/pins`, + null, + { + params: { strong: true }, + headers: PlexClient.plexHeaders(clientId), + }, + ); + return { id: data.id, code: data.code }; + } + + /** Build the app.plex.tv URL the user visits to authorize the PIN. */ + static buildAuthUrl(clientId, code, forwardUrl) { + const params = new URLSearchParams({ + clientID: clientId, + code, + "context[device][product]": PLEX_PRODUCT, + }); + if (forwardUrl) params.set("forwardUrl", forwardUrl); + return `${PLEX_AUTH_APP}/auth#?${params.toString()}`; + } + + /** + * Poll a PIN. Returns the authToken once the user authorizes, else null. + */ + static async checkPin(pinId, code, clientId) { + const { data } = await axios.get(`${PLEX_TV}/api/v2/pins/${pinId}`, { + params: { code }, + headers: PlexClient.plexHeaders(clientId), + }); + return data.authToken || null; + } + + /** Validate a token against plex.tv. Returns the account object or null. */ + static async validateToken(token, clientId) { + try { + const { data } = await axios.get(`${PLEX_TV}/api/v2/user`, { + headers: PlexClient.plexHeaders(clientId, { token }), + }); + return data || null; + } catch { + return null; + } + } + + /** + * Discover Plex servers owned by / shared with the account. + * Returns [{ name, clientIdentifier, owned, connections: [{ uri, local }] }]. + */ + static async getResources(token, clientId) { + const { data } = await axios.get(`${PLEX_TV}/api/v2/resources`, { + params: { includeHttps: 1, includeRelay: 1 }, + headers: PlexClient.plexHeaders(clientId, { token }), + }); + const list = Array.isArray(data) ? data : []; + return list + .filter((r) => r.provides && r.provides.includes("server")) + .map((r) => ({ + name: r.name, + clientIdentifier: r.clientIdentifier, + owned: !!r.owned, + connections: (r.connections || []).map((c) => ({ + uri: c.uri, + local: !!c.local, + address: c.address, + port: c.port, + })), + })); + } + + // --- Plex Media Server requests ----------------------------------------- + + async request(path, { params = {}, method = "GET", data = null } = {}) { + if (!this.isConfigured()) throw new Error("Plex not configured"); + try { + const response = await axios({ + method, + url: `${this.url}${path}`, + params, + data, + headers: PlexClient.plexHeaders(this.clientId, { token: this.token }), + }); + return response.data; + } catch (error) { + const detail = error.response?.data || error.message; + console.error( + `Plex Error [${method} ${path}]:`, + typeof detail === "string" ? detail : error.message, + ); + throw error; + } + } + + /** Test connectivity + capture the server's machineIdentifier. */ + async ping() { + const data = await this.request("/identity"); + const mc = data?.MediaContainer || {}; + if (mc.machineIdentifier) this._machineIdentifier = mc.machineIdentifier; + return mc; + } + + async getMachineIdentifier() { + if (this._machineIdentifier) return this._machineIdentifier; + await this.ping(); + return this._machineIdentifier; + } + + async getLibraries() { + const data = await this.request("/library/sections"); + return data?.MediaContainer?.Directory || []; + } + + /** + * Find (or create) the Aurral music library pointed at `libraryPath`. + * Mirrors NavidromeClient.ensureWeeklyFlowLibrary. + */ + async ensureWeeklyFlowLibrary(libraryPath) { + if (!this.isConfigured()) return null; + const name = "Aurral Flow"; + try { + const libs = await this.getLibraries(); + const existing = libs.find( + (lib) => + lib.title === name || + (lib.Location || []).some((loc) => loc.path === libraryPath), + ); + if (existing) return existing; + + // POST /library/sections creates the library; returns the new section. + const data = await this.request("/library/sections", { + method: "POST", + params: { + name, + type: MUSIC_SECTION_TYPE, + agent: MUSIC_AGENT, + scanner: MUSIC_SCANNER, + language: "en", + location: libraryPath, + }, + }); + return data?.MediaContainer?.Directory?.[0] || null; + } catch (err) { + console.warn( + "[Plex] ensureWeeklyFlowLibrary failed:", + err?.response?.data || err.message, + ); + return null; + } + } + + async scanLibrary(sectionId) { + if (!this.isConfigured() || sectionId == null) return null; + try { + return await this.request(`/library/sections/${sectionId}/refresh`); + } catch (err) { + console.warn("[Plex] scanLibrary failed:", err?.message); + return null; + } + } + + /** + * Fetch all tracks in a library section with their on-disk file paths. + * Returns [{ ratingKey, title, artist, file }]. + */ + async getTracks(sectionId) { + const data = await this.request(`/library/sections/${sectionId}/all`, { + params: { type: TRACK_TYPE }, + }); + const items = data?.MediaContainer?.Metadata || []; + return items.map((t) => ({ + ratingKey: t.ratingKey, + title: t.title, + artist: t.grandparentTitle || t.originalTitle, + file: t.Media?.[0]?.Part?.[0]?.file || null, + })); + } + + async getPlaylists() { + const data = await this.request("/playlists", { + params: { playlistType: "audio" }, + }); + return data?.MediaContainer?.Metadata || []; + } + + _metadataUri(machineId, ratingKeys) { + const keys = (Array.isArray(ratingKeys) ? ratingKeys : [ratingKeys]).join( + ",", + ); + return `server://${machineId}/com.plexapp.plugins.library/library/metadata/${keys}`; + } + + /** + * Create (or replace) an audio playlist from a list of track ratingKeys. + * Mirrors NavidromeClient.createPlaylist. + */ + async createPlaylist(title, ratingKeys, replace = false) { + const machineId = await this.getMachineIdentifier(); + if (!machineId) throw new Error("Could not resolve Plex machineIdentifier"); + + const existing = (await this.getPlaylists()).find( + (p) => p.title === title, + ); + + if (existing && replace) { + await this.deletePlaylist(existing.ratingKey); + } else if (existing && !replace) { + if (ratingKeys?.length) { + await this.addToPlaylist(existing.ratingKey, ratingKeys); + } + return existing; + } + + if (!ratingKeys?.length) return null; + + const data = await this.request("/playlists", { + method: "POST", + params: { + type: "audio", + title, + smart: 0, + uri: this._metadataUri(machineId, ratingKeys), + }, + }); + return data?.MediaContainer?.Metadata?.[0] || null; + } + + async addToPlaylist(playlistRatingKey, ratingKeys) { + const machineId = await this.getMachineIdentifier(); + return this.request(`/playlists/${playlistRatingKey}/items`, { + method: "PUT", + params: { uri: this._metadataUri(machineId, ratingKeys) }, + }); + } + + async deletePlaylist(playlistRatingKey) { + return this.request(`/playlists/${playlistRatingKey}`, { + method: "DELETE", + }); + } +} diff --git a/backend/services/weeklyFlowPlaylistManager.js b/backend/services/weeklyFlowPlaylistManager.js index f3cc6c6f..c818054d 100644 --- a/backend/services/weeklyFlowPlaylistManager.js +++ b/backend/services/weeklyFlowPlaylistManager.js @@ -2,6 +2,7 @@ import path from "path"; import fs from "fs/promises"; import { dbOps } from "../config/db-helpers.js"; import { NavidromeClient } from "./navidrome.js"; +import { PlexClient } from "./plex.js"; import { flowPlaylistConfig } from "./weeklyFlowPlaylistConfig.js"; import { downloadTracker } from "./weeklyFlowDownloadTracker.js"; import { writePlaylistArtworkSidecar } from "./playlistArtwork.js"; @@ -17,6 +18,9 @@ export class WeeklyFlowPlaylistManager { this.libraryRoot = path.join(this.weeklyFlowRoot, "aurral-weekly-flow"); this.navidromeClient = null; this._navidromeConfigKey = ""; + this.plexClient = null; + this._plexConfigKey = ""; + this._plexSectionId = null; this._ensureInFlight = null; this.updateConfig(triggerEnsureOnInit); } @@ -48,6 +52,28 @@ export class WeeklyFlowPlaylistManager { this.navidromeClient = null; } + const plexConfig = settings.integrations?.plex || {}; + const nextPlexKey = JSON.stringify({ + url: plexConfig.url || "", + token: plexConfig.token || "", + clientId: plexConfig.clientId || "", + }); + const plexChanged = this._plexConfigKey !== nextPlexKey; + this._plexConfigKey = nextPlexKey; + if (plexConfig.url && plexConfig.token) { + if (!this.plexClient || plexChanged) { + this.plexClient = new PlexClient( + plexConfig.url, + plexConfig.token, + plexConfig.clientId, + ); + this._plexSectionId = null; + } + } else { + this.plexClient = null; + this._plexSectionId = null; + } + if (triggerEnsurePlaylists) { this.ensureSmartPlaylists().catch((err) => console.warn( @@ -259,11 +285,149 @@ export class WeeklyFlowPlaylistManager { err?.message, ); } + + if (this.plexClient?.isConfigured()) { + try { + await this._syncPlexPlaylists(flows, sharedPlaylists); + } catch (err) { + console.warn( + "[WeeklyFlowPlaylistManager] Plex playlist sync failed:", + err?.message, + ); + } + } + } + + async _ensurePlexSectionId() { + if (this._plexSectionId != null) return this._plexSectionId; + const hostPath = this._getWeeklyFlowLibraryHostPath(); + const library = await this.plexClient.ensureWeeklyFlowLibrary(hostPath); + // Plex section objects expose the section id as `key`. + const id = library?.key ?? null; + this._plexSectionId = id; + return id; + } + + /** + * Plex has no equivalent of Navidrome's .nsp smart playlists, so we build + * regular audio playlists from the tracks Plex has indexed: group indexed + * tracks by their weekly-flow subfolder and create/replace one playlist per + * enabled flow / shared playlist. New downloads are picked up on the next + * sync once Plex has scanned them. + */ + async _syncPlexPlaylists(flows, sharedPlaylists) { + const sectionId = await this._ensurePlexSectionId(); + if (sectionId == null) return; + + const tracks = await this.plexClient.getTracks(sectionId); + const ratingKeysFor = (playlistType) => { + const needle = `/${playlistType}/`; + return tracks + .filter((t) => t.file && t.file.replace(/\\/g, "/").includes(needle)) + .map((t) => t.ratingKey) + .filter(Boolean); + }; + + const deletePlexPlaylistsByNames = async (names) => { + const playlists = await this.plexClient.getPlaylists(); + for (const name of [...new Set((names || []).filter(Boolean))]) { + const existing = playlists.find((p) => p.title === name); + if (existing) { + try { + await this.plexClient.deletePlaylist(existing.ratingKey); + } catch (err) { + console.warn( + `[WeeklyFlowPlaylistManager] Failed to delete Plex playlist "${name}":`, + err?.message, + ); + } + } + } + }; + + for (const flow of flows) { + const { current, legacy } = this._getFlowPlaylistNames(flow.name); + if (flow.enabled) { + const ratingKeys = ratingKeysFor(flow.id); + if (ratingKeys.length) { + await this.plexClient.createPlaylist(current, ratingKeys, true); + } else { + await deletePlexPlaylistsByNames([current]); + } + await deletePlexPlaylistsByNames(legacy); + } else { + await deletePlexPlaylistsByNames([current, ...legacy]); + } + } + + for (const playlist of sharedPlaylists) { + const { current, legacy } = this._getSharedPlaylistNames(playlist.name); + const ratingKeys = ratingKeysFor(playlist.id); + if (ratingKeys.length) { + await this.plexClient.createPlaylist(current, ratingKeys, true); + } else { + await deletePlexPlaylistsByNames([current]); + } + await deletePlexPlaylistsByNames(legacy); + } + } + + /** + * One-shot Plex sync for an existing flow: ensure the library exists, + * trigger a scan, wait (bounded) for Plex to index the files, then build + * the playlists. Returns a summary for the UI. + */ + async syncPlexNow({ waitMs = 30000, intervalMs = 3000 } = {}) { + if (!this.plexClient?.isConfigured()) { + return { configured: false }; + } + const sectionId = await this._ensurePlexSectionId(); + if (sectionId == null) { + throw new Error("Could not create or find the Aurral Plex library"); + } + await this.plexClient.scanLibrary(sectionId); + + // Poll until Plex has indexed at least one track, or we time out. + const deadline = Date.now() + waitMs; + let tracks = await this.plexClient.getTracks(sectionId); + while (tracks.length === 0 && Date.now() < deadline) { + await new Promise((r) => setTimeout(r, intervalMs)); + tracks = await this.plexClient.getTracks(sectionId); + } + + const flows = flowPlaylistConfig.getFlows(); + const sharedPlaylists = flowPlaylistConfig.getSharedPlaylists(); + await this._syncPlexPlaylists(flows, sharedPlaylists); + + const playlists = await this.plexClient.getPlaylists(); + return { + configured: true, + sectionId, + indexedTracks: tracks.length, + playlists: playlists + .filter( + (p) => p.title?.startsWith("[A] ") || p.title?.startsWith("[AS] "), + ) + .map((p) => ({ title: p.title, count: p.leafCount ?? null })), + }; } async scanLibrary() { - if (!this.navidromeClient?.isConfigured()) return null; - return this.navidromeClient.scanLibrary(); + const results = []; + if (this.navidromeClient?.isConfigured()) { + results.push(await this.navidromeClient.scanLibrary()); + } + if (this.plexClient?.isConfigured()) { + try { + const sectionId = await this._ensurePlexSectionId(); + if (sectionId != null) { + results.push(await this.plexClient.scanLibrary(sectionId)); + } + } catch (err) { + console.warn("[WeeklyFlowPlaylistManager] Plex scan failed:", err?.message); + } + } + return results.length ? results : null; } async weeklyReset(playlistTypes = null) { diff --git a/frontend/src/pages/Settings/components/SettingsIntegrationsTab.jsx b/frontend/src/pages/Settings/components/SettingsIntegrationsTab.jsx index 4cc705cb..0cd29058 100644 --- a/frontend/src/pages/Settings/components/SettingsIntegrationsTab.jsx +++ b/frontend/src/pages/Settings/components/SettingsIntegrationsTab.jsx @@ -6,6 +6,11 @@ import { getLidarrProfiles, getLidarrTags, testLidarrConnection, + startPlexAuth, + checkPlexAuth, + getPlexResources, + testPlexConnection, + syncPlexNow, } from "../../../utils/api"; export function SettingsIntegrationsTab({ @@ -40,8 +45,13 @@ export function SettingsIntegrationsTab({ lastfm: true, ticketmaster: true, navidrome: true, + plex: true, }); const [lidarrTestLatencyMs, setLidarrTestLatencyMs] = useState(null); + const [plexConnecting, setPlexConnecting] = useState(false); + const [testingPlex, setTestingPlex] = useState(false); + const [syncingPlex, setSyncingPlex] = useState(false); + const [plexServers, setPlexServers] = useState([]); const safeLidarrProfiles = Array.isArray(lidarrProfiles) ? lidarrProfiles : []; @@ -61,6 +71,152 @@ export function SettingsIntegrationsTab({ })); }; + const updatePlex = (patch) => + updateSettings({ + ...settings, + integrations: { + ...settings.integrations, + plex: { ...(settings.integrations?.plex || {}), ...patch }, + }, + }); + + const loadPlexServers = async (token) => { + try { + const { servers } = await getPlexResources(token); + setPlexServers(Array.isArray(servers) ? servers : []); + return servers; + } catch { + setPlexServers([]); + return []; + } + }; + + const handleConnectPlex = async () => { + setPlexConnecting(true); + try { + // No forwardUrl: we poll for the token and close the popup ourselves. + const { pinId, code, authUrl } = await startPlexAuth(); + const popup = window.open( + authUrl, + "plex-auth", + "width=600,height=700" + ); + // Poll the PIN until the user authorizes (or we time out ~3 min). + const deadline = Date.now() + 3 * 60 * 1000; + let token = null; + while (Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 2000)); + try { + const res = await checkPlexAuth(pinId, code); + if (res.token) { + token = res.token; + break; + } + } catch { + // transient; keep polling + } + } + if (popup && !popup.closed) popup.close(); + if (!token) { + showError("Plex authentication timed out. Please try again."); + return; + } + updatePlex({ token }); + showSuccess("Signed in to Plex. Select your server below."); + const servers = await loadPlexServers(token); + // Auto-select the only owned server, preferring a local connection. + const owned = (servers || []).filter((s) => s.owned); + if (owned.length === 1) { + handleSelectPlexServer(owned[0], token); + } + } catch (err) { + const errorMsg = + err.response?.data?.message || err.response?.data?.error || err.message; + showError(`Plex sign-in failed: ${errorMsg}`); + } finally { + setPlexConnecting(false); + } + }; + + const handleSelectPlexServer = (server, tokenOverride) => { + const conns = server.connections || []; + const best = + conns.find((c) => c.local) || conns.find((c) => c.uri) || conns[0]; + if (!best?.uri) { + showError("Selected Plex server has no usable connection."); + return; + } + updateSettings({ + ...settings, + integrations: { + ...settings.integrations, + plex: { + ...(settings.integrations?.plex || {}), + ...(tokenOverride ? { token: tokenOverride } : {}), + url: best.uri, + machineIdentifier: server.clientIdentifier, + }, + }, + }); + showInfo(`Selected "${server.name}". Remember to Save settings.`); + }; + + const handleTestPlex = async () => { + const url = settings.integrations?.plex?.url; + const token = settings.integrations?.plex?.token; + if (!url || !token) { + showError("Connect to Plex and select a server first"); + return; + } + setTestingPlex(true); + try { + const result = await testPlexConnection(url, token); + if (result.success) { + showSuccess( + `Plex connection successful!${result.version ? ` (v${result.version})` : ""}` + ); + if (result.machineIdentifier) { + updatePlex({ machineIdentifier: result.machineIdentifier }); + } + } else { + showError(`Connection failed: ${result.message || result.error}`); + } + } catch (err) { + const errorMsg = + err.response?.data?.message || err.response?.data?.error || err.message; + showError(`Connection failed: ${errorMsg}`); + } finally { + setTestingPlex(false); + } + }; + + const handleSyncPlex = async () => { + if (hasUnsavedChanges) { + showError("Save settings first, then sync to Plex."); + return; + } + setSyncingPlex(true); + try { + const result = await syncPlexNow(); + const built = (result.playlists || []).length; + if (result.indexedTracks === 0) { + showInfo( + "Library created and a Plex scan was triggered, but no tracks are indexed yet. Give Plex a minute to scan, then sync again." + ); + } else { + showSuccess( + `Synced to Plex: ${built} playlist(s) from ${result.indexedTracks} indexed track(s).` + ); + } + } catch (err) { + const errorMsg = + err.response?.data?.message || err.response?.data?.error || err.message; + showError(`Plex sync failed: ${errorMsg}`); + } finally { + setSyncingPlex(false); + } + }; + const handleTestLidarr = async () => { const url = settings.integrations?.lidarr?.url; const apiKey = settings.integrations?.lidarr?.apiKey; @@ -1089,6 +1245,176 @@ export function SettingsIntegrationsTab({ )} +
+
+

+ +

+
+ {settings.integrations?.plex?.token && + settings.integrations?.plex?.url && ( + + + Configured + + )} +
+
+ {!collapsedSections.plex && ( +
+

+ Sign in with your Plex account to let Aurral create a dedicated + music library pointed at your Weekly Flow downloads and build a + playlist for each flow. Playlists appear in Plex and Plexamp. +

+
+ + {settings.integrations?.plex?.token && ( + + + Signed in + + )} +
+ + {plexServers.length > 0 && ( +
+ + +
+ )} + +
+ + updatePlex({ url: e.target.value })} + /> +

+ Auto-filled when you select a server, or enter it manually. +

+
+ +
+ + +
+

+ Creates an "Aurral Flow" music library pointed at your + downloads, scans it, and builds a playlist per flow. The Plex + server must be able to read the same downloads path Aurral writes + to. Save settings before syncing. +

+
+ )} +
); diff --git a/frontend/src/pages/Settings/hooks/useSettingsData.js b/frontend/src/pages/Settings/hooks/useSettingsData.js index 3a4fbaea..caa926dd 100644 --- a/frontend/src/pages/Settings/hooks/useSettingsData.js +++ b/frontend/src/pages/Settings/hooks/useSettingsData.js @@ -26,6 +26,7 @@ const defaultSettings = { releaseTypes: allReleaseTypes, integrations: { navidrome: { url: "", username: "", password: "" }, + plex: { url: "", token: "", clientId: "", machineIdentifier: "" }, lastfm: { apiKey: "", username: "", diff --git a/frontend/src/pages/Settings/utils.js b/frontend/src/pages/Settings/utils.js index 4a06e9ee..3c19eae4 100644 --- a/frontend/src/pages/Settings/utils.js +++ b/frontend/src/pages/Settings/utils.js @@ -54,6 +54,13 @@ export const normalizeSettings = (savedSettings) => { password: "", ...(savedSettings.integrations?.navidrome || {}), }, + plex: { + url: "", + token: "", + clientId: "", + machineIdentifier: "", + ...(savedSettings.integrations?.plex || {}), + }, lastfm: { apiKey: "", username: "", diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index d907ad6a..3588a1f4 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -241,6 +241,34 @@ export const testNavidromeOnboarding = async (url, username, password) => { return response.data; }; +export const startPlexAuth = async (forwardUrl) => { + const response = await api.post("/settings/plex/auth/pin", { forwardUrl }); + return response.data; +}; + +export const checkPlexAuth = async (pinId, code) => { + const response = await api.post("/settings/plex/auth/check", { pinId, code }); + return response.data; +}; + +export const getPlexResources = async (token) => { + const response = await api.post("/settings/plex/resources", { token }); + return response.data; +}; + +export const testPlexConnection = async (url, token) => { + const response = await api.post("/settings/plex/test", { + url: url?.replace(/\/+$/, ""), + token, + }); + return response.data; +}; + +export const syncPlexNow = async () => { + const response = await api.post("/settings/plex/sync"); + return response.data; +}; + export const getAuthConfig = async () => { const response = await api.get("/auth/config"); return response.data; From a1381fc8059e5a873b92962827e975e485bc929f Mon Sep 17 00:00:00 2001 From: dylfrancis Date: Tue, 2 Jun 2026 19:36:30 -0400 Subject: [PATCH 2/9] fix(plex): surface library sync error --- backend/services/plex.js | 35 ++++++++++++++++++++++++----------- 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/backend/services/plex.js b/backend/services/plex.js index 881cbfd9..67292ee7 100644 --- a/backend/services/plex.js +++ b/backend/services/plex.js @@ -170,17 +170,21 @@ export class PlexClient { async ensureWeeklyFlowLibrary(libraryPath) { if (!this.isConfigured()) return null; const name = "Aurral Flow"; - try { - const libs = await this.getLibraries(); - const existing = libs.find( + const findExisting = (libs) => + libs.find( (lib) => lib.title === name || (lib.Location || []).some((loc) => loc.path === libraryPath), ); - if (existing) return existing; - // POST /library/sections creates the library; returns the new section. - const data = await this.request("/library/sections", { + const existing = findExisting(await this.getLibraries()); + if (existing) return existing; + + // POST /library/sections creates the library. Plex's response shape here + // is inconsistent across versions, so we create then re-read the section + // list to resolve the new library (and its `key`) reliably. + try { + await this.request("/library/sections", { method: "POST", params: { name, @@ -191,14 +195,23 @@ export class PlexClient { location: libraryPath, }, }); - return data?.MediaContainer?.Directory?.[0] || null; } catch (err) { - console.warn( - "[Plex] ensureWeeklyFlowLibrary failed:", - err?.response?.data || err.message, + const detail = err?.response?.data || err.message; + const status = err?.response?.status; + throw new Error( + `Plex rejected library creation (${status || "no status"}) for path "${libraryPath}": ${ + typeof detail === "string" ? detail : JSON.stringify(detail) + }`, + ); + } + + const created = findExisting(await this.getLibraries()); + if (!created) { + throw new Error( + `Plex accepted the request but no "Aurral Flow" library appeared. Verify the Plex server can access the path "${libraryPath}".`, ); - return null; } + return created; } async scanLibrary(sectionId) { From 4751719be12bf0497877b8ecaeebe329e0be91be Mon Sep 17 00:00:00 2001 From: dylfrancis Date: Tue, 2 Jun 2026 19:40:08 -0400 Subject: [PATCH 3/9] fix(plex): use correct english locale --- backend/services/plex.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/services/plex.js b/backend/services/plex.js index 67292ee7..6898fad3 100644 --- a/backend/services/plex.js +++ b/backend/services/plex.js @@ -191,7 +191,7 @@ export class PlexClient { type: MUSIC_SECTION_TYPE, agent: MUSIC_AGENT, scanner: MUSIC_SCANNER, - language: "en", + language: "en-US", location: libraryPath, }, }); From 5ee0a82d6032eb0051de6b3241a0ebbc9612d2c9 Mon Sep 17 00:00:00 2001 From: dylfrancis Date: Tue, 2 Jun 2026 19:55:07 -0400 Subject: [PATCH 4/9] feat(plex): make playlist sync async on manual sync --- backend/services/weeklyFlowPlaylistManager.js | 54 ++++++++++++++----- .../components/SettingsIntegrationsTab.jsx | 4 +- 2 files changed, 44 insertions(+), 14 deletions(-) diff --git a/backend/services/weeklyFlowPlaylistManager.js b/backend/services/weeklyFlowPlaylistManager.js index c818054d..484de609 100644 --- a/backend/services/weeklyFlowPlaylistManager.js +++ b/backend/services/weeklyFlowPlaylistManager.js @@ -373,11 +373,13 @@ export class WeeklyFlowPlaylistManager { } /** - * One-shot Plex sync for an existing flow: ensure the library exists, - * trigger a scan, wait (bounded) for Plex to index the files, then build - * the playlists. Returns a summary for the UI. + * Manual Plex sync for an existing flow. Returns quickly: ensures the + * library, triggers a scan, builds playlists from whatever Plex has already + * indexed, and reports status. Because Plex's music scan (with online + * metadata matching) can take minutes, we don't block on it here — instead a + * background catch-up rebuilds the playlists as tracks get indexed. */ - async syncPlexNow({ waitMs = 30000, intervalMs = 3000 } = {}) { + async syncPlexNow() { if (!this.plexClient?.isConfigured()) { return { configured: false }; } @@ -387,23 +389,22 @@ export class WeeklyFlowPlaylistManager { } await this.plexClient.scanLibrary(sectionId); - // Poll until Plex has indexed at least one track, or we time out. - const deadline = Date.now() + waitMs; - let tracks = await this.plexClient.getTracks(sectionId); - while (tracks.length === 0 && Date.now() < deadline) { - await new Promise((r) => setTimeout(r, intervalMs)); - tracks = await this.plexClient.getTracks(sectionId); - } - const flows = flowPlaylistConfig.getFlows(); const sharedPlaylists = flowPlaylistConfig.getSharedPlaylists(); await this._syncPlexPlaylists(flows, sharedPlaylists); + const tracks = await this.plexClient.getTracks(sectionId); const playlists = await this.plexClient.getPlaylists(); + + // Kick off a non-blocking catch-up so playlists fill in once Plex finishes + // indexing, without the user needing to click sync again. + this._schedulePlexCatchup(sectionId); + return { configured: true, sectionId, indexedTracks: tracks.length, + scanInProgress: tracks.length === 0, playlists: playlists .filter( (p) => p.title?.startsWith("[A] ") || p.title?.startsWith("[AS] "), @@ -412,6 +413,35 @@ export class WeeklyFlowPlaylistManager { }; } + /** + * Rebuild Plex playlists a few times over the next several minutes so they + * populate as a freshly-triggered scan indexes tracks. Only one catch-up + * runs at a time. + */ + _schedulePlexCatchup(sectionId, delaysMs = [30000, 90000, 180000]) { + if (this._plexCatchupRunning) return; + this._plexCatchupRunning = true; + const run = async () => { + try { + for (const delay of delaysMs) { + await new Promise((r) => setTimeout(r, delay)); + if (!this.plexClient?.isConfigured()) break; + const flows = flowPlaylistConfig.getFlows(); + const sharedPlaylists = flowPlaylistConfig.getSharedPlaylists(); + await this._syncPlexPlaylists(flows, sharedPlaylists); + } + } catch (err) { + console.warn( + "[WeeklyFlowPlaylistManager] Plex catch-up failed:", + err?.message, + ); + } finally { + this._plexCatchupRunning = false; + } + }; + run(); + } + async scanLibrary() { const results = []; if (this.navidromeClient?.isConfigured()) { diff --git a/frontend/src/pages/Settings/components/SettingsIntegrationsTab.jsx b/frontend/src/pages/Settings/components/SettingsIntegrationsTab.jsx index 0cd29058..6424ea7c 100644 --- a/frontend/src/pages/Settings/components/SettingsIntegrationsTab.jsx +++ b/frontend/src/pages/Settings/components/SettingsIntegrationsTab.jsx @@ -199,9 +199,9 @@ export function SettingsIntegrationsTab({ try { const result = await syncPlexNow(); const built = (result.playlists || []).length; - if (result.indexedTracks === 0) { + if (result.scanInProgress) { showInfo( - "Library created and a Plex scan was triggered, but no tracks are indexed yet. Give Plex a minute to scan, then sync again." + "Library ready and a Plex scan is running. Playlists will fill in automatically over the next few minutes as Plex indexes the tracks — no need to click again." ); } else { showSuccess( From a713df59ae4a9b10130d74f8ba3ccb829a757272 Mon Sep 17 00:00:00 2001 From: dylfrancis Date: Tue, 2 Jun 2026 20:11:02 -0400 Subject: [PATCH 5/9] feat(plex): add file browsing to select path that plex can see for aurral downloads --- backend/config/constants.js | 8 +- backend/routes/settings.js | 38 +++++ backend/services/weeklyFlowPlaylistManager.js | 19 ++- .../components/SettingsIntegrationsTab.jsx | 157 +++++++++++++++++- .../pages/Settings/hooks/useSettingsData.js | 8 +- frontend/src/pages/Settings/utils.js | 1 + frontend/src/utils/api.js | 7 + 7 files changed, 233 insertions(+), 5 deletions(-) diff --git a/backend/config/constants.js b/backend/config/constants.js index aa9340c9..b1de8b4f 100644 --- a/backend/config/constants.js +++ b/backend/config/constants.js @@ -176,7 +176,13 @@ export const defaultData = { ], integrations: { navidrome: { url: "", username: "", password: "" }, - plex: { url: "", token: "", clientId: "", machineIdentifier: "" }, + plex: { + url: "", + token: "", + clientId: "", + machineIdentifier: "", + downloadsPath: "", + }, lastfm: { apiKey: "", username: "", diff --git a/backend/routes/settings.js b/backend/routes/settings.js index e92c646a..a2084794 100644 --- a/backend/routes/settings.js +++ b/backend/routes/settings.js @@ -1122,6 +1122,44 @@ router.post("/lidarr/apply-community-guide", async (req, res) => { } }); +// --- Filesystem browse (admin-only) ---------------------------------------- + +// List subdirectories of a path as the backend sees them, for path pickers. +router.get("/browse", async (req, res) => { + try { + const fs = await import("fs/promises"); + const path = await import("path"); + const target = path.resolve(req.query.path ? String(req.query.path) : "/"); + const dirents = await fs.readdir(target, { withFileTypes: true }); + const directories = ( + await Promise.all( + dirents.map(async (d) => { + let isDir = d.isDirectory(); + if (!isDir && d.isSymbolicLink()) { + try { + isDir = (await fs.stat(path.join(target, d.name))).isDirectory(); + } catch { + isDir = false; + } + } + return isDir ? { name: d.name, path: path.join(target, d.name) } : null; + }), + ) + ) + .filter(Boolean) + .sort((a, b) => a.name.localeCompare(b.name)); + res.json({ + path: target, + parent: target === "/" ? null : path.dirname(target), + directories, + }); + } catch (error) { + res + .status(400) + .json({ error: "Cannot read path", message: error.message }); + } +}); + // --- Plex integration ------------------------------------------------------ function getPlexConfig() { diff --git a/backend/services/weeklyFlowPlaylistManager.js b/backend/services/weeklyFlowPlaylistManager.js index 484de609..bf0184a8 100644 --- a/backend/services/weeklyFlowPlaylistManager.js +++ b/backend/services/weeklyFlowPlaylistManager.js @@ -57,9 +57,11 @@ export class WeeklyFlowPlaylistManager { url: plexConfig.url || "", token: plexConfig.token || "", clientId: plexConfig.clientId || "", + downloadsPath: plexConfig.downloadsPath || "", }); const plexChanged = this._plexConfigKey !== nextPlexKey; this._plexConfigKey = nextPlexKey; + this._plexDownloadsPath = plexConfig.downloadsPath || ""; if (plexConfig.url && plexConfig.token) { if (!this.plexClient || plexChanged) { this.plexClient = new PlexClient( @@ -298,10 +300,23 @@ export class WeeklyFlowPlaylistManager { } } + // The library location must be the path the *Plex server* uses to reach the + // downloads, which can differ from Aurral's host path when Plex runs in its + // own container. Prefer the user-provided override; fall back to the host + // path when unset. + _getPlexLibraryPath() { + const override = String(this._plexDownloadsPath || "").trim(); + if (override) { + const base = override.replace(/\\/g, "/").replace(/\/+$/, ""); + return `${base}/aurral-weekly-flow`; + } + return this._getWeeklyFlowLibraryHostPath(); + } + async _ensurePlexSectionId() { if (this._plexSectionId != null) return this._plexSectionId; - const hostPath = this._getWeeklyFlowLibraryHostPath(); - const library = await this.plexClient.ensureWeeklyFlowLibrary(hostPath); + const libraryPath = this._getPlexLibraryPath(); + const library = await this.plexClient.ensureWeeklyFlowLibrary(libraryPath); // Plex section objects expose the section id as `key`. const id = library?.key ?? null; this._plexSectionId = id; diff --git a/frontend/src/pages/Settings/components/SettingsIntegrationsTab.jsx b/frontend/src/pages/Settings/components/SettingsIntegrationsTab.jsx index 6424ea7c..2ddbf1c7 100644 --- a/frontend/src/pages/Settings/components/SettingsIntegrationsTab.jsx +++ b/frontend/src/pages/Settings/components/SettingsIntegrationsTab.jsx @@ -1,5 +1,11 @@ import { useState } from "react"; -import { CheckCircle, ChevronDown, RefreshCw } from "lucide-react"; +import { + CheckCircle, + ChevronDown, + RefreshCw, + Folder, + CornerLeftUp, +} from "lucide-react"; import FlipSaveButton from "../../../components/FlipSaveButton"; import { getLidarrMetadataProfiles, @@ -11,6 +17,7 @@ import { getPlexResources, testPlexConnection, syncPlexNow, + browsePaths, } from "../../../utils/api"; export function SettingsIntegrationsTab({ @@ -52,6 +59,13 @@ export function SettingsIntegrationsTab({ const [testingPlex, setTestingPlex] = useState(false); const [syncingPlex, setSyncingPlex] = useState(false); const [plexServers, setPlexServers] = useState([]); + const [browseOpen, setBrowseOpen] = useState(false); + const [browseLoading, setBrowseLoading] = useState(false); + const [browseState, setBrowseState] = useState({ + path: "/", + parent: null, + directories: [], + }); const safeLidarrProfiles = Array.isArray(lidarrProfiles) ? lidarrProfiles : []; @@ -217,6 +231,34 @@ export function SettingsIntegrationsTab({ } }; + const loadBrowse = async (path) => { + setBrowseLoading(true); + try { + const result = await browsePaths(path); + setBrowseState(result); + } catch (err) { + const errorMsg = + err.response?.data?.message || err.response?.data?.error || err.message; + showError(`Cannot read path: ${errorMsg}`); + } finally { + setBrowseLoading(false); + } + }; + + const handleToggleBrowse = () => { + if (browseOpen) { + setBrowseOpen(false); + return; + } + setBrowseOpen(true); + loadBrowse(settings.integrations?.plex?.downloadsPath || "/"); + }; + + const handleUseBrowsedFolder = () => { + updatePlex({ downloadsPath: browseState.path }); + setBrowseOpen(false); + }; + const handleTestLidarr = async () => { const url = settings.integrations?.lidarr?.url; const apiKey = settings.integrations?.lidarr?.apiKey; @@ -1366,6 +1408,119 @@ export function SettingsIntegrationsTab({

+
+ +
+ updatePlex({ downloadsPath: e.target.value })} + /> + +
+

+ Only needed if Plex runs in a different container/host than + Aurral. Enter the downloads folder path as the{" "} + Plex server sees it — Aurral appends{" "} + /aurral-weekly-flow. Leave blank to use Aurral's + own download path. Browse shows the filesystem as Aurral sees + it; type manually if Plex's mount path differs. +

+ + {browseOpen && ( +
+
+ + {browseState.path} + + +
+
+ {browseLoading ? ( +
+ + Loading… +
+ ) : ( +
    + {browseState.parent && ( +
  • + +
  • + )} + {browseState.directories.length === 0 && ( +
  • + No subfolders here. +
  • + )} + {browseState.directories.map((dir) => ( +
  • + +
  • + ))} +
+ )} +
+
+ )} +
+
{settings.integrations?.plex?.token && ( - - - Signed in - + <> + + + + Signed in + + )}
@@ -1415,110 +1389,21 @@ export function SettingsIntegrationsTab({ > Plex downloads path (optional) -
- updatePlex({ downloadsPath: e.target.value })} - /> - -
+ updatePlex({ downloadsPath: e.target.value })} + />

Only needed if Plex runs in a different container/host than Aurral. Enter the downloads folder path as the{" "} Plex server sees it — Aurral appends{" "} /aurral-weekly-flow. Leave blank to use Aurral's - own download path. Browse shows the filesystem as Aurral sees - it; type manually if Plex's mount path differs. + own download path.

- - {browseOpen && ( -
-
- - {browseState.path} - - -
-
- {browseLoading ? ( -
- - Loading… -
- ) : ( -
    - {browseState.parent && ( -
  • - -
  • - )} - {browseState.directories.length === 0 && ( -
  • - No subfolders here. -
  • - )} - {browseState.directories.map((dir) => ( -
  • - -
  • - ))} -
- )} -
-
- )}
diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index ddaeef66..3588a1f4 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -269,13 +269,6 @@ export const syncPlexNow = async () => { return response.data; }; -export const browsePaths = async (path) => { - const response = await api.get("/settings/browse", { - params: path ? { path } : {}, - }); - return response.data; -}; - export const getAuthConfig = async () => { const response = await api.get("/auth/config"); return response.data; From 1a9054a2a66b48fcf0b075a831b4b960ee192501 Mon Sep 17 00:00:00 2001 From: dylfrancis Date: Tue, 2 Jun 2026 20:48:52 -0400 Subject: [PATCH 7/9] fix(plex): persist clientId --- backend/routes/settings.js | 35 ++++++++-- backend/services/plex.js | 42 +++++++---- .../components/SettingsIntegrationsTab.jsx | 70 +++++++++++-------- 3 files changed, 95 insertions(+), 52 deletions(-) diff --git a/backend/routes/settings.js b/backend/routes/settings.js index e92c646a..0d9e95e6 100644 --- a/backend/routes/settings.js +++ b/backend/routes/settings.js @@ -132,6 +132,16 @@ router.post("/", async (req, res) => { ? { ...(mergedIntegrations.plex || {}), ...integrations.plex, + // Never let a blank token/clientId from the client wipe the + // stored credentials (the UI doesn't always carry them). + token: + integrations.plex.token || + mergedIntegrations.plex?.token || + "", + clientId: + integrations.plex.clientId || + mergedIntegrations.plex?.clientId || + "", } : mergedIntegrations.plex, slskd: integrations.slskd @@ -1190,18 +1200,29 @@ router.post("/plex/auth/check", async (req, res) => { router.post("/plex/resources", async (req, res) => { try { const { PlexClient } = await import("../services/plex.js"); - const clientId = getPlexConfig().clientId; - const token = req.body?.token || getPlexConfig().token; + const stored = getPlexConfig(); + // Use the freshest token the client has (e.g. just-minted during connect), + // falling back to the persisted one. The clientId MUST be the stored one + // the token was minted under — Plex ties the token to that identifier. + const token = req.body?.token || stored.token; + const clientId = stored.clientId; if (!token || !clientId) { return res.status(400).json({ error: "Plex authentication required" }); } - const servers = await PlexClient.getResources(token, clientId); - res.json({ servers }); + const { servers, total } = await PlexClient.getResources(token, clientId); + res.json({ servers, total }); } catch (error) { - console.error("[Settings] Plex resources failed:", error.message); - res.status(500).json({ + const status = error.response?.status; + console.error( + "[Settings] Plex resources failed:", + status ? `${status} ${JSON.stringify(error.response?.data)}` : error.message, + ); + res.status(status === 401 ? 401 : 500).json({ error: "Failed to list Plex servers", - message: error.message, + message: + status === 401 + ? "Plex rejected the token (401). Reconnect your Plex account." + : error.message, }); } }); diff --git a/backend/services/plex.js b/backend/services/plex.js index efad8a18..3b52568d 100644 --- a/backend/services/plex.js +++ b/backend/services/plex.js @@ -105,20 +105,34 @@ export class PlexClient { params: { includeHttps: 1, includeRelay: 1 }, headers: PlexClient.plexHeaders(clientId, { token }), }); - const list = Array.isArray(data) ? data : []; - return list - .filter((r) => r.provides && r.provides.includes("server")) - .map((r) => ({ - name: r.name, - clientIdentifier: r.clientIdentifier, - owned: !!r.owned, - connections: (r.connections || []).map((c) => ({ - uri: c.uri, - local: !!c.local, - address: c.address, - port: c.port, - })), - })); + // v2 returns a JSON array; tolerate XML-shaped responses too. + let list = []; + if (Array.isArray(data)) list = data; + else if (Array.isArray(data?.MediaContainer?.Device)) + list = data.MediaContainer.Device; + else if (data?.MediaContainer?.Device) list = [data.MediaContainer.Device]; + + const servers = list + .filter((r) => String(r.provides || "").includes("server")) + .map((r) => { + const rawConns = r.connections || r.Connection || []; + const conns = Array.isArray(rawConns) ? rawConns : [rawConns]; + return { + name: r.name, + clientIdentifier: r.clientIdentifier, + owned: r.owned === true || r.owned === "1" || r.owned === 1, + connections: conns.map((c) => ({ + uri: c.uri, + local: c.local === true || c.local === "1" || c.local === 1, + address: c.address, + port: c.port, + })), + }; + }); + console.log( + `[Plex] getResources: ${list.length} device(s) returned, ${servers.length} provide "server"`, + ); + return { servers, total: list.length }; } // --- Plex Media Server requests ----------------------------------------- diff --git a/frontend/src/pages/Settings/components/SettingsIntegrationsTab.jsx b/frontend/src/pages/Settings/components/SettingsIntegrationsTab.jsx index cfae5bbc..589215b4 100644 --- a/frontend/src/pages/Settings/components/SettingsIntegrationsTab.jsx +++ b/frontend/src/pages/Settings/components/SettingsIntegrationsTab.jsx @@ -81,20 +81,26 @@ export function SettingsIntegrationsTab({ }); const loadPlexServers = async (token) => { - try { - const { servers } = await getPlexResources(token); - setPlexServers(Array.isArray(servers) ? servers : []); - return servers; - } catch { - setPlexServers([]); - return []; - } + const { servers } = await getPlexResources(token); + const list = Array.isArray(servers) ? servers : []; + setPlexServers(list); + return list; }; const handleChoosePlexServer = async () => { - const servers = await loadPlexServers(settings.integrations?.plex?.token); - if (!servers || servers.length === 0) { - showError("No Plex servers found for this account."); + try { + const servers = await loadPlexServers(settings.integrations?.plex?.token); + if (servers.length === 0) { + showError( + "Plex returned no servers for this account. Make sure your server is signed in to the same Plex account." + ); + } else { + showInfo(`Found ${servers.length} Plex server(s).`); + } + } catch (err) { + const msg = + err.response?.data?.message || err.response?.data?.error || err.message; + showError(`Failed to load Plex servers: ${msg}`); } }; @@ -102,7 +108,7 @@ export function SettingsIntegrationsTab({ setPlexConnecting(true); try { // No forwardUrl: we poll for the token and close the popup ourselves. - const { pinId, code, authUrl } = await startPlexAuth(); + const { pinId, code, authUrl, clientId } = await startPlexAuth(); const popup = window.open( authUrl, "plex-auth", @@ -128,14 +134,24 @@ export function SettingsIntegrationsTab({ showError("Plex authentication timed out. Please try again."); return; } - updatePlex({ token }); - showSuccess("Signed in to Plex. Select your server below."); const servers = await loadPlexServers(token); - // Auto-select the only owned server, preferring a local connection. + // Build a single atomic update (avoids stale-closure overwrites that + // could drop the token/clientId we just obtained). const owned = (servers || []).filter((s) => s.owned); + const patch = { token, ...(clientId ? { clientId } : {}) }; if (owned.length === 1) { - handleSelectPlexServer(owned[0], token); + const best = pickBestConnection(owned[0]); + if (best?.uri) { + patch.url = best.uri; + patch.machineIdentifier = owned[0].clientIdentifier; + } } + updatePlex(patch); + showSuccess( + owned.length === 1 && patch.url + ? `Signed in and selected "${owned[0].name}". Remember to Save settings.` + : "Signed in to Plex. Select your server below.", + ); } catch (err) { const errorMsg = err.response?.data?.message || err.response?.data?.error || err.message; @@ -145,26 +161,18 @@ export function SettingsIntegrationsTab({ } }; - const handleSelectPlexServer = (server, tokenOverride) => { + const pickBestConnection = (server) => { const conns = server.connections || []; - const best = - conns.find((c) => c.local) || conns.find((c) => c.uri) || conns[0]; + return conns.find((c) => c.local) || conns.find((c) => c.uri) || conns[0]; + }; + + const handleSelectPlexServer = (server) => { + const best = pickBestConnection(server); if (!best?.uri) { showError("Selected Plex server has no usable connection."); return; } - updateSettings({ - ...settings, - integrations: { - ...settings.integrations, - plex: { - ...(settings.integrations?.plex || {}), - ...(tokenOverride ? { token: tokenOverride } : {}), - url: best.uri, - machineIdentifier: server.clientIdentifier, - }, - }, - }); + updatePlex({ url: best.uri, machineIdentifier: server.clientIdentifier }); showInfo(`Selected "${server.name}". Remember to Save settings.`); }; From ff57a72408643742ebf9022f00b292e9239529f7 Mon Sep 17 00:00:00 2001 From: dylfrancis Date: Tue, 2 Jun 2026 20:56:15 -0400 Subject: [PATCH 8/9] fix(plex): always show server --- .../components/SettingsIntegrationsTab.jsx | 55 ++++++++----------- 1 file changed, 24 insertions(+), 31 deletions(-) diff --git a/frontend/src/pages/Settings/components/SettingsIntegrationsTab.jsx b/frontend/src/pages/Settings/components/SettingsIntegrationsTab.jsx index 589215b4..4b77f0d8 100644 --- a/frontend/src/pages/Settings/components/SettingsIntegrationsTab.jsx +++ b/frontend/src/pages/Settings/components/SettingsIntegrationsTab.jsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useEffect } from "react"; import { CheckCircle, ChevronDown, RefreshCw } from "lucide-react"; import FlipSaveButton from "../../../components/FlipSaveButton"; import { @@ -87,22 +87,24 @@ export function SettingsIntegrationsTab({ return list; }; - const handleChoosePlexServer = async () => { - try { - const servers = await loadPlexServers(settings.integrations?.plex?.token); - if (servers.length === 0) { - showError( - "Plex returned no servers for this account. Make sure your server is signed in to the same Plex account." - ); - } else { - showInfo(`Found ${servers.length} Plex server(s).`); - } - } catch (err) { - const msg = - err.response?.data?.message || err.response?.data?.error || err.message; - showError(`Failed to load Plex servers: ${msg}`); + // Auto-load the account's servers whenever we have a token, so the dropdown + // is always populated (e.g. on page load) without a manual "choose" step. + const plexToken = settings.integrations?.plex?.token; + useEffect(() => { + if (!plexToken) { + setPlexServers([]); + return; } - }; + let cancelled = false; + getPlexResources(plexToken) + .then(({ servers }) => { + if (!cancelled) setPlexServers(Array.isArray(servers) ? servers : []); + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, [plexToken]); const handleConnectPlex = async () => { setPlexConnecting(true); @@ -1323,23 +1325,14 @@ export function SettingsIntegrationsTab({ )} {settings.integrations?.plex?.token && ( - <> - - - - Signed in - - + + + Signed in + )}
- {plexServers.length > 0 && ( + {settings.integrations?.plex?.token && (