diff --git a/.gitignore b/.gitignore index 6e051447e8..66c7a8f528 100644 --- a/.gitignore +++ b/.gitignore @@ -54,6 +54,9 @@ config/db/db.sqlite3-journal # VS Code .vscode/launch.json +# Cursor +.cursor/ + # Cypress cypress.env.json cypress/videos 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/docs/multi-plex-server-implementation.md b/docs/multi-plex-server-implementation.md new file mode 100644 index 0000000000..f0faadcf7d --- /dev/null +++ b/docs/multi-plex-server-implementation.md @@ -0,0 +1,474 @@ +# 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 | +| `server/entity/User.ts` | Modified | Added plexServerId and plexServerName columns | +| `server/migration/1750000000001-AddUserPlexServerId.ts` | New | Migration to add plexServerId to user table | +| `server/migration/1750000000002-AddUserPlexServerName.ts` | New | Migration to add plexServerName to user table | +| `server/routes/user/index.ts` | Modified | User import with server association | +| `server/routes/auth.ts` | Modified | Multi-server access check on login | + +### 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 | +| `src/components/UserList/index.tsx` | Modified | Server column, colored badges, bulk delete | +| `src/components/UserList/PlexImportModal.tsx` | Modified | Server column with colored badges | +| `src/components/UserProfile/UserSettings/UserGeneralSettings/index.tsx` | Modified | Plex Server field for user settings | +| `src/hooks/useUser.ts` | Modified | Added plexServerId to User interface | + +## 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 + +## Multi-Owner Authentication (Implemented) + +### The Problem (Solved) + +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). + +This has now been **fully implemented**. + +### How Multi-Owner Auth Works + +``` +User logs in via Plex OAuth → gets their authToken + ↓ +PlexTvAPI.checkUserAccessAnyServer() is called + ↓ +For each configured Plex server: + ├── Use server's authToken (or fallback to admin token) + ├── Call Plex.tv /api/users with that token + ├── Check if user appears in response + └── If user has access to this server → ALLOW + record plexServerId + ↓ +If no server grants access → DENY +``` + +### Implementation Details + +#### 1. PlexSettings Interface + +```typescript +export interface PlexSettings { + id: number; + name: string; + machineId?: string; + ip: string; + port: number; + useSsl?: boolean; + libraries: Library[]; + webAppUrl?: string; + authToken?: string; // Server owner's Plex token for multi-owner support +} +``` + +#### 2. User Entity + +Added `plexServerId` and `plexServerName` to track which server authenticated the user: + +```typescript +@Column({ nullable: true, select: true }) +public plexServerId?: number; // The Plex server this user was authenticated from + +@Column({ nullable: true, select: true }) +public plexServerName?: string; // Name of the Plex server (denormalized for non-admin access) +``` + +The `plexServerName` is denormalized (stored on the user) so that: + +1. Non-admin users can see their server name without needing access to the admin-only `/api/v1/settings/plex` endpoint +2. Reduces API calls when displaying user information +3. Provides backwards compatibility - old users will have NULL until they next log in + +#### 3. Static Access Check Method + +`PlexTvAPI.checkUserAccessAnyServer()` iterates through all servers: + +```typescript +public static async checkUserAccessAnyServer( + userId: number, + fallbackToken?: string +): Promise<{ hasAccess: boolean; plexServerId?: number; plexServerName?: string }> +``` + +Returns access status, which server granted access, and the server name. + +#### 4. Helper: Get All Users from All Servers + +`PlexTvAPI.getAllUsersFromAllServers()` aggregates users from all configured servers with their server association. + +#### 5. Updated Auth Routes + +Both `/auth/plex` and `/auth/local` now use the new multi-server access check. + +#### 6. Server Configuration UI + +`PlexServerModal` behavior varies by server type: + +**Primary server (id=0)**: + +- Server preset dropdown visible (load servers from Plex.tv) +- Server Owner Token is optional (uses admin token as fallback) + +**Additional servers (id>0)**: + +- Server preset dropdown hidden +- Server Owner Token is **required** with validation +- Different tip text: "Required for additional servers" + +#### 7. User Import Enhancements + +`PlexTvAPI.getAllUsersFromAllServers()` now: + +- Fetches the **server owner** for each server (they don't appear in `/api/users`) +- Returns `plexServerId` and `plexServerName` for each user +- Handles server ID `0` correctly (JavaScript falsy value edge case) + +#### 8. User List UI Enhancements + +The User List (`src/components/UserList/index.tsx`) now displays: + +- **Server column** with colored badges indicating which Plex server each user is from +- **Bulk Delete button** alongside Bulk Edit for mass user deletion +- Color-coded badges using a rotating palette (purple, cyan, pink, teal, orange, blue, emerald, rose) + +The Import Plex Users modal (`src/components/UserList/PlexImportModal.tsx`) now shows: + +- **Server column** with colored badges for each unimported user +- Server owners are included in the import list + +The User General Settings page (`src/components/UserProfile/UserSettings/UserGeneralSettings/index.tsx`) now shows: + +- **Plex Server field** for Plex users displaying which server they're from + +### Backwards Compatibility + +- If `authToken` is not set on a server, the admin's token is used as fallback +- Existing single-server installations continue to work without changes +- `plexServerId` and `plexServerName` on User are nullable - existing users will have NULL +- On next login, users will automatically get `plexServerId` and `plexServerName` populated +- UI gracefully handles NULL values by hiding the server badge + +### 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 Fallback**: Admin token is used when server-specific token is not configured + +## 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 + +## 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 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..0d8a5313ab 100644 --- a/server/api/plextv.ts +++ b/server/api/plextv.ts @@ -225,18 +225,22 @@ class PlexTvAPI extends ExternalAPI { } } + /** + * Check if user has access using THIS instance's token. + * Only checks servers that belong to this token owner. + * @deprecated Use PlexTvAPI.checkUserAccessAnyServer() for multi-owner support + */ 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,15 +249,257 @@ 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; } } + /** + * Check if a user has access to ANY configured Plex server. + * Iterates through all servers and uses each server's authToken to check access. + * Returns the server ID if access is granted, or null if no access. + */ + public static async checkUserAccessAnyServer( + userId: number, + fallbackToken?: string + ): Promise<{ + hasAccess: boolean; + plexServerId?: number; + plexServerName?: string; + }> { + const settings = getSettings(); + const plexServers = settings.plex; + + if (plexServers.length === 0) { + logger.error('No Plex servers configured!'); + return { hasAccess: false }; + } + + // Iterate through each server and check using its token + for (const plexServer of plexServers) { + const token = plexServer.authToken || fallbackToken; + + if (!token) { + logger.debug( + `Skipping server ${plexServer.name}: no auth token configured`, + { label: 'Plex.tv API' } + ); + continue; + } + + if (!plexServer.machineId) { + logger.debug( + `Skipping server ${plexServer.name}: no machine ID configured`, + { label: 'Plex.tv API' } + ); + continue; + } + + try { + const plexTv = new PlexTvAPI(token); + + // First check if this user IS the server owner + // (owners don't appear in getUsers(), they are the token holder) + try { + const owner = await plexTv.getUser(); + if (owner && owner.id === userId) { + logger.info( + `User ${userId} is the owner of server: ${plexServer.name}`, + { label: 'Plex.tv API' } + ); + return { + hasAccess: true, + plexServerId: plexServer.id, + plexServerName: plexServer.name, + }; + } + } catch (ownerError) { + logger.debug( + `Could not get owner info for ${plexServer.name}: ${ownerError.message}`, + { label: 'Plex.tv API' } + ); + } + + // Check shared users + const usersResponse = await plexTv.getUsers(); + const users = usersResponse.MediaContainer.User; + const user = users.find((u) => parseInt(u.$.id) === userId); + + if (user) { + const hasAccess = user.Server?.some( + (server) => server.$.machineIdentifier === plexServer.machineId + ); + + if (hasAccess) { + logger.info( + `User ${userId} has access via server: ${plexServer.name}`, + { label: 'Plex.tv API' } + ); + return { + hasAccess: true, + plexServerId: plexServer.id, + plexServerName: plexServer.name, + }; + } + } + } catch (e) { + logger.warn( + `Failed to check user access on server ${plexServer.name}: ${e.message}`, + { label: 'Plex.tv API' } + ); + // Continue to next server + } + } + + logger.debug( + `User ${userId} does not have access to any configured server`, + { + label: 'Plex.tv API', + } + ); + return { hasAccess: false }; + } + + /** + * Get all users from all configured Plex servers. + * Returns users with their associated server information. + */ + public static async getAllUsersFromAllServers( + fallbackToken?: string + ): Promise< + { + plexId: number; + username: string; + email: string; + thumb: string; + plexServerId: number; + plexServerName: string; + }[] + > { + const settings = getSettings(); + const plexServers = settings.plex; + 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, + } + ); + + if (!token || !plexServer.machineId) { + logger.debug( + `Skipping server ${plexServer.name}: missing token or machineId`, + { + label: 'Plex.tv API', + } + ); + continue; + } + + try { + const plexTv = new PlexTvAPI(token); + + // Get the server owner's info (they won't appear in getUsers()) + try { + const owner = await plexTv.getUser(); + if (owner) { + allUsers.push({ + plexId: owner.id, + username: owner.username || owner.title, + email: owner.email, + thumb: owner.thumb, + plexServerId: plexServer.id, + plexServerName: plexServer.name, + }); + 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', + } + ); + } + + // Get shared users + const usersResponse = await plexTv.getUsers(); + + let usersFoundOnThisServer = 0; + for (const user of usersResponse.MediaContainer.User) { + const hasAccessToThisServer = user.Server?.some( + (server) => server.$.machineIdentifier === plexServer.machineId + ); + + if (hasAccessToThisServer) { + usersFoundOnThisServer++; + allUsers.push({ + plexId: parseInt(user.$.id), + username: user.$.username || user.$.title, + email: user.$.email, + thumb: user.$.thumb, + plexServerId: plexServer.id, + plexServerName: plexServer.name, + }); + } + } + + 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}`, + { label: 'Plex.tv API' } + ); + } + } + + return allUsers; + } + public async getUsers(): Promise { const response = await this.axios.get('/api/users', { transformResponse: [], 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/entity/User.ts b/server/entity/User.ts index 7dbdb31b29..39cd863539 100644 --- a/server/entity/User.ts +++ b/server/entity/User.ts @@ -79,6 +79,12 @@ export class User { @Column({ nullable: true, select: false }) public plexToken?: string; + @Column({ nullable: true, select: true }) + public plexServerId?: number; // The Plex server this user was authenticated from + + @Column({ nullable: true, select: true }) + public plexServerName?: string; // Name of the Plex server (denormalized for non-admin access) + @Column({ type: 'integer', default: 0 }) public permissions = 0; diff --git a/server/index.ts b/server/index.ts index 97f05ab1c9..0a2454e71a 100644 --- a/server/index.ts +++ b/server/index.ts @@ -59,27 +59,40 @@ 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, + }); + + // Use server-specific token if available, fallback to admin token + const token = plexServer.authToken || admin.plexToken; + const plexapi = new PlexAPI({ + plexToken: token, + 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..eec0a3f072 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...`, { @@ -42,7 +50,25 @@ class AvailabilitySync { }); if (admin) { - this.plexClient = new PlexAPI({ plexToken: admin.plexToken }); + this.adminPlexToken = admin.plexToken; + // Initialize PlexAPI clients for all configured servers + // Use server-specific token if available, fallback to admin token + for (const plexServer of this.plexServers) { + const token = plexServer.authToken || admin.plexToken; + if (!token) { + logger.warn( + `Skipping server ${plexServer.name} in availability sync: no auth token`, + { label: 'Availability Sync' } + ); + continue; + } + this.plexClients.push( + new PlexAPI({ + plexToken: token, + plexSettings: plexServer, + }) + ); + } } else { logger.error('An admin is not configured.'); } @@ -562,46 +588,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..c05c0e0010 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,119 @@ 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'); + + // Use server-specific token if available, fallback to admin token + const token = plexServer.authToken || admin.plexToken; + + if (!token) { this.log( - `Beginning to process recently added for library: ${library.name}`, - 'info', - { lastScan: library.lastScan } + `Skipping server ${plexServer.name}: no auth token available`, + 'warn' ); - 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 + continue; + } + + this.plexClient = new PlexAPI({ + plexToken: token, + plexSettings: plexServer, + }); + + this.libraries = plexServer.libraries.filter( + (library) => library.enabled + ); + + if (this.libraries.length === 0) { + this.log( + `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..69ca508322 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; @@ -33,6 +34,7 @@ export interface PlexSettings { useSsl?: boolean; libraries: Library[]; webAppUrl?: string; + authToken?: string; // Server owner's Plex token for multi-owner support } export interface TautulliSettings { @@ -261,7 +263,7 @@ interface AllSettings { vapidPublic: string; vapidPrivate: string; main: MainSettings; - plex: PlexSettings; + plex: PlexSettings[]; tautulli: TautulliSettings; radarr: RadarrSettings[]; sonarr: SonarrSettings[]; @@ -302,13 +304,7 @@ class Settings { partialRequestsEnabled: true, locale: 'en', }, - plex: { - name: '', - ip: '', - port: 32400, - useSsl: false, - libraries: [], - }, + plex: [], tautulli: {}, radarr: [], sonarr: [], @@ -450,11 +446,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 +587,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/migration/1750000000001-AddUserPlexServerId.ts b/server/migration/1750000000001-AddUserPlexServerId.ts new file mode 100644 index 0000000000..112199c4fc --- /dev/null +++ b/server/migration/1750000000001-AddUserPlexServerId.ts @@ -0,0 +1,15 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddUserPlexServerId1750000000001 implements MigrationInterface { + name = 'AddUserPlexServerId1750000000001'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user" ADD COLUMN "plexServerId" integer` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "plexServerId"`); + } +} diff --git a/server/migration/1750000000002-AddUserPlexServerName.ts b/server/migration/1750000000002-AddUserPlexServerName.ts new file mode 100644 index 0000000000..2a59c01f1d --- /dev/null +++ b/server/migration/1750000000002-AddUserPlexServerName.ts @@ -0,0 +1,15 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddUserPlexServerName1750000000002 implements MigrationInterface { + name = 'AddUserPlexServerName1750000000002'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user" ADD COLUMN "plexServerName" varchar` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "plexServerName"`); + } +} diff --git a/server/routes/auth.ts b/server/routes/auth.ts index cb6db87c9c..95d64d2f7c 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -51,6 +51,9 @@ authRoutes.post('/plex', async (req, res, next) => { .getOne(); 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, @@ -59,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); @@ -67,7 +72,6 @@ authRoutes.post('/plex', async (req, res, next) => { select: { id: true, plexToken: true, plexId: true, email: true }, where: { id: 1 }, }); - const mainPlexTv = new PlexTvAPI(mainUser.plexToken ?? ''); if (!account.id) { logger.error('Plex ID was missing from Plex.tv response', { @@ -83,11 +87,26 @@ authRoutes.post('/plex', async (req, res, next) => { }); } - if ( + // Check if user is admin or has access to ANY configured server + const isAdmin = account.id === mainUser.plexId || - (account.email === mainUser.email && !mainUser.plexId) || - (await mainPlexTv.checkUserAccess(account.id)) - ) { + (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: 0, + plexServerName: primaryServer?.name, + } + : await PlexTvAPI.checkUserAccessAnyServer( + account.id, + mainUser.plexToken ?? undefined + ); + + if (isAdmin || accessResult.hasAccess) { if (user) { if (!user.plexId) { logger.info( @@ -109,6 +128,12 @@ authRoutes.post('/plex', async (req, res, next) => { user.email = account.email; user.plexUsername = account.username; user.userType = UserType.PLEX; + // Track which server granted access (if not admin) + // Also update if plexServerName is missing (for backwards compatibility) + if (accessResult.plexServerId !== undefined) { + user.plexServerId = accessResult.plexServerId; + user.plexServerName = accessResult.plexServerName; + } await userRepository.save(user); } else if (!settings.main.newPlexLogin) { @@ -135,6 +160,7 @@ authRoutes.post('/plex', async (req, res, next) => { email: account.email, plexId: account.id, plexUsername: account.username, + plexServerId: accessResult.plexServerId, } ); user = new User({ @@ -145,13 +171,15 @@ authRoutes.post('/plex', async (req, res, next) => { permissions: settings.main.defaultPermissions, avatar: account.thumb, userType: UserType.PLEX, + plexServerId: accessResult.plexServerId, + plexServerName: accessResult.plexServerName, }); await userRepository.save(user); } } else { logger.warn( - 'Failed sign-in attempt by Plex user without access to the media server', + 'Failed sign-in attempt by Plex user without access to any configured media server', { label: 'API', ip: req.ip, @@ -222,21 +250,20 @@ authRoutes.post('/local', async (req, res, next) => { select: { id: true, plexToken: true, plexId: true }, where: { id: 1 }, }); - const mainPlexTv = new PlexTvAPI(mainUser.plexToken ?? ''); if (!user.plexId) { try { - const plexUsersResponse = await mainPlexTv.getUsers(); - const account = plexUsersResponse.MediaContainer.User.find( - (account) => - account.$.email && - account.$.email.toLowerCase() === user.email.toLowerCase() - )?.$; - - if ( - account && - (await mainPlexTv.checkUserAccess(parseInt(account.id))) - ) { + // Try to find matching Plex user across all servers + const allPlexUsers = await PlexTvAPI.getAllUsersFromAllServers( + mainUser.plexToken ?? undefined + ); + const matchingPlexUser = allPlexUsers.find( + (plexUser) => + plexUser.email && + plexUser.email.toLowerCase() === user.email.toLowerCase() + ); + + if (matchingPlexUser) { logger.info( 'Found matching Plex user; updating user with Plex data', { @@ -244,16 +271,19 @@ authRoutes.post('/local', async (req, res, next) => { ip: req.ip, email: body.email, userId: user.id, - plexId: account.id, - plexUsername: account.username, + plexId: matchingPlexUser.plexId, + plexUsername: matchingPlexUser.username, + plexServerId: matchingPlexUser.plexServerId, } ); - user.plexId = parseInt(account.id); - user.avatar = account.thumb; - user.email = account.email; - user.plexUsername = account.username; + user.plexId = matchingPlexUser.plexId; + user.avatar = matchingPlexUser.thumb; + user.email = matchingPlexUser.email; + user.plexUsername = matchingPlexUser.username; user.userType = UserType.PLEX; + user.plexServerId = matchingPlexUser.plexServerId; + user.plexServerName = matchingPlexUser.plexServerName; await userRepository.save(user); } @@ -265,27 +295,43 @@ authRoutes.post('/local', async (req, res, next) => { } } - if ( - user.plexId && - user.plexId !== mainUser.plexId && - !(await mainPlexTv.checkUserAccess(user.plexId)) - ) { - logger.warn( - 'Failed sign-in attempt from Plex user without access to the media server', - { - label: 'API', - account: { - ip: req.ip, - email: body.email, - userId: user.id, - plexId: user.plexId, - }, - } + // Verify user still has access to at least one server + if (user.plexId && user.plexId !== mainUser.plexId) { + const accessResult = await PlexTvAPI.checkUserAccessAnyServer( + user.plexId, + mainUser.plexToken ?? undefined ); - return next({ - status: 403, - message: 'Access denied.', - }); + + if (!accessResult.hasAccess) { + logger.warn( + 'Failed sign-in attempt from Plex user without access to any configured media server', + { + label: 'API', + account: { + ip: req.ip, + email: body.email, + userId: user.id, + plexId: user.plexId, + }, + } + ); + return next({ + status: 403, + message: 'Access denied.', + }); + } + + // Update plexServerId/plexServerName if changed or missing + if (accessResult.plexServerId !== undefined) { + const needsUpdate = + user.plexServerId !== accessResult.plexServerId || + !user.plexServerName; + if (needsUpdate) { + user.plexServerId = accessResult.plexServerId; + user.plexServerName = accessResult.plexServerName; + await userRepository.save(user); + } + } } // Set logged in session diff --git a/server/routes/settings/index.ts b/server/routes/settings/index.ts index 98fe0f7769..491fe57766 100644 --- a/server/routes/settings/index.ts +++ b/server/routes/settings/index.ts @@ -1,11 +1,8 @@ -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 { User } from '@server/entity/User'; import type { LogMessage, LogsResultsResponse, @@ -16,7 +13,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'; @@ -27,18 +23,19 @@ 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'; -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 +82,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(); @@ -280,69 +119,6 @@ settingsRoutes.post('/tautulli', async (req, res, next) => { return res.status(200).json(settings.tautulli); }); -settingsRoutes.get( - '/plex/users', - isAuthenticated(Permission.MANAGE_USERS), - async (req, res, next) => { - const userRepository = getRepository(User); - const qb = userRepository.createQueryBuilder('user'); - - try { - const admin = await userRepository.findOneOrFail({ - select: { id: true, plexToken: true }, - where: { id: 1 }, - }); - const plexApi = new PlexTvAPI(admin.plexToken ?? ''); - const plexUsers = (await plexApi.getUsers()).MediaContainer.User.map( - (user) => user.$ - ).filter((user) => user.email); - - const unimportedPlexUsers: { - id: string; - title: string; - username: string; - email: string; - thumb: string; - }[] = []; - - const existingUsers = await qb - .where('user.plexId IN (:...plexIds)', { - plexIds: plexUsers.map((plexUser) => plexUser.id), - }) - .orWhere('user.email IN (:...plexEmails)', { - plexEmails: plexUsers.map((plexUser) => plexUser.email.toLowerCase()), - }) - .getMany(); - - await Promise.all( - plexUsers.map(async (plexUser) => { - if ( - !existingUsers.find( - (user) => - user.plexId === parseInt(plexUser.id) || - user.email === plexUser.email.toLowerCase() - ) && - (await plexApi.checkUserAccess(parseInt(plexUser.id))) - ) { - unimportedPlexUsers.push(plexUser); - } - }) - ); - - return res.status(200).json(sortBy(unimportedPlexUsers, 'username')); - } catch (e) { - logger.error('Something went wrong getting unimported Plex users', { - label: 'API', - errorMessage: e.message, - }); - next({ - status: 500, - message: 'Unable to retrieve unimported Plex users.', - }); - } - } -); - settingsRoutes.get( '/logs', rateLimit({ windowMs: 60 * 1000, max: 50 }), diff --git a/server/routes/settings/plex.ts b/server/routes/settings/plex.ts new file mode 100644 index 0000000000..7db1a45bef --- /dev/null +++ b/server/routes/settings/plex.ts @@ -0,0 +1,564 @@ +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 }, + }); + + // Use server-specific token if provided, otherwise fallback to admin token + const token = req.body.authToken || admin.plexToken; + + logger.debug('Testing Plex connection', { + label: 'Plex', + ip: req.body.ip, + port: req.body.port, + useSsl: req.body.useSsl, + hasToken: !!token, + usingServerToken: !!req.body.authToken, + }); + + if (!token) { + logger.error('No Plex token available for test', { label: 'Plex' }); + return next({ + status: 400, + message: + 'No authentication token available. Please provide a server owner token or sign in with Plex first.', + }); + } + + const plexClient = new PlexAPI({ + plexToken: token, + 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.', + }); + } +}); + +// GET /users - Fetch unimported Plex users from all servers +plexRoutes.get('/users', async (req, res, next) => { + const userRepository = getRepository(User); + const qb = userRepository.createQueryBuilder('user'); + + try { + const admin = await userRepository.findOneOrFail({ + select: { id: true, plexToken: true }, + where: { id: 1 }, + }); + + // Get users from ALL configured Plex servers (multi-owner support) + const allPlexUsers = await PlexTvAPI.getAllUsersFromAllServers( + admin.plexToken ?? undefined + ); + + 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 unimportedPlexUsers: { + id: string; + title: string; + username: string; + email: string; + thumb: string; + plexServerId: number; + plexServerName: string; + }[] = []; + + if (uniquePlexUsers.length === 0) { + logger.debug('No Plex users found on any server', { label: 'Plex' }); + return res.status(200).json([]); + } + + const existingUsers = await qb + .where('user.plexId IN (:...plexIds)', { + plexIds: uniquePlexUsers.map((plexUser) => plexUser.plexId), + }) + .orWhere('user.email IN (:...plexEmails)', { + plexEmails: uniquePlexUsers + .map((plexUser) => plexUser.email?.toLowerCase()) + .filter(Boolean), + }) + .getMany(); + + for (const plexUser of uniquePlexUsers) { + const alreadyExists = existingUsers.find( + (user) => + user.plexId === plexUser.plexId || + (plexUser.email && user.email === plexUser.email.toLowerCase()) + ); + + if (!alreadyExists) { + unimportedPlexUsers.push({ + id: String(plexUser.plexId), + title: plexUser.username, + username: plexUser.username, + email: plexUser.email, + thumb: plexUser.thumb, + plexServerId: plexUser.plexServerId, + plexServerName: plexUser.plexServerName, + }); + } + } + + // 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); + + logger.debug( + `Found ${unimportedPlexUsers.length} unimported users (${existingUsers.length} already exist)`, + { label: 'Plex', serverCounts } + ); + + return res + .status(200) + .json( + unimportedPlexUsers.sort((a, b) => a.username.localeCompare(b.username)) + ); + } catch (e) { + logger.error('Something went wrong getting unimported Plex users', { + label: 'API', + errorMessage: e.message, + }); + next({ + status: 500, + message: 'Unable to retrieve unimported Plex users.', + }); + } +}); + +// ============================================================ +// 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; + + // Use server-specific token if provided, otherwise fallback to admin token + const token = newPlex.authToken || admin.plexToken; + + const plexClient = new PlexAPI({ + plexToken: token, + 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), + }; + + // Use server-specific token if provided, otherwise fallback to admin token + const token = updatedPlex.authToken || admin.plexToken; + + const plexClient = new PlexAPI({ + plexToken: token, + 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 }, + }); + + // Use server-specific token if available, fallback to admin token + const token = plexServer.authToken || admin.plexToken; + + const plexClient = new PlexAPI({ + plexToken: token, + 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 }, + }); + + // Use server-specific token if available, fallback to admin token + const token = settings.plex[plexIndex].authToken || admin.plexToken; + + const plexClient = new PlexAPI({ + plexToken: token, + 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/server/routes/user/index.ts b/server/routes/user/index.ts index 8bcde77444..919f7026d7 100644 --- a/server/routes/user/index.ts +++ b/server/routes/user/index.ts @@ -491,59 +491,116 @@ router.post( const userRepository = getRepository(User); const body = req.body as { plexIds: string[] } | undefined; - // taken from auth.ts const mainUser = await userRepository.findOneOrFail({ select: { id: true, plexToken: true }, where: { id: 1 }, }); - const mainPlexTv = new PlexTvAPI(mainUser.plexToken ?? ''); - const plexUsersResponse = await mainPlexTv.getUsers(); + // Get users from ALL configured Plex servers (multi-owner support) + const allPlexUsers = await PlexTvAPI.getAllUsersFromAllServers( + mainUser.plexToken ?? undefined + ); + + logger.debug( + `Import: Found ${allPlexUsers.length} users across all Plex servers`, + { + label: 'User Import', + } + ); + const createdUsers: User[] = []; - for (const rawUser of plexUsersResponse.MediaContainer.User) { - const account = rawUser.$; - - if (account.email) { - const user = await userRepository - .createQueryBuilder('user') - .where('user.plexId = :id', { id: account.id }) - .orWhere('user.email = :email', { - email: account.email.toLowerCase(), - }) - .getOne(); - - if (user) { - // Update the user's avatar with their Plex thumbnail, in case it changed - user.avatar = account.thumb; - user.email = account.email; - user.plexUsername = account.username; - - // In case the user was previously a local account - if (user.userType === UserType.LOCAL) { - user.userType = UserType.PLEX; - user.plexId = parseInt(account.id); - } - await userRepository.save(user); - } else if (!body || body.plexIds.includes(account.id)) { - if (await mainPlexTv.checkUserAccess(parseInt(account.id))) { - const newUser = new User({ - plexUsername: account.username, - email: account.email, - permissions: settings.main.defaultPermissions, - plexId: parseInt(account.id), - plexToken: '', - avatar: account.thumb, - userType: UserType.PLEX, - }); - await userRepository.save(newUser); - createdUsers.push(newUser); + const updatedUsers: User[] = []; + const skippedUsers: string[] = []; + + for (const plexUser of allPlexUsers) { + // Skip if no email + if (!plexUser.email) { + logger.debug( + `Import: Skipping user ${plexUser.username} - no email`, + { + label: 'User Import', } + ); + skippedUsers.push(`${plexUser.username} (no email)`); + continue; + } + + // Skip if not in the requested list (when importing specific users) + if (body?.plexIds && !body.plexIds.includes(String(plexUser.plexId))) { + continue; + } + + // Check if user already exists + const existingUser = await userRepository + .createQueryBuilder('user') + .where('user.plexId = :id', { id: plexUser.plexId }) + .orWhere('user.email = :email', { + email: plexUser.email.toLowerCase(), + }) + .getOne(); + + if (existingUser) { + // Update existing user's avatar and info + existingUser.avatar = plexUser.thumb; + existingUser.email = plexUser.email; + existingUser.plexUsername = plexUser.username; + + // In case the user was previously a local account + if (existingUser.userType === UserType.LOCAL) { + existingUser.userType = UserType.PLEX; + existingUser.plexId = plexUser.plexId; + } + + // Update plexServerId/plexServerName if not set or missing + if ( + existingUser.plexServerId === undefined || + !existingUser.plexServerName + ) { + existingUser.plexServerId = plexUser.plexServerId; + existingUser.plexServerName = plexUser.plexServerName; } + + await userRepository.save(existingUser); + updatedUsers.push(existingUser); + + logger.debug( + `Import: Updated existing user ${plexUser.username} (${plexUser.email})`, + { label: 'User Import' } + ); + } else { + // Create new user + const newUser = new User({ + plexUsername: plexUser.username, + email: plexUser.email, + permissions: settings.main.defaultPermissions, + plexId: plexUser.plexId, + plexToken: '', + avatar: plexUser.thumb, + userType: UserType.PLEX, + plexServerId: plexUser.plexServerId, + plexServerName: plexUser.plexServerName, + }); + await userRepository.save(newUser); + createdUsers.push(newUser); + + logger.debug( + `Import: Created new user ${plexUser.username} (${plexUser.email}) from server ${plexUser.plexServerName}`, + { label: 'User Import' } + ); } } + logger.info( + `Import complete: ${createdUsers.length} created, ${updatedUsers.length} updated, ${skippedUsers.length} skipped`, + { label: 'User Import' } + ); + return res.status(201).json(User.filterMany(createdUsers)); } catch (e) { + logger.error('Failed to import users from Plex', { + label: 'User Import', + errorMessage: e.message, + }); next({ status: 500, message: e.message }); } } 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..206c1e29e3 --- /dev/null +++ b/src/components/Settings/PlexServerModal.tsx @@ -0,0 +1,511 @@ +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', + authToken: 'Server Owner Token', + authTokenTip: + 'Required for servers owned by different Plex accounts. Leave empty to use the admin token.', + authTokenTipRequired: + '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', +}); + +interface PlexServerModalProps { + plex: PlexSettings | null; + onClose: () => void; + onSave: () => void; + isFirstServer?: boolean; // If true, shows server preset dropdown; if false, requires authToken +} + +const PlexServerModal = ({ + onClose, + plex, + onSave, + isFirstServer = true, +}: 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)), + authToken: isFirstServer + ? Yup.string().nullable() + : Yup.string().required( + intl.formatMessage(messages.validationAuthTokenRequired) + ), + }); + + 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; + authToken?: string; + }) => { + setIsTesting(true); + try { + await axios.post('/api/v1/settings/plex/test', { + ip: values.hostname, + port: Number(values.port), + useSsl: values.useSsl, + authToken: values.authToken || undefined, + 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, + authToken: values.authToken || 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, + authToken: values.authToken, + }) + } + secondaryDisabled={isTesting || !isValid} + okDisabled={isSubmitting || !isValid} + onOk={() => handleSubmit()} + title={ + !plex + ? intl.formatMessage(messages.createplex) + : intl.formatMessage(messages.editplex) + } + > +
    + {/* Server preset dropdown - only shown for first server */} + {isFirstServer && ( +
    + +
    +
    + + +
    +
    +
    + )} +
    + +
    +
    + +
    +
    +
    +
    + +
    +
    + + {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}
    + )} +
    +
    +
    + +
    +
    + +
    + {errors.authToken && + touched.authToken && + typeof errors.authToken === 'string' && ( +
    {errors.authToken}
    + )} +
    +
    +
    +
    + ); + }} +
    + ); +}; + +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 = () => {
    + + {(() => { + 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..dc5c386e2b 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,59 @@ const UserList = () => { }&sort=${currentSort}` ); + // Fetch Plex servers to map plexServerId to server names + 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-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, 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); const [showImportModal, setShowImportModal] = useState(false); const [deleteModal, setDeleteModal] = useState<{ @@ -117,6 +177,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 +256,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 +347,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 +767,24 @@ const UserList = () => { )} + + {(() => { + const serverInfo = getServerInfo( + user.plexServerId, + user.plexServerName + ); + 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..5dfde76f77 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,13 @@ const UserGeneralSettings = () => { user ? `/api/v1/user/${user?.id}/settings/main` : null ); + // Color for server badge + const serverBadgeColor = { + bg: 'bg-purple-600', + border: 'border-purple-500', + text: 'text-purple-100', + }; + const UserGeneralSettingsSchema = Yup.object().shape({ discordId: Yup.string() .nullable() @@ -202,6 +210,22 @@ const UserGeneralSettings = () => { + {user?.userType === UserType.PLEX && user?.plexServerName && ( +
    + +
    +
    + + {user.plexServerName} + +
    +
    +
    + )}