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({
)}
+
+
+
+ toggleSection("plex")}
+ className="flex items-center gap-2 text-left"
+ style={{ color: "#fff" }}
+ aria-expanded={!collapsedSections.plex}
+ >
+
+ Plex
+
+
+
+ {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.
+
+
+
+ {plexConnecting ? (
+
+
+ Waiting for Plex…
+
+ ) : settings.integrations?.plex?.token ? (
+ "Reconnect Plex account"
+ ) : (
+ "Connect Plex account"
+ )}
+
+ {settings.integrations?.plex?.token && (
+
+
+ Signed in
+
+ )}
+
+
+ {plexServers.length > 0 && (
+
+
+ Plex server
+
+ {
+ const server = plexServers.find(
+ (s) => s.clientIdentifier === e.target.value
+ );
+ if (server) handleSelectPlexServer(server);
+ }}
+ >
+
+ Select a server…
+
+ {plexServers.map((s) => (
+
+ {s.name}
+ {s.owned ? "" : " (shared)"}
+
+ ))}
+
+
+ )}
+
+
+
+ Server URL
+
+
updatePlex({ url: e.target.value })}
+ />
+
+ Auto-filled when you select a server, or enter it manually.
+
+
+
+
+
+ {testingPlex ? (
+
+
+ Testing…
+
+ ) : (
+ "Test connection"
+ )}
+
+
+ {syncingPlex ? (
+
+
+ Syncing…
+
+ ) : (
+ "Sync to Plex now"
+ )}
+
+
+
+ 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({
+
+
+ Plex downloads path (optional)
+
+
+ updatePlex({ downloadsPath: e.target.value })}
+ />
+
+
+
+ {browseOpen ? "Close" : "Browse"}
+
+
+
+
+ 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}
+
+
+ Use this folder
+
+
+
+ {browseLoading ? (
+
+
+ Loading…
+
+ ) : (
+
+ {browseState.parent && (
+
+ loadBrowse(browseState.parent)}
+ >
+
+ ..
+
+
+ )}
+ {browseState.directories.length === 0 && (
+
+ No subfolders here.
+
+ )}
+ {browseState.directories.map((dir) => (
+
+ loadBrowse(dir.path)}
+ >
+
+ {dir.name}
+
+
+ ))}
+
+ )}
+
+
+ )}
+
+
{
token: "",
clientId: "",
machineIdentifier: "",
+ downloadsPath: "",
...(savedSettings.integrations?.plex || {}),
},
lastfm: {
diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js
index 3588a1f4..ddaeef66 100644
--- a/frontend/src/utils/api.js
+++ b/frontend/src/utils/api.js
@@ -269,6 +269,13 @@ 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 c429055333caf4a1fbe5a2ef679267880f3f5c0e Mon Sep 17 00:00:00 2001
From: dylfrancis
Date: Tue, 2 Jun 2026 20:27:07 -0400
Subject: [PATCH 6/9] fix(plex): playlist naming and recreation
---
backend/routes/settings.js | 38 ----
backend/services/plex.js | 54 +++++-
backend/services/weeklyFlowPlaylistManager.js | 61 ++++--
.../components/SettingsIntegrationsTab.jsx | 175 +++---------------
frontend/src/utils/api.js | 7 -
5 files changed, 133 insertions(+), 202 deletions(-)
diff --git a/backend/routes/settings.js b/backend/routes/settings.js
index a2084794..e92c646a 100644
--- a/backend/routes/settings.js
+++ b/backend/routes/settings.js
@@ -1122,44 +1122,6 @@ 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/plex.js b/backend/services/plex.js
index 6898fad3..efad8a18 100644
--- a/backend/services/plex.js
+++ b/backend/services/plex.js
@@ -178,7 +178,28 @@ export class PlexClient {
);
const existing = findExisting(await this.getLibraries());
- if (existing) return existing;
+ 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
@@ -224,6 +245,20 @@ export class PlexClient {
}
}
+ /**
+ * 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",
+ });
+ }
+
/**
* Fetch all tracks in a library section with their on-disk file paths.
* Returns [{ ratingKey, title, artist, file }].
@@ -248,6 +283,13 @@ export class PlexClient {
return data?.MediaContainer?.Metadata || [];
}
+ /** Return the track ratingKeys currently in a playlist. */
+ 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(
",",
@@ -268,6 +310,16 @@ export class PlexClient {
);
if (existing && replace) {
+ // Skip the delete+recreate churn when the playlist already holds exactly
+ // the desired tracks (order-insensitive) — keeps the playlist's identity
+ // and history intact.
+ 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) {
diff --git a/backend/services/weeklyFlowPlaylistManager.js b/backend/services/weeklyFlowPlaylistManager.js
index bf0184a8..396725ef 100644
--- a/backend/services/weeklyFlowPlaylistManager.js
+++ b/backend/services/weeklyFlowPlaylistManager.js
@@ -1,5 +1,6 @@
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";
@@ -21,6 +22,9 @@ export class WeeklyFlowPlaylistManager {
this.plexClient = null;
this._plexConfigKey = "";
this._plexSectionId = null;
+ // playlist title -> fingerprint of the track set we last pushed to Plex,
+ // so unchanged playlists skip the getPlaylistItems round-trip.
+ this._plexSyncHashes = new Map();
this._ensureInFlight = null;
this.updateConfig(triggerEnsureOnInit);
}
@@ -70,10 +74,12 @@ export class WeeklyFlowPlaylistManager {
plexConfig.clientId,
);
this._plexSectionId = null;
+ this._plexSyncHashes.clear();
}
} else {
this.plexClient = null;
this._plexSectionId = null;
+ this._plexSyncHashes.clear();
}
if (triggerEnsurePlaylists) {
@@ -300,7 +306,7 @@ export class WeeklyFlowPlaylistManager {
}
}
- // The library location must be the path the *Plex server* uses to reach the
+ // 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.
@@ -313,6 +319,11 @@ export class WeeklyFlowPlaylistManager {
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();
@@ -360,30 +371,49 @@ export class WeeklyFlowPlaylistManager {
}
};
+ // Build/refresh a playlist only when its desired track set changed since
+ // the last sync — skips the getPlaylistItems round-trip on no-op runs.
+ 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 playlists use the flow/playlist name directly (no "[A]"/"[AS]"
+ // prefix). Any previously-created prefixed names are treated as stale and
+ // removed so renames are clean.
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 this.plexClient.createPlaylist(current, ratingKeys, true);
+ await buildIfChanged(desired, ratingKeys);
} else {
- await deletePlexPlaylistsByNames([current]);
+ await deletePlexPlaylistsByNames([desired]);
+ this._plexSyncHashes.delete(desired);
}
- await deletePlexPlaylistsByNames(legacy);
+ await deletePlexPlaylistsByNames(stale);
} else {
- await deletePlexPlaylistsByNames([current, ...legacy]);
+ 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 this.plexClient.createPlaylist(current, ratingKeys, true);
+ await buildIfChanged(desired, ratingKeys);
} else {
- await deletePlexPlaylistsByNames([current]);
+ await deletePlexPlaylistsByNames([desired]);
+ this._plexSyncHashes.delete(desired);
}
- await deletePlexPlaylistsByNames(legacy);
+ await deletePlexPlaylistsByNames(stale);
}
}
@@ -404,6 +434,10 @@ export class WeeklyFlowPlaylistManager {
}
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);
@@ -415,15 +449,20 @@ export class WeeklyFlowPlaylistManager {
// indexing, without the user needing to click sync again.
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) => p.title?.startsWith("[A] ") || p.title?.startsWith("[AS] "),
- )
+ .filter((p) => managedNames.has(p.title))
.map((p) => ({ title: p.title, count: p.leafCount ?? null })),
};
}
diff --git a/frontend/src/pages/Settings/components/SettingsIntegrationsTab.jsx b/frontend/src/pages/Settings/components/SettingsIntegrationsTab.jsx
index 2ddbf1c7..cfae5bbc 100644
--- a/frontend/src/pages/Settings/components/SettingsIntegrationsTab.jsx
+++ b/frontend/src/pages/Settings/components/SettingsIntegrationsTab.jsx
@@ -1,11 +1,5 @@
import { useState } from "react";
-import {
- CheckCircle,
- ChevronDown,
- RefreshCw,
- Folder,
- CornerLeftUp,
-} from "lucide-react";
+import { CheckCircle, ChevronDown, RefreshCw } from "lucide-react";
import FlipSaveButton from "../../../components/FlipSaveButton";
import {
getLidarrMetadataProfiles,
@@ -17,7 +11,6 @@ import {
getPlexResources,
testPlexConnection,
syncPlexNow,
- browsePaths,
} from "../../../utils/api";
export function SettingsIntegrationsTab({
@@ -59,13 +52,6 @@ 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
: [];
@@ -105,6 +91,13 @@ export function SettingsIntegrationsTab({
}
};
+ const handleChoosePlexServer = async () => {
+ const servers = await loadPlexServers(settings.integrations?.plex?.token);
+ if (!servers || servers.length === 0) {
+ showError("No Plex servers found for this account.");
+ }
+ };
+
const handleConnectPlex = async () => {
setPlexConnecting(true);
try {
@@ -231,34 +224,6 @@ 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;
@@ -1350,10 +1315,19 @@ export function SettingsIntegrationsTab({
)}
{settings.integrations?.plex?.token && (
-
-
- Signed in
-
+ <>
+
+ Choose server
+
+
+
+ Signed in
+
+ >
)}
@@ -1415,110 +1389,21 @@ export function SettingsIntegrationsTab({
>
Plex downloads path (optional)
-
- updatePlex({ downloadsPath: e.target.value })}
- />
-
-
-
- {browseOpen ? "Close" : "Browse"}
-
-
-
+ 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}
-
-
- Use this folder
-
-
-
- {browseLoading ? (
-
-
- Loading…
-
- ) : (
-
- {browseState.parent && (
-
- loadBrowse(browseState.parent)}
- >
-
- ..
-
-
- )}
- {browseState.directories.length === 0 && (
-
- No subfolders here.
-
- )}
- {browseState.directories.map((dir) => (
-
- loadBrowse(dir.path)}
- >
-
- {dir.name}
-
-
- ))}
-
- )}
-
-
- )}
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 && (
- <>
-
- Choose server
-
-
-
- Signed in
-
- >
+
+
+ Signed in
+
)}
- {plexServers.length > 0 && (
+ {settings.integrations?.plex?.token && (
- Select a server…
+ {plexServers.length ? "Select a server…" : "Loading servers…"}
{plexServers.map((s) => (
From 89c379c1a723cebbe5cae9961c94319f81b0c0d2 Mon Sep 17 00:00:00 2001
From: dylfrancis
Date: Wed, 3 Jun 2026 14:47:08 -0400
Subject: [PATCH 9/9] chore(plex): cleanup
---
backend/routes/settings.js | 7 ---
backend/services/plex.js | 43 +-----------------
backend/services/weeklyFlowPlaylistManager.js | 44 +++++--------------
.../components/SettingsIntegrationsTab.jsx | 4 +-
4 files changed, 14 insertions(+), 84 deletions(-)
diff --git a/backend/routes/settings.js b/backend/routes/settings.js
index 0d9e95e6..f75b26c4 100644
--- a/backend/routes/settings.js
+++ b/backend/routes/settings.js
@@ -1132,13 +1132,10 @@ 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");
@@ -1172,7 +1169,6 @@ router.post("/plex/auth/pin", async (req, res) => {
}
});
-// 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");
@@ -1196,7 +1192,6 @@ router.post("/plex/auth/check", async (req, res) => {
}
});
-// List Plex servers available to the authenticated account.
router.post("/plex/resources", async (req, res) => {
try {
const { PlexClient } = await import("../services/plex.js");
@@ -1227,7 +1222,6 @@ router.post("/plex/resources", async (req, res) => {
}
});
-// 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");
@@ -1259,7 +1253,6 @@ router.post("/plex/test", async (req, res) => {
}
});
-// Build the Aurral library + flow playlists on the configured Plex server now.
router.post("/plex/sync", async (req, res) => {
try {
const plex = getPlexConfig();
diff --git a/backend/services/plex.js b/backend/services/plex.js
index 3b52568d..20b403ae 100644
--- a/backend/services/plex.js
+++ b/backend/services/plex.js
@@ -10,13 +10,6 @@ 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;
@@ -29,8 +22,6 @@ export class PlexClient {
return !!(this.url && this.token);
}
- // --- plex.tv OAuth (PIN) flow -------------------------------------------
-
static plexHeaders(clientId, { token } = {}) {
const headers = {
Accept: "application/json",
@@ -41,15 +32,10 @@ export class PlexClient {
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`,
@@ -62,7 +48,6 @@ export class PlexClient {
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,
@@ -73,9 +58,6 @@ export class PlexClient {
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 },
@@ -84,7 +66,6 @@ export class PlexClient {
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`, {
@@ -96,10 +77,6 @@ export class PlexClient {
}
}
- /**
- * 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 },
@@ -135,8 +112,6 @@ export class PlexClient {
return { servers, total: list.length };
}
- // --- Plex Media Server requests -----------------------------------------
-
async request(path, { params = {}, method = "GET", data = null } = {}) {
if (!this.isConfigured()) throw new Error("Plex not configured");
try {
@@ -158,7 +133,6 @@ export class PlexClient {
}
}
- /** Test connectivity + capture the server's machineIdentifier. */
async ping() {
const data = await this.request("/identity");
const mc = data?.MediaContainer || {};
@@ -177,10 +151,6 @@ export class PlexClient {
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";
@@ -273,10 +243,6 @@ export class PlexClient {
});
}
- /**
- * 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 },
@@ -297,7 +263,6 @@ export class PlexClient {
return data?.MediaContainer?.Metadata || [];
}
- /** Return the track ratingKeys currently in a playlist. */
async getPlaylistItems(playlistRatingKey) {
const data = await this.request(`/playlists/${playlistRatingKey}/items`);
const items = data?.MediaContainer?.Metadata || [];
@@ -311,10 +276,6 @@ export class PlexClient {
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");
@@ -324,9 +285,7 @@ export class PlexClient {
);
if (existing && replace) {
- // Skip the delete+recreate churn when the playlist already holds exactly
- // the desired tracks (order-insensitive) — keeps the playlist's identity
- // and history intact.
+ // 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));
diff --git a/backend/services/weeklyFlowPlaylistManager.js b/backend/services/weeklyFlowPlaylistManager.js
index 396725ef..7344ef11 100644
--- a/backend/services/weeklyFlowPlaylistManager.js
+++ b/backend/services/weeklyFlowPlaylistManager.js
@@ -22,8 +22,8 @@ export class WeeklyFlowPlaylistManager {
this.plexClient = null;
this._plexConfigKey = "";
this._plexSectionId = null;
- // playlist title -> fingerprint of the track set we last pushed to Plex,
- // so unchanged playlists skip the getPlaylistItems round-trip.
+ // playlist title -> fingerprint last pushed, so unchanged playlists skip
+ // the getPlaylistItems round-trip.
this._plexSyncHashes = new Map();
this._ensureInFlight = null;
this.updateConfig(triggerEnsureOnInit);
@@ -306,10 +306,8 @@ 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.
+ // 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) {
@@ -334,13 +332,8 @@ export class WeeklyFlowPlaylistManager {
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.
- */
+ // 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;
@@ -371,8 +364,6 @@ export class WeeklyFlowPlaylistManager {
}
};
- // Build/refresh a playlist only when its desired track set changed since
- // the last sync — skips the getPlaylistItems round-trip on no-op runs.
const buildIfChanged = async (desired, ratingKeys) => {
const hash = this._hashKeys(ratingKeys);
if (this._plexSyncHashes.get(desired) === hash) return;
@@ -380,9 +371,7 @@ export class WeeklyFlowPlaylistManager {
this._plexSyncHashes.set(desired, hash);
};
- // Plex playlists use the flow/playlist name directly (no "[A]"/"[AS]"
- // prefix). Any previously-created prefixed names are treated as stale and
- // removed so renames are clean.
+ // 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);
@@ -417,13 +406,9 @@ export class WeeklyFlowPlaylistManager {
}
}
- /**
- * 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.
- */
+ // 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 };
@@ -445,8 +430,6 @@ export class WeeklyFlowPlaylistManager {
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);
const managedNames = new Set(
@@ -467,11 +450,8 @@ 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.
- */
+ // 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;
diff --git a/frontend/src/pages/Settings/components/SettingsIntegrationsTab.jsx b/frontend/src/pages/Settings/components/SettingsIntegrationsTab.jsx
index 4b77f0d8..f3d45ada 100644
--- a/frontend/src/pages/Settings/components/SettingsIntegrationsTab.jsx
+++ b/frontend/src/pages/Settings/components/SettingsIntegrationsTab.jsx
@@ -87,8 +87,7 @@ export function SettingsIntegrationsTab({
return list;
};
- // 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.
+ // Auto-load servers when we have a token so the dropdown is always populated.
const plexToken = settings.integrations?.plex?.token;
useEffect(() => {
if (!plexToken) {
@@ -116,7 +115,6 @@ export function SettingsIntegrationsTab({
"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) {