# Plex Media Server Integration **Status:** ✅ Implemented Connectivity to Plex for OAuth, library management, content detection, and automatic scanning. Database stores all audiobooks from Plex as source of truth for availability. ## Data Flow 1. **Plex Scan Job** → Fetches all audiobooks → Populates DB with `availabilityStatus: 'available'` 2. **Audible Refresh** → Fuzzy matches against Plex data in DB → Sets `availabilityStatus: 'available'` for matches 3. **UI** → Queries DB → Shows "In Your Library" badge → Prevents duplicate requests **Key Principle:** Database reflects Plex content. Audible data matched against this. ## Core Endpoints **GET {server_url}/identity** - Server info (machineIdentifier, version, platform) | Also used for access verification **GET {server_url}/library/sections** - List libraries with IDs and types **GET {server_url}/library/sections/{id}/all?type=9** - All albums (type 9 = audiobooks) **GET {server_url}/library/sections/{id}/all?type=9&sort=addedAt:desc&X-Plex-Container-Start=0&X-Plex-Container-Size=10** - Recently added (lightweight polling) **GET {server_url}/library/sections/{id}/refresh** - Trigger async scan **GET {server_url}/library/metadata/{rating_key}** - Item metadata (includes user's personal rating) **GET {server_url}/library/sections/{id}/search?title={query}** - Search **DELETE {server_url}/library/metadata/{rating_key}** - Delete library item (requires deletion enabled in Plex settings) Auth: `X-Plex-Token` header Response: XML (requires `xml2js` parsing to JSON) API Docs: `/PlexMediaServerAPIDocs.json` **Security:** During OAuth, user's accessible servers are fetched from `plex.tv/api/v2/resources`. Only users with the configured server in their resource list can authenticate. ## Plex OAuth **Base:** `https://plex.tv/api/v2` 1. `POST /pins` → Get PIN id and code 2. Build auth URL: `https://app.plex.tv/auth#?clientID={id}&code={code}` 3. `GET /pins/{id}` → Poll until authToken populated 4. `GET /users/account` → Get user info with token 5. **Security check:** Get server machineIdentifier from configured server 6. **Security check:** Fetch user's accessible servers (`GET plex.tv/api/v2/resources` with user token) 7. **Security check:** Verify configured server's machineIdentifier is in user's resource list 8. Only grant access if server found in user's accessible resources (validates shared access) ## Audiobook Detection - Plex has no dedicated audiobook type - Stored as Music library (type="artist") - Admin selects library during setup - Query with `type=9` for Album-level items (books) - `item.title` = book name, `item.parentTitle` = author ## Library Scanning ### Full Library Scan **Scan Process:** 1. Fetch all audiobooks via API (`type=9`) 2. For each: - Exists by `plexGuid`? Update metadata - New? Create entry in `plex_library` table 3. Match downloaded requests (status: 'downloaded'): - Uses centralized `audiobook-matcher.ts` (ASIN matching, title normalization, narrator support) - Matched → Update request status to 'available' + link plexGuid 4. Return summary (total, new count, updated count, matched downloads) **Trigger:** Scheduled (every 6 hours default) or manual admin action **Note:** Heavy operation, scans entire library ### Recently Added Check (Lightweight Polling) **Process:** 1. Query top 10 items sorted by `addedAt:desc` with pagination 2. For each item: - New? Create in `plex_library` table - Existing? Update metadata 3. Match downloaded requests: - Uses centralized `audiobook-matcher.ts` (same as full scan and homepage) - Searches entire `plex_library` table for matches 4. Return summary (new, updated, matched downloads) **Trigger:** Scheduled (every 5 minutes default), enabled by default **Benefits:** Lightweight polling for new items + comprehensive matching for downloaded requests **Note:** Requests transition: pending → searching → downloading → processing → downloaded → available (after detection) ## Auto-Completion of Stuck Requests Library scans (full and incremental) now check **all non-terminal requests** for matches: **Eligible statuses:** - pending, searching, downloading, processing, downloaded - failed, awaiting_search, awaiting_import, warn **Excluded statuses:** - available (already completed) - cancelled (user cancelled) **Use Case:** 1. Request stuck in 'awaiting_search' or 'failed' status 2. User manually imports audiobook to library (via Plex/ABS or external tool) 3. Next library scan (manual trigger or scheduled recently-added check) 4. Request auto-matches and marks as 'available' 5. Error messages and retry counters cleared **State Cleanup on Match:** - errorMessage → null - searchAttempts → 0 - downloadAttempts → 0 - importAttempts → 0 - completedAt → scan timestamp **Edge Cases:** - Active downloads/jobs continue but become no-ops (download completes, organize skips) - Torrent/NZB remains in download client (manual cleanup if desired) **Logging:** - Transitions from non-downloaded statuses logged with original status: `Match found! "Book" → "Library Book" (was 'failed')` - Provides visibility into which stuck requests were auto-completed ## Data Models ```typescript interface PlexAudiobook { ratingKey: string; guid: string; title: string; author: string; // from parentTitle narrator?: string; duration: number; // ms year?: number; summary?: string; thumb?: string; addedAt: number; updatedAt: number; filePath: string; } interface PlexLibrary { id: string; title: string; type: string; // "artist", "audio" locations: string[]; itemCount: number; } ``` ## BookDate Ratings **Problem:** Library scan runs with system Plex token, storing those ratings in cache. Different users need different ratings for recommendations. **Solution:** 1. **Local admin users:** Use cached ratings (from system Plex token) 2. **Plex-authenticated users (including admins):** Fetch library with user's token to get personal ratings **How Per-User Ratings Work:** - **Key insight:** `/library/sections/{id}/all` returns items with the **authenticated user's ratings** - Plex ratings are tied to user accounts (stored on plex.tv), not the server - When fetched with a user's token, each item includes that user's personal `userRating` - No special permissions needed - works for all authenticated users (admin and non-admin) **Implementation:** - `getLibraryContent(serverUrl, userToken, libraryId)` - Fetches library with user-specific ratings - Returns `PlexAudiobook[]` with `userRating` field specific to the authenticated user - Plex-authenticated users: Fetch full library (~1-2s), match by plexGuid/ratingKey against cached structure - Local admin: Use cached ratings (skip API call, user has no Plex account) **BookDate Integration:** - `enrichWithUserRatings(userId, cachedBooks)` - Determines user type and returns appropriate ratings - Local admin (plexId starts with 'local-') → cached ratings from system token (no API call) - Plex-authenticated (everyone else) → user's plex.tv token + stored machineIdentifier → server access token → fetch library with user's ratings **Notes:** - System Plex token (configured during setup) is used for library scanning, testing, admin operations only - Cached ratings reflect whoever owns that system token - Local admins use cached ratings because they don't have Plex accounts (user.authToken is bcrypt hash) - **Token types:** Plex uses two token types per the API documentation - plex.tv OAuth tokens: For authenticating to plex.tv services - Server access tokens: For talking to individual PMS instances - Must call `/api/v2/resources` with plex.tv token + machineIdentifier to get server-specific access tokens - Each server in user's resources list has its own `accessToken` - **Security:** machineIdentifier stored in Configuration during setup to avoid accessing system token for user operations - BookDate correctly fetches server-specific access tokens without touching the system Plex token ## Fixed Issues ✅ **1. Response Format Handling** - Issue: Server info "unknown", libraries failing to load - Cause: Modern Plex returns JSON when `Accept: application/json` set, not XML - Fix: Added JSON handling alongside XML parsing, optional chaining for `$` attributes **2. OAuth Callback Missing pinId** - Issue: "Missing pinId parameter" after auth - Fix: Modified `getOAuthUrl()` to append pinId to callback URL **3. Scan Architecture** - Issue: Matched requests instead of populating library (0 matches when DB empty) - User Feedback: "Seeing books on homepage I know are in library" - Fix: Rewrote to populate ALL Plex audiobooks to DB as source of truth, Audible matches against this **4. Mapping Artist Instead of Album** - Issue: Author names as titles, undefined authors - Cause: Querying without `type=9` returned Artist items, not Albums - Fix: Added `type=9` parameter, changed `grandparentTitle` to `parentTitle` for author **5. Immediate Plex Search After File Organization (400 Error)** - Issue: organize_files job triggered match_plex immediately after copying files - Cause: Plex hadn't scanned new files yet, search API returned 400 error - User Experience: Error logs despite successful download - Fix: Removed immediate match_plex trigger, changed workflow: - organize_files → status: 'downloaded' (green) - Scheduled scan_plex (every 6 hours) → matches downloaded requests → status: 'available' **6. Recently Added Check Used Different Matching Criteria** - Issue: Recently added check didn't match downloaded requests that full scan matched - Cause: Recently added used AND logic (title >= 70% AND author >= 70%), full scan used weighted average (title × 0.7 + author × 0.3 >= 0.7) - User Experience: "The Tenant" → "The Tenant (Unabridged)" matched in full scan but not in recently added check - Fix: Changed recently added check to use same weighted scoring algorithm as full scan **7. Scan Methods Not Using Centralized Matcher** - Issue: Full scan and recently added check had custom matching logic, different from homepage matcher - Cause: Each component implemented its own fuzzy matching without title normalization, ASIN matching, or narrator support - User Experience: Inconsistent matching behavior across the application - Fix: Both scan methods now use `audiobook-matcher.ts` utility (same as homepage) - ASIN matching: Checks plexGuid for exact ASIN (100% confidence) - Title normalization: Removes "(Unabridged)", "(Abridged)", etc. - Narrator matching: Can match narrator to Plex author field - ASIN filtering: Rejects candidates with wrong ASINs in plexGuid - Consistent 70% weighted threshold everywhere **8. BookDate Token Decryption Failures** - Issue: Decryption errors when fetching user ratings for BookDate recommendations - User Experience: "Failed to decrypt user authToken" / "Failed to decrypt system Plex token" - Cause: Tokens may be stored as plain text (from before encryption implementation or different encryption key) - Fix: Added fallback to use tokens as plain text if decryption fails - User Plex token: Try decrypt, fallback to plain text - System Plex token: Try decrypt, fallback to plain text (before architectural fix) - Allows BookDate to function with both encrypted and plain text tokens **9. BookDate Accessing System Token for User Operations** ⚡ **ARCHITECTURAL FIX** - Issue: Every BookDate user request was decrypting system Plex token to get machineIdentifier - User Experience: Unnecessary decryption operations, security concern (users shouldn't access admin token) - Cause: machineIdentifier was fetched via testConnection() using system token for each user request - Fix: Store machineIdentifier in Configuration during setup, use stored value for user operations - Added `plex_machine_identifier` to Configuration table - Setup/complete route saves machineIdentifier from test-plex response - config.service.ts returns machineIdentifier from config - enrichWithUserRatings() uses stored machineIdentifier (no system token access) - System token now only used for: library scanning, setup, testing, admin operations - User flow: user's plex.tv token + stored machineIdentifier → server access token - Security: Users never access or decrypt the system Plex token **10. OAuth Callback Re-fetching machineIdentifier** ⚡ **ARCHITECTURAL FIX** - Issue: auth/plex/callback route was calling testConnection() to fetch machineIdentifier on every user login - User Experience: Unnecessary Plex API call on every authentication (adds latency, wastes resources) - Cause: Inconsistent architecture - setup/settings save machineIdentifier, but callback re-fetched it - Fix: Use stored machineIdentifier from config (via getPlexConfig().machineIdentifier) - auth/plex/callback now reads from database instead of API call - Consistent with BookDate and other user operations - testConnection() only used for: testing connections, initial fetching during setup/settings - Result: Faster authentication, no unnecessary API calls, consistent architecture ## Library Item Deletion **Endpoint:** `DELETE /library/metadata/{ratingKey}` **Use Case:** When admin deletes a request, also delete from Plex library to keep in sync **Requirements:** - Deletion must be enabled: Settings > Server > Library in Plex webui - Without this setting enabled, DELETE requests will fail **Implementation:** - `deleteItem(serverUrl, authToken, ratingKey)` - Deletes library item by ratingKey - Called during request deletion when backend mode is 'plex' - Extracts ratingKey from audiobook.plexGuid (format: `plex://album/{ratingKey}`) - Mirrors ABS deletion behavior for consistency **Error Handling:** - 404: Item not found (already deleted) - logged but not thrown - Other errors: Logged but deletion continues (prevents blocking request deletion) ## Availability Checking 1. **DB Population:** Plex scan creates/updates records with `plexGuid` + ASIN + `availabilityStatus: 'available'` 2. **Audible Matching:** Real-time ASIN-only matching (100% confidence, exact matches only) 3. **API Enrichment:** Discovery APIs use real-time ASIN matching at query time 4. **UI:** `AudiobookCard` shows "In Your Library" if `isAvailable: true` (ASIN exact match) 5. **Server Validation:** `/api/requests` returns 409 if `availabilityStatus === 'available'` **Match Priority (ASIN-Only):** - ASIN in dedicated field (100% confidence) → Match - ASIN in plexGuid (backward compatibility) → Match - No ASIN match → Return null (no fuzzy fallback) **Note:** Fuzzy matching (70% threshold) is preserved in `ranking-algorithm.ts` for Prowlarr torrent selection, but NOT used for library availability checks. This eliminates false positives (e.g., "Foundation" matching "Foundation and Empire"). ## Tech Stack - axios/node-fetch - xml2js (XML → JSON) - string-similarity (fuzzy matching)