From e2b3b059596445f5933e964053751bc4d1c1209d Mon Sep 17 00:00:00 2001 From: Juan J Chong Date: Mon, 22 Dec 2025 00:02:08 -0800 Subject: [PATCH 1/6] feat: add multi-Plex server support - Allow connecting multiple Plex servers - Media shown as available if on ANY server - Users granted access if they have access to ANY server - Card-based UI following Radarr/Sonarr pattern - Per-server library sync and toggle - Database migration for per-server rating keys --- docs/multi-plex-server-implementation.md | 467 +++++++++++++++ overseerr-api.yml | 203 ++++++- server/api/plexapi.ts | 30 +- server/api/plextv.ts | 26 +- server/entity/Media.ts | 19 +- server/entity/MediaPlexServer.ts | 35 ++ server/index.ts | 43 +- server/lib/availabilitySync.ts | 94 ++-- server/lib/scanners/plex/index.ts | 149 +++-- server/lib/settings.ts | 49 +- .../1750000000000-AddMediaPlexServer.ts | 41 ++ server/routes/settings/index.ts | 164 +----- server/routes/settings/plex.ts | 442 +++++++++++++++ src/components/Common/Button/index.tsx | 1 + src/components/Common/Modal/index.tsx | 249 ++++---- src/components/Settings/LibraryItem.tsx | 4 +- src/components/Settings/PlexServerModal.tsx | 450 +++++++++++++++ src/components/Settings/SettingsPlex.tsx | 531 ++---------------- .../Settings/SettingsPlexServers.tsx | 329 +++++++++++ src/components/Setup/index.tsx | 26 +- 20 files changed, 2408 insertions(+), 944 deletions(-) create mode 100644 docs/multi-plex-server-implementation.md create mode 100644 server/entity/MediaPlexServer.ts create mode 100644 server/migration/1750000000000-AddMediaPlexServer.ts create mode 100644 server/routes/settings/plex.ts create mode 100644 src/components/Settings/PlexServerModal.tsx create mode 100644 src/components/Settings/SettingsPlexServers.tsx diff --git a/docs/multi-plex-server-implementation.md b/docs/multi-plex-server-implementation.md new file mode 100644 index 0000000000..a54a0cd77c --- /dev/null +++ b/docs/multi-plex-server-implementation.md @@ -0,0 +1,467 @@ +# Multi-Plex Server Support Implementation + +This document describes the implementation of multi-Plex server support in Overseerr. The changes allow users to connect multiple Plex servers, with media availability checked across all servers and user access granted if they have access to ANY configured server. + +## Architecture Overview + +### Design Decisions + +1. **Access Model**: Users are granted access if they have access to ANY configured Plex server (not all) +2. **Availability Tracking**: Media is shown as "available" if it exists on ANY server (merged availability) +3. **Configuration Pattern**: Follows the existing Radarr/Sonarr multi-instance pattern + +### Data Flow + +``` +┌─────────────────────┐ ┌──────────────────────┐ +│ Settings Layer │────▶│ PlexSettings[] │ +│ (settings.json) │ │ (id, name, ip, etc) │ +└─────────────────────┘ └──────────────────────┘ + │ + ┌─────────────────┼─────────────────┐ + ▼ ▼ ▼ + ┌───────────┐ ┌───────────┐ ┌───────────┐ + │ PlexAPI │ │ PlexAPI │ │ PlexAPI │ + │ Server 1 │ │ Server 2 │ │ Server N │ + └───────────┘ └───────────┘ └───────────┘ + │ │ │ + └─────────────────┼─────────────────┘ + ▼ + ┌─────────────────┐ + │ PlexScanner / │ + │AvailabilitySync │ + └─────────────────┘ + │ + ▼ + ┌─────────────────┐ + │ Media Entity │ + │ (merged status) │ + └─────────────────┘ +``` + +## Files Changed + +### Backend + +| File | Change Type | Description | +| ------------------------------------------------------ | ----------- | ----------------------------------------------------------------- | +| `server/lib/settings.ts` | Modified | Changed `plex` from single object to array, added migration logic | +| `server/entity/MediaPlexServer.ts` | New | Entity for per-server rating key tracking | +| `server/entity/Media.ts` | Modified | Added relation to MediaPlexServer, updated setPlexUrls() | +| `server/migration/1750000000000-AddMediaPlexServer.ts` | New | Database migration for new table | +| `server/api/plexapi.ts` | Modified | Made plexSettings required, updated syncLibraries() | +| `server/api/plextv.ts` | Modified | Updated checkUserAccess() for multi-server | +| `server/routes/settings/plex.ts` | New | CRUD routes for Plex servers | +| `server/routes/settings/index.ts` | Modified | Mounted new plex routes, removed old single-server routes | +| `server/lib/scanners/plex/index.ts` | Modified | Iterates all servers, tracks per-server status | +| `server/lib/availabilitySync.ts` | Modified | Checks all servers for availability | + +### Frontend + +| File | Change Type | Description | +| ------------------------------------------------- | ----------- | ------------------------------------------------ | +| `src/components/Settings/SettingsPlexServers.tsx` | New | Server list component with CRUD UI | +| `src/components/Settings/PlexServerModal.tsx` | New | Modal for adding/editing servers | +| `src/components/Settings/SettingsPlex.tsx` | Modified | Uses new components, updated scan status display | + +## Detailed Implementation + +### 1. Settings Model Changes + +**File: `server/lib/settings.ts`** + +The `PlexSettings` interface now includes an `id` field: + +```typescript +export interface PlexSettings { + id: number; // NEW - unique identifier for each server + name: string; + machineId?: string; + ip: string; + port: number; + useSsl?: boolean; + libraries: Library[]; + webAppUrl?: string; +} +``` + +The `AllSettings` interface changed from single object to array: + +```typescript +interface AllSettings { + // ... other fields + plex: PlexSettings[]; // Changed from PlexSettings + // ... +} +``` + +**Migration Logic**: On startup, if `settings.plex` is an object (old format), it's automatically converted to an array with `id: 0`. + +### 2. MediaPlexServer Entity + +**File: `server/entity/MediaPlexServer.ts`** + +New entity to track which server has which content: + +```typescript +@Entity() +class MediaPlexServer { + @PrimaryGeneratedColumn() + id: number; + + @ManyToOne(() => Media, (media) => media.plexServers, { onDelete: 'CASCADE' }) + media: Media; + + @Column() + @Index() + plexServerId: number; // References PlexSettings.id + + @Column({ nullable: true, type: 'varchar' }) + ratingKey?: string | null; + + @Column({ nullable: true, type: 'varchar' }) + ratingKey4k?: string | null; +} +``` + +### 3. PlexAPI Changes + +**File: `server/api/plexapi.ts`** + +The constructor now **requires** explicit server configuration: + +```typescript +constructor({ + plexToken, + plexSettings, // NOW REQUIRED + timeout, +}: { + plexToken?: string; + plexSettings: PlexSettings; // Not optional anymore + timeout?: number; +}) +``` + +The `syncLibraries()` method now returns libraries instead of modifying global state: + +```typescript +public async syncLibraries(): Promise { + // Returns array of libraries for the caller to handle +} +``` + +### 4. Multi-Server Authentication + +**File: `server/api/plextv.ts`** + +The `checkUserAccess()` method checks if user has access to ANY configured server: + +```typescript +public async checkUserAccess(userId: number): Promise { + const plexServers = settings.plex; + + for (const plexServer of plexServers) { + const hasAccess = user.Server?.some( + (server) => server.$.machineIdentifier === plexServer.machineId + ); + if (hasAccess) return true; // Access to ANY server = allowed + } + return false; +} +``` + +### 5. Plex Server CRUD Routes + +**File: `server/routes/settings/plex.ts`** + +New REST API endpoints: + +| Method | Endpoint | Description | +| ------ | ------------------------------------------------------ | -------------------- | +| GET | `/api/v1/settings/plex` | List all servers | +| POST | `/api/v1/settings/plex` | Add new server | +| POST | `/api/v1/settings/plex/test` | Test connection | +| GET | `/api/v1/settings/plex/{plexId}` | Get single server | +| PUT | `/api/v1/settings/plex/{plexId}` | Update server | +| DELETE | `/api/v1/settings/plex/{plexId}` | Delete server | +| GET | `/api/v1/settings/plex/{plexId}/libraries` | Get server libraries | +| POST | `/api/v1/settings/plex/{plexId}/libraries/sync` | Sync libraries | +| PUT | `/api/v1/settings/plex/{plexId}/libraries/{libraryId}` | Toggle library | +| GET | `/api/v1/settings/plex/sync` | Get scan status | +| POST | `/api/v1/settings/plex/sync` | Start/cancel scan | +| GET | `/api/v1/settings/plex/devices/servers` | Fetch from plex.tv | + +### 6. PlexScanner Updates + +**File: `server/lib/scanners/plex/index.ts`** + +The scanner now iterates over all configured servers: + +```typescript +public async run(): Promise { + const plexServers = settings.plex; + + for (const plexServer of plexServers) { + this.currentServer = plexServer; + this.plexClient = new PlexAPI({ + plexToken: admin.plexToken, + plexSettings: plexServer, + }); + + this.libraries = plexServer.libraries.filter((lib) => lib.enabled); + + // Scan each library on this server + for (const library of this.libraries) { + // ... scanning logic + } + } +} +``` + +Status now includes current server: + +```typescript +type SyncStatus = StatusBase & { + currentLibrary: Library; + libraries: Library[]; + currentServer?: PlexSettings; // NEW +}; +``` + +### 7. AvailabilitySync Updates + +**File: `server/lib/availabilitySync.ts`** + +Initializes clients for all servers: + +```typescript +async run() { + for (const plexServer of this.plexServers) { + this.plexClients.push( + new PlexAPI({ + plexToken: admin.plexToken, + plexSettings: plexServer, + }) + ); + } +} +``` + +Checks all servers for availability: + +```typescript +private async mediaExistsInPlex(media: Media, is4k: boolean) { + for (const plexClient of this.plexClients) { + // Check each server + if (plexMedia) { + existsInPlex = true; + break; // Found on this server, no need to check others + } + } +} +``` + +### 8. Frontend Components + +**SettingsPlexServers Component** + +- Displays all servers as cards +- Each card shows: name, address, SSL badge, enabled libraries count +- Expandable library list with toggle switches +- Sync button per server +- Edit/Delete buttons + +**PlexServerModal Component** + +- Server preset dropdown (fetches available servers from plex.tv) +- Manual hostname/port/SSL configuration +- Server name field +- Web App URL field +- Test connection button + +## Database Migration + +The migration (`1750000000000-AddMediaPlexServer.ts`): + +1. Creates `media_plex_server` table +2. Creates index on `plexServerId` +3. Migrates existing `ratingKey`/`ratingKey4k` from Media table with `plexServerId = 0` + +```sql +CREATE TABLE "media_plex_server" ( + "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, + "plexServerId" integer NOT NULL, + "ratingKey" varchar, + "ratingKey4k" varchar, + "mediaId" integer, + FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE +); + +CREATE INDEX "IDX_media_plex_server_plexServerId" ON "media_plex_server" ("plexServerId"); +``` + +## Backwards Compatibility + +1. **Settings Migration**: Old single-server `plex` object is automatically converted to array on startup +2. **Database Migration**: Existing rating keys are migrated to new table with `plexServerId = 0` +3. **Legacy Columns**: `ratingKey`/`ratingKey4k` columns on Media entity are preserved for backwards compatibility +4. **URL Generation**: Uses first configured server's machineId for Plex URLs + +## Testing Considerations + +1. **Empty State**: Test with no Plex servers configured +2. **Single Server**: Test backwards compatibility with migrated single server +3. **Multiple Servers**: Test with 2+ servers, including overlapping content +4. **User Access**: Test user login with access to different servers +5. **Scanning**: Test full/recent scans across multiple servers +6. **Availability**: Test media showing as available when on any server + +## Critical Issue: Multi-Owner Authentication + +### The Problem + +The original implementation assumed all Plex servers are owned by the same Plex account (the Overseerr admin). This is **incorrect** for the multi-server use case where different servers are owned by different Plex accounts (to bypass the 100-user limit). + +**How Plex Auth Currently Works:** + +``` +1. User logs in via Plex OAuth → gets their authToken +2. Overseerr uses Admin (user id=1) plexToken to call Plex.tv/api/users +3. /api/users returns ONLY users the Admin has shared THEIR servers with +4. User is checked against this list +``` + +**The Flaw:** + +`/api/users` only returns users that the **token owner** has shared access with. If: + +- Server A is owned by Admin A (Overseerr admin, user id=1) +- Server B is owned by Admin B (different Plex account) + +Users shared on Server B will **NOT appear** in Admin A's `/api/users` response, so they cannot log in. + +### Required Changes + +#### 1. Update PlexSettings Interface + +Add `authToken` to each server configuration: + +```typescript +export interface PlexSettings { + id: number; + name: string; + machineId?: string; + ip: string; + port: number; + useSsl?: boolean; + libraries: Library[]; + webAppUrl?: string; + authToken?: string; // NEW: Server owner's Plex token +} +``` + +#### 2. Update Server Configuration UI + +`PlexServerModal.tsx` must: + +1. Prompt for the server owner's Plex token (or allow OAuth login for that specific server) +2. Store the token in `PlexSettings.authToken` +3. Use this token when testing connection and syncing libraries + +#### 3. Update `checkUserAccess` Method + +`server/api/plextv.ts` - check each server using ITS owner's token: + +```typescript +public static async checkUserAccessAnyServer(userId: number): Promise { + const settings = getSettings(); + + for (const plexServer of settings.plex) { + if (!plexServer.authToken || !plexServer.machineId) { + continue; + } + + // Use THIS server's owner token + const plexTv = new PlexTvAPI(plexServer.authToken); + + try { + const usersResponse = await plexTv.getUsers(); + const user = usersResponse.MediaContainer.User.find( + (u) => parseInt(u.$.id) === userId + ); + + if (user) { + const hasAccess = user.Server?.some( + (server) => server.$.machineIdentifier === plexServer.machineId + ); + if (hasAccess) { + return true; + } + } + } catch (e) { + logger.warn(`Failed to check access for server ${plexServer.name}`, { + errorMessage: e.message, + }); + } + } + + return false; +} +``` + +#### 4. Update Auth Routes + +`server/routes/auth.ts` - use new method: + +```typescript +// Replace: +const mainPlexTv = new PlexTvAPI(mainUser.plexToken ?? ''); +if (await mainPlexTv.checkUserAccess(account.id)) + +// With: +if (await PlexTvAPI.checkUserAccessAnyServer(account.id)) +``` + +#### 5. Update PlexAPI Instantiation + +For scanning and availability, use server-specific token: + +```typescript +// In PlexScanner.run(): +for (const plexServer of settings.plex) { + const token = plexServer.authToken || admin.plexToken; // Fallback for migration + this.plexClient = new PlexAPI({ + plexToken: token, + plexSettings: plexServer, + }); +} +``` + +### Migration Path + +For existing single-server installations: + +1. On upgrade, copy `admin.plexToken` to `settings.plex[0].authToken` +2. UI should prompt to configure tokens for additional servers +3. Provide clear documentation that each server needs its owner's token + +### Security Considerations + +1. **Token Storage**: Server tokens are stored in `settings.json` (same as current admin token) +2. **Token Permissions**: Each token only needs access to its own server +3. **Token Refresh**: May need to implement token refresh per-server + +## Known Limitations + +1. **Per-Server Rating Keys**: While `MediaPlexServer` entity exists, the full implementation for per-server deep links is not complete - currently uses first server's rating key +2. **Library Names**: Libraries with the same name on different servers may cause confusion in UI +3. **Scan Performance**: Scanning many servers sequentially may take longer than single server +4. **Per-Server Auth Tokens**: **NOT YET IMPLEMENTED** - Required for multi-owner server support (see above) + +## Future Enhancements + +1. Implement full per-server deep links using `MediaPlexServer` entity +2. Add server health monitoring/status indicators +3. Add per-server scan scheduling +4. Add server priority ordering for deep link generation +5. **Implement per-server auth tokens for multi-owner support** (critical for 100-user limit bypass) diff --git a/overseerr-api.yml b/overseerr-api.yml index c48b6575f7..762edfbe5f 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -153,14 +153,15 @@ components: PlexSettings: type: object properties: + id: + type: number + example: 0 name: type: string example: 'Main Server' - readOnly: true machineId: type: string example: '1234123412341234' - readOnly: true ip: type: string example: '127.0.0.1' @@ -172,7 +173,6 @@ components: nullable: true libraries: type: array - readOnly: true items: $ref: '#/components/schemas/PlexLibrary' webAppUrl: @@ -180,8 +180,6 @@ components: nullable: true example: 'https://app.plex.tv/desktop' required: - - name - - machineId - ip - port PlexConnection: @@ -1967,6 +1965,201 @@ paths: application/json: schema: $ref: '#/components/schemas/PlexSettings' + /settings/plex/test: + post: + summary: Test Plex server connection + description: Tests the connection to a Plex server with the provided settings. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PlexSettings' + responses: + '200': + description: Connection successful + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + example: true + machineId: + type: string + example: 'abc123' + name: + type: string + example: 'My Plex Server' + '400': + description: Missing Plex token + '500': + description: Connection failed + /settings/plex/{plexId}: + get: + summary: Get a single Plex server + description: Returns a single Plex server configuration. + tags: + - settings + parameters: + - in: path + name: plexId + required: true + schema: + type: number + responses: + '200': + description: Plex server returned + content: + application/json: + schema: + $ref: '#/components/schemas/PlexSettings' + '404': + description: Server not found + put: + summary: Update a Plex server + description: Updates an existing Plex server configuration. + tags: + - settings + parameters: + - in: path + name: plexId + required: true + schema: + type: number + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PlexSettings' + responses: + '200': + description: Plex server updated + content: + application/json: + schema: + $ref: '#/components/schemas/PlexSettings' + '404': + description: Server not found + '500': + description: Failed to connect to server + delete: + summary: Delete a Plex server + description: Removes a Plex server from the configuration. + tags: + - settings + parameters: + - in: path + name: plexId + required: true + schema: + type: number + responses: + '200': + description: Plex server deleted + content: + application/json: + schema: + $ref: '#/components/schemas/PlexSettings' + '404': + description: Server not found + /settings/plex/{plexId}/libraries: + get: + summary: Get libraries for a Plex server + description: Returns the libraries available on a specific Plex server. + tags: + - settings + parameters: + - in: path + name: plexId + required: true + schema: + type: number + responses: + '200': + description: Plex libraries returned + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/PlexLibrary' + '404': + description: Server not found + '500': + description: Failed to fetch libraries + /settings/plex/{plexId}/libraries/sync: + post: + summary: Sync libraries for a Plex server + description: Syncs the library list from a specific Plex server. + tags: + - settings + parameters: + - in: path + name: plexId + required: true + schema: + type: number + requestBody: + required: false + content: + application/json: + schema: + type: object + responses: + '200': + description: Libraries synced + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/PlexLibrary' + '404': + description: Server not found + '500': + description: Failed to sync libraries + /settings/plex/{plexId}/libraries/{libraryId}: + put: + summary: Toggle library enabled state + description: Enables or disables a library on a specific Plex server. If no body is provided, the enabled state is toggled. + tags: + - settings + parameters: + - in: path + name: plexId + required: true + schema: + type: number + - in: path + name: libraryId + required: true + schema: + type: string + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + enabled: + type: boolean + responses: + '200': + description: Library toggled + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/PlexLibrary' + '404': + description: Server or library not found /settings/plex/library: get: summary: Get Plex libraries diff --git a/server/api/plexapi.ts b/server/api/plexapi.ts index f6b8f3cb09..8828f9724b 100644 --- a/server/api/plexapi.ts +++ b/server/api/plexapi.ts @@ -86,6 +86,7 @@ interface PlexMetadataResponse { class PlexAPI { private plexClient: NodePlexAPI; + private plexSettings: PlexSettings; constructor({ plexToken, @@ -93,19 +94,16 @@ class PlexAPI { timeout, }: { plexToken?: string; - plexSettings?: PlexSettings; + plexSettings: PlexSettings; timeout?: number; }) { const settings = getSettings(); - let settingsPlex: PlexSettings | undefined; - plexSettings - ? (settingsPlex = plexSettings) - : (settingsPlex = getSettings().plex); + this.plexSettings = plexSettings; this.plexClient = new NodePlexAPI({ - hostname: settingsPlex.ip, - port: settingsPlex.port, - https: settingsPlex.useSsl, + hostname: plexSettings.ip, + port: plexSettings.port, + https: plexSettings.useSsl, timeout: timeout, token: plexToken, authenticator: { @@ -131,6 +129,10 @@ class PlexAPI { }); } + public getPlexSettings(): PlexSettings { + return this.plexSettings; + } + public async getStatus() { return await this.plexClient.query('/'); } @@ -143,9 +145,7 @@ class PlexAPI { return response.MediaContainer.Directory; } - public async syncLibraries(): Promise { - const settings = getSettings(); - + public async syncLibraries(): Promise { try { const libraries = await this.getLibraries(); @@ -157,7 +157,7 @@ class PlexAPI { // Remove libraries that do not have a metadata agent set (usually personal video libraries) .filter((library) => library.agent !== 'com.plexapp.agents.none') .map((library) => { - const existing = settings.plex.libraries.find( + const existing = this.plexSettings.libraries.find( (l) => l.id === library.key && l.name === library.title ); @@ -170,17 +170,15 @@ class PlexAPI { }; }); - settings.plex.libraries = newLibraries; + return newLibraries; } catch (e) { logger.error('Failed to fetch Plex libraries', { label: 'Plex API', message: e.message, }); - settings.plex.libraries = []; + return []; } - - settings.save(); } public async getLibraryContents( diff --git a/server/api/plextv.ts b/server/api/plextv.ts index ef2cee4286..1d8e9d84b2 100644 --- a/server/api/plextv.ts +++ b/server/api/plextv.ts @@ -227,16 +227,15 @@ class PlexTvAPI extends ExternalAPI { public async checkUserAccess(userId: number): Promise { const settings = getSettings(); + const plexServers = settings.plex; try { - if (!settings.plex.machineId) { - throw new Error('Plex is not configured!'); + if (plexServers.length === 0) { + throw new Error('No Plex servers configured!'); } const usersResponse = await this.getUsers(); - const users = usersResponse.MediaContainer.User; - const user = users.find((u) => parseInt(u.$.id) === userId); if (!user) { @@ -245,9 +244,22 @@ class PlexTvAPI extends ExternalAPI { ); } - return !!user.Server?.find( - (server) => server.$.machineIdentifier === settings.plex.machineId - ); + // Check if user has access to ANY configured Plex server + for (const plexServer of plexServers) { + if (!plexServer.machineId) { + continue; + } + + const hasAccess = user.Server?.some( + (server) => server.$.machineIdentifier === plexServer.machineId + ); + + if (hasAccess) { + return true; + } + } + + return false; } catch (e) { logger.error(`Error checking user access: ${e.message}`); return false; diff --git a/server/entity/Media.ts b/server/entity/Media.ts index 756b006c4d..a64728c076 100644 --- a/server/entity/Media.ts +++ b/server/entity/Media.ts @@ -18,6 +18,7 @@ import { UpdateDateColumn, } from 'typeorm'; import Issue from './Issue'; +import MediaPlexServer from './MediaPlexServer'; import { MediaRequest } from './MediaRequest'; import Season from './Season'; @@ -104,6 +105,12 @@ class Media { @OneToMany(() => Issue, (issue) => issue.media, { cascade: true }) public issues: Issue[]; + @OneToMany(() => MediaPlexServer, (plexServer) => plexServer.media, { + cascade: true, + eager: true, + }) + public plexServers: MediaPlexServer[]; + @CreateDateColumn() public createdAt: Date; @@ -160,10 +167,16 @@ class Media { @AfterLoad() public setPlexUrls(): void { - const { machineId, webAppUrl } = getSettings().plex; + const plexServers = getSettings().plex; const { externalUrl: tautulliUrl } = getSettings().tautulli; - if (this.ratingKey) { + // Use the first configured Plex server for URL generation + // In multi-server setup, we use the legacy ratingKey columns for backwards compatibility + const firstServer = plexServers.length > 0 ? plexServers[0] : null; + const machineId = firstServer?.machineId; + const webAppUrl = firstServer?.webAppUrl; + + if (this.ratingKey && machineId) { this.plexUrl = `${ webAppUrl ? webAppUrl : 'https://app.plex.tv/desktop' }#!/server/${machineId}/details?key=%2Flibrary%2Fmetadata%2F${ @@ -177,7 +190,7 @@ class Media { } } - if (this.ratingKey4k) { + if (this.ratingKey4k && machineId) { this.plexUrl4k = `${ webAppUrl ? webAppUrl : 'https://app.plex.tv/desktop' }#!/server/${machineId}/details?key=%2Flibrary%2Fmetadata%2F${ diff --git a/server/entity/MediaPlexServer.ts b/server/entity/MediaPlexServer.ts new file mode 100644 index 0000000000..ebd2f1b822 --- /dev/null +++ b/server/entity/MediaPlexServer.ts @@ -0,0 +1,35 @@ +import { + Column, + Entity, + Index, + ManyToOne, + PrimaryGeneratedColumn, +} from 'typeorm'; +import Media from './Media'; + +@Entity() +class MediaPlexServer { + @PrimaryGeneratedColumn() + public id: number; + + @ManyToOne(() => Media, (media) => media.plexServers, { + onDelete: 'CASCADE', + }) + public media: Media; + + @Column() + @Index() + public plexServerId: number; + + @Column({ nullable: true, type: 'varchar' }) + public ratingKey?: string | null; + + @Column({ nullable: true, type: 'varchar' }) + public ratingKey4k?: string | null; + + constructor(init?: Partial) { + Object.assign(this, init); + } +} + +export default MediaPlexServer; diff --git a/server/index.ts b/server/index.ts index 97f05ab1c9..71a645e7fa 100644 --- a/server/index.ts +++ b/server/index.ts @@ -59,27 +59,38 @@ app const settings = getSettings().load(); restartFlag.initializeSettings(settings.main); - // Migrate library types - if ( - settings.plex.libraries.length > 1 && - !settings.plex.libraries[0].type - ) { - const userRepository = getRepository(User); - const admin = await userRepository.findOne({ - select: { id: true, plexToken: true }, - where: { id: 1 }, - }); - - if (admin) { - logger.info('Migrating Plex libraries to include media type', { - label: 'Settings', + // Migrate library types for each Plex server + for (const plexServer of settings.plex) { + if (plexServer.libraries.length > 1 && !plexServer.libraries[0].type) { + const userRepository = getRepository(User); + const admin = await userRepository.findOne({ + select: { id: true, plexToken: true }, + where: { id: 1 }, }); - const plexapi = new PlexAPI({ plexToken: admin.plexToken }); - await plexapi.syncLibraries(); + if (admin) { + logger.info('Migrating Plex libraries to include media type', { + label: 'Settings', + plexServer: plexServer.name, + }); + + const plexapi = new PlexAPI({ + plexToken: admin.plexToken, + plexSettings: plexServer, + }); + const libraries = await plexapi.syncLibraries(); + + // Update the server's libraries with the synced data + plexServer.libraries = libraries; + } } } + // Save settings if any migrations occurred + if (settings.plex.length > 0) { + getSettings().save(); + } + // Register Notification Agents notificationManager.registerAgents([ new DiscordAgent(), diff --git a/server/lib/availabilitySync.ts b/server/lib/availabilitySync.ts index b6b343d321..ed2c3a117c 100644 --- a/server/lib/availabilitySync.ts +++ b/server/lib/availabilitySync.ts @@ -9,17 +9,23 @@ import Media from '@server/entity/Media'; import MediaRequest from '@server/entity/MediaRequest'; import type Season from '@server/entity/Season'; import { User } from '@server/entity/User'; -import type { RadarrSettings, SonarrSettings } from '@server/lib/settings'; +import type { + PlexSettings, + RadarrSettings, + SonarrSettings, +} from '@server/lib/settings'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; class AvailabilitySync { public running = false; - private plexClient: PlexAPI; + private plexClients: PlexAPI[] = []; + private plexServers: PlexSettings[] = []; private plexSeasonsCache: Record; private sonarrSeasonsCache: Record; private radarrServers: RadarrSettings[]; private sonarrServers: SonarrSettings[]; + private adminPlexToken: string | undefined; async run() { const settings = getSettings(); @@ -28,6 +34,8 @@ class AvailabilitySync { this.sonarrSeasonsCache = {}; this.radarrServers = settings.radarr.filter((server) => server.syncEnabled); this.sonarrServers = settings.sonarr.filter((server) => server.syncEnabled); + this.plexServers = settings.plex; + this.plexClients = []; try { logger.info(`Starting availability sync...`, { @@ -41,8 +49,17 @@ class AvailabilitySync { where: { id: 1 }, }); - if (admin) { - this.plexClient = new PlexAPI({ plexToken: admin.plexToken }); + if (admin && admin.plexToken) { + this.adminPlexToken = admin.plexToken; + // Initialize PlexAPI clients for all configured servers + for (const plexServer of this.plexServers) { + this.plexClients.push( + new PlexAPI({ + plexToken: admin.plexToken, + plexSettings: plexServer, + }) + ); + } } else { logger.error('An admin is not configured.'); } @@ -562,46 +579,49 @@ class AvailabilitySync { let existsInPlex = false; let preventSeasonSearch = false; - // Check each plex instance to see if the media still exists - // If found, we will assume the media exists and prevent removal - // We can use the cache we built when we fetched the series with mediaExistsInPlex - try { - let plexMedia: PlexMetadata | undefined; + // Check ALL configured Plex servers to see if the media exists + // If found on ANY server, we consider it exists + for (const plexClient of this.plexClients) { + try { + let plexMedia: PlexMetadata | undefined; - if (ratingKey && !is4k) { - plexMedia = await this.plexClient?.getMetadata(ratingKey); + if (ratingKey && !is4k) { + plexMedia = await plexClient.getMetadata(ratingKey); - if (media.mediaType === 'tv') { - this.plexSeasonsCache[ratingKey] = - await this.plexClient?.getChildrenMetadata(ratingKey); + if (media.mediaType === 'tv') { + this.plexSeasonsCache[ratingKey] = + await plexClient.getChildrenMetadata(ratingKey); + } } - } - if (ratingKey4k && is4k) { - plexMedia = await this.plexClient?.getMetadata(ratingKey4k); + if (ratingKey4k && is4k) { + plexMedia = await plexClient.getMetadata(ratingKey4k); - if (media.mediaType === 'tv') { - this.plexSeasonsCache[ratingKey4k] = - await this.plexClient?.getChildrenMetadata(ratingKey4k); + if (media.mediaType === 'tv') { + this.plexSeasonsCache[ratingKey4k] = + await plexClient.getChildrenMetadata(ratingKey4k); + } } - } - if (plexMedia) { - existsInPlex = true; - } - } catch (ex) { - if (!ex.message.includes('404')) { - existsInPlex = true; - preventSeasonSearch = true; - logger.debug( - `Failure retrieving the ${is4k ? '4K' : 'non-4K'} ${ - media.mediaType === 'tv' ? 'show' : 'movie' - } [TMDB ID ${media.tmdbId}] from Plex.`, - { - errorMessage: ex.message, - label: 'Availability Sync', - } - ); + if (plexMedia) { + existsInPlex = true; + break; // Found on this server, no need to check others + } + } catch (ex) { + if (!ex.message.includes('404')) { + existsInPlex = true; + preventSeasonSearch = true; + logger.debug( + `Failure retrieving the ${is4k ? '4K' : 'non-4K'} ${ + media.mediaType === 'tv' ? 'show' : 'movie' + } [TMDB ID ${media.tmdbId}] from Plex.`, + { + errorMessage: ex.message, + label: 'Availability Sync', + } + ); + } + // Continue checking other servers even if one fails with 404 } } diff --git a/server/lib/scanners/plex/index.ts b/server/lib/scanners/plex/index.ts index 58a948a86e..fd75b7cb89 100644 --- a/server/lib/scanners/plex/index.ts +++ b/server/lib/scanners/plex/index.ts @@ -12,7 +12,7 @@ import type { StatusBase, } from '@server/lib/scanners/baseScanner'; import BaseScanner from '@server/lib/scanners/baseScanner'; -import type { Library } from '@server/lib/settings'; +import type { Library, PlexSettings } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings'; import { uniqWith } from 'lodash'; @@ -30,6 +30,7 @@ const HAMA_AGENT = 'com.plexapp.agents.hama'; type SyncStatus = StatusBase & { currentLibrary: Library; libraries: Library[]; + currentServer?: PlexSettings; }; class PlexScanner @@ -39,6 +40,7 @@ class PlexScanner private plexClient: PlexAPI; private libraries: Library[]; private currentLibrary: Library; + private currentServer: PlexSettings; private isRecentOnly = false; public constructor(isRecentOnly = false) { @@ -53,6 +55,7 @@ class PlexScanner total: this.totalSize ?? 0, currentLibrary: this.currentLibrary, libraries: this.libraries, + currentServer: this.currentServer, }; } @@ -70,74 +73,108 @@ class PlexScanner return this.log('No admin configured. Plex scan skipped.', 'warn'); } - this.plexClient = new PlexAPI({ plexToken: admin.plexToken }); + const plexServers = settings.plex; - this.libraries = settings.plex.libraries.filter( - (library) => library.enabled - ); - - const hasHama = await this.hasHamaAgent(); - if (hasHama) { - await animeList.sync(); + if (plexServers.length === 0) { + return this.log('No Plex servers configured. Scan skipped.', 'warn'); } - if (this.isRecentOnly) { - for (const library of this.libraries) { - this.currentLibrary = library; + // Iterate over all configured Plex servers + for (const plexServer of plexServers) { + this.currentServer = plexServer; + this.log(`Scanning Plex server: ${plexServer.name}`, 'info'); + + this.plexClient = new PlexAPI({ + plexToken: admin.plexToken, + plexSettings: plexServer, + }); + + this.libraries = plexServer.libraries.filter( + (library) => library.enabled + ); + + if (this.libraries.length === 0) { this.log( - `Beginning to process recently added for library: ${library.name}`, - 'info', - { lastScan: library.lastScan } - ); - const libraryItems = await this.plexClient.getRecentlyAdded( - library.id, - library.lastScan - ? { - // We remove 10 minutes from the last scan as a buffer - addedAt: library.lastScan - 1000 * 60 * 10, - } - : undefined, - library.type + `No enabled libraries on server: ${plexServer.name}. Skipping.`, + 'info' ); + continue; + } - // Bundle items up by rating keys - this.items = uniqWith(libraryItems, (mediaA, mediaB) => { - if (mediaA.grandparentRatingKey && mediaB.grandparentRatingKey) { - return ( - mediaA.grandparentRatingKey === mediaB.grandparentRatingKey - ); - } + const hasHama = await this.hasHamaAgent(); + if (hasHama) { + await animeList.sync(); + } - if (mediaA.parentRatingKey && mediaB.parentRatingKey) { - return mediaA.parentRatingKey === mediaB.parentRatingKey; - } + if (this.isRecentOnly) { + for (const library of this.libraries) { + this.currentLibrary = library; + this.log( + `Beginning to process recently added for library: ${library.name} on server: ${plexServer.name}`, + 'info', + { lastScan: library.lastScan } + ); + const libraryItems = await this.plexClient.getRecentlyAdded( + library.id, + library.lastScan + ? { + // We remove 10 minutes from the last scan as a buffer + addedAt: library.lastScan - 1000 * 60 * 10, + } + : undefined, + library.type + ); - return mediaA.ratingKey === mediaB.ratingKey; - }); + // Bundle items up by rating keys + this.items = uniqWith(libraryItems, (mediaA, mediaB) => { + if (mediaA.grandparentRatingKey && mediaB.grandparentRatingKey) { + return ( + mediaA.grandparentRatingKey === mediaB.grandparentRatingKey + ); + } - await this.loop(this.processItem.bind(this), { sessionId }); + if (mediaA.parentRatingKey && mediaB.parentRatingKey) { + return mediaA.parentRatingKey === mediaB.parentRatingKey; + } - // After run completes, update last scan time - const newLibraries = settings.plex.libraries.map((lib) => { - if (lib.id === library.id) { - return { - ...lib, - lastScan: Date.now(), - }; - } - return lib; - }); + return mediaA.ratingKey === mediaB.ratingKey; + }); - settings.plex.libraries = newLibraries; - settings.save(); - } - } else { - for (const library of this.libraries) { - this.currentLibrary = library; - this.log(`Beginning to process library: ${library.name}`, 'info'); - await this.paginateLibrary(library, { sessionId }); + await this.loop(this.processItem.bind(this), { sessionId }); + + // After run completes, update last scan time for this server's library + const serverIndex = settings.plex.findIndex( + (s) => s.id === plexServer.id + ); + if (serverIndex !== -1) { + const newLibraries = settings.plex[serverIndex].libraries.map( + (lib) => { + if (lib.id === library.id) { + return { + ...lib, + lastScan: Date.now(), + }; + } + return lib; + } + ); + + settings.plex[serverIndex].libraries = newLibraries; + settings.save(); + } + } + } else { + for (const library of this.libraries) { + this.currentLibrary = library; + this.log( + `Beginning to process library: ${library.name} on server: ${plexServer.name}`, + 'info' + ); + await this.paginateLibrary(library, { sessionId }); + } } } + this.log( this.isRecentOnly ? 'Recently Added Scan Complete' diff --git a/server/lib/settings.ts b/server/lib/settings.ts index 63daf17f36..484a564b3e 100644 --- a/server/lib/settings.ts +++ b/server/lib/settings.ts @@ -26,6 +26,7 @@ export interface Language { } export interface PlexSettings { + id: number; name: string; machineId?: string; ip: string; @@ -261,7 +262,7 @@ interface AllSettings { vapidPublic: string; vapidPrivate: string; main: MainSettings; - plex: PlexSettings; + plex: PlexSettings[]; tautulli: TautulliSettings; radarr: RadarrSettings[]; sonarr: SonarrSettings[]; @@ -302,13 +303,7 @@ class Settings { partialRequestsEnabled: true, locale: 'en', }, - plex: { - name: '', - ip: '', - port: 32400, - useSsl: false, - libraries: [], - }, + plex: [], tautulli: {}, radarr: [], sonarr: [], @@ -450,11 +445,11 @@ class Settings { this.data.main = data; } - get plex(): PlexSettings { + get plex(): PlexSettings[] { return this.data.plex; } - set plex(data: PlexSettings) { + set plex(data: PlexSettings[]) { this.data.plex = data; } @@ -591,7 +586,39 @@ class Settings { const data = fs.readFileSync(SETTINGS_PATH, 'utf-8'); if (data) { - this.data = merge(this.data, JSON.parse(data)); + const loadedData = JSON.parse(data); + + // Migrate old single plex object to array format + if (loadedData.plex && !Array.isArray(loadedData.plex)) { + const oldPlex = loadedData.plex as { + name?: string; + machineId?: string; + ip?: string; + port?: number; + useSsl?: boolean; + libraries?: Library[]; + webAppUrl?: string; + }; + // Only migrate if the old plex config has an IP (was actually configured) + if (oldPlex.ip) { + loadedData.plex = [ + { + id: 0, + name: oldPlex.name || 'Default Server', + machineId: oldPlex.machineId, + ip: oldPlex.ip, + port: oldPlex.port || 32400, + useSsl: oldPlex.useSsl || false, + libraries: oldPlex.libraries || [], + webAppUrl: oldPlex.webAppUrl, + }, + ]; + } else { + loadedData.plex = []; + } + } + + this.data = merge(this.data, loadedData); this.save(); } return this; diff --git a/server/migration/1750000000000-AddMediaPlexServer.ts b/server/migration/1750000000000-AddMediaPlexServer.ts new file mode 100644 index 0000000000..510b7c74db --- /dev/null +++ b/server/migration/1750000000000-AddMediaPlexServer.ts @@ -0,0 +1,41 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddMediaPlexServer1750000000000 implements MigrationInterface { + name = 'AddMediaPlexServer1750000000000'; + + public async up(queryRunner: QueryRunner): Promise { + // Create the media_plex_server table + await queryRunner.query( + `CREATE TABLE "media_plex_server" ( + "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, + "plexServerId" integer NOT NULL, + "ratingKey" varchar, + "ratingKey4k" varchar, + "mediaId" integer, + CONSTRAINT "FK_media_plex_server_media" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION + )` + ); + + // Create index on plexServerId for faster lookups + await queryRunner.query( + `CREATE INDEX "IDX_media_plex_server_plexServerId" ON "media_plex_server" ("plexServerId")` + ); + + // Migrate existing ratingKey data from media table to media_plex_server + // This assumes the old single server had id = 0 + await queryRunner.query( + `INSERT INTO "media_plex_server" ("plexServerId", "ratingKey", "ratingKey4k", "mediaId") + SELECT 0, "ratingKey", "ratingKey4k", "id" + FROM "media" + WHERE "ratingKey" IS NOT NULL OR "ratingKey4k" IS NOT NULL` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + // Drop the index + await queryRunner.query(`DROP INDEX "IDX_media_plex_server_plexServerId"`); + + // Drop the table + await queryRunner.query(`DROP TABLE "media_plex_server"`); + } +} diff --git a/server/routes/settings/index.ts b/server/routes/settings/index.ts index 98fe0f7769..049858df2c 100644 --- a/server/routes/settings/index.ts +++ b/server/routes/settings/index.ts @@ -1,11 +1,9 @@ -import PlexAPI from '@server/api/plexapi'; import PlexTvAPI from '@server/api/plextv'; import TautulliAPI from '@server/api/tautulli'; import { getRepository } from '@server/datasource'; import Media from '@server/entity/Media'; import { MediaRequest } from '@server/entity/MediaRequest'; import { User } from '@server/entity/User'; -import type { PlexConnection } from '@server/interfaces/api/plexInterfaces'; import type { LogMessage, LogsResultsResponse, @@ -16,7 +14,6 @@ import type { AvailableCacheIds } from '@server/lib/cache'; import cacheManager from '@server/lib/cache'; import ImageProxy from '@server/lib/imageproxy'; import { Permission } from '@server/lib/permissions'; -import { plexFullScanner } from '@server/lib/scanners/plex'; import type { JobId, MainSettings } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; @@ -31,14 +28,15 @@ import { escapeRegExp, merge, omit, set, sortBy } from 'lodash'; import { rescheduleJob } from 'node-schedule'; import path from 'path'; import semver from 'semver'; -import { URL } from 'url'; import notificationRoutes from './notifications'; +import plexRoutes from './plex'; import radarrRoutes from './radarr'; import sonarrRoutes from './sonarr'; const settingsRoutes = Router(); settingsRoutes.use('/notifications', notificationRoutes); +settingsRoutes.use('/plex', plexRoutes); settingsRoutes.use('/radarr', radarrRoutes); settingsRoutes.use('/sonarr', sonarrRoutes); settingsRoutes.use('/discover', discoverSettingRoutes); @@ -85,164 +83,6 @@ settingsRoutes.post('/main/regenerate', (req, res, next) => { return res.status(200).json(filteredMainSettings(req.user, main)); }); -settingsRoutes.get('/plex', (_req, res) => { - const settings = getSettings(); - - res.status(200).json(settings.plex); -}); - -settingsRoutes.post('/plex', async (req, res, next) => { - const userRepository = getRepository(User); - const settings = getSettings(); - try { - const admin = await userRepository.findOneOrFail({ - select: { id: true, plexToken: true }, - where: { id: 1 }, - }); - - Object.assign(settings.plex, req.body); - - const plexClient = new PlexAPI({ plexToken: admin.plexToken }); - - const result = await plexClient.getStatus(); - - if (!result?.MediaContainer?.machineIdentifier) { - throw new Error('Server not found'); - } - - settings.plex.machineId = result.MediaContainer.machineIdentifier; - settings.plex.name = result.MediaContainer.friendlyName; - - settings.save(); - } catch (e) { - logger.error('Something went wrong testing Plex connection', { - label: 'API', - errorMessage: e.message, - }); - return next({ - status: 500, - message: 'Unable to connect to Plex.', - }); - } - - return res.status(200).json(settings.plex); -}); - -settingsRoutes.get('/plex/devices/servers', async (req, res, next) => { - const userRepository = getRepository(User); - try { - const admin = await userRepository.findOneOrFail({ - select: { id: true, plexToken: true }, - where: { id: 1 }, - }); - const plexTvClient = admin.plexToken - ? new PlexTvAPI(admin.plexToken) - : null; - const devices = (await plexTvClient?.getDevices())?.filter((device) => { - return device.provides.includes('server') && device.owned; - }); - const settings = getSettings(); - - if (devices) { - await Promise.all( - devices.map(async (device) => { - const plexDirectConnections: PlexConnection[] = []; - - device.connection.forEach((connection) => { - const url = new URL(connection.uri); - - if (url.hostname !== connection.address) { - const plexDirectConnection = { ...connection }; - plexDirectConnection.address = url.hostname; - plexDirectConnections.push(plexDirectConnection); - - // Connect to IP addresses over HTTP - connection.protocol = 'http'; - } - }); - - plexDirectConnections.forEach((plexDirectConnection) => { - device.connection.push(plexDirectConnection); - }); - - await Promise.all( - device.connection.map(async (connection) => { - const plexDeviceSettings = { - ...settings.plex, - ip: connection.address, - port: connection.port, - useSsl: connection.protocol === 'https', - }; - const plexClient = new PlexAPI({ - plexToken: admin.plexToken, - plexSettings: plexDeviceSettings, - timeout: 5000, - }); - - try { - await plexClient.getStatus(); - connection.status = 200; - connection.message = 'OK'; - } catch (e) { - connection.status = 500; - connection.message = e.message.split(':')[0]; - } - }) - ); - }) - ); - } - return res.status(200).json(devices); - } catch (e) { - logger.error('Something went wrong retrieving Plex server list', { - label: 'API', - errorMessage: e.message, - }); - return next({ - status: 500, - message: 'Unable to retrieve Plex server list.', - }); - } -}); - -settingsRoutes.get('/plex/library', async (req, res) => { - const settings = getSettings(); - - if (req.query.sync) { - const userRepository = getRepository(User); - const admin = await userRepository.findOneOrFail({ - select: { id: true, plexToken: true }, - where: { id: 1 }, - }); - const plexapi = new PlexAPI({ plexToken: admin.plexToken }); - - await plexapi.syncLibraries(); - } - - const enabledLibraries = req.query.enable - ? (req.query.enable as string).split(',') - : []; - settings.plex.libraries = settings.plex.libraries.map((library) => ({ - ...library, - enabled: enabledLibraries.includes(library.id), - })); - settings.save(); - return res.status(200).json(settings.plex.libraries); -}); - -settingsRoutes.get('/plex/sync', (_req, res) => { - return res.status(200).json(plexFullScanner.status()); -}); - -settingsRoutes.post('/plex/sync', (req, res) => { - if (req.body.cancel) { - plexFullScanner.cancel(); - } else if (req.body.start) { - plexFullScanner.run(); - } - return res.status(200).json(plexFullScanner.status()); -}); - settingsRoutes.get('/tautulli', (_req, res) => { const settings = getSettings(); diff --git a/server/routes/settings/plex.ts b/server/routes/settings/plex.ts new file mode 100644 index 0000000000..b050b87580 --- /dev/null +++ b/server/routes/settings/plex.ts @@ -0,0 +1,442 @@ +import PlexAPI from '@server/api/plexapi'; +import PlexTvAPI from '@server/api/plextv'; +import { getRepository } from '@server/datasource'; +import { User } from '@server/entity/User'; +import type { PlexConnection } from '@server/interfaces/api/plexInterfaces'; +import { plexFullScanner } from '@server/lib/scanners/plex'; +import type { PlexSettings } from '@server/lib/settings'; +import { getSettings } from '@server/lib/settings'; +import logger from '@server/logger'; +import { Router } from 'express'; + +const plexRoutes = Router(); + +// ============================================================ +// STATIC ROUTES MUST BE DEFINED BEFORE DYNAMIC /:id ROUTES +// ============================================================ + +// GET /sync - Get scan status +plexRoutes.get('/sync', (_req, res) => { + return res.status(200).json(plexFullScanner.status()); +}); + +// POST /sync - Start/cancel scan +plexRoutes.post('/sync', (req, res) => { + if (req.body.cancel) { + plexFullScanner.cancel(); + } else if (req.body.start) { + plexFullScanner.run(); + } + return res.status(200).json(plexFullScanner.status()); +}); + +// POST /test - Test connection +plexRoutes.post, PlexSettings>( + '/test', + async (req, res, next) => { + const userRepository = getRepository(User); + + try { + const admin = await userRepository.findOneOrFail({ + select: { id: true, plexToken: true }, + where: { id: 1 }, + }); + + logger.debug('Testing Plex connection', { + label: 'Plex', + ip: req.body.ip, + port: req.body.port, + useSsl: req.body.useSsl, + hasToken: !!admin.plexToken, + }); + + if (!admin.plexToken) { + logger.error('Admin user has no Plex token', { label: 'Plex' }); + return next({ + status: 400, + message: + 'Please sign in with Plex first to get an authentication token.', + }); + } + + const plexClient = new PlexAPI({ + plexToken: admin.plexToken, + plexSettings: req.body, + }); + + const result = await plexClient.getStatus(); + + if (!result?.MediaContainer?.machineIdentifier) { + throw new Error('Server not found - no machineIdentifier in response'); + } + + logger.info('Plex connection test successful', { + label: 'Plex', + machineId: result.MediaContainer.machineIdentifier, + name: result.MediaContainer.friendlyName, + }); + + return res.status(200).json({ + success: true, + machineId: result.MediaContainer.machineIdentifier, + name: result.MediaContainer.friendlyName, + }); + } catch (e) { + logger.error('Failed to test Plex connection', { + label: 'Plex', + errorMessage: e.message, + stack: e.stack, + ip: req.body.ip, + port: req.body.port, + }); + return next({ + status: 500, + message: `Failed to connect to Plex server: ${e.message}`, + }); + } + } +); + +// GET /devices/servers - Fetch available servers from plex.tv +plexRoutes.get('/devices/servers', async (_req, res, next) => { + const userRepository = getRepository(User); + try { + const admin = await userRepository.findOneOrFail({ + select: { id: true, plexToken: true }, + where: { id: 1 }, + }); + const plexTvClient = admin.plexToken + ? new PlexTvAPI(admin.plexToken) + : null; + const devices = (await plexTvClient?.getDevices())?.filter((device) => { + return device.provides.includes('server') && device.owned; + }); + + if (devices) { + await Promise.all( + devices.map(async (device) => { + const plexDirectConnections: PlexConnection[] = []; + + device.connection.forEach((connection) => { + const url = new URL(connection.uri); + + if (url.hostname !== connection.address) { + const plexDirectConnection = { ...connection }; + plexDirectConnection.address = url.hostname; + plexDirectConnections.push(plexDirectConnection); + + // Connect to IP addresses over HTTP + connection.protocol = 'http'; + } + }); + + plexDirectConnections.forEach((plexDirectConnection) => { + device.connection.push(plexDirectConnection); + }); + + await Promise.all( + device.connection.map(async (connection) => { + const plexDeviceSettings: PlexSettings = { + id: -1, // Temporary ID for testing + name: device.name, + ip: connection.address, + port: connection.port, + useSsl: connection.protocol === 'https', + libraries: [], + }; + const plexClient = new PlexAPI({ + plexToken: admin.plexToken, + plexSettings: plexDeviceSettings, + timeout: 5000, + }); + + try { + await plexClient.getStatus(); + connection.status = 200; + connection.message = 'OK'; + } catch (e) { + connection.status = 500; + connection.message = e.message.split(':')[0]; + } + }) + ); + }) + ); + } + return res.status(200).json(devices); + } catch (e) { + logger.error('Something went wrong retrieving Plex server list', { + label: 'API', + errorMessage: e.message, + }); + return next({ + status: 500, + message: 'Unable to retrieve Plex server list.', + }); + } +}); + +// ============================================================ +// BASE ROUTES (/ for list and add) +// ============================================================ + +plexRoutes.get('/', (_req, res) => { + const settings = getSettings(); + res.status(200).json(settings.plex); +}); + +plexRoutes.post('/', async (req, res, next) => { + const userRepository = getRepository(User); + const settings = getSettings(); + + try { + const admin = await userRepository.findOneOrFail({ + select: { id: true, plexToken: true }, + where: { id: 1 }, + }); + + const newPlex = req.body as PlexSettings; + const lastItem = settings.plex[settings.plex.length - 1]; + newPlex.id = lastItem ? lastItem.id + 1 : 0; + + const plexClient = new PlexAPI({ + plexToken: admin.plexToken, + plexSettings: newPlex, + }); + + const result = await plexClient.getStatus(); + + if (!result?.MediaContainer?.machineIdentifier) { + throw new Error('Server not found'); + } + + newPlex.machineId = result.MediaContainer.machineIdentifier; + newPlex.name = newPlex.name || result.MediaContainer.friendlyName; + + settings.plex = [...settings.plex, newPlex]; + settings.save(); + + return res.status(201).json(newPlex); + } catch (e) { + logger.error('Failed to add Plex server', { + label: 'Plex', + errorMessage: e.message, + }); + return next({ + status: 500, + message: 'Failed to connect to Plex server.', + }); + } +}); + +// ============================================================ +// DYNAMIC /:plexId ROUTES +// ============================================================ + +plexRoutes.put<{ plexId: string }, PlexSettings, PlexSettings>( + '/:plexId', + async (req, res, next) => { + const userRepository = getRepository(User); + const settings = getSettings(); + + const plexIndex = settings.plex.findIndex( + (p) => p.id === Number(req.params.plexId) + ); + + if (plexIndex === -1) { + return next({ status: 404, message: 'Plex server not found' }); + } + + try { + const admin = await userRepository.findOneOrFail({ + select: { id: true, plexToken: true }, + where: { id: 1 }, + }); + + const updatedPlex = { + ...req.body, + id: Number(req.params.plexId), + }; + + const plexClient = new PlexAPI({ + plexToken: admin.plexToken, + plexSettings: updatedPlex, + }); + + const result = await plexClient.getStatus(); + + if (!result?.MediaContainer?.machineIdentifier) { + throw new Error('Server not found'); + } + + updatedPlex.machineId = result.MediaContainer.machineIdentifier; + updatedPlex.name = updatedPlex.name || result.MediaContainer.friendlyName; + + settings.plex[plexIndex] = updatedPlex; + settings.save(); + + return res.status(200).json(updatedPlex); + } catch (e) { + logger.error('Failed to update Plex server', { + label: 'Plex', + errorMessage: e.message, + }); + return next({ + status: 500, + message: 'Failed to connect to Plex server.', + }); + } + } +); + +plexRoutes.delete<{ plexId: string }>('/:plexId', (req, res, next) => { + const settings = getSettings(); + + const plexIndex = settings.plex.findIndex( + (p) => p.id === Number(req.params.plexId) + ); + + if (plexIndex === -1) { + return next({ status: 404, message: 'Plex server not found' }); + } + + const removed = settings.plex.splice(plexIndex, 1); + settings.save(); + + return res.status(200).json(removed[0]); +}); + +plexRoutes.get<{ plexId: string }>('/:plexId', (req, res, next) => { + const settings = getSettings(); + + const plexServer = settings.plex.find( + (p) => p.id === Number(req.params.plexId) + ); + + if (!plexServer) { + return next({ status: 404, message: 'Plex server not found' }); + } + + return res.status(200).json(plexServer); +}); + +plexRoutes.get<{ plexId: string }>( + '/:plexId/libraries', + async (req, res, next) => { + const userRepository = getRepository(User); + const settings = getSettings(); + + const plexServer = settings.plex.find( + (p) => p.id === Number(req.params.plexId) + ); + + if (!plexServer) { + return next({ status: 404, message: 'Plex server not found' }); + } + + try { + const admin = await userRepository.findOneOrFail({ + select: { id: true, plexToken: true }, + where: { id: 1 }, + }); + + const plexClient = new PlexAPI({ + plexToken: admin.plexToken, + plexSettings: plexServer, + }); + + const libraries = await plexClient.syncLibraries(); + + return res.status(200).json(libraries); + } catch (e) { + logger.error('Failed to fetch Plex libraries', { + label: 'Plex', + errorMessage: e.message, + }); + return next({ + status: 500, + message: 'Failed to fetch Plex libraries.', + }); + } + } +); + +plexRoutes.post<{ plexId: string }>( + '/:plexId/libraries/sync', + async (req, res, next) => { + const userRepository = getRepository(User); + const settings = getSettings(); + + const plexIndex = settings.plex.findIndex( + (p) => p.id === Number(req.params.plexId) + ); + + if (plexIndex === -1) { + return next({ status: 404, message: 'Plex server not found' }); + } + + try { + const admin = await userRepository.findOneOrFail({ + select: { id: true, plexToken: true }, + where: { id: 1 }, + }); + + const plexClient = new PlexAPI({ + plexToken: admin.plexToken, + plexSettings: settings.plex[plexIndex], + }); + + const libraries = await plexClient.syncLibraries(); + settings.plex[plexIndex].libraries = libraries; + settings.save(); + + return res.status(200).json(libraries); + } catch (e) { + logger.error('Failed to sync Plex libraries', { + label: 'Plex', + errorMessage: e.message, + }); + return next({ + status: 500, + message: 'Failed to sync Plex libraries.', + }); + } + } +); + +// Toggle library enabled state for a specific server +plexRoutes.put<{ plexId: string; libraryId: string }>( + '/:plexId/libraries/:libraryId', + (req, res, next) => { + const settings = getSettings(); + + const plexIndex = settings.plex.findIndex( + (p) => p.id === Number(req.params.plexId) + ); + + if (plexIndex === -1) { + return next({ status: 404, message: 'Plex server not found' }); + } + + const libraryIndex = settings.plex[plexIndex].libraries.findIndex( + (lib) => lib.id === req.params.libraryId + ); + + if (libraryIndex === -1) { + return next({ status: 404, message: 'Library not found' }); + } + + // Toggle or set enabled state + const newEnabled = + req.body.enabled !== undefined + ? req.body.enabled + : !settings.plex[plexIndex].libraries[libraryIndex].enabled; + + settings.plex[plexIndex].libraries[libraryIndex].enabled = newEnabled; + settings.save(); + + return res.status(200).json(settings.plex[plexIndex].libraries); + } +); + +export default plexRoutes; diff --git a/src/components/Common/Button/index.tsx b/src/components/Common/Button/index.tsx index a4df31150d..61dca849e8 100644 --- a/src/components/Common/Button/index.tsx +++ b/src/components/Common/Button/index.tsx @@ -107,6 +107,7 @@ function Button

