From 590f0897332759be6e9d239b29c4deaf388a3079 Mon Sep 17 00:00:00 2001 From: kikootwo Date: Fri, 30 Jan 2026 15:59:25 -0500 Subject: [PATCH 1/7] Add first-class ebook request support and UI Implements first-class ebook requests with their own type, parent-child relationship to audiobook requests, and separate status flow. Updates database schema and migrations to support 'type' and 'parentRequestId' fields on requests. Adds processors and job types for ebook search and direct HTTP download from Anna's Archive, with FlareSolverr integration for Cloudflare bypass. Enhances admin UI tables and request actions to display and manage ebook requests, including orange badge and source links. Updates documentation to reflect new ebook support, configuration, and behavior. --- CLAUDE.md | 2 + documentation/TABLEOFCONTENTS.md | 12 +- documentation/integrations/ebook-sidecar.md | 386 +++++--------- .../migration.sql | 19 + prisma/schema.prisma | 15 +- .../admin/components/ActiveDownloadsTable.tsx | 20 +- .../admin/components/RecentRequestsTable.tsx | 25 +- .../components/RequestActionsDropdown.tsx | 43 +- src/app/api/admin/downloads/active/route.ts | 39 +- src/app/api/admin/requests/recent/route.ts | 1 + .../audiobooks/request-with-torrent/route.ts | 8 +- src/app/api/bookdate/swipe/route.ts | 3 + .../api/requests/[id]/fetch-ebook/route.ts | 175 +++--- src/app/api/requests/route.ts | 12 +- src/components/requests/RequestCard.tsx | 59 +- src/lib/bookdate/helpers.ts | 4 +- .../cleanup-seeded-torrents.processor.ts | 7 +- .../processors/direct-download.processor.ts | 504 ++++++++++++++++++ .../processors/monitor-rss-feeds.processor.ts | 4 +- .../processors/organize-files.processor.ts | 238 +++++++++ .../plex-recently-added.processor.ts | 4 +- .../retry-failed-imports.processor.ts | 4 +- .../retry-missing-torrents.processor.ts | 4 +- src/lib/processors/scan-plex.processor.ts | 4 +- src/lib/processors/search-ebook.processor.ts | 216 ++++++++ src/lib/services/ebook-scraper.ts | 14 +- src/lib/services/job-queue.service.ts | 138 ++++- src/lib/services/request-delete.service.ts | 318 ++++++----- src/lib/utils/file-organizer.ts | 157 ++++-- src/lib/utils/ranking-algorithm.ts | 155 ++++++ tests/api/requests-actions.routes.test.ts | 114 ++-- .../direct-download.processor.test.ts | 362 +++++++++++++ .../organize-files.processor.test.ts | 6 + .../processors/search-ebook.processor.test.ts | 328 ++++++++++++ tests/services/job-queue.service.test.ts | 14 + tests/services/request-delete.service.test.ts | 3 + tests/utils/file-organizer.test.ts | 59 +- 37 files changed, 2810 insertions(+), 666 deletions(-) create mode 100644 prisma/migrations/20260130000000_add_ebook_request_fields/migration.sql create mode 100644 src/lib/processors/direct-download.processor.ts create mode 100644 src/lib/processors/search-ebook.processor.ts create mode 100644 tests/processors/direct-download.processor.test.ts create mode 100644 tests/processors/search-ebook.processor.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 02c3a7c..ef61d3f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,6 +2,8 @@ **Critical:** This document defines AI-optimized documentation standards and development workflow. **NEVER PERFORM COMMITS ON THE REPOSITORY.** +**ALWAYS DO:** When you feel work is complete, use the docker compose build readmebook to confirm you have no errors. If the build succeeds, then you can tell me it is ready to be tested. + --- ## 1. Token-Efficient Documentation System diff --git a/documentation/TABLEOFCONTENTS.md b/documentation/TABLEOFCONTENTS.md index 725d42d..5373898 100644 --- a/documentation/TABLEOFCONTENTS.md +++ b/documentation/TABLEOFCONTENTS.md @@ -38,10 +38,12 @@ - **Database caching, real-time matching** → [integrations/audible.md](integrations/audible.md) - **Book covers API for login page** → [frontend/pages/login.md](frontend/pages/login.md) -## E-book Sidecar -- **Optional e-book downloads from Anna's Archive** → [integrations/ebook-sidecar.md](integrations/ebook-sidecar.md) +## E-book Support (First-Class) +- **First-class ebook requests, separate tracking** → [integrations/ebook-sidecar.md](integrations/ebook-sidecar.md) - **ASIN-based matching, format selection** → [integrations/ebook-sidecar.md](integrations/ebook-sidecar.md) -- **Non-blocking, atomic failures** → [integrations/ebook-sidecar.md](integrations/ebook-sidecar.md) +- **Ebook ranking algorithm (inverted size scoring)** → [integrations/ebook-sidecar.md](integrations/ebook-sidecar.md) +- **Direct HTTP downloads from Anna's Archive** → [integrations/ebook-sidecar.md](integrations/ebook-sidecar.md) +- **Ebook delete behavior (files only)** → [integrations/ebook-sidecar.md](integrations/ebook-sidecar.md) ## Automation Pipeline - **Full pipeline overview** → [phase3/README.md](phase3/README.md) @@ -108,8 +110,10 @@ **"How do Usenet/NZB downloads work?"** → [phase3/sabnzbd.md](phase3/sabnzbd.md), [backend/services/jobs.md](backend/services/jobs.md) **"Can I use both qBittorrent and SABnzbd?"** → [phase3/download-clients.md](phase3/download-clients.md) **"How does Plex matching work?"** → [integrations/plex.md](integrations/plex.md) -**"How does e-book sidecar work?"** → [integrations/ebook-sidecar.md](integrations/ebook-sidecar.md) +**"How does e-book support work?"** → [integrations/ebook-sidecar.md](integrations/ebook-sidecar.md) **"How do I enable e-book downloads?"** → [integrations/ebook-sidecar.md](integrations/ebook-sidecar.md), [settings-pages.md](settings-pages.md) +**"What happens when I delete an ebook request?"** → [integrations/ebook-sidecar.md](integrations/ebook-sidecar.md#delete-behavior) +**"Why do ebook requests have an orange badge?"** → [integrations/ebook-sidecar.md](integrations/ebook-sidecar.md#ui-representation) **"How do scheduled jobs work?"** → [backend/services/scheduler.md](backend/services/scheduler.md) **"How do I configure external services?"** → [setup-wizard.md](setup-wizard.md), [settings-pages.md](settings-pages.md) **"What's the database schema?"** → [backend/database.md](backend/database.md) diff --git a/documentation/integrations/ebook-sidecar.md b/documentation/integrations/ebook-sidecar.md index a6a8bf5..7e99497 100644 --- a/documentation/integrations/ebook-sidecar.md +++ b/documentation/integrations/ebook-sidecar.md @@ -1,19 +1,29 @@ -# E-book Sidecar +# E-book Support -**Status:** ✅ Implemented | Optional e-book downloads from Anna's Archive +**Status:** ✅ Implemented | First-class ebook requests with Anna's Archive integration ## Overview -Automatically downloads e-books from Anna's Archive to accompany audiobooks, placing them in the same folder. +Ebooks are first-class citizens in RMAB, with their own request type, tracking, and UI representation. When an audiobook request completes, an ebook request is automatically created (if enabled). Ebooks are downloaded directly from Anna's Archive via HTTP. ## Key Details -- **When:** Runs during file organization (after audiobook copied, after cover art) -- **Matching:** ASIN-based search (exact match) -- **Non-blocking:** Failures don't affect audiobook download -- **Atomic:** Either succeeds or fails gracefully -- **Location:** E-book placed in same directory as audiobook -- **Filename:** `[Title] - [Author].[format]` (sanitized) -## Configuration +### First-Class Ebook Requests +- **Request Type:** `type: 'ebook'` (vs `'audiobook'`) +- **Parent Relationship:** Ebook requests are children of audiobook requests (`parentRequestId`) +- **Terminal State:** `downloaded` (ebooks don't have "available" state like audiobooks) +- **UI Badge:** Orange (#f16f19) ebook badge to distinguish from audiobooks +- **Separate Tracking:** Own progress, status, and error handling + +### Flow +1. Audiobook organization completes +2. Ebook request created automatically (if enabled) +3. `search_ebook` job searches Anna's Archive +4. `start_direct_download` downloads via HTTP +5. `organize_files` copies to audiobook folder +6. Request marked as `downloaded` (terminal) +7. "Available" notification sent + +### Configuration **Admin Settings → E-book Sidecar tab** @@ -21,287 +31,155 @@ Automatically downloads e-books from Anna's Archive to accompany audiobooks, pla |-----|---------|---------|-------------| | `ebook_sidecar_enabled` | `false` | `true/false` | Enable feature | | `ebook_sidecar_preferred_format` | `epub` | `epub, pdf, mobi, azw3, any` | Preferred format | -| `ebook_sidecar_base_url` | `https://annas-archive.li` | URL | Base URL (mirror resilience) | -| `ebook_sidecar_flaresolverr_url` | `` (empty) | URL | FlareSolverr proxy URL (optional) | +| `ebook_sidecar_base_url` | `https://annas-archive.li` | URL | Base URL | +| `ebook_sidecar_flaresolverr_url` | `` (empty) | URL | FlareSolverr proxy (optional) | -**Stored in:** `Configuration` table (database) +## Database Schema + +**Request model additions:** +```prisma +type String @default("audiobook") // 'audiobook' | 'ebook' +parentRequestId String? @map("parent_request_id") +parentRequest Request? @relation("EbookParent", fields: [parentRequestId], references: [id]) +childRequests Request[] @relation("EbookParent") +``` + +**Indexes:** `type`, `parentRequestId` + +## Job Processors + +### search_ebook +- Searches Anna's Archive by ASIN first, then title + author +- Creates download history record with `downloadClient: 'direct'` +- Triggers `start_direct_download` job + +### start_direct_download +- Downloads file via HTTP with progress tracking +- Tries multiple slow download links on failure +- Triggers `organize_files` on success + +### monitor_direct_download +- Future use for async download monitoring +- Currently, most tracking happens in start_direct_download + +## Ranking Algorithm + +Ebook ranking (for future multi-source support): +- **Format Score:** 40 pts (exact match) to 10 pts (different format) +- **Size Score:** 30 pts (inverse - smaller files preferred) +- **Source Score:** 30 pts (Anna's Archive gets full score) + +## Delete Behavior + +**Ebook deletion is different from audiobook deletion:** +- Only deletes ebook files (`.epub`, `.pdf`, `.mobi`, etc.) +- Does NOT delete the title folder (audiobook files remain) +- Does NOT delete from backend library (Plex/ABS) +- Does NOT clear audiobook availability linkage +- Soft-deletes the ebook request record + +## UI Representation + +### RequestCard +- Orange ebook badge displayed next to status badge +- Orange book icon for placeholder cover art +- Interactive search disabled (Anna's Archive only) + +### Status Flow +``` +pending → searching → downloading → processing → downloaded (terminal) + ↘ awaiting_search (retry) ↗ +``` ## FlareSolverr Integration -Anna's Archive uses Cloudflare protection which may block direct scraping requests. FlareSolverr solves this by using a headless browser to bypass the protection. - -### What is FlareSolverr? -- Proxy server using headless Chrome/Chromium -- Automatically solves Cloudflare challenges -- Returns HTML content after challenge is solved -- Open source: https://github.com/FlareSolverr/FlareSolverr - -### When to Use FlareSolverr -- **Required:** When e-book downloads consistently fail with no search results -- **Optional:** If direct requests work (depends on Cloudflare's current state) -- **Recommended:** For reliable, consistent downloads +Anna's Archive uses Cloudflare protection. FlareSolverr bypasses this using a headless browser. ### Setup -1. Run FlareSolverr via Docker: - ```bash - docker run -d --name flaresolverr -p 8191:8191 ghcr.io/flaresolverr/flaresolverr:latest - ``` -2. In Admin Settings → E-book Sidecar, enter: `http://localhost:8191` -3. Click "Test Connection" to verify +```bash +docker run -d --name flaresolverr -p 8191:8191 ghcr.io/flaresolverr/flaresolverr:latest +``` -### How It Works -1. Requests are routed through FlareSolverr -2. FlareSolverr loads the page in headless Chrome -3. If Cloudflare challenge appears, it waits for solution -4. HTML is returned after page loads -5. Falls back to direct requests if FlareSolverr fails +Configure URL in Admin Settings → E-book Sidecar: `http://localhost:8191` -### Performance Impact -- **First request:** ~5-10 seconds (browser startup) -- **Subsequent requests:** ~2-5 seconds per page -- **Total time:** ~15-30 seconds per e-book (vs ~5-15 without) +### Performance +- First request: ~5-10 seconds +- Subsequent: ~2-5 seconds per page +- Total: ~15-30 seconds per ebook -## How It Works +## Scraping Strategy -### Flow -1. **Trigger:** File organization completes audiobook copy -2. **Check:** `ebook_sidecar_enabled === 'true'` -3. **Search:** Try ASIN first (if available), then fall back to title + author -4. **Extract MD5:** First search result → MD5 hash -5. **Get Download Links:** Find "no waitlist" slow download links -6. **Extract URL:** Parse slow download page for actual file server URL -7. **Download:** Stream file to audiobook directory -8. **Rename:** Sanitize filename based on metadata - -### Scraping Strategy - -**Method 1: ASIN Search (exact match)** +### Method 1: ASIN Search (exact match) ``` Search: https://annas-archive.li/search?ext=epub&lang=en&q="asin:B09TWSRMCB" ↓ MD5 Page: https://annas-archive.li/md5/[md5] - ↓ (Filter: "slow partner server" links) -Slow Download: https://annas-archive.li/slow_download/[md5]/0/5 - ↓ (Parse for actual download URL) -File Server: http://[server-ip]:port/path/to/file.epub -``` - -**Method 2: Title + Author Search (fallback)** -``` -Search: https://annas-archive.li/search?q=The+Housemaid+Freida+McFadden - &ext=epub - &content=book_nonfiction&content=book_fiction&content=book_unknown - &lang=en ↓ -(Same flow as ASIN search from MD5 page onwards) +Slow Download: https://annas-archive.li/slow_download/[md5]/0/5 + ↓ +File Server: http://[server]/path/to/file.epub ``` -### Matching Priority -1. **ASIN** (exact match - most accurate, if available) -2. **Title + Author** (fuzzy match with book/language filters) - -### Retry Logic -- **Max attempts:** 5 slow download links -- **Timeout:** 60 seconds per download -- **Delays:** 1.5 seconds between requests -- **Retries:** 3x for 5xx errors with exponential backoff - -## Format Support - -| Format | Extension | Recommended | Notes | -|--------|-----------|-------------|-------| -| EPUB | `.epub` | ✅ Yes | Most compatible with e-readers | -| PDF | `.pdf` | ⚠️ Sometimes | Best for fixed-layout books | -| MOBI | `.mobi` | ⚠️ Legacy | Kindle (older devices) | -| AZW3 | `.azw3` | ⚠️ Sometimes | Kindle (newer devices) | -| Any | `[first available]` | ❌ No | Downloads first match | - -**Recommendation:** Use EPUB for maximum compatibility. +### Method 2: Title + Author (fallback) +``` +Search: https://annas-archive.li/search?q=Title+Author&ext=epub&lang=en + ↓ (Same flow from MD5 page) +``` ## File Naming **Pattern:** `[Title] - [Author].[format]` **Sanitization:** -- Remove invalid chars: `<>:"/\|?*` -- Collapse multiple spaces -- Trim leading/trailing spaces and dots -- Limit to 100 characters - -**Examples:** -- `The Housemaid - Freida McFadden.epub` -- `Project Hail Mary - Andy Weir.pdf` +- Remove: `<>:"/\|?*` +- Collapse spaces, trim, limit to 200 chars ## Error Handling -**Graceful Failures (non-blocking):** -- No ASIN available → Skip silently (log info) -- No search results → Log warning, continue audiobook -- No download links → Log warning, continue audiobook -- All downloads fail → Log error, continue audiobook -- Download timeout → Log error, continue audiobook +**Non-blocking errors:** +- No search results → Request goes to `awaiting_search` for retry +- All downloads fail → Same retry behavior +- Audiobook organization never affected -**Never Blocks Audiobook:** -- All e-book errors are non-fatal -- Audiobook organization completes regardless -- Errors logged to job events (visible in admin) +## Technical Files -## Logging +**Processors:** +- `src/lib/processors/search-ebook.processor.ts` +- `src/lib/processors/direct-download.processor.ts` +- `src/lib/processors/organize-files.processor.ts` (ebook branch) -**Success (with FlareSolverr):** -``` -E-book sidecar enabled, searching for e-book... -Using FlareSolverr at http://localhost:8191 -Searching by ASIN: B09TWSRMCB (format: epub)... -Found via ASIN: 3b6f9c0f1665c4ba6e3214d43c37e1de -Found MD5: 3b6f9c0f1665c4ba6e3214d43c37e1de -Found 8 download link(s) -Attempting download link 1/5... -Downloading from: 93.123.118.12 -E-book downloaded: The Housemaid - Freida McFadden.epub -``` +**Services:** +- `src/lib/services/ebook-scraper.ts` +- `src/lib/services/job-queue.service.ts` (ebook job types) -**Success (ASIN match, direct):** -``` -E-book sidecar enabled, searching for e-book... -Searching by ASIN: B09TWSRMCB (format: epub)... -Found via ASIN: 3b6f9c0f1665c4ba6e3214d43c37e1de -Found MD5: 3b6f9c0f1665c4ba6e3214d43c37e1de -Found 8 download link(s) -Attempting download link 1/5... -Downloading from: 93.123.118.12 -E-book downloaded: The Housemaid - Freida McFadden.epub -``` +**Utils:** +- `src/lib/utils/file-organizer.ts` (`organizeEbook` method) +- `src/lib/utils/ranking-algorithm.ts` (`rankEbooks` function) -**Success (Title fallback):** -``` -E-book sidecar enabled, searching for e-book... -Searching by ASIN: B09TWSRMCB (format: epub)... -No results for ASIN, falling back to title + author search... -Searching by title + author: "The Housemaid" by Freida McFadden... -Found via title search: 3b6f9c0f1665c4ba6e3214d43c37e1de -Found MD5: 3b6f9c0f1665c4ba6e3214d43c37e1de -Found 8 download link(s) -E-book downloaded: The Housemaid - Freida McFadden.epub -``` +**UI:** +- `src/components/requests/RequestCard.tsx` (ebook badge) -**Failure:** -``` -E-book sidecar enabled, searching for e-book... -Searching by ASIN: B09TWSRMCB (format: epub)... -No results for ASIN, falling back to title + author search... -Searching by title + author: "The Housemaid" by Freida McFadden... -No search results found for title: "The Housemaid" by Freida McFadden -E-book download failed: No search results found (tried ASIN and title+author) -``` +**Delete:** +- `src/lib/services/request-delete.service.ts` (ebook-specific logic) -## Troubleshooting +## Format Support -### E-book Not Downloaded - -**Cause:** No matching e-book in Anna's Archive (tried ASIN and title+author) -**Solution:** Not all audiobooks have e-book equivalents, this is expected - -**Cause:** ASIN mismatch (Anna's Archive has different ASIN) -**Solution:** Feature now automatically falls back to title + author search - -**Cause:** All download links failed -**Solution:** Check job logs for errors, may be temporary server issues - -### Wrong Format Downloaded - -**Cause:** Preferred format not available -**Solution:** Anna's Archive doesn't have that format, falls back to available format - -### Download Timeout - -**Cause:** Slow file server or large file -**Solution:** Automatic retry with next download link - -### Feature Not Working - -**Cause:** Feature disabled -**Solution:** Admin Settings → E-book Sidecar → Enable toggle - -### Cloudflare Blocking - -**Cause:** Anna's Archive has Cloudflare protection enabled -**Solution:** Configure FlareSolverr (see FlareSolverr Integration section) - -**Symptoms:** -- No search results found -- Requests timing out -- Errors about Cloudflare challenge - -### FlareSolverr Not Working - -**Cause:** FlareSolverr not running or unreachable -**Solution:** -1. Verify FlareSolverr is running: `docker ps | grep flaresolverr` -2. Check URL is correct (usually `http://localhost:8191`) -3. Test connection in Admin Settings - -**Cause:** FlareSolverr timing out -**Solution:** FlareSolverr may need more time; check container logs for errors - -## Security & Legal - -**Important Notes:** -- Anna's Archive is a shadow library -- Use at your own discretion and responsibility -- Ensure compliance with local laws and regulations -- Feature is optional and disabled by default -- No API key required (web scraping) - -**Privacy:** -- User-Agent: `ReadMeABook/1.0 (Audiobook Automation)` -- No tracking or analytics -- Distributed (each user scrapes for themselves) - -## Technical Implementation - -**Files:** -- Service: `src/lib/services/ebook-scraper.ts` -- Integration: `src/lib/utils/file-organizer.ts` (line 265+) -- Settings API: `src/app/api/admin/settings/ebook/route.ts` -- FlareSolverr Test API: `src/app/api/admin/settings/ebook/test-flaresolverr/route.ts` -- UI: `src/app/admin/settings/page.tsx` (ebook tab) - -**Dependencies:** -- axios (HTTP requests) -- cheerio (HTML parsing) -- fs/promises (file operations) - -**Caching:** -- MD5 lookups cached in-memory (prevents re-scraping same ASIN) -- Cache cleared on service restart - -## Performance - -**Impact:** -- **Network:** 3-5 requests per e-book (search, MD5, slow download pages) -- **Time:** ~5-15 seconds per e-book (depends on file server) -- **Storage:** E-books typically 1-50 MB -- **CPU:** Minimal (streaming download) +| Format | Extension | Recommended | +|--------|-----------|-------------| +| EPUB | `.epub` | ✅ Yes | +| PDF | `.pdf` | ⚠️ Sometimes | +| MOBI | `.mobi` | ⚠️ Legacy | +| AZW3 | `.azw3` | ⚠️ Sometimes | ## Limitations -1. **Match Accuracy:** Title + author search may return wrong book if title is common -2. **Format Availability:** Depends on Anna's Archive catalog -3. **Download Speed:** Depends on file server load -4. **Language:** Title search filters for English books only -5. **Success Rate:** ~70-90% (ASIN has higher accuracy, title fallback is less precise) - -## Future Enhancements - -- ISBN-13 fallback matching (between ASIN and title search) -- Format preference priority list (try EPUB, then PDF, then MOBI) -- Per-request override (API endpoint) -- Statistics tracking (success rate, formats, match method) -- Rate limit monitoring -- Relevance scoring for title search results +1. Single source (Anna's Archive) - future Prowlarr support stubbed +2. Title search may return wrong book for common titles +3. Download speed depends on file server load +4. English books only (title search filter) ## Related -- [File Organization](../phase3/file-organization.md) - Where e-book download happens +- [File Organization](../phase3/file-organization.md) - Ebook organization - [Settings Pages](../settings-pages.md) - Configuration UI -- [Configuration Service](../backend/services/config.md) - Settings storage +- [Ranking Algorithm](../phase3/ranking-algorithm.md) - Ebook ranking +- [Request Deletion](../admin-features/request-deletion.md) - Delete behavior diff --git a/prisma/migrations/20260130000000_add_ebook_request_fields/migration.sql b/prisma/migrations/20260130000000_add_ebook_request_fields/migration.sql new file mode 100644 index 0000000..e2b78e2 --- /dev/null +++ b/prisma/migrations/20260130000000_add_ebook_request_fields/migration.sql @@ -0,0 +1,19 @@ +-- AlterTable +ALTER TABLE "requests" ADD COLUMN IF NOT EXISTS "type" TEXT NOT NULL DEFAULT 'audiobook'; +ALTER TABLE "requests" ADD COLUMN IF NOT EXISTS "parent_request_id" TEXT; + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "requests_type_idx" ON "requests"("type"); +CREATE INDEX IF NOT EXISTS "requests_parent_request_id_idx" ON "requests"("parent_request_id"); + +-- AddForeignKey (with ON DELETE SET NULL) +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'requests_parent_request_id_fkey' + ) THEN + ALTER TABLE "requests" ADD CONSTRAINT "requests_parent_request_id_fkey" + FOREIGN KEY ("parent_request_id") REFERENCES "requests"("id") ON DELETE SET NULL ON UPDATE CASCADE; + END IF; +END $$; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 77e5692..a37cb95 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -212,7 +212,8 @@ model Request { audiobookId String @map("audiobook_id") status String @default("pending") // Status values: pending, awaiting_approval, denied, searching, downloading, processing, downloaded, available, failed, cancelled, awaiting_search, awaiting_import, warn - // Flow: pending → searching → downloading → processing → downloaded → available (when matched in Plex) + // Flow (audiobook): pending → searching → downloading → processing → downloaded → available (when matched in Plex) + // Flow (ebook): pending → searching → downloading → processing → downloaded (terminal - no available state) progress Int @default(0) // 0-100 priority Int @default(0) errorMessage String? @map("error_message") @db.Text @@ -227,6 +228,11 @@ model Request { updatedAt DateTime @updatedAt @map("updated_at") completedAt DateTime? @map("completed_at") + // Request type: 'audiobook' (default) or 'ebook' + // Ebook requests are created automatically when an audiobook is organized (if ebook downloads enabled) + type String @default("audiobook") // 'audiobook' | 'ebook' + parentRequestId String? @map("parent_request_id") // Links ebook request to originating audiobook request + // Soft delete support deletedAt DateTime? @map("deleted_at") deletedBy String? @map("deleted_by") // Admin user ID @@ -236,12 +242,16 @@ model Request { audiobook Audiobook @relation(fields: [audiobookId], references: [id], onDelete: Cascade) downloadHistory DownloadHistory[] jobs Job[] + parentRequest Request? @relation("EbookParent", fields: [parentRequestId], references: [id], onDelete: SetNull) + childRequests Request[] @relation("EbookParent") @@index([userId]) @@index([audiobookId]) @@index([status]) @@index([createdAt(sort: Desc)]) @@index([deletedAt]) + @@index([type]) + @@index([parentRequestId]) @@map("requests") } @@ -260,7 +270,7 @@ model DownloadHistory { leechers Int? qualityScore Int? @map("quality_score") selected Boolean @default(false) - downloadClient String? @map("download_client") // qbittorrent, sabnzbd + downloadClient String? @map("download_client") // qbittorrent, sabnzbd, direct (HTTP download for ebooks) downloadClientId String? @map("download_client_id") downloadStatus String? @map("download_status") // Status values: queued, downloading, completed, failed, stalled @@ -302,6 +312,7 @@ model Job { requestId String? @map("request_id") type String // Job types: search_indexers, monitor_download, organize_files, scan_plex, plex_recently_added_check, match_plex + // Ebook job types: search_ebook, start_direct_download, monitor_direct_download status String @default("pending") // Status values: pending, active, completed, failed, delayed, stuck priority Int @default(0) diff --git a/src/app/admin/components/ActiveDownloadsTable.tsx b/src/app/admin/components/ActiveDownloadsTable.tsx index afdad54..a8abc8f 100644 --- a/src/app/admin/components/ActiveDownloadsTable.tsx +++ b/src/app/admin/components/ActiveDownloadsTable.tsx @@ -16,6 +16,7 @@ interface ActiveDownload { eta: number | null; user: string; startedAt: Date; + type?: 'audiobook' | 'ebook'; } interface ActiveDownloadsTableProps { @@ -77,7 +78,7 @@ export function ActiveDownloadsTable({ downloads }: ActiveDownloadsTableProps) { - Audiobook + Request User @@ -104,8 +105,21 @@ export function ActiveDownloadsTable({ downloads }: ActiveDownloadsTableProps) { >
-
- {download.title} +
+ + {download.title} + + {download.type === 'ebook' && ( + + + + + Ebook + + )}
{download.author} diff --git a/src/app/admin/components/RecentRequestsTable.tsx b/src/app/admin/components/RecentRequestsTable.tsx index f165669..d09557f 100644 --- a/src/app/admin/components/RecentRequestsTable.tsx +++ b/src/app/admin/components/RecentRequestsTable.tsx @@ -18,6 +18,7 @@ interface RecentRequest { title: string; author: string; status: string; + type?: 'audiobook' | 'ebook'; user: string; createdAt: Date; completedAt: Date | null; @@ -237,7 +238,7 @@ export function RecentRequestsTable({ requests, ebookSidecarEnabled = false }: R - Audiobook + Request User @@ -264,8 +265,21 @@ export function RecentRequestsTable({ requests, ebookSidecarEnabled = false }: R >
-
- {request.title} +
+ + {request.title} + + {request.type === 'ebook' && ( + + + + + Ebook + + )}
{request.author} @@ -280,7 +294,9 @@ export function RecentRequestsTable({ requests, ebookSidecarEnabled = false }: R {request.user} - {getStatusBadge(request.status)} + + {getStatusBadge(request.status)} + {formatDistanceToNow(new Date(request.createdAt), { addSuffix: true })} @@ -298,6 +314,7 @@ export function RecentRequestsTable({ requests, ebookSidecarEnabled = false }: R title: request.title, author: request.author, status: request.status, + type: request.type, torrentUrl: request.torrentUrl, }} onDelete={handleDeleteClick} diff --git a/src/app/admin/components/RequestActionsDropdown.tsx b/src/app/admin/components/RequestActionsDropdown.tsx index f6bf79d..face4fe 100644 --- a/src/app/admin/components/RequestActionsDropdown.tsx +++ b/src/app/admin/components/RequestActionsDropdown.tsx @@ -18,6 +18,7 @@ export interface RequestActionsDropdownProps { title: string; author: string; status: string; + type?: 'audiobook' | 'ebook'; torrentUrl?: string | null; }; onDelete: (requestId: string, title: string) => void; @@ -41,15 +42,41 @@ export function RequestActionsDropdown({ const [showInteractiveSearch, setShowInteractiveSearch] = useState(false); const { containerRef, dropdownRef, positionAbove, style } = useSmartDropdownPosition(isOpen); - // Determine available actions based on status - const canSearch = ['pending', 'failed', 'awaiting_search'].includes(request.status); + // Determine request type + const isEbook = request.type === 'ebook'; + + // Determine available actions based on status and type + // Ebooks don't support manual/interactive search (Anna's Archive only) + const canSearch = !isEbook && ['pending', 'failed', 'awaiting_search'].includes(request.status); const canCancel = ['pending', 'searching', 'downloading'].includes(request.status); const canDelete = true; // Admins can always delete - // Only show "View Source" if we have a valid indexer page URL (not a magnet link) - const canViewSource = !!request.torrentUrl && - !request.torrentUrl.startsWith('magnet:') && + + // View Source: For ebooks, extract MD5 from slow download URL and link to Anna's Archive + // For audiobooks, show indexer page URL (not magnet links) + let viewSourceUrl: string | null = null; + if (isEbook && request.torrentUrl) { + // torrentUrl for ebooks is JSON array of slow download URLs + // Extract MD5 from URL pattern: /slow_download/[md5]/... + try { + const urls = JSON.parse(request.torrentUrl); + if (Array.isArray(urls) && urls.length > 0) { + const md5Match = urls[0].match(/\/slow_download\/([a-f0-9]{32})\//i); + if (md5Match) { + viewSourceUrl = `https://annas-archive.li/md5/${md5Match[1]}`; + } + } + } catch { + // Not JSON, ignore + } + } else if (request.torrentUrl && !request.torrentUrl.startsWith('magnet:')) { + viewSourceUrl = request.torrentUrl; + } + + const canViewSource = !!viewSourceUrl && ['downloading', 'processing', 'downloaded', 'available'].includes(request.status); - const canFetchEbook = ebookSidecarEnabled && ['downloaded', 'available'].includes(request.status); + + // "Try to fetch Ebook" only for audiobook requests + const canFetchEbook = !isEbook && ebookSidecarEnabled && ['downloaded', 'available'].includes(request.status); // Close dropdown when clicking outside useEffect(() => { @@ -166,9 +193,9 @@ export function RequestActionsDropdown({ )} {/* View Source */} - {canViewSource && ( + {canViewSource && viewSourceUrl && ( setIsOpen(false)} diff --git a/src/app/api/admin/downloads/active/route.ts b/src/app/api/admin/downloads/active/route.ts index c19ecee..9726179 100644 --- a/src/app/api/admin/downloads/active/route.ts +++ b/src/app/api/admin/downloads/active/route.ts @@ -17,7 +17,7 @@ export async function GET(request: NextRequest) { return requireAuth(request, async (req: AuthenticatedRequest) => { return requireAdmin(req, async () => { try { - // Get active downloads with related data + // Get active downloads with related data (both audiobook and ebook) const activeDownloads = await prisma.request.findMany({ where: { status: 'downloading', @@ -26,6 +26,7 @@ export async function GET(request: NextRequest) { select: { id: true, status: true, + type: true, // 'audiobook' or 'ebook' progress: true, updatedAt: true, audiobook: { @@ -54,6 +55,8 @@ export async function GET(request: NextRequest) { torrentName: true, torrentHash: true, nzbId: true, + downloadClient: true, // qbittorrent, sabnzbd, or direct + torrentSizeBytes: true, startedAt: true, createdAt: true, }, @@ -75,19 +78,38 @@ export async function GET(request: NextRequest) { let speed = 0; let eta: number | null = null; + const downloadHistory = download.downloadHistory[0]; + const downloadClient = downloadHistory?.downloadClient; + try { - if (clientType === 'qbittorrent') { + if (downloadClient === 'direct') { + // Direct HTTP download (ebooks) - estimate speed from progress and time elapsed + const startedAt = downloadHistory?.startedAt || downloadHistory?.createdAt; + const totalSize = downloadHistory?.torrentSizeBytes ? Number(downloadHistory.torrentSizeBytes) : 0; + + if (startedAt && download.progress > 0 && totalSize > 0) { + const elapsedMs = Date.now() - new Date(startedAt).getTime(); + const elapsedSeconds = elapsedMs / 1000; + const bytesDownloaded = (download.progress / 100) * totalSize; + + if (elapsedSeconds > 0) { + speed = Math.round(bytesDownloaded / elapsedSeconds); + const remainingBytes = totalSize - bytesDownloaded; + eta = speed > 0 ? Math.round(remainingBytes / speed) : null; + } + } + } else if (downloadClient === 'qbittorrent' || (!downloadClient && clientType === 'qbittorrent')) { // Get torrent hash from download history - const torrentHash = download.downloadHistory[0]?.torrentHash; + const torrentHash = downloadHistory?.torrentHash; if (torrentHash) { const qbService = await getQBittorrentService(); const torrentInfo = await qbService.getTorrent(torrentHash); speed = torrentInfo.dlspeed; eta = torrentInfo.eta > 0 ? torrentInfo.eta : null; } - } else if (clientType === 'sabnzbd') { + } else if (downloadClient === 'sabnzbd' || (!downloadClient && clientType === 'sabnzbd')) { // Get NZB ID from download history - const nzbId = download.downloadHistory[0]?.nzbId; + const nzbId = downloadHistory?.nzbId; if (nzbId) { const sabnzbdService = await getSABnzbdService(); const nzbInfo = await sabnzbdService.getNZB(nzbId); @@ -107,13 +129,14 @@ export async function GET(request: NextRequest) { title: download.audiobook.title, author: download.audiobook.author, status: download.status, + type: download.type, // 'audiobook' or 'ebook' progress: download.progress, speed, eta, - torrentName: download.downloadHistory[0]?.torrentName || null, - downloadStatus: download.downloadHistory[0]?.downloadStatus || null, + torrentName: downloadHistory?.torrentName || null, + downloadStatus: downloadHistory?.downloadStatus || null, user: download.user.plexUsername, - startedAt: download.downloadHistory[0]?.startedAt || download.downloadHistory[0]?.createdAt || download.updatedAt, + startedAt: downloadHistory?.startedAt || downloadHistory?.createdAt || download.updatedAt, }; }) ); diff --git a/src/app/api/admin/requests/recent/route.ts b/src/app/api/admin/requests/recent/route.ts index fe99aa4..e84ed4b 100644 --- a/src/app/api/admin/requests/recent/route.ts +++ b/src/app/api/admin/requests/recent/route.ts @@ -55,6 +55,7 @@ export async function GET(request: NextRequest) { title: request.audiobook.title, author: request.audiobook.author, status: request.status, + type: request.type, // 'audiobook' or 'ebook' user: request.user.plexUsername, createdAt: request.createdAt, completedAt: request.completedAt, diff --git a/src/app/api/audiobooks/request-with-torrent/route.ts b/src/app/api/audiobooks/request-with-torrent/route.ts index b2cdab7..5a66a2a 100644 --- a/src/app/api/audiobooks/request-with-torrent/route.ts +++ b/src/app/api/audiobooks/request-with-torrent/route.ts @@ -61,13 +61,14 @@ export async function POST(request: NextRequest) { const body = await req.json(); const { audiobook, torrent } = RequestWithTorrentSchema.parse(body); - // First check: Is there an existing request in 'downloaded' or 'available' status? + // First check: Is there an existing audiobook request in 'downloaded' or 'available' status? // This catches the gap where files are organized but Plex hasn't scanned yet const existingActiveRequest = await prisma.request.findFirst({ where: { audiobook: { audibleAsin: audiobook.asin, }, + type: 'audiobook', // Only check audiobook requests (ebook requests are separate) status: { in: ['downloaded', 'available'] }, deletedAt: null, }, @@ -181,11 +182,12 @@ export async function POST(request: NextRequest) { logger.debug(`Updated audiobook ${audiobookRecord.id} with year: ${year || 'unchanged'}, series: ${series || 'unchanged'}`); } - // Check if user already has an active (non-deleted) request for this audiobook + // Check if user already has an active (non-deleted) audiobook request for this audiobook const existingRequest = await prisma.request.findFirst({ where: { userId: req.user.id, audiobookId: audiobookRecord.id, + type: 'audiobook', // Only check audiobook requests (ebook requests are separate) deletedAt: null, // Only check active requests }, }); @@ -263,6 +265,7 @@ export async function POST(request: NextRequest) { userId: req.user.id, audiobookId: audiobookRecord.id, status: 'awaiting_approval', + type: 'audiobook', // Explicit type for user-created requests progress: 0, selectedTorrent: torrent as any, // Store the selected torrent for later }, @@ -304,6 +307,7 @@ export async function POST(request: NextRequest) { userId: req.user.id, audiobookId: audiobookRecord.id, status: 'downloading', + type: 'audiobook', // Explicit type for user-created requests progress: 0, }, include: { diff --git a/src/app/api/bookdate/swipe/route.ts b/src/app/api/bookdate/swipe/route.ts index d2fa39d..7ddccc0 100644 --- a/src/app/api/bookdate/swipe/route.ts +++ b/src/app/api/bookdate/swipe/route.ts @@ -136,6 +136,8 @@ async function handler(req: AuthenticatedRequest) { where: { userId, audiobookId: audiobook.id, + type: 'audiobook', // Only check audiobook requests (ebook requests are separate) + deletedAt: null, // Only check active requests }, }); @@ -187,6 +189,7 @@ async function handler(req: AuthenticatedRequest) { userId, audiobookId: audiobook.id, status: initialStatus, + type: 'audiobook', // Explicit type for user-created requests priority: 0, }, }); diff --git a/src/app/api/requests/[id]/fetch-ebook/route.ts b/src/app/api/requests/[id]/fetch-ebook/route.ts index fdfda36..1714de9 100644 --- a/src/app/api/requests/[id]/fetch-ebook/route.ts +++ b/src/app/api/requests/[id]/fetch-ebook/route.ts @@ -2,16 +2,13 @@ * Component: Fetch E-book API * Documentation: documentation/integrations/ebook-sidecar.md * - * Triggers e-book download for a completed request + * Creates an ebook request for a completed audiobook request */ import { NextRequest, NextResponse } from 'next/server'; import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth'; import { prisma } from '@/lib/db'; -import { downloadEbook } from '@/lib/services/ebook-scraper'; -import { buildAudiobookPath } from '@/lib/utils/file-organizer'; -import fs from 'fs/promises'; -import path from 'path'; +import { getJobQueueService } from '@/lib/services/job-queue.service'; import { RMABLogger } from '@/lib/utils/logger'; const logger = RMABLogger.create('API.FetchEbook'); @@ -23,7 +20,7 @@ export async function POST( return requireAuth(request, async (req: AuthenticatedRequest) => { return requireAdmin(req, async () => { try { - const { id } = await params; + const { id: parentRequestId } = await params; // Check if e-book sidecar is enabled const ebookEnabledConfig = await prisma.configuration.findUnique({ @@ -37,118 +34,108 @@ export async function POST( ); } - // Get the request with audiobook data - const requestRecord = await prisma.request.findUnique({ - where: { id }, + // Get the parent request with audiobook data + const parentRequest = await prisma.request.findUnique({ + where: { id: parentRequestId }, include: { audiobook: true, }, }); - if (!requestRecord) { + if (!parentRequest) { return NextResponse.json( { error: 'Request not found' }, { status: 404 } ); } - // Check if request is in completed state - if (!['downloaded', 'available'].includes(requestRecord.status)) { + // Check if parent request is in completed state + if (!['downloaded', 'available'].includes(parentRequest.status)) { return NextResponse.json( - { error: `Cannot fetch e-book for request in ${requestRecord.status} status` }, + { error: `Cannot fetch e-book for request in ${parentRequest.status} status` }, { status: 400 } ); } - const audiobook = requestRecord.audiobook; - - // Get configuration - const [mediaDirConfig, templateConfig, formatConfig, baseUrlConfig, flaresolverrConfig] = await Promise.all([ - prisma.configuration.findUnique({ where: { key: 'media_dir' } }), - prisma.configuration.findUnique({ where: { key: 'audiobook_path_template' } }), - prisma.configuration.findUnique({ where: { key: 'ebook_sidecar_preferred_format' } }), - prisma.configuration.findUnique({ where: { key: 'ebook_sidecar_base_url' } }), - prisma.configuration.findUnique({ where: { key: 'ebook_sidecar_flaresolverr_url' } }), - ]); - - const mediaDir = mediaDirConfig?.value || '/media/audiobooks'; - const template = templateConfig?.value || '{author}/{title} {asin}'; - const preferredFormat = formatConfig?.value || 'epub'; - const baseUrl = baseUrlConfig?.value || 'https://annas-archive.li'; - const flaresolverrUrl = flaresolverrConfig?.value || undefined; - - // Fetch year from audible cache if ASIN is available - let year: number | undefined; - if (audiobook.audibleAsin) { - const audibleCache = await prisma.audibleCache.findUnique({ - where: { asin: audiobook.audibleAsin }, - select: { releaseDate: true }, - }); - if (audibleCache?.releaseDate) { - year = new Date(audibleCache.releaseDate).getFullYear(); - } - } - - // Build target path using centralized function - const targetPath = buildAudiobookPath( - mediaDir, - template, - { - author: audiobook.author, - title: audiobook.title, - narrator: audiobook.narrator || undefined, - asin: audiobook.audibleAsin || undefined, - year, - } - ); - - logger.debug('Fetch e-book request', { - requestId: id, - title: audiobook.title, - author: audiobook.author, - targetPath, - format: preferredFormat, - baseUrl, - flaresolverr: flaresolverrUrl || 'none' + // Check if an ebook request already exists for this parent + const existingEbookRequest = await prisma.request.findFirst({ + where: { + parentRequestId, + type: 'ebook', + deletedAt: null, + }, }); - // Check if target directory exists - try { - await fs.access(targetPath); - } catch { - logger.debug(`Target directory not found: ${targetPath}`); - return NextResponse.json( - { error: 'Audiobook directory not found. Was the audiobook properly organized?' }, - { status: 400 } - ); - } + if (existingEbookRequest) { + // Check status - if failed/pending, we can retry + if (['failed', 'awaiting_search'].includes(existingEbookRequest.status)) { + // Reset and retry + await prisma.request.update({ + where: { id: existingEbookRequest.id }, + data: { + status: 'pending', + progress: 0, + errorMessage: null, + updatedAt: new Date(), + }, + }); - // Download e-book - const result = await downloadEbook( - audiobook.audibleAsin || '', - audiobook.title, - audiobook.author, - targetPath, - preferredFormat, - baseUrl, - undefined, // No logger in API context - flaresolverrUrl - ); + // Trigger search job + const jobQueue = getJobQueueService(); + await jobQueue.addSearchEbookJob(existingEbookRequest.id, { + id: parentRequest.audiobook.id, + title: parentRequest.audiobook.title, + author: parentRequest.audiobook.author, + asin: parentRequest.audiobook.audibleAsin || undefined, + }); - if (result.success) { - logger.info(`E-book downloaded: ${result.filePath ? path.basename(result.filePath) : 'unknown'} for "${audiobook.title}"`); - return NextResponse.json({ - success: true, - message: `E-book downloaded: ${result.filePath ? path.basename(result.filePath) : 'unknown'}`, - format: result.format, - }); - } else { - logger.warn(`E-book download failed for "${audiobook.title}"`, { error: result.error }); + logger.info(`Retrying ebook request ${existingEbookRequest.id} for "${parentRequest.audiobook.title}"`); + + return NextResponse.json({ + success: true, + message: 'E-book search retried', + requestId: existingEbookRequest.id, + }); + } + + // Already exists and not in a retryable state return NextResponse.json({ success: false, - message: result.error || 'E-book download failed', + message: `E-book request already exists (status: ${existingEbookRequest.status})`, + requestId: existingEbookRequest.id, }); } + + // Create new ebook request + const ebookRequest = await prisma.request.create({ + data: { + userId: parentRequest.userId, + audiobookId: parentRequest.audiobookId, + type: 'ebook', + parentRequestId, + status: 'pending', + progress: 0, + }, + }); + + logger.info(`Created ebook request ${ebookRequest.id} for "${parentRequest.audiobook.title}"`); + + // Trigger ebook search job + const jobQueue = getJobQueueService(); + await jobQueue.addSearchEbookJob(ebookRequest.id, { + id: parentRequest.audiobook.id, + title: parentRequest.audiobook.title, + author: parentRequest.audiobook.author, + asin: parentRequest.audiobook.audibleAsin || undefined, + }); + + logger.info(`Triggered search_ebook job for request ${ebookRequest.id}`); + + return NextResponse.json({ + success: true, + message: 'E-book request created and search started', + requestId: ebookRequest.id, + }); } catch (error) { logger.error('Unexpected error', { error: error instanceof Error ? error.message : String(error) }); return NextResponse.json( diff --git a/src/app/api/requests/route.ts b/src/app/api/requests/route.ts index c5914c9..ae0a73d 100644 --- a/src/app/api/requests/route.ts +++ b/src/app/api/requests/route.ts @@ -45,13 +45,14 @@ export async function POST(request: NextRequest) { const body = await req.json(); const { audiobook } = CreateRequestSchema.parse(body); - // First check: Is there an existing request in 'downloaded' or 'available' status? + // First check: Is there an existing audiobook request in 'downloaded' or 'available' status? // This catches the gap where files are organized but Plex hasn't scanned yet const existingActiveRequest = await prisma.request.findFirst({ where: { audiobook: { audibleAsin: audiobook.asin, }, + type: 'audiobook', // Only check audiobook requests (ebook requests are separate) status: { in: ['downloaded', 'available'] }, deletedAt: null, }, @@ -165,11 +166,12 @@ export async function POST(request: NextRequest) { logger.debug(`Updated audiobook ${audiobookRecord.id} with year: ${year || 'unchanged'}, series: ${series || 'unchanged'}`); } - // Check if user already has an active (non-deleted) request for this audiobook + // Check if user already has an active (non-deleted) audiobook request for this audiobook const existingRequest = await prisma.request.findFirst({ where: { userId: req.user.id, audiobookId: audiobookRecord.id, + type: 'audiobook', // Only check audiobook requests (ebook requests are separate) deletedAt: null, // Only check active requests }, }); @@ -257,6 +259,7 @@ export async function POST(request: NextRequest) { userId: req.user.id, audiobookId: audiobookRecord.id, status: initialStatus, + type: 'audiobook', // Explicit type for user-created requests progress: 0, }, include: { @@ -353,6 +356,7 @@ export async function GET(request: NextRequest) { const status = searchParams.get('status'); const limit = parseInt(searchParams.get('limit') || '50', 10); const myOnly = searchParams.get('myOnly') === 'true'; + const type = searchParams.get('type'); // 'audiobook', 'ebook', or null for all const isAdmin = req.user.role === 'admin'; // Build query @@ -362,6 +366,10 @@ export async function GET(request: NextRequest) { if (status) { where.status = status; } + // Filter by type if specified (otherwise returns all types) + if (type && ['audiobook', 'ebook'].includes(type)) { + where.type = type; + } // Only show active (non-deleted) requests where.deletedAt = null; diff --git a/src/components/requests/RequestCard.tsx b/src/components/requests/RequestCard.tsx index 0121a59..cebe64d 100644 --- a/src/components/requests/RequestCard.tsx +++ b/src/components/requests/RequestCard.tsx @@ -16,6 +16,7 @@ import { InteractiveTorrentSearchModal } from './InteractiveTorrentSearchModal'; interface RequestCardProps { request: { id: string; + type?: 'audiobook' | 'ebook'; status: string; progress: number; errorMessage?: string; @@ -38,10 +39,14 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) { const [showError, setShowError] = React.useState(false); const [showInteractiveSearch, setShowInteractiveSearch] = React.useState(false); + const requestType = request.type || 'audiobook'; + const isEbook = requestType === 'ebook'; + const canCancel = ['pending', 'searching', 'downloading'].includes(request.status); const isActive = ['searching', 'downloading', 'processing'].includes(request.status); const isFailed = request.status === 'failed'; - const canSearch = ['pending', 'failed', 'awaiting_search'].includes(request.status); + // Ebook requests don't support interactive search (Anna's Archive only) + const canSearch = !isEbook && ['pending', 'failed', 'awaiting_search'].includes(request.status); const handleCancel = async () => { if (window.confirm('Are you sure you want to cancel this request?')) { @@ -100,19 +105,30 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) { /> ) : (
- - - + {isEbook ? ( + + + + ) : ( + + + + )}
)}
@@ -130,9 +146,20 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) {

- {/* Status Badge */} -
+ {/* Status Badge and Type Badge */} +
+ {isEbook && ( + + + + + Ebook + + )} {isActive && request.progress > 0 && (
diff --git a/src/lib/bookdate/helpers.ts b/src/lib/bookdate/helpers.ts index bb06420..77e58ab 100644 --- a/src/lib/bookdate/helpers.ts +++ b/src/lib/bookdate/helpers.ts @@ -927,7 +927,7 @@ export async function isInLibrary( } /** - * Check if book has already been requested + * Check if book has already been requested (audiobook request) * @param userId - User ID * @param asin - Audible ASIN * @returns true if book is already requested @@ -939,6 +939,8 @@ export async function isAlreadyRequested( const request = await prisma.request.findFirst({ where: { userId, + type: 'audiobook', // Only check audiobook requests (ebook requests are separate) + deletedAt: null, // Only check active requests audiobook: { audibleAsin: asin, }, diff --git a/src/lib/processors/cleanup-seeded-torrents.processor.ts b/src/lib/processors/cleanup-seeded-torrents.processor.ts index 0ec6cd5..d0c9255 100644 --- a/src/lib/processors/cleanup-seeded-torrents.processor.ts +++ b/src/lib/processors/cleanup-seeded-torrents.processor.ts @@ -44,12 +44,14 @@ export async function processCleanupSeededTorrents(payload: CleanupSeededTorrent logger.info(`Loaded configuration for ${indexerConfigMap.size} indexers`); - // Find all completed requests + soft-deleted requests (orphaned downloads) + // Find all completed audiobook requests + soft-deleted audiobook requests (orphaned downloads) // IMPORTANT: Only cleanup requests that are truly complete and not being actively processed // NOTE: Multiple requests can share the same torrent hash (e.g., re-requesting same audiobook) // Before deleting torrent, we check if other active requests are using it + // NOTE: Ebook requests use direct HTTP downloads (no torrent seeding), so they're excluded const completedRequests = await prisma.request.findMany({ where: { + type: 'audiobook', // Only audiobook requests (ebooks don't have torrents to seed) OR: [ // Active requests that are fully available (scanned by Plex/ABS) { @@ -148,11 +150,12 @@ export async function processCleanupSeededTorrents(payload: CleanupSeededTorrent logger.info(`Torrent ${torrent.name} (${indexerName}) has met seeding requirement (${Math.floor(actualSeedingTime / 60)}/${seedingConfig.seedingTimeMinutes} minutes)`); - // CRITICAL: Check if any other active (non-deleted) request is using this same torrent hash + // CRITICAL: Check if any other active (non-deleted) audiobook request is using this same torrent hash // This prevents deleting shared torrents when user re-requests the same audiobook const otherActiveRequests = await prisma.request.findMany({ where: { id: { not: request.id }, // Exclude current request + type: 'audiobook', // Only check audiobook requests deletedAt: null, // Only check active requests downloadHistory: { some: { diff --git a/src/lib/processors/direct-download.processor.ts b/src/lib/processors/direct-download.processor.ts new file mode 100644 index 0000000..d4c819e --- /dev/null +++ b/src/lib/processors/direct-download.processor.ts @@ -0,0 +1,504 @@ +/** + * Component: Direct Download Job Processors + * Documentation: documentation/integrations/ebook-sidecar.md + * + * Handles direct HTTP downloads for ebooks from Anna's Archive. + * Reports progress similar to qBittorrent/SABnzbd for unified UI. + */ + +import { StartDirectDownloadPayload, MonitorDirectDownloadPayload, getJobQueueService } from '../services/job-queue.service'; +import { prisma } from '../db'; +import { getConfigService } from '../services/config.service'; +import { RMABLogger } from '../utils/logger'; +import { extractDownloadUrl, ExtractedDownload } from '../services/ebook-scraper'; +import axios from 'axios'; +import fs from 'fs/promises'; +import { createWriteStream } from 'fs'; +import path from 'path'; + +const DOWNLOAD_TIMEOUT_MS = 120000; // 2 minutes per download attempt +const MAX_DOWNLOAD_ATTEMPTS = 5; +const PROGRESS_UPDATE_INTERVAL_MS = 2000; // Update progress every 2 seconds + +// In-memory tracking for active downloads +interface ActiveDownload { + id: string; + requestId: string; + downloadHistoryId: string; + targetPath: string; + bytesDownloaded: number; + bytesTotal: number; + startTime: number; + lastUpdateTime: number; + completed: boolean; + failed: boolean; + error?: string; +} + +const activeDownloads = new Map(); + +/** + * Generate unique download ID + */ +function generateDownloadId(): string { + return `dl_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; +} + +/** + * Process start direct download job + * Initiates the HTTP download and schedules monitoring + */ +export async function processStartDirectDownload(payload: StartDirectDownloadPayload): Promise { + const { requestId, downloadHistoryId, downloadUrl, targetFilename, expectedSize, jobId } = payload; + + const logger = RMABLogger.forJob(jobId, 'DirectDownload'); + + logger.info(`Starting direct download for request ${requestId}`); + + try { + // Update request status to downloading + await prisma.request.update({ + where: { id: requestId }, + data: { + status: 'downloading', + progress: 0, + downloadAttempts: { increment: 1 }, + updatedAt: new Date(), + }, + }); + + // Update download history + await prisma.downloadHistory.update({ + where: { id: downloadHistoryId }, + data: { + downloadStatus: 'downloading', + startedAt: new Date(), + }, + }); + + // Get download configuration + const configService = getConfigService(); + const downloadsDir = await configService.get('downloads_dir') || '/downloads'; + const baseUrl = await configService.get('ebook_sidecar_base_url') || 'https://annas-archive.li'; + const preferredFormat = await configService.get('ebook_sidecar_preferred_format') || 'epub'; + const flaresolverrUrl = await configService.get('ebook_sidecar_flaresolverr_url') || undefined; + + // Get all download URLs from download history (stored as JSON in torrentUrl) + const downloadHistory = await prisma.downloadHistory.findUnique({ + where: { id: downloadHistoryId }, + }); + + let downloadUrls: string[] = []; + try { + downloadUrls = downloadHistory?.torrentUrl ? JSON.parse(downloadHistory.torrentUrl) : [downloadUrl]; + } catch { + downloadUrls = [downloadUrl]; + } + + logger.info(`Have ${downloadUrls.length} download URL(s) to try`); + + // Try each slow download URL until one succeeds + let downloadResult: { success: boolean; filePath?: string; format?: string; error?: string } = { + success: false, + error: 'No download URLs available', + }; + + const attemptsLimit = Math.min(downloadUrls.length, MAX_DOWNLOAD_ATTEMPTS); + + for (let i = 0; i < attemptsLimit; i++) { + const slowLink = downloadUrls[i]; + logger.info(`Attempting download link ${i + 1}/${attemptsLimit}...`); + + try { + // Extract actual download URL from slow download page + const extracted = await extractDownloadUrl( + slowLink, + baseUrl, + preferredFormat, + logger, + flaresolverrUrl + ); + + if (!extracted) { + logger.warn(`No download URL found on page ${i + 1}`); + continue; + } + + logger.info(`Downloading from: ${new URL(extracted.url).host} (format: ${extracted.format})`); + + // Build target path with actual format + const sanitizedFilename = sanitizeFilename(`${targetFilename.replace(/\.[^.]+$/, '')}.${extracted.format}`); + const targetPath = path.join(downloadsDir, sanitizedFilename); + + // Create download tracking entry + const downloadId = generateDownloadId(); + const downloadEntry: ActiveDownload = { + id: downloadId, + requestId, + downloadHistoryId, + targetPath, + bytesDownloaded: 0, + bytesTotal: expectedSize || 0, + startTime: Date.now(), + lastUpdateTime: Date.now(), + completed: false, + failed: false, + }; + activeDownloads.set(downloadId, downloadEntry); + + // Start download with progress tracking + const success = await downloadFileWithProgress( + extracted.url, + targetPath, + downloadEntry, + logger + ); + + if (success) { + downloadResult = { + success: true, + filePath: targetPath, + format: extracted.format, + }; + + // Get final file size + try { + const stats = await fs.stat(targetPath); + downloadEntry.bytesTotal = stats.size; + downloadEntry.bytesDownloaded = stats.size; + } catch { + // Ignore stat errors + } + + logger.info(`Download completed: ${sanitizedFilename}`); + break; + } + + logger.warn(`Download attempt ${i + 1} failed`); + activeDownloads.delete(downloadId); + } catch (error) { + logger.warn(`Download link ${i + 1} error: ${error instanceof Error ? error.message : 'Unknown'}`); + } + } + + if (!downloadResult.success) { + // All attempts failed + logger.error(`All ${attemptsLimit} download attempts failed`); + + await prisma.request.update({ + where: { id: requestId }, + data: { + status: 'failed', + errorMessage: downloadResult.error || 'All download attempts failed', + updatedAt: new Date(), + }, + }); + + await prisma.downloadHistory.update({ + where: { id: downloadHistoryId }, + data: { + downloadStatus: 'failed', + downloadError: downloadResult.error || 'All download attempts failed', + }, + }); + + return { + success: false, + message: 'Download failed', + requestId, + error: downloadResult.error, + }; + } + + // Download succeeded - update records and trigger organize + await prisma.request.update({ + where: { id: requestId }, + data: { + status: 'processing', + progress: 100, + updatedAt: new Date(), + }, + }); + + await prisma.downloadHistory.update({ + where: { id: downloadHistoryId }, + data: { + downloadStatus: 'completed', + completedAt: new Date(), + }, + }); + + // Get audiobook ID for organize job + const request = await prisma.request.findUnique({ + where: { id: requestId }, + include: { audiobook: true }, + }); + + if (!request) { + throw new Error('Request not found after download'); + } + + // Trigger organize files job + const jobQueue = getJobQueueService(); + await jobQueue.addOrganizeJob( + requestId, + request.audiobookId, + downloadResult.filePath! + ); + + logger.info(`Download complete, triggered organize job for ${downloadResult.filePath}`); + + return { + success: true, + message: 'Download completed, organizing files', + requestId, + filePath: downloadResult.filePath, + format: downloadResult.format, + }; + } catch (error) { + logger.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`); + + await prisma.request.update({ + where: { id: requestId }, + data: { + status: 'failed', + errorMessage: error instanceof Error ? error.message : 'Unknown error during download', + updatedAt: new Date(), + }, + }); + + await prisma.downloadHistory.update({ + where: { id: downloadHistoryId }, + data: { + downloadStatus: 'failed', + downloadError: error instanceof Error ? error.message : 'Unknown error', + }, + }); + + throw error; + } +} + +/** + * Download file with progress tracking + */ +async function downloadFileWithProgress( + url: string, + targetPath: string, + tracking: ActiveDownload, + logger: RMABLogger +): Promise { + try { + // Ensure target directory exists + await fs.mkdir(path.dirname(targetPath), { recursive: true }); + + // Start download with axios streaming + const response = await axios({ + method: 'GET', + url, + responseType: 'stream', + timeout: DOWNLOAD_TIMEOUT_MS, + headers: { + 'User-Agent': 'ReadMeABook/1.0 (Audiobook Automation)', + }, + }); + + // Get content length if available + const contentLength = parseInt(response.headers['content-length'] || '0', 10); + if (contentLength > 0) { + tracking.bytesTotal = contentLength; + } + + // Create write stream + const writer = createWriteStream(targetPath); + + // Track progress + let bytesDownloaded = 0; + let lastLogTime = Date.now(); + let lastDbUpdateTime = Date.now(); + + response.data.on('data', (chunk: Buffer) => { + bytesDownloaded += chunk.length; + tracking.bytesDownloaded = bytesDownloaded; + tracking.lastUpdateTime = Date.now(); + + // Log and update database every 2 seconds + const now = Date.now(); + if (now - lastLogTime >= 2000) { + const percent = tracking.bytesTotal > 0 + ? Math.round((bytesDownloaded / tracking.bytesTotal) * 100) + : 0; + const speedMBps = bytesDownloaded / ((now - tracking.startTime) / 1000) / (1024 * 1024); + logger.info(`Download progress: ${percent}% (${(bytesDownloaded / (1024 * 1024)).toFixed(1)} MB, ${speedMBps.toFixed(2)} MB/s)`); + lastLogTime = now; + + // Update database with progress (non-blocking) + if (now - lastDbUpdateTime >= PROGRESS_UPDATE_INTERVAL_MS) { + lastDbUpdateTime = now; + + // Non-blocking update - fire and forget + prisma.request.update({ + where: { id: tracking.requestId }, + data: { + progress: Math.min(percent, 99), // Cap at 99% until fully complete + updatedAt: new Date(), + }, + }).catch(() => {}); // Ignore errors during progress update + } + } + }); + + // Pipe to file + response.data.pipe(writer); + + // Wait for completion + return new Promise((resolve, reject) => { + writer.on('finish', () => { + tracking.completed = true; + resolve(true); + }); + + writer.on('error', (error) => { + tracking.failed = true; + tracking.error = error.message; + reject(error); + }); + + response.data.on('error', (error: Error) => { + tracking.failed = true; + tracking.error = error.message; + writer.close(); + // Clean up partial file + fs.unlink(targetPath).catch(() => {}); + reject(error); + }); + }); + } catch (error) { + tracking.failed = true; + tracking.error = error instanceof Error ? error.message : 'Unknown error'; + + // Clean up partial file + try { + await fs.unlink(targetPath); + } catch { + // Ignore cleanup errors + } + + return false; + } +} + +/** + * Process monitor direct download job + * Checks download progress and updates database + * Note: For direct downloads, most tracking happens in processStartDirectDownload + * This is kept for potential future use with async downloads + */ +export async function processMonitorDirectDownload(payload: MonitorDirectDownloadPayload): Promise { + const { requestId, downloadHistoryId, downloadId, targetPath, expectedSize, jobId } = payload; + + const logger = RMABLogger.forJob(jobId, 'MonitorDirectDownload'); + + // Check if download is tracked + const download = activeDownloads.get(downloadId); + + if (!download) { + // Download not in memory - check file existence + try { + const stats = await fs.stat(targetPath); + logger.info(`Download file exists: ${targetPath} (${stats.size} bytes)`); + + // If file exists and is complete, assume success + if (expectedSize && stats.size >= expectedSize) { + return { + success: true, + completed: true, + message: 'Download already completed', + requestId, + }; + } + } catch { + // File doesn't exist + } + + logger.warn(`Download ${downloadId} not found in tracking`); + return { + success: false, + message: 'Download not found', + requestId, + }; + } + + // Update database with progress + const progress = download.bytesTotal > 0 + ? Math.min(99, Math.round((download.bytesDownloaded / download.bytesTotal) * 100)) + : 0; + + const elapsed = Date.now() - download.startTime; + const speed = elapsed > 0 ? download.bytesDownloaded / (elapsed / 1000) : 0; + const eta = speed > 0 && download.bytesTotal > download.bytesDownloaded + ? Math.round((download.bytesTotal - download.bytesDownloaded) / speed) + : 0; + + await prisma.request.update({ + where: { id: requestId }, + data: { + progress, + updatedAt: new Date(), + }, + }); + + if (download.completed) { + logger.info(`Download ${downloadId} completed`); + return { + success: true, + completed: true, + requestId, + bytesDownloaded: download.bytesDownloaded, + bytesTotal: download.bytesTotal, + }; + } + + if (download.failed) { + logger.error(`Download ${downloadId} failed: ${download.error}`); + return { + success: false, + completed: false, + requestId, + error: download.error, + }; + } + + // Still in progress - schedule another monitor + const jobQueue = getJobQueueService(); + await jobQueue.addMonitorDirectDownloadJob( + requestId, + downloadHistoryId, + downloadId, + targetPath, + expectedSize, + PROGRESS_UPDATE_INTERVAL_MS / 1000 + ); + + return { + success: true, + completed: false, + requestId, + progress, + speed, + eta, + bytesDownloaded: download.bytesDownloaded, + bytesTotal: download.bytesTotal, + }; +} + +/** + * Sanitize filename for filesystem + */ +function sanitizeFilename(filename: string): string { + return filename + .replace(/[<>:"/\\|?*]/g, '') // Remove invalid chars + .replace(/\s+/g, ' ') // Collapse spaces + .trim() + .substring(0, 200); // Limit length +} diff --git a/src/lib/processors/monitor-rss-feeds.processor.ts b/src/lib/processors/monitor-rss-feeds.processor.ts index 2ab5c76..462c31c 100644 --- a/src/lib/processors/monitor-rss-feeds.processor.ts +++ b/src/lib/processors/monitor-rss-feeds.processor.ts @@ -57,9 +57,11 @@ export async function processMonitorRssFeeds(payload: MonitorRssFeedsPayload): P return { success: true, message: 'No RSS results', matched: 0 }; } - // Get all active requests awaiting search (missing audiobooks) + // Get all active audiobook requests awaiting search (missing audiobooks) + // Note: RSS feeds are for torrents, so only audiobook requests are matched const missingRequests = await prisma.request.findMany({ where: { + type: 'audiobook', // Only audiobook requests (RSS feeds are for torrents) status: 'awaiting_search', deletedAt: null, }, diff --git a/src/lib/processors/organize-files.processor.ts b/src/lib/processors/organize-files.processor.ts index 281dca4..9976f20 100644 --- a/src/lib/processors/organize-files.processor.ts +++ b/src/lib/processors/organize-files.processor.ts @@ -14,6 +14,7 @@ import { generateFilesHash } from '../utils/files-hash'; /** * Process organize files job * Moves completed downloads to media library in proper directory structure + * Handles both audiobook and ebook request types with appropriate branching */ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promise { const { requestId, audiobookId, downloadPath, jobId } = payload; @@ -24,6 +25,27 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi logger.info(`Download path: ${downloadPath}`); try { + // Fetch request to determine type + const request = await prisma.request.findUnique({ + where: { id: requestId }, + include: { + user: { select: { plexUsername: true } }, + }, + }); + + if (!request) { + throw new Error(`Request ${requestId} not found`); + } + + const requestType = request.type || 'audiobook'; // Default to audiobook for backward compatibility + logger.info(`Request type: ${requestType}`); + + // Branch based on request type + if (requestType === 'ebook') { + return await processEbookOrganization(payload, request, logger); + } + + // Continue with audiobook organization flow // Update request status to processing await prisma.request.update({ where: { id: requestId }, @@ -149,6 +171,10 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi errors: result.errors, }); + // Create ebook request if ebook downloads enabled (for audiobook requests only) + // This replaces the old inline ebook sidecar download + await createEbookRequestIfEnabled(requestId, audiobook, request.userId, result.targetPath, logger); + // Trigger filesystem scan if enabled (Plex or Audiobookshelf) const configService = getConfigService(); const backendMode = await configService.getBackendMode(); @@ -433,3 +459,215 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi } } } + +// ========================================================================= +// EBOOK-SPECIFIC ORGANIZATION +// ========================================================================= + +/** + * Process ebook organization (simplified flow compared to audiobooks) + * - No metadata tagging + * - No cover art download + * - No files hash generation + * - Sends "available" notification at downloaded state (terminal for ebooks) + */ +async function processEbookOrganization( + payload: OrganizeFilesPayload, + request: { id: string; userId: string; type: string; user: { plexUsername: string | null } }, + logger: RMABLogger +): Promise { + const { requestId, audiobookId, downloadPath, jobId } = payload; + + logger.info(`Processing ebook organization for request ${requestId}`); + + // Update request status to processing + await prisma.request.update({ + where: { id: requestId }, + data: { + status: 'processing', + progress: 100, + updatedAt: new Date(), + }, + }); + + // Get book details (works for both audiobooks and ebooks) + const book = await prisma.audiobook.findUnique({ + where: { id: audiobookId }, + }); + + if (!book) { + throw new Error(`Book ${audiobookId} not found`); + } + + logger.info(`Organizing ebook: ${book.title} by ${book.author}`); + + // Get file organizer and template + const organizer = await getFileOrganizer(); + const templateConfig = await prisma.configuration.findUnique({ + where: { key: 'audiobook_path_template' }, + }); + const template = templateConfig?.value || '{author}/{title} {asin}'; + + // Organize ebook files (organizer will detect ebook type and skip audio-specific processing) + const result = await organizer.organizeEbook( + downloadPath, + { + title: book.title, + author: book.author, + asin: book.audibleAsin || undefined, + year: book.year || undefined, + }, + template, + jobId ? { jobId, context: 'FileOrganizer.Ebook' } : undefined + ); + + if (!result.success) { + throw new Error(`Ebook organization failed: ${result.errors.join(', ')}`); + } + + logger.info(`Successfully moved ebook to ${result.targetPath}`); + + // Update book record with file path + await prisma.audiobook.update({ + where: { id: audiobookId }, + data: { + filePath: result.targetPath, + fileFormat: result.format || 'epub', + status: 'completed', + completedAt: new Date(), + updatedAt: new Date(), + }, + }); + + // Update request to downloaded (terminal state for ebooks) + await prisma.request.update({ + where: { id: requestId }, + data: { + status: 'downloaded', + progress: 100, + completedAt: new Date(), + updatedAt: new Date(), + }, + }); + + logger.info(`Ebook request ${requestId} completed - status: downloaded (terminal)`); + + // Send "available" notification for ebooks at downloaded state + // (since ebooks don't transition to 'available' via Plex matching) + const jobQueue = getJobQueueService(); + await jobQueue.addNotificationJob( + 'request_available', + requestId, + book.title, + book.author, + request.user.plexUsername || 'Unknown User' + ).catch((error) => { + logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) }); + }); + + // Trigger filesystem scan if enabled (same as audiobooks) + const configService = getConfigService(); + const backendMode = await configService.getBackendMode(); + const configKey = backendMode === 'audiobookshelf' + ? 'audiobookshelf.trigger_scan_after_import' + : 'plex.trigger_scan_after_import'; + const scanEnabled = await configService.get(configKey); + + logger.debug(`Ebook library scan check: backendMode=${backendMode}, configKey=${configKey}, scanEnabled=${scanEnabled}`); + + if (scanEnabled === 'true') { + try { + const libraryService = await getLibraryService(); + const libraryId = backendMode === 'audiobookshelf' + ? await configService.get('audiobookshelf.library_id') + : await configService.get('plex_audiobook_library_id'); + + if (libraryId) { + await libraryService.triggerLibraryScan(libraryId); + logger.info(`Triggered ${backendMode} filesystem scan for library ${libraryId}`); + } else { + logger.warn(`Library ID not configured for ${backendMode}, skipping scan`); + } + } catch (error) { + logger.error(`Failed to trigger filesystem scan: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } else { + logger.debug(`Ebook library scan disabled (scanEnabled=${scanEnabled})`); + } + + return { + success: true, + message: 'Ebook organized successfully', + requestId, + audiobookId, + targetPath: result.targetPath, + format: result.format, + }; +} + +/** + * Create ebook request if ebook downloads are enabled + * Called after audiobook organization completes + */ +async function createEbookRequestIfEnabled( + parentRequestId: string, + audiobook: { id: string; title: string; author: string; audibleAsin: string | null }, + userId: string, + targetPath: string, + logger: RMABLogger +): Promise { + try { + // Check if ebook downloads are enabled + const configService = getConfigService(); + const ebookEnabled = await configService.get('ebook_sidecar_enabled'); + + if (ebookEnabled !== 'true') { + logger.info('Ebook downloads disabled, skipping ebook request creation'); + return; + } + + // Check if an ebook request already exists for this parent + const existingEbookRequest = await prisma.request.findFirst({ + where: { + parentRequestId, + type: 'ebook', + deletedAt: null, + }, + }); + + if (existingEbookRequest) { + logger.info(`Ebook request already exists for parent ${parentRequestId}: ${existingEbookRequest.id}`); + return; + } + + logger.info(`Creating ebook request for "${audiobook.title}" (parent: ${parentRequestId})`); + + // Create new ebook request (auto-approved since parent was approved) + const ebookRequest = await prisma.request.create({ + data: { + userId, + audiobookId: audiobook.id, + type: 'ebook', + parentRequestId, + status: 'pending', // Will trigger search_ebook job + progress: 0, + }, + }); + + logger.info(`Created ebook request ${ebookRequest.id}`); + + // Trigger ebook search job + const jobQueue = getJobQueueService(); + await jobQueue.addSearchEbookJob(ebookRequest.id, { + id: audiobook.id, + title: audiobook.title, + author: audiobook.author, + asin: audiobook.audibleAsin || undefined, + }); + + logger.info(`Triggered search_ebook job for request ${ebookRequest.id}`); + } catch (error) { + // Don't fail the main audiobook organization if ebook request creation fails + logger.error(`Failed to create ebook request: ${error instanceof Error ? error.message : 'Unknown error'}`); + } +} diff --git a/src/lib/processors/plex-recently-added.processor.ts b/src/lib/processors/plex-recently-added.processor.ts index f187bb3..1ce4321 100644 --- a/src/lib/processors/plex-recently-added.processor.ts +++ b/src/lib/processors/plex-recently-added.processor.ts @@ -249,9 +249,11 @@ export async function processPlexRecentlyAddedCheck(payload: PlexRecentlyAddedPa } } - // Check for all non-terminal requests to match + // Check for all non-terminal audiobook requests to match + // Note: Ebook requests don't match to Plex/ABS library - they stop at 'downloaded' status const matchableRequests = await prisma.request.findMany({ where: { + type: 'audiobook', // Only match audiobook requests (ebooks don't go to 'available') status: { notIn: ['available', 'cancelled'] }, deletedAt: null, }, diff --git a/src/lib/processors/retry-failed-imports.processor.ts b/src/lib/processors/retry-failed-imports.processor.ts index ac1d41c..5472e80 100644 --- a/src/lib/processors/retry-failed-imports.processor.ts +++ b/src/lib/processors/retry-failed-imports.processor.ts @@ -37,9 +37,11 @@ export async function processRetryFailedImports(payload: RetryFailedImportsPaylo localPath: pathMappingConfig.download_client_local_path || '', }; - // Find all active requests in awaiting_import status + // Find all active audiobook requests in awaiting_import status + // Note: Ebook requests use the same organize_files processor but with type branching const requests = await prisma.request.findMany({ where: { + type: 'audiobook', // Only audiobook requests (ebooks handled by same processor but different flow) status: 'awaiting_import', deletedAt: null, }, diff --git a/src/lib/processors/retry-missing-torrents.processor.ts b/src/lib/processors/retry-missing-torrents.processor.ts index ddd1fd2..a869b68 100644 --- a/src/lib/processors/retry-missing-torrents.processor.ts +++ b/src/lib/processors/retry-missing-torrents.processor.ts @@ -21,9 +21,11 @@ export async function processRetryMissingTorrents(payload: RetryMissingTorrentsP logger.info('Starting retry job for requests awaiting search...'); try { - // Find all active requests in awaiting_search status + // Find all active audiobook requests in awaiting_search status + // Note: Ebook requests have separate search mechanism (search_ebook job) const requests = await prisma.request.findMany({ where: { + type: 'audiobook', // Only audiobook requests (ebooks use different search) status: 'awaiting_search', deletedAt: null, }, diff --git a/src/lib/processors/scan-plex.processor.ts b/src/lib/processors/scan-plex.processor.ts index 56a3d35..cfbec59 100644 --- a/src/lib/processors/scan-plex.processor.ts +++ b/src/lib/processors/scan-plex.processor.ts @@ -433,10 +433,12 @@ export async function processScanPlex(payload: ScanPlexPayload): Promise { logger.info(`No orphaned audiobooks found`); } - // 6. Match all non-terminal requests against library + // 6. Match all non-terminal audiobook requests against library + // Note: Ebook requests don't match to Plex/ABS library - they stop at 'downloaded' status logger.info(`Checking for matchable requests...`); const matchableRequests = await prisma.request.findMany({ where: { + type: 'audiobook', // Only match audiobook requests (ebooks don't go to 'available') status: { notIn: ['available', 'cancelled'] }, deletedAt: null, }, diff --git a/src/lib/processors/search-ebook.processor.ts b/src/lib/processors/search-ebook.processor.ts new file mode 100644 index 0000000..103ea81 --- /dev/null +++ b/src/lib/processors/search-ebook.processor.ts @@ -0,0 +1,216 @@ +/** + * Component: Search Ebook Job Processor + * Documentation: documentation/integrations/ebook-sidecar.md + * + * Searches Anna's Archive for ebook downloads. + * Part of the first-class ebook request flow. + */ + +import { SearchEbookPayload, EbookSearchResult, getJobQueueService } from '../services/job-queue.service'; +import { prisma } from '../db'; +import { getConfigService } from '../services/config.service'; +import { RMABLogger } from '../utils/logger'; + +// Import ebook scraper functions (we'll refactor these to be reusable) +import { + searchByAsin, + searchByTitle, + getSlowDownloadLinks, +} from '../services/ebook-scraper'; + +/** + * Process search ebook job + * Searches Anna's Archive for ebook matching the audiobook + */ +export async function processSearchEbook(payload: SearchEbookPayload): Promise { + const { requestId, audiobook, preferredFormat: payloadFormat, jobId } = payload; + + const logger = RMABLogger.forJob(jobId, 'SearchEbook'); + + logger.info(`Processing ebook request ${requestId} for "${audiobook.title}"`); + + try { + // Update request status to searching + await prisma.request.update({ + where: { id: requestId }, + data: { + status: 'searching', + searchAttempts: { increment: 1 }, + updatedAt: new Date(), + }, + }); + + // Get ebook configuration + const configService = getConfigService(); + const preferredFormat = payloadFormat || await configService.get('ebook_sidecar_preferred_format') || 'epub'; + const baseUrl = await configService.get('ebook_sidecar_base_url') || 'https://annas-archive.li'; + const flaresolverrUrl = await configService.get('ebook_sidecar_flaresolverr_url') || undefined; + + if (flaresolverrUrl) { + logger.info(`Using FlareSolverr at ${flaresolverrUrl}`); + } + + let md5: string | null = null; + let searchMethod: 'asin' | 'title' = 'title'; + + // Step 1: Try ASIN search (exact match - best) + if (audiobook.asin) { + logger.info(`Searching by ASIN: ${audiobook.asin} (format: ${preferredFormat})...`); + md5 = await searchByAsin(audiobook.asin, preferredFormat, baseUrl, logger, flaresolverrUrl); + + if (md5) { + logger.info(`Found via ASIN: ${md5}`); + searchMethod = 'asin'; + } else { + logger.info(`No results for ASIN, falling back to title + author search...`); + } + } + + // Step 2: Fallback to title + author search + if (!md5) { + logger.info(`Searching by title + author: "${audiobook.title}" by ${audiobook.author}...`); + md5 = await searchByTitle(audiobook.title, audiobook.author, preferredFormat, baseUrl, logger, flaresolverrUrl); + + if (md5) { + logger.info(`Found via title search: ${md5}`); + searchMethod = 'title'; + } + } + + if (!md5) { + // No results found - queue for re-search instead of failing + logger.warn(`No ebook found for request ${requestId}, marking as awaiting_search`); + + await prisma.request.update({ + where: { id: requestId }, + data: { + status: 'awaiting_search', + errorMessage: 'No ebook found on Anna\'s Archive. Will retry automatically.', + lastSearchAt: new Date(), + updatedAt: new Date(), + }, + }); + + return { + success: false, + message: 'No ebook found, queued for re-search', + requestId, + }; + } + + logger.info(`Found MD5: ${md5}`); + + // Step 3: Get slow download links + const slowLinks = await getSlowDownloadLinks(md5, baseUrl, logger, flaresolverrUrl); + + if (slowLinks.length === 0) { + logger.warn(`No download links available for MD5: ${md5}`); + + await prisma.request.update({ + where: { id: requestId }, + data: { + status: 'awaiting_search', + errorMessage: 'Found ebook but no download links available. Will retry automatically.', + lastSearchAt: new Date(), + updatedAt: new Date(), + }, + }); + + return { + success: false, + message: 'No download links available, queued for re-search', + requestId, + }; + } + + logger.info(`Found ${slowLinks.length} download link(s)`); + + // Create ebook search result + // Note: For future multi-source ranking, this would be one of many results + const searchResult: EbookSearchResult = { + md5, + title: audiobook.title, + author: audiobook.author, + format: preferredFormat, + downloadUrls: slowLinks, + source: 'annas_archive', + score: searchMethod === 'asin' ? 100 : 80, // ASIN match = higher confidence + }; + + // TODO: Future enhancement - when indexer support is added for ebooks: + // 1. Search Prowlarr for ebook results (filtered to ebook categories) + // 2. Rank results using rankEbookResults() with inverted size scoring + // 3. Anna's Archive results should get priority bonus to come out on top + // For now, Anna's Archive is the only source and always wins. + + logger.info(`==================== EBOOK SEARCH RESULT ====================`); + logger.info(`Title: "${audiobook.title}"`); + logger.info(`Author: "${audiobook.author}"`); + logger.info(`Match Method: ${searchMethod === 'asin' ? 'ASIN (exact)' : 'Title + Author (fuzzy)'}`); + logger.info(`Format: ${preferredFormat}`); + logger.info(`MD5: ${md5}`); + logger.info(`Download Links: ${slowLinks.length}`); + logger.info(`Score: ${searchResult.score}/100`); + logger.info(`==============================================================`); + + // Create download history record + const downloadHistory = await prisma.downloadHistory.create({ + data: { + requestId, + indexerName: 'Anna\'s Archive', + torrentName: `${audiobook.title} - ${audiobook.author}.${preferredFormat}`, + torrentSizeBytes: null, // Unknown until download starts + qualityScore: searchResult.score, + selected: true, + downloadClient: 'direct', // Direct HTTP download + downloadStatus: 'queued', + }, + }); + + // Trigger direct download job with the best (only) result + const jobQueue = getJobQueueService(); + + // The first slow link will be tried; if it fails, the processor will try others + await jobQueue.addStartDirectDownloadJob( + requestId, + downloadHistory.id, + slowLinks[0], // Start with first link + `${audiobook.title} - ${audiobook.author}.${preferredFormat}`, + undefined // Size unknown + ); + + // Store all download URLs in download history for retry purposes + await prisma.downloadHistory.update({ + where: { id: downloadHistory.id }, + data: { + // Store additional URLs in torrentUrl field (JSON array) + torrentUrl: JSON.stringify(slowLinks), + }, + }); + + return { + success: true, + message: `Found ebook via ${searchMethod === 'asin' ? 'ASIN' : 'title search'}, starting download`, + requestId, + searchResult: { + md5: searchResult.md5, + format: searchResult.format, + score: searchResult.score, + downloadLinksCount: slowLinks.length, + }, + }; + } catch (error) { + logger.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`); + + await prisma.request.update({ + where: { id: requestId }, + data: { + status: 'failed', + errorMessage: error instanceof Error ? error.message : 'Unknown error during ebook search', + updatedAt: new Date(), + }, + }); + + throw error; + } +} diff --git a/src/lib/services/ebook-scraper.ts b/src/lib/services/ebook-scraper.ts index e308e27..7eb87f9 100644 --- a/src/lib/services/ebook-scraper.ts +++ b/src/lib/services/ebook-scraper.ts @@ -304,8 +304,9 @@ export async function downloadEbook( /** * Step 1: Search Anna's Archive by ASIN and extract MD5 hash + * Exported for use by search-ebook processor */ -async function searchByAsin( +export async function searchByAsin( asin: string, format: string, baseUrl: string, @@ -394,8 +395,9 @@ async function searchByAsin( /** * Search Anna's Archive by title and author (fallback method) + * Exported for use by search-ebook processor */ -async function searchByTitle( +export async function searchByTitle( title: string, author: string, format: string, @@ -486,8 +488,9 @@ async function searchByTitle( /** * Step 3: Get slow download links from MD5 page (no waitlist only) + * Exported for use by search-ebook processor */ -async function getSlowDownloadLinks( +export async function getSlowDownloadLinks( md5: string, baseUrl: string, logger?: RMABLogger, @@ -561,7 +564,7 @@ async function getSlowDownloadLinks( } } -interface ExtractedDownload { +export interface ExtractedDownload { url: string; format: string; } @@ -570,8 +573,9 @@ interface ExtractedDownload { * Step 4: Extract actual download URL from slow download page * IMPORTANT: Supports dynamic file formats (not hardcoded to .epub) * Returns both URL and detected format + * Exported for use by direct-download processor */ -async function extractDownloadUrl( +export async function extractDownloadUrl( slowDownloadUrl: string, baseUrl: string, format: string, diff --git a/src/lib/services/job-queue.service.ts b/src/lib/services/job-queue.service.ts index 24b38a6..075574c 100644 --- a/src/lib/services/job-queue.service.ts +++ b/src/lib/services/job-queue.service.ts @@ -24,7 +24,11 @@ export type JobType = | 'retry_failed_imports' | 'cleanup_seeded_torrents' | 'monitor_rss_feeds' - | 'send_notification'; + | 'send_notification' + // Ebook-specific job types + | 'search_ebook' + | 'start_direct_download' + | 'monitor_direct_download'; export interface JobPayload { jobId?: string; // Database job ID (added automatically by addJob) @@ -95,6 +99,45 @@ export interface CleanupSeededTorrentsPayload extends JobPayload { scheduledJobId?: string; } +// Ebook-specific payload interfaces +export interface SearchEbookPayload extends JobPayload { + requestId: string; + audiobook: { + id: string; + title: string; + author: string; + asin?: string; // ASIN for Anna's Archive search (best match) + }; + preferredFormat?: string; // epub, pdf, mobi, azw3 (default: from config) +} + +export interface EbookSearchResult { + md5: string; + title: string; + author: string; + format: string; + fileSize?: number; + downloadUrls: string[]; // Slow download URLs from Anna's Archive + source: 'annas_archive'; // For future indexer support + score: number; // Ranking score (for future multi-source ranking) +} + +export interface StartDirectDownloadPayload extends JobPayload { + requestId: string; + downloadHistoryId: string; + downloadUrl: string; + targetFilename: string; + expectedSize?: number; +} + +export interface MonitorDirectDownloadPayload extends JobPayload { + requestId: string; + downloadHistoryId: string; + downloadId: string; // Internal tracking ID + targetPath: string; // Full path to the downloading file + expectedSize?: number; +} + export interface SendNotificationPayload extends JobPayload { event: 'request_pending_approval' | 'request_approved' | 'request_available' | 'request_error'; requestId: string; @@ -301,6 +344,22 @@ export class JobQueueService { const { processSendNotification } = await import('../processors/send-notification.processor'); return await processSendNotification(job.data); }); + + // Ebook-specific processors + this.queue.process('search_ebook', 3, async (job: BullJob) => { + const { processSearchEbook } = await import('../processors/search-ebook.processor'); + return await processSearchEbook(job.data); + }); + + this.queue.process('start_direct_download', 3, async (job: BullJob) => { + const { processStartDirectDownload } = await import('../processors/direct-download.processor'); + return await processStartDirectDownload(job.data); + }); + + this.queue.process('monitor_direct_download', 5, async (job: BullJob) => { + const { processMonitorDirectDownload } = await import('../processors/direct-download.processor'); + return await processMonitorDirectDownload(job.data); + }); } /** @@ -635,6 +694,83 @@ export class JobQueueService { ); } + // ========================================================================= + // EBOOK-SPECIFIC JOB METHODS + // ========================================================================= + + /** + * Add search ebook job (Anna's Archive search) + */ + async addSearchEbookJob( + requestId: string, + audiobook: { id: string; title: string; author: string; asin?: string }, + preferredFormat?: string + ): Promise { + return await this.addJob( + 'search_ebook', + { + requestId, + audiobook, + preferredFormat, + } as SearchEbookPayload, + { + priority: 10, // High priority for user-initiated requests + } + ); + } + + /** + * Add start direct download job (HTTP download for ebooks) + */ + async addStartDirectDownloadJob( + requestId: string, + downloadHistoryId: string, + downloadUrl: string, + targetFilename: string, + expectedSize?: number + ): Promise { + return await this.addJob( + 'start_direct_download', + { + requestId, + downloadHistoryId, + downloadUrl, + targetFilename, + expectedSize, + } as StartDirectDownloadPayload, + { + priority: 9, // High priority - download selected ebook + } + ); + } + + /** + * Add monitor direct download job (tracks HTTP download progress) + */ + async addMonitorDirectDownloadJob( + requestId: string, + downloadHistoryId: string, + downloadId: string, + targetPath: string, + expectedSize?: number, + delaySeconds: number = 0 + ): Promise { + return await this.addJob( + 'monitor_direct_download', + { + requestId, + downloadHistoryId, + downloadId, + targetPath, + expectedSize, + } as MonitorDirectDownloadPayload, + { + priority: 5, // Medium priority + delay: delaySeconds * 1000, + } + ); + } + /** * Get job by ID */ diff --git a/src/lib/services/request-delete.service.ts b/src/lib/services/request-delete.service.ts index 4432b76..d99928c 100644 --- a/src/lib/services/request-delete.service.ts +++ b/src/lib/services/request-delete.service.ts @@ -26,7 +26,7 @@ export interface DeleteRequestResult { /** * Soft delete a request with intelligent cleanup of media files and torrents * - * Logic: + * Logic (audiobook requests): * 1. Check if request exists and is not already deleted * 2. For each download: * - If unlimited seeding (0): Log and keep seeding, no monitoring @@ -34,7 +34,15 @@ export interface DeleteRequestResult { * - If seeding requirement met: Delete torrent + files * - If still seeding: Keep in qBittorrent for cleanup job * 3. Delete media files (title folder only) - * 4. Soft delete request (set deletedAt, deletedBy) + * 4. Delete from backend library (Plex/ABS) + * 5. Clear audiobook availability linkage + * 6. Soft delete request (set deletedAt, deletedBy) + * + * Logic (ebook requests): + * 1. Check if request exists and is not already deleted + * 2. Delete ebook files only (leave audiobook files intact) + * 3. Soft delete request (set deletedAt, deletedBy) + * Note: No backend library deletion or audiobook linkage clearing for ebooks */ export async function deleteRequest( requestId: string, @@ -57,6 +65,7 @@ export async function deleteRequest( audibleAsin: true, plexGuid: true, absItemId: true, + fileFormat: true, }, }, downloadHistory: { @@ -71,6 +80,10 @@ export async function deleteRequest( }, }); + // Determine request type (default to audiobook for backward compatibility) + const requestType = (request as any)?.type || 'audiobook'; + const isEbook = requestType === 'ebook'; + if (!request) { return { success: false, @@ -87,10 +100,11 @@ export async function deleteRequest( let torrentsKeptSeeding = 0; let torrentsKeptUnlimited = 0; - // 2. Handle downloads & seeding + // 2. Handle downloads & seeding (skip for ebooks - they use direct HTTP downloads) const downloadHistory = request.downloadHistory[0]; + const skipTorrentHandling = isEbook; // Ebooks use direct downloads, not torrents/NZBs - if (downloadHistory && downloadHistory.indexerName) { + if (!skipTorrentHandling && downloadHistory && downloadHistory.indexerName) { try { // Get indexer seeding configuration const { getConfigService } = await import('./config.service'); @@ -186,7 +200,9 @@ export async function deleteRequest( } } - // 3. Delete media files (title folder only) + // 3. Delete media files + // For audiobooks: delete entire title folder + // For ebooks: delete only ebook files (leave audiobook files intact) let filesDeleted = false; try { const { getConfigService } = await import('./config.service'); @@ -219,15 +235,34 @@ export async function deleteRequest( } ); - // Check if folder exists and delete it + // Check if folder exists try { await fs.access(titleFolderPath); - // Delete the title folder (not the author folder) - await fs.rm(titleFolderPath, { recursive: true, force: true }); + if (isEbook) { + // For ebooks: only delete ebook files, leave audiobook files intact + const ebookExtensions = ['.epub', '.pdf', '.mobi', '.azw', '.azw3', '.fb2', '.cbz', '.cbr']; + const files = await fs.readdir(titleFolderPath); - logger.info(`Deleted media directory: ${titleFolderPath}`); - filesDeleted = true; + let deletedCount = 0; + for (const file of files) { + const ext = path.extname(file).toLowerCase(); + if (ebookExtensions.includes(ext)) { + const filePath = path.join(titleFolderPath, file); + await fs.unlink(filePath); + logger.info(`Deleted ebook file: ${file}`); + deletedCount++; + } + } + + filesDeleted = deletedCount > 0; + logger.info(`Deleted ${deletedCount} ebook file(s) from: ${titleFolderPath}`); + } else { + // For audiobooks: delete the entire title folder + await fs.rm(titleFolderPath, { recursive: true, force: true }); + logger.info(`Deleted media directory: ${titleFolderPath}`); + filesDeleted = true; + } } catch (accessError) { // Folder doesn't exist - that's okay logger.info(`Media directory not found: ${titleFolderPath}`); @@ -242,143 +277,188 @@ export async function deleteRequest( } // 4. Delete from plex_library table and clear audiobook availability + // Skip for ebooks - audiobook files and library entry should remain intact // This ensures the book immediately shows as NOT available when searching - try { - const { getConfigService } = await import('./config.service'); - const configService = getConfigService(); - const backendMode = await configService.getBackendMode(); + if (!isEbook) { + try { + const { getConfigService } = await import('./config.service'); + const configService = getConfigService(); + const backendMode = await configService.getBackendMode(); - // Delete from library backend (ABS or Plex) - if (backendMode === 'audiobookshelf' && request.audiobook.absItemId) { - // Audiobookshelf: delete the library item from ABS - try { - const { deleteABSItem } = await import('../services/audiobookshelf/api'); - await deleteABSItem(request.audiobook.absItemId); - logger.info( - `Deleted Audiobookshelf library item ${request.audiobook.absItemId} for "${request.audiobook.title}"` - ); - } catch (absError) { - logger.error( - `Error deleting Audiobookshelf library item ${request.audiobook.absItemId}`, - { error: absError instanceof Error ? absError.message : String(absError) } - ); - // Continue with deletion even if ABS deletion fails + // Delete from library backend (ABS or Plex) + if (backendMode === 'audiobookshelf' && request.audiobook.absItemId) { + // Audiobookshelf: delete the library item from ABS + try { + const { deleteABSItem } = await import('../services/audiobookshelf/api'); + await deleteABSItem(request.audiobook.absItemId); + logger.info( + `Deleted Audiobookshelf library item ${request.audiobook.absItemId} for "${request.audiobook.title}"` + ); + } catch (absError) { + logger.error( + `Error deleting Audiobookshelf library item ${request.audiobook.absItemId}`, + { error: absError instanceof Error ? absError.message : String(absError) } + ); + // Continue with deletion even if ABS deletion fails + } + } else if (backendMode === 'plex' && request.audiobook.plexGuid) { + // Plex: delete the library item from Plex by ratingKey + try { + // Query plex_library table to get the ratingKey + const plexLibraryRecord = await prisma.plexLibrary.findUnique({ + where: { plexGuid: request.audiobook.plexGuid }, + select: { plexRatingKey: true }, + }); + + if (plexLibraryRecord && plexLibraryRecord.plexRatingKey) { + const ratingKey = plexLibraryRecord.plexRatingKey; + + // Get Plex config + const plexServerUrl = (await configService.get('plex_url')) || ''; + const plexToken = (await configService.get('plex_token')) || ''; + + if (plexServerUrl && plexToken) { + const { getPlexService } = await import('../integrations/plex.service'); + const plexService = getPlexService(); + await plexService.deleteItem(plexServerUrl, plexToken, ratingKey); + logger.info( + `Deleted Plex library item ${ratingKey} (plexGuid: ${request.audiobook.plexGuid}) for "${request.audiobook.title}"` + ); + } else { + logger.warn('Plex server URL or token not configured, skipping Plex library deletion'); + } + } else { + logger.warn( + `No plexRatingKey found in plex_library for plexGuid: ${request.audiobook.plexGuid}` + ); + } + } catch (plexError) { + logger.error( + `Error deleting Plex library item (plexGuid: ${request.audiobook.plexGuid})`, + { error: plexError instanceof Error ? plexError.message : String(plexError) } + ); + // Continue with deletion even if Plex deletion fails + } } - } else if (backendMode === 'plex' && request.audiobook.plexGuid) { - // Plex: delete the library item from Plex by ratingKey + + // Delete ALL plex_library records matching this audiobook's title and author + // This handles cases where there might be duplicate library records + // and ensures the book doesn't show as "In Your Library" during searches try { - // Query plex_library table to get the ratingKey - const plexLibraryRecord = await prisma.plexLibrary.findUnique({ - where: { plexGuid: request.audiobook.plexGuid }, - select: { plexRatingKey: true }, + // Find all matching library records (by title/author fuzzy match) + const matchingLibraryRecords = await prisma.plexLibrary.findMany({ + where: { + title: { + contains: request.audiobook.title.substring(0, 20), + mode: 'insensitive', + }, + }, }); - if (plexLibraryRecord && plexLibraryRecord.plexRatingKey) { - const ratingKey = plexLibraryRecord.plexRatingKey; + // Filter to exact matches (case-insensitive title and author) + const exactMatches = matchingLibraryRecords.filter((record) => { + const titleMatch = record.title.toLowerCase() === request.audiobook.title.toLowerCase(); + const authorMatch = record.author.toLowerCase() === request.audiobook.author.toLowerCase(); + return titleMatch && authorMatch; + }); - // Get Plex config - const plexServerUrl = (await configService.get('plex_url')) || ''; - const plexToken = (await configService.get('plex_token')) || ''; + if (exactMatches.length > 0) { + // Delete all exact matches + const deletePromises = exactMatches.map((record) => + prisma.plexLibrary.delete({ where: { id: record.id } }) + ); - if (plexServerUrl && plexToken) { - const { getPlexService } = await import('../integrations/plex.service'); - const plexService = getPlexService(); - await plexService.deleteItem(plexServerUrl, plexToken, ratingKey); - logger.info( - `Deleted Plex library item ${ratingKey} (plexGuid: ${request.audiobook.plexGuid}) for "${request.audiobook.title}"` - ); - } else { - logger.warn('Plex server URL or token not configured, skipping Plex library deletion'); - } + await Promise.all(deletePromises); + + logger.info( + `Deleted ${exactMatches.length} plex_library record(s) for "${request.audiobook.title}"` + ); } else { - logger.warn( - `No plexRatingKey found in plex_library for plexGuid: ${request.audiobook.plexGuid}` + logger.info( + `No plex_library records found for "${request.audiobook.title}"` ); } - } catch (plexError) { + } catch (libError) { logger.error( - `Error deleting Plex library item (plexGuid: ${request.audiobook.plexGuid})`, - { error: plexError instanceof Error ? plexError.message : String(plexError) } + `Error deleting plex_library records`, + { error: libError instanceof Error ? libError.message : String(libError) } ); - // Continue with deletion even if Plex deletion fails + // Continue with deletion even if library cleanup fails } - } - // Delete ALL plex_library records matching this audiobook's title and author - // This handles cases where there might be duplicate library records - // and ensures the book doesn't show as "In Your Library" during searches + // Clear audiobook record linkage + const updateData: any = { + status: 'requested', // Reset to requested state + updatedAt: new Date(), + }; + + // Clear library linkage based on backend mode + if (backendMode === 'audiobookshelf') { + updateData.absItemId = null; + } else { + updateData.plexGuid = null; + } + + await prisma.audiobook.update({ + where: { id: request.audiobook.id }, + data: updateData, + }); + + logger.info( + `Cleared availability status for audiobook ${request.audiobook.id}` + ); + } catch (error) { + logger.error( + `Error clearing audiobook status`, + { error: error instanceof Error ? error.message : String(error) } + ); + // Continue with deletion even if this fails + } + } else { + logger.info(`Skipping backend library deletion for ebook request ${requestId}`); + } + + // 5. Delete child requests (ebook requests linked to this audiobook request) + if (!isEbook) { try { - // Find all matching library records (by title/author fuzzy match) - const matchingLibraryRecords = await prisma.plexLibrary.findMany({ + const childRequests = await prisma.request.findMany({ where: { - title: { - contains: request.audiobook.title.substring(0, 20), - mode: 'insensitive', - }, + parentRequestId: requestId, + deletedAt: null, + }, + select: { + id: true, + type: true, }, }); - // Filter to exact matches (case-insensitive title and author) - const exactMatches = matchingLibraryRecords.filter((record) => { - const titleMatch = record.title.toLowerCase() === request.audiobook.title.toLowerCase(); - const authorMatch = record.author.toLowerCase() === request.audiobook.author.toLowerCase(); - return titleMatch && authorMatch; - }); + if (childRequests.length > 0) { + logger.info(`Found ${childRequests.length} child request(s) to delete`); - if (exactMatches.length > 0) { - // Delete all exact matches - const deletePromises = exactMatches.map((record) => - prisma.plexLibrary.delete({ where: { id: record.id } }) - ); + // Soft delete all child requests + await prisma.request.updateMany({ + where: { + parentRequestId: requestId, + deletedAt: null, + }, + data: { + deletedAt: new Date(), + deletedBy: adminUserId, + }, + }); - await Promise.all(deletePromises); - - logger.info( - `Deleted ${exactMatches.length} plex_library record(s) for "${request.audiobook.title}"` - ); - } else { - logger.info( - `No plex_library records found for "${request.audiobook.title}"` - ); + logger.info(`Soft-deleted ${childRequests.length} child request(s)`); } - } catch (libError) { + } catch (error) { logger.error( - `Error deleting plex_library records`, - { error: libError instanceof Error ? libError.message : String(libError) } + `Error deleting child requests for ${requestId}`, + { error: error instanceof Error ? error.message : String(error) } ); - // Continue with deletion even if library cleanup fails + // Continue with parent deletion even if child deletion fails } - - // Clear audiobook record linkage - const updateData: any = { - status: 'requested', // Reset to requested state - updatedAt: new Date(), - }; - - // Clear library linkage based on backend mode - if (backendMode === 'audiobookshelf') { - updateData.absItemId = null; - } else { - updateData.plexGuid = null; - } - - await prisma.audiobook.update({ - where: { id: request.audiobook.id }, - data: updateData, - }); - - logger.info( - `Cleared availability status for audiobook ${request.audiobook.id}` - ); - } catch (error) { - logger.error( - `Error clearing audiobook status`, - { error: error instanceof Error ? error.message : String(error) } - ); - // Continue with deletion even if this fails } - // 5. Soft delete request + // 6. Soft delete request await prisma.request.update({ where: { id: requestId }, data: { diff --git a/src/lib/utils/file-organizer.ts b/src/lib/utils/file-organizer.ts index d27b8d8..d32c102 100644 --- a/src/lib/utils/file-organizer.ts +++ b/src/lib/utils/file-organizer.ts @@ -19,7 +19,6 @@ import { checkDiskSpace, } from './chapter-merger'; import { prisma } from '../db'; -import { downloadEbook } from '../services/ebook-scraper'; import { substituteTemplate, type TemplateVariables } from './path-template.util'; export interface AudiobookMetadata { @@ -42,6 +41,13 @@ export interface OrganizationResult { coverArtFile?: string; } +export interface EbookOrganizationResult { + success: boolean; + targetPath: string; + errors: string[]; + format?: string; +} + export interface ValidationResult { isValid: boolean; issues: string[]; @@ -399,55 +405,10 @@ export class FileOrganizer { } } - // E-book sidecar: Download accompanying e-book if enabled - try { - const ebookConfig = await prisma.configuration.findUnique({ - where: { key: 'ebook_sidecar_enabled' }, - }); - - const ebookEnabled = ebookConfig?.value === 'true'; - - if (ebookEnabled) { - await logger?.info(`E-book sidecar enabled, searching for e-book...`); - - // Get configuration - const [formatConfig, baseUrlConfig, flaresolverrConfig] = await Promise.all([ - prisma.configuration.findUnique({ where: { key: 'ebook_sidecar_preferred_format' } }), - prisma.configuration.findUnique({ where: { key: 'ebook_sidecar_base_url' } }), - prisma.configuration.findUnique({ where: { key: 'ebook_sidecar_flaresolverr_url' } }), - ]); - - const preferredFormat = formatConfig?.value || 'epub'; - const baseUrl = baseUrlConfig?.value || 'https://annas-archive.li'; - const flaresolverrUrl = flaresolverrConfig?.value || undefined; - - // Download e-book (will try ASIN first, then fall back to title+author) - const ebookResult = await downloadEbook( - audiobook.asin || '', // ASIN (optional - will fallback to title+author if empty) - audiobook.title, - audiobook.author, - targetPath, // Same directory as audiobook - preferredFormat, - baseUrl, - logger ?? undefined, - flaresolverrUrl - ); - - if (ebookResult.success && ebookResult.filePath) { - await logger?.info(`E-book downloaded: ${path.basename(ebookResult.filePath)}`); - result.filesMovedCount++; - } else { - await logger?.warn(`E-book download failed: ${ebookResult.error}`); - result.errors.push(`E-book sidecar: ${ebookResult.error}`); - } - } - } catch (error) { - await logger?.warn( - `E-book sidecar error: ${error instanceof Error ? error.message : 'Unknown error'}` - ); - result.errors.push('E-book sidecar failed'); - // Don't throw - audiobook organization continues - } + // NOTE: E-book downloads are now handled via first-class ebook requests + // The createEbookRequestIfEnabled() function in organize-files.processor.ts + // creates a separate ebook request that goes through the full job queue flow. + // This replaces the old inline ebook sidecar download that happened here. result.targetPath = targetPath; result.success = true; @@ -680,6 +641,102 @@ export class FileOrganizer { return result; } + + /** + * Organize ebook file into proper directory structure + * Simplified compared to audiobooks - no metadata tagging, cover art, or chapter merging + */ + async organizeEbook( + downloadPath: string, + metadata: { title: string; author: string; asin?: string; year?: number }, + template: string, + loggerConfig?: LoggerConfig + ): Promise { + const logger = loggerConfig ? RMABLogger.forJob(loggerConfig.jobId, loggerConfig.context) : null; + + const result: EbookOrganizationResult = { + success: false, + targetPath: '', + errors: [], + }; + + try { + await logger?.info(`Organizing ebook: ${downloadPath}`); + + // Get file info + const stats = await fs.stat(downloadPath); + if (!stats.isFile()) { + throw new Error('Ebook download path must be a file'); + } + + // Detect format from extension + const ext = path.extname(downloadPath).toLowerCase().slice(1); + const ebookFormats = ['epub', 'pdf', 'mobi', 'azw', 'azw3', 'fb2', 'cbz', 'cbr']; + if (!ebookFormats.includes(ext)) { + throw new Error(`Unsupported ebook format: ${ext}`); + } + + result.format = ext; + await logger?.info(`Detected ebook format: ${ext}`); + + // Build target directory using same template as audiobooks + const targetDir = this.buildTargetPath( + this.mediaDir, + template, + metadata.author, + metadata.title, + undefined, // narrator + metadata.asin, + metadata.year + ); + + await logger?.info(`Target directory: ${targetDir}`); + + // Create target directory + await fs.mkdir(targetDir, { recursive: true }); + + // Build target filename (sanitize source filename) + const sourceFilename = path.basename(downloadPath); + const targetFilename = this.sanitizePath(sourceFilename); + const targetPath = path.join(targetDir, targetFilename); + + // Check if target already exists + try { + await fs.access(targetPath); + await logger?.info(`Ebook already exists at target, skipping copy: ${targetFilename}`); + result.success = true; + result.targetPath = targetDir; + return result; + } catch { + // File doesn't exist, continue with copy + } + + // Copy ebook file (don't delete original in case of direct download retry) + await fs.copyFile(downloadPath, targetPath); + await fs.chmod(targetPath, 0o644); + + await logger?.info(`Copied ebook: ${targetFilename}`); + + // Clean up source file (for direct HTTP downloads, we don't need to keep the original) + try { + await fs.unlink(downloadPath); + await logger?.info(`Cleaned up source file: ${sourceFilename}`); + } catch { + // Ignore cleanup errors + } + + result.success = true; + result.targetPath = targetDir; + + await logger?.info(`Ebook organization complete: ${targetFilename}`); + + return result; + } catch (error) { + await logger?.error(`Ebook organization failed: ${error instanceof Error ? error.message : 'Unknown error'}`); + result.errors.push(error instanceof Error ? error.message : 'Unknown error'); + return result; + } + } } /** diff --git a/src/lib/utils/ranking-algorithm.ts b/src/lib/utils/ranking-algorithm.ts index 63867f2..190c844 100644 --- a/src/lib/utils/ranking-algorithm.ts +++ b/src/lib/utils/ranking-algorithm.ts @@ -624,6 +624,161 @@ export class RankingAlgorithm { } } +// ========================================================================= +// EBOOK RANKING (simplified algorithm for ebook search results) +// ========================================================================= + +export interface EbookResult { + md5: string; + title: string; + author: string; + format: string; // epub, pdf, mobi, etc. + fileSize?: number; // in bytes + downloadUrls: string[]; + source: 'annas_archive' | 'prowlarr'; // Source of the result + indexerId?: number; // Prowlarr indexer ID (if applicable) +} + +export interface EbookRequest { + title: string; + author: string; + preferredFormat: string; // User's preferred format (epub, pdf, etc.) +} + +export interface RankedEbook extends EbookResult { + score: number; // Total score (0-100) + rank: number; + breakdown: { + formatScore: number; // 0-40 points + sizeScore: number; // 0-30 points (inverted - smaller is better) + sourceScore: number; // 0-30 points (Anna's Archive priority) + notes: string[]; + }; +} + +/** + * Rank ebook search results + * Scoring priorities (inverted from audiobooks): + * - Format match: 40 points (matching preferred format) + * - Size: 30 points (smaller files = better, inverted from audiobooks) + * - Source: 30 points (Anna's Archive priority for reliability) + */ +export function rankEbooks( + results: EbookResult[], + request: EbookRequest +): RankedEbook[] { + const preferredFormat = request.preferredFormat.toLowerCase(); + + const ranked = results.map((result): RankedEbook => { + const notes: string[] = []; + + // ========== FORMAT SCORING (0-40 points) ========== + // Exact format match gets full points + // Similar formats get partial credit + let formatScore = 0; + const resultFormat = result.format.toLowerCase(); + + if (resultFormat === preferredFormat) { + formatScore = 40; + notes.push(`✓ Preferred format (${result.format.toUpperCase()})`); + } else { + // Partial credit for compatible formats + const ebookFormatGroups = [ + ['epub', 'kepub'], // EPUB family + ['mobi', 'azw', 'azw3'], // Kindle family + ['pdf'], // PDF standalone + ['fb2', 'fb2.zip'], // FB2 family + ['cbz', 'cbr'], // Comic formats + ]; + + const preferredGroup = ebookFormatGroups.find(g => g.includes(preferredFormat)); + const resultGroup = ebookFormatGroups.find(g => g.includes(resultFormat)); + + if (preferredGroup && resultGroup && preferredGroup === resultGroup) { + formatScore = 30; // Same family + notes.push(`Similar format (${result.format.toUpperCase()})`); + } else if (resultFormat === 'epub') { + formatScore = 25; // EPUB is universally convertible + notes.push(`Convertible format (${result.format.toUpperCase()})`); + } else if (resultFormat === 'pdf') { + formatScore = 15; // PDF is common but less flexible + notes.push(`PDF format (less flexible)`); + } else { + formatScore = 10; // Other formats + notes.push(`Different format (${result.format.toUpperCase()})`); + } + } + + // ========== SIZE SCORING (0-30 points, inverted) ========== + // For ebooks, smaller files are generally better (cleaner, no bloat) + // Typical ebook sizes: 0.5-5 MB (good), 5-20 MB (has images), 20+ MB (may have issues) + let sizeScore = 0; + + if (result.fileSize !== undefined && result.fileSize > 0) { + const sizeMB = result.fileSize / (1024 * 1024); + + if (sizeMB <= 2) { + sizeScore = 30; // Ideal size + notes.push('✓ Optimal file size'); + } else if (sizeMB <= 5) { + sizeScore = 25; // Good size + notes.push('Good file size'); + } else if (sizeMB <= 15) { + sizeScore = 20; // Has images, acceptable + notes.push('Larger file (may have images)'); + } else if (sizeMB <= 50) { + sizeScore = 10; // Large, possibly bloated + notes.push('⚠️ Large file size'); + } else { + sizeScore = 5; // Very large, suspicious + notes.push('⚠️ Very large file (may include extras)'); + } + } else { + // No size info - give middle score + sizeScore = 15; + notes.push('File size unknown'); + } + + // ========== SOURCE SCORING (0-30 points) ========== + // Anna's Archive is the primary reliable source + // Future: Prowlarr indexers will get configurable priority + let sourceScore = 0; + + if (result.source === 'annas_archive') { + sourceScore = 30; // Full points for Anna's Archive + notes.push('✓ Anna\'s Archive (reliable)'); + } else if (result.source === 'prowlarr') { + // Future: Use indexer priority from config + sourceScore = 15; // Base score for Prowlarr results + notes.push('Prowlarr indexer'); + } + + const totalScore = formatScore + sizeScore + sourceScore; + + return { + ...result, + score: totalScore, + rank: 0, // Will be assigned after sorting + breakdown: { + formatScore, + sizeScore, + sourceScore, + notes, + }, + }; + }); + + // Sort by score descending + ranked.sort((a, b) => b.score - a.score); + + // Assign ranks + ranked.forEach((r, index) => { + r.rank = index + 1; + }); + + return ranked; +} + // Singleton instance let ranker: RankingAlgorithm | null = null; diff --git a/tests/api/requests-actions.routes.test.ts b/tests/api/requests-actions.routes.test.ts index 5616cae..ae7200a 100644 --- a/tests/api/requests-actions.routes.test.ts +++ b/tests/api/requests-actions.routes.test.ts @@ -20,6 +20,7 @@ const jobQueueMock = vi.hoisted(() => ({ addSearchJob: vi.fn(), addDownloadJob: vi.fn(), addNotificationJob: vi.fn(() => Promise.resolve()), + addSearchEbookJob: vi.fn(() => Promise.resolve()), })); const downloadEbookMock = vi.hoisted(() => vi.fn()); const fsMock = vi.hoisted(() => ({ @@ -355,42 +356,75 @@ describe('Request action routes', () => { expect(payload.error).toMatch(/Cannot fetch e-book/); }); - it('returns 400 when audiobook directory is missing', async () => { + it('creates ebook request and triggers search job', async () => { configState.values.set('ebook_sidecar_enabled', 'true'); + + // Mock parent request lookup prismaMock.request.findUnique.mockResolvedValueOnce({ id: 'req-6', + userId: 'user-1', + audiobookId: 'ab-1', status: 'downloaded', - audiobook: { title: 'Title', author: 'Author', audibleAsin: 'ASIN' }, + audiobook: { id: 'ab-1', title: 'Title', author: 'Author', audibleAsin: 'ASIN123' }, + }); + + // Mock check for existing ebook request + prismaMock.request.findFirst.mockResolvedValueOnce(null); + + // Mock ebook request creation + prismaMock.request.create.mockResolvedValueOnce({ + id: 'ebook-req-1', + type: 'ebook', + parentRequestId: 'req-6', }); - fsMock.access.mockRejectedValueOnce(new Error('missing')); const { POST } = await import('@/app/api/requests/[id]/fetch-ebook/route'); const response = await POST({} as any, { params: Promise.resolve({ id: 'req-6' }) }); const payload = await response.json(); - expect(response.status).toBe(400); - expect(payload.error).toMatch(/directory not found/); + expect(payload.success).toBe(true); + expect(payload.message).toMatch(/created/i); + expect(payload.requestId).toBe('ebook-req-1'); + expect(prismaMock.request.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + type: 'ebook', + parentRequestId: 'req-6', + status: 'pending', + }), + }); + expect(jobQueueMock.addSearchEbookJob).toHaveBeenCalledWith( + 'ebook-req-1', + expect.objectContaining({ + id: 'ab-1', + title: 'Title', + author: 'Author', + asin: 'ASIN123', + }) + ); }); - it('downloads ebook and returns success', async () => { + it('retries existing failed ebook request', async () => { configState.values.set('ebook_sidecar_enabled', 'true'); - configState.values.set('media_dir', '/media/audiobooks'); - configState.values.set('audiobook_path_template', '{author}/{title} {asin}'); - configState.values.set('ebook_sidecar_preferred_format', 'epub'); - configState.values.set('ebook_sidecar_base_url', 'https://ebooks.example'); - configState.values.set('ebook_sidecar_flaresolverr_url', 'http://flaresolverr'); + // Mock parent request lookup prismaMock.request.findUnique.mockResolvedValueOnce({ id: 'req-7', + userId: 'user-1', + audiobookId: 'ab-1', status: 'available', - audiobook: { title: 'Title', author: 'Author', audibleAsin: 'ASIN123' }, + audiobook: { id: 'ab-1', title: 'Title', author: 'Author', audibleAsin: 'ASIN123' }, }); - prismaMock.audibleCache.findUnique.mockResolvedValueOnce({ releaseDate: '2022-05-01' }); - fsMock.access.mockResolvedValueOnce(undefined); - downloadEbookMock.mockResolvedValueOnce({ - success: true, - format: 'epub', - filePath: '/media/audiobooks/Author/Title ASIN123/Title.epub', + + // Mock existing failed ebook request + prismaMock.request.findFirst.mockResolvedValueOnce({ + id: 'ebook-req-existing', + status: 'failed', + }); + + // Mock update for retry + prismaMock.request.update.mockResolvedValueOnce({ + id: 'ebook-req-existing', + status: 'pending', }); const { POST } = await import('@/app/api/requests/[id]/fetch-ebook/route'); @@ -398,29 +432,35 @@ describe('Request action routes', () => { const payload = await response.json(); expect(payload.success).toBe(true); - expect(downloadEbookMock).toHaveBeenCalledWith( - 'ASIN123', - 'Title', - 'Author', - expect.stringContaining('Title ASIN123'), - 'epub', - 'https://ebooks.example', - undefined, - 'http://flaresolverr' - ); + expect(payload.message).toMatch(/retried/i); + expect(payload.requestId).toBe('ebook-req-existing'); + expect(prismaMock.request.update).toHaveBeenCalledWith({ + where: { id: 'ebook-req-existing' }, + data: expect.objectContaining({ + status: 'pending', + progress: 0, + errorMessage: null, + }), + }); + expect(jobQueueMock.addSearchEbookJob).toHaveBeenCalled(); }); - it('returns failure payload when ebook download fails', async () => { + it('returns message when ebook request already exists and in progress', async () => { configState.values.set('ebook_sidecar_enabled', 'true'); + + // Mock parent request lookup prismaMock.request.findUnique.mockResolvedValueOnce({ id: 'req-8', + userId: 'user-1', + audiobookId: 'ab-1', status: 'downloaded', - audiobook: { title: 'Title', author: 'Author', audibleAsin: 'ASIN123' }, + audiobook: { id: 'ab-1', title: 'Title', author: 'Author', audibleAsin: 'ASIN123' }, }); - fsMock.access.mockResolvedValueOnce(undefined); - downloadEbookMock.mockResolvedValueOnce({ - success: false, - error: 'Download failed', + + // Mock existing in-progress ebook request + prismaMock.request.findFirst.mockResolvedValueOnce({ + id: 'ebook-req-existing', + status: 'downloading', }); const { POST } = await import('@/app/api/requests/[id]/fetch-ebook/route'); @@ -428,7 +468,11 @@ describe('Request action routes', () => { const payload = await response.json(); expect(payload.success).toBe(false); - expect(payload.message).toMatch(/Download failed/); + expect(payload.message).toMatch(/already exists/i); + expect(payload.requestId).toBe('ebook-req-existing'); + // Should not create new request or trigger search + expect(prismaMock.request.create).not.toHaveBeenCalled(); + expect(jobQueueMock.addSearchEbookJob).not.toHaveBeenCalled(); }); }); diff --git a/tests/processors/direct-download.processor.test.ts b/tests/processors/direct-download.processor.test.ts new file mode 100644 index 0000000..ed3466a --- /dev/null +++ b/tests/processors/direct-download.processor.test.ts @@ -0,0 +1,362 @@ +/** + * Component: Direct Download Processor Tests + * Documentation: documentation/integrations/ebook-sidecar.md + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPrismaMock } from '../helpers/prisma'; + +const prismaMock = createPrismaMock(); + +const configServiceMock = vi.hoisted(() => ({ + get: vi.fn(), +})); + +const jobQueueMock = vi.hoisted(() => ({ + addOrganizeJob: vi.fn(() => Promise.resolve()), + addMonitorDirectDownloadJob: vi.fn(() => Promise.resolve()), +})); + +const ebookScraperMock = vi.hoisted(() => ({ + extractDownloadUrl: vi.fn(), +})); + +const fsMock = vi.hoisted(() => ({ + mkdir: vi.fn().mockResolvedValue(undefined), + stat: vi.fn(), + unlink: vi.fn().mockResolvedValue(undefined), +})); + +const axiosMock = vi.hoisted(() => vi.fn()); + +const createWriteStreamMock = vi.hoisted(() => vi.fn()); + +vi.mock('@/lib/db', () => ({ + prisma: prismaMock, +})); + +vi.mock('@/lib/services/config.service', () => ({ + getConfigService: () => configServiceMock, +})); + +vi.mock('@/lib/services/job-queue.service', () => ({ + getJobQueueService: () => jobQueueMock, +})); + +vi.mock('@/lib/services/ebook-scraper', () => ebookScraperMock); + +vi.mock('fs/promises', () => ({ + default: fsMock, + ...fsMock, +})); + +vi.mock('fs', () => ({ + createWriteStream: createWriteStreamMock, +})); + +vi.mock('axios', () => ({ + default: axiosMock, +})); + +describe('processStartDirectDownload', () => { + beforeEach(() => { + vi.clearAllMocks(); + configServiceMock.get.mockImplementation(async (key: string) => { + if (key === 'downloads_dir') return '/downloads'; + if (key === 'ebook_sidecar_base_url') return 'https://annas-archive.li'; + if (key === 'ebook_sidecar_preferred_format') return 'epub'; + return null; + }); + }); + + it('updates request status to downloading', async () => { + prismaMock.request.update.mockResolvedValue({}); + prismaMock.downloadHistory.update.mockResolvedValue({}); + prismaMock.downloadHistory.findUnique.mockResolvedValue({ + torrentUrl: JSON.stringify(['https://slow.example.com/book']), + }); + + // Mock successful download + ebookScraperMock.extractDownloadUrl.mockResolvedValue({ + url: 'https://direct.example.com/book.epub', + format: 'epub', + }); + + // Mock axios stream + const mockWriteStream = { + on: vi.fn((event, cb) => { + if (event === 'finish') setTimeout(cb, 10); + return mockWriteStream; + }), + close: vi.fn(), + }; + createWriteStreamMock.mockReturnValue(mockWriteStream); + + const mockDataStream = { + on: vi.fn().mockReturnThis(), + pipe: vi.fn().mockReturnValue(mockWriteStream), + }; + axiosMock.mockResolvedValue({ + data: mockDataStream, + headers: { 'content-length': '1000000' }, + }); + + fsMock.stat.mockResolvedValue({ size: 1000000 }); + prismaMock.request.findUnique.mockResolvedValue({ + id: 'req-1', + audiobookId: 'ab-1', + audiobook: { id: 'ab-1' }, + }); + + const { processStartDirectDownload } = await import('@/lib/processors/direct-download.processor'); + + const result = await processStartDirectDownload({ + requestId: 'req-1', + downloadHistoryId: 'dh-1', + downloadUrl: 'https://slow.example.com/book', + targetFilename: 'Test Book.epub', + jobId: 'job-1', + }); + + // Check status updates + expect(prismaMock.request.update).toHaveBeenCalledWith({ + where: { id: 'req-1' }, + data: expect.objectContaining({ + status: 'downloading', + progress: 0, + }), + }); + + expect(prismaMock.downloadHistory.update).toHaveBeenCalledWith({ + where: { id: 'dh-1' }, + data: expect.objectContaining({ + downloadStatus: 'downloading', + }), + }); + }); + + it('triggers organize job after successful download', async () => { + prismaMock.request.update.mockResolvedValue({}); + prismaMock.downloadHistory.update.mockResolvedValue({}); + prismaMock.downloadHistory.findUnique.mockResolvedValue({ + torrentUrl: JSON.stringify(['https://slow.example.com/book']), + }); + + ebookScraperMock.extractDownloadUrl.mockResolvedValue({ + url: 'https://direct.example.com/book.epub', + format: 'epub', + }); + + const mockWriteStream = { + on: vi.fn((event, cb) => { + if (event === 'finish') setTimeout(cb, 10); + return mockWriteStream; + }), + close: vi.fn(), + }; + createWriteStreamMock.mockReturnValue(mockWriteStream); + + const mockDataStream = { + on: vi.fn().mockReturnThis(), + pipe: vi.fn().mockReturnValue(mockWriteStream), + }; + axiosMock.mockResolvedValue({ + data: mockDataStream, + headers: { 'content-length': '500000' }, + }); + + fsMock.stat.mockResolvedValue({ size: 500000 }); + prismaMock.request.findUnique.mockResolvedValue({ + id: 'req-2', + audiobookId: 'ab-2', + audiobook: { id: 'ab-2' }, + }); + + const { processStartDirectDownload } = await import('@/lib/processors/direct-download.processor'); + + const result = await processStartDirectDownload({ + requestId: 'req-2', + downloadHistoryId: 'dh-2', + downloadUrl: 'https://slow.example.com/book2', + targetFilename: 'Another Book.epub', + jobId: 'job-2', + }); + + expect(result.success).toBe(true); + expect(jobQueueMock.addOrganizeJob).toHaveBeenCalledWith( + 'req-2', + 'ab-2', + expect.stringContaining('Another Book.epub') + ); + }); + + it('marks request as failed when all download attempts fail', async () => { + prismaMock.request.update.mockResolvedValue({}); + prismaMock.downloadHistory.update.mockResolvedValue({}); + prismaMock.downloadHistory.findUnique.mockResolvedValue({ + torrentUrl: JSON.stringify([ + 'https://slow1.example.com/book', + 'https://slow2.example.com/book', + ]), + }); + + // All extract attempts fail + ebookScraperMock.extractDownloadUrl.mockResolvedValue(null); + + const { processStartDirectDownload } = await import('@/lib/processors/direct-download.processor'); + + const result = await processStartDirectDownload({ + requestId: 'req-3', + downloadHistoryId: 'dh-3', + downloadUrl: 'https://slow1.example.com/book', + targetFilename: 'Failed Book.epub', + jobId: 'job-3', + }); + + expect(result.success).toBe(false); + // Verify the second call (final failure status update) + expect(prismaMock.request.update).toHaveBeenLastCalledWith({ + where: { id: 'req-3' }, + data: expect.objectContaining({ + status: 'failed', + }), + }); + expect(prismaMock.downloadHistory.update).toHaveBeenLastCalledWith({ + where: { id: 'dh-3' }, + data: expect.objectContaining({ + downloadStatus: 'failed', + }), + }); + }); + + it('uses FlareSolverr when configured', async () => { + prismaMock.request.update.mockResolvedValue({}); + prismaMock.downloadHistory.update.mockResolvedValue({}); + prismaMock.downloadHistory.findUnique.mockResolvedValue({ + torrentUrl: JSON.stringify(['https://slow.example.com/book']), + }); + + configServiceMock.get.mockImplementation(async (key: string) => { + if (key === 'downloads_dir') return '/downloads'; + if (key === 'ebook_sidecar_base_url') return 'https://annas-archive.li'; + if (key === 'ebook_sidecar_preferred_format') return 'epub'; + if (key === 'ebook_sidecar_flaresolverr_url') return 'http://flaresolverr:8191'; + return null; + }); + + ebookScraperMock.extractDownloadUrl.mockResolvedValue({ + url: 'https://direct.example.com/book.epub', + format: 'epub', + }); + + const mockWriteStream = { + on: vi.fn((event, cb) => { + if (event === 'finish') setTimeout(cb, 10); + return mockWriteStream; + }), + close: vi.fn(), + }; + createWriteStreamMock.mockReturnValue(mockWriteStream); + + const mockDataStream = { + on: vi.fn().mockReturnThis(), + pipe: vi.fn().mockReturnValue(mockWriteStream), + }; + axiosMock.mockResolvedValue({ + data: mockDataStream, + headers: { 'content-length': '500000' }, + }); + + fsMock.stat.mockResolvedValue({ size: 500000 }); + prismaMock.request.findUnique.mockResolvedValue({ + id: 'req-4', + audiobookId: 'ab-4', + audiobook: { id: 'ab-4' }, + }); + + const { processStartDirectDownload } = await import('@/lib/processors/direct-download.processor'); + + await processStartDirectDownload({ + requestId: 'req-4', + downloadHistoryId: 'dh-4', + downloadUrl: 'https://slow.example.com/book', + targetFilename: 'Flare Book.epub', + jobId: 'job-4', + }); + + expect(ebookScraperMock.extractDownloadUrl).toHaveBeenCalledWith( + 'https://slow.example.com/book', + 'https://annas-archive.li', + 'epub', + expect.anything(), + 'http://flaresolverr:8191' + ); + }); + + it('handles errors and updates request status', async () => { + prismaMock.request.update.mockResolvedValue({}); + prismaMock.downloadHistory.update.mockResolvedValue({}); + prismaMock.downloadHistory.findUnique.mockRejectedValue(new Error('Database error')); + + const { processStartDirectDownload } = await import('@/lib/processors/direct-download.processor'); + + await expect(processStartDirectDownload({ + requestId: 'req-5', + downloadHistoryId: 'dh-5', + downloadUrl: 'https://slow.example.com/book', + targetFilename: 'Error Book.epub', + jobId: 'job-5', + })).rejects.toThrow('Database error'); + + expect(prismaMock.request.update).toHaveBeenCalledWith({ + where: { id: 'req-5' }, + data: expect.objectContaining({ + status: 'failed', + errorMessage: 'Database error', + }), + }); + }); +}); + +describe('processMonitorDirectDownload', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns completed status when download file exists', async () => { + fsMock.stat.mockResolvedValue({ size: 1000000 }); + prismaMock.request.update.mockResolvedValue({}); + + const { processMonitorDirectDownload } = await import('@/lib/processors/direct-download.processor'); + + const result = await processMonitorDirectDownload({ + requestId: 'req-m1', + downloadHistoryId: 'dh-m1', + downloadId: 'dl_unknown', + targetPath: '/downloads/book.epub', + expectedSize: 1000000, + jobId: 'job-m1', + }); + + expect(result.success).toBe(true); + expect(result.completed).toBe(true); + }); + + it('returns not found when download is not tracked', async () => { + fsMock.stat.mockRejectedValue(new Error('ENOENT')); + + const { processMonitorDirectDownload } = await import('@/lib/processors/direct-download.processor'); + + const result = await processMonitorDirectDownload({ + requestId: 'req-m2', + downloadHistoryId: 'dh-m2', + downloadId: 'dl_missing', + targetPath: '/downloads/missing.epub', + expectedSize: 500000, + jobId: 'job-m2', + }); + + expect(result.success).toBe(false); + expect(result.message).toContain('not found'); + }); +}); diff --git a/tests/processors/organize-files.processor.test.ts b/tests/processors/organize-files.processor.test.ts index 40648a3..a847c02 100644 --- a/tests/processors/organize-files.processor.test.ts +++ b/tests/processors/organize-files.processor.test.ts @@ -40,6 +40,12 @@ vi.mock('@/lib/services/job-queue.service', () => ({ describe('processOrganizeFiles', () => { beforeEach(() => { vi.clearAllMocks(); + // Default mock for request lookup (processor needs to determine request type) + prismaMock.request.findUnique.mockResolvedValue({ + id: 'req-default', + type: 'audiobook', // Default to audiobook type + user: { plexUsername: 'testuser' }, + }); }); it('organizes files and triggers filesystem scan when enabled', async () => { diff --git a/tests/processors/search-ebook.processor.test.ts b/tests/processors/search-ebook.processor.test.ts new file mode 100644 index 0000000..9aa5b20 --- /dev/null +++ b/tests/processors/search-ebook.processor.test.ts @@ -0,0 +1,328 @@ +/** + * Component: Search Ebook Processor Tests + * Documentation: documentation/integrations/ebook-sidecar.md + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPrismaMock } from '../helpers/prisma'; + +const prismaMock = createPrismaMock(); + +const configServiceMock = vi.hoisted(() => ({ + get: vi.fn(), +})); + +const jobQueueMock = vi.hoisted(() => ({ + addStartDirectDownloadJob: vi.fn(() => Promise.resolve()), +})); + +const ebookScraperMock = vi.hoisted(() => ({ + searchByAsin: vi.fn(), + searchByTitle: vi.fn(), + getSlowDownloadLinks: vi.fn(), +})); + +vi.mock('@/lib/db', () => ({ + prisma: prismaMock, +})); + +vi.mock('@/lib/services/config.service', () => ({ + getConfigService: () => configServiceMock, +})); + +vi.mock('@/lib/services/job-queue.service', () => ({ + getJobQueueService: () => jobQueueMock, +})); + +vi.mock('@/lib/services/ebook-scraper', () => ebookScraperMock); + +describe('processSearchEbook', () => { + beforeEach(() => { + vi.clearAllMocks(); + configServiceMock.get.mockImplementation(async (key: string) => { + if (key === 'ebook_sidecar_preferred_format') return 'epub'; + if (key === 'ebook_sidecar_base_url') return 'https://annas-archive.li'; + return null; + }); + }); + + it('searches by ASIN when available and triggers download', async () => { + prismaMock.request.update.mockResolvedValue({}); + prismaMock.downloadHistory.create.mockResolvedValue({ id: 'dh-1' }); + prismaMock.downloadHistory.update.mockResolvedValue({}); + + ebookScraperMock.searchByAsin.mockResolvedValue('abc123md5'); + ebookScraperMock.getSlowDownloadLinks.mockResolvedValue([ + 'https://slow1.example.com/abc123', + 'https://slow2.example.com/abc123', + ]); + + const { processSearchEbook } = await import('@/lib/processors/search-ebook.processor'); + + const result = await processSearchEbook({ + requestId: 'req-1', + audiobook: { + id: 'ab-1', + title: 'Test Book', + author: 'Test Author', + asin: 'B001ASIN', + }, + jobId: 'job-1', + }); + + expect(result.success).toBe(true); + expect(result.message).toContain('ASIN'); + expect(ebookScraperMock.searchByAsin).toHaveBeenCalledWith( + 'B001ASIN', + 'epub', + 'https://annas-archive.li', + expect.anything(), + undefined + ); + expect(jobQueueMock.addStartDirectDownloadJob).toHaveBeenCalledWith( + 'req-1', + 'dh-1', + 'https://slow1.example.com/abc123', + 'Test Book - Test Author.epub', + undefined + ); + }); + + it('falls back to title search when ASIN search fails', async () => { + prismaMock.request.update.mockResolvedValue({}); + prismaMock.downloadHistory.create.mockResolvedValue({ id: 'dh-2' }); + prismaMock.downloadHistory.update.mockResolvedValue({}); + + ebookScraperMock.searchByAsin.mockResolvedValue(null); + ebookScraperMock.searchByTitle.mockResolvedValue('xyz789md5'); + ebookScraperMock.getSlowDownloadLinks.mockResolvedValue([ + 'https://slow1.example.com/xyz789', + ]); + + const { processSearchEbook } = await import('@/lib/processors/search-ebook.processor'); + + const result = await processSearchEbook({ + requestId: 'req-2', + audiobook: { + id: 'ab-2', + title: 'Another Book', + author: 'Another Author', + asin: 'B002ASIN', + }, + jobId: 'job-2', + }); + + expect(result.success).toBe(true); + expect(result.message).toContain('title search'); + expect(ebookScraperMock.searchByAsin).toHaveBeenCalled(); + expect(ebookScraperMock.searchByTitle).toHaveBeenCalledWith( + 'Another Book', + 'Another Author', + 'epub', + 'https://annas-archive.li', + expect.anything(), + undefined + ); + }); + + it('searches by title when no ASIN is available', async () => { + prismaMock.request.update.mockResolvedValue({}); + prismaMock.downloadHistory.create.mockResolvedValue({ id: 'dh-3' }); + prismaMock.downloadHistory.update.mockResolvedValue({}); + + ebookScraperMock.searchByTitle.mockResolvedValue('noasin123'); + ebookScraperMock.getSlowDownloadLinks.mockResolvedValue([ + 'https://slow.example.com/noasin123', + ]); + + const { processSearchEbook } = await import('@/lib/processors/search-ebook.processor'); + + const result = await processSearchEbook({ + requestId: 'req-3', + audiobook: { + id: 'ab-3', + title: 'No ASIN Book', + author: 'No ASIN Author', + // No asin field + }, + jobId: 'job-3', + }); + + expect(result.success).toBe(true); + expect(ebookScraperMock.searchByAsin).not.toHaveBeenCalled(); + expect(ebookScraperMock.searchByTitle).toHaveBeenCalled(); + }); + + it('marks request as awaiting_search when no ebook found', async () => { + prismaMock.request.update.mockResolvedValue({}); + + ebookScraperMock.searchByAsin.mockResolvedValue(null); + ebookScraperMock.searchByTitle.mockResolvedValue(null); + + const { processSearchEbook } = await import('@/lib/processors/search-ebook.processor'); + + const result = await processSearchEbook({ + requestId: 'req-4', + audiobook: { + id: 'ab-4', + title: 'Unfindable Book', + author: 'Unknown Author', + asin: 'B004ASIN', + }, + jobId: 'job-4', + }); + + expect(result.success).toBe(false); + expect(result.message).toContain('re-search'); + expect(prismaMock.request.update).toHaveBeenCalledWith({ + where: { id: 'req-4' }, + data: expect.objectContaining({ + status: 'awaiting_search', + errorMessage: expect.stringContaining('No ebook found'), + }), + }); + expect(jobQueueMock.addStartDirectDownloadJob).not.toHaveBeenCalled(); + }); + + it('marks request as awaiting_search when no download links available', async () => { + prismaMock.request.update.mockResolvedValue({}); + + ebookScraperMock.searchByAsin.mockResolvedValue('md5nolinks'); + ebookScraperMock.getSlowDownloadLinks.mockResolvedValue([]); + + const { processSearchEbook } = await import('@/lib/processors/search-ebook.processor'); + + const result = await processSearchEbook({ + requestId: 'req-5', + audiobook: { + id: 'ab-5', + title: 'Book No Links', + author: 'Author No Links', + asin: 'B005ASIN', + }, + jobId: 'job-5', + }); + + expect(result.success).toBe(false); + expect(result.message).toContain('re-search'); + expect(prismaMock.request.update).toHaveBeenCalledWith({ + where: { id: 'req-5' }, + data: expect.objectContaining({ + status: 'awaiting_search', + errorMessage: expect.stringContaining('no download links'), + }), + }); + }); + + it('uses FlareSolverr when configured', async () => { + prismaMock.request.update.mockResolvedValue({}); + prismaMock.downloadHistory.create.mockResolvedValue({ id: 'dh-6' }); + prismaMock.downloadHistory.update.mockResolvedValue({}); + + configServiceMock.get.mockImplementation(async (key: string) => { + if (key === 'ebook_sidecar_preferred_format') return 'epub'; + if (key === 'ebook_sidecar_base_url') return 'https://annas-archive.li'; + if (key === 'ebook_sidecar_flaresolverr_url') return 'http://flaresolverr:8191'; + return null; + }); + + ebookScraperMock.searchByAsin.mockResolvedValue('md5withflare'); + ebookScraperMock.getSlowDownloadLinks.mockResolvedValue(['https://slow.example.com/flare']); + + const { processSearchEbook } = await import('@/lib/processors/search-ebook.processor'); + + await processSearchEbook({ + requestId: 'req-6', + audiobook: { + id: 'ab-6', + title: 'Flare Book', + author: 'Flare Author', + asin: 'B006ASIN', + }, + jobId: 'job-6', + }); + + expect(ebookScraperMock.searchByAsin).toHaveBeenCalledWith( + 'B006ASIN', + 'epub', + 'https://annas-archive.li', + expect.anything(), + 'http://flaresolverr:8191' + ); + }); + + it('fails request on unexpected errors', async () => { + prismaMock.request.update.mockResolvedValue({}); + + ebookScraperMock.searchByAsin.mockRejectedValue(new Error('Network error')); + + const { processSearchEbook } = await import('@/lib/processors/search-ebook.processor'); + + await expect(processSearchEbook({ + requestId: 'req-7', + audiobook: { + id: 'ab-7', + title: 'Error Book', + author: 'Error Author', + asin: 'B007ASIN', + }, + jobId: 'job-7', + })).rejects.toThrow('Network error'); + + expect(prismaMock.request.update).toHaveBeenCalledWith({ + where: { id: 'req-7' }, + data: expect.objectContaining({ + status: 'failed', + errorMessage: 'Network error', + }), + }); + }); + + it('creates download history with correct metadata', async () => { + prismaMock.request.update.mockResolvedValue({}); + prismaMock.downloadHistory.create.mockResolvedValue({ id: 'dh-8' }); + prismaMock.downloadHistory.update.mockResolvedValue({}); + + ebookScraperMock.searchByAsin.mockResolvedValue('md5metadata'); + ebookScraperMock.getSlowDownloadLinks.mockResolvedValue([ + 'https://link1.example.com', + 'https://link2.example.com', + ]); + + const { processSearchEbook } = await import('@/lib/processors/search-ebook.processor'); + + await processSearchEbook({ + requestId: 'req-8', + audiobook: { + id: 'ab-8', + title: 'Metadata Book', + author: 'Metadata Author', + asin: 'B008ASIN', + }, + jobId: 'job-8', + }); + + expect(prismaMock.downloadHistory.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + requestId: 'req-8', + indexerName: "Anna's Archive", + torrentName: 'Metadata Book - Metadata Author.epub', + downloadClient: 'direct', + downloadStatus: 'queued', + selected: true, + qualityScore: 100, // ASIN match = 100 + }), + }); + + // Check that all URLs are stored + expect(prismaMock.downloadHistory.update).toHaveBeenCalledWith({ + where: { id: 'dh-8' }, + data: { + torrentUrl: JSON.stringify([ + 'https://link1.example.com', + 'https://link2.example.com', + ]), + }, + }); + }); +}); diff --git a/tests/services/job-queue.service.test.ts b/tests/services/job-queue.service.test.ts index e44599c..884cefa 100644 --- a/tests/services/job-queue.service.test.ts +++ b/tests/services/job-queue.service.test.ts @@ -21,6 +21,10 @@ const processorsMock = vi.hoisted(() => ({ processRetryMissingTorrents: vi.fn().mockResolvedValue('ok'), processRetryFailedImports: vi.fn().mockResolvedValue('ok'), processCleanupSeededTorrents: vi.fn().mockResolvedValue('ok'), + // Ebook processors + processSearchEbook: vi.fn().mockResolvedValue('ok'), + processStartDirectDownload: vi.fn().mockResolvedValue('ok'), + processMonitorDirectDownload: vi.fn().mockResolvedValue('ok'), })); const queueMock = vi.hoisted(() => ({ @@ -111,6 +115,16 @@ vi.mock('@/lib/processors/cleanup-seeded-torrents.processor', () => ({ processCleanupSeededTorrents: processorsMock.processCleanupSeededTorrents, })); +// Ebook processors +vi.mock('@/lib/processors/search-ebook.processor', () => ({ + processSearchEbook: processorsMock.processSearchEbook, +})); + +vi.mock('@/lib/processors/direct-download.processor', () => ({ + processStartDirectDownload: processorsMock.processStartDirectDownload, + processMonitorDirectDownload: processorsMock.processMonitorDirectDownload, +})); + vi.mock('@/lib/db', () => ({ prisma: prismaMock, })); diff --git a/tests/services/request-delete.service.test.ts b/tests/services/request-delete.service.test.ts index a7d5e41..b08326a 100644 --- a/tests/services/request-delete.service.test.ts +++ b/tests/services/request-delete.service.test.ts @@ -56,6 +56,9 @@ vi.mock('@/lib/utils/file-organizer', () => ({ describe('deleteRequest', () => { beforeEach(() => { vi.clearAllMocks(); + // Default mock for child request queries (audiobook requests check for child ebook requests) + prismaMock.request.findMany.mockResolvedValue([]); + prismaMock.request.updateMany.mockResolvedValue({ count: 0 }); }); it('returns not found when request is missing', async () => { diff --git a/tests/utils/file-organizer.test.ts b/tests/utils/file-organizer.test.ts index 0fce271..0605daa 100644 --- a/tests/utils/file-organizer.test.ts +++ b/tests/utils/file-organizer.test.ts @@ -275,17 +275,8 @@ describe('file organizer', () => { expect(fsMock.copyFile).toHaveBeenCalledWith(sourcePath, targetFile); }); - it('downloads remote cover art and ebook sidecar when enabled', async () => { + it('downloads remote cover art when no local cover exists', async () => { configState.values.set('metadata_tagging_enabled', 'false'); - configState.values.set('ebook_sidecar_enabled', 'true'); - configState.values.set('ebook_sidecar_preferred_format', 'epub'); - configState.values.set('ebook_sidecar_base_url', 'https://ebooks.example'); - configState.values.set('ebook_sidecar_flaresolverr_url', 'http://flaresolverr'); - - ebookMock.downloadEbook.mockResolvedValue({ - success: true, - filePath: '/media/Author/Book/book.epub', - }); const organizer = new FileOrganizer('/media', '/tmp'); (organizer as any).findAudiobookFiles = vi.fn().mockResolvedValue({ @@ -322,18 +313,11 @@ describe('file organizer', () => { 'https://images.example/cover.jpg', expect.objectContaining({ responseType: 'arraybuffer' }) ); - expect(ebookMock.downloadEbook).toHaveBeenCalledWith( - 'ASIN123', - 'Book', - 'Author', - expectedDir, - 'epub', - 'https://ebooks.example', - undefined, - 'http://flaresolverr' - ); + // NOTE: Ebook downloads are now handled as first-class requests through the job queue + // The file organizer no longer downloads ebooks inline + expect(ebookMock.downloadEbook).not.toHaveBeenCalled(); expect(fsMock.copyFile).toHaveBeenCalledWith(sourcePath, targetFile); - expect(result.filesMovedCount).toBe(2); + expect(result.filesMovedCount).toBe(1); }); it('records an error when cover art download fails', async () => { @@ -444,36 +428,9 @@ describe('file organizer', () => { expect(result.errors.join(' ')).toContain('Failed to tag 1 file(s) with metadata'); }); - it('records ebook sidecar errors when download throws', async () => { - configState.values.set('metadata_tagging_enabled', 'false'); - configState.values.set('ebook_sidecar_enabled', 'true'); - - ebookMock.downloadEbook.mockRejectedValue(new Error('ebook down')); - - const organizer = new FileOrganizer('/media', '/tmp'); - (organizer as any).findAudiobookFiles = vi.fn().mockResolvedValue({ - audioFiles: ['book.m4b'], - coverFile: undefined, - isFile: false, - }); - - const sourcePath = path.join('/downloads', 'book', 'book.m4b'); - fsMock.access.mockImplementation(async (filePath: string) => { - if (path.normalize(filePath) === path.normalize(sourcePath)) return undefined; - throw new Error('missing'); - }); - fsMock.mkdir.mockResolvedValue(undefined); - fsMock.copyFile.mockResolvedValue(undefined); - fsMock.chmod.mockResolvedValue(undefined); - - const result = await organizer.organize('/downloads/book', { - title: 'Book', - author: 'Author', - }, '{author}/{title}'); - - expect(result.success).toBe(true); - expect(result.errors).toContain('E-book sidecar failed'); - }); + // NOTE: The ebook sidecar test was removed because ebook downloads are now + // handled as first-class requests through the job queue, not inline during + // file organization. See organize-files.processor.ts createEbookRequestIfEnabled(). it('finds audio files and cover art in nested folders', async () => { const organizer = new FileOrganizer('/media', '/tmp'); From 5a0cce79856fa2d8b0653f7ab2b91cd9b050d25c Mon Sep 17 00:00:00 2001 From: kikootwo Date: Fri, 30 Jan 2026 22:12:24 -0500 Subject: [PATCH 2/7] Add multi-source ebook support and per-indexer categories Introduces granular toggles for Anna's Archive and Indexer Search as ebook sources, updates settings UI to a three-section layout, and documents the new configuration. Adds per-indexer category configuration with separate tabs for audiobooks and ebooks, updates API routes and types for new settings, and ensures legacy config migration. Indexer grouping and file organization logic now support the new category structure and ebook source toggles. --- documentation/TABLEOFCONTENTS.md | 7 +- documentation/integrations/ebook-sidecar.md | 43 ++- documentation/settings-pages.md | 58 ++- src/app/admin/page.tsx | 2 +- src/app/admin/settings/lib/types.ts | 15 +- src/app/admin/settings/page.tsx | 3 +- .../admin/settings/tabs/EbookTab/EbookTab.tsx | 339 +++++++++++------- .../tabs/EbookTab/useEbookSettings.ts | 9 +- src/app/api/admin/settings/ebook/route.ts | 25 +- .../admin/settings/prowlarr/indexers/route.ts | 16 +- src/app/api/admin/settings/route.ts | 10 +- .../api/requests/[id]/fetch-ebook/route.ts | 28 +- src/app/setup/steps/ProwlarrStep.tsx | 3 +- .../admin/indexers/CategoryTreeView.tsx | 7 +- .../admin/indexers/IndexerConfigModal.tsx | 114 +++++- .../admin/indexers/IndexerManagement.tsx | 3 +- .../processors/organize-files.processor.ts | 30 +- src/lib/utils/indexer-grouping.ts | 57 ++- src/lib/utils/torrent-categories.ts | 6 +- 19 files changed, 563 insertions(+), 212 deletions(-) diff --git a/documentation/TABLEOFCONTENTS.md b/documentation/TABLEOFCONTENTS.md index 5373898..5c99244 100644 --- a/documentation/TABLEOFCONTENTS.md +++ b/documentation/TABLEOFCONTENTS.md @@ -40,10 +40,13 @@ ## E-book Support (First-Class) - **First-class ebook requests, separate tracking** → [integrations/ebook-sidecar.md](integrations/ebook-sidecar.md) +- **Multi-source ebook downloads (Anna's Archive + Indexer Search)** → [integrations/ebook-sidecar.md](integrations/ebook-sidecar.md) - **ASIN-based matching, format selection** → [integrations/ebook-sidecar.md](integrations/ebook-sidecar.md) - **Ebook ranking algorithm (inverted size scoring)** → [integrations/ebook-sidecar.md](integrations/ebook-sidecar.md) - **Direct HTTP downloads from Anna's Archive** → [integrations/ebook-sidecar.md](integrations/ebook-sidecar.md) - **Ebook delete behavior (files only)** → [integrations/ebook-sidecar.md](integrations/ebook-sidecar.md) +- **Ebook settings (3-section UI)** → [settings-pages.md](settings-pages.md#e-book-sidecar) +- **Indexer categories (audiobook/ebook tabs)** → [settings-pages.md](settings-pages.md#indexer-categories-tabbed) ## Automation Pipeline - **Full pipeline overview** → [phase3/README.md](phase3/README.md) @@ -111,7 +114,9 @@ **"Can I use both qBittorrent and SABnzbd?"** → [phase3/download-clients.md](phase3/download-clients.md) **"How does Plex matching work?"** → [integrations/plex.md](integrations/plex.md) **"How does e-book support work?"** → [integrations/ebook-sidecar.md](integrations/ebook-sidecar.md) -**"How do I enable e-book downloads?"** → [integrations/ebook-sidecar.md](integrations/ebook-sidecar.md), [settings-pages.md](settings-pages.md) +**"How do I enable e-book downloads?"** → [integrations/ebook-sidecar.md](integrations/ebook-sidecar.md), [settings-pages.md](settings-pages.md#e-book-sidecar) +**"How do I configure ebook sources (Anna's Archive vs Indexer)?"** → [settings-pages.md](settings-pages.md#e-book-sidecar) +**"How do I configure ebook categories per indexer?"** → [settings-pages.md](settings-pages.md#indexer-categories-tabbed) **"What happens when I delete an ebook request?"** → [integrations/ebook-sidecar.md](integrations/ebook-sidecar.md#delete-behavior) **"Why do ebook requests have an orange badge?"** → [integrations/ebook-sidecar.md](integrations/ebook-sidecar.md#ui-representation) **"How do scheduled jobs work?"** → [backend/services/scheduler.md](backend/services/scheduler.md) diff --git a/documentation/integrations/ebook-sidecar.md b/documentation/integrations/ebook-sidecar.md index 7e99497..7098442 100644 --- a/documentation/integrations/ebook-sidecar.md +++ b/documentation/integrations/ebook-sidecar.md @@ -1,9 +1,9 @@ # E-book Support -**Status:** ✅ Implemented | First-class ebook requests with Anna's Archive integration +**Status:** ✅ Implemented | First-class ebook requests with multi-source support (Anna's Archive + future Indexer Search) ## Overview -Ebooks are first-class citizens in RMAB, with their own request type, tracking, and UI representation. When an audiobook request completes, an ebook request is automatically created (if enabled). Ebooks are downloaded directly from Anna's Archive via HTTP. +Ebooks are first-class citizens in RMAB, with their own request type, tracking, and UI representation. When an audiobook request completes, an ebook request is automatically created (if a source is enabled). Supports multiple sources: Anna's Archive (direct HTTP) and Indexer Search (via Prowlarr, coming soon). ## Key Details @@ -14,9 +14,9 @@ Ebooks are first-class citizens in RMAB, with their own request type, tracking, - **UI Badge:** Orange (#f16f19) ebook badge to distinguish from audiobooks - **Separate Tracking:** Own progress, status, and error handling -### Flow +### Flow (Anna's Archive) 1. Audiobook organization completes -2. Ebook request created automatically (if enabled) +2. Ebook request created automatically (if Anna's Archive enabled) 3. `search_ebook` job searches Anna's Archive 4. `start_direct_download` downloads via HTTP 5. `organize_files` copies to audiobook folder @@ -25,14 +25,31 @@ Ebooks are first-class citizens in RMAB, with their own request type, tracking, ### Configuration -**Admin Settings → E-book Sidecar tab** +**Admin Settings → E-book Sidecar tab** (3 sections) +#### Section 1: Anna's Archive +| Key | Default | Description | +|-----|---------|-------------| +| `ebook_annas_archive_enabled` | `false` | Enable Anna's Archive downloads | +| `ebook_sidecar_base_url` | `https://annas-archive.li` | Base URL for mirror | +| `ebook_sidecar_flaresolverr_url` | `` (empty) | FlareSolverr proxy URL (optional) | + +#### Section 2: Indexer Search +| Key | Default | Description | +|-----|---------|-------------| +| `ebook_indexer_search_enabled` | `false` | Enable Indexer Search (not yet implemented) | + +*Note: Ebook categories are configured per-indexer in Settings → Indexers → Edit Indexer → EBook tab* + +#### Section 3: General Settings | Key | Default | Options | Description | |-----|---------|---------|-------------| -| `ebook_sidecar_enabled` | `false` | `true/false` | Enable feature | | `ebook_sidecar_preferred_format` | `epub` | `epub, pdf, mobi, azw3, any` | Preferred format | -| `ebook_sidecar_base_url` | `https://annas-archive.li` | URL | Base URL | -| `ebook_sidecar_flaresolverr_url` | `` (empty) | URL | FlareSolverr proxy (optional) | + +### Source Priority +- If **Anna's Archive** is enabled → Use Anna's Archive (current behavior) +- If **only Indexer Search** is enabled → Log "not yet implemented", skip gracefully +- If **both disabled** → Ebook downloads disabled entirely ## Database Schema @@ -173,11 +190,19 @@ Search: https://annas-archive.li/search?q=Title+Author&ext=epub&lang=en ## Limitations -1. Single source (Anna's Archive) - future Prowlarr support stubbed +1. Indexer Search not yet implemented (settings ready, search stubbed) 2. Title search may return wrong book for common titles 3. Download speed depends on file server load 4. English books only (title search filter) +## Indexer Categories + +Indexer configuration supports separate category arrays for audiobooks and ebooks: +- **Audiobook Categories:** Default `[3030]` (Audio/Audiobook) +- **Ebook Categories:** Default `[7020]` (Books/EBook) + +Categories are configured per-indexer via the tabbed interface in the Edit Indexer modal. + ## Related - [File Organization](../phase3/file-organization.md) - Ebook organization - [Settings Pages](../settings-pages.md) - Configuration UI diff --git a/documentation/settings-pages.md b/documentation/settings-pages.md index bce650e..9da5277 100644 --- a/documentation/settings-pages.md +++ b/documentation/settings-pages.md @@ -66,11 +66,63 @@ src/app/admin/settings/ 1. **Plex** - URL, token (masked), library ID, Audible region, filesystem scan trigger toggle 2. **Audiobookshelf** - URL, API token (masked), library ID, Audible region, filesystem scan trigger toggle -3. **Prowlarr** - URL, API key (masked), indexer selection with priority, seeding time, RSS monitoring toggle +3. **Prowlarr** - URL, API key (masked), indexer selection with priority, seeding time, RSS monitoring toggle, **audiobook/ebook categories per indexer** 4. **Download Client** - Type, URL, credentials (masked) 5. **Paths** - Download + media directories, audiobook organization template, metadata tagging toggle, chapter merging toggle -6. **BookDate** - AI provider, API key (encrypted), model selection, library scope, custom prompt, swipe history -7. **Notifications** - Multiple backends (Discord, Pushover), event subscriptions, test functionality +6. **E-book Sidecar** - Multi-source ebook downloads (Anna's Archive + Indexer Search), preferred format +7. **BookDate** - AI provider, API key (encrypted), model selection, library scope, custom prompt, swipe history +8. **Notifications** - Multiple backends (Discord, Pushover), event subscriptions, test functionality + +## E-book Sidecar + +**Purpose:** Configure ebook download sources and preferences to accompany audiobook downloads. + +**Tab Structure (3 sections):** + +1. **Anna's Archive Section** + - Enable toggle for Anna's Archive downloads + - Base URL (default: `https://annas-archive.li`) + - FlareSolverr URL (optional, for Cloudflare bypass) + +2. **Indexer Search Section** + - Enable toggle for indexer-based ebook search (not yet implemented) + - Hint directing users to Indexers tab for category configuration + +3. **General Settings Section** (visible when any source enabled) + - Preferred format: EPUB (recommended), PDF, MOBI, AZW3, Any + +**Configuration Keys:** +| Key | Default | Description | +|-----|---------|-------------| +| `ebook_annas_archive_enabled` | `false` | Enable Anna's Archive | +| `ebook_indexer_search_enabled` | `false` | Enable Indexer Search (stubbed) | +| `ebook_sidecar_preferred_format` | `epub` | Preferred format | +| `ebook_sidecar_base_url` | `https://annas-archive.li` | Anna's Archive mirror | +| `ebook_sidecar_flaresolverr_url` | `` | FlareSolverr URL | + +**Behavior:** +- If Anna's Archive enabled → Downloads work (current implementation) +- If only Indexer Search enabled → Gracefully logs "not yet implemented" +- If both disabled → Ebook downloads completely off + +## Indexer Categories (Tabbed) + +**Purpose:** Configure separate category sets for audiobook and ebook searches per indexer. + +**UI:** Edit Indexer modal has Categories section with two tabs: +- **AudioBook tab** - Categories for audiobook searches (default: `[3030]`) +- **EBook tab** - Categories for ebook searches (default: `[7020]`) + +**Storage:** `prowlarr_indexers` JSON config stores: +```json +{ + "id": 1, + "name": "MyIndexer", + "audiobookCategories": [3030], + "ebookCategories": [7020], + ... +} +``` ## Audible Region diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 96bdbd8..f01e8dc 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -497,7 +497,7 @@ function AdminDashboardContent() {
diff --git a/src/app/admin/settings/lib/types.ts b/src/app/admin/settings/lib/types.ts index 3fd8a46..23cb2bf 100644 --- a/src/app/admin/settings/lib/types.ts +++ b/src/app/admin/settings/lib/types.ts @@ -103,12 +103,17 @@ export interface PathsSettings { /** * E-book sidecar configuration + * Supports two sources: Anna's Archive (direct HTTP) and Indexer Search (Prowlarr) */ export interface EbookSettings { - enabled: boolean; - preferredFormat: string; + // Source toggles + annasArchiveEnabled: boolean; + indexerSearchEnabled: boolean; + // Anna's Archive specific settings baseUrl: string; flaresolverrUrl: string; + // General settings (shared across sources) + preferredFormat: string; } /** @@ -143,7 +148,8 @@ export interface IndexerConfig { seedingTimeMinutes?: number; // Torrents only removeAfterProcessing?: boolean; // Usenet only rssEnabled: boolean; - categories?: number[]; + audiobookCategories?: number[]; // Category IDs for audiobook searches (default: [3030]) + ebookCategories?: number[]; // Category IDs for ebook searches (default: [7020]) supportsRss?: boolean; } @@ -158,7 +164,8 @@ export interface SavedIndexerConfig { seedingTimeMinutes?: number; // Torrents only removeAfterProcessing?: boolean; // Usenet only rssEnabled: boolean; - categories: number[]; + audiobookCategories: number[]; // Category IDs for audiobook searches (default: [3030]) + ebookCategories: number[]; // Category IDs for ebook searches (default: [7020]) } /** diff --git a/src/app/admin/settings/page.tsx b/src/app/admin/settings/page.tsx index 3f1636c..dec2cd8 100644 --- a/src/app/admin/settings/page.tsx +++ b/src/app/admin/settings/page.tsx @@ -106,7 +106,8 @@ export default function AdminSettings() { protocol: idx.protocol, priority: idx.priority, rssEnabled: idx.rssEnabled, - categories: idx.categories || [3030], + audiobookCategories: idx.audiobookCategories || [3030], + ebookCategories: idx.ebookCategories || [7020], }; // Add protocol-specific fields diff --git a/src/app/admin/settings/tabs/EbookTab/EbookTab.tsx b/src/app/admin/settings/tabs/EbookTab/EbookTab.tsx index 3293014..7493ca2 100644 --- a/src/app/admin/settings/tabs/EbookTab/EbookTab.tsx +++ b/src/app/admin/settings/tabs/EbookTab/EbookTab.tsx @@ -1,6 +1,11 @@ /** * Component: E-book Settings Tab * Documentation: documentation/settings-pages.md + * + * Three-section layout: + * 1. Anna's Archive - Direct HTTP downloads from Anna's Archive + * 2. Indexer Search - Search via Prowlarr indexers (future feature) + * 3. General Settings - Shared settings like preferred format */ 'use client'; @@ -27,167 +32,233 @@ export function EbookTab({ ebook, onChange, onSuccess, onError, markAsSaved }: E updateEbook, testFlaresolverrConnection, saveSettings, + isAnySourceEnabled, } = useEbookSettings({ ebook, onChange, onSuccess, onError, markAsSaved }); return (
+ {/* Header */}

E-book Sidecar

- Automatically download e-books from Anna's Archive to accompany your audiobooks. + Automatically download e-books to accompany your audiobooks. E-books are placed in the same folder as the audiobook files.

- {/* Enable Toggle */} -
-
- updateEbook('enabled', e.target.checked)} - className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500" - /> -
- -

- When enabled, the system will search for e-books matching your audiobook's ASIN - and download them to the same folder. -

+ {/* ═══════════════════════════════════════════════════════════════════════ + SECTION 1: ANNA'S ARCHIVE + ═══════════════════════════════════════════════════════════════════════ */} +
+
+

+ Anna's Archive +

+
+
+ {/* Enable Toggle */} +
+ updateEbook('annasArchiveEnabled', e.target.checked)} + className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500" + /> +
+ +

+ Download e-books directly from Anna's Archive using ASIN or title matching. +

+
+ + {/* Anna's Archive specific settings - only shown when enabled */} + {ebook.annasArchiveEnabled && ( + <> + {/* Base URL */} +
+ + updateEbook('baseUrl', e.target.value)} + placeholder="https://annas-archive.li" + className="font-mono" + /> +

+ Change this if the primary Anna's Archive mirror is unavailable. +

+
+ + {/* FlareSolverr URL */} +
+
+ +
+ updateEbook('flaresolverrUrl', e.target.value)} + placeholder="http://localhost:8191" + className="font-mono flex-1" + /> + +
+

+ FlareSolverr helps bypass Cloudflare protection. +

+ {flaresolverrTestResult && ( +
+ {flaresolverrTestResult.success ? '✓ ' : '✗ '} + {flaresolverrTestResult.message} +
+ )} +
+ {!ebook.flaresolverrUrl && ( +
+

+ Note: Without FlareSolverr, e-book downloads may fail if Anna's Archive + has Cloudflare protection enabled. +

+
+ )} +
+ + )}
- {/* Format Selection */} - {ebook.enabled && ( -
- - -

- EPUB is recommended for most e-readers. "Any format" will download the first available format. -

+ {/* ═══════════════════════════════════════════════════════════════════════ + SECTION 2: INDEXER SEARCH + ═══════════════════════════════════════════════════════════════════════ */} +
+
+

+ Indexer Search +

- )} - - {/* Base URL (Advanced) */} - {ebook.enabled && ( -
- - updateEbook('baseUrl', e.target.value)} - placeholder="https://annas-archive.li" - className="font-mono" - /> -

- Change this if the primary Anna's Archive mirror is unavailable. -

-
- )} - - {/* FlareSolverr (Optional - for Cloudflare bypass) */} - {ebook.enabled && ( -
-
- -
- updateEbook('flaresolverrUrl', e.target.value)} - placeholder="http://localhost:8191" - className="font-mono flex-1" - /> - + Enable Indexer Search + +

+ Search for e-books via Prowlarr indexers (torrent/NZB sources). +

-

- FlareSolverr helps bypass Cloudflare protection on Anna's Archive. - Leave empty if not needed. -

- {flaresolverrTestResult && ( -
- {flaresolverrTestResult.success ? '✓ ' : '✗ '} - {flaresolverrTestResult.message} -
- )}
- {!ebook.flaresolverrUrl && ( -
-

- Note: Without FlareSolverr, e-book downloads may fail if Anna's Archive - has Cloudflare protection enabled. Success rates are typically lower without it. + + {/* Info hint about indexer settings */} + {ebook.indexerSearchEnabled && ( +

+

+ Configure Categories: E-book category settings are configured per-indexer + in the Indexers tab. Look for the "EBook" tab when + editing an indexer. +

+
+ )} + + {/* Coming soon notice */} + {ebook.indexerSearchEnabled && ( +
+

+ Coming Soon: Indexer search for e-books is not yet implemented. + Enabling this setting prepares your configuration for when the feature is released.

)}
+
+ + {/* ═══════════════════════════════════════════════════════════════════════ + SECTION 3: GENERAL SETTINGS + ═══════════════════════════════════════════════════════════════════════ */} + {isAnySourceEnabled && ( +
+
+

+ General Settings +

+
+
+ {/* Preferred Format */} +
+ + +

+ EPUB is recommended for most e-readers. "Any format" accepts the first available. +

+
+
+
)} - {/* Info Box */} -
-

- How it works -

-
    -
  • • Searches Anna's Archive in two ways:
  • -
  • 1. First tries ASIN (exact match - most accurate)
  • -
  • 2. Falls back to title + author (with book/language filters)
  • -
  • • Downloads matching e-book in your preferred format
  • -
  • • Places e-book file in the same folder as the audiobook
  • -
  • • If no match is found or download fails, audiobook download continues normally
  • -
  • • Completely optional and non-blocking
  • -
-
- - {/* Warning Box */} -
-

- ⚠️ Important Note -

-

- Anna's Archive is a shadow library. Use of this feature is at your own discretion and responsibility. - Ensure compliance with your local laws and regulations. -

-
+ {/* How it works - only show when Anna's Archive is enabled */} + {ebook.annasArchiveEnabled && ( +
+

+ How Anna's Archive works +

+
    +
  • • Searches by ASIN first (exact match), then title + author
  • +
  • • Downloads matching e-book in your preferred format
  • +
  • • Places e-book file in the same folder as the audiobook
  • +
  • • If no match is found, audiobook download continues normally
  • +
+
+ )} {/* Save Button */}
diff --git a/src/app/admin/settings/tabs/EbookTab/useEbookSettings.ts b/src/app/admin/settings/tabs/EbookTab/useEbookSettings.ts index e57fbd7..41485a1 100644 --- a/src/app/admin/settings/tabs/EbookTab/useEbookSettings.ts +++ b/src/app/admin/settings/tabs/EbookTab/useEbookSettings.ts @@ -77,7 +77,8 @@ export function useEbookSettings({ ebook, onChange, onSuccess, onError, markAsSa method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - enabled: ebook.enabled || false, + annasArchiveEnabled: ebook.annasArchiveEnabled || false, + indexerSearchEnabled: ebook.indexerSearchEnabled || false, format: ebook.preferredFormat || 'epub', baseUrl: ebook.baseUrl || 'https://annas-archive.li', flaresolverrUrl: ebook.flaresolverrUrl || '', @@ -98,6 +99,11 @@ export function useEbookSettings({ ebook, onChange, onSuccess, onError, markAsSa } }; + /** + * Helper to check if any ebook source is enabled + */ + const isAnySourceEnabled = ebook.annasArchiveEnabled || ebook.indexerSearchEnabled; + return { saving, testingFlaresolverr, @@ -105,5 +111,6 @@ export function useEbookSettings({ ebook, onChange, onSuccess, onError, markAsSa updateEbook, testFlaresolverrConnection, saveSettings, + isAnySourceEnabled, }; } diff --git a/src/app/api/admin/settings/ebook/route.ts b/src/app/api/admin/settings/ebook/route.ts index 0a9115f..60a16ef 100644 --- a/src/app/api/admin/settings/ebook/route.ts +++ b/src/app/api/admin/settings/ebook/route.ts @@ -13,8 +13,8 @@ export async function PUT(request: NextRequest) { return requireAuth(request, async (req: AuthenticatedRequest) => { return requireAdmin(req, async () => { try { - // Parse request body - const { enabled, format, baseUrl, flaresolverrUrl } = await request.json(); + // Parse request body - new structure with separate source toggles + const { annasArchiveEnabled, indexerSearchEnabled, format, baseUrl, flaresolverrUrl } = await request.json(); // Validate format const validFormats = ['epub', 'pdf', 'mobi', 'azw3', 'any']; @@ -25,8 +25,8 @@ export async function PUT(request: NextRequest) { ); } - // Validate baseUrl (basic check) - if (baseUrl && !baseUrl.startsWith('http')) { + // Validate baseUrl (basic check) - only required if Anna's Archive is enabled + if (annasArchiveEnabled && baseUrl && !baseUrl.startsWith('http')) { return NextResponse.json( { error: 'Base URL must start with http:// or https://' }, { status: 400 } @@ -46,23 +46,32 @@ export async function PUT(request: NextRequest) { const configService = getConfigService(); const configs = [ + // New granular source toggles { - key: 'ebook_sidecar_enabled', - value: enabled ? 'true' : 'false', + key: 'ebook_annas_archive_enabled', + value: annasArchiveEnabled ? 'true' : 'false', category: 'ebook', - description: 'Enable e-book sidecar downloads from Annas Archive', + description: 'Enable e-book downloads from Anna\'s Archive', }, + { + key: 'ebook_indexer_search_enabled', + value: indexerSearchEnabled ? 'true' : 'false', + category: 'ebook', + description: 'Enable e-book downloads via indexer search (Prowlarr)', + }, + // General settings { key: 'ebook_sidecar_preferred_format', value: format || 'epub', category: 'ebook', description: 'Preferred e-book format', }, + // Anna's Archive specific settings { key: 'ebook_sidecar_base_url', value: baseUrl || 'https://annas-archive.li', category: 'ebook', - description: 'Base URL for Annas Archive', + description: 'Base URL for Anna\'s Archive', }, { key: 'ebook_sidecar_flaresolverr_url', diff --git a/src/app/api/admin/settings/prowlarr/indexers/route.ts b/src/app/api/admin/settings/prowlarr/indexers/route.ts index d422936..1a54d47 100644 --- a/src/app/api/admin/settings/prowlarr/indexers/route.ts +++ b/src/app/api/admin/settings/prowlarr/indexers/route.ts @@ -19,7 +19,9 @@ interface SavedIndexerConfig { seedingTimeMinutes?: number; // Torrents only removeAfterProcessing?: boolean; // Usenet only rssEnabled?: boolean; - categories?: number[]; // Array of category IDs (default: [3030] for audiobooks) + audiobookCategories?: number[]; // Array of category IDs for audiobooks (default: [3030]) + ebookCategories?: number[]; // Array of category IDs for ebooks (default: [7020]) + categories?: number[]; // Legacy field for migration } /** @@ -54,6 +56,12 @@ export async function GET(request: NextRequest) { const isAdded = !!saved; const isTorrent = indexer.protocol?.toLowerCase() === 'torrent'; + // Migration: if old 'categories' field exists but new fields don't, migrate + const migratedAudiobookCategories = saved?.audiobookCategories || + saved?.categories || // Legacy migration + [3030]; // Default to audiobooks category + const migratedEbookCategories = saved?.ebookCategories || [7020]; // Default to ebooks category + const config: any = { id: indexer.id, name: indexer.name, @@ -63,7 +71,8 @@ export async function GET(request: NextRequest) { isAdded, // Explicit flag for UI (new card-based interface) priority: saved?.priority || 10, rssEnabled: saved?.rssEnabled ?? false, - categories: saved?.categories || [3030], // Default to audiobooks category + audiobookCategories: migratedAudiobookCategories, + ebookCategories: migratedEbookCategories, supportsRss: indexer.capabilities?.supportsRss !== false, // Default to true if not specified }; @@ -117,7 +126,8 @@ export async function PUT(request: NextRequest) { protocol: indexer.protocol, priority: indexer.priority, rssEnabled: indexer.rssEnabled || false, - categories: indexer.categories || [3030], // Default to audiobooks if not specified + audiobookCategories: indexer.audiobookCategories || [3030], // Default to audiobooks + ebookCategories: indexer.ebookCategories || [7020], // Default to ebooks }; // Add protocol-specific fields diff --git a/src/app/api/admin/settings/route.ts b/src/app/api/admin/settings/route.ts index 059b4c7..f90127c 100644 --- a/src/app/api/admin/settings/route.ts +++ b/src/app/api/admin/settings/route.ts @@ -100,10 +100,16 @@ export async function GET(request: NextRequest) { chapterMergingEnabled: configMap.get('chapter_merging_enabled') === 'true', }, ebook: { - enabled: configMap.get('ebook_sidecar_enabled') === 'true', - preferredFormat: configMap.get('ebook_sidecar_preferred_format') || 'epub', + // New granular source toggles (with migration from legacy ebook_sidecar_enabled) + annasArchiveEnabled: configMap.get('ebook_annas_archive_enabled') === 'true' || + // Migration: if old key is true and new key doesn't exist, use old value + (configMap.get('ebook_annas_archive_enabled') === undefined && configMap.get('ebook_sidecar_enabled') === 'true'), + indexerSearchEnabled: configMap.get('ebook_indexer_search_enabled') === 'true', + // Anna's Archive specific settings baseUrl: configMap.get('ebook_sidecar_base_url') || 'https://annas-archive.li', flaresolverrUrl: configMap.get('ebook_sidecar_flaresolverr_url') || '', + // General settings + preferredFormat: configMap.get('ebook_sidecar_preferred_format') || 'epub', }, general: { appName: configMap.get('app_name') || 'ReadMeABook', diff --git a/src/app/api/requests/[id]/fetch-ebook/route.ts b/src/app/api/requests/[id]/fetch-ebook/route.ts index 1714de9..e6f99b7 100644 --- a/src/app/api/requests/[id]/fetch-ebook/route.ts +++ b/src/app/api/requests/[id]/fetch-ebook/route.ts @@ -22,14 +22,30 @@ export async function POST( try { const { id: parentRequestId } = await params; - // Check if e-book sidecar is enabled - const ebookEnabledConfig = await prisma.configuration.findUnique({ - where: { key: 'ebook_sidecar_enabled' }, - }); + // Check which ebook sources are enabled + const [annasArchiveConfig, indexerSearchConfig, legacyConfig] = await Promise.all([ + prisma.configuration.findUnique({ where: { key: 'ebook_annas_archive_enabled' } }), + prisma.configuration.findUnique({ where: { key: 'ebook_indexer_search_enabled' } }), + prisma.configuration.findUnique({ where: { key: 'ebook_sidecar_enabled' } }), + ]); - if (ebookEnabledConfig?.value !== 'true') { + // Legacy migration: check old key if new keys don't exist + const isAnnasArchiveEnabled = annasArchiveConfig?.value === 'true' || + (annasArchiveConfig === null && legacyConfig?.value === 'true'); + const isIndexerSearchEnabled = indexerSearchConfig?.value === 'true'; + + // If no sources are enabled, return error + if (!isAnnasArchiveEnabled && !isIndexerSearchEnabled) { return NextResponse.json( - { error: 'E-book sidecar feature is not enabled' }, + { error: 'E-book sidecar feature is not enabled (no sources configured)' }, + { status: 400 } + ); + } + + // If only indexer search is enabled (not yet implemented), return error + if (!isAnnasArchiveEnabled && isIndexerSearchEnabled) { + return NextResponse.json( + { error: 'E-book indexer search is not yet implemented. Enable Anna\'s Archive to fetch e-books.' }, { status: 400 } ); } diff --git a/src/app/setup/steps/ProwlarrStep.tsx b/src/app/setup/steps/ProwlarrStep.tsx index 537a140..4f22d5c 100644 --- a/src/app/setup/steps/ProwlarrStep.tsx +++ b/src/app/setup/steps/ProwlarrStep.tsx @@ -26,7 +26,8 @@ interface SelectedIndexer { seedingTimeMinutes?: number; // Torrents only removeAfterProcessing?: boolean; // Usenet only rssEnabled: boolean; - categories: number[]; + audiobookCategories: number[]; // Categories for audiobook searches + ebookCategories: number[]; // Categories for ebook searches } export function ProwlarrStep({ diff --git a/src/components/admin/indexers/CategoryTreeView.tsx b/src/components/admin/indexers/CategoryTreeView.tsx index 98ec21c..c2ff92d 100644 --- a/src/components/admin/indexers/CategoryTreeView.tsx +++ b/src/components/admin/indexers/CategoryTreeView.tsx @@ -16,12 +16,15 @@ import { interface CategoryTreeViewProps { selectedCategories: number[]; onChange: (categories: number[]) => void; + defaultCategories?: number[]; // Categories to show "Default" badge for (e.g., [3030] for audiobook, [7020] for ebook) } export function CategoryTreeView({ selectedCategories, onChange, + defaultCategories = [3030], // Default to audiobook category for backwards compatibility }: CategoryTreeViewProps) { + const isDefaultCategory = (categoryId: number) => defaultCategories.includes(categoryId); const handleParentToggle = (parentId: number) => { const childIds = getChildIds(parentId); const allChildrenSelected = areAllChildrenSelected(parentId, selectedCategories); @@ -75,7 +78,7 @@ export function CategoryTreeView({ [{category.id}] - {category.id === 3030 && ( + {isDefaultCategory(category.id) && ( Default @@ -109,7 +112,7 @@ export function CategoryTreeView({ [{child.id}] - {child.id === 3030 && ( + {isDefaultCategory(child.id) && ( Default diff --git a/src/components/admin/indexers/IndexerConfigModal.tsx b/src/components/admin/indexers/IndexerConfigModal.tsx index 77af36f..8d84eb0 100644 --- a/src/components/admin/indexers/IndexerConfigModal.tsx +++ b/src/components/admin/indexers/IndexerConfigModal.tsx @@ -1,6 +1,9 @@ /** * Component: Indexer Configuration Modal * Documentation: documentation/frontend/components.md + * + * Supports separate category configurations for AudioBook and EBook searches + * via tabbed interface in the Categories section. */ 'use client'; @@ -10,7 +13,9 @@ import { Modal } from '@/components/ui/Modal'; import { Button } from '@/components/ui/Button'; import { Input } from '@/components/ui/Input'; import { CategoryTreeView } from './CategoryTreeView'; -import { DEFAULT_CATEGORIES } from '@/lib/utils/torrent-categories'; +import { DEFAULT_AUDIOBOOK_CATEGORIES, DEFAULT_EBOOK_CATEGORIES } from '@/lib/utils/torrent-categories'; + +type CategoryTab = 'audiobook' | 'ebook'; interface IndexerConfigModalProps { isOpen: boolean; @@ -27,7 +32,8 @@ interface IndexerConfigModalProps { seedingTimeMinutes?: number; removeAfterProcessing?: boolean; rssEnabled: boolean; - categories: number[]; + audiobookCategories: number[]; + ebookCategories: number[]; }; onSave: (config: { id: number; @@ -37,7 +43,8 @@ interface IndexerConfigModalProps { seedingTimeMinutes?: number; removeAfterProcessing?: boolean; rssEnabled: boolean; - categories: number[]; + audiobookCategories: number[]; + ebookCategories: number[]; }) => void; } @@ -56,7 +63,8 @@ export function IndexerConfigModal({ seedingTimeMinutes: 0, removeAfterProcessing: true, // Default to true for Usenet rssEnabled: indexer.supportsRss, - categories: DEFAULT_CATEGORIES, // Default to Audio/Audiobook [3030] + audiobookCategories: DEFAULT_AUDIOBOOK_CATEGORIES, + ebookCategories: DEFAULT_EBOOK_CATEGORIES, }; // Form state @@ -72,15 +80,24 @@ export function IndexerConfigModal({ const [rssEnabled, setRssEnabled] = useState( initialConfig?.rssEnabled ?? defaults.rssEnabled ); - const [selectedCategories, setSelectedCategories] = useState( - initialConfig?.categories ?? defaults.categories + + // Dual category state + const [audiobookCategories, setAudiobookCategories] = useState( + initialConfig?.audiobookCategories ?? defaults.audiobookCategories ); + const [ebookCategories, setEbookCategories] = useState( + initialConfig?.ebookCategories ?? defaults.ebookCategories + ); + + // Tab state for categories + const [activeTab, setActiveTab] = useState('audiobook'); // Validation errors const [errors, setErrors] = useState<{ priority?: string; seedingTimeMinutes?: string; - categories?: string; + audiobookCategories?: string; + ebookCategories?: string; }>({}); // Reset form when modal opens or indexer changes @@ -91,14 +108,17 @@ export function IndexerConfigModal({ setSeedingTimeMinutes(defaults.seedingTimeMinutes); setRemoveAfterProcessing(defaults.removeAfterProcessing); setRssEnabled(defaults.rssEnabled); - setSelectedCategories(defaults.categories); + setAudiobookCategories(defaults.audiobookCategories); + setEbookCategories(defaults.ebookCategories); } else { setPriority(initialConfig?.priority ?? defaults.priority); setSeedingTimeMinutes(initialConfig?.seedingTimeMinutes ?? defaults.seedingTimeMinutes); setRemoveAfterProcessing(initialConfig?.removeAfterProcessing ?? defaults.removeAfterProcessing); setRssEnabled(initialConfig?.rssEnabled ?? defaults.rssEnabled); - setSelectedCategories(initialConfig?.categories ?? defaults.categories); + setAudiobookCategories(initialConfig?.audiobookCategories ?? defaults.audiobookCategories); + setEbookCategories(initialConfig?.ebookCategories ?? defaults.ebookCategories); } + setActiveTab('audiobook'); setErrors({}); } }, [isOpen, mode, indexer.id]); @@ -114,8 +134,12 @@ export function IndexerConfigModal({ newErrors.seedingTimeMinutes = 'Seeding time cannot be negative'; } - if (selectedCategories.length === 0) { - newErrors.categories = 'At least one category must be selected'; + if (audiobookCategories.length === 0) { + newErrors.audiobookCategories = 'At least one audiobook category must be selected'; + } + + if (ebookCategories.length === 0) { + newErrors.ebookCategories = 'At least one ebook category must be selected'; } setErrors(newErrors); @@ -124,6 +148,12 @@ export function IndexerConfigModal({ const handleSave = () => { if (!validate()) { + // If there's a category error, switch to the relevant tab + if (errors.audiobookCategories && activeTab !== 'audiobook') { + setActiveTab('audiobook'); + } else if (errors.ebookCategories && activeTab !== 'ebook') { + setActiveTab('ebook'); + } return; } @@ -133,7 +163,8 @@ export function IndexerConfigModal({ protocol: indexer.protocol, priority, rssEnabled: indexer.supportsRss ? rssEnabled : false, - categories: selectedCategories, + audiobookCategories, + ebookCategories, }; // Add protocol-specific fields @@ -168,6 +199,12 @@ export function IndexerConfigModal({ } }; + // Get the current categories based on active tab + const currentCategories = activeTab === 'audiobook' ? audiobookCategories : ebookCategories; + const setCurrentCategories = activeTab === 'audiobook' ? setAudiobookCategories : setEbookCategories; + const currentError = activeTab === 'audiobook' ? errors.audiobookCategories : errors.ebookCategories; + const defaultForTab = activeTab === 'audiobook' ? DEFAULT_AUDIOBOOK_CATEGORIES : DEFAULT_EBOOK_CATEGORIES; + return ( - {/* Categories */} + {/* Categories with Tabs */}
-
+ + {/* Tab Navigation */} +
+ + +
+ + {/* Tab Content */} +
+

- Select categories to search on this indexer. Parent selection locks all children as selected. + {activeTab === 'audiobook' + ? 'Categories to search for audiobooks. Default: Audio/Audiobook [3030]' + : 'Categories to search for e-books. Default: Books/EBook [7020]'}

- {errors.categories && ( + + {currentError && (

- {errors.categories} + {currentError}

)}
diff --git a/src/components/admin/indexers/IndexerManagement.tsx b/src/components/admin/indexers/IndexerManagement.tsx index cb96618..da95b3f 100644 --- a/src/components/admin/indexers/IndexerManagement.tsx +++ b/src/components/admin/indexers/IndexerManagement.tsx @@ -28,7 +28,8 @@ interface SavedIndexerConfig { seedingTimeMinutes?: number; // Torrents only removeAfterProcessing?: boolean; // Usenet only rssEnabled: boolean; - categories: number[]; + audiobookCategories: number[]; // Categories for audiobook searches + ebookCategories: number[]; // Categories for ebook searches } interface IndexerManagementProps { diff --git a/src/lib/processors/organize-files.processor.ts b/src/lib/processors/organize-files.processor.ts index 9976f20..55a314c 100644 --- a/src/lib/processors/organize-files.processor.ts +++ b/src/lib/processors/organize-files.processor.ts @@ -608,6 +608,10 @@ async function processEbookOrganization( /** * Create ebook request if ebook downloads are enabled * Called after audiobook organization completes + * + * Supports two ebook sources: + * - Anna's Archive (ebook_annas_archive_enabled) - Currently implemented + * - Indexer Search (ebook_indexer_search_enabled) - Future feature, gracefully skipped */ async function createEbookRequestIfEnabled( parentRequestId: string, @@ -617,15 +621,31 @@ async function createEbookRequestIfEnabled( logger: RMABLogger ): Promise { try { - // Check if ebook downloads are enabled + // Check which ebook sources are enabled const configService = getConfigService(); - const ebookEnabled = await configService.get('ebook_sidecar_enabled'); + const annasArchiveEnabled = await configService.get('ebook_annas_archive_enabled'); + const indexerSearchEnabled = await configService.get('ebook_indexer_search_enabled'); - if (ebookEnabled !== 'true') { - logger.info('Ebook downloads disabled, skipping ebook request creation'); + // Legacy migration: check old key if new keys don't exist + const legacyEnabled = await configService.get('ebook_sidecar_enabled'); + const isAnnasArchiveEnabled = annasArchiveEnabled === 'true' || + (annasArchiveEnabled === null && legacyEnabled === 'true'); + const isIndexerSearchEnabled = indexerSearchEnabled === 'true'; + + // If no sources are enabled, skip ebook creation + if (!isAnnasArchiveEnabled && !isIndexerSearchEnabled) { + logger.info('Ebook downloads disabled (no sources enabled), skipping ebook request creation'); return; } + // If only indexer search is enabled (not yet implemented), log and skip + if (!isAnnasArchiveEnabled && isIndexerSearchEnabled) { + logger.info('Ebook indexer search is enabled but not yet implemented, skipping ebook request creation'); + return; + } + + // Anna's Archive is enabled - proceed with ebook request creation + // Check if an ebook request already exists for this parent const existingEbookRequest = await prisma.request.findFirst({ where: { @@ -656,7 +676,7 @@ async function createEbookRequestIfEnabled( logger.info(`Created ebook request ${ebookRequest.id}`); - // Trigger ebook search job + // Trigger ebook search job (Anna's Archive) const jobQueue = getJobQueueService(); await jobQueue.addSearchEbookJob(ebookRequest.id, { id: audiobook.id, diff --git a/src/lib/utils/indexer-grouping.ts b/src/lib/utils/indexer-grouping.ts index f6c72f3..c3c413c 100644 --- a/src/lib/utils/indexer-grouping.ts +++ b/src/lib/utils/indexer-grouping.ts @@ -4,13 +4,18 @@ * * Groups indexers by their category configuration to minimize API calls. * Indexers with identical categories are grouped together for a single search. + * Supports separate audiobook and ebook category configurations per indexer. */ +export type CategoryType = 'audiobook' | 'ebook'; + export interface IndexerConfig { id: number; name: string; priority?: number; - categories?: number[]; + audiobookCategories?: number[]; // Categories for audiobook searches + ebookCategories?: number[]; // Categories for ebook searches + categories?: number[]; // Legacy field for backwards compatibility [key: string]: any; // Allow other properties } @@ -20,38 +25,70 @@ export interface IndexerGroup { indexers: IndexerConfig[]; } +/** + * Gets the appropriate categories from an indexer based on the category type. + * + * @param indexer - The indexer configuration + * @param type - The category type ('audiobook' or 'ebook') + * @returns Array of category IDs + */ +export function getCategoriesForType(indexer: IndexerConfig, type: CategoryType): number[] { + if (type === 'ebook') { + return indexer.ebookCategories && indexer.ebookCategories.length > 0 + ? indexer.ebookCategories + : [7020]; // Default ebook category + } + + // Audiobook - check new field first, then legacy field + if (indexer.audiobookCategories && indexer.audiobookCategories.length > 0) { + return indexer.audiobookCategories; + } + if (indexer.categories && indexer.categories.length > 0) { + return indexer.categories; // Legacy fallback + } + return [3030]; // Default audiobook category +} + /** * Groups indexers by their category configuration. * Indexers with identical category arrays are grouped together. * * @param indexers - Array of indexer configurations + * @param type - The category type to group by ('audiobook' or 'ebook') * @returns Array of groups, each containing indexers with matching categories * * @example * const indexers = [ - * { id: 1, categories: [3030] }, - * { id: 2, categories: [3030] }, - * { id: 3, categories: [3030, 3010] }, + * { id: 1, audiobookCategories: [3030], ebookCategories: [7020] }, + * { id: 2, audiobookCategories: [3030], ebookCategories: [7020] }, + * { id: 3, audiobookCategories: [3030, 3010], ebookCategories: [7020] }, * ]; * - * const groups = groupIndexersByCategories(indexers); + * const audiobookGroups = groupIndexersByCategories(indexers, 'audiobook'); * // Result: * // [ * // { categories: [3030], indexerIds: [1, 2], indexers: [...] }, * // { categories: [3030, 3010], indexerIds: [3], indexers: [...] } * // ] + * + * const ebookGroups = groupIndexersByCategories(indexers, 'ebook'); + * // Result: + * // [ + * // { categories: [7020], indexerIds: [1, 2, 3], indexers: [...] } + * // ] */ -export function groupIndexersByCategories(indexers: IndexerConfig[]): IndexerGroup[] { +export function groupIndexersByCategories( + indexers: IndexerConfig[], + type: CategoryType = 'audiobook' +): IndexerGroup[] { // Map to track unique category combinations // Key: sorted category IDs as string (e.g., "3030,3010") // Value: array of indexers with those categories const groupMap = new Map(); for (const indexer of indexers) { - // Get categories, default to [3030] (audiobooks) if not specified - const categories = indexer.categories && indexer.categories.length > 0 - ? indexer.categories - : [3030]; + // Get categories for the specified type + const categories = getCategoriesForType(indexer, type); // Sort categories to ensure consistent grouping // [3030, 3010] and [3010, 3030] should be the same group diff --git a/src/lib/utils/torrent-categories.ts b/src/lib/utils/torrent-categories.ts index 55006d9..1b27559 100644 --- a/src/lib/utils/torrent-categories.ts +++ b/src/lib/utils/torrent-categories.ts @@ -36,7 +36,11 @@ export const TORRENT_CATEGORIES: TorrentCategory[] = [ }, ]; -export const DEFAULT_CATEGORIES = [3030]; // Audio/Audiobook +export const DEFAULT_AUDIOBOOK_CATEGORIES = [3030]; // Audio/Audiobook +export const DEFAULT_EBOOK_CATEGORIES = [7020]; // Books/EBook + +// Legacy alias for backwards compatibility +export const DEFAULT_CATEGORIES = DEFAULT_AUDIOBOOK_CATEGORIES; /** * Get all child IDs for a parent category From 9dd09ec836e9b89951249d42323d7efbfbb562db Mon Sep 17 00:00:00 2001 From: kikootwo Date: Mon, 2 Feb 2026 12:27:54 -0500 Subject: [PATCH 3/7] Add multi-source ebook search & processing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactor ebook flow to support multiple sources (Anna's Archive direct downloads + Prowlarr indexer search) and unify handling with existing audiobook processors. Key changes: - search-ebook.processor: rewritten to try Anna's Archive first then fall back to indexer search, add Prowlarr grouping, ranking (rankEbookTorrents), and handlers to route results to direct-download or download-torrent flows. - organize-files.processor: enriches audiobook/ebook metadata from AudibleCache (year, narrator), treats indexer downloads specially (seed retention), adds optional NZB cleanup/archive logic, and improves retryable error detection. - file-organizer: organizeEbook now accepts additional metadata and an isIndexerDownload flag and supports directories vs single-file paths. - API/UI: include request.type in admin requests API and remove the “coming soon” notice from Ebook settings tab. - fetch-ebook route: removed blocking error for indexer-only mode so the flow can proceed when indexer search is enabled. - Documentation: update TOC, ebook-sidecar, settings-pages, and ranking-algorithm docs to describe indexer search, unified ebook ranking, configuration, and flows. These changes enable indexer-based ebook discovery, ranking, and downloads while preserving existing Anna's Archive behavior and reusing audiobook download processors where possible. --- documentation/TABLEOFCONTENTS.md | 7 +- documentation/integrations/ebook-sidecar.md | 102 ++-- documentation/phase3/ranking-algorithm.md | 74 +++ documentation/settings-pages.md | 8 +- .../admin/settings/tabs/EbookTab/EbookTab.tsx | 10 - src/app/api/admin/requests/route.ts | 1 + .../api/requests/[id]/fetch-ebook/route.ts | 8 - .../processors/organize-files.processor.ts | 214 ++++++- src/lib/processors/search-ebook.processor.ts | 542 ++++++++++++++---- src/lib/utils/file-organizer.ts | 110 +++- src/lib/utils/ranking-algorithm.ts | 304 ++++++++++ 11 files changed, 1142 insertions(+), 238 deletions(-) diff --git a/documentation/TABLEOFCONTENTS.md b/documentation/TABLEOFCONTENTS.md index 5c99244..893287c 100644 --- a/documentation/TABLEOFCONTENTS.md +++ b/documentation/TABLEOFCONTENTS.md @@ -41,10 +41,11 @@ ## E-book Support (First-Class) - **First-class ebook requests, separate tracking** → [integrations/ebook-sidecar.md](integrations/ebook-sidecar.md) - **Multi-source ebook downloads (Anna's Archive + Indexer Search)** → [integrations/ebook-sidecar.md](integrations/ebook-sidecar.md) +- **Ebook indexer search (Prowlarr with ebook categories)** → [integrations/ebook-sidecar.md](integrations/ebook-sidecar.md#flow-indexer-search) - **ASIN-based matching, format selection** → [integrations/ebook-sidecar.md](integrations/ebook-sidecar.md) -- **Ebook ranking algorithm (inverted size scoring)** → [integrations/ebook-sidecar.md](integrations/ebook-sidecar.md) +- **Ebook ranking algorithm (unified with audiobooks)** → [phase3/ranking-algorithm.md](phase3/ranking-algorithm.md#ebook-torrent-ranking) - **Direct HTTP downloads from Anna's Archive** → [integrations/ebook-sidecar.md](integrations/ebook-sidecar.md) -- **Ebook delete behavior (files only)** → [integrations/ebook-sidecar.md](integrations/ebook-sidecar.md) +- **Ebook delete behavior (files only, torrents seed)** → [integrations/ebook-sidecar.md](integrations/ebook-sidecar.md#delete-behavior) - **Ebook settings (3-section UI)** → [settings-pages.md](settings-pages.md#e-book-sidecar) - **Indexer categories (audiobook/ebook tabs)** → [settings-pages.md](settings-pages.md#indexer-categories-tabbed) @@ -116,7 +117,9 @@ **"How does e-book support work?"** → [integrations/ebook-sidecar.md](integrations/ebook-sidecar.md) **"How do I enable e-book downloads?"** → [integrations/ebook-sidecar.md](integrations/ebook-sidecar.md), [settings-pages.md](settings-pages.md#e-book-sidecar) **"How do I configure ebook sources (Anna's Archive vs Indexer)?"** → [settings-pages.md](settings-pages.md#e-book-sidecar) +**"How does ebook indexer search work?"** → [integrations/ebook-sidecar.md](integrations/ebook-sidecar.md#flow-indexer-search) **"How do I configure ebook categories per indexer?"** → [settings-pages.md](settings-pages.md#indexer-categories-tabbed) +**"How does ebook ranking work?"** → [phase3/ranking-algorithm.md](phase3/ranking-algorithm.md#ebook-torrent-ranking) **"What happens when I delete an ebook request?"** → [integrations/ebook-sidecar.md](integrations/ebook-sidecar.md#delete-behavior) **"Why do ebook requests have an orange badge?"** → [integrations/ebook-sidecar.md](integrations/ebook-sidecar.md#ui-representation) **"How do scheduled jobs work?"** → [backend/services/scheduler.md](backend/services/scheduler.md) diff --git a/documentation/integrations/ebook-sidecar.md b/documentation/integrations/ebook-sidecar.md index 7098442..3c97b51 100644 --- a/documentation/integrations/ebook-sidecar.md +++ b/documentation/integrations/ebook-sidecar.md @@ -1,9 +1,9 @@ # E-book Support -**Status:** ✅ Implemented | First-class ebook requests with multi-source support (Anna's Archive + future Indexer Search) +**Status:** ✅ Implemented | First-class ebook requests with multi-source support (Anna's Archive + Indexer Search) ## Overview -Ebooks are first-class citizens in RMAB, with their own request type, tracking, and UI representation. When an audiobook request completes, an ebook request is automatically created (if a source is enabled). Supports multiple sources: Anna's Archive (direct HTTP) and Indexer Search (via Prowlarr, coming soon). +Ebooks are first-class citizens in RMAB, with their own request type, tracking, and UI representation. When an audiobook request completes, an ebook request is automatically created (if a source is enabled). Supports multiple sources: Anna's Archive (direct HTTP) and Indexer Search (via Prowlarr with ebook categories). ## Key Details @@ -14,15 +14,35 @@ Ebooks are first-class citizens in RMAB, with their own request type, tracking, - **UI Badge:** Orange (#f16f19) ebook badge to distinguish from audiobooks - **Separate Tracking:** Own progress, status, and error handling +### Source Priority +1. **Anna's Archive** (if enabled) - Direct HTTP downloads + - Searched first via ASIN, then title + author + - Uses FlareSolverr if configured (Cloudflare bypass) +2. **Indexer Search** (if enabled, and no Anna's Archive result) + - Searches Prowlarr with ebook categories (default: 7020) + - Ranks using unified ranking algorithm with ebook-specific scoring + - Downloads via qBittorrent (torrents) or SABnzbd (Usenet) +3. **Both disabled** → Ebook downloads disabled entirely + ### Flow (Anna's Archive) 1. Audiobook organization completes -2. Ebook request created automatically (if Anna's Archive enabled) +2. Ebook request created automatically (if source enabled) 3. `search_ebook` job searches Anna's Archive 4. `start_direct_download` downloads via HTTP 5. `organize_files` copies to audiobook folder 6. Request marked as `downloaded` (terminal) 7. "Available" notification sent +### Flow (Indexer Search) +1. Audiobook organization completes +2. Ebook request created automatically (if source enabled) +3. `search_ebook` job searches indexers (if Anna's Archive failed/disabled) +4. `download_torrent` job adds to qBittorrent/SABnzbd (reuses audiobook processor) +5. `monitor_download` tracks progress +6. `organize_files` copies to audiobook folder +7. Request marked as `downloaded` (terminal) +8. Torrent left to seed (respects seeding limits) + ### Configuration **Admin Settings → E-book Sidecar tab** (3 sections) @@ -37,7 +57,7 @@ Ebooks are first-class citizens in RMAB, with their own request type, tracking, #### Section 2: Indexer Search | Key | Default | Description | |-----|---------|-------------| -| `ebook_indexer_search_enabled` | `false` | Enable Indexer Search (not yet implemented) | +| `ebook_indexer_search_enabled` | `false` | Enable Indexer Search via Prowlarr | *Note: Ebook categories are configured per-indexer in Settings → Indexers → Edit Indexer → EBook tab* @@ -46,11 +66,6 @@ Ebooks are first-class citizens in RMAB, with their own request type, tracking, |-----|---------|---------|-------------| | `ebook_sidecar_preferred_format` | `epub` | `epub, pdf, mobi, azw3, any` | Preferred format | -### Source Priority -- If **Anna's Archive** is enabled → Use Anna's Archive (current behavior) -- If **only Indexer Search** is enabled → Log "not yet implemented", skip gracefully -- If **both disabled** → Ebook downloads disabled entirely - ## Database Schema **Request model additions:** @@ -66,25 +81,36 @@ childRequests Request[] @relation("EbookParent") ## Job Processors ### search_ebook -- Searches Anna's Archive by ASIN first, then title + author -- Creates download history record with `downloadClient: 'direct'` -- Triggers `start_direct_download` job +- Searches Anna's Archive first (if enabled), then indexers (if enabled) +- Anna's Archive: Creates download history with `downloadClient: 'direct'`, triggers `start_direct_download` +- Indexer: Triggers `download_torrent` job (reuses audiobook processor) ### start_direct_download - Downloads file via HTTP with progress tracking - Tries multiple slow download links on failure - Triggers `organize_files` on success -### monitor_direct_download -- Future use for async download monitoring -- Currently, most tracking happens in start_direct_download +### download_torrent (shared with audiobooks) +- Routes to qBittorrent (torrents) or SABnzbd (Usenet) +- Creates download history with indexer metadata +- Triggers `monitor_download` job -## Ranking Algorithm +## Ranking Algorithm (Indexer Results) -Ebook ranking (for future multi-source support): -- **Format Score:** 40 pts (exact match) to 10 pts (different format) -- **Size Score:** 30 pts (inverse - smaller files preferred) -- **Source Score:** 30 pts (Anna's Archive gets full score) +Ebook torrent ranking uses unified algorithm with ebook-specific scoring: + +| Component | Points | Description | +|-----------|--------|-------------| +| **Title/Author Match** | 60 pts | Reuses audiobook matching logic (word coverage, author presence) | +| **Format Match** | 10 pts | 10 pts if matches preferred format, 0 otherwise | +| **Size Quality** | 15 pts | Inverted: < 5MB = 15pts, 5-15MB = 10pts, 15-20MB = 5pts | +| **Seeder Count** | 15 pts | Logarithmic scaling (same as audiobooks) | + +**Filtering:** +- Files > 20 MB are filtered out (too large for ebooks) +- Dual threshold: base score >= 50 AND final score >= 50 + +**Bonus System:** Same as audiobooks (indexer priority, flag bonuses) ## Delete Behavior @@ -94,6 +120,7 @@ Ebook ranking (for future multi-source support): - Does NOT delete from backend library (Plex/ABS) - Does NOT clear audiobook availability linkage - Soft-deletes the ebook request record +- Torrents left to seed (respects seeding limits) ## UI Representation @@ -124,7 +151,7 @@ Configure URL in Admin Settings → E-book Sidecar: `http://localhost:8191` - Subsequent: ~2-5 seconds per page - Total: ~15-30 seconds per ebook -## Scraping Strategy +## Scraping Strategy (Anna's Archive) ### Method 1: ASIN Search (exact match) ``` @@ -161,17 +188,19 @@ Search: https://annas-archive.li/search?q=Title+Author&ext=epub&lang=en ## Technical Files **Processors:** -- `src/lib/processors/search-ebook.processor.ts` -- `src/lib/processors/direct-download.processor.ts` +- `src/lib/processors/search-ebook.processor.ts` - Multi-source search +- `src/lib/processors/direct-download.processor.ts` - Anna's Archive downloads +- `src/lib/processors/download-torrent.processor.ts` - Indexer downloads (shared) - `src/lib/processors/organize-files.processor.ts` (ebook branch) **Services:** -- `src/lib/services/ebook-scraper.ts` +- `src/lib/services/ebook-scraper.ts` - Anna's Archive scraping - `src/lib/services/job-queue.service.ts` (ebook job types) **Utils:** - `src/lib/utils/file-organizer.ts` (`organizeEbook` method) -- `src/lib/utils/ranking-algorithm.ts` (`rankEbooks` function) +- `src/lib/utils/ranking-algorithm.ts` (`rankEbookTorrents` function) +- `src/lib/utils/indexer-grouping.ts` (supports `'ebook'` type) **UI:** - `src/components/requests/RequestCard.tsx` (ebook badge) @@ -183,17 +212,10 @@ Search: https://annas-archive.li/search?q=Title+Author&ext=epub&lang=en | Format | Extension | Recommended | |--------|-----------|-------------| -| EPUB | `.epub` | ✅ Yes | -| PDF | `.pdf` | ⚠️ Sometimes | -| MOBI | `.mobi` | ⚠️ Legacy | -| AZW3 | `.azw3` | ⚠️ Sometimes | - -## Limitations - -1. Indexer Search not yet implemented (settings ready, search stubbed) -2. Title search may return wrong book for common titles -3. Download speed depends on file server load -4. English books only (title search filter) +| EPUB | `.epub` | Yes | +| PDF | `.pdf` | Sometimes | +| MOBI | `.mobi` | Legacy | +| AZW3 | `.azw3` | Sometimes | ## Indexer Categories @@ -203,8 +225,16 @@ Indexer configuration supports separate category arrays for audiobooks and ebook Categories are configured per-indexer via the tabbed interface in the Edit Indexer modal. +## Limitations + +1. Title search may return wrong book for common titles +2. Download speed depends on file server load (Anna's Archive) +3. English books only (title search filter for Anna's Archive) +4. Format detection from torrent titles may be imprecise + ## Related - [File Organization](../phase3/file-organization.md) - Ebook organization - [Settings Pages](../settings-pages.md) - Configuration UI - [Ranking Algorithm](../phase3/ranking-algorithm.md) - Ebook ranking - [Request Deletion](../admin-features/request-deletion.md) - Delete behavior +- [Prowlarr Integration](../phase3/prowlarr.md) - Indexer search diff --git a/documentation/phase3/ranking-algorithm.md b/documentation/phase3/ranking-algorithm.md index 35f3830..97d62b6 100644 --- a/documentation/phase3/ranking-algorithm.md +++ b/documentation/phase3/ranking-algorithm.md @@ -286,6 +286,80 @@ const ranked = rankTorrents(torrents, audiobook, { return ranked; // User can see torrents without author info ``` +## Ebook Torrent Ranking + +The ranking algorithm also supports ebook torrents from indexers with ebook-specific scoring. + +### Unified Code Architecture + +Ebook ranking **reuses** the following from audiobook ranking: +- `scoreMatch()` - Title/author matching (60 pts) +- `scoreSeeders()` - Seeder count scoring (15 pts) +- Bonus modifier system (indexer priority, flag bonuses) +- Dual threshold filtering (base >= 50, final >= 50) + +### Ebook-Specific Scoring + +**Format Match (10 pts max)** +- 10 pts if torrent format matches preferred format +- 0 pts otherwise (no partial credit) +- Format detected from torrent title keywords: `.epub`, `.pdf`, `.mobi`, `.azw3`, etc. + +**Size Quality (15 pts max, INVERTED)** +- < 5 MB: 15 pts (optimal for ebooks) +- 5-15 MB: 10 pts (may have images) +- 15-20 MB: 5 pts (large but acceptable) +- > 20 MB: **Filtered out** (too large for ebooks) + +### Ebook vs Audiobook Comparison + +| Component | Audiobook | Ebook | +|-----------|-----------|-------| +| Title/Author | 60 pts (reused) | 60 pts (reused) | +| Format | 10 pts (M4B > M4A > MP3) | 10 pts (match = 10, else 0) | +| Size | 15 pts (larger = better) | 15 pts (smaller = better) | +| Seeders | 15 pts (reused) | 15 pts (reused) | +| Size Filter | < 20 MB filtered | > 20 MB filtered | + +### Ebook Interface + +```typescript +interface EbookTorrentRequest { + title: string; + author: string; + preferredFormat: string; // 'epub', 'pdf', 'mobi', etc. +} + +interface RankEbookTorrentsOptions { + indexerPriorities?: Map; + flagConfigs?: IndexerFlagConfig[]; + requireAuthor?: boolean; // Default: true +} + +function rankEbookTorrents( + torrents: TorrentResult[], + ebook: EbookTorrentRequest, + options?: RankEbookTorrentsOptions +): RankedEbookTorrent[]; +``` + +### Ebook Usage Example + +```typescript +// Ebook search from indexers +const ranked = rankEbookTorrents(prowlarrResults, { + title: 'Project Hail Mary', + author: 'Andy Weir', + preferredFormat: 'epub', +}, { + indexerPriorities, + flagConfigs, + requireAuthor: true, +}); + +const bestEbook = ranked[0]; // Safe to auto-download +``` + ## Tech Stack - string-similarity (fuzzy matching) diff --git a/documentation/settings-pages.md b/documentation/settings-pages.md index 9da5277..6e215e0 100644 --- a/documentation/settings-pages.md +++ b/documentation/settings-pages.md @@ -85,7 +85,7 @@ src/app/admin/settings/ - FlareSolverr URL (optional, for Cloudflare bypass) 2. **Indexer Search Section** - - Enable toggle for indexer-based ebook search (not yet implemented) + - Enable toggle for indexer-based ebook search via Prowlarr - Hint directing users to Indexers tab for category configuration 3. **General Settings Section** (visible when any source enabled) @@ -95,14 +95,14 @@ src/app/admin/settings/ | Key | Default | Description | |-----|---------|-------------| | `ebook_annas_archive_enabled` | `false` | Enable Anna's Archive | -| `ebook_indexer_search_enabled` | `false` | Enable Indexer Search (stubbed) | +| `ebook_indexer_search_enabled` | `false` | Enable Indexer Search via Prowlarr | | `ebook_sidecar_preferred_format` | `epub` | Preferred format | | `ebook_sidecar_base_url` | `https://annas-archive.li` | Anna's Archive mirror | | `ebook_sidecar_flaresolverr_url` | `` | FlareSolverr URL | **Behavior:** -- If Anna's Archive enabled → Downloads work (current implementation) -- If only Indexer Search enabled → Gracefully logs "not yet implemented" +- If Anna's Archive enabled → Searches Anna's Archive first +- If Indexer Search enabled → Falls back to indexer search if Anna's Archive fails/disabled - If both disabled → Ebook downloads completely off ## Indexer Categories (Tabbed) diff --git a/src/app/admin/settings/tabs/EbookTab/EbookTab.tsx b/src/app/admin/settings/tabs/EbookTab/EbookTab.tsx index 7493ca2..3c8aefe 100644 --- a/src/app/admin/settings/tabs/EbookTab/EbookTab.tsx +++ b/src/app/admin/settings/tabs/EbookTab/EbookTab.tsx @@ -195,16 +195,6 @@ export function EbookTab({ ebook, onChange, onSuccess, onError, markAsSaved }: E

)} - - {/* Coming soon notice */} - {ebook.indexerSearchEnabled && ( -
-

- Coming Soon: Indexer search for e-books is not yet implemented. - Enabling this setting prepares your configuration for when the feature is released. -

-
- )}
diff --git a/src/app/api/admin/requests/route.ts b/src/app/api/admin/requests/route.ts index 8c50d00..9ecb571 100644 --- a/src/app/api/admin/requests/route.ts +++ b/src/app/api/admin/requests/route.ts @@ -130,6 +130,7 @@ export async function GET(request: NextRequest) { title: request.audiobook.title, author: request.audiobook.author, status: request.status, + type: request.type || 'audiobook', // Include request type for UI display userId: request.user.id, user: request.user.plexUsername, createdAt: request.createdAt, diff --git a/src/app/api/requests/[id]/fetch-ebook/route.ts b/src/app/api/requests/[id]/fetch-ebook/route.ts index e6f99b7..d3b731b 100644 --- a/src/app/api/requests/[id]/fetch-ebook/route.ts +++ b/src/app/api/requests/[id]/fetch-ebook/route.ts @@ -42,14 +42,6 @@ export async function POST( ); } - // If only indexer search is enabled (not yet implemented), return error - if (!isAnnasArchiveEnabled && isIndexerSearchEnabled) { - return NextResponse.json( - { error: 'E-book indexer search is not yet implemented. Enable Anna\'s Archive to fetch e-books.' }, - { status: 400 } - ); - } - // Get the parent request with audiobook data const parentRequest = await prisma.request.findUnique({ where: { id: parentRequestId }, diff --git a/src/lib/processors/organize-files.processor.ts b/src/lib/processors/organize-files.processor.ts index 55a314c..046ba52 100644 --- a/src/lib/processors/organize-files.processor.ts +++ b/src/lib/processors/organize-files.processor.ts @@ -67,36 +67,53 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi logger.info(`Organizing: ${audiobook.title} by ${audiobook.author}`); - // Fetch year from multiple sources (priority order) + // Fetch missing metadata from AudibleCache if needed + // Year and narrator can both be part of path templates let year = audiobook.year || undefined; - logger.info(`Initial year from audiobook record: ${year || 'null'}`); + let narrator = audiobook.narrator || undefined; - if (!year && audiobook.audibleAsin) { - logger.info(`No year in audiobook record, attempting to fetch from AudibleCache for ASIN: ${audiobook.audibleAsin}`); + logger.info(`Initial metadata from audiobook record: year=${year || 'null'}, narrator=${narrator || 'null'}`); + + // Try to enrich missing metadata from AudibleCache + if (audiobook.audibleAsin && (!year || !narrator)) { + logger.info(`Missing metadata, attempting to fetch from AudibleCache for ASIN: ${audiobook.audibleAsin}`); - // Try AudibleCache (for popular/new releases) const audibleCache = await prisma.audibleCache.findUnique({ where: { asin: audiobook.audibleAsin }, - select: { releaseDate: true }, + select: { releaseDate: true, narrator: true }, }); - if (audibleCache?.releaseDate) { - logger.info(`Found AudibleCache entry with releaseDate: ${audibleCache.releaseDate}`); - year = new Date(audibleCache.releaseDate).getFullYear(); - logger.info(`Extracted year ${year} from AudibleCache releaseDate`); + if (audibleCache) { + const updates: { year?: number; narrator?: string } = {}; - // Update audiobook record with year for future use - await prisma.audiobook.update({ - where: { id: audiobookId }, - data: { year }, - }); - logger.info(`Updated audiobook record with year ${year}`); + // Extract year from releaseDate if missing + if (!year && audibleCache.releaseDate) { + year = new Date(audibleCache.releaseDate).getFullYear(); + updates.year = year; + logger.info(`Extracted year ${year} from AudibleCache releaseDate`); + } + + // Get narrator if missing + if (!narrator && audibleCache.narrator) { + narrator = audibleCache.narrator; + updates.narrator = narrator; + logger.info(`Got narrator "${narrator}" from AudibleCache`); + } + + // Update audiobook record with enriched data for future use + if (Object.keys(updates).length > 0) { + await prisma.audiobook.update({ + where: { id: audiobookId }, + data: updates, + }); + logger.info(`Updated audiobook record with enriched metadata`); + } } else { - logger.info(`No year found in AudibleCache for ASIN ${audiobook.audibleAsin}`); + logger.info(`No AudibleCache entry found for ASIN ${audiobook.audibleAsin}`); } } - logger.info(`Final year value for path organization: ${year || 'null (year will be omitted from path)'}`) + logger.info(`Final metadata for path organization: year=${year || 'null'}, narrator=${narrator || 'null'}`) // Get file organizer (reads media_dir from database config) const organizer = await getFileOrganizer(); @@ -113,7 +130,7 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi { title: audiobook.title, author: audiobook.author, - narrator: audiobook.narrator || undefined, + narrator, coverArtUrl: audiobook.coverArtUrl || undefined, asin: audiobook.audibleAsin || undefined, year, @@ -329,8 +346,10 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi const errorMessage = error instanceof Error ? error.message : 'File organization failed'; // Check if this is a retryable error (transient filesystem issues or no files found) + // These errors may resolve on retry (e.g., files still being extracted, permissions being set) const isRetryableError = errorMessage.includes('No audiobook files found') || + errorMessage.includes('No ebook files found') || // Ebook equivalent of above errorMessage.includes('ENOENT') || // File/directory not found errorMessage.includes('no such file or directory') || errorMessage.includes('EACCES') || // Permission denied (might be temporary) @@ -501,6 +520,64 @@ async function processEbookOrganization( logger.info(`Organizing ebook: ${book.title} by ${book.author}`); + // Fetch missing metadata from AudibleCache (same pattern as audiobooks) + // Year, narrator, series, seriesPart can all be part of path templates + let year = book.year || undefined; + let narrator = book.narrator || undefined; + let series = book.series || undefined; + let seriesPart = book.seriesPart || undefined; + + logger.info(`Initial metadata from book record: year=${year || 'null'}, narrator=${narrator || 'null'}, series=${series || 'null'}`); + + // Try to enrich missing metadata from AudibleCache + if (book.audibleAsin && (!year || !narrator)) { + logger.info(`Missing metadata, attempting to fetch from AudibleCache for ASIN: ${book.audibleAsin}`); + + const audibleCache = await prisma.audibleCache.findUnique({ + where: { asin: book.audibleAsin }, + select: { releaseDate: true, narrator: true, }, + }); + + if (audibleCache) { + const updates: { year?: number; narrator?: string } = {}; + + // Extract year from releaseDate if missing + if (!year && audibleCache.releaseDate) { + year = new Date(audibleCache.releaseDate).getFullYear(); + updates.year = year; + logger.info(`Extracted year ${year} from AudibleCache releaseDate`); + } + + // Get narrator if missing + if (!narrator && audibleCache.narrator) { + narrator = audibleCache.narrator; + updates.narrator = narrator; + logger.info(`Got narrator "${narrator}" from AudibleCache`); + } + + // Update book record with enriched data for future use + if (Object.keys(updates).length > 0) { + await prisma.audiobook.update({ + where: { id: audiobookId }, + data: updates, + }); + logger.info(`Updated book record with enriched metadata`); + } + } else { + logger.info(`No AudibleCache entry found for ASIN ${book.audibleAsin}`); + } + } + + logger.info(`Final metadata for path organization: year=${year || 'null'}, narrator=${narrator || 'null'}, series=${series || 'null'}, seriesPart=${seriesPart || 'null'}`); + + // Check if this is an indexer download (needs to keep source for seeding) + const downloadHistory = await prisma.downloadHistory.findFirst({ + where: { requestId }, + orderBy: { createdAt: 'desc' }, + }); + const isIndexerDownload = downloadHistory?.downloadClient !== 'direct'; + logger.info(`Download source: ${downloadHistory?.downloadClient || 'unknown'} (indexer download: ${isIndexerDownload})`); + // Get file organizer and template const organizer = await getFileOrganizer(); const templateConfig = await prisma.configuration.findUnique({ @@ -509,16 +586,21 @@ async function processEbookOrganization( const template = templateConfig?.value || '{author}/{title} {asin}'; // Organize ebook files (organizer will detect ebook type and skip audio-specific processing) + // Pass all metadata that could be used in path templates (same as audiobooks) const result = await organizer.organizeEbook( downloadPath, { title: book.title, author: book.author, + narrator, asin: book.audibleAsin || undefined, - year: book.year || undefined, + year, + series, + seriesPart, }, template, - jobId ? { jobId, context: 'FileOrganizer.Ebook' } : undefined + jobId ? { jobId, context: 'FileOrganizer.Ebook' } : undefined, + isIndexerDownload ); if (!result.success) { @@ -595,6 +677,88 @@ async function processEbookOrganization( logger.debug(`Ebook library scan disabled (scanEnabled=${scanEnabled})`); } + // Cleanup Usenet downloads if configured (same logic as audiobooks) + try { + logger.info('Checking if cleanup is needed for ebook download'); + + // downloadHistory was already fetched earlier in this function + logger.info(`Download history found: ${downloadHistory ? 'yes' : 'no'}`, { + hasNzbId: !!downloadHistory?.nzbId, + hasIndexerId: !!downloadHistory?.indexerId, + nzbId: downloadHistory?.nzbId || 'none', + indexerId: downloadHistory?.indexerId || 'none', + }); + + if (downloadHistory?.nzbId && downloadHistory?.indexerId) { + // Get indexer configuration + const indexersConfig = await configService.get('prowlarr_indexers'); + logger.info(`Indexers config found: ${indexersConfig ? 'yes' : 'no'}`); + + if (indexersConfig) { + const indexers: Array<{ id: number; protocol: string; removeAfterProcessing?: boolean }> = JSON.parse(indexersConfig); + const indexer = indexers.find(idx => idx.id === downloadHistory.indexerId); + + logger.info(`Indexer found in config: ${indexer ? 'yes' : 'no'}`, { + indexerId: downloadHistory.indexerId, + protocol: indexer?.protocol || 'none', + removeAfterProcessing: indexer?.removeAfterProcessing ?? 'undefined', + }); + + // Check if this is a Usenet indexer with cleanup enabled + if (indexer && indexer.protocol?.toLowerCase() !== 'torrent' && indexer.removeAfterProcessing) { + logger.info(`Cleaning up NZB ${downloadHistory.nzbId} (cleanup enabled for indexer ${indexer.id})`); + + // First, manually delete files from filesystem + if (downloadPath) { + logger.info(`Removing download files from filesystem: ${downloadPath}`); + + const fs = await import('fs/promises'); + + try { + // Check if it's a file or directory + const stats = await fs.stat(downloadPath); + + if (stats.isDirectory()) { + // Remove directory and all contents + await fs.rm(downloadPath, { recursive: true, force: true }); + logger.info(`Removed directory: ${downloadPath}`); + } else { + // Remove single file + await fs.unlink(downloadPath); + logger.info(`Removed file: ${downloadPath}`); + } + } catch (fsError) { + // File/directory might already be deleted or not exist + if ((fsError as NodeJS.ErrnoException).code === 'ENOENT') { + logger.info(`Download path already deleted: ${downloadPath}`); + } else { + throw fsError; + } + } + } else { + logger.warn(`No download path available, skipping filesystem deletion`); + } + + // Then archive from SABnzbd history (hides from UI but preserves for troubleshooting) + const { getSABnzbdService } = await import('../integrations/sabnzbd.service'); + const sabnzbd = await getSABnzbdService(); + + await sabnzbd.archiveCompletedNZB(downloadHistory.nzbId); + + logger.info(`Successfully archived NZB ${downloadHistory.nzbId} and removed files`); + } + } + } + } catch (error) { + // Log error but don't fail the job - cleanup is optional + logger.warn( + `Failed to cleanup NZB download: ${error instanceof Error ? error.message : 'Unknown error'}`, + { + error: error instanceof Error ? error.stack : undefined, + } + ); + } + return { success: true, message: 'Ebook organized successfully', @@ -638,13 +802,7 @@ async function createEbookRequestIfEnabled( return; } - // If only indexer search is enabled (not yet implemented), log and skip - if (!isAnnasArchiveEnabled && isIndexerSearchEnabled) { - logger.info('Ebook indexer search is enabled but not yet implemented, skipping ebook request creation'); - return; - } - - // Anna's Archive is enabled - proceed with ebook request creation + // At least one source is enabled - proceed with ebook request creation // Check if an ebook request already exists for this parent const existingEbookRequest = await prisma.request.findFirst({ diff --git a/src/lib/processors/search-ebook.processor.ts b/src/lib/processors/search-ebook.processor.ts index 103ea81..40f51d9 100644 --- a/src/lib/processors/search-ebook.processor.ts +++ b/src/lib/processors/search-ebook.processor.ts @@ -2,16 +2,20 @@ * Component: Search Ebook Job Processor * Documentation: documentation/integrations/ebook-sidecar.md * - * Searches Anna's Archive for ebook downloads. - * Part of the first-class ebook request flow. + * Searches for ebook downloads using multiple sources: + * 1. Anna's Archive (if enabled) - direct HTTP downloads + * 2. Indexer Search (if enabled) - via Prowlarr with ebook categories */ import { SearchEbookPayload, EbookSearchResult, getJobQueueService } from '../services/job-queue.service'; import { prisma } from '../db'; import { getConfigService } from '../services/config.service'; import { RMABLogger } from '../utils/logger'; +import { getProwlarrService } from '../integrations/prowlarr.service'; +import { rankEbookTorrents, RankedEbookTorrent } from '../utils/ranking-algorithm'; +import { groupIndexersByCategories, getGroupDescription } from '../utils/indexer-grouping'; -// Import ebook scraper functions (we'll refactor these to be reusable) +// Import ebook scraper functions for Anna's Archive import { searchByAsin, searchByTitle, @@ -20,7 +24,7 @@ import { /** * Process search ebook job - * Searches Anna's Archive for ebook matching the audiobook + * Searches Anna's Archive first (if enabled), then falls back to indexer search (if enabled) */ export async function processSearchEbook(payload: SearchEbookPayload): Promise { const { requestId, audiobook, preferredFormat: payloadFormat, jobId } = payload; @@ -43,49 +47,58 @@ export async function processSearchEbook(payload: SearchEbookPayload): Promise
0 + ? `No ebook found on ${enabledSources.join(' or ')}. Will retry automatically.` + : 'No ebook sources enabled. Enable Anna\'s Archive or Indexer Search in settings.'; + logger.warn(`No ebook found for request ${requestId}, marking as awaiting_search`); await prisma.request.update({ where: { id: requestId }, data: { status: 'awaiting_search', - errorMessage: 'No ebook found on Anna\'s Archive. Will retry automatically.', + errorMessage: message, lastSearchAt: new Date(), updatedAt: new Date(), }, @@ -98,107 +111,18 @@ export async function processSearchEbook(payload: SearchEbookPayload): Promise { + const configService = getConfigService(); + const baseUrl = await configService.get('ebook_sidecar_base_url') || 'https://annas-archive.li'; + const flaresolverrUrl = await configService.get('ebook_sidecar_flaresolverr_url') || undefined; + + if (flaresolverrUrl) { + logger.info(`Using FlareSolverr at ${flaresolverrUrl}`); + } + + let md5: string | null = null; + let searchMethod: 'asin' | 'title' = 'title'; + + // Try ASIN search first (exact match - best) + if (audiobook.asin) { + logger.info(`Searching Anna's Archive by ASIN: ${audiobook.asin} (format: ${preferredFormat})...`); + md5 = await searchByAsin(audiobook.asin, preferredFormat, baseUrl, logger, flaresolverrUrl); + + if (md5) { + logger.info(`Found via ASIN: ${md5}`); + searchMethod = 'asin'; + } else { + logger.info(`No ASIN results, trying title + author...`); + } + } + + // Fallback to title + author search + if (!md5) { + logger.info(`Searching Anna's Archive by title + author: "${audiobook.title}" by ${audiobook.author}...`); + md5 = await searchByTitle(audiobook.title, audiobook.author, preferredFormat, baseUrl, logger, flaresolverrUrl); + + if (md5) { + logger.info(`Found via title search: ${md5}`); + searchMethod = 'title'; + } + } + + if (!md5) { + return null; + } + + // Get slow download links + const slowLinks = await getSlowDownloadLinks(md5, baseUrl, logger, flaresolverrUrl); + + if (slowLinks.length === 0) { + logger.warn(`Found MD5 ${md5} but no download links available`); + return null; + } + + logger.info(`Found ${slowLinks.length} download link(s) for MD5 ${md5}`); + + return { + md5, + title: audiobook.title, + author: audiobook.author, + format: preferredFormat, + downloadUrls: slowLinks, + source: 'annas_archive', + score: searchMethod === 'asin' ? 100 : 80, + }; +} + +/** + * Search indexers for ebook torrents/NZBs + */ +async function searchIndexers( + requestId: string, + audiobook: { title: string; author: string }, + preferredFormat: string, + logger: RMABLogger +): Promise { + const configService = getConfigService(); + + // Get enabled indexers from configuration + const indexersConfigStr = await configService.get('prowlarr_indexers'); + + if (!indexersConfigStr) { + logger.warn('No indexers configured'); + return null; + } + + const indexersConfig = JSON.parse(indexersConfigStr); + + if (indexersConfig.length === 0) { + logger.warn('No indexers enabled'); + return null; + } + + // Build indexer priorities map (indexerId -> priority 1-25, default 10) + const indexerPriorities = new Map( + indexersConfig.map((indexer: any) => [indexer.id, indexer.priority ?? 10]) + ); + + // Get flag configurations + const flagConfigStr = await configService.get('indexer_flag_config'); + const flagConfigs = flagConfigStr ? JSON.parse(flagConfigStr) : []; + + // Group indexers by their EBOOK category configuration + const groups = groupIndexersByCategories(indexersConfig, 'ebook'); + + logger.info(`Searching ${indexersConfig.length} enabled indexers in ${groups.length} group${groups.length > 1 ? 's' : ''}`); + + // Log each group for transparency + groups.forEach((group, index) => { + logger.info(`Group ${index + 1}: ${getGroupDescription(group)}`); + }); + + // Get Prowlarr service + const prowlarr = await getProwlarrService(); + + // Build search query (title only - cast wide net, let ranking filter) + const searchQuery = audiobook.title; + + logger.info(`Searching for: "${searchQuery}"`); + + // Search Prowlarr for each group and combine results + const allResults = []; + + for (let i = 0; i < groups.length; i++) { + const group = groups[i]; + logger.info(`Searching group ${i + 1}/${groups.length}: ${getGroupDescription(group)}`); + + try { + const groupResults = await prowlarr.search(searchQuery, { + categories: group.categories, + indexerIds: group.indexerIds, + minSeeders: 0, // Ebooks may have fewer seeders + maxResults: 100, + }); + + logger.info(`Group ${i + 1} returned ${groupResults.length} results`); + allResults.push(...groupResults); + } catch (error) { + logger.error(`Group ${i + 1} search failed: ${error instanceof Error ? error.message : 'Unknown error'}`); + // Continue with other groups even if one fails + } + } + + logger.info(`Found ${allResults.length} total results from ${groups.length} group${groups.length > 1 ? 's' : ''}`); + + if (allResults.length === 0) { + return null; + } + + // Log filter info (ebooks > 20MB will be filtered) + const preFilterCount = allResults.length; + const aboveThreshold = allResults.filter(r => (r.size / (1024 * 1024)) > 20); + if (aboveThreshold.length > 0) { + logger.info(`Will filter ${aboveThreshold.length} results > 20 MB (too large for ebooks)`); + } + + // Rank results with ebook-specific scoring + // This filters out > 20MB and uses inverted size scoring + const rankedResults = rankEbookTorrents(allResults, { + title: audiobook.title, + author: audiobook.author, + preferredFormat, + }, { + indexerPriorities, + flagConfigs, + requireAuthor: true, // Automatic mode - prevent wrong authors + }); + + // Log filter results + const postFilterCount = rankedResults.length; + if (postFilterCount < preFilterCount) { + logger.info(`Filtered out ${preFilterCount - postFilterCount} results > 20 MB`); + } + + // Dual threshold filtering (same as audiobooks) + const filteredResults = rankedResults.filter(result => + result.score >= 50 && result.finalScore >= 50 + ); + + const disqualifiedByNegativeBonus = rankedResults.filter(result => + result.score >= 50 && result.finalScore < 50 + ).length; + + logger.info(`Ranked ${rankedResults.length} results, ${filteredResults.length} above threshold (50/100 base + final)`); + if (disqualifiedByNegativeBonus > 0) { + logger.info(`${disqualifiedByNegativeBonus} ebooks disqualified by negative flag bonuses`); + } + + if (filteredResults.length === 0) { + logger.warn(`No quality matches found (all below 50/100)`); + return null; + } + + // Select best result + const bestResult = filteredResults[0]; + + // Log top 3 results with detailed breakdown + const top3 = filteredResults.slice(0, 3); + logger.info(`==================== EBOOK RANKING DEBUG ====================`); + logger.info(`Requested Title: "${audiobook.title}"`); + logger.info(`Requested Author: "${audiobook.author}"`); + logger.info(`Preferred Format: ${preferredFormat}`); + logger.info(`Top ${top3.length} results (out of ${filteredResults.length} above threshold):`); + logger.info(`--------------------------------------------------------------`); + for (let i = 0; i < top3.length; i++) { + const result = top3[i]; + const sizeMB = (result.size / (1024 * 1024)).toFixed(1); + + logger.info(`${i + 1}. "${result.title}"`); + logger.info(` Indexer: ${result.indexer}${result.indexerId ? ` (ID: ${result.indexerId})` : ''}`); + logger.info(``); + logger.info(` Base Score: ${result.score.toFixed(1)}/100`); + logger.info(` - Title/Author Match: ${result.breakdown.matchScore.toFixed(1)}/60`); + logger.info(` - Format Match: ${result.breakdown.formatScore.toFixed(1)}/10`); + logger.info(` - Size Quality: ${result.breakdown.sizeScore.toFixed(1)}/15 (${sizeMB} MB)`); + logger.info(` - Seeder Count: ${result.breakdown.seederScore.toFixed(1)}/15 (${result.seeders !== undefined ? result.seeders + ' seeders' : 'N/A for Usenet'})`); + logger.info(``); + logger.info(` Bonus Points: +${result.bonusPoints.toFixed(1)}`); + if (result.bonusModifiers.length > 0) { + for (const mod of result.bonusModifiers) { + logger.info(` - ${mod.reason}: +${mod.points.toFixed(1)}`); + } + } + logger.info(``); + logger.info(` Final Score: ${result.finalScore.toFixed(1)}`); + if (result.breakdown.notes.length > 0) { + logger.info(` Notes: ${result.breakdown.notes.join(', ')}`); + } + if (i < top3.length - 1) { + logger.info(`--------------------------------------------------------------`); + } + } + logger.info(`==============================================================`); + logger.info(`Selected best result: ${bestResult.title} (final score: ${bestResult.finalScore.toFixed(1)})`); + + return bestResult; +} + +/** + * Handle Anna's Archive download (direct HTTP) + */ +async function handleAnnasArchiveDownload( + requestId: string, + audiobook: { title: string; author: string }, + result: EbookSearchResult, + preferredFormat: string, + logger: RMABLogger +): Promise { + logger.info(`==================== EBOOK SEARCH RESULT ====================`); + logger.info(`Source: Anna's Archive`); + logger.info(`Title: "${audiobook.title}"`); + logger.info(`Author: "${audiobook.author}"`); + logger.info(`Format: ${preferredFormat}`); + logger.info(`MD5: ${result.md5}`); + logger.info(`Download Links: ${result.downloadUrls.length}`); + logger.info(`Score: ${result.score}/100`); + logger.info(`==============================================================`); + + // Create download history record + const downloadHistory = await prisma.downloadHistory.create({ + data: { + requestId, + indexerName: "Anna's Archive", + torrentName: `${audiobook.title} - ${audiobook.author}.${preferredFormat}`, + torrentSizeBytes: null, // Unknown until download starts + qualityScore: result.score, + selected: true, + downloadClient: 'direct', // Direct HTTP download + downloadStatus: 'queued', + }, + }); + + // Trigger direct download job + const jobQueue = getJobQueueService(); + await jobQueue.addStartDirectDownloadJob( + requestId, + downloadHistory.id, + result.downloadUrls[0], // Start with first link + `${audiobook.title} - ${audiobook.author}.${preferredFormat}`, + undefined // Size unknown + ); + + // Store all download URLs for retry purposes + await prisma.downloadHistory.update({ + where: { id: downloadHistory.id }, + data: { + torrentUrl: JSON.stringify(result.downloadUrls), + }, + }); + + return { + success: true, + message: `Found ebook via Anna's Archive, starting download`, + requestId, + source: 'annas_archive', + searchResult: { + md5: result.md5, + format: result.format, + score: result.score, + downloadLinksCount: result.downloadUrls.length, + }, + }; +} + +/** + * Handle indexer download (torrent/NZB via download-torrent processor) + */ +async function handleIndexerDownload( + requestId: string, + audiobook: { title: string; author: string }, + result: RankedEbookTorrent, + preferredFormat: string, + logger: RMABLogger +): Promise { + logger.info(`==================== EBOOK SEARCH RESULT ====================`); + logger.info(`Source: Indexer (${result.indexer})`); + logger.info(`Title: "${audiobook.title}"`); + logger.info(`Author: "${audiobook.author}"`); + logger.info(`Torrent: "${result.title}"`); + logger.info(`Size: ${(result.size / (1024 * 1024)).toFixed(1)} MB`); + logger.info(`Seeders: ${result.seeders !== undefined ? result.seeders : 'N/A'}`); + logger.info(`Final Score: ${result.finalScore.toFixed(1)}/100`); + logger.info(`==============================================================`); + + // Trigger download job using the SAME processor as audiobooks + // The download-torrent processor is already generic and handles both torrent and NZB + const jobQueue = getJobQueueService(); + + // Fetch the request to get the parent audiobook ID for the download job + const request = await prisma.request.findUnique({ + where: { id: requestId }, + include: { parentRequest: true }, + }); + + if (!request) { + throw new Error(`Request ${requestId} not found`); + } + + // Use the parent audiobook's ID for the download job, or fall back to request ID + const audiobookId = request.parentRequest?.id || request.id; + + await jobQueue.addDownloadJob(requestId, { + id: audiobookId, + title: audiobook.title, + author: audiobook.author, + }, result); + + return { + success: true, + message: `Found ebook via indexer search, starting download`, + requestId, + source: 'prowlarr', + resultsCount: 1, + selectedTorrent: { + title: result.title, + score: result.score, + finalScore: result.finalScore, + seeders: result.seeders || 0, + size: result.size, + }, + }; +} diff --git a/src/lib/utils/file-organizer.ts b/src/lib/utils/file-organizer.ts index d32c102..c1649ac 100644 --- a/src/lib/utils/file-organizer.ts +++ b/src/lib/utils/file-organizer.ts @@ -645,12 +645,14 @@ export class FileOrganizer { /** * Organize ebook file into proper directory structure * Simplified compared to audiobooks - no metadata tagging, cover art, or chapter merging + * Supports both direct file paths (Anna's Archive) and directories (indexer downloads) */ async organizeEbook( downloadPath: string, - metadata: { title: string; author: string; asin?: string; year?: number }, + metadata: { title: string; author: string; narrator?: string; asin?: string; year?: number; series?: string; seriesPart?: string }, template: string, - loggerConfig?: LoggerConfig + loggerConfig?: LoggerConfig, + isIndexerDownload: boolean = false ): Promise { const logger = loggerConfig ? RMABLogger.forJob(loggerConfig.jobId, loggerConfig.context) : null; @@ -663,19 +665,21 @@ export class FileOrganizer { try { await logger?.info(`Organizing ebook: ${downloadPath}`); - // Get file info - const stats = await fs.stat(downloadPath); - if (!stats.isFile()) { - throw new Error('Ebook download path must be a file'); + const ebookFormats = ['epub', 'pdf', 'mobi', 'azw', 'azw3', 'fb2', 'cbz', 'cbr']; + + // Find ebook file (handle both file and directory cases) + const { ebookFile, baseSourcePath, isFile } = await this.findEbookFile(downloadPath, ebookFormats); + + if (!ebookFile) { + throw new Error(`No ebook files found in download (looking for: ${ebookFormats.join(', ')})`); } + // Build full path to source file + const sourceFilePath = isFile ? downloadPath : path.join(baseSourcePath, ebookFile); + await logger?.info(`Found ebook file: ${ebookFile}`); + // Detect format from extension - const ext = path.extname(downloadPath).toLowerCase().slice(1); - const ebookFormats = ['epub', 'pdf', 'mobi', 'azw', 'azw3', 'fb2', 'cbz', 'cbr']; - if (!ebookFormats.includes(ext)) { - throw new Error(`Unsupported ebook format: ${ext}`); - } - + const ext = path.extname(ebookFile).toLowerCase().slice(1); result.format = ext; await logger?.info(`Detected ebook format: ${ext}`); @@ -685,9 +689,11 @@ export class FileOrganizer { template, metadata.author, metadata.title, - undefined, // narrator + metadata.narrator, metadata.asin, - metadata.year + metadata.year, + metadata.series, + metadata.seriesPart ); await logger?.info(`Target directory: ${targetDir}`); @@ -696,7 +702,7 @@ export class FileOrganizer { await fs.mkdir(targetDir, { recursive: true }); // Build target filename (sanitize source filename) - const sourceFilename = path.basename(downloadPath); + const sourceFilename = path.basename(ebookFile); const targetFilename = this.sanitizePath(sourceFilename); const targetPath = path.join(targetDir, targetFilename); @@ -711,18 +717,22 @@ export class FileOrganizer { // File doesn't exist, continue with copy } - // Copy ebook file (don't delete original in case of direct download retry) - await fs.copyFile(downloadPath, targetPath); + // Copy ebook file (do NOT delete original - may need for seeding or retry) + await fs.copyFile(sourceFilePath, targetPath); await fs.chmod(targetPath, 0o644); await logger?.info(`Copied ebook: ${targetFilename}`); - // Clean up source file (for direct HTTP downloads, we don't need to keep the original) - try { - await fs.unlink(downloadPath); - await logger?.info(`Cleaned up source file: ${sourceFilename}`); - } catch { - // Ignore cleanup errors + // Clean up source file ONLY for direct HTTP downloads (not indexer downloads which need to seed) + if (!isIndexerDownload && isFile) { + try { + await fs.unlink(sourceFilePath); + await logger?.info(`Cleaned up source file: ${sourceFilename}`); + } catch { + // Ignore cleanup errors + } + } else if (isIndexerDownload) { + await logger?.info(`Keeping source file for seeding: ${sourceFilename}`); } result.success = true; @@ -737,6 +747,60 @@ export class FileOrganizer { return result; } } + + /** + * Find ebook file in download path (handles both single file and directory) + */ + private async findEbookFile( + downloadPath: string, + ebookFormats: string[] + ): Promise<{ ebookFile: string | null; baseSourcePath: string; isFile: boolean }> { + let ebookFile: string | null = null; + let isFile = false; + + try { + const stats = await fs.stat(downloadPath); + + if (stats.isFile()) { + // Handle single file case + isFile = true; + const ext = path.extname(downloadPath).toLowerCase().slice(1); + + if (ebookFormats.includes(ext)) { + ebookFile = path.basename(downloadPath); + } + } else { + // Handle directory case - find ebook files inside + const files = await this.walkDirectory(downloadPath); + + // Filter to ebook files and sort by preference (epub > pdf > others) + const ebookFiles = files.filter(file => { + const ext = path.extname(file).toLowerCase().slice(1); + return ebookFormats.includes(ext); + }); + + if (ebookFiles.length > 0) { + // Sort by format preference + ebookFiles.sort((a, b) => { + const extA = path.extname(a).toLowerCase().slice(1); + const extB = path.extname(b).toLowerCase().slice(1); + const priorityOrder = ['epub', 'pdf', 'mobi', 'azw3', 'azw', 'fb2', 'cbz', 'cbr']; + return priorityOrder.indexOf(extA) - priorityOrder.indexOf(extB); + }); + + ebookFile = ebookFiles[0]; + } + } + } catch { + // Path doesn't exist or inaccessible + } + + return { + ebookFile, + baseSourcePath: downloadPath, + isFile, + }; + } } /** diff --git a/src/lib/utils/ranking-algorithm.ts b/src/lib/utils/ranking-algorithm.ts index 190c844..53f6b65 100644 --- a/src/lib/utils/ranking-algorithm.ts +++ b/src/lib/utils/ranking-algorithm.ts @@ -42,6 +42,18 @@ export interface RankTorrentsOptions { requireAuthor?: boolean; // Enforce author presence check (default: true) } +export interface EbookTorrentRequest { + title: string; + author: string; + preferredFormat: string; // User's preferred format (epub, pdf, etc.) +} + +export interface RankEbookTorrentsOptions { + indexerPriorities?: Map; // indexerId -> priority (1-25) + flagConfigs?: IndexerFlagConfig[]; // Flag bonus configurations + requireAuthor?: boolean; // Enforce author presence check (default: true) +} + export interface BonusModifier { type: 'indexer_priority' | 'indexer_flag' | 'custom'; value: number; // Multiplier (e.g., 0.4 for 40%) @@ -67,6 +79,24 @@ export interface RankedTorrent extends TorrentResult { breakdown: ScoreBreakdown; } +export interface EbookScoreBreakdown { + formatScore: number; // 0-10 points (match preferred = 10, else 0) + sizeScore: number; // 0-15 points (inverted - smaller is better) + seederScore: number; // 0-15 points (same as audiobooks) + matchScore: number; // 0-60 points (same as audiobooks) + totalScore: number; + notes: string[]; +} + +export interface RankedEbookTorrent extends TorrentResult { + score: number; // Base score (0-100) + bonusModifiers: BonusModifier[]; + bonusPoints: number; // Sum of all bonus points + finalScore: number; // score + bonusPoints + rank: number; + breakdown: EbookScoreBreakdown; +} + export class RankingAlgorithm { /** * Rank all torrents and return sorted by finalScore (best first) @@ -622,6 +652,257 @@ export class RankingAlgorithm { return notes; } + + // ========================================================================= + // EBOOK TORRENT RANKING (for indexer results) + // Reuses scoreMatch() and scoreSeeders() from audiobook ranking + // Uses ebook-specific format and size scoring + // ========================================================================= + + /** + * Rank ebook torrents from indexers + * Reuses title/author matching and seeder scoring from audiobook ranking + * Uses ebook-specific format scoring (10 pts for match, 0 otherwise) + * Uses inverted size scoring (smaller = better, > 20MB filtered) + * + * @param torrents - Array of torrent results from Prowlarr + * @param ebook - Ebook request details (title, author, preferredFormat) + * @param options - Optional configuration for ranking behavior + */ + rankEbookTorrents( + torrents: TorrentResult[], + ebook: EbookTorrentRequest, + options: RankEbookTorrentsOptions = {} + ): RankedEbookTorrent[] { + const { + indexerPriorities, + flagConfigs, + requireAuthor = true // Safe default: require author in automatic mode + } = options; + + // Filter out files > 20 MB (too large for ebooks) + const filteredTorrents = torrents.filter((torrent) => { + const sizeMB = torrent.size / (1024 * 1024); + return sizeMB <= 20; + }); + + const ranked = filteredTorrents.map((torrent) => { + // Calculate base scores (0-100) + // Reuse scoreMatch and scoreSeeders from audiobook ranking + const formatScore = this.scoreEbookFormat(torrent, ebook.preferredFormat); + const sizeScore = this.scoreEbookSize(torrent); + const seederScore = this.scoreSeeders(torrent.seeders); + const matchScore = this.scoreMatch(torrent, { + title: ebook.title, + author: ebook.author, + }, requireAuthor); + + const baseScore = formatScore + sizeScore + seederScore + matchScore; + + // Calculate bonus modifiers (same as audiobooks) + const bonusModifiers: BonusModifier[] = []; + + // Indexer priority bonus (default: 10/25 = 40%) + if (torrent.indexerId !== undefined) { + const priority = indexerPriorities?.get(torrent.indexerId) ?? 10; + const modifier = priority / 25; // Convert 1-25 to 0.04-1.0 (4%-100%) + const points = baseScore * modifier; + + bonusModifiers.push({ + type: 'indexer_priority', + value: modifier, + points: points, + reason: `Indexer priority ${priority}/25 (${Math.round(modifier * 100)}%)`, + }); + } + + // Flag bonuses/penalties (same as audiobooks) + if (torrent.flags && torrent.flags.length > 0 && flagConfigs && flagConfigs.length > 0) { + torrent.flags.forEach(torrentFlag => { + const matchingConfig = flagConfigs.find(cfg => + cfg.name.trim().toLowerCase() === torrentFlag.trim().toLowerCase() + ); + + if (matchingConfig) { + const modifier = matchingConfig.modifier / 100; + const points = baseScore * modifier; + + bonusModifiers.push({ + type: 'indexer_flag', + value: modifier, + points: points, + reason: `Flag "${torrentFlag}" (${matchingConfig.modifier > 0 ? '+' : ''}${matchingConfig.modifier}%)`, + }); + } + }); + } + + // Sum all bonus points + const bonusPoints = bonusModifiers.reduce((sum, mod) => sum + mod.points, 0); + + // Calculate final score + const finalScore = baseScore + bonusPoints; + + return { + ...torrent, + score: baseScore, + bonusModifiers, + bonusPoints, + finalScore, + rank: 0, // Will be assigned after sorting + breakdown: { + formatScore, + sizeScore, + seederScore, + matchScore, + totalScore: baseScore, + notes: this.generateEbookNotes(torrent, { + formatScore, + sizeScore, + seederScore, + matchScore, + totalScore: baseScore, + notes: [], + }, ebook.preferredFormat), + }, + }; + }); + + // Sort by finalScore descending (best first), then by publishDate descending (newest first) + ranked.sort((a, b) => { + if (b.finalScore !== a.finalScore) { + return b.finalScore - a.finalScore; + } + return b.publishDate.getTime() - a.publishDate.getTime(); + }); + + // Assign ranks + ranked.forEach((r, index) => { + r.rank = index + 1; + }); + + return ranked; + } + + /** + * Score ebook format (10 points max) + * Full points for matching preferred format, 0 otherwise + */ + private scoreEbookFormat(torrent: TorrentResult, preferredFormat: string): number { + const detectedFormat = this.detectEbookFormat(torrent); + const preferred = preferredFormat.toLowerCase(); + + // Exact match = full points, otherwise 0 + if (detectedFormat === preferred) { + return 10; + } + + return 0; + } + + /** + * Score ebook file size (15 points max, inverted - smaller is better) + * < 5 MB = 15 pts (full) + * 5-15 MB = 10 pts + * 15-20 MB = 5 pts + * > 20 MB = filtered out (not scored) + */ + private scoreEbookSize(torrent: TorrentResult): number { + const sizeMB = torrent.size / (1024 * 1024); + + if (sizeMB < 5) { + return 15; // Optimal size for ebooks + } else if (sizeMB <= 15) { + return 10; // Acceptable, may have images + } else if (sizeMB <= 20) { + return 5; // Large but within limit + } + + // > 20 MB should have been filtered, but return 0 as safety + return 0; + } + + /** + * Detect ebook format from torrent title + */ + private detectEbookFormat(torrent: TorrentResult): string { + const title = torrent.title.toLowerCase(); + + // Check for common ebook format extensions/keywords + if (title.includes('.epub') || title.includes(' epub')) return 'epub'; + if (title.includes('.pdf') || title.includes(' pdf')) return 'pdf'; + if (title.includes('.mobi') || title.includes(' mobi')) return 'mobi'; + if (title.includes('.azw3') || title.includes(' azw3')) return 'azw3'; + if (title.includes('.azw') || title.includes(' azw')) return 'azw'; + if (title.includes('.fb2') || title.includes(' fb2')) return 'fb2'; + if (title.includes('.cbz') || title.includes(' cbz')) return 'cbz'; + if (title.includes('.cbr') || title.includes(' cbr')) return 'cbr'; + + // Default to unknown + return 'unknown'; + } + + /** + * Generate human-readable notes for ebook scoring + */ + private generateEbookNotes( + torrent: TorrentResult, + breakdown: EbookScoreBreakdown, + preferredFormat: string + ): string[] { + const notes: string[] = []; + + // Format notes + const detectedFormat = this.detectEbookFormat(torrent); + if (breakdown.formatScore === 10) { + notes.push(`✓ Preferred format (${detectedFormat.toUpperCase()})`); + } else if (detectedFormat !== 'unknown') { + notes.push(`Different format (${detectedFormat.toUpperCase()}, wanted ${preferredFormat.toUpperCase()})`); + } else { + notes.push('⚠️ Unknown format'); + } + + // Size notes + const sizeMB = torrent.size / (1024 * 1024); + if (sizeMB < 5) { + notes.push('✓ Optimal file size'); + } else if (sizeMB <= 15) { + notes.push('Good file size (may have images)'); + } else if (sizeMB <= 20) { + notes.push('⚠️ Large file size'); + } + + // Seeder notes (same logic as audiobooks) + if (torrent.seeders !== undefined && torrent.seeders !== null && !isNaN(torrent.seeders)) { + if (torrent.seeders === 0) { + notes.push('⚠️ No seeders available'); + } else if (torrent.seeders < 5) { + notes.push(`Low seeders (${torrent.seeders})`); + } else if (torrent.seeders >= 50) { + notes.push(`Excellent availability (${torrent.seeders} seeders)`); + } + } + + // Match notes (same thresholds as audiobooks) + if (breakdown.matchScore < 24) { + notes.push('⚠️ Poor title/author match'); + } else if (breakdown.matchScore < 42) { + notes.push('⚠️ Weak title/author match'); + } else if (breakdown.matchScore >= 54) { + notes.push('✓ Excellent title/author match'); + } + + // Overall quality assessment + if (breakdown.totalScore >= 75) { + notes.push('✓ Excellent choice'); + } else if (breakdown.totalScore >= 55) { + notes.push('✓ Good choice'); + } else if (breakdown.totalScore < 35) { + notes.push('⚠️ Consider reviewing this choice'); + } + + return notes; + } } // ========================================================================= @@ -844,3 +1125,26 @@ export function rankTorrents( qualityScore: Math.round(r.score), })); } + +/** + * Helper function to rank ebook torrents using the singleton instance + * + * @param torrents - Array of torrent results from Prowlarr + * @param ebook - Ebook request details (title, author, preferredFormat) + * @param options - Optional ranking configuration + * @returns Ranked ebook torrents with quality scores + */ +export function rankEbookTorrents( + torrents: TorrentResult[], + ebook: EbookTorrentRequest, + options?: RankEbookTorrentsOptions +): (RankedEbookTorrent & { qualityScore: number })[] { + const algorithm = getRankingAlgorithm(); + const ranked = algorithm.rankEbookTorrents(torrents, ebook, options || {}); + + // Add qualityScore field for UI compatibility (rounded score) + return ranked.map((r) => ({ + ...r, + qualityScore: Math.round(r.score), + })); +} From c913be5ca211846bc36e86632dbc0440d037a0e8 Mon Sep 17 00:00:00 2001 From: kikootwo Date: Mon, 2 Feb 2026 12:51:06 -0500 Subject: [PATCH 4/7] Support plain indexer URLs for ebook source Update RequestActionsDropdown to handle ebook torrentUrl values that are plain indexer URLs in addition to JSON arrays of slow-download URLs. Clarify comment about audiobooks and indexer-sourced ebooks, and on JSON parse failure use the plain URL directly (unless it's a magnet: link) to build the view source URL. --- src/app/admin/components/RequestActionsDropdown.tsx | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/app/admin/components/RequestActionsDropdown.tsx b/src/app/admin/components/RequestActionsDropdown.tsx index face4fe..884d867 100644 --- a/src/app/admin/components/RequestActionsDropdown.tsx +++ b/src/app/admin/components/RequestActionsDropdown.tsx @@ -52,11 +52,12 @@ export function RequestActionsDropdown({ const canDelete = true; // Admins can always delete // View Source: For ebooks, extract MD5 from slow download URL and link to Anna's Archive - // For audiobooks, show indexer page URL (not magnet links) + // For audiobooks and indexer-sourced ebooks, show indexer page URL (not magnet links) let viewSourceUrl: string | null = null; if (isEbook && request.torrentUrl) { - // torrentUrl for ebooks is JSON array of slow download URLs - // Extract MD5 from URL pattern: /slow_download/[md5]/... + // torrentUrl for ebooks can be: + // 1. JSON array of slow download URLs (Anna's Archive) - extract MD5 + // 2. Plain URL string (indexer source) - use directly try { const urls = JSON.parse(request.torrentUrl); if (Array.isArray(urls) && urls.length > 0) { @@ -66,7 +67,11 @@ export function RequestActionsDropdown({ } } } catch { - // Not JSON, ignore + // Not JSON - it's a plain URL from indexer source + // Use it directly if it's not a magnet link + if (!request.torrentUrl.startsWith('magnet:')) { + viewSourceUrl = request.torrentUrl; + } } } else if (request.torrentUrl && !request.torrentUrl.startsWith('magnet:')) { viewSourceUrl = request.torrentUrl; From 1afab5d47fd67285cb82cc9fb2417b551613ee5c Mon Sep 17 00:00:00 2001 From: kikootwo Date: Mon, 2 Feb 2026 19:59:58 -0500 Subject: [PATCH 5/7] Add interactive ebook search & selection Introduce interactive ebook support: adds two API endpoints to search (interactive-search-ebook) and create/select ebook requests (select-ebook), plus server-side handlers to route Anna's Archive (direct) and indexer (torrent/NZB) downloads. Frontend: extend RequestActionsDropdown and InteractiveTorrentSearchModal to support an "ebook" search mode and selection flow, and add hooks (useInteractiveSearchEbook / useSelectEbook). Settings: add ebook_auto_grab_enabled with UI toggle and enforce disabling when no ebook sources are enabled; settings GET/PUT updated to persist the flag (default = true to preserve behavior). Documentation updated (scheduler, ebook-sidecar, settings pages) and ranking algorithm docs/tests extended to cover ebook-related normalization and matching cases. Includes logging and ranking integration for indexer results and normalization for Anna's Archive handling. --- documentation/backend/services/scheduler.md | 4 +- documentation/integrations/ebook-sidecar.md | 3 + documentation/phase3/ranking-algorithm.md | 15 +- documentation/settings-pages.md | 3 + .../components/RequestActionsDropdown.tsx | 50 +- src/app/admin/settings/lib/types.ts | 1 + .../admin/settings/tabs/EbookTab/EbookTab.tsx | 23 + .../tabs/EbookTab/useEbookSettings.ts | 1 + src/app/api/admin/settings/ebook/route.ts | 11 +- src/app/api/admin/settings/route.ts | 2 + .../[id]/interactive-search-ebook/route.ts | 430 ++++++++++++++++++ .../api/requests/[id]/select-ebook/route.ts | 258 +++++++++++ .../InteractiveTorrentSearchModal.tsx | 141 ++++-- src/lib/hooks/useRequests.ts | 85 ++++ .../processors/monitor-rss-feeds.processor.ts | 38 +- .../processors/organize-files.processor.ts | 10 +- .../retry-missing-torrents.processor.ts | 35 +- src/lib/utils/ranking-algorithm.ts | 191 ++++++-- tests/utils/ranking-algorithm.test.ts | 153 +++++++ 19 files changed, 1339 insertions(+), 115 deletions(-) create mode 100644 src/app/api/requests/[id]/interactive-search-ebook/route.ts create mode 100644 src/app/api/requests/[id]/select-ebook/route.ts diff --git a/documentation/backend/services/scheduler.md b/documentation/backend/services/scheduler.md index 23c456d..202c2c3 100644 --- a/documentation/backend/services/scheduler.md +++ b/documentation/backend/services/scheduler.md @@ -18,10 +18,10 @@ Manages recurring/scheduled jobs providing automated tasks (Plex scans, Audible 1. **plex_library_scan** - Default: every 6 hours, full library scan, disabled by default (enable after setup) 2. **plex_recently_added_check** - Default: every 5 minutes, lightweight polling of top 10 recently added items, enabled by default 3. **audible_refresh** - Default: daily midnight, fetches 200 popular + 200 new releases, stores with rankings, disabled by default -4. **retry_missing_torrents** - Default: daily midnight, re-searches 'awaiting_search' status (limit 50), enabled by default +4. **retry_missing_torrents** - Default: daily midnight, re-searches 'awaiting_search' status (limit 50), handles both audiobook and ebook requests, enabled by default 5. **retry_failed_imports** - Default: every 6 hours, re-attempts 'awaiting_import' status (limit 50), enabled by default 6. **cleanup_seeded_torrents** - Default: every 30 mins, deletes torrents after seeding requirements met, respects `seeding_time_minutes` config (0 = never), enabled by default -7. **monitor_rss_feeds** - Default: every 15 mins, checks RSS feeds from enabled indexers, matches against 'awaiting_search' requests (limit 100), triggers search jobs for matches, enabled by default +7. **monitor_rss_feeds** - Default: every 15 mins, checks RSS feeds from enabled indexers, matches against 'awaiting_search' requests (audiobook and ebook, limit 100), triggers appropriate search jobs for matches, enabled by default ## Architecture: Bull + Cron diff --git a/documentation/integrations/ebook-sidecar.md b/documentation/integrations/ebook-sidecar.md index 3c97b51..3116e30 100644 --- a/documentation/integrations/ebook-sidecar.md +++ b/documentation/integrations/ebook-sidecar.md @@ -65,6 +65,9 @@ Ebooks are first-class citizens in RMAB, with their own request type, tracking, | Key | Default | Options | Description | |-----|---------|---------|-------------| | `ebook_sidecar_preferred_format` | `epub` | `epub, pdf, mobi, azw3, any` | Preferred format | +| `ebook_auto_grab_enabled` | `true` | `true, false` | Auto-create ebook requests after audiobook downloads | + +*Note: Auto-grab is automatically disabled if no ebook sources are enabled. Manual fetch via admin buttons still works.* ## Database Schema diff --git a/documentation/phase3/ranking-algorithm.md b/documentation/phase3/ranking-algorithm.md index 97d62b6..8204a2f 100644 --- a/documentation/phase3/ranking-algorithm.md +++ b/documentation/phase3/ranking-algorithm.md @@ -1,7 +1,7 @@ # Intelligent Ranking Algorithm **Status:** ✅ Implemented | Comprehensive edge case test coverage -**Tests:** tests/utils/ranking-algorithm.test.ts (73 test cases) +**Tests:** tests/utils/ranking-algorithm.test.ts (80+ test cases) Evaluates and scores torrents to automatically select best audiobook download. @@ -19,6 +19,7 @@ Evaluates and scores torrents to automatically select best audiobook download. - ✅ **Author presence check (10 tests)** - ✅ **Context-aware filtering (3 tests)** - ✅ **API compatibility (2 tests)** +- ✅ **CamelCase and punctuation separator handling (7 tests)** **Tested edge cases prevent regressions from previous tweaks:** - "We Are Legion (We Are Bob)" matching with/without subtitle @@ -35,6 +36,18 @@ Evaluates and scores torrents to automatically select best audiobook download. **1. Title/Author Match (60 pts max) - MOST IMPORTANT** +**Pre-Processing: Text Normalization** +- All titles and author names are normalized before matching +- **CamelCase splitting:** `"TheCorrespondent"` → `"the correspondent"` +- **Punctuation to spaces:** `"Twelve.Months-Jim"` → `"twelve months jim"` +- **Preserves apostrophes:** `"O'Brien"` remains `"o'brien"` +- Handles common indexer naming patterns (NZB, torrent scene releases) + +**Examples of normalization:** +- `"VirginaEvans TheCorrespondent"` → `"virgina evans the correspondent"` +- `"Twelve.Months-Jim.Butcher"` → `"twelve months jim butcher"` +- `"Author_Name-Book.Title.2024"` → `"author name book title 2024"` + **Multi-Stage Matching:** **Stage 1: Word Coverage Filter (MANDATORY)** diff --git a/documentation/settings-pages.md b/documentation/settings-pages.md index 6e215e0..935dfe9 100644 --- a/documentation/settings-pages.md +++ b/documentation/settings-pages.md @@ -90,6 +90,7 @@ src/app/admin/settings/ 3. **General Settings Section** (visible when any source enabled) - Preferred format: EPUB (recommended), PDF, MOBI, AZW3, Any + - Auto-grab toggle: Automatically create ebook requests after audiobook downloads **Configuration Keys:** | Key | Default | Description | @@ -97,6 +98,7 @@ src/app/admin/settings/ | `ebook_annas_archive_enabled` | `false` | Enable Anna's Archive | | `ebook_indexer_search_enabled` | `false` | Enable Indexer Search via Prowlarr | | `ebook_sidecar_preferred_format` | `epub` | Preferred format | +| `ebook_auto_grab_enabled` | `true` | Auto-create ebook requests after audiobook downloads | | `ebook_sidecar_base_url` | `https://annas-archive.li` | Anna's Archive mirror | | `ebook_sidecar_flaresolverr_url` | `` | FlareSolverr URL | @@ -104,6 +106,7 @@ src/app/admin/settings/ - If Anna's Archive enabled → Searches Anna's Archive first - If Indexer Search enabled → Falls back to indexer search if Anna's Archive fails/disabled - If both disabled → Ebook downloads completely off +- If auto-grab disabled → Manual "Fetch Ebook" button only (admin buttons still work) ## Indexer Categories (Tabbed) diff --git a/src/app/admin/components/RequestActionsDropdown.tsx b/src/app/admin/components/RequestActionsDropdown.tsx index 884d867..a653ca6 100644 --- a/src/app/admin/components/RequestActionsDropdown.tsx +++ b/src/app/admin/components/RequestActionsDropdown.tsx @@ -40,6 +40,7 @@ export function RequestActionsDropdown({ }: RequestActionsDropdownProps) { const [isOpen, setIsOpen] = useState(false); const [showInteractiveSearch, setShowInteractiveSearch] = useState(false); + const [showInteractiveSearchEbook, setShowInteractiveSearchEbook] = useState(false); const { containerRef, dropdownRef, positionAbove, style } = useSmartDropdownPosition(isOpen); // Determine request type @@ -80,7 +81,7 @@ export function RequestActionsDropdown({ const canViewSource = !!viewSourceUrl && ['downloading', 'processing', 'downloaded', 'available'].includes(request.status); - // "Try to fetch Ebook" only for audiobook requests + // Ebook actions (Grab Ebook, Interactive Search Ebook) only for audiobook requests const canFetchEbook = !isEbook && ebookSidecarEnabled && ['downloaded', 'available'].includes(request.status); // Close dropdown when clicking outside @@ -114,6 +115,11 @@ export function RequestActionsDropdown({ setShowInteractiveSearch(true); }; + const handleInteractiveSearchEbook = () => { + setIsOpen(false); + setShowInteractiveSearchEbook(true); + }; + const handleCancel = async () => { setIsOpen(false); if (window.confirm(`Are you sure you want to cancel the request for "${request.title}"?`)) { @@ -224,7 +230,7 @@ export function RequestActionsDropdown({ )} - {/* Fetch E-book */} + {/* Grab E-book (automatic) */} {canFetchEbook && ( + )} + + {/* Interactive Search E-book */} + {canFetchEbook && ( + )} @@ -332,7 +362,7 @@ export function RequestActionsDropdown({ {/* Dropdown menu (rendered via portal) */} {typeof window !== 'undefined' && dropdownMenu && createPortal(dropdownMenu, document.body)} - {/* Interactive Search Modal */} + {/* Interactive Search Modal (Audiobook) */} setShowInteractiveSearch(false)} @@ -342,6 +372,18 @@ export function RequestActionsDropdown({ author: request.author, }} /> + + {/* Interactive Search Modal (Ebook) */} + setShowInteractiveSearchEbook(false)} + requestId={request.requestId} + audiobook={{ + title: request.title, + author: request.author, + }} + searchMode="ebook" + /> ); } diff --git a/src/app/admin/settings/lib/types.ts b/src/app/admin/settings/lib/types.ts index 23cb2bf..c4d1f3a 100644 --- a/src/app/admin/settings/lib/types.ts +++ b/src/app/admin/settings/lib/types.ts @@ -114,6 +114,7 @@ export interface EbookSettings { flaresolverrUrl: string; // General settings (shared across sources) preferredFormat: string; + autoGrabEnabled: boolean; } /** diff --git a/src/app/admin/settings/tabs/EbookTab/EbookTab.tsx b/src/app/admin/settings/tabs/EbookTab/EbookTab.tsx index 3c8aefe..90c35be 100644 --- a/src/app/admin/settings/tabs/EbookTab/EbookTab.tsx +++ b/src/app/admin/settings/tabs/EbookTab/EbookTab.tsx @@ -231,6 +231,29 @@ export function EbookTab({ ebook, onChange, onSuccess, onError, markAsSaved }: E EPUB is recommended for most e-readers. "Any format" accepts the first available.

+ + {/* Auto Grab Toggle */} +
+ updateEbook('autoGrabEnabled', e.target.checked)} + className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500" + /> +
+ +

+ When enabled, ebook requests are created automatically after audiobook downloads complete. + When disabled, use the "Fetch Ebook" button on completed requests. +

+
+
)} diff --git a/src/app/admin/settings/tabs/EbookTab/useEbookSettings.ts b/src/app/admin/settings/tabs/EbookTab/useEbookSettings.ts index 41485a1..381fe94 100644 --- a/src/app/admin/settings/tabs/EbookTab/useEbookSettings.ts +++ b/src/app/admin/settings/tabs/EbookTab/useEbookSettings.ts @@ -82,6 +82,7 @@ export function useEbookSettings({ ebook, onChange, onSuccess, onError, markAsSa format: ebook.preferredFormat || 'epub', baseUrl: ebook.baseUrl || 'https://annas-archive.li', flaresolverrUrl: ebook.flaresolverrUrl || '', + autoGrabEnabled: ebook.autoGrabEnabled ?? true, }), }); diff --git a/src/app/api/admin/settings/ebook/route.ts b/src/app/api/admin/settings/ebook/route.ts index 60a16ef..0f9a0c8 100644 --- a/src/app/api/admin/settings/ebook/route.ts +++ b/src/app/api/admin/settings/ebook/route.ts @@ -14,7 +14,10 @@ export async function PUT(request: NextRequest) { return requireAdmin(req, async () => { try { // Parse request body - new structure with separate source toggles - const { annasArchiveEnabled, indexerSearchEnabled, format, baseUrl, flaresolverrUrl } = await request.json(); + const { annasArchiveEnabled, indexerSearchEnabled, format, baseUrl, flaresolverrUrl, autoGrabEnabled } = await request.json(); + + // Enforce: auto-grab must be false if no sources are enabled + const effectiveAutoGrabEnabled = (annasArchiveEnabled || indexerSearchEnabled) ? (autoGrabEnabled ?? true) : false; // Validate format const validFormats = ['epub', 'pdf', 'mobi', 'azw3', 'any']; @@ -66,6 +69,12 @@ export async function PUT(request: NextRequest) { category: 'ebook', description: 'Preferred e-book format', }, + { + key: 'ebook_auto_grab_enabled', + value: effectiveAutoGrabEnabled ? 'true' : 'false', + category: 'ebook', + description: 'Automatically create ebook requests after audiobook downloads complete', + }, // Anna's Archive specific settings { key: 'ebook_sidecar_base_url', diff --git a/src/app/api/admin/settings/route.ts b/src/app/api/admin/settings/route.ts index 9158889..1b020be 100644 --- a/src/app/api/admin/settings/route.ts +++ b/src/app/api/admin/settings/route.ts @@ -139,6 +139,8 @@ export async function GET(request: NextRequest) { flaresolverrUrl: configMap.get('ebook_sidecar_flaresolverr_url') || '', // General settings preferredFormat: configMap.get('ebook_sidecar_preferred_format') || 'epub', + // Auto-grab: default true to preserve existing behavior + autoGrabEnabled: configMap.get('ebook_auto_grab_enabled') !== 'false', }, general: { appName: configMap.get('app_name') || 'ReadMeABook', diff --git a/src/app/api/requests/[id]/interactive-search-ebook/route.ts b/src/app/api/requests/[id]/interactive-search-ebook/route.ts new file mode 100644 index 0000000..6dc586b --- /dev/null +++ b/src/app/api/requests/[id]/interactive-search-ebook/route.ts @@ -0,0 +1,430 @@ +/** + * Component: Interactive Search Ebook API + * Documentation: documentation/integrations/ebook-sidecar.md + * + * Searches for ebooks from multiple sources (Anna's Archive + Indexers) + * Returns combined results for user selection in interactive modal + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth'; +import { prisma } from '@/lib/db'; +import { getConfigService } from '@/lib/services/config.service'; +import { getProwlarrService } from '@/lib/integrations/prowlarr.service'; +import { rankEbookTorrents, RankedEbookTorrent } from '@/lib/utils/ranking-algorithm'; +import { groupIndexersByCategories, getGroupDescription } from '@/lib/utils/indexer-grouping'; +import { RMABLogger } from '@/lib/utils/logger'; +import { + searchByAsin, + searchByTitle, + getSlowDownloadLinks, +} from '@/lib/services/ebook-scraper'; + +const logger = RMABLogger.create('API.InteractiveSearchEbook'); + +// Unified result type for frontend +export interface EbookSearchResult { + // Common fields (match RankedTorrent shape for UI compatibility) + guid: string; + title: string; + size: number; + seeders?: number; + indexer: string; + indexerId?: number; + publishDate: Date; + downloadUrl: string; + infoUrl?: string; + protocol?: string; // 'torrent' or 'usenet' - determines download client + + // Ranking fields + score: number; + finalScore: number; + bonusPoints: number; + bonusModifiers: Array<{ type: string; value: number; points: number; reason: string }>; + rank: number; + breakdown: { + formatScore: number; + sizeScore: number; + seederScore: number; + matchScore: number; + totalScore: number; + notes: string[]; + }; + + // Ebook-specific fields + source: 'annas_archive' | 'prowlarr'; + format?: string; + md5?: string; + downloadUrls?: string[]; +} + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + return requireAuth(request, async (req: AuthenticatedRequest) => { + return requireAdmin(req, async () => { + try { + const { id: parentRequestId } = await params; + const body = await request.json().catch(() => ({})); + const customTitle = body.customTitle as string | undefined; + + // Get the parent audiobook request + const parentRequest = await prisma.request.findUnique({ + where: { id: parentRequestId }, + include: { audiobook: true }, + }); + + if (!parentRequest) { + return NextResponse.json({ error: 'Request not found' }, { status: 404 }); + } + + if (parentRequest.type !== 'audiobook') { + return NextResponse.json({ error: 'Can only search ebooks for audiobook requests' }, { status: 400 }); + } + + if (!['downloaded', 'available'].includes(parentRequest.status)) { + return NextResponse.json( + { error: `Cannot search ebooks for request in ${parentRequest.status} status` }, + { status: 400 } + ); + } + + // Check for existing non-retryable ebook request + const existingEbookRequest = await prisma.request.findFirst({ + where: { + parentRequestId, + type: 'ebook', + deletedAt: null, + }, + }); + + if (existingEbookRequest && !['failed', 'awaiting_search'].includes(existingEbookRequest.status)) { + return NextResponse.json({ + error: `E-book request already exists (status: ${existingEbookRequest.status})`, + existingRequestId: existingEbookRequest.id, + }, { status: 400 }); + } + + // Get ebook configuration + const configService = getConfigService(); + const [annasArchiveEnabled, indexerSearchEnabled, preferredFormat, baseUrl, flaresolverrUrl] = await Promise.all([ + configService.get('ebook_annas_archive_enabled'), + configService.get('ebook_indexer_search_enabled'), + configService.get('ebook_sidecar_preferred_format'), + configService.get('ebook_sidecar_base_url'), + configService.get('ebook_sidecar_flaresolverr_url'), + ]); + + const isAnnasArchiveEnabled = annasArchiveEnabled === 'true'; + const isIndexerSearchEnabled = indexerSearchEnabled === 'true'; + const format = preferredFormat || 'epub'; + const annasBaseUrl = baseUrl || 'https://annas-archive.li'; + + if (!isAnnasArchiveEnabled && !isIndexerSearchEnabled) { + return NextResponse.json( + { error: 'No ebook sources enabled. Enable Anna\'s Archive or Indexer Search in settings.' }, + { status: 400 } + ); + } + + const audiobook = parentRequest.audiobook; + const searchTitle = customTitle || audiobook.title; + + logger.info(`Interactive ebook search for "${searchTitle}" by ${audiobook.author}`); + logger.info(`Sources: Anna's Archive=${isAnnasArchiveEnabled}, Indexer=${isIndexerSearchEnabled}`); + + // Search both sources in parallel + const searchPromises: Promise[] = []; + + if (isAnnasArchiveEnabled) { + searchPromises.push( + searchAnnasArchiveForInteractive( + audiobook.audibleAsin || undefined, + searchTitle, + audiobook.author, + format, + annasBaseUrl, + flaresolverrUrl || undefined + ).catch((err) => { + logger.error(`Anna's Archive search failed: ${err.message}`); + return null; + }) + ); + } + + if (isIndexerSearchEnabled) { + searchPromises.push( + searchIndexersForInteractive( + searchTitle, + audiobook.author, + format + ).catch((err) => { + logger.error(`Indexer search failed: ${err.message}`); + return null; + }) + ); + } + + const searchResults = await Promise.all(searchPromises); + + // Combine results: Anna's Archive first (if found), then ranked indexer results + const combinedResults: EbookSearchResult[] = []; + let rank = 1; + + // Add Anna's Archive result first (if enabled and found) + if (isAnnasArchiveEnabled && searchResults[0]) { + const annasResults = searchResults[0]; + for (const result of annasResults) { + combinedResults.push({ ...result, rank: rank++ }); + } + } + + // Add indexer results (already ranked) + const indexerResultsIndex = isAnnasArchiveEnabled ? 1 : 0; + if (isIndexerSearchEnabled && searchResults[indexerResultsIndex]) { + const indexerResults = searchResults[indexerResultsIndex]; + for (const result of indexerResults) { + combinedResults.push({ ...result, rank: rank++ }); + } + } + + logger.info(`Found ${combinedResults.length} total ebook results`); + + return NextResponse.json({ + results: combinedResults, + searchTitle, + preferredFormat: format, + }); + + } catch (error) { + logger.error('Unexpected error', { error: error instanceof Error ? error.message : String(error) }); + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Internal server error' }, + { status: 500 } + ); + } + }); + }); +} + +/** + * Search Anna's Archive and return normalized results + */ +async function searchAnnasArchiveForInteractive( + asin: string | undefined, + title: string, + author: string, + preferredFormat: string, + baseUrl: string, + flaresolverrUrl?: string +): Promise { + let md5: string | null = null; + let searchMethod: 'asin' | 'title' = 'title'; + + // Try ASIN search first + if (asin) { + logger.info(`Searching Anna's Archive by ASIN: ${asin}`); + md5 = await searchByAsin(asin, preferredFormat, baseUrl, undefined, flaresolverrUrl); + if (md5) { + searchMethod = 'asin'; + logger.info(`Found via ASIN: ${md5}`); + } + } + + // Fallback to title search + if (!md5) { + logger.info(`Searching Anna's Archive by title: "${title}"`); + md5 = await searchByTitle(title, author, preferredFormat, baseUrl, undefined, flaresolverrUrl); + if (md5) { + logger.info(`Found via title: ${md5}`); + } + } + + if (!md5) { + logger.info('No results from Anna\'s Archive'); + return []; + } + + // Get download links + const slowLinks = await getSlowDownloadLinks(md5, baseUrl, undefined, flaresolverrUrl); + + if (slowLinks.length === 0) { + logger.warn(`Found MD5 ${md5} but no download links available`); + return []; + } + + // Return as normalized result - always score 100 for Anna's Archive + const score = 100; + + return [{ + guid: `annas-archive-${md5}`, + title: `${title} - ${author}`, + size: 0, // Unknown until download + seeders: 999, // N/A for direct download, use high number for display + indexer: "Anna's Archive", + publishDate: new Date(), + downloadUrl: slowLinks[0], + infoUrl: `${baseUrl}/md5/${md5}`, + + score, + finalScore: score, + bonusPoints: 0, + bonusModifiers: [], + rank: 1, + breakdown: { + formatScore: 10, + sizeScore: 15, + seederScore: 15, + matchScore: 60, + totalScore: score, + notes: [searchMethod === 'asin' ? 'ASIN match' : 'Title/Author match', "Anna's Archive"], + }, + + source: 'annas_archive', + format: preferredFormat, + md5, + downloadUrls: slowLinks, + }]; +} + +/** + * Search indexers and return ranked results + */ +async function searchIndexersForInteractive( + title: string, + author: string, + preferredFormat: string +): Promise { + const configService = getConfigService(); + + // Get indexer configuration + const indexersConfigStr = await configService.get('prowlarr_indexers'); + if (!indexersConfigStr) { + logger.warn('No indexers configured'); + return []; + } + + const indexersConfig = JSON.parse(indexersConfigStr); + if (indexersConfig.length === 0) { + logger.warn('No indexers enabled'); + return []; + } + + // Build indexer priorities map + const indexerPriorities = new Map( + indexersConfig.map((indexer: any) => [indexer.id, indexer.priority ?? 10]) + ); + + // Get flag configurations + const flagConfigStr = await configService.get('indexer_flag_config'); + const flagConfigs = flagConfigStr ? JSON.parse(flagConfigStr) : []; + + // Group indexers by ebook categories + const groups = groupIndexersByCategories(indexersConfig, 'ebook'); + + logger.info(`Searching ${indexersConfig.length} indexers in ${groups.length} group(s)`); + + // Get Prowlarr service + const prowlarr = await getProwlarrService(); + + // Search each group and combine results + const allResults = []; + + for (const group of groups) { + try { + const groupResults = await prowlarr.search(title, { + categories: group.categories, + indexerIds: group.indexerIds, + minSeeders: 0, + maxResults: 100, + }); + allResults.push(...groupResults); + } catch (error) { + logger.error(`Group search failed: ${error instanceof Error ? error.message : 'Unknown'}`); + } + } + + logger.info(`Found ${allResults.length} results from indexers`); + + if (allResults.length === 0) { + return []; + } + + // Rank results with ebook scoring + // Use requireAuthor=false for interactive mode (let user decide) + const rankedResults = rankEbookTorrents(allResults, { + title, + author, + preferredFormat, + }, { + indexerPriorities, + flagConfigs, + requireAuthor: false, + }); + + // Log ranking debug info (same format as search-ebook.processor.ts) + if (rankedResults.length > 0) { + const top3 = rankedResults.slice(0, 3); + logger.info(`==================== EBOOK INTERACTIVE SEARCH DEBUG ====================`); + logger.info(`Requested Title: "${title}"`); + logger.info(`Requested Author: "${author}"`); + logger.info(`Preferred Format: ${preferredFormat}`); + logger.info(`Top ${top3.length} results (out of ${rankedResults.length} total):`); + logger.info(`--------------------------------------------------------------`); + for (let i = 0; i < top3.length; i++) { + const result = top3[i]; + const sizeMB = (result.size / (1024 * 1024)).toFixed(1); + + logger.info(`${i + 1}. "${result.title}"`); + logger.info(` Indexer: ${result.indexer}${result.indexerId ? ` (ID: ${result.indexerId})` : ''}`); + logger.info(` Format: ${result.ebookFormat || 'unknown'}`); + logger.info(``); + logger.info(` Base Score: ${result.score.toFixed(1)}/100`); + logger.info(` - Title/Author Match: ${result.breakdown.matchScore.toFixed(1)}/60`); + logger.info(` - Format Match: ${result.breakdown.formatScore.toFixed(1)}/10`); + logger.info(` - Size Quality: ${result.breakdown.sizeScore.toFixed(1)}/15 (${sizeMB} MB)`); + logger.info(` - Seeder Count: ${result.breakdown.seederScore.toFixed(1)}/15 (${result.seeders !== undefined ? result.seeders + ' seeders' : 'N/A for Usenet'})`); + logger.info(``); + logger.info(` Bonus Points: +${result.bonusPoints.toFixed(1)}`); + if (result.bonusModifiers.length > 0) { + for (const mod of result.bonusModifiers) { + logger.info(` - ${mod.reason}: +${mod.points.toFixed(1)}`); + } + } + logger.info(``); + logger.info(` Final Score: ${result.finalScore.toFixed(1)}`); + if (result.breakdown.notes.length > 0) { + logger.info(` Notes: ${result.breakdown.notes.join(', ')}`); + } + if (i < top3.length - 1) { + logger.info(`--------------------------------------------------------------`); + } + } + logger.info(`==============================================================`); + } + + // Convert to unified result type + return rankedResults.map((result: RankedEbookTorrent): EbookSearchResult => ({ + guid: result.guid, + title: result.title, + size: result.size, + seeders: result.seeders, + indexer: result.indexer, + indexerId: result.indexerId, + publishDate: result.publishDate, + downloadUrl: result.downloadUrl, + infoUrl: result.infoUrl, + + score: result.score, + finalScore: result.finalScore, + bonusPoints: result.bonusPoints, + bonusModifiers: result.bonusModifiers, + rank: result.rank, + breakdown: result.breakdown, + + source: 'prowlarr', + format: result.ebookFormat, + protocol: result.protocol, + })); +} diff --git a/src/app/api/requests/[id]/select-ebook/route.ts b/src/app/api/requests/[id]/select-ebook/route.ts new file mode 100644 index 0000000..7c38f22 --- /dev/null +++ b/src/app/api/requests/[id]/select-ebook/route.ts @@ -0,0 +1,258 @@ +/** + * Component: Select Ebook API + * Documentation: documentation/integrations/ebook-sidecar.md + * + * Creates an ebook request with a user-selected source (Anna's Archive or indexer) + * Routes to appropriate download processor based on source type + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth'; +import { prisma } from '@/lib/db'; +import { getJobQueueService } from '@/lib/services/job-queue.service'; +import { getConfigService } from '@/lib/services/config.service'; +import { RMABLogger } from '@/lib/utils/logger'; + +const logger = RMABLogger.create('API.SelectEbook'); + +interface SelectedEbook { + guid: string; + title: string; + size: number; + seeders: number; + indexer: string; + indexerId?: number; + downloadUrl: string; + infoUrl?: string; + score: number; + finalScore: number; + source: 'annas_archive' | 'prowlarr'; + format?: string; + md5?: string; + downloadUrls?: string[]; + protocol?: string; // 'torrent' or 'usenet' - determines download client +} + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + return requireAuth(request, async (req: AuthenticatedRequest) => { + return requireAdmin(req, async () => { + try { + const { id: parentRequestId } = await params; + const body = await request.json(); + const selectedEbook = body.ebook as SelectedEbook; + + if (!selectedEbook) { + return NextResponse.json({ error: 'No ebook selected' }, { status: 400 }); + } + + if (!selectedEbook.source) { + return NextResponse.json({ error: 'Ebook source not specified' }, { status: 400 }); + } + + // Get the parent audiobook request + const parentRequest = await prisma.request.findUnique({ + where: { id: parentRequestId }, + include: { audiobook: true }, + }); + + if (!parentRequest) { + return NextResponse.json({ error: 'Request not found' }, { status: 404 }); + } + + if (parentRequest.type !== 'audiobook') { + return NextResponse.json({ error: 'Can only select ebooks for audiobook requests' }, { status: 400 }); + } + + if (!['downloaded', 'available'].includes(parentRequest.status)) { + return NextResponse.json( + { error: `Cannot select ebook for request in ${parentRequest.status} status` }, + { status: 400 } + ); + } + + // Check for existing ebook request + let ebookRequest = await prisma.request.findFirst({ + where: { + parentRequestId, + type: 'ebook', + deletedAt: null, + }, + }); + + if (ebookRequest && !['failed', 'awaiting_search', 'pending'].includes(ebookRequest.status)) { + return NextResponse.json({ + error: `E-book request already exists (status: ${ebookRequest.status})`, + existingRequestId: ebookRequest.id, + }, { status: 400 }); + } + + // Create or update ebook request + if (ebookRequest) { + // Reset existing failed/pending request + ebookRequest = await prisma.request.update({ + where: { id: ebookRequest.id }, + data: { + status: 'searching', + progress: 0, + errorMessage: null, + updatedAt: new Date(), + }, + }); + logger.info(`Reusing existing ebook request ${ebookRequest.id}`); + } else { + // Create new ebook request + ebookRequest = await prisma.request.create({ + data: { + userId: parentRequest.userId, + audiobookId: parentRequest.audiobookId, + type: 'ebook', + parentRequestId, + status: 'searching', + progress: 0, + }, + }); + logger.info(`Created new ebook request ${ebookRequest.id}`); + } + + const audiobook = parentRequest.audiobook; + const jobQueue = getJobQueueService(); + + // Route to appropriate download based on source + if (selectedEbook.source === 'annas_archive') { + // Anna's Archive: Direct HTTP download + await handleAnnasArchiveDownload( + ebookRequest.id, + audiobook, + selectedEbook, + jobQueue + ); + } else { + // Indexer: Torrent/NZB download + await handleIndexerDownload( + ebookRequest.id, + audiobook, + selectedEbook, + jobQueue + ); + } + + return NextResponse.json({ + success: true, + message: `E-book download started from ${selectedEbook.source === 'annas_archive' ? "Anna's Archive" : selectedEbook.indexer}`, + requestId: ebookRequest.id, + }); + + } catch (error) { + logger.error('Unexpected error', { error: error instanceof Error ? error.message : String(error) }); + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Internal server error' }, + { status: 500 } + ); + } + }); + }); +} + +/** + * Handle Anna's Archive download (direct HTTP) + */ +async function handleAnnasArchiveDownload( + requestId: string, + audiobook: { id: string; title: string; author: string }, + selectedEbook: SelectedEbook, + jobQueue: ReturnType +) { + const configService = getConfigService(); + const preferredFormat = await configService.get('ebook_sidecar_preferred_format') || 'epub'; + + logger.info(`Starting Anna's Archive download for "${audiobook.title}"`); + logger.info(`MD5: ${selectedEbook.md5}, Format: ${selectedEbook.format || preferredFormat}`); + + // Create download history record + const downloadHistory = await prisma.downloadHistory.create({ + data: { + requestId, + indexerName: "Anna's Archive", + torrentName: `${audiobook.title} - ${audiobook.author}.${selectedEbook.format || preferredFormat}`, + torrentSizeBytes: null, // Unknown until download starts + qualityScore: selectedEbook.score, + selected: true, + downloadClient: 'direct', + downloadStatus: 'queued', + }, + }); + + // Store all download URLs for retry purposes + if (selectedEbook.downloadUrls && selectedEbook.downloadUrls.length > 0) { + await prisma.downloadHistory.update({ + where: { id: downloadHistory.id }, + data: { + torrentUrl: JSON.stringify(selectedEbook.downloadUrls), + }, + }); + } + + // Trigger direct download job + await jobQueue.addStartDirectDownloadJob( + requestId, + downloadHistory.id, + selectedEbook.downloadUrl, + `${audiobook.title} - ${audiobook.author}.${selectedEbook.format || preferredFormat}`, + undefined // Size unknown + ); + + logger.info(`Queued direct download job for request ${requestId}`); +} + +/** + * Handle indexer download (torrent/NZB) + */ +async function handleIndexerDownload( + requestId: string, + audiobook: { id: string; title: string; author: string }, + selectedEbook: SelectedEbook, + jobQueue: ReturnType +) { + logger.info(`Starting indexer download for "${audiobook.title}"`); + logger.info(`Torrent: "${selectedEbook.title}", Indexer: ${selectedEbook.indexer}`); + + // Convert to RankedTorrent shape expected by download job + // Note: format is omitted as ebook formats (epub, pdf) differ from audiobook formats (M4B, M4A, MP3) + const torrentForJob = { + guid: selectedEbook.guid, + title: selectedEbook.title, + size: selectedEbook.size, + seeders: selectedEbook.seeders || 0, + indexer: selectedEbook.indexer, + indexerId: selectedEbook.indexerId, + downloadUrl: selectedEbook.downloadUrl, + infoUrl: selectedEbook.infoUrl, + publishDate: new Date(), + score: selectedEbook.score, + finalScore: selectedEbook.finalScore, + bonusPoints: 0, + bonusModifiers: [], + rank: 1, + breakdown: { + formatScore: 0, + sizeScore: 0, + seederScore: 0, + matchScore: 0, + totalScore: selectedEbook.score, + notes: [], + }, + protocol: selectedEbook.protocol, // Pass through protocol for torrent vs usenet routing + }; + + // Use the download job (same as audiobooks) + await jobQueue.addDownloadJob(requestId, { + id: audiobook.id, + title: audiobook.title, + author: audiobook.author, + }, torrentForJob as any); // Cast to any since ebook torrents don't have audiobook format field + + logger.info(`Queued download job for request ${requestId}`); +} diff --git a/src/components/requests/InteractiveTorrentSearchModal.tsx b/src/components/requests/InteractiveTorrentSearchModal.tsx index ea69d5f..9ee5ff9 100644 --- a/src/components/requests/InteractiveTorrentSearchModal.tsx +++ b/src/components/requests/InteractiveTorrentSearchModal.tsx @@ -1,6 +1,10 @@ /** * Component: Interactive Torrent Search Modal * Documentation: documentation/phase3/prowlarr.md + * + * Supports two search modes: + * - audiobook: Search for audiobook torrents/NZBs (default) + * - ebook: Search for ebooks from Anna's Archive + indexers */ 'use client'; @@ -10,7 +14,14 @@ import { Modal } from '@/components/ui/Modal'; import { Button } from '@/components/ui/Button'; import { ConfirmModal } from '@/components/ui/ConfirmModal'; import { TorrentResult, RankedTorrent } from '@/lib/utils/ranking-algorithm'; -import { useInteractiveSearch, useSelectTorrent, useSearchTorrents, useRequestWithTorrent } from '@/lib/hooks/useRequests'; +import { + useInteractiveSearch, + useSelectTorrent, + useSearchTorrents, + useRequestWithTorrent, + useInteractiveSearchEbook, + useSelectEbook, +} from '@/lib/hooks/useRequests'; import { Audiobook } from '@/lib/hooks/useAudiobooks'; interface InteractiveTorrentSearchModalProps { @@ -23,6 +34,7 @@ interface InteractiveTorrentSearchModalProps { }; fullAudiobook?: Audiobook; // Optional - only provided when called from details modal onSuccess?: () => void; + searchMode?: 'audiobook' | 'ebook'; // Search mode - defaults to audiobook } export function InteractiveTorrentSearchModal({ @@ -32,8 +44,9 @@ export function InteractiveTorrentSearchModal({ audiobook, fullAudiobook, onSuccess, + searchMode = 'audiobook', }: InteractiveTorrentSearchModalProps) { - // Hooks for existing request flow + // Hooks for existing audiobook request flow const { searchTorrents: searchByRequestId, isLoading: isSearchingByRequest, error: searchByRequestError } = useInteractiveSearch(); const { selectTorrent, isLoading: isSelectingTorrent, error: selectTorrentError } = useSelectTorrent(); @@ -41,17 +54,30 @@ export function InteractiveTorrentSearchModal({ const { searchTorrents: searchByAudiobook, isLoading: isSearchingByAudiobook, error: searchByAudiobookError } = useSearchTorrents(); const { requestWithTorrent, isLoading: isRequestingWithTorrent, error: requestWithTorrentError } = useRequestWithTorrent(); - const [results, setResults] = useState<(RankedTorrent & { qualityScore?: number })[]>([]); + // Hooks for ebook flow + const { searchEbooks, isLoading: isSearchingEbooks, error: searchEbooksError } = useInteractiveSearchEbook(); + const { selectEbook, isLoading: isSelectingEbook, error: selectEbookError } = useSelectEbook(); + + const [results, setResults] = useState<(RankedTorrent & { qualityScore?: number; source?: string })[]>([]); const [confirmTorrent, setConfirmTorrent] = useState(null); const [searchTitle, setSearchTitle] = useState(audiobook.title); // Determine which mode we're in + const isEbookMode = searchMode === 'ebook'; const hasRequestId = !!requestId; - const isSearching = hasRequestId ? isSearchingByRequest : isSearchingByAudiobook; - const isDownloading = hasRequestId ? isSelectingTorrent : isRequestingWithTorrent; - const error = hasRequestId - ? (searchByRequestError || selectTorrentError) - : (searchByAudiobookError || requestWithTorrentError); + + // Loading/error state based on mode + const isSearching = isEbookMode + ? isSearchingEbooks + : (hasRequestId ? isSearchingByRequest : isSearchingByAudiobook); + const isDownloading = isEbookMode + ? isSelectingEbook + : (hasRequestId ? isSelectingTorrent : isRequestingWithTorrent); + const error = isEbookMode + ? (searchEbooksError || selectEbookError) + : (hasRequestId + ? (searchByRequestError || selectTorrentError) + : (searchByAudiobookError || requestWithTorrentError)); // Reset search title when modal opens/closes or audiobook changes React.useEffect(() => { @@ -72,12 +98,20 @@ export function InteractiveTorrentSearchModal({ try { let data; - if (hasRequestId) { - // Existing flow: search by requestId with optional custom title + if (isEbookMode) { + // Ebook mode: search Anna's Archive + indexers + if (!requestId) { + console.error('Ebook search requires a requestId'); + return; + } + const customTitle = searchTitle !== audiobook.title ? searchTitle : undefined; + data = await searchEbooks(requestId, customTitle); + } else if (hasRequestId) { + // Existing audiobook flow: search by requestId with optional custom title const customTitle = searchTitle !== audiobook.title ? searchTitle : undefined; data = await searchByRequestId(requestId, customTitle); } else { - // New flow: search by custom title + original author + optional ASIN for size scoring + // New audiobook flow: search by custom title + original author + optional ASIN for size scoring const asin = fullAudiobook?.asin; data = await searchByAudiobook(searchTitle, audiobook.author, asin); } @@ -102,11 +136,17 @@ export function InteractiveTorrentSearchModal({ if (!confirmTorrent) return; try { - if (hasRequestId) { - // Existing flow: select torrent for existing request + if (isEbookMode) { + // Ebook flow: select ebook for existing audiobook request + if (!requestId) { + throw new Error('Request ID required for ebook selection'); + } + await selectEbook(requestId, confirmTorrent); + } else if (hasRequestId) { + // Existing audiobook flow: select torrent for existing request await selectTorrent(requestId, confirmTorrent); } else { - // New flow: create request with torrent + // New audiobook flow: create request with torrent if (!fullAudiobook) { throw new Error('Audiobook data required to create request'); } @@ -120,7 +160,7 @@ export function InteractiveTorrentSearchModal({ // Request list will auto-refresh via SWR } catch (err) { // Error already handled by hook - console.error('Failed to download torrent:', err); + console.error('Failed to download:', err); setConfirmTorrent(null); } }; @@ -138,14 +178,26 @@ export function InteractiveTorrentSearchModal({ return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200'; }; + // UI text based on mode + const modalTitle = isEbookMode ? 'Select Ebook Source' : 'Select Torrent'; + const searchLabel = isEbookMode ? 'Search Title' : 'Search Title'; + const searchPlaceholder = isEbookMode ? 'Enter book title to search...' : 'Enter book title to search...'; + const loadingText = isEbookMode ? 'Searching for ebooks...' : 'Searching for torrents...'; + const noResultsText = isEbookMode ? 'No ebooks found' : 'No torrents/nzbs found'; + const resultCountText = (count: number) => + isEbookMode + ? `Found ${count} ebook${count !== 1 ? 's' : ''}` + : `Found ${count} torrent${count !== 1 ? 's' : ''}`; + const confirmTitle = isEbookMode ? 'Download Ebook' : 'Download Torrent'; + return ( <> - +
{/* Search customization - editable for ALL modes */}
setSearchTitle(e.target.value)} onKeyPress={handleSearchKeyPress} - placeholder="Enter book title to search..." + placeholder={searchPlaceholder} disabled={isSearching} className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 disabled:opacity-50" /> @@ -180,14 +232,14 @@ export function InteractiveTorrentSearchModal({ {isSearching && (
- Searching for torrents... + {loadingText}
)} {/* No results */} {!isSearching && results.length === 0 && (
-

No torrents/nzbs found

+

{noResultsText}

@@ -220,7 +272,7 @@ export function InteractiveTorrentSearchModal({ Seeds - Indexer + {isEbookMode ? 'Source' : 'Indexer'} Action @@ -246,21 +298,30 @@ export function InteractiveTorrentSearchModal({
+ {/* Anna's Archive badge for ebook mode */} + {isEbookMode && result.source === 'annas_archive' && ( + + Anna's Archive + + )} {result.format && ( - + {result.format} )} - {formatSize(result.size)} - - - {result.seeders} seeds + {result.size > 0 ? formatSize(result.size) : 'Unknown'} + {/* Hide seeds badge for Anna's Archive results */} + {!(isEbookMode && result.source === 'annas_archive') && ( + + {result.seeders} seeds + + )}
- {formatSize(result.size)} + {result.size > 0 ? formatSize(result.size) : '—'} @@ -271,15 +332,23 @@ export function InteractiveTorrentSearchModal({ {result.bonusPoints > 0 ? `+${Math.round(result.bonusPoints)}` : '—'} - - - - - {result.seeders} - + {isEbookMode && result.source === 'annas_archive' ? ( + N/A + ) : ( + + + + + {result.seeders} + + )} - {result.indexer} + {isEbookMode && result.source === 'annas_archive' ? ( + Anna's Archive + ) : ( + result.indexer + )} + + {/* Interactive Search Ebook Button */} + + + )} + + {/* Show ebook request status if one exists */} + {ebookStatus?.hasActiveEbookRequest && ( +
+ + + + + Ebook: {ebookStatus.existingEbookStatus === 'awaiting_approval' + ? 'Pending Approval' + : ebookStatus.existingEbookStatus === 'available' || ebookStatus.existingEbookStatus === 'downloaded' + ? 'Available' + : 'In Progress'} + +
+ )} + ); } @@ -542,7 +713,7 @@ export function AudiobookDetailsModal({ {showToast && (

- ✓ Request created successfully! + ✓ {toastMessage}

)} @@ -555,7 +726,7 @@ export function AudiobookDetailsModal({ return ( <> {createPortal(modalContent, document.body)} - {/* Interactive Search Modal - render with higher z-index to appear above details modal */} + {/* Interactive Search Modal (Audiobook) - render with higher z-index to appear above details modal */} {showInteractiveSearch && audiobook && createPortal(
@@ -573,6 +744,25 @@ export function AudiobookDetailsModal({
, document.body )} + {/* Interactive Search Modal (Ebook) - render with higher z-index to appear above details modal */} + {showInteractiveSearchEbook && audiobook && createPortal( +
+
+ +
+
, + document.body + )} ); } diff --git a/src/components/requests/InteractiveTorrentSearchModal.tsx b/src/components/requests/InteractiveTorrentSearchModal.tsx index 9ee5ff9..10814f7 100644 --- a/src/components/requests/InteractiveTorrentSearchModal.tsx +++ b/src/components/requests/InteractiveTorrentSearchModal.tsx @@ -21,6 +21,8 @@ import { useRequestWithTorrent, useInteractiveSearchEbook, useSelectEbook, + useInteractiveSearchEbookByAsin, + useSelectEbookByAsin, } from '@/lib/hooks/useRequests'; import { Audiobook } from '@/lib/hooks/useAudiobooks'; @@ -28,6 +30,7 @@ interface InteractiveTorrentSearchModalProps { isOpen: boolean; onClose: () => void; requestId?: string; // Optional - only provided when called from existing request + asin?: string; // Optional - ASIN for ebook mode when no request exists audiobook: { title: string; author: string; @@ -41,6 +44,7 @@ export function InteractiveTorrentSearchModal({ isOpen, onClose, requestId, + asin, audiobook, fullAudiobook, onSuccess, @@ -54,10 +58,14 @@ export function InteractiveTorrentSearchModal({ const { searchTorrents: searchByAudiobook, isLoading: isSearchingByAudiobook, error: searchByAudiobookError } = useSearchTorrents(); const { requestWithTorrent, isLoading: isRequestingWithTorrent, error: requestWithTorrentError } = useRequestWithTorrent(); - // Hooks for ebook flow + // Hooks for ebook flow (request ID-based - admin) const { searchEbooks, isLoading: isSearchingEbooks, error: searchEbooksError } = useInteractiveSearchEbook(); const { selectEbook, isLoading: isSelectingEbook, error: selectEbookError } = useSelectEbook(); + // Hooks for ebook flow (ASIN-based - user) + const { searchEbooks: searchEbooksByAsin, isLoading: isSearchingEbooksByAsin, error: searchEbooksByAsinError } = useInteractiveSearchEbookByAsin(); + const { selectEbook: selectEbookByAsin, isLoading: isSelectingEbookByAsin, error: selectEbookByAsinError } = useSelectEbookByAsin(); + const [results, setResults] = useState<(RankedTorrent & { qualityScore?: number; source?: string })[]>([]); const [confirmTorrent, setConfirmTorrent] = useState(null); const [searchTitle, setSearchTitle] = useState(audiobook.title); @@ -65,16 +73,18 @@ export function InteractiveTorrentSearchModal({ // Determine which mode we're in const isEbookMode = searchMode === 'ebook'; const hasRequestId = !!requestId; + const hasAsin = !!asin; + const useAsinMode = isEbookMode && hasAsin && !hasRequestId; // Loading/error state based on mode const isSearching = isEbookMode - ? isSearchingEbooks + ? (useAsinMode ? isSearchingEbooksByAsin : isSearchingEbooks) : (hasRequestId ? isSearchingByRequest : isSearchingByAudiobook); const isDownloading = isEbookMode - ? isSelectingEbook + ? (useAsinMode ? isSelectingEbookByAsin : isSelectingEbook) : (hasRequestId ? isSelectingTorrent : isRequestingWithTorrent); const error = isEbookMode - ? (searchEbooksError || selectEbookError) + ? (useAsinMode ? (searchEbooksByAsinError || selectEbookByAsinError) : (searchEbooksError || selectEbookError)) : (hasRequestId ? (searchByRequestError || selectTorrentError) : (searchByAudiobookError || requestWithTorrentError)); @@ -100,20 +110,25 @@ export function InteractiveTorrentSearchModal({ let data; if (isEbookMode) { // Ebook mode: search Anna's Archive + indexers - if (!requestId) { - console.error('Ebook search requires a requestId'); + const customTitle = searchTitle !== audiobook.title ? searchTitle : undefined; + if (useAsinMode && asin) { + // ASIN-based ebook search (user flow from details modal) + data = await searchEbooksByAsin(asin, customTitle); + } else if (requestId) { + // Request ID-based ebook search (admin flow) + data = await searchEbooks(requestId, customTitle); + } else { + console.error('Ebook search requires either requestId or asin'); return; } - const customTitle = searchTitle !== audiobook.title ? searchTitle : undefined; - data = await searchEbooks(requestId, customTitle); } else if (hasRequestId) { // Existing audiobook flow: search by requestId with optional custom title const customTitle = searchTitle !== audiobook.title ? searchTitle : undefined; data = await searchByRequestId(requestId, customTitle); } else { // New audiobook flow: search by custom title + original author + optional ASIN for size scoring - const asin = fullAudiobook?.asin; - data = await searchByAudiobook(searchTitle, audiobook.author, asin); + const audiobookAsin = fullAudiobook?.asin; + data = await searchByAudiobook(searchTitle, audiobook.author, audiobookAsin); } setResults(data || []); } catch (err) { @@ -137,11 +152,16 @@ export function InteractiveTorrentSearchModal({ try { if (isEbookMode) { - // Ebook flow: select ebook for existing audiobook request - if (!requestId) { - throw new Error('Request ID required for ebook selection'); + // Ebook flow + if (useAsinMode && asin) { + // ASIN-based ebook selection (user flow from details modal) + await selectEbookByAsin(asin, confirmTorrent); + } else if (requestId) { + // Request ID-based ebook selection (admin flow) + await selectEbook(requestId, confirmTorrent); + } else { + throw new Error('Request ID or ASIN required for ebook selection'); } - await selectEbook(requestId, confirmTorrent); } else if (hasRequestId) { // Existing audiobook flow: select torrent for existing request await selectTorrent(requestId, confirmTorrent); diff --git a/src/lib/hooks/useRequests.ts b/src/lib/hooks/useRequests.ts index c57a43e..0be4a8e 100644 --- a/src/lib/hooks/useRequests.ts +++ b/src/lib/hooks/useRequests.ts @@ -482,3 +482,162 @@ export function useSelectEbook() { return { selectEbook, isLoading, error }; } + +// ==================== ASIN-based Ebook Hooks ==================== +// These hooks are used for requesting ebooks from the audiobook details modal +// where we only have an ASIN, not an existing request ID + +export interface EbookStatus { + ebookSourcesEnabled: boolean; + hasActiveEbookRequest: boolean; + existingEbookStatus: string | null; + existingEbookRequestId: string | null; +} + +export function useEbookStatus(asin: string | null) { + const { accessToken } = useAuth(); + + const endpoint = accessToken && asin ? `/api/audiobooks/${asin}/ebook-status` : null; + + const { data, error, isLoading, mutate: revalidate } = useSWR( + endpoint, + fetcher, + { + refreshInterval: 10000, // Refresh every 10 seconds + } + ); + + return { + ebookStatus: data || null, + isLoading, + error, + revalidate, + }; +} + +export function useFetchEbookByAsin() { + const { accessToken } = useAuth(); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const fetchEbook = async (asin: string) => { + if (!accessToken) { + throw new Error('Not authenticated'); + } + + setIsLoading(true); + setError(null); + + try { + const response = await fetchWithAuth(`/api/audiobooks/${asin}/fetch-ebook`, { + method: 'POST', + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || data.message || 'Failed to request ebook'); + } + + // Revalidate requests and ebook status + mutate((key) => typeof key === 'string' && key.includes('/api/requests')); + mutate((key) => typeof key === 'string' && key.includes('/api/audiobooks')); + + return data; + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + setError(message); + throw err; + } finally { + setIsLoading(false); + } + }; + + return { fetchEbook, isLoading, error }; +} + +export function useInteractiveSearchEbookByAsin() { + const { accessToken } = useAuth(); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const searchEbooks = async (asin: string, customTitle?: string) => { + if (!accessToken) { + throw new Error('Not authenticated'); + } + + setIsLoading(true); + setError(null); + + try { + const response = await fetchWithAuth(`/api/audiobooks/${asin}/interactive-search-ebook`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: customTitle ? JSON.stringify({ customTitle }) : undefined, + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || data.message || 'Failed to search for ebooks'); + } + + return data.results || []; + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + setError(message); + throw err; + } finally { + setIsLoading(false); + } + }; + + return { searchEbooks, isLoading, error }; +} + +export function useSelectEbookByAsin() { + const { accessToken } = useAuth(); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const selectEbook = async (asin: string, ebook: any) => { + if (!accessToken) { + throw new Error('Not authenticated'); + } + + setIsLoading(true); + setError(null); + + try { + const response = await fetchWithAuth(`/api/audiobooks/${asin}/select-ebook`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ ebook }), + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || data.message || 'Failed to download ebook'); + } + + // Revalidate requests and ebook status + mutate((key) => typeof key === 'string' && key.includes('/api/requests')); + mutate((key) => typeof key === 'string' && key.includes('/api/audiobooks')); + + return data; + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + setError(message); + throw err; + } finally { + setIsLoading(false); + } + }; + + return { selectEbook, isLoading, error }; +} diff --git a/src/lib/utils/audiobook-matcher.ts b/src/lib/utils/audiobook-matcher.ts index 61ad68e..67ca135 100644 --- a/src/lib/utils/audiobook-matcher.ts +++ b/src/lib/utils/audiobook-matcher.ts @@ -168,7 +168,7 @@ export async function enrichAudiobooksWithMatches( // Always enrich with request status (check ANY user's requests) const asins = audiobooks.map(book => book.asin); - // Get all audiobook records for these ASINs with ALL requests + // Get all audiobook records for these ASINs with ALL audiobook requests (not ebook requests) const audiobookRecords = await prisma.audiobook.findMany({ where: { audibleAsin: { in: asins }, @@ -179,6 +179,7 @@ export async function enrichAudiobooksWithMatches( requests: { where: { deletedAt: null, // Only include active (non-deleted) requests + type: 'audiobook', // Only check audiobook requests, not ebook requests }, select: { id: true, From c0d2585f762882d11dce23a96a7e1d23a109522f Mon Sep 17 00:00:00 2001 From: kikootwo Date: Tue, 3 Feb 2026 03:09:13 -0500 Subject: [PATCH 7/7] Delete PlexMediaServerAPIDocs.json --- PlexMediaServerAPIDocs.json | 29848 ---------------------------------- 1 file changed, 29848 deletions(-) delete mode 100644 PlexMediaServerAPIDocs.json diff --git a/PlexMediaServerAPIDocs.json b/PlexMediaServerAPIDocs.json deleted file mode 100644 index 6675f42..0000000 --- a/PlexMediaServerAPIDocs.json +++ /dev/null @@ -1,29848 +0,0 @@ -{ - "openapi": "3.1.0", - "info": { - "title": "Plex Media Server", - "version": "1.1.1\n", - "license": { - "name": "Apache 2.0", - "url": "https://www.apache.org/licenses/LICENSE-2.0.html" - }, - "description": "# API Info\n## Content Types\nThe API supports responses in both XML and JSON, and clients can request one or the other using the standard `Accept` HTTP header. The default is XML, so JSON will only be returned if it's explicitly requested (`Accept: application/json`). New applications should use JSON.\n\nThroughout the docs, it's common for a examples to be given in JSON only since the JSON response would be preferred for new applications.\n\n## Headers\n\nPMS accept a variety of custom headers that follow the pattern `X-Plex-{name}`. The full set of headers isn't enumerated here since some may only apply to certain endpoints, but common headers that can be included on all requests include:\n\n| Header | Description | Sample |\n| --- | --- | --- |\n| X-Plex-Client-Identifier | An opaque identifier unique to the client | abc123 |\n| X-Plex-Token | An authentication token, obtained from plex.tv | XXXXXXXXXXXX |\n| X-Plex-Product | The name of the client product | Plex for Roku |\n| X-Plex-Version | The version of the client application | 2.4.1 |\n| X-Plex-Platform | The platform of the client | Roku |\n| X-Plex-Platform-Version | The version of the platform | 4.3 build 1057 |\n| X-Plex-Device | A relatively friendly name for the client device | Roku 3 |\n| X-Plex-Model | A potentially less friendly identifier for the device model | 4200X |\n| X-Plex-Device-Vendor | The device vendor | Roku |\n| X-Plex-Device-Name | A friendly name for the client | Living Room TV |\n| X-Plex-Marketplace | The marketplace on which the client application is distributed | googlePlay |\n\n`X-Plex-Client-Identifier` is typically required, as is `X-Plex-Token` for authentication.\n\nThere's no standard way to send non-ASCII values as HTTP headers. We attempt to recognize and parse UTF-8 and ISO-8859-1. If you're sending something that may include non-ASCII characters (often `X-Plex-Device-Name`), use UTF-8 if possible.\n\nThese are referred to as headers throughout documentation, but all `X-Plex-` headers can also be sent as query string arguments.\n\n## Auth\n\nMost endpoints require token based authentication, and the token is expected to be sent in the `X-Plex-Token` header. Tokens are obtained from plex.tv. See the Authenticating with Plex section.\n\n## Paths and Keys\n\nMany parts of the API reference things that can be fetched by their `key`. These keys follow a sort of relative URL resolution pattern. Some examples will help clarify.\n\n- For a request to `/library/sections` that includes an item with a `key` of `home` in the response, that item can be fetched at `/library/sections/home`.\n- For a request to `/library/sections/home` that includes an item with a `key` of `/library/metadata/deadbeef` in the response, that item can be fetched at `/library/metadata/deadbeef`.\n\nWe say this follows a \"sort of\" relative URL resolution pattern because all requests are treated as though they have a trailing slash.\n\n```\n/library/sections/ + home => /library/sections/home\n/library/sections + home => /library/sections/home\n/library/sections + /library/sections/home => /library/sections/home\n```\n\nJust like URL resolution, keys may contain absolute URLs as well, especially absolute `https://...` URLs or custom `view://...` URLs. In these cases the key resolved by simply using it, the parent is irrelevant.\n\nAlso note that the features described in this API can generally be present at a different paths. The `/media/providers` path defines where all features can be found. Note that a PMS can contain multiple providers which will be enumerated here. For simplicity, these docs use the most common, default paths. But when we say that `/library/sections/{id}` is part of the API, what we really mean is that a endpoint exists which is composed of the key for the `content` feature and the key for the library section.\n\nFinally, it's worth noting that many paths can potentially be discovered by walking API responses and fetching `key`s, but paths that aren't documented here aren't part of the API contract, they just happen to exist for a particular provider. For example, a particular content directory might include a directory with `key={baseLibraryPath}/genre`. That's not an official part of the API that's guaranteed to exist for every content directory, it's just a `key` that happened to exist within that content directory.\n\n## Types\n\nMany elements throughout the API have a `type` attribute. These types are meant to give helpful information, such as whether something is a movie library or a TV show library. Some API elements rely on a type number so both are provided below\n\n### List of Metadata Types\n\n| Type Name | Type Number |\n| -- | -- |\n| `movie` | 1 |\n| `show` | 2 |\n| `season` | 3 |\n| `episode` | 4 |\n| `trailer` | 5 |\n| `person` | 7 |\n| `artist` | 8 |\n| `album` | 9 |\n| `track` | 10 |\n| `clip` | 12 |\n| `photo` | 13 |\n| `photoalbum` | 14 |\n| `playlist` | 15 |\n| `playlistfolder` | 16 |\n| `collection` | 18 |\n\nWhen an element has both `type` and `key` attributes, the type describes what will be returned when fetching that key. Some types will return a list of other elements. That list may have a `Meta` element describing the specific types within the list. Consider the following examples:\n\n```json\n[\n {\n \"key\": \"/foo\",\n \"type\": \"movie\",\n \"title\": \"A Movie\"\n },\n {\n \"key\": \"/bar\",\n \"type\": \"collection\",\n \"title\": \"My Favorite Movies\"\n },\n {\n \"key\": \"/baz\",\n \"type\": \"show\",\n \"title\": \"A Show\"\n }\n]\n```\n\nIn each case, the `type` describes what will be returned when fetching the key. One exception is the `/children` key for parents like shows and seasons. It will return a list of children even though the `type` describes the parent.\n\nSome elements may also include an optional `subtype` attribute. The subtype is meant to be a refinement of the type, not a completely different type. One test is trying to explain the type in natural language. `type=\"clip\" subtype=\"news\"` passes the test that \"This is a clip, a news clip specifically.\" Another test is considering the client UI. A client should be functional if it ignores the subtype, and optimized if it respects it. If `type=\"track\" subtype=\"podcast\"`, a client can successfully play the podcast in an audio player based purely on the type, but it may tweak the display or which advanced playback controls are visible based on the subtype.\n\n### List of Metadata Subtypes\n\n- `podcast`\n- `webshow`\n- `news`\n- `photo`\n\n#### Collection Subtypes\n\n- `movie`\n- `show`\n- `artist`\n- `album`\n\n#### Extras Subtypes\n\n- `trailer`\n- `deletedScene`\n- `interview`\n- `musicVideo`\n- `behindTheScenes`\n- `sceneOrSample`\n- `liveMusicVideo`\n- `lyricMusicVideo`\n- `concert`\n- `featurette`\n- `short`\n- `other`\n\n## Sources\n\nSource URIs and attributes make it possible to uniquely reference content outside the local server context without requiring a fixed url. This might be desirable when showing related albums from a friend's shared media server, building a universal play queue, or returning aggregated hubs that span multiple providers. Source components are immutable and act as pointers to a single item or directory in the Plex ecosystem.\n\nA source URI from a media server uses the `server` scheme while a cloud provider uses the `provider` scheme.\n\n```\nserver://{SERVER_ID}/{PROVIDER_ID}/{PATH}\nprovider://{PROVIDER_ID}/{PATH}\n```\n\nAs a single regular expression, that's:\n\n```\n/^(server|provider):\\/\\/([a-fA-F0-9-]+)?\\/?([^/]+)([^\\?]+)\\??(.*)?/\n```\n\nThe server id is the server's `machineIdentifier`. The provider id is the provider's `identifier`. The rest of the path represents the path of the content at the provider and may include additional query parameters like `X-Plex-` headers or media query syntax for sorts and filters.\n\nSome examples may be helpful:\n\n```\nserver://546684a3d18ac5c39037360ec9ce900b7af9cc36/com.plexapp.plugins.library/library/metadata/2814936\nprovider://tv.plex.provider.podcasts/library/sections/audio/all\n```\n\nThe `source` attribute has the same structure as the source URI, but omits the path.\n\n```\n{SOURCE_TYPE}://{SOURCE_ID}/{PROVIDER_ID?}\n```\n```\n/^(server|provider):\\/\\/([a-fA-F0-9-]+)?\\/?([^/]+)$/\n```\n\n```\nsource=\"server://546684a3d18ac5c39037360ec9ce900b7af9cc36/com.plexapp.plugins.library\"\nsource=\"provider://tv.plex.provider.podcasts\"\n```\n\nSource attributes can be used as a base and combined with `key` or other root-relative path components to construct unique source URIs.\n\n## Pagination\n\nMany endpoints that return a list of items support pagination. Additionally some endpoints will force pagination and limit number of elements returned if the client attempts to request all items. To request a specific subset of data, add two headers to specify the starting offset and the number of desired items.\n\n- **X-Plex-Container-Start** - The desired starting offset\n- **X-Plex-Container-Size** - The desired number of items\n\nBoth headers should be sent in order to request paginated content. Note that it's possible to request a size of 0 on supported endpoints in order to learn the total size without actually getting any content.\n\nThe response **must** be checked to see if the response is in fact paginated. The response might not be paginated at all, or it might include a different number of items than what was requested. A paginated response will include the headers:\n\n- **X-Plex-Container-Start** - The offset of the first returned item\n- **X-Plex-Container-Total-Size** - The **total** size of the collection (optional but typically present)\n\nThe response body will also typically include pagination info. If the response is a `MediaContainer`, then it will have `offset` and `size` attributes representing the start index and the number of items in the current response along with an optional `totalSize` attribute for the total number of elements in the collection.\n\n```\nHTTP/1.1 200 OK\nX-Plex-Container-Start: 2\nX-Plex-Container-Total-Size: 5\nContent-Type: application/xml\n\n{\n \"MediaContainer\": {\n \"size\": 3,\n \"totalSize\": 5,\n \"offset\": 2,\n \"Metadata\" : [\n …\n ]\n }\n}\n```\n\nRather than requesting a page starting at an index, it is also possible in some lists to request a page centered on a specific item in the list.\n\n- **X-Plex-Container-Focus-Key** - The key of an item to center on\n- **X-Plex-Container-Size** - The desired number of items\n\nThe requested size is respected regardless of the position of the focus item in the list. If the item is at the start of the list and 10 items are requested, 9 items in the response will be after the item. If the item is in the middle of the list and 10 items are requested, 4 items will be before the item and 5 items will be after.\n\nEndpoints that support rich media queries also have a `limit` parameter that interacts with pagination. Sending `limit` in a query string limits the desired number of items, much like the `X-Plex-Container-Size` header. There are two major differences:\n\n1. When using `limit`, the total size of the collection is not returned. The minimum of the limit and the actual total size will be returned as the total size.\n2. The request may be more efficient when using `limit`, since the total size doesn't have to be known.\n\nIf the total size of the collection isn't needed, use `limit`, since the request may be more efficient.\n\nNote that `limit` and `X-Plex-Container-Size` aren't mutually exclusive. You can page within the results that are bounded by the limit. If you want a total of 1000 items from a collection of many thousands of items, but you want to page through them 20 at a time, you'd use `limit=1000&X-Plex-Container-Size=20&X-Plex-Container-Start=0`.\n\n## API Versioning\n\nPMS has never used API versioning before the creation of this document. The first published API is considered `1.0` with the API prior to publication considered `0.0`. A client species its version via the `X-Plex-Pms-Api-Version` header on requests. If no header is provided, the version `0.0` is assumed.\n\n### API Changes\n - 1.0.0 (Supported in PMS >= 1.41.9)\n - Added `/downloadQueue` endpoints.\n - Public release of API.\n - The `includeFields` parameter has been renamed to `includeOptionalFields`. The `includeFields` parameter now means \"include only these fields\" where in the past it meant \"please add these fields you wouldn't normally include.\" This was changed to be consistent with the cloud provider API.\n\n\n- 1.1.0 (Supported in PMS >= 1.42.0)\n - Added ability to filter '/media/providers/metadata' endpoint by metadata types (PM-3702)\n - Changed `types` in `/playlists/{playlistId}/items` to array of integers.\n - Document the `/photo/:/transcode` endpoints\n - Fixed serialization of MetadataType objects for '/media/providers/metadata' calls.\n\n\n- 1.1.1 (Supported in PMS >= 1.42.2)\n - Added 'metadataAgentProviderGroupId' query param to create and edit library section (PM-3577)\n - Fixed Add library section method type.\n\n## Response Customization\n\nMany endpoints allow the data that is included in the response to be tailored to exactly what the client wants. This is possible by either specifying things that should be excluded or the set of things that should be included. PMS's ability to include/exclude elements and fields is currently limited but expanding so this should be used with care.\n\nAttributes can be customized by using a query string arg of either `excludeFields` or `includeFields`. This single parameter should be a comma-separated list of attribute names. For example, a request with `excludeFields=summary,tagline` is asking for the summary and title attributes to be left off any metadata items while the `includeFields` parameter indicated that only the specified fields should be included.\n\nChild elements can be customized by using a query string arg of either `excludeElements` or `includeElements`. This single parameter should be a comma-separated list of element names. For example, a request with `excludeElements=Media` is asking for the `Media` elements to be omitted while the `includeElements` parameter indicated that only the specified elements should be included.\n\nIn addition to the above are the parameters `includeOptionalFields` and `includeOptionalElements`. These indicate that the fields/elements which are not normally included should be included in this request. One example is `includeOptionalElements=musicAnalysis` on metadata will include the `musicAnalysis` parameter which can be large and typically not needed by a client.\n\nTrimming the response to only include what a client will actually use can result in much better performance, especially in large collections. Increasingly these are being used to select which data is fetched from the database. So if a client knows it will only ever use a few parameters from a request, it should specify those with `includeFields`.\n\nNote that these inclusions/exclusions are treated as requests, not guarantees. Some endpoints will disregard them completely, and others may ignore them for specific items and insist on returning data that the client didn't necessarily ask for.\n\n## Media Providers\n\nMedia providers are general purpose entities which supply media to Plex clients. Their API describes the Plex Media Server API, via a set of features on the \"root\" endpoint of the provider. Media provider can be hosted by a media server or in the cloud, linked to a specific Plex account. This section explains media providers generally, and then provides the specific server-hosted APIs around media providers.\n\n### Client Guide to Media Providers\n\nThe philosophy behind media providers in general is to allow a common API between cloud servers and PMS, since the APIs are nearly identical to a normal PMS. The general guidelines are:\n- Consume `/media/providers` instead of `/library/sections`\n\n The new providers endpoint give you a list of all providers exported by a server and their features. Remember that the library itself is considered a (very rich) provider! This change will also require changing the client to not hardwire paths on the server, but rather read them from the feature keys directly (e.g. scrobble and rating endpoints).\n\n- Gate management functionality on the `manage` feature\n\n Server libraries allow management (e.g. media deletion). The correct way to gate this functionality is via the manage feature.\n\n- Make sure key construction is correct for things like genre lists\n\n For example, `/library/sections/x/genre` returns a relative key for each genre, but there's nothing which says that the `key` can't be an absolute URL. This is why servers pass back `fastKey` separately so as to not break clients which don't do key construction correctly. Media providers do not pass back `fastKey`, but assume clients will be doing correct key construction.\n\n- Don't call `/library/sections/X/filters|sorts`\n\n You can get all that information (and more) in a single call by hitting `/library/sections/X?includeDetails=1`. Media providers include the extra information by default.\n\n- Respect the Type keys in `/library/sections/x`\n\n The top-level type pivots have their own keys, which should be used over the old \"just append `/all` to the path and add the type\" approach. Not only is this more flexible, it also allows for \"virtual\" pivots, like music videos inside a music library.\n\n- Look for the `skipChildren`/`skipParent` attributes for shows\n\n Because of things like Podcasts, single-season shows can now be made to skip seasons. This is indicated by a `skipChildren` attribute on the show, or a `skipParent` attribute on an episode. If this is set on a show, the client should use `/grandchildren` instead of `/children` in the show's key.\n\n### Features\n\nThe list of supported features, along with the API endpoints each feature represents is shown in the following list. Note that each feature can define a custom endpoint URL, so it doesn't have to match the server API exactly.\n\n- **search**: This feature implies that it supports search via the provided key.\n\n- **metadata**: This feature implies that it supports metadata endpoint. For example, if the `key` were `/library/metadata` then the endpoints `/library/metadata/X`, `/library/metadata/X/children` and `/library/metadata/X/grandchildren` would be supported. This endpoint family allows browsing a hierarchical tree of media (e.g. show to episodes, or artist to tracks).\n\n- **content**: This feature implies that the provider exposes a content catalog, in the form of libraries to browse (grid of content), or discover (via hubs). Each entry in the content feature can contain:\n\n - `hubKey`: This implies it supports a discovery endpoint with hubs.\n - `key`: This implies it supports a content catalog.\n - `icon`: Optional, specifies the icon used for a content directory.\n\n Each content feature can contain one or both of these keys, depending on the structure. More details on the various combinations are provided below.\n\n- **match**: The match feature is used to match a piece of media to the provider's content catalog via a set of hints. As a specific example, you might pass in a title hint of \"Attack of the 50 Foot Woman\" and a year hint of 1958 for the movie type. The provider would then use all the hints to attempt to match to entries in its catalog.\n\n- **manage**: The manage feature implies a whole host of endpoints around _changing_ data inside a library (e.g. editing fields, customizing artwork, etc.). This feature is generally only available on an actual server and generally only to the admin.\n\n- **timeline**: The timeline feature implies that the provider wants to receive timeline (playback notifications) requests from a client at the endpoint defined by `key`. The feature may additionally specify the `scrobbleKey` and `unscrobbleKey` attributes, which represent the endpoints which allow marking a piece of media played or unplayed.\n\n- **rate**: This feature implies the provider supports the endpoint which allows rating content.\n\n- **playqueue**: This feature implies the provider supports the play queue family of endpoints. The `flavor` attribute further specifies the subset; the only supported flavor is currently `full`.\n\n- **playlist**: This feature implies the provider supports the playlist family of endpoints. If `readonly` is set, that means that the provider only allows listing and playing playlists (via play queue API), not actually creating or editing them.\n\n- **subscribe**: This provider allows media subscriptions to be created. If the flavor is `record` then media can be recorded from this library (such as DVR). If the flavor is `download` then the user is allowed to download from this library.\n\n- **promoted**: This feature allows the provider to supply an endpoint that will return a collection of \"promoted\" hubs that many clients show on a user's home screen.\n\n- **continuewatching**: This feature allows the provider to supply an endpoint that will return a hub for merging into a global Continue Watching hub.\n\n- **collection**: This feature implies the provider supports the collection family of endpoints.\n\n- **actions**\n - **removeFromContinueWatching** - Action to remove an item from continue watching\n\n- **imagetranscoder** - This feature implies the provider supports the image transcoder endpoints used to scale images for clients where memory and processor is at a premium\n\n- **queryParser** - This feature implies the provider supports the media queries language below\n\n- **grid** - This feature implies the provider supports displaying metadata in a grid over time (such as live TV)\n\n##### Home discovery and browsable libraries\n\nShown in the example in [/media/providers](#tag/Provider/operation/getMediaProviders), in this media provider the first content directory is an item with only `hubKey`, meaning it only providers discovery hubs. This is the set of hubs covering the whole library which contains continue watching, recently added, recommendations, etc. It's essentially \"landing page\" for the provider.\n\nThe subsequent directories also have a browse `key`, which means they provide a list view of the content with options for filtering and sorting. EPG providers may have only the `key` and no `hubKey`.\n\n##### Minimal provider\n\nThere's no requirement to provide the content feature, given that there are two other ways to access content within a provider: search and match. The former can contribute to global search, whereas the latter is used for things like the DVR engine; once media subscriptions are set up, they look for matching content using the match feature, and examined using the metadata feature.\n\n##### Deeper Hierarchies\n\nIf you examine an app like Spotify, you'll see many of the concepts here apply to their content hierarchy. Their content screens are either grids or hubs. But one notable difference is that the content hierarchy runs a bit deeper than the examples we've examined thus far. For example, one of the top-level selections is \"Genres & Moods\". Diving into one of the genres leads to a discovery area with different hubs for popular playlists, artists, and albums from the genre. Selecting a mood leads to a grid with popular playlists for the mood. In order to support this sort of hierarchy, we need an extension to the regular library, which is a *content directory*. This allows us to nest content, without losing any of the power and features—for example, the grid with popular playlists could list filters and sorts specific for that grid. This is power you simply don't have with the old channel architecture.\n\n##### Extensions to regular libraries\n\nThis section examines extensions to plain libraries which content providers can use, and which clients need to be aware of.\n\n- **Nested content directories**: In regular libraries, there are fixed types of directories (e.g. shows, or music albums). In content providers, we want to have the ability to display other types of things (e.g. stations, or moods, or genres) as first-class things in a grid or discovery layout. Here's an example of what a nested content directory looks like. Given the `type` of content, the client knows that this directory should be treated like a content directory feature entry.\n\n ```json\n {\n \"Directory\":[\n {\n \"key\":\"foo\",\n \"hubKey\":\"foo2\",\n \"type\":\"content\",\n \"aspectRatio\":\"1:1\",\n \"title\":\"Genres and Moods\"\n }\n ]\n }\n ```\n\n- **Aspect ratio hint**: Because the entities listed in content directories can be arbitrary, it's important to tell the client some information about how they should be displayed. The `thumb` attribute contains no information about aspect ratio, so clients make assumptions based upon known types (e.g. movies are 2:3, episode thumbs are 16:9, etc.). This attributes allows the provider to specify exactly the aspect ratio of the thing being displayed.\n\n## Media Queries\n\nMedia queries are a querystring-based filtering language used to select subsets of media. The language is rich, and can express complex expressions for media selection, as well as sorting and grouping.\n\n### Fields\n\nQueries reference fields, which can be of a few types:\n\n - *integer*: numbers\n - *boolean*: true/false\n - *tag*: integers representing tag IDs.\n - *string*: strings\n - *date*: epoch seconds\n - *language*: string in ISO639-2b format.\n\nThese fields are detailed in `Field` elements in the section description endpoint (e.g. `/library/sections/X?includeDetails=1`).\n\n### Operators\n\nGiven that media queries are expressible using querystrings, the operator syntax might look a bit quirky, because a) they have to include the `=` character, and b) characters to the left of the equal sign usually have to be URI encoded.\n\nOperators are defined per type:\n\n - *integer*: `=` (equals), `!=` (not equals), `>>=` (greater than), `<<=` (less than), `<=` (less than or equals), `>=` (greater than or equals)\n - *boolean*: `=0` (false) and `=1` (true)\n - *tag*: `=` (is) and `!=` (is not)\n - *string*: `=` (contains), `!=` (does not contain), `==` (equals), `!==` (does not equal), `<=` (begins with), `>=` (ends with)\n - *date*: `=` (equals), `!=` (not equals), `>>=` (after), `<<=` (before)\n - *language*: `=` (equals), `!=` (not equals)\n\n### Relative Values and Units\n\nFor some types, values can be specified as relative. For dates, epoch seconds can be specified as relative to “now” as follows: `+N` (in N seconds from now and `-N` (N seconds ago).\n\nIn addition, the following unit suffixes can be used on date values:\n\n - *m*: minutes\n - *h*: hours\n - *d*: days\n - *w*: weeks\n - *mon*: months\n - *y*: years\n\nFor example, `>>=-3y` means “within the last 3 years”.\n\n### Field Scoping\n\nSome media is organized hierarchically (e.g. shows), and in those cases, many fields are common to different elements in the hierarchy (e.g. show title vs episode title). The following rules are used to resolve field references.\n\n - A `type` parameter must be included to specify the result type.\n - Any non-qualified field is defaulted to refer to the result type.\n - In order to refer to other levels of the hierarchy, use the scoping operator, e.g. `show.title` or `episode.year`. A query may be comprised of multiple fields from different levels of the hierarchy.\n - the `sourceType` parameter may be used to change the default level to which fields refer. For example, `type=4&sourceType=2&title==24` means “all episodes where the show title is 24”.\n\n### Sorting\n\nThe `sort` parameter is used to indicate an ordering on results. Typically, the sort value is a field (including optional scoping). The `:` character is used to indicate additional features of the sort, and the `,` character is used to include multiple fields to the sort.\n\nFor example, `sort=title,index` means “sort first by title ascending, then by index”. Sort features are:\n\n - *desc*: indicates a descending sort.\n - *nullsLast*: indicates that null values are sorted last.\n\nSort features may be mixed and matched, e.g. `sort=title,index:desc`.\n\n### Grouping\n\nThe `group` parameter is used to group results by a field, similar to the SQL feature `group by`. For example, when listing popular tracks, we use the query `type=10&sort=ratingCount:desc&group=title`, because we don't want multiple tracks with the same name (e.g. same track on different albums) showing up.\n\n### Limits\n\nThe `limit` parameter is used to limit the number of results returned. Because it's implemented on top of the SQL `limit` operator, it currently only operates at the level of the type returned. In other words, `type=10&limit=100` will return at most 100 tracks, but you can't select tracks from a limit of 10 _albums_.\n\n### Boolean Operators\n\nGiven the nature of querystrings, it makes a lot of sense to interpret the `&` character as a boolean AND operator. For example `rating=10&index=5` means “rating is 10 AND index is 5”.\n\nWe leverage the `,` operator to signify the boolean OR operator. SO `rating=1,2,3` means “rating is 1 OR 2 OR 3. Given standard precedence rules, `rating=1,2,3&index=5` is parsed as `(rating = 1 or rating = 2 or rating = 3) and index = 5)`.\n\n### Complex Expressions\n\nThere's only so many expressions you can form using vanilla querystring-to-boolean mapping (essentially, “ANDs of ORs”). In order to fully represent complex boolean expressions, there are a few synthetic additions:\n\n - *push=1* and *pop=1*: These are the equivalent of opening and closing parenthesis.\n - *or=1*: These is an explicit OR operator.\n\nAs an example: `push=1&index=1&or=1&rating=2&pop=1&duration=10` parses into `(index = 1 OR rating = 2) AND duration = 10`. This could not be expressed by the simplified syntax above.\n\nHappy query building!\n\n## Profile Augmentations\n\nThe universal transcode endpoint supports the following header or query string parameter: ```X-Plex-Client-Profile-Extra```.\n\nThe value of this parameter is url-encoded. When url-decoded, it consists of a string expressed in the following (poor man's) BNF grammar:\n\n```\n ::= \"+\" *\n :: = \n ::= \"add-direct-play-profile\" | \"add-limitation\" | \"add-transcode-target-codec\" | \"append-transcode-target-codec\" | \"add-transcode-target\" | \"add-settings\"\n ::= \"(\" ( \"=\" ) \"&\")*\n ::= \n ::= \n```\n\n### add-direct-play-profile\nThis directive augments the set of Direct Play profiles in the client profile. The following parameters are required:\n\n- `type` = \"videoProfile\" | \"musicProfile\" | \"photoProfile\" | \"subtitleProfile\"\n- `container` = * or a comma-separated list of containers\n- `videoCodec` = * or a comma-separated list of video codecs\n- `audioCodec` = * or a comma-separated list of audio codecs\n- `subtitleCodec` = * or a comma-separated list of subtitle formats\n\n`*` means to use all existing matching values in the profile. At least one of the `videoCodec`, `audioCodec` and `subtitleCodec` parameters must not be `*`.\n\n\n#### add-direct-play-profile example\nTo add `ac3` as a video audio codec for mp4 and mov containers:\n\n```\nadd-direct-play-profile(type=videoProfile&container=mp4,mov&videoCodec=*&audioCodec=ac3&subtitleCodec=*)\n```\n\n### add-limitation\nThis directive adds a scoped limitation to the profile. The following parameters are required:\n\n- `scope` = \"videoContainer\" | \"musicContainer\" | \"photoContainer\" | \"videoCodec\" | \"videoAudioCodec\" | \"musicCodec\" | \"subtitleCodec\" | \"transcodeTarget\"\n- `scopeName` = the name of the relevant container or codec\n- `type` = \"match\" | \"notMatch\" | \"upperBound\" | \"lowerBound\"\n- `name` = the name of the limitation\n\nThe following parameters are optional:\n- `isRequired` = true|false (default is false)\n- `allStreams` = true|false (default is false)\n- `replace` = true|false (default is false)\n\nIf the `replace` parameter is true, the limitation will replace any similarly scoped limitations (i.e. identical `scope` and `scopeName`. If false, the new limitation will simply add itself to the list of limitations.\n\nExactly one of the following three parameters is required:\n- `value` = the value of the limitation\n- `substring` = the substring of the limitation\n- `regex` = the regex of the limitation\n\nThe `transcodeTarget` scope exists to attach a limitation to a transcode target. This allows clients to tell the MDE to select a specific transcode target for a context/protocol pair, based on specific information about the media itself. When multiple transcode targets match, the first one in the profile will be selected.\n\n\n#### add-limitation examples\nTo add a limitation on ac3 audio tracks in video media specifying a maximum of 6 channels:\n```\nadd-limitation(scope=videoAudioCodec&scopeName=ac3&type=upperBound&name=audio.channels&value=6)\n```\n\nTo add a limitation on ac3 audio tracks in video media specifying a maximum bitrate:\n```\nadd-limitation(scope=videoAudioCodec&scopeName=ac3&type=upperBound&name=audio.bitrate&value=160)\n```\n\nTo add a limitation on h264 video specifying a maximum level:\n```\nadd-limitation(scope=videoCodec&scopeName=h264&type=upperBound&name=video.level&value=40&isRequired=true)\n```\n\nTo add a limitation to a transcode target:\n```\nadd-limitation(scope=transcodeTarget&scopeName=MyTranscodeProfile&type=upperBound&name=audio.channels&value=2)\n```\n\n### add-transcode-target-codec\nThis directive adds additional codecs to the beginning of the audioCodec and/or subtitleCodec lists for the specified transcode target. The following parameters are required:\n\n- `type` = \"videoProfile\" | \"musicProfile\" | \"photoProfile\" | \"subtitleProfile\"\n\nEither `id` or `context` and `protocol` are required:\n\n- `id` = a transcode target identifier\n- `context` = a transcode context (\"streaming\" | \"static\")\n- `protocol` = a protocol (\"hls\" | \"http\" | \"slss\" ... )\n\nAt least one of the following parameters are also required:\n\n- `videoCodec` = a comma-separated list of videoCodecs, which are added to the set of video codecs on the target.\n- `audioCodec` = a comma-separated list of audioCodecs, which are added to the set of audio codecs on the target.\n- `subtitleCodec` = a comma-separated list of audioCodecs, which are added to the set of subtitle codecs on the target.\n\n#### add-transcode-target-codec example\nTo add `ac3` as an additional transcode target option to a HTTP Live Streaming target:\n\n```\nadd-transcode-target-codec(type=videoProfile&context=streaming&protocol=hls&audioCodec=ac3)\n```\n\n### append-transcode-target-codec\nThis directive appends additional codecs to the end of the audioCodec and/or subtitleCodec lists for the specified transcode target. The parameters are the same as for `add-transcode-target-codec`.\n\n```\nappend-transcode-target-codec(type=videoProfile&context=streaming&protocol=hls&audioCodec=dca)\n```\n\n### add-transcode-target\nThis directive adds a new transcode target. If a transcode target matching the type/context/profile already exists in the profile, then this directive is ignored. The following parameters are required:\n\n- `type` = \"videoProfile\" | \"musicProfile\" | \"photoProfile\" | \"subtitleProfile\"\n- `context` = a transcode context (\"streaming\" | \"static\")\n- `protocol` = a protocol (\"hls\" | \"http\" | \"slss\" ... )\n- `container` = a container\n\nThe following parameters are optional:\n\n- `id` = a transcode target identifier\n- `replace` = true|false (default is false)\n\nIf the `replace` parameter is true, the transcode target will replace any similarly scoped transcode target (i.e. identical `type`, `context` and `protocol`. If false, the augmentation will fail if there is an existing transcode target.\n\nThe following parameters are required, depending on the type:\n\n- `videoCodec` = a video codec (required for video) or a comma-separated list of video codecs\n- `audioCodec` = an audio codec (required for music and video) or a comma-separated list of audio codecs\n- `subtitleCodec` = an subtitle codec (required for subtitles and optional for video) or a comma-separated list of subtitle codecs\n\n#### add-transcode-target examples\n\n```\nadd-transcode-target(type=videoProfile&context=streaming&protocol=http&container=mkv&videoCodec=h264&audioCodec=aac,ac3&subtitleCodec=srt)\n```\n\n```\nadd-transcode-target(type=musicProfile&context=streaming&protocol=http&container=flac&audioCodec=flac)\n```\n\n```\nadd-transcode-target(type=subtitleProfile&context=all&protocol=http&container=webvtt&subtitleCodec=webvtt)\n```\n\n### add-settings\nThis directive overrides global settings for the profile. The parameters are name/value pairs matching existing client profile settings.\n\n```\nadd-settings(DirectPlayStreamSelection=false&RandomAccessDataModel=limited)\n```\n\n## Authenticating with Plex\n\nPlex supports two authentication methods:\n\n### JWT Authentication (Recommended)\n\nPlex now supports JSON Web Token (JWT) authentication that provides better security, shorter token lifespans, and improved protection against potential security breaches.\n\n#### Why JWT Authentication?\n\nThe new JWT system addresses security concerns by:\n- **Short-lived tokens**: Tokens expire after 7 days\n- **Public-key cryptography**: Uses modern cryptographic standards (ED25519) for enhanced security\n- **Better clock synchronization**: Built-in timestamp validation helps devices stay in sync\n\n#### How JWT Authentication Works\n\nThe new system uses a public-key authentication model where each device uploads a public key (JWK) and then requests short-lived JWT tokens. Here's the flow:\n\n**1. Device Key Registration**\nFirst, your device needs to register its public key with Plex.tv:\n\n```bash\nPOST https://clients.plex.tv/api/v2/auth/jwk\nHeaders:\n X-Plex-Client-Identifier: your-device-identifier\n X-Plex-Token: your-existing-token\n\nBody:\n{\n \"jwk\": {\n \"kty\": \"OKP\",\n \"crv\": \"Ed25519\",\n \"x\": \"your-public-key-data\",\n \"use\": \"sig\",\n \"alg\": \"EdDSA\"\n }\n}\n```\n\n**2. Token Refresh Process**\nOnce registered, your device can refresh its token every 7 days using this three-step process:\n\n**Step 1: Get a Nonce**\n```bash\nGET https://clients.plex.tv/api/v2/auth/nonce\nHeaders:\n X-Plex-Client-Identifier: your-device-identifier\n```\n\nThis returns a unique nonce valid for 5 minutes:\n```json\n{\n \"nonce\": \"7c415b56-8f48-488a-98ab-847ef4460442\"\n}\n```\n\n**Step 2: Create a Device JWT**\nYour device creates a JWT containing:\n- The nonce from step 1\n- Required scope permissions (see Scope Details below)\n- Audience set to `plex.tv`\n- Issuer set to your `client_identifier`\n- Signed with your device's private key\n\n**Scope Details:**\nThe scope field in your device JWT should contain comma-separated values for the user data you need included in the JWT:\n- `username` - Access to the user's username\n- `email` - Access to the user's email address\n- `friendly_name` - Access to the user's friendly name\n- `restricted` - Access to the user's restricted status\n- `anonymous` - Access to the user's anonymous status\n- `joinedAt` - Access to the user's account creation timestamp\n\n**Example Device JWT Payload:**\n```json\n{\n \"nonce\": \"7c415b56-8f48-488a-98ab-847ef4460442\",\n \"scope\": \"username,email,friendly_name\",\n \"aud\": \"plex.tv\",\n \"iss\": \"your-client-identifier\",\n \"iat\": 1705785603,\n \"exp\": 1705789203\n}\n```\n\n**Step 3: Exchange for Plex Token**\n```bash\nPOST https://clients.plex.tv/api/v2/auth/token\nHeaders:\n X-Plex-Client-Identifier: your-device-identifier\n\nBody:\n{\n \"jwt\": \"your-device-signed-jwt\"\n}\n```\n\nThis returns a new Plex.tv JWT valid for 7 days:\n```json\n{\n \"auth_token\": \"eyJraWQiOiJYeVRRN21seXFtVmhJcEo0U1pDZGltdXo3ZjdEYXU1Ym9MLXU2MG5JeEdJIiwidHlwIjoiSldUIiwiYWxnIjoiRWREU0EifQ...\"\n}\n```\n\n**Using Your JWT Token**\nOnce you have a JWT token, use it exactly like the old tokens in the `X-Plex-Token` header:\n\n```bash\nGET https://clients.plex.tv/api/v2/library/sections\nHeaders:\n X-Plex-Token: your-jwt-token\n```\n\n#### JWT Authentication Benefits\n\n**Security Features:**\n- **Token Rotation**: Automatic expiration every 7 days\n- **Individual Revocation**: Each device can be individually disabled\n- **Cryptographic Verification**: Uses industry-standard ED25519 signatures\n- **Nonce Protection**: Prevents replay attacks\n\n**Developer Experience:**\n- **Familiar Interface**: Same `X-Plex-Token` header usage\n- **Automatic Clock Sync**: Built-in timestamp validation\n- **Clear Error Codes**: Specific error responses for different failure modes\n- **Rate Limiting**: Built-in protection against abuse\n\n#### Error Handling\n\nThe JWT system provides clear error responses with specific HTTP status codes:\n- **498 Token Expired**: Your JWT has expired and needs refresh\n- **422 Signature Verification Failed**: Invalid device signature or JWT structure\n- **422 Thumbprint Already Taken**: JWK already registered by another device\n- **400 Bad Request**: Invalid request format or missing required fields\n- **429 Too Many Requests**: Rate limit exceeded (nonce requests are rate-limited)\n\n#### Migration Guide\n\n**For New Applications:**\n1. Generate an ED25519 key pair for your device\n2. Register your public key using `POST https://clients.plex.tv/api/v2/auth/jwk`\n3. Implement the token refresh flow\n4. Use the returned JWT in your `X-Plex-Token` header\n\n**For Existing Applications:**\n1. Continue using your current token for now\n2. Implement JWT authentication alongside existing auth\n3. Test the new system thoroughly\n4. Switch over when ready\n\n### Traditional Token Authentication (Legacy)\n\nYou're developing an app that needs access to a user's Plex account. To do this, you'll need to get access to the user's Access Token. This document details how to check whether an Access Token is valid, and how to obtain a new one.\n\n#### High-level Steps\n\n1. Choose a unique app name, like \"My Cool App\"\n2. Check storage for your app's Client Identifier; generate and store one if none is present.\n3. Check storage for the user's Access Token; if present, verify its validity and carry on.\n4. If an Access Token is missing or invalid, generate a PIN, and store its `id`.\n5. Construct an Auth App url and send the user's browser there to authenticate.\n6. After authentication, check the PIN's `id` to obtain and store the user's Access Token.\n\n#### Detailed Steps\n\n1. Choose a unique app name\n\n The app name you choose will be visible in the user's Authorized Devices view. The name you choose should be different from any existing Plex products.\n\n1. Generate a Client Identifier\n\n The Client Identifier identifies the specific instance of your app. A random string or UUID is sufficient here. There are no hard requirements for Client Identifier length or format, but once one is generated the client should store and re-use this identifier for subsequent requests.\n\n1. Verify stored Access Token validity\n\n You can check whether a user's stored Access Token is valid by requesting user info from the plex.tv API and examining the HTTP status code of the response.\n\n ```\n $ curl -X GET https://plex.tv/api/v2/user \\\n -H 'Accept: application/json' \\\n -H 'X-Plex-Product: My Cool App' \\\n -H 'X-Plex-Client-Identifier: ' \\\n -H 'X-Plex-Token: '\n ```\n\n | HTTP Status Code | |\n |-|-|\n | `200` | Access Token is valid |\n | `401` | Access Token is invalid |\n\n If an Access Token is invalid, it should be discarded, and new one should be obtained through the authentication process.\n\n If plex.tv cannot be reached, or if you receive any other status code it indicates an error state, but does not indicate an invalid Access Token.\n\n\n1. Generate a PIN\n\n To sign a user in, the app must create a time-limited PIN. The user is then led through a process to \"claim\" the PIN, associating it with their account and granting the app access to the user's plex.tv account.\n\n ```\n $ curl -X POST https://plex.tv/api/v2/pins?strong=true \\\n -H 'Accept: application/json' \\\n -H 'X-Plex-Product: My Cool App' \\\n -H 'X-Plex-Client-Identifier: '\n ```\n\n Note: the `strong=true` header provides a longer length pin which will have a longer lifetime. This is useful in cases where the user is not expected to type in the pin themselves. If not specified, a shorter pin is created but will have a much shorter lifetime.\n\n The response will be a JSON payload; the two important properties are `id` and `code`. Store the `id` locally, and use the `code` to construct the Auth App url.\n\n ```\n {\n \"id\": 564964751,\n \"code\": \"8lzjqnq8lye02n52jq3fqxf8e\",\n …\n }\n ```\n\n1. Checking the PIN\n\n There are two primary ways apps interact with the Auth App and the PIN-claiming process; **Forwarding** and **Polling**.\n\n **Forwarding** is used by web-based apps. A user visits your app in their web browser, leaves your app to authenticate with Plex, and returns to your app via a `forwardUrl` your app provides.\n\n **Polling** is used by native apps running outside of a web browser. A user indicates their intention to sign-in from within your app, and your app opens a web browser pointing to the Auth App where the user completes sign-in. Your app will periodically poll on the generated PIN until it is claimed, or it expires.\n\n1. Construct the Auth App url\n\n The user will authenticate with the plex.tv Auth App through their web browser.\n\n If you're using the **Forwarding** flow, the user will be returned to your app after authenticating where you'll be able to check the created PIN to determine the user's Access Token. The `forwardUrl` to which the user will be returned can carry the PIN `id` which needs to be checked on their return to the app.\n\n Auth App urls are encoded as parameters to the url fragment. Practically, this means that your Auth App url will be prefixed with `https://app.plex.tv/auth#?`; the `#?` at the end indicates the beginning of the url fragment, and that the content of the fragment afterwards is encoded as url parameter key-values pairs.\n\n Append these parameters to construct the final URL.\n\n | Parameter | |\n |----------------------------------|-----------------------------------------------------------------|\n | `clientID` | Your client identifier |\n | `code` | The `code` from the generated PIN |\n | `forwardUrl` | The url to which the user will be returned after authenticating. |\n | `context%5Bdevice%5D%5Bproduct%5D` | The name of your App; ex \"My Cool App\" |\n\n *Example*\n\n ```\n https://app.plex.tv/auth#?clientID=&code=&context%5Bdevice%5D%5Bproduct%5D=My%20Cool%20Plex%20App&forwardUrl=https%3A%2F%2Fmy-cool-plex-app.com\n ```\n\n You can use the [`qs`](https://www.npmjs.com/package/qs) module to encode all necessary parameters, including the nested `context` parameter.\n\n\n ```js\n const authAppUrl =\n 'https://app.plex.tv/auth#?' +\n require('qs').stringify({\n clientID: '',\n code: '',\n forwardUrl: 'https://my-cool-plex-app.com',\n context: {\n device: {\n product: 'My Cool App',\n },\n },\n });\n ```\n\n1. Send user's browser to constructed Auth App url\n\n Once the Auth App URL has been constructed, send the user's browser there to authenticate.\n\n1. Check PIN\n\n If you're using the **Polling** flow, your app should periodically (once per second) check the PIN `id` to determine when the user has signed-in.\n\n If you're using the **Forwarding** flow, check the stored PIN `id` from the PIN creation step. If the PIN has been claimed, the `authToken` field in the response will contain the user's Access Token you need to make API calls on behalf of the user. If authentication failed, the `authToken` field will remain `null`.\n\n ```\n $ curl -X GET 'https://plex.tv/api/v2/pins/' \\\n -H 'Accept: application/json' \\\n -H 'X-Plex-Client-Identifier: '\n ```\n### Talking to PMS\n\nOnce you have a token to talk to plex.tv, you will need to obtain a different set of tokens used to talk to PMS instances.\n\n```\n$ curl https://clients.plex.tv/api/v2/resources?includeHttps=1&includeRelay=1&includeIPv6=1 \\\n -H 'Accept: application/json' \\\n -H 'X-Plex-Product: My Cool App' \\\n -H 'X-Plex-Client-Identifier: ' \\\n -H 'X-Plex-Token: '\n```\n\nThe response will be a JSON document which will contain available PMS instances, the `accessToken` used in communication with this PMS, and the list of connection URLs where the PMS may be contacted. Connections labeled as `local` should be preferred over those that are not, and `relay` should only be used as a last resort as bandwidth on relay connections is limited.\n" - }, - "servers": [ - { - "url": "https://{IP-description}.{identifier}.plex.direct:{port}", - "variables": { - "IP-description": { - "default": "1-2-3-4", - "description": "A `-` separated string of the IPv4 or IPv6 address components" - }, - "identifier": { - "default": "0123456789abcdef0123456789abcdef", - "description": "The unique identifier of this particular PMS" - }, - "port": { - "default": "32400" - } - } - } - ], - "security": [ - { - "user_token": [ - "shared user", - "admin" - ] - } - ], - "components": { - "securitySchemes": { - "user_token": { - "type": "apiKey", - "in": "header", - "name": "X-Plex-Token", - "description": "The token which identifies the user accessing the PMS. This is typically provided to the client by plex.tv. This can be either a traditional access token or a JWT token obtained through the JWT authentication flow." - } - }, - "schemas": { - "MediaContainer": { - "type": "object", - "properties": { - "identifier": { - "type": "string" - }, - "size": { - "type": "integer" - }, - "totalSize": { - "type": "integer", - "description": "The total size of objects available. Also provided in the X-Plex-Container-Total-Size header" - }, - "offset": { - "type": "integer", - "description": "The offset of where this container page starts among the total objects available. Also provided in the X-Plex-Container-Start header" - } - } - }, - "serverConfiguration": { - "allOf": [ - { - "$ref": "#/components/schemas/MediaContainer" - }, - { - "type": "object", - "properties": { - "allowCameraUpload": { - "type": "boolean" - }, - "allowChannelAccess": { - "type": "boolean" - }, - "allowMediaDeletion": { - "type": "boolean" - }, - "allowSharing": { - "type": "boolean" - }, - "allowSync": { - "type": "boolean" - }, - "allowTuners": { - "type": "boolean" - }, - "backgroundProcessing": { - "type": "boolean" - }, - "certificate": { - "type": "boolean" - }, - "companionProxy": { - "type": "boolean" - }, - "countryCode": { - "type": "string" - }, - "diagnostics": { - "type": "string" - }, - "eventStream": { - "type": "boolean" - }, - "friendlyName": { - "type": "string" - }, - "hubSearch": { - "type": "boolean" - }, - "itemClusters": { - "type": "boolean" - }, - "livetv": { - "type": "integer", - "example": 7 - }, - "machineIdentifier": { - "example": "0123456789abcdef0123456789abcdef012345678" - }, - "mediaProviders": { - "type": "boolean" - }, - "multiuser": { - "type": "boolean" - }, - "musicAnalysis": { - "type": "integer", - "example": 2 - }, - "myPlex": { - "type": "boolean" - }, - "myPlexMappingState": { - "example": "mapped" - }, - "myPlexSigninState": { - "example": "ok" - }, - "myPlexSubscription": { - "type": "boolean" - }, - "myPlexUsername": { - "type": "string" - }, - "offlineTranscode": { - "example": 1 - }, - "ownerFeatures": { - "description": "A comma-separated list of features which are enabled for the server owner", - "type": "string" - }, - "platform": { - "type": "string" - }, - "platformVersion": { - "type": "string" - }, - "pluginHost": { - "type": "boolean" - }, - "pushNotifications": { - "type": "boolean" - }, - "readOnlyLibraries": { - "type": "boolean" - }, - "streamingBrainABRVersion": { - "type": "integer" - }, - "streamingBrainVersion": { - "type": "integer" - }, - "sync": { - "type": "boolean" - }, - "transcoderActiveVideoSessions": { - "type": "integer" - }, - "transcoderAudio": { - "type": "boolean" - }, - "transcoderLyrics": { - "type": "boolean" - }, - "transcoderPhoto": { - "type": "boolean" - }, - "transcoderSubtitles": { - "type": "boolean" - }, - "transcoderVideo": { - "type": "boolean" - }, - "transcoderVideoBitrates": { - "description": "The suggested video quality bitrates to present to the user" - }, - "transcoderVideoQualities": { - "type": "string" - }, - "transcoderVideoResolutions": { - "description": "The suggested video resolutions to the above quality bitrates" - }, - "updatedAt": { - "type": "integer" - }, - "updater": { - "type": "boolean" - }, - "version": { - "type": "string" - }, - "voiceSearch": { - "type": "boolean" - } - } - } - ] - }, - "mediaContainerWithSettings": { - "type": "object", - "properties": { - "MediaContainer": { - "allOf": [ - { - "$ref": "#/components/schemas/MediaContainer" - }, - { - "type": "object", - "properties": { - "Setting": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "label": { - "type": "string", - "description": "A user-friendly name for the preference" - }, - "summary": { - "type": "string", - "description": "A description of the preference" - }, - "type": { - "type": "string", - "enum": [ - "bool", - "int", - "text", - "double" - ], - "description": "The type of the value of this pref" - }, - "default": { - "oneOf": [ - { - "type": "string" - }, - { - "type": "number" - }, - { - "type": "boolean" - } - ], - "description": "The default value of this pref" - }, - "value": { - "oneOf": [ - { - "type": "string" - }, - { - "type": "number" - }, - { - "type": "boolean" - } - ], - "description": "The current value of this pref" - }, - "hidden": { - "type": "boolean", - "description": "Whether the pref is hidden or not" - }, - "advanced": { - "type": "boolean", - "description": "Whether the pref is considered advanced and normally hidden from the user" - }, - "group": { - "type": "string", - "description": "The group name of this pref to aid in display of a hierarchy." - }, - "enumValues": { - "type": "string", - "description": "The possible values for this pref if restricted. The list is `|` separated with `value:name` entries." - } - } - } - } - } - } - ] - } - } - }, - "stream": { - "description": "`Stream` represents a particular stream from a media item, such as the video stream, audio stream, or subtitle stream. The stream may either be part of the file represented by the parent `Part` or, especially for subtitles, an external file. The stream contains more detailed information about the specific stream. For example, a video may include the `aspectRatio` at the `Media` level, but detailed information about the video stream like the color space will be included on the `Stream` for the video stream. Note that photos do not have streams (mostly as an optimization).\n", - "type": "object", - "properties": { - "audioChannelLayout": { - "example": "stereo" - }, - "bitDepth": { - "type": "integer", - "example": 8 - }, - "bitrate": { - "type": "integer", - "example": 5466 - }, - "canAutoSync": { - "type": "boolean", - "description": "For subtitle streams only. If `true` then the server can attempt to automatically sync the subtitle timestamps with the video.", - "example": true - }, - "chromaLocation": { - "example": "topleft" - }, - "chromaSubsampling": { - "example": "4:2:0" - }, - "codec": { - "description": "The codec of the stream, such as `h264` or `aac`", - "example": "h264" - }, - "colorPrimaries": { - "example": "bt709" - }, - "colorRange": { - "example": "tv" - }, - "colorSpace": { - "example": "bt709" - }, - "colorTrc": { - "example": "bt709" - }, - "default": { - "type": "boolean", - "example": true - }, - "displayTitle": { - "description": "A friendly name for the stream, often comprised of the language and codec information", - "example": "English (H.264 Main)" - }, - "frameRate": { - "type": "number", - "example": 23.976 - }, - "hasScalingMatrix": { - "example": false - }, - "height": { - "type": "integer", - "example": 544 - }, - "id": { - "type": "integer", - "example": 1 - }, - "index": { - "type": "integer", - "description": "If the stream is part of the `Part` and not an external resource, the index of the stream within that part", - "example": 0 - }, - "key": { - "description": "If the stream is independently streamable, the key from which it can be streamed", - "example": "/library/streams/1" - }, - "language": { - "example": "English" - }, - "languageCode": { - "description": "The three character language code for the stream contents", - "example": "eng" - }, - "level": { - "type": "integer", - "example": 31 - }, - "profile": { - "example": "main" - }, - "refFrames": { - "type": "integer", - "example": 2 - }, - "samplingRate": { - "type": "integer", - "example": 48000 - }, - "selected": { - "type": "boolean" - }, - "streamIdentifier": { - "type": "integer", - "example": 1 - }, - "streamType": { - "type": "integer", - "description": "A number indicating the type of the stream. `1` for video, `2` for audio, `3` for subtitles, `4` for lyrics", - "example": 1 - }, - "width": { - "type": "integer", - "example": 1280 - } - }, - "additionalProperties": true - }, - "part": { - "description": "`Part` represents a particular file or \"part\" of a media item. The part is the playable unit of the media hierarchy. Suppose that a movie library contains a movie that is broken up into files, reminiscent of a movie split across two BDs. The metadata item represents information about the movie, the media item represents this instance of the movie at this resolution and quality, and the part items represent the two playable files. If another media were added which contained the joining of these two parts transcoded down to a lower resolution, then this metadata would contain 2 medias, one with 2 parts and one with 1 part.\n", - "type": "object", - "properties": { - "audioProfile": { - "example": "lc" - }, - "container": { - "description": "The container of the media file, such as `mp4` or `mkv`", - "example": "mov" - }, - "duration": { - "type": "integer", - "description": "The duration of the media item, in milliseconds", - "example": 150192 - }, - "file": { - "description": "The local file path at which the part is stored on the server", - "example": "/home/schuyler/Videos/Trailers/Cloud Atlas (2012).mov" - }, - "has64bitOffsets": { - "type": "boolean", - "example": false - }, - "id": { - "type": "integer", - "example": 1 - }, - "key": { - "description": "The key from which the media can be streamed", - "example": "/library/parts/1/1531779263/file.mov" - }, - "optimizedForStreaming": { - "type": "boolean", - "example": false - }, - "size": { - "type": "integer", - "description": "The size of the media, in bytes", - "example": 105355654 - }, - "videoProfile": { - "example": "main" - }, - "Stream": { - "type": "array", - "items": { - "$ref": "#/components/schemas/stream" - } - } - }, - "additionalProperties": true - }, - "media": { - "description": "`Media` represents an one or more media files (parts) and is a child of a metadata item. There aren't necessarily any guaranteed attributes on media elements since the attributes will vary based on the type. The possible attributes are not documented here, but they typically have self-evident names. High-level media information that can be used for badging and flagging, such as `videoResolution` and codecs, is included on the media element.\n", - "type": "object", - "properties": { - "aspectRatio": { - "type": "number", - "example": 2.35 - }, - "audioChannels": { - "type": "integer", - "example": 2 - }, - "audioCodec": { - "example": "aac" - }, - "audioProfile": { - "example": "lc" - }, - "bitrate": { - "type": "integer", - "example": 5612 - }, - "container": { - "example": "mov" - }, - "duration": { - "type": "integer", - "example": 150192 - }, - "has64bitOffsets": { - "type": "boolean", - "example": false - }, - "hasVoiceActivity": { - "type": "boolean", - "example": true - }, - "height": { - "type": "integer", - "example": 544 - }, - "id": { - "type": "integer", - "example": 1 - }, - "optimizedForStreaming": { - "type": "boolean", - "example": false - }, - "videoCodec": { - "example": "h264" - }, - "videoFrameRate": { - "example": "24p" - }, - "videoProfile": { - "example": "main" - }, - "videoResolution": { - "example": "720" - }, - "width": { - "type": "integer", - "example": 1280 - }, - "Part": { - "type": "array", - "items": { - "$ref": "#/components/schemas/part" - } - } - }, - "additionalProperties": true - }, - "image": { - "description": "Images such as movie posters and background artwork are represented by Image elements.\n", - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "background", - "banner", - "clearLogo", - "coverPoster", - "snapshot" - ], - "description": "Describes both the purpose and intended presentation of the image." - }, - "url": { - "type": "string", - "description": "The relative path or absolute url for the image." - }, - "alt": { - "type": "string", - "description": "Title to use for accessibility." - } - } - }, - "tag": { - "description": "A variety of extra information about a metadata item is included as tags. These tags use their own element names such as `Genre`, `Writer`, `Directory`, and `Role`. Individual tag types may introduce their own extra attributes.\n", - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "tag": { - "description": "The value of the tag (the name)", - "example": "Shaun Lawton" - }, - "tagKey": { - "description": "Plex identifier for this tag which can be used to fetch additional information from plex.tv", - "example": "5d3ee12c4cde6a001c3e0b27" - }, - "tagType": { - "type": "integer" - }, - "filter": { - "description": "A filter parameter that can be used to query for more content that matches this tag value.", - "example": "actor=49" - }, - "role": { - "description": "The role this actor played", - "example": "Secretary" - }, - "thumb": { - "example": "http://image.tmdb.org/t/p/original/lcJ8qM51ClAR2UzXU1mkZGfnn3o.jpg" - }, - "context": { - "type": "string" - }, - "ratingKey": { - "type": "string" - }, - "confidence": { - "type": "number", - "description": "Measure of the confidence of an automatic tag" - } - } - }, - "directory": { - "type": "object", - "properties": { - "hubKey": { - "type": "string" - }, - "key": { - "type": "string" - }, - "title": { - "type": "string" - }, - "thumb": { - "type": "string" - }, - "art": { - "type": "string" - }, - "share": { - "type": "integer" - }, - "hasStoreServices": { - "type": "boolean" - }, - "hasPrefs": { - "type": "boolean" - }, - "identifier": { - "type": "string" - }, - "titleBar": { - "type": "string" - }, - "lastAccessedAt": { - "type": "integer" - }, - "type": { - "type": "string" - }, - "content": { - "type": "boolean" - }, - "filter": { - "type": "string" - }, - "Pivot": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "key": { - "type": "string" - }, - "type": { - "type": "string" - }, - "title": { - "type": "string" - }, - "context": { - "type": "string" - }, - "symbol": { - "type": "string" - } - } - } - } - }, - "additionalProperties": true - }, - "filter": { - "allOf": [ - { - "$ref": "#/components/schemas/directory" - }, - { - "type": "object", - "description": "Each `Filter` object contains a description of the filter. Note that it is not an exhaustive list of the full media query language, but an important subset useful for top-level API.\n", - "properties": { - "filter": { - "type": "string", - "description": "This represents the filter name used for the filter, which can be used to construct complex media queries with." - }, - "filterType": { - "type": "string", - "description": "This is either `string`, `integer`, or `boolean`, and describes the type of values used for the filter." - }, - "key": { - "type": "string", - "description": "This provides the endpoint where the possible range of values for the filter can be retrieved (e.g. for a \"Genre\" filter, it returns a list of all the genres in the library). This will include a `type` argument that matches the metadata type of the Type element." - }, - "title": { - "type": "string", - "description": "The title for the filter." - } - } - } - ] - }, - "sort": { - "allOf": [ - { - "$ref": "#/components/schemas/directory" - }, - { - "type": "object", - "description": "Each `Sort` object contains a description of the sort field.\n", - "properties": { - "defaultDirection": { - "type": "string", - "enum": [ - "asc", - "desc" - ], - "description": "This default diction of this sort" - }, - "default": { - "type": "string", - "enum": [ - "asc", - "desc" - ], - "description": "If present, this sort is the default and in this direction" - }, - "key": { - "type": "string", - "description": "The key to use in the sort field to make items sort by this item" - }, - "descKey": { - "type": "string", - "description": "The key for sorting this field in reverse order" - }, - "title": { - "type": "string", - "description": "The title of the field." - }, - "firstCharacterKey": { - "type": "string", - "description": "The key to use to get items sorted by this field and indexed by the first character" - } - } - } - ] - }, - "metadata": { - "description": "Items in a library are referred to as \"metadata items.\" These metadata items are distinct from \"media items\" which represent actual instances of media that can be consumed. Consider a TV library that has a single video file in it for a particular episode of a show. The library has a single media item, but it has three metadata items: one for the show, one for the season, and one for the episode. Consider a movie library that has two video files in it: the same movie, but two different resolutions. The library has a single metadata item for the movie, but that metadata item has two media items, one for each resolution. Additionally a \"media item\" will have one or more \"media parts\" where the the parts are intended to be watched together, such as a CD1 and CD2 parts of the same movie.\n\nNote that when a metadata item has multiple media items, those media items should be isomorphic. That is, a 4K version and 1080p version of a movie are different versions of the same movie. They have the same duration, same summary, same rating, etc. and they can generally be considered interchangeable. A theatrical release vs. director's cut vs. unrated version on the other hand would be separate metadata items.\n\nMetadata items can often live in a hierarchy with relationships between them. For example, the metadata item for an episodes is associated with a season metadata item which is associated with a show metadata item. A similar hierarchy exists with track, album, and artist and photos and photo album. The relationships may be expressed via relative terms and absolute terms. For example, \"leaves\" refer to metadata items which has associated media (there is no media for a season nor show). A show will have \"children\" in the form of seasons and a season will have \"children\" in the form of episodes and episodes have \"parent\" in the form of a season which has a \"parent\" in the form of a show. Similarly, a show has \"grandchildren\" in the form of episodse and an episode has a \"grandparent\" in the form of a show.\n", - "type": "object", - "properties": { - "type": { - "description": "The type of the video item, such as `movie`, `episode`, or `clip`." - }, - "subtype": { - "description": "The subtype of the video item, such as `photo` when the video item is in a photo library" - }, - "key": { - "description": "The key at which the item's details can be fetched. In many cases a metadata item may be passed without all the details (such as in a hub) and this key corresponds to the endpoint to fetch additional details." - }, - "ratingKey": { - "description": "This is the opaque string to be passed into timeline, scrobble, and rating endpoints to identify them. While it often appears to be numeric, this is not guaranteed." - }, - "title": { - "description": "The title of the item (e.g. “300” or “The Simpsons”)" - }, - "titleSort": { - "description": "Whene present, this is the string used for sorting the item. It's usually the title with any leading articles removed (e.g. “Simpsons”)." - }, - "originalTitle": { - "description": "When present, used to indicate an item's original title, e.g. a movie's foreign title." - }, - "year": { - "type": "integer", - "description": "When present, the year associated with the item's release (e.g. release year for a movie)." - }, - "index": { - "type": "integer", - "description": "When present, this represents the episode number for episodes, season number for seasons, or track number for audio tracks." - }, - "absoluteIndex": { - "type": "integer", - "description": "When present, contains the disc number for a track on multi-disc albums." - }, - "originallyAvailableAt": { - "description": "When present, in the format YYYY-MM-DD [HH:MM:SS] (the hours/minutes/seconds part is not always present). The air date, or a higher resolution release date for an item, depending on type. For example, episodes usually have air date like 1979-08-10 (we don't use epoch seconds because media existed prior to 1970). In some cases, recorded over-the-air content has higher resolution air date which includes a time component. Albums and movies may have day-resolution release dates as well." - }, - "duration": { - "type": "integer", - "description": "When present, the duration for the item, in units of milliseconds." - }, - "summary": { - "description": "When present, the extended textual information about the item (e.g. movie plot, artist biography, album review)." - }, - "tagline": { - "description": "When present, a pithy one-liner about the item (usually only seen for movies)." - }, - "thumb": { - "description": "When present, the URL for the poster or thumbnail for the item. When available for types like movie, it will be the poster graphic, but fall-back to the extracted media thumbnail." - }, - "art": { - "description": "When present, the URL for the background artwork for the item." - }, - "banner": { - "description": "When present, the URL for a banner graphic for the item." - }, - "hero": { - "description": "When present, the URL for a hero image for the item." - }, - "theme": { - "description": "When present, the URL for theme music for the item (usually only for TV shows)." - }, - "composite": { - "description": "When present, the URL for a composite image for descendent items (e.g. photo albums or playlists)." - }, - "studio": { - "description": "When present, the studio or label which produced an item (e.g. movie studio for movies, record label for albums)." - }, - "contentRating": { - "description": "If known, the content rating (e.g. MPAA) for an item." - }, - "rating": { - "type": "number", - "minimum": 0, - "maximum": 10, - "description": "When present, the rating for the item. The exact meaning and representation depends on where the rating was sourced from." - }, - "ratingImage": { - "description": "When present, indicates an image to be shown with the rating. This is passed back as a small set of defined URI values, e.g. rottentomatoes://image.rating.rotten." - }, - "audienceRating": { - "type": "number", - "minimum": 0, - "maximum": 10, - "description": "Some rating systems separate reviewer ratings from audience ratings" - }, - "audienceRatingImage": { - "description": "A URI representing the image to be shown with the audience rating (e.g. rottentomatoes://image.rating.spilled)." - }, - "userRating": { - "type": "number", - "minimum": 0, - "maximum": 10, - "description": "When the user has rated an item, this contains the user rating" - }, - "viewOffset": { - "type": "integer", - "description": "When a user is in the process of viewing or listening to this item, this attribute contains the current offset, in units of milliseconds." - }, - "viewCount": { - "type": "integer", - "description": "When a users has completed watched or listened to an item, this attribute contains the number of consumptions." - }, - "lastViewedAt": { - "type": "integer", - "description": "When a user has watched or listened to an item, this contains a timestamp (epoch seconds) for that last consumption time." - }, - "addedAt": { - "type": "integer", - "description": "In units of seconds since the epoch, returns the time at which the item was added to the library." - }, - "updatedAt": { - "type": "integer", - "description": "In units of seconds since the epoch, returns the time at which the item was last changed (e.g. had its metadata updated)." - }, - "chapterSource": { - "description": "When present, indicates the source for the chapters in the media file. Can be media (the chapters were embedded in the media itself), agent (a metadata agent computed them), or mixed (a combination of the two)." - }, - "primaryExtraKey": { - "description": "Indicates that the item has a primary extra; for a movie, this is a trailer, and for a music track it is a music video. The URL points to the metadata details endpoint for the item." - }, - "skipChildren": { - "type": "boolean", - "description": "When found on a show item, indicates that the children (seasons) should be skipped in favor of the grandchildren (episodes). Useful for mini-series, etc." - }, - "skipParent": { - "type": "boolean", - "description": "When present on an episode or track item, indicates parent should be skipped in favor of grandparent (show)." - }, - "leafCount": { - "type": "integer", - "description": "For shows and seasons, contains the number of total episodes." - }, - "viewedLeafCount": { - "type": "integer", - "description": "For shows and seasons, contains the number of viewed episodes." - }, - "parentKey": { - "type": "string", - "description": "The `key` of the parent" - }, - "grandparentKey": { - "type": "string", - "description": "The `key` of the grandparent" - }, - "parentRatingKey": { - "type": "string", - "description": "The `ratingKey` of the parent" - }, - "grandparentRatingKey": { - "type": "string", - "description": "The `ratingKey` of the grandparent" - }, - "parentThumb": { - "type": "string", - "description": "The `thumb` of the parent" - }, - "grandparentThumb": { - "type": "string", - "description": "The `thumb` of the grandparent" - }, - "grandparentArt": { - "type": "string", - "description": "The `art` of the grandparent" - }, - "parentHero": { - "type": "string", - "description": "The `hero` of the parent" - }, - "grandparentHero": { - "type": "string", - "description": "The `hero` of the grandparent" - }, - "grandparentTheme": { - "type": "string", - "description": "The `theme` of the grandparent" - }, - "parentTitle": { - "type": "string", - "description": "The `title` of the parent" - }, - "grandparentTitle": { - "type": "string", - "description": "The `title` of the grandparent" - }, - "parentIndex": { - "type": "integer", - "description": "The `index` of the parent" - }, - "secondary": { - "type": "boolean", - "description": "Used by old clients to provide nested menus allowing for primative (but structured) navigation." - }, - "prompt": { - "type": "string", - "description": "Prompt to give the user for this directory (such as `Search Movies`)" - }, - "search": { - "type": "boolean", - "description": "Indicates this is a search directory" - }, - "ratingCount": { - "type": "integer", - "description": "Number of ratings under this metadata" - }, - "Media": { - "type": "array", - "items": { - "$ref": "#/components/schemas/media" - } - }, - "Image": { - "type": "array", - "items": { - "$ref": "#/components/schemas/image" - } - }, - "Genre": { - "type": "array", - "items": { - "$ref": "#/components/schemas/tag" - } - }, - "Country": { - "type": "array", - "items": { - "$ref": "#/components/schemas/tag" - } - }, - "Guid": { - "type": "array", - "items": { - "$ref": "#/components/schemas/tag" - } - }, - "Rating": { - "type": "array", - "items": { - "$ref": "#/components/schemas/tag" - } - }, - "Director": { - "type": "array", - "items": { - "$ref": "#/components/schemas/tag" - } - }, - "Writer": { - "type": "array", - "items": { - "$ref": "#/components/schemas/tag" - } - }, - "Role": { - "type": "array", - "items": { - "$ref": "#/components/schemas/tag" - } - }, - "Autotag": { - "type": "array", - "items": { - "$ref": "#/components/schemas/tag" - } - }, - "Filter": { - "type": "array", - "description": "Typically only seen in metadata at a library's top level", - "items": { - "$ref": "#/components/schemas/filter" - } - }, - "Sort": { - "type": "array", - "description": "Typically only seen in metadata at a library's top level", - "items": { - "$ref": "#/components/schemas/sort" - } - } - }, - "additionalProperties": true - }, - "properties-MediaContainer": { - "type": "object", - "properties": { - "identifier": { - "type": "string" - }, - "size": { - "type": "integer" - }, - "totalSize": { - "type": "integer", - "description": "The total size of objects available. Also provided in the X-Plex-Container-Total-Size header" - }, - "offset": { - "type": "integer", - "description": "The offset of where this container page starts among the total objects available. Also provided in the X-Plex-Container-Start header" - }, - "Metadata": { - "type": "array", - "items": { - "$ref": "#/components/schemas/metadata" - } - } - }, - "additionalProperties": true - }, - "mediaContainerWithDecision": { - "description": "`MediaContainer` is commonly found as the root of a response and is a pretty generic container. Common attributes include `identifier` and things related to paging (`offset`, `size`, `totalSize`).\n\nIt is also common for a `MediaContainer` to contain attributes \"hoisted\" from its children. If every element in the container would have had the same attribute, then that attribute can be present on the container instead of being repeated on every element. For example, an album's list of tracks might include `parentTitle` on the container since all of the tracks have the same album title. A container may have a `source` attribute when all of the items came from the same source. Generally speaking, when looking for an attribute on an item, if the attribute wasn't found then the container should be checked for that attribute as well.\n", - "type": "object", - "properties": { - "MediaContainer": { - "allOf": [ - { - "$ref": "#/components/schemas/properties-MediaContainer" - }, - { - "type": "object", - "properties": { - "mdeDecisionCode": { - "type": "integer", - "description": "The code indicating the status of evaluation of playback when client indicates `hasMDE=1`" - }, - "mdeDecisionText": { - "type": "string", - "description": "Descriptive text for the above code" - }, - "availableBandwidth": { - "type": "integer", - "description": "The maximum available bitrate when the decision was rendered." - }, - "generalDecisionCode": { - "type": "integer", - "description": "The overall decision. 1xxx are playback can succeed, 2xxx are a general error (such as insufficient bandwidth), 3xxx are errors in direct play, and 4xxx are errors in transcodes. Same codes are used in all." - }, - "generalDecisionText": { - "type": "string" - }, - "directPlayDecisionCode": { - "type": "integer" - }, - "directPlayDecisionText": { - "type": "string" - }, - "transcodeDecisionCode": { - "type": "integer" - }, - "transcodeDecisionText": { - "type": "string" - }, - "Metadata": { - "type": "array", - "items": { - "allOf": [ - { - "$ref": "#/components/schemas/metadata" - }, - { - "type": "object", - "properties": { - "Media": { - "type": "array", - "items": { - "allOf": [ - { - "$ref": "#/components/schemas/media" - }, - { - "type": "object", - "properties": { - "abr": { - "type": "boolean" - }, - "resourceSession": { - "type": "string" - }, - "selected": { - "type": "boolean" - }, - "Part": { - "type": "array", - "items": { - "allOf": [ - { - "$ref": "#/components/schemas/part" - }, - { - "type": "object", - "properties": { - "decision": { - "type": "string", - "enum": [ - "directplay", - "transcode", - "none" - ] - }, - "selected": { - "type": "boolean" - }, - "Stream": { - "type": "array", - "items": { - "allOf": [ - { - "$ref": "#/components/schemas/stream" - }, - { - "type": "object", - "properties": { - "location": { - "type": "string", - "enum": [ - "direct", - "sidecar-subs", - "segments-video", - "segments-audio", - "segments-av", - "segments-subs", - "embedded", - "sidecar" - ] - }, - "decision": { - "type": "string", - "enum": [ - "copy", - "transcode", - "burn", - "unavailable", - "ignore", - "none" - ] - } - } - } - ] - } - } - } - } - ] - } - } - } - } - ] - } - } - } - } - ] - } - } - } - } - ] - } - } - }, - "hub": { - "type": "object", - "properties": { - "context": { - "type": "string", - "example": "hub.home.onDeck" - }, - "hubIdentifier": { - "type": "string", - "description": "A unique identifier for the hub", - "example": "home.onDeck" - }, - "hubKey": { - "type": "string", - "description": "A key at which the exact content currently displayed can be fetched again. This is particularly important when a hub is marked as random and requesting the `key` may get different results. It's otherwise optional.\n" - }, - "key": { - "type": "string", - "description": "The key at which all of the content for this hub can be retrieved", - "example": "/hubs/sections/home/onDeck" - }, - "more": { - "type": "boolean", - "description": "\"A boolean indicating that the hub contains more than what's included in the current response.\"\n" - }, - "size": { - "type": "integer", - "example": 1 - }, - "totalSize": { - "type": "integer", - "example": 8 - }, - "type": { - "type": "string", - "description": "The type of the items contained in this hub, or possibly `mixed` if there are multiple types", - "example": "track" - }, - "subtype": { - "type": "string", - "description": "The subtype of the items contained in this hub, or possibly `mixed` if there are multiple types", - "example": "podcast" - }, - "promoted": { - "type": "boolean", - "description": "Indicating if the hub should be promoted to the user's homescreen" - }, - "random": { - "type": "boolean", - "description": "Indicating that the contents of the hub may change on each request" - }, - "style": { - "type": "string", - "description": "A suggestion on how this hub's contents might be displayed by a client. Some examples include `hero`, `list`, `spotlight`, and `upsell`" - }, - "title": { - "type": "string", - "description": "A title for this grouping of content" - }, - "Metadata": { - "type": "array", - "items": { - "$ref": "#/components/schemas/metadata" - } - } - }, - "additionalProperties": true - }, - "mediaContainerWithMetadata": { - "description": "`MediaContainer` is commonly found as the root of a response and is a pretty generic container. Common attributes include `identifier` and things related to paging (`offset`, `size`, `totalSize`).\n\nIt is also common for a `MediaContainer` to contain attributes \"hoisted\" from its children. If every element in the container would have had the same attribute, then that attribute can be present on the container instead of being repeated on every element. For example, an album's list of tracks might include `parentTitle` on the container since all of the tracks have the same album title. A container may have a `source` attribute when all of the items came from the same source. Generally speaking, when looking for an attribute on an item, if the attribute wasn't found then the container should be checked for that attribute as well.\n", - "type": "object", - "properties": { - "MediaContainer": { - "type": "object", - "properties": { - "identifier": { - "type": "string" - }, - "size": { - "type": "integer" - }, - "totalSize": { - "type": "integer", - "description": "The total size of objects available. Also provided in the X-Plex-Container-Total-Size header" - }, - "offset": { - "type": "integer", - "description": "The offset of where this container page starts among the total objects available. Also provided in the X-Plex-Container-Start header" - }, - "Metadata": { - "type": "array", - "items": { - "$ref": "#/components/schemas/metadata" - } - } - }, - "additionalProperties": true - } - } - }, - "items": { - "type": "object", - "allOf": [ - { - "$ref": "#/components/schemas/metadata" - }, - { - "type": "object", - "properties": { - "MetadataItem": { - "type": "array", - "items": { - "type": "object", - "description": "Nested metadata items", - "$ref": "#/components/schemas/items" - } - } - } - } - ] - }, - "mediaContainerWithNestedMetadata": { - "type": "object", - "properties": { - "MediaContainer": { - "allOf": [ - { - "$ref": "#/components/schemas/MediaContainer" - }, - { - "type": "object", - "properties": { - "MetadataItem": { - "type": "array", - "items": { - "type": "object", - "allOf": [ - { - "$ref": "#/components/schemas/metadata" - }, - { - "type": "object", - "properties": { - "MetadataItem": { - "type": "array", - "items": { - "type": "object", - "description": "Nested metadata items", - "$ref": "#/components/schemas/items" - } - } - } - } - ] - } - } - } - } - ] - } - } - }, - "mediaContainerWithArtwork": { - "type": "object", - "properties": { - "MediaContainer": { - "allOf": [ - { - "$ref": "#/components/schemas/MediaContainer" - }, - { - "type": "object", - "properties": { - "Metadata": { - "type": "array", - "items": { - "type": "object", - "properties": { - "title": { - "type": "string", - "description": "The title of the item" - }, - "type": { - "type": "string", - "enum": [ - "image" - ] - }, - "key": { - "type": "string", - "description": "The path to the artwork" - } - }, - "additionalProperties": true - } - } - } - } - ] - } - } - }, - "mediaContainer": { - "description": "`MediaContainer` is commonly found as the root of a response and is a pretty generic container. Common attributes include `identifier` and things related to paging (`offset`, `size`, `totalSize`).\n\nIt is also common for a `MediaContainer` to contain attributes \"hoisted\" from its children. If every element in the container would have had the same attribute, then that attribute can be present on the container instead of being repeated on every element. For example, an album's list of tracks might include `parentTitle` on the container since all of the tracks have the same album title. A container may have a `source` attribute when all of the items came from the same source. Generally speaking, when looking for an attribute on an item, if the attribute wasn't found then the container should be checked for that attribute as well.\n", - "type": "object", - "properties": { - "MediaContainer": { - "type": "object", - "properties": { - "identifier": { - "type": "string" - }, - "size": { - "type": "integer" - }, - "totalSize": { - "type": "integer", - "description": "The total size of objects available. Also provided in the X-Plex-Container-Total-Size header" - }, - "offset": { - "type": "integer", - "description": "The offset of where this container page starts among the total objects available. Also provided in the X-Plex-Container-Start header" - } - } - } - } - }, - "allowSync": { - "type": "boolean" - }, - "art": { - "type": "string" - }, - "thumb": { - "type": "string" - }, - "key": { - "type": "string" - }, - "type": { - "type": "string" - }, - "title": { - "type": "string" - }, - "content": { - "type": "boolean" - }, - "librarySection": { - "type": "object", - "properties": { - "allowSync": { - "type": "boolean" - }, - "art": { - "$ref": "#/components/schemas/art" - }, - "composite": { - "type": "string" - }, - "filters": { - "type": "boolean", - "description": "Indicates whether this section has filtering capabilities" - }, - "refreshing": { - "type": "boolean", - "description": "Indicates whether this library section is currently scanning" - }, - "thumb": { - "$ref": "#/components/schemas/thumb" - }, - "key": { - "$ref": "#/components/schemas/key" - }, - "type": { - "$ref": "#/components/schemas/type" - }, - "title": { - "$ref": "#/components/schemas/title" - }, - "agent": { - "type": "string" - }, - "scanner": { - "type": "string" - }, - "language": { - "type": "string" - }, - "updatedAt": { - "type": "integer" - }, - "createdAt": { - "type": "integer" - }, - "scannedAt": { - "type": "integer" - }, - "content": { - "$ref": "#/components/schemas/content" - }, - "directory": { - "type": "boolean" - }, - "contentChangedAt": { - "type": "integer" - }, - "hidden": { - "type": "boolean" - }, - "Location": { - "type": "array", - "items": { - "type": "object", - "description": "Represents a top-level location on disk where media in this library section is stored", - "properties": { - "id": { - "type": "integer" - }, - "path": { - "description": "The path of where this directory exists on disk" - } - } - } - } - } - }, - "mediaContainerWithStatus_properties-MediaContainer": { - "allOf": [ - { - "$ref": "#/components/schemas/MediaContainer" - }, - { - "type": "object", - "properties": { - "status": { - "type": "integer", - "description": "A status indicator. If present and non-zero, indicates an error" - }, - "message": { - "type": "string", - "description": "A message associated with the status. Typically an error message." - } - } - } - ] - }, - "Device-items": { - "type": "object", - "properties": { - "key": { - "type": "string" - }, - "lastSeenAt": { - "type": "integer" - }, - "make": { - "type": "string" - }, - "model": { - "type": "string" - }, - "modelNumber": { - "type": "string" - }, - "protocol": { - "type": "string" - }, - "sources": { - "type": "string" - }, - "state": { - "type": "string" - }, - "status": { - "type": "string" - }, - "tuners": { - "type": "string" - }, - "uri": { - "type": "string" - }, - "uuid": { - "type": "string" - }, - "ChannelMapping": { - "type": "array", - "items": { - "type": "object", - "properties": { - "channelKey": { - "type": "string" - }, - "deviceIdentifier": { - "type": "string" - }, - "enabled": { - "type": "string" - }, - "lineupIdentifier": { - "type": "string" - } - } - } - } - } - }, - "channel": { - "type": "object", - "properties": { - "identifier": { - "type": "string" - }, - "key": { - "type": "string" - }, - "channelVcn": { - "type": "string" - }, - "hd": { - "type": "boolean" - }, - "thumb": { - "type": "string" - }, - "title": { - "type": "string" - }, - "callSign": { - "type": "string" - }, - "language": { - "type": "string" - } - } - }, - "mediaContainerWithLineup": { - "type": "object", - "properties": { - "MediaContainer": { - "allOf": [ - { - "$ref": "#/components/schemas/MediaContainer" - }, - { - "type": "object", - "properties": { - "uuid": { - "type": "string", - "description": "The UUID of this set lineups" - }, - "Lineup": { - "type": "array", - "items": { - "type": "object", - "properties": { - "uuid": { - "type": "string", - "description": "The uuid of this lineup" - }, - "type": { - "type": "string", - "description": "The type of this object (`lineup` in this case)" - }, - "title": { - "type": "string" - }, - "lineupType": { - "type": "integer", - "enum": [ - -1, - 0, - 1, - 2, - 3, - 4 - ], - "description": "- `-1`: N/A\n- `0`: Over the air\n- `1`: Cable\n- `2`: Satellite\n- `3`: IPTV\n- `4`: Virtual\n" - }, - "location": { - "type": "string" - } - } - } - } - } - } - ] - } - } - }, - "Lineup-items": { - "type": "object", - "properties": { - "uuid": { - "type": "string", - "description": "The uuid of this lineup" - }, - "type": { - "type": "string", - "description": "The type of this object (`lineup` in this case)" - }, - "title": { - "type": "string" - }, - "lineupType": { - "type": "integer", - "enum": [ - -1, - 0, - 1, - 2, - 3, - 4 - ], - "description": "- `-1`: N/A\n- `0`: Over the air\n- `1`: Cable\n- `2`: Satellite\n- `3`: IPTV\n- `4`: Virtual\n" - }, - "location": { - "type": "string" - } - } - }, - "mediaContainerWithDevice": { - "type": "object", - "properties": { - "MediaContainer": { - "allOf": [ - { - "$ref": "#/components/schemas/MediaContainer" - }, - { - "type": "object", - "properties": { - "Device": { - "type": "array", - "items": { - "type": "object", - "properties": { - "key": { - "type": "string" - }, - "lastSeenAt": { - "type": "integer" - }, - "make": { - "type": "string" - }, - "model": { - "type": "string" - }, - "modelNumber": { - "type": "string" - }, - "protocol": { - "type": "string" - }, - "sources": { - "type": "string" - }, - "state": { - "type": "string" - }, - "status": { - "type": "string" - }, - "tuners": { - "type": "string" - }, - "uri": { - "type": "string" - }, - "uuid": { - "type": "string" - }, - "ChannelMapping": { - "type": "array", - "items": { - "type": "object", - "properties": { - "channelKey": { - "type": "string" - }, - "deviceIdentifier": { - "type": "string" - }, - "enabled": { - "type": "string" - }, - "lineupIdentifier": { - "type": "string" - } - } - } - } - } - } - } - } - } - ] - } - } - }, - "mediaGrabOperation": { - "description": "A media grab opration represents a scheduled or active recording of media\n", - "type": "object", - "properties": { - "mediaSubscriptionID": { - "type": "integer" - }, - "mediaIndex": { - "type": "integer" - }, - "id": { - "type": "string" - }, - "key": { - "type": "string" - }, - "grabberIdentifier": { - "type": "string" - }, - "grabberProtocol": { - "type": "string" - }, - "percent": { - "type": "number" - }, - "currentSize": { - "type": "integer" - }, - "status": { - "type": "string", - "enum": [ - "inactive", - "scheduled", - "inprogress", - "complete", - "cancelled", - "error", - "postprocessing", - "paused" - ] - }, - "provider": { - "type": "string" - }, - "Metadata": { - "$ref": "#/components/schemas/metadata" - } - } - }, - "mediaSubscription": { - "description": "A media subscription contains a representation of metadata desired to be recorded\n", - "type": "object", - "properties": { - "key": { - "type": "string" - }, - "type": { - "type": "integer", - "description": "The metadata type of the root item of the subscription" - }, - "targetLibrarySectionID": { - "type": "integer", - "description": "The library section id for where the item is to be recorded" - }, - "targetSectionLocationID": { - "type": "integer", - "description": "The library section location id for where the item is to be recorded" - }, - "createdAt": { - "type": "integer" - }, - "title": { - "type": "string" - }, - "storageTotal": { - "type": "integer", - "description": "Only included if `includeStorage` is specified" - }, - "durationTotal": { - "type": "integer", - "description": "Only included if `includeStorage` is specified" - }, - "airingsType": { - "type": "string", - "enum": [ - "New Airings Only", - "New and Repeat Airings" - ] - }, - "librarySectionTitle": { - "type": "string" - }, - "locationPath": { - "type": "string" - }, - "Video": { - "description": "Media Matching Hints", - "additionalProperties": true - }, - "Directory": { - "description": "Media Matching Hints", - "additionalProperties": true - }, - "Playlist": { - "description": "Media Matching Hints", - "additionalProperties": true - }, - "Setting": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "The query parameter name for this setting" - }, - "label": { - "type": "string", - "description": "The user-facing name of this setting" - }, - "summary": { - "type": "string", - "description": "A user-facing description of the setting" - }, - "type": { - "type": "string", - "enum": [ - "bool", - "int", - "text" - ], - "description": "They type of the value" - }, - "hidden": { - "type": "boolean", - "description": "Whether this setting is hidden from the user" - }, - "default": { - "oneOf": [ - { - "type": "string" - }, - { - "type": "number" - }, - { - "type": "boolean" - } - ], - "description": "The default value for this setting" - }, - "value": { - "oneOf": [ - { - "type": "string" - }, - { - "type": "number" - }, - { - "type": "boolean" - } - ], - "description": "The current value of this setting" - }, - "advanced": { - "type": "boolean", - "description": "Whether this is considered an advanced setting" - }, - "group": { - "type": "string", - "description": "The group name for this setting" - }, - "enumValues": { - "type": "string", - "description": "The possible values for this setting if restricted. The list is `|` separated with `value:name` entries." - } - } - } - }, - "MediaGrabOperation": { - "type": "array", - "items": { - "$ref": "#/components/schemas/mediaGrabOperation" - } - } - } - }, - "mediaContainerWithSubscription": { - "type": "object", - "properties": { - "MediaContainer": { - "allOf": [ - { - "$ref": "#/components/schemas/MediaContainer" - }, - { - "type": "object", - "properties": { - "MediaSubscription": { - "type": "array", - "items": { - "$ref": "#/components/schemas/mediaSubscription" - } - } - } - } - ] - } - } - }, - "mediaContainerWithPlaylistMetadata": { - "type": "object", - "properties": { - "MediaContainer": { - "allOf": [ - { - "$ref": "#/components/schemas/properties-MediaContainer" - }, - { - "type": "object", - "properties": { - "Metadata": { - "type": "array", - "items": { - "allOf": [ - { - "type": "object", - "properties": { - "smart": { - "type": "boolean", - "description": "Whether or not the playlist is smart." - }, - "duration": { - "type": "integer", - "description": "The total duration of the playlist in ms" - }, - "leafCount": { - "type": "integer", - "description": "The number of items in the playlist." - }, - "composite": { - "type": "string", - "description": "A composite image for the playlist." - }, - "key": { - "type": "string", - "description": "Leads to a list of items in the playlist." - }, - "playlistType": { - "type": "string", - "enum": [ - "audio", - "video", - "photo" - ], - "description": "The type of the playlist." - }, - "specialPlaylistType": { - "type": "string", - "description": "If this is a special playlist, this returns its type (e.g. favorites)." - }, - "readOnly": { - "type": "boolean", - "description": "If we return this as true then this playlist cannot be altered or deleted directly by the client." - } - } - }, - { - "$ref": "#/components/schemas/metadata" - } - ] - } - } - } - } - ] - } - } - }, - "directoryType": { - "description": "These represent the types of things found in this library, and for each one, a list of `Filter` and `Sort` objects. These can be used to build rich controls around a grid of media to allow filtering and organizing. Note that these filters and sorts are optional, and without them, the client won't render any filtering controls.\n", - "type": "object", - "properties": { - "key": { - "type": "string", - "description": "This provides the root endpoint returning the actual media list for the type." - }, - "type": { - "type": "string", - "description": "This is the metadata type for the type (if a standard Plex type)." - }, - "title": { - "type": "string", - "description": "The title for for the content of this type (e.g. \"Movies\")." - } - } - }, - "Hub": { - "$ref": "#/components/schemas/hub" - }, - "Metadata": { - "$ref": "#/components/schemas/metadata" - }, - "MediaContainerWithMetadata": { - "$ref": "#/components/schemas/mediaContainerWithMetadata" - }, - "Media": { - "$ref": "#/components/schemas/media" - }, - "Part": { - "$ref": "#/components/schemas/part" - }, - "Stream": { - "$ref": "#/components/schemas/stream" - }, - "Tag": { - "$ref": "#/components/schemas/tag" - }, - "ServerConfiguration": { - "$ref": "#/components/schemas/serverConfiguration" - }, - "Directory": { - "$ref": "#/components/schemas/directory" - }, - "LibrarySection": { - "$ref": "#/components/schemas/librarySection" - }, - "DirectoryType": { - "$ref": "#/components/schemas/directoryType" - }, - "Filter": { - "$ref": "#/components/schemas/filter" - }, - "Sort": { - "$ref": "#/components/schemas/sort" - }, - "MediaSubscription": { - "$ref": "#/components/schemas/mediaSubscription" - }, - "MediaGrabOperation": { - "$ref": "#/components/schemas/mediaGrabOperation" - }, - "MediaContainerWithSubscription": { - "$ref": "#/components/schemas/mediaContainerWithSubscription" - }, - "MediaContainerWithDevice": { - "$ref": "#/components/schemas/mediaContainerWithSubscription" - }, - "MediaContainerWithLineup": { - "$ref": "#/components/schemas/mediaContainerWithLineup" - }, - "MediaContainerWithPlaylistMetadata": { - "$ref": "#/components/schemas/mediaContainerWithPlaylistMetadata" - }, - "MediaContainerWithArtwork": { - "$ref": "#/components/schemas/mediaContainerWithArtwork" - }, - "MediaContainerWithNestedMetadata": { - "$ref": "#/components/schemas/mediaContainerWithNestedMetadata" - }, - "MediaContainerWithSettings": { - "$ref": "#/components/schemas/mediaContainerWithSettings" - } - }, - "parameters": { - "2": { - "in": "query", - "name": "advancedSubtitles", - "schema": { - "type": "string", - "enum": [ - "burn", - "text", - "unknown" - ] - }, - "example": "burn", - "description": "Indicates how incompatible advanced subtitles (such as ass/ssa) should be included: * 'burn' - Burn incompatible advanced text subtitles into the video stream * 'text' - Transcode incompatible advanced text subtitles to a compatible text format, even if some markup is lost\n" - }, - "3": { - "in": "query", - "name": "audioBoost", - "schema": { - "type": "integer", - "minimum": 1 - }, - "example": 50, - "description": "Percentage of original audio loudness to use when transcoding (100 is equivalent to original volume, 50 is half, 200 is double, etc)" - }, - "4": { - "in": "query", - "name": "audioChannelCount", - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 8 - }, - "example": 5, - "description": "Target video number of audio channels." - }, - "5": { - "in": "query", - "name": "autoAdjustQuality", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "example": 1, - "description": "Indicates the client supports ABR." - }, - "6": { - "in": "query", - "name": "autoAdjustSubtitle", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "example": 1, - "description": "Indicates if the server should adjust subtitles based on Voice Activity Data." - }, - "7": { - "in": "query", - "name": "directPlay", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "example": 1, - "description": "Indicates the client supports direct playing the indicated content." - }, - "8": { - "in": "query", - "name": "directStream", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "example": 1, - "description": "Indicates the client supports direct streaming the video of the indicated content." - }, - "9": { - "in": "query", - "name": "directStreamAudio", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "example": 1, - "description": "Indicates the client supports direct streaming the audio of the indicated content." - }, - "10": { - "in": "query", - "name": "disableResolutionRotation", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "example": 1, - "description": "Indicates if resolution should be adjusted for orientation." - }, - "11": { - "in": "query", - "name": "hasMDE", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "example": 1, - "description": "Ignore client profiles when determining if direct play is possible. Only has an effect when directPlay=1 and both mediaIndex and partIndex are specified and neither are -1" - }, - "12": { - "in": "query", - "name": "location", - "schema": { - "type": "string", - "enum": [ - "lan", - "wan", - "cellular" - ] - }, - "example": "wan", - "description": "Network type of the client, can be used to help determine target bitrate." - }, - "13": { - "in": "query", - "name": "mediaBufferSize", - "schema": { - "type": "integer" - }, - "example": 102400, - "description": "Buffer size used in playback (in KB). Clients should specify a lower bound if not known exactly. This value could make the difference between transcoding and direct play on bandwidth constrained networks." - }, - "14": { - "in": "query", - "name": "mediaIndex", - "schema": { - "type": "integer" - }, - "example": 0, - "description": "Index of the media to transcode. -1 or not specified indicates let the server choose." - }, - "15": { - "in": "query", - "name": "musicBitrate", - "schema": { - "type": "integer", - "minimum": 0 - }, - "example": 5000, - "description": "Target bitrate for audio only files (in kbps, used to transcode)." - }, - "16": { - "in": "query", - "name": "offset", - "schema": { - "type": "number" - }, - "example": 90.5, - "description": "Offset from the start of the media (in seconds)." - }, - "17": { - "in": "query", - "name": "partIndex", - "schema": { - "type": "integer" - }, - "example": 0, - "description": "Index of the part to transcode. -1 or not specified indicates the server should join parts together in a transcode" - }, - "18": { - "in": "query", - "name": "path", - "schema": { - "type": "string" - }, - "example": "/library/metadata/151671", - "description": "Internal PMS path of the media to transcode." - }, - "19": { - "in": "query", - "name": "peakBitrate", - "schema": { - "type": "integer", - "minimum": 0 - }, - "example": 12000, - "description": "Maximum bitrate (in kbps) to use in ABR." - }, - "20": { - "in": "query", - "name": "photoResolution", - "schema": { - "type": "string", - "pattern": "^\\d[x:]\\d$" - }, - "example": "1080x1080", - "description": "Target photo resolution." - }, - "21": { - "in": "query", - "name": "protocol", - "schema": { - "type": "string", - "enum": [ - "http", - "hls", - "dash" - ] - }, - "example": "dash", - "description": "Indicates the network streaming protocol to be used for the transcode session: * 'http' - include the file in the http response such as MKV streaming * 'hls' - hls stream (RFC 8216) * 'dash' - dash stream (ISO/IEC 23009-1:2022)\n" - }, - "22": { - "in": "query", - "name": "secondsPerSegment", - "schema": { - "type": "integer" - }, - "example": 5, - "description": "Number of seconds to include in each transcoded segment" - }, - "23": { - "in": "query", - "name": "subtitleSize", - "schema": { - "type": "integer", - "minimum": 1 - }, - "example": 50, - "description": "Percentage of original subtitle size to use when burning subtitles (100 is equivalent to original size, 50 is half, ect)" - }, - "24": { - "in": "query", - "name": "subtitles", - "schema": { - "type": "string", - "enum": [ - "auto", - "burn", - "none", - "sidecar", - "embedded", - "segmented", - "unknown" - ] - }, - "example": "Burn", - "description": "Indicates how subtitles should be included: * 'auto' - Compute the appropriate subtitle setting automatically * 'burn' - Burn the selected subtitle; auto if no selected subtitle * 'none' - Ignore all subtitle streams * 'sidecar' - The selected subtitle should be provided as a sidecar * 'embedded' - The selected subtitle should be provided as an embedded stream * 'segmented' - The selected subtitle should be provided as a segmented stream\n" - }, - "25": { - "in": "query", - "name": "videoBitrate", - "schema": { - "type": "integer", - "minimum": 0 - }, - "example": 12000, - "description": "Target video bitrate (in kbps)." - }, - "26": { - "in": "query", - "name": "videoQuality", - "schema": { - "type": "integer", - "minimum": 0, - "maximum": 99 - }, - "example": 50, - "description": "Target photo quality." - }, - "27": { - "in": "query", - "name": "videoResolution", - "schema": { - "type": "string", - "pattern": "^\\d[x:]\\d$" - }, - "example": "1080x1080", - "description": "Target maximum video resolution." - }, - "28": { - "in": "header", - "name": "X-Plex-Client-Identifier", - "schema": { - "type": "string" - }, - "required": true, - "description": "Unique per client." - }, - "29": { - "in": "header", - "name": "X-Plex-Client-Profile-Extra", - "schema": { - "type": "string" - }, - "example": "add-limitation(scope=videoCodec&scopeName=*&type=upperBound&name=video.frameRate&value=60&replace=true)+append-transcode-target-codec(type=videoProfile&context=streaming&videoCodec=h264%2Chevc&audioCodec=aac&protocol=dash)", - "description": "See [Profile Augmentations](#section/API-Info/Profile-Augmentations) ." - }, - "30": { - "in": "header", - "name": "X-Plex-Client-Profile-Name", - "schema": { - "type": "string" - }, - "example": "generic", - "description": "Which built in Client Profile to use in the decision. Generally should only be used to specify the Generic profile." - }, - "31": { - "in": "header", - "name": "X-Plex-Device", - "schema": { - "type": "string" - }, - "example": "Windows", - "description": "Device the client is running on" - }, - "32": { - "in": "header", - "name": "X-Plex-Model", - "schema": { - "type": "string" - }, - "example": "standalone", - "description": "Model of the device the client is running on" - }, - "33": { - "in": "header", - "name": "X-Plex-Platform", - "schema": { - "type": "string" - }, - "example": "Chrome", - "description": "Client Platform" - }, - "34": { - "in": "header", - "name": "X-Plex-Platform-Version", - "schema": { - "type": "string" - }, - "example": 135, - "description": "Client Platform Version" - }, - "35": { - "in": "header", - "name": "X-Plex-Session-Identifier", - "schema": { - "type": "string" - }, - "description": "Unique per client playback session. Used if a client can playback multiple items at a time (such as a browser with multiple tabs)" - }, - "mediaQuery": { - "in": "query", - "name": "mediaQuery", - "schema": { - "type": "object" - }, - "examples": { - "simplyAnd": { - "description": "Represents type = 4 AND sourceType = 2 AND title = \"24\"", - "value": { - "type": 4, - "sourceType": 2, - "title=": 24 - } - }, - "sorting": { - "description": "Represents type = 4 AND sourceType = 2 AND title IS \\\"24\\\" sort by duration in descending order and index (ascending)\"", - "value": { - "type": 4, - "sourceType": 2, - "title=": 24, - "sort": [ - "duration:desc", - "index" - ] - } - }, - "implicitOr": { - "description": "Represents (rating=1 OR rating=2 OR rating=3) AND index=5", - "value": { - "rating": [ - 1, - 2, - 3 - ], - "index": 5 - } - }, - "complex": { - "description": "Represents (index = 1 OR rating = 2) AND duration = 10", - "value": { - "push": 1, - "index": 1, - "or": 1, - "rating": 2, - "pop": 1, - "duration": 10 - } - } - }, - "description": "This is a complex query built of several parameters. See [API Info section](#section/API-Info/Media-Queries) for information on building media queries" - }, - "composite": { - "in": "query", - "name": "composite", - "schema": { - "type": "object", - "properties": { - "rows": { - "type": "integer", - "description": "Number of rows to construct in the composite image" - }, - "cols": { - "type": "integer", - "description": "Number of columns to construct in the composite image" - }, - "width": { - "type": "integer", - "description": "The width of the image" - }, - "height": { - "type": "integer", - "description": "The height of the image" - }, - "border": { - "type": "integer", - "description": "The width of the intra-image border" - }, - "type": { - "type": "integer", - "description": "Limit composite to specified metadata types" - }, - "format": { - "type": "string", - "enum": [ - "jpg", - "png" - ], - "description": "The image type" - }, - "crop": { - "type": "string", - "enum": [ - "center", - "top" - ], - "description": "Where to crop source images to fit into composite image proportions" - }, - "repeat": { - "type": "boolean", - "description": "Allow repetion of images if there are not enough source images to fill grid" - }, - "media": { - "type": "string", - "enum": [ - "thumb", - "art", - "banner" - ], - "description": "The default image type to use as the sources" - }, - "backgroundColor": { - "type": "string", - "description": "6 character hex RGB value for background color for image" - } - } - }, - "examples": { - "default": { - "description": "The default parameters if none are provided", - "value": { - "rows": 2, - "cols": 2, - "width": 512, - "height": 512, - "border": 0, - "type": -1, - "format": "jpg", - "crop": "center", - "repeat": true, - "media": "thumb", - "backgroundColor": 0 - } - }, - "smallerImage": { - "description": "Get a composite image in a smaller size", - "value": { - "height": 256, - "width": 256 - } - } - } - }, - "transcodeType": { - "in": "path", - "name": "transcodeType", - "description": "Type of transcode media", - "required": true, - "schema": { - "type": "string", - "enum": [ - "video", - "music", - "audio", - "subtitles" - ] - } - }, - "transcodeSessionId": { - "in": "query", - "name": "transcodeSessionId", - "description": "Transcode session UUID", - "schema": { - "type": "string" - } - } - }, - "responses": { - "200": { - "description": "OK", - "content": { - "text/html": { - "examples": { - "ok": { - "summary": "OK", - "value": "" - } - } - } - } - }, - "204": { - "description": "No Content", - "content": { - "text/html": { - "examples": { - "noContent": { - "summary": "No Content", - "value": "" - } - } - } - } - }, - "400": { - "description": "Bad Request", - "content": { - "text/html": { - "examples": { - "badRequest": { - "summary": "A parameter has a bad value or required parameter is missing", - "value": "Bad Request

400 Bad Request

" - } - } - } - } - }, - "403": { - "description": "Forbidden", - "content": { - "text/html": { - "examples": { - "forbidden": { - "summary": "Forbidden", - "value": "Forbidden

403 Forbidden

" - } - } - } - } - }, - "404": { - "description": "Not Found", - "content": { - "text/html": { - "examples": { - "notFound": { - "summary": "Not Found", - "value": "Not Found

404 Not Found

" - } - } - } - } - }, - "responses-200": { - "description": "OK", - "headers": { - "X-Plex-Container-Total-Size": { - "description": "Provided on all MediaContainer objects indicating the total size of objects available", - "schema": { - "type": "integer" - } - }, - "X-Plex-Container-Start": { - "description": "Provided on all MediaContainer objects indicating the offset of where this container page starts", - "schema": { - "type": "integer" - } - } - }, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "MediaContainer": { - "allOf": [ - { - "$ref": "#/components/schemas/MediaContainer" - }, - { - "type": "object", - "properties": { - "Hub": { - "type": "array", - "items": { - "$ref": "#/components/schemas/hub" - } - } - } - } - ] - } - } - }, - "examples": { - "someHubs": { - "summary": "An example of global hubs", - "value": { - "MediaContainer": { - "size": 8, - "allowSync": true, - "identifier": "com.plexapp.plugins.library", - "Hub": [ - { - "hubKey": "/library/metadata/37", - "key": "/hubs/home/continueWatching", - "title": "Continue Watching", - "type": "mixed", - "hubIdentifier": "home.continue", - "context": "hub.home.continue", - "size": 1, - "more": false, - "style": "hero", - "promoted": true, - "Metadata": [] - }, - { - "hubKey": "/library/metadata/217", - "key": "/hubs/home/onDeck", - "title": "On Deck", - "type": "episode", - "hubIdentifier": "home.ondeck", - "context": "hub.home.ondeck", - "size": 1, - "more": false, - "style": "shelf", - "promoted": true, - "Metadata": [] - }, - { - "hubKey": "/library/metadata/146,37,38,67,81,3", - "key": "/hubs/home/recentlyAdded?type=1", - "title": "Recently Added Movies", - "type": "movie", - "hubIdentifier": "home.movies.recent", - "context": "hub.home.movies.recent", - "size": 6, - "more": true, - "style": "shelf", - "promoted": true, - "Metadata": [] - }, - { - "hubKey": "/library/metadata/192,164,203,155,199,236", - "key": "/hubs/home/recentlyAdded?type=2", - "title": "Recently Added TV", - "type": "mixed", - "hubIdentifier": "home.television.recent", - "context": "hub.home.television.recent", - "size": 6, - "more": true, - "style": "shelf", - "promoted": true, - "Metadata": [] - }, - { - "hubKey": "/library/metadata/370,365,334,308,294,281", - "key": "/hubs/home/recentlyAdded?type=8", - "title": "Recently Added Music", - "type": "album", - "hubIdentifier": "home.music.recent", - "context": "hub.home.music.recent", - "size": 6, - "more": true, - "style": "shelf", - "promoted": true, - "Metadata": [] - }, - { - "hubKey": "/library/metadata/3390,3391,3392,3393,4230,4229", - "key": "/hubs/home/recentlyAdded?type=13", - "title": "Recently Added Photos", - "type": "photo", - "hubIdentifier": "home.photos.recent", - "context": "hub.home.photos.recent", - "size": 6, - "more": true, - "style": "shelf", - "promoted": true, - "Metadata": [] - }, - { - "hubKey": "/library/metadata/3376,3339,3340,3341,3342,3343", - "key": "/hubs/home/recentlyAdded?type=1&personal=1", - "title": "Recently Added Videos", - "type": "clip", - "hubIdentifier": "home.videos.recent", - "context": "hub.home.videos.recent", - "size": 6, - "more": true, - "style": "shelf", - "promoted": true, - "Metadata": [] - }, - { - "hubKey": "/library/metadata/3373,3225", - "key": "/playlists/all?type=15&sort=lastViewedAt:desc&playlistType=video,audio", - "title": "Recent Playlists", - "type": "playlist", - "hubIdentifier": "home.playlists", - "context": "hub.home.playlists", - "size": 2, - "more": false, - "style": "shelf", - "promoted": true, - "Metadata": [] - } - ] - } - } - } - } - } - } - }, - "get-responses-200": { - "description": "OK", - "headers": { - "X-Plex-Container-Total-Size": { - "description": "Provided on all MediaContainer objects indicating the total size of objects available", - "schema": { - "type": "integer" - } - }, - "X-Plex-Container-Start": { - "description": "Provided on all MediaContainer objects indicating the offset of where this container page starts", - "schema": { - "type": "integer" - } - } - }, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "MediaContainer": { - "allOf": [ - { - "$ref": "#/components/schemas/MediaContainer" - }, - { - "type": "object", - "properties": { - "Hub": { - "type": "array", - "items": { - "type": "object", - "properties": { - "identifier": { - "type": "string", - "description": "The identifier for this hub" - }, - "title": { - "type": "string", - "description": "The title of this hub" - }, - "recommendationsVisibility": { - "type": "string", - "enum": [ - "all", - "none", - "admin", - "shared" - ], - "description": "The visibility of this hub in recommendations:\n - all: Visible to all users\n - none: Visible to no users\n - admin: Visible to only admin users\n - shared: Visible to shared users\n" - }, - "homeVisibility": { - "type": "string", - "enum": [ - "all", - "none", - "admin", - "shared" - ], - "description": "Whether this hub is visible on the home screen\n - all: Visible to all users\n - none: Visible to no users\n - admin: Visible to only admin users\n - shared: Visible to shared users\n" - }, - "promotedToRecommended": { - "type": "boolean", - "description": "Whether this hub is promoted to all for recommendations" - }, - "promotedToOwnHome": { - "type": "boolean", - "description": "Whether this hub is visible to admin user home" - }, - "promotedToSharedHome": { - "type": "boolean", - "description": "Whether this hub is visible to shared user's home" - } - } - } - } - } - } - ] - } - } - }, - "examples": { - "someHubs": { - "summary": "An example of movie managed hubs", - "value": { - "MediaContainer": { - "size": 8, - "Hub": [ - { - "identifier": "movie.recentlyreleased", - "title": "Recently Released Movies", - "recommendationsVisibility": "all", - "homeVisibility": "none", - "promotedToRecommended": true, - "promotedToOwnHome": false, - "promotedToSharedHome": false - }, - { - "identifier": "movie.recentlyadded", - "title": "Recently Added Movies", - "recommendationsVisibility": "all", - "homeVisibility": "all", - "promotedToRecommended": true, - "promotedToOwnHome": true, - "promotedToSharedHome": true - }, - { - "identifier": "recent.library.playlists", - "title": "Library Playlists", - "recommendationsVisibility": "all", - "homeVisibility": "none", - "promotedToRecommended": true, - "promotedToOwnHome": false, - "promotedToSharedHome": false - }, - { - "identifier": "movie.genre", - "title": "Top Movies in (Genre)", - "recommendationsVisibility": "all", - "homeVisibility": "none", - "promotedToRecommended": true, - "promotedToOwnHome": false, - "promotedToSharedHome": false - }, - { - "identifier": "movie.by.actor.or.director", - "title": "Top Movies by (Actor or Director)", - "recommendationsVisibility": "all", - "homeVisibility": "none", - "promotedToRecommended": true, - "promotedToOwnHome": false, - "promotedToSharedHome": false - }, - { - "identifier": "movie.topunwatched", - "title": "Top Unplayed Movies", - "recommendationsVisibility": "all", - "homeVisibility": "none", - "promotedToRecommended": true, - "promotedToOwnHome": false, - "promotedToSharedHome": false - }, - { - "identifier": "movie.curated", - "title": "Seasonal Movies", - "recommendationsVisibility": "all", - "homeVisibility": "all", - "promotedToRecommended": true, - "promotedToOwnHome": true, - "promotedToSharedHome": true - }, - { - "identifier": "movie.recentlyviewed", - "title": "Recently Played Movies", - "recommendationsVisibility": "all", - "homeVisibility": "none", - "promotedToRecommended": true, - "promotedToOwnHome": false, - "promotedToSharedHome": false - } - ] - } - } - } - } - } - } - }, - "post-responses-200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "MediaContainer": { - "allOf": [ - { - "$ref": "#/components/schemas/MediaContainer" - }, - { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "type": { - "type": "string", - "enum": [ - "intro", - "commercial", - "bookmark", - "resume", - "credit" - ] - }, - "startTimeOffset": { - "type": "integer" - }, - "endTimeOffset": { - "type": "integer" - }, - "title": { - "type": "string" - }, - "color": { - "type": "string" - } - }, - "additionalProperties": true - } - ] - } - } - }, - "examples": { - "creditMarker": { - "value": { - "MediaContainer": { - "size": 1, - "Marker": [ - { - "final": true, - "id": 1025, - "type": "credits", - "startTimeOffset": 8249805, - "endTimeOffset": 8715339 - } - ] - } - } - } - } - } - } - }, - "responses-400": { - "description": "Request parameters are bad, such as an `endTimeOffset` prior to the `startTimeOffset`", - "content": { - "text/html": { - "examples": { - "badRequest": { - "summary": "A parameter has a bad value or required parameter is missing", - "value": "Bad Request

400 Bad Request

" - } - } - } - } - }, - "slash-get-responses-200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "properties": { - "MediaContainer": { - "type": "object", - "properties": { - "allowSync": { - "type": "boolean" - }, - "art": { - "type": "string" - }, - "content": { - "type": "string", - "description": "The flavors of directory found here:\n - Primary: (e.g. all, On Deck) These are still used in some clients to provide \"shortcuts\" to subsets of media. However, with the exception of On Deck, all of them can be created by media queries, and the desire is to allow these to be customized by users.\n - Secondary: These are marked with `\"secondary\": true` and were used by old clients to provide nested menus allowing for primative (but structured) navigation.\n - Special: There is a By Folder entry which allows browsing the media by the underlying filesystem structure, and there's a completely obsolete entry marked `\"search\": true` which used to be used to allow clients to build search dialogs on the fly." - }, - "identifier": { - "type": "string" - }, - "librarySectionID": { - "type": "integer" - }, - "mediaTagPrefix": { - "type": "string" - }, - "mediaTagVersion": { - "type": "integer" - }, - "size": { - "type": "integer" - }, - "sortAsc": { - "type": "boolean" - }, - "thumb": { - "type": "string" - }, - "title1": { - "type": "string" - }, - "viewGroup": { - "type": "string" - }, - "viewMode": { - "type": "integer" - }, - "Directory": { - "type": "array", - "items": { - "$ref": "#/components/schemas/metadata" - } - } - } - } - } - }, - "examples": { - "movie": { - "value": { - "MediaContainer": { - "allowSync": false, - "art": "/:/resources/movie-fanart.jpg", - "content": "secondary", - "identifier": "com.plexapp.plugins.library", - "librarySectionID": 1, - "mediaTagPrefix": "/system/bundle/media/flags/", - "mediaTagVersion": 1484125920, - "size": 20, - "sortAsc": true, - "thumb": "/:/resources/movie.png", - "title1": "Movies", - "viewGroup": "secondary", - "viewMode": 65592, - "Directory": [ - { - "key": "all", - "title": "All Movies" - }, - { - "key": "unwatched", - "title": "Unwatched" - }, - { - "key": "newest", - "title": "Recently Released" - }, - { - "key": "recentlyAdded", - "title": "Recently Added" - }, - { - "key": "recentlyViewed", - "title": "Recently Viewed" - }, - { - "key": "onDeck", - "title": "On Deck" - }, - { - "key": "collection", - "secondary": true, - "title": "By Collection" - }, - { - "key": "genre", - "secondary": true, - "title": "By Genre" - }, - { - "key": "year", - "secondary": true, - "title": "By Year" - }, - { - "key": "decade", - "secondary": true, - "title": "By Decade" - }, - { - "key": "director", - "secondary": true, - "title": "By Director" - }, - { - "key": "actor", - "secondary": true, - "title": "By Starring Actor" - }, - { - "key": "country", - "secondary": true, - "title": "By Country" - }, - { - "key": "contentRating", - "secondary": true, - "title": "By Content Rating" - }, - { - "key": "rating", - "secondary": true, - "title": "By Rating" - }, - { - "key": "resolution", - "secondary": true, - "title": "By Resolution" - }, - { - "key": "firstCharacter", - "secondary": true, - "title": "By First Letter" - }, - { - "key": "folder", - "title": "By Folder" - }, - { - "key": "search?type=1", - "prompt": "Search Movies", - "search": true, - "title": "Search..." - }, - { - "key": "/library/sections/1/all?type=1", - "title": "Movies", - "type": "1", - "Filter": [ - { - "filter": "genre", - "filterType": "string", - "key": "/library/sections/1/genre", - "title": "Genre", - "type": "filter" - }, - { - "filter": "year", - "filterType": "integer", - "key": "/library/sections/1/year", - "title": "Year", - "type": "filter" - }, - { - "filter": "decade", - "filterType": "integer", - "key": "/library/sections/1/decade", - "title": "Decade", - "type": "filter" - }, - { - "filter": "contentRating", - "filterType": "string", - "key": "/library/sections/1/contentRating", - "title": "Content Rating", - "type": "filter" - }, - { - "filter": "collection", - "filterType": "string", - "key": "/library/sections/1/collection", - "title": "Collection", - "type": "filter" - }, - { - "filter": "director", - "filterType": "string", - "key": "/library/sections/1/director", - "title": "Director", - "type": "filter" - }, - { - "filter": "actor", - "filterType": "string", - "key": "/library/sections/1/actor", - "title": "Actor", - "type": "filter" - }, - { - "filter": "country", - "filterType": "string", - "key": "/library/sections/1/country", - "title": "Country", - "type": "filter" - }, - { - "filter": "studio", - "filterType": "string", - "key": "/library/sections/1/studio", - "title": "Studio", - "type": "filter" - }, - { - "filter": "resolution", - "filterType": "string", - "key": "/library/sections/1/resolution", - "title": "Resolution", - "type": "filter" - }, - { - "filter": "unwatched", - "filterType": "boolean", - "key": "/library/sections/1/unwatched", - "title": "Unwatched", - "type": "filter" - }, - { - "filter": "label", - "filterType": "string", - "key": "/library/sections/1/label", - "title": "Labels", - "type": "filter" - } - ], - "Sort": [ - { - "defaultDirection": "desc", - "descKey": "addedAt:desc", - "key": "addedAt", - "title": "Date Added" - }, - { - "defaultDirection": "desc", - "descKey": "originallyAvailableAt:desc", - "key": "originallyAvailableAt", - "title": "Release Date" - }, - { - "defaultDirection": "desc", - "descKey": "lastViewedAt:desc", - "key": "lastViewedAt", - "title": "Date Viewed" - }, - { - "default": "asc", - "defaultDirection": "asc", - "descKey": "titleSort:desc", - "key": "titleSort", - "title": "Name" - }, - { - "defaultDirection": "desc", - "descKey": "rating:desc", - "key": "rating", - "title": "Rating" - }, - { - "defaultDirection": "asc", - "descKey": "mediaHeight:desc", - "key": "mediaHeight", - "title": "Resolution" - }, - { - "defaultDirection": "desc", - "descKey": "duration:desc", - "key": "duration", - "title": "Duration" - } - ] - } - ] - } - } - } - } - } - } - }, - "requestHandler_slash-get-responses-200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "MediaContainer": { - "allOf": [ - { - "$ref": "#/components/schemas/serverConfiguration" - }, - { - "type": "object", - "properties": { - "Directory": { - "type": "array", - "items": { - "type": "object", - "properties": { - "count": { - "type": "integer" - }, - "key": { - "type": "string", - "description": "The key where this directory is found" - }, - "title": { - "type": "string" - } - } - } - } - } - } - ] - } - } - }, - "examples": { - "info": { - "value": { - "MediaContainer": { - "size": 1, - "allowCameraUpload": true, - "allowChannelAccess": true, - "allowMediaDeletion": true, - "allowSharing": true, - "allowSync": true, - "allowTuners": true, - "backgroundProcessing": true, - "certificate": true, - "companionProxy": true, - "countryCode": "usa", - "diagnostics": "logs,databases,streaminglogs", - "eventStream": true, - "friendlyName": "Server Name", - "hubSearch": true, - "itemClusters": true, - "livetv": 7, - "machineIdentifier": "c997cf82c4158cb986ccc0e8f829a6f5d5086a63", - "mediaProviders": true, - "multiuser": true, - "musicAnalysis": 2, - "myPlex": true, - "myPlexMappingState": "mapped", - "myPlexSigninState": "ok", - "myPlexSubscription": true, - "myPlexUsername": "me@somewhere.else", - "offlineTranscode": 1, - "ownerFeatures\"": "adaptive_bitrate,advanced-playback-settings,camera_upload,collections,content_filter,download_certificates,dvr,federated-auth,hardware_transcoding,home,hwtranscode,item_clusters,kevin-bacon,livetv,loudness,lyrics,music-analysis,music_videos,pass,photosV6-edit,photosV6-tv-albums,premium_music_metadata,radio,session_bandwidth_restrictions,session_kick,shared-radio,sync,trailers,tuner-sharing,type-first,ump-matching-pref,unsupportedtuners,webhooks", - "platform": "MacOSX", - "platformVersion": "14.4.1", - "pluginHost": true, - "pushNotifications": false, - "readOnlyLibraries": false, - "streamingBrainABRVersion": 3, - "streamingBrainVersion": 2, - "sync": true, - "transcoderActiveVideoSessions": 0, - "transcoderAudio": true, - "transcoderLyrics": true, - "transcoderPhoto": true, - "transcoderSubtitles": true, - "transcoderVideo": true, - "transcoderVideoBitrates": "64,96,208,320,720,1500,2000,3000,4000,8000,10000,12000,20000", - "transcoderVideoQualities": "0,1,2,3,4,5,6,7,8,9,10,11,12", - "transcoderVideoResolutions": "128,128,160,240,320,480,768,720,720,1080,1080,1080,1080", - "updatedAt": 1714653009, - "updater": true, - "version": "1.40.2.8395-c67dce28e", - "voiceSearch": true, - "Directory": [ - { - "count": 1, - "key": "key", - "title": "title" - } - ] - } - } - } - } - } - } - }, - "responses-403": { - "description": "The media is not accessible to the user", - "content": { - "text/html": { - "examples": { - "forbidden": { - "summary": "Forbidden", - "value": "Forbidden

403 Forbidden

" - } - } - } - } - }, - "responses-404": { - "description": "The stream doesn't exist, or the loudness feature is not available on this PMS", - "content": { - "text/html": { - "examples": { - "notFound": { - "summary": "Not Found", - "value": "Not Found

404 Not Found

" - } - } - } - } - }, - "dvrRequestHandler_slash-get-responses-200": { - "description": "OK", - "headers": { - "X-Plex-Container-Total-Size": { - "description": "Provided on all MediaContainer objects indicating the total size of objects available", - "schema": { - "type": "integer" - } - }, - "X-Plex-Container-Start": { - "description": "Provided on all MediaContainer objects indicating the offset of where this container page starts", - "schema": { - "type": "integer" - } - } - }, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "MediaContainer": { - "allOf": [ - { - "$ref": "#/components/schemas/mediaContainerWithStatus_properties-MediaContainer" - }, - { - "type": "object", - "properties": { - "DVR": { - "type": "array", - "items": { - "type": "object", - "properties": { - "key": { - "type": "string" - }, - "language": { - "type": "string" - }, - "lineup": { - "type": "string" - }, - "uuid": { - "type": "string" - }, - "Device": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Device-items" - } - } - } - } - } - } - } - ] - } - } - }, - "examples": { - "simple": { - "value": { - "MediaContainer": { - "size": 1, - "Dvr": [ - { - "key": "28", - "language": "eng", - "lineup": "lineup://tv.plex.providers.epg.onconnect/USA-HI51418-X", - "uuid": "811e2e8a-f98f-4d1f-a26a-8bc26e4999a7" - }, - { - "key": "17", - "lastSeenAt": "1463297728", - "make": "Silicondust", - "model": "HDHomeRun EXTEND", - "modelNumber": "HDTC-2US", - "protocol": "livetv", - "sources": "0,1", - "state": "1", - "status": "1", - "tuners": "2", - "uri": "http://10.0.0.42", - "uuid": "device://tv.plex.grabbers.hdhomerun/1053C0CA" - } - ], - "ChannelMapping": [ - { - "channelKey": "5cc83d73af4a72001e9b16d7-5cab3c634df507001fefcad0", - "deviceIdentifier": "46.3", - "enabled": "1", - "lineupIdentifier": "002" - }, - { - "channelKey": "5cc83d73af4a72001e9b16d7-5cab3d20d30eca001db32922", - "deviceIdentifier": "48.1", - "enabled": "1", - "lineupIdentifier": "009" - } - ] - } - } - } - } - } - } - }, - "slash-post-responses-200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/mediaContainerWithPlaylistMetadata" - }, - "examples": { - "playlist": { - "description": "A created playlist", - "value": { - "MediaContainer": { - "size": 1, - "Metadata": [ - { - "addedAt": 1476942219, - "composite": "/playlists/2561805/composite/1485900004", - "duration": 1512000, - "key": "/playlists/2561805/items", - "lastViewedAt": 1484680617, - "leafCount": 8, - "playlistType": "video", - "ratingKey": "2561805", - "smart": false, - "title": "Background videos", - "type": "playlist", - "updatedAt": 1485900004, - "viewCount": 8 - } - ] - } - } - } - } - } - } - }, - "historyAll-get-responses-200": { - "description": "OK", - "headers": { - "X-Plex-Container-Total-Size": { - "description": "Provided on all MediaContainer objects indicating the total size of objects available", - "schema": { - "type": "integer" - } - }, - "X-Plex-Container-Start": { - "description": "Provided on all MediaContainer objects indicating the offset of where this container page starts", - "schema": { - "type": "integer" - } - } - }, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "MediaContainer": { - "allOf": [ - { - "$ref": "#/components/schemas/properties-MediaContainer" - }, - { - "type": "object", - "properties": { - "Metadata": { - "type": "array", - "items": { - "type": "object", - "properties": { - "historyKey": { - "type": "string", - "description": "The key for this individual history item" - }, - "key": { - "type": "string", - "description": "The metadata key for the item played" - }, - "ratingKey": { - "type": "string", - "description": "The rating key for the item played" - }, - "librarySectionID": { - "type": "string", - "description": "The library section id containing the item played" - }, - "title": { - "type": "string", - "description": "The title of the item played" - }, - "type": { - "type": "string", - "description": "The metadata type of the item played" - }, - "thumb": { - "type": "string", - "description": "The thumb of the item played" - }, - "originallyAvailableAt": { - "type": "string", - "description": "The originally available at of the item played" - }, - "viewedAt": { - "type": "integer", - "description": "The time when the item was played" - }, - "accountID": { - "type": "integer", - "description": "The account id of this playback" - }, - "deviceID": { - "type": "integer", - "description": "The device id which played the item" - } - } - } - } - } - } - ] - } - } - }, - "examples": { - "aHistory": { - "description": "OK", - "value": { - "MediaContainer": { - "size": 1, - "totalSize": 33, - "offset": 0, - "Metadata": [ - { - "historyKey": "/status/sessions/history/12", - "key": "/library/metadata/1234", - "ratingKey": "1234", - "librarySectionID": "1", - "title": "My Wonderful Movie", - "type": "movie", - "thumb": "/library/metadata/1234/thumb/1234567890", - "originallyAvailableAt": "2023-01-01", - "viewedAt": 1345678901, - "accountID": 123456, - "deviceID": 12 - } - ] - } - } - } - } - } - } - } - } - }, - "tags": [ - { - "name": "General", - "description": "General endpoints for basic PMS operation not specific to any media provider" - }, - { - "name": "Provider", - "description": "Media providers are the starting points for the entire Plex Media Server media library API. It defines the paths for the groups of endpoints. The `/media/providers` should be the only hard-coded path in clients when accessing the media library. Non-media library endpoints are outside the scope of the media provider. See the description in See [the section in API Info](#section/API-Info/Media-Providers) for more information on how to use media providers." - }, - { - "name": "Hubs", - "description": "The hubs within a media provider" - }, - { - "name": "Search", - "description": "The search feature within a media provider" - }, - { - "name": "Rate", - "description": "The rate feature within a media provider" - }, - { - "name": "Playlist", - "description": "The playlist feature within a media provider\nPlaylists are ordered collections of media. They can be dumb (just a list of media) or smart (based on a media query, such as \"all albums from 2017\"). They can be organized in (optionally nesting) folders.\nRetrieving a playlist, or its items, will trigger a refresh of its metadata. This may cause the duration and number of items to change." - }, - { - "name": "Content", - "description": "The actual content of the media provider" - }, - { - "name": "Play Queue", - "description": "The playqueue feature within a media provider\nA play queue represents the current list of media for playback. Although queues are persisted by the server, they should be regarded by the user as a fairly lightweight, an ephemeral list of items queued up for playback in a session. There is generally one active queue for each type of media (music, video, photos) that can be added to or destroyed and replaced with a fresh queue.\nPlay Queues has a region, which we refer to in this doc (partially for historical reasons) as \"Up Next\". This region is defined by `playQueueLastAddedItemID` existing on the media container. This follows iTunes' terminology. It is a special region after the currently playing item but before the originally-played items. This enables \"Party Mode\" listening/viewing, where items can be added on-the-fly, and normal queue playback resumed when completed. \nYou can visualize the play queue as a sliding window in the complete list of media queued for playback. This model is important when scaling to larger play queues (e.g. shuffling 40,000 audio tracks). The client only needs visibility into small areas of the queue at any given time, and the server can optimize access in this fashion.\nAll created play queues will have an empty \"Up Next\" area - unless the item is an album and no `key` is provided. In this case the \"Up Next\" area will be populated by the contents of the album. This is to allow queueing of multiple albums - since the 'Add to Up Next' will insert after all the tracks. This means that If you're creating a PQ from an album, you can only shuffle it if you set `key`. This is due to the above implicit queueing of albums when no `key` is provided as well as the current limitation that you cannot shuffle a PQ with an \"Up Next\" area.\nThe play queue window advances as the server receives timeline requests. The client needs to retrieve the play queue as the “now playing” item changes. There is no play queue API to update the playing item." - }, - { - "name": "Timeline", - "description": "The actions feature within a media provider" - }, - { - "name": "Library", - "description": "Library endpoints which are outside of the Media Provider API. Typically this is manipulation of the library (adding/removing sections, modifying preferences, etc)." - }, - { - "name": "Library Playlists", - "description": "Endpoints for manipulating playlists.", - "x-displayName": "Library: Playlists" - }, - { - "name": "Library Collections", - "description": "Endpoints for manipulating collections. In addition to these endpoints, `/library/collections/:collectionId/X` will be rerouted to `/library/metadata/:collectionId/X` and respond to those endpoints as well.", - "x-displayName": "Library: Collections" - }, - { - "name": "Status", - "description": "The status endpoints give you information about current playbacks, play history, and even terminating sessions." - }, - { - "name": "Activities", - "description": "Activities provide a way to monitor and control asynchronous operations on the server. In order to receive real-time updates for activities, a client would normally subscribe via either EventSource or Websocket endpoints.\n\nActivities are associated with HTTP replies via a special `X-Plex-Activity` header which contains the UUID of the activity.\n\nActivities are optional cancellable. If cancellable, they may be cancelled via the `DELETE` endpoint.\n" - }, - { - "name": "Updater", - "description": "This describes the API for searching and applying updates to the Plex Media Server.\nUpdates to the status can be observed via the Event API.\n" - }, - { - "name": "Butler", - "description": "The butler is responsible for running periodic tasks. Some tasks run daily, others every few days, and some weekly. These includes database maintenance, metadata updating, thumbnail generation, media analysis, and other tasks." - }, - { - "name": "Events", - "description": "The server can notify clients in real-time of a wide range of events, from library scanning, to preferences being modified, to changes to media, and many other things. This is also the mechanism by which activity progress is reported.\n\nTwo protocols for receiving the events are available: EventSource (also known as SSE), and WebSocket.\n" - }, - { - "name": "DVRs", - "description": "The DVR provides means to watch and record live TV. This section of endpoints describes how to setup the DVR itself\n" - }, - { - "name": "Devices", - "description": "Media grabbers provide ways for media to be obtained for a given protocol. The simplest ones are `stream` and `download`. More complex grabbers can have associated devices\n\nNetwork tuners can present themselves on the network using the Simple Service Discovery Protocol and Plex Media Server will discover them. The following XML is an example of the data returned from SSDP. The `deviceType`, `serviceType`, and `serviceId` values must remain as they are in the example in order for PMS to properly discover the device. Other less-obvious fields are described in the parameters section below.\n\nExample SSDP output\n```\n\n \n 1\n 0\n \n \n urn:plex-tv:device:Media:1\n Turing Hopper 3000\n Plex, Inc.\n https://plex.tv/\n Turing Hopper 3000 Media Grabber\n Plex Media Grabber\n 1\n https://plex.tv\n uuid:42fde8e4-93b6-41e5-8a63-12d848655811\n \n \n http://10.0.0.5:8088\n urn:plex-tv:service:MediaGrabber:1\n urn:plex-tv:serviceId:MediaGrabber\n \n \n \n\n```\n\n - UDN: (string) A UUID for the device. This should be unique across models of a device at minimum.\n - URLBase: (string) The base HTTP URL for the device from which all of the other endpoints are hosted.\n" - }, - { - "name": "EPG", - "description": "The EPG (Electronic Program Guide) is responsible for obtaining metadata for what is airing on each channel and when\n" - }, - { - "name": "Subscriptions", - "description": "Subscriptions determine which media will be recorded and the criteria for selecting an airing when multiple are available\n" - }, - { - "name": "Live TV", - "description": "LiveTV contains the playback sessions of a channel from a DVR device\n" - }, - { - "name": "Log", - "description": "Logging mechanism to allow clients to log to the server" - }, - { - "name": "UltraBlur", - "description": "Service provided to compute UltraBlur colors and images." - } - ], - "x-tagGroups": [ - { - "name": "General", - "tags": [ - "General", - "Library", - "Library Playlists", - "Library Collections", - "Status", - "Activities", - "Updater", - "Butler", - "Events", - "Log", - "Preferences", - "Download Queue", - "UltraBlur", - "Transcoder" - ] - }, - { - "name": "Media Provider", - "tags": [ - "Provider", - "Content", - "Hubs", - "Search", - "Rate", - "Playlist", - "Play Queue", - "Timeline" - ] - }, - { - "name": "DVR", - "tags": [ - "DVRs", - "Devices", - "EPG", - "Subscriptions", - "Live TV" - ] - } - ], - "paths": { - "/": { - "get": { - "tags": [ - "General" - ], - "summary": "Get PMS info", - "description": "Information about this PMS setup and configuration", - "operationId": "getSlash", - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "MediaContainer": { - "allOf": [ - { - "$ref": "#/components/schemas/serverConfiguration" - }, - { - "type": "object", - "properties": { - "Directory": { - "type": "array", - "items": { - "type": "object", - "properties": { - "count": { - "type": "integer" - }, - "key": { - "type": "string", - "description": "The key where this directory is found" - }, - "title": { - "type": "string" - } - } - } - } - } - } - ] - } - } - }, - "examples": { - "info": { - "value": { - "MediaContainer": { - "size": 1, - "allowCameraUpload": true, - "allowChannelAccess": true, - "allowMediaDeletion": true, - "allowSharing": true, - "allowSync": true, - "allowTuners": true, - "backgroundProcessing": true, - "certificate": true, - "companionProxy": true, - "countryCode": "usa", - "diagnostics": "logs,databases,streaminglogs", - "eventStream": true, - "friendlyName": "Server Name", - "hubSearch": true, - "itemClusters": true, - "livetv": 7, - "machineIdentifier": "c997cf82c4158cb986ccc0e8f829a6f5d5086a63", - "mediaProviders": true, - "multiuser": true, - "musicAnalysis": 2, - "myPlex": true, - "myPlexMappingState": "mapped", - "myPlexSigninState": "ok", - "myPlexSubscription": true, - "myPlexUsername": "me@somewhere.else", - "offlineTranscode": 1, - "ownerFeatures\"": "adaptive_bitrate,advanced-playback-settings,camera_upload,collections,content_filter,download_certificates,dvr,federated-auth,hardware_transcoding,home,hwtranscode,item_clusters,kevin-bacon,livetv,loudness,lyrics,music-analysis,music_videos,pass,photosV6-edit,photosV6-tv-albums,premium_music_metadata,radio,session_bandwidth_restrictions,session_kick,shared-radio,sync,trailers,tuner-sharing,type-first,ump-matching-pref,unsupportedtuners,webhooks", - "platform": "MacOSX", - "platformVersion": "14.4.1", - "pluginHost": true, - "pushNotifications": false, - "readOnlyLibraries": false, - "streamingBrainABRVersion": 3, - "streamingBrainVersion": 2, - "sync": true, - "transcoderActiveVideoSessions": 0, - "transcoderAudio": true, - "transcoderLyrics": true, - "transcoderPhoto": true, - "transcoderSubtitles": true, - "transcoderVideo": true, - "transcoderVideoBitrates": "64,96,208,320,720,1500,2000,3000,4000,8000,10000,12000,20000", - "transcoderVideoQualities": "0,1,2,3,4,5,6,7,8,9,10,11,12", - "transcoderVideoResolutions": "128,128,160,240,320,480,768,720,720,1080,1080,1080,1080", - "updatedAt": 1714653009, - "updater": true, - "version": "1.40.2.8395-c67dce28e", - "voiceSearch": true, - "Directory": [ - { - "count": 1, - "key": "key", - "title": "title" - } - ] - } - } - } - } - } - } - } - } - } - }, - "/:/eventsource/notifications": { - "get": { - "tags": [ - "Events" - ], - "summary": "Connect to Eventsource", - "description": "Connect to the event source to get a stream of events", - "operationId": "eventsourceGetSlash", - "parameters": [ - { - "in": "query", - "name": "filter", - "schema": { - "type": "array", - "items": { - "type": "string" - } - }, - "description": "By default, all events except logs are sent. A rich filtering mechanism is provided to allow clients to opt into or out of each event type using the `filters` parameter. For example:\n\n- `filters=-log`: All event types except logs (the default).\n- `filters=foo,bar`: Only the foo and bar event types.\n- `filters=`: All events types.\n- `filters=-foo,bar`: All event types except foo and bar.\n" - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/octet-stream": { - "schema": { - "type": "string", - "format": "binary" - } - } - } - } - } - } - }, - "/:/prefs": { - "get": { - "tags": [ - "Preferences" - ], - "summary": "Get all preferences", - "description": "Get the list of all preferences", - "operationId": "preferencesGetSlash", - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/mediaContainerWithSettings" - }, - "examples": { - "somePrefs": { - "value": { - "MediaContainer": { - "size": 171, - "Setting": [ - { - "id": "FriendlyName", - "label": "Friendly name", - "summary": "This name will be used to identify this media server to other computers on your network. If you leave it blank, your computer's name will be used instead.", - "type": "text", - "default": "", - "value": "", - "hidden": false, - "advanced": false, - "group": "general" - }, - { - "id": "sendCrashReports", - "label": "Send crash reports to Plex", - "summary": "This helps us improve your experience.", - "type": "bool", - "default": true, - "value": true, - "hidden": false, - "advanced": false, - "group": "general" - }, - { - "id": "ScheduledLibraryUpdateInterval", - "label": "Library scan interval", - "summary": "", - "type": "int", - "default": 3600, - "value": 3600, - "hidden": false, - "advanced": false, - "group": "library", - "enumValues": "900:every 15 minutes|1800:every 30 minutes|3600:hourly|7200:every 2 hours|21600:every 6 hours|43200:every 12 hours|86400:daily" - }, - { - "id": "OnDeckWindow", - "label": "Weeks to consider for Continue Watching", - "summary": "Media that has not been watched in this many weeks will not appear in Continue Watching.", - "type": "int", - "default": 16, - "value": 16, - "hidden": false, - "advanced": true, - "group": "library" - }, - { - "id": "LibraryVideoPlayedAtBehaviour", - "label": "Video play completion behaviour", - "summary": "Decide whether to use end credits markers to determine the 'watched' state of video items. When markers are not available the selected threshold percentage will be used.", - "type": "text", - "default": "3", - "value": "3", - "hidden": false, - "advanced": true, - "group": "library", - "enumValues": "0:at selected threshold percentage|1:at final credits marker position|2:at first credits marker position|3:earliest between threshold percent and first credits marker" - }, - { - "id": "TranscoderH264MinimumCRF", - "label": "", - "summary": "", - "type": "double", - "default": 16, - "value": 16, - "hidden": true, - "advanced": false, - "group": "transcoder" - } - ] - } - } - } - } - } - } - } - } - }, - "put": { - "tags": [ - "Preferences" - ], - "summary": "Set preferences", - "description": "Set a set of preferences in query parameters", - "operationId": "preferencesPutSlash", - "parameters": [ - { - "in": "query", - "name": "prefs", - "schema": { - "type": "object" - }, - "required": true, - "example": { - "FriendlyName": "My Server", - "sendCrashReports": 1, - "ScheduledLibraryUpdateInterval": 3600 - } - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/200" - }, - "400": { - "description": "Attempt to set a preferences that doesn't exist", - "content": { - "text/html": { - "examples": { - "badRequest": { - "summary": "A parameter has a bad value or required parameter is missing", - "value": "cannot set preference value for unknown preference foo" - } - } - } - } - }, - "403": { - "description": "Attempt to set a preferences that doesn't exist", - "content": { - "text/html": { - "examples": { - "forbidden": { - "summary": "Forbidden", - "value": "Forbidden

403 Forbidden

" - } - } - } - } - } - } - } - }, - "/:/prefs/get": { - "get": { - "tags": [ - "Preferences" - ], - "summary": "Get a preferences", - "description": "Get a single preference and value", - "operationId": "preferencesGetGet", - "parameters": [ - { - "in": "query", - "name": "id", - "schema": { - "type": "string" - }, - "description": "The preference to fetch" - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/mediaContainerWithSettings" - }, - "examples": { - "friendlyName": { - "value": { - "MediaContainer": { - "size": 1, - "Setting": [ - { - "id": "FriendlyName", - "label": "Friendly name", - "summary": "This name will be used to identify this media server to other computers on your network. If you leave it blank, your computer's name will be used instead.", - "type": "text", - "default": "", - "value": "", - "hidden": false, - "advanced": false, - "group": "general" - } - ] - } - } - }, - "sendCrashReports": { - "value": { - "MediaContainer": { - "size": 1, - "Setting": [ - { - "id": "sendCrashReports", - "label": "Send crash reports to Plex", - "summary": "This helps us improve your experience.", - "type": "bool", - "default": true, - "value": true, - "hidden": false, - "advanced": false, - "group": "general" - } - ] - } - } - }, - "scheduledLibraryUpdateInterval": { - "value": { - "MediaContainer": { - "size": 1, - "Setting": [ - { - "id": "ScheduledLibraryUpdateInterval", - "label": "Library scan interval", - "summary": "", - "type": "int", - "default": 3600, - "value": 3600, - "hidden": false, - "advanced": false, - "group": "library", - "enumValues": "900:every 15 minutes|1800:every 30 minutes|3600:hourly|7200:every 2 hours|21600:every 6 hours|43200:every 12 hours|86400:daily" - } - ] - } - } - }, - "onDeckWindow": { - "value": { - "MediaContainer": { - "size": 1, - "Setting": [ - { - "id": "OnDeckWindow", - "label": "Weeks to consider for Continue Watching", - "summary": "Media that has not been watched in this many weeks will not appear in Continue Watching.", - "type": "int", - "default": 16, - "value": 16, - "hidden": false, - "advanced": true, - "group": "library" - } - ] - } - } - }, - "libraryVideoPlayedAtBehaviour": { - "value": { - "MediaContainer": { - "size": 1, - "Setting": [ - { - "id": "LibraryVideoPlayedAtBehaviour", - "label": "Video play completion behaviour", - "summary": "Decide whether to use end credits markers to determine the 'watched' state of video items. When markers are not available the selected threshold percentage will be used.", - "type": "text", - "default": "3", - "value": "3", - "hidden": false, - "advanced": true, - "group": "library", - "enumValues": "0:at selected threshold percentage|1:at final credits marker position|2:at first credits marker position|3:earliest between threshold percent and first credits marker" - } - ] - } - } - }, - "transcoderH264MinimumCRF": { - "value": { - "MediaContainer": { - "size": 1, - "Setting": [ - { - "id": "TranscoderH264MinimumCRF", - "label": "", - "summary": "", - "type": "double", - "default": 16, - "value": 16, - "hidden": true, - "advanced": false, - "group": "transcoder" - } - ] - } - } - } - } - } - } - }, - "404": { - "description": "No preference with the provided name found.", - "content": { - "text/html": { - "examples": { - "notFound": { - "summary": "Not Found", - "value": "Not Found

404 Not Found

" - } - } - } - } - } - } - } - }, - "/:/rate": { - "put": { - "tags": [ - "Rate" - ], - "operationId": "putRate", - "summary": "Rate an item", - "description": "Set the rating on an item.\nThis API does respond to the GET verb but applications should use PUT", - "parameters": [ - { - "in": "query", - "name": "identifier", - "required": true, - "schema": { - "type": "string" - }, - "description": "The identifier of the media provider containing the media to rate. Typically `com.plexapp.plugins.library`" - }, - { - "in": "query", - "name": "key", - "required": true, - "schema": { - "type": "string" - }, - "description": "The key of the item to rate. This is the `ratingKey` found in metadata items" - }, - { - "in": "query", - "name": "rating", - "required": true, - "schema": { - "type": "number", - "minimum": 0, - "maximum": 10 - }, - "description": "The rating to give the item." - }, - { - "in": "query", - "name": "ratedAt", - "required": false, - "schema": { - "type": "integer" - }, - "description": "The time when the rating occurred. If not present, interpreted as now." - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/200" - }, - "400": { - "description": "Bad Request. Can occur when parameters are of the wrong type, missing, or if the `ratedAt` is in the future", - "content": { - "text/html": { - "examples": { - "badRequest": { - "summary": "A parameter has a bad value or required parameter is missing", - "value": "Bad Request

400 Bad Request

" - } - } - } - } - }, - "404": { - "description": "Indicates that no library with the provide identifier can be found or no item can be found with the rating key", - "content": { - "text/html": { - "examples": { - "notFound": { - "summary": "Not Found", - "value": "Not Found

404 Not Found

" - } - } - } - } - } - } - } - }, - "/:/scrobble": { - "put": { - "tags": [ - "Timeline" - ], - "operationId": "putScrobble", - "summary": "Mark an item as played", - "description": "Mark an item as played. Note, this does not create any view history of this item but rather just sets the state as played. The client must provide either the `key` or `uri` query parameter\nThis API does respond to the GET verb but applications should use PUT", - "parameters": [ - { - "in": "query", - "name": "identifier", - "required": true, - "schema": { - "type": "string" - }, - "description": "The identifier of the media provider containing the media to rate. Typically `com.plexapp.plugins.library`" - }, - { - "in": "query", - "name": "key", - "required": false, - "schema": { - "type": "string" - }, - "description": "The key of the item to rate. This is the `ratingKey` found in metadata items" - }, - { - "in": "query", - "name": "uri", - "required": false, - "schema": { - "type": "string" - }, - "description": "The URI of the item to mark as played. See intro for description of the URIs" - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/200" - }, - "400": { - "description": "Bad Request. Can occur when parameters are of the wrong type, or missing", - "content": { - "text/html": { - "examples": { - "badRequest": { - "summary": "A parameter has a bad value or required parameter is missing", - "value": "Bad Request

400 Bad Request

" - } - } - } - } - }, - "404": { - "description": "Indicates that no library with the provide identifier can be found or no item can be found with the rating key", - "content": { - "text/html": { - "examples": { - "notFound": { - "summary": "Not Found", - "value": "Not Found

404 Not Found

" - } - } - } - } - } - } - } - }, - "/:/timeline": { - "post": { - "tags": [ - "Timeline" - ], - "summary": "Report media timeline", - "description": "This endpoint is hit during media playback for an item. It must be hit whenever the play state changes, or in the absence of a play state change, in a regular fashion (generally this means every 10 seconds on a LAN/WAN, and every 20 seconds over cellular).\n", - "operationId": "timelinePostSlash", - "parameters": [ - { - "in": "query", - "name": "key", - "schema": { - "type": "string" - }, - "example": "/foo", - "description": "The details key for the item." - }, - { - "in": "query", - "name": "ratingKey", - "schema": { - "type": "string" - }, - "example": "xyz", - "description": "The rating key attribute for the item." - }, - { - "in": "query", - "name": "state", - "schema": { - "type": "string", - "enum": [ - "stopped", - "buffering", - "playing", - "paused" - ] - }, - "example": "playing", - "description": "The current state of the media." - }, - { - "in": "query", - "name": "playQueueItemID", - "schema": { - "type": "string" - }, - "example": 123, - "description": "If playing media from a play queue, the play queue's ID." - }, - { - "in": "query", - "name": "time", - "schema": { - "type": "integer" - }, - "example": 0, - "description": "The current time offset of playback in ms." - }, - { - "in": "query", - "name": "duration", - "schema": { - "type": "integer" - }, - "example": 10000, - "description": "The total duration of the item in ms." - }, - { - "in": "query", - "name": "continuing", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "example": 1, - "description": "When state is `stopped`, a flag indicating whether or not the client is going to continue playing anothe item." - }, - { - "in": "query", - "name": "updated", - "schema": { - "type": "integer" - }, - "example": 14200000, - "description": "Used when a sync client comes online and is syncing media timelines, holds the time at which the playback state was last updated." - }, - { - "in": "query", - "name": "offline", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "example": 1, - "description": "Also used by sync clients, used to indicate that a timeline is being synced from being offline, as opposed to being \"live\"." - }, - { - "in": "query", - "name": "timeToFirstFrame", - "schema": { - "type": "integer" - }, - "example": 1000, - "description": "Time in seconds till first frame is displayed. Sent only on the first playing timeline request." - }, - { - "in": "query", - "name": "timeStalled", - "schema": { - "type": "integer" - }, - "example": 1000, - "description": "Time in seconds spent buffering since last request." - }, - { - "in": "query", - "name": "bandwidth", - "schema": { - "type": "integer" - }, - "example": 100, - "description": "Bandwidth in kbps as estimated by the client." - }, - { - "in": "query", - "name": "bufferedTime", - "schema": { - "type": "integer" - }, - "example": 100, - "description": "Amount of time in seconds buffered by client. Omit if computed by `bufferedSize` below." - }, - { - "in": "query", - "name": "bufferedSize", - "schema": { - "type": "integer" - }, - "example": 1024, - "description": "Size in kilobytes of data buffered by client. Omit if computed by `bufferedTime` above" - }, - { - "in": "header", - "name": "X-Plex-Client-Identifier", - "schema": { - "type": "string" - }, - "required": true, - "description": "Unique per client." - }, - { - "in": "header", - "name": "X-Plex-Session-Identifier", - "schema": { - "type": "string" - }, - "description": "Unique per client playback session. Used if a client can playback multiple items at a time (such as a browser with multiple tabs)" - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "MediaContainer": { - "allOf": [ - { - "$ref": "#/components/schemas/serverConfiguration" - }, - { - "type": "object", - "properties": { - "terminationCode": { - "type": "integer", - "description": "A code describing why the session was terminated by the server." - }, - "terminationText": { - "type": "string", - "description": "A user friendly and localized text describing why the session was terminated by the server." - }, - "Bandwidths": { - "type": "object", - "description": "A list of media times and bandwidths when trascoding is using with auto adjustment of bandwidth", - "properties": { - "Bandwidth": { - "type": "array", - "items": { - "type": "object", - "properties": { - "time": { - "type": "integer", - "description": "Media playback time where this bandwidth started" - }, - "bandwidth": { - "type": "integer", - "description": "The bandwidth at this time in kbps" - }, - "resolution": { - "type": "string", - "description": "The user-friendly resolution at this time" - } - } - } - } - } - } - } - } - ] - } - } - }, - "examples": { - "normal": { - "description": "Normal response", - "value": { - "MediaContainer": { - "size": 0 - } - } - }, - "adminTerminatedSession": { - "description": "Admin Terminated the session", - "value": { - "MediaContainer": { - "size": 0, - "terminationCode": 2006, - "terminationText": "Admin terminated playback with reason: Go Away" - } - } - }, - "bandwidthChanges": { - "description": "Bandwidth changes included", - "value": { - "MediaContainer": { - "size": 1, - "Bandwidths": { - "Bandwidth": [ - { - "time": 0, - "bandwidth": 15000, - "resolution": "1080p" - }, - { - "time": 1050008, - "bandwidth": 12000, - "resolution": "1080p" - }, - { - "time": 1053011, - "bandwidth": 8000, - "resolution": "1080p" - }, - { - "time": 1098014, - "bandwidth": 4000, - "resolution": "720p" - }, - { - "time": 1101017, - "bandwidth": 2000, - "resolution": "SD" - }, - { - "time": 1104020, - "bandwidth": 1000, - "resolution": "SD" - }, - { - "time": 1107023, - "bandwidth": 750, - "resolution": "SD" - }, - { - "time": 1110026, - "bandwidth": 350, - "resolution": "SD" - }, - { - "time": 1113029, - "bandwidth": 750, - "resolution": "SD" - }, - { - "time": 1116032, - "bandwidth": 1000, - "resolution": "SD" - }, - { - "time": 1119035, - "bandwidth": 4000, - "resolution": "720p" - }, - { - "time": 1122038, - "bandwidth": 10000, - "resolution": "1080p" - } - ] - } - } - } - } - } - } - } - }, - "400": { - "$ref": "#/components/responses/400" - } - } - } - }, - "/:/unscrobble": { - "put": { - "tags": [ - "Timeline" - ], - "operationId": "putUnscrobble", - "summary": "Mark an item as unplayed", - "description": "Mark an item as unplayed. The client must provide either the `key` or `uri` query parameter\nThis API does respond to the GET verb but applications should use PUT", - "parameters": [ - { - "in": "query", - "name": "identifier", - "required": true, - "schema": { - "type": "string" - }, - "description": "The identifier of the media provider containing the media to rate. Typically `com.plexapp.plugins.library`" - }, - { - "in": "query", - "name": "key", - "required": false, - "schema": { - "type": "string" - }, - "description": "The key of the item to rate. This is the `ratingKey` found in metadata items" - }, - { - "in": "query", - "name": "uri", - "required": false, - "schema": { - "type": "string" - }, - "description": "The URI of the item to mark as played. See intro for description of the URIs" - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/200" - }, - "400": { - "description": "Bad Request. Can occur when parameters are of the wrong type, or missing", - "content": { - "text/html": { - "examples": { - "badRequest": { - "summary": "A parameter has a bad value or required parameter is missing", - "value": "Bad Request

400 Bad Request

" - } - } - } - } - }, - "404": { - "description": "Indicates that no library with the provide identifier can be found or no item can be found with the rating key", - "content": { - "text/html": { - "examples": { - "notFound": { - "summary": "Not Found", - "value": "Not Found

404 Not Found

" - } - } - } - } - } - } - } - }, - "/:/websocket/notifications": { - "get": { - "tags": [ - "Events" - ], - "summary": "Connect to WebSocket", - "description": "Connect to the web socket to get a stream of events", - "operationId": "websocketGetSlash", - "parameters": [ - { - "in": "query", - "name": "filter", - "schema": { - "type": "array", - "items": { - "type": "string" - } - }, - "description": "By default, all events except logs are sent. A rich filtering mechanism is provided to allow clients to opt into or out of each event type using the `filters` parameter. For example:\n\n- `filters=-log`: All event types except logs (the default).\n- `filters=foo,bar`: Only the foo and bar event types.\n- `filters=`: All events types.\n- `filters=-foo,bar`: All event types except foo and bar.\n" - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/octet-stream": { - "schema": { - "type": "string", - "format": "binary" - } - } - } - } - } - } - }, - "/activities": { - "get": { - "tags": [ - "Activities" - ], - "summary": "Get all activities", - "description": "List all activities on the server. Admins can see all activities but other users can only see their own", - "operationId": "activitiesGetSlash", - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "MediaContainer": { - "allOf": [ - { - "type": "object", - "properties": { - "Activity": { - "type": "array", - "items": { - "type": "object", - "properties": { - "uuid": { - "type": "string", - "description": "The ID of the activity" - }, - "type": { - "type": "string", - "description": "The type of activity" - }, - "cancellable": { - "type": "boolean", - "description": "Indicates whether this activity can be cancelled" - }, - "userID": { - "type": "integer", - "description": "The user this activity belongs to" - }, - "title": { - "type": "string", - "description": "A user-friendly title for this activity" - }, - "subtitle": { - "type": "string", - "description": "A user-friendly sub-title for this activity" - }, - "progress": { - "type": "number", - "maximum": 100, - "minimum": -1, - "description": "A progress percentage. A value of -1 means the progress is indeterminate" - }, - "Context": { - "type": "object", - "additionalProperties": true, - "description": "An object with additional values" - }, - "Response": { - "type": "object", - "additionalProperties": true, - "description": "An object with the response to the async opperation" - } - } - } - } - } - } - ] - } - } - }, - "examples": { - "activity": { - "description": "Activity of updating EPG and detecting credits", - "value": { - "MediaContainer": { - "size": 2, - "Activity": [ - { - "uuid": "d6199ba1-fb5e-4cae-bf17-1a5369c1cf1e", - "cancellable": false, - "progress": 7, - "subtitle": "Downloaded 173 airings", - "title": "Refreshing EPG", - "type": "provider.epg.load", - "userID": 1 - }, - { - "uuid": "e3c8fe07-675b-47d0-b957-8ab8c20d0d18", - "type": "media.generate.credits", - "cancellable": false, - "userID": 1, - "title": "Detecting Credits", - "subtitle": "Firefly S01 E05", - "progress": -1 - } - ] - } - } - } - } - } - } - } - } - } - }, - "/activities/{activityId}": { - "delete": { - "tags": [ - "Activities" - ], - "security": [ - { - "user_token": [ - "admin" - ] - } - ], - "summary": "Cancel a running activity", - "description": "Cancel a running activity. Admins can cancel all activities but other users can only cancel their own", - "operationId": "activitiesDeleteActivity", - "parameters": [ - { - "in": "path", - "name": "activityId", - "schema": { - "type": "string" - }, - "example": "d6199ba1-fb5e-4cae-bf17-1a5369c1cf1e", - "description": "The UUID of the activity to cancel.", - "required": true - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/200" - }, - "400": { - "description": "Activity is not cancellable", - "content": { - "text/html": { - "examples": { - "badRequest": { - "summary": "A parameter has a bad value or required parameter is missing", - "value": "Bad Request

400 Bad Request

" - } - } - } - } - }, - "404": { - "description": "No activity with the provided id is found", - "content": { - "text/html": { - "examples": { - "notFound": { - "summary": "Not Found", - "value": "Not Found

404 Not Found

" - } - } - } - } - } - } - } - }, - "/butler": { - "get": { - "tags": [ - "Butler" - ], - "security": [ - { - "user_token": [ - "admin" - ] - } - ], - "summary": "Get all Butler tasks", - "description": "Get the list of butler tasks and their scheduling\n", - "operationId": "butlerGetSlash", - "responses": { - "200": { - "description": "Butler tasks", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ButlerTasks": { - "type": "object", - "properties": { - "ButlerTask": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "The name of the task" - }, - "interval": { - "type": "integer", - "description": "The interval (in days) of when this task is run. A value of 1 is run every day, 7 is every week, etc." - }, - "scheduleRandomized": { - "type": "boolean", - "description": "Indicates whether the timing of the task is randomized within the butler interval" - }, - "enabled": { - "type": "boolean", - "description": "Whether this task is enabled or not" - }, - "title": { - "type": "string", - "description": "A user-friendly title of the task" - }, - "description": { - "type": "string", - "description": "A user-friendly description of the task" - } - } - } - } - } - } - } - }, - "examples": { - "tasks": { - "description": "Example tasks", - "value": { - "ButlerTasks": { - "ButlerTask": [ - { - "name": "AutomaticUpdates", - "interval": 1, - "scheduleRandomized": false, - "enabled": false - }, - { - "name": "BackupDatabase", - "interval": 3, - "scheduleRandomized": false, - "enabled": true, - "title": "Backup Database", - "description": "Create a backup copy of the server's database in the configured backup directory" - }, - { - "name": "ButlerTaskGenerateAdMarkers", - "interval": 1, - "scheduleRandomized": false, - "enabled": false - }, - { - "name": "ButlerTaskGenerateCreditsMarkers", - "interval": 1, - "scheduleRandomized": true, - "enabled": true - }, - { - "name": "ButlerTaskGenerateIntroMarkers", - "interval": 1, - "scheduleRandomized": false, - "enabled": true - }, - { - "name": "ButlerTaskGenerateVoiceActivity", - "interval": 1, - "scheduleRandomized": true, - "enabled": true - }, - { - "name": "CleanOldBundles", - "interval": 7, - "scheduleRandomized": false, - "enabled": true - }, - { - "name": "CleanOldCacheFiles", - "interval": 7, - "scheduleRandomized": false, - "enabled": true - }, - { - "name": "DeepMediaAnalysis", - "interval": 1, - "scheduleRandomized": false, - "enabled": true - }, - { - "name": "GarbageCollectBlobs", - "interval": 7, - "scheduleRandomized": false, - "enabled": true - }, - { - "name": "GarbageCollectLibraryMedia", - "interval": 1, - "scheduleRandomized": false, - "enabled": true - }, - { - "name": "GenerateBlurHashes", - "interval": 1, - "scheduleRandomized": false, - "enabled": true - }, - { - "name": "GenerateChapterThumbs", - "interval": 1, - "scheduleRandomized": false, - "enabled": true - }, - { - "name": "GenerateMediaIndexFiles", - "interval": 1, - "scheduleRandomized": false, - "enabled": false - }, - { - "name": "LoudnessAnalysis", - "interval": 1, - "scheduleRandomized": false, - "enabled": true - }, - { - "name": "MusicAnalysis", - "interval": 1, - "scheduleRandomized": false, - "enabled": true - }, - { - "name": "OptimizeDatabase", - "interval": 7, - "scheduleRandomized": false, - "enabled": true - }, - { - "name": "RefreshEpgGuides", - "interval": 1, - "scheduleRandomized": true, - "enabled": true - }, - { - "name": "RefreshLibraries", - "interval": 1, - "scheduleRandomized": false, - "enabled": false - }, - { - "name": "RefreshLocalMedia", - "interval": 3, - "scheduleRandomized": false, - "enabled": true - }, - { - "name": "RefreshPeriodicMetadata", - "interval": 1, - "scheduleRandomized": true, - "enabled": true - }, - { - "name": "UpgradeMediaAnalysis", - "interval": 1, - "scheduleRandomized": false, - "enabled": true - } - ] - } - } - } - } - } - } - } - } - }, - "post": { - "tags": [ - "Butler" - ], - "security": [ - { - "user_token": [ - "admin" - ] - } - ], - "summary": "Start all Butler tasks", - "description": "This endpoint will attempt to start all Butler tasks that are enabled in the settings. Butler tasks normally run automatically during a time window configured on the server's Settings page but can be manually started using this endpoint. Tasks will run with the following criteria:\n\n 1. Any tasks not scheduled to run on the current day will be skipped.\n 2. If a task is configured to run at a random time during the configured window and we are outside that window, the task will start immediately.\n 3. If a task is configured to run at a random time during the configured window and we are within that window, the task will be scheduled at a random time within the window.\n 4. If we are outside the configured window, the task will start immediately.\n", - "operationId": "butlerPostSlash", - "responses": { - "200": { - "$ref": "#/components/responses/200" - } - } - }, - "delete": { - "tags": [ - "Butler" - ], - "security": [ - { - "user_token": [ - "admin" - ] - } - ], - "summary": "Stop all Butler tasks", - "description": "This endpoint will stop all currently running tasks and remove any scheduled tasks from the queue.", - "operationId": "butlerDeleteSlash", - "responses": { - "200": { - "$ref": "#/components/responses/200" - } - } - } - }, - "/butler/{task}": { - "post": { - "tags": [ - "Butler" - ], - "security": [ - { - "user_token": [ - "admin" - ] - } - ], - "summary": "Start a single Butler task", - "description": "This endpoint will attempt to start a specific Butler task by name.\n", - "operationId": "butlerPostTask", - "parameters": [ - { - "in": "path", - "name": "task", - "schema": { - "type": "string", - "enum": [ - "AutomaticUpdates", - "BackupDatabase", - "ButlerTaskGenerateAdMarkers", - "ButlerTaskGenerateCreditsMarkers", - "ButlerTaskGenerateIntroMarkers", - "ButlerTaskGenerateVoiceActivity", - "CleanOldBundles", - "CleanOldCacheFiles", - "DeepMediaAnalysis", - "GarbageCollectBlobs", - "GarbageCollectLibraryMedia", - "GenerateBlurHashes", - "GenerateChapterThumbs", - "GenerateMediaIndexFiles", - "LoudnessAnalysis", - "MusicAnalysis", - "OptimizeDatabase", - "RefreshEpgGuides", - "RefreshLibraries", - "RefreshLocalMedia", - "RefreshPeriodicMetadata", - "UpgradeMediaAnalysis" - ] - }, - "description": "The task name", - "required": true - } - ], - "responses": { - "200": { - "description": "Task started", - "content": { - "text/html": { - "examples": { - "ok": { - "summary": "OK", - "value": "" - } - } - } - } - }, - "202": { - "description": "Task is already running", - "content": { - "text/html": { - "examples": { - "ok": { - "summary": "OK", - "value": "" - } - } - } - } - }, - "404": { - "description": "No task with this name was found", - "content": { - "text/html": { - "examples": { - "notFound": { - "summary": "Not Found", - "value": "Not Found

404 Not Found

" - } - } - } - } - } - } - }, - "delete": { - "tags": [ - "Butler" - ], - "security": [ - { - "user_token": [ - "admin" - ] - } - ], - "summary": "Stop a single Butler task", - "description": "This endpoint will stop a currently running task by name, or remove it from the list of scheduled tasks if it exists\n", - "operationId": "butlerDeleteTask", - "parameters": [ - { - "in": "path", - "name": "task", - "schema": { - "type": "string", - "enum": [ - "AutomaticUpdates", - "BackupDatabase", - "ButlerTaskGenerateAdMarkers", - "ButlerTaskGenerateCreditsMarkers", - "ButlerTaskGenerateIntroMarkers", - "ButlerTaskGenerateVoiceActivity", - "CleanOldBundles", - "CleanOldCacheFiles", - "DeepMediaAnalysis", - "GarbageCollectBlobs", - "GarbageCollectLibraryMedia", - "GenerateBlurHashes", - "GenerateChapterThumbs", - "GenerateMediaIndexFiles", - "LoudnessAnalysis", - "MusicAnalysis", - "OptimizeDatabase", - "RefreshEpgGuides", - "RefreshLibraries", - "RefreshLocalMedia", - "RefreshPeriodicMetadata", - "UpgradeMediaAnalysis" - ] - }, - "description": "The task name", - "required": true - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/200" - }, - "404": { - "description": "No task with this name was found or no task with this name was running", - "content": { - "text/html": { - "examples": { - "notFound": { - "summary": "Not Found", - "value": "Not Found

404 Not Found

" - } - } - } - } - } - } - } - }, - "/downloadQueue": { - "post": { - "tags": [ - "Download Queue" - ], - "operationId": "downloadQueuePost", - "summary": "Get or create a download queue", - "description": "Available: 0.2.0\n\nGet or create a download queue for this client by its client id and for this user as identified by the token\n", - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "properties": { - "MediaContainer": { - "allOf": [ - { - "$ref": "#/components/schemas/MediaContainer" - }, - { - "type": "object", - "properties": { - "DownloadQueue": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "status": { - "type": "string", - "enum": [ - "deciding", - "waiting", - "processing", - "done", - "error" - ], - "description": "The state of this queue\n - deciding: At least one item is still being decided\n - waiting: At least one item is waiting for transcode and none are currently transcoding\n - processing: At least one item is being transcoded\n - done: All items are available (or potentially expired)\n - error: At least one item has encountered an error\n" - }, - "itemCount": { - "type": "integer" - } - } - } - } - } - } - ] - } - } - }, - "examples": { - "empty": { - "value": { - "MediaContainer": { - "size": 1, - "DownloadQueue": [ - { - "id": 1, - "status": "done", - "itemCount": 0 - } - ] - } - } - }, - "processing": { - "value": { - "MediaContainer": { - "size": 1, - "DownloadQueue": [ - { - "id": 1, - "status": "processing", - "itemCount": 32 - } - ] - } - } - } - } - } - } - } - } - } - }, - "/downloadQueue/{queueId}": { - "get": { - "tags": [ - "Download Queue" - ], - "operationId": "downloadQueueGetQueue", - "summary": "Get a download queue", - "description": "Available: 0.2.0\n\nGet a download queue by its id\n", - "parameters": [ - { - "in": "path", - "name": "queueId", - "schema": { - "type": "integer" - }, - "required": true, - "description": "The queue id" - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "properties": { - "MediaContainer": { - "allOf": [ - { - "$ref": "#/components/schemas/MediaContainer" - }, - { - "type": "object", - "properties": { - "DownloadQueue": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "status": { - "type": "string", - "enum": [ - "deciding", - "waiting", - "processing", - "done", - "error" - ], - "description": "The state of this queue\n - deciding: At least one item is still being decided\n - waiting: At least one item is waiting for transcode and none are currently transcoding\n - processing: At least one item is being transcoded\n - done: All items are available (or potentially expired)\n - error: At least one item has encountered an error\n" - }, - "itemCount": { - "type": "integer" - } - } - } - } - } - } - ] - } - } - }, - "examples": { - "empty": { - "value": { - "MediaContainer": { - "size": 1, - "DownloadQueue": [ - { - "id": 1, - "status": "done", - "itemCount": 0 - } - ] - } - } - }, - "processing": { - "value": { - "MediaContainer": { - "size": 1, - "DownloadQueue": [ - { - "id": 1, - "status": "processing", - "itemCount": 32 - } - ] - } - } - } - } - } - } - } - } - } - }, - "/downloadQueue/{queueId}/add": { - "post": { - "tags": [ - "Download Queue" - ], - "operationId": "downloadQueuePostQueueAdd", - "summary": "Add to download queue", - "description": "Available: 0.2.0\n\nAdd items to the download queue\n", - "parameters": [ - { - "in": "path", - "name": "queueId", - "schema": { - "type": "integer" - }, - "required": true, - "description": "The queue id" - }, - { - "in": "query", - "name": "keys", - "schema": { - "type": "array", - "items": { - "type": "string" - } - }, - "explode": false, - "required": true, - "description": "Keys to add", - "example": [ - "/library/metadata/3", - "/library/metadata/6" - ] - }, - { - "$ref": "#/components/parameters/2" - }, - { - "$ref": "#/components/parameters/3" - }, - { - "$ref": "#/components/parameters/4" - }, - { - "$ref": "#/components/parameters/5" - }, - { - "$ref": "#/components/parameters/6" - }, - { - "$ref": "#/components/parameters/7" - }, - { - "$ref": "#/components/parameters/8" - }, - { - "$ref": "#/components/parameters/9" - }, - { - "$ref": "#/components/parameters/10" - }, - { - "$ref": "#/components/parameters/11" - }, - { - "$ref": "#/components/parameters/12" - }, - { - "$ref": "#/components/parameters/13" - }, - { - "$ref": "#/components/parameters/14" - }, - { - "$ref": "#/components/parameters/15" - }, - { - "$ref": "#/components/parameters/16" - }, - { - "$ref": "#/components/parameters/17" - }, - { - "$ref": "#/components/parameters/18" - }, - { - "$ref": "#/components/parameters/19" - }, - { - "$ref": "#/components/parameters/20" - }, - { - "$ref": "#/components/parameters/21" - }, - { - "$ref": "#/components/parameters/22" - }, - { - "$ref": "#/components/parameters/23" - }, - { - "$ref": "#/components/parameters/24" - }, - { - "$ref": "#/components/parameters/25" - }, - { - "$ref": "#/components/parameters/26" - }, - { - "$ref": "#/components/parameters/27" - }, - { - "$ref": "#/components/parameters/28" - }, - { - "$ref": "#/components/parameters/29" - }, - { - "$ref": "#/components/parameters/30" - }, - { - "$ref": "#/components/parameters/31" - }, - { - "$ref": "#/components/parameters/32" - }, - { - "$ref": "#/components/parameters/33" - }, - { - "$ref": "#/components/parameters/34" - }, - { - "$ref": "#/components/parameters/35" - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "properties": { - "MediaContainer": { - "allOf": [ - { - "$ref": "#/components/schemas/MediaContainer" - }, - { - "type": "object", - "properties": { - "AddedQueueItems": { - "type": "array", - "items": { - "type": "object", - "properties": { - "key": { - "type": "string", - "description": "The key added to the queue" - }, - "id": { - "type": "integer", - "description": "The queue item id that was added or the existing one if an item already exists in this queue with the same parameters" - } - } - } - } - } - } - ] - } - } - }, - "examples": { - "addingOneItem": { - "value": { - "MediaContainer": { - "size": 1, - "AddedQueueItems": [ - { - "key": "/library/metadata/146", - "id": 23 - }, - { - "key": "/library/metadata/147", - "id": 24 - } - ] - } - } - } - } - } - } - } - } - } - }, - "/downloadQueue/{queueId}/item/{itemId}/media": { - "get": { - "tags": [ - "Download Queue" - ], - "operationId": "downloadQueueGetQueueItemItemMedia", - "summary": "Grab download queue media", - "description": "Available: 0.2.0\n\nGrab the media for a download queue item\n", - "parameters": [ - { - "in": "path", - "name": "queueId", - "schema": { - "type": "integer" - }, - "required": true, - "description": "The queue id" - }, - { - "in": "path", - "name": "itemId", - "schema": { - "type": "integer" - }, - "example": 32, - "required": true, - "description": "The item ids" - } - ], - "responses": { - "200": { - "description": "The raw media file" - }, - "503": { - "description": "![503](https://http.cat/503.jpg)\n\nThe queue item is not yet complete and is currently transcoding or waiting to transcode\n", - "headers": { - "Retry-After": { - "schema": { - "type": "integer" - }, - "description": "The estimated time before completion or -1 if unknown" - } - } - } - } - } - }, - "/downloadQueue/{queueId}/item/{itemId}/decision": { - "get": { - "tags": [ - "Download Queue" - ], - "operationId": "downloadQueueGetQueueItemItemDecision", - "summary": "Grab download queue item decision", - "description": "Available: 0.2.0\n\nGrab the decision for a download queue item\n", - "parameters": [ - { - "in": "path", - "name": "queueId", - "schema": { - "type": "integer" - }, - "required": true, - "description": "The queue id" - }, - { - "in": "path", - "name": "itemId", - "schema": { - "type": "integer" - }, - "example": 32, - "required": true, - "description": "The item ids" - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/mediaContainerWithDecision" - }, - "examples": { - "Big-buck-bunny Decision": { - "value": { - "MediaContainer": { - "allowSync": "1", - "directPlayDecisionCode": 3000, - "directPlayDecisionText": "App cannot direct play this item. Direct play is disabled.", - "generalDecisionCode": 1001, - "generalDecisionText": "Direct play not available; Conversion OK.", - "identifier": "com.plexapp.plugins.library", - "librarySectionID": "60", - "librarySectionTitle": "Test Files", - "librarySectionUUID": "32ed11af-f829-4ee3-ae64-2665c66ced52", - "mediaTagPrefix": "/system/bundle/media/flags/", - "mediaTagVersion": "1663870359", - "Metadata": [ - { - "addedAt": 1745854354, - "art": "/library/metadata/151671/art/1745854446", - "createdAtAccuracy": "epoch,local", - "createdAtTZOffset": "0", - "duration": 634533, - "Genre": [ - { - "filter": "genre=1328", - "id": "1328", - "tag": "Animation" - } - ], - "guid": "com.plexapp.agents.none://0abf533a5e478d72fb6f2aaa2543511f9bdaa269?lang=xn", - "key": "/library/metadata/151671", - "librarySectionID": "60", - "librarySectionKey": "/library/sections/60", - "librarySectionTitle": "Test Files", - "Media": [ - { - "audioChannels": 2, - "audioCodec": "opus", - "bitrate": 3538, - "container": "mkv", - "duration": 634533, - "hasVoiceActivity": "0", - "height": 720, - "id": "221632", - "Part": [ - { - "bitrate": 3538, - "container": "mkv", - "decision": "transcode", - "duration": 634533, - "height": 720, - "id": "221638", - "protocol": "hls", - "selected": true, - "Stream": [ - { - "bitDepth": 8, - "bitrate": 3419, - "codec": "h264", - "decision": "transcode", - "default": true, - "displayTitle": "1080p", - "extendedDisplayTitle": "1080p (H.264)", - "frameRate": 60, - "height": 720, - "id": "332899", - "location": "segments-av", - "streamType": 1, - "width": 1280 - }, - { - "bitrate": 119, - "bitrateMode": "cbr", - "channels": 2, - "codec": "opus", - "decision": "transcode", - "default": true, - "displayTitle": "Unknown (MP3 Stereo)", - "extendedDisplayTitle": "Unknown (MP3 Stereo)", - "id": "332900", - "location": "segments-av", - "selected": true, - "streamType": 2 - } - ], - "videoProfile": "high", - "width": 1280 - } - ], - "protocol": "hls", - "selected": true, - "videoCodec": "h264", - "videoFrameRate": "60p", - "videoProfile": "high", - "videoResolution": "720p", - "width": 1280 - } - ], - "originallyAvailableAt": "2025-04-28", - "ratingKey": "151671", - "Role": [ - { - "filter": "actor=429547", - "id": "429547", - "tag": "Blender Foundation 2008" - }, - { - "filter": "actor=429548", - "id": "429548", - "tag": "Janus Bager Kristensen 2013" - } - ], - "subtype": "clip", - "thumb": "/library/metadata/151671/thumb/1745854446", - "title": "big-buck-bunny", - "type": "movie", - "UltraBlurColors": [ - { - "bottomLeft": "61252d", - "bottomRight": "2b6770", - "topLeft": "1a2c53", - "topRight": "2b686b" - } - ], - "updatedAt": 1745854446, - "year": 2025 - } - ], - "resourceSession": "E26A4C81-FB5E-4B49-BE2C-5973D7F5A98C", - "size": 1, - "transcodeDecisionCode": 1001, - "transcodeDecisionText": "Direct play not available; Conversion OK." - } - } - } - } - } - } - }, - "400": { - "description": "The item is not in a state where a decision is available", - "content": { - "text/html": { - "examples": { - "badRequest": { - "summary": "A parameter has a bad value or required parameter is missing", - "value": "Bad Request

400 Bad Request

" - } - } - } - } - } - } - } - }, - "/downloadQueue/{queueId}/items": { - "get": { - "tags": [ - "Download Queue" - ], - "operationId": "downloadQueueGetQueueItems", - "summary": "Get download queue items", - "description": "Available: 0.2.0\n\nGet items from a download queue\n", - "parameters": [ - { - "in": "path", - "name": "queueId", - "schema": { - "type": "integer" - }, - "required": true, - "description": "The queue id" - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "properties": { - "MediaContainer": { - "allOf": [ - { - "$ref": "#/components/schemas/MediaContainer" - }, - { - "type": "object", - "properties": { - "DownloadQueueItem": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "queueId": { - "type": "integer" - }, - "key": { - "type": "string" - }, - "status": { - "type": "string", - "enum": [ - "deciding", - "waiting", - "processing", - "available", - "error", - "expired" - ], - "description": "The state of the item:\n - deciding: The item decision is pending\n - waiting: The item is waiting for transcode\n - processing: The item is being transcoded\n - available: The item is available for download\n - error: The item encountered an error in the decision or transcode\n - expired: The transcoded item has timed out and is no longer available\n" - }, - "transcode": { - "type": "object", - "description": "The transcode session object which is not yet documented otherwise it'd be a $ref here." - }, - "error": { - "type": "string", - "description": "The error encountered in transcoding or decision" - }, - "DecisionResult": { - "type": "object", - "properties": { - "mdeDecisionCode": { - "type": "integer", - "description": "The code indicating the status of evaluation of playback when client indicates `hasMDE=1`" - }, - "mdeDecisionText": { - "type": "string", - "description": "Descriptive text for the above code" - }, - "availableBandwidth": { - "type": "integer", - "description": "The maximum bitrate set when item was added" - }, - "generalDecisionCode": { - "type": "integer" - }, - "generalDecisionText": { - "type": "string" - }, - "directPlayDecisionCode": { - "type": "integer" - }, - "directPlayDecisionText": { - "type": "string" - }, - "transcodeDecisionCode": { - "type": "integer" - }, - "transcodeDecisionText": { - "type": "string" - } - } - }, - "TranscodeSession": { - "type": "object", - "description": "The transcode session if item is currently being transcoded.", - "properties": { - "key": { - "type": "string" - }, - "throttled": { - "type": "boolean" - }, - "complete": { - "type": "boolean" - }, - "progress": { - "type": "number", - "minimum": 0, - "maximum": 100 - }, - "size": { - "type": "integer" - }, - "speed": { - "type": "number" - }, - "error": { - "type": "boolean" - }, - "duration": { - "type": "integer" - }, - "context": { - "type": "string" - }, - "sourceVideoCodec": { - "type": "string" - }, - "sourceAudioCodec": { - "type": "string" - }, - "protocol": { - "type": "string" - }, - "transcodeHwRequested": { - "type": "boolean" - }, - "transcodeHwFullPipeline": { - "type": "boolean" - } - } - } - } - } - } - } - } - ] - } - } - }, - "examples": { - "oneItem": { - "value": { - "MediaContainer": { - "size": 1, - "DownloadQueueItem": [ - { - "id": 1, - "queueId": 1, - "key": "/library/metadata/3", - "status": "available", - "DecisionResult": { - "generalDecisionCode": 1000, - "generalDecisionText": "Direct play OK.", - "directPlayDecisionCode": 1000, - "directPlayDecisionText": "Direct play OK." - } - } - ] - } - } - }, - "processing": { - "value": { - "MediaContainer": { - "size": 1, - "DownloadQueueItem": [ - { - "id": 1, - "queueId": 1, - "key": "/library/metadata/3", - "status": "processing", - "DecisionResult": { - "generalDecisionCode": 1001, - "generalDecisionText": "Direct play not available; Conversion OK.", - "directPlayDecisionCode": 3001, - "directPlayDecisionText": "Not enough bandwidth for direct play of this item.", - "transcodeDecisionCode": 1001, - "transcodeDecisionText": "Direct play not available; Conversion OK." - }, - "TranscodeSession": { - "key": "/transcode/sessions/e1a515e6-59c3-4c6a-a98c-0c7f727b7ee8", - "throttled": false, - "complete": false, - "progress": 0, - "size": 0, - "speed": 11.24183464050293, - "error": false, - "duration": 300000000, - "context": "streaming", - "sourceVideoCodec": "h264", - "sourceAudioCodec": "aac", - "protocol": "http", - "transcodeHwRequested": false, - "transcodeHwFullPipeline": false - } - } - ] - } - } - } - } - } - } - } - } - } - }, - "/downloadQueue/{queueId}/items/{itemId}": { - "get": { - "tags": [ - "Download Queue" - ], - "operationId": "downloadQueueGetQueueItemsItem", - "summary": "Get download queue items", - "description": "Available: 0.2.0\n\nGet items from a download queue\n", - "parameters": [ - { - "in": "path", - "name": "queueId", - "schema": { - "type": "integer" - }, - "required": true, - "description": "The queue id" - }, - { - "in": "path", - "name": "itemId", - "schema": { - "type": "array", - "items": { - "type": "integer" - } - }, - "explode": false, - "example": [ - 32, - 345, - 23 - ], - "required": true, - "description": "The item ids" - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "properties": { - "MediaContainer": { - "allOf": [ - { - "$ref": "#/components/schemas/MediaContainer" - }, - { - "type": "object", - "properties": { - "DownloadQueueItem": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "queueId": { - "type": "integer" - }, - "key": { - "type": "string" - }, - "status": { - "type": "string", - "enum": [ - "deciding", - "waiting", - "processing", - "available", - "error", - "expired" - ], - "description": "The state of the item:\n - deciding: The item decision is pending\n - waiting: The item is waiting for transcode\n - processing: The item is being transcoded\n - available: The item is available for download\n - error: The item encountered an error in the decision or transcode\n - expired: The transcoded item has timed out and is no longer available\n" - }, - "transcode": { - "type": "object", - "description": "The transcode session object which is not yet documented otherwise it'd be a $ref here." - }, - "error": { - "type": "string", - "description": "The error encountered in transcoding or decision" - }, - "DecisionResult": { - "type": "object", - "properties": { - "mdeDecisionCode": { - "type": "integer", - "description": "The code indicating the status of evaluation of playback when client indicates `hasMDE=1`" - }, - "mdeDecisionText": { - "type": "string", - "description": "Descriptive text for the above code" - }, - "availableBandwidth": { - "type": "integer", - "description": "The maximum bitrate set when item was added" - }, - "generalDecisionCode": { - "type": "integer" - }, - "generalDecisionText": { - "type": "string" - }, - "directPlayDecisionCode": { - "type": "integer" - }, - "directPlayDecisionText": { - "type": "string" - }, - "transcodeDecisionCode": { - "type": "integer" - }, - "transcodeDecisionText": { - "type": "string" - } - } - }, - "TranscodeSession": { - "type": "object", - "description": "The transcode session if item is currently being transcoded.", - "properties": { - "key": { - "type": "string" - }, - "throttled": { - "type": "boolean" - }, - "complete": { - "type": "boolean" - }, - "progress": { - "type": "number", - "minimum": 0, - "maximum": 100 - }, - "size": { - "type": "integer" - }, - "speed": { - "type": "number" - }, - "error": { - "type": "boolean" - }, - "duration": { - "type": "integer" - }, - "context": { - "type": "string" - }, - "sourceVideoCodec": { - "type": "string" - }, - "sourceAudioCodec": { - "type": "string" - }, - "protocol": { - "type": "string" - }, - "transcodeHwRequested": { - "type": "boolean" - }, - "transcodeHwFullPipeline": { - "type": "boolean" - } - } - } - } - } - } - } - } - ] - } - } - }, - "examples": { - "oneItem": { - "value": { - "MediaContainer": { - "size": 1, - "DownloadQueueItem": [ - { - "id": 1, - "queueId": 1, - "key": "/library/metadata/3", - "status": "available", - "DecisionResult": { - "generalDecisionCode": 1000, - "generalDecisionText": "Direct play OK.", - "directPlayDecisionCode": 1000, - "directPlayDecisionText": "Direct play OK." - } - } - ] - } - } - }, - "processing": { - "value": { - "MediaContainer": { - "size": 1, - "DownloadQueueItem": [ - { - "id": 1, - "queueId": 1, - "key": "/library/metadata/3", - "status": "processing", - "DecisionResult": { - "generalDecisionCode": 1001, - "generalDecisionText": "Direct play not available; Conversion OK.", - "directPlayDecisionCode": 3001, - "directPlayDecisionText": "Not enough bandwidth for direct play of this item.", - "transcodeDecisionCode": 1001, - "transcodeDecisionText": "Direct play not available; Conversion OK." - }, - "TranscodeSession": { - "key": "/transcode/sessions/e1a515e6-59c3-4c6a-a98c-0c7f727b7ee8", - "throttled": false, - "complete": false, - "progress": 0, - "size": 0, - "speed": 11.24183464050293, - "error": false, - "duration": 300000000, - "context": "streaming", - "sourceVideoCodec": "h264", - "sourceAudioCodec": "aac", - "protocol": "http", - "transcodeHwRequested": false, - "transcodeHwFullPipeline": false - } - } - ] - } - } - } - } - } - } - } - } - }, - "delete": { - "tags": [ - "Download Queue" - ], - "operationId": "downloadQueueDeleteQueueItemsItem", - "summary": "Delete download queue items", - "description": "delete items from a download queue", - "parameters": [ - { - "in": "path", - "name": "queueId", - "schema": { - "type": "integer" - }, - "required": true, - "description": "The queue id" - }, - { - "in": "path", - "name": "itemId", - "schema": { - "type": "array", - "items": { - "type": "integer" - } - }, - "explode": false, - "example": [ - 32, - 345, - 23 - ], - "required": true, - "description": "The item id" - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/200" - } - } - } - }, - "/downloadQueue/{queueId}/items/{itemId}/restart": { - "post": { - "tags": [ - "Download Queue" - ], - "operationId": "downloadQueuePostQueueItemsItemRestart", - "summary": "Restart processing of items from the decision", - "description": "Available: 0.2.0\n\nReprocess download queue items with previous decision parameters\n", - "parameters": [ - { - "in": "path", - "name": "queueId", - "schema": { - "type": "integer" - }, - "required": true, - "description": "The queue id" - }, - { - "in": "path", - "name": "itemId", - "schema": { - "type": "array", - "items": { - "type": "integer" - } - }, - "explode": false, - "example": [ - 32, - 345, - 23 - ], - "required": true, - "description": "The item ids" - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/200" - } - } - } - }, - "/identity": { - "get": { - "tags": [ - "General" - ], - "summary": "Get PMS identity", - "description": "Get details about this PMS's identity", - "operationId": "getIdentity", - "security": [ - {} - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "MediaContainer": { - "type": "object", - "properties": { - "size": { - "type": "integer" - }, - "claimed": { - "type": "boolean", - "description": "Indicates whether this server has been claimed by a user" - }, - "machineIdentifier": { - "type": "string", - "description": "A unique identifier of the computer" - }, - "version": { - "type": "string", - "description": "The full version string of the PMS" - } - } - } - } - }, - "examples": { - "identity": { - "value": { - "MediaContainer": { - "size": 1, - "claimed": true, - "machineIdentifier": "0123456789abcdef0123456789abcdef", - "version": "1.40.2.8395-c67dce28e" - } - } - } - } - } - } - } - } - } - }, - "/hubs": { - "get": { - "tags": [ - "Hubs" - ], - "summary": "Get global hubs", - "description": "Get the global hubs in this PMS", - "operationId": "hubsGetSlash", - "parameters": [ - { - "in": "query", - "name": "count", - "schema": { - "type": "integer" - }, - "description": "Limit hub entries to count items" - }, - { - "in": "query", - "name": "onlyTransient", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "description": "Only return hubs which are \"transient\", meaning those which are prone to changing after media playback or addition (e.g. On Deck, or Recently Added)" - }, - { - "in": "query", - "name": "identifier", - "schema": { - "type": "array", - "items": { - "type": "string" - } - }, - "description": "If provided, limit to only specified hubs" - } - ], - "responses": { - "200": { - "description": "OK", - "headers": { - "X-Plex-Container-Total-Size": { - "description": "Provided on all MediaContainer objects indicating the total size of objects available", - "schema": { - "type": "integer" - } - }, - "X-Plex-Container-Start": { - "description": "Provided on all MediaContainer objects indicating the offset of where this container page starts", - "schema": { - "type": "integer" - } - } - }, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "MediaContainer": { - "allOf": [ - { - "$ref": "#/components/schemas/MediaContainer" - }, - { - "type": "object", - "properties": { - "Hub": { - "type": "array", - "items": { - "$ref": "#/components/schemas/hub" - } - } - } - } - ] - } - } - }, - "examples": { - "someHubs": { - "summary": "An example of global hubs", - "value": { - "MediaContainer": { - "size": 8, - "allowSync": true, - "identifier": "com.plexapp.plugins.library", - "Hub": [ - { - "hubKey": "/library/metadata/37", - "key": "/hubs/home/continueWatching", - "title": "Continue Watching", - "type": "mixed", - "hubIdentifier": "home.continue", - "context": "hub.home.continue", - "size": 1, - "more": false, - "style": "hero", - "promoted": true, - "Metadata": [] - }, - { - "hubKey": "/library/metadata/217", - "key": "/hubs/home/onDeck", - "title": "On Deck", - "type": "episode", - "hubIdentifier": "home.ondeck", - "context": "hub.home.ondeck", - "size": 1, - "more": false, - "style": "shelf", - "promoted": true, - "Metadata": [] - }, - { - "hubKey": "/library/metadata/146,37,38,67,81,3", - "key": "/hubs/home/recentlyAdded?type=1", - "title": "Recently Added Movies", - "type": "movie", - "hubIdentifier": "home.movies.recent", - "context": "hub.home.movies.recent", - "size": 6, - "more": true, - "style": "shelf", - "promoted": true, - "Metadata": [] - }, - { - "hubKey": "/library/metadata/192,164,203,155,199,236", - "key": "/hubs/home/recentlyAdded?type=2", - "title": "Recently Added TV", - "type": "mixed", - "hubIdentifier": "home.television.recent", - "context": "hub.home.television.recent", - "size": 6, - "more": true, - "style": "shelf", - "promoted": true, - "Metadata": [] - }, - { - "hubKey": "/library/metadata/370,365,334,308,294,281", - "key": "/hubs/home/recentlyAdded?type=8", - "title": "Recently Added Music", - "type": "album", - "hubIdentifier": "home.music.recent", - "context": "hub.home.music.recent", - "size": 6, - "more": true, - "style": "shelf", - "promoted": true, - "Metadata": [] - }, - { - "hubKey": "/library/metadata/3390,3391,3392,3393,4230,4229", - "key": "/hubs/home/recentlyAdded?type=13", - "title": "Recently Added Photos", - "type": "photo", - "hubIdentifier": "home.photos.recent", - "context": "hub.home.photos.recent", - "size": 6, - "more": true, - "style": "shelf", - "promoted": true, - "Metadata": [] - }, - { - "hubKey": "/library/metadata/3376,3339,3340,3341,3342,3343", - "key": "/hubs/home/recentlyAdded?type=1&personal=1", - "title": "Recently Added Videos", - "type": "clip", - "hubIdentifier": "home.videos.recent", - "context": "hub.home.videos.recent", - "size": 6, - "more": true, - "style": "shelf", - "promoted": true, - "Metadata": [] - }, - { - "hubKey": "/library/metadata/3373,3225", - "key": "/playlists/all?type=15&sort=lastViewedAt:desc&playlistType=video,audio", - "title": "Recent Playlists", - "type": "playlist", - "hubIdentifier": "home.playlists", - "context": "hub.home.playlists", - "size": 2, - "more": false, - "style": "shelf", - "promoted": true, - "Metadata": [] - } - ] - } - } - } - } - } - } - } - } - } - }, - "/hubs/items": { - "get": { - "tags": [ - "Hubs" - ], - "summary": "Get a hub's items", - "description": "Get the items within a single hub specified by identifier", - "operationId": "hubsGetItems", - "parameters": [ - { - "in": "query", - "name": "count", - "schema": { - "type": "integer" - }, - "description": "Limit hub entry to count items" - }, - { - "in": "query", - "name": "identifier", - "schema": { - "type": "array", - "items": { - "type": "string" - } - }, - "required": true, - "description": "If provided, limit to only specified hubs" - } - ], - "responses": { - "200": { - "description": "OK", - "headers": { - "X-Plex-Container-Total-Size": { - "description": "Provided on all MediaContainer objects indicating the total size of objects available", - "schema": { - "type": "integer" - } - }, - "X-Plex-Container-Start": { - "description": "Provided on all MediaContainer objects indicating the offset of where this container page starts", - "schema": { - "type": "integer" - } - } - }, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "MediaContainer": { - "$ref": "#/components/schemas/properties-MediaContainer" - } - } - } - } - } - }, - "404": { - "description": "The specified hub could not be found", - "content": { - "text/html": { - "examples": { - "notFound": { - "summary": "Not Found", - "value": "Not Found

404 Not Found

" - } - } - } - } - } - } - } - }, - "/hubs/continueWatching": { - "get": { - "tags": [ - "Hubs" - ], - "summary": "Get the continue watching hub", - "description": "Get the global continue watching hub", - "operationId": "hubsGetContinueWatching", - "parameters": [ - { - "in": "query", - "name": "count", - "schema": { - "type": "integer" - }, - "description": "Limit hub entry to count items" - } - ], - "responses": { - "200": { - "description": "OK", - "headers": { - "X-Plex-Container-Total-Size": { - "description": "Provided on all MediaContainer objects indicating the total size of objects available", - "schema": { - "type": "integer" - } - }, - "X-Plex-Container-Start": { - "description": "Provided on all MediaContainer objects indicating the offset of where this container page starts", - "schema": { - "type": "integer" - } - } - }, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "MediaContainer": { - "allOf": [ - { - "$ref": "#/components/schemas/MediaContainer" - }, - { - "type": "object", - "properties": { - "Hub": { - "type": "array", - "items": { - "$ref": "#/components/schemas/hub" - } - } - } - } - ] - } - } - }, - "examples": { - "someHubs": { - "summary": "An example of global hubs", - "value": { - "MediaContainer": { - "size": 8, - "allowSync": true, - "identifier": "com.plexapp.plugins.library", - "Hub": [ - { - "hubKey": "/library/metadata/37", - "key": "/hubs/home/continueWatching", - "title": "Continue Watching", - "type": "mixed", - "hubIdentifier": "home.continue", - "context": "hub.home.continue", - "size": 1, - "more": false, - "style": "hero", - "promoted": true, - "Metadata": [] - }, - { - "hubKey": "/library/metadata/217", - "key": "/hubs/home/onDeck", - "title": "On Deck", - "type": "episode", - "hubIdentifier": "home.ondeck", - "context": "hub.home.ondeck", - "size": 1, - "more": false, - "style": "shelf", - "promoted": true, - "Metadata": [] - }, - { - "hubKey": "/library/metadata/146,37,38,67,81,3", - "key": "/hubs/home/recentlyAdded?type=1", - "title": "Recently Added Movies", - "type": "movie", - "hubIdentifier": "home.movies.recent", - "context": "hub.home.movies.recent", - "size": 6, - "more": true, - "style": "shelf", - "promoted": true, - "Metadata": [] - }, - { - "hubKey": "/library/metadata/192,164,203,155,199,236", - "key": "/hubs/home/recentlyAdded?type=2", - "title": "Recently Added TV", - "type": "mixed", - "hubIdentifier": "home.television.recent", - "context": "hub.home.television.recent", - "size": 6, - "more": true, - "style": "shelf", - "promoted": true, - "Metadata": [] - }, - { - "hubKey": "/library/metadata/370,365,334,308,294,281", - "key": "/hubs/home/recentlyAdded?type=8", - "title": "Recently Added Music", - "type": "album", - "hubIdentifier": "home.music.recent", - "context": "hub.home.music.recent", - "size": 6, - "more": true, - "style": "shelf", - "promoted": true, - "Metadata": [] - }, - { - "hubKey": "/library/metadata/3390,3391,3392,3393,4230,4229", - "key": "/hubs/home/recentlyAdded?type=13", - "title": "Recently Added Photos", - "type": "photo", - "hubIdentifier": "home.photos.recent", - "context": "hub.home.photos.recent", - "size": 6, - "more": true, - "style": "shelf", - "promoted": true, - "Metadata": [] - }, - { - "hubKey": "/library/metadata/3376,3339,3340,3341,3342,3343", - "key": "/hubs/home/recentlyAdded?type=1&personal=1", - "title": "Recently Added Videos", - "type": "clip", - "hubIdentifier": "home.videos.recent", - "context": "hub.home.videos.recent", - "size": 6, - "more": true, - "style": "shelf", - "promoted": true, - "Metadata": [] - }, - { - "hubKey": "/library/metadata/3373,3225", - "key": "/playlists/all?type=15&sort=lastViewedAt:desc&playlistType=video,audio", - "title": "Recent Playlists", - "type": "playlist", - "hubIdentifier": "home.playlists", - "context": "hub.home.playlists", - "size": 2, - "more": false, - "style": "shelf", - "promoted": true, - "Metadata": [] - } - ] - } - } - } - } - } - } - } - } - } - }, - "/hubs/promoted": { - "get": { - "tags": [ - "Hubs" - ], - "summary": "Get the hubs which are promoted", - "description": "Get the global hubs which are promoted (should be displayed on the home screen)", - "operationId": "hubsGetPromoted", - "parameters": [ - { - "in": "query", - "name": "count", - "schema": { - "type": "integer" - }, - "description": "Limit hub entry to count items" - } - ], - "responses": { - "200": { - "description": "OK", - "headers": { - "X-Plex-Container-Total-Size": { - "description": "Provided on all MediaContainer objects indicating the total size of objects available", - "schema": { - "type": "integer" - } - }, - "X-Plex-Container-Start": { - "description": "Provided on all MediaContainer objects indicating the offset of where this container page starts", - "schema": { - "type": "integer" - } - } - }, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "MediaContainer": { - "allOf": [ - { - "$ref": "#/components/schemas/MediaContainer" - }, - { - "type": "object", - "properties": { - "Hub": { - "type": "array", - "items": { - "$ref": "#/components/schemas/hub" - } - } - } - } - ] - } - } - }, - "examples": { - "someHubs": { - "summary": "An example of global hubs", - "value": { - "MediaContainer": { - "size": 8, - "allowSync": true, - "identifier": "com.plexapp.plugins.library", - "Hub": [ - { - "hubKey": "/library/metadata/37", - "key": "/hubs/home/continueWatching", - "title": "Continue Watching", - "type": "mixed", - "hubIdentifier": "home.continue", - "context": "hub.home.continue", - "size": 1, - "more": false, - "style": "hero", - "promoted": true, - "Metadata": [] - }, - { - "hubKey": "/library/metadata/217", - "key": "/hubs/home/onDeck", - "title": "On Deck", - "type": "episode", - "hubIdentifier": "home.ondeck", - "context": "hub.home.ondeck", - "size": 1, - "more": false, - "style": "shelf", - "promoted": true, - "Metadata": [] - }, - { - "hubKey": "/library/metadata/146,37,38,67,81,3", - "key": "/hubs/home/recentlyAdded?type=1", - "title": "Recently Added Movies", - "type": "movie", - "hubIdentifier": "home.movies.recent", - "context": "hub.home.movies.recent", - "size": 6, - "more": true, - "style": "shelf", - "promoted": true, - "Metadata": [] - }, - { - "hubKey": "/library/metadata/192,164,203,155,199,236", - "key": "/hubs/home/recentlyAdded?type=2", - "title": "Recently Added TV", - "type": "mixed", - "hubIdentifier": "home.television.recent", - "context": "hub.home.television.recent", - "size": 6, - "more": true, - "style": "shelf", - "promoted": true, - "Metadata": [] - }, - { - "hubKey": "/library/metadata/370,365,334,308,294,281", - "key": "/hubs/home/recentlyAdded?type=8", - "title": "Recently Added Music", - "type": "album", - "hubIdentifier": "home.music.recent", - "context": "hub.home.music.recent", - "size": 6, - "more": true, - "style": "shelf", - "promoted": true, - "Metadata": [] - }, - { - "hubKey": "/library/metadata/3390,3391,3392,3393,4230,4229", - "key": "/hubs/home/recentlyAdded?type=13", - "title": "Recently Added Photos", - "type": "photo", - "hubIdentifier": "home.photos.recent", - "context": "hub.home.photos.recent", - "size": 6, - "more": true, - "style": "shelf", - "promoted": true, - "Metadata": [] - }, - { - "hubKey": "/library/metadata/3376,3339,3340,3341,3342,3343", - "key": "/hubs/home/recentlyAdded?type=1&personal=1", - "title": "Recently Added Videos", - "type": "clip", - "hubIdentifier": "home.videos.recent", - "context": "hub.home.videos.recent", - "size": 6, - "more": true, - "style": "shelf", - "promoted": true, - "Metadata": [] - }, - { - "hubKey": "/library/metadata/3373,3225", - "key": "/playlists/all?type=15&sort=lastViewedAt:desc&playlistType=video,audio", - "title": "Recent Playlists", - "type": "playlist", - "hubIdentifier": "home.playlists", - "context": "hub.home.playlists", - "size": 2, - "more": false, - "style": "shelf", - "promoted": true, - "Metadata": [] - } - ] - } - } - } - } - } - } - } - } - } - }, - "/hubs/metadata/{metadataId}": { - "get": { - "tags": [ - "Hubs" - ], - "summary": "Get hubs for a section by metadata item", - "description": "Get the hubs for a section by metadata item. Currently only for music sections", - "operationId": "hubsGetMetadataMetadata", - "parameters": [ - { - "in": "path", - "name": "metadataId", - "schema": { - "type": "integer" - }, - "description": "The metadata ID for the hubs to fetch", - "required": true - }, - { - "in": "query", - "name": "count", - "schema": { - "type": "integer" - }, - "description": "Limit hub entries to count items" - }, - { - "in": "query", - "name": "onlyTransient", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "description": "Only return hubs which are \"transient\", meaning those which are prone to changing after media playback or addition (e.g. On Deck, or Recently Added)" - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/responses-200" - }, - "400": { - "description": "No metadata with that id or permission is denied", - "content": { - "text/html": { - "examples": { - "badRequest": { - "summary": "A parameter has a bad value or required parameter is missing", - "value": "Bad Request

400 Bad Request

" - } - } - } - } - } - } - } - }, - "/hubs/metadata/{metadataId}/related": { - "get": { - "tags": [ - "Hubs" - ], - "summary": "Get related hubs", - "description": "Get the hubs for a metadata related to the provided metadata item", - "operationId": "hubsGetMetadataMetadataRelated", - "parameters": [ - { - "in": "path", - "name": "metadataId", - "schema": { - "type": "integer" - }, - "description": "The metadata ID for the hubs to fetch", - "required": true - }, - { - "in": "query", - "name": "count", - "schema": { - "type": "integer" - }, - "description": "Limit hub entries to count items" - }, - { - "in": "query", - "name": "onlyTransient", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "description": "Only return hubs which are \"transient\", meaning those which are prone to changing after media playback or addition (e.g. On Deck, or Recently Added)" - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/responses-200" - }, - "400": { - "description": "No metadata with that id or permission is denied", - "content": { - "text/html": { - "examples": { - "badRequest": { - "summary": "A parameter has a bad value or required parameter is missing", - "value": "Bad Request

400 Bad Request

" - } - } - } - } - } - } - } - }, - "/hubs/metadata/{metadataId}/postplay": { - "get": { - "tags": [ - "Hubs" - ], - "summary": "Get postplay hubs", - "description": "Get the hubs for a metadata to be displayed in post play", - "operationId": "hubsGetMetadataMetadataPostplay", - "parameters": [ - { - "in": "path", - "name": "metadataId", - "schema": { - "type": "integer" - }, - "description": "The metadata ID for the hubs to fetch", - "required": true - }, - { - "in": "query", - "name": "count", - "schema": { - "type": "integer" - }, - "description": "Limit hub entries to count items" - }, - { - "in": "query", - "name": "onlyTransient", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "description": "Only return hubs which are \"transient\", meaning those which are prone to changing after media playback or addition (e.g. On Deck, or Recently Added)" - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/responses-200" - }, - "400": { - "description": "No metadata with that id or permission is denied", - "content": { - "text/html": { - "examples": { - "badRequest": { - "summary": "A parameter has a bad value or required parameter is missing", - "value": "Bad Request

400 Bad Request

" - } - } - } - } - } - } - } - }, - "/hubs/search": { - "get": { - "tags": [ - "Search" - ], - "summary": "Search Hub", - "description": "Perform a search and get the result as hubs\n\nThis endpoint performs a search across all library sections, or a single section, and returns matches as hubs, split up by type. It performs spell checking, looks for partial matches, and orders the hubs based on quality of results. In addition, based on matches, it will return other related matches (e.g. for a genre match, it may return movies in that genre, or for an actor match, movies with that actor).\n\nIn the response's items, the following extra attributes are returned to further describe or disambiguate the result:\n\n- `reason`: The reason for the result, if not because of a direct search term match; can be either:\n - `section`: There are multiple identical results from different sections.\n - `originalTitle`: There was a search term match from the original title field (sometimes those can be very different or in a foreign language).\n - ``: If the reason for the result is due to a result in another hub, the source hub identifier is returned. For example, if the search is for \"dylan\" then Bob Dylan may be returned as an artist result, an a few of his albums returned as album results with a reason code of `artist` (the identifier of that particular hub). Or if the search is for \"arnold\", there might be movie results returned with a reason of `actor`\n- `reasonTitle`: The string associated with the reason code. For a section reason, it'll be the section name; For a hub identifier, it'll be a string associated with the match (e.g. `Arnold Schwarzenegger` for movies which were returned because the search was for \"arnold\").\n- `reasonID`: The ID of the item associated with the reason for the result. This might be a section ID, a tag ID, an artist ID, or a show ID.\n\nThis request is intended to be very fast, and called as the user types.\n", - "operationId": "hubsGetSearch", - "parameters": [ - { - "in": "query", - "name": "query", - "schema": { - "type": "string" - }, - "required": true, - "description": "The query term" - }, - { - "in": "query", - "name": "sectionId", - "schema": { - "type": "integer" - }, - "example": 1, - "description": "This gives context to the search, and can result in re-ordering of search result hubs." - }, - { - "in": "query", - "name": "limit", - "schema": { - "type": "integer" - }, - "description": "The number of items to return per hub. 3 if not specified" - } - ], - "responses": { - "200": { - "description": "OK", - "headers": { - "X-Plex-Container-Total-Size": { - "description": "Provided on all MediaContainer objects indicating the total size of objects available", - "schema": { - "type": "integer" - } - }, - "X-Plex-Container-Start": { - "description": "Provided on all MediaContainer objects indicating the offset of where this container page starts", - "schema": { - "type": "integer" - } - } - }, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "MediaContainer": { - "allOf": [ - { - "$ref": "#/components/schemas/MediaContainer" - }, - { - "type": "object", - "properties": { - "Hub": { - "type": "array", - "items": { - "$ref": "#/components/schemas/hub" - } - } - } - } - ] - } - } - }, - "examples": { - "simpson": { - "summary": "An example of search for `simpsons`", - "value": { - "MediaContainer": { - "size": 12, - "Hub": [ - { - "hubIdentifier": "show", - "more": false, - "size": 1, - "title": "Shows", - "type": "show", - "Directory": [ - { - "title": "The Simpsons", - "titleSort": "Simpsons", - "type": "show", - "more": "metadata" - } - ] - }, - { - "hubIdentifier": "movie", - "more": false, - "size": 1, - "title": "Movies", - "type": "movie", - "Metadata": [ - { - "title": "The Simpsons Movie", - "type": "movie", - "more": "metadata" - } - ] - } - ] - } - } - } - } - } - } - }, - "400": { - "description": "A required parameter was not given, the wrong type, or wrong value", - "content": { - "text/html": { - "examples": { - "badRequest": { - "summary": "A parameter has a bad value or required parameter is missing", - "value": "Bad Request

400 Bad Request

" - } - } - } - } - }, - "404": { - "description": "Search restrictions result in no possible items found (such as searching no sections)", - "content": { - "text/html": { - "examples": { - "notFound": { - "summary": "Not Found", - "value": "Not Found

404 Not Found

" - } - } - } - } - } - } - } - }, - "/hubs/search/voice": { - "get": { - "tags": [ - "Search" - ], - "summary": "Voice Search Hub", - "description": "Perform a search tailored to voice input and get the result as hubs\n\nThis endpoint performs a search specifically tailored towards voice or other imprecise input which may work badly with the substring and spell-checking heuristics used by the `/hubs/search` endpoint. It uses a [Levenshtein distance](https://en.wikipedia.org/wiki/Levenshtein_distance) heuristic to search titles, and as such is much slower than the other search endpoint. Whenever possible, clients should limit the search to the appropriate type.\n\nResults, as well as their containing per-type hubs, contain a `distance` attribute which can be used to judge result quality.\n", - "operationId": "hubsSearchGetVoice", - "parameters": [ - { - "in": "query", - "name": "query", - "schema": { - "type": "string" - }, - "required": true, - "description": "The query term" - }, - { - "in": "query", - "name": "type", - "schema": { - "type": "integer" - }, - "example": 8, - "description": "The type of thing to limit the search to." - }, - { - "in": "query", - "name": "limit", - "schema": { - "type": "integer" - }, - "description": "The number of items to return per hub. 3 if not specified" - } - ], - "responses": { - "200": { - "description": "OK", - "headers": { - "X-Plex-Container-Total-Size": { - "description": "Provided on all MediaContainer objects indicating the total size of objects available", - "schema": { - "type": "integer" - } - }, - "X-Plex-Container-Start": { - "description": "Provided on all MediaContainer objects indicating the offset of where this container page starts", - "schema": { - "type": "integer" - } - } - }, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "MediaContainer": { - "allOf": [ - { - "$ref": "#/components/schemas/MediaContainer" - }, - { - "type": "object", - "properties": { - "Hub": { - "type": "array", - "items": { - "$ref": "#/components/schemas/hub" - } - } - } - } - ] - } - } - }, - "examples": { - "simpson": { - "summary": "An example of search for `simpsons`", - "value": { - "MediaContainer": { - "size": 2, - "Hub": [ - { - "distance": 3, - "hubIdentifier": "results.search.1", - "size": 2, - "title": "movie", - "type": "movie", - "Metadata": [ - { - "distance": 3, - "title": "Deadpool", - "type": "movie" - }, - { - "distance": 4, - "title": "Dead Snow", - "type": "movie" - } - ] - }, - { - "distance": 4, - "hubIdentifier": "results.search.2", - "size": 1, - "title": "show", - "type": "show", - "Directory": [ - { - "distance": 4, - "title": "Deadwood", - "type": "show" - } - ] - } - ] - } - } - } - } - } - } - }, - "400": { - "description": "A required parameter was not given, the wrong type, or wrong value", - "content": { - "text/html": { - "examples": { - "badRequest": { - "summary": "A parameter has a bad value or required parameter is missing", - "value": "Bad Request

400 Bad Request

" - } - } - } - } - } - } - } - }, - "/hubs/sections/{sectionId}": { - "get": { - "tags": [ - "Hubs" - ], - "summary": "Get section hubs", - "description": "Get the hubs for a single section", - "operationId": "hubsGetSection", - "parameters": [ - { - "in": "path", - "name": "sectionId", - "schema": { - "type": "integer" - }, - "description": "The section ID for the hubs to fetch", - "required": true - }, - { - "in": "query", - "name": "count", - "schema": { - "type": "integer" - }, - "description": "Limit hub entries to count items" - }, - { - "in": "query", - "name": "onlyTransient", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "description": "Only return hubs which are \"transient\", meaning those which are prone to changing after media playback or addition (e.g. On Deck, or Recently Added)" - } - ], - "responses": { - "200": { - "description": "OK", - "headers": { - "X-Plex-Container-Total-Size": { - "description": "Provided on all MediaContainer objects indicating the total size of objects available", - "schema": { - "type": "integer" - } - }, - "X-Plex-Container-Start": { - "description": "Provided on all MediaContainer objects indicating the offset of where this container page starts", - "schema": { - "type": "integer" - } - } - }, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "MediaContainer": { - "allOf": [ - { - "$ref": "#/components/schemas/MediaContainer" - }, - { - "type": "object", - "properties": { - "Hub": { - "type": "array", - "items": { - "$ref": "#/components/schemas/hub" - } - } - } - } - ] - } - } - }, - "examples": { - "someHubs": { - "summary": "An example of a movie section hubs", - "value": { - "MediaContainer": { - "size": 7, - "allowSync": true, - "identifier": "com.plexapp.plugins.library", - "librarySectionID": 1, - "librarySectionTitle": "Movies", - "librarySectionUUID": "82503060-0d68-4603-b594-8b071d54819e", - "Hub": [ - { - "hubKey": "/library/metadata/37", - "key": "/hubs/sections/1/continueWatching/items", - "title": "Continue Watching", - "type": "movie", - "hubIdentifier": "movie.inprogress.1", - "context": "hub.movie.inprogress", - "size": 1, - "more": false, - "style": "shelf", - "Metadata": [] - }, - { - "key": "/library/sections/1/all?sort=originallyAvailableAt:desc&originallyAvailableAt>=-1y", - "title": "Recently Released Movies", - "type": "movie", - "hubIdentifier": "movie.recentlyreleased.1", - "context": "hub.movie.recentlyreleased", - "size": 0, - "more": false, - "style": "shelf" - }, - { - "hubKey": "/library/metadata/146,37,38,67,81,3", - "key": "/library/sections/1/all?sort=addedAt:desc", - "title": "Recently Added in Movies", - "type": "movie", - "hubIdentifier": "movie.recentlyadded.1", - "context": "hub.movie.recentlyadded", - "size": 6, - "more": true, - "style": "shelf", - "promoted": true, - "Metadata": [] - }, - { - "hubKey": "/library/metadata/146,81", - "key": "/library/sections/1/all?unwatched=1&genre=176&audienceRating>=7.0", - "title": "Top Movies in Comedy", - "type": "movie", - "hubIdentifier": "movie.genre.1.176", - "context": "hub.movie.genre", - "size": 2, - "more": false, - "style": "shelf", - "Metadata": [] - }, - { - "key": "/library/sections/1/all?unwatched=1&director=252&sort=audienceRating:desc", - "title": "Top Movies by James Ford Murphy", - "type": "movie", - "hubIdentifier": "movie.by.actor.or.director.1.252", - "context": "hub.movie.by.actor.or.director", - "size": 0, - "more": false, - "random": true, - "style": "shelf" - }, - { - "hubKey": "/library/metadata/121,65,123,2,146,81", - "key": "/library/sections/1/all?unwatched=1&audienceRating>=7.0&sort=audienceRating:desc", - "title": "Top Unwatched Movies", - "type": "movie", - "hubIdentifier": "movie.topunwatched.1", - "context": "hub.movie.topunwatched", - "size": 6, - "more": false, - "random": true, - "style": "shelf", - "Metadata": [] - }, - { - "hubKey": "/library/metadata/66,38,78,4,1,3", - "key": "/library/sections/1/all?sort=lastViewedAt:desc&unwatched=0&viewOffset=0", - "title": "Recently Watched Movies", - "type": "movie", - "hubIdentifier": "movie.recentlyviewed.1", - "context": "hub.movie.recentlyviewed", - "size": 6, - "more": true, - "style": "shelf", - "Metadata": [] - } - ] - } - } - } - } - } - } - }, - "400": { - "description": "No section with that id or permission is denied", - "content": { - "text/html": { - "examples": { - "badRequest": { - "summary": "A parameter has a bad value or required parameter is missing", - "value": "Bad Request

400 Bad Request

" - } - } - } - } - } - } - } - }, - "/hubs/sections/{sectionId}/manage": { - "get": { - "tags": [ - "Hubs" - ], - "security": [ - { - "user_token": [ - "admin" - ] - } - ], - "summary": "Get hubs", - "description": "Get the list of hubs including both built-in and custom", - "operationId": "hubsSectionsSectionManageGetSlash", - "parameters": [ - { - "in": "path", - "name": "sectionId", - "schema": { - "type": "integer" - }, - "description": "The section ID for the hubs to reorder", - "required": true - }, - { - "in": "query", - "name": "metadataItemId", - "schema": { - "type": "integer" - }, - "description": "Restrict hubs to ones relevant to the provided metadata item" - } - ], - "responses": { - "200": { - "description": "OK", - "headers": { - "X-Plex-Container-Total-Size": { - "description": "Provided on all MediaContainer objects indicating the total size of objects available", - "schema": { - "type": "integer" - } - }, - "X-Plex-Container-Start": { - "description": "Provided on all MediaContainer objects indicating the offset of where this container page starts", - "schema": { - "type": "integer" - } - } - }, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "MediaContainer": { - "allOf": [ - { - "$ref": "#/components/schemas/MediaContainer" - }, - { - "type": "object", - "properties": { - "Hub": { - "type": "array", - "items": { - "type": "object", - "properties": { - "identifier": { - "type": "string", - "description": "The identifier for this hub" - }, - "title": { - "type": "string", - "description": "The title of this hub" - }, - "recommendationsVisibility": { - "type": "string", - "enum": [ - "all", - "none", - "admin", - "shared" - ], - "description": "The visibility of this hub in recommendations:\n - all: Visible to all users\n - none: Visible to no users\n - admin: Visible to only admin users\n - shared: Visible to shared users\n" - }, - "homeVisibility": { - "type": "string", - "enum": [ - "all", - "none", - "admin", - "shared" - ], - "description": "Whether this hub is visible on the home screen\n - all: Visible to all users\n - none: Visible to no users\n - admin: Visible to only admin users\n - shared: Visible to shared users\n" - }, - "promotedToRecommended": { - "type": "boolean", - "description": "Whether this hub is promoted to all for recommendations" - }, - "promotedToOwnHome": { - "type": "boolean", - "description": "Whether this hub is visible to admin user home" - }, - "promotedToSharedHome": { - "type": "boolean", - "description": "Whether this hub is visible to shared user's home" - } - } - } - } - } - } - ] - } - } - }, - "examples": { - "someHubs": { - "summary": "An example of movie managed hubs", - "value": { - "MediaContainer": { - "size": 8, - "Hub": [ - { - "identifier": "movie.recentlyreleased", - "title": "Recently Released Movies", - "recommendationsVisibility": "all", - "homeVisibility": "none", - "promotedToRecommended": true, - "promotedToOwnHome": false, - "promotedToSharedHome": false - }, - { - "identifier": "movie.recentlyadded", - "title": "Recently Added Movies", - "recommendationsVisibility": "all", - "homeVisibility": "all", - "promotedToRecommended": true, - "promotedToOwnHome": true, - "promotedToSharedHome": true - }, - { - "identifier": "recent.library.playlists", - "title": "Library Playlists", - "recommendationsVisibility": "all", - "homeVisibility": "none", - "promotedToRecommended": true, - "promotedToOwnHome": false, - "promotedToSharedHome": false - }, - { - "identifier": "movie.genre", - "title": "Top Movies in (Genre)", - "recommendationsVisibility": "all", - "homeVisibility": "none", - "promotedToRecommended": true, - "promotedToOwnHome": false, - "promotedToSharedHome": false - }, - { - "identifier": "movie.by.actor.or.director", - "title": "Top Movies by (Actor or Director)", - "recommendationsVisibility": "all", - "homeVisibility": "none", - "promotedToRecommended": true, - "promotedToOwnHome": false, - "promotedToSharedHome": false - }, - { - "identifier": "movie.topunwatched", - "title": "Top Unplayed Movies", - "recommendationsVisibility": "all", - "homeVisibility": "none", - "promotedToRecommended": true, - "promotedToOwnHome": false, - "promotedToSharedHome": false - }, - { - "identifier": "movie.curated", - "title": "Seasonal Movies", - "recommendationsVisibility": "all", - "homeVisibility": "all", - "promotedToRecommended": true, - "promotedToOwnHome": true, - "promotedToSharedHome": true - }, - { - "identifier": "movie.recentlyviewed", - "title": "Recently Played Movies", - "recommendationsVisibility": "all", - "homeVisibility": "none", - "promotedToRecommended": true, - "promotedToOwnHome": false, - "promotedToSharedHome": false - } - ] - } - } - } - } - } - } - }, - "403": { - "$ref": "#/components/responses/403" - }, - "404": { - "description": "Section id was not found", - "content": { - "text/html": { - "examples": { - "forbidden": { - "summary": "Forbidden", - "value": "Forbidden

403 Forbidden

" - } - } - } - } - } - } - }, - "delete": { - "tags": [ - "Hubs" - ], - "security": [ - { - "user_token": [ - "admin" - ] - } - ], - "summary": "Reset hubs to defaults", - "description": "Reset hubs for this section to defaults and delete custom hubs", - "operationId": "hubsSectionsSectionManageDeleteSlash", - "parameters": [ - { - "in": "path", - "name": "sectionId", - "schema": { - "type": "integer" - }, - "description": "The section ID for the hubs to reorder", - "required": true - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/200" - }, - "403": { - "$ref": "#/components/responses/403" - }, - "404": { - "description": "Section id was not found", - "content": { - "text/html": { - "examples": { - "forbidden": { - "summary": "Forbidden", - "value": "Forbidden

403 Forbidden

" - } - } - } - } - } - } - }, - "post": { - "tags": [ - "Hubs" - ], - "security": [ - { - "user_token": [ - "admin" - ] - } - ], - "summary": "Create a custom hub", - "description": "Create a custom hub based on a metadata item", - "operationId": "hubsSectionsSectionManagePostSlash", - "parameters": [ - { - "in": "path", - "name": "sectionId", - "schema": { - "type": "integer" - }, - "description": "The section ID for the hubs to reorder", - "required": true - }, - { - "in": "query", - "name": "metadataItemId", - "schema": { - "type": "integer" - }, - "required": true, - "description": "The metadata item on which to base this hub. This must currently be a collection" - }, - { - "in": "query", - "name": "promotedToRecommended", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "description": "Whether this hub should be displayed in recommended" - }, - { - "in": "query", - "name": "promotedToOwnHome", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "description": "Whether this hub should be displayed in admin's home" - }, - { - "in": "query", - "name": "promotedToSharedHome", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "description": "Whether this hub should be displayed in shared user's home" - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/200" - }, - "400": { - "description": "A hub could not be created with this metadata item", - "content": { - "text/html": { - "examples": { - "badRequest": { - "summary": "A parameter has a bad value or required parameter is missing", - "value": "Bad Request

400 Bad Request

" - } - } - } - } - }, - "403": { - "$ref": "#/components/responses/403" - }, - "404": { - "description": "Section id or metadata item was not found", - "content": { - "text/html": { - "examples": { - "forbidden": { - "summary": "Forbidden", - "value": "Forbidden

403 Forbidden

" - } - } - } - } - } - } - } - }, - "/hubs/sections/{sectionId}/manage/move": { - "put": { - "tags": [ - "Hubs" - ], - "security": [ - { - "user_token": [ - "admin" - ] - } - ], - "summary": "Move Hub", - "description": "Changed the ordering of a hub among others hubs", - "operationId": "hubsSectionsSectionManagePutMove", - "parameters": [ - { - "in": "path", - "name": "sectionId", - "schema": { - "type": "integer" - }, - "description": "The section ID for the hubs to reorder", - "required": true - }, - { - "in": "query", - "name": "identifier", - "schema": { - "type": "string" - }, - "required": true, - "description": "The identifier of the hub to move" - }, - { - "in": "query", - "name": "after", - "schema": { - "type": "string" - }, - "description": "The identifier of the hub to order this hub after (or empty/missing to put this hub first)" - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/get-responses-200" - }, - "403": { - "$ref": "#/components/responses/403" - }, - "404": { - "description": "Section id was not found", - "content": { - "text/html": { - "examples": { - "forbidden": { - "summary": "Forbidden", - "value": "Forbidden

403 Forbidden

" - } - } - } - } - } - } - } - }, - "/hubs/sections/{sectionId}/manage/{identifier}": { - "put": { - "tags": [ - "Hubs" - ], - "security": [ - { - "user_token": [ - "admin" - ] - } - ], - "summary": "Change hub visibility", - "description": "Changed the visibility of a hub for both the admin and shared users", - "operationId": "hubsSectionsSectionManagePutIdentifier", - "parameters": [ - { - "in": "path", - "name": "sectionId", - "schema": { - "type": "integer" - }, - "description": "The section ID for the hubs to change", - "required": true - }, - { - "in": "path", - "name": "identifier", - "schema": { - "type": "string" - }, - "required": true, - "description": "The identifier of the hub to change" - }, - { - "in": "query", - "name": "promotedToRecommended", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "description": "Whether this hub should be displayed in recommended" - }, - { - "in": "query", - "name": "promotedToOwnHome", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "description": "Whether this hub should be displayed in admin's home" - }, - { - "in": "query", - "name": "promotedToSharedHome", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "description": "Whether this hub should be displayed in shared user's home" - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/200" - }, - "403": { - "$ref": "#/components/responses/403" - }, - "404": { - "description": "Section id was not found", - "content": { - "text/html": { - "examples": { - "forbidden": { - "summary": "Forbidden", - "value": "Forbidden

403 Forbidden

" - } - } - } - } - } - } - }, - "delete": { - "tags": [ - "Hubs" - ], - "security": [ - { - "user_token": [ - "admin" - ] - } - ], - "summary": "Delete a custom hub", - "description": "Delete a custom hub from the server", - "operationId": "hubsSectionsSectionManageDeleteIdentifier", - "parameters": [ - { - "in": "path", - "name": "sectionId", - "schema": { - "type": "integer" - }, - "description": "The section ID for the hubs to change", - "required": true - }, - { - "in": "path", - "name": "identifier", - "schema": { - "type": "string" - }, - "required": true, - "description": "The identifier of the hub to change" - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/200" - }, - "400": { - "description": "The hub is not a custom hub", - "content": { - "text/html": { - "examples": { - "badRequest": { - "summary": "A parameter has a bad value or required parameter is missing", - "value": "Bad Request

400 Bad Request

" - } - } - } - } - }, - "403": { - "$ref": "#/components/responses/403" - }, - "404": { - "description": "The section or hub was not found", - "content": { - "text/html": { - "examples": { - "notFound": { - "summary": "Not Found", - "value": "Not Found

404 Not Found

" - } - } - } - } - } - } - } - }, - "/library/all": { - "get": { - "tags": [ - "Library" - ], - "operationId": "libraryGetAll", - "summary": "Get all items in library", - "description": "Request all metadata items according to a query.", - "parameters": [ - { - "$ref": "#/components/parameters/mediaQuery" - } - ], - "responses": { - "200": { - "description": "OK", - "headers": { - "X-Plex-Container-Total-Size": { - "description": "Provided on all MediaContainer objects indicating the total size of objects available", - "schema": { - "type": "integer" - } - }, - "X-Plex-Container-Start": { - "description": "Provided on all MediaContainer objects indicating the offset of where this container page starts", - "schema": { - "type": "integer" - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/mediaContainerWithMetadata" - }, - "examples": { - "Zoolander Metadata": { - "value": { - "MediaContainer": { - "size": "1", - "allowSync": true, - "art": "/:/resources/movie-fanart.jpg", - "identifier": "com.plexapp.plugins.library", - "librarySectionID": 26, - "librarySectionTitle": "Movies", - "librarySectionUUID": "70cb5089-b165-429b-809a-9e0a31493abf", - "mediaTagPrefix": "/system/bundle/media/flags/", - "mediaTagVersion": "1436742334", - "thumb": "/:/resources/movie.png", - "title1": "Movies", - "title2": "All Movies", - "viewGroup": "movie", - "Metadata": [ - { - "id": "1049", - "ratingKey": "1049", - "key": "/library/metadata/1049", - "studio": "Paramount Pictures", - "type": "movie", - "title": "Zoolander", - "contentRating": "PG-13", - "summary": "FunnyStuff", - "year": 2001, - "tagline": "3% Body Fat. 1% Brain Activity.", - "thumb": "/library/metadata/1049/thumb/1434341184", - "art": "/library/metadata/1049/art/1434341184", - "duration": 5129000, - "originallyAvailableAt": "2001-09-27", - "addedAt": 1408525217, - "updatedAt": 1434341184, - "chapterSource": "media", - "primaryExtraKey": "/library/metadata/1073", - "rating": 6, - "Media": [ - { - "id": 827, - "duration": 5129000, - "bitrate": 6564, - "width": 720, - "height": 576, - "aspectRatio": 1.78, - "audioChannels": 6, - "audioCodec": "ac3", - "videoCodec": "mpeg2video", - "container": "mkv", - "videoFrameRate": "PAL", - "Part": [ - { - "id": "827", - "key": "/library/parts/827/file.mkv", - "duration": 5129000, - "file": "O:\\fatboy\\Media\\Ripped\\Movies\\Zoolander (2001).mkv", - "size": 4208219125, - "container": "mkv" - } - ] - } - ], - "Image": [ - { - "type": "coverPoster", - "alt": "Zoolander", - "url": "/library/metadata/1049/thumb/1434341184" - } - ], - "Genre": [ - { - "tag": "Comedy" - } - ], - "Writer": [ - { - "tag": "Drake Sather" - }, - { - "tag": "Ben Stiller" - } - ], - "Director": [ - { - "tag": "Ben Stiller" - } - ], - "Country": [ - { - "tag": "Australia" - }, - { - "tag": "Germany" - } - ], - "Role": [ - { - "tag": "Ben Stiller" - }, - { - "tag": "Owen Wilson" - }, - { - "tag": "Christine Taylor" - } - ] - } - ] - } - } - } - } - } - } - } - } - } - }, - "/library/caches": { - "delete": { - "tags": [ - "Library" - ], - "security": [ - { - "user_token": [ - "admin" - ] - } - ], - "operationId": "libraryDeleteCaches", - "summary": "Delete library caches", - "description": "Delete the hub caches so they are recomputed on next request", - "responses": { - "200": { - "$ref": "#/components/responses/200" - } - } - } - }, - "/library/clean/bundles": { - "put": { - "tags": [ - "Library" - ], - "security": [ - { - "user_token": [ - "admin" - ] - } - ], - "operationId": "libraryPutCleanBundles", - "summary": "Clean bundles", - "description": "Clean out any now unused bundles. Bundles can become unused when media is deleted", - "responses": { - "200": { - "$ref": "#/components/responses/200" - } - } - } - }, - "/library/collections": { - "post": { - "tags": [ - "Collections" - ], - "operationId": "libraryCollectionPostSlash", - "summary": "Create a collection", - "description": "Create a collection in the library", - "parameters": [ - { - "in": "query", - "name": "sectionId", - "schema": { - "type": "string" - }, - "required": true, - "description": "The section where this collection will be created" - }, - { - "in": "query", - "name": "title", - "schema": { - "type": "string" - }, - "required": true, - "description": "The title of this collection" - }, - { - "in": "query", - "name": "smart", - "schema": { - "type": "boolean" - }, - "description": "Whether this is a smart collection. Defaults to false" - }, - { - "in": "query", - "name": "uri", - "schema": { - "type": "string" - }, - "description": "The URI for processing the smart collection. Required for a smart collection" - }, - { - "in": "query", - "name": "type", - "schema": { - "type": "integer" - }, - "description": "The type of metadata this collection will hold" - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/mediaContainerWithMetadata" - }, - "examples": { - "Zoolander Metadata": { - "value": { - "MediaContainer": { - "size": "1", - "allowSync": true, - "art": "/:/resources/movie-fanart.jpg", - "identifier": "com.plexapp.plugins.library", - "librarySectionID": 26, - "librarySectionTitle": "Movies", - "librarySectionUUID": "70cb5089-b165-429b-809a-9e0a31493abf", - "mediaTagPrefix": "/system/bundle/media/flags/", - "mediaTagVersion": "1436742334", - "thumb": "/:/resources/movie.png", - "title1": "Movies", - "title2": "All Movies", - "viewGroup": "movie", - "Metadata": [ - { - "id": "1049", - "ratingKey": "1049", - "key": "/library/metadata/1049", - "studio": "Paramount Pictures", - "type": "movie", - "title": "Zoolander", - "contentRating": "PG-13", - "summary": "FunnyStuff", - "year": 2001, - "tagline": "3% Body Fat. 1% Brain Activity.", - "thumb": "/library/metadata/1049/thumb/1434341184", - "art": "/library/metadata/1049/art/1434341184", - "duration": 5129000, - "originallyAvailableAt": "2001-09-27", - "addedAt": 1408525217, - "updatedAt": 1434341184, - "chapterSource": "media", - "primaryExtraKey": "/library/metadata/1073", - "rating": 6, - "Media": [ - { - "id": 827, - "duration": 5129000, - "bitrate": 6564, - "width": 720, - "height": 576, - "aspectRatio": 1.78, - "audioChannels": 6, - "audioCodec": "ac3", - "videoCodec": "mpeg2video", - "container": "mkv", - "videoFrameRate": "PAL", - "Part": [ - { - "id": "827", - "key": "/library/parts/827/file.mkv", - "duration": 5129000, - "file": "O:\\fatboy\\Media\\Ripped\\Movies\\Zoolander (2001).mkv", - "size": 4208219125, - "container": "mkv" - } - ] - } - ], - "Image": [ - { - "type": "coverPoster", - "alt": "Zoolander", - "url": "/library/metadata/1049/thumb/1434341184" - } - ], - "Genre": [ - { - "tag": "Comedy" - } - ], - "Writer": [ - { - "tag": "Drake Sather" - }, - { - "tag": "Ben Stiller" - } - ], - "Director": [ - { - "tag": "Ben Stiller" - } - ], - "Country": [ - { - "tag": "Australia" - }, - { - "tag": "Germany" - } - ], - "Role": [ - { - "tag": "Ben Stiller" - }, - { - "tag": "Owen Wilson" - }, - { - "tag": "Christine Taylor" - } - ] - } - ] - } - } - } - } - } - } - }, - "400": { - "description": "The uri is missing for a smart collection or the section could not be found", - "content": { - "text/html": { - "examples": { - "badRequest": { - "summary": "A parameter has a bad value or required parameter is missing", - "value": "Bad Request

400 Bad Request

" - } - } - } - } - } - } - } - }, - "/library/collections/{collectionId}/composite/{updatedAt}": { - "get": { - "tags": [ - "Content" - ], - "operationId": "libraryCollectionCollectionGetComposite", - "summary": "Get a collection's image", - "description": "Get an image for the collection based on the items within", - "parameters": [ - { - "in": "path", - "name": "collectionId", - "schema": { - "type": "integer" - }, - "description": "The collection id", - "required": true - }, - { - "in": "path", - "name": "updatedAt", - "schema": { - "type": "integer" - }, - "description": "The update time of the image. Used for busting cache.", - "required": true - }, - { - "$ref": "#/components/parameters/composite" - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "image/jpeg": { - "schema": { - "type": "string", - "format": "binary" - } - } - } - }, - "404": { - "description": "Collection not found", - "content": { - "text/html": { - "examples": { - "notFound": { - "summary": "Not Found", - "value": "Not Found

404 Not Found

" - } - } - } - } - } - } - } - }, - "/library/collections/{collectionId}/items": { - "get": { - "tags": [ - "Content" - ], - "operationId": "libraryCollectionCollectionGetItems", - "summary": "Get items in a collection", - "description": "Get items in a collection. Note if this collection contains more than 100 items, paging must be used.", - "parameters": [ - { - "in": "path", - "name": "collectionId", - "schema": { - "type": "integer" - }, - "description": "The collection id", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/mediaContainerWithMetadata" - }, - "examples": { - "Zoolander Metadata": { - "value": { - "MediaContainer": { - "size": "1", - "allowSync": true, - "art": "/:/resources/movie-fanart.jpg", - "identifier": "com.plexapp.plugins.library", - "librarySectionID": 26, - "librarySectionTitle": "Movies", - "librarySectionUUID": "70cb5089-b165-429b-809a-9e0a31493abf", - "mediaTagPrefix": "/system/bundle/media/flags/", - "mediaTagVersion": "1436742334", - "thumb": "/:/resources/movie.png", - "title1": "Movies", - "title2": "All Movies", - "viewGroup": "movie", - "Metadata": [ - { - "id": "1049", - "ratingKey": "1049", - "key": "/library/metadata/1049", - "studio": "Paramount Pictures", - "type": "movie", - "title": "Zoolander", - "contentRating": "PG-13", - "summary": "FunnyStuff", - "year": 2001, - "tagline": "3% Body Fat. 1% Brain Activity.", - "thumb": "/library/metadata/1049/thumb/1434341184", - "art": "/library/metadata/1049/art/1434341184", - "duration": 5129000, - "originallyAvailableAt": "2001-09-27", - "addedAt": 1408525217, - "updatedAt": 1434341184, - "chapterSource": "media", - "primaryExtraKey": "/library/metadata/1073", - "rating": 6, - "Media": [ - { - "id": 827, - "duration": 5129000, - "bitrate": 6564, - "width": 720, - "height": 576, - "aspectRatio": 1.78, - "audioChannels": 6, - "audioCodec": "ac3", - "videoCodec": "mpeg2video", - "container": "mkv", - "videoFrameRate": "PAL", - "Part": [ - { - "id": "827", - "key": "/library/parts/827/file.mkv", - "duration": 5129000, - "file": "O:\\fatboy\\Media\\Ripped\\Movies\\Zoolander (2001).mkv", - "size": 4208219125, - "container": "mkv" - } - ] - } - ], - "Image": [ - { - "type": "coverPoster", - "alt": "Zoolander", - "url": "/library/metadata/1049/thumb/1434341184" - } - ], - "Genre": [ - { - "tag": "Comedy" - } - ], - "Writer": [ - { - "tag": "Drake Sather" - }, - { - "tag": "Ben Stiller" - } - ], - "Director": [ - { - "tag": "Ben Stiller" - } - ], - "Country": [ - { - "tag": "Australia" - }, - { - "tag": "Germany" - } - ], - "Role": [ - { - "tag": "Ben Stiller" - }, - { - "tag": "Owen Wilson" - }, - { - "tag": "Christine Taylor" - } - ] - } - ] - } - } - } - } - } - } - }, - "404": { - "description": "Collection not found", - "content": { - "text/html": { - "examples": { - "notFound": { - "summary": "Not Found", - "value": "Not Found

404 Not Found

" - } - } - } - } - } - } - }, - "put": { - "tags": [ - "Library Collections" - ], - "security": [ - { - "user_token": [ - "admin" - ] - } - ], - "operationId": "libraryCollectionCollectionPutItems", - "summary": "Add items to a collection", - "description": "Add items to a collection by uri", - "parameters": [ - { - "in": "path", - "name": "collectionId", - "schema": { - "type": "integer" - }, - "required": true, - "description": "The collection id" - }, - { - "in": "query", - "name": "uri", - "schema": { - "type": "string" - }, - "required": true, - "description": "The URI describing the items to add to this collection" - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/mediaContainerWithMetadata" - }, - "examples": { - "Zoolander Metadata": { - "value": { - "MediaContainer": { - "size": "1", - "allowSync": true, - "art": "/:/resources/movie-fanart.jpg", - "identifier": "com.plexapp.plugins.library", - "librarySectionID": 26, - "librarySectionTitle": "Movies", - "librarySectionUUID": "70cb5089-b165-429b-809a-9e0a31493abf", - "mediaTagPrefix": "/system/bundle/media/flags/", - "mediaTagVersion": "1436742334", - "thumb": "/:/resources/movie.png", - "title1": "Movies", - "title2": "All Movies", - "viewGroup": "movie", - "Metadata": [ - { - "id": "1049", - "ratingKey": "1049", - "key": "/library/metadata/1049", - "studio": "Paramount Pictures", - "type": "movie", - "title": "Zoolander", - "contentRating": "PG-13", - "summary": "FunnyStuff", - "year": 2001, - "tagline": "3% Body Fat. 1% Brain Activity.", - "thumb": "/library/metadata/1049/thumb/1434341184", - "art": "/library/metadata/1049/art/1434341184", - "duration": 5129000, - "originallyAvailableAt": "2001-09-27", - "addedAt": 1408525217, - "updatedAt": 1434341184, - "chapterSource": "media", - "primaryExtraKey": "/library/metadata/1073", - "rating": 6, - "Media": [ - { - "id": 827, - "duration": 5129000, - "bitrate": 6564, - "width": 720, - "height": 576, - "aspectRatio": 1.78, - "audioChannels": 6, - "audioCodec": "ac3", - "videoCodec": "mpeg2video", - "container": "mkv", - "videoFrameRate": "PAL", - "Part": [ - { - "id": "827", - "key": "/library/parts/827/file.mkv", - "duration": 5129000, - "file": "O:\\fatboy\\Media\\Ripped\\Movies\\Zoolander (2001).mkv", - "size": 4208219125, - "container": "mkv" - } - ] - } - ], - "Image": [ - { - "type": "coverPoster", - "alt": "Zoolander", - "url": "/library/metadata/1049/thumb/1434341184" - } - ], - "Genre": [ - { - "tag": "Comedy" - } - ], - "Writer": [ - { - "tag": "Drake Sather" - }, - { - "tag": "Ben Stiller" - } - ], - "Director": [ - { - "tag": "Ben Stiller" - } - ], - "Country": [ - { - "tag": "Australia" - }, - { - "tag": "Germany" - } - ], - "Role": [ - { - "tag": "Ben Stiller" - }, - { - "tag": "Owen Wilson" - }, - { - "tag": "Christine Taylor" - } - ] - } - ] - } - } - } - } - } - } - }, - "404": { - "description": "Collection not found", - "content": { - "text/html": { - "examples": { - "notFound": { - "summary": "Not Found", - "value": "Not Found

404 Not Found

" - } - } - } - } - } - } - } - }, - "/library/collections/{collectionId}/items/{itemId}": { - "put": { - "tags": [ - "Library Collections" - ], - "security": [ - { - "user_token": [ - "admin" - ] - } - ], - "operationId": "libraryCollectionCollectionPutItemsItem", - "summary": "Delete an item from a collection", - "description": "Delete an item from a collection", - "parameters": [ - { - "in": "path", - "name": "collectionId", - "schema": { - "type": "integer" - }, - "required": true, - "description": "The collection id" - }, - { - "in": "path", - "name": "itemId", - "schema": { - "type": "integer" - }, - "required": true, - "description": "The item to delete" - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/mediaContainerWithMetadata" - }, - "examples": { - "Zoolander Metadata": { - "value": { - "MediaContainer": { - "size": "1", - "allowSync": true, - "art": "/:/resources/movie-fanart.jpg", - "identifier": "com.plexapp.plugins.library", - "librarySectionID": 26, - "librarySectionTitle": "Movies", - "librarySectionUUID": "70cb5089-b165-429b-809a-9e0a31493abf", - "mediaTagPrefix": "/system/bundle/media/flags/", - "mediaTagVersion": "1436742334", - "thumb": "/:/resources/movie.png", - "title1": "Movies", - "title2": "All Movies", - "viewGroup": "movie", - "Metadata": [ - { - "id": "1049", - "ratingKey": "1049", - "key": "/library/metadata/1049", - "studio": "Paramount Pictures", - "type": "movie", - "title": "Zoolander", - "contentRating": "PG-13", - "summary": "FunnyStuff", - "year": 2001, - "tagline": "3% Body Fat. 1% Brain Activity.", - "thumb": "/library/metadata/1049/thumb/1434341184", - "art": "/library/metadata/1049/art/1434341184", - "duration": 5129000, - "originallyAvailableAt": "2001-09-27", - "addedAt": 1408525217, - "updatedAt": 1434341184, - "chapterSource": "media", - "primaryExtraKey": "/library/metadata/1073", - "rating": 6, - "Media": [ - { - "id": 827, - "duration": 5129000, - "bitrate": 6564, - "width": 720, - "height": 576, - "aspectRatio": 1.78, - "audioChannels": 6, - "audioCodec": "ac3", - "videoCodec": "mpeg2video", - "container": "mkv", - "videoFrameRate": "PAL", - "Part": [ - { - "id": "827", - "key": "/library/parts/827/file.mkv", - "duration": 5129000, - "file": "O:\\fatboy\\Media\\Ripped\\Movies\\Zoolander (2001).mkv", - "size": 4208219125, - "container": "mkv" - } - ] - } - ], - "Image": [ - { - "type": "coverPoster", - "alt": "Zoolander", - "url": "/library/metadata/1049/thumb/1434341184" - } - ], - "Genre": [ - { - "tag": "Comedy" - } - ], - "Writer": [ - { - "tag": "Drake Sather" - }, - { - "tag": "Ben Stiller" - } - ], - "Director": [ - { - "tag": "Ben Stiller" - } - ], - "Country": [ - { - "tag": "Australia" - }, - { - "tag": "Germany" - } - ], - "Role": [ - { - "tag": "Ben Stiller" - }, - { - "tag": "Owen Wilson" - }, - { - "tag": "Christine Taylor" - } - ] - } - ] - } - } - } - } - } - } - }, - "400": { - "description": "Item not found", - "content": { - "text/html": { - "examples": { - "badRequest": { - "summary": "A parameter has a bad value or required parameter is missing", - "value": "Bad Request

400 Bad Request

" - } - } - } - } - }, - "404": { - "description": "Collection not found", - "content": { - "text/html": { - "examples": { - "notFound": { - "summary": "Not Found", - "value": "Not Found

404 Not Found

" - } - } - } - } - } - } - } - }, - "/library/collections/{collectionId}/items/{itemId}/move": { - "put": { - "tags": [ - "Library Collections" - ], - "security": [ - { - "user_token": [ - "admin" - ] - } - ], - "operationId": "libraryCollectionCollectionPutItemsItemMove", - "summary": "Reorder an item in the collection", - "description": "Reorder items in a collection with one item after another", - "parameters": [ - { - "in": "path", - "name": "collectionId", - "schema": { - "type": "integer" - }, - "required": true, - "description": "The collection id" - }, - { - "in": "path", - "name": "itemId", - "schema": { - "type": "integer" - }, - "required": true, - "description": "The item to move" - }, - { - "in": "query", - "name": "after", - "schema": { - "type": "integer" - }, - "description": "The item to move this item after. If not provided, this item will be moved to the beginning" - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/mediaContainerWithMetadata" - }, - "examples": { - "Zoolander Metadata": { - "value": { - "MediaContainer": { - "size": "1", - "allowSync": true, - "art": "/:/resources/movie-fanart.jpg", - "identifier": "com.plexapp.plugins.library", - "librarySectionID": 26, - "librarySectionTitle": "Movies", - "librarySectionUUID": "70cb5089-b165-429b-809a-9e0a31493abf", - "mediaTagPrefix": "/system/bundle/media/flags/", - "mediaTagVersion": "1436742334", - "thumb": "/:/resources/movie.png", - "title1": "Movies", - "title2": "All Movies", - "viewGroup": "movie", - "Metadata": [ - { - "id": "1049", - "ratingKey": "1049", - "key": "/library/metadata/1049", - "studio": "Paramount Pictures", - "type": "movie", - "title": "Zoolander", - "contentRating": "PG-13", - "summary": "FunnyStuff", - "year": 2001, - "tagline": "3% Body Fat. 1% Brain Activity.", - "thumb": "/library/metadata/1049/thumb/1434341184", - "art": "/library/metadata/1049/art/1434341184", - "duration": 5129000, - "originallyAvailableAt": "2001-09-27", - "addedAt": 1408525217, - "updatedAt": 1434341184, - "chapterSource": "media", - "primaryExtraKey": "/library/metadata/1073", - "rating": 6, - "Media": [ - { - "id": 827, - "duration": 5129000, - "bitrate": 6564, - "width": 720, - "height": 576, - "aspectRatio": 1.78, - "audioChannels": 6, - "audioCodec": "ac3", - "videoCodec": "mpeg2video", - "container": "mkv", - "videoFrameRate": "PAL", - "Part": [ - { - "id": "827", - "key": "/library/parts/827/file.mkv", - "duration": 5129000, - "file": "O:\\fatboy\\Media\\Ripped\\Movies\\Zoolander (2001).mkv", - "size": 4208219125, - "container": "mkv" - } - ] - } - ], - "Image": [ - { - "type": "coverPoster", - "alt": "Zoolander", - "url": "/library/metadata/1049/thumb/1434341184" - } - ], - "Genre": [ - { - "tag": "Comedy" - } - ], - "Writer": [ - { - "tag": "Drake Sather" - }, - { - "tag": "Ben Stiller" - } - ], - "Director": [ - { - "tag": "Ben Stiller" - } - ], - "Country": [ - { - "tag": "Australia" - }, - { - "tag": "Germany" - } - ], - "Role": [ - { - "tag": "Ben Stiller" - }, - { - "tag": "Owen Wilson" - }, - { - "tag": "Christine Taylor" - } - ] - } - ] - } - } - } - } - } - } - }, - "400": { - "description": "Item not found", - "content": { - "text/html": { - "examples": { - "badRequest": { - "summary": "A parameter has a bad value or required parameter is missing", - "value": "Bad Request

400 Bad Request

" - } - } - } - } - }, - "404": { - "description": "Collection not found", - "content": { - "text/html": { - "examples": { - "notFound": { - "summary": "Not Found", - "value": "Not Found

404 Not Found

" - } - } - } - } - } - } - } - }, - "/library/file": { - "post": { - "tags": [ - "Library" - ], - "operationId": "libraryPostFile", - "summary": "Ingest a transient item", - "description": "This endpoint takes a file path specified in the `url` parameter, matches it using the scanner's match mechanism, downloads rich metadata, and then ingests the item as a transient item (without a library section). In the case where the file represents an episode, the entire tree (show, season, and episode) is added as transient items. At this time, movies and episodes are the only supported types, which are gleaned automatically from the file path.\nNote that any of the parameters passed to the metadata details endpoint (e.g. `includeExtras=1`) work here.", - "parameters": [ - { - "in": "query", - "name": "url", - "schema": { - "type": "string", - "format": "url" - }, - "example": "file:///storage%2Femulated%2F0%2FArcher-S01E01.mkv", - "description": "The file of the file to ingest." - }, - { - "in": "query", - "name": "virtualFilePath", - "schema": { - "type": "string" - }, - "example": "/Avatar.mkv", - "description": "A virtual path to use when the url is opaque." - }, - { - "in": "query", - "name": "computeHashes", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "example": 1, - "description": "Whether or not to compute Plex and OpenSubtitle hashes for the file. Defaults to 0." - }, - { - "in": "query", - "name": "ingestNonMatches", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "example": 1, - "description": "Whether or not non matching media should be stored. Defaults to 0." - } - ], - "responses": { - "200": { - "description": "OK", - "headers": { - "X-Plex-Container-Total-Size": { - "description": "Provided on all MediaContainer objects indicating the total size of objects available", - "schema": { - "type": "integer" - } - }, - "X-Plex-Container-Start": { - "description": "Provided on all MediaContainer objects indicating the offset of where this container page starts", - "schema": { - "type": "integer" - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/mediaContainerWithMetadata" - }, - "examples": { - "result": { - "value": { - "MediaContainer": { - "size": 1, - "Metadata": [] - } - } - } - } - } - } - } - } - } - }, - "/library/matches": { - "get": { - "tags": [ - "Library" - ], - "operationId": "libraryGetMatches", - "summary": "Get library matches", - "description": "The matches endpoint is used to match content external to the library with content inside the library. This is done by passing a series of semantic \"hints\" about the content (its type, name, or release year). Each type (e.g. movie) has a canonical set of minimal required hints.\nThis ability to match content is useful in a variety of scenarios. For example, in the DVR, the EPG uses the endpoint to match recording rules against airing content. And in the cloud, the UMP uses the endpoint to match up a piece of media with rich metadata.\nThe endpoint response can including multiple matches, if there is ambiguity, each one containing a `score` from 0 to 100. For somewhat historical reasons, anything over 85 is considered a positive match (we prefer false negatives over false positives in general for matching).\nThe `guid` hint is somewhat special, in that it generally represents a unique identity for a piece of media (e.g. the IMDB `ttXXX`) identifier, in contrast with other hints which can be much more ambiguous (e.g. a title of `Jane Eyre`, which could refer to the 1943 or the 2011 version).\nEpisodes require either a season/episode pair, or an air date (or both). Either the path must be sent, or the show title", - "parameters": [ - { - "in": "query", - "name": "type", - "schema": { - "type": "integer" - }, - "required": true, - "description": "The metadata type to match against" - }, - { - "in": "query", - "name": "includeFullMetadata", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ], - "description": "Whether or not to include full metadata on a positive match. When set, and the best match exceeds a score threshold of 85, metadata as rich as possible is sent back." - } - }, - { - "in": "query", - "name": "includeAncestorMetadata", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ], - "description": "Whether or not to include metadata for the item's ancestor (e.g. show and season data for an episode)." - } - }, - { - "in": "query", - "name": "includeAlternateMetadataSources", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ], - "description": "Whether or not to return all sources for each metadata field, which results in a different structure being passed back." - } - }, - { - "in": "query", - "name": "guid", - "schema": { - "type": "string" - }, - "description": "Used for movies, shows, artists, albums, and tracks. Allowed for various URI schemes, to be defined." - }, - { - "in": "query", - "name": "title", - "schema": { - "type": "string" - }, - "description": "Used for movies, shows, artists, and albums. Required if `path` is not specified." - }, - { - "in": "query", - "name": "year", - "schema": { - "type": "integer" - }, - "description": "Used for movies shows, and albums. Optional." - }, - { - "in": "query", - "name": "path", - "schema": { - "type": "string" - }, - "description": "Used for movies, episodes, and tracks. The full path to the media file, used for \"cloud-scanning\" an item." - }, - { - "in": "query", - "name": "grandparentTitle", - "schema": { - "type": "string" - }, - "description": "Used for episodes and tracks. The title of the show/artist. Required if `path` isn't passed." - }, - { - "in": "query", - "name": "grandparentYear", - "schema": { - "type": "integer" - }, - "description": "Used for episodes. The year of the show." - }, - { - "in": "query", - "name": "parentIndex", - "schema": { - "type": "integer" - }, - "description": "Used for episodes and tracks. The season/album number." - }, - { - "in": "query", - "name": "index", - "schema": { - "type": "integer" - }, - "description": "Used for episodes and tracks. The episode/tracks number in the season/album." - }, - { - "in": "query", - "name": "originallyAvailableAt", - "schema": { - "type": "string" - }, - "description": "Used for episodes. In the format `YYYY-MM-DD`." - }, - { - "in": "query", - "name": "parentTitle", - "schema": { - "type": "string" - }, - "description": "Used for albums and tracks. The artist name for albums or the album name for tracks." - } - ], - "responses": { - "200": { - "description": "OK", - "headers": { - "X-Plex-Container-Total-Size": { - "description": "Provided on all MediaContainer objects indicating the total size of objects available", - "schema": { - "type": "integer" - } - }, - "X-Plex-Container-Start": { - "description": "Provided on all MediaContainer objects indicating the offset of where this container page starts", - "schema": { - "type": "integer" - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/mediaContainerWithMetadata" - }, - "examples": { - "psychoMatch": { - "value": { - "MediaContainer": { - "size": 1, - "Metadata": [ - { - "score": 100, - "ratingKey": "667573", - "key": "/library/metadata/667573", - "studio": "Shamley Productions", - "type": "movie", - "title": "Psycho" - } - ] - } - } - } - } - } - } - } - } - } - }, - "/library/media/{mediaId}/chapterImages/{chapter}": { - "get": { - "tags": [ - "Library" - ], - "operationId": "libraryGetMediaMediaChapterImagesChapter", - "summary": "Get a chapter image", - "description": "Get a single chapter image for a piece of media", - "parameters": [ - { - "in": "path", - "name": "mediaId", - "schema": { - "type": "integer" - }, - "required": true, - "description": "The id of the media item" - }, - { - "in": "path", - "name": "chapter", - "schema": { - "type": "integer" - }, - "required": true, - "description": "The index of the chapter" - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "image/jpeg": { - "schema": { - "type": "string", - "format": "binary" - } - } - } - }, - "404": { - "description": "Either the media item or the chapter image was not found", - "content": { - "text/html": { - "examples": { - "notFound": { - "summary": "Not Found", - "value": "Not Found

404 Not Found

" - } - } - } - } - } - } - } - }, - "/library/metadata/{ids}": { - "get": { - "tags": [ - "Content" - ], - "operationId": "libraryMetadataGetSlash", - "summary": "Get a metadata item", - "description": "Get one or more metadata items.", - "parameters": [ - { - "in": "path", - "name": "ids", - "schema": { - "type": "array", - "items": { - "type": "string" - } - }, - "required": true - }, - { - "in": "query", - "name": "asyncCheckFiles", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "description": "Determines if file check should be performed asynchronously. An activity is created to indicate progress. Default is false." - }, - { - "in": "query", - "name": "asyncRefreshLocalMediaAgent", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "description": "Determines if local media agent refresh should be performed asynchronously. An activity is created to indicate progress. Default is false." - }, - { - "in": "query", - "name": "asyncRefreshAnalysis", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "description": "Determines if analysis refresh should be performed asynchronously. An activity is created to indicate progress. Default is false." - }, - { - "in": "query", - "name": "checkFiles", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "description": "Determines if file check should be performed synchronously. Specifying `asyncCheckFiles` will cause this option to be ignored. Default is false." - }, - { - "in": "query", - "name": "skipRefresh", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "description": "Determines if synchronous local media agent and analysis refresh should be skipped. Specifying async versions will cause synchronous versions to be skipped. Default is false." - }, - { - "in": "query", - "name": "checkFileAvailability", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "description": "Determines if file existence check should be performed synchronously. Specifying `checkFiles` will imply this option. Default is false." - }, - { - "in": "query", - "name": "asyncAugmentMetadata", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "description": "Add metadata augmentations. An activity is created to indicate progress. Option will be ignored if specified by non-admin or if multiple metadata items are requested. Default is false." - }, - { - "in": "query", - "name": "augmentCount", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "description": "Number of augmentations to add. Requires `asyncAugmentMetadata` to be specified." - } - ], - "responses": { - "200": { - "description": "OK", - "headers": { - "X-Plex-Container-Total-Size": { - "description": "Provided on all MediaContainer objects indicating the total size of objects available", - "schema": { - "type": "integer" - } - }, - "X-Plex-Container-Start": { - "description": "Provided on all MediaContainer objects indicating the offset of where this container page starts", - "schema": { - "type": "integer" - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/mediaContainerWithMetadata" - }, - "examples": { - "Zoolander Metadata": { - "value": { - "MediaContainer": { - "size": "1", - "allowSync": true, - "art": "/:/resources/movie-fanart.jpg", - "identifier": "com.plexapp.plugins.library", - "librarySectionID": 26, - "librarySectionTitle": "Movies", - "librarySectionUUID": "70cb5089-b165-429b-809a-9e0a31493abf", - "mediaTagPrefix": "/system/bundle/media/flags/", - "mediaTagVersion": "1436742334", - "thumb": "/:/resources/movie.png", - "title1": "Movies", - "title2": "All Movies", - "viewGroup": "movie", - "Metadata": [ - { - "id": "1049", - "ratingKey": "1049", - "key": "/library/metadata/1049", - "studio": "Paramount Pictures", - "type": "movie", - "title": "Zoolander", - "contentRating": "PG-13", - "summary": "FunnyStuff", - "year": 2001, - "tagline": "3% Body Fat. 1% Brain Activity.", - "thumb": "/library/metadata/1049/thumb/1434341184", - "art": "/library/metadata/1049/art/1434341184", - "duration": 5129000, - "originallyAvailableAt": "2001-09-27", - "addedAt": 1408525217, - "updatedAt": 1434341184, - "chapterSource": "media", - "primaryExtraKey": "/library/metadata/1073", - "rating": 6, - "Media": [ - { - "id": 827, - "duration": 5129000, - "bitrate": 6564, - "width": 720, - "height": 576, - "aspectRatio": 1.78, - "audioChannels": 6, - "audioCodec": "ac3", - "videoCodec": "mpeg2video", - "container": "mkv", - "videoFrameRate": "PAL", - "Part": [ - { - "id": "827", - "key": "/library/parts/827/file.mkv", - "duration": 5129000, - "file": "O:\\fatboy\\Media\\Ripped\\Movies\\Zoolander (2001).mkv", - "size": 4208219125, - "container": "mkv" - } - ] - } - ], - "Image": [ - { - "type": "coverPoster", - "alt": "Zoolander", - "url": "/library/metadata/1049/thumb/1434341184" - } - ], - "Genre": [ - { - "tag": "Comedy" - } - ], - "Writer": [ - { - "tag": "Drake Sather" - }, - { - "tag": "Ben Stiller" - } - ], - "Director": [ - { - "tag": "Ben Stiller" - } - ], - "Country": [ - { - "tag": "Australia" - }, - { - "tag": "Germany" - } - ], - "Role": [ - { - "tag": "Ben Stiller" - }, - { - "tag": "Owen Wilson" - }, - { - "tag": "Christine Taylor" - } - ] - } - ] - } - } - } - } - } - } - } - } - }, - "put": { - "tags": [ - "Library" - ], - "security": [ - { - "user_token": [ - "admin" - ] - } - ], - "operationId": "libraryMetadataPutSlash", - "summary": "Edit a metadata item", - "description": "Edit metadata items setting fields", - "parameters": [ - { - "in": "path", - "name": "ids", - "schema": { - "type": "array", - "items": { - "type": "string" - } - }, - "required": true - }, - { - "in": "query", - "name": "args", - "schema": { - "type": "object" - }, - "examples": { - "titles": { - "value": { - "title": "A New Title", - "sortTitle": "New Title" - } - } - }, - "description": "The new values for the metadata item" - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/200" - }, - "400": { - "description": "Media items could not be deleted", - "content": { - "text/html": { - "examples": { - "badRequest": { - "summary": "A parameter has a bad value or required parameter is missing", - "value": "Bad Request

400 Bad Request

" - } - } - } - } - } - } - }, - "delete": { - "tags": [ - "Library" - ], - "security": [ - { - "user_token": [ - "admin" - ] - } - ], - "operationId": "libraryMetadataDeleteSlash", - "summary": "Delete a metadata item", - "description": "Delete a single metadata item from the library, deleting media as well", - "parameters": [ - { - "in": "path", - "name": "ids", - "schema": { - "type": "string" - }, - "required": true - }, - { - "in": "query", - "name": "proxy", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "description": "Whether proxy items, such as media optimized versions, should also be deleted. Defaults to false." - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/200" - }, - "400": { - "description": "Media items could not be deleted", - "content": { - "text/html": { - "examples": { - "badRequest": { - "summary": "A parameter has a bad value or required parameter is missing", - "value": "Bad Request

400 Bad Request

" - } - } - } - } - } - } - } - }, - "/library/metadata/{ids}/addetect": { - "put": { - "security": [ - { - "user_token": [ - "admin" - ] - } - ], - "tags": [ - "Library" - ], - "operationId": "libraryMetadataPutAddetect", - "summary": "Ad-detect an item", - "description": "Start the detection of ads in a metadata item", - "parameters": [ - { - "in": "path", - "name": "ids", - "schema": { - "type": "string" - }, - "required": true - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/200" - } - } - } - }, - "/library/metadata/{ids}/allLeaves": { - "get": { - "tags": [ - "Library" - ], - "operationId": "libraryMetadataGetAllLeaves", - "summary": "Get the leaves of an item", - "description": "Get the leaves for a metadata item such as the episodes in a show", - "parameters": [ - { - "in": "path", - "name": "ids", - "schema": { - "type": "string" - }, - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "headers": { - "X-Plex-Container-Total-Size": { - "description": "Provided on all MediaContainer objects indicating the total size of objects available", - "schema": { - "type": "integer" - } - }, - "X-Plex-Container-Start": { - "description": "Provided on all MediaContainer objects indicating the offset of where this container page starts", - "schema": { - "type": "integer" - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/mediaContainerWithMetadata" - }, - "examples": { - "Zoolander Metadata": { - "value": { - "MediaContainer": { - "size": "1", - "allowSync": true, - "art": "/:/resources/movie-fanart.jpg", - "identifier": "com.plexapp.plugins.library", - "librarySectionID": 26, - "librarySectionTitle": "Movies", - "librarySectionUUID": "70cb5089-b165-429b-809a-9e0a31493abf", - "mediaTagPrefix": "/system/bundle/media/flags/", - "mediaTagVersion": "1436742334", - "thumb": "/:/resources/movie.png", - "title1": "Movies", - "title2": "All Movies", - "viewGroup": "movie", - "Metadata": [ - { - "id": "1049", - "ratingKey": "1049", - "key": "/library/metadata/1049", - "studio": "Paramount Pictures", - "type": "movie", - "title": "Zoolander", - "contentRating": "PG-13", - "summary": "FunnyStuff", - "year": 2001, - "tagline": "3% Body Fat. 1% Brain Activity.", - "thumb": "/library/metadata/1049/thumb/1434341184", - "art": "/library/metadata/1049/art/1434341184", - "duration": 5129000, - "originallyAvailableAt": "2001-09-27", - "addedAt": 1408525217, - "updatedAt": 1434341184, - "chapterSource": "media", - "primaryExtraKey": "/library/metadata/1073", - "rating": 6, - "Media": [ - { - "id": 827, - "duration": 5129000, - "bitrate": 6564, - "width": 720, - "height": 576, - "aspectRatio": 1.78, - "audioChannels": 6, - "audioCodec": "ac3", - "videoCodec": "mpeg2video", - "container": "mkv", - "videoFrameRate": "PAL", - "Part": [ - { - "id": "827", - "key": "/library/parts/827/file.mkv", - "duration": 5129000, - "file": "O:\\fatboy\\Media\\Ripped\\Movies\\Zoolander (2001).mkv", - "size": 4208219125, - "container": "mkv" - } - ] - } - ], - "Image": [ - { - "type": "coverPoster", - "alt": "Zoolander", - "url": "/library/metadata/1049/thumb/1434341184" - } - ], - "Genre": [ - { - "tag": "Comedy" - } - ], - "Writer": [ - { - "tag": "Drake Sather" - }, - { - "tag": "Ben Stiller" - } - ], - "Director": [ - { - "tag": "Ben Stiller" - } - ], - "Country": [ - { - "tag": "Australia" - }, - { - "tag": "Germany" - } - ], - "Role": [ - { - "tag": "Ben Stiller" - }, - { - "tag": "Owen Wilson" - }, - { - "tag": "Christine Taylor" - } - ] - } - ] - } - } - } - } - } - } - } - } - } - }, - "/library/metadata/{ids}/analyze": { - "put": { - "security": [ - { - "user_token": [ - "admin" - ] - } - ], - "tags": [ - "Library" - ], - "operationId": "libraryMetadataPutAnalyze", - "summary": "Analyze an item", - "description": "Start the analysis of a metadata item", - "parameters": [ - { - "in": "path", - "name": "ids", - "schema": { - "type": "string" - }, - "required": true - }, - { - "in": "query", - "name": "thumbOffset", - "schema": { - "type": "number" - }, - "required": false, - "description": "Set the offset to be used for thumbnails" - }, - { - "in": "query", - "name": "artOffset", - "schema": { - "type": "number" - }, - "required": false, - "description": "Set the offset to be used for artwork" - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/200" - } - } - } - }, - "/library/metadata/{ids}/chapterThumbs": { - "put": { - "security": [ - { - "user_token": [ - "admin" - ] - } - ], - "tags": [ - "Library" - ], - "operationId": "libraryMetadataPutChapterThumbs", - "summary": "Generate thumbs of chapters for an item", - "description": "Start the chapter thumb generation for an item", - "parameters": [ - { - "in": "path", - "name": "ids", - "schema": { - "type": "string" - }, - "required": true - }, - { - "in": "query", - "name": "force", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "required": false - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/200" - } - } - } - }, - "/library/metadata/{ids}/credits": { - "put": { - "tags": [ - "Library" - ], - "security": [ - { - "user_token": [ - "admin" - ] - } - ], - "operationId": "libraryMetadataPutCredits", - "summary": "Credit detect a metadata item", - "description": "Start credit detection on a metadata item", - "parameters": [ - { - "in": "path", - "name": "ids", - "schema": { - "type": "string" - }, - "required": true - }, - { - "in": "query", - "name": "force", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "required": false - }, - { - "in": "query", - "name": "manual", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "required": false - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/200" - } - } - } - }, - "/library/metadata/{ids}/extras": { - "get": { - "tags": [ - "Library" - ], - "operationId": "libraryMetadataGetExtras", - "summary": "Get an item's extras", - "description": "Get the extras for a metadata item", - "parameters": [ - { - "in": "path", - "name": "ids", - "schema": { - "type": "string" - }, - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "headers": { - "X-Plex-Container-Total-Size": { - "description": "Provided on all MediaContainer objects indicating the total size of objects available", - "schema": { - "type": "integer" - } - }, - "X-Plex-Container-Start": { - "description": "Provided on all MediaContainer objects indicating the offset of where this container page starts", - "schema": { - "type": "integer" - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/mediaContainerWithMetadata" - }, - "examples": { - "Zoolander Metadata": { - "value": { - "MediaContainer": { - "size": "1", - "allowSync": true, - "art": "/:/resources/movie-fanart.jpg", - "identifier": "com.plexapp.plugins.library", - "librarySectionID": 26, - "librarySectionTitle": "Movies", - "librarySectionUUID": "70cb5089-b165-429b-809a-9e0a31493abf", - "mediaTagPrefix": "/system/bundle/media/flags/", - "mediaTagVersion": "1436742334", - "thumb": "/:/resources/movie.png", - "title1": "Movies", - "title2": "All Movies", - "viewGroup": "movie", - "Metadata": [ - { - "id": "1049", - "ratingKey": "1049", - "key": "/library/metadata/1049", - "studio": "Paramount Pictures", - "type": "movie", - "title": "Zoolander", - "contentRating": "PG-13", - "summary": "FunnyStuff", - "year": 2001, - "tagline": "3% Body Fat. 1% Brain Activity.", - "thumb": "/library/metadata/1049/thumb/1434341184", - "art": "/library/metadata/1049/art/1434341184", - "duration": 5129000, - "originallyAvailableAt": "2001-09-27", - "addedAt": 1408525217, - "updatedAt": 1434341184, - "chapterSource": "media", - "primaryExtraKey": "/library/metadata/1073", - "rating": 6, - "Media": [ - { - "id": 827, - "duration": 5129000, - "bitrate": 6564, - "width": 720, - "height": 576, - "aspectRatio": 1.78, - "audioChannels": 6, - "audioCodec": "ac3", - "videoCodec": "mpeg2video", - "container": "mkv", - "videoFrameRate": "PAL", - "Part": [ - { - "id": "827", - "key": "/library/parts/827/file.mkv", - "duration": 5129000, - "file": "O:\\fatboy\\Media\\Ripped\\Movies\\Zoolander (2001).mkv", - "size": 4208219125, - "container": "mkv" - } - ] - } - ], - "Image": [ - { - "type": "coverPoster", - "alt": "Zoolander", - "url": "/library/metadata/1049/thumb/1434341184" - } - ], - "Genre": [ - { - "tag": "Comedy" - } - ], - "Writer": [ - { - "tag": "Drake Sather" - }, - { - "tag": "Ben Stiller" - } - ], - "Director": [ - { - "tag": "Ben Stiller" - } - ], - "Country": [ - { - "tag": "Australia" - }, - { - "tag": "Germany" - } - ], - "Role": [ - { - "tag": "Ben Stiller" - }, - { - "tag": "Owen Wilson" - }, - { - "tag": "Christine Taylor" - } - ] - } - ] - } - } - } - } - } - } - } - } - }, - "post": { - "tags": [ - "Library" - ], - "operationId": "libraryMetadataPostExtras", - "summary": "Add to an item's extras", - "description": "Add an extra to a metadata item", - "parameters": [ - { - "in": "path", - "name": "ids", - "schema": { - "type": "string" - }, - "required": true - }, - { - "in": "query", - "name": "extraType", - "schema": { - "type": "integer" - }, - "description": "The metadata type of the extra" - }, - { - "in": "query", - "name": "url", - "schema": { - "type": "string" - }, - "description": "The URL of the extra", - "required": true - }, - { - "in": "query", - "name": "title", - "schema": { - "type": "string" - }, - "description": "The title of the extra", - "required": true - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/200" - }, - "404": { - "description": "Either the metadata item is not present or the extra could not be added", - "content": { - "text/html": { - "examples": { - "notFound": { - "summary": "Not Found", - "value": "Not Found

404 Not Found

" - } - } - } - } - } - } - } - }, - "/library/metadata/{ids}/file": { - "get": { - "tags": [ - "Library" - ], - "operationId": "libraryMetadataGetFile", - "summary": "Get a file from a metadata or media bundle", - "description": "Get a bundle file for a metadata or media item. This is either an image or a mp3 (for a show's theme)", - "parameters": [ - { - "in": "path", - "name": "ids", - "schema": { - "type": "string" - }, - "required": true - }, - { - "in": "query", - "name": "url", - "schema": { - "type": "string" - }, - "description": "The bundle url, typically starting with `metadata://` or `media://`" - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "audio/mpeg3": { - "schema": { - "type": "string", - "format": "binary" - } - }, - "image/jpeg": { - "schema": { - "type": "string", - "format": "binary" - } - } - } - } - } - } - }, - "/library/metadata/{ids}/index": { - "put": { - "security": [ - { - "user_token": [ - "admin" - ] - } - ], - "tags": [ - "Library" - ], - "operationId": "libraryMetadataPutIndex", - "summary": "Start BIF generation of an item", - "description": "Start the indexing (BIF generation) of an item", - "parameters": [ - { - "in": "path", - "name": "ids", - "schema": { - "type": "string" - }, - "required": true - }, - { - "in": "query", - "name": "force", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "required": false - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/200" - } - } - } - }, - "/library/metadata/{ids}/intro": { - "put": { - "security": [ - { - "user_token": [ - "admin" - ] - } - ], - "tags": [ - "Library" - ], - "operationId": "libraryMetadataPutIntro", - "summary": "Intro detect an item", - "description": "Start the detection of intros in a metadata item", - "parameters": [ - { - "in": "path", - "name": "ids", - "schema": { - "type": "string" - }, - "required": true - }, - { - "in": "query", - "name": "force", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "required": false, - "description": "Indicate whether detection should be re-run" - }, - { - "in": "query", - "name": "threshold", - "schema": { - "type": "number" - }, - "required": false, - "description": "The threshold for determining if content is an intro or not" - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/200" - } - } - } - }, - "/library/metadata/{ids}/marker": { - "post": { - "tags": [ - "Library" - ], - "operationId": "libraryMetadataPostMarker", - "summary": "Create a marker", - "description": "Create a marker for this user on the metadata item", - "parameters": [ - { - "in": "path", - "name": "ids", - "schema": { - "type": "string" - }, - "required": true - }, - { - "in": "query", - "name": "type", - "schema": { - "type": "integer" - }, - "required": true, - "description": "The type of marker to edit/create" - }, - { - "in": "query", - "name": "startTimeOffset", - "schema": { - "type": "integer" - }, - "required": true, - "description": "The start time of the marker" - }, - { - "in": "query", - "name": "endTimeOffset", - "schema": { - "type": "integer" - }, - "description": "The end time of the marker" - }, - { - "in": "query", - "name": "attributes", - "schema": { - "type": "object" - }, - "style": "deepObject", - "example": { - "title": "My favorite spot" - }, - "description": "The attributes to assign to this marker" - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "MediaContainer": { - "allOf": [ - { - "$ref": "#/components/schemas/MediaContainer" - }, - { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "type": { - "type": "string", - "enum": [ - "intro", - "commercial", - "bookmark", - "resume", - "credit" - ] - }, - "startTimeOffset": { - "type": "integer" - }, - "endTimeOffset": { - "type": "integer" - }, - "title": { - "type": "string" - }, - "color": { - "type": "string" - } - }, - "additionalProperties": true - } - ] - } - } - }, - "examples": { - "creditMarker": { - "value": { - "MediaContainer": { - "size": 1, - "Marker": [ - { - "final": true, - "id": 1025, - "type": "credits", - "startTimeOffset": 8249805, - "endTimeOffset": 8715339 - } - ] - } - } - } - } - } - } - }, - "400": { - "description": "Request parameters are bad, such as an `endTimeOffset` prior to the `startTimeOffset`", - "content": { - "text/html": { - "examples": { - "badRequest": { - "summary": "A parameter has a bad value or required parameter is missing", - "value": "Bad Request

400 Bad Request

" - } - } - } - } - } - } - } - }, - "/library/metadata/{ids}/marker/{marker}": { - "delete": { - "tags": [ - "Library" - ], - "operationId": "libraryMetadataDeleteMarkerMarker", - "summary": "Delete a marker", - "description": "Delete a marker for this user on the metadata item", - "parameters": [ - { - "in": "path", - "name": "ids", - "schema": { - "type": "string" - }, - "required": true - }, - { - "in": "path", - "name": "marker", - "schema": { - "type": "string" - }, - "required": true - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/200" - }, - "400": { - "description": "Marker is not a bookmark", - "content": { - "text/html": { - "examples": { - "badRequest": { - "summary": "A parameter has a bad value or required parameter is missing", - "value": "Bad Request

400 Bad Request

" - } - } - } - } - }, - "404": { - "description": "Marker could not be found", - "content": { - "text/html": { - "examples": { - "notFound": { - "summary": "Not Found", - "value": "Not Found

404 Not Found

" - } - } - } - } - } - } - }, - "put": { - "tags": [ - "Library" - ], - "operationId": "libraryMetadataPutMarkerMarker", - "summary": "Edit a marker", - "description": "Edit a marker for this user on the metadata item", - "parameters": [ - { - "in": "path", - "name": "ids", - "schema": { - "type": "string" - }, - "required": true - }, - { - "in": "path", - "name": "marker", - "schema": { - "type": "string" - }, - "description": "The id of the marker to edit", - "required": true - }, - { - "in": "query", - "name": "type", - "schema": { - "type": "integer" - }, - "required": true, - "description": "The type of marker to edit/create" - }, - { - "in": "query", - "name": "startTimeOffset", - "schema": { - "type": "integer" - }, - "required": true, - "description": "The start time of the marker" - }, - { - "in": "query", - "name": "endTimeOffset", - "schema": { - "type": "integer" - }, - "description": "The end time of the marker" - }, - { - "in": "query", - "name": "attributes", - "schema": { - "type": "object" - }, - "style": "deepObject", - "example": { - "title": "My favorite spot" - }, - "description": "The attributes to assign to this marker" - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/post-responses-200" - }, - "400": { - "$ref": "#/components/responses/responses-400" - }, - "404": { - "description": "The marker could not be found", - "content": { - "text/html": { - "examples": { - "notFound": { - "summary": "Not Found", - "value": "Not Found

404 Not Found

" - } - } - } - } - } - } - } - }, - "/library/metadata/{ids}/match": { - "put": { - "tags": [ - "Library" - ], - "security": [ - { - "user_token": [ - "admin" - ] - } - ], - "operationId": "libraryMetadataPutMatch", - "summary": "Match a metadata item", - "description": "Match a metadata item to a guid", - "parameters": [ - { - "in": "path", - "name": "ids", - "schema": { - "type": "string" - }, - "required": true - }, - { - "in": "query", - "name": "guid", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "name", - "schema": { - "type": "string" - }, - "required": false - }, - { - "in": "query", - "name": "year", - "schema": { - "type": "integer" - }, - "required": false - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/200" - } - } - } - }, - "/library/metadata/{ids}/matches": { - "put": { - "tags": [ - "Library" - ], - "security": [ - { - "user_token": [ - "admin" - ] - } - ], - "operationId": "libraryMetadataGetMatches", - "summary": "Get metadata matches for an item", - "description": "Get the list of metadata matches for a metadata item", - "parameters": [ - { - "in": "path", - "name": "ids", - "schema": { - "type": "string" - }, - "required": true - }, - { - "in": "query", - "name": "title", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "parentTitle", - "schema": { - "type": "string" - }, - "required": false - }, - { - "in": "query", - "name": "agent", - "schema": { - "type": "string" - }, - "required": false - }, - { - "in": "query", - "name": "language", - "schema": { - "type": "string" - }, - "required": false - }, - { - "in": "query", - "name": "year", - "schema": { - "type": "integer" - }, - "required": false - }, - { - "in": "query", - "name": "manual", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "required": false - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/mediaContainerWithMetadata" - }, - "examples": { - "Zoolander Metadata": { - "value": { - "MediaContainer": { - "size": "1", - "allowSync": true, - "art": "/:/resources/movie-fanart.jpg", - "identifier": "com.plexapp.plugins.library", - "librarySectionID": 26, - "librarySectionTitle": "Movies", - "librarySectionUUID": "70cb5089-b165-429b-809a-9e0a31493abf", - "mediaTagPrefix": "/system/bundle/media/flags/", - "mediaTagVersion": "1436742334", - "thumb": "/:/resources/movie.png", - "title1": "Movies", - "title2": "All Movies", - "viewGroup": "movie", - "Metadata": [ - { - "id": "1049", - "ratingKey": "1049", - "key": "/library/metadata/1049", - "studio": "Paramount Pictures", - "type": "movie", - "title": "Zoolander", - "contentRating": "PG-13", - "summary": "FunnyStuff", - "year": 2001, - "tagline": "3% Body Fat. 1% Brain Activity.", - "thumb": "/library/metadata/1049/thumb/1434341184", - "art": "/library/metadata/1049/art/1434341184", - "duration": 5129000, - "originallyAvailableAt": "2001-09-27", - "addedAt": 1408525217, - "updatedAt": 1434341184, - "chapterSource": "media", - "primaryExtraKey": "/library/metadata/1073", - "rating": 6, - "Media": [ - { - "id": 827, - "duration": 5129000, - "bitrate": 6564, - "width": 720, - "height": 576, - "aspectRatio": 1.78, - "audioChannels": 6, - "audioCodec": "ac3", - "videoCodec": "mpeg2video", - "container": "mkv", - "videoFrameRate": "PAL", - "Part": [ - { - "id": "827", - "key": "/library/parts/827/file.mkv", - "duration": 5129000, - "file": "O:\\fatboy\\Media\\Ripped\\Movies\\Zoolander (2001).mkv", - "size": 4208219125, - "container": "mkv" - } - ] - } - ], - "Image": [ - { - "type": "coverPoster", - "alt": "Zoolander", - "url": "/library/metadata/1049/thumb/1434341184" - } - ], - "Genre": [ - { - "tag": "Comedy" - } - ], - "Writer": [ - { - "tag": "Drake Sather" - }, - { - "tag": "Ben Stiller" - } - ], - "Director": [ - { - "tag": "Ben Stiller" - } - ], - "Country": [ - { - "tag": "Australia" - }, - { - "tag": "Germany" - } - ], - "Role": [ - { - "tag": "Ben Stiller" - }, - { - "tag": "Owen Wilson" - }, - { - "tag": "Christine Taylor" - } - ] - } - ] - } - } - } - } - } - } - } - } - } - }, - "/library/metadata/{ids}/media/{mediaItem}": { - "delete": { - "tags": [ - "Library" - ], - "security": [ - { - "user_token": [ - "admin" - ] - } - ], - "operationId": "libraryMetadataDeleteMediaMediaItem", - "summary": "Delete a media item", - "description": "Delete a single media from a metadata item in the library", - "parameters": [ - { - "in": "path", - "name": "ids", - "schema": { - "type": "string" - }, - "required": true - }, - { - "in": "path", - "name": "mediaItem", - "schema": { - "type": "string" - }, - "required": true - }, - { - "in": "query", - "name": "proxy", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "description": "Whether proxy items, such as media optimized versions, should also be deleted. Defaults to false." - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/200" - }, - "400": { - "description": "Media item could not be deleted", - "content": { - "text/html": { - "examples": { - "badRequest": { - "summary": "A parameter has a bad value or required parameter is missing", - "value": "Bad Request

400 Bad Request

" - } - } - } - } - }, - "404": { - "description": "Media item could not be found", - "content": { - "text/html": { - "examples": { - "notFound": { - "summary": "Not Found", - "value": "Not Found

404 Not Found

" - } - } - } - } - } - } - } - }, - "/library/metadata/{ids}/merge": { - "put": { - "tags": [ - "Library" - ], - "security": [ - { - "user_token": [ - "admin" - ] - } - ], - "operationId": "libraryMetadataPutMerge", - "summary": "Merge a metadata item", - "description": "Merge a metadata item with other items", - "parameters": [ - { - "in": "path", - "name": "ids", - "schema": { - "type": "string" - }, - "required": true - }, - { - "in": "query", - "name": "ids", - "schema": { - "type": "array", - "items": { - "type": "string" - } - }, - "explode": false, - "examples": { - "twoIds": { - "value": [ - 5, - 6 - ] - } - } - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/200" - } - } - } - }, - "/library/metadata/{ids}/nearest": { - "get": { - "tags": [ - "Library" - ], - "operationId": "libraryMetadataGetNearest", - "summary": "Get nearest tracks to metadata item", - "description": "Get the nearest tracks, sonically, to the provided track", - "parameters": [ - { - "in": "path", - "name": "ids", - "schema": { - "type": "string" - }, - "required": true - }, - { - "in": "query", - "name": "excludeParentID", - "schema": { - "type": "integer" - }, - "required": false - }, - { - "in": "query", - "name": "excludeGrandparentID", - "schema": { - "type": "integer" - }, - "required": false - }, - { - "in": "query", - "name": "limit", - "schema": { - "type": "integer" - }, - "required": false - }, - { - "in": "query", - "name": "maxDistance", - "schema": { - "type": "number" - }, - "required": false - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/mediaContainerWithMetadata" - }, - "examples": { - "Zoolander Metadata": { - "value": { - "MediaContainer": { - "size": "1", - "allowSync": true, - "art": "/:/resources/movie-fanart.jpg", - "identifier": "com.plexapp.plugins.library", - "librarySectionID": 26, - "librarySectionTitle": "Movies", - "librarySectionUUID": "70cb5089-b165-429b-809a-9e0a31493abf", - "mediaTagPrefix": "/system/bundle/media/flags/", - "mediaTagVersion": "1436742334", - "thumb": "/:/resources/movie.png", - "title1": "Movies", - "title2": "All Movies", - "viewGroup": "movie", - "Metadata": [ - { - "id": "1049", - "ratingKey": "1049", - "key": "/library/metadata/1049", - "studio": "Paramount Pictures", - "type": "movie", - "title": "Zoolander", - "contentRating": "PG-13", - "summary": "FunnyStuff", - "year": 2001, - "tagline": "3% Body Fat. 1% Brain Activity.", - "thumb": "/library/metadata/1049/thumb/1434341184", - "art": "/library/metadata/1049/art/1434341184", - "duration": 5129000, - "originallyAvailableAt": "2001-09-27", - "addedAt": 1408525217, - "updatedAt": 1434341184, - "chapterSource": "media", - "primaryExtraKey": "/library/metadata/1073", - "rating": 6, - "Media": [ - { - "id": 827, - "duration": 5129000, - "bitrate": 6564, - "width": 720, - "height": 576, - "aspectRatio": 1.78, - "audioChannels": 6, - "audioCodec": "ac3", - "videoCodec": "mpeg2video", - "container": "mkv", - "videoFrameRate": "PAL", - "Part": [ - { - "id": "827", - "key": "/library/parts/827/file.mkv", - "duration": 5129000, - "file": "O:\\fatboy\\Media\\Ripped\\Movies\\Zoolander (2001).mkv", - "size": 4208219125, - "container": "mkv" - } - ] - } - ], - "Image": [ - { - "type": "coverPoster", - "alt": "Zoolander", - "url": "/library/metadata/1049/thumb/1434341184" - } - ], - "Genre": [ - { - "tag": "Comedy" - } - ], - "Writer": [ - { - "tag": "Drake Sather" - }, - { - "tag": "Ben Stiller" - } - ], - "Director": [ - { - "tag": "Ben Stiller" - } - ], - "Country": [ - { - "tag": "Australia" - }, - { - "tag": "Germany" - } - ], - "Role": [ - { - "tag": "Ben Stiller" - }, - { - "tag": "Owen Wilson" - }, - { - "tag": "Christine Taylor" - } - ] - } - ] - } - } - } - } - } - } - } - } - } - }, - "/library/metadata/{ids}/prefs": { - "put": { - "tags": [ - "Library" - ], - "security": [ - { - "user_token": [ - "admin" - ] - } - ], - "operationId": "libraryMetadataPutPrefs", - "summary": "Set metadata preferences", - "description": "Set the preferences on a metadata item", - "parameters": [ - { - "in": "path", - "name": "ids", - "schema": { - "type": "string" - }, - "required": true - }, - { - "in": "query", - "name": "args", - "schema": { - "type": "object" - }, - "examples": { - "titles": { - "value": { - "pref1": "value1", - "pref2": "value2" - } - } - } - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/200" - } - } - } - }, - "/library/metadata/{ids}/refresh": { - "put": { - "tags": [ - "Library" - ], - "security": [ - { - "user_token": [ - "admin" - ] - } - ], - "operationId": "libraryMetadataPutRefresh", - "summary": "Refresh a metadata item", - "description": "Refresh a metadata item from the agent", - "parameters": [ - { - "in": "path", - "name": "ids", - "schema": { - "type": "string" - }, - "required": true - }, - { - "in": "query", - "name": "agent", - "schema": { - "type": "string" - }, - "required": false - }, - { - "in": "query", - "name": "markUpdated", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "required": false - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/200" - } - } - } - }, - "/library/metadata/{ids}/related": { - "get": { - "tags": [ - "Library" - ], - "operationId": "libraryMetadataGetRelated", - "summary": "Get related items", - "description": "Get a hub of related items to a metadata item", - "parameters": [ - { - "in": "path", - "name": "ids", - "schema": { - "type": "string" - }, - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "MediaContainer": { - "allOf": [ - { - "$ref": "#/components/schemas/MediaContainer" - }, - { - "type": "object", - "properties": { - "Hub": { - "type": "array", - "items": { - "$ref": "#/components/schemas/hub" - } - } - } - } - ] - } - } - }, - "examples": { - "onDeck": { - "summary": "An example of search for `simpsons`", - "value": { - "MediaContainer": { - "size": 1, - "Hub": [ - { - "context": "hub.home.onDeck", - "hubIdentifier": "home.onDeck", - "key": "/hubs/sections/home/onDeck", - "more": true, - "size": 1, - "subtype": "podcast", - "title": "On Deck", - "totalSize": 8, - "type": "track" - } - ] - } - } - } - } - } - } - } - } - } - }, - "/library/metadata/{ids}/similar": { - "get": { - "tags": [ - "Library" - ], - "operationId": "libraryMetadataGetSimilar", - "summary": "Get similar items", - "description": "Get a list of similar items to a metadata item", - "parameters": [ - { - "in": "path", - "name": "ids", - "schema": { - "type": "string" - }, - "required": true - }, - { - "in": "query", - "name": "count", - "schema": { - "type": "integer" - }, - "description": "The maximum number of similar items; defaults to 200" - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/mediaContainerWithMetadata" - }, - "examples": { - "Zoolander Metadata": { - "value": { - "MediaContainer": { - "size": "1", - "allowSync": true, - "art": "/:/resources/movie-fanart.jpg", - "identifier": "com.plexapp.plugins.library", - "librarySectionID": 26, - "librarySectionTitle": "Movies", - "librarySectionUUID": "70cb5089-b165-429b-809a-9e0a31493abf", - "mediaTagPrefix": "/system/bundle/media/flags/", - "mediaTagVersion": "1436742334", - "thumb": "/:/resources/movie.png", - "title1": "Movies", - "title2": "All Movies", - "viewGroup": "movie", - "Metadata": [ - { - "id": "1049", - "ratingKey": "1049", - "key": "/library/metadata/1049", - "studio": "Paramount Pictures", - "type": "movie", - "title": "Zoolander", - "contentRating": "PG-13", - "summary": "FunnyStuff", - "year": 2001, - "tagline": "3% Body Fat. 1% Brain Activity.", - "thumb": "/library/metadata/1049/thumb/1434341184", - "art": "/library/metadata/1049/art/1434341184", - "duration": 5129000, - "originallyAvailableAt": "2001-09-27", - "addedAt": 1408525217, - "updatedAt": 1434341184, - "chapterSource": "media", - "primaryExtraKey": "/library/metadata/1073", - "rating": 6, - "Media": [ - { - "id": 827, - "duration": 5129000, - "bitrate": 6564, - "width": 720, - "height": 576, - "aspectRatio": 1.78, - "audioChannels": 6, - "audioCodec": "ac3", - "videoCodec": "mpeg2video", - "container": "mkv", - "videoFrameRate": "PAL", - "Part": [ - { - "id": "827", - "key": "/library/parts/827/file.mkv", - "duration": 5129000, - "file": "O:\\fatboy\\Media\\Ripped\\Movies\\Zoolander (2001).mkv", - "size": 4208219125, - "container": "mkv" - } - ] - } - ], - "Image": [ - { - "type": "coverPoster", - "alt": "Zoolander", - "url": "/library/metadata/1049/thumb/1434341184" - } - ], - "Genre": [ - { - "tag": "Comedy" - } - ], - "Writer": [ - { - "tag": "Drake Sather" - }, - { - "tag": "Ben Stiller" - } - ], - "Director": [ - { - "tag": "Ben Stiller" - } - ], - "Country": [ - { - "tag": "Australia" - }, - { - "tag": "Germany" - } - ], - "Role": [ - { - "tag": "Ben Stiller" - }, - { - "tag": "Owen Wilson" - }, - { - "tag": "Christine Taylor" - } - ] - } - ] - } - } - } - } - } - } - } - } - } - }, - "/library/metadata/{ids}/split": { - "put": { - "tags": [ - "Library" - ], - "security": [ - { - "user_token": [ - "admin" - ] - } - ], - "operationId": "libraryMetadataPutSplit", - "summary": "Split a metadata item", - "description": "Split a metadata item into multiple items", - "parameters": [ - { - "in": "path", - "name": "ids", - "schema": { - "type": "string" - }, - "required": true - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/200" - } - } - } - }, - "/library/metadata/{ids}/subtitles": { - "get": { - "security": [ - { - "user_token": [ - "admin" - ] - } - ], - "tags": [ - "Library" - ], - "operationId": "libraryMetadataPostSubtitles", - "summary": "Add subtitles", - "description": "Add a subtitle to a metadata item", - "parameters": [ - { - "in": "path", - "name": "ids", - "schema": { - "type": "string" - }, - "required": true - }, - { - "in": "query", - "name": "title", - "schema": { - "type": "string" - }, - "required": false - }, - { - "in": "query", - "name": "language", - "schema": { - "type": "string" - }, - "required": false - }, - { - "in": "query", - "name": "mediaItemID", - "schema": { - "type": "integer" - }, - "required": false - }, - { - "in": "query", - "name": "url", - "schema": { - "type": "string" - }, - "description": "The URL of the subtitle. If not provided, the contents of the subtitle must be in the post body", - "required": false - }, - { - "in": "query", - "name": "format", - "schema": { - "type": "string" - }, - "required": false - }, - { - "in": "query", - "name": "forced", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "required": false - }, - { - "in": "query", - "name": "hearingImpaired", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "required": false - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/200" - } - } - } - }, - "/library/metadata/{ids}/tree": { - "get": { - "tags": [ - "Library" - ], - "operationId": "libraryMetadataGetTree", - "summary": "Get metadata items as a tree", - "description": "Get a tree of metadata items, such as the seasons/episodes of a show", - "parameters": [ - { - "in": "path", - "name": "ids", - "schema": { - "type": "string" - }, - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/mediaContainerWithNestedMetadata" - }, - "examples": { - "show": { - "value": { - "MediaContainer": { - "size": 1, - "identifier": "com.plexapp.plugins.library", - "mediaTagPrefix": "/system/bundle/media/flags/", - "mediaTagVersion": 1680272530, - "MetadataItem": [ - { - "id": 148, - "title": "Babylon 5", - "guid": "plex://show/5d9c087202391c001f58a287", - "parentTitle": "", - "instanceRatingKey": "", - "index": 1, - "originallyAvailableAt": "1993-02-22", - "addedAt": 1348349390, - "refreshedAt": 1733670142, - "MetadataItem": [ - { - "id": 155, - "title": "", - "guid": "plex://season/602e691966dfdb002c0a4fe8", - "parentTitle": "", - "instanceRatingKey": "", - "index": 0, - "originallyAvailableAt": "", - "addedAt": 1535655413, - "refreshedAt": 1681173733, - "MetadataItem": [ - { - "id": 156, - "title": "The Gathering", - "guid": "plex://episode/5de84fff473b6d001f36c4ff", - "parentTitle": "", - "instanceRatingKey": "", - "index": 1, - "originallyAvailableAt": "1993-02-22", - "addedAt": 1535655413, - "refreshedAt": 1731766766, - "MediaItem": [ - { - "id": 380, - "MediaPart": [ - { - "metadataType": 4, - "id": 868, - "file": "/Volumes/Media/TV Shows/Babylon 5/Specials/Babylon 5 S00E01 The Gathering.mkv", - "size": 4034073857, - "openSubtitleHash": "65f52aa3bd4025d4", - "hash": "47da8a62adf441b14a925e779766adb6680e96da", - "duration": 5682009, - "MediaStream": [ - { - "id": 2000, - "index": 0, - "type": 1, - "codec": "mpeg2video", - "language": "en", - "anamorphic": "1", - "bitDepth": "8", - "chromaLocation": "left", - "chromaSubsampling": "4:2:0", - "codedHeight": "480", - "codedWidth": "720", - "colorRange": "tv", - "frameRate": "29.970", - "height": "480", - "level": "8", - "pixelAspectRatio": "8:9", - "profile": "main", - "refFrames": "1", - "requiredBandwidths": "6015,5740,5448,5276,5256,5256,5256,5256", - "scanType": "interlaced", - "width": "720" - }, - { - "id": 2001, - "index": 1, - "type": 2, - "channels": 6, - "codec": "ac3", - "language": "en", - "audioChannelLayout": "5.1(side)", - "requiredBandwidths": "448,448,448,448,448,448,448,448", - "samplingRate": "48000", - "title": "Surround 5.1" - } - ] - } - ] - }, - { - "id": 157, - "title": "In the Beginning", - "guid": "plex://episode/5d9c13597b5c2e001e6d14d8", - "parentTitle": "", - "instanceRatingKey": "", - "index": 2, - "originallyAvailableAt": "1998-01-04", - "addedAt": 1535655814, - "refreshedAt": 1731766766, - "MediaItem": [ - { - "id": 381, - "MediaPart": [ - { - "metadataType": 4, - "id": 869, - "file": "/Volumes/Media/TV Shows/Babylon 5/Specials/Babylon 5 S00E02 In the Beginning.mkv", - "size": 4018284691, - "openSubtitleHash": "adb464543eaec254", - "hash": "dca84a0062e4bab47380a8409e3175b72f0e8b86", - "duration": 5657652, - "MediaStream": [ - { - "id": 2002, - "index": 0, - "type": 1, - "codec": "mpeg2video", - "language": "en", - "anamorphic": "1", - "bitDepth": "8", - "chromaLocation": "left", - "chromaSubsampling": "4:2:0", - "codedHeight": "480", - "codedWidth": "720", - "colorRange": "tv", - "frameRate": "29.970", - "height": "480", - "level": "8", - "pixelAspectRatio": "853:720", - "profile": "main", - "refFrames": "1", - "requiredBandwidths": "6086,5901,5671,5671,5671,5671,5671,5671", - "scanType": "interlaced", - "width": "720" - }, - { - "id": 2003, - "index": 1, - "type": 2, - "channels": 6, - "codec": "ac3", - "language": "en", - "audioChannelLayout": "5.1(side)", - "requiredBandwidths": "448,448,448,448,448,448,448,448", - "samplingRate": "48000", - "title": "Surround 5.1" - } - ] - } - ] - } - ] - } - ] - } - ] - }, - { - "id": 149, - "title": "", - "guid": "plex://season/602e691b66dfdb002c0a5034", - "parentTitle": "", - "instanceRatingKey": "", - "index": 4, - "originallyAvailableAt": "", - "addedAt": 1348349390, - "refreshedAt": 1681173733, - "MetadataItem": [ - { - "id": 150, - "title": "The Illusion of Truth", - "guid": "plex://episode/5d9c1359e264b7001fcb529c", - "parentTitle": "", - "instanceRatingKey": "", - "index": 8, - "originallyAvailableAt": "1997-02-17", - "addedAt": 1348349390, - "refreshedAt": 1731766766, - "MediaItem": [ - { - "id": 376, - "MediaPart": [ - { - "metadataType": 4, - "id": 872, - "file": "/Volumes/Media/TV Shows/Babylon 5/Season 4/Babylon 5 S04E08 The Illusion of Truth.mkv", - "size": 1883816967, - "openSubtitleHash": "c39a45711f9b45ce", - "hash": "db2c2d744655454a9c714daa1f22311e3933a204", - "duration": 2625089, - "MediaStream": [ - { - "id": 2008, - "index": 0, - "type": 1, - "codec": "mpeg2video", - "language": "en", - "anamorphic": "1", - "bitDepth": "8", - "chromaLocation": "left", - "chromaSubsampling": "4:2:0", - "codedHeight": "480", - "codedWidth": "720", - "colorRange": "tv", - "frameRate": "29.970", - "height": "480", - "level": "8", - "pixelAspectRatio": "853:720", - "profile": "main", - "refFrames": "1", - "requiredBandwidths": "6499,6124,5357,5357,5357,5357,5357,5357", - "scanType": "interlaced", - "width": "720" - }, - { - "id": 2009, - "index": 1, - "type": 2, - "channels": 6, - "codec": "ac3", - "language": "en", - "audioChannelLayout": "5.1(side)", - "requiredBandwidths": "448,448,448,448,448,448,448,448", - "samplingRate": "48000", - "title": "3/2+1" - } - ] - } - ] - } - ] - }, - { - "id": 151, - "title": "No Surrender, No Retreat", - "guid": "plex://episode/5d9c1359e264b7001fcb5296", - "parentTitle": "", - "instanceRatingKey": "", - "index": 15, - "originallyAvailableAt": "1997-05-26", - "addedAt": 1348349511, - "refreshedAt": 1731766766, - "MediaItem": [ - { - "id": 377, - "MediaPart": [ - { - "metadataType": 4, - "id": 873, - "file": "/Volumes/Media/TV Shows/Babylon 5/Season 4/Babylon 5 S04E15 No Surrender, No Retreat.mkv", - "size": 1906783890, - "openSubtitleHash": "f218ae96150b8943", - "hash": "6ac941e62a1d39c09ff5fd1ee7bd9c6b517ad55c", - "duration": 2657021, - "MediaStream": [ - { - "id": 2010, - "index": 0, - "type": 1, - "codec": "mpeg2video", - "language": "en", - "anamorphic": "1", - "bitDepth": "8", - "chromaLocation": "left", - "chromaSubsampling": "4:2:0", - "codedHeight": "480", - "codedWidth": "720", - "colorRange": "tv", - "frameRate": "29.970", - "height": "480", - "level": "8", - "pixelAspectRatio": "853:720", - "profile": "main", - "refFrames": "1", - "requiredBandwidths": "5716,5481,5481,5481,5481,5481,5481,5481", - "scanType": "interlaced", - "width": "720" - }, - { - "id": 2011, - "index": 1, - "type": 2, - "channels": 6, - "codec": "ac3", - "language": "en", - "audioChannelLayout": "5.1(side)", - "requiredBandwidths": "448,448,448,448,448,448,448,448", - "samplingRate": "48000", - "title": "3/2+1" - } - ] - } - ] - } - ] - } - ] - }, - { - "id": 152, - "title": "", - "guid": "plex://season/602e691c66dfdb002c0a504b", - "parentTitle": "", - "instanceRatingKey": "", - "index": 5, - "originallyAvailableAt": "", - "addedAt": 1352798796, - "refreshedAt": 1681173733, - "MetadataItem": [ - { - "id": 153, - "title": "Movements of Fire and Shadow (1)", - "guid": "plex://episode/5d9c1359ba2e21001f1b6ac1", - "parentTitle": "", - "instanceRatingKey": "", - "index": 17, - "originallyAvailableAt": "1998-06-17", - "addedAt": 1352798796, - "refreshedAt": 1731766766, - "MediaItem": [ - { - "id": 378, - "MediaPart": [ - { - "metadataType": 4, - "id": 866, - "file": "/Volumes/Media/TV Shows/Babylon 5/Season 5/Babylon 5 S05E17 Movements of Fire and Shadow (1).mkv", - "size": 1903904159, - "openSubtitleHash": "d0ca03c8c517a440", - "hash": "db2f9485ca2a7df94a980f854d527e9f48ff8a11", - "duration": 2652983, - "MediaStream": [ - { - "id": 1993, - "index": 0, - "type": 1, - "codec": "mpeg2video", - "language": "en", - "anamorphic": "1", - "bitDepth": "8", - "chromaLocation": "left", - "chromaSubsampling": "4:2:0", - "codedHeight": "480", - "codedWidth": "720", - "colorRange": "tv", - "frameRate": "29.970", - "height": "480", - "level": "8", - "pixelAspectRatio": "853:720", - "profile": "main", - "refFrames": "1", - "requiredBandwidths": "6220,5545,5451,5451,5451,5451,5451,5451", - "scanType": "interlaced", - "width": "720" - }, - { - "id": 1994, - "index": 1, - "type": 2, - "channels": 6, - "codec": "ac3", - "language": "en", - "audioChannelLayout": "5.1(side)", - "requiredBandwidths": "448,448,448,448,448,448,448,448", - "samplingRate": "48000", - "title": "3/2+1" - } - ] - } - ] - } - ] - }, - { - "id": 154, - "title": "The Fall of Centauri Prime (2)", - "guid": "plex://episode/5d9c13592192ba001f38f283", - "parentTitle": "", - "instanceRatingKey": "", - "index": 18, - "originallyAvailableAt": "1998-10-28", - "addedAt": 1352798813, - "refreshedAt": 1731766766, - "MediaItem": [ - { - "id": 379, - "MediaPart": [ - { - "metadataType": 4, - "id": 867, - "file": "/Volumes/Media/TV Shows/Babylon 5/Season 5/Babylon 5 S05E18 The Fall of Centauri Prime (2).mkv", - "size": 1904087004, - "openSubtitleHash": "96e203e09a1844e8", - "hash": "c68e775307bb01d9c4ffad55fae6e606c82b09ac", - "duration": 2653250, - "MediaStream": [ - { - "id": 1995, - "index": 0, - "type": 1, - "codec": "mpeg2video", - "language": "en", - "anamorphic": "1", - "bitDepth": "8", - "chromaLocation": "left", - "chromaSubsampling": "4:2:0", - "codedHeight": "480", - "codedWidth": "720", - "colorRange": "tv", - "frameRate": "29.970", - "height": "480", - "level": "8", - "pixelAspectRatio": "853:720", - "profile": "main", - "refFrames": "1", - "requiredBandwidths": "5958,5926,5926,5926,5926,5926,5926,5926", - "scanType": "interlaced", - "width": "720" - }, - { - "id": 1996, - "index": 1, - "type": 2, - "channels": 6, - "codec": "ac3", - "language": "en", - "audioChannelLayout": "5.1(side)", - "requiredBandwidths": "448,448,448,448,448,448,448,448", - "samplingRate": "48000", - "title": "3/2+1" - } - ] - } - ] - } - ] - } - ] - } - ] - } - ], - "Setting": [ - { - "id": "episodeSort", - "label": "Episode sorting", - "summary": "How to sort the episodes for this show.", - "type": "text", - "default": "-1", - "value": "-1", - "hidden": false, - "advanced": false, - "group": "", - "enumValues": "-1:Library default|0:Oldest first|1:Newest first" - }, - { - "id": "autoDeletionItemPolicyUnwatchedLibrary", - "label": "Keep", - "summary": "Set the maximum number of unplayed episodes to keep for the show.", - "type": "int", - "default": "0", - "value": "0", - "hidden": false, - "advanced": false, - "group": "", - "enumValues": "0:All episodes|5:5 latest episodes|3:3 latest episodes|1:Latest episode|-3:Episodes added in the past 3 days|-7:Episodes added in the past 7 days|-30:Episodes added in the past 30 days" - }, - { - "id": "autoDeletionItemPolicyWatchedLibrary", - "label": "Delete episodes after playing", - "summary": "Choose how quickly episodes are removed after the server admin has watched them.", - "type": "int", - "default": "0", - "value": "0", - "hidden": false, - "advanced": false, - "group": "", - "enumValues": "0:Never|1:After a day|7:After a week|30:After a month|100:On next refresh" - }, - { - "id": "flattenSeasons", - "label": "Seasons", - "summary": "Choose whether to display seasons.", - "type": "int", - "default": "-1", - "value": "-1", - "hidden": false, - "advanced": false, - "group": "", - "enumValues": "-1:Library default|0:Show|1:Hide" - }, - { - "id": "showOrdering", - "label": "", - "summary": "", - "type": "text", - "default": "", - "value": "", - "hidden": false, - "advanced": false, - "group": "" - }, - { - "id": "audioLanguage", - "label": "Preferred audio language", - "summary": "", - "type": "text", - "default": "", - "value": "", - "hidden": false, - "advanced": false, - "group": "", - "enumValues": ":Account default|ar-SA:Arabic (Saudi Arabia)|bg-BG:Bulgarian|ca-ES:Catalan|zh-CN:Chinese|zh-HK:Chinese (Hong Kong)|zh-TW:Chinese (Taiwan)|hr-HR:Croatian|cs-CZ:Czech|da-DK:Danish|nl-NL:Dutch|en-US:English|en-AU:English (Australia)|en-CA:English (Canada)|en-GB:English (UK)|et-EE:Estonian|fi-FI:Finnish|fr-FR:French|fr-CA:French (Canada)|de-DE:German|el-GR:Greek|he-IL:Hebrew|hi-IN:Hindi|hu-HU:Hungarian|id-ID:Indonesian|it-IT:Italian|ja-JP:Japanese|ko-KR:Korean|lv-LV:Latvian|lt-LT:Lithuanian|nb-NO:Norwegian Bokmål|fa-IR:Persian|pl-PL:Polish|pt-BR:Portuguese|pt-PT:Portuguese (Portugal)|ro-RO:Romanian|ru-RU:Russian|sk-SK:Slovak|es-ES:Spanish|es-MX:Spanish (Mexico)|sv-SE:Swedish|th-TH:Thai|tr-TR:Turkish|uk-UA:Ukrainian|vi-VN:Vietnamese" - }, - { - "id": "subtitleLanguage", - "label": "Preferred subtitle language", - "summary": "", - "type": "text", - "default": "", - "value": "", - "hidden": false, - "advanced": false, - "group": "", - "enumValues": ":Account default|ar-SA:Arabic (Saudi Arabia)|bg-BG:Bulgarian|ca-ES:Catalan|zh-CN:Chinese|zh-HK:Chinese (Hong Kong)|zh-TW:Chinese (Taiwan)|hr-HR:Croatian|cs-CZ:Czech|da-DK:Danish|nl-NL:Dutch|en-US:English|en-AU:English (Australia)|en-CA:English (Canada)|en-GB:English (UK)|et-EE:Estonian|fi-FI:Finnish|fr-FR:French|fr-CA:French (Canada)|de-DE:German|el-GR:Greek|he-IL:Hebrew|hi-IN:Hindi|hu-HU:Hungarian|id-ID:Indonesian|it-IT:Italian|ja-JP:Japanese|ko-KR:Korean|lv-LV:Latvian|lt-LT:Lithuanian|nb-NO:Norwegian Bokmål|fa-IR:Persian|pl-PL:Polish|pt-BR:Portuguese|pt-PT:Portuguese (Portugal)|ro-RO:Romanian|ru-RU:Russian|sk-SK:Slovak|es-ES:Spanish|es-MX:Spanish (Mexico)|sv-SE:Swedish|th-TH:Thai|tr-TR:Turkish|uk-UA:Ukrainian|vi-VN:Vietnamese" - }, - { - "id": "subtitleMode", - "label": "Auto-select subtitle mode", - "summary": "", - "type": "int", - "default": "-1", - "value": "-1", - "hidden": false, - "advanced": false, - "group": "", - "enumValues": "-1:Account default|0:Manually selected|1:Shown with foreign audio|2:Always enabled" - } - ] - } - } - } - } - } - } - } - } - } - }, - "/library/metadata/{ids}/unmatch": { - "put": { - "tags": [ - "Library" - ], - "security": [ - { - "user_token": [ - "admin" - ] - } - ], - "operationId": "libraryMetadataPutUnmatch", - "summary": "Unmatch a metadata item", - "description": "Unmatch a metadata item to info fetched from the agent", - "parameters": [ - { - "in": "path", - "name": "ids", - "schema": { - "type": "string" - }, - "required": true - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/200" - } - } - } - }, - "/library/metadata/{ids}/users/top": { - "get": { - "tags": [ - "Library" - ], - "security": [ - { - "user_token": [ - "admin" - ] - } - ], - "operationId": "libraryMetadataGetUsersTop", - "summary": "Get metadata top users", - "description": "Get the list of users which have played this item starting with the most", - "parameters": [ - { - "in": "path", - "name": "ids", - "schema": { - "type": "string" - }, - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "MediaContainer": { - "allOf": [ - { - "$ref": "#/components/schemas/MediaContainer" - }, - { - "type": "object", - "properties": { - "Account": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "globalViewCount": { - "type": "integer" - } - } - } - } - } - } - ] - } - } - }, - "examples": { - "users": { - "value": { - "MediaContainer": { - "size": 2, - "Account": [ - { - "id": 1, - "globalViewCount": 3 - }, - { - "id": 12345, - "globalViewCount": 2 - } - ] - } - } - } - } - } - } - } - } - } - }, - "/library/metadata/{ids}/voiceActivity": { - "put": { - "security": [ - { - "user_token": [ - "admin" - ] - } - ], - "tags": [ - "Library" - ], - "operationId": "libraryMetadataPutVoiceActivity", - "summary": "Detect voice activity", - "description": "Start the detection of voice in a metadata item", - "parameters": [ - { - "in": "path", - "name": "ids", - "schema": { - "type": "string" - }, - "required": true - }, - { - "in": "query", - "name": "force", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "required": false, - "description": "Indicate whether detection should be re-run" - }, - { - "in": "query", - "name": "manual", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "required": false, - "description": "Indicate whether detection is manually run" - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/200" - } - } - } - }, - "/library/metadata/{ids}/{element}": { - "post": { - "tags": [ - "Library" - ], - "operationId": "libraryMetadataPostElement", - "summary": "Set an item's artwork, theme, etc", - "description": "Set the artwork, thumb, element for a metadata item\nGenerally only the admin can perform this action. The exception is if the metadata is a playlist created by the user", - "parameters": [ - { - "in": "path", - "name": "ids", - "schema": { - "type": "string" - }, - "required": true - }, - { - "in": "path", - "name": "element", - "schema": { - "type": "string", - "enum": [ - "thumb", - "art", - "clearLogo", - "banner", - "poster", - "theme" - ] - }, - "required": true - }, - { - "in": "query", - "name": "url", - "schema": { - "type": "string" - }, - "description": "The url of the new asset. If not provided, the binary of the asset must be provided in the post body." - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/200" - } - } - }, - "put": { - "tags": [ - "Library" - ], - "operationId": "libraryMetadataPutElement", - "summary": "Set an item's artwork, theme, etc", - "description": "Set the artwork, thumb, element for a metadata item\nGenerally only the admin can perform this action. The exception is if the metadata is a playlist created by the user", - "parameters": [ - { - "in": "path", - "name": "ids", - "schema": { - "type": "string" - }, - "required": true - }, - { - "in": "path", - "name": "element", - "schema": { - "type": "string", - "enum": [ - "thumb", - "art", - "clearLogo", - "banner", - "poster", - "theme" - ] - }, - "required": true - }, - { - "in": "query", - "name": "url", - "schema": { - "type": "string" - }, - "description": "The url of the new asset." - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/200" - } - } - } - }, - "/library/metadata/{ids}/{element}/{timestamp}": { - "get": { - "tags": [ - "Library" - ], - "operationId": "libraryMetadataGetElement", - "summary": "Get an item's artwork, theme, etc", - "description": "Get the artwork, thumb, element for a metadata item", - "parameters": [ - { - "in": "path", - "name": "ids", - "schema": { - "type": "string" - }, - "required": true - }, - { - "in": "path", - "name": "element", - "schema": { - "type": "string", - "enum": [ - "thumb", - "art", - "clearLogo", - "banner", - "poster", - "theme" - ] - }, - "required": true - }, - { - "in": "path", - "name": "timestamp", - "schema": { - "type": "integer" - }, - "description": "A timestamp on the element used for cache management in the client", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "audio/mpeg3": { - "schema": { - "type": "string", - "format": "binary" - } - }, - "image/jpeg": { - "schema": { - "type": "string", - "format": "binary" - } - } - } - } - } - } - }, - "/library/metadata/augmentations/{augmentationId}": { - "get": { - "tags": [ - "Library" - ], - "operationId": "libraryGetMetadataAugmentationsAugmentation", - "summary": "Get augmentation status", - "description": "Get augmentation status and potentially wait for completion", - "parameters": [ - { - "in": "path", - "name": "augmentationId", - "schema": { - "type": "string" - }, - "description": "The id of the augmentation", - "required": true - }, - { - "in": "query", - "name": "wait", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "description": "Wait for augmentation completion before returning" - } - ], - "responses": { - "204": { - "$ref": "#/components/responses/204" - }, - "401": { - "description": "This augmentation is not owned by the requesting user", - "content": { - "text/html": { - "examples": { - "unauthorized": { - "summary": "Unauthorized", - "value": "Unauthorized

401 Unauthorized

" - } - } - } - } - }, - "404": { - "description": "No augmentation found", - "content": { - "text/html": { - "examples": { - "notFound": { - "summary": "Not Found", - "value": "Not Found

404 Not Found

" - } - } - } - } - } - } - } - }, - "/library/optimize": { - "put": { - "tags": [ - "Library" - ], - "security": [ - { - "user_token": [ - "admin" - ] - } - ], - "operationId": "libraryPutOptimize", - "summary": "Optimize the Database", - "description": "Initiate optimize on the database.", - "parameters": [ - { - "in": "query", - "name": "async", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "description": "If set, don't wait for completion but return an activity" - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/200" - } - } - } - }, - "/library/parts/{partId}": { - "put": { - "tags": [ - "Library" - ], - "operationId": "libraryPutPartsPart", - "summary": "Set stream selection", - "description": "Set which streams (audio/subtitle) are selected by this user", - "parameters": [ - { - "in": "path", - "name": "partId", - "schema": { - "type": "integer" - }, - "description": "The id of the part to select streams on", - "required": true - }, - { - "in": "query", - "name": "audioStreamID", - "schema": { - "type": "integer" - }, - "description": "The id of the audio stream to select in this part" - }, - { - "in": "query", - "name": "subtitleStreamID", - "schema": { - "type": "integer" - }, - "description": "The id of the subtitle stream to select in this part. Specify 0 to select no subtitle" - }, - { - "in": "query", - "name": "allParts", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "description": "Perform the same for all parts of this media selecting similar streams in each" - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/200" - }, - "400": { - "description": "One of the audio or subtitle streams does not belong to this part", - "content": { - "text/html": { - "examples": { - "badRequest": { - "summary": "A parameter has a bad value or required parameter is missing", - "value": "Bad Request

400 Bad Request

" - } - } - } - } - } - } - } - }, - "/library/parts/{partId}/indexes/{index}": { - "get": { - "tags": [ - "Library" - ], - "operationId": "libraryGetPartsPartIndexesIndex", - "summary": "Get BIF index for a part", - "description": "Get BIF index for a part by index type", - "parameters": [ - { - "in": "path", - "name": "partId", - "schema": { - "type": "integer" - }, - "required": true, - "description": "The part id who's index is to be fetched" - }, - { - "in": "path", - "name": "index", - "schema": { - "type": "string", - "enum": [ - "sd" - ] - }, - "required": true, - "description": "The type of index to grab." - }, - { - "in": "query", - "name": "interval", - "schema": { - "type": "integer" - }, - "description": "The interval between images to return in ms." - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/octet-stream": { - "schema": { - "type": "string", - "format": "binary" - } - } - } - }, - "404": { - "description": "The part or the index doesn't exist or the interval is too small", - "content": { - "text/html": { - "examples": { - "notFound": { - "summary": "Not Found", - "value": "Not Found

404 Not Found

" - } - } - } - } - } - } - } - }, - "/library/parts/{partId}/indexes/{index}/{offset}": { - "get": { - "tags": [ - "Library" - ], - "operationId": "libraryGetPartsPartIndexesIndexOffset", - "summary": "Get an image from part BIF", - "description": "Extract an image from the BIF for a part at a particular offset", - "parameters": [ - { - "in": "path", - "name": "partId", - "schema": { - "type": "integer" - }, - "required": true, - "description": "The part id who's index is to be fetched" - }, - { - "in": "path", - "name": "index", - "schema": { - "type": "string", - "enum": [ - "sd" - ] - }, - "required": true, - "description": "The type of index to grab." - }, - { - "in": "path", - "name": "offset", - "schema": { - "type": "integer" - }, - "description": "The offset to seek in ms.", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "image/jpeg": { - "schema": { - "type": "string", - "format": "binary" - } - } - } - }, - "404": { - "description": "The part or the index doesn't exist", - "content": { - "text/html": { - "examples": { - "notFound": { - "summary": "Not Found", - "value": "Not Found

404 Not Found

" - } - } - } - } - } - } - } - }, - "/library/parts/{partId}/{changestamp}/{filename}": { - "get": { - "tags": [ - "Library" - ], - "operationId": "libraryGetPartsPartChangestampFilename", - "summary": "Get a media part", - "description": "Get a media part for streaming or download.\n - streaming: This is the default scenario. Bandwidth usage on this endpoint will be guaranteed (on the server's end) to be at least the bandwidth reservation given in the decision. If no decision exists, an ad-hoc decision will be created if sufficient bandwidth exists. Clients should not rely on ad-hoc decisions being made as this may be removed in the future.\n - download: Indicated if the query parameter indicates this is a download. Bandwidth will be prioritized behind playbacks and will get a fair share of what remains.\n", - "parameters": [ - { - "in": "path", - "name": "partId", - "schema": { - "type": "integer" - }, - "required": true, - "description": "The part id who's index is to be fetched" - }, - { - "in": "path", - "name": "changestamp", - "schema": { - "type": "integer" - }, - "required": true, - "description": "The changestamp of the part; used for busting potential caches. Provided in the `key` for the part" - }, - { - "in": "path", - "name": "filename", - "schema": { - "type": "string" - }, - "required": true, - "description": "A generic filename used for a client media stack which relies on the extension in the request. Provided in the `key` for the part" - }, - { - "in": "query", - "name": "download", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "description": "Whether this is a file download" - } - ], - "responses": { - "200": { - "description": "OK", - "headers": { - "Content-Disposition": { - "schema": { - "type": "string" - }, - "description": "Note: This header is only included in download requests" - } - } - }, - "403": { - "description": "Client requested download and server owner has forbidden download of media", - "content": { - "text/html": { - "examples": { - "forbidden": { - "summary": "Forbidden", - "value": "Forbidden

403 Forbidden

" - } - } - } - } - }, - "404": { - "description": "The part doesn't exist", - "content": { - "text/html": { - "examples": { - "notFound": { - "summary": "Not Found", - "value": "Not Found

404 Not Found

" - } - } - } - } - }, - "503": { - "description": "Client requested the part without a decision and no decision could be made or decision is for a transcode", - "content": { - "text/html": { - "examples": { - "notFound": { - "summary": "Service Unavailable", - "value": "Service Unavailable

503 Service Unavailable

" - } - } - } - } - }, - "509": { - "description": "Client requested the part without a decision and no decision could be made because there is insufficient bandwidth for client to direct play this part", - "content": { - "text/html": { - "examples": { - "notFound": { - "summary": "Bandwidth Limit Exceeded", - "value": "Bandwidth Limit Exceeded

509 Bandwidth Limit Exceeded

" - } - } - } - } - } - } - } - }, - "/library/people/{personId}": { - "get": { - "tags": [ - "Library" - ], - "operationId": "libraryGetPeoplePerson", - "summary": "Get person details", - "description": "Get details for a single actor.", - "parameters": [ - { - "in": "path", - "name": "personId", - "schema": { - "oneOf": [ - { - "type": "integer" - }, - { - "type": "string" - } - ] - }, - "required": true, - "description": "Either the PMS tag `id` of the person or `tagKey` of the actor. Note the `tagKey` is the hex portion of the plex guid for the actor" - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "MediaContainer": { - "allOf": [ - { - "$ref": "#/components/schemas/MediaContainer" - }, - { - "type": "object", - "properties": { - "Directory": { - "type": "array", - "items": { - "$ref": "#/components/schemas/tag" - } - } - } - } - ] - } - } - }, - "examples": { - "somePerson": { - "value": { - "MediaContainer": { - "size": 1, - "Directory": [ - { - "id": 53374, - "filter": "actor=53374", - "tag": "Jay Chandrasekhar", - "tagType": 6, - "tagKey": "5d7768316f4521001ea9b4bc", - "thumb": "https://metadata-static.plex.tv/f/people/f5762921b6efdcc7854bfd7553da937f.jpg" - } - ] - } - } - } - } - } - } - }, - "404": { - "$ref": "#/components/responses/404" - } - } - } - }, - "/library/people/{personId}/media": { - "get": { - "tags": [ - "Library" - ], - "operationId": "libraryGetPeoplePersonMedia", - "summary": "Get media for a person", - "description": "Get all the media for a single actor.", - "parameters": [ - { - "in": "path", - "name": "personId", - "schema": { - "oneOf": [ - { - "type": "integer" - }, - { - "type": "string" - } - ] - }, - "required": true, - "description": "Either the PMS tag `id` of the person or `tagKey` of the actor. Note the `tagKey` is the hex portion of the plex guid for the actor" - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/mediaContainerWithMetadata" - }, - "examples": { - "Zoolander Metadata": { - "value": { - "MediaContainer": { - "size": "1", - "allowSync": true, - "art": "/:/resources/movie-fanart.jpg", - "identifier": "com.plexapp.plugins.library", - "librarySectionID": 26, - "librarySectionTitle": "Movies", - "librarySectionUUID": "70cb5089-b165-429b-809a-9e0a31493abf", - "mediaTagPrefix": "/system/bundle/media/flags/", - "mediaTagVersion": "1436742334", - "thumb": "/:/resources/movie.png", - "title1": "Movies", - "title2": "All Movies", - "viewGroup": "movie", - "Metadata": [ - { - "id": "1049", - "ratingKey": "1049", - "key": "/library/metadata/1049", - "studio": "Paramount Pictures", - "type": "movie", - "title": "Zoolander", - "contentRating": "PG-13", - "summary": "FunnyStuff", - "year": 2001, - "tagline": "3% Body Fat. 1% Brain Activity.", - "thumb": "/library/metadata/1049/thumb/1434341184", - "art": "/library/metadata/1049/art/1434341184", - "duration": 5129000, - "originallyAvailableAt": "2001-09-27", - "addedAt": 1408525217, - "updatedAt": 1434341184, - "chapterSource": "media", - "primaryExtraKey": "/library/metadata/1073", - "rating": 6, - "Media": [ - { - "id": 827, - "duration": 5129000, - "bitrate": 6564, - "width": 720, - "height": 576, - "aspectRatio": 1.78, - "audioChannels": 6, - "audioCodec": "ac3", - "videoCodec": "mpeg2video", - "container": "mkv", - "videoFrameRate": "PAL", - "Part": [ - { - "id": "827", - "key": "/library/parts/827/file.mkv", - "duration": 5129000, - "file": "O:\\fatboy\\Media\\Ripped\\Movies\\Zoolander (2001).mkv", - "size": 4208219125, - "container": "mkv" - } - ] - } - ], - "Image": [ - { - "type": "coverPoster", - "alt": "Zoolander", - "url": "/library/metadata/1049/thumb/1434341184" - } - ], - "Genre": [ - { - "tag": "Comedy" - } - ], - "Writer": [ - { - "tag": "Drake Sather" - }, - { - "tag": "Ben Stiller" - } - ], - "Director": [ - { - "tag": "Ben Stiller" - } - ], - "Country": [ - { - "tag": "Australia" - }, - { - "tag": "Germany" - } - ], - "Role": [ - { - "tag": "Ben Stiller" - }, - { - "tag": "Owen Wilson" - }, - { - "tag": "Christine Taylor" - } - ] - } - ] - } - } - } - } - } - } - }, - "404": { - "$ref": "#/components/responses/404" - } - } - } - }, - "/library/randomArtwork": { - "get": { - "tags": [ - "Library" - ], - "operationId": "libraryGetRandomArtwork", - "summary": "Get random artwork", - "description": "Get random artwork across sections. This is commonly used for a screensaver.\n\nThis retrieves 100 random artwork paths in the specified sections and returns them. Restrictions are put in place to not return artwork for items the user is not allowed to access. Artwork will be for Movies, Shows, and Artists only.\n", - "parameters": [ - { - "in": "query", - "name": "sections", - "schema": { - "type": "array", - "items": { - "type": "integer" - } - }, - "explode": false, - "example": [ - 5, - 6 - ], - "description": "The sections for which to fetch artwork." - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/mediaContainerWithArtwork" - }, - "examples": { - "someImages": { - "value": { - "MediaContainer": { - "size": 7, - "Metadata": [ - { - "title": "Lava", - "type": "image", - "key": "/library/metadata/34/art/1715112805" - }, - { - "title": "Firefly", - "type": "image", - "key": "/library/metadata/163/art/1714485503" - }, - { - "title": "The Lord of the Rings: The Return of the King", - "type": "image", - "key": "/library/metadata/65/art/1715112827" - }, - { - "title": "La Luna", - "type": "image", - "key": "/library/metadata/4/art/1715112803" - }, - { - "title": "Babylon 5", - "type": "image", - "key": "/library/metadata/148/art/1715112830" - }, - { - "title": "The Expanse", - "type": "image", - "key": "/library/metadata/201/art/1715112832" - }, - { - "title": "Jack-Jack Attack", - "type": "image", - "key": "/library/metadata/146/art/1715112830" - } - ] - } - } - } - } - } - } - } - } - } - }, - "/library/sections/all": { - "get": { - "tags": [ - "Library" - ], - "operationId": "libraryGetSections", - "summary": "Get library sections (main Media Provider Only)", - "description": "A library section (commonly referred to as just a library) is a collection of media. Libraries are typed, and depending on their type provide either a flat or a hierarchical view of the media. For example, a music library has an artist > albums > tracks structure, whereas a movie library is flat.\nLibraries have features beyond just being a collection of media; for starters, they include information about supported types, filters and sorts. This allows a client to provide a rich interface around the media (e.g. allow sorting movies by release year).", - "responses": { - "200": { - "description": "OK", - "headers": { - "X-Plex-Container-Total-Size": { - "description": "Provided on all MediaContainer objects indicating the total size of objects available", - "schema": { - "type": "integer" - } - }, - "X-Plex-Container-Start": { - "description": "Provided on all MediaContainer objects indicating the offset of where this container page starts", - "schema": { - "type": "integer" - } - } - }, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "MediaContainer": { - "allOf": [ - { - "$ref": "#/components/schemas/mediaContainer" - }, - { - "type": "object", - "properties": { - "allowSync": { - "$ref": "#/components/schemas/allowSync" - }, - "title1": { - "type": "string", - "description": "Typically just \"Plex Library\"" - }, - "Directory": { - "type": "array", - "items": { - "$ref": "#/components/schemas/librarySection" - } - } - } - } - ] - } - } - }, - "examples": { - "basic": { - "summary": "Basic response for library sections", - "value": { - "MediaContainer": { - "size": 1, - "allowSync": true, - "title1": "Plex Library", - "Directory": [ - { - "allowSync": true, - "art": "/:/resources/movie-fanart.jpg", - "composite": "/library/sections/1/composite/1706626696", - "filters": true, - "refreshing": false, - "thumb": "/:/resources/movie.png", - "key": "1", - "type": "movie", - "title": "Movies", - "agent": "tv.plex.agents.movie", - "scanner": "Plex Movie", - "language": "en-US", - "updatedAt": 1689270983, - "createdAt": 1689270983, - "scannedAt": 1706626696, - "content": true, - "directory": true, - "contentChangedAt": 1234, - "hidden": false, - "Location": [ - { - "id": 1, - "path": "O:\\fatboy\\Media\\Ripped\\Movies" - } - ] - }, - { - "allowSync": true, - "art": "/:/resources/series-fanart.jpg", - "composite": "/library/sections/1/composite/1714485942", - "filters": true, - "refreshing": false, - "thumb": "/:/resources/series.png", - "key": "2", - "type": "show", - "title": "TV Shows", - "agent": "tv.plex.agents.series", - "scanner": "Plex TV Series", - "language": "en-US", - "updatedAt": 1682628353, - "createdAt": 1682628353, - "scannedAt": 1714485942, - "content": true, - "directory": true, - "contentChangedAt": 1235, - "hidden": false, - "Location": [ - { - "id": 2, - "path": "O:\\fatboy\\Media\\Ripped\\Shows" - } - ] - }, - { - "allowSync": true, - "art": "/:/resources/artist-fanart.jpg", - "composite": "/library/sections/1/composite/1690487664", - "filters": true, - "refreshing": false, - "thumb": "/:/resources/artist.png", - "key": "3", - "type": "artist", - "title": "Music", - "agent": "tv.plex.agents.music", - "scanner": "Plex Music", - "language": "en-US", - "updatedAt": 1691606667, - "createdAt": 1691606667, - "scannedAt": 1690487664, - "content": true, - "directory": true, - "contentChangedAt": 1233, - "hidden": false, - "Location": [ - { - "id": 3, - "path": "O:\\fatboy\\Media\\Ripped\\Music" - }, - { - "id": 4, - "path": "O:\\fatboy\\Media\\My Music" - } - ] - } - ] - } - } - } - } - } - } - } - } - }, - "post": { - "tags": [ - "Library" - ], - "security": [ - { - "user_token": [ - "admin" - ] - } - ], - "summary": "Add a library section", - "description": "Add a new library section to the server", - "operationId": "libraryPostSection", - "parameters": [ - { - "in": "query", - "name": "name", - "schema": { - "type": "string" - }, - "required": true, - "description": "The name of the new section" - }, - { - "in": "query", - "name": "type", - "schema": { - "type": "integer" - }, - "required": true, - "description": "The type of library section" - }, - { - "in": "query", - "name": "scanner", - "schema": { - "type": "string" - }, - "description": "The scanner this section should use" - }, - { - "in": "query", - "name": "agent", - "schema": { - "type": "string" - }, - "required": true, - "description": "The agent this section should use for metadata" - }, - { - "in": "query", - "name": "metadataAgentProviderGroupId", - "schema": { - "type": "string" - }, - "description": "The agent group id for this section" - }, - { - "in": "query", - "name": "language", - "schema": { - "type": "string" - }, - "required": true, - "description": "The language of this section" - }, - { - "in": "query", - "name": "locations", - "schema": { - "type": "array", - "items": { - "type": "string" - } - }, - "example": [ - "O:\\fatboy\\Media\\Ripped\\Music", - "O:\\fatboy\\Media\\My Music" - ], - "description": "The locations on disk to add to this section" - }, - { - "in": "query", - "name": "prefs", - "schema": { - "type": "object" - }, - "style": "deepObject", - "example": { - "hidden": 0, - "collectionMode": 2 - }, - "description": "The preferences for this section" - }, - { - "in": "query", - "name": "relative", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "description": "If set, paths are relative to `Media Upload` path" - }, - { - "in": "query", - "name": "importFromiTunes", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "description": "If set, import media from iTunes." - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/slash-get-responses-200" - }, - "400": { - "description": "Section cannot be created due to bad parameters in request", - "content": { - "text/html": { - "examples": { - "badRequest": { - "summary": "A parameter has a bad value or required parameter is missing", - "value": "Bad Request

400 Bad Request

" - } - } - } - } - } - } - } - }, - "/library/sections/all/refresh": { - "delete": { - "tags": [ - "Library" - ], - "security": [ - { - "user_token": [ - "admin" - ] - } - ], - "operationId": "libraryDeleteSectionsAllRefresh", - "summary": "Stop refresh", - "description": "Stop all refreshes across all sections", - "responses": { - "200": { - "$ref": "#/components/responses/requestHandler_slash-get-responses-200" - } - } - } - }, - "/library/sections/prefs": { - "get": { - "tags": [ - "Library" - ], - "security": [ - { - "user_token": [ - "admin" - ] - } - ], - "operationId": "libraryGetSectionsPrefs", - "summary": "Get section prefs", - "description": "Get a section's preferences for a metadata type", - "parameters": [ - { - "in": "query", - "name": "type", - "schema": { - "type": "integer" - }, - "required": true, - "description": "The metadata type" - }, - { - "in": "query", - "name": "agent", - "schema": { - "type": "string" - }, - "description": "The metadata agent in use" - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/requestHandler_slash-get-responses-200" - }, - "400": { - "description": "type not provided or not an integer", - "content": { - "text/html": { - "examples": { - "badRequest": { - "summary": "A parameter has a bad value or required parameter is missing", - "value": "Bad Request

400 Bad Request

" - } - } - } - } - } - } - } - }, - "/library/sections/refresh": { - "post": { - "tags": [ - "Library" - ], - "security": [ - { - "user_token": [ - "admin" - ] - } - ], - "operationId": "libraryPostSectionsRefresh", - "summary": "Refresh all sections", - "description": "Tell PMS to refresh all section metadata", - "parameters": [ - { - "in": "query", - "name": "force", - "schema": { - "type": "boolean" - }, - "description": "Force refresh of metadata" - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/200" - }, - "503": { - "description": "Server cannot refresh a music library when not signed in", - "content": { - "text/html": { - "examples": { - "notFound": { - "summary": "Service Unavailable", - "value": "Service Unavailable

503 Service Unavailable

" - } - } - } - } - } - } - } - }, - "/library/sections/{sectionId}": { - "get": { - "tags": [ - "Library" - ], - "operationId": "librarySectionGetSection", - "summary": "Get a library section by id", - "description": "Returns details for the library. This can be thought of as an interstitial endpoint because it contains information about the library, rather than content itself. It often contains a list of `Directory` metadata objects: These used to be used by clients to build a menuing system.", - "parameters": [ - { - "in": "path", - "name": "sectionId", - "required": true, - "schema": { - "type": "string" - }, - "description": "The section identifier" - }, - { - "in": "query", - "name": "includeDetails", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "description": "Whether or not to include details for a section (types, filters, and sorts). Only exists for backwards compatibility, media providers other than the server libraries have it on always." - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "properties": { - "MediaContainer": { - "type": "object", - "properties": { - "allowSync": { - "type": "boolean" - }, - "art": { - "type": "string" - }, - "content": { - "type": "string", - "description": "The flavors of directory found here:\n - Primary: (e.g. all, On Deck) These are still used in some clients to provide \"shortcuts\" to subsets of media. However, with the exception of On Deck, all of them can be created by media queries, and the desire is to allow these to be customized by users.\n - Secondary: These are marked with `\"secondary\": true` and were used by old clients to provide nested menus allowing for primative (but structured) navigation.\n - Special: There is a By Folder entry which allows browsing the media by the underlying filesystem structure, and there's a completely obsolete entry marked `\"search\": true` which used to be used to allow clients to build search dialogs on the fly." - }, - "identifier": { - "type": "string" - }, - "librarySectionID": { - "type": "integer" - }, - "mediaTagPrefix": { - "type": "string" - }, - "mediaTagVersion": { - "type": "integer" - }, - "size": { - "type": "integer" - }, - "sortAsc": { - "type": "boolean" - }, - "thumb": { - "type": "string" - }, - "title1": { - "type": "string" - }, - "viewGroup": { - "type": "string" - }, - "viewMode": { - "type": "integer" - }, - "Directory": { - "type": "array", - "items": { - "$ref": "#/components/schemas/metadata" - } - } - } - } - } - }, - "examples": { - "movie": { - "value": { - "MediaContainer": { - "allowSync": false, - "art": "/:/resources/movie-fanart.jpg", - "content": "secondary", - "identifier": "com.plexapp.plugins.library", - "librarySectionID": 1, - "mediaTagPrefix": "/system/bundle/media/flags/", - "mediaTagVersion": 1484125920, - "size": 20, - "sortAsc": true, - "thumb": "/:/resources/movie.png", - "title1": "Movies", - "viewGroup": "secondary", - "viewMode": 65592, - "Directory": [ - { - "key": "all", - "title": "All Movies" - }, - { - "key": "unwatched", - "title": "Unwatched" - }, - { - "key": "newest", - "title": "Recently Released" - }, - { - "key": "recentlyAdded", - "title": "Recently Added" - }, - { - "key": "recentlyViewed", - "title": "Recently Viewed" - }, - { - "key": "onDeck", - "title": "On Deck" - }, - { - "key": "collection", - "secondary": true, - "title": "By Collection" - }, - { - "key": "genre", - "secondary": true, - "title": "By Genre" - }, - { - "key": "year", - "secondary": true, - "title": "By Year" - }, - { - "key": "decade", - "secondary": true, - "title": "By Decade" - }, - { - "key": "director", - "secondary": true, - "title": "By Director" - }, - { - "key": "actor", - "secondary": true, - "title": "By Starring Actor" - }, - { - "key": "country", - "secondary": true, - "title": "By Country" - }, - { - "key": "contentRating", - "secondary": true, - "title": "By Content Rating" - }, - { - "key": "rating", - "secondary": true, - "title": "By Rating" - }, - { - "key": "resolution", - "secondary": true, - "title": "By Resolution" - }, - { - "key": "firstCharacter", - "secondary": true, - "title": "By First Letter" - }, - { - "key": "folder", - "title": "By Folder" - }, - { - "key": "search?type=1", - "prompt": "Search Movies", - "search": true, - "title": "Search..." - }, - { - "key": "/library/sections/1/all?type=1", - "title": "Movies", - "type": "1", - "Filter": [ - { - "filter": "genre", - "filterType": "string", - "key": "/library/sections/1/genre", - "title": "Genre", - "type": "filter" - }, - { - "filter": "year", - "filterType": "integer", - "key": "/library/sections/1/year", - "title": "Year", - "type": "filter" - }, - { - "filter": "decade", - "filterType": "integer", - "key": "/library/sections/1/decade", - "title": "Decade", - "type": "filter" - }, - { - "filter": "contentRating", - "filterType": "string", - "key": "/library/sections/1/contentRating", - "title": "Content Rating", - "type": "filter" - }, - { - "filter": "collection", - "filterType": "string", - "key": "/library/sections/1/collection", - "title": "Collection", - "type": "filter" - }, - { - "filter": "director", - "filterType": "string", - "key": "/library/sections/1/director", - "title": "Director", - "type": "filter" - }, - { - "filter": "actor", - "filterType": "string", - "key": "/library/sections/1/actor", - "title": "Actor", - "type": "filter" - }, - { - "filter": "country", - "filterType": "string", - "key": "/library/sections/1/country", - "title": "Country", - "type": "filter" - }, - { - "filter": "studio", - "filterType": "string", - "key": "/library/sections/1/studio", - "title": "Studio", - "type": "filter" - }, - { - "filter": "resolution", - "filterType": "string", - "key": "/library/sections/1/resolution", - "title": "Resolution", - "type": "filter" - }, - { - "filter": "unwatched", - "filterType": "boolean", - "key": "/library/sections/1/unwatched", - "title": "Unwatched", - "type": "filter" - }, - { - "filter": "label", - "filterType": "string", - "key": "/library/sections/1/label", - "title": "Labels", - "type": "filter" - } - ], - "Sort": [ - { - "defaultDirection": "desc", - "descKey": "addedAt:desc", - "key": "addedAt", - "title": "Date Added" - }, - { - "defaultDirection": "desc", - "descKey": "originallyAvailableAt:desc", - "key": "originallyAvailableAt", - "title": "Release Date" - }, - { - "defaultDirection": "desc", - "descKey": "lastViewedAt:desc", - "key": "lastViewedAt", - "title": "Date Viewed" - }, - { - "default": "asc", - "defaultDirection": "asc", - "descKey": "titleSort:desc", - "key": "titleSort", - "title": "Name" - }, - { - "defaultDirection": "desc", - "descKey": "rating:desc", - "key": "rating", - "title": "Rating" - }, - { - "defaultDirection": "asc", - "descKey": "mediaHeight:desc", - "key": "mediaHeight", - "title": "Resolution" - }, - { - "defaultDirection": "desc", - "descKey": "duration:desc", - "key": "duration", - "title": "Duration" - } - ] - } - ] - } - } - } - } - } - } - } - } - }, - "delete": { - "tags": [ - "Library" - ], - "security": [ - { - "user_token": [ - "admin" - ] - } - ], - "summary": "Delete a library section", - "description": "Delete a library section by id", - "operationId": "libraryDeleteSection", - "parameters": [ - { - "in": "path", - "name": "sectionId", - "required": true, - "schema": { - "type": "string" - }, - "description": "The section identifier" - }, - { - "in": "query", - "name": "async", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "description": "If set, response will return an activity with the actual deletion process. Otherwise request will return when deletion is complete" - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/200" - } - } - }, - "put": { - "tags": [ - "Library" - ], - "security": [ - { - "user_token": [ - "admin" - ] - } - ], - "summary": "Edit a library section", - "description": "Edit a library section by id setting parameters", - "operationId": "librarySectionPutSection", - "parameters": [ - { - "in": "path", - "name": "sectionId", - "required": true, - "schema": { - "type": "string" - }, - "description": "The section identifier" - }, - { - "in": "query", - "name": "name", - "schema": { - "type": "string" - }, - "description": "The name of the new section" - }, - { - "in": "query", - "name": "scanner", - "schema": { - "type": "string" - }, - "description": "The scanner this section should use" - }, - { - "in": "query", - "name": "agent", - "schema": { - "type": "string" - }, - "required": true, - "description": "The agent this section should use for metadata" - }, - { - "in": "query", - "name": "metadataAgentProviderGroupId", - "schema": { - "type": "string" - }, - "description": "The agent group id for this section" - }, - { - "in": "query", - "name": "language", - "schema": { - "type": "string" - }, - "description": "The language of this section" - }, - { - "in": "query", - "name": "locations", - "schema": { - "type": "array", - "items": { - "type": "string" - } - }, - "example": [ - "O:\\fatboy\\Media\\Ripped\\Music", - "O:\\fatboy\\Media\\My Music" - ], - "description": "The locations on disk to add to this section" - }, - { - "in": "query", - "name": "prefs", - "schema": { - "type": "object" - }, - "style": "deepObject", - "example": { - "hidden": 0, - "collectionMode": 2 - }, - "description": "The preferences for this section" - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/200" - }, - "400": { - "description": "Section cannot be created due to bad parameters in request", - "content": { - "text/html": { - "examples": { - "badRequest": { - "summary": "A parameter has a bad value or required parameter is missing", - "value": "Bad Request

400 Bad Request

" - } - } - } - } - } - } - } - }, - "/library/sections/{sectionId}/all": { - "get": { - "tags": [ - "Content" - ], - "operationId": "librarySectionGetAll", - "summary": "Get items in the section", - "description": "Get the items in a section, potentially filtering them", - "parameters": [ - { - "$ref": "#/components/parameters/mediaQuery" - }, - { - "in": "path", - "name": "sectionId", - "required": true, - "schema": { - "type": "string" - }, - "description": "The id of the section" - } - ], - "responses": { - "200": { - "description": "OK", - "headers": { - "X-Plex-Container-Total-Size": { - "description": "Provided on all MediaContainer objects indicating the total size of objects available", - "schema": { - "type": "integer" - } - }, - "X-Plex-Container-Start": { - "description": "Provided on all MediaContainer objects indicating the offset of where this container page starts", - "schema": { - "type": "integer" - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/mediaContainerWithMetadata" - }, - "examples": { - "aMeta": { - "value": { - "MediaContainer": { - "allowSync": true, - "art": "/:/resources/movie-fanart.jpg", - "identifier": "com.plexapp.plugins.library", - "librarySectionID": 26, - "librarySectionTitle": "Movies", - "librarySectionUUID": "70cb5089-b165-429b-809a-9e0a31493abf", - "mediaTagPrefix": "/system/bundle/media/flags/", - "mediaTagVersion": 1436742334, - "size": 1, - "thumb": "/:/resources/movie.png", - "title1": "Movies", - "title2": "All Movies", - "viewGroup": "movie", - "viewMode": "65592", - "Metadata": [ - { - "addedAt": 1408525217, - "art": "/library/metadata/1049/art/1434341184", - "chapterSource": "media", - "contentRating": "PG-13", - "duration": 5129000, - "key": "/library/metadata/1049", - "originallyAvailableAt": "2001-09-27", - "primaryExtraKey": "/library/metadata/1073", - "rating": 6, - "ratingKey": "1049", - "studio": "Paramount Pictures", - "summary": "FunnyStuff", - "tagline": "3% Body Fat. 1% Brain Activity.", - "thumb": "/library/metadata/1049/thumb/1434341184", - "title": "Zoolander", - "type": "movie", - "updatedAt": 1434341184, - "year": 2001, - "Media": [ - { - "aspectRatio": 1.78, - "audioChannels": 6, - "audioCodec": "ac3", - "bitrate": 6564, - "container": "mkv", - "duration": 5129000, - "height": 576, - "id": 827, - "videoCodec": "mpeg2video", - "videoFrameRate": "PAL", - "videoResolution": "576", - "width": 720, - "Part": [ - { - "container": "mkv", - "duration": 5129000, - "file": "O:\\fatboy\\Media\\Ripped\\Movies\\Zoolander (2001).mkv", - "id": 827, - "key": "/library/parts/827/file.mkv", - "size": 4208219125 - } - ] - } - ], - "Genre": [ - { - "tag": "Comedy" - } - ], - "Writer": [ - { - "tag": "Drake Sather" - }, - { - "tag": "Ben Stiller" - } - ], - "Director": [ - { - "tag": "Ben Stiller" - } - ], - "Country": [ - { - "tag": "Australia" - }, - { - "tag": "Germany" - } - ], - "Role": [ - { - "tag": "Ben Stiller" - }, - { - "tag": "Owen Wilson" - }, - { - "tag": "Christine Taylor" - } - ] - } - ] - } - } - } - } - } - } - } - } - }, - "put": { - "tags": [ - "Library" - ], - "operationId": "librarySectionPutAll", - "summary": "Set the fields of the filtered items", - "description": "This endpoint takes an large possible set of values. Here are some examples.\n- **Parameters, extra documentation**\n - artist.title.value\n - When used with track, both artist.title.value and album.title.value need to be specified\n - title.value usage\n - Summary\n - Tracks always rename and never merge\n - Albums and Artists\n - if single item and item without title does not exist, it is renamed.\n - if single item and item with title does exist they are merged.\n - if multiple they are always merged.\n - Tracks\n - Works as expected will update the track's title\n - Single track: `/library/sections/{id}/all?type=10&id=42&title.value=NewName`\n - Multiple tracks: `/library/sections/{id}/all?type=10&id=42,43,44&title.value=NewName`\n - All tracks: `/library/sections/{id}/all?type=10&title.value=NewName`\n - Albums\n - Functionality changes depending on the existence of an album with the same title\n - Album exists\n - Single album: `/library/sections/{id}/all?type=9&id=42&title.value=Album 2`\n - Album with id 42 is merged into album titled \"Album 2\"\n - Multiple/All albums: `/library/sections/{id}/all?type=9&title.value=Moo Album`\n - All albums are merged into the existing album titled \"Moo Album\"\n - Album does not exist\n - Single album: `/library/sections/{id}/all?type=9&id=42&title.value=NewAlbumTitle`\n - Album with id 42 has title modified to \"NewAlbumTitle\"\n - Multiple/All albums: `/library/sections/{id}/all?type=9&title.value=NewAlbumTitle`\n - All albums are merged into a new album with title=\"NewAlbumTitle\"\n - Artists\n - Functionaly changes depending on the existence of an artist with the same title.\n - Artist exists\n - Single artist: `/library/sections/{id}/all?type=8&id=42&title.value=Artist 2`\n - Artist with id 42 is merged into existing artist titled \"Artist 2\"\n - Multiple/All artists: `/library/sections/{id}/all?type=8&title.value=Artist 3`\n - All artists are merged into the existing artist titled \"Artist 3\"\n - Artist does not exist\n - Single artist: `/library/sections/{id}/all?type=8&id=42&title.value=NewArtistTitle`\n - Artist with id 42 has title modified to \"NewArtistTitle\"\n - Multiple/All artists: `/library/sections/{id}/all?type=8&title.value=NewArtistTitle`\n - All artists are merged into a new artist with title=\"NewArtistTitle\"\n\n- **Notes**\n - Technically square brackets are not allowed in an URI except the Internet Protocol Literal Address\n - RFC3513: A host identified by an Internet Protocol literal address, version 6 [RFC3513] or later, is distinguished by enclosing the IP literal within square brackets (\"[\" and \"]\"). This is the only place where square bracket characters are allowed in the URI syntax.\n - Escaped square brackets are allowed, but don't render well", - "parameters": [ - { - "in": "path", - "name": "sectionId", - "required": true, - "schema": { - "type": "string" - }, - "description": "The id of the section" - }, - { - "in": "query", - "name": "type", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "filters", - "schema": { - "type": "string" - }, - "description": "The filters to apply to determine which items should be modified" - }, - { - "in": "query", - "name": "field.value", - "schema": { - "type": "string" - }, - "description": "Set the specified field to a new value" - }, - { - "in": "query", - "name": "field.locked", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "description": "Set the specified field to locked (or unlocked if set to 0)" - }, - { - "in": "query", - "name": "title.value", - "schema": { - "type": "string" - }, - "description": "This field is treated specially by albums or artists and may be used for implicit reparenting." - }, - { - "in": "query", - "name": "artist.title.value", - "schema": { - "type": "string" - }, - "description": "Reparents set of Tracks or Albums - used with album.title.* in the case of tracks" - }, - { - "in": "query", - "name": "artist.title.id", - "schema": { - "type": "string" - }, - "description": "Reparents set of Tracks or Albums - used with album.title.* in the case of tracks" - }, - { - "in": "query", - "name": "album.title.value", - "schema": { - "type": "string" - }, - "description": "Reparents set of Tracks - Must be used in conjunction with artist.title.value or id" - }, - { - "in": "query", - "name": "album.title.id", - "schema": { - "type": "string" - }, - "description": "Reparents set of Tracks - Must be used in conjunction with artist.title.value or id" - }, - { - "in": "query", - "name": "tagtype[idx].tag.tag", - "schema": { - "type": "string" - }, - "description": "Creates tag and associates it with each item in the set. - [idx] links this and the next parameters together" - }, - { - "in": "query", - "name": "tagtype[idx].tagging.object", - "schema": { - "type": "string" - }, - "description": "Here `object` may be text/thumb/art/theme - Optionally used in conjunction with tag.tag, to update association info across the set." - }, - { - "in": "query", - "name": "tagtype[].tag.tag-", - "schema": { - "type": "string" - }, - "description": "Remove comma separated tags from the set of items" - }, - { - "in": "query", - "name": "tagtype[].tag", - "schema": { - "type": "string" - }, - "description": "Remove associations of this type (e.g. genre) from the set of items" - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/200" - }, - "400": { - "description": "The set of parameters are inconsistent or invalid values", - "content": { - "text/html": { - "examples": { - "badRequest": { - "summary": "A parameter has a bad value or required parameter is missing", - "value": "Bad Request

400 Bad Request

" - } - } - } - } - }, - "404": { - "description": "A required item could not be found", - "content": { - "text/html": { - "examples": { - "notFound": { - "summary": "Not Found", - "value": "Not Found

404 Not Found

" - } - } - } - } - }, - "409": { - "description": "Rename of a collection to a name that's already taken", - "content": { - "text/html": { - "examples": { - "conflict": { - "summary": "Conflict", - "value": "Conflict

409 Conflict

" - } - } - } - } - } - } - } - }, - "/library/sections/{sectionId}/allLeaves": { - "get": { - "tags": [ - "Content" - ], - "operationId": "librarySectionGetAllLeaves", - "summary": "Set section leaves", - "description": "Get all leaves in a section (such as episodes in a show section)", - "parameters": [ - { - "in": "path", - "name": "sectionId", - "schema": { - "type": "integer" - }, - "description": "Section identifier", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/mediaContainerWithMetadata" - }, - "example": { - "MediaContainer": { - "size": 41, - "allowSync": false, - "art": "/:/resources/show-fanart.jpg", - "content": "secondary", - "identifier": "com.plexapp.plugins.library", - "mediaTagPrefix": "/system/bundle/media/flags/", - "mediaTagVersion": 1680272530, - "nocache": true, - "thumb": "/:/resources/show.png", - "title1": "TV Shows", - "viewGroup": "show", - "Metadata": [ - { - "ratingKey": "150", - "key": "/library/metadata/150", - "parentRatingKey": "149", - "grandparentRatingKey": "148", - "guid": "plex://episode/5d9c1359e264b7001fcb529c", - "parentGuid": "plex://season/602e691b66dfdb002c0a5034", - "grandparentGuid": "plex://show/5d9c087202391c001f58a287", - "grandparentSlug": "babylon-5", - "type": "episode", - "title": "The Illusion of Truth", - "titleSort": "Illusion of Truth", - "grandparentKey": "/library/metadata/148", - "parentKey": "/library/metadata/149", - "grandparentTitle": "Babylon 5", - "parentTitle": "Season 4", - "contentRating": "TV-PG", - "summary": "A team of ISN reporters arrives at the station wanting to do a story about Babylon 5. Sheridan refuses at first, but finally agrees on the theory that at least a small part of their side of the conflict will be shown.", - "index": 8, - "parentIndex": 4, - "audienceRating": 7.7, - "viewCount": 1, - "lastViewedAt": 1612468663, - "year": 1997, - "thumb": "/library/metadata/150/thumb/1681283788", - "art": "/library/metadata/148/art/1715112830", - "parentThumb": "/library/metadata/149/thumb/1681152133", - "grandparentThumb": "/library/metadata/148/thumb/1715112830", - "grandparentArt": "/library/metadata/148/art/1715112830", - "grandparentTheme": "/library/metadata/148/theme/1715112830", - "duration": 2625089, - "originallyAvailableAt": "1997-02-17", - "addedAt": 1348327790, - "updatedAt": 1681283788, - "audienceRatingImage": "themoviedb://image.rating", - "chapterSource": "media", - "Media": [ - { - "id": 376, - "duration": 2625089, - "bitrate": 5741, - "width": 720, - "height": 480, - "aspectRatio": 1.78, - "audioChannels": 6, - "audioCodec": "ac3", - "videoCodec": "mpeg2video", - "videoResolution": "480", - "container": "mkv", - "videoFrameRate": "NTSC", - "videoProfile": "main", - "Part": [ - { - "id": 872, - "key": "/library/parts/872/1348327790/file.mkv", - "duration": 2625089, - "file": "/Volumes/Media/TV Shows/Babylon 5/Season 4/Babylon 5 S04E08 The Illusion of Truth.mkv", - "size": 1883816967, - "container": "mkv", - "videoProfile": "main" - } - ] - } - ], - "Director": [ - { - "tag": "Stephen Furst" - } - ], - "Writer": [ - { - "tag": "J. Michael Straczynski" - } - ], - "Role": [ - { - "tag": "Hank Delgado" - }, - { - "tag": "Diana Morgan" - }, - { - "tag": "Jeff Griggs" - } - ] - } - ] - } - } - } - } - } - } - } - }, - "/library/sections/{sectionId}/arts": { - "get": { - "tags": [ - "Content" - ], - "operationId": "librarySectionGetArts", - "summary": "Set section artwork", - "description": "Get artwork for a library section", - "parameters": [ - { - "in": "path", - "name": "sectionId", - "schema": { - "type": "integer" - }, - "description": "Section identifier", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/mediaContainerWithArtwork" - }, - "examples": { - "someImages": { - "value": { - "MediaContainer": { - "size": 7, - "Metadata": [ - { - "title": "Lava", - "type": "image", - "key": "/library/metadata/34/art/1715112805" - }, - { - "title": "The Lord of the Rings: The Return of the King", - "type": "image", - "key": "/library/metadata/65/art/1715112827" - }, - { - "title": "La Luna", - "type": "image", - "key": "/library/metadata/4/art/1715112803" - }, - { - "title": "Jack-Jack Attack", - "type": "image", - "key": "/library/metadata/146/art/1715112830" - } - ] - } - } - } - } - } - } - } - } - } - }, - "/library/sections/{sectionId}/albums": { - "get": { - "tags": [ - "Content" - ], - "operationId": "librarySectionGetAlbums", - "summary": "Set section albums", - "description": "Get all albums in a music section", - "parameters": [ - { - "in": "path", - "name": "sectionId", - "schema": { - "type": "integer" - }, - "description": "Section identifier", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/mediaContainerWithMetadata" - }, - "example": { - "MediaContainer": { - "size": 12, - "allowSync": false, - "art": "/:/resources/artist-fanart.jpg", - "content": "secondary", - "identifier": "com.plexapp.plugins.library", - "mediaTagPrefix": "/system/bundle/media/flags/", - "mediaTagVersion": 1680272530, - "mixedParents": true, - "nocache": true, - "thumb": "/:/resources/artist.png", - "title1": "Music", - "title2": "By Album", - "viewGroup": "album", - "Metadata": [ - { - "allowSync": true, - "librarySectionID": 3, - "librarySectionTitle": "Music", - "librarySectionUUID": "d7fd8c81-a345-4e68-8113-92f23cb47e70", - "ratingKey": "265", - "key": "/library/metadata/265/children", - "parentRatingKey": "251", - "guid": "plex://album/5d07c894403c640290c0e196", - "parentGuid": "plex://artist/5d07bbfc403c6402904a60e7", - "studio": "RCA", - "type": "album", - "title": "Mandatory Fun", - "parentKey": "/library/metadata/251", - "parentTitle": "“Weird Al” Yankovic", - "summary": "Already accepted as a bona fide talent in the world of parody -- his musicianship, comedic timing, his pop-culture reference awareness, and his great wordplay are all well-documented -- the only thing that matters when it comes to \"Weird Al\" Yankovic albums is how inspired the king of novelty songs sounds on any given LP. On his 14th studio album, Mandatory Fun, the inspiration meter goes well into the red, something heard instantly as Iggy Azalea's electro-rap \"Fancy\" does a complete 180 thematically on the opening \"Handy,\" the song now heading toward the local home improvement store where the craftsmen vogue in their orange vests and blow sweet come-ons like \"I'll bring you up to code\" and \"My socket wrenches are second to none.\" Pharrell's \"Happy\" becomes \"Tacky\" and Al's amazing ability to follow an everyday poke (\"Wear my Ed Hardy shirt with fluorescent orange pants\") with something brainy and reserved (\"Got my new résumé, it's printed in Comic Sans\") surprises once more, but for end-to-end \"wows,\" it's his brilliant redo of Robin Thicke's \"Blurred Lines,\" now the smug and twerking \"Word Crimes,\" which gives copy editors, English professors, and grammar nerds a reason to hit the dancefloor (\"And listen up when I tell you this/I hope you never use quotation marks for emphasis!\"). Hardcore and hilarious musical moments start to happen when Imagine Dragons' \"Radioactive\" becomes \"Inactive,\" a singalong anthem for the sluggish and the slovenly (\"Near comatose, no exercise/Don't tag my toe, I'm still alive\") with a dubstep-rock bassline that sounds like Galactus burping. Better still is the every-Al-album pop-polka medley, this time called \"Now That's What I Call Polka!\" which polkas-up Daft Punk (\"Get Lucky\"), PSY (\"Gangnam Style\"), and Miley Cyrus (\"Wrecking Ball\"), and with more Spike Jones-styled sound effects than usual. As for the originals this time out, the \"you suck!\"-minded \"Sports Song\" will be unavoidable under Friday night lights once a teen gets hold of it, while the ranting and wonderfully weird \"First World Problems\" sounds more like the Pixies than anything the Pixies did in 2014. Wonders never cease on Mandatory Fun, and neither do the laughs. ~ David Jeffries", - "index": 1, - "rating": 8, - "year": 2014, - "thumb": "/library/metadata/265/thumb/1715112705", - "art": "/library/metadata/251/art/1716801576", - "parentThumb": "/library/metadata/251/thumb/1716801576", - "originallyAvailableAt": "2014-07-15", - "leafCount": 12, - "addedAt": 1681152176, - "updatedAt": 1715112705, - "deletedAt": 1682628386, - "loudnessAnalysisVersion": "2", - "Genre": [ - { - "tag": "Comedy/Spoken" - } - ] - } - ] - } - } - } - } - } - } - } - }, - "/library/sections/{sectionId}/analyze": { - "put": { - "tags": [ - "Library" - ], - "security": [ - { - "user_token": [ - "admin" - ] - } - ], - "operationId": "librarySectionPutAnalyze", - "summary": "Analyze a section", - "description": "Start analysis of all items in a section. If BIF generation is enabled, this will also be started on this section", - "parameters": [ - { - "in": "path", - "name": "sectionId", - "schema": { - "type": "integer" - }, - "description": "Section identifier", - "required": true - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/200" - } - } - } - }, - "/library/sections/{sectionId}/autocomplete": { - "get": { - "tags": [ - "Library" - ], - "operationId": "librarySectionGetAutocomplete", - "summary": "Get autocompletions for search", - "description": "The field to autocomplete on is specified by the {field}.query parameter. For example `genre.query` or `title.query`.\nReturns a set of items from the filtered items whose {field} starts with {field}.query. In the results, a {field}.queryRange will be present to express the range of the match", - "parameters": [ - { - "in": "path", - "name": "sectionId", - "schema": { - "type": "integer" - }, - "description": "Section identifier", - "required": true - }, - { - "in": "query", - "name": "type", - "schema": { - "type": "integer" - }, - "description": "Item type" - }, - { - "in": "query", - "name": "field.query", - "schema": { - "type": "string" - }, - "description": "The \"field\" stands in for any field, the value is a partial string for matching" - }, - { - "$ref": "#/components/parameters/mediaQuery" - } - ], - "responses": { - "200": { - "description": "OK", - "headers": { - "X-Plex-Container-Total-Size": { - "description": "Provided on all MediaContainer objects indicating the total size of objects available", - "schema": { - "type": "integer" - } - }, - "X-Plex-Container-Start": { - "description": "Provided on all MediaContainer objects indicating the offset of where this container page starts", - "schema": { - "type": "integer" - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/mediaContainerWithMetadata" - }, - "examples": { - "title": { - "summary": "Request with `title.query=a`", - "value": { - "MediaContainer": { - "allowSync": false, - "art": "/:/resources/movie-fanart.jpg", - "content": "secondary", - "identifier": "com.plexapp.plugins.library", - "mediaTagPrefix": "/system/bundle/media/flags/", - "mediaTagVersion": 1436742334, - "size": 2, - "thumb": "/:/resources/movie.png", - "title1": "Movies", - "viewGroup": "secondary", - "viewMode": 65592, - "Metadata": [ - { - "addedAt": 1408492156, - "art": "/library/metadata/1024/art/1434341159", - "chapterSource": "media", - "contentRating": "R", - "duration": 6627200, - "key": "/library/metadata/1024", - "originallyAvailableAt": "2002-12-06", - "primaryExtraKey": "/library/metadata/1051", - "rating": 7.1, - "ratingKey": "1024", - "studio": "Columbia Pictures", - "summary": "A love-lorn script writer grows increasingly desperate in his quest to adapt the book 'The Orchid Thief'.", - "tagline": "Charlie Kaufman writes the way he lives... With Great Difficulty. His Twin Brother Donald Lives the way he writes... with foolish abandon. Susan writes about life... But can't live it. John's life is a book...Waiting to be adapted. One story... Four Lives... A million ways it can end.", - "thumb": "/library/metadata/1024/thumb/1434341159", - "title": "Adaptation.", - "title.queryRange": "0,0", - "type": "movie", - "updatedAt": 1434341159, - "year": 2002, - "Media": [ - { - "aspectRatio": 1.78, - "audioChannels": 6, - "audioCodec": "ac3", - "bitrate": 5421, - "container": "mkv", - "duration": 6627200, - "height": 576, - "id": 802, - "videoCodec": "mpeg2video", - "videoFrameRate": "PAL", - "videoResolution": "576", - "width": 720, - "Part": [ - { - "container": "mkv", - "duration": 6627200, - "file": "O:\\fatboy\\Media\\Ripped\\Movies\\Adaptation (2002).mkv", - "id": 802, - "key": "/library/parts/802/file.mkv", - "size": 4490974984 - } - ] - } - ], - "Genre": [ - { - "tag": "Comedy" - }, - { - "tag": "Crime" - } - ], - "Writer": [ - { - "tag": "Charlie Kaufman" - }, - { - "tag": "Donald Kaufman" - } - ], - "Director": [ - { - "tag": "Spike Jonze" - } - ], - "Country": [ - { - "tag": "USA" - } - ], - "Role": [ - { - "tag": "Nicolas Cage" - }, - { - "tag": "Meryl Streep" - }, - { - "tag": "Chris Cooper" - } - ] - }, - { - "addedAt": 1407669060, - "art": "/library/metadata/1025/art/1434341158", - "chapterSource": "media", - "duration": 5165210, - "key": "/library/metadata/1025", - "originalTitle": "Neco z Alenky", - "originallyAvailableAt": "1988-08-03", - "rating": 6.9, - "ratingKey": "1025", - "studio": "Channel Four Films", - "summary": "A memorably bizarre screen version of Lewis Carroll's novel 'Alice's Adventures in Wonderland'. The original story is followed reasonably faithfully, though those familiar with this director's other films won't be the least bit surprised by the numerous digressions into Svankmajer territory, living slabs of meat and all. As the opening narration says, it's a film made for children... perhaps?", - "thumb": "/library/metadata/1025/thumb/1434341158", - "title": "Alice", - "title.queryRange": "0,0", - "type": "movie", - "updatedAt": 1434341158, - "year": 1988, - "Media": [ - { - "aspectRatio": 1.33, - "audioChannels": 2, - "audioCodec": "ac3", - "bitrate": 6672, - "container": "mkv", - "duration": 5165210, - "height": 480, - "id": 803, - "videoCodec": "mpeg2video", - "videoFrameRate": "NTSC", - "videoResolution": "480", - "width": 720, - "Part": [ - { - "container": "mkv", - "duration": 5165210, - "file": "O:\\fatboy\\Media\\Ripped\\Movies\\Alice (1988).mkv", - "id": 803, - "key": "/library/parts/803/file.mkv", - "size": 430806944 - } - ] - } - ], - "Genre": [ - { - "tag": "Animation" - }, - { - "tag": "Fantasy" - } - ], - "Writer": [ - { - "tag": "Jan ?vankmajer" - } - ], - "Director": [ - { - "tag": "Jan ?vankmajer" - } - ], - "Country": [ - { - "tag": "Switzerland" - }, - { - "tag": "Czech Republic" - } - ], - "Role": [ - { - "tag": "Krist?na Kohoutov?" - } - ] - } - ] - } - } - }, - "genre": { - "summary": "Request with `genre.query=a`", - "value": { - "MediaContainer": { - "allowSync": false, - "art": "/:/resources/movie-fanart.jpg", - "content": "secondary", - "identifier": "com.plexapp.plugins.library", - "mediaTagPrefix": "/system/bundle/media/flags/", - "mediaTagVersion": 1436742334, - "size": 3, - "thumb": "/:/resources/movie.png", - "title1": "Movies", - "viewGroup": "secondary", - "viewMode": 65592, - "Directory": [ - { - "id": 190, - "filter": "genre=190", - "tag": "Action", - "tagType": 1 - }, - { - "id": 98, - "filter": "genre=98", - "tag": "Adventure", - "tagType": 1 - }, - { - "id": 135, - "filter": "genre=135", - "tag": "Animation", - "tagType": 1 - } - ] - } - } - } - } - } - } - }, - "400": { - "description": "A paramater is either invalid or missing", - "content": { - "text/html": { - "examples": { - "badRequest": { - "summary": "A parameter has a bad value or required parameter is missing", - "value": "Bad Request

400 Bad Request

" - } - } - } - } - } - } - } - }, - "/library/sections/{sectionId}/categories": { - "get": { - "tags": [ - "Content" - ], - "operationId": "librarySectionGetCategories", - "summary": "Set section categories", - "description": "Get categories in a library section", - "parameters": [ - { - "in": "path", - "name": "sectionId", - "schema": { - "type": "integer" - }, - "description": "Section identifier", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/mediaContainerWithArtwork" - }, - "examples": { - "someCategories": { - "value": { - "MediaContainer": { - "size": 16, - "allowSync": false, - "art": "/:/resources/show-fanart.jpg", - "content": "secondary", - "identifier": "com.plexapp.plugins.library", - "mediaTagPrefix": "/system/bundle/media/flags/", - "mediaTagVersion": 1680272530, - "nocache": true, - "thumb": "/:/resources/show.png", - "title1": "TV Shows", - "title2": "", - "viewGroup": "secondary", - "Meta": { - "Type": [ - { - "key": "/library/sections/2/categories", - "type": "directory", - "title": "Categories", - "active": true - } - ] - }, - "Directory": [ - { - "thumb": "/photo/:/transcode?blendColor=2B717E&width=2560&height=1440&url=%2Flibrary%2Fmetadata%2F183%2Fart%2F1715112831", - "type": "directory", - "key": "/library/sections/2/all?genre=5", - "title": "Action" - }, - { - "thumb": "/photo/:/transcode?blendColor=4C561B&width=2560&height=1440&url=%2Flibrary%2Fmetadata%2F234%2Fart%2F1715112832", - "type": "directory", - "key": "/library/sections/2/all?genre=263", - "title": "Adventure" - }, - { - "thumb": "/photo/:/transcode?blendColor=51284C&width=2560&height=1440&url=%2Flibrary%2Fmetadata%2F206%2Fart%2F1715112832", - "type": "directory", - "key": "/library/sections/2/all?genre=176", - "title": "Comedy" - } - ] - } - } - } - } - } - } - } - } - } - }, - "/library/sections/{sectionId}/cluster": { - "get": { - "tags": [ - "Content" - ], - "operationId": "librarySectionGetCluster", - "summary": "Set section clusters", - "description": "Get clusters in a library section (typically for photos)", - "parameters": [ - { - "in": "path", - "name": "sectionId", - "schema": { - "type": "integer" - }, - "description": "Section identifier", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/mediaContainerWithArtwork" - }, - "examples": { - "someClusters": { - "value": { - "MediaContainer": { - "size": 19, - "allowSync": false, - "art": "/:/resources/photo-fanart.jpg", - "clusterZoomLevel": 1, - "clusteringActive": false, - "content": "secondary", - "identifier": "com.plexapp.plugins.library", - "key": "/library/sections/5/all?clusterZoomLevel=1&clustering<=4154", - "mediaTagPrefix": "/system/bundle/media/flags/", - "mediaTagVersion": 1680272530, - "nocache": true, - "thumb": "/:/resources/photo.png", - "title1": "Photos", - "title2": "By Moment", - "viewGroup": "secondary", - "Directory": [ - { - "fastKey": "/library/sections/5/all?cluster=46&clustering<=4154", - "title": "Oct 24, 2017", - "id": 46, - "size": 1, - "startsAt": 1508885256, - "endsAt": 1508885256, - "avgAR": "1.78" - }, - { - "fastKey": "/library/sections/5/all?cluster=42&clustering<=4154", - "title": "Oct 14, 2017", - "id": 42, - "size": 1, - "startsAt": 1507945565, - "endsAt": 1507945565, - "avgAR": "1.78" - }, - { - "fastKey": "/library/sections/5/all?cluster=44&clustering<=4154", - "title": "Oct 25, 2016", - "id": 44, - "size": 2, - "startsAt": 1477353502, - "endsAt": 1477353842, - "avgAR": "1.78" - }, - { - "fastKey": "/library/sections/5/all?cluster=17&clustering<=4154", - "title": "Oct 20, 2016", - "id": 17, - "size": 804, - "startsAt": 1477019560, - "endsAt": 1477022904, - "avgAR": "1.43" - } - ] - } - } - } - } - } - } - } - } - } - }, - "/library/sections/{sectionId}/collections": { - "get": { - "tags": [ - "Library" - ], - "operationId": "librarySectionGetCollections", - "summary": "Get collections in a section", - "description": "Get all collections in a section", - "parameters": [ - { - "in": "path", - "name": "sectionId", - "schema": { - "type": "integer" - }, - "description": "Section identifier", - "required": true - }, - { - "$ref": "#/components/parameters/mediaQuery" - } - ], - "responses": { - "200": { - "description": "OK", - "headers": { - "X-Plex-Container-Total-Size": { - "description": "Provided on all MediaContainer objects indicating the total size of objects available", - "schema": { - "type": "integer" - } - }, - "X-Plex-Container-Start": { - "description": "Provided on all MediaContainer objects indicating the offset of where this container page starts", - "schema": { - "type": "integer" - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/mediaContainerWithMetadata" - }, - "examples": { - "aMeta": { - "value": { - "MediaContainer": { - "allowSync": true, - "art": "/:/resources/movie-fanart.jpg", - "identifier": "com.plexapp.plugins.library", - "librarySectionID": 26, - "librarySectionTitle": "Movies", - "librarySectionUUID": "70cb5089-b165-429b-809a-9e0a31493abf", - "mediaTagPrefix": "/system/bundle/media/flags/", - "mediaTagVersion": 1436742334, - "size": 1, - "thumb": "/:/resources/movie.png", - "title1": "Movies", - "title2": "All Movies", - "viewGroup": "movie", - "viewMode": "65592", - "Metadata": [ - { - "addedAt": 1408525217, - "art": "/library/metadata/1049/art/1434341184", - "chapterSource": "media", - "contentRating": "PG-13", - "duration": 5129000, - "key": "/library/metadata/1049", - "originallyAvailableAt": "2001-09-27", - "primaryExtraKey": "/library/metadata/1073", - "rating": 6, - "ratingKey": "1049", - "studio": "Paramount Pictures", - "summary": "FunnyStuff", - "tagline": "3% Body Fat. 1% Brain Activity.", - "thumb": "/library/metadata/1049/thumb/1434341184", - "title": "Zoolander", - "type": "movie", - "updatedAt": 1434341184, - "year": 2001, - "Media": [ - { - "aspectRatio": 1.78, - "audioChannels": 6, - "audioCodec": "ac3", - "bitrate": 6564, - "container": "mkv", - "duration": 5129000, - "height": 576, - "id": 827, - "videoCodec": "mpeg2video", - "videoFrameRate": "PAL", - "videoResolution": "576", - "width": 720, - "Part": [ - { - "container": "mkv", - "duration": 5129000, - "file": "O:\\fatboy\\Media\\Ripped\\Movies\\Zoolander (2001).mkv", - "id": 827, - "key": "/library/parts/827/file.mkv", - "size": 4208219125 - } - ] - } - ], - "Genre": [ - { - "tag": "Comedy" - } - ], - "Writer": [ - { - "tag": "Drake Sather" - }, - { - "tag": "Ben Stiller" - } - ], - "Director": [ - { - "tag": "Ben Stiller" - } - ], - "Country": [ - { - "tag": "Australia" - }, - { - "tag": "Germany" - } - ], - "Role": [ - { - "tag": "Ben Stiller" - }, - { - "tag": "Owen Wilson" - }, - { - "tag": "Christine Taylor" - } - ] - } - ] - } - } - } - } - } - } - } - } - } - }, - "/library/sections/{sectionId}/collection/{collectionId}": { - "delete": { - "tags": [ - "Library" - ], - "security": [ - { - "user_token": [ - "admin" - ] - } - ], - "operationId": "librarySectionDeleteCollectionCollection", - "summary": "Delete a collection", - "description": "Delete a library collection from the PMS", - "parameters": [ - { - "in": "path", - "name": "sectionId", - "schema": { - "type": "integer" - }, - "description": "Section identifier", - "required": true - }, - { - "in": "path", - "name": "collectionId", - "schema": { - "type": "integer" - }, - "description": "Collection Id", - "required": true - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/200" - }, - "404": { - "description": "Collection not found", - "content": { - "text/html": { - "examples": { - "notFound": { - "summary": "Not Found", - "value": "Not Found

404 Not Found

" - } - } - } - } - } - } - } - }, - "/library/sections/{sectionId}/common": { - "get": { - "tags": [ - "Library" - ], - "operationId": "librarySectionGetCommon", - "summary": "Get common fields for items", - "description": "Represents a \"Common\" item. It contains only the common attributes of the items selected by the provided filter\nFields which are not common will be expressed in the `mixedFields` field", - "parameters": [ - { - "in": "path", - "name": "sectionId", - "schema": { - "type": "integer" - }, - "description": "Section identifier", - "required": true - }, - { - "in": "query", - "name": "type", - "schema": { - "type": "integer" - }, - "description": "Item type" - }, - { - "$ref": "#/components/parameters/mediaQuery" - } - ], - "responses": { - "200": { - "description": "OK", - "headers": { - "X-Plex-Container-Total-Size": { - "description": "Provided on all MediaContainer objects indicating the total size of objects available", - "schema": { - "type": "integer" - } - }, - "X-Plex-Container-Start": { - "description": "Provided on all MediaContainer objects indicating the offset of where this container page starts", - "schema": { - "type": "integer" - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/mediaContainerWithMetadata" - }, - "examples": { - "none": { - "summary": "No common items in filter set", - "value": { - "MediaContainer": { - "allowSync": false, - "art": "/:/resources/movie-fanart.jpg", - "content": "secondary", - "identifier": "com.plexapp.plugins.library", - "mediaTagPrefix": "/system/bundle/media/flags/", - "mediaTagVersion": 1436742334, - "size": 1, - "thumb": "/:/resources/movie.png", - "title1": "Movies", - "title2": "Common", - "viewGroup": "secondary", - "viewMode": 65592, - "Metadata": [ - { - "index": 1, - "mixedFields": "title,ratingKey,titleSort,tagline,rating,summary,year,studio,originallyAvailableAt,originalTitle,contentRating", - "ratingCount": 0, - "type": "common" - } - ] - } - } - }, - "all": { - "summary": "All filtered items are common", - "value": { - "MediaContainer": { - "allowSync": false, - "art": "/:/resources/movie-fanart.jpg", - "content": "secondary", - "identifier": "com.plexapp.plugins.library", - "mediaTagPrefix": "/system/bundle/media/flags/", - "mediaTagVersion": 1436742334, - "size": 1, - "thumb": "/:/resources/movie.png", - "title1": "Movies", - "title2": "Common", - "viewGroup": "secondary", - "viewMode": 65592, - "Metadata": [ - { - "index": 1, - "key": "/library/metadata/1025/children", - "originalTitle": "Neco z Alenky", - "originallyAvailableAt": "1988-08-03", - "rating": 6.9, - "ratingCount": 0, - "ratingKey": "1025", - "studio": "Channel Four Films", - "summary": "A memorably bizarre screen version of Lewis Carroll's novel 'Alice's Adventures in Wonderland'. The original story is followed reasonably faithfully, though those familiar with this director's other films won't be the least bit surprised by the numerous digressions into Svankmajer territory, living slabs of meat and all. As the opening narration says, it's a film made for children... perhaps?", - "title": "Alice", - "titleSort": "Alice", - "type": "common", - "year": 1988, - "Genre": [ - { - "id": 135, - "tag": "Animation" - }, - { - "id": 136, - "tag": "Fantasy" - }, - { - "id": 42, - "tag": "Science Fiction" - } - ], - "Writer": [ - { - "id": 133, - "tag": "Jan ?vankmajer" - } - ], - "Director": [ - { - "id": 132, - "tag": "Jan ?vankmajer" - } - ], - "Country": [ - { - "id": 137, - "tag": "Switzerland" - }, - { - "id": 138, - "tag": "Czech Republic" - }, - { - "id": 130, - "tag": "Germany" - }, - { - "id": 139, - "tag": "United Kingdom" - } - ], - "Role": [ - { - "id": 134, - "role": "Alice", - "tag": "Krist?na Kohoutov?" - } - ] - } - ] - } - } - } - } - } - } - }, - "400": { - "$ref": "#/components/responses/400" - }, - "404": { - "$ref": "#/components/responses/404" - } - } - } - }, - "/library/sections/{sectionId}/composite/{updatedAt}": { - "get": { - "tags": [ - "Library" - ], - "security": [ - { - "user_token": [ - "admin" - ] - } - ], - "operationId": "librarySectionGetComposite", - "summary": "Get a section composite image", - "description": "Get a composite image of images in this section", - "parameters": [ - { - "in": "path", - "name": "sectionId", - "schema": { - "type": "integer" - }, - "description": "Section identifier", - "required": true - }, - { - "in": "path", - "name": "updatedAt", - "schema": { - "type": "integer" - }, - "description": "The update time of the image. Used for busting cache.", - "required": true - }, - { - "$ref": "#/components/parameters/mediaQuery" - }, - { - "$ref": "#/components/parameters/composite" - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/200" - } - } - } - }, - "/library/sections/{sectionId}/computePath": { - "get": { - "tags": [ - "Content" - ], - "operationId": "librarySectionGetComputePath", - "summary": "Similar tracks to transition from one to another", - "description": "Get a list of audio tracks starting at one and ending at another which are similar across the path", - "parameters": [ - { - "in": "path", - "name": "sectionId", - "schema": { - "type": "integer" - }, - "description": "Section identifier", - "required": true - }, - { - "in": "query", - "name": "startID", - "schema": { - "type": "integer" - }, - "required": true, - "description": "The starting metadata item id" - }, - { - "in": "query", - "name": "endID", - "schema": { - "type": "integer" - }, - "required": true, - "description": "The ending metadata item id" - }, - { - "in": "query", - "name": "count", - "schema": { - "type": "integer" - }, - "description": "The number of items along the path; defaults to 50" - }, - { - "in": "query", - "name": "maxDistance", - "schema": { - "type": "number" - }, - "description": "The maximum distance allowed along the path; defaults to 0.25" - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/mediaContainerWithMetadata" - }, - "examples": { - "Zoolander Metadata": { - "value": { - "MediaContainer": { - "size": "1", - "allowSync": true, - "art": "/:/resources/movie-fanart.jpg", - "identifier": "com.plexapp.plugins.library", - "librarySectionID": 26, - "librarySectionTitle": "Movies", - "librarySectionUUID": "70cb5089-b165-429b-809a-9e0a31493abf", - "mediaTagPrefix": "/system/bundle/media/flags/", - "mediaTagVersion": "1436742334", - "thumb": "/:/resources/movie.png", - "title1": "Movies", - "title2": "All Movies", - "viewGroup": "movie", - "Metadata": [ - { - "id": "1049", - "ratingKey": "1049", - "key": "/library/metadata/1049", - "studio": "Paramount Pictures", - "type": "movie", - "title": "Zoolander", - "contentRating": "PG-13", - "summary": "FunnyStuff", - "year": 2001, - "tagline": "3% Body Fat. 1% Brain Activity.", - "thumb": "/library/metadata/1049/thumb/1434341184", - "art": "/library/metadata/1049/art/1434341184", - "duration": 5129000, - "originallyAvailableAt": "2001-09-27", - "addedAt": 1408525217, - "updatedAt": 1434341184, - "chapterSource": "media", - "primaryExtraKey": "/library/metadata/1073", - "rating": 6, - "Media": [ - { - "id": 827, - "duration": 5129000, - "bitrate": 6564, - "width": 720, - "height": 576, - "aspectRatio": 1.78, - "audioChannels": 6, - "audioCodec": "ac3", - "videoCodec": "mpeg2video", - "container": "mkv", - "videoFrameRate": "PAL", - "Part": [ - { - "id": "827", - "key": "/library/parts/827/file.mkv", - "duration": 5129000, - "file": "O:\\fatboy\\Media\\Ripped\\Movies\\Zoolander (2001).mkv", - "size": 4208219125, - "container": "mkv" - } - ] - } - ], - "Image": [ - { - "type": "coverPoster", - "alt": "Zoolander", - "url": "/library/metadata/1049/thumb/1434341184" - } - ], - "Genre": [ - { - "tag": "Comedy" - } - ], - "Writer": [ - { - "tag": "Drake Sather" - }, - { - "tag": "Ben Stiller" - } - ], - "Director": [ - { - "tag": "Ben Stiller" - } - ], - "Country": [ - { - "tag": "Australia" - }, - { - "tag": "Germany" - } - ], - "Role": [ - { - "tag": "Ben Stiller" - }, - { - "tag": "Owen Wilson" - }, - { - "tag": "Christine Taylor" - } - ] - } - ] - } - } - } - } - } - } - } - } - } - }, - "/library/sections/{sectionId}/emptyTrash": { - "put": { - "tags": [ - "Library" - ], - "security": [ - { - "user_token": [ - "admin" - ] - } - ], - "operationId": "librarySectionPutEmptyTrash", - "summary": "Empty section trash", - "description": "Empty trash in the section, permanently deleting media/metadata for missing media", - "parameters": [ - { - "in": "path", - "name": "sectionId", - "schema": { - "type": "integer" - }, - "description": "Section identifier", - "required": true - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/200" - } - } - } - }, - "/library/sections/{sectionId}/filters": { - "get": { - "tags": [ - "Library" - ], - "security": [ - { - "user_token": [ - "admin" - ] - } - ], - "operationId": "librarySectionGetFilters", - "summary": "Get section filters", - "description": "Get common filters on a section", - "parameters": [ - { - "in": "path", - "name": "sectionId", - "schema": { - "type": "integer" - }, - "description": "Section identifier", - "required": true - } - ], - "responses": { - "200": { - "description": "The filters on the section", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "MediaContainer": { - "allOf": [ - { - "$ref": "#/components/schemas/MediaContainer" - }, - { - "type": "object", - "properties": { - "Directory": { - "type": "array", - "items": { - "$ref": "#/components/schemas/directory" - } - } - } - } - ] - } - } - }, - "examples": { - "movieSection": { - "summary": "A movie section filters", - "value": { - "MediaContainer": { - "size": 20, - "allowSync": false, - "art": "/:/resources/movie-fanart.jpg", - "content": "secondary", - "identifier": "com.plexapp.plugins.library", - "mediaTagPrefix": "/system/bundle/media/flags/", - "mediaTagVersion": 1680272530, - "thumb": "/:/resources/movie.png", - "title1": "Movies", - "viewGroup": "secondary", - "Directory": [ - { - "filter": "genre", - "filterType": "string", - "key": "/library/sections/1/genre", - "title": "Genre", - "type": "filter" - }, - { - "filter": "year", - "filterType": "integer", - "key": "/library/sections/1/year", - "title": "Year", - "type": "filter" - }, - { - "filter": "decade", - "filterType": "integer", - "key": "/library/sections/1/decade", - "title": "Decade", - "type": "filter" - }, - { - "filter": "contentRating", - "filterType": "string", - "key": "/library/sections/1/contentRating", - "title": "Content Rating", - "type": "filter" - }, - { - "filter": "collection", - "filterType": "string", - "key": "/library/sections/1/collection", - "title": "Collection", - "type": "filter" - }, - { - "filter": "director", - "filterType": "string", - "key": "/library/sections/1/director", - "title": "Director", - "type": "filter" - }, - { - "filter": "actor", - "filterType": "string", - "key": "/library/sections/1/actor", - "title": "Actor", - "type": "filter" - }, - { - "filter": "writer", - "filterType": "string", - "key": "/library/sections/1/writer", - "title": "Writer", - "type": "filter" - }, - { - "filter": "producer", - "filterType": "string", - "key": "/library/sections/1/producer", - "title": "Producer", - "type": "filter" - }, - { - "filter": "country", - "filterType": "string", - "key": "/library/sections/1/country", - "title": "Country", - "type": "filter" - }, - { - "filter": "studio", - "filterType": "string", - "key": "/library/sections/1/studio", - "title": "Studio", - "type": "filter" - }, - { - "filter": "resolution", - "filterType": "string", - "key": "/library/sections/1/resolution", - "title": "Resolution", - "type": "filter" - }, - { - "filter": "hdr", - "filterType": "boolean", - "key": "/library/sections/1/hdr", - "title": "HDR", - "type": "filter" - }, - { - "filter": "unwatched", - "filterType": "boolean", - "key": "/library/sections/1/unwatched", - "title": "Unwatched", - "type": "filter" - }, - { - "filter": "inProgress", - "filterType": "boolean", - "key": "/library/sections/1/inProgress", - "title": "In Progress", - "type": "filter" - }, - { - "filter": "unmatched", - "filterType": "boolean", - "key": "/library/sections/1/unmatched", - "title": "Unmatched", - "type": "filter" - }, - { - "filter": "audioLanguage", - "filterType": "string", - "key": "/library/sections/1/audioLanguage", - "title": "Audio Language", - "type": "filter" - }, - { - "filter": "subtitleLanguage", - "filterType": "string", - "key": "/library/sections/1/subtitleLanguage", - "title": "Subtitle Language", - "type": "filter" - }, - { - "filter": "editionTitle", - "filterType": "string", - "key": "/library/sections/1/editionTitle", - "title": "Edition", - "type": "filter" - }, - { - "filter": "label", - "filterType": "string", - "key": "/library/sections/1/label", - "title": "Labels", - "type": "filter" - }, - { - "filter": "location", - "filterType": "string", - "key": "/library/sections/1/location", - "title": "Folder Location", - "type": "filter" - } - ] - } - } - } - } - } - } - } - } - } - }, - "/library/sections/{sectionId}/firstCharacters": { - "get": { - "tags": [ - "Library" - ], - "operationId": "librarySectionGetFirstCharaters", - "summary": "Get list of first characters", - "description": "Get list of first characters in this section", - "parameters": [ - { - "in": "path", - "name": "sectionId", - "schema": { - "type": "integer" - }, - "description": "Section identifier", - "required": true - }, - { - "in": "query", - "name": "type", - "schema": { - "type": "integer" - }, - "description": "The metadata type to filter on" - }, - { - "in": "query", - "name": "sort", - "schema": { - "type": "integer" - }, - "description": "The metadata type to filter on" - }, - { - "$ref": "#/components/parameters/mediaQuery" - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "MediaContainer": { - "allOf": [ - { - "$ref": "#/components/schemas/MediaContainer" - }, - { - "type": "object", - "properties": { - "Directory": { - "type": "array", - "items": { - "type": "object", - "properties": { - "size": { - "type": "integer", - "description": "The number of items starting with this character" - }, - "key": { - "type": "string" - }, - "title": { - "type": "string" - } - } - } - } - } - } - ] - } - } - }, - "examples": { - "someMovies": { - "description": "A small movie section", - "value": { - "MediaContainer": { - "size": 14, - "allowSync": false, - "art": "/:/resources/movie-fanart.jpg", - "content": "secondary", - "identifier": "com.plexapp.plugins.library", - "mediaTagPrefix": "/system/bundle/media/flags/", - "mediaTagVersion": 1680272530, - "thumb": "/:/resources/movie.png", - "title1": "Movies", - "title2": "By First Letter", - "viewGroup": "secondary", - "Directory": [ - { - "size": 2, - "key": "%23", - "title": "#" - }, - { - "size": 1, - "key": "A", - "title": "A" - }, - { - "size": 1, - "key": "C", - "title": "C" - }, - { - "size": 1, - "key": "D", - "title": "D" - }, - { - "size": 1, - "key": "E", - "title": "E" - }, - { - "size": 1, - "key": "G", - "title": "G" - }, - { - "size": 1, - "key": "J", - "title": "J" - }, - { - "size": 3, - "key": "L", - "title": "L" - }, - { - "size": 1, - "key": "M", - "title": "M" - }, - { - "size": 1, - "key": "P", - "title": "P" - }, - { - "size": 1, - "key": "S", - "title": "S" - }, - { - "size": 1, - "key": "T", - "title": "T" - }, - { - "size": 2, - "key": "V", - "title": "V" - }, - { - "size": 1, - "key": "Z", - "title": "Z" - } - ] - } - } - } - } - } - } - } - } - } - }, - "/library/sections/{sectionId}/indexes": { - "delete": { - "tags": [ - "Library" - ], - "security": [ - { - "user_token": [ - "admin" - ] - } - ], - "operationId": "librarySectionDeleteIndexes", - "summary": "Delete section indexes", - "description": "Delete all the indexes in a section", - "parameters": [ - { - "in": "path", - "name": "sectionId", - "schema": { - "type": "integer" - }, - "description": "Section identifier", - "required": true - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/200" - } - } - } - }, - "/library/sections/{sectionId}/intros": { - "delete": { - "tags": [ - "Library" - ], - "security": [ - { - "user_token": [ - "admin" - ] - } - ], - "operationId": "librarySectionDeleteIntros", - "summary": "Delete section intro markers", - "description": "Delete all the intro markers in a section", - "parameters": [ - { - "in": "path", - "name": "sectionId", - "schema": { - "type": "integer" - }, - "description": "Section identifier", - "required": true - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/200" - } - } - } - }, - "/library/sections/{sectionId}/location": { - "get": { - "tags": [ - "Content" - ], - "operationId": "librarySectionGetLocations", - "summary": "Get all folder locations", - "description": "Get all folder locations of the media in a section", - "parameters": [ - { - "in": "path", - "name": "sectionId", - "schema": { - "type": "integer" - }, - "description": "Section identifier", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "MediaContainer": { - "allOf": [ - { - "$ref": "#/components/schemas/MediaContainer" - }, - { - "type": "object", - "properties": { - "Directory": { - "type": "array", - "items": { - "type": "object", - "properties": { - "fastKey": { - "type": "string" - }, - "key": { - "type": "string" - }, - "title": { - "type": "string" - } - } - } - } - } - } - ] - } - } - } - } - } - } - } - } - }, - "/library/sections/{sectionId}/nearest": { - "get": { - "tags": [ - "Content" - ], - "operationId": "librarySectionGetNearest", - "summary": "The nearest audio tracks", - "description": "Get the nearest audio tracks to a particular analysis", - "parameters": [ - { - "in": "path", - "name": "sectionId", - "schema": { - "type": "integer" - }, - "description": "Section identifier", - "required": true - }, - { - "in": "query", - "name": "type", - "schema": { - "type": "integer" - }, - "description": "The metadata type to fetch (should be 10 for audio track)" - }, - { - "in": "query", - "name": "values", - "schema": { - "type": "array", - "items": { - "type": "integer", - "minimum": 50, - "maximum": 50 - } - }, - "explode": false, - "required": true, - "description": "The music analysis to center the search. Typically obtained from the `musicAnalysis` of a track" - }, - { - "in": "query", - "name": "limit", - "schema": { - "type": "integer" - }, - "description": "The limit of the number of items to fetch; defaults to 50" - }, - { - "in": "query", - "name": "maxDistance", - "schema": { - "type": "number" - }, - "description": "The maximum distance to search, defaults to 0.25" - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/mediaContainerWithMetadata" - }, - "examples": { - "Zoolander Metadata": { - "value": { - "MediaContainer": { - "size": "1", - "allowSync": true, - "art": "/:/resources/movie-fanart.jpg", - "identifier": "com.plexapp.plugins.library", - "librarySectionID": 26, - "librarySectionTitle": "Movies", - "librarySectionUUID": "70cb5089-b165-429b-809a-9e0a31493abf", - "mediaTagPrefix": "/system/bundle/media/flags/", - "mediaTagVersion": "1436742334", - "thumb": "/:/resources/movie.png", - "title1": "Movies", - "title2": "All Movies", - "viewGroup": "movie", - "Metadata": [ - { - "id": "1049", - "ratingKey": "1049", - "key": "/library/metadata/1049", - "studio": "Paramount Pictures", - "type": "movie", - "title": "Zoolander", - "contentRating": "PG-13", - "summary": "FunnyStuff", - "year": 2001, - "tagline": "3% Body Fat. 1% Brain Activity.", - "thumb": "/library/metadata/1049/thumb/1434341184", - "art": "/library/metadata/1049/art/1434341184", - "duration": 5129000, - "originallyAvailableAt": "2001-09-27", - "addedAt": 1408525217, - "updatedAt": 1434341184, - "chapterSource": "media", - "primaryExtraKey": "/library/metadata/1073", - "rating": 6, - "Media": [ - { - "id": 827, - "duration": 5129000, - "bitrate": 6564, - "width": 720, - "height": 576, - "aspectRatio": 1.78, - "audioChannels": 6, - "audioCodec": "ac3", - "videoCodec": "mpeg2video", - "container": "mkv", - "videoFrameRate": "PAL", - "Part": [ - { - "id": "827", - "key": "/library/parts/827/file.mkv", - "duration": 5129000, - "file": "O:\\fatboy\\Media\\Ripped\\Movies\\Zoolander (2001).mkv", - "size": 4208219125, - "container": "mkv" - } - ] - } - ], - "Image": [ - { - "type": "coverPoster", - "alt": "Zoolander", - "url": "/library/metadata/1049/thumb/1434341184" - } - ], - "Genre": [ - { - "tag": "Comedy" - } - ], - "Writer": [ - { - "tag": "Drake Sather" - }, - { - "tag": "Ben Stiller" - } - ], - "Director": [ - { - "tag": "Ben Stiller" - } - ], - "Country": [ - { - "tag": "Australia" - }, - { - "tag": "Germany" - } - ], - "Role": [ - { - "tag": "Ben Stiller" - }, - { - "tag": "Owen Wilson" - }, - { - "tag": "Christine Taylor" - } - ] - } - ] - } - } - } - } - } - } - } - } - } - }, - "/library/sections/{sectionId}/moment": { - "get": { - "tags": [ - "Content" - ], - "operationId": "librarySectionGetMoment", - "summary": "Set section moments", - "description": "Get moments in a library section (typically for photos)", - "parameters": [ - { - "in": "path", - "name": "sectionId", - "schema": { - "type": "integer" - }, - "description": "Section identifier", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/mediaContainerWithArtwork" - }, - "examples": { - "someClusters": { - "value": { - "MediaContainer": { - "size": 19, - "allowSync": false, - "art": "/:/resources/photo-fanart.jpg", - "clusterZoomLevel": 1, - "clusteringActive": false, - "content": "secondary", - "identifier": "com.plexapp.plugins.library", - "key": "/library/sections/5/all?clusterZoomLevel=1&clustering<=4154", - "mediaTagPrefix": "/system/bundle/media/flags/", - "mediaTagVersion": 1680272530, - "nocache": true, - "thumb": "/:/resources/photo.png", - "title1": "Photos", - "title2": "By Moment", - "viewGroup": "secondary", - "Directory": [ - { - "fastKey": "/library/sections/5/all?cluster=46&clustering<=4154", - "title": "Oct 24, 2017", - "id": 46, - "size": 1, - "startsAt": 1508885256, - "endsAt": 1508885256, - "avgAR": "1.78" - }, - { - "fastKey": "/library/sections/5/all?cluster=42&clustering<=4154", - "title": "Oct 14, 2017", - "id": 42, - "size": 1, - "startsAt": 1507945565, - "endsAt": 1507945565, - "avgAR": "1.78" - }, - { - "fastKey": "/library/sections/5/all?cluster=44&clustering<=4154", - "title": "Oct 25, 2016", - "id": 44, - "size": 2, - "startsAt": 1477353502, - "endsAt": 1477353842, - "avgAR": "1.78" - }, - { - "fastKey": "/library/sections/5/all?cluster=17&clustering<=4154", - "title": "Oct 20, 2016", - "id": 17, - "size": 804, - "startsAt": 1477019560, - "endsAt": 1477022904, - "avgAR": "1.43" - } - ] - } - } - } - } - } - } - } - } - } - }, - "/library/sections/{sectionId}/prefs": { - "get": { - "tags": [ - "Library" - ], - "operationId": "librarySectionGetPrefs", - "summary": "Get section prefs", - "description": "Get the prefs for a section by id and potentially overriding the agent", - "parameters": [ - { - "in": "path", - "name": "sectionId", - "schema": { - "type": "integer" - }, - "description": "Section identifier", - "required": true - }, - { - "in": "query", - "name": "agent", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/mediaContainerWithSettings" - }, - "examples": { - "somePrefs": { - "value": { - "MediaContainer": { - "size": 171, - "Setting": [ - { - "id": "FriendlyName", - "label": "Friendly name", - "summary": "This name will be used to identify this media server to other computers on your network. If you leave it blank, your computer's name will be used instead.", - "type": "text", - "default": "", - "value": "", - "hidden": false, - "advanced": false, - "group": "general" - }, - { - "id": "sendCrashReports", - "label": "Send crash reports to Plex", - "summary": "This helps us improve your experience.", - "type": "bool", - "default": true, - "value": true, - "hidden": false, - "advanced": false, - "group": "general" - }, - { - "id": "ScheduledLibraryUpdateInterval", - "label": "Library scan interval", - "summary": "", - "type": "int", - "default": 3600, - "value": 3600, - "hidden": false, - "advanced": false, - "group": "library", - "enumValues": "900:every 15 minutes|1800:every 30 minutes|3600:hourly|7200:every 2 hours|21600:every 6 hours|43200:every 12 hours|86400:daily" - }, - { - "id": "OnDeckWindow", - "label": "Weeks to consider for Continue Watching", - "summary": "Media that has not been watched in this many weeks will not appear in Continue Watching.", - "type": "int", - "default": 16, - "value": 16, - "hidden": false, - "advanced": true, - "group": "library" - }, - { - "id": "LibraryVideoPlayedAtBehaviour", - "label": "Video play completion behaviour", - "summary": "Decide whether to use end credits markers to determine the 'watched' state of video items. When markers are not available the selected threshold percentage will be used.", - "type": "text", - "default": "3", - "value": "3", - "hidden": false, - "advanced": true, - "group": "library", - "enumValues": "0:at selected threshold percentage|1:at final credits marker position|2:at first credits marker position|3:earliest between threshold percent and first credits marker" - }, - { - "id": "TranscoderH264MinimumCRF", - "label": "", - "summary": "", - "type": "double", - "default": 16, - "value": 16, - "hidden": true, - "advanced": false, - "group": "transcoder" - } - ] - } - } - } - } - } - } - } - } - }, - "put": { - "tags": [ - "Library" - ], - "security": [ - { - "user_token": [ - "admin" - ] - } - ], - "operationId": "librarySectionPutPrefs", - "summary": "Set section prefs", - "description": "Set the prefs for a section by id", - "parameters": [ - { - "in": "path", - "name": "sectionId", - "schema": { - "type": "integer" - }, - "description": "Section identifier", - "required": true - }, - { - "in": "query", - "name": "prefs", - "schema": { - "type": "object" - }, - "required": true, - "example": { - "hidden": 0, - "enableCinemaTrailers": 1 - } - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/200" - } - } - } - }, - "/library/sections/{sectionId}/refresh": { - "post": { - "tags": [ - "Library" - ], - "security": [ - { - "user_token": [ - "admin" - ] - } - ], - "operationId": "librarySectionPostRefresh", - "summary": "Refresh section", - "description": "Start a refresh of this section", - "parameters": [ - { - "in": "path", - "name": "sectionId", - "schema": { - "type": "integer" - }, - "description": "Section identifier", - "required": true - }, - { - "in": "query", - "name": "force", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "description": "Whether the update of metadata and items should be performed even if modification dates indicate the items have not change" - }, - { - "in": "query", - "name": "path", - "schema": { - "type": "string" - }, - "description": "Restrict refresh to the specified path" - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/200" - } - } - }, - "delete": { - "tags": [ - "Library" - ], - "security": [ - { - "user_token": [ - "admin" - ] - } - ], - "operationId": "librarySectionDeleteRefresh", - "summary": "Cancel section refresh", - "description": "Cancel the refresh of a section", - "parameters": [ - { - "in": "path", - "name": "sectionId", - "schema": { - "type": "integer" - }, - "description": "Section identifier", - "required": true - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/200" - } - } - } - }, - "/library/sections/{sectionId}/sorts": { - "get": { - "tags": [ - "Library" - ], - "operationId": "librarySectionGetSorts", - "summary": "Get a section sorts", - "description": "Get the sort mechanisms available in a section", - "parameters": [ - { - "in": "path", - "name": "sectionId", - "schema": { - "type": "integer" - }, - "description": "Section identifier", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "MediaContainer": { - "allOf": [ - { - "$ref": "#/components/schemas/MediaContainer" - }, - { - "type": "object", - "properties": { - "Directory": { - "type": "array", - "items": { - "$ref": "#/components/schemas/sort" - } - } - } - } - ] - } - } - }, - "examples": { - "movieSorts": { - "value": { - "MediaContainer": { - "size": 9, - "allowSync": false, - "art": "/:/resources/movie-fanart.jpg", - "content": "secondary", - "identifier": "com.plexapp.plugins.library", - "mediaTagPrefix": "/system/bundle/media/flags/", - "mediaTagVersion": 1680272530, - "thumb": "/:/resources/movie.png", - "title1": "Movies", - "viewGroup": "secondary", - "Directory": [ - { - "default": "asc", - "defaultDirection": "asc", - "descKey": "titleSort:desc", - "firstCharacterKey": "/library/sections/1/firstCharacter", - "key": "titleSort", - "title": "Title" - }, - { - "defaultDirection": "desc", - "descKey": "originallyAvailableAt:desc", - "key": "originallyAvailableAt", - "title": "Release Date" - }, - { - "defaultDirection": "desc", - "descKey": "rating:desc", - "key": "rating", - "title": "Critic Rating" - }, - { - "defaultDirection": "desc", - "descKey": "audienceRating:desc", - "key": "audienceRating", - "title": "Audience Rating" - }, - { - "defaultDirection": "desc", - "descKey": "duration:desc", - "key": "duration", - "title": "Duration" - }, - { - "defaultDirection": "desc", - "descKey": "addedAt:desc", - "key": "addedAt", - "title": "Date Added" - }, - { - "defaultDirection": "desc", - "descKey": "lastViewedAt:desc", - "key": "lastViewedAt", - "title": "Date Viewed" - }, - { - "defaultDirection": "asc", - "descKey": "mediaHeight:desc", - "key": "mediaHeight", - "title": "Resolution" - }, - { - "defaultDirection": "desc", - "descKey": "random:desc", - "key": "random", - "title": "Randomly" - } - ] - } - } - } - } - } - } - } - } - } - }, - "/library/streams/{streamId}.{ext}": { - "get": { - "tags": [ - "Library" - ], - "operationId": "libraryGetStreamsStream", - "summary": "Get a stream", - "description": "Get a stream (such a a sidecar subtitle stream)", - "parameters": [ - { - "in": "path", - "name": "streamId", - "schema": { - "type": "integer" - }, - "required": true, - "description": "The id of the stream" - }, - { - "in": "path", - "name": "ext", - "schema": { - "type": "string" - }, - "required": true, - "description": "The extension of the stream. Required to fetch the `sub` portion of `idx`/`sub` subtitles" - }, - { - "in": "query", - "name": "encoding", - "schema": { - "type": "string" - }, - "required": false, - "description": "The requested encoding for the subtitle (only used for text subtitles)" - }, - { - "in": "query", - "name": "format", - "schema": { - "type": "string" - }, - "required": false, - "description": "The requested format for the subtitle to convert the subtitles to (only used for text subtitles)" - }, - { - "in": "query", - "name": "autoAdjustSubtitle", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "required": false, - "description": "Whether the server should attempt to automatically adjust the subtitle timestamps to match the media" - } - ], - "responses": { - "200": { - "description": "The stream in the requested format." - }, - "403": { - "description": "The media is not accessible to the user", - "content": { - "text/html": { - "examples": { - "forbidden": { - "summary": "Forbidden", - "value": "Forbidden

403 Forbidden

" - } - } - } - } - }, - "404": { - "description": "The stream doesn't exist or has no data", - "content": { - "text/html": { - "examples": { - "notFound": { - "summary": "Not Found", - "value": "Not Found

404 Not Found

" - } - } - } - } - }, - "501": { - "description": "The stream is not a sidecar subtitle", - "content": { - "text/html": { - "examples": { - "notFound": { - "summary": "Not Implemented", - "value": "Not Implemented

501 Not Implemented

" - } - } - } - } - } - } - }, - "put": { - "tags": [ - "Library" - ], - "operationId": "libraryPutStreamsStream", - "summary": "Set a stream offset", - "description": "Set a stream offset in ms. This may not be respected by all clients", - "parameters": [ - { - "in": "path", - "name": "streamId", - "schema": { - "type": "integer" - }, - "required": true, - "description": "The id of the stream" - }, - { - "in": "path", - "name": "ext", - "schema": { - "type": "string" - }, - "required": true, - "description": "This is not a part of this endpoint but documented here to satisfy OpenAPI" - }, - { - "in": "query", - "name": "offset", - "schema": { - "type": "integer" - }, - "required": false, - "description": "The offest in ms" - } - ], - "responses": { - "200": { - "description": "The stream in the requested format." - }, - "400": { - "description": "The stream doesn't exist", - "content": { - "text/html": { - "examples": { - "badRequest": { - "summary": "A parameter has a bad value or required parameter is missing", - "value": "Bad Request

400 Bad Request

" - } - } - } - } - } - } - }, - "delete": { - "tags": [ - "Library" - ], - "operationId": "libraryDeleteStreamsStream", - "summary": "Delete a stream", - "description": "Delete a stream. Only applies to downloaded subtitle streams or a sidecar subtitle when media deletion is enabled.", - "parameters": [ - { - "in": "path", - "name": "streamId", - "schema": { - "type": "integer" - }, - "required": true, - "description": "The id of the stream" - }, - { - "in": "path", - "name": "ext", - "schema": { - "type": "string" - }, - "required": true, - "description": "This is not a part of this endpoint but documented here to satisfy OpenAPI" - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/200" - }, - "403": { - "description": "This user cannot delete this stream", - "content": { - "text/html": { - "examples": { - "forbidden": { - "summary": "Forbidden", - "value": "Forbidden

403 Forbidden

" - } - } - } - } - }, - "500": { - "description": "The stream cannot be deleted", - "content": { - "text/html": { - "examples": { - "badParam": { - "summary": "Processing failed inside the server", - "value": "Internal Server Error

500 Internal Server Error

" - } - } - } - } - } - } - } - }, - "/library/streams/{streamId}/loudness": { - "get": { - "tags": [ - "Library" - ], - "operationId": "libraryGetStreamsStreamLoudness", - "summary": "Get loudness about a stream", - "description": "The the loudness of a stream in db, one number per line, one entry per 100ms", - "parameters": [ - { - "in": "path", - "name": "streamId", - "schema": { - "type": "integer" - }, - "required": true, - "description": "The id of the stream" - }, - { - "in": "query", - "name": "subsample", - "schema": { - "type": "integer" - }, - "description": "Subsample result down to return only the provided number of samples" - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "text/plain": { - "schema": { - "type": "string" - }, - "examples": { - "someOfLoudness": { - "value": "-40.0\n-40.0\n-40.0\n-36.9\n-25.3\n-22.9\n-21.5\n-20.6\n" - } - } - } - } - }, - "403": { - "description": "The media is not accessible to the user", - "content": { - "text/html": { - "examples": { - "forbidden": { - "summary": "Forbidden", - "value": "Forbidden

403 Forbidden

" - } - } - } - } - }, - "404": { - "description": "The stream doesn't exist, or the loudness feature is not available on this PMS", - "content": { - "text/html": { - "examples": { - "notFound": { - "summary": "Not Found", - "value": "Not Found

404 Not Found

" - } - } - } - } - } - } - } - }, - "/library/streams/{streamId}/levels": { - "get": { - "tags": [ - "Library" - ], - "operationId": "libraryGetStreamsStreamLevels", - "summary": "Get loudness about a stream in json", - "description": "The the loudness of a stream in db, one entry per 100ms", - "parameters": [ - { - "in": "path", - "name": "streamId", - "schema": { - "type": "integer" - }, - "required": true, - "description": "The id of the stream" - }, - { - "in": "query", - "name": "subsample", - "schema": { - "type": "integer" - }, - "description": "Subsample result down to return only the provided number of samples" - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "MediaContainer": { - "allOf": [ - { - "$ref": "#/components/schemas/MediaContainer" - }, - { - "type": "object", - "properties": { - "totalSamples": { - "type": "string", - "description": "The total number of samples (as a string)" - }, - "Level": { - "type": "array", - "items": { - "type": "object", - "properties": { - "v": { - "type": "number", - "description": "The level in db." - } - } - } - } - } - } - ] - } - } - }, - "examples": { - "someOfLoudness": { - "value": { - "MediaContainer": { - "size": 3215, - "totalSamples": "3215", - "Level": [ - { - "v": -39.9 - }, - { - "v": -39.9 - }, - { - "v": -39.9 - }, - { - "v": -36.8 - }, - { - "v": -25.2 - }, - { - "v": -22.8 - }, - { - "v": -21.4 - }, - { - "v": -20.5 - }, - { - "v": -21.1 - }, - { - "v": -21.5 - }, - { - "v": -21.6 - }, - { - "v": -21.5 - }, - { - "v": -21.6 - }, - { - "v": -20.6 - } - ] - } - } - } - } - } - } - }, - "403": { - "$ref": "#/components/responses/responses-403" - }, - "404": { - "$ref": "#/components/responses/responses-404" - } - } - } - }, - "/library/tags": { - "get": { - "tags": [ - "Library" - ], - "operationId": "libraryGetTags", - "summary": "Get all library tags of a type", - "description": "Get all library tags of a type", - "parameters": [ - { - "in": "query", - "name": "type", - "schema": { - "type": "integer" - }, - "description": "The type of tags to fetch" - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "MediaContainer": { - "allOf": [ - { - "$ref": "#/components/schemas/MediaContainer" - }, - { - "type": "object", - "properties": { - "Directory": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "filter": { - "type": "string", - "description": "The filter string to view metadata wit this tag" - }, - "tag": { - "type": "string", - "description": "The name of the tag" - }, - "tagType": { - "type": "integer", - "description": "The type of the tag" - }, - "tagKey": { - "type": "string", - "description": "The key of this tag. This is a universal key across all PMS instances and plex.tv services" - }, - "thumb": { - "type": "string", - "description": "The URL to a thumbnail for this tag" - } - } - } - } - } - } - ] - } - } - }, - "examples": { - "someOfLoudness": { - "value": { - "MediaContainer": { - "size": 2274, - "identifier": "com.plexapp.plugins.library", - "mediaTagPrefix": "/system/bundle/media/flags/", - "mediaTagVersion": 1680272530, - "Directory": [ - { - "id": 15, - "filter": "actor=15", - "tag": "Thomas Sadoski", - "tagType": 6, - "tagKey": "5d77683785719b001f3a4386", - "thumb": "https://metadata-static.plex.tv/people/5d77683785719b001f3a4386.jpg" - }, - { - "id": 184, - "filter": "actor=184", - "tag": "Julianne Moore", - "tagType": 6, - "tagKey": "5d7768256f4521001ea989be", - "thumb": "https://metadata-static.plex.tv/6/people/6ff18839116b7c7d4eb3cb4a5d401943.jpg" - }, - { - "id": 267, - "filter": "actor=267", - "tag": "Dwayne Johnson", - "tagType": 6, - "tagKey": "5d77682b6f4521001ea99f61", - "thumb": "https://metadata-static.plex.tv/1/people/117e6b9ffe277568350e4c5c552f7800.jpg" - } - ] - } - } - } - } - } - } - } - } - } - }, - "/livetv/dvrs": { - "get": { - "tags": [ - "DVRs" - ], - "summary": "Get DVRs", - "description": "Get the list of all available DVRs", - "operationId": "livetvDvrGetSlash", - "responses": { - "200": { - "description": "OK", - "headers": { - "X-Plex-Container-Total-Size": { - "description": "Provided on all MediaContainer objects indicating the total size of objects available", - "schema": { - "type": "integer" - } - }, - "X-Plex-Container-Start": { - "description": "Provided on all MediaContainer objects indicating the offset of where this container page starts", - "schema": { - "type": "integer" - } - } - }, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "MediaContainer": { - "allOf": [ - { - "$ref": "#/components/schemas/mediaContainerWithStatus_properties-MediaContainer" - }, - { - "type": "object", - "properties": { - "DVR": { - "type": "array", - "items": { - "type": "object", - "properties": { - "key": { - "type": "string" - }, - "language": { - "type": "string" - }, - "lineup": { - "type": "string" - }, - "uuid": { - "type": "string" - }, - "Device": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Device-items" - } - } - } - } - } - } - } - ] - } - } - }, - "examples": { - "simple": { - "value": { - "MediaContainer": { - "size": 1, - "Dvr": [ - { - "key": "28", - "language": "eng", - "lineup": "lineup://tv.plex.providers.epg.onconnect/USA-HI51418-X", - "uuid": "811e2e8a-f98f-4d1f-a26a-8bc26e4999a7" - }, - { - "key": "17", - "lastSeenAt": "1463297728", - "make": "Silicondust", - "model": "HDHomeRun EXTEND", - "modelNumber": "HDTC-2US", - "protocol": "livetv", - "sources": "0,1", - "state": "1", - "status": "1", - "tuners": "2", - "uri": "http://10.0.0.42", - "uuid": "device://tv.plex.grabbers.hdhomerun/1053C0CA" - } - ], - "ChannelMapping": [ - { - "channelKey": "5cc83d73af4a72001e9b16d7-5cab3c634df507001fefcad0", - "deviceIdentifier": "46.3", - "enabled": "1", - "lineupIdentifier": "002" - }, - { - "channelKey": "5cc83d73af4a72001e9b16d7-5cab3d20d30eca001db32922", - "deviceIdentifier": "48.1", - "enabled": "1", - "lineupIdentifier": "009" - } - ] - } - } - } - } - } - } - } - } - }, - "post": { - "tags": [ - "DVRs" - ], - "security": [ - { - "user_token": [ - "admin" - ] - } - ], - "summary": "Create a DVR", - "description": "Creation of a DVR, after creation of a devcie and a lineup is selected", - "operationId": "livetvDvrPostSlash", - "parameters": [ - { - "in": "query", - "name": "lineup", - "schema": { - "type": "string" - }, - "example": "lineup://tv.plex.providers.epg.onconnect/USA-HI51418-DEFAULT", - "description": "The EPG lineup." - }, - { - "in": "query", - "name": "device", - "schema": { - "type": "array", - "items": { - "type": "string" - } - }, - "example": "device[]=device://tv.plex.grabbers.hdhomerun/1053C0CA", - "description": "The device." - }, - { - "in": "query", - "name": "language", - "schema": { - "type": "string" - }, - "example": "eng", - "description": "The language." - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/dvrRequestHandler_slash-get-responses-200" - } - } - } - }, - "/livetv/dvrs/{dvrId}": { - "get": { - "tags": [ - "DVRs" - ], - "summary": "Get a single DVR", - "description": "Get a single DVR by its id (key)", - "operationId": "livetvDvrGetDVR", - "parameters": [ - { - "in": "path", - "name": "dvrId", - "schema": { - "type": "integer" - }, - "description": "The ID of the DVR.", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "headers": { - "X-Plex-Container-Total-Size": { - "description": "Provided on all MediaContainer objects indicating the total size of objects available", - "schema": { - "type": "integer" - } - }, - "X-Plex-Container-Start": { - "description": "Provided on all MediaContainer objects indicating the offset of where this container page starts", - "schema": { - "type": "integer" - } - } - }, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "MediaContainer": { - "allOf": [ - { - "$ref": "#/components/schemas/mediaContainerWithStatus_properties-MediaContainer" - }, - { - "type": "object", - "properties": { - "DVR": { - "type": "array", - "items": { - "type": "object", - "properties": { - "key": { - "type": "string" - }, - "language": { - "type": "string" - }, - "lineup": { - "type": "string" - }, - "uuid": { - "type": "string" - }, - "Device": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Device-items" - } - } - } - } - } - } - } - ] - } - } - }, - "examples": { - "simple": { - "value": { - "MediaContainer": { - "size": 1, - "Dvr": [ - { - "key": "28", - "language": "eng", - "lineup": "lineup://tv.plex.providers.epg.onconnect/USA-HI51418-X", - "uuid": "811e2e8a-f98f-4d1f-a26a-8bc26e4999a7" - }, - { - "key": "17", - "lastSeenAt": "1463297728", - "make": "Silicondust", - "model": "HDHomeRun EXTEND", - "modelNumber": "HDTC-2US", - "protocol": "livetv", - "sources": "0,1", - "state": "1", - "status": "1", - "tuners": "2", - "uri": "http://10.0.0.42", - "uuid": "device://tv.plex.grabbers.hdhomerun/1053C0CA" - } - ], - "ChannelMapping": [ - { - "channelKey": "5cc83d73af4a72001e9b16d7-5cab3c634df507001fefcad0", - "deviceIdentifier": "46.3", - "enabled": "1", - "lineupIdentifier": "002" - }, - { - "channelKey": "5cc83d73af4a72001e9b16d7-5cab3d20d30eca001db32922", - "deviceIdentifier": "48.1", - "enabled": "1", - "lineupIdentifier": "009" - } - ] - } - } - } - } - } - } - } - } - }, - "delete": { - "tags": [ - "DVRs" - ], - "security": [ - { - "user_token": [ - "admin" - ] - } - ], - "summary": "Delete a single DVR", - "description": "Delete a single DVR by its id (key)", - "operationId": "livetvDvrDeleteDVR", - "parameters": [ - { - "in": "path", - "name": "dvrId", - "schema": { - "type": "integer" - }, - "description": "The ID of the DVR.", - "required": true - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/200" - } - } - } - }, - "/livetv/dvrs/{dvrId}/channels/{channel}/tune": { - "post": { - "tags": [ - "DVRs" - ], - "summary": "Tune a channel on a DVR", - "description": "Tune a channel on a DVR to the provided channel", - "operationId": "livetvDvrPostChannelsChannelTune", - "parameters": [ - { - "in": "path", - "name": "dvrId", - "schema": { - "type": "integer" - }, - "description": "The ID of the DVR.", - "required": true - }, - { - "in": "path", - "name": "channel", - "schema": { - "type": "string" - }, - "example": "2.1", - "description": "The channel ID to tune", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "headers": { - "X-Plex-Container-Total-Size": { - "description": "Provided on all MediaContainer objects indicating the total size of objects available", - "schema": { - "type": "integer" - } - }, - "X-Plex-Container-Start": { - "description": "Provided on all MediaContainer objects indicating the offset of where this container page starts", - "schema": { - "type": "integer" - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/mediaContainerWithMetadata" - }, - "examples": { - "channelMetadata": { - "value": { - "MediaContainer": { - "size": 1, - "Metadata": [ - { - "genuineMediaAnalysis": true, - "live": true, - "Media": [ - { - "uuid": "dc30f95e-6379-44f7-8168-172ffc820496", - "Part": [ - { - "Stream": [ - { - "codec": "h264", - "frameRate": 29.97, - "height": 1080, - "index": 0, - "level": 40, - "pixelAspectRatio": "1:1", - "profile": "high", - "scanType": "interlaced", - "streamType": 1, - "width": 1920 - }, - { - "audioChannelLayout": "5.1(side)", - "channels": 6, - "codec": "ac3", - "index": 1, - "samplingRate": 48000, - "streamType": 2 - } - ] - } - ] - } - ] - } - ] - } - } - } - } - } - } - }, - "500": { - "description": "Tuning failed", - "content": { - "text/html": { - "examples": { - "badParam": { - "summary": "Processing failed inside the server", - "value": "Internal Server Error

500 Internal Server Error

" - } - } - } - } - } - } - } - }, - "/livetv/dvrs/{dvrId}/devices/{deviceId}": { - "put": { - "tags": [ - "DVRs" - ], - "security": [ - { - "user_token": [ - "admin" - ] - } - ], - "summary": "Add a device to an existing DVR", - "description": "Add a device to an existing DVR", - "operationId": "livetvDvrPutDvrDevice", - "parameters": [ - { - "in": "path", - "name": "dvrId", - "schema": { - "type": "integer" - }, - "description": "The ID of the DVR.", - "required": true - }, - { - "in": "path", - "name": "deviceId", - "schema": { - "type": "integer" - }, - "description": "The ID of the device to add.", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "headers": { - "X-Plex-Container-Total-Size": { - "description": "Provided on all MediaContainer objects indicating the total size of objects available", - "schema": { - "type": "integer" - } - }, - "X-Plex-Container-Start": { - "description": "Provided on all MediaContainer objects indicating the offset of where this container page starts", - "schema": { - "type": "integer" - } - } - }, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "MediaContainer": { - "allOf": [ - { - "$ref": "#/components/schemas/mediaContainerWithStatus_properties-MediaContainer" - }, - { - "type": "object", - "properties": { - "DVR": { - "type": "array", - "items": { - "type": "object", - "properties": { - "key": { - "type": "string" - }, - "language": { - "type": "string" - }, - "lineup": { - "type": "string" - }, - "uuid": { - "type": "string" - }, - "Device": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Device-items" - } - } - } - } - } - } - } - ] - } - } - }, - "examples": { - "simple": { - "value": { - "MediaContainer": { - "size": 1, - "Dvr": [ - { - "key": "28", - "language": "eng", - "lineup": "lineup://tv.plex.providers.epg.onconnect/USA-HI51418-X", - "uuid": "811e2e8a-f98f-4d1f-a26a-8bc26e4999a7" - }, - { - "key": "17", - "lastSeenAt": "1463297728", - "make": "Silicondust", - "model": "HDHomeRun EXTEND", - "modelNumber": "HDTC-2US", - "protocol": "livetv", - "sources": "0,1", - "state": "1", - "status": "1", - "tuners": "2", - "uri": "http://10.0.0.42", - "uuid": "device://tv.plex.grabbers.hdhomerun/1053C0CA" - } - ], - "ChannelMapping": [ - { - "channelKey": "5cc83d73af4a72001e9b16d7-5cab3c634df507001fefcad0", - "deviceIdentifier": "46.3", - "enabled": "1", - "lineupIdentifier": "002" - }, - { - "channelKey": "5cc83d73af4a72001e9b16d7-5cab3d20d30eca001db32922", - "deviceIdentifier": "48.1", - "enabled": "1", - "lineupIdentifier": "009" - } - ] - } - } - } - } - } - } - } - } - }, - "delete": { - "tags": [ - "DVRs" - ], - "security": [ - { - "user_token": [ - "admin" - ] - } - ], - "summary": "Remove a device from an existing DVR", - "description": "Remove a device from an existing DVR", - "operationId": "livetvDvrDeleteDvrDevice", - "parameters": [ - { - "in": "path", - "name": "dvrId", - "schema": { - "type": "integer" - }, - "description": "The ID of the DVR.", - "required": true - }, - { - "in": "path", - "name": "deviceId", - "schema": { - "type": "integer" - }, - "description": "The ID of the device to add.", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "headers": { - "X-Plex-Container-Total-Size": { - "description": "Provided on all MediaContainer objects indicating the total size of objects available", - "schema": { - "type": "integer" - } - }, - "X-Plex-Container-Start": { - "description": "Provided on all MediaContainer objects indicating the offset of where this container page starts", - "schema": { - "type": "integer" - } - } - }, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "MediaContainer": { - "allOf": [ - { - "$ref": "#/components/schemas/mediaContainerWithStatus_properties-MediaContainer" - }, - { - "type": "object", - "properties": { - "DVR": { - "type": "array", - "items": { - "type": "object", - "properties": { - "key": { - "type": "string" - }, - "language": { - "type": "string" - }, - "lineup": { - "type": "string" - }, - "uuid": { - "type": "string" - }, - "Device": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Device-items" - } - } - } - } - } - } - } - ] - } - } - }, - "examples": { - "simple": { - "value": { - "MediaContainer": { - "size": 1, - "Dvr": [ - { - "key": "28", - "language": "eng", - "lineup": "lineup://tv.plex.providers.epg.onconnect/USA-HI51418-X", - "uuid": "811e2e8a-f98f-4d1f-a26a-8bc26e4999a7" - }, - { - "key": "17", - "lastSeenAt": "1463297728", - "make": "Silicondust", - "model": "HDHomeRun EXTEND", - "modelNumber": "HDTC-2US", - "protocol": "livetv", - "sources": "0,1", - "state": "1", - "status": "1", - "tuners": "2", - "uri": "http://10.0.0.42", - "uuid": "device://tv.plex.grabbers.hdhomerun/1053C0CA" - } - ], - "ChannelMapping": [ - { - "channelKey": "5cc83d73af4a72001e9b16d7-5cab3c634df507001fefcad0", - "deviceIdentifier": "46.3", - "enabled": "1", - "lineupIdentifier": "002" - }, - { - "channelKey": "5cc83d73af4a72001e9b16d7-5cab3d20d30eca001db32922", - "deviceIdentifier": "48.1", - "enabled": "1", - "lineupIdentifier": "009" - } - ] - } - } - } - } - } - } - } - } - } - }, - "/livetv/dvrs/{dvrId}/lineups": { - "put": { - "tags": [ - "DVRs" - ], - "security": [ - { - "user_token": [ - "admin" - ] - } - ], - "summary": "Add a DVR Lineup", - "description": "Add a lineup to a DVR device's set of lineups.", - "operationId": "livetvDvrPutLineup", - "parameters": [ - { - "in": "path", - "name": "dvrId", - "schema": { - "type": "integer" - }, - "description": "The ID of the DVR.", - "required": true - }, - { - "in": "query", - "name": "lineup", - "schema": { - "type": "string" - }, - "required": true, - "description": "The lineup to delete" - } - ], - "responses": { - "200": { - "description": "OK", - "headers": { - "X-Plex-Container-Total-Size": { - "description": "Provided on all MediaContainer objects indicating the total size of objects available", - "schema": { - "type": "integer" - } - }, - "X-Plex-Container-Start": { - "description": "Provided on all MediaContainer objects indicating the offset of where this container page starts", - "schema": { - "type": "integer" - } - } - }, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "MediaContainer": { - "allOf": [ - { - "$ref": "#/components/schemas/mediaContainerWithStatus_properties-MediaContainer" - }, - { - "type": "object", - "properties": { - "DVR": { - "type": "array", - "items": { - "type": "object", - "properties": { - "key": { - "type": "string" - }, - "language": { - "type": "string" - }, - "lineup": { - "type": "string" - }, - "uuid": { - "type": "string" - }, - "Device": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Device-items" - } - } - } - } - } - } - } - ] - } - } - }, - "examples": { - "simple": { - "value": { - "MediaContainer": { - "size": 1, - "Dvr": [ - { - "key": "28", - "language": "eng", - "lineup": "lineup://tv.plex.providers.epg.onconnect/USA-HI51418-X", - "uuid": "811e2e8a-f98f-4d1f-a26a-8bc26e4999a7" - }, - { - "key": "17", - "lastSeenAt": "1463297728", - "make": "Silicondust", - "model": "HDHomeRun EXTEND", - "modelNumber": "HDTC-2US", - "protocol": "livetv", - "sources": "0,1", - "state": "1", - "status": "1", - "tuners": "2", - "uri": "http://10.0.0.42", - "uuid": "device://tv.plex.grabbers.hdhomerun/1053C0CA" - } - ], - "ChannelMapping": [ - { - "channelKey": "5cc83d73af4a72001e9b16d7-5cab3c634df507001fefcad0", - "deviceIdentifier": "46.3", - "enabled": "1", - "lineupIdentifier": "002" - }, - { - "channelKey": "5cc83d73af4a72001e9b16d7-5cab3d20d30eca001db32922", - "deviceIdentifier": "48.1", - "enabled": "1", - "lineupIdentifier": "009" - } - ] - } - } - } - } - } - } - } - } - }, - "delete": { - "tags": [ - "DVRs" - ], - "security": [ - { - "user_token": [ - "admin" - ] - } - ], - "summary": "Delete a DVR Lineup", - "description": "Deletes a DVR device's lineup.", - "operationId": "livetvDvrDeleteLineup", - "parameters": [ - { - "in": "path", - "name": "dvrId", - "schema": { - "type": "integer" - }, - "description": "The ID of the DVR.", - "required": true - }, - { - "in": "query", - "name": "lineup", - "schema": { - "type": "string" - }, - "required": true, - "description": "The lineup to delete" - } - ], - "responses": { - "200": { - "description": "OK", - "headers": { - "X-Plex-Container-Total-Size": { - "description": "Provided on all MediaContainer objects indicating the total size of objects available", - "schema": { - "type": "integer" - } - }, - "X-Plex-Container-Start": { - "description": "Provided on all MediaContainer objects indicating the offset of where this container page starts", - "schema": { - "type": "integer" - } - } - }, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "MediaContainer": { - "allOf": [ - { - "$ref": "#/components/schemas/mediaContainerWithStatus_properties-MediaContainer" - }, - { - "type": "object", - "properties": { - "DVR": { - "type": "array", - "items": { - "type": "object", - "properties": { - "key": { - "type": "string" - }, - "language": { - "type": "string" - }, - "lineup": { - "type": "string" - }, - "uuid": { - "type": "string" - }, - "Device": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Device-items" - } - } - } - } - } - } - } - ] - } - } - }, - "examples": { - "simple": { - "value": { - "MediaContainer": { - "size": 1, - "Dvr": [ - { - "key": "28", - "language": "eng", - "lineup": "lineup://tv.plex.providers.epg.onconnect/USA-HI51418-X", - "uuid": "811e2e8a-f98f-4d1f-a26a-8bc26e4999a7" - }, - { - "key": "17", - "lastSeenAt": "1463297728", - "make": "Silicondust", - "model": "HDHomeRun EXTEND", - "modelNumber": "HDTC-2US", - "protocol": "livetv", - "sources": "0,1", - "state": "1", - "status": "1", - "tuners": "2", - "uri": "http://10.0.0.42", - "uuid": "device://tv.plex.grabbers.hdhomerun/1053C0CA" - } - ], - "ChannelMapping": [ - { - "channelKey": "5cc83d73af4a72001e9b16d7-5cab3c634df507001fefcad0", - "deviceIdentifier": "46.3", - "enabled": "1", - "lineupIdentifier": "002" - }, - { - "channelKey": "5cc83d73af4a72001e9b16d7-5cab3d20d30eca001db32922", - "deviceIdentifier": "48.1", - "enabled": "1", - "lineupIdentifier": "009" - } - ] - } - } - } - } - } - } - } - } - } - }, - "/livetv/dvrs/{dvrId}/prefs": { - "put": { - "tags": [ - "DVRs" - ], - "security": [ - { - "user_token": [ - "admin" - ] - } - ], - "summary": "Set DVR preferences", - "description": "Set DVR preferences by name avd value", - "operationId": "livetvDvrPutPrefs", - "parameters": [ - { - "in": "path", - "name": "dvrId", - "schema": { - "type": "integer" - }, - "description": "The ID of the DVR.", - "required": true - }, - { - "in": "query", - "name": "name", - "schema": { - "type": "string" - }, - "description": "Set the `name` preference to the provided value" - } - ], - "responses": { - "200": { - "description": "OK", - "headers": { - "X-Plex-Container-Total-Size": { - "description": "Provided on all MediaContainer objects indicating the total size of objects available", - "schema": { - "type": "integer" - } - }, - "X-Plex-Container-Start": { - "description": "Provided on all MediaContainer objects indicating the offset of where this container page starts", - "schema": { - "type": "integer" - } - } - }, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "MediaContainer": { - "allOf": [ - { - "$ref": "#/components/schemas/mediaContainerWithStatus_properties-MediaContainer" - }, - { - "type": "object", - "properties": { - "DVR": { - "type": "array", - "items": { - "type": "object", - "properties": { - "key": { - "type": "string" - }, - "language": { - "type": "string" - }, - "lineup": { - "type": "string" - }, - "uuid": { - "type": "string" - }, - "Device": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Device-items" - } - } - } - } - } - } - } - ] - } - } - }, - "examples": { - "simple": { - "value": { - "MediaContainer": { - "size": 1, - "Dvr": [ - { - "key": "28", - "language": "eng", - "lineup": "lineup://tv.plex.providers.epg.onconnect/USA-HI51418-X", - "uuid": "811e2e8a-f98f-4d1f-a26a-8bc26e4999a7" - }, - { - "key": "17", - "lastSeenAt": "1463297728", - "make": "Silicondust", - "model": "HDHomeRun EXTEND", - "modelNumber": "HDTC-2US", - "protocol": "livetv", - "sources": "0,1", - "state": "1", - "status": "1", - "tuners": "2", - "uri": "http://10.0.0.42", - "uuid": "device://tv.plex.grabbers.hdhomerun/1053C0CA" - } - ], - "ChannelMapping": [ - { - "channelKey": "5cc83d73af4a72001e9b16d7-5cab3c634df507001fefcad0", - "deviceIdentifier": "46.3", - "enabled": "1", - "lineupIdentifier": "002" - }, - { - "channelKey": "5cc83d73af4a72001e9b16d7-5cab3d20d30eca001db32922", - "deviceIdentifier": "48.1", - "enabled": "1", - "lineupIdentifier": "009" - } - ] - } - } - } - } - } - } - } - } - } - }, - "/livetv/dvrs/{dvrId}/reloadGuide": { - "post": { - "tags": [ - "DVRs" - ], - "security": [ - { - "user_token": [ - "admin" - ] - } - ], - "summary": "Tell a DVR to reload program guide", - "description": "Tell a DVR to reload program guide", - "operationId": "livetvDvrPostReloadGuide", - "parameters": [ - { - "in": "path", - "name": "dvrId", - "schema": { - "type": "integer" - }, - "description": "The ID of the DVR.", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "text/html": { - "examples": { - "ok": { - "summary": "OK", - "value": "" - } - } - } - }, - "headers": { - "X-Plex-Activity": { - "schema": { - "type": "string" - }, - "description": "The activity of the reload process" - } - } - } - } - }, - "delete": { - "tags": [ - "DVRs" - ], - "security": [ - { - "user_token": [ - "admin" - ] - } - ], - "summary": "Tell a DVR to stop reloading program guide", - "description": "Tell a DVR to stop reloading program guide", - "operationId": "livetvDvrDeleteReloadGuide", - "parameters": [ - { - "in": "path", - "name": "dvrId", - "schema": { - "type": "integer" - }, - "description": "The ID of the DVR.", - "required": true - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/200" - } - } - } - }, - "/livetv/epg/channelmap": { - "get": { - "tags": [ - "EPG" - ], - "summary": "Compute the best channel map", - "description": "Compute the best channel map, given device and lineup", - "operationId": "livetvEpgGetChannelmap", - "parameters": [ - { - "in": "query", - "name": "device", - "schema": { - "type": "string" - }, - "required": true, - "description": "The URI describing the device" - }, - { - "in": "query", - "name": "lineup", - "schema": { - "type": "string" - }, - "required": true, - "description": "The URI describing the lineup" - } - ], - "responses": { - "200": { - "description": "OK", - "headers": { - "X-Plex-Container-Total-Size": { - "description": "Provided on all MediaContainer objects indicating the total size of objects available", - "schema": { - "type": "integer" - } - }, - "X-Plex-Container-Start": { - "description": "Provided on all MediaContainer objects indicating the offset of where this container page starts", - "schema": { - "type": "integer" - } - } - }, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "MediaContainer": { - "allOf": [ - { - "$ref": "#/components/schemas/MediaContainer" - }, - { - "type": "object", - "properties": { - "ChannelMapping": { - "type": "array", - "items": { - "type": "object", - "properties": { - "deviceIdentifier": { - "type": "string", - "description": "The channel description on the device" - }, - "favorite": { - "type": "boolean" - }, - "lineupIdentifier": { - "type": "string", - "description": "The channel identifier in the lineup" - }, - "channelKey": { - "type": "string" - } - } - } - } - } - } - ] - } - } - }, - "examples": { - "computedLineup": { - "value": { - "MediaContainer": { - "size": 1, - "ChannelMapping": [ - { - "channelKey": "5cc83d73af4a72001e9b16d7-5cab3c634df507001fefcad0", - "deviceIdentifier": "48.9", - "lineupIdentifier": "103" - } - ] - } - } - } - } - } - } - }, - "404": { - "description": "No device or provider with the identifier was found", - "content": { - "text/html": { - "examples": { - "notFound": { - "summary": "Not Found", - "value": "Not Found

404 Not Found

" - } - } - } - } - }, - "500": { - "description": "Failed to compute channel map", - "content": { - "text/html": { - "examples": { - "badParam": { - "summary": "Processing failed inside the server", - "value": "Internal Server Error

500 Internal Server Error

" - } - } - } - } - } - } - } - }, - "/livetv/epg/channels": { - "get": { - "tags": [ - "EPG" - ], - "summary": "Get channels for a lineup", - "description": "Get channels for a lineup within an EPG provider", - "operationId": "livetvEpgGetChannels", - "parameters": [ - { - "in": "query", - "name": "lineup", - "schema": { - "type": "string" - }, - "required": true, - "description": "The URI describing the lineup" - } - ], - "responses": { - "200": { - "description": "OK", - "headers": { - "X-Plex-Container-Total-Size": { - "description": "Provided on all MediaContainer objects indicating the total size of objects available", - "schema": { - "type": "integer" - } - }, - "X-Plex-Container-Start": { - "description": "Provided on all MediaContainer objects indicating the offset of where this container page starts", - "schema": { - "type": "integer" - } - } - }, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "MediaContainer": { - "allOf": [ - { - "$ref": "#/components/schemas/MediaContainer" - }, - { - "type": "object", - "properties": { - "Channel": { - "type": "array", - "items": { - "$ref": "#/components/schemas/channel" - } - } - } - } - ] - } - } - }, - "examples": { - "someChannels": { - "value": { - "MediaContainer": { - "size": 2, - "Channel": [ - { - "callSign": "BBC1SCO", - "identifier": "001", - "thumb": "http://plex.tmsimg.com/h3/NowShowing/21439/s21439_h3_aa.png" - }, - { - "callSign": "BBC2SCO", - "identifier": "002" - } - ] - } - } - } - } - } - } - }, - "404": { - "description": "No provider with the identifier was found", - "content": { - "text/html": { - "examples": { - "notFound": { - "summary": "Not Found", - "value": "Not Found

404 Not Found

" - } - } - } - } - } - } - } - }, - "/livetv/epg/countries": { - "get": { - "tags": [ - "EPG" - ], - "summary": "Get all countries", - "description": "This endpoint returns a list of countries which EPG data is available for. There are three flavors, as specfied by the `flavor` attribute", - "operationId": "livetvEpgGetCountries", - "responses": { - "200": { - "description": "OK", - "headers": { - "X-Plex-Container-Total-Size": { - "description": "Provided on all MediaContainer objects indicating the total size of objects available", - "schema": { - "type": "integer" - } - }, - "X-Plex-Container-Start": { - "description": "Provided on all MediaContainer objects indicating the offset of where this container page starts", - "schema": { - "type": "integer" - } - } - }, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "MediaContainer": { - "allOf": [ - { - "$ref": "#/components/schemas/MediaContainer" - }, - { - "type": "object", - "properties": { - "Country": { - "type": "array", - "items": { - "type": "object", - "properties": { - "key": { - "type": "string" - }, - "type": { - "type": "string" - }, - "title": { - "type": "string" - }, - "code": { - "type": "string", - "description": "Three letter code" - }, - "language": { - "type": "string", - "description": "Three letter language code" - }, - "languageTitle": { - "type": "string", - "description": "The title of the language" - }, - "example": { - "type": "string" - }, - "flavor": { - "type": "integer", - "enum": [ - 0, - 1, - 2 - ], - "description": "- `0`: The country is divided into regions, and following the key will lead to a list of regions.\n- `1`: The county is divided by postal codes, and an example code is returned in `example`.\n- `2`: The country has a single postal code, returned in `example`.\n" - } - } - } - } - } - } - ] - } - } - }, - "examples": { - "threeCountries": { - "value": { - "MediaContainer": { - "size": 3, - "Country": [ - { - "key": "aia/tv.plex.providers.epg.onconnect/lineups", - "type": "country", - "title": "Anguilla", - "code": "aia", - "language": "eng", - "languageTitle": "English", - "example": "AI-2640", - "flavor": 2 - }, - { - "key": "atg/tv.plex.providers.epg.onconnect/lineups", - "type": "country", - "title": "Antigua and Barbuda", - "code": "atg", - "language": "eng", - "languageTitle": "English", - "example": "AG", - "flavor": 2 - }, - { - "key": "arg/tv.plex.providers.epg.onconnect/lineups", - "type": "country", - "title": "Argentina", - "code": "arg", - "language": "spa", - "languageTitle": "Español", - "example": "A4190", - "flavor": 1 - } - ] - } - } - } - } - } - } - } - } - } - }, - "/livetv/epg/countries/{country}/{epgId}/lineups": { - "get": { - "tags": [ - "EPG" - ], - "summary": "Get lineups for a country via postal code", - "description": "Returns a list of lineups for a given country, EPG provider and postal code", - "operationId": "livetvEpgGetCountriesCountryLineups", - "parameters": [ - { - "in": "path", - "name": "country", - "schema": { - "type": "string" - }, - "description": "3 letter country code", - "required": true - }, - { - "in": "path", - "name": "epgId", - "schema": { - "type": "string" - }, - "description": "The `providerIdentifier` of the provider", - "required": true - }, - { - "in": "query", - "name": "postalCode", - "schema": { - "type": "string" - }, - "description": "The postal code for the lineups to fetch" - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/mediaContainerWithLineup" - }, - "examples": { - "aSetOfLineups": { - "value": { - "MediaContainer": { - "size": 3, - "uuid": "lineup-group://tv.plex.providers.epg.onconnect/aia/AI-2640", - "Lineup": [ - { - "uuid": "lineup://tv.plex.providers.epg.onconnect/AIA-0000040-DEFAULT#Caribbean%20Cable%20Communications%20-%20Anguilla", - "type": "lineup", - "title": "Caribbean Cable Communications - Anguilla", - "lineupType": 1, - "location": "The Valley" - }, - { - "uuid": "lineup://tv.plex.providers.epg.onconnect/AIA-0000040-X#Caribbean%20Cable%20Communications%20-%20Anguilla%20-%20Digital", - "type": "lineup", - "title": "Caribbean Cable Communications - Anguilla - Digital", - "lineupType": 1, - "location": "The Valley" - }, - { - "uuid": "lineup://tv.plex.providers.epg.onconnect/AIA-0002293-X#DirecTV%20Anguilla%20-%20Digital", - "type": "lineup", - "title": "DirecTV Anguilla - Digital", - "lineupType": 1, - "location": "The Valley" - } - ] - } - } - } - } - } - } - }, - "404": { - "description": "No provider with the identifier was found", - "content": { - "text/html": { - "examples": { - "notFound": { - "summary": "Not Found", - "value": "Not Found

404 Not Found

" - } - } - } - } - } - } - } - }, - "/livetv/epg/countries/{country}/{epgId}/regions": { - "get": { - "tags": [ - "EPG" - ], - "summary": "Get regions for a country", - "description": "Get regions for a country within an EPG provider", - "operationId": "livetvEpgGetCountriesCountryRegions", - "parameters": [ - { - "in": "path", - "name": "country", - "schema": { - "type": "string" - }, - "description": "3 letter country code", - "required": true - }, - { - "in": "path", - "name": "epgId", - "schema": { - "type": "string" - }, - "description": "The `providerIdentifier` of the provider", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "headers": { - "X-Plex-Container-Total-Size": { - "description": "Provided on all MediaContainer objects indicating the total size of objects available", - "schema": { - "type": "integer" - } - }, - "X-Plex-Container-Start": { - "description": "Provided on all MediaContainer objects indicating the offset of where this container page starts", - "schema": { - "type": "integer" - } - } - }, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "MediaContainer": { - "allOf": [ - { - "$ref": "#/components/schemas/MediaContainer" - }, - { - "type": "object", - "properties": { - "Country": { - "type": "array", - "items": { - "type": "object", - "properties": { - "key": { - "type": "string" - }, - "type": { - "type": "string" - }, - "title": { - "type": "string" - }, - "national": { - "type": "boolean" - } - } - } - } - } - } - ] - } - } - }, - "examples": { - "someRegions": { - "value": { - "MediaContainer": { - "size": 2, - "Region": [ - { - "key": "132718/lineups", - "type": "region", - "title": "Bruxelles" - }, - { - "key": "116043/lineups", - "type": "region", - "title": "Région wallonne" - } - ] - } - } - } - } - } - } - }, - "404": { - "description": "No provider with the identifier was found", - "content": { - "text/html": { - "examples": { - "notFound": { - "summary": "Not Found", - "value": "Not Found

404 Not Found

" - } - } - } - } - } - } - } - }, - "/livetv/epg/countries/{country}/{epgId}/regions/{region}/lineups": { - "get": { - "tags": [ - "EPG" - ], - "summary": "Get lineups for a region", - "description": "Get lineups for a region within an EPG provider", - "operationId": "livetvEpgGetCountriesCountryRegionsRegionLineups", - "parameters": [ - { - "in": "path", - "name": "country", - "schema": { - "type": "string" - }, - "description": "3 letter country code", - "required": true - }, - { - "in": "path", - "name": "epgId", - "schema": { - "type": "string" - }, - "description": "The `providerIdentifier` of the provider", - "required": true - }, - { - "in": "path", - "name": "region", - "schema": { - "type": "string" - }, - "description": "The region for the lineup", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "headers": { - "X-Plex-Container-Total-Size": { - "description": "Provided on all MediaContainer objects indicating the total size of objects available", - "schema": { - "type": "integer" - } - }, - "X-Plex-Container-Start": { - "description": "Provided on all MediaContainer objects indicating the offset of where this container page starts", - "schema": { - "type": "integer" - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/mediaContainerWithLineup" - }, - "examples": { - "aSetOfLineups": { - "value": { - "MediaContainer": { - "size": 1, - "uuid": "lineup-group://tv.plex.providers.epg.eyeq/bhr/134535", - "Lineup": [ - { - "uuid": "lineup://tv.plex.providers.epg.eyeq/410357488-CE9BE5630D077FE397F3B42E984AC8DD/bhr#OSN", - "type": "lineup", - "title": "OSN", - "lineupType": 2 - } - ] - } - } - } - } - } - } - }, - "404": { - "description": "No provider with the identifier was found", - "content": { - "text/html": { - "examples": { - "notFound": { - "summary": "Not Found", - "value": "Not Found

404 Not Found

" - } - } - } - } - } - } - } - }, - "/livetv/epg/languages": { - "get": { - "tags": [ - "EPG" - ], - "summary": "Get all languages", - "description": "Returns a list of all possible languages for EPG data.", - "operationId": "livetvEpgGetLanguages", - "responses": { - "200": { - "description": "OK", - "headers": { - "X-Plex-Container-Total-Size": { - "description": "Provided on all MediaContainer objects indicating the total size of objects available", - "schema": { - "type": "integer" - } - }, - "X-Plex-Container-Start": { - "description": "Provided on all MediaContainer objects indicating the offset of where this container page starts", - "schema": { - "type": "integer" - } - } - }, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "MediaContainer": { - "allOf": [ - { - "$ref": "#/components/schemas/MediaContainer" - }, - { - "type": "object", - "properties": { - "Language": { - "type": "array", - "items": { - "type": "object", - "properties": { - "code": { - "type": "string", - "description": "3 letter language code" - }, - "title": { - "type": "string" - } - } - } - } - } - } - ] - } - } - }, - "examples": { - "threeLanguages": { - "value": { - "MediaContainer": { - "size": 3, - "Language": [ - { - "code": "aar", - "title": "Afaraf" - }, - { - "code": "abk", - "title": "аҧсуа" - }, - { - "code": "afr", - "title": "Afrikaans" - } - ] - } - } - } - } - } - } - } - } - } - }, - "/livetv/epg/lineup": { - "get": { - "tags": [ - "EPG" - ], - "summary": "Compute the best lineup", - "description": "Compute the best lineup, given lineup group and device", - "operationId": "livetvEpgGetLineup", - "parameters": [ - { - "in": "query", - "name": "device", - "schema": { - "type": "string" - }, - "required": true, - "description": "The URI describing the device" - }, - { - "in": "query", - "name": "lineupGroup", - "schema": { - "type": "string" - }, - "required": true, - "description": "The URI describing the lineupGroup" - } - ], - "responses": { - "200": { - "description": "OK", - "headers": { - "X-Plex-Activity": { - "schema": { - "type": "string" - }, - "description": "The activity of the reload process" - } - } - }, - "404": { - "description": "No device or provider with the identifier was found", - "content": { - "text/html": { - "examples": { - "notFound": { - "summary": "Not Found", - "value": "Not Found

404 Not Found

" - } - } - } - } - }, - "500": { - "description": "Could not get device's channels", - "content": { - "text/html": { - "examples": { - "badParam": { - "summary": "Processing failed inside the server", - "value": "Internal Server Error

500 Internal Server Error

" - } - } - } - } - } - } - } - }, - "/livetv/epg/lineupchannels": { - "get": { - "tags": [ - "EPG" - ], - "summary": "Get the channels for mulitple lineups", - "description": "Get the channels across multiple lineups", - "operationId": "livetvEpgGetLineupchannels", - "parameters": [ - { - "in": "query", - "name": "lineup", - "schema": { - "type": "array", - "items": { - "type": "string" - } - }, - "required": true, - "description": "The URIs describing the lineups" - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "MediaContainer": { - "allOf": [ - { - "$ref": "#/components/schemas/MediaContainer" - }, - { - "type": "object", - "properties": { - "Lineup": { - "type": "array", - "items": { - "allOf": [ - { - "$ref": "#/components/schemas/Lineup-items" - }, - { - "type": "object", - "properties": { - "Channel": { - "type": "array", - "items": { - "$ref": "#/components/schemas/channel" - } - } - } - } - ] - } - } - } - } - ] - } - } - }, - "examples": { - "aChannelSet": { - "description": "A lineup channels", - "value": { - "MediaContainer": { - "size": 1, - "Lineup": [ - { - "uuid": "lineup://tv.plex.providers.epg.cloud/0123456789abcdef01234567", - "type": "lineup", - "lineupType": -1, - "Channel": [ - { - "key": "0123456789abcdef01234567-5cab3c634df507001fefcad0", - "channelVcn": "001", - "title": "WINS", - "callSign": "WINS", - "language": "en" - } - ] - } - ] - } - } - } - } - } - } - }, - "404": { - "description": "No provider with the identifier was found", - "content": { - "text/html": { - "examples": { - "notFound": { - "summary": "Not Found", - "value": "Not Found

404 Not Found

" - } - } - } - } - } - } - } - }, - "/livetv/sessions": { - "get": { - "tags": [ - "Live TV" - ], - "summary": "Get all sessions", - "description": "Get all livetv sessions and metadata", - "operationId": "livetvSessionsGetSlash", - "responses": { - "200": { - "description": "OK", - "headers": { - "X-Plex-Container-Total-Size": { - "description": "Provided on all MediaContainer objects indicating the total size of objects available", - "schema": { - "type": "integer" - } - }, - "X-Plex-Container-Start": { - "description": "Provided on all MediaContainer objects indicating the offset of where this container page starts", - "schema": { - "type": "integer" - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/mediaContainerWithMetadata", - "examples": { - "oneStream": { - "value": { - "MediaContainer": { - "size": 1, - "Metadata": [ - { - "genuineMediaAnalysis": true, - "live": true, - "key": "/livetv/sessions/dc30f95e-6379-44f7-8168-172ffc820496", - "Media": [ - { - "uuid": "dc30f95e-6379-44f7-8168-172ffc820496", - "Part": [ - { - "Stream": [ - { - "codec": "h264", - "frameRate": 29.97, - "height": 1080, - "index": 0, - "level": "40", - "pixelAspectRatio": "1:1", - "profile": "high", - "scanType": "interlaced", - "streamType": 1, - "width": 1920 - }, - { - "audioChannelLayout": "5.1(side)", - "channels": 6, - "codec": "ac3", - "index": 1, - "samplingRate": 48000, - "streamType": 2 - } - ] - } - ] - } - ] - } - ] - } - } - } - } - } - } - } - } - } - } - }, - "/livetv/sessions/{sessionId}": { - "get": { - "tags": [ - "Live TV" - ], - "summary": "Get a single session", - "description": "Get a single livetv session and metadata", - "operationId": "livetvSessionsGetSession", - "parameters": [ - { - "in": "path", - "name": "sessionId", - "schema": { - "type": "string" - }, - "description": "The session id", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "headers": { - "X-Plex-Container-Total-Size": { - "description": "Provided on all MediaContainer objects indicating the total size of objects available", - "schema": { - "type": "integer" - } - }, - "X-Plex-Container-Start": { - "description": "Provided on all MediaContainer objects indicating the offset of where this container page starts", - "schema": { - "type": "integer" - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/mediaContainerWithMetadata", - "examples": { - "oneStream": { - "value": { - "MediaContainer": { - "size": 1, - "Metadata": [ - { - "genuineMediaAnalysis": true, - "live": true, - "key": "/livetv/sessions/dc30f95e-6379-44f7-8168-172ffc820496", - "Media": [ - { - "uuid": "dc30f95e-6379-44f7-8168-172ffc820496", - "Part": [ - { - "Stream": [ - { - "codec": "h264", - "frameRate": 29.97, - "height": 1080, - "index": 0, - "level": "40", - "pixelAspectRatio": "1:1", - "profile": "high", - "scanType": "interlaced", - "streamType": 1, - "width": 1920 - }, - { - "audioChannelLayout": "5.1(side)", - "channels": 6, - "codec": "ac3", - "index": 1, - "samplingRate": 48000, - "streamType": 2 - } - ] - } - ] - } - ] - } - ] - } - } - } - } - } - } - } - } - } - } - }, - "/livetv/sessions/{sessionId}/{consumerId}/index.m3u8": { - "get": { - "tags": [ - "Live TV" - ], - "summary": "Get a session playlist index", - "description": "Get a playlist index for playing this session", - "operationId": "livetvSessionsGetSessionConsumerIndex", - "parameters": [ - { - "in": "path", - "name": "sessionId", - "schema": { - "type": "string" - }, - "description": "The session id", - "required": true - }, - { - "in": "path", - "name": "consumerId", - "schema": { - "type": "string" - }, - "description": "The consumer id", - "required": true - } - ], - "responses": { - "200": { - "description": "Index playlist for playing HLS content", - "content": { - "application/vnd.apple.mpegurl": { - "examples": { - "anIndex": { - "value": "#EXTM3U\n#EXT-X-VERSION:4\n#EXT-X-TARGETDURATION:3\n#EXT-X-MEDIA-SEQUENCE:0\n#EXT-X-PROGRAM-DATE-TIME:2024-05-22T17:18:48.044851000Z\n#EXTINF:0.551044, nodesc\n00000.ts\n#EXTINF:1.001000, nodesc\n00001.ts\n#EXTINF:1.067323, nodesc\n00002.ts\n#EXTINF:1.001000, nodesc\n00003.ts\n" - } - } - } - } - }, - "404": { - "description": "Session or consumer not found" - } - } - } - }, - "/livetv/sessions/{sessionId}/{consumerId}/{segmentId}": { - "get": { - "tags": [ - "Live TV" - ], - "summary": "Get a single session segment", - "description": "Get a single livetv session segment", - "operationId": "livetvSessionsGetSessionConsumerSegment", - "parameters": [ - { - "in": "path", - "name": "sessionId", - "schema": { - "type": "string" - }, - "description": "The session id", - "required": true - }, - { - "in": "path", - "name": "consumerId", - "schema": { - "type": "string" - }, - "description": "The consumer id", - "required": true - }, - { - "in": "path", - "name": "segmentId", - "schema": { - "type": "string" - }, - "description": "The segment id", - "required": true - } - ], - "responses": { - "200": { - "description": "MPEG-TS segment for playing HLS content" - }, - "404": { - "description": "Session, consumer, or segment not found" - } - } - } - }, - "/log": { - "put": { - "tags": [ - "Log" - ], - "security": [ - { - "user_token": [ - "admin" - ] - } - ], - "summary": "Logging a single-line message to the Plex Media Server log", - "description": "This endpoint will write a single-line log message, including a level and source to the main Plex Media Server log.\n\nNote: This endpoint responds to all HTTP verbs **except POST** but PUT is preferred\n", - "operationId": "logPutSlash", - "parameters": [ - { - "in": "query", - "name": "level", - "schema": { - "type": "integer", - "enum": [ - 0, - 1, - 2, - 3, - 4 - ] - }, - "description": "An integer log level to write to the PMS log with.\n - 0: Error\n - 1: Warning\n - 2: Info\n - 3: Debug\n - 4: Verbose\n" - }, - { - "in": "query", - "name": "message", - "schema": { - "type": "string" - }, - "description": "The text of the message to write to the log." - }, - { - "in": "query", - "name": "source", - "schema": { - "type": "string" - }, - "description": "A string indicating the source of the message." - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/200" - } - } - }, - "post": { - "tags": [ - "Log" - ], - "security": [ - { - "user_token": [ - "admin" - ] - } - ], - "summary": "Logging a multi-line message to the Plex Media Server log", - "description": "This endpoint will write multiple lines to the main Plex Media Server log in a single request. It takes a set of query strings as would normally sent to the above PUT endpoint as a linefeed-separated block of POST data. The parameters for each query string match as above.\n", - "operationId": "logPostSlash", - "requestBody": { - "description": "Line separated list of log items", - "content": { - "text/plain": { - "examples": { - "twoLogs": { - "value": "level=3&source=diskless%20client&Things%20are%20OK%20right%20now\nlevel=0&source=diskless%20client&We%20have%20a%20problem\n" - } - } - } - } - }, - "responses": { - "200": { - "$ref": "#/components/responses/200" - }, - "400": { - "$ref": "#/components/responses/400" - } - } - } - }, - "/log/networked": { - "post": { - "tags": [ - "Log" - ], - "security": [ - { - "user_token": [ - "admin" - ] - } - ], - "summary": "Enabling Papertrail", - "description": "This endpoint will enable all Plex Media Serverlogs to be sent to the Papertrail networked logging site for a period of time\n\nNote: This endpoint responds to all HTTP verbs but POST is preferred\n", - "operationId": "logPostPapertrail", - "parameters": [ - { - "in": "query", - "name": "minutes", - "schema": { - "type": "integer" - }, - "description": "The number of minutes logging should be sent to Papertrail" - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/200" - }, - "403": { - "description": "User doesn't have permission", - "content": { - "text/html": { - "examples": { - "forbidden": { - "summary": "Forbidden", - "value": "Forbidden

403 Forbidden

" - } - } - } - } - } - } - } - }, - "/media/grabbers": { - "get": { - "tags": [ - "Devices" - ], - "summary": "Get available grabbers", - "description": "Get available grabbers visible to the server", - "operationId": "mediaGrabberGetSlash", - "parameters": [ - { - "in": "query", - "name": "protocol", - "schema": { - "type": "string" - }, - "example": "livetv", - "description": "Only return grabbers providing this protocol." - } - ], - "responses": { - "200": { - "description": "OK", - "headers": { - "X-Plex-Container-Total-Size": { - "description": "Provided on all MediaContainer objects indicating the total size of objects available", - "schema": { - "type": "integer" - } - }, - "X-Plex-Container-Start": { - "description": "Provided on all MediaContainer objects indicating the offset of where this container page starts", - "schema": { - "type": "integer" - } - } - }, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "MediaContainer": { - "allOf": [ - { - "$ref": "#/components/schemas/MediaContainer" - }, - { - "type": "object", - "properties": { - "MediaGrabber": { - "type": "array", - "items": { - "type": "object", - "properties": { - "title": { - "type": "string" - }, - "identifier": { - "type": "string" - }, - "protocol": { - "type": "string" - } - } - } - } - } - } - ] - } - } - }, - "examples": { - "twoDevices": { - "value": { - "MediaContainer": { - "size": 3, - "MediaGrabber": [ - { - "identifier": "tv.plex.grabbers.hdhomerun", - "protocol": "livetv", - "title": "HDHomerun" - }, - { - "identifier": "tv.plex.grabbers.stream", - "protocol": "stream", - "title": "Stream" - }, - { - "identifier": "tv.plex.grabbers.download", - "protocol": "download", - "title": "Download" - } - ] - } - } - } - } - } - } - } - } - } - }, - "/media/grabbers/devices": { - "get": { - "tags": [ - "Devices" - ], - "security": [ - { - "user_token": [ - "admin" - ] - } - ], - "summary": "Get all devices", - "description": "Get the list of all devices present", - "operationId": "mediaGrabberGetDevices", - "responses": { - "200": { - "description": "OK", - "headers": { - "X-Plex-Container-Total-Size": { - "description": "Provided on all MediaContainer objects indicating the total size of objects available", - "schema": { - "type": "integer" - } - }, - "X-Plex-Container-Start": { - "description": "Provided on all MediaContainer objects indicating the offset of where this container page starts", - "schema": { - "type": "integer" - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/mediaContainerWithDevice" - }, - "examples": { - "twoDevices": { - "value": { - "MediaContainer": { - "size": 2, - "Devices": [ - { - "key": "1053C0CA", - "lastSeenAt": "1461450473", - "make": "Silicondust", - "model": "HDHomeRun EXTEND", - "modelNumber": "HDTC-2US", - "protocol": "livetv", - "tuners": "2", - "sources": "Antenna,Cable", - "uri": "http://10.0.0.164", - "uuid": "1053C0CA" - }, - { - "key": "141007E7", - "lastSeenAt": "1461450479", - "make": "Silicondust", - "model": "HDHomeRun EXPAND", - "modelNumber": "HDHR3-4DC", - "protocol": "livetv", - "tuners": "4", - "sources": "Cable", - "uri": "http://home.techconnect.nl:8822", - "uuid": "141007E7" - } - ] - } - } - } - } - } - } - } - } - }, - "post": { - "tags": [ - "Devices" - ], - "security": [ - { - "user_token": [ - "admin" - ] - } - ], - "summary": "Add a device", - "description": "This endpoint adds a device to an existing grabber. The device is identified, and added to the correct grabber.", - "operationId": "mediaGrabberPostDevices", - "parameters": [ - { - "in": "query", - "name": "uri", - "schema": { - "type": "string" - }, - "example": "http://10.0.0.5", - "description": "The URI of the device." - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/mediaContainerWithDevice" - }, - "examples": { - "addedDevice": { - "value": { - "MediaContainer": { - "size": 1, - "Device": [ - { - "key": "1053C0CA", - "lastSeenAt": 1461450473, - "make": "Silicondust", - "model": "HDHomeRun EXTEND", - "modelNumber": "HDTC-2US", - "protocol": "livetv", - "tuners": "2", - "uri": "http://10.0.0.5", - "uuid": "1053C0CA" - } - ] - } - } - } - } - } - } - }, - "400": { - "$ref": "#/components/responses/400" - } - } - } - }, - "/media/grabbers/devices/discover": { - "post": { - "tags": [ - "Devices" - ], - "security": [ - { - "user_token": [ - "admin" - ] - } - ], - "summary": "Tell grabbers to discover devices", - "description": "Tell grabbers to discover devices", - "operationId": "mediaGrabberPostDeviceDiscover", - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/mediaContainerWithDevice" - }, - "examples": { - "discoveredDevice": { - "value": { - "MediaContainer": { - "size": 1, - "Device": [ - { - "key": "1053C0CA", - "lastSeenAt": 1461450473, - "make": "Silicondust", - "model": "HDHomeRun EXTEND", - "modelNumber": "HDTC-2US", - "protocol": "livetv", - "tuners": "2", - "uri": "http://10.0.0.164", - "uuid": "1053C0CA" - } - ] - } - } - } - } - } - } - } - } - } - }, - "/media/grabbers/devices/{deviceId}": { - "get": { - "tags": [ - "Devices" - ], - "security": [ - { - "user_token": [ - "admin" - ] - } - ], - "summary": "Get device details", - "description": "Get a device's details by its id", - "operationId": "mediaGrabberDevicesDeviceGetSlash", - "parameters": [ - { - "in": "path", - "name": "deviceId", - "schema": { - "type": "integer" - }, - "description": "The ID of the device.", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/mediaContainerWithDevice" - }, - "examples": { - "discoveredDevice": { - "value": { - "MediaContainer": { - "size": 1, - "Device": [ - { - "key": "6", - "lastSeenAt": 1461737032, - "make": "Silicondust", - "model": "HDHomeRun EXPAND", - "modelNumber": "HDHR3-4DC", - "protocol": "livetv", - "sources": "Cable", - "state": "1", - "status": "1", - "tuners": "4", - "uri": "http://home.techconnect.nl:8822", - "uuid": "141007E7", - "ChannelMapping": [ - { - "channelKey": "5cc83d73af4a72001e9b16d7-5cab3c634df507001fefcad0", - "deviceIdentifier": "46.3", - "enabled": "1", - "lineupIdentifier": "002" - }, - { - "channelKey": "5cc83d73af4a72001e9b16d7-5cab3d20d30eca001db32922", - "deviceIdentifier": "48.9", - "enabled": "0", - "lineupIdentifier": "004" - }, - { - "channelKey": "5cc83d73af4a72001e9b16d7-5cab3d07771bb2001ef88f72", - "deviceIdentifier": "49.12", - "enabled": "1", - "lineupIdentifier": "011" - }, - { - "channelKey": "5cc83d73af4a72001e9b16d7-5cab3c63de29da001cf021c2", - "deviceIdentifier": "49.3", - "enabled": "0", - "lineupIdentifier": "008" - }, - { - "channelKey": "5cc83d73af4a72001e9b16d7-5cab3c63e3ef4d001d05ba70", - "deviceIdentifier": "10.4", - "enabled": "1" - } - ] - } - ] - } - } - } - } - } - } - }, - "404": { - "description": "Device not found", - "content": { - "text/html": { - "examples": { - "notFound": { - "summary": "Not Found", - "value": "Not Found

404 Not Found

" - } - } - } - } - } - } - }, - "delete": { - "tags": [ - "Devices" - ], - "security": [ - { - "user_token": [ - "admin" - ] - } - ], - "summary": "Remove a device", - "description": "Remove a devices by its id along with its channel mappings", - "operationId": "mediaGrabberDevicesDeviceDeleteSlash", - "parameters": [ - { - "in": "path", - "name": "deviceId", - "schema": { - "type": "integer" - }, - "description": "The ID of the device.", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "headers": { - "X-Plex-Container-Total-Size": { - "description": "Provided on all MediaContainer objects indicating the total size of objects available", - "schema": { - "type": "integer" - } - }, - "X-Plex-Container-Start": { - "description": "Provided on all MediaContainer objects indicating the offset of where this container page starts", - "schema": { - "type": "integer" - } - } - }, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "MediaContainer": { - "allOf": [ - { - "$ref": "#/components/schemas/MediaContainer" - }, - { - "type": "object", - "properties": { - "message": { - "type": "string" - }, - "status": { - "type": "integer" - } - } - } - ] - } - } - }, - "examples": { - "discoveredDevice": { - "value": { - "MediaContainer": { - "size": 0, - "message": "", - "status": 0 - } - } - } - } - } - } - }, - "404": { - "description": "Device not found", - "content": { - "text/html": { - "examples": { - "notFound": { - "summary": "Not Found", - "value": "Not Found

404 Not Found

" - } - } - } - } - } - } - }, - "put": { - "tags": [ - "Devices" - ], - "security": [ - { - "user_token": [ - "admin" - ] - } - ], - "summary": "Enable or disable a device", - "description": "Enable or disable a device by its id", - "operationId": "mediaGrabberDevicesDevicePutSlash", - "parameters": [ - { - "in": "path", - "name": "deviceId", - "schema": { - "type": "integer" - }, - "description": "The ID of the device.", - "required": true - }, - { - "in": "query", - "name": "enabled", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "description": "Whether to enable the device" - } - ], - "responses": { - "200": { - "description": "OK", - "headers": { - "X-Plex-Container-Total-Size": { - "description": "Provided on all MediaContainer objects indicating the total size of objects available", - "schema": { - "type": "integer" - } - }, - "X-Plex-Container-Start": { - "description": "Provided on all MediaContainer objects indicating the offset of where this container page starts", - "schema": { - "type": "integer" - } - } - }, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "MediaContainer": { - "allOf": [ - { - "$ref": "#/components/schemas/MediaContainer" - }, - { - "type": "object", - "properties": { - "message": { - "type": "string" - }, - "status": { - "type": "integer" - } - } - } - ] - } - } - }, - "examples": { - "discoveredDevice": { - "value": { - "MediaContainer": { - "size": 0, - "message": "", - "status": 0 - } - } - } - } - } - } - }, - "404": { - "description": "Device not found", - "content": { - "text/html": { - "examples": { - "notFound": { - "summary": "Not Found", - "value": "Not Found

404 Not Found

" - } - } - } - } - } - } - } - }, - "/media/grabbers/devices/{deviceId}/channelmap": { - "put": { - "tags": [ - "Devices" - ], - "summary": "Set a device's channel mapping", - "description": "Set a device's channel mapping", - "operationId": "mediaGrabberDevicesDevicePutChannelmap", - "parameters": [ - { - "in": "path", - "name": "deviceId", - "schema": { - "type": "integer" - }, - "description": "The ID of the device.", - "required": true - }, - { - "in": "query", - "name": "channelMapping", - "schema": { - "type": "object" - }, - "style": "deepObject", - "example": { - "46.3": 2, - "48.9": 4 - }, - "description": "The mapping of changes, passed as a map of device channel to lineup VCN." - }, - { - "in": "query", - "name": "channelMappingByKey", - "schema": { - "type": "object" - }, - "style": "deepObject", - "example": { - "46.3": "5cc83d73af4a72001e9b16d7-5cab3c634df507001fefcad0", - "48.9": "5cc83d73af4a72001e9b16d7-5cab3c63ec158a001d32db8d" - }, - "description": "The mapping of changes, passed as a map of device channel to lineup key." - }, - { - "in": "query", - "name": "channelsEnabled", - "schema": { - "type": "array", - "items": { - "type": "string" - } - }, - "example": "46.1,44.1,45.1", - "description": "The channels which are enabled." - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/mediaContainerWithDevice" - }, - "examples": { - "emptyContainer": { - "value": { - "MediaContainer": { - "message": "", - "size": 0, - "status": 0 - } - } - } - } - } - } - } - } - } - }, - "/media/grabbers/devices/{deviceId}/channels": { - "get": { - "tags": [ - "Devices" - ], - "security": [ - { - "user_token": [ - "admin" - ] - } - ], - "summary": "Get a device's channels", - "description": "Get a device's channels by its id", - "operationId": "mediaGrabberDevicesDeviceGetChannels", - "parameters": [ - { - "in": "path", - "name": "deviceId", - "schema": { - "type": "integer" - }, - "description": "The ID of the device.", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "headers": { - "X-Plex-Container-Total-Size": { - "description": "Provided on all MediaContainer objects indicating the total size of objects available", - "schema": { - "type": "integer" - } - }, - "X-Plex-Container-Start": { - "description": "Provided on all MediaContainer objects indicating the offset of where this container page starts", - "schema": { - "type": "integer" - } - } - }, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "MediaContainer": { - "allOf": [ - { - "$ref": "#/components/schemas/MediaContainer" - }, - { - "type": "object", - "properties": { - "DeviceChannel": { - "type": "array", - "items": { - "type": "object", - "properties": { - "key": { - "type": "string" - }, - "identifier": { - "type": "string" - }, - "name": { - "type": "string" - }, - "drm": { - "type": "boolean", - "description": "Indicates the channel is DRMed and thus may not be playable" - }, - "hd": { - "type": "boolean" - }, - "favorite": { - "type": "boolean" - }, - "signalStrength": { - "type": "integer" - }, - "signalQuality": { - "type": "integer" - } - } - } - } - } - } - ] - } - } - }, - "examples": { - "discoveredDevice": { - "value": { - "MediaContainer": { - "size": 48, - "DeviceChannel": [ - { - "drm": false, - "hd": false, - "identifier": "46.1", - "name": "KPXO HD" - }, - { - "drm": false, - "hd": false, - "identifier": "46.3", - "name": "KHON HD" - } - ] - } - } - } - } - } - } - }, - "404": { - "description": "Device not found", - "content": { - "text/html": { - "examples": { - "notFound": { - "summary": "Not Found", - "value": "Not Found

404 Not Found

" - } - } - } - } - } - } - } - }, - "/media/grabbers/devices/{deviceId}/prefs": { - "put": { - "tags": [ - "Devices" - ], - "security": [ - { - "user_token": [ - "admin" - ] - } - ], - "summary": "Set device preferences", - "description": "Set device preferences by its id", - "operationId": "mediaGrabberDevicesDevicePutPrefs", - "parameters": [ - { - "in": "path", - "name": "deviceId", - "schema": { - "type": "integer" - }, - "description": "The ID of the device.", - "required": true - }, - { - "in": "query", - "name": "name", - "schema": { - "type": "string" - }, - "description": "The preference names and values." - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/200" - } - } - } - }, - "/media/grabbers/devices/{deviceId}/scan": { - "post": { - "tags": [ - "Devices" - ], - "summary": "Tell a device to scan for channels", - "description": "Tell a device to scan for channels", - "operationId": "mediaGrabberDevicesDevicePostScan", - "parameters": [ - { - "in": "path", - "name": "deviceId", - "schema": { - "type": "integer" - }, - "description": "The ID of the device.", - "required": true - }, - { - "in": "query", - "name": "source", - "schema": { - "type": "string" - }, - "example": "Cable", - "description": "A valid source for the scan" - } - ], - "responses": { - "200": { - "description": "OK", - "headers": { - "X-Plex-Activity": { - "schema": { - "type": "string" - }, - "description": "The activity of the reload process" - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/mediaContainerWithDevice" - }, - "examples": { - "emptyContainer": { - "value": { - "MediaContainer": { - "message": "", - "size": 0, - "status": 0 - } - } - } - } - } - } - } - } - }, - "delete": { - "tags": [ - "Devices" - ], - "summary": "Tell a device to stop scanning for channels", - "description": "Tell a device to stop scanning for channels", - "operationId": "mediaGrabberDeleteDevicesDeviceScan", - "parameters": [ - { - "in": "path", - "name": "deviceId", - "schema": { - "type": "integer" - }, - "description": "The ID of the device.", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/mediaContainerWithDevice" - }, - "examples": { - "emptyContainer": { - "value": { - "MediaContainer": { - "message": "", - "size": 0, - "status": 0 - } - } - } - } - } - } - } - } - } - }, - "/media/grabbers/devices/{deviceId}/thumb/{version}": { - "get": { - "tags": [ - "Devices" - ], - "security": [ - { - "user_token": [ - "admin" - ] - } - ], - "summary": "Get device thumb", - "description": "Get a device's thumb for display to the user", - "operationId": "mediaGrabberDevicesDeviceGetThumbVersion", - "parameters": [ - { - "in": "path", - "name": "deviceId", - "schema": { - "type": "integer" - }, - "description": "The ID of the device.", - "required": true - }, - { - "in": "path", - "name": "version", - "schema": { - "type": "integer" - }, - "description": "A version number of the thumb used for busting cache", - "required": true - } - ], - "responses": { - "200": { - "description": "The thumbnail for the device" - }, - "301": { - "description": "The thumb URL on the device" - }, - "404": { - "description": "No thumb found for this device", - "content": { - "text/html": { - "examples": { - "notFound": { - "summary": "Not Found", - "value": "Not Found

404 Not Found

" - } - } - } - } - } - } - } - }, - "/media/grabbers/operations/{operationId}": { - "delete": { - "tags": [ - "Subscriptions" - ], - "summary": "Cancel an existing grab", - "description": "Cancels an existing media grab (recording). It can be used to resolve a conflict which exists for a rolling subscription.\nNote: This cancellation does not persist across a server restart, but neither does a rolling subscription itself.", - "operationId": "mediaGrabberDeleteOperationsOperation", - "parameters": [ - { - "in": "path", - "name": "operationId", - "schema": { - "type": "string" - }, - "description": "The ID of the operation.", - "required": true - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/200" - }, - "403": { - "description": "User is not owner of the grab and not the admin", - "content": { - "text/html": { - "examples": { - "forbidden": { - "summary": "Forbidden", - "value": "Forbidden

403 Forbidden

" - } - } - } - } - }, - "404": { - "$ref": "#/components/responses/404" - } - } - } - }, - "/media/providers": { - "get": { - "tags": [ - "Provider" - ], - "operationId": "getMediaProviders", - "summary": "Get the list of available media providers", - "description": "Get the list of all available media providers for this PMS. This will generally include the library provider and possibly EPG if DVR is set up.", - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "MediaContainer": { - "allOf": [ - { - "$ref": "#/components/schemas/serverConfiguration" - }, - { - "type": "object", - "properties": { - "identifier": { - "type": "string", - "description": "A unique identifier for the provider, e.g. `com.plexapp.plugins.library`." - }, - "title": { - "type": "string", - "description": "The title of the provider." - }, - "types": { - "type": "string", - "description": "This attribute contains a comma-separated list of the media types exposed by the provider (e.g. `video, audio`)." - }, - "protocols": { - "type": "string", - "description": "A comma-separated list of default protocols for the provider, which can be:\n- `stream`: The provider allows streaming media directly from the provider (e.g. for Vimeo). - `download`: The provider allows downloading media for offline storage, sync, etc. (e.g. Podcasts). - `livetv`: The provider provides live content which is only available on a schedule basis." - }, - "Feature": { - "type": "array", - "items": { - "type": "object", - "properties": { - "key": { - "type": "string" - }, - "type": { - "type": "string" - }, - "Directory": { - "type": "array", - "items": { - "$ref": "#/components/schemas/directory" - } - } - } - } - } - } - } - ] - } - } - }, - "examples": { - "providerWithEpg": { - "summary": "Media providers including EPG", - "value": { - "MediaContainer": { - "size": 2, - "allowCameraUpload": true, - "allowChannelAccess": true, - "allowMediaDeletion": true, - "allowSharing": true, - "allowSync": true, - "allowTuners": true, - "backgroundProcessing": true, - "certificate": true, - "companionProxy": true, - "countryCode": "usa", - "diagnostics": "logs,databases,streaminglogs", - "eventStream": true, - "friendlyName": "Server Name", - "hubSearch": true, - "itemClusters": true, - "livetv": 7, - "machineIdentifier": "c997cf82c4158cb986ccc0e8f829a6f5d5086a63", - "mediaProviders": true, - "multiuser": true, - "musicAnalysis": 2, - "myPlex": true, - "myPlexMappingState": "mapped", - "myPlexSigninState": "ok", - "myPlexSubscription": true, - "myPlexUsername": "me@somewhere.else", - "offlineTranscode": 1, - "ownerFeatures": "adaptive_bitrate,advanced-playback-settings,camera_upload,collections,content_filter,download_certificates,dvr,federated-auth,hardware_transcoding,home,hwtranscode,item_clusters,kevin-bacon,livetv,loudness,lyrics,music-analysis,music_videos,pass,photosV6-edit,photosV6-tv-albums,premium_music_metadata,radio,session_bandwidth_restrictions,session_kick,shared-radio,sync,trailers,tuner-sharing,type-first,ump-matching-pref,unsupportedtuners,webhooks", - "platform": "MacOSX", - "platformVersion": "14.4.1", - "pluginHost": true, - "pushNotifications": false, - "readOnlyLibraries": false, - "streamingBrainABRVersion": 3, - "streamingBrainVersion": 2, - "sync": true, - "transcoderActiveVideoSessions": 0, - "transcoderAudio": true, - "transcoderLyrics": true, - "transcoderPhoto": true, - "transcoderSubtitles": true, - "transcoderVideo": true, - "transcoderVideoBitrates": "64,96,208,320,720,1500,2000,3000,4000,8000,10000,12000,20000", - "transcoderVideoQualities": "0,1,2,3,4,5,6,7,8,9,10,11,12", - "transcoderVideoResolutions": "128,128,160,240,320,480,768,720,720,1080,1080,1080,1080", - "updatedAt": 1714653009, - "updater": true, - "version": "1.40.2.8395-c67dce28e", - "voiceSearch": true, - "MediaProvider": [ - { - "identifier": "com.plexapp.plugins.library", - "title": "Library", - "types": "video,audio,photo", - "protocols": "stream,download", - "Feature": [ - { - "key": "/library/sections", - "type": "content", - "Directory": [ - { - "hubKey": "/hubs", - "title": "Home" - }, - { - "agent": "tv.plex.agents.movie", - "language": "en-US", - "refreshing": false, - "scanner": "Plex Movie", - "uuid": "82503060-0d68-4603-b594-8b071d54819e", - "id": "1", - "key": "/library/sections/1", - "hubKey": "/hubs/sections/1", - "type": "movie", - "title": "Movies", - "updatedAt": 1689270983, - "scannedAt": 1706626696, - "Pivot": [ - { - "id": "recommended", - "key": "/hubs/sections/1", - "type": "hub", - "title": "Recommended", - "context": "content.discover", - "symbol": "star" - }, - { - "id": "library", - "key": "/library/sections/1/all?type=1", - "type": "list", - "title": "Library", - "context": "content.library", - "symbol": "library" - }, - { - "id": "collections", - "key": "/library/sections/1/collections", - "type": "list", - "title": "Collections", - "context": "content.collections", - "symbol": "stack" - }, - { - "id": "categories", - "key": "/library/sections/1/categories", - "type": "list", - "title": "Categories", - "context": "content.categories", - "symbol": "stack" - } - ] - }, - { - "agent": "tv.plex.agents.series", - "language": "en-US", - "refreshing": false, - "scanner": "Plex TV Series", - "uuid": "3a85ee1d-238c-4af9-88ee-45fc634fc478", - "id": "2", - "key": "/library/sections/2", - "hubKey": "/hubs/sections/2", - "type": "show", - "title": "TV Shows", - "updatedAt": 1682628353, - "scannedAt": 1714485942, - "Pivot": [ - { - "id": "recommended", - "key": "/hubs/sections/2", - "type": "hub", - "title": "Recommended", - "context": "content.discover", - "symbol": "star" - }, - { - "id": "library", - "key": "/library/sections/2/all?type=2", - "type": "list", - "title": "Library", - "context": "content.library", - "symbol": "library" - }, - { - "id": "categories", - "key": "/library/sections/2/categories", - "type": "list", - "title": "Categories", - "context": "content.categories", - "symbol": "stack" - } - ] - }, - { - "agent": "tv.plex.agents.music", - "language": "en-US", - "refreshing": false, - "scanner": "Plex Music", - "uuid": "d7fd8c81-a345-4e68-8113-92f23cb47e70", - "id": "3", - "key": "/library/sections/3", - "hubKey": "/hubs/sections/3", - "type": "artist", - "title": "Music", - "updatedAt": 1691606667, - "scannedAt": 1690487664, - "Pivot": [ - { - "id": "recommended", - "key": "/hubs/sections/3", - "type": "hub", - "title": "Recommended", - "context": "content.discover", - "symbol": "star" - }, - { - "id": "library", - "key": "/library/sections/3/all?type=8", - "type": "list", - "title": "Library", - "context": "content.library", - "symbol": "library" - }, - { - "id": "playlists", - "key": "/playlists?type=15§ionID=3&playlistType=audio", - "type": "list", - "title": "Playlists", - "context": "content.playlists", - "symbol": "playlist" - } - ] - }, - { - "id": "playlists", - "key": "/playlists", - "type": "playlist", - "title": "Playlists", - "Pivot": [ - { - "id": "playlists.audio", - "key": "/playlists?playlistType=audio", - "type": "list", - "title": "Music", - "context": "content.playlists.music", - "symbol": "playlist" - }, - { - "id": "playlists.video", - "key": "/playlists?playlistType=video", - "type": "list", - "title": "Video", - "context": "content.playlists.video", - "symbol": "playlist" - } - ] - } - ] - }, - { - "key": "/hubs/search", - "type": "search" - }, - { - "key": "/library/matches", - "type": "match" - }, - { - "key": "/library/metadata", - "type": "metadata" - }, - { - "key": "/:/rate", - "type": "rate" - }, - { - "key": "/photo/:/transcode", - "type": "imagetranscoder" - }, - { - "key": "/hubs/promoted", - "type": "promoted" - }, - { - "key": "/hubs/continueWatching", - "type": "continuewatching" - }, - { - "key": "/actions", - "type": "actions", - "Action": [ - { - "id": "removeFromContinueWatching", - "key": "/actions/removeFromContinueWatching" - } - ] - }, - { - "flavor": "universal", - "key": "/playlists", - "type": "playlist" - }, - { - "flavor": "universal", - "key": "/playQueues", - "type": "playqueue" - }, - { - "key": "/library/collections", - "type": "collection" - }, - { - "scrobbleKey": "/:/scrobble", - "unscrobbleKey": "/:/unscrobble", - "key": "/:/timeline", - "type": "timeline" - }, - { - "type": "manage" - }, - { - "type": "queryParser" - }, - { - "flavor": "download", - "type": "subscribe" - } - ] - }, - { - "id": 13, - "parentID": 12, - "identifier": "tv.plex.providers.epg.cloud:12", - "providerIdentifier": "tv.plex.providers.epg.cloud", - "title": "Live TV & DVR", - "types": "video", - "protocols": "livetv", - "epgSource": "Gracenote", - "friendlyName": "My Server", - "Feature": [ - { - "key": "/tv.plex.providers.epg.cloud:12/sections", - "type": "content", - "Directory": [ - { - "id": "tv.plex.providers.epg.cloud:12", - "hubKey": "/tv.plex.providers.epg.cloud:12/hubs/discover", - "title": "Live TV & DVR", - "Pivot": [ - { - "id": "dvr.whatson", - "key": "/tv.plex.providers.epg.cloud:12/hubs/discover", - "type": "hub", - "title": "What's On", - "context": "content.dvr.discover", - "symbol": "star" - }, - { - "id": "dvr.guide", - "key": "view://dvr/guide", - "type": "view", - "title": "Guide", - "context": "content.dvr.guide", - "symbol": "guide" - }, - { - "id": "dvr.schedule", - "key": "view://dvr/recording-schedule", - "type": "view", - "title": "DVR Schedule", - "context": "content.dvr.schedule", - "symbol": "schedule" - }, - { - "id": "dvr.priority", - "key": "view://dvr/recording-priority", - "type": "view", - "title": "Recording Priority", - "context": "content.dvr.priority", - "symbol": "list" - }, - { - "id": "dvr.browse", - "key": "/tv.plex.providers.epg.cloud:12/sections/2/all?type=4", - "type": "list", - "title": "Browse", - "context": "content.dvr.browse", - "symbol": "library" - } - ] - }, - { - "type": "mixed", - "key": "/tv.plex.providers.epg.cloud:12/watchnow", - "title": "Guide", - "icon": "/:/resources/dvr/dvr-watchnow-icon.png" - }, - { - "key": "/tv.plex.providers.epg.cloud:12/sections/1", - "type": "movie", - "title": "Movies", - "icon": "/:/resources/dvr/dvr-movies-icon.png", - "updatedAt": "171458958\"" - }, - { - "key": "/tv.plex.providers.epg.cloud:12/sections/2", - "type": "show", - "title": "Shows", - "icon": "/:/resources/dvr/dvr-allshows-icon.png", - "updatedAt": "171458958\"" - }, - { - "key": "/tv.plex.providers.epg.cloud:12/sections/3", - "type": "show", - "title": "Sports", - "icon": "/:/resources/dvr/dvr-sports-icon.png", - "updatedAt": "171458958\"" - } - ] - }, - { - "key": "/tv.plex.providers.epg.cloud:12/hubs/search", - "type": "search" - }, - { - "key": "/tv.plex.providers.epg.cloud:12/matches", - "type": "match" - }, - { - "key": "/tv.plex.providers.epg.cloud:12/metadata", - "type": "metadata" - }, - { - "key": "/photo/:/transcode", - "type": "imagetranscoder" - }, - { - "key": "/tv.plex.providers.epg.cloud:12/hubs/discover?promoted=1&includeTypeFirst=1", - "type": "promoted" - }, - { - "key": "/tv.plex.providers.epg.cloud:12/grid", - "type": "grid", - "GridChannelFilter": [ - { - "key": "/tv.plex.providers.epg.cloud:12/lineups/dvr/channels?genre=genre_6006cc1a610ee2002c74f34a", - "title": "Hit TV", - "genreRatingKey": "genre_6006cc1a610ee2002c74f34a" - }, - { - "key": "/tv.plex.providers.epg.cloud:12/lineups/dvr/channels?genre=genre_6006cc1a610ee2002c74f33f", - "title": "Crime", - "genreRatingKey": "genre_6006cc1a610ee2002c74f33f" - }, - { - "key": "/tv.plex.providers.epg.cloud:12/lineups/dvr/channels?genre=genre_6006cc1d610ee2002c74f38c", - "title": "Reality", - "genreRatingKey": "genre_6006cc1d610ee2002c74f38c" - }, - { - "key": "/tv.plex.providers.epg.cloud:12/lineups/dvr/channels?genre=genre_6006cc1d610ee2002c74f37a", - "title": "News", - "genreRatingKey": "genre_6006cc1d610ee2002c74f37a" - }, - { - "key": "/tv.plex.providers.epg.cloud:12/lineups/dvr/channels?genre=genre_620143f98578b9238e1cdb8a", - "title": "Sports", - "genreRatingKey": "genre_620143f98578b9238e1cdb8a" - }, - { - "key": "/tv.plex.providers.epg.cloud:12/lineups/dvr/channels?genre=genre_6006cc1b610ee2002c74f358", - "title": "Game Shows", - "genreRatingKey": "genre_6006cc1b610ee2002c74f358" - }, - { - "key": "/tv.plex.providers.epg.cloud:12/lineups/dvr/channels?genre=genre_6529021f0f3f142ea6049ecc", - "title": "History & Science", - "genreRatingKey": "genre_6529021f0f3f142ea6049ecc" - }, - { - "key": "/tv.plex.providers.epg.cloud:12/lineups/dvr/channels?genre=genre_6006cc18610ee2002c74f2f6", - "title": "Sci-Fi & Action", - "genreRatingKey": "genre_6006cc18610ee2002c74f2f6" - }, - { - "key": "/tv.plex.providers.epg.cloud:12/lineups/dvr/channels?genre=genre_620143f98578b9238e1cdb89", - "title": "Movies", - "genreRatingKey": "genre_620143f98578b9238e1cdb89" - }, - { - "key": "/tv.plex.providers.epg.cloud:12/lineups/dvr/channels?genre=genre_6006cc18610ee2002c74f308", - "title": "Chills & Thrills", - "genreRatingKey": "genre_6006cc18610ee2002c74f308" - }, - { - "key": "/tv.plex.providers.epg.cloud:12/lineups/dvr/channels?genre=genre_6529021f0f3f142ea6049ecb", - "title": "Classic TV", - "genreRatingKey": "genre_6529021f0f3f142ea6049ecb" - }, - { - "key": "/tv.plex.providers.epg.cloud:12/lineups/dvr/channels?genre=genre_6529021f0f3f142ea6049ecd", - "title": "Nature & Travel", - "genreRatingKey": "genre_6529021f0f3f142ea6049ecd" - }, - { - "key": "/tv.plex.providers.epg.cloud:12/lineups/dvr/channels?genre=genre_6006cc18610ee2002c74f2f9", - "title": "Comedy", - "genreRatingKey": "genre_6006cc18610ee2002c74f2f9" - }, - { - "key": "/tv.plex.providers.epg.cloud:12/lineups/dvr/channels?genre=genre_6529021f0f3f142ea6049ece", - "title": "Black Entertainment", - "genreRatingKey": "genre_6529021f0f3f142ea6049ece" - }, - { - "key": "/tv.plex.providers.epg.cloud:12/lineups/dvr/channels?genre=genre_6006cc1a610ee2002c74f33b", - "title": "Cooking", - "genreRatingKey": "genre_6006cc1a610ee2002c74f33b" - }, - { - "key": "/tv.plex.providers.epg.cloud:12/lineups/dvr/channels?genre=genre_6006cc1b610ee2002c74f361", - "title": "Home", - "genreRatingKey": "genre_6006cc1b610ee2002c74f361" - }, - { - "key": "/tv.plex.providers.epg.cloud:12/lineups/dvr/channels?genre=genre_6074b802507c8d42cf7e1678", - "title": "Kids & Family", - "genreRatingKey": "genre_6074b802507c8d42cf7e1678" - }, - { - "key": "/tv.plex.providers.epg.cloud:12/lineups/dvr/channels?genre=genre_6529021f0f3f142ea6049ed0", - "title": "Sporting", - "genreRatingKey": "genre_6529021f0f3f142ea6049ed0" - }, - { - "key": "/tv.plex.providers.epg.cloud:12/lineups/dvr/channels?genre=genre_6171e01d4c451f5a44debdf6", - "title": "En Español", - "genreRatingKey": "genre_6171e01d4c451f5a44debdf6" - }, - { - "key": "/tv.plex.providers.epg.cloud:12/lineups/dvr/channels?genre=genre_6529021f0f3f142ea6049ecf", - "title": "International", - "genreRatingKey": "genre_6529021f0f3f142ea6049ecf" - }, - { - "key": "/tv.plex.providers.epg.cloud:12/lineups/dvr/channels?genre=genre_60d37385f631b9aabb67bf37", - "title": "Gaming & Anime", - "genreRatingKey": "genre_60d37385f631b9aabb67bf37" - }, - { - "key": "/tv.plex.providers.epg.cloud:12/lineups/dvr/channels?genre=genre_620143f98578b9238e1cdb88", - "title": "Lifestyle", - "genreRatingKey": "genre_620143f98578b9238e1cdb88" - }, - { - "key": "/tv.plex.providers.epg.cloud:12/lineups/dvr/channels?genre=genre_6006cc1c610ee2002c74f378", - "title": "Music", - "genreRatingKey": "genre_6006cc1c610ee2002c74f378" - } - ] - }, - { - "key": "/tv.plex.providers.epg.cloud:12/collections", - "type": "collection" - }, - { - "scrobbleKey": "/:/scrobble", - "unscrobbleKey": "/:/unscrobble", - "key": "/:/timeline", - "type": "timeline" - }, - { - "flavor": "record", - "type": "subscribe" - } - ] - } - ] - } - } - } - } - } - } - } - } - }, - "post": { - "tags": [ - "Provider" - ], - "operationId": "postMediaProviders", - "summary": "Add a media provider", - "description": "This endpoint registers a media provider with the server. Once registered, the media server acts as a reverse proxy to the provider, allowing both local and remote providers to work.", - "parameters": [ - { - "in": "query", - "name": "url", - "required": true, - "schema": { - "type": "string" - }, - "description": "The URL of the media provider to add." - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/200" - }, - "400": { - "$ref": "#/components/responses/400" - } - } - } - }, - "/media/providers/refresh": { - "post": { - "tags": [ - "Provider" - ], - "operationId": "postMediaProvidersRefresh", - "summary": "Refresh media providers", - "description": "Refresh all known media providers. This is useful in case a provider has updated features.", - "responses": { - "200": { - "$ref": "#/components/responses/200" - } - } - } - }, - "/media/providers/{provider}": { - "delete": { - "tags": [ - "Provider" - ], - "operationId": "deleteMediaProvider", - "summary": "Delete a media provider", - "description": "Deletes a media provider with the given id", - "parameters": [ - { - "in": "path", - "name": "provider", - "schema": { - "type": "string" - }, - "description": "The ID of the media provider to delete", - "required": true - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/200" - }, - "400": { - "$ref": "#/components/responses/400" - }, - "403": { - "description": "Cannot delete a provider which is a child of another provider", - "content": { - "text/html": { - "examples": { - "forbidden": { - "summary": "Forbidden", - "value": "Forbidden

403 Forbidden

" - } - } - } - } - } - } - } - }, - "/media/subscriptions": { - "get": { - "tags": [ - "Subscriptions" - ], - "summary": "Get all subscriptions", - "description": "Get all subscriptions and potentially the grabs too", - "operationId": "mediaSubscriptionsGetSlash", - "parameters": [ - { - "in": "query", - "name": "includeGrabs", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "description": "Indicates whether the active grabs should be included as well" - }, - { - "in": "query", - "name": "includeStorage", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "description": "Compute the storage of recorded items desired by this subscription" - } - ], - "responses": { - "200": { - "description": "OK", - "headers": { - "X-Plex-Container-Total-Size": { - "description": "Provided on all MediaContainer objects indicating the total size of objects available", - "schema": { - "type": "integer" - } - }, - "X-Plex-Container-Start": { - "description": "Provided on all MediaContainer objects indicating the offset of where this container page starts", - "schema": { - "type": "integer" - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/mediaContainerWithSubscription" - }, - "examples": { - "Subs": { - "value": { - "MediaContainer": { - "size": 1, - "MediaSubscription": [ - { - "key": "1", - "type": 2, - "targetLibrarySectionID": 2, - "title": "fresh off the boat", - "Setting": [ - { - "advanced": false, - "default": false, - "enumValues": "0:all episodes|1:only new episodes", - "group": "", - "hidden": false, - "id": "newnessPolicy", - "label": "Record", - "summary": "", - "type": "int", - "value": false - } - ] - } - ] - } - } - } - } - } - } - }, - "403": { - "description": "User cannot access DVR on this server", - "content": { - "text/html": { - "examples": { - "forbidden": { - "summary": "Forbidden", - "value": "Forbidden

403 Forbidden

" - } - } - } - } - } - } - }, - "post": { - "tags": [ - "Subscriptions" - ], - "summary": "Create a subscription", - "description": "Create a subscription. The query parameters should be mostly derived from the [template](#tag/Subscriptions/operation/mediaSubscriptionsGetTemplate)", - "operationId": "mediaSubscriptionsPostSlash", - "parameters": [ - { - "in": "query", - "name": "targetLibrarySectionID", - "schema": { - "type": "integer" - }, - "example": 1, - "description": "The library section into which we'll grab the media. Not actually required when the subscription is to a playlist." - }, - { - "in": "query", - "name": "targetSectionLocationID", - "schema": { - "type": "integer" - }, - "example": 3, - "description": "The section location into which to grab." - }, - { - "in": "query", - "name": "type", - "schema": { - "type": "integer" - }, - "example": 2, - "description": "The type of the thing we're subscribing too (e.g. show, season)." - }, - { - "in": "query", - "name": "hints", - "schema": { - "type": "object" - }, - "style": "deepObject", - "example": { - "title": "Family Guy" - }, - "description": "Hints describing what we're looking for. Note: The hint `ratingKey` is required for downloading from a PMS remote." - }, - { - "in": "query", - "name": "prefs", - "schema": { - "type": "object" - }, - "style": "deepObject", - "example": { - "minVideoQuality": 720 - }, - "description": "Subscription preferences." - }, - { - "in": "query", - "name": "params", - "schema": { - "type": "object" - }, - "style": "deepObject", - "example": { - "mediaProviderID": 1 - }, - "description": "Subscription parameters.\n - `mediaProviderID`: Required for downloads to indicate which MP the subscription will download into\n - `source`: Required for downloads to indicate the source of the downloaded content.\n" - } - ], - "responses": { - "200": { - "description": "OK", - "headers": { - "X-Plex-Container-Total-Size": { - "description": "Provided on all MediaContainer objects indicating the total size of objects available", - "schema": { - "type": "integer" - } - }, - "X-Plex-Container-Start": { - "description": "Provided on all MediaContainer objects indicating the offset of where this container page starts", - "schema": { - "type": "integer" - } - } - }, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "MediaContainer": { - "allOf": [ - { - "$ref": "#/components/schemas/MediaContainer" - }, - { - "type": "object", - "properties": { - "MediaSubscription": { - "type": "array", - "items": { - "$ref": "#/components/schemas/mediaSubscription" - } - } - } - } - ] - } - } - }, - "examples": { - "addedSub": { - "value": { - "MediaContainer": { - "size": 1, - "MediaSubscription": [ - { - "key": "18", - "type": 2, - "targetLibrarySectionID": 2, - "title": "Family Guy", - "Video": { - "beginsAt": "1465814100", - "endsAt": "1465815900", - "mediaSubscriptionID": "19", - "status": "1", - "addedAt": 1464994517, - "contentRating": "TV-14", - "duration": 1800000, - "grandparentTitle": "Family Guy", - "index": 1, - "key": "/tv.plex.providers.epg.onconnect-811e2e8a-f98f-4d1f-a26a-8bc26e4999a7/metadata/2214", - "originallyAvailableAt": "1999-01-31", - "parentIndex": 1, - "parentKey": "/tv.plex.providers.epg.onconnect-811e2e8a-f98f-4d1f-a26a-8bc26e4999a7/metadata/2213", - "parentRatingKey": "2213", - "ratingKey": "com.gracenote.onconnect://episode/EP002960010001", - "summary": "After Peter gets fired following a riotous bachelor party, he receives a check for $150,000 from the welfare department.", - "title": "Death Has a Shadow", - "type": "episode", - "year": 1999, - "Media": [ - { - "audioChannels": 2, - "beginsAt": "1465814100", - "channelID": "32", - "channelIdentifier": "005", - "duration": 1800000, - "endsAt": "1465815900", - "id": 2052, - "protocol": "livetv", - "videoResolution": "480", - "Part": [ - { - "id": 2052, - "key": "/library/parts/2052/0/file" - } - ] - } - ] - }, - "Setting": [ - { - "advanced": false, - "default": 0, - "enumValues": "0:all episodes|1:only new episodes", - "group": "", - "hidden": false, - "id": "newnessPolicy", - "label": "Record", - "summary": "", - "type": "int", - "value": 0 - }, - { - "advanced": false, - "default": 0, - "enumValues": "0:never|1:with higher resolution recordings", - "group": "", - "hidden": false, - "id": "replacementPolicy", - "label": "Replacement existing media", - "summary": "", - "type": "int", - "value": 0 - }, - { - "advanced": false, - "default": 0, - "enumValues": "0:none|720:HD", - "group": "", - "hidden": false, - "id": "minVideoQuality", - "label": "Minimum resolution", - "summary": "", - "type": "int", - "value": 720 - }, - { - "advanced": false, - "default": "", - "group": "", - "hidden": false, - "id": "lineupChannel", - "label": "Channel to record from", - "summary": "", - "type": "text", - "value": "" - }, - { - "advanced": false, - "default": 0, - "group": "", - "hidden": false, - "id": "startOffsetSeconds", - "label": "Padding before show starts", - "summary": "", - "type": "int", - "value": 0 - }, - { - "advanced": false, - "default": 0, - "group": "", - "hidden": false, - "id": "endOffsetSeconds", - "label": "Padding after show ends", - "summary": "", - "type": "int", - "value": 0 - }, - { - "advanced": false, - "default": true, - "group": "", - "hidden": false, - "id": "recordPartials", - "label": "Record partial media", - "summary": "", - "type": "bool", - "value": true - } - ] - } - ] - } - } - } - } - } - } - }, - "400": { - "$ref": "#/components/responses/400" - }, - "403": { - "description": "User cannot access DVR on this server", - "content": { - "text/html": { - "examples": { - "forbidden": { - "summary": "Forbidden", - "value": "Forbidden

403 Forbidden

" - } - } - } - } - }, - "409": { - "description": "An subscription with the same parameters already exists", - "content": { - "text/html": { - "examples": { - "conflict": { - "summary": "Conflict", - "value": "Conflict

409 Conflict

" - } - } - } - } - } - } - } - }, - "/media/subscriptions/process": { - "post": { - "tags": [ - "Subscriptions" - ], - "summary": "Process all subscriptions", - "description": "Process all subscriptions asynchronously", - "operationId": "mediaSubscriptionsPostProcess", - "responses": { - "200": { - "description": "OK", - "content": { - "text/html": { - "examples": { - "ok": { - "summary": "OK", - "value": "" - } - } - } - }, - "headers": { - "X-Plex-Activity": { - "schema": { - "type": "string" - }, - "description": "The activity of the process" - } - } - }, - "403": { - "description": "User cannot access DVR on this server", - "content": { - "text/html": { - "examples": { - "forbidden": { - "summary": "Forbidden", - "value": "Forbidden

403 Forbidden

" - } - } - } - } - } - } - } - }, - "/media/subscriptions/{subscriptionId}": { - "get": { - "tags": [ - "Subscriptions" - ], - "summary": "Get a single subscription", - "description": "Get a single subscription and potentially the grabs too", - "operationId": "mediaSubscriptionsGetSubscription", - "parameters": [ - { - "in": "path", - "name": "subscriptionId", - "schema": { - "type": "integer" - }, - "required": true - }, - { - "in": "query", - "name": "includeGrabs", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "description": "Indicates whether the active grabs should be included as well" - }, - { - "in": "query", - "name": "includeStorage", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "description": "Compute the storage of recorded items desired by this subscription" - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/mediaContainerWithSubscription" - }, - "examples": { - "Subs": { - "value": { - "MediaContainer": { - "size": 1, - "MediaSubscription": [ - { - "key": "1", - "type": 2, - "targetLibrarySectionID": 2, - "title": "fresh off the boat", - "Setting": [ - { - "advanced": false, - "default": 0, - "enumValues": "0:all episodes|1:only new episodes", - "group": "", - "hidden": false, - "id": "newnessPolicy", - "label": "Record", - "summary": "", - "type": "int", - "value": 0 - } - ] - } - ] - } - } - } - } - } - } - }, - "400": { - "$ref": "#/components/responses/400" - }, - "403": { - "description": "User cannot access DVR on this server or cannot access this subscription", - "content": { - "text/html": { - "examples": { - "forbidden": { - "summary": "Forbidden", - "value": "Forbidden

403 Forbidden

" - } - } - } - } - }, - "404": { - "$ref": "#/components/responses/404" - } - } - }, - "delete": { - "tags": [ - "Subscriptions" - ], - "summary": "Delete a subscription", - "description": "Delete a subscription, cancelling all of its grabs as well", - "operationId": "mediaSubscriptionsDeleteSubscription", - "parameters": [ - { - "in": "path", - "name": "subscriptionId", - "schema": { - "type": "integer" - }, - "required": true - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/200" - }, - "400": { - "$ref": "#/components/responses/400" - }, - "403": { - "description": "User cannot access DVR on this server or cannot access this subscription", - "content": { - "text/html": { - "examples": { - "forbidden": { - "summary": "Forbidden", - "value": "Forbidden

403 Forbidden

" - } - } - } - } - }, - "404": { - "$ref": "#/components/responses/404" - } - } - }, - "put": { - "tags": [ - "Subscriptions" - ], - "summary": "Edit a subscription", - "description": "Edit a subscription's preferences", - "operationId": "mediaSubscriptionsPutSubscription", - "parameters": [ - { - "in": "path", - "name": "subscriptionId", - "schema": { - "type": "integer" - }, - "required": true - }, - { - "in": "query", - "name": "prefs", - "schema": { - "type": "object" - }, - "style": "deepObject", - "example": { - "minVideoQuality": 720 - } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/mediaContainerWithSubscription" - }, - "examples": { - "Subs": { - "value": { - "MediaContainer": { - "size": 1, - "MediaSubscription": [ - { - "key": "1", - "type": 2, - "targetLibrarySectionID": 2, - "title": "fresh off the boat", - "Setting": [ - { - "advanced": false, - "default": 0, - "enumValues": "0:all episodes|1:only new episodes", - "group": "", - "hidden": false, - "id": "newnessPolicy", - "label": "Record", - "summary": "", - "type": "int", - "value": 0 - } - ] - } - ] - } - } - } - } - } - } - }, - "400": { - "$ref": "#/components/responses/400" - }, - "403": { - "description": "User cannot access DVR on this server or cannot access this subscription", - "content": { - "text/html": { - "examples": { - "forbidden": { - "summary": "Forbidden", - "value": "Forbidden

403 Forbidden

" - } - } - } - } - }, - "404": { - "$ref": "#/components/responses/404" - } - } - } - }, - "/media/subscriptions/{subscriptionId}/move": { - "put": { - "tags": [ - "Subscriptions" - ], - "security": [ - { - "user_token": [ - "admin" - ] - } - ], - "summary": "Re-order a subscription", - "description": "Re-order a subscription to change its priority", - "operationId": "mediaSubscriptionsPutSubscriptionMove", - "parameters": [ - { - "in": "path", - "name": "subscriptionId", - "schema": { - "type": "integer" - }, - "required": true - }, - { - "in": "query", - "name": "after", - "schema": { - "type": "integer" - }, - "description": "The subscription to move this sub after. If missing will insert at the beginning of the list" - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/mediaContainerWithSubscription" - }, - "examples": { - "Subs": { - "value": { - "MediaContainer": { - "size": 1, - "MediaSubscription": [ - { - "key": "1", - "type": 2, - "targetLibrarySectionID": 2, - "title": "fresh off the boat", - "Setting": [ - { - "advanced": false, - "default": 0, - "enumValues": "0:all episodes|1:only new episodes", - "group": "", - "hidden": false, - "id": "newnessPolicy", - "label": "Record", - "summary": "", - "type": "int", - "value": 0 - } - ] - } - ] - } - } - } - } - } - } - }, - "400": { - "$ref": "#/components/responses/400" - }, - "403": { - "description": "User cannot access DVR on this server or cannot access this subscription", - "content": { - "text/html": { - "examples": { - "forbidden": { - "summary": "Forbidden", - "value": "Forbidden

403 Forbidden

" - } - } - } - } - }, - "404": { - "$ref": "#/components/responses/404" - } - } - } - }, - "/media/subscriptions/scheduled": { - "get": { - "tags": [ - "Subscriptions" - ], - "summary": "Get all scheduled recordings", - "description": "Get all scheduled recordings across all subscriptions", - "operationId": "mediaSubscriptionsGetScheduled", - "responses": { - "200": { - "description": "OK", - "headers": { - "X-Plex-Container-Total-Size": { - "description": "Provided on all MediaContainer objects indicating the total size of objects available", - "schema": { - "type": "integer" - } - }, - "X-Plex-Container-Start": { - "description": "Provided on all MediaContainer objects indicating the offset of where this container page starts", - "schema": { - "type": "integer" - } - } - }, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "MediaContainer": { - "allOf": [ - { - "$ref": "#/components/schemas/MediaContainer" - }, - { - "type": "object", - "properties": { - "MediaGrabOperation": { - "type": "array", - "items": { - "$ref": "#/components/schemas/mediaGrabOperation" - } - } - } - } - ] - } - } - }, - "examples": { - "aGrab": { - "value": { - "MediaContainer": { - "size": 1, - "MediaGrabOperation": [ - { - "mediaSubscriptionID": 1, - "status": "scheduled", - "Metadata": { - "addedAt": 1464994564, - "contentRating": "TV-PG", - "duration": 1800000, - "grandparentTitle": "Fresh Off the Boat", - "index": 5, - "key": "/tv.plex.providers.epg.onconnect-811e2e8a-f98f-4d1f-a26a-8bc26e4999a7/metadata/2543", - "originallyAvailableAt": "2015-10-27", - "parentIndex": 2, - "parentKey": "/tv.plex.providers.epg.onconnect-811e2e8a-f98f-4d1f-a26a-8bc26e4999a7/metadata/2542", - "parentRatingKey": "2542", - "ratingKey": "com.gracenote.onconnect://episode/EP019218760019", - "summary": "Louis wants to make his street a prime trick-or-treat destination.", - "title": "Miracle on Dead Street", - "type": "episode", - "year": 2015, - "Media": [ - { - "audioChannels": 2, - "beginsAt": 1466060400, - "channelID": "21", - "channelIdentifier": "004", - "duration": 1800000, - "endsAt": "1466062200", - "id": 2581, - "protocol": "livetv", - "videoResolution": "480", - "Part": [ - { - "id": 2581, - "key": "/library/parts/2581/0/file" - } - ] - } - ] - } - } - ] - } - } - } - } - } - } - }, - "403": { - "description": "User cannot access DVR on this server", - "content": { - "text/html": { - "examples": { - "forbidden": { - "summary": "Forbidden", - "value": "Forbidden

403 Forbidden

" - } - } - } - } - } - } - } - }, - "/media/subscriptions/template": { - "get": { - "tags": [ - "Subscriptions" - ], - "summary": "Get the subscription template", - "description": "Get the templates for a piece of media which could include fetching one airing, season, the whole show, etc.", - "operationId": "mediaSubscriptionsGetTemplate", - "parameters": [ - { - "in": "query", - "name": "guid", - "schema": { - "type": "string" - }, - "example": "plex://episode/5fc70265c40548002d539d23", - "description": "The guid of the item for which to get the template" - } - ], - "responses": { - "200": { - "description": "OK", - "headers": { - "X-Plex-Container-Total-Size": { - "description": "Provided on all MediaContainer objects indicating the total size of objects available", - "schema": { - "type": "integer" - } - }, - "X-Plex-Container-Start": { - "description": "Provided on all MediaContainer objects indicating the offset of where this container page starts", - "schema": { - "type": "integer" - } - } - }, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "MediaContainer": { - "allOf": [ - { - "$ref": "#/components/schemas/MediaContainer" - }, - { - "type": "object", - "properties": { - "SubscriptionTemplate": { - "type": "array", - "items": { - "type": "object", - "properties": { - "MediaSubscription": { - "type": "array", - "items": { - "allOf": [ - { - "$ref": "#/components/schemas/mediaSubscription" - }, - { - "type": "object", - "properties": { - "parameters": { - "type": "string", - "description": "Parameter string for creating this subscription" - }, - "selected": { - "type": "boolean" - }, - "type": { - "type": "integer", - "description": "Metadata type number" - }, - "targetLibrarySectionID": { - "type": "integer", - "description": "Where this subscription will record to" - }, - "title": { - "type": "string", - "example": "This Episode", - "description": "The title of this subscription type" - }, - "airingsType": { - "type": "string" - }, - "librarySectionTitle": { - "type": "string" - }, - "locationPath": { - "type": "string" - } - } - } - ] - } - } - } - } - } - } - } - ] - } - } - }, - "examples": { - "threeCountries": { - "value": { - "MediaContainer": { - "size": 1, - "SubscriptionTemplate": [ - { - "MediaSubscription": [ - { - "parameters": "hints%5BgrandparentGuid%5D=plex%3A%2F%2Fshow%2F5fc6af416b022a002d70b32b&hints%5BgrandparentThumb%5D=https%3A%2F%2Fmetadata-static%2Eplex%2Etv%2F1%2Fgracenote%2F1df5e54a60dfe28b4afac1a445fc86df%2Ejpg&hints%5BgrandparentTitle%5D=Law%20%26%20Order%3A%20Special%20Victims%20Unit&hints%5BgrandparentYear%5D=1999&hints%5Bguid%5D=plex%3A%2F%2Fepisode%2F5fc70265c40548002d539d23&hints%5Bindex%5D=4&hints%5BoriginallyAvailableAt%5D=2010-10-06&hints%5BparentGuid%5D=plex%3A%2F%2Fseason%2F5fc6af416b022a002d70b32b%2F12&hints%5BparentIndex%5D=12&hints%5BparentTitle%5D=Season%2012&hints%5BratingKey%5D=plex%253A%252F%252Fepisode%252F5fc70265c40548002d539d23&hints%5Bthumb%5D=https%3A%2F%2Fmetadata-static%2Eplex%2Etv%2F4%2Fgracenote%2F499a3c4f04eb050705ade1eab8b5a61a%2Ejpg&hints%5Btitle%5D=Merchandise&hints%5Btype%5D=4&hints%5Byear%5D=2010¶ms%5BairingChannels%5D=5fc76db1286b56002e2bed03-5fc705ea104230002d14d8f1%253D3%25252E2%252520KBTXDT2%252520%252528The%252520CW%252529¶ms%5BairingTimes%5D=1715319000%252C1715281200¶ms%5BlibraryType%5D=2¶ms%5BmediaProviderID%5D=13", - "selected": true, - "type": 4, - "targetLibrarySectionID": 2, - "title": "This Episode", - "airingsType": "New Airings Only", - "Video": { - "grandparentGuid": "plex://show/5fc6af416b022a002d70b32b", - "grandparentThumb": "https://metadata-static.plex.tv/1/gracenote/1df5e54a60dfe28b4afac1a445fc86df.jpg", - "grandparentTitle": "Law & Order: Special Victims Unit", - "grandparentYear": "1999", - "guid": "plex://episode/5fc70265c40548002d539d23", - "index": 4, - "mediaProviderID": "13", - "originallyAvailableAt": "2010-10-06", - "parentGuid": "plex://season/5fc6af416b022a002d70b32b/12", - "parentIndex": 12, - "parentTitle": "Season 12", - "ratingKey": "plex%3A%2F%2Fepisode%2F5fc70265c40548002d539d23", - "thumb": "https://metadata-static.plex.tv/4/gracenote/499a3c4f04eb050705ade1eab8b5a61a.jpg", - "title": "Merchandise", - "type": "episode", - "year": 2010 - }, - "Setting": [ - { - "id": "minVideoQuality", - "label": "Resolution", - "summary": "Choose the minimum resolution for airings to be recorded.", - "type": "int", - "default": "0", - "value": "0", - "hidden": false, - "advanced": true, - "group": "", - "enumValues": "0:Prefer HD|720:HD only" - }, - { - "id": "replaceLowerQuality", - "label": "Replace lower resolution items", - "summary": "Set whether items in your library may be replaced by higher resolution recordings. This will replace any matching items in your library, not just prior DVR recordings.", - "type": "bool", - "default": "false", - "value": "false", - "hidden": false, - "advanced": true, - "group": "" - }, - { - "id": "recordPartials", - "label": "Allow partial airings", - "summary": "Choose whether a recording may begin for an airing already in progress.", - "type": "bool", - "default": "true", - "value": "true", - "hidden": false, - "advanced": true, - "group": "" - }, - { - "id": "startOffsetMinutes", - "label": "Minutes before start", - "summary": "Increase the recording duration by adding minutes before the scheduled time.", - "type": "int", - "default": "0", - "value": "0", - "hidden": false, - "advanced": true, - "group": "" - }, - { - "id": "endOffsetMinutes", - "label": "Minutes after end", - "summary": "Increase the recording duration by adding minutes after the scheduled time.", - "type": "int", - "default": "0", - "value": "0", - "hidden": false, - "advanced": true, - "group": "" - }, - { - "id": "lineupChannel", - "label": "Limit to channel", - "summary": "Choose whether to restrict recording to a specific channel.", - "type": "text", - "default": "", - "value": "", - "hidden": false, - "advanced": true, - "group": "", - "enumValues": ":Any|5fc76db1286b56002e2bed03-5fc705ea104230002d14d8f1:3.2 KBTXDT2 (The CW)" - }, - { - "id": "startTimeslot", - "label": "Limit to airing time", - "summary": "Choose whether to restrict recording to a specific airing time.", - "type": "int", - "default": "-1", - "value": "-1", - "hidden": false, - "advanced": true, - "group": "", - "enumValues": "-1:Any|1715319000:12%3A30 AM|1715281200:02%3A00 PM" - }, - { - "id": "comskipEnabled", - "label": "", - "summary": "", - "type": "int", - "default": "-1", - "value": "-1", - "hidden": true, - "advanced": false, - "group": "" - }, - { - "id": "comskipMethod", - "label": "Detect commercials", - "summary": "Attempt to automatically detect and remove commercials from recordings. This process may take a long time and cause high CPU usage. 'Detect and delete commercials' will delete detected commercial footage from your video files. 'Detect commercials and mark for skip' will leave the video files intact.", - "type": "int", - "default": "-1", - "value": "0", - "hidden": false, - "advanced": true, - "group": "", - "enumValues": "-1:Use DVR Setting|0:Disabled|1:Detect and delete commercials|2:Detect commercials and mark for skip" - }, - { - "id": "oneShot", - "label": "", - "summary": "", - "type": "bool", - "default": "false", - "value": "true", - "hidden": true, - "advanced": false, - "group": "" - }, - { - "id": "remoteMedia", - "label": "", - "summary": "", - "type": "bool", - "default": "false", - "value": "false", - "hidden": true, - "advanced": false, - "group": "" - } - ] - }, - { - "parameters": "hints%5Bguid%5D=plex%3A%2F%2Fshow%2F5fc6af416b022a002d70b32b&hints%5BratingKey%5D=plex%253A%252F%252Fshow%252F5fc6af416b022a002d70b32b&hints%5Bthumb%5D=https%3A%2F%2Fmetadata-static%2Eplex%2Etv%2F1%2Fgracenote%2F1df5e54a60dfe28b4afac1a445fc86df%2Ejpg&hints%5Btitle%5D=Law%20%26%20Order%3A%20Special%20Victims%20Unit&hints%5Btype%5D=2&hints%5Byear%5D=1999¶ms%5BairingChannels%5D=5fc76db1286b56002e2bed03-5fc705ea104230002d14d8f1%253D3%25252E2%252520KBTXDT2%252520%252528The%252520CW%252529¶ms%5BairingTimes%5D=1715319000%252C1715281200¶ms%5BlibraryType%5D=2¶ms%5BmediaProviderID%5D=13", - "type": 2, - "targetLibrarySectionID": 2, - "title": "All Episodes", - "airingsType": "New Airings Only", - "librarySectionTitle": "TV Shows", - "locationPath": "/Volumes/Media/TV Shows", - "Directory": { - "guid": "plex://show/5fc6af416b022a002d70b32b", - "mediaProviderID": "13", - "ratingKey": "plex%3A%2F%2Fshow%2F5fc6af416b022a002d70b32b", - "thumb": "https://metadata-static.plex.tv/1/gracenote/1df5e54a60dfe28b4afac1a445fc86df.jpg", - "title": "Law & Order: Special Victims Unit", - "type": "show", - "year": 1999 - }, - "Setting": [ - { - "id": "onlyNewAirings", - "label": "Airings", - "summary": "", - "type": "int", - "default": "0", - "value": "1", - "hidden": false, - "advanced": false, - "group": "", - "enumValues": "0:New and Repeat Airings|1:New Airings Only" - }, - { - "id": "minVideoQuality", - "label": "Resolution", - "summary": "Choose the minimum resolution for airings to be recorded.", - "type": "int", - "default": "0", - "value": "0", - "hidden": false, - "advanced": true, - "group": "", - "enumValues": "0:Prefer HD|720:HD only" - }, - { - "id": "replaceLowerQuality", - "label": "Replace lower resolution items", - "summary": "Set whether items in your library may be replaced by higher resolution recordings. This will replace any matching items in your library, not just prior DVR recordings.", - "type": "bool", - "default": "false", - "value": "false", - "hidden": false, - "advanced": true, - "group": "" - }, - { - "id": "recordPartials", - "label": "Allow partial airings", - "summary": "Choose whether a recording may begin for an airing already in progress.", - "type": "bool", - "default": "true", - "value": "true", - "hidden": false, - "advanced": true, - "group": "" - }, - { - "id": "startOffsetMinutes", - "label": "Minutes before start", - "summary": "Increase the recording duration by adding minutes before the scheduled time.", - "type": "int", - "default": "0", - "value": "0", - "hidden": false, - "advanced": true, - "group": "" - }, - { - "id": "endOffsetMinutes", - "label": "Minutes after end", - "summary": "Increase the recording duration by adding minutes after the scheduled time.", - "type": "int", - "default": "0", - "value": "0", - "hidden": false, - "advanced": true, - "group": "" - }, - { - "id": "lineupChannel", - "label": "Limit to channel", - "summary": "Choose whether to restrict recording to a specific channel.", - "type": "text", - "default": "", - "value": "", - "hidden": false, - "advanced": true, - "group": "", - "enumValues": ":Any|5fc76db1286b56002e2bed03-5fc705ea104230002d14d8f1:3.2 KBTXDT2 (The CW)" - }, - { - "id": "startTimeslot", - "label": "Limit to airing time", - "summary": "Choose whether to restrict recording to a specific airing time.", - "type": "int", - "default": "-1", - "value": "-1", - "hidden": false, - "advanced": true, - "group": "", - "enumValues": "-1:Any|1715319000:12%3A30 AM|1715281200:02%3A00 PM" - }, - { - "id": "comskipEnabled", - "label": "", - "summary": "", - "type": "int", - "default": "-1", - "value": "-1", - "hidden": true, - "advanced": false, - "group": "" - }, - { - "id": "comskipMethod", - "label": "Detect commercials", - "summary": "Attempt to automatically detect and remove commercials from recordings. This process may take a long time and cause high CPU usage. 'Detect and delete commercials' will delete detected commercial footage from your video files. 'Detect commercials and mark for skip' will leave the video files intact.", - "type": "int", - "default": "-1", - "value": "0", - "hidden": false, - "advanced": true, - "group": "", - "enumValues": "-1:Use DVR Setting|0:Disabled|1:Detect and delete commercials|2:Detect commercials and mark for skip" - }, - { - "id": "oneShot", - "label": "", - "summary": "", - "type": "bool", - "default": "false", - "value": "false", - "hidden": true, - "advanced": false, - "group": "" - }, - { - "id": "remoteMedia", - "label": "", - "summary": "", - "type": "bool", - "default": "false", - "value": "false", - "hidden": true, - "advanced": false, - "group": "" - }, - { - "id": "autoDeletionItemPolicyUnwatchedLibrary", - "label": "Keep", - "summary": "Set the maximum number of unplayed episodes to keep for the show.", - "type": "int", - "default": "0", - "value": "0", - "hidden": false, - "advanced": true, - "group": "", - "enumValues": "0:All episodes|5:5 latest episodes|3:3 latest episodes|1:Latest episode|-3:Episodes added in the past 3 days|-7:Episodes added in the past 7 days|-30:Episodes added in the past 30 days" - }, - { - "id": "autoDeletionItemPolicyWatchedLibrary", - "label": "Delete episodes after playing", - "summary": "Choose how quickly episodes are removed after the server admin has watched them.", - "type": "int", - "default": "0", - "value": "0", - "hidden": false, - "advanced": true, - "group": "", - "enumValues": "0:Never|1:After a day|7:After a week|30:After a month|100:On next refresh" - } - ] - } - ] - } - ] - } - } - } - } - } - } - }, - "403": { - "description": "User cannot access DVR on this server", - "content": { - "text/html": { - "examples": { - "forbidden": { - "summary": "Forbidden", - "value": "Forbidden

403 Forbidden

" - } - } - } - } - } - } - } - }, - "/photo/:/transcode": { - "get": { - "tags": [ - "Transcoder" - ], - "summary": "Transcode an image", - "description": "Transcode an image, possibly changing format or size", - "operationId": "imageTranscode", - "parameters": [ - { - "in": "query", - "name": "url", - "schema": { - "type": "string" - }, - "description": "The source URL for the image to transcode. Note, if this URL requires a token such as `X-Plex-Token`, it should be given as a query parameter to this url.", - "example": "/library/metadata/265/thumb/1715112705" - }, - { - "in": "query", - "name": "format", - "schema": { - "type": "string", - "enum": [ - "jpg", - "jpeg", - "png", - "ppm" - ] - }, - "required": false, - "description": "The output format for the image; defaults to jpg" - }, - { - "in": "query", - "name": "width", - "schema": { - "type": "integer" - }, - "description": "The desired width of the output image" - }, - { - "in": "query", - "name": "height", - "schema": { - "type": "integer" - }, - "description": "The desired height of the output image" - }, - { - "in": "query", - "name": "quality", - "schema": { - "type": "integer", - "minimum": -1, - "maximum": 127 - }, - "required": false, - "description": "The desired quality of the output. -1 means the highest quality. Defaults to -1" - }, - { - "in": "query", - "name": "background", - "schema": { - "type": "string" - }, - "required": false, - "description": "The background color to apply before painting the image. Only really applicable if image has transparency. Defaults to none", - "example": "#ff5522" - }, - { - "in": "query", - "name": "upscale", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "required": false, - "description": "Indicates if image should be upscaled to the desired width/height. Defaults to false" - }, - { - "in": "query", - "name": "minSize", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "required": false, - "description": "Indicates if image should be scaled to fit the smaller dimension. By default (false) the image is scaled to fit within the width/height specified but if this parameter is true, it will allow overflowing one dimension to fit the other. Essentially it is making the width/height minimum sizes of the image or sizing the image to fill the entire width/height even if it overflows one dimension." - }, - { - "in": "query", - "name": "rotate", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "required": false, - "description": "Obey the rotation values specified in EXIF data. Defaults to true." - }, - { - "in": "query", - "name": "blur", - "schema": { - "type": "integer" - }, - "required": false, - "description": "Apply a blur to the image, Defaults to 0 (none)" - }, - { - "in": "query", - "name": "saturation", - "schema": { - "type": "integer", - "minimum": 0, - "maximum": 100 - }, - "required": false, - "description": "Scale the image saturation by the specified percentage. Defaults to 100" - }, - { - "in": "query", - "name": "opacity", - "schema": { - "type": "integer", - "minimum": 0, - "maximum": 100 - }, - "required": false, - "description": "Render the image at the specified opacity percentage. Defaults to 100" - }, - { - "in": "query", - "name": "chromaSubsampling", - "schema": { - "type": "integer", - "enum": [ - 0, - 1, - 2, - 3 - ] - }, - "required": false, - "description": "Use the specified chroma subsambling.\n - 0: 411\n - 1: 420\n - 2: 422\n - 3: 444\nDefaults to 3 (444)" - }, - { - "in": "query", - "name": "blendColor", - "schema": { - "type": "string" - }, - "required": false, - "description": "The color to blend with the image. Defaults to none", - "example": "#ff5522" - } - ], - "responses": { - "200": { - "description": "The resulting image", - "content": { - "image/jpeg": { - "schema": { - "type": "string", - "format": "binary" - } - }, - "image/png": { - "schema": { - "type": "string", - "format": "binary" - } - }, - "image/x-portable-pixmap": { - "schema": { - "type": "string", - "format": "binary" - } - } - } - }, - "400": { - "$ref": "#/components/responses/400" - }, - "403": { - "$ref": "#/components/responses/403" - }, - "404": { - "$ref": "#/components/responses/404" - } - } - } - }, - "/playlists": { - "get": { - "tags": [ - "Playlist" - ], - "summary": "Retrieve Playlists", - "description": "Gets a list of playlists and playlist folders for a user. General filters are permitted, such as `sort=lastViewedAt:desc`. A flat playlist list can be retrieved using `type=15` to limit the collection to just playlists.", - "operationId": "playlistGetSlash", - "parameters": [ - { - "in": "query", - "name": "playlistType", - "schema": { - "type": "string", - "enum": [ - "audio", - "video", - "photo" - ] - }, - "description": "Limit to a type of playlist" - }, - { - "in": "query", - "name": "smart", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "description": "Type of playlists to return, smart or not. When not provided, will return both." - } - ], - "responses": { - "200": { - "description": "OK", - "headers": { - "X-Plex-Container-Total-Size": { - "description": "Provided on all MediaContainer objects indicating the total size of objects available", - "schema": { - "type": "integer" - } - }, - "X-Plex-Container-Start": { - "description": "Provided on all MediaContainer objects indicating the offset of where this container page starts", - "schema": { - "type": "integer" - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/mediaContainerWithPlaylistMetadata" - }, - "examples": { - "playlists": { - "description": "A list of playlists", - "value": { - "MediaContainer": { - "size": 2, - "Metadata": [ - { - "addedAt": 1476942219, - "composite": "/playlists/2561805/composite/1485900004", - "duration": 1512000, - "key": "/playlists/2561805/items", - "lastViewedAt": 1484680617, - "leafCount": 8, - "playlistType": "video", - "ratingKey": "2561805", - "smart": false, - "title": "Background videos", - "type": "playlist", - "updatedAt": 1485900004, - "viewCount": 8 - }, - { - "addedAt": 1428993345, - "composite": "/playlists/1956389/composite/1486498661", - "duration": 938507000, - "key": "/playlists/1956389/items", - "leafCount": 3934, - "playlistType": "audio", - "ratingKey": "1956389", - "smart": true, - "title": "Fairly Recent", - "type": "playlist", - "updatedAt": 1486498661 - } - ] - } - } - } - } - } - } - } - } - }, - "post": { - "tags": [ - "Library Playlists" - ], - "summary": "Create a Playlist", - "description": "Create a new playlist. By default the playlist is blank.", - "operationId": "playlistPostSlash", - "parameters": [ - { - "in": "query", - "name": "uri", - "schema": { - "type": "string" - }, - "description": "The content URI for what we're playing (e.g. `library://...`)." - }, - { - "in": "query", - "name": "playQueueID", - "schema": { - "type": "integer" - }, - "description": "To create a playlist from an existing play queue." - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/mediaContainerWithPlaylistMetadata" - }, - "examples": { - "playlist": { - "description": "A created playlist", - "value": { - "MediaContainer": { - "size": 1, - "Metadata": [ - { - "addedAt": 1476942219, - "composite": "/playlists/2561805/composite/1485900004", - "duration": 1512000, - "key": "/playlists/2561805/items", - "lastViewedAt": 1484680617, - "leafCount": 8, - "playlistType": "video", - "ratingKey": "2561805", - "smart": false, - "title": "Background videos", - "type": "playlist", - "updatedAt": 1485900004, - "viewCount": 8 - } - ] - } - } - } - } - } - } - }, - "400": { - "$ref": "#/components/responses/400" - } - } - } - }, - "/playlists/{playlistId}": { - "get": { - "tags": [ - "Playlist" - ], - "summary": "Retrieve Playlist", - "description": "Gets detailed metadata for a playlist. A playlist for many purposes (rating, editing metadata, tagging), can be treated like a regular metadata item:\nSmart playlist details contain the `content` attribute. This is the content URI for the generator. This can then be parsed by a client to provide smart playlist editing.", - "operationId": "playlistGetPlaylist", - "parameters": [ - { - "in": "path", - "name": "playlistId", - "schema": { - "type": "integer" - }, - "description": "The ID of the playlist", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/mediaContainerWithPlaylistMetadata" - }, - "examples": { - "playlist": { - "description": "An example playlist", - "value": { - "MediaContainer": { - "size": 1, - "Metadata": [ - { - "addedAt\"": 1428993345, - "composite\"": "/playlists/1956389/composite/1486764293", - "content\"": "library://22d0bfe3-21ce-4a67-9065-ccaf470cb3c2/directory/%2Flibrary%2Fsections%2F1224%2Fall%3Ftrack%2EaddedAt%3E%3D-12mon%26album%2Egenre!%3DPodcast%26album%2Egenre!%3DBooks%2520%2526%2520Spoken%26artist%2Etitle!%3DAOL%2520Music", - "duration\"": 957684000, - "guid\"": "com.plexapp.plugins.itunes://9F7B78609597F45F", - "key\"": "/playlists/1956389/items", - "leafCount\"": 4015, - "playlistType\"": "audio", - "ratingKey\"": "1956389", - "smart\"": true, - "title\"": "Fairly Recent", - "type\"": "playlist", - "updatedAt\"": 1486764293 - } - ] - } - } - } - } - } - } - }, - "404": { - "description": "Playlist not found (or user may not have permission to access playlist)", - "content": { - "text/html": { - "examples": { - "notFound": { - "summary": "Not Found", - "value": "Not Found

404 Not Found

" - } - } - } - } - } - } - }, - "delete": { - "tags": [ - "Library Playlists" - ], - "summary": "Delete a Playlist", - "description": "Deletes a playlist by provided id", - "operationId": "playlistDeletePlaylist", - "parameters": [ - { - "in": "path", - "name": "playlistId", - "schema": { - "type": "integer" - }, - "description": "The ID of the playlist", - "required": true - } - ], - "responses": { - "204": { - "$ref": "#/components/responses/204" - }, - "404": { - "description": "Playlist not found (or user may not have permission to access playlist)", - "content": { - "text/html": { - "examples": { - "notFound": { - "summary": "Not Found", - "value": "Not Found

404 Not Found

" - } - } - } - } - } - } - }, - "put": { - "tags": [ - "Library Playlists" - ], - "summary": "Editing a Playlist", - "description": "Edits a playlist in the same manner as [editing metadata](#tag/Provider/operation/metadataPutItem)", - "operationId": "playlistPutPlaylist", - "parameters": [ - { - "in": "path", - "name": "playlistId", - "schema": { - "type": "integer" - }, - "description": "The ID of the playlist", - "required": true - } - ], - "responses": { - "204": { - "$ref": "#/components/responses/204" - }, - "404": { - "description": "Playlist not found (or user may not have permission to access playlist)", - "content": { - "text/html": { - "examples": { - "notFound": { - "summary": "Not Found", - "value": "Not Found

404 Not Found

" - } - } - } - } - } - } - } - }, - "/playlists/{playlistId}/items": { - "get": { - "tags": [ - "Playlist" - ], - "summary": "Retrieve Playlist Contents", - "description": "Gets the contents if a playlist. Should be paged by clients via standard mechanisms. By default leaves are returned (e.g. episodes, movies). In order to return other types you can use the `type` parameter. For example, you could use this to display a list of recently added albums vis a smart playlist. Note that for dumb playlists, items have a `playlistItemID` attribute which is used for deleting or moving items.", - "operationId": "playlistGetItems", - "parameters": [ - { - "in": "path", - "name": "playlistId", - "schema": { - "type": "integer" - }, - "description": "The ID of the playlist", - "required": true - }, - { - "in": "query", - "name": "type", - "schema": { - "type": "array", - "items": { - "type": "integer" - } - }, - "explode": false, - "description": "The metadata types of the item to return. Values past the first are only used in fetching items from the background processing playlist." - } - ], - "responses": { - "200": { - "description": "OK", - "headers": { - "X-Plex-Container-Total-Size": { - "description": "Provided on all MediaContainer objects indicating the total size of objects available", - "schema": { - "type": "integer" - } - }, - "X-Plex-Container-Start": { - "description": "Provided on all MediaContainer objects indicating the offset of where this container page starts", - "schema": { - "type": "integer" - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/mediaContainerWithMetadata" - }, - "examples": { - "contents": { - "description": "Example playlist items", - "value": { - "MediaContainer": { - "size": 100, - "Metadata": [ - { - "playlistItemID": 123, - "restOf": "metadataHere" - } - ] - } - } - } - } - } - } - }, - "404": { - "description": "Playlist not found (or user may not have permission to access playlist)", - "content": { - "text/html": { - "examples": { - "notFound": { - "summary": "Not Found", - "value": "Not Found

404 Not Found

" - } - } - } - } - } - } - }, - "delete": { - "tags": [ - "Library Playlists" - ], - "summary": "Clearing a playlist", - "description": "Clears a playlist, only works with dumb playlists. Returns the playlist.", - "operationId": "playlistDeleteItems", - "parameters": [ - { - "in": "path", - "name": "playlistId", - "schema": { - "type": "integer" - }, - "description": "The ID of the playlist", - "required": true - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/slash-post-responses-200" - }, - "400": { - "$ref": "#/components/responses/400" - }, - "404": { - "description": "Playlist not found (or user may not have permission to access playlist)", - "content": { - "text/html": { - "examples": { - "notFound": { - "summary": "Not Found", - "value": "Not Found

404 Not Found

" - } - } - } - } - } - } - }, - "put": { - "tags": [ - "Library Playlists" - ], - "summary": "Adding to a Playlist", - "description": "Adds a generator to a playlist, same parameters as the POST above. With a dumb playlist, this adds the specified items to the playlist. With a smart playlist, passing a new `uri` parameter replaces the rules for the playlist. Returns the playlist.", - "operationId": "playlistPutItems", - "parameters": [ - { - "in": "path", - "name": "playlistId", - "schema": { - "type": "integer" - }, - "description": "The ID of the playlist", - "required": true - }, - { - "in": "query", - "name": "uri", - "schema": { - "type": "string" - }, - "description": "The content URI for the playlist." - }, - { - "in": "query", - "name": "playQueueID", - "schema": { - "type": "integer" - }, - "description": "The play queue to add to a playlist." - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/slash-post-responses-200" - }, - "400": { - "$ref": "#/components/responses/400" - }, - "404": { - "description": "Playlist not found (or user may not have permission to access playlist)", - "content": { - "text/html": { - "examples": { - "notFound": { - "summary": "Not Found", - "value": "Not Found

404 Not Found

" - } - } - } - } - } - } - } - }, - "/playlists/{playlistId}/items/{generatorId}": { - "get": { - "tags": [ - "Library Playlists" - ], - "summary": "Get a playlist generator", - "description": "Get a playlist's generator. Only used for optimized versions", - "operationId": "playlistGetItemsGenerator", - "parameters": [ - { - "in": "path", - "name": "playlistId", - "schema": { - "type": "integer" - }, - "description": "The ID of the playlist", - "required": true - }, - { - "in": "path", - "name": "generatorId", - "schema": { - "type": "integer" - }, - "description": "The generator item ID to delete.", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "MediaContainer": { - "allOf": [ - { - "$ref": "#/components/schemas/MediaContainer" - }, - { - "type": "object", - "properties": { - "Item": { - "type": "array", - "items": { - "type": "object", - "properties": { - "composite": { - "type": "string", - "description": "The composite thumbnail image path" - }, - "id": { - "type": "integer" - }, - "type": { - "type": "integer", - "enum": [ - -1, - 42 - ], - "description": "The type of this generator" - }, - "title": { - "type": "string" - }, - "target": { - "type": "string" - }, - "targetTagID": { - "type": "integer", - "description": "The tag of this generator's settings" - }, - "Status": { - "type": "object", - "properties": { - "itemsCount": { - "type": "integer" - }, - "itemsCompleteCount": { - "type": "integer" - }, - "itemsSuccessfulCount": { - "type": "integer" - }, - "totalSize": { - "type": "integer" - }, - "state": { - "type": "string", - "enum": [ - "pending", - "complete", - "failed" - ] - } - } - }, - "MediaSettings": { - "type": "object", - "properties": { - "directPlay": { - "type": "boolean" - }, - "directStream": { - "type": "boolean" - }, - "directStreamAudio": { - "type": "boolean" - }, - "autoAdjustQuality": { - "type": "boolean" - }, - "videoQuality": { - "type": "integer", - "minimum": 0, - "maximum": 100 - }, - "videoBitrate": { - "type": "integer" - }, - "maxVideoBitrate": { - "type": "integer" - }, - "musicBitrate": { - "type": "integer" - }, - "peakBitrate": { - "type": "integer" - }, - "photoQuality": { - "type": "integer", - "minimum": 0, - "maximum": 100 - }, - "subtitles": { - "type": "string", - "enum": [ - "auto", - "burn", - "none", - "sidecar", - "embedded", - "segmented" - ] - }, - "advancedSubtitles": { - "type": "string", - "enum": [ - "auto", - "burn", - "none", - "sidecar", - "embedded", - "segmented" - ] - }, - "autoAdjustSubtitle": { - "type": "boolean" - }, - "videoResolution": { - "type": "string" - }, - "photoResolution": { - "type": "string" - }, - "audioChannelCount": { - "type": "integer" - }, - "subtitleSize": { - "type": "integer" - }, - "audioBoost": { - "type": "integer" - }, - "secondsPerSegment": { - "type": "integer" - }, - "disableResolutionRotation": { - "type": "boolean" - } - } - }, - "Policy": { - "type": "object", - "properties": { - "scope": { - "type": "string", - "enum": [ - "all", - "count" - ] - }, - "value": { - "type": "integer", - "description": "If the scope is count, the number of items to optimize" - }, - "unwatched": { - "type": "boolean", - "description": "True if only unwatched items are optimized" - } - } - }, - "Location": { - "type": "object", - "properties": { - "uri": { - "type": "string" - }, - "librarySectionID": { - "type": "integer" - } - } - }, - "Device": { - "type": "object", - "properties": { - "profile": { - "type": "string" - } - } - } - } - } - } - } - } - ] - } - } - }, - "examples": { - "optimizeGenerator": { - "value": { - "MediaContainer": { - "size": 1, - "Item": [ - { - "composite": "/playlists/3215/items/467/composite/1716402079", - "id": 467, - "type": 42, - "title": "Jack-Jack Attack", - "target": "Optimized for TV", - "targetTagID": 2, - "Status": { - "itemsCount": 1, - "itemsCompleteCount": 1, - "itemsSuccessfulCount": 1, - "totalSize": 196226904, - "state": "complete" - }, - "MediaSettings": { - "videoQuality": 99, - "videoBitrate": 8000, - "maxVideoBitrate": 8000, - "subtitles": "auto", - "advancedSubtitles": "auto", - "videoResolution": "1920x1080" - }, - "Policy": { - "scope": "all", - "unwatched": false - }, - "Location": { - "uri": "library://82503060-0d68-4603-b594-8b071d54819e/item/%2Flibrary%2Fmetadata%2F146", - "librarySectionID": 1 - }, - "Device": { - "profile": "Universal TV" - } - } - ] - } - } - } - } - } - } - }, - "400": { - "$ref": "#/components/responses/400" - }, - "404": { - "description": "Playlist not found (or user may not have permission to access playlist) or generator not found", - "content": { - "text/html": { - "examples": { - "notFound": { - "summary": "Not Found", - "value": "Not Found

404 Not Found

" - } - } - } - } - } - } - }, - "delete": { - "tags": [ - "Library Playlists" - ], - "summary": "Delete a Generator", - "description": "Deletes an item from a playlist. Only works with dumb playlists.", - "operationId": "playlistDeleteItemsGenerator", - "parameters": [ - { - "in": "path", - "name": "playlistId", - "schema": { - "type": "integer" - }, - "description": "The ID of the playlist", - "required": true - }, - { - "in": "path", - "name": "generatorId", - "schema": { - "type": "integer" - }, - "description": "The generator item ID to delete.", - "required": true - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/slash-post-responses-200" - }, - "400": { - "$ref": "#/components/responses/400" - }, - "404": { - "description": "Playlist not found (or user may not have permission to access playlist) or generator not found", - "content": { - "text/html": { - "examples": { - "notFound": { - "summary": "Not Found", - "value": "Not Found

404 Not Found

" - } - } - } - } - } - } - }, - "put": { - "tags": [ - "Library Playlists" - ], - "summary": "Modify a Generator", - "description": "Modify a playlist generator. Only used for optimizer", - "operationId": "playlistPutItemsGenerator", - "parameters": [ - { - "in": "path", - "name": "playlistId", - "schema": { - "type": "integer" - }, - "description": "The ID of the playlist", - "required": true - }, - { - "in": "path", - "name": "generatorId", - "schema": { - "type": "integer" - }, - "description": "The generator item ID to modify.", - "required": true - }, - { - "in": "query", - "name": "Item", - "schema": { - "type": "object", - "properties": { - "type": { - "type": "integer" - }, - "title": { - "type": "string" - }, - "target": { - "type": "string" - }, - "targetTagID": { - "type": "integer" - }, - "locationID": { - "type": "integer" - }, - "Location": { - "type": "object", - "properties": { - "uri": { - "type": "string" - } - } - }, - "Policy": { - "type": "object", - "properties": { - "scope": { - "type": "string", - "enum": [ - "all", - "count" - ] - }, - "value": { - "type": "integer" - }, - "unwatched": { - "type": "integer", - "enum": [ - 0, - 1 - ] - } - } - } - } - }, - "explode": true, - "style": "deepObject", - "example": { - "type": 42, - "title": "Jack-Jack Attack", - "target": "", - "targetTagID": 1, - "locationID": -1, - "Location": { - "uri": "library://82503060-0d68-4603-b594-8b071d54819e/item/%2Flibrary%2Fmetadata%2F146" - }, - "Policy": { - "scope": "all", - "value": "", - "unwatched": 0 - } - }, - "description": "Note: OpenAPI cannot properly render this query parameter example ([See GHI](https://github.com/OAI/OpenAPI-Specification/issues/1706)). It should be rendered as:\n\nItem[type]=42&Item[title]=Jack-Jack Attack&Item[target]=&Item[targetTagID]=1&Item[locationID]=-1&Item[Location][uri]=library://82503060-0d68-4603-b594-8b071d54819e/item//library/metadata/146&Item[Policy][scope]=all&Item[Policy][value]=&Item[Policy][unwatched]=0\n" - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/slash-post-responses-200" - }, - "400": { - "$ref": "#/components/responses/400" - }, - "404": { - "description": "Playlist not found (or user may not have permission to access playlist) or generator not found", - "content": { - "text/html": { - "examples": { - "notFound": { - "summary": "Not Found", - "value": "Not Found

404 Not Found

" - } - } - } - } - } - } - } - }, - "/playlists/{playlistId}/items/{generatorId}/items": { - "get": { - "tags": [ - "Library Playlists" - ], - "summary": "Get a playlist generator's items", - "description": "Get a playlist generator's items", - "operationId": "playlistGetItemsGeneratorItems", - "parameters": [ - { - "in": "path", - "name": "playlistId", - "schema": { - "type": "integer" - }, - "description": "The ID of the playlist", - "required": true - }, - { - "in": "path", - "name": "generatorId", - "schema": { - "type": "integer" - }, - "description": "The generator item ID to delete.", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "MediaContainer": { - "allOf": [ - { - "$ref": "#/components/schemas/MediaContainer" - }, - { - "type": "object", - "properties": { - "Metadata": { - "allOf": [ - { - "$ref": "#/components/schemas/metadata" - }, - { - "type": "object", - "properties": { - "processingState": { - "type": "string", - "enum": [ - "processed", - "completed", - "tombstoned", - "disabled", - "error", - "pending" - ], - "description": "The state of processing if this generator is part of an optimizer playlist" - }, - "processingStateContext": { - "type": "string", - "enum": [ - "good", - "sourceFileUnavailable", - "sourceFileMetadataError", - "clientProfileError", - "ioError", - "transcoderError", - "unknownError", - "mediaAnalysisError", - "downloadFailed", - "accessDenied", - "cannotTranscode", - "codecInstallError" - ], - "description": "The error which could have occurred (or `good`)" - } - } - } - ] - } - } - } - ] - } - } - }, - "examples": { - "optimizeGenerator": { - "value": { - "MediaContainer": { - "size": 1, - "Metadata": { - "processingState": "processed", - "processingStateContext": "good", - "ratingKey": "146", - "key": "/library/metadata/146", - "guid": "plex://movie/5d9f3505ca3253001ef27c9e", - "slug": "jack-jack-attack", - "studio": "Pixar", - "type": "movie", - "title": "Jack-Jack Attack", - "contentRating": "Not Rated", - "summary": "In a dark room, Agent Rick Dicker interrogates Kari, a young girl with braces. He asks what happened. Kari explains that she was babysitting for an infant, Jack-Jack, who seems able to levitate, float, pass through walls, catch fire, and cause havoc. Kari stays nimble but barely holds on until an odd young man with orange hair and an \"S\" on his shirt rings the bell.", - "audienceRating": 7.6, - "year": 2008, - "thumb": "/library/metadata/146/thumb/1715112830", - "art": "/library/metadata/146/art/1715112830", - "duration": 300000, - "originallyAvailableAt": "2008-01-11", - "addedAt": 1657899281, - "updatedAt": 1715112830, - "audienceRatingImage": "imdb://image.rating" - } - } - } - } - } - } - } - }, - "400": { - "$ref": "#/components/responses/400" - }, - "404": { - "description": "Playlist not found (or user may not have permission to access playlist) or generator not found", - "content": { - "text/html": { - "examples": { - "notFound": { - "summary": "Not Found", - "value": "Not Found

404 Not Found

" - } - } - } - } - } - } - } - }, - "/playlists/{playlistId}/items/{generatorId}/{metadataId}/{action}": { - "put": { - "tags": [ - "Library Playlists" - ], - "summary": "Reprocess a generator", - "description": "Make a generator reprocess (refresh)", - "operationId": "playlistPutItemsGeneratorReprocess", - "parameters": [ - { - "in": "path", - "name": "playlistId", - "schema": { - "type": "integer" - }, - "description": "The ID of the playlist", - "required": true - }, - { - "in": "path", - "name": "generatorId", - "schema": { - "type": "integer" - }, - "description": "The generator item ID to act on", - "required": true - }, - { - "in": "path", - "name": "metadataId", - "schema": { - "type": "integer" - }, - "description": "The metadata item ID to act on", - "required": true - }, - { - "in": "path", - "name": "action", - "schema": { - "type": "string", - "enum": [ - "reprocess", - "disable", - "enable" - ] - }, - "description": "The action to perform for this item on this optimizer queue", - "required": true - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/200" - }, - "400": { - "$ref": "#/components/responses/400" - }, - "404": { - "description": "Playlist not found (or user may not have permission to access playlist) or generator or metadata item not found", - "content": { - "text/html": { - "examples": { - "notFound": { - "summary": "Not Found", - "value": "Not Found

404 Not Found

" - } - } - } - } - } - } - } - }, - "/playlists/{playlistId}/items/{playlistItemId}/move": { - "put": { - "tags": [ - "Library Playlists" - ], - "summary": "Moving items in a playlist", - "description": "Moves an item in a playlist. Only works with dumb playlists.", - "operationId": "playlistPutItemsMove", - "parameters": [ - { - "in": "path", - "name": "playlistId", - "schema": { - "type": "integer" - }, - "description": "The ID of the playlist", - "required": true - }, - { - "in": "path", - "name": "playlistItemId", - "schema": { - "type": "integer" - }, - "description": "The playlist item ID to move.", - "required": true - }, - { - "in": "query", - "name": "after", - "schema": { - "type": "integer" - }, - "description": "The playlist item ID to insert the new item after. If not provided, item is moved to beginning of playlist" - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/slash-post-responses-200" - }, - "400": { - "$ref": "#/components/responses/400" - }, - "404": { - "description": "Playlist not found (or user may not have permission to access playlist)", - "content": { - "text/html": { - "examples": { - "notFound": { - "summary": "Not Found", - "value": "Not Found

404 Not Found

" - } - } - } - } - } - } - } - }, - "/playlists/{playlistId}/generators": { - "get": { - "tags": [ - "Library Playlists" - ], - "summary": "Get a playlist's generators", - "description": "Get all the generators in a playlist", - "operationId": "playlistGetGenerators", - "parameters": [ - { - "in": "path", - "name": "playlistId", - "schema": { - "type": "integer" - }, - "description": "The ID of the playlist", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "MediaContainer": { - "allOf": [ - { - "$ref": "#/components/schemas/MediaContainer" - }, - { - "type": "object", - "properties": { - "PlayQueueGenerator": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "playlistID": { - "type": "integer" - }, - "uri": { - "type": "string", - "description": "The URI indicating the search for this generator" - }, - "createdAt": { - "type": "integer" - }, - "updatedAt": { - "type": "integer" - }, - "changedAt": { - "type": "integer" - }, - "type": { - "type": "integer", - "enum": [ - -1, - 42 - ], - "description": "The type of playlist generator.\n\n - -1: A smart playlist generator\n - 42: A optimized version generator\n" - } - } - } - } - } - } - ] - } - } - }, - "examples": { - "aGenerator": { - "value": { - "MediaContainer": { - "size": 1, - "PlayQueueGenerator": [ - { - "id": 468, - "playlistID": 4787, - "uri": "library://x/directory/%2Flibrary%2Fsections%2F1%2Fall%3Ftype%3D1%26sort%3DtitleSort%26addedAt%253C%253C%3D2024-01-01", - "createdAt": 1716403491, - "updatedAt": 1716403491, - "changedAt": 509850, - "type": -1 - } - ] - } - } - } - } - } - } - }, - "404": { - "description": "Playlist not found (or user may not have permission to access playlist) or generator not found", - "content": { - "text/html": { - "examples": { - "notFound": { - "summary": "Not Found", - "value": "Not Found

404 Not Found

" - } - } - } - } - } - } - } - }, - "/playlists/upload": { - "post": { - "tags": [ - "Library Playlists" - ], - "security": [ - { - "user_token": [ - "admin" - ] - } - ], - "summary": "Upload", - "description": "Imports m3u playlists by passing a path on the server to scan for m3u-formatted playlist files, or a path to a single playlist file.", - "operationId": "playlistPostUpload", - "parameters": [ - { - "in": "query", - "name": "path", - "schema": { - "type": "string" - }, - "example": "/home/barkley/playlist.m3u", - "description": "Absolute path to a directory on the server where m3u files are stored, or the absolute path to a playlist file on the server. If the `path` argument is a directory, that path will be scanned for playlist files to be processed. Each file in that directory creates a separate playlist, with a name based on the filename of the file that created it. The GUID of each playlist is based on the filename. If the `path` argument is a file, that file will be used to create a new playlist, with the name based on the filename of the file that created it. The GUID of each playlist is based on the filename." - }, - { - "in": "query", - "name": "force", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "description": "Force overwriting of duplicate playlists. By default, a playlist file uploaded with the same path will overwrite the existing playlist. The `force` argument is used to disable overwriting. If the `force` argument is set to 0, a new playlist will be created suffixed with the date and time that the duplicate was uploaded." - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/200" - }, - "403": { - "$ref": "#/components/responses/200" - }, - "500": { - "description": "The playlist could not be imported", - "content": { - "text/html": { - "examples": { - "badParam": { - "summary": "Processing failed inside the server", - "value": "Internal Server Error

500 Internal Server Error

" - } - } - } - } - } - } - } - }, - "/playQueues": { - "post": { - "tags": [ - "Play Queue" - ], - "summary": "Create a play queue", - "description": "Makes a new play queue for a device. The source of the playqueue can either be a URI, or a playlist. The response is a media container with the initial items in the queue. Each item in the queue will be a regular item but with `playQueueItemID` - a unique ID since the queue could have repeated items with the same `ratingKey`.\nNote: Either `uri` or `playlistID` must be specified", - "operationId": "playQueuePostSlash", - "parameters": [ - { - "in": "query", - "name": "uri", - "schema": { - "type": "string" - }, - "description": "The content URI for what we're playing." - }, - { - "in": "query", - "name": "playlistID", - "schema": { - "type": "integer" - }, - "description": "the ID of the playlist we're playing." - }, - { - "in": "query", - "name": "type", - "schema": { - "type": "string", - "enum": [ - "audio", - "video", - "photo" - ] - }, - "required": true, - "description": "The type of play queue to create" - }, - { - "in": "query", - "name": "key", - "schema": { - "type": "string" - }, - "description": "The key of the first item to play, defaults to the first in the play queue." - }, - { - "in": "query", - "name": "shuffle", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "description": "Whether to shuffle the playlist, defaults to 0." - }, - { - "in": "query", - "name": "repeat", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "description": "If the PQ is bigger than the window, fill any empty space with wraparound items, defaults to 0." - }, - { - "in": "query", - "name": "continuous", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "description": "Whether to create a continuous play queue (e.g. from an episode), defaults to 0." - }, - { - "in": "query", - "name": "extrasPrefixCount", - "schema": { - "type": "integer" - }, - "description": "Number of trailers to prepend a movie with not including the pre-roll. If omitted the pre-roll will not be returned in the play queue. When resuming a movie `extrasPrefixCount` should be omitted as a parameter instead of passing 0." - }, - { - "in": "query", - "name": "recursive", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "description": "Only applies to queues of type photo, whether to retrieve all descendent photos from an album or section, defaults to 1." - }, - { - "in": "query", - "name": "onDeck", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "description": "Only applies to queues of type show or seasons, whether to return a queue that is started on the On Deck episode if one exists. Otherwise begins the play queue on the beginning of the show or season." - } - ], - "responses": { - "200": { - "description": "OK", - "headers": { - "X-Plex-Container-Total-Size": { - "description": "Provided on all MediaContainer objects indicating the total size of objects available", - "schema": { - "type": "integer" - } - }, - "X-Plex-Container-Start": { - "description": "Provided on all MediaContainer objects indicating the offset of where this container page starts", - "schema": { - "type": "integer" - } - } - }, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "MediaContainer": { - "allOf": [ - { - "$ref": "#/components/schemas/properties-MediaContainer" - }, - { - "type": "object", - "properties": { - "playQueueID": { - "type": "integer", - "description": "The ID of the play queue, which is used in subsequent requests." - }, - "playQueueSelectedItemID": { - "type": "integer", - "description": "The queue item ID of the currently selected item." - }, - "playQueueSelectedItemOffset": { - "type": "integer", - "description": "The offset of the selected item in the play queue, from the beginning of the queue." - }, - "playQueueSelectedMetadataItemID": { - "type": "integer", - "description": "The metadata item ID of the currently selected item (matches `ratingKey` attribute in metadata item if the media provider is a library)." - }, - "playQueueShuffled": { - "type": "boolean", - "description": "Whether or not the queue is shuffled." - }, - "playQueueSourceURI": { - "type": "string", - "description": "The original URI used to create the play queue." - }, - "playQueueTotalCount": { - "type": "integer", - "description": "The total number of items in the play queue." - }, - "playQueueVersion": { - "type": "integer", - "description": "The version of the play queue. It increments every time a change is made to the play queue to assist clients in knowing when to refresh." - }, - "playQueueLastAddedItemID": { - "type": "string", - "description": "Defines where the \"Up Next\" region starts" - } - } - } - ] - } - } - }, - "examples": { - "playQueue": { - "description": "A play queue", - "value": { - "MediaContainer": { - "playQueueID": 9631, - "playQueueSelectedItemID": 2211762, - "playQueueSelectedItemOffset": 0, - "playQueueSelectedMetadataItemID": 1941458, - "playQueueShuffled": false, - "playQueueSourceURI": "library://2d8ea42e-7845-498b-b349-095ecaa3c451/item/%2Flibrary%2Fmetadata%2F1906642", - "playQueueTotalCount": 22, - "playQueueVersion": 1, - "size": 21, - "Metadata": [ - { - "playQueueItemID": 2211762, - "restOf": "metadataHere" - } - ] - } - } - } - } - } - } - }, - "400": { - "$ref": "#/components/responses/400" - } - } - } - }, - "/playQueues/{playQueueId}": { - "get": { - "tags": [ - "Play Queue" - ], - "summary": "Retrieve a play queue", - "description": "Retrieves the play queue, centered at current item. This can be treated as a regular container by play queue-oblivious clients, but they may wish to request a large window onto the queue since they won't know to refresh.", - "operationId": "playQueueQueueGetSlash", - "parameters": [ - { - "in": "path", - "name": "playQueueId", - "schema": { - "type": "integer" - }, - "required": true, - "description": "The ID of the play queue." - }, - { - "in": "query", - "name": "own", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "description": "If the server should transfer ownership to the requesting client (used in remote control scenarios)." - }, - { - "in": "query", - "name": "center", - "schema": { - "type": "string" - }, - "description": "The play queue item ID for the center of the window - this doesn't change the current selected item." - }, - { - "in": "query", - "name": "window", - "schema": { - "type": "integer" - }, - "description": "How many items on each side of the center of the window" - }, - { - "in": "query", - "name": "includeBefore", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "description": "Whether to include the items before the center (if 0, center is not included either), defaults to 1." - }, - { - "in": "query", - "name": "includeAfter", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "description": "Whether to include the items after the center (if 0, center is not included either), defaults to 1." - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/slash-post-responses-200" - }, - "400": { - "$ref": "#/components/responses/400" - }, - "404": { - "description": "Play queue not found", - "content": { - "text/html": { - "examples": { - "notFound": { - "summary": "Not Found", - "value": "Not Found

404 Not Found

" - } - } - } - } - } - } - }, - "put": { - "tags": [ - "Play Queue" - ], - "summary": "Add a generator or playlist to a play queue", - "operationId": "playQueueQueuePutSlash", - "description": "Adds an item to a play queue (e.g. party mode). Increments the version of the play queue. Takes the following parameters (`uri` and `playlistID` are mutually exclusive). Returns the modified play queue.", - "parameters": [ - { - "in": "path", - "name": "playQueueId", - "schema": { - "type": "integer" - }, - "required": true, - "description": "The ID of the play queue." - }, - { - "in": "query", - "name": "uri", - "schema": { - "type": "string" - }, - "description": "The content URI for what we're adding to the queue." - }, - { - "in": "query", - "name": "playlistID", - "schema": { - "type": "string" - }, - "description": "The ID of the playlist to add to the playQueue." - }, - { - "in": "query", - "name": "next", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "description": "Play this item next (defaults to 0 - queueing at the end of manually queued items)." - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/slash-post-responses-200" - }, - "400": { - "$ref": "#/components/responses/400" - }, - "404": { - "description": "Play queue not found", - "content": { - "text/html": { - "examples": { - "notFound": { - "summary": "Not Found", - "value": "Not Found

404 Not Found

" - } - } - } - } - } - } - } - }, - "/playQueues/{playQueueId}/items": { - "delete": { - "tags": [ - "Play Queue" - ], - "summary": "Clear a play queue", - "operationId": "playQueueQueueDeleteItems", - "description": "Deletes all items in the play queue, and increases the version of the play queue.", - "parameters": [ - { - "in": "path", - "name": "playQueueId", - "schema": { - "type": "integer" - }, - "description": "The ID of the play queue.", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/mediaContainerWithPlaylistMetadata" - }, - "examples": { - "playQueue": { - "description": "An empty play queue", - "value": { - "MediaContainer": { - "playQueueID": 9631, - "playQueueSelectedItemID": 2211762, - "playQueueSelectedItemOffset": 0, - "playQueueSelectedMetadataItemID": 1941458, - "playQueueShuffled": false, - "playQueueTotalCount": 0, - "playQueueVersion": 2, - "size": 0 - } - } - } - } - } - } - } - } - } - }, - "/playQueues/{playQueueId}/items/{playQueueItemId}": { - "delete": { - "tags": [ - "Play Queue" - ], - "summary": "Delete an item from a play queue", - "operationId": "playQueueQueueDeleteItemsItem", - "description": "Deletes an item in a play queue. Increments the version of the play queue. Returns the modified play queue.", - "parameters": [ - { - "in": "path", - "name": "playQueueId", - "schema": { - "type": "integer" - }, - "description": "The ID of the play queue.", - "required": true - }, - { - "in": "path", - "name": "playQueueItemId", - "schema": { - "type": "integer" - }, - "description": "The play queue item ID to delete.", - "required": true - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/slash-post-responses-200" - }, - "400": { - "$ref": "#/components/responses/400" - }, - "404": { - "description": "Play queue not found", - "content": { - "text/html": { - "examples": { - "notFound": { - "summary": "Not Found", - "value": "Not Found

404 Not Found

" - } - } - } - } - } - } - } - }, - "/playQueues/{playQueueId}/items/{playQueueItemId}/move": { - "put": { - "tags": [ - "Play Queue" - ], - "summary": "Move an item in a play queue", - "operationId": "playQueueQueuePutItemsMove", - "description": "Moves an item in a play queue, and increases the version of the play queue. Returns the modified play queue.", - "parameters": [ - { - "in": "path", - "name": "playQueueId", - "schema": { - "type": "integer" - }, - "description": "The ID of the play queue.", - "required": true - }, - { - "in": "path", - "name": "playQueueItemId", - "schema": { - "type": "integer" - }, - "description": "The play queue item ID to delete.", - "required": true - }, - { - "in": "query", - "name": "after", - "schema": { - "type": "integer" - }, - "description": "The play queue item ID to insert the new item after. If not present, moves to the beginning." - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/slash-post-responses-200" - }, - "400": { - "$ref": "#/components/responses/400" - }, - "404": { - "description": "Play queue or queue item not found", - "content": { - "text/html": { - "examples": { - "notFound": { - "summary": "Not Found", - "value": "Not Found

404 Not Found

" - } - } - } - } - } - } - } - }, - "/playQueues/{playQueueId}/reset": { - "put": { - "tags": [ - "Play Queue" - ], - "summary": "Reset a play queue", - "operationId": "playQueuePlayQueueReset", - "description": "Reset a play queue to the first item being the current item", - "parameters": [ - { - "in": "path", - "name": "playQueueId", - "schema": { - "type": "integer" - }, - "description": "The ID of the play queue.", - "required": true - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/slash-post-responses-200" - }, - "400": { - "$ref": "#/components/responses/400" - }, - "404": { - "description": "Play queue not found", - "content": { - "text/html": { - "examples": { - "notFound": { - "summary": "Not Found", - "value": "Not Found

404 Not Found

" - } - } - } - } - } - } - } - }, - "/playQueues/{playQueueId}/shuffle": { - "put": { - "tags": [ - "Play Queue" - ], - "summary": "Shuffle a play queue", - "operationId": "playQueueQueuePutItemsShuffle", - "description": "Shuffle a play queue (or reshuffles if already shuffled). The currently selected item is maintained. Note that this is currently only supported for play queues *without* an Up Next area. Returns the modified play queue.", - "parameters": [ - { - "in": "path", - "name": "playQueueId", - "schema": { - "type": "integer" - }, - "description": "The ID of the play queue.", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/mediaContainerWithPlaylistMetadata" - }, - "examples": { - "playQueue": { - "description": "A shuffled play queue", - "value": { - "MediaContainer": { - "playQueueID": 9631, - "playQueueSelectedItemID": 2211762, - "playQueueSelectedItemOffset": 0, - "playQueueSelectedMetadataItemID": 1941458, - "playQueueShuffled": true, - "playQueueSourceURI": "library://2d8ea42e-7845-498b-b349-095ecaa3c451/item/%2Flibrary%2Fmetadata%2F1906642", - "playQueueTotalCount": 22, - "playQueueVersion": 1, - "size": 21, - "Metadata": [ - { - "playQueueItemID": 2211762, - "restOf": "metadataHere" - } - ] - } - } - } - } - } - } - }, - "400": { - "$ref": "#/components/responses/400" - }, - "404": { - "description": "Play queue not found or current item not found", - "content": { - "text/html": { - "examples": { - "notFound": { - "summary": "Not Found", - "value": "Not Found

404 Not Found

" - } - } - } - } - } - } - } - }, - "/playQueues/{playQueueId}/unshuffle": { - "put": { - "tags": [ - "Play Queue" - ], - "summary": "Unshuffle a play queue", - "operationId": "playQueueQueuePutItemsUnshuffle", - "description": "Unshuffles a play queue and restores \"natural order\". Note that this is currently only supported for play queues *without* an Up Next area. Returns the modified play queue.", - "parameters": [ - { - "in": "path", - "name": "playQueueId", - "schema": { - "type": "integer" - }, - "description": "The ID of the play queue.", - "required": true - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/slash-post-responses-200" - }, - "400": { - "$ref": "#/components/responses/400" - }, - "404": { - "description": "Play queue not found or current item not found", - "content": { - "text/html": { - "examples": { - "notFound": { - "summary": "Not Found", - "value": "Not Found

404 Not Found

" - } - } - } - } - } - } - } - }, - "/security/resources": { - "get": { - "tags": [ - "General" - ], - "summary": "Get Source Connection Information", - "description": "If a caller requires connection details and a transient token for a source that is known to the server, for example a cloud media provider or shared PMS, then this endpoint can be called. This endpoint is only accessible with either an admin token or a valid transient token generated from an admin token.", - "operationId": "securityGetResources", - "parameters": [ - { - "in": "query", - "name": "source", - "schema": { - "type": "string" - }, - "required": true, - "description": "The source identifier with an included prefix." - }, - { - "in": "query", - "name": "refresh", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "description": "Force refresh" - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "MediaContainer": { - "allOf": [ - { - "$ref": "#/components/schemas/MediaContainer" - }, - { - "type": "object", - "properties": { - "Device": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "clientIdentifier": { - "type": "string" - }, - "accessToken": { - "type": "string" - }, - "Connection": { - "type": "array", - "items": { - "type": "object", - "properties": { - "protocol": { - "type": "string" - }, - "address": { - "type": "string" - }, - "uri": { - "type": "string" - }, - "port": { - "type": "integer" - }, - "local": { - "type": "boolean", - "description": "Indicates if the connection is the server's LAN address" - }, - "relay": { - "type": "boolean", - "description": "Indicates the connection is over a relayed connection" - } - } - } - } - } - } - } - } - ] - } - } - }, - "examples": { - "resource": { - "description": "An example of a resource for a remote server", - "value": { - "MediaContainer": { - "size": 1, - "Device": { - "name": "PlexCorp (plex-corp)", - "clientIdentifier": "243b471948ace337a8f92f129ec97d1902fcb1df", - "accessToken": "transient-fa75f159-b9d2-42b6-8fbd-1761c7a4195a", - "Connection": [ - { - "protocol": "https", - "address": "10.0.2.123", - "uri": "https://10-0-2-123.93b10b279ff8456686414add109854cd.plex.direct:32400", - "port": 32400, - "local": true - }, - { - "protocol": "https", - "address": "64.71.188.222", - "uri": "https://64-71-188-222.93b10b279ff8456686414add109854cd.plex.direct:32403", - "port": 32403, - "local": false - }, - { - "protocol": "https", - "address": "139.162.158.105", - "uri": "https://139-162-158-105.93b10b279ff8456686414add109854cd.plex.direct:8443", - "port": 8443, - "local": false, - "relay": true - } - ] - } - } - } - } - } - } - } - }, - "400": { - "description": "A query param is missing or the wrong value", - "content": { - "text/html": { - "examples": { - "badRequest": { - "summary": "A parameter has a bad value or required parameter is missing", - "value": "Bad Request

400 Bad Request

" - } - } - } - } - }, - "403": { - "description": "Invalid or no token provided or a transient token could not be created", - "content": { - "text/html": { - "examples": { - "forbidden": { - "summary": "Forbidden", - "value": "Forbidden

403 Forbidden

" - } - } - } - } - } - } - } - }, - "/security/token": { - "post": { - "tags": [ - "General" - ], - "summary": "Get Transient Tokens", - "description": "This endpoint provides the caller with a temporary token with the same access level as the caller's token. These tokens are valid for up to 48 hours and are destroyed if the server instance is restarted.\nNote: This endpoint responds to all HTTP verbs but POST in preferred", - "operationId": "securityPostToken", - "parameters": [ - { - "in": "query", - "name": "type", - "schema": { - "type": "string", - "enum": [ - "delegation" - ] - }, - "required": true, - "description": "The value `delegation` is the only supported `type` parameter." - }, - { - "in": "query", - "name": "scope", - "schema": { - "type": "string", - "enum": [ - "all" - ] - }, - "required": true, - "description": "The value `all` is the only supported `scope` parameter." - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "MediaContainer": { - "allOf": [ - { - "$ref": "#/components/schemas/mediaContainer" - }, - { - "type": "object", - "properties": { - "token": { - "type": "string", - "description": "The transient token" - } - } - } - ] - } - } - }, - "examples": { - "token": { - "description": "An example of a transient token", - "value": { - "MediaContainer": { - "size": 0, - "token": "transient-90904684-f91a-4391-8bf7-e0dfa7240285" - } - } - } - } - } - } - }, - "400": { - "description": "A query param is missing or the wrong value", - "content": { - "text/html": { - "examples": { - "badRequest": { - "summary": "A parameter has a bad value or required parameter is missing", - "value": "Bad Request

400 Bad Request

" - } - } - } - } - }, - "403": { - "description": "Invalid or no token provided or a transient token could not be created", - "content": { - "text/html": { - "examples": { - "forbidden": { - "summary": "Forbidden", - "value": "Forbidden

403 Forbidden

" - } - } - } - } - } - } - } - }, - "/services/ultrablur/colors": { - "get": { - "tags": [ - "UltraBlur" - ], - "summary": "Get UltraBlur Colors", - "description": "Retrieves the four colors extracted from an image for clients to use to generate an ultrablur image.", - "operationId": "ultraBlurGetColors", - "parameters": [ - { - "in": "query", - "name": "url", - "schema": { - "type": "string" - }, - "example": "/library/metadata/217745/art/1718931408", - "description": "Url for image which requires color extraction. Can be relative PMS library path or absolute url." - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "MediaContainer": { - "allOf": [ - { - "$ref": "#/components/schemas/MediaContainer" - }, - { - "type": "object", - "properties": { - "UltraBlurColors": { - "type": "array", - "items": { - "type": "object", - "properties": { - "topLeft": { - "type": "string", - "description": "The color (hex) for the top left quadrant." - }, - "topRight": { - "type": "string", - "description": "The color (hex) for the top right quadrant." - }, - "bottomRight": { - "type": "string", - "description": "The color (hex) for the bottom right quadrant." - }, - "bottomLeft": { - "type": "string", - "description": "The color (hex) for the bottom left quadrant." - } - } - } - } - } - } - ] - } - } - }, - "examples": { - "colors": { - "description": "Colors extracted from provided image url.", - "value": { - "MediaContainer": { - "size": 1, - "UltraBlurColors": [ - { - "topLeft": "44181b", - "topRight": "55140b", - "bottomRight": "9a3936", - "bottomLeft": "31313a" - } - ] - } - } - } - } - } - } - }, - "404": { - "description": "The image url could not be found.", - "content": { - "text/html": { - "examples": { - "notFound": { - "summary": "Not Found", - "value": "Not Found

404 Not Found

" - } - } - } - } - }, - "500": { - "description": "The server was unable to successfully extract the UltraBlur colors.", - "content": { - "text/html": { - "examples": { - "badParam": { - "summary": "Processing failed inside the server", - "value": "Internal Server Error

500 Internal Server Error

" - } - } - } - } - } - } - } - }, - "/services/ultrablur/image": { - "get": { - "tags": [ - "UltraBlur" - ], - "summary": "Get UltraBlur Image", - "description": "Retrieves a server-side generated UltraBlur image based on the provided color inputs. Clients should always call this via the photo transcoder endpoint.", - "operationId": "ultraBlurGetImage", - "parameters": [ - { - "in": "query", - "name": "topLeft", - "schema": { - "type": "string" - }, - "example": "3f280a", - "description": "The base color (hex) for the top left quadrant." - }, - { - "in": "query", - "name": "topRight", - "schema": { - "type": "string" - }, - "example": "6b4713", - "description": "The base color (hex) for the top right quadrant." - }, - { - "in": "query", - "name": "bottomRight", - "schema": { - "type": "string" - }, - "example": "0f2a43", - "description": "The base color (hex) for the bottom right quadrant." - }, - { - "in": "query", - "name": "bottomLeft", - "schema": { - "type": "string" - }, - "example": "1c425d", - "description": "The base color (hex) for the bottom left quadrant." - }, - { - "in": "query", - "name": "width", - "schema": { - "type": "integer", - "minimum": 320, - "maximum": 3840 - }, - "example": 1920, - "description": "Width in pixels for the image." - }, - { - "in": "query", - "name": "height", - "schema": { - "type": "integer", - "minimum": 240, - "maximum": 2160 - }, - "example": 1080, - "description": "Height in pixels for the image." - }, - { - "in": "query", - "name": "noise", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "example": 1, - "description": "Whether to add noise to the ouput image. Noise can reduce color banding with the gradients. Image sizes with noise will be larger." - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "image/png": { - "schema": { - "type": "string", - "format": "binary" - } - } - } - }, - "400": { - "description": "Requested width and height parameters are out of bounds (maximum 3840 x 2160)", - "content": { - "text/html": { - "examples": { - "badRequest": { - "summary": "A parameter has a bad value or required parameter is missing", - "value": "Bad Request

400 Bad Request

" - } - } - } - } - } - } - } - }, - "/status/sessions": { - "get": { - "tags": [ - "Status" - ], - "security": [ - { - "user_token": [ - "admin" - ] - } - ], - "summary": "List Sessions", - "description": "List all current playbacks on this server", - "operationId": "statusGetSlash", - "responses": { - "200": { - "description": "OK", - "headers": { - "X-Plex-Container-Total-Size": { - "description": "Provided on all MediaContainer objects indicating the total size of objects available", - "schema": { - "type": "integer" - } - }, - "X-Plex-Container-Start": { - "description": "Provided on all MediaContainer objects indicating the offset of where this container page starts", - "schema": { - "type": "integer" - } - } - }, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "MediaContainer": { - "allOf": [ - { - "$ref": "#/components/schemas/properties-MediaContainer" - }, - { - "type": "object", - "properties": { - "Metadata": { - "type": "array", - "items": { - "allOf": [ - { - "type": "object", - "properties": { - "User": { - "type": "object", - "description": "The user playing the content", - "properties": { - "id": { - "type": "string", - "description": "The id of the user" - }, - "thumb": { - "type": "string", - "description": "Thumb image to display for the user" - }, - "title": { - "type": "string", - "description": "The username" - } - } - }, - "Player": { - "type": "object", - "description": "Info about the player being used", - "properties": { - "address": { - "type": "string", - "description": "The remote address" - }, - "machineIdentifier": { - "type": "string", - "description": "The identifier of the client" - }, - "model": { - "type": "string", - "description": "The model of the client" - }, - "platform": { - "type": "string", - "description": "The platform of the client" - }, - "platformVersion": { - "type": "string", - "description": "The platformVersion of the client" - }, - "product": { - "type": "string", - "description": "The product name of the client" - }, - "remotePublicAddress": { - "type": "string", - "description": "The client's public address" - }, - "state": { - "type": "string", - "description": "The client's last reported state" - }, - "title": { - "type": "string", - "description": "The title of the client" - }, - "vendor": { - "type": "string", - "description": "The vendor of the client" - }, - "version": { - "type": "string", - "description": "The version of the client" - }, - "local": { - "type": "boolean", - "description": "Indicating if the client is playing from the local LAN" - }, - "relayed": { - "type": "boolean", - "description": "Indicating if the client is playing over a relay connection" - }, - "secure": { - "type": "boolean", - "description": "Indicating if the client is playing over HTTPS" - }, - "userID": { - "type": "integer", - "description": "The id of the user" - } - } - }, - "Session": { - "type": "object", - "description": "Info about the playback session", - "properties": { - "id": { - "type": "string", - "description": "The id of the playback session" - }, - "bandwidth": { - "type": "integer", - "description": "The bandwidth used by this client's playback in kbps" - }, - "location": { - "type": "string", - "enum": [ - "lan", - "wan" - ], - "description": "The location of the client" - } - } - } - } - }, - { - "$ref": "#/components/schemas/metadata" - } - ] - } - } - } - } - ] - } - } - }, - "examples": { - "playlists": { - "description": "A playback session", - "value": { - "MediaContainer": { - "size": 1, - "Metadata": [ - { - "details": "omitted", - "Media": [ - { - "details": "omitted", - "Part": [ - { - "details": "omitted", - "decision": "directplay", - "Stream": [ - { - "details": "omitted", - "location": "direct" - }, - { - "details": "omitted", - "location": "direct" - } - ] - } - ] - } - ], - "User": { - "id": "123456789", - "thumb": "https://plex.tv/users/abcdef0123456789/avatar?c=1234567890", - "title": "username" - }, - "Player": { - "address": "127.0.0.1", - "machineIdentifier": "abcdefghijklmnopqrstuvwx", - "model": "Gecko", - "platform": "macos", - "platformVersion": "14.4", - "product": "Plex HTPC for Mac", - "remotePublicAddress": "1.2.3.4", - "state": "playing", - "title": "name.localdomain", - "vendor": "Plex", - "version": "1.58.0.106-d213ced1", - "local": true, - "relayed": false, - "secure": true, - "userID": 123456789 - }, - "Session": { - "id": "cdefghijklmnopqrstuvwxyz", - "bandwidth": 5212, - "location": "lan" - } - } - ] - } - } - } - } - } - } - } - } - } - }, - "/status/sessions/background": { - "get": { - "tags": [ - "Status" - ], - "security": [ - { - "user_token": [ - "admin" - ] - } - ], - "summary": "Get background tasks", - "description": "Get the list of all background tasks", - "operationId": "statusGetBackground", - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "MediaContainer": { - "allOf": [ - { - "$ref": "#/components/schemas/MediaContainer" - }, - { - "type": "object", - "properties": { - "TranscodeJob": { - "type": "array", - "items": { - "type": "object", - "properties": { - "generatorID": { - "type": "integer" - }, - "type": { - "type": "string", - "enum": [ - "transcode" - ] - }, - "key": { - "type": "string" - }, - "progress": { - "type": "number", - "minimum": 0, - "maximum": 100 - }, - "speed": { - "type": "number", - "description": "The speed of the transcode; 1.0 means real-time" - }, - "remaining": { - "type": "integer", - "description": "The number of seconds remaining in this job" - }, - "size": { - "type": "integer", - "description": "The size of the result so far" - }, - "title": { - "type": "string" - }, - "thumb": { - "type": "string" - }, - "ratingKey": { - "type": "string" - }, - "targetTagID": { - "type": "integer", - "description": "The tag associated with the job. This could be the tag containing the optimizer settings." - } - } - } - } - } - } - ] - } - } - }, - "examples": { - "optimizer": { - "description": "An example of the media optimizer running", - "value": { - "MediaContainer": { - "size": 1, - "TranscodeJob": [ - { - "generatorID": 467, - "type": "transcode", - "key": "/transcode/sessions/fb7b6af8-e75a-4fb4-9985-990e05f62119", - "progress": 42, - "speed": 20.299999237060547, - "remaining": 8, - "size": 82313268, - "title": "Jack-Jack Attack (2008)", - "thumb": "/library/metadata/146/thumb/1715112830", - "ratingKey": "146", - "targetTagID": 2 - } - ] - } - } - } - } - } - } - } - } - } - }, - "/status/sessions/history/all": { - "get": { - "tags": [ - "Status" - ], - "summary": "List Playback History", - "description": "List all playback history (Admin can see all users, others can only see their own).\nPagination should be used on this endpoint. Additionally this endpoint supports `includeFields`, `excludeFields`, `includeElements`, and `excludeElements` parameters.", - "operationId": "statusGetHistoryAll", - "parameters": [ - { - "in": "query", - "name": "accountID", - "schema": { - "type": "integer" - }, - "description": "The account id to restrict view history" - }, - { - "in": "query", - "name": "viewedAt", - "schema": { - "type": "integer" - }, - "description": "The time period to restrict history (typically of the form `viewedAt>=12456789`)" - }, - { - "in": "query", - "name": "librarySectionID", - "schema": { - "type": "integer" - }, - "description": "The library section id to restrict view history" - }, - { - "in": "query", - "name": "metadataItemID", - "schema": { - "type": "integer" - }, - "description": "The metadata item to restrict view history (can provide the id for a show to see all of that show's view history). Note this is translated to `metadata_items.id`, `parents.id`, or `grandparents.id` internally depending on the metadata type." - }, - { - "in": "query", - "name": "sort", - "schema": { - "type": "array", - "items": { - "type": "string" - } - }, - "example": "viewedAt:desc,accountID", - "description": "The field on which to sort. Multiple orderings can be specified separated by `,` and the direction specified following a `:` (`desc` or `asc`; `asc` is assumed if not provided). Note `metadataItemID` may not be used here." - } - ], - "responses": { - "200": { - "description": "OK", - "headers": { - "X-Plex-Container-Total-Size": { - "description": "Provided on all MediaContainer objects indicating the total size of objects available", - "schema": { - "type": "integer" - } - }, - "X-Plex-Container-Start": { - "description": "Provided on all MediaContainer objects indicating the offset of where this container page starts", - "schema": { - "type": "integer" - } - } - }, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "MediaContainer": { - "allOf": [ - { - "$ref": "#/components/schemas/properties-MediaContainer" - }, - { - "type": "object", - "properties": { - "Metadata": { - "type": "array", - "items": { - "type": "object", - "properties": { - "historyKey": { - "type": "string", - "description": "The key for this individual history item" - }, - "key": { - "type": "string", - "description": "The metadata key for the item played" - }, - "ratingKey": { - "type": "string", - "description": "The rating key for the item played" - }, - "librarySectionID": { - "type": "string", - "description": "The library section id containing the item played" - }, - "title": { - "type": "string", - "description": "The title of the item played" - }, - "type": { - "type": "string", - "description": "The metadata type of the item played" - }, - "thumb": { - "type": "string", - "description": "The thumb of the item played" - }, - "originallyAvailableAt": { - "type": "string", - "description": "The originally available at of the item played" - }, - "viewedAt": { - "type": "integer", - "description": "The time when the item was played" - }, - "accountID": { - "type": "integer", - "description": "The account id of this playback" - }, - "deviceID": { - "type": "integer", - "description": "The device id which played the item" - } - } - } - } - } - } - ] - } - } - }, - "examples": { - "aHistory": { - "description": "OK", - "value": { - "MediaContainer": { - "size": 1, - "totalSize": 33, - "offset": 0, - "Metadata": [ - { - "historyKey": "/status/sessions/history/12", - "key": "/library/metadata/1234", - "ratingKey": "1234", - "librarySectionID": "1", - "title": "My Wonderful Movie", - "type": "movie", - "thumb": "/library/metadata/1234/thumb/1234567890", - "originallyAvailableAt": "2023-01-01", - "viewedAt": 1345678901, - "accountID": 123456, - "deviceID": 12 - } - ] - } - } - } - } - } - } - } - } - } - }, - "/status/sessions/history/{historyId}": { - "get": { - "tags": [ - "Status" - ], - "security": [ - { - "user_token": [ - "admin" - ] - } - ], - "summary": "Get Single History Item", - "description": "Get a single history item by id", - "operationId": "statusGetHistory", - "parameters": [ - { - "in": "path", - "name": "historyId", - "schema": { - "type": "integer" - }, - "description": "The id of the history item (the `historyKey` from above)", - "required": true - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/historyAll-get-responses-200" - }, - "404": { - "description": "History item not found", - "content": { - "text/html": { - "examples": { - "notFound": { - "summary": "Not Found", - "value": "Not Found

404 Not Found

" - } - } - } - } - } - } - }, - "delete": { - "tags": [ - "Status" - ], - "security": [ - { - "user_token": [ - "admin" - ] - } - ], - "summary": "Delete Single History Item", - "description": "Delete a single history item by id", - "operationId": "statusDeleteHistory", - "parameters": [ - { - "in": "path", - "name": "historyId", - "schema": { - "type": "integer" - }, - "description": "The id of the history item (the `historyKey` from above)", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "headers": { - "X-Plex-Container-Total-Size": { - "description": "Provided on all MediaContainer objects indicating the total size of objects available", - "schema": { - "type": "integer" - } - }, - "X-Plex-Container-Start": { - "description": "Provided on all MediaContainer objects indicating the offset of where this container page starts", - "schema": { - "type": "integer" - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/mediaContainer" - }, - "examples": { - "emptyContainer": { - "description": "OK", - "value": { - "MediaContainer": { - "size": 0 - } - } - } - } - } - } - }, - "404": { - "description": "History item not found", - "content": { - "text/html": { - "examples": { - "notFound": { - "summary": "Not Found", - "value": "Not Found

404 Not Found

" - } - } - } - } - } - } - } - }, - "/status/sessions/terminate": { - "post": { - "tags": [ - "Status" - ], - "security": [ - { - "user_token": [ - "admin" - ] - } - ], - "summary": "Terminate a session", - "description": "Terminate a playback session kicking off the user", - "operationId": "statusPostTerminate", - "parameters": [ - { - "in": "query", - "name": "sessionId", - "schema": { - "type": "string" - }, - "required": true, - "example": "cdefghijklmnopqrstuvwxyz", - "description": "The session id (found in the `Session` element in [/status/sessions](#tag/Status/operation/statusGetSlash))" - }, - { - "in": "query", - "name": "reason", - "schema": { - "type": "string" - }, - "example": "Stop Playing", - "description": "The reason to give to the user (typically displayed in the client)" - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/200" - }, - "401": { - "description": "Server does not have the feature enabled", - "content": { - "text/html": { - "examples": { - "unauthorized": { - "summary": "Unauthorized", - "value": "Unauthorized

401 Unauthorized

" - } - } - } - } - }, - "403": { - "description": "sessionId is empty", - "content": { - "text/html": { - "examples": { - "forbidden": { - "summary": "Forbidden", - "value": "Forbidden

403 Forbidden

" - } - } - } - } - }, - "404": { - "description": "Session not found", - "content": { - "text/html": { - "examples": { - "notFound": { - "summary": "Not Found", - "value": "Not Found

404 Not Found

" - } - } - } - } - } - } - } - }, - "/{transcodeType}/:/transcode/universal/decision": { - "get": { - "tags": [ - "Transcoder" - ], - "summary": "Make a decision on media playback", - "description": "Make a decision on media playback based on client profile, and requested settings such as bandwidth and resolution.", - "operationId": "transcodeDecision", - "parameters": [ - { - "$ref": "#/components/parameters/transcodeType" - }, - { - "$ref": "#/components/parameters/transcodeSessionId" - }, - { - "in": "query", - "name": "advancedSubtitles", - "schema": { - "type": "string", - "enum": [ - "burn", - "text", - "unknown" - ] - }, - "example": "burn", - "description": "Indicates how incompatible advanced subtitles (such as ass/ssa) should be included: * 'burn' - Burn incompatible advanced text subtitles into the video stream * 'text' - Transcode incompatible advanced text subtitles to a compatible text format, even if some markup is lost\n" - }, - { - "in": "query", - "name": "audioBoost", - "schema": { - "type": "integer", - "minimum": 1 - }, - "example": 50, - "description": "Percentage of original audio loudness to use when transcoding (100 is equivalent to original volume, 50 is half, 200 is double, etc)" - }, - { - "in": "query", - "name": "audioChannelCount", - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 8 - }, - "example": 5, - "description": "Target video number of audio channels." - }, - { - "in": "query", - "name": "autoAdjustQuality", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "example": 1, - "description": "Indicates the client supports ABR." - }, - { - "in": "query", - "name": "autoAdjustSubtitle", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "example": 1, - "description": "Indicates if the server should adjust subtitles based on Voice Activity Data." - }, - { - "in": "query", - "name": "directPlay", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "example": 1, - "description": "Indicates the client supports direct playing the indicated content." - }, - { - "in": "query", - "name": "directStream", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "example": 1, - "description": "Indicates the client supports direct streaming the video of the indicated content." - }, - { - "in": "query", - "name": "directStreamAudio", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "example": 1, - "description": "Indicates the client supports direct streaming the audio of the indicated content." - }, - { - "in": "query", - "name": "disableResolutionRotation", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "example": 1, - "description": "Indicates if resolution should be adjusted for orientation." - }, - { - "in": "query", - "name": "hasMDE", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "example": 1, - "description": "Ignore client profiles when determining if direct play is possible. Only has an effect when directPlay=1 and both mediaIndex and partIndex are specified and neither are -1" - }, - { - "in": "query", - "name": "location", - "schema": { - "type": "string", - "enum": [ - "lan", - "wan", - "cellular" - ] - }, - "example": "wan", - "description": "Network type of the client, can be used to help determine target bitrate." - }, - { - "in": "query", - "name": "mediaBufferSize", - "schema": { - "type": "integer" - }, - "example": 102400, - "description": "Buffer size used in playback (in KB). Clients should specify a lower bound if not known exactly. This value could make the difference between transcoding and direct play on bandwidth constrained networks." - }, - { - "in": "query", - "name": "mediaIndex", - "schema": { - "type": "integer" - }, - "example": 0, - "description": "Index of the media to transcode. -1 or not specified indicates let the server choose." - }, - { - "in": "query", - "name": "musicBitrate", - "schema": { - "type": "integer", - "minimum": 0 - }, - "example": 5000, - "description": "Target bitrate for audio only files (in kbps, used to transcode)." - }, - { - "in": "query", - "name": "offset", - "schema": { - "type": "number" - }, - "example": 90.5, - "description": "Offset from the start of the media (in seconds)." - }, - { - "in": "query", - "name": "partIndex", - "schema": { - "type": "integer" - }, - "example": 0, - "description": "Index of the part to transcode. -1 or not specified indicates the server should join parts together in a transcode" - }, - { - "in": "query", - "name": "path", - "schema": { - "type": "string" - }, - "example": "/library/metadata/151671", - "description": "Internal PMS path of the media to transcode." - }, - { - "in": "query", - "name": "peakBitrate", - "schema": { - "type": "integer", - "minimum": 0 - }, - "example": 12000, - "description": "Maximum bitrate (in kbps) to use in ABR." - }, - { - "in": "query", - "name": "photoResolution", - "schema": { - "type": "string", - "pattern": "^\\d[x:]\\d$" - }, - "example": "1080x1080", - "description": "Target photo resolution." - }, - { - "in": "query", - "name": "protocol", - "schema": { - "type": "string", - "enum": [ - "http", - "hls", - "dash" - ] - }, - "example": "dash", - "description": "Indicates the network streaming protocol to be used for the transcode session: * 'http' - include the file in the http response such as MKV streaming * 'hls' - hls stream (RFC 8216) * 'dash' - dash stream (ISO/IEC 23009-1:2022)\n" - }, - { - "in": "query", - "name": "secondsPerSegment", - "schema": { - "type": "integer" - }, - "example": 5, - "description": "Number of seconds to include in each transcoded segment" - }, - { - "in": "query", - "name": "subtitleSize", - "schema": { - "type": "integer", - "minimum": 1 - }, - "example": 50, - "description": "Percentage of original subtitle size to use when burning subtitles (100 is equivalent to original size, 50 is half, ect)" - }, - { - "in": "query", - "name": "subtitles", - "schema": { - "type": "string", - "enum": [ - "auto", - "burn", - "none", - "sidecar", - "embedded", - "segmented", - "unknown" - ] - }, - "example": "Burn", - "description": "Indicates how subtitles should be included: * 'auto' - Compute the appropriate subtitle setting automatically * 'burn' - Burn the selected subtitle; auto if no selected subtitle * 'none' - Ignore all subtitle streams * 'sidecar' - The selected subtitle should be provided as a sidecar * 'embedded' - The selected subtitle should be provided as an embedded stream * 'segmented' - The selected subtitle should be provided as a segmented stream\n" - }, - { - "in": "query", - "name": "videoBitrate", - "schema": { - "type": "integer", - "minimum": 0 - }, - "example": 12000, - "description": "Target video bitrate (in kbps)." - }, - { - "in": "query", - "name": "videoQuality", - "schema": { - "type": "integer", - "minimum": 0, - "maximum": 99 - }, - "example": 50, - "description": "Target photo quality." - }, - { - "in": "query", - "name": "videoResolution", - "schema": { - "type": "string", - "pattern": "^\\d[x:]\\d$" - }, - "example": "1080x1080", - "description": "Target maximum video resolution." - }, - { - "in": "header", - "name": "X-Plex-Client-Identifier", - "schema": { - "type": "string" - }, - "required": true, - "description": "Unique per client." - }, - { - "in": "header", - "name": "X-Plex-Client-Profile-Extra", - "schema": { - "type": "string" - }, - "example": "add-limitation(scope=videoCodec&scopeName=*&type=upperBound&name=video.frameRate&value=60&replace=true)+append-transcode-target-codec(type=videoProfile&context=streaming&videoCodec=h264%2Chevc&audioCodec=aac&protocol=dash)", - "description": "See [Profile Augmentations](#section/API-Info/Profile-Augmentations) ." - }, - { - "in": "header", - "name": "X-Plex-Client-Profile-Name", - "schema": { - "type": "string" - }, - "example": "generic", - "description": "Which built in Client Profile to use in the decision. Generally should only be used to specify the Generic profile." - }, - { - "in": "header", - "name": "X-Plex-Device", - "schema": { - "type": "string" - }, - "example": "Windows", - "description": "Device the client is running on" - }, - { - "in": "header", - "name": "X-Plex-Model", - "schema": { - "type": "string" - }, - "example": "standalone", - "description": "Model of the device the client is running on" - }, - { - "in": "header", - "name": "X-Plex-Platform", - "schema": { - "type": "string" - }, - "example": "Chrome", - "description": "Client Platform" - }, - { - "in": "header", - "name": "X-Plex-Platform-Version", - "schema": { - "type": "string" - }, - "example": 135, - "description": "Client Platform Version" - }, - { - "in": "header", - "name": "X-Plex-Session-Identifier", - "schema": { - "type": "string" - }, - "description": "Unique per client playback session. Used if a client can playback multiple items at a time (such as a browser with multiple tabs)" - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/mediaContainerWithDecision" - }, - "examples": { - "Big-buck-bunny Decision": { - "value": { - "MediaContainer": { - "allowSync": "1", - "directPlayDecisionCode": 3000, - "directPlayDecisionText": "App cannot direct play this item. Direct play is disabled.", - "generalDecisionCode": 1001, - "generalDecisionText": "Direct play not available; Conversion OK.", - "identifier": "com.plexapp.plugins.library", - "librarySectionID": "60", - "librarySectionTitle": "Test Files", - "librarySectionUUID": "32ed11af-f829-4ee3-ae64-2665c66ced52", - "mediaTagPrefix": "/system/bundle/media/flags/", - "mediaTagVersion": "1663870359", - "Metadata": [ - { - "addedAt": 1745854354, - "art": "/library/metadata/151671/art/1745854446", - "createdAtAccuracy": "epoch,local", - "createdAtTZOffset": "0", - "duration": 634533, - "Genre": [ - { - "filter": "genre=1328", - "id": "1328", - "tag": "Animation" - } - ], - "guid": "com.plexapp.agents.none://0abf533a5e478d72fb6f2aaa2543511f9bdaa269?lang=xn", - "key": "/library/metadata/151671", - "librarySectionID": "60", - "librarySectionKey": "/library/sections/60", - "librarySectionTitle": "Test Files", - "Media": [ - { - "audioChannels": 2, - "audioCodec": "opus", - "bitrate": 3538, - "container": "mkv", - "duration": 634533, - "hasVoiceActivity": "0", - "height": 720, - "id": "221632", - "Part": [ - { - "bitrate": 3538, - "container": "mkv", - "decision": "transcode", - "duration": 634533, - "height": 720, - "id": "221638", - "protocol": "hls", - "selected": true, - "Stream": [ - { - "bitDepth": 8, - "bitrate": 3419, - "codec": "h264", - "decision": "transcode", - "default": true, - "displayTitle": "1080p", - "extendedDisplayTitle": "1080p (H.264)", - "frameRate": 60, - "height": 720, - "id": "332899", - "location": "segments-av", - "streamType": 1, - "width": 1280 - }, - { - "bitrate": 119, - "bitrateMode": "cbr", - "channels": 2, - "codec": "opus", - "decision": "transcode", - "default": true, - "displayTitle": "Unknown (MP3 Stereo)", - "extendedDisplayTitle": "Unknown (MP3 Stereo)", - "id": "332900", - "location": "segments-av", - "selected": true, - "streamType": 2 - } - ], - "videoProfile": "high", - "width": 1280 - } - ], - "protocol": "hls", - "selected": true, - "videoCodec": "h264", - "videoFrameRate": "60p", - "videoProfile": "high", - "videoResolution": "720p", - "width": 1280 - } - ], - "originallyAvailableAt": "2025-04-28", - "ratingKey": "151671", - "Role": [ - { - "filter": "actor=429547", - "id": "429547", - "tag": "Blender Foundation 2008" - }, - { - "filter": "actor=429548", - "id": "429548", - "tag": "Janus Bager Kristensen 2013" - } - ], - "subtype": "clip", - "thumb": "/library/metadata/151671/thumb/1745854446", - "title": "big-buck-bunny", - "type": "movie", - "UltraBlurColors": [ - { - "bottomLeft": "61252d", - "bottomRight": "2b6770", - "topLeft": "1a2c53", - "topRight": "2b686b" - } - ], - "updatedAt": 1745854446, - "year": 2025 - } - ], - "resourceSession": "E26A4C81-FB5E-4B49-BE2C-5973D7F5A98C", - "size": 1, - "transcodeDecisionCode": 1001, - "transcodeDecisionText": "Direct play not available; Conversion OK." - } - } - } - } - } - } - } - } - } - }, - "/{transcodeType}/:/transcode/universal/fallback": { - "post": { - "tags": [ - "Transcoder" - ], - "summary": "Manually trigger a transcoder fallback", - "description": "Manually trigger a transcoder fallback ex: HEVC to h.264 or hw to sw", - "operationId": "transcodeFallback", - "parameters": [ - { - "$ref": "#/components/parameters/transcodeType" - }, - { - "$ref": "#/components/parameters/transcodeSessionId" - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/200" - }, - "404": { - "description": "Session ID does not exist", - "content": { - "text/html": { - "examples": { - "notFound": { - "summary": "Not Found", - "value": "Not Found

404 Not Found

" - } - } - } - } - }, - "412": { - "description": "Transcode could not fallback", - "content": { - "text/html": { - "examples": { - "notFound": { - "summary": "Precondition Failed", - "value": "Precondition Failed

412 Precondition Failed

" - } - } - } - } - }, - "500": { - "description": "Transcode failed to fallback", - "content": { - "text/html": { - "examples": { - "badParam": { - "summary": "Processing failed inside the server", - "value": "Internal Server Error

500 Internal Server Error

" - } - } - } - } - } - } - } - }, - "/{transcodeType}/:/transcode/universal/start.*": { - "get": { - "tags": [ - "Transcoder" - ], - "summary": "Start A Transcoding Session", - "description": "Starts the transcoder and returns the corresponding streaming resource document.", - "operationId": "transcodeStart", - "parameters": [ - { - "$ref": "#/components/parameters/transcodeType" - }, - { - "$ref": "#/components/parameters/transcodeSessionId" - }, - { - "in": "query", - "name": "advancedSubtitles", - "schema": { - "type": "string", - "enum": [ - "burn", - "text", - "unknown" - ] - }, - "example": "burn", - "description": "Indicates how incompatible advanced subtitles (such as ass/ssa) should be included: * 'burn' - Burn incompatible advanced text subtitles into the video stream * 'text' - Transcode incompatible advanced text subtitles to a compatible text format, even if some markup is lost\n" - }, - { - "in": "query", - "name": "audioBoost", - "schema": { - "type": "integer", - "minimum": 1 - }, - "example": 50, - "description": "Percentage of original audio loudness to use when transcoding (100 is equivalent to original volume, 50 is half, 200 is double, etc)" - }, - { - "in": "query", - "name": "audioChannelCount", - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 8 - }, - "example": 5, - "description": "Target video number of audio channels." - }, - { - "in": "query", - "name": "autoAdjustQuality", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "example": 1, - "description": "Indicates the client supports ABR." - }, - { - "in": "query", - "name": "autoAdjustSubtitle", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "example": 1, - "description": "Indicates if the server should adjust subtitles based on Voice Activity Data." - }, - { - "in": "query", - "name": "directPlay", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "example": 1, - "description": "Indicates the client supports direct playing the indicated content." - }, - { - "in": "query", - "name": "directStream", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "example": 1, - "description": "Indicates the client supports direct streaming the video of the indicated content." - }, - { - "in": "query", - "name": "directStreamAudio", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "example": 1, - "description": "Indicates the client supports direct streaming the audio of the indicated content." - }, - { - "in": "query", - "name": "disableResolutionRotation", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "example": 1, - "description": "Indicates if resolution should be adjusted for orientation." - }, - { - "in": "query", - "name": "hasMDE", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "example": 1, - "description": "Ignore client profiles when determining if direct play is possible. Only has an effect when directPlay=1 and both mediaIndex and partIndex are specified and neither are -1" - }, - { - "in": "query", - "name": "location", - "schema": { - "type": "string", - "enum": [ - "lan", - "wan", - "cellular" - ] - }, - "example": "wan", - "description": "Network type of the client, can be used to help determine target bitrate." - }, - { - "in": "query", - "name": "mediaBufferSize", - "schema": { - "type": "integer" - }, - "example": 102400, - "description": "Buffer size used in playback (in KB). Clients should specify a lower bound if not known exactly. This value could make the difference between transcoding and direct play on bandwidth constrained networks." - }, - { - "in": "query", - "name": "mediaIndex", - "schema": { - "type": "integer" - }, - "example": 0, - "description": "Index of the media to transcode. -1 or not specified indicates let the server choose." - }, - { - "in": "query", - "name": "musicBitrate", - "schema": { - "type": "integer", - "minimum": 0 - }, - "example": 5000, - "description": "Target bitrate for audio only files (in kbps, used to transcode)." - }, - { - "in": "query", - "name": "offset", - "schema": { - "type": "number" - }, - "example": 90.5, - "description": "Offset from the start of the media (in seconds)." - }, - { - "in": "query", - "name": "partIndex", - "schema": { - "type": "integer" - }, - "example": 0, - "description": "Index of the part to transcode. -1 or not specified indicates the server should join parts together in a transcode" - }, - { - "in": "query", - "name": "path", - "schema": { - "type": "string" - }, - "example": "/library/metadata/151671", - "description": "Internal PMS path of the media to transcode." - }, - { - "in": "query", - "name": "peakBitrate", - "schema": { - "type": "integer", - "minimum": 0 - }, - "example": 12000, - "description": "Maximum bitrate (in kbps) to use in ABR." - }, - { - "in": "query", - "name": "photoResolution", - "schema": { - "type": "string", - "pattern": "^\\d[x:]\\d$" - }, - "example": "1080x1080", - "description": "Target photo resolution." - }, - { - "in": "query", - "name": "protocol", - "schema": { - "type": "string", - "enum": [ - "http", - "hls", - "dash" - ] - }, - "example": "dash", - "description": "Indicates the network streaming protocol to be used for the transcode session: * 'http' - include the file in the http response such as MKV streaming * 'hls' - hls stream (RFC 8216) * 'dash' - dash stream (ISO/IEC 23009-1:2022)\n" - }, - { - "in": "query", - "name": "secondsPerSegment", - "schema": { - "type": "integer" - }, - "example": 5, - "description": "Number of seconds to include in each transcoded segment" - }, - { - "in": "query", - "name": "subtitleSize", - "schema": { - "type": "integer", - "minimum": 1 - }, - "example": 50, - "description": "Percentage of original subtitle size to use when burning subtitles (100 is equivalent to original size, 50 is half, ect)" - }, - { - "in": "query", - "name": "subtitles", - "schema": { - "type": "string", - "enum": [ - "auto", - "burn", - "none", - "sidecar", - "embedded", - "segmented", - "unknown" - ] - }, - "example": "Burn", - "description": "Indicates how subtitles should be included: * 'auto' - Compute the appropriate subtitle setting automatically * 'burn' - Burn the selected subtitle; auto if no selected subtitle * 'none' - Ignore all subtitle streams * 'sidecar' - The selected subtitle should be provided as a sidecar * 'embedded' - The selected subtitle should be provided as an embedded stream * 'segmented' - The selected subtitle should be provided as a segmented stream\n" - }, - { - "in": "query", - "name": "videoBitrate", - "schema": { - "type": "integer", - "minimum": 0 - }, - "example": 12000, - "description": "Target video bitrate (in kbps)." - }, - { - "in": "query", - "name": "videoQuality", - "schema": { - "type": "integer", - "minimum": 0, - "maximum": 99 - }, - "example": 50, - "description": "Target photo quality." - }, - { - "in": "query", - "name": "videoResolution", - "schema": { - "type": "string", - "pattern": "^\\d[x:]\\d$" - }, - "example": "1080x1080", - "description": "Target maximum video resolution." - }, - { - "in": "header", - "name": "X-Plex-Client-Identifier", - "schema": { - "type": "string" - }, - "required": true, - "description": "Unique per client." - }, - { - "in": "header", - "name": "X-Plex-Client-Profile-Extra", - "schema": { - "type": "string" - }, - "example": "add-limitation(scope=videoCodec&scopeName=*&type=upperBound&name=video.frameRate&value=60&replace=true)+append-transcode-target-codec(type=videoProfile&context=streaming&videoCodec=h264%2Chevc&audioCodec=aac&protocol=dash)", - "description": "See [Profile Augmentations](#section/API-Info/Profile-Augmentations) ." - }, - { - "in": "header", - "name": "X-Plex-Client-Profile-Name", - "schema": { - "type": "string" - }, - "example": "generic", - "description": "Which built in Client Profile to use in the decision. Generally should only be used to specify the Generic profile." - }, - { - "in": "header", - "name": "X-Plex-Device", - "schema": { - "type": "string" - }, - "example": "Windows", - "description": "Device the client is running on" - }, - { - "in": "header", - "name": "X-Plex-Model", - "schema": { - "type": "string" - }, - "example": "standalone", - "description": "Model of the device the client is running on" - }, - { - "in": "header", - "name": "X-Plex-Platform", - "schema": { - "type": "string" - }, - "example": "Chrome", - "description": "Client Platform" - }, - { - "in": "header", - "name": "X-Plex-Platform-Version", - "schema": { - "type": "string" - }, - "example": 135, - "description": "Client Platform Version" - }, - { - "in": "header", - "name": "X-Plex-Session-Identifier", - "schema": { - "type": "string" - }, - "description": "Unique per client playback session. Used if a client can playback multiple items at a time (such as a browser with multiple tabs)" - } - ], - "responses": { - "200": { - "description": "MPD file (see ISO/IEC 23009-1:2022), m3u8 file (see RFC 8216), or binary http stream", - "content": { - "text/html": { - "example": "\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n" - }, - "application/vnd.apple.mpegurl": { - "example": "#EXTM3U\n#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=3538000,RESOLUTION=1280x720,FRAME-RATE=60.000000\nsession/32635662-0d05-4acd-8f72-512cc64396d4/base/index.m3u8\n" - }, - "video/x-matroska": { - "schema": { - "type": "string", - "format": "binary" - } - } - } - }, - "400": { - "$ref": "#/components/responses/400" - }, - "403": { - "$ref": "#/components/responses/403" - }, - "404": { - "$ref": "#/components/responses/404" - } - } - } - }, - "/{transcodeType}/:/transcode/universal/subtitles": { - "get": { - "tags": [ - "Transcoder" - ], - "summary": "Transcode subtitles", - "description": "Only transcode subtitle streams.", - "operationId": "transcodeSubtitles", - "parameters": [ - { - "$ref": "#/components/parameters/transcodeType" - }, - { - "$ref": "#/components/parameters/transcodeSessionId" - }, - { - "in": "query", - "name": "advancedSubtitles", - "schema": { - "type": "string", - "enum": [ - "burn", - "text", - "unknown" - ] - }, - "example": "burn", - "description": "Indicates how incompatible advanced subtitles (such as ass/ssa) should be included: * 'burn' - Burn incompatible advanced text subtitles into the video stream * 'text' - Transcode incompatible advanced text subtitles to a compatible text format, even if some markup is lost\n" - }, - { - "in": "query", - "name": "audioBoost", - "schema": { - "type": "integer", - "minimum": 1 - }, - "example": 50, - "description": "Percentage of original audio loudness to use when transcoding (100 is equivalent to original volume, 50 is half, 200 is double, etc)" - }, - { - "in": "query", - "name": "audioChannelCount", - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 8 - }, - "example": 5, - "description": "Target video number of audio channels." - }, - { - "in": "query", - "name": "autoAdjustQuality", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "example": 1, - "description": "Indicates the client supports ABR." - }, - { - "in": "query", - "name": "autoAdjustSubtitle", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "example": 1, - "description": "Indicates if the server should adjust subtitles based on Voice Activity Data." - }, - { - "in": "query", - "name": "directPlay", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "example": 1, - "description": "Indicates the client supports direct playing the indicated content." - }, - { - "in": "query", - "name": "directStream", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "example": 1, - "description": "Indicates the client supports direct streaming the video of the indicated content." - }, - { - "in": "query", - "name": "directStreamAudio", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "example": 1, - "description": "Indicates the client supports direct streaming the audio of the indicated content." - }, - { - "in": "query", - "name": "disableResolutionRotation", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "example": 1, - "description": "Indicates if resolution should be adjusted for orientation." - }, - { - "in": "query", - "name": "hasMDE", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "example": 1, - "description": "Ignore client profiles when determining if direct play is possible. Only has an effect when directPlay=1 and both mediaIndex and partIndex are specified and neither are -1" - }, - { - "in": "query", - "name": "location", - "schema": { - "type": "string", - "enum": [ - "lan", - "wan", - "cellular" - ] - }, - "example": "wan", - "description": "Network type of the client, can be used to help determine target bitrate." - }, - { - "in": "query", - "name": "mediaBufferSize", - "schema": { - "type": "integer" - }, - "example": 102400, - "description": "Buffer size used in playback (in KB). Clients should specify a lower bound if not known exactly. This value could make the difference between transcoding and direct play on bandwidth constrained networks." - }, - { - "in": "query", - "name": "mediaIndex", - "schema": { - "type": "integer" - }, - "example": 0, - "description": "Index of the media to transcode. -1 or not specified indicates let the server choose." - }, - { - "in": "query", - "name": "musicBitrate", - "schema": { - "type": "integer", - "minimum": 0 - }, - "example": 5000, - "description": "Target bitrate for audio only files (in kbps, used to transcode)." - }, - { - "in": "query", - "name": "offset", - "schema": { - "type": "number" - }, - "example": 90.5, - "description": "Offset from the start of the media (in seconds)." - }, - { - "in": "query", - "name": "partIndex", - "schema": { - "type": "integer" - }, - "example": 0, - "description": "Index of the part to transcode. -1 or not specified indicates the server should join parts together in a transcode" - }, - { - "in": "query", - "name": "path", - "schema": { - "type": "string" - }, - "example": "/library/metadata/151671", - "description": "Internal PMS path of the media to transcode." - }, - { - "in": "query", - "name": "peakBitrate", - "schema": { - "type": "integer", - "minimum": 0 - }, - "example": 12000, - "description": "Maximum bitrate (in kbps) to use in ABR." - }, - { - "in": "query", - "name": "photoResolution", - "schema": { - "type": "string", - "pattern": "^\\d[x:]\\d$" - }, - "example": "1080x1080", - "description": "Target photo resolution." - }, - { - "in": "query", - "name": "protocol", - "schema": { - "type": "string", - "enum": [ - "http", - "hls", - "dash" - ] - }, - "example": "dash", - "description": "Indicates the network streaming protocol to be used for the transcode session: * 'http' - include the file in the http response such as MKV streaming * 'hls' - hls stream (RFC 8216) * 'dash' - dash stream (ISO/IEC 23009-1:2022)\n" - }, - { - "in": "query", - "name": "secondsPerSegment", - "schema": { - "type": "integer" - }, - "example": 5, - "description": "Number of seconds to include in each transcoded segment" - }, - { - "in": "query", - "name": "subtitleSize", - "schema": { - "type": "integer", - "minimum": 1 - }, - "example": 50, - "description": "Percentage of original subtitle size to use when burning subtitles (100 is equivalent to original size, 50 is half, ect)" - }, - { - "in": "query", - "name": "subtitles", - "schema": { - "type": "string", - "enum": [ - "auto", - "burn", - "none", - "sidecar", - "embedded", - "segmented", - "unknown" - ] - }, - "example": "Burn", - "description": "Indicates how subtitles should be included: * 'auto' - Compute the appropriate subtitle setting automatically * 'burn' - Burn the selected subtitle; auto if no selected subtitle * 'none' - Ignore all subtitle streams * 'sidecar' - The selected subtitle should be provided as a sidecar * 'embedded' - The selected subtitle should be provided as an embedded stream * 'segmented' - The selected subtitle should be provided as a segmented stream\n" - }, - { - "in": "query", - "name": "videoBitrate", - "schema": { - "type": "integer", - "minimum": 0 - }, - "example": 12000, - "description": "Target video bitrate (in kbps)." - }, - { - "in": "query", - "name": "videoQuality", - "schema": { - "type": "integer", - "minimum": 0, - "maximum": 99 - }, - "example": 50, - "description": "Target photo quality." - }, - { - "in": "query", - "name": "videoResolution", - "schema": { - "type": "string", - "pattern": "^\\d[x:]\\d$" - }, - "example": "1080x1080", - "description": "Target maximum video resolution." - }, - { - "in": "header", - "name": "X-Plex-Client-Identifier", - "schema": { - "type": "string" - }, - "required": true, - "description": "Unique per client." - }, - { - "in": "header", - "name": "X-Plex-Client-Profile-Extra", - "schema": { - "type": "string" - }, - "example": "add-limitation(scope=videoCodec&scopeName=*&type=upperBound&name=video.frameRate&value=60&replace=true)+append-transcode-target-codec(type=videoProfile&context=streaming&videoCodec=h264%2Chevc&audioCodec=aac&protocol=dash)", - "description": "See [Profile Augmentations](#section/API-Info/Profile-Augmentations) ." - }, - { - "in": "header", - "name": "X-Plex-Client-Profile-Name", - "schema": { - "type": "string" - }, - "example": "generic", - "description": "Which built in Client Profile to use in the decision. Generally should only be used to specify the Generic profile." - }, - { - "in": "header", - "name": "X-Plex-Device", - "schema": { - "type": "string" - }, - "example": "Windows", - "description": "Device the client is running on" - }, - { - "in": "header", - "name": "X-Plex-Model", - "schema": { - "type": "string" - }, - "example": "standalone", - "description": "Model of the device the client is running on" - }, - { - "in": "header", - "name": "X-Plex-Platform", - "schema": { - "type": "string" - }, - "example": "Chrome", - "description": "Client Platform" - }, - { - "in": "header", - "name": "X-Plex-Platform-Version", - "schema": { - "type": "string" - }, - "example": 135, - "description": "Client Platform Version" - }, - { - "in": "header", - "name": "X-Plex-Session-Identifier", - "schema": { - "type": "string" - }, - "description": "Unique per client playback session. Used if a client can playback multiple items at a time (such as a browser with multiple tabs)" - } - ], - "responses": { - "200": { - "description": "Transcoded subtitle file", - "content": { - "text/srt": { - "example": "1\n00:00:02,499 --> 00:00:06,416\n[SERENE MUSIC]\n\n2\n00:00:11,791 --> 00:00:13,958\n[BROOK BABBLES] \n[FLY BUZZES]\n\n3\n00:00:16,166 --> 00:00:17,666\n[BIRD TWEETS]\n\n4\n00:00:17,666 --> 00:00:18,708\n[WINGS FLAP]\n\n5\n00:00:19,833 --> 00:00:20,374\n[BIRD TWEETS] \n[WINGS FLAP]\n\n6\n00:00:20,374 --> 00:00:21,041\n[THUD]\n\n7\n00:00:21,374 --> 00:00:22,249\n[THUD]\n\n8\n00:00:22,249 --> 00:00:23,083\n[SQUIRREL LAUGHS]\n\n9\n00:00:26,249 --> 00:00:27,541\n[SNORES]\n\n10\n00:00:29,416 --> 00:00:30,708\n[SNORES]\n\n11\n00:00:32,749 --> 00:00:34,041\n[BUNNY SNORES]\n\n12\n00:00:35,916 --> 00:00:37,249\n[BUNNY SNORES]\n" - } - } - }, - "400": { - "$ref": "#/components/responses/400" - }, - "403": { - "$ref": "#/components/responses/403" - }, - "404": { - "$ref": "#/components/responses/404" - } - } - } - }, - "/updater/status": { - "get": { - "tags": [ - "Updater" - ], - "security": [ - { - "user_token": [ - "admin" - ] - } - ], - "summary": "Querying status of updates", - "description": "Get the status of updating the server", - "operationId": "updaterGetStatus", - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "MediaContainer": { - "allOf": [ - { - "type": "object", - "properties": { - "canInstall": { - "type": "boolean", - "description": "Indicates whether this install can be updated through these endpoints (typically only on MacOS and Windows)" - }, - "autoUpdateVersion": { - "type": "integer", - "description": "The version of the updater (currently `1`)" - }, - "checkedAt": { - "type": "integer", - "description": "The last time a check for updates was performed" - }, - "downloadURL": { - "type": "string", - "description": "The URL where the update is available" - }, - "status": { - "type": "integer", - "description": "The current error code (`0` means no error)" - }, - "Release": { - "type": "array", - "items": { - "type": "object", - "properties": { - "key": { - "type": "string", - "description": "The URL key of the update" - }, - "version": { - "type": "string", - "description": "The version available" - }, - "added": { - "type": "string", - "description": "A list of what has been added in this version" - }, - "fixed": { - "type": "string", - "description": "A list of what has been fixed in this version" - }, - "downloadURL": { - "type": "string", - "description": "The URL of where this update is available" - }, - "state": { - "type": "string", - "enum": [ - "available", - "downloading", - "downloaded", - "installing", - "tonight", - "skipped", - "error", - "notify", - "done" - ], - "description": "The status of this update.\n\n- available - This release is available\n- downloading - This release is downloading\n- downloaded - This release has been downloaded\n- installing - This release is installing\n- tonight - This release will be installed tonight\n- skipped - This release has been skipped\n- error - This release has an error\n- notify - This release is only notifying it is available (typically because it cannot be installed on this setup)\n- done - This release is complete\n" - } - } - } - } - } - } - ] - } - } - }, - "examples": { - "status": { - "description": "An example of update status", - "value": { - "MediaContainer": { - "size": 1, - "autoUpdateVersion": 1, - "canInstall": true, - "checkedAt": 1715109491, - "downloadURL": "https://plex.tv/downloads/latest/5?channel=16&build=windows-x86_64&distro=windows&X-Plex-Token=xxxxxxxxxxxxxxxxxxxx", - "status": 0, - "Release": [ - { - "key": "https://plex.tv/updater/releases/5315", - "version": "1.40.2.8395-c67dce28e", - "added": "(PLEASE NOTE) Please also be patient when updating to this version if you have a very large database and allow the upgrade process to finish.\nRename 'un/played' to 'un/watched' terminology for video types (PM-1042)\nWe have identified an issue where automatic updates were not respecting custom paths for existing Windows 64-bit installs. Unfortunately, any automatic fix would introduce security vulnerabilities so we encourage users who installed in a custom path to uninstall and then manually reinstall Plex Media Server.", - "fixed": "(Auto Update) Custom install paths are not respected when auto-updating on 64 bit Windows. (PM-1143)\n(CreditsDetection) Retry detection only a limited amount of times on failures (PM-1093)\n(DB Optimize) Server could become unresponsive during a DB optimize in certain circumstances (PM-1129)\n(History) Query parsing would return Bad Request when encountering includeFields arguments.\n(History) View history would yield fewer entries than requested (PM-1306)\n(Loudness Analysis) Some files could cause errors when preforming Loudness Analysis. (PM-627)\n(Mac) Linker optimization would incorrectly generate code that would cause the server to unexpectedly exit while syncing view state. (PM-1308)\n(Nvidia Shield) Running on Nvidia Shield would result in 'core component problem' error. (PM-1364)\n(Push Notifications) Used expensive DB query during playback progress notifications (PM-1166)\n(Thumbnails) Thumbnails were not properly updated when underlying file changed (PM-1162)\n(Trailers) Premium trailers and extras could fail to load (PM-1347)\n(Transcoder) On Windows, headless (no display attached) Nvidia cards were not recognized (PM-962)\n(Transcoder) On Windows, the first Intel device was used for transcoding regardless of which Intel device was selected (PM-962)", - "downloadURL": "https://plex.tv/downloads/latest/5?channel=16&build=windows-x86_64&distro=windows&X-Plex-Token=xxxxxxxxxxxxxxxxxxxx", - "state": "available" - } - ] - } - } - } - } - } - } - } - } - } - }, - "/updater/check": { - "put": { - "tags": [ - "Updater" - ], - "security": [ - { - "user_token": [ - "admin" - ] - } - ], - "summary": "Checking for updates", - "description": "Perform an update check and potentially download", - "operationId": "updaterPutCheck", - "parameters": [ - { - "in": "query", - "name": "download", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "description": "Indicate that you want to start download any updates found." - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/200" - } - } - } - }, - "/updater/apply": { - "put": { - "tags": [ - "Updater" - ], - "security": [ - { - "user_token": [ - "admin" - ] - } - ], - "summary": "Applying updates", - "description": "Apply any downloaded updates. Note that the two parameters `tonight` and `skip` are effectively mutually exclusive. The `tonight` parameter takes precedence and `skip` will be ignored if `tonight` is also passed.", - "operationId": "updaterPutApply", - "parameters": [ - { - "in": "query", - "name": "tonight", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "description": "Indicate that you want the update to run during the next Butler execution. Omitting this or setting it to false indicates that the update should install immediately." - }, - { - "in": "query", - "name": "skip", - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "description": "Indicate that the latest version should be marked as skipped. The entry for this version will have the `state` set to `skipped`." - } - ], - "responses": { - "200": { - "description": "The update process started correctly", - "content": { - "text/html": { - "examples": { - "ok": { - "summary": "OK", - "value": "" - } - } - } - } - }, - "400": { - "description": "This system cannot install updates", - "content": { - "text/html": { - "examples": { - "badRequest": { - "summary": "A parameter has a bad value or required parameter is missing", - "value": "Bad Request

400 Bad Request

" - } - } - } - } - }, - "500": { - "description": "The update process failed to start", - "content": { - "text/html": { - "examples": { - "badParam": { - "summary": "Processing failed inside the server", - "value": "Internal Server Error

500 Internal Server Error

" - } - } - } - } - } - } - } - } - } -} \ No newline at end of file