diff --git a/backend/config/constants.js b/backend/config/constants.js index a2c28d09..b1de8b4f 100644 --- a/backend/config/constants.js +++ b/backend/config/constants.js @@ -176,6 +176,13 @@ export const defaultData = { ], integrations: { navidrome: { url: "", username: "", password: "" }, + plex: { + url: "", + token: "", + clientId: "", + machineIdentifier: "", + downloadsPath: "", + }, 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..f75b26c4 100644 --- a/backend/routes/settings.js +++ b/backend/routes/settings.js @@ -128,6 +128,22 @@ router.post("/", async (req, res) => { ...integrations.navidrome, } : mergedIntegrations.navidrome, + plex: integrations.plex + ? { + ...(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 ? { ...(mergedIntegrations.slskd || {}), @@ -1116,6 +1132,151 @@ router.post("/lidarr/apply-community-guide", async (req, res) => { } }); +function getPlexConfig() { + return dbOps.getSettings()?.integrations?.plex || {}; +} + +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, + }); + } +}); + +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, + }); + } +}); + +router.post("/plex/resources", async (req, res) => { + try { + const { PlexClient } = await import("../services/plex.js"); + 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, total } = await PlexClient.getResources(token, clientId); + res.json({ servers, total }); + } catch (error) { + 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: + status === 401 + ? "Plex rejected the token (401). Reconnect your Plex account." + : error.message, + }); + } +}); + +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, + }); + } +}); + +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..20b403ae --- /dev/null +++ b/backend/services/plex.js @@ -0,0 +1,331 @@ +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 + +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); + } + + 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; + } + + static generateClientId() { + return crypto.randomUUID(); + } + + 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 }; + } + + 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()}`; + } + + 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; + } + + 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; + } + } + + 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 }), + }); + // 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 }; + } + + 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; + } + } + + 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 || []; + } + + async ensureWeeklyFlowLibrary(libraryPath) { + if (!this.isConfigured()) return null; + const name = "Aurral Flow"; + const findExisting = (libs) => + libs.find( + (lib) => + lib.title === name || + (lib.Location || []).some((loc) => loc.path === libraryPath), + ); + + const existing = findExisting(await this.getLibraries()); + if (existing) { + // The library already exists. Reconcile its folder(s) to the desired + // path so changing the downloads-path setting actually takes effect + // (Plex keeps the original location otherwise). + const currentLocations = (existing.Location || []) + .map((loc) => loc.path) + .filter(Boolean); + const alreadyCorrect = + currentLocations.length === 1 && currentLocations[0] === libraryPath; + if (!alreadyCorrect) { + try { + await this.setLibraryLocations(existing.key, [libraryPath]); + return findExisting(await this.getLibraries()) || existing; + } catch (err) { + console.warn( + "[Plex] Could not update Aurral library location:", + err?.response?.data || err.message, + ); + } + } + 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, + type: MUSIC_SECTION_TYPE, + agent: MUSIC_AGENT, + scanner: MUSIC_SCANNER, + language: "en-US", + location: libraryPath, + }, + }); + } catch (err) { + 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 created; + } + + 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; + } + } + + /** + * Replace a library section's folder locations. Plex expects repeated + * `location=` query params (no array brackets), so the query is built by + * hand. The Plex server must be able to browse each path. + */ + async setLibraryLocations(sectionId, locations) { + const qs = new URLSearchParams(); + qs.set("agent", MUSIC_AGENT); + for (const loc of locations) qs.append("location", loc); + return this.request(`/library/sections/${sectionId}?${qs.toString()}`, { + method: "PUT", + }); + } + + 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 || []; + } + + async getPlaylistItems(playlistRatingKey) { + const data = await this.request(`/playlists/${playlistRatingKey}/items`); + const items = data?.MediaContainer?.Metadata || []; + return items.map((i) => i.ratingKey).filter(Boolean); + } + + _metadataUri(machineId, ratingKeys) { + const keys = (Array.isArray(ratingKeys) ? ratingKeys : [ratingKeys]).join( + ",", + ); + return `server://${machineId}/com.plexapp.plugins.library/library/metadata/${keys}`; + } + + 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) { + // Skip delete+recreate when the track set already matches (order-insensitive). + const current = await this.getPlaylistItems(existing.ratingKey); + const desiredSet = new Set((ratingKeys || []).map(String)); + const currentSet = new Set(current.map(String)); + const unchanged = + desiredSet.size === currentSet.size && + [...desiredSet].every((k) => currentSet.has(k)); + if (unchanged) return existing; + 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..7344ef11 100644 --- a/backend/services/weeklyFlowPlaylistManager.js +++ b/backend/services/weeklyFlowPlaylistManager.js @@ -1,7 +1,9 @@ import path from "path"; import fs from "fs/promises"; +import crypto from "crypto"; 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 +19,12 @@ 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; + // playlist title -> fingerprint last pushed, so unchanged playlists skip + // the getPlaylistItems round-trip. + this._plexSyncHashes = new Map(); this._ensureInFlight = null; this.updateConfig(triggerEnsureOnInit); } @@ -48,6 +56,32 @@ export class WeeklyFlowPlaylistManager { this.navidromeClient = null; } + const plexConfig = settings.integrations?.plex || {}; + const nextPlexKey = JSON.stringify({ + 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( + plexConfig.url, + plexConfig.token, + plexConfig.clientId, + ); + this._plexSectionId = null; + this._plexSyncHashes.clear(); + } + } else { + this.plexClient = null; + this._plexSectionId = null; + this._plexSyncHashes.clear(); + } + if (triggerEnsurePlaylists) { this.ensureSmartPlaylists().catch((err) => console.warn( @@ -259,11 +293,205 @@ 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, + ); + } + } + } + + // The location must be the path the Plex server uses, which differs from + // Aurral's host path when Plex runs in its own container. + _getPlexLibraryPath() { + const override = String(this._plexDownloadsPath || "").trim(); + if (override) { + const base = override.replace(/\\/g, "/").replace(/\/+$/, ""); + return `${base}/aurral-weekly-flow`; + } + return this._getWeeklyFlowLibraryHostPath(); + } + + _hashKeys(ratingKeys) { + const sorted = (ratingKeys || []).map(String).sort(); + return crypto.createHash("sha1").update(sorted.join(",")).digest("hex"); + } + + async _ensurePlexSectionId() { + if (this._plexSectionId != null) return this._plexSectionId; + 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; + return id; + } + + // Plex has no equivalent of Navidrome's .nsp smart playlists, so we build + // regular playlists from indexed tracks, grouped by their weekly-flow subfolder. + 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, + ); + } + } + } + }; + + const buildIfChanged = async (desired, ratingKeys) => { + const hash = this._hashKeys(ratingKeys); + if (this._plexSyncHashes.get(desired) === hash) return; + await this.plexClient.createPlaylist(desired, ratingKeys, true); + this._plexSyncHashes.set(desired, hash); + }; + + // Plex uses the bare flow name; remove any old "[A]"/"[AS]" prefixed names. + for (const flow of flows) { + const desired = String(flow.name || "").trim(); + const { current, legacy } = this._getFlowPlaylistNames(flow.name); + const stale = [current, ...legacy].filter((name) => name !== desired); + if (flow.enabled) { + const ratingKeys = ratingKeysFor(flow.id); + if (ratingKeys.length) { + await buildIfChanged(desired, ratingKeys); + } else { + await deletePlexPlaylistsByNames([desired]); + this._plexSyncHashes.delete(desired); + } + await deletePlexPlaylistsByNames(stale); + } else { + await deletePlexPlaylistsByNames([desired, ...stale]); + this._plexSyncHashes.delete(desired); + } + } + + for (const playlist of sharedPlaylists) { + const desired = String(playlist.name || "").trim(); + const { current, legacy } = this._getSharedPlaylistNames(playlist.name); + const stale = [current, ...legacy].filter((name) => name !== desired); + const ratingKeys = ratingKeysFor(playlist.id); + if (ratingKeys.length) { + await buildIfChanged(desired, ratingKeys); + } else { + await deletePlexPlaylistsByNames([desired]); + this._plexSyncHashes.delete(desired); + } + await deletePlexPlaylistsByNames(stale); + } + } + + // Returns quickly rather than blocking: Plex's music scan (with online + // metadata matching) can take minutes, so a background catch-up rebuilds the + // playlists as tracks get indexed. + async syncPlexNow() { + 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); + + // Manual sync is authoritative: drop cached fingerprints so we reconcile + // against Plex's real state (catches manual edits made in Plex). + this._plexSyncHashes.clear(); + + 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(); + + this._schedulePlexCatchup(sectionId); + + const managedNames = new Set( + [ + ...flows.map((f) => String(f.name || "").trim()), + ...sharedPlaylists.map((p) => String(p.name || "").trim()), + ].filter(Boolean), + ); + + return { + configured: true, + sectionId, + indexedTracks: tracks.length, + scanInProgress: tracks.length === 0, + playlists: playlists + .filter((p) => managedNames.has(p.title)) + .map((p) => ({ title: p.title, count: p.leafCount ?? null })), + }; + } + + // Rebuilds playlists over the next few minutes as a 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() { - 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..f3d45ada 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 { @@ -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,167 @@ export function SettingsIntegrationsTab({ })); }; + const updatePlex = (patch) => + updateSettings({ + ...settings, + integrations: { + ...settings.integrations, + plex: { ...(settings.integrations?.plex || {}), ...patch }, + }, + }); + + const loadPlexServers = async (token) => { + const { servers } = await getPlexResources(token); + const list = Array.isArray(servers) ? servers : []; + setPlexServers(list); + return list; + }; + + // Auto-load servers when we have a token so the dropdown is always populated. + 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); + try { + // No forwardUrl: we poll for the token and close the popup ourselves. + const { pinId, code, authUrl, clientId } = await startPlexAuth(); + const popup = window.open( + authUrl, + "plex-auth", + "width=600,height=700" + ); + 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; + } + const servers = await loadPlexServers(token); + // 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) { + 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; + showError(`Plex sign-in failed: ${errorMsg}`); + } finally { + setPlexConnecting(false); + } + }; + + const pickBestConnection = (server) => { + const conns = server.connections || []; + 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; + } + updatePlex({ 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.scanInProgress) { + showInfo( + "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( + `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 +1260,200 @@ export function SettingsIntegrationsTab({ )} +