( } else { return ( - )} - {typeof onSecondary === 'function' && secondaryText && ( - - )} - {typeof onTertiary === 'function' && tertiaryText && ( - + {children} + )} - {typeof onCancel === 'function' && ( - + {(onCancel || onOk || onSecondary || onTertiary) && ( +

+ {typeof onOk === 'function' && ( + + )} + {typeof onSecondary === 'function' && secondaryText && ( + + )} + {typeof onTertiary === 'function' && tertiaryText && ( + + )} + {typeof onCancel === 'function' && ( + + )} +
)} )} - - , + + , document.body ); } diff --git a/src/components/Settings/LibraryItem.tsx b/src/components/Settings/LibraryItem.tsx index ab8f5fd837..7423a684a2 100644 --- a/src/components/Settings/LibraryItem.tsx +++ b/src/components/Settings/LibraryItem.tsx @@ -9,8 +9,8 @@ interface LibraryItemProps { const LibraryItem = ({ isEnabled, name, onToggle }: LibraryItemProps) => { return (
  • -
    -
    +
    +
    {name}
    diff --git a/src/components/Settings/PlexServerModal.tsx b/src/components/Settings/PlexServerModal.tsx new file mode 100644 index 0000000000..a94c1ae4cf --- /dev/null +++ b/src/components/Settings/PlexServerModal.tsx @@ -0,0 +1,450 @@ +import Modal from '@app/components/Common/Modal'; +import globalMessages from '@app/i18n/globalMessages'; +import { ArrowPathIcon } from '@heroicons/react/24/solid'; +import type { PlexDevice } from '@server/interfaces/api/plexInterfaces'; +import type { PlexSettings } from '@server/lib/settings'; +import axios from 'axios'; +import { Field, Formik } from 'formik'; +import { orderBy } from 'lodash'; +import { useMemo, useState } from 'react'; +import { defineMessages, useIntl } from 'react-intl'; +import { useToasts } from 'react-toast-notifications'; +import * as Yup from 'yup'; + +interface PresetServerDisplay { + name: string; + ssl: boolean; + uri: string; + address: string; + port: number; + local: boolean; + status?: boolean; + message?: string; +} + +const messages = defineMessages({ + createplex: 'Add New Plex Server', + editplex: 'Edit Plex Server', + validationHostnameRequired: 'You must provide a valid hostname or IP address', + validationPortRequired: 'You must provide a valid port number', + toastPlexTestSuccess: 'Plex connection established successfully!', + toastPlexTestFailure: 'Failed to connect to Plex.', + toastPlexRefresh: 'Retrieving server list from Plex…', + toastPlexRefreshSuccess: 'Plex server list retrieved successfully!', + toastPlexRefreshFailure: 'Failed to retrieve Plex server list.', + add: 'Add Server', + servername: 'Server Name', + hostname: 'Hostname or IP Address', + port: 'Port', + ssl: 'Use SSL', + serverWebAppUrl: 'Web App URL', + serverWebAppUrlTip: + 'Optionally direct users to the web app on your server instead of the "hosted" web app', + serverpreset: 'Server', + serverLocal: 'local', + serverRemote: 'remote', + serverSecure: 'secure', + serverpresetManualMessage: 'Manual configuration', + serverpresetRefreshing: 'Retrieving servers…', + serverpresetLoad: 'Press the button to load available servers', + testConnection: 'Test Connection', + testing: 'Testing…', + validationUrl: 'You must provide a valid URL', +}); + +interface PlexServerModalProps { + plex: PlexSettings | null; + onClose: () => void; + onSave: () => void; +} + +const PlexServerModal = ({ onClose, plex, onSave }: PlexServerModalProps) => { + const intl = useIntl(); + const { addToast, removeToast } = useToasts(); + const [isTesting, setIsTesting] = useState(false); + const [isRefreshingPresets, setIsRefreshingPresets] = useState(false); + const [availableServers, setAvailableServers] = useState( + null + ); + + const PlexSettingsSchema = Yup.object().shape({ + hostname: Yup.string() + .required(intl.formatMessage(messages.validationHostnameRequired)) + .matches( + /^(((([a-z]|\d|_|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*)?([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])):((([a-z]|\d|_|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*)?([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]))@)?(([a-z]|\d|_|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*)?([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])$/i, + intl.formatMessage(messages.validationHostnameRequired) + ), + port: Yup.number() + .nullable() + .required(intl.formatMessage(messages.validationPortRequired)), + webAppUrl: Yup.string() + .nullable() + .url(intl.formatMessage(messages.validationUrl)), + }); + + const availablePresets = useMemo(() => { + const finalPresets: PresetServerDisplay[] = []; + availableServers?.forEach((dev) => { + dev.connection.forEach((conn) => + finalPresets.push({ + name: dev.name, + ssl: conn.protocol === 'https', + uri: conn.uri, + address: conn.address, + port: conn.port, + local: conn.local, + status: conn.status === 200, + message: conn.message, + }) + ); + }); + + return orderBy(finalPresets, ['status', 'ssl'], ['desc', 'desc']); + }, [availableServers]); + + const refreshPresetServers = async () => { + setIsRefreshingPresets(true); + let toastId: string | undefined; + try { + addToast( + intl.formatMessage(messages.toastPlexRefresh), + { + autoDismiss: false, + appearance: 'info', + }, + (id) => { + toastId = id; + } + ); + const response = await axios.get( + '/api/v1/settings/plex/devices/servers' + ); + if (response.data) { + setAvailableServers(response.data); + } + if (toastId) { + removeToast(toastId); + } + addToast(intl.formatMessage(messages.toastPlexRefreshSuccess), { + autoDismiss: true, + appearance: 'success', + }); + } catch (e) { + if (toastId) { + removeToast(toastId); + } + addToast(intl.formatMessage(messages.toastPlexRefreshFailure), { + autoDismiss: true, + appearance: 'error', + }); + } finally { + setIsRefreshingPresets(false); + } + }; + + const testConnection = async (values: { + hostname: string; + port: number; + useSsl: boolean; + }) => { + setIsTesting(true); + try { + await axios.post('/api/v1/settings/plex/test', { + ip: values.hostname, + port: Number(values.port), + useSsl: values.useSsl, + id: -1, + name: '', + libraries: [], + } as PlexSettings); + + addToast(intl.formatMessage(messages.toastPlexTestSuccess), { + appearance: 'success', + autoDismiss: true, + }); + } catch (e) { + const axiosError = e as { + response?: { status: number; data: unknown }; + message: string; + }; + const errorMessage = + axiosError.response?.data && + typeof axiosError.response.data === 'object' && + 'message' in axiosError.response.data + ? (axiosError.response.data as { message: string }).message + : intl.formatMessage(messages.toastPlexTestFailure); + + addToast(errorMessage, { + appearance: 'error', + autoDismiss: true, + }); + } finally { + setIsTesting(false); + } + }; + + return ( + { + try { + const submission: Partial = { + ip: values.hostname, + port: Number(values.port), + useSsl: values.useSsl, + name: values.name, + webAppUrl: values.webAppUrl || undefined, + libraries: plex?.libraries ?? [], + }; + + if (!plex) { + await axios.post('/api/v1/settings/plex', submission); + } else { + await axios.put(`/api/v1/settings/plex/${plex.id}`, submission); + } + + addToast( + plex + ? 'Plex server updated successfully!' + : 'Plex server added successfully!', + { + appearance: 'success', + autoDismiss: true, + } + ); + onSave(); + } catch (e) { + addToast('Failed to save Plex server.', { + appearance: 'error', + autoDismiss: true, + }); + } + }} + > + {({ + errors, + touched, + values, + handleSubmit, + setFieldValue, + isSubmitting, + isValid, + }) => { + return ( + + testConnection({ + hostname: values.hostname, + port: values.port, + useSsl: values.useSsl, + }) + } + secondaryDisabled={isTesting || !isValid} + okDisabled={isSubmitting || !isValid} + onOk={() => handleSubmit()} + title={ + !plex + ? intl.formatMessage(messages.createplex) + : intl.formatMessage(messages.editplex) + } + > +
    +
    + +
    +
    + + +
    +
    +
    +
    + +
    +
    + +
    +
    +
    +
    + +
    +
    + + {values.useSsl ? 'https://' : 'http://'} + + +
    + {errors.hostname && + touched.hostname && + typeof errors.hostname === 'string' && ( +
    {errors.hostname}
    + )} +
    +
    +
    + +
    + + {errors.port && + touched.port && + typeof errors.port === 'string' && ( +
    {errors.port}
    + )} +
    +
    +
    + +
    + { + setFieldValue('useSsl', !values.useSsl); + }} + /> +
    +
    +
    + +
    +
    + +
    + {errors.webAppUrl && + touched.webAppUrl && + typeof errors.webAppUrl === 'string' && ( +
    {errors.webAppUrl}
    + )} +
    +
    +
    +
    + ); + }} +
    + ); +}; + +export default PlexServerModal; diff --git a/src/components/Settings/SettingsPlex.tsx b/src/components/Settings/SettingsPlex.tsx index 9f5420c35c..aae79aafe3 100644 --- a/src/components/Settings/SettingsPlex.tsx +++ b/src/components/Settings/SettingsPlex.tsx @@ -1,24 +1,15 @@ -import Alert from '@app/components/Common/Alert'; import Badge from '@app/components/Common/Badge'; import Button from '@app/components/Common/Button'; import LoadingSpinner from '@app/components/Common/LoadingSpinner'; import PageTitle from '@app/components/Common/PageTitle'; import SensitiveInput from '@app/components/Common/SensitiveInput'; -import LibraryItem from '@app/components/Settings/LibraryItem'; -import SettingsBadge from '@app/components/Settings/SettingsBadge'; +import SettingsPlexServers from '@app/components/Settings/SettingsPlexServers'; import globalMessages from '@app/i18n/globalMessages'; import { ArrowDownOnSquareIcon } from '@heroicons/react/24/outline'; -import { - ArrowPathIcon, - MagnifyingGlassIcon, - XMarkIcon, -} from '@heroicons/react/24/solid'; -import type { PlexDevice } from '@server/interfaces/api/plexInterfaces'; +import { MagnifyingGlassIcon, XMarkIcon } from '@heroicons/react/24/solid'; import type { PlexSettings, TautulliSettings } from '@server/lib/settings'; import axios from 'axios'; import { Field, Formik } from 'formik'; -import { orderBy } from 'lodash'; -import { useMemo, useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; import { useToasts } from 'react-toast-notifications'; import useSWR from 'swr'; @@ -27,50 +18,25 @@ import * as Yup from 'yup'; const messages = defineMessages({ plex: 'Plex', plexsettings: 'Plex Settings', - plexsettingsDescription: - 'Configure the settings for your Plex server. Overseerr scans your Plex libraries to determine content availability.', - serverpreset: 'Server', - serverLocal: 'local', - serverRemote: 'remote', - serverSecure: 'secure', - serverpresetManualMessage: 'Manual configuration', - serverpresetRefreshing: 'Retrieving servers…', - serverpresetLoad: 'Press the button to load available servers', - toastPlexRefresh: 'Retrieving server list from Plex…', - toastPlexRefreshSuccess: 'Plex server list retrieved successfully!', - toastPlexRefreshFailure: 'Failed to retrieve Plex server list.', - toastPlexConnecting: 'Attempting to connect to Plex…', - toastPlexConnectingSuccess: 'Plex connection established successfully!', - toastPlexConnectingFailure: 'Failed to connect to Plex.', - settingUpPlexDescription: - 'To set up Plex, you can either enter the details manually or select a server retrieved from plex.tv. Press the button to the right of the dropdown to fetch the list of available servers.', - hostname: 'Hostname or IP Address', - port: 'Port', - enablessl: 'Use SSL', - plexlibraries: 'Plex Libraries', - plexlibrariesDescription: - 'The libraries Overseerr scans for titles. Set up and save your Plex connection settings, then click the button below if no libraries are listed.', - scanning: 'Syncing…', - scan: 'Sync Libraries', manualscan: 'Manual Library Scan', manualscanDescription: "Normally, this will only be run once every 24 hours. Overseerr will check your Plex server's recently added more aggressively. If this is your first time configuring Plex, a one-time full manual library scan is recommended!", - notrunning: 'Not Running', currentlibrary: 'Current Library: {name}', + currentserver: 'Current Server: {name}', librariesRemaining: 'Libraries Remaining: {count}', startscan: 'Start Scan', cancelscan: 'Cancel Scan', - validationHostnameRequired: 'You must provide a valid hostname or IP address', - validationPortRequired: 'You must provide a valid port number', - webAppUrl: 'Web App URL', - webAppUrlTip: - 'Optionally direct users to the web app on your server instead of the "hosted" web app', + hostname: 'Hostname or IP Address', + port: 'Port', + enablessl: 'Use SSL', tautulliSettings: 'Tautulli Settings', tautulliSettingsDescription: 'Optionally configure the settings for your Tautulli server. Overseerr fetches watch history data for your Plex media from Tautulli.', urlBase: 'URL Base', tautulliApiKey: 'API Key', externalUrl: 'External URL', + validationHostnameRequired: 'You must provide a valid hostname or IP address', + validationPortRequired: 'You must provide a valid port number', validationApiKey: 'You must provide an API key', validationUrl: 'You must provide a valid URL', validationUrlTrailingSlash: 'URL must not end in a trailing slash', @@ -93,33 +59,19 @@ interface SyncStatus { total: number; currentLibrary?: Library; libraries: Library[]; + currentServer?: PlexSettings; } -interface PresetServerDisplay { - name: string; - ssl: boolean; - uri: string; - address: string; - port: number; - local: boolean; - status?: boolean; - message?: string; -} interface SettingsPlexProps { onComplete?: () => void; } const SettingsPlex = ({ onComplete }: SettingsPlexProps) => { - const [isSyncing, setIsSyncing] = useState(false); - const [isRefreshingPresets, setIsRefreshingPresets] = useState(false); - const [availableServers, setAvailableServers] = useState( - null - ); const { - data, + data: plexServers, error, - mutate: revalidate, - } = useSWR('/api/v1/settings/plex'); + mutate: revalidatePlex, + } = useSWR('/api/v1/settings/plex'); const { data: dataTautulli, mutate: revalidateTautulli } = useSWR('/api/v1/settings/tautulli'); const { data: dataSync, mutate: revalidateSync } = useSWR( @@ -129,23 +81,7 @@ const SettingsPlex = ({ onComplete }: SettingsPlexProps) => { } ); const intl = useIntl(); - const { addToast, removeToast } = useToasts(); - - const PlexSettingsSchema = Yup.object().shape({ - hostname: Yup.string() - .nullable() - .required(intl.formatMessage(messages.validationHostnameRequired)) - .matches( - /^(((([a-z]|\d|_|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*)?([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])):((([a-z]|\d|_|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*)?([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]))@)?(([a-z]|\d|_|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*)?([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])$/i, - intl.formatMessage(messages.validationHostnameRequired) - ), - port: Yup.number() - .nullable() - .required(intl.formatMessage(messages.validationPortRequired)), - webAppUrl: Yup.string() - .nullable() - .url(intl.formatMessage(messages.validationUrl)), - }); + const { addToast } = useToasts(); const TautulliSettingsSchema = Yup.object().shape( { @@ -204,88 +140,10 @@ const SettingsPlex = ({ onComplete }: SettingsPlexProps) => { ] ); - const activeLibraries = - data?.libraries - .filter((library) => library.enabled) - .map((library) => library.id) ?? []; - - const availablePresets = useMemo(() => { - const finalPresets: PresetServerDisplay[] = []; - availableServers?.forEach((dev) => { - dev.connection.forEach((conn) => - finalPresets.push({ - name: dev.name, - ssl: conn.protocol === 'https', - uri: conn.uri, - address: conn.address, - port: conn.port, - local: conn.local, - status: conn.status === 200, - message: conn.message, - }) - ); - }); - - return orderBy(finalPresets, ['status', 'ssl'], ['desc', 'desc']); - }, [availableServers]); - - const syncLibraries = async () => { - setIsSyncing(true); - - const params: { sync: boolean; enable?: string } = { - sync: true, - }; - - if (activeLibraries.length > 0) { - params.enable = activeLibraries.join(','); - } - - await axios.get('/api/v1/settings/plex/library', { - params, - }); - setIsSyncing(false); - revalidate(); - }; - - const refreshPresetServers = async () => { - setIsRefreshingPresets(true); - let toastId: string | undefined; - try { - addToast( - intl.formatMessage(messages.toastPlexRefresh), - { - autoDismiss: false, - appearance: 'info', - }, - (id) => { - toastId = id; - } - ); - const response = await axios.get( - '/api/v1/settings/plex/devices/servers' - ); - if (response.data) { - setAvailableServers(response.data); - } - if (toastId) { - removeToast(toastId); - } - addToast(intl.formatMessage(messages.toastPlexRefreshSuccess), { - autoDismiss: true, - appearance: 'success', - }); - } catch (e) { - if (toastId) { - removeToast(toastId); - } - addToast(intl.formatMessage(messages.toastPlexRefreshFailure), { - autoDismiss: true, - appearance: 'error', - }); - } finally { - setIsRefreshingPresets(false); - } - }; + // Check if any servers have enabled libraries + const hasEnabledLibraries = plexServers?.some((server) => + server.libraries.some((lib) => lib.enabled) + ); const startScan = async () => { await axios.post('/api/v1/settings/plex/sync', { @@ -301,32 +159,7 @@ const SettingsPlex = ({ onComplete }: SettingsPlexProps) => { revalidateSync(); }; - const toggleLibrary = async (libraryId: string) => { - setIsSyncing(true); - if (activeLibraries.includes(libraryId)) { - const params: { enable?: string } = {}; - - if (activeLibraries.length > 1) { - params.enable = activeLibraries - .filter((id) => id !== libraryId) - .join(','); - } - - await axios.get('/api/v1/settings/plex/library', { - params, - }); - } else { - await axios.get('/api/v1/settings/plex/library', { - params: { - enable: [...activeLibraries, libraryId].join(','), - }, - }); - } - setIsSyncing(false); - revalidate(); - }; - - if ((!data || !dataTautulli) && !error) { + if ((!plexServers || !dataTautulli) && !error) { return ; } return ( @@ -337,319 +170,14 @@ const SettingsPlex = ({ onComplete }: SettingsPlexProps) => { intl.formatMessage(globalMessages.settings), ]} /> -
    -

    {intl.formatMessage(messages.plexsettings)}

    -

    - {intl.formatMessage(messages.plexsettingsDescription)} -

    - {!!onComplete && ( -
    - ( - - {msg} - - ), - })} - type="info" - /> -
    - )} -
    - { - let toastId: string | null = null; - try { - addToast( - intl.formatMessage(messages.toastPlexConnecting), - { - autoDismiss: false, - appearance: 'info', - }, - (id) => { - toastId = id; - } - ); - await axios.post('/api/v1/settings/plex', { - ip: values.hostname, - port: Number(values.port), - useSsl: values.useSsl, - webAppUrl: values.webAppUrl, - } as PlexSettings); - - syncLibraries(); - - if (toastId) { - removeToast(toastId); - } - addToast(intl.formatMessage(messages.toastPlexConnectingSuccess), { - autoDismiss: true, - appearance: 'success', - }); - - if (onComplete) { - onComplete(); - } - } catch (e) { - if (toastId) { - removeToast(toastId); - } - addToast(intl.formatMessage(messages.toastPlexConnectingFailure), { - autoDismiss: true, - appearance: 'error', - }); + { + revalidatePlex(); + if (onComplete) { + onComplete(); } }} - > - {({ - errors, - touched, - values, - handleSubmit, - setFieldValue, - isSubmitting, - isValid, - }) => { - return ( -
    -
    - -
    -
    - - -
    -
    -
    -
    - -
    -
    - - {values.useSsl ? 'https://' : 'http://'} - - -
    - {errors.hostname && - touched.hostname && - typeof errors.hostname === 'string' && ( -
    {errors.hostname}
    - )} -
    -
    -
    - -
    - - {errors.port && - touched.port && - typeof errors.port === 'string' && ( -
    {errors.port}
    - )} -
    -
    -
    - -
    - { - setFieldValue('useSsl', !values.useSsl); - }} - /> -
    -
    -
    - -
    -
    - -
    - {errors.webAppUrl && - touched.webAppUrl && - typeof errors.webAppUrl === 'string' && ( -
    {errors.webAppUrl}
    - )} -
    -
    -
    -
    - - - -
    -
    -
    - ); - }} -
    -
    -

    - {intl.formatMessage(messages.plexlibraries)} -

    -

    - {intl.formatMessage(messages.plexlibrariesDescription)} -

    -
    -
    - -
      - {data?.libraries.map((library) => ( - toggleLibrary(library.id)} - /> - ))} -
    -
    + />

    {intl.formatMessage(messages.manualscan)}

    @@ -680,6 +208,15 @@ const SettingsPlex = ({ onComplete }: SettingsPlexProps) => {

    {dataSync?.running && ( <> + {dataSync.currentServer && ( +
    + + {intl.formatMessage(messages.currentserver, { + name: dataSync.currentServer.name, + })} + +
    + )} {dataSync.currentLibrary && (
    @@ -710,7 +247,7 @@ const SettingsPlex = ({ onComplete }: SettingsPlexProps) => { +
    +
    + +
    +
    + {showLibraries && server.libraries.length > 0 && ( +
    +
      + {server.libraries.map((library) => ( + onToggleLibrary(library.id)} + /> + ))} +
    +
    + )} +
    +
    +
    + +
    +
    + +
    +
    +
    +
  • + ); +}; + +interface SettingsPlexServersProps { + onComplete?: () => void; +} + +const SettingsPlexServers = ({ onComplete }: SettingsPlexServersProps) => { + const intl = useIntl(); + const { addToast } = useToasts(); + const { + data: plexServers, + error, + mutate: revalidatePlex, + } = useSWR('/api/v1/settings/plex'); + const [editPlexModal, setEditPlexModal] = useState<{ + open: boolean; + plex: PlexSettings | null; + }>({ + open: false, + plex: null, + }); + const [deleteServerModal, setDeleteServerModal] = useState<{ + open: boolean; + serverId: number | null; + }>({ + open: false, + serverId: null, + }); + const [syncingServer, setSyncingServer] = useState(null); + + const deleteServer = async () => { + if (deleteServerModal.serverId === null) return; + + await axios.delete(`/api/v1/settings/plex/${deleteServerModal.serverId}`); + + setDeleteServerModal({ open: false, serverId: null }); + revalidatePlex(); + mutate('/api/v1/settings/plex'); + }; + + const syncLibraries = async (serverId: number) => { + setSyncingServer(serverId); + try { + await axios.post(`/api/v1/settings/plex/${serverId}/libraries/sync`, {}); + revalidatePlex(); + addToast('Libraries synced successfully!', { + appearance: 'success', + autoDismiss: true, + }); + } catch (e) { + addToast('Failed to sync libraries.', { + appearance: 'error', + autoDismiss: true, + }); + } finally { + setSyncingServer(null); + } + }; + + const toggleLibrary = async (serverId: number, libraryId: string) => { + try { + await axios.put( + `/api/v1/settings/plex/${serverId}/libraries/${libraryId}`, + {} // Empty body to satisfy OpenAPI validator + ); + revalidatePlex(); + } catch (e) { + addToast('Failed to toggle library.', { + appearance: 'error', + autoDismiss: true, + }); + } + }; + + return ( + <> + + {editPlexModal.open && ( + setEditPlexModal({ open: false, plex: null })} + onSave={() => { + revalidatePlex(); + mutate('/api/v1/settings/plex'); + setEditPlexModal({ open: false, plex: null }); + if (onComplete) { + onComplete(); + } + }} + /> + )} + {deleteServerModal.open && ( + setDeleteServerModal({ open: false, serverId: null })} + title={intl.formatMessage(messages.deletePlexServer)} + > + {intl.formatMessage(messages.deleteserverconfirm)} + + )} +
    +

    {intl.formatMessage(messages.plexservers)}

    +

    + {intl.formatMessage(messages.plexsettingsDescription)} +

    +
    + {!plexServers && !error && } + {plexServers && !error && ( + <> + {plexServers.length === 0 && ( +
    +

    + {intl.formatMessage(messages.noServersConfigured)} +

    +

    + {intl.formatMessage(messages.noServersConfiguredDescription)} +

    +
    + )} +
      + {plexServers.map((plex) => ( + setEditPlexModal({ open: true, plex })} + onDelete={() => + setDeleteServerModal({ + open: true, + serverId: plex.id, + }) + } + onSync={() => syncLibraries(plex.id)} + onToggleLibrary={(libraryId) => + toggleLibrary(plex.id, libraryId) + } + isSyncing={syncingServer === plex.id} + /> + ))} +
    • +
      + +
      +
    • +
    + + )} + + ); +}; + +export default SettingsPlexServers; diff --git a/src/components/Setup/index.tsx b/src/components/Setup/index.tsx index 3926da90cd..69c8c42ce9 100644 --- a/src/components/Setup/index.tsx +++ b/src/components/Setup/index.tsx @@ -9,6 +9,7 @@ import SettingsServices from '@app/components/Settings/SettingsServices'; import LoginWithPlex from '@app/components/Setup/LoginWithPlex'; import SetupSteps from '@app/components/Setup/SetupSteps'; import useLocale from '@app/hooks/useLocale'; +import type { PlexSettings } from '@server/lib/settings'; import axios from 'axios'; import { useRouter } from 'next/router'; import { useState } from 'react'; @@ -36,6 +37,22 @@ const Setup = () => { const router = useRouter(); const { locale } = useLocale(); + // Fetch Plex servers to check if setup is complete + const { data: plexServers, mutate: revalidatePlexServers } = useSWR< + PlexSettings[] + >(currentStep === 2 ? '/api/v1/settings/plex' : null, { + refreshInterval: 2000, // Poll to detect changes + }); + + // Check if any servers have enabled libraries + const hasConfiguredServers = plexServers?.some( + (server) => + server.libraries.length > 0 && server.libraries.some((lib) => lib.enabled) + ); + + // Allow continuing if user manually set complete OR if servers are already configured + const canContinue = plexSettingsComplete || hasConfiguredServers; + const finishSetup = async () => { setIsUpdating(true); const response = await axios.post<{ initialized: boolean }>( @@ -108,7 +125,12 @@ const Setup = () => { )} {currentStep === 2 && (
    - setPlexSettingsComplete(true)} /> + { + setPlexSettingsComplete(true); + revalidatePlexServers(); + }} + />
    {intl.formatMessage(messages.tip)} @@ -120,7 +142,7 @@ const Setup = () => {
    +
    + +
    +
    + +
    +
    +
    ); diff --git a/src/components/Settings/SettingsPlexServers.tsx b/src/components/Settings/SettingsPlexServers.tsx index 21ddd3713c..f43bea9326 100644 --- a/src/components/Settings/SettingsPlexServers.tsx +++ b/src/components/Settings/SettingsPlexServers.tsx @@ -289,7 +289,7 @@ const SettingsPlexServers = ({ onComplete }: SettingsPlexServersProps) => {

    )} -
      +
        {plexServers.map((plex) => ( {userCount} Plex {userCount, plural, one {user} other {users}} imported successfully!', user: 'User', + server: 'Server', nouserstoimport: 'There are no Plex users to import.', newplexsigninenabled: 'The Enable New Plex Sign-In setting is currently enabled. Plex users with library access do not need to be imported in order to sign in.', }); +// Color palette for server badges +const serverColors = [ + { bg: 'bg-purple-600', border: 'border-purple-500', text: 'text-purple-100' }, + { bg: 'bg-cyan-600', border: 'border-cyan-500', text: 'text-cyan-100' }, + { bg: 'bg-pink-600', border: 'border-pink-500', text: 'text-pink-100' }, + { bg: 'bg-teal-600', border: 'border-teal-500', text: 'text-teal-100' }, + { bg: 'bg-orange-600', border: 'border-orange-500', text: 'text-orange-100' }, + { bg: 'bg-blue-600', border: 'border-blue-500', text: 'text-blue-100' }, + { bg: 'bg-emerald-600', border: 'border-emerald-500', text: 'text-emerald-100' }, + { bg: 'bg-rose-600', border: 'border-rose-500', text: 'text-rose-100' }, +]; + const PlexImportModal = ({ onCancel, onComplete }: PlexImportProps) => { const intl = useIntl(); const settings = useSettings(); @@ -37,11 +50,27 @@ const PlexImportModal = ({ onCancel, onComplete }: PlexImportProps) => { username: string; email: string; thumb: string; + plexServerId?: number; + plexServerName?: string; }[] >(`/api/v1/settings/plex/users`, { revalidateOnMount: true, }); + // Build a map of server IDs to color indices + const serverColorMap = new Map(); + data?.forEach((user) => { + if (user.plexServerId !== undefined && !serverColorMap.has(user.plexServerId)) { + serverColorMap.set(user.plexServerId, serverColorMap.size); + } + }); + + const getServerColor = (serverId?: number) => { + if (serverId === undefined) return null; + const colorIndex = serverColorMap.get(serverId) ?? 0; + return serverColors[colorIndex % serverColors.length]; + }; + const importUsers = async () => { setImporting(true); @@ -162,6 +191,9 @@ const PlexImportModal = ({ onCancel, onComplete }: PlexImportProps) => { {intl.formatMessage(messages.user)} + + {intl.formatMessage(messages.server)} + @@ -219,6 +251,25 @@ const PlexImportModal = ({ onCancel, onComplete }: PlexImportProps) => { + + {(() => { + const color = getServerColor(user.plexServerId); + if (!color || !user.plexServerName) { + return ( + + — + + ); + } + return ( + + {user.plexServerName} + + ); + })()} + ))} diff --git a/src/components/UserList/index.tsx b/src/components/UserList/index.tsx index 5aec7b3d95..17d20b7ecf 100644 --- a/src/components/UserList/index.tsx +++ b/src/components/UserList/index.tsx @@ -21,6 +21,7 @@ import { ChevronRightIcon, InboxArrowDownIcon, PencilIcon, + TrashIcon, UserPlusIcon, } from '@heroicons/react/24/solid'; import type { UserResultsResponse } from '@server/interfaces/api/userInterfaces'; @@ -42,9 +43,15 @@ const messages = defineMessages({ user: 'User', totalrequests: 'Requests', accounttype: 'Type', + server: 'Server', role: 'Role', created: 'Joined', bulkedit: 'Bulk Edit', + bulkdelete: 'Bulk Delete', + bulkdeleteconfirm: + 'Are you sure you want to delete {count} selected users? All of their request data will be permanently removed.', + bulkdeleted: '{count} users deleted successfully!', + bulkdeleteerror: 'Something went wrong while deleting users.', owner: 'Owner', admin: 'Admin', plexuser: 'Plex User', @@ -103,6 +110,32 @@ const UserList = () => { }&sort=${currentSort}` ); + // Fetch Plex servers to map plexServerId to server names + const { data: plexServers } = useSWR< + Array<{ id: number; name: string }> + >('/api/v1/settings/plex'); + + // Color palette for server badges - distinct colors that work well on dark background + const serverColors = [ + { bg: 'bg-purple-600', border: 'border-purple-500', text: 'text-purple-100' }, + { bg: 'bg-cyan-600', border: 'border-cyan-500', text: 'text-cyan-100' }, + { bg: 'bg-pink-600', border: 'border-pink-500', text: 'text-pink-100' }, + { bg: 'bg-teal-600', border: 'border-teal-500', text: 'text-teal-100' }, + { bg: 'bg-orange-600', border: 'border-orange-500', text: 'text-orange-100' }, + { bg: 'bg-blue-600', border: 'border-blue-500', text: 'text-blue-100' }, + { bg: 'bg-emerald-600', border: 'border-emerald-500', text: 'text-emerald-100' }, + { bg: 'bg-rose-600', border: 'border-rose-500', text: 'text-rose-100' }, + ]; + + const getServerInfo = (serverId?: number) => { + if (serverId === undefined || !plexServers) return null; + const serverIndex = plexServers.findIndex((s) => s.id === serverId); + if (serverIndex === -1) return null; + const server = plexServers[serverIndex]; + const colorIndex = serverIndex % serverColors.length; + return { name: server.name, color: serverColors[colorIndex] }; + }; + const [isDeleting, setDeleting] = useState(false); const [showImportModal, setShowImportModal] = useState(false); const [deleteModal, setDeleteModal] = useState<{ @@ -117,6 +150,8 @@ const UserList = () => { isOpen: false, }); const [showBulkEditModal, setShowBulkEditModal] = useState(false); + const [showBulkDeleteModal, setShowBulkDeleteModal] = useState(false); + const [isBulkDeleting, setIsBulkDeleting] = useState(false); const [selectedUsers, setSelectedUsers] = useState([]); useEffect(() => { @@ -194,6 +229,36 @@ const UserList = () => { } }; + const bulkDeleteUsers = async () => { + setIsBulkDeleting(true); + + try { + await Promise.all( + selectedUsers.map((userId) => axios.delete(`/api/v1/user/${userId}`)) + ); + + addToast( + intl.formatMessage(messages.bulkdeleted, { + count: selectedUsers.length, + }), + { + autoDismiss: true, + appearance: 'success', + } + ); + setShowBulkDeleteModal(false); + setSelectedUsers([]); + } catch (e) { + addToast(intl.formatMessage(messages.bulkdeleteerror), { + autoDismiss: true, + appearance: 'error', + }); + } finally { + setIsBulkDeleting(false); + revalidate(); + } + }; + if (!data && !error) { return ; } @@ -255,6 +320,35 @@ const UserList = () => { + + bulkDeleteUsers()} + okText={ + isBulkDeleting + ? intl.formatMessage(globalMessages.deleting) + : intl.formatMessage(globalMessages.delete) + } + okDisabled={isBulkDeleting} + okButtonType="danger" + onCancel={() => setShowBulkDeleteModal(false)} + title={intl.formatMessage(messages.bulkdelete)} + subTitle={`${selectedUsers.length} users selected`} + > + {intl.formatMessage(messages.bulkdeleteconfirm, { + count: selectedUsers.length, + })} + + + { {intl.formatMessage(messages.user)} {intl.formatMessage(messages.totalrequests)} {intl.formatMessage(messages.accounttype)} + {intl.formatMessage(messages.server)} {intl.formatMessage(messages.role)} {intl.formatMessage(messages.created)} {(data.results ?? []).length > 1 && ( - +
        + + +
        )}
        @@ -635,6 +740,21 @@ const UserList = () => { )} + + {(() => { + const serverInfo = getServerInfo(user.plexServerId); + if (!serverInfo) { + return ; + } + return ( + + {serverInfo.name} + + ); + })()} + {user.id === 1 ? intl.formatMessage(messages.owner) diff --git a/src/components/UserProfile/UserSettings/UserGeneralSettings/index.tsx b/src/components/UserProfile/UserSettings/UserGeneralSettings/index.tsx index 842ea7af26..0407dc1932 100644 --- a/src/components/UserProfile/UserSettings/UserGeneralSettings/index.tsx +++ b/src/components/UserProfile/UserSettings/UserGeneralSettings/index.tsx @@ -30,6 +30,7 @@ const messages = defineMessages({ accounttype: 'Account Type', plexuser: 'Plex User', localuser: 'Local User', + plexserver: 'Plex Server', role: 'Role', owner: 'Owner', admin: 'Admin', @@ -81,6 +82,32 @@ const UserGeneralSettings = () => { user ? `/api/v1/user/${user?.id}/settings/main` : null ); + // Fetch Plex servers to map plexServerId to server names + const { data: plexServers } = useSWR>( + '/api/v1/settings/plex' + ); + + // Color palette for server badges + const serverColors = [ + { bg: 'bg-purple-600', border: 'border-purple-500', text: 'text-purple-100' }, + { bg: 'bg-cyan-600', border: 'border-cyan-500', text: 'text-cyan-100' }, + { bg: 'bg-pink-600', border: 'border-pink-500', text: 'text-pink-100' }, + { bg: 'bg-teal-600', border: 'border-teal-500', text: 'text-teal-100' }, + { bg: 'bg-orange-600', border: 'border-orange-500', text: 'text-orange-100' }, + { bg: 'bg-blue-600', border: 'border-blue-500', text: 'text-blue-100' }, + { bg: 'bg-emerald-600', border: 'border-emerald-500', text: 'text-emerald-100' }, + { bg: 'bg-rose-600', border: 'border-rose-500', text: 'text-rose-100' }, + ]; + + const getServerInfo = (serverId?: number) => { + if (serverId === undefined || !plexServers) return null; + const serverIndex = plexServers.findIndex((s) => s.id === serverId); + if (serverIndex === -1) return null; + const server = plexServers[serverIndex]; + const colorIndex = serverIndex % serverColors.length; + return { name: server.name, color: serverColors[colorIndex] }; + }; + const UserGeneralSettingsSchema = Yup.object().shape({ discordId: Yup.string() .nullable() @@ -202,6 +229,32 @@ const UserGeneralSettings = () => { + {user?.userType === UserType.PLEX && ( +
        + +
        +
        + {(() => { + const serverInfo = getServerInfo(user?.plexServerId); + if (!serverInfo) { + return ( + + ); + } + return ( + + {serverInfo.name} + + ); + })()} +
        +
        +
        + )}
        diff --git a/src/components/Settings/SettingsPlexServers.tsx b/src/components/Settings/SettingsPlexServers.tsx index f43bea9326..2ec051bbb9 100644 --- a/src/components/Settings/SettingsPlexServers.tsx +++ b/src/components/Settings/SettingsPlexServers.tsx @@ -248,6 +248,12 @@ const SettingsPlexServers = ({ onComplete }: SettingsPlexServersProps) => { {editPlexModal.open && ( setEditPlexModal({ open: false, plex: null })} onSave={() => { revalidatePlex(); diff --git a/src/components/UserList/index.tsx b/src/components/UserList/index.tsx index 17d20b7ecf..073e6e5d16 100644 --- a/src/components/UserList/index.tsx +++ b/src/components/UserList/index.tsx @@ -127,13 +127,28 @@ const UserList = () => { { bg: 'bg-rose-600', border: 'border-rose-500', text: 'text-rose-100' }, ]; - const getServerInfo = (serverId?: number) => { - if (serverId === undefined || !plexServers) return null; - const serverIndex = plexServers.findIndex((s) => s.id === serverId); - if (serverIndex === -1) return null; - const server = plexServers[serverIndex]; - const colorIndex = serverIndex % serverColors.length; - return { name: server.name, color: serverColors[colorIndex] }; + const getServerInfo = (serverId?: number, cachedName?: string) => { + if (serverId === undefined) return null; + + // Try to get color from server index for consistency + let colorIndex = 0; + let name = cachedName; + + if (plexServers) { + const serverIndex = plexServers.findIndex((s) => s.id === serverId); + if (serverIndex !== -1) { + colorIndex = serverIndex % serverColors.length; + // Use cached name if available, otherwise fall back to server list + if (!name) { + name = plexServers[serverIndex].name; + } + } + } + + // If no name available (old user, no server list), return null + if (!name) return null; + + return { name, color: serverColors[colorIndex] }; }; const [isDeleting, setDeleting] = useState(false); @@ -742,7 +757,7 @@ const UserList = () => {
        {(() => { - const serverInfo = getServerInfo(user.plexServerId); + const serverInfo = getServerInfo(user.plexServerId, user.plexServerName); if (!serverInfo) { return ; } diff --git a/src/components/UserProfile/UserSettings/UserGeneralSettings/index.tsx b/src/components/UserProfile/UserSettings/UserGeneralSettings/index.tsx index 0407dc1932..5dfde76f77 100644 --- a/src/components/UserProfile/UserSettings/UserGeneralSettings/index.tsx +++ b/src/components/UserProfile/UserSettings/UserGeneralSettings/index.tsx @@ -82,30 +82,11 @@ const UserGeneralSettings = () => { user ? `/api/v1/user/${user?.id}/settings/main` : null ); - // Fetch Plex servers to map plexServerId to server names - const { data: plexServers } = useSWR>( - '/api/v1/settings/plex' - ); - - // Color palette for server badges - const serverColors = [ - { bg: 'bg-purple-600', border: 'border-purple-500', text: 'text-purple-100' }, - { bg: 'bg-cyan-600', border: 'border-cyan-500', text: 'text-cyan-100' }, - { bg: 'bg-pink-600', border: 'border-pink-500', text: 'text-pink-100' }, - { bg: 'bg-teal-600', border: 'border-teal-500', text: 'text-teal-100' }, - { bg: 'bg-orange-600', border: 'border-orange-500', text: 'text-orange-100' }, - { bg: 'bg-blue-600', border: 'border-blue-500', text: 'text-blue-100' }, - { bg: 'bg-emerald-600', border: 'border-emerald-500', text: 'text-emerald-100' }, - { bg: 'bg-rose-600', border: 'border-rose-500', text: 'text-rose-100' }, - ]; - - const getServerInfo = (serverId?: number) => { - if (serverId === undefined || !plexServers) return null; - const serverIndex = plexServers.findIndex((s) => s.id === serverId); - if (serverIndex === -1) return null; - const server = plexServers[serverIndex]; - const colorIndex = serverIndex % serverColors.length; - return { name: server.name, color: serverColors[colorIndex] }; + // Color for server badge + const serverBadgeColor = { + bg: 'bg-purple-600', + border: 'border-purple-500', + text: 'text-purple-100', }; const UserGeneralSettingsSchema = Yup.object().shape({ @@ -229,28 +210,18 @@ const UserGeneralSettings = () => { - {user?.userType === UserType.PLEX && ( + {user?.userType === UserType.PLEX && user?.plexServerName && (
        - {(() => { - const serverInfo = getServerInfo(user?.plexServerId); - if (!serverInfo) { - return ( - - ); - } - return ( - - {serverInfo.name} - - ); - })()} + + {user.plexServerName} +
        diff --git a/src/hooks/useUser.ts b/src/hooks/useUser.ts index c918ac227d..d10f036165 100644 --- a/src/hooks/useUser.ts +++ b/src/hooks/useUser.ts @@ -21,6 +21,7 @@ export interface User { updatedAt: Date; requestCount: number; plexServerId?: number; + plexServerName?: string; settings?: UserSettings; } From 9607726babb925219cd09b3c519096187b59881b Mon Sep 17 00:00:00 2001 From: Juan J Chong Date: Mon, 22 Dec 2025 02:51:18 -0800 Subject: [PATCH 4/6] chore: fix linting errors and apply code formatting - Update README to mention multi-Plex server support - Fix Array type syntax to use T[] instead of Array - Remove unused sortBy import - Apply Prettier formatting to modified files --- README.md | 1 + server/api/plextv.ts | 91 +++++++++++++------ .../1750000000001-AddUserPlexServerId.ts | 1 - .../1750000000002-AddUserPlexServerName.ts | 1 - server/routes/settings/index.ts | 5 +- server/routes/settings/plex.ts | 41 ++++----- server/routes/user/index.ts | 18 ++-- src/components/Settings/PlexServerModal.tsx | 26 ++++-- src/components/UserList/PlexImportModal.tsx | 11 ++- src/components/UserList/index.tsx | 29 ++++-- 10 files changed, 143 insertions(+), 81 deletions(-) diff --git a/README.md b/README.md index 900f1d40df..c64bcbdcde 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ ## Current Features - Full Plex integration. Authenticate and manage user access with Plex! +- Support for multiple Plex servers, including servers owned by different accounts. - Easy integration with your existing services. Currently, Overseerr supports Sonarr and Radarr. More to come! - Plex library scan, to keep track of the titles which are already available. - Customizable request system, which allows users to request individual seasons or movies in a friendly, easy-to-use interface. diff --git a/server/api/plextv.ts b/server/api/plextv.ts index 74c7594c2a..0d8a5313ab 100644 --- a/server/api/plextv.ts +++ b/server/api/plextv.ts @@ -279,7 +279,11 @@ class PlexTvAPI extends ExternalAPI { public static async checkUserAccessAnyServer( userId: number, fallbackToken?: string - ): Promise<{ hasAccess: boolean; plexServerId?: number; plexServerName?: string }> { + ): Promise<{ + hasAccess: boolean; + plexServerId?: number; + plexServerName?: string; + }> { const settings = getSettings(); const plexServers = settings.plex; @@ -320,7 +324,11 @@ class PlexTvAPI extends ExternalAPI { `User ${userId} is the owner of server: ${plexServer.name}`, { label: 'Plex.tv API' } ); - return { hasAccess: true, plexServerId: plexServer.id, plexServerName: plexServer.name }; + return { + hasAccess: true, + plexServerId: plexServer.id, + plexServerName: plexServer.name, + }; } } catch (ownerError) { logger.debug( @@ -344,7 +352,11 @@ class PlexTvAPI extends ExternalAPI { `User ${userId} has access via server: ${plexServer.name}`, { label: 'Plex.tv API' } ); - return { hasAccess: true, plexServerId: plexServer.id, plexServerName: plexServer.name }; + return { + hasAccess: true, + plexServerId: plexServer.id, + plexServerName: plexServer.name, + }; } } } catch (e) { @@ -356,9 +368,12 @@ class PlexTvAPI extends ExternalAPI { } } - logger.debug(`User ${userId} does not have access to any configured server`, { - label: 'Plex.tv API', - }); + logger.debug( + `User ${userId} does not have access to any configured server`, + { + label: 'Plex.tv API', + } + ); return { hasAccess: false }; } @@ -369,41 +384,47 @@ class PlexTvAPI extends ExternalAPI { public static async getAllUsersFromAllServers( fallbackToken?: string ): Promise< - Array<{ + { plexId: number; username: string; email: string; thumb: string; plexServerId: number; plexServerName: string; - }> + }[] > { const settings = getSettings(); const plexServers = settings.plex; - const allUsers: Array<{ + const allUsers: { plexId: number; username: string; email: string; thumb: string; plexServerId: number; plexServerName: string; - }> = []; + }[] = []; for (const plexServer of plexServers) { const token = plexServer.authToken || fallbackToken; - logger.debug(`Processing server ${plexServer.name} (id: ${plexServer.id})`, { - label: 'Plex.tv API', - hasToken: !!token, - hasAuthToken: !!plexServer.authToken, - hasMachineId: !!plexServer.machineId, - machineId: plexServer.machineId, - }); + logger.debug( + `Processing server ${plexServer.name} (id: ${plexServer.id})`, + { + label: 'Plex.tv API', + hasToken: !!token, + hasAuthToken: !!plexServer.authToken, + hasMachineId: !!plexServer.machineId, + machineId: plexServer.machineId, + } + ); if (!token || !plexServer.machineId) { - logger.debug(`Skipping server ${plexServer.name}: missing token or machineId`, { - label: 'Plex.tv API', - }); + logger.debug( + `Skipping server ${plexServer.name}: missing token or machineId`, + { + label: 'Plex.tv API', + } + ); continue; } @@ -422,14 +443,20 @@ class PlexTvAPI extends ExternalAPI { plexServerId: plexServer.id, plexServerName: plexServer.name, }); - logger.debug(`Added server owner ${owner.username} for ${plexServer.name}`, { - label: 'Plex.tv API', - }); + logger.debug( + `Added server owner ${owner.username} for ${plexServer.name}`, + { + label: 'Plex.tv API', + } + ); } } catch (ownerError) { - logger.warn(`Failed to get owner info for ${plexServer.name}: ${ownerError.message}`, { - label: 'Plex.tv API', - }); + logger.warn( + `Failed to get owner info for ${plexServer.name}: ${ownerError.message}`, + { + label: 'Plex.tv API', + } + ); } // Get shared users @@ -454,10 +481,14 @@ class PlexTvAPI extends ExternalAPI { } } - logger.debug(`Found ${usersFoundOnThisServer} shared users with access to ${plexServer.name}`, { - label: 'Plex.tv API', - totalUsersInResponse: usersResponse.MediaContainer.User?.length ?? 0, - }); + logger.debug( + `Found ${usersFoundOnThisServer} shared users with access to ${plexServer.name}`, + { + label: 'Plex.tv API', + totalUsersInResponse: + usersResponse.MediaContainer.User?.length ?? 0, + } + ); } catch (e) { logger.warn( `Failed to fetch users from server ${plexServer.name}: ${e.message}`, diff --git a/server/migration/1750000000001-AddUserPlexServerId.ts b/server/migration/1750000000001-AddUserPlexServerId.ts index 136c55148a..112199c4fc 100644 --- a/server/migration/1750000000001-AddUserPlexServerId.ts +++ b/server/migration/1750000000001-AddUserPlexServerId.ts @@ -13,4 +13,3 @@ export class AddUserPlexServerId1750000000001 implements MigrationInterface { await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "plexServerId"`); } } - diff --git a/server/migration/1750000000002-AddUserPlexServerName.ts b/server/migration/1750000000002-AddUserPlexServerName.ts index f18e641847..2a59c01f1d 100644 --- a/server/migration/1750000000002-AddUserPlexServerName.ts +++ b/server/migration/1750000000002-AddUserPlexServerName.ts @@ -13,4 +13,3 @@ export class AddUserPlexServerName1750000000002 implements MigrationInterface { await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "plexServerName"`); } } - diff --git a/server/routes/settings/index.ts b/server/routes/settings/index.ts index 801f77ef23..491fe57766 100644 --- a/server/routes/settings/index.ts +++ b/server/routes/settings/index.ts @@ -2,7 +2,7 @@ import TautulliAPI from '@server/api/tautulli'; import { getRepository } from '@server/datasource'; import Media from '@server/entity/Media'; import { MediaRequest } from '@server/entity/MediaRequest'; -import { User } from '@server/entity/User'; +import type { User } from '@server/entity/User'; import type { LogMessage, LogsResultsResponse, @@ -23,7 +23,7 @@ import { getAppVersion } from '@server/utils/appVersion'; import { Router } from 'express'; import rateLimit from 'express-rate-limit'; import fs from 'fs'; -import { escapeRegExp, merge, omit, set, sortBy } from 'lodash'; +import { escapeRegExp, merge, omit, set } from 'lodash'; import { rescheduleJob } from 'node-schedule'; import path from 'path'; import semver from 'semver'; @@ -119,7 +119,6 @@ settingsRoutes.post('/tautulli', async (req, res, next) => { return res.status(200).json(settings.tautulli); }); - settingsRoutes.get( '/logs', rateLimit({ windowMs: 60 * 1000, max: 50 }), diff --git a/server/routes/settings/plex.ts b/server/routes/settings/plex.ts index de056362b0..7db1a45bef 100644 --- a/server/routes/settings/plex.ts +++ b/server/routes/settings/plex.ts @@ -196,20 +196,20 @@ plexRoutes.get('/users', async (req, res, next) => { admin.plexToken ?? undefined ); - logger.debug(`Found ${allPlexUsers.length} total users across all Plex servers`, { - label: 'Plex', - }); + logger.debug( + `Found ${allPlexUsers.length} total users across all Plex servers`, + { + label: 'Plex', + } + ); // Dedupe by plexId, keeping the first server encountered - const uniquePlexUsers = allPlexUsers.reduce( - (acc, user) => { - if (!acc.find((u) => u.plexId === user.plexId)) { - acc.push(user); - } - return acc; - }, - [] as typeof allPlexUsers - ); + const uniquePlexUsers = allPlexUsers.reduce((acc, user) => { + if (!acc.find((u) => u.plexId === user.plexId)) { + acc.push(user); + } + return acc; + }, [] as typeof allPlexUsers); const unimportedPlexUsers: { id: string; @@ -258,14 +258,11 @@ plexRoutes.get('/users', async (req, res, next) => { } // Count users per server for debugging - const serverCounts = unimportedPlexUsers.reduce( - (acc, user) => { - const serverName = user.plexServerName || 'unknown'; - acc[serverName] = (acc[serverName] || 0) + 1; - return acc; - }, - {} as Record - ); + const serverCounts = unimportedPlexUsers.reduce((acc, user) => { + const serverName = user.plexServerName || 'unknown'; + acc[serverName] = (acc[serverName] || 0) + 1; + return acc; + }, {} as Record); logger.debug( `Found ${unimportedPlexUsers.length} unimported users (${existingUsers.length} already exist)`, @@ -274,7 +271,9 @@ plexRoutes.get('/users', async (req, res, next) => { return res .status(200) - .json(unimportedPlexUsers.sort((a, b) => a.username.localeCompare(b.username))); + .json( + unimportedPlexUsers.sort((a, b) => a.username.localeCompare(b.username)) + ); } catch (e) { logger.error('Something went wrong getting unimported Plex users', { label: 'API', diff --git a/server/routes/user/index.ts b/server/routes/user/index.ts index 80310424ad..919f7026d7 100644 --- a/server/routes/user/index.ts +++ b/server/routes/user/index.ts @@ -501,9 +501,12 @@ router.post( mainUser.plexToken ?? undefined ); - logger.debug(`Import: Found ${allPlexUsers.length} users across all Plex servers`, { - label: 'User Import', - }); + logger.debug( + `Import: Found ${allPlexUsers.length} users across all Plex servers`, + { + label: 'User Import', + } + ); const createdUsers: User[] = []; const updatedUsers: User[] = []; @@ -512,9 +515,12 @@ router.post( for (const plexUser of allPlexUsers) { // Skip if no email if (!plexUser.email) { - logger.debug(`Import: Skipping user ${plexUser.username} - no email`, { - label: 'User Import', - }); + logger.debug( + `Import: Skipping user ${plexUser.username} - no email`, + { + label: 'User Import', + } + ); skippedUsers.push(`${plexUser.username} (no email)`); continue; } diff --git a/src/components/Settings/PlexServerModal.tsx b/src/components/Settings/PlexServerModal.tsx index d31be72a07..206c1e29e3 100644 --- a/src/components/Settings/PlexServerModal.tsx +++ b/src/components/Settings/PlexServerModal.tsx @@ -57,7 +57,8 @@ const messages = defineMessages({ 'The Plex authentication token for the owner of this server. Required for additional servers.', authTokenPlaceholder: 'Plex authentication token (optional)', authTokenPlaceholderRequired: 'Plex authentication token (required)', - validationAuthTokenRequired: 'Server Owner Token is required for additional servers', + validationAuthTokenRequired: + 'Server Owner Token is required for additional servers', }); interface PlexServerModalProps { @@ -67,7 +68,12 @@ interface PlexServerModalProps { isFirstServer?: boolean; // If true, shows server preset dropdown; if false, requires authToken } -const PlexServerModal = ({ onClose, plex, onSave, isFirstServer = true }: PlexServerModalProps) => { +const PlexServerModal = ({ + onClose, + plex, + onSave, + isFirstServer = true, +}: PlexServerModalProps) => { const intl = useIntl(); const { addToast, removeToast } = useToasts(); const [isTesting, setIsTesting] = useState(false); @@ -333,20 +339,20 @@ const PlexServerModal = ({ onClose, plex, onSave, isFirstServer = true }: PlexSe disabled={!server.status} > {` - ${server.name} (${server.address}) - [${ - server.local - ? intl.formatMessage(messages.serverLocal) - : intl.formatMessage(messages.serverRemote) - }]${ + ${server.name} (${server.address}) + [${ + server.local + ? intl.formatMessage(messages.serverLocal) + : intl.formatMessage(messages.serverRemote) + }]${ server.ssl ? ` [${intl.formatMessage( messages.serverSecure )}]` : '' } - ${server.status ? '' : '(' + server.message + ')'} - `} + ${server.status ? '' : '(' + server.message + ')'} + `} ))} diff --git a/src/components/UserList/PlexImportModal.tsx b/src/components/UserList/PlexImportModal.tsx index 589b06a718..958d273f11 100644 --- a/src/components/UserList/PlexImportModal.tsx +++ b/src/components/UserList/PlexImportModal.tsx @@ -33,7 +33,11 @@ const serverColors = [ { bg: 'bg-teal-600', border: 'border-teal-500', text: 'text-teal-100' }, { bg: 'bg-orange-600', border: 'border-orange-500', text: 'text-orange-100' }, { bg: 'bg-blue-600', border: 'border-blue-500', text: 'text-blue-100' }, - { bg: 'bg-emerald-600', border: 'border-emerald-500', text: 'text-emerald-100' }, + { + bg: 'bg-emerald-600', + border: 'border-emerald-500', + text: 'text-emerald-100', + }, { bg: 'bg-rose-600', border: 'border-rose-500', text: 'text-rose-100' }, ]; @@ -60,7 +64,10 @@ const PlexImportModal = ({ onCancel, onComplete }: PlexImportProps) => { // Build a map of server IDs to color indices const serverColorMap = new Map(); data?.forEach((user) => { - if (user.plexServerId !== undefined && !serverColorMap.has(user.plexServerId)) { + if ( + user.plexServerId !== undefined && + !serverColorMap.has(user.plexServerId) + ) { serverColorMap.set(user.plexServerId, serverColorMap.size); } }); diff --git a/src/components/UserList/index.tsx b/src/components/UserList/index.tsx index 073e6e5d16..dc5c386e2b 100644 --- a/src/components/UserList/index.tsx +++ b/src/components/UserList/index.tsx @@ -111,19 +111,31 @@ const UserList = () => { ); // Fetch Plex servers to map plexServerId to server names - const { data: plexServers } = useSWR< - Array<{ id: number; name: string }> - >('/api/v1/settings/plex'); + const { data: plexServers } = useSWR<{ id: number; name: string }[]>( + '/api/v1/settings/plex' + ); // Color palette for server badges - distinct colors that work well on dark background const serverColors = [ - { bg: 'bg-purple-600', border: 'border-purple-500', text: 'text-purple-100' }, + { + bg: 'bg-purple-600', + border: 'border-purple-500', + text: 'text-purple-100', + }, { bg: 'bg-cyan-600', border: 'border-cyan-500', text: 'text-cyan-100' }, { bg: 'bg-pink-600', border: 'border-pink-500', text: 'text-pink-100' }, { bg: 'bg-teal-600', border: 'border-teal-500', text: 'text-teal-100' }, - { bg: 'bg-orange-600', border: 'border-orange-500', text: 'text-orange-100' }, + { + bg: 'bg-orange-600', + border: 'border-orange-500', + text: 'text-orange-100', + }, { bg: 'bg-blue-600', border: 'border-blue-500', text: 'text-blue-100' }, - { bg: 'bg-emerald-600', border: 'border-emerald-500', text: 'text-emerald-100' }, + { + bg: 'bg-emerald-600', + border: 'border-emerald-500', + text: 'text-emerald-100', + }, { bg: 'bg-rose-600', border: 'border-rose-500', text: 'text-rose-100' }, ]; @@ -757,7 +769,10 @@ const UserList = () => {
        {(() => { - const serverInfo = getServerInfo(user.plexServerId, user.plexServerName); + const serverInfo = getServerInfo( + user.plexServerId, + user.plexServerName + ); if (!serverInfo) { return ; } From 9fb67d39922b95b97adaa3aadb558c9b90f0a1cb Mon Sep 17 00:00:00 2001 From: Juan J Chong Date: Mon, 22 Dec 2025 03:02:29 -0800 Subject: [PATCH 5/6] fix: assign admin user to primary Plex server on login - Set plexServerId to 0 for admin on first login - Set plexServerName from primary server configuration - Existing admins get server info updated on next login --- server/routes/auth.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/server/routes/auth.ts b/server/routes/auth.ts index 5690bb7a9b..95d64d2f7c 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -52,6 +52,8 @@ authRoutes.post('/plex', async (req, res, next) => { if (!user && !(await userRepository.count())) { // First user - becomes admin + // Assign to primary server (id 0) if configured + const primaryServer = settings.plex[0]; user = new User({ email: account.email, plexUsername: account.username, @@ -60,6 +62,8 @@ authRoutes.post('/plex', async (req, res, next) => { permissions: Permission.ADMIN, avatar: account.thumb, userType: UserType.PLEX, + plexServerId: 0, + plexServerName: primaryServer?.name, }); await userRepository.save(user); @@ -89,8 +93,14 @@ authRoutes.post('/plex', async (req, res, next) => { (account.email === mainUser.email && !mainUser.plexId); // Use the new multi-server access check with fallback to admin token + // For admin, assign to primary server (id 0) + const primaryServer = settings.plex[0]; const accessResult = isAdmin - ? { hasAccess: true, plexServerId: undefined } + ? { + hasAccess: true, + plexServerId: 0, + plexServerName: primaryServer?.name, + } : await PlexTvAPI.checkUserAccessAnyServer( account.id, mainUser.plexToken ?? undefined From 23baf057c0db258e0d13f7306d2c714247d778b2 Mon Sep 17 00:00:00 2001 From: Juan J Chong Date: Mon, 22 Dec 2025 03:09:56 -0800 Subject: [PATCH 6/6] chore: extract translation keys for multi-Plex server feature - Add 32 new translation keys for server management UI - Add keys for bulk delete, server column, user settings --- src/i18n/locale/en.json | 44 ++++++++++++++++++++++++++++++----------- 1 file changed, 32 insertions(+), 12 deletions(-) diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index e9f3b41187..385ae47f75 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -889,23 +889,35 @@ "components.Settings.SonarrModal.validationProfileRequired": "You must select a quality profile", "components.Settings.SonarrModal.validationRootFolderRequired": "You must select a root folder", "components.Settings.activeProfile": "Active Profile", + "components.Settings.add": "Add Server", + "components.Settings.addplex": "Add Plex Server", "components.Settings.addradarr": "Add Radarr Server", "components.Settings.address": "Address", "components.Settings.addsonarr": "Add Sonarr Server", "components.Settings.advancedTooltip": "Incorrectly configuring this setting may result in broken functionality", + "components.Settings.authToken": "Server Owner Token", + "components.Settings.authTokenPlaceholder": "Plex authentication token (optional)", + "components.Settings.authTokenPlaceholderRequired": "Plex authentication token (required)", + "components.Settings.authTokenTip": "Required for servers owned by different Plex accounts. Leave empty to use the admin token.", + "components.Settings.authTokenTipRequired": "The Plex authentication token for the owner of this server. Required for additional servers.", "components.Settings.cancelscan": "Cancel Scan", "components.Settings.copied": "Copied API key to clipboard.", + "components.Settings.createplex": "Add New Plex Server", "components.Settings.currentlibrary": "Current Library: {name}", + "components.Settings.currentserver": "Current Server: {name}", "components.Settings.default": "Default", "components.Settings.default4k": "Default 4K", + "components.Settings.deletePlexServer": "Delete Plex Server", "components.Settings.deleteServer": "Delete {serverType} Server", "components.Settings.deleteserverconfirm": "Are you sure you want to delete this server?", + "components.Settings.editplex": "Edit Plex Server", "components.Settings.email": "Email", "components.Settings.enablessl": "Use SSL", "components.Settings.experimentalTooltip": "Enabling this setting may result in unexpected application behavior", "components.Settings.externalUrl": "External URL", "components.Settings.hostname": "Hostname or IP Address", "components.Settings.is4k": "4K", + "components.Settings.libraries": "Libraries", "components.Settings.librariesRemaining": "Libraries Remaining: {count}", "components.Settings.manualscan": "Manual Library Scan", "components.Settings.manualscanDescription": "Normally, this will only be run once every 24 hours. Overseerr will check your Plex server's recently added more aggressively. If this is your first time configuring Plex, a one-time full manual library scan is recommended!", @@ -922,54 +934,56 @@ "components.Settings.noDefault4kServer": "A 4K {serverType} server must be marked as default in order to enable users to submit 4K {mediaType} requests.", "components.Settings.noDefaultNon4kServer": "If you only have a single {serverType} server for both non-4K and 4K content (or if you only download 4K content), your {serverType} server should NOT be designated as a 4K server.", "components.Settings.noDefaultServer": "At least one {serverType} server must be marked as default in order for {mediaType} requests to be processed.", + "components.Settings.noServersConfigured": "No Plex servers configured.", + "components.Settings.noServersConfiguredDescription": "Add a Plex server to enable media scanning and user authentication.", "components.Settings.notificationAgentSettingsDescription": "Configure and enable notification agents.", "components.Settings.notifications": "Notifications", "components.Settings.notificationsettings": "Notification Settings", - "components.Settings.notrunning": "Not Running", "components.Settings.plex": "Plex", - "components.Settings.plexlibraries": "Plex Libraries", - "components.Settings.plexlibrariesDescription": "The libraries Overseerr scans for titles. Set up and save your Plex connection settings, then click the button below if no libraries are listed.", + "components.Settings.plexservers": "Plex Servers", "components.Settings.plexsettings": "Plex Settings", - "components.Settings.plexsettingsDescription": "Configure the settings for your Plex server. Overseerr scans your Plex libraries to determine content availability.", + "components.Settings.plexsettingsDescription": "Configure your Plex server(s) below. Overseerr scans your Plex libraries to determine content availability. Users will be granted access if they have access to any configured server.", "components.Settings.port": "Port", "components.Settings.radarrsettings": "Radarr Settings", "components.Settings.restartrequiredTooltip": "Overseerr must be restarted for changes to this setting to take effect", - "components.Settings.scan": "Sync Libraries", - "components.Settings.scanning": "Syncing…", "components.Settings.serverLocal": "local", "components.Settings.serverRemote": "remote", "components.Settings.serverSecure": "secure", + "components.Settings.serverWebAppUrl": "Web App URL", + "components.Settings.serverWebAppUrlTip": "Optionally direct users to the web app on your server instead of the \"hosted\" web app", + "components.Settings.servername": "Server Name", "components.Settings.serverpreset": "Server", "components.Settings.serverpresetLoad": "Press the button to load available servers", "components.Settings.serverpresetManualMessage": "Manual configuration", "components.Settings.serverpresetRefreshing": "Retrieving servers…", "components.Settings.serviceSettingsDescription": "Configure your {serverType} server(s) below. You can connect multiple {serverType} servers, but only two of them can be marked as defaults (one non-4K and one 4K). Administrators are able to override the server used to process new requests prior to approval.", "components.Settings.services": "Services", - "components.Settings.settingUpPlexDescription": "To set up Plex, you can either enter the details manually or select a server retrieved from plex.tv. Press the button to the right of the dropdown to fetch the list of available servers.", "components.Settings.sonarrsettings": "Sonarr Settings", "components.Settings.ssl": "SSL", "components.Settings.startscan": "Start Scan", + "components.Settings.syncLibraries": "Sync Libraries", + "components.Settings.syncing": "Syncing…", "components.Settings.tautulliApiKey": "API Key", "components.Settings.tautulliSettings": "Tautulli Settings", "components.Settings.tautulliSettingsDescription": "Optionally configure the settings for your Tautulli server. Overseerr fetches watch history data for your Plex media from Tautulli.", - "components.Settings.toastPlexConnecting": "Attempting to connect to Plex…", - "components.Settings.toastPlexConnectingFailure": "Failed to connect to Plex.", - "components.Settings.toastPlexConnectingSuccess": "Plex connection established successfully!", + "components.Settings.testConnection": "Test Connection", + "components.Settings.testing": "Testing…", "components.Settings.toastPlexRefresh": "Retrieving server list from Plex…", "components.Settings.toastPlexRefreshFailure": "Failed to retrieve Plex server list.", "components.Settings.toastPlexRefreshSuccess": "Plex server list retrieved successfully!", + "components.Settings.toastPlexTestFailure": "Failed to connect to Plex.", + "components.Settings.toastPlexTestSuccess": "Plex connection established successfully!", "components.Settings.toastTautulliSettingsFailure": "Something went wrong while saving Tautulli settings.", "components.Settings.toastTautulliSettingsSuccess": "Tautulli settings saved successfully!", "components.Settings.urlBase": "URL Base", "components.Settings.validationApiKey": "You must provide an API key", + "components.Settings.validationAuthTokenRequired": "Server Owner Token is required for additional servers", "components.Settings.validationHostnameRequired": "You must provide a valid hostname or IP address", "components.Settings.validationPortRequired": "You must provide a valid port number", "components.Settings.validationUrl": "You must provide a valid URL", "components.Settings.validationUrlBaseLeadingSlash": "URL base must have a leading slash", "components.Settings.validationUrlBaseTrailingSlash": "URL base must not end in a trailing slash", "components.Settings.validationUrlTrailingSlash": "URL must not end in a trailing slash", - "components.Settings.webAppUrl": "Web App URL", - "components.Settings.webAppUrlTip": "Optionally direct users to the web app on your server instead of the \"hosted\" web app", "components.Settings.webhook": "Webhook", "components.Settings.webpush": "Web Push", "components.Setup.configureplex": "Configure Plex", @@ -1036,6 +1050,10 @@ "components.UserList.admin": "Admin", "components.UserList.autogeneratepassword": "Automatically Generate Password", "components.UserList.autogeneratepasswordTip": "Email a server-generated password to the user", + "components.UserList.bulkdelete": "Bulk Delete", + "components.UserList.bulkdeleteconfirm": "Are you sure you want to delete {count} selected users? All of their request data will be permanently removed.", + "components.UserList.bulkdeleted": "{count} users deleted successfully!", + "components.UserList.bulkdeleteerror": "Something went wrong while deleting users.", "components.UserList.bulkedit": "Bulk Edit", "components.UserList.create": "Create", "components.UserList.created": "Joined", @@ -1058,6 +1076,7 @@ "components.UserList.passwordinfodescription": "Configure an application URL and enable email notifications to allow automatic password generation.", "components.UserList.plexuser": "Plex User", "components.UserList.role": "Role", + "components.UserList.server": "Server", "components.UserList.sortCreated": "Join Date", "components.UserList.sortDisplayName": "Display Name", "components.UserList.sortRequests": "Request Count", @@ -1093,6 +1112,7 @@ "components.UserProfile.UserSettings.UserGeneralSettings.originallanguage": "Discover Language", "components.UserProfile.UserSettings.UserGeneralSettings.originallanguageTip": "Filter content by original language", "components.UserProfile.UserSettings.UserGeneralSettings.owner": "Owner", + "components.UserProfile.UserSettings.UserGeneralSettings.plexserver": "Plex Server", "components.UserProfile.UserSettings.UserGeneralSettings.plexuser": "Plex User", "components.UserProfile.UserSettings.UserGeneralSettings.plexwatchlistsyncmovies": "Auto-Request Movies", "components.UserProfile.UserSettings.UserGeneralSettings.plexwatchlistsyncmoviestip": "Automatically request movies on your Plex Watchlist",