diff --git a/.cursor/rules/ASYNC_PATTERNS.mdc b/.cursor/rules/ASYNC_PATTERNS.mdc new file mode 100644 index 00000000..5cd336de --- /dev/null +++ b/.cursor/rules/ASYNC_PATTERNS.mdc @@ -0,0 +1,88 @@ +--- +alwaysApply: false +--- +# Async Patterns + +## Fire-and-Forget Promises Must Handle Errors + +Every fire-and-forget `void` promise call must include a `.catch()` that logs the error. An unhandled async rejection is a silent bug. + +```typescript +// ✅ Good — error is logged +void this.evictByDiskUsage().catch((error) => { + void this.logger.error({ message: 'Failed to evict by disk usage', data: { error } }) +}) + +// ✅ Good — singleton init pattern (see SINGLETON_CONCURRENCY.md) +void this.initAsync().catch((error) => { + void this.logger.error({ message: 'Failed to initialize service', data: { error } }) +}) + +// ❌ Bad — error silently lost +void this.evictByDiskUsage() + +// ❌ Bad — empty catch swallows errors +void this.evictByDiskUsage().catch(() => {}) +``` + +### Exception: Browser `video.play()` + +Browser `HTMLMediaElement.play()` rejects with `AbortError` when navigation interrupts playback. This is benign and expected. An empty `.catch(() => {})` is acceptable **only** with an explanatory comment. + +```typescript +// ✅ Acceptable — benign browser rejection documented +// play() can reject with AbortError when navigation interrupts playback; this is benign +void video.play().catch(() => {}) +``` + +### When `void` logger calls are fine + +Logger methods return promises but are fire-and-forget by design. `void this.logger.verbose(...)` is the correct pattern — do not `await` logger calls in hot paths. + +### When `void` in frontend event handlers is fine + +Sync DOM callbacks (`onclick`, `onsubmit`, `ondrop`) cannot be `async`. Using `void asyncFn()` is correct when the called function handles its own errors internally (e.g., shows user-facing error notifications). + +## Prefer `fs/promises` in Async Contexts + +When inside an `async` function, use `fs/promises` instead of sync `fs` methods to avoid blocking the Node.js event loop. + +```typescript +// ✅ Good — non-blocking in async context +import { mkdir, stat, readdir, rm } from 'fs/promises' +import { existsAsync } from '../../../utils/exists-async.js' + +async function ensureDir(dir: string) { + if (!(await existsAsync(dir))) { + await mkdir(dir, { recursive: true }) + } +} + +// ❌ Bad — blocks event loop in async context +import { existsSync, mkdirSync } from 'fs' + +async function ensureDir(dir: string) { + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }) + } +} +``` + +### Mapping sync → async + +| Sync | Async equivalent | +| ------------------------ | -------------------------------------------------- | +| `existsSync(p)` | `existsAsync(p)` (from `service/src/utils/exists-async.ts`) | +| `statSync(p)` | `stat(p)` from `fs/promises` | +| `mkdirSync(p, opts)` | `mkdir(p, opts)` from `fs/promises` | +| `readdirSync(p, opts)` | `readdir(p, opts)` from `fs/promises` | +| `rmSync(p, opts)` | `rm(p, opts)` from `fs/promises` | +| `readFileSync(p, enc)` | `readFile(p, enc)` from `fs/promises` | +| `writeFileSync(p, data)` | `writeFile(p, data)` from `fs/promises` | + +### When sync FS is acceptable + +- **Sync-only API contracts:** Vite middleware, Vite build hooks, Express middleware +- **Test setup/teardown:** `beforeEach` / `afterEach` with small temp directories +- **One-time cached helpers:** Sync helper that runs once and caches the result (e.g., `getBaseDir()`) +- **Synchronous process cleanup:** `destroySession()` where the caller needs the directory removed before the reference is dropped diff --git a/.cursor/rules/BACKEND_PATTERNS.mdc b/.cursor/rules/BACKEND_PATTERNS.mdc index 775b2829..1fd0c7f2 100644 --- a/.cursor/rules/BACKEND_PATTERNS.mdc +++ b/.cursor/rules/BACKEND_PATTERNS.mdc @@ -91,6 +91,39 @@ service/src/app-models/[module]/ - **Clean up**: Remove partial state on failures - **Log appropriately**: Use scoped loggers with meaningful context +### Provider / Adapter Fallback Chains + +When building fallback chains where multiple providers are tried in priority order: + +- **Use a narrow internal result type** for provider helpers, not the top-level result type +- Providers should only return what they can produce (e.g., an `imdbId`), not dummy values for fields they don't have +- The orchestrator function creates the full result from the provider's narrow output + +```typescript +// ✅ Good - narrow type for internal helpers +type ProviderResult = + | { status: 'skip' } + | { status: 'rate-limited' } + | { status: 'linked'; imdbId: string } + +const tryProvider = async (...): Promise => { + // ... + return { status: 'linked', imdbId: result.imdbID } +} + +// The orchestrator builds the full result +for (const provider of priority) { + const result = await providers[provider](injector, params) + if (result.status === 'linked') { + linkedImdbId = result.imdbId + break + } +} + +// ❌ Bad - reusing the top-level type forces dummy values +return { status: 'linked', movieFile: undefined as unknown as MovieFile, movie } +``` + ### Service Layer ```typescript diff --git a/.cursor/rules/SCRIPT_EXECUTION.md b/.cursor/rules/SCRIPT_EXECUTION.md index a0511d4a..1b898cce 100644 --- a/.cursor/rules/SCRIPT_EXECUTION.md +++ b/.cursor/rules/SCRIPT_EXECUTION.md @@ -79,6 +79,13 @@ yarn create-schemas # Generate schemas from API definitions yarn clean # Clean build artifacts ``` +> **WARNING -- never run `tsc`, `tsc -b`, or `tsc --build` directly.** +> The project uses `"composite": true` with project references, so bare +> `tsc -b` emits `.js`, `.d.ts`, and `.js.map` files **into the source +> tree**. Always use `yarn build` instead. If you only need a type check +> without emitting, use the built-in linter tools (ReadLints) or run a +> targeted `vitest` suite -- do NOT run tsc in any form. + **Testing:** ```bash diff --git a/.cursor/rules/SINGLETON_CONCURRENCY.md b/.cursor/rules/SINGLETON_CONCURRENCY.md index 9260b205..464d2a90 100644 --- a/.cursor/rules/SINGLETON_CONCURRENCY.md +++ b/.cursor/rules/SINGLETON_CONCURRENCY.md @@ -56,6 +56,8 @@ Apply this pattern when **all** of the following are true: ## Fire-and-Forget Async Initialization +> See also [ASYNC_PATTERNS.mdc](./ASYNC_PATTERNS.mdc) for the broader rule on fire-and-forget promises and `fs/promises` usage. + Singleton services that need async initialization (e.g., loading config from a database, connecting to external APIs) should **not** block startup. Use a synchronous `init()` that kicks off the async work and logs errors. ### The Problem diff --git a/.cursor/rules/TYPESCRIPT_GUIDELINES.mdc b/.cursor/rules/TYPESCRIPT_GUIDELINES.mdc index 86801dd6..f3e97c09 100644 --- a/.cursor/rules/TYPESCRIPT_GUIDELINES.mdc +++ b/.cursor/rules/TYPESCRIPT_GUIDELINES.mdc @@ -551,6 +551,30 @@ const value = 'hello' as string; const value = unknownValue as User; // Prefer type guard ``` +### Double-Cast Anti-Pattern (`as unknown as T`) + +**NEVER** use `undefined as unknown as T` or `null as unknown as T` to satisfy a type contract. +This hides a design flaw — if a function doesn't have the value, the return type should reflect that. + +```typescript +// ❌ FORBIDDEN - double-cast to satisfy type +return { status: 'linked', movieFile: undefined as unknown as MovieFile, movie }; + +// ✅ Good - use a narrower return type that doesn't require the value +type ProviderResult = + | { status: 'skip' } + | { status: 'rate-limited' } + | { status: 'linked'; imdbId: string }; + +return { status: 'linked', imdbId: added.imdbID }; + +// ✅ Good - if the caller needs different shapes, use discriminated unions +type InternalResult = { status: 'linked'; imdbId: string }; +type FullResult = { status: 'linked'; movieFile: MovieFile; movie: Movie }; +``` + +If you encounter `as unknown as T`, refactor the types so the cast is unnecessary. + ### Non-Null Assertion Operator - Avoid using `!` operator when possible diff --git a/.cursor/rules/rules-index.mdc b/.cursor/rules/rules-index.mdc index 6874da3d..5facd45a 100644 --- a/.cursor/rules/rules-index.mdc +++ b/.cursor/rules/rules-index.mdc @@ -12,3 +12,6 @@ This file contains a list of helpful information and context that the agent can - [REST action Validate() wrappers and explicit param checking before system calls](./REST_ACTION_VALIDATION.mdc) - [Singleton concurrency guards, getOrCreate race prevention, fire-and-forget init, dispose-before-reinit, and safe Map iteration](./SINGLETON_CONCURRENCY.md) - [MFE runtime type boundaries, duplicate type contracts, and cross-package type sync](./MFE_TYPE_CONTRACTS.md) +- [Double-cast anti-pattern (`as unknown as T`) and non-null assertion avoidance](./TYPESCRIPT_GUIDELINES.mdc) +- [Provider/adapter fallback chain patterns with narrow internal result types](./BACKEND_PATTERNS.mdc) +- [Fire-and-forget error handling, `fs/promises` in async contexts, and `void` promise patterns](./ASYNC_PATTERNS.mdc) diff --git a/.yarn/changelogs/common.90cc3afc.md b/.yarn/changelogs/common.90cc3afc.md new file mode 100644 index 00000000..b2ddf71b --- /dev/null +++ b/.yarn/changelogs/common.90cc3afc.md @@ -0,0 +1,39 @@ + + +# common + +## ✨ Features + +### TMDB Integration Models + +- Added `TmdbMovieMetadata` model with fields for TMDB movie details including genres, vote data, production info, and multi-language support +- Added `TmdbSeriesMetadata` model with fields for TMDB series details including season/episode counts and language info +- Added `TmdbConfig` configuration type for TMDB API key, default language, and additional language preferences +- Added `MetadataProviderConfig` type to define an ordered priority list of metadata providers (`omdb` | `tmdb`) + +### Localized Metadata Models + +- Added `MovieMetadataLocalized` model for per-language movie metadata (title, plot, poster, genre) with source tracking (`omdb` | `tmdb`) +- Added `SeriesMetadataLocalized` model for per-language series metadata with source tracking + +### Media API Endpoints + +- Added REST endpoints for `TmdbMovieMetadata`, `TmdbSeriesMetadata`, `MovieMetadataLocalized`, and `SeriesMetadataLocalized` entity browsing +- Added `audioTrack` and `startTime` query parameters to HLS master, stream, segment, init, and teardown endpoints to support mid-stream seeking and audio track selection + +### Other + +- Added `HLS_SEGMENT_DURATION` constant (6 seconds) shared between frontend and service +- Extended `isMovieFile()` to recognize `.mp4` and `.mov` extensions +- Added `tmdb` field to `ServiceStatusResponse` for TMDB API availability checks + +## ♻️ Refactoring + +- Moved language-dependent fields (`title`, `plot`, `genre`, `thumbnailImageUrl`) from `Movie` and `Series` models into the new localized metadata models +- Renamed `omdb-not-configured` / `omdb-error` link statuses to `provider-not-configured` / `provider-error` to reflect multi-provider support +- Renamed `omdbNotConfigured` / `omdbError` scan progress fields to `providerNotConfigured` / `providerError` + +## ⚠️ Breaking Changes + +- `LinkMovie` response status strings `omdb-not-configured` and `omdb-error` have been renamed to `provider-not-configured` and `provider-error` +- `ScanProgress` fields `omdbNotConfigured` and `omdbError` have been renamed to `providerNotConfigured` and `providerError` diff --git a/.yarn/changelogs/frontend.90cc3afc.md b/.yarn/changelogs/frontend.90cc3afc.md new file mode 100644 index 00000000..0959b332 --- /dev/null +++ b/.yarn/changelogs/frontend.90cc3afc.md @@ -0,0 +1,51 @@ + + +# frontend + +## ✨ Features + +### TMDB Settings Page + +Added admin settings page at `/settings/tmdb` for configuring TMDB API credentials, default language, and additional languages for metadata fetching. + +### Localized Metadata Service + +Added `LocalizedMetadataService` with caches for fetching `MovieMetadataLocalized` and `SeriesMetadataLocalized` by IMDB ID and language. Movie and series overview pages now display localized titles, plots, posters, and genres from this service. + +### Movie Player Seeking and Quality Switching + +- Added server-side seek support via `startTime` parameter — the player requests a new HLS session starting at the desired position when seeking outside the buffered range +- Added `switchResolution()` for changing playback quality mid-stream with optional full session reload +- Added `isSwitching` guard to avoid progress updates during resolution/audio/seek transitions + +### Entity Browser Pages + +- Added entity browser pages for `TmdbMovieMetadata` and `TmdbSeriesMetadata` + +## 🐛 Bug Fixes + +- Fixed movie duration not displaying correctly by preferring `playbackInfo.duration` over stream duration in seek bar +- Fixed legacy navigation issues by relocating route utilities to `utils/` directory + +## ♻️ Refactoring + +- Replaced `media-chrome` and `hls.js` dependencies with modular native player controls (`PlayButton`, `SeekBar`, `VolumeControl`, `SettingsMenu`, etc.) under `controls/` directory +- Moved video event binding and playback state (play/pause, volume, duration, buffered) into `MoviePlayerService` observables +- Extracted file context menu items into a pure `getContextMenuItems()` function, replacing the Shade-based `FileContextMenu` component +- Extracted file drag-and-drop upload logic into `handleFileDrop()` utility +- Extracted `SessionUserUnavailableError`, `getUser()`, and `hasRole()` into `utils/session-helpers.ts` for reusable session access +- Relocated `environment-options.ts`, `navigate-to-route.ts`, `trigger-download.ts`, and `theme-switch-cheat.tsx` into `utils/` directory +- Simplified `SessionService.currentUser` to store the full `User` object instead of a partial pick + +## 🧪 Tests + +- Added tests for `LocalizedMetadataService` cache behavior +- Added tests for `TmdbSettings` page form validation and config persistence +- Added tests for `session-helpers` utilities +- Updated `MoviePlayerService` tests for seeking, resolution switching, and start-time support +- Updated `MoviePlayerV2Component` tests for the new seeking and resolution switching behavior + +## ⬆️ Dependencies + +- Removed `media-chrome` +- Re-added `hls.js` (^1.6.15) for cross-browser HLS playback support diff --git a/.yarn/changelogs/pi-rat.90cc3afc.md b/.yarn/changelogs/pi-rat.90cc3afc.md new file mode 100644 index 00000000..f7cac6d8 --- /dev/null +++ b/.yarn/changelogs/pi-rat.90cc3afc.md @@ -0,0 +1,11 @@ + + +# pi-rat + +## 📦 Build + +- Updated ESLint configuration with refined ignore globs for generated schemas and build artifacts + +## ⬆️ Dependencies + +- Updated FuryStack framework dependencies diff --git a/.yarn/changelogs/service.90cc3afc.md b/.yarn/changelogs/service.90cc3afc.md new file mode 100644 index 00000000..208fda2a --- /dev/null +++ b/.yarn/changelogs/service.90cc3afc.md @@ -0,0 +1,68 @@ + + +# service + +## ✨ Features + +### TMDB Client Service + +Added `TmdbClientService` with support for searching and fetching movie/series details from the TMDB API, including multi-language metadata retrieval based on the configured `TmdbConfig` languages. + +### Configurable Metadata Provider Chain + +The movie-linking pipeline now supports a configurable provider priority (`omdb`, `tmdb`) via `MetadataProviderConfig`. Providers are tried in order; the first one to return a result wins. + +### IMDB ID Extraction from Files + +- Added `extractImdbIdFromNfoFiles()` — scans `.nfo` files in the parent directory for IMDB IDs (e.g. `tt1234567`), enabling direct metadata lookup without a search API call +- Added `extractImdbIdFromFfprobeTags()` — extracts IMDB IDs from ffprobe format tags (`imdb_id`, `imdb-id`, `imdb`, etc.) + +### Localized Metadata Storage + +- Added `ensureMovieLocalizedMetadataExists()` and `ensureSeriesLocalizedMetadataExists()` for upserting per-language metadata records +- Added `mapOmdbMovieToLocalized()` / `mapOmdbSeriesToLocalized()` to convert OMDB metadata into localized format (English only) +- Added `mapTmdbMovieToLocalized()` / `mapTmdbSeriesToLocalized()` to convert TMDB responses into localized format for any language + +### HLS Start-Time Seeking + +- Transcoding sessions now accept a `startTime` parameter, using FFmpeg input seeking (`-ss` before `-i`) to start transcoding from an arbitrary position +- Added `padPlaylistToFullDuration()` to pad HLS playlists for correct total duration when segments don't cover the full file + +### Other + +- Added `removeAllSessionsForFile()` to tear down every active transcoding session for a given file path +- Added 4K (3840x2160) resolution variant to the HLS manifest generator +- Added `TmdbMovieMetadata`, `TmdbSeriesMetadata`, `MovieMetadataLocalized`, and `SeriesMetadataLocalized` data sets and API endpoints +- Added TMDB status check to the service status endpoint + +## 🐛 Bug Fixes + +- Fixed HLS session teardown to remove all sessions for a file instead of requiring exact mode/resolution/audioTrack match + +## ♻️ Refactoring + +### `setup-media.ts` Split + +Extracted the monolithic `setup-media.ts` into focused modules: + +- `media-sequelize-models.ts` — Sequelize model definitions for all media entities +- `media-data-sets.ts` — Repository data set registration +- `media-schema-setup.ts` — Database schema setup and sync +- `announce-movie-file-added.ts` — WebSocket notification when movie files are added + +### Link-Movie Provider Architecture + +Refactored `link-movie.ts` from a single OMDB-only flow into a provider-based architecture with `tryOmdbProvider()` and `tryTmdbProvider()` functions, plus an `enrichMetadataFromProviders()` step that fetches additional localized metadata after initial linking. + +## 🧪 Tests + +- Added tests for `TmdbClientService` covering search, detail fetching, multi-language support, and error/rate-limit handling +- Added tests for `extractImdbIdFromNfoFiles()` and `extractImdbIdFromFfprobeTags()` +- Added tests for `ensureMovieLocalizedMetadataExists()` and `ensureSeriesLocalizedMetadataExists()` +- Added tests for `mapOmdbMovieToLocalized()` and `mapTmdbMovieToLocalized()` / `mapTmdbSeriesToLocalized()` +- Added tests for `ensureTmdbMovieExists()` and `ensureTmdbSeriesExists()` +- Added tests for `HlsManifestGenerator` including 4K variant and `startTime` / `audioTrack` pass-through +- Added tests for `HlsStreamAction` start-time parameter handling +- Updated `TranscodingSession` tests for start-time seeking and playlist padding +- Updated `link-movie` tests for provider chain, NFO extraction, and TMDB fallback +- Updated `HlsSessionTeardownAction` tests for `removeAllSessionsForFile()` behavior diff --git a/.yarn/versions/90cc3afc.yml b/.yarn/versions/90cc3afc.yml new file mode 100644 index 00000000..b371570e --- /dev/null +++ b/.yarn/versions/90cc3afc.yml @@ -0,0 +1,5 @@ +releases: + common: minor + frontend: patch + pi-rat: patch + service: patch diff --git a/common/schemas/ai-api.json b/common/schemas/ai-api.json index 31a17a87..9f19d8e1 100644 --- a/common/schemas/ai-api.json +++ b/common/schemas/ai-api.json @@ -61,7 +61,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false @@ -86,7 +101,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false @@ -101,7 +131,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false @@ -116,7 +161,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false @@ -131,7 +191,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false diff --git a/common/schemas/chat-api.json b/common/schemas/chat-api.json index 11f5476c..943f6f3d 100644 --- a/common/schemas/chat-api.json +++ b/common/schemas/chat-api.json @@ -148,7 +148,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false @@ -176,7 +191,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false @@ -191,7 +221,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false @@ -206,7 +251,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false @@ -221,7 +281,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false diff --git a/common/schemas/config-api.json b/common/schemas/config-api.json index 2eb7b7b0..d18ee617 100644 --- a/common/schemas/config-api.json +++ b/common/schemas/config-api.json @@ -26,6 +26,14 @@ { "type": "string", "const": "OLLAMA_CONFIG" + }, + { + "type": "string", + "const": "TMDB_CONFIG" + }, + { + "type": "string", + "const": "METADATA_PROVIDER_CONFIG" } ], "description": "Configuration entry for the OMDB API" @@ -163,6 +171,43 @@ }, "required": ["host"], "additionalProperties": false + }, + { + "type": "object", + "properties": { + "apiKey": { + "type": "string", + "description": "The API key (v3 auth) or Read Access Token (v4 auth / Bearer) for the TMDB API. Can be obtained at https://www.themoviedb.org/settings/api" + }, + "defaultLanguage": { + "type": "string", + "description": "Primary language for metadata fetches, in TMDB locale format (e.g. 'en-US', 'fr-FR')." + }, + "additionalLanguages": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Additional languages to fetch alongside the default (e.g. ['fr-FR', 'de-DE'])." + } + }, + "required": ["apiKey", "defaultLanguage", "additionalLanguages"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "priority": { + "type": "array", + "items": { + "type": "string", + "enum": ["omdb", "tmdb"] + }, + "description": "Ordered list of metadata providers to try when linking movies. The first available provider that returns a result wins." + } + }, + "required": ["priority"], + "additionalProperties": false } ] }, @@ -223,7 +268,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false @@ -248,7 +308,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false @@ -263,7 +338,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false @@ -278,7 +368,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false @@ -293,7 +398,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false @@ -331,6 +451,14 @@ { "type": "string", "const": "OLLAMA_CONFIG" + }, + { + "type": "string", + "const": "TMDB_CONFIG" + }, + { + "type": "string", + "const": "METADATA_PROVIDER_CONFIG" } ], "description": "Configuration entry for the OMDB API" @@ -402,6 +530,14 @@ { "type": "string", "const": "OLLAMA_CONFIG" + }, + { + "type": "string", + "const": "TMDB_CONFIG" + }, + { + "type": "string", + "const": "METADATA_PROVIDER_CONFIG" } ], "description": "Configuration entry for the OMDB API" @@ -428,6 +564,14 @@ { "type": "string", "const": "OLLAMA_CONFIG" + }, + { + "type": "string", + "const": "TMDB_CONFIG" + }, + { + "type": "string", + "const": "METADATA_PROVIDER_CONFIG" } ], "description": "Configuration entry for the OMDB API" @@ -454,6 +598,14 @@ { "type": "string", "const": "OLLAMA_CONFIG" + }, + { + "type": "string", + "const": "TMDB_CONFIG" + }, + { + "type": "string", + "const": "METADATA_PROVIDER_CONFIG" } ], "description": "Configuration entry for the OMDB API" @@ -480,6 +632,14 @@ { "type": "string", "const": "OLLAMA_CONFIG" + }, + { + "type": "string", + "const": "TMDB_CONFIG" + }, + { + "type": "string", + "const": "METADATA_PROVIDER_CONFIG" } ], "description": "Configuration entry for the OMDB API" @@ -512,6 +672,14 @@ { "type": "string", "const": "OLLAMA_CONFIG" + }, + { + "type": "string", + "const": "TMDB_CONFIG" + }, + { + "type": "string", + "const": "METADATA_PROVIDER_CONFIG" } ], "description": "Configuration entry for the OMDB API" @@ -538,6 +706,14 @@ { "type": "string", "const": "OLLAMA_CONFIG" + }, + { + "type": "string", + "const": "TMDB_CONFIG" + }, + { + "type": "string", + "const": "METADATA_PROVIDER_CONFIG" } ], "description": "Configuration entry for the OMDB API" @@ -572,6 +748,14 @@ { "type": "string", "const": "OLLAMA_CONFIG" + }, + { + "type": "string", + "const": "TMDB_CONFIG" + }, + { + "type": "string", + "const": "METADATA_PROVIDER_CONFIG" } ], "description": "Configuration entry for the OMDB API" @@ -601,6 +785,14 @@ { "type": "string", "const": "OLLAMA_CONFIG" + }, + { + "type": "string", + "const": "TMDB_CONFIG" + }, + { + "type": "string", + "const": "METADATA_PROVIDER_CONFIG" } ], "description": "Configuration entry for the OMDB API" @@ -749,6 +941,43 @@ }, "required": ["host"], "additionalProperties": false + }, + { + "type": "object", + "properties": { + "apiKey": { + "type": "string", + "description": "The API key (v3 auth) or Read Access Token (v4 auth / Bearer) for the TMDB API. Can be obtained at https://www.themoviedb.org/settings/api" + }, + "defaultLanguage": { + "type": "string", + "description": "Primary language for metadata fetches, in TMDB locale format (e.g. 'en-US', 'fr-FR')." + }, + "additionalLanguages": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Additional languages to fetch alongside the default (e.g. ['fr-FR', 'de-DE'])." + } + }, + "required": ["apiKey", "defaultLanguage", "additionalLanguages"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "priority": { + "type": "array", + "items": { + "type": "string", + "enum": ["omdb", "tmdb"] + }, + "description": "Ordered list of metadata providers to try when linking movies. The first available provider that returns a result wins." + } + }, + "required": ["priority"], + "additionalProperties": false } ] }, @@ -885,6 +1114,43 @@ }, "required": ["host"], "additionalProperties": false + }, + { + "type": "object", + "properties": { + "apiKey": { + "type": "string", + "description": "The API key (v3 auth) or Read Access Token (v4 auth / Bearer) for the TMDB API. Can be obtained at https://www.themoviedb.org/settings/api" + }, + "defaultLanguage": { + "type": "string", + "description": "Primary language for metadata fetches, in TMDB locale format (e.g. 'en-US', 'fr-FR')." + }, + "additionalLanguages": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Additional languages to fetch alongside the default (e.g. ['fr-FR', 'de-DE'])." + } + }, + "required": ["apiKey", "defaultLanguage", "additionalLanguages"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "priority": { + "type": "array", + "items": { + "type": "string", + "enum": ["omdb", "tmdb"] + }, + "description": "Ordered list of metadata providers to try when linking movies. The first available provider that returns a result wins." + } + }, + "required": ["priority"], + "additionalProperties": false } ] } @@ -1029,6 +1295,43 @@ }, "required": ["host"], "additionalProperties": false + }, + { + "type": "object", + "properties": { + "apiKey": { + "type": "string", + "description": "The API key (v3 auth) or Read Access Token (v4 auth / Bearer) for the TMDB API. Can be obtained at https://www.themoviedb.org/settings/api" + }, + "defaultLanguage": { + "type": "string", + "description": "Primary language for metadata fetches, in TMDB locale format (e.g. 'en-US', 'fr-FR')." + }, + "additionalLanguages": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Additional languages to fetch alongside the default (e.g. ['fr-FR', 'de-DE'])." + } + }, + "required": ["apiKey", "defaultLanguage", "additionalLanguages"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "priority": { + "type": "array", + "items": { + "type": "string", + "enum": ["omdb", "tmdb"] + }, + "description": "Ordered list of metadata providers to try when linking movies. The first available provider that returns a result wins." + } + }, + "required": ["priority"], + "additionalProperties": false } ] } @@ -1168,6 +1471,43 @@ }, "required": ["host"], "additionalProperties": false + }, + { + "type": "object", + "properties": { + "apiKey": { + "type": "string", + "description": "The API key (v3 auth) or Read Access Token (v4 auth / Bearer) for the TMDB API. Can be obtained at https://www.themoviedb.org/settings/api" + }, + "defaultLanguage": { + "type": "string", + "description": "Primary language for metadata fetches, in TMDB locale format (e.g. 'en-US', 'fr-FR')." + }, + "additionalLanguages": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Additional languages to fetch alongside the default (e.g. ['fr-FR', 'de-DE'])." + } + }, + "required": ["apiKey", "defaultLanguage", "additionalLanguages"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "priority": { + "type": "array", + "items": { + "type": "string", + "enum": ["omdb", "tmdb"] + }, + "description": "Ordered list of metadata providers to try when linking movies. The first available provider that returns a result wins." + } + }, + "required": ["priority"], + "additionalProperties": false } ] } @@ -1430,6 +1770,14 @@ { "type": "string", "const": "OLLAMA_CONFIG" + }, + { + "type": "string", + "const": "TMDB_CONFIG" + }, + { + "type": "string", + "const": "METADATA_PROVIDER_CONFIG" } ], "description": "The entity's unique identifier" @@ -1480,6 +1828,14 @@ { "type": "string", "const": "OLLAMA_CONFIG" + }, + { + "type": "string", + "const": "TMDB_CONFIG" + }, + { + "type": "string", + "const": "METADATA_PROVIDER_CONFIG" } ], "description": "Configuration entry for the OMDB API" @@ -1632,6 +1988,43 @@ }, "required": ["host"], "additionalProperties": false + }, + { + "type": "object", + "properties": { + "apiKey": { + "type": "string", + "description": "The API key (v3 auth) or Read Access Token (v4 auth / Bearer) for the TMDB API. Can be obtained at https://www.themoviedb.org/settings/api" + }, + "defaultLanguage": { + "type": "string", + "description": "Primary language for metadata fetches, in TMDB locale format (e.g. 'en-US', 'fr-FR')." + }, + "additionalLanguages": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Additional languages to fetch alongside the default (e.g. ['fr-FR', 'de-DE'])." + } + }, + "required": ["apiKey", "defaultLanguage", "additionalLanguages"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "priority": { + "type": "array", + "items": { + "type": "string", + "enum": ["omdb", "tmdb"] + }, + "description": "Ordered list of metadata providers to try when linking movies. The first available provider that returns a result wins." + } + }, + "required": ["priority"], + "additionalProperties": false } ] } @@ -1664,6 +2057,14 @@ { "type": "string", "const": "OLLAMA_CONFIG" + }, + { + "type": "string", + "const": "TMDB_CONFIG" + }, + { + "type": "string", + "const": "METADATA_PROVIDER_CONFIG" } ], "description": "Configuration entry for the OMDB API" @@ -1801,6 +2202,43 @@ }, "required": ["host"], "additionalProperties": false + }, + { + "type": "object", + "properties": { + "apiKey": { + "type": "string", + "description": "The API key (v3 auth) or Read Access Token (v4 auth / Bearer) for the TMDB API. Can be obtained at https://www.themoviedb.org/settings/api" + }, + "defaultLanguage": { + "type": "string", + "description": "Primary language for metadata fetches, in TMDB locale format (e.g. 'en-US', 'fr-FR')." + }, + "additionalLanguages": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Additional languages to fetch alongside the default (e.g. ['fr-FR', 'de-DE'])." + } + }, + "required": ["apiKey", "defaultLanguage", "additionalLanguages"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "priority": { + "type": "array", + "items": { + "type": "string", + "enum": ["omdb", "tmdb"] + }, + "description": "Ordered list of metadata providers to try when linking movies. The first available provider that returns a result wins." + } + }, + "required": ["priority"], + "additionalProperties": false } ] } diff --git a/common/schemas/config-entities.json b/common/schemas/config-entities.json index 454cf1ed..72b5ec27 100644 --- a/common/schemas/config-entities.json +++ b/common/schemas/config-entities.json @@ -26,6 +26,14 @@ { "type": "string", "const": "OLLAMA_CONFIG" + }, + { + "type": "string", + "const": "TMDB_CONFIG" + }, + { + "type": "string", + "const": "METADATA_PROVIDER_CONFIG" } ], "description": "Configuration entry for the OMDB API" @@ -163,6 +171,43 @@ }, "required": ["host"], "additionalProperties": false + }, + { + "type": "object", + "properties": { + "apiKey": { + "type": "string", + "description": "The API key (v3 auth) or Read Access Token (v4 auth / Bearer) for the TMDB API. Can be obtained at https://www.themoviedb.org/settings/api" + }, + "defaultLanguage": { + "type": "string", + "description": "Primary language for metadata fetches, in TMDB locale format (e.g. 'en-US', 'fr-FR')." + }, + "additionalLanguages": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Additional languages to fetch alongside the default (e.g. ['fr-FR', 'de-DE'])." + } + }, + "required": ["apiKey", "defaultLanguage", "additionalLanguages"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "priority": { + "type": "array", + "items": { + "type": "string", + "enum": ["omdb", "tmdb"] + }, + "description": "Ordered list of metadata providers to try when linking movies. The first available provider that returns a result wins." + } + }, + "required": ["priority"], + "additionalProperties": false } ] }, @@ -194,6 +239,12 @@ }, { "$ref": "#/definitions/OllamaConfig" + }, + { + "$ref": "#/definitions/TmdbConfig" + }, + { + "$ref": "#/definitions/MetadataProviderConfig" } ] }, @@ -247,6 +298,32 @@ "required": ["id", "value"], "additionalProperties": false }, + "MetadataProviderConfig": { + "type": "object", + "properties": { + "id": { + "type": "string", + "const": "METADATA_PROVIDER_CONFIG" + }, + "value": { + "type": "object", + "properties": { + "priority": { + "type": "array", + "items": { + "type": "string", + "enum": ["omdb", "tmdb"] + }, + "description": "Ordered list of metadata providers to try when linking movies. The first available provider that returns a result wins." + } + }, + "required": ["priority"], + "additionalProperties": false + } + }, + "required": ["id", "value"], + "additionalProperties": false + }, "MoviesConfig": { "type": "object", "properties": { @@ -374,6 +451,39 @@ }, "required": ["id", "value"], "additionalProperties": false + }, + "TmdbConfig": { + "type": "object", + "properties": { + "id": { + "type": "string", + "const": "TMDB_CONFIG" + }, + "value": { + "type": "object", + "properties": { + "apiKey": { + "type": "string", + "description": "The API key (v3 auth) or Read Access Token (v4 auth / Bearer) for the TMDB API. Can be obtained at https://www.themoviedb.org/settings/api" + }, + "defaultLanguage": { + "type": "string", + "description": "Primary language for metadata fetches, in TMDB locale format (e.g. 'en-US', 'fr-FR')." + }, + "additionalLanguages": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Additional languages to fetch alongside the default (e.g. ['fr-FR', 'de-DE'])." + } + }, + "required": ["apiKey", "defaultLanguage", "additionalLanguages"], + "additionalProperties": false + } + }, + "required": ["id", "value"], + "additionalProperties": false } } } diff --git a/common/schemas/dashboards-api.json b/common/schemas/dashboards-api.json index 55c46089..a9686f73 100644 --- a/common/schemas/dashboards-api.json +++ b/common/schemas/dashboards-api.json @@ -123,7 +123,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false @@ -148,7 +163,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false @@ -163,7 +193,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false @@ -178,7 +223,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false @@ -193,7 +253,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false diff --git a/common/schemas/drives-api.json b/common/schemas/drives-api.json index addbbc9d..89a2e7eb 100644 --- a/common/schemas/drives-api.json +++ b/common/schemas/drives-api.json @@ -261,7 +261,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false @@ -276,7 +291,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false @@ -291,7 +321,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false @@ -306,7 +351,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false diff --git a/common/schemas/identity-api.json b/common/schemas/identity-api.json index 4f09af85..63231882 100644 --- a/common/schemas/identity-api.json +++ b/common/schemas/identity-api.json @@ -453,7 +453,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false @@ -478,7 +493,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false @@ -493,7 +523,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false @@ -508,7 +553,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false @@ -523,7 +583,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false diff --git a/common/schemas/install-api.json b/common/schemas/install-api.json index e5792ef6..30eeb01c 100644 --- a/common/schemas/install-api.json +++ b/common/schemas/install-api.json @@ -73,7 +73,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false @@ -88,7 +103,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false @@ -103,7 +133,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false @@ -118,7 +163,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false @@ -133,7 +193,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false @@ -148,7 +223,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false @@ -163,7 +253,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false @@ -190,12 +295,16 @@ "type": "boolean", "description": "OMDB API Installation Status for metadata fetching" }, + "tmdb": { + "type": "boolean", + "description": "TMDB API Installation Status for metadata fetching" + }, "github": { "type": "boolean", "description": "Github API Installation Status for external authentication" } }, - "required": ["omdb", "github"], + "required": ["omdb", "tmdb", "github"], "additionalProperties": false } }, diff --git a/common/schemas/iot-api.json b/common/schemas/iot-api.json index 7f2752a9..35dd7e49 100644 --- a/common/schemas/iot-api.json +++ b/common/schemas/iot-api.json @@ -1357,7 +1357,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false @@ -1382,7 +1397,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false @@ -1397,7 +1427,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false @@ -1412,7 +1457,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false @@ -1427,7 +1487,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false diff --git a/common/schemas/logging-api.json b/common/schemas/logging-api.json index 8ab04a1b..18083383 100644 --- a/common/schemas/logging-api.json +++ b/common/schemas/logging-api.json @@ -567,7 +567,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false @@ -582,7 +597,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false @@ -597,7 +627,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false @@ -612,7 +657,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false @@ -627,7 +687,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false @@ -642,7 +717,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false @@ -657,7 +747,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false @@ -672,7 +777,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false diff --git a/common/schemas/media-api.json b/common/schemas/media-api.json index 8964bd68..44316181 100644 --- a/common/schemas/media-api.json +++ b/common/schemas/media-api.json @@ -528,58 +528,6 @@ } ] }, - "title": { - "anyOf": [ - { - "type": "object", - "properties": { - "$startsWith": { - "type": "string" - }, - "$endsWith": { - "type": "string" - }, - "$like": { - "type": "string" - }, - "$regex": { - "type": "string" - } - }, - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "$eq": { - "type": "string" - }, - "$ne": { - "type": "string" - } - }, - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "$in": { - "type": "array", - "items": { - "type": "string" - } - }, - "$nin": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "additionalProperties": false - } - ] - }, "year": { "anyOf": [ { @@ -676,162 +624,6 @@ } ] }, - "genre": { - "anyOf": [ - { - "type": "object", - "properties": { - "$eq": { - "type": "array", - "items": { - "type": "string" - } - }, - "$ne": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "$in": { - "type": "array", - "items": { - "anyOf": [ - { - "type": "array", - "items": { - "type": "string" - } - }, - { - "not": {} - } - ] - } - }, - "$nin": { - "type": "array", - "items": { - "anyOf": [ - { - "type": "array", - "items": { - "type": "string" - } - }, - { - "not": {} - } - ] - } - } - }, - "additionalProperties": false - } - ] - }, - "thumbnailImageUrl": { - "anyOf": [ - { - "type": "object", - "properties": { - "$eq": { - "type": "string" - }, - "$ne": { - "type": "string" - } - }, - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "$in": { - "type": "array", - "items": { - "anyOf": [ - { - "type": "string" - }, - { - "not": {} - } - ] - } - }, - "$nin": { - "type": "array", - "items": { - "anyOf": [ - { - "type": "string" - }, - { - "not": {} - } - ] - } - } - }, - "additionalProperties": false - } - ] - }, - "plot": { - "anyOf": [ - { - "type": "object", - "properties": { - "$eq": { - "type": "string" - }, - "$ne": { - "type": "string" - } - }, - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "$in": { - "type": "array", - "items": { - "anyOf": [ - { - "type": "string" - }, - { - "not": {} - } - ] - } - }, - "$nin": { - "type": "array", - "items": { - "anyOf": [ - { - "type": "string" - }, - { - "not": {} - } - ] - } - } - }, - "additionalProperties": false - } - ] - }, "type": { "anyOf": [ { @@ -1514,35 +1306,35 @@ } } }, - "FilterType": { + "FilterType": { "type": "object", "additionalProperties": false, "properties": { "$and": { "type": "array", "items": { - "$ref": "#/definitions/FilterType%3COmdbMovieMetadata%3E" + "$ref": "#/definitions/FilterType%3CMovieMetadataLocalized%3E" } }, "$not": { "type": "array", "items": { - "$ref": "#/definitions/FilterType%3COmdbMovieMetadata%3E" + "$ref": "#/definitions/FilterType%3CMovieMetadataLocalized%3E" } }, "$nor": { "type": "array", "items": { - "$ref": "#/definitions/FilterType%3COmdbMovieMetadata%3E" + "$ref": "#/definitions/FilterType%3CMovieMetadataLocalized%3E" } }, "$or": { "type": "array", "items": { - "$ref": "#/definitions/FilterType%3COmdbMovieMetadata%3E" + "$ref": "#/definitions/FilterType%3CMovieMetadataLocalized%3E" } }, - "Title": { + "id": { "anyOf": [ { "type": "object", @@ -1594,7 +1386,7 @@ } ] }, - "Year": { + "movieImdbId": { "anyOf": [ { "type": "object", @@ -1646,7 +1438,7 @@ } ] }, - "Rated": { + "language": { "anyOf": [ { "type": "object", @@ -1698,7 +1490,7 @@ } ] }, - "Released": { + "title": { "anyOf": [ { "type": "object", @@ -1750,26 +1542,8 @@ } ] }, - "Runtime": { + "plot": { "anyOf": [ - { - "type": "object", - "properties": { - "$startsWith": { - "type": "string" - }, - "$endsWith": { - "type": "string" - }, - "$like": { - "type": "string" - }, - "$regex": { - "type": "string" - } - }, - "additionalProperties": false - }, { "type": "object", "properties": { @@ -1788,48 +1562,98 @@ "$in": { "type": "array", "items": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "not": {} + } + ] } }, "$nin": { "type": "array", "items": { - "type": "string" - } - } - }, - "additionalProperties": false - } - ] - }, - "Genre": { + "anyOf": [ + { + "type": "string" + }, + { + "not": {} + } + ] + } + } + }, + "additionalProperties": false + } + ] + }, + "posterUrl": { "anyOf": [ { "type": "object", "properties": { - "$startsWith": { - "type": "string" - }, - "$endsWith": { - "type": "string" - }, - "$like": { + "$eq": { "type": "string" }, - "$regex": { + "$ne": { "type": "string" } }, "additionalProperties": false }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "not": {} + } + ] + } + }, + "$nin": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "not": {} + } + ] + } + } + }, + "additionalProperties": false + } + ] + }, + "genre": { + "anyOf": [ { "type": "object", "properties": { "$eq": { - "type": "string" + "type": "array", + "items": { + "type": "string" + } }, "$ne": { - "type": "string" + "type": "array", + "items": { + "type": "string" + } } }, "additionalProperties": false @@ -1840,13 +1664,33 @@ "$in": { "type": "array", "items": { - "type": "string" + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "not": {} + } + ] } }, "$nin": { "type": "array", "items": { - "type": "string" + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "not": {} + } + ] } } }, @@ -1854,26 +1698,68 @@ } ] }, - "Director": { + "source": { "anyOf": [ { "type": "object", "properties": { "$startsWith": { - "type": "string" + "type": "string", + "enum": ["omdb", "tmdb"] }, "$endsWith": { - "type": "string" + "type": "string", + "enum": ["omdb", "tmdb"] }, "$like": { - "type": "string" + "type": "string", + "enum": ["omdb", "tmdb"] }, "$regex": { - "type": "string" + "type": "string", + "enum": ["omdb", "tmdb"] + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$eq": { + "type": "string", + "enum": ["omdb", "tmdb"] + }, + "$ne": { + "type": "string", + "enum": ["omdb", "tmdb"] } }, "additionalProperties": false }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "type": "string", + "enum": ["omdb", "tmdb"] + } + }, + "$nin": { + "type": "array", + "items": { + "type": "string", + "enum": ["omdb", "tmdb"] + } + } + }, + "additionalProperties": false + } + ] + }, + "sourceId": { + "anyOf": [ { "type": "object", "properties": { @@ -1892,13 +1778,27 @@ "$in": { "type": "array", "items": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "not": {} + } + ] } }, "$nin": { "type": "array", "items": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "not": {} + } + ] } } }, @@ -1906,7 +1806,7 @@ } ] }, - "Writer": { + "createdAt": { "anyOf": [ { "type": "object", @@ -1958,7 +1858,7 @@ } ] }, - "Actors": { + "updatedAt": { "anyOf": [ { "type": "object", @@ -2009,8 +1909,38 @@ "additionalProperties": false } ] + } + } + }, + "FilterType": { + "type": "object", + "additionalProperties": false, + "properties": { + "$and": { + "type": "array", + "items": { + "$ref": "#/definitions/FilterType%3COmdbMovieMetadata%3E" + } }, - "Plot": { + "$not": { + "type": "array", + "items": { + "$ref": "#/definitions/FilterType%3COmdbMovieMetadata%3E" + } + }, + "$nor": { + "type": "array", + "items": { + "$ref": "#/definitions/FilterType%3COmdbMovieMetadata%3E" + } + }, + "$or": { + "type": "array", + "items": { + "$ref": "#/definitions/FilterType%3COmdbMovieMetadata%3E" + } + }, + "Title": { "anyOf": [ { "type": "object", @@ -2062,7 +1992,7 @@ } ] }, - "Language": { + "Year": { "anyOf": [ { "type": "object", @@ -2114,7 +2044,7 @@ } ] }, - "Country": { + "Rated": { "anyOf": [ { "type": "object", @@ -2166,7 +2096,7 @@ } ] }, - "Awards": { + "Released": { "anyOf": [ { "type": "object", @@ -2218,7 +2148,7 @@ } ] }, - "Poster": { + "Runtime": { "anyOf": [ { "type": "object", @@ -2270,42 +2200,34 @@ } ] }, - "Ratings": { + "Genre": { "anyOf": [ + { + "type": "object", + "properties": { + "$startsWith": { + "type": "string" + }, + "$endsWith": { + "type": "string" + }, + "$like": { + "type": "string" + }, + "$regex": { + "type": "string" + } + }, + "additionalProperties": false + }, { "type": "object", "properties": { "$eq": { - "type": "array", - "items": { - "type": "object", - "properties": { - "Source": { - "type": "string" - }, - "Value": { - "type": "string" - } - }, - "required": ["Source", "Value"], - "additionalProperties": false - } + "type": "string" }, "$ne": { - "type": "array", - "items": { - "type": "object", - "properties": { - "Source": { - "type": "string" - }, - "Value": { - "type": "string" - } - }, - "required": ["Source", "Value"], - "additionalProperties": false - } + "type": "string" } }, "additionalProperties": false @@ -2316,39 +2238,13 @@ "$in": { "type": "array", "items": { - "type": "array", - "items": { - "type": "object", - "properties": { - "Source": { - "type": "string" - }, - "Value": { - "type": "string" - } - }, - "required": ["Source", "Value"], - "additionalProperties": false - } + "type": "string" } }, "$nin": { "type": "array", "items": { - "type": "array", - "items": { - "type": "object", - "properties": { - "Source": { - "type": "string" - }, - "Value": { - "type": "string" - } - }, - "required": ["Source", "Value"], - "additionalProperties": false - } + "type": "string" } } }, @@ -2356,7 +2252,7 @@ } ] }, - "Metascore": { + "Director": { "anyOf": [ { "type": "object", @@ -2408,7 +2304,7 @@ } ] }, - "imdbRating": { + "Writer": { "anyOf": [ { "type": "object", @@ -2460,7 +2356,7 @@ } ] }, - "imdbVotes": { + "Actors": { "anyOf": [ { "type": "object", @@ -2512,7 +2408,7 @@ } ] }, - "imdbID": { + "Plot": { "anyOf": [ { "type": "object", @@ -2564,26 +2460,22 @@ } ] }, - "Type": { + "Language": { "anyOf": [ { "type": "object", "properties": { "$startsWith": { - "type": "string", - "enum": ["movie", "episode"] + "type": "string" }, "$endsWith": { - "type": "string", - "enum": ["movie", "episode"] + "type": "string" }, "$like": { - "type": "string", - "enum": ["movie", "episode"] + "type": "string" }, "$regex": { - "type": "string", - "enum": ["movie", "episode"] + "type": "string" } }, "additionalProperties": false @@ -2592,12 +2484,10 @@ "type": "object", "properties": { "$eq": { - "type": "string", - "enum": ["movie", "episode"] + "type": "string" }, "$ne": { - "type": "string", - "enum": ["movie", "episode"] + "type": "string" } }, "additionalProperties": false @@ -2608,15 +2498,13 @@ "$in": { "type": "array", "items": { - "type": "string", - "enum": ["movie", "episode"] + "type": "string" } }, "$nin": { "type": "array", "items": { - "type": "string", - "enum": ["movie", "episode"] + "type": "string" } } }, @@ -2624,56 +2512,26 @@ } ] }, - "DVD": { + "Country": { "anyOf": [ { "type": "object", "properties": { - "$eq": { + "$startsWith": { "type": "string" }, - "$ne": { + "$endsWith": { "type": "string" - } - }, - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "$in": { - "type": "array", - "items": { - "anyOf": [ - { - "type": "string" - }, - { - "not": {} - } - ] - } }, - "$nin": { - "type": "array", - "items": { - "anyOf": [ - { - "type": "string" - }, - { - "not": {} - } - ] - } + "$like": { + "type": "string" + }, + "$regex": { + "type": "string" } }, "additionalProperties": false - } - ] - }, - "BoxOffice": { - "anyOf": [ + }, { "type": "object", "properties": { @@ -2692,27 +2550,13 @@ "$in": { "type": "array", "items": { - "anyOf": [ - { - "type": "string" - }, - { - "not": {} - } - ] + "type": "string" } }, "$nin": { "type": "array", "items": { - "anyOf": [ - { - "type": "string" - }, - { - "not": {} - } - ] + "type": "string" } } }, @@ -2720,56 +2564,26 @@ } ] }, - "Production": { + "Awards": { "anyOf": [ { "type": "object", "properties": { - "$eq": { + "$startsWith": { "type": "string" }, - "$ne": { + "$endsWith": { "type": "string" - } - }, - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "$in": { - "type": "array", - "items": { - "anyOf": [ - { - "type": "string" - }, - { - "not": {} - } - ] - } }, - "$nin": { - "type": "array", - "items": { - "anyOf": [ - { - "type": "string" - }, - { - "not": {} - } - ] - } + "$like": { + "type": "string" + }, + "$regex": { + "type": "string" } }, "additionalProperties": false - } - ] - }, - "Website": { - "anyOf": [ + }, { "type": "object", "properties": { @@ -2788,27 +2602,13 @@ "$in": { "type": "array", "items": { - "anyOf": [ - { - "type": "string" - }, - { - "not": {} - } - ] + "type": "string" } }, "$nin": { "type": "array", "items": { - "anyOf": [ - { - "type": "string" - }, - { - "not": {} - } - ] + "type": "string" } } }, @@ -2816,26 +2616,22 @@ } ] }, - "Response": { + "Poster": { "anyOf": [ { "type": "object", "properties": { "$startsWith": { - "type": "string", - "const": "True" + "type": "string" }, "$endsWith": { - "type": "string", - "const": "True" + "type": "string" }, "$like": { - "type": "string", - "const": "True" + "type": "string" }, "$regex": { - "type": "string", - "const": "True" + "type": "string" } }, "additionalProperties": false @@ -2844,12 +2640,10 @@ "type": "object", "properties": { "$eq": { - "type": "string", - "const": "True" + "type": "string" }, "$ne": { - "type": "string", - "const": "True" + "type": "string" } }, "additionalProperties": false @@ -2860,15 +2654,13 @@ "$in": { "type": "array", "items": { - "type": "string", - "const": "True" + "type": "string" } }, "$nin": { "type": "array", "items": { - "type": "string", - "const": "True" + "type": "string" } } }, @@ -2876,115 +2668,45 @@ } ] }, - "seriesID": { + "Ratings": { "anyOf": [ { "type": "object", "properties": { "$eq": { - "type": "string" - }, - "$ne": { - "type": "string" - } - }, - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "$in": { "type": "array", "items": { - "anyOf": [ - { + "type": "object", + "properties": { + "Source": { "type": "string" }, - { - "not": {} + "Value": { + "type": "string" } - ] + }, + "required": ["Source", "Value"], + "additionalProperties": false } }, - "$nin": { + "$ne": { "type": "array", "items": { - "anyOf": [ - { + "type": "object", + "properties": { + "Source": { "type": "string" }, - { - "not": {} + "Value": { + "type": "string" } - ] - } - } - }, - "additionalProperties": false - } - ] - }, - "Season": { - "anyOf": [ - { - "type": "object", - "properties": { - "$eq": { - "type": "string" - }, - "$ne": { - "type": "string" - } - }, - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "$in": { - "type": "array", - "items": { - "anyOf": [ - { - "type": "string" - }, - { - "not": {} - } - ] - } - }, - "$nin": { - "type": "array", - "items": { - "anyOf": [ - { - "type": "string" - }, - { - "not": {} - } - ] + }, + "required": ["Source", "Value"], + "additionalProperties": false } } }, "additionalProperties": false - } - ] - }, - "Episode": { - "anyOf": [ - { - "type": "object", - "properties": { - "$eq": { - "type": "string" - }, - "$ne": { - "type": "string" - } - }, - "additionalProperties": false }, { "type": "object", @@ -2992,27 +2714,39 @@ "$in": { "type": "array", "items": { - "anyOf": [ - { - "type": "string" + "type": "array", + "items": { + "type": "object", + "properties": { + "Source": { + "type": "string" + }, + "Value": { + "type": "string" + } }, - { - "not": {} - } - ] + "required": ["Source", "Value"], + "additionalProperties": false + } } }, "$nin": { "type": "array", "items": { - "anyOf": [ - { - "type": "string" + "type": "array", + "items": { + "type": "object", + "properties": { + "Source": { + "type": "string" + }, + "Value": { + "type": "string" + } }, - { - "not": {} - } - ] + "required": ["Source", "Value"], + "additionalProperties": false + } } } }, @@ -3020,7 +2754,7 @@ } ] }, - "createdAt": { + "Metascore": { "anyOf": [ { "type": "object", @@ -3072,7 +2806,7 @@ } ] }, - "updatedAt": { + "imdbRating": { "anyOf": [ { "type": "object", @@ -3123,38 +2857,8 @@ "additionalProperties": false } ] - } - } - }, - "FilterType": { - "type": "object", - "additionalProperties": false, - "properties": { - "$and": { - "type": "array", - "items": { - "$ref": "#/definitions/FilterType%3COmdbSeriesMetadata%3E" - } - }, - "$not": { - "type": "array", - "items": { - "$ref": "#/definitions/FilterType%3COmdbSeriesMetadata%3E" - } - }, - "$nor": { - "type": "array", - "items": { - "$ref": "#/definitions/FilterType%3COmdbSeriesMetadata%3E" - } - }, - "$or": { - "type": "array", - "items": { - "$ref": "#/definitions/FilterType%3COmdbSeriesMetadata%3E" - } }, - "Title": { + "imdbVotes": { "anyOf": [ { "type": "object", @@ -3206,7 +2910,7 @@ } ] }, - "Year": { + "imdbID": { "anyOf": [ { "type": "object", @@ -3258,22 +2962,26 @@ } ] }, - "Rated": { + "Type": { "anyOf": [ { "type": "object", "properties": { "$startsWith": { - "type": "string" + "type": "string", + "enum": ["movie", "episode"] }, "$endsWith": { - "type": "string" + "type": "string", + "enum": ["movie", "episode"] }, "$like": { - "type": "string" + "type": "string", + "enum": ["movie", "episode"] }, "$regex": { - "type": "string" + "type": "string", + "enum": ["movie", "episode"] } }, "additionalProperties": false @@ -3282,10 +2990,12 @@ "type": "object", "properties": { "$eq": { - "type": "string" + "type": "string", + "enum": ["movie", "episode"] }, "$ne": { - "type": "string" + "type": "string", + "enum": ["movie", "episode"] } }, "additionalProperties": false @@ -3296,13 +3006,15 @@ "$in": { "type": "array", "items": { - "type": "string" + "type": "string", + "enum": ["movie", "episode"] } }, "$nin": { "type": "array", "items": { - "type": "string" + "type": "string", + "enum": ["movie", "episode"] } } }, @@ -3310,26 +3022,8 @@ } ] }, - "Released": { + "DVD": { "anyOf": [ - { - "type": "object", - "properties": { - "$startsWith": { - "type": "string" - }, - "$endsWith": { - "type": "string" - }, - "$like": { - "type": "string" - }, - "$regex": { - "type": "string" - } - }, - "additionalProperties": false - }, { "type": "object", "properties": { @@ -3348,13 +3042,27 @@ "$in": { "type": "array", "items": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "not": {} + } + ] } }, "$nin": { "type": "array", "items": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "not": {} + } + ] } } }, @@ -3362,26 +3070,8 @@ } ] }, - "Runtime": { + "BoxOffice": { "anyOf": [ - { - "type": "object", - "properties": { - "$startsWith": { - "type": "string" - }, - "$endsWith": { - "type": "string" - }, - "$like": { - "type": "string" - }, - "$regex": { - "type": "string" - } - }, - "additionalProperties": false - }, { "type": "object", "properties": { @@ -3400,13 +3090,27 @@ "$in": { "type": "array", "items": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "not": {} + } + ] } }, "$nin": { "type": "array", "items": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "not": {} + } + ] } } }, @@ -3414,26 +3118,56 @@ } ] }, - "Genre": { + "Production": { "anyOf": [ { "type": "object", "properties": { - "$startsWith": { - "type": "string" - }, - "$endsWith": { + "$eq": { "type": "string" }, - "$like": { - "type": "string" - }, - "$regex": { + "$ne": { "type": "string" } }, "additionalProperties": false }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "not": {} + } + ] + } + }, + "$nin": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "not": {} + } + ] + } + } + }, + "additionalProperties": false + } + ] + }, + "Website": { + "anyOf": [ { "type": "object", "properties": { @@ -3452,13 +3186,27 @@ "$in": { "type": "array", "items": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "not": {} + } + ] } }, "$nin": { "type": "array", "items": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "not": {} + } + ] } } }, @@ -3466,22 +3214,26 @@ } ] }, - "Director": { + "Response": { "anyOf": [ { "type": "object", "properties": { "$startsWith": { - "type": "string" + "type": "string", + "const": "True" }, "$endsWith": { - "type": "string" + "type": "string", + "const": "True" }, "$like": { - "type": "string" + "type": "string", + "const": "True" }, "$regex": { - "type": "string" + "type": "string", + "const": "True" } }, "additionalProperties": false @@ -3490,10 +3242,12 @@ "type": "object", "properties": { "$eq": { - "type": "string" + "type": "string", + "const": "True" }, "$ne": { - "type": "string" + "type": "string", + "const": "True" } }, "additionalProperties": false @@ -3504,13 +3258,15 @@ "$in": { "type": "array", "items": { - "type": "string" + "type": "string", + "const": "True" } }, "$nin": { "type": "array", "items": { - "type": "string" + "type": "string", + "const": "True" } } }, @@ -3518,26 +3274,104 @@ } ] }, - "Writer": { + "seriesID": { "anyOf": [ { "type": "object", "properties": { - "$startsWith": { + "$eq": { "type": "string" }, - "$endsWith": { + "$ne": { "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "not": {} + } + ] + } }, - "$like": { + "$nin": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "not": {} + } + ] + } + } + }, + "additionalProperties": false + } + ] + }, + "Season": { + "anyOf": [ + { + "type": "object", + "properties": { + "$eq": { "type": "string" }, - "$regex": { + "$ne": { "type": "string" } }, "additionalProperties": false }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "not": {} + } + ] + } + }, + "$nin": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "not": {} + } + ] + } + } + }, + "additionalProperties": false + } + ] + }, + "Episode": { + "anyOf": [ { "type": "object", "properties": { @@ -3556,13 +3390,27 @@ "$in": { "type": "array", "items": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "not": {} + } + ] } }, "$nin": { "type": "array", "items": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "not": {} + } + ] } } }, @@ -3570,7 +3418,7 @@ } ] }, - "Actors": { + "createdAt": { "anyOf": [ { "type": "object", @@ -3622,7 +3470,7 @@ } ] }, - "Plot": { + "updatedAt": { "anyOf": [ { "type": "object", @@ -3673,8 +3521,38 @@ "additionalProperties": false } ] + } + } + }, + "FilterType": { + "type": "object", + "additionalProperties": false, + "properties": { + "$and": { + "type": "array", + "items": { + "$ref": "#/definitions/FilterType%3COmdbSeriesMetadata%3E" + } }, - "Language": { + "$not": { + "type": "array", + "items": { + "$ref": "#/definitions/FilterType%3COmdbSeriesMetadata%3E" + } + }, + "$nor": { + "type": "array", + "items": { + "$ref": "#/definitions/FilterType%3COmdbSeriesMetadata%3E" + } + }, + "$or": { + "type": "array", + "items": { + "$ref": "#/definitions/FilterType%3COmdbSeriesMetadata%3E" + } + }, + "Title": { "anyOf": [ { "type": "object", @@ -3726,7 +3604,7 @@ } ] }, - "Country": { + "Year": { "anyOf": [ { "type": "object", @@ -3778,7 +3656,7 @@ } ] }, - "Awards": { + "Rated": { "anyOf": [ { "type": "object", @@ -3830,7 +3708,7 @@ } ] }, - "Poster": { + "Released": { "anyOf": [ { "type": "object", @@ -3882,42 +3760,34 @@ } ] }, - "Ratings": { + "Runtime": { "anyOf": [ { "type": "object", "properties": { - "$eq": { - "type": "array", - "items": { - "type": "object", - "properties": { - "Source": { - "type": "string" - }, - "Value": { - "type": "string" - } - }, - "required": ["Source", "Value"], - "additionalProperties": false - } + "$startsWith": { + "type": "string" }, - "$ne": { - "type": "array", - "items": { - "type": "object", - "properties": { - "Source": { - "type": "string" - }, - "Value": { - "type": "string" - } - }, - "required": ["Source", "Value"], - "additionalProperties": false - } + "$endsWith": { + "type": "string" + }, + "$like": { + "type": "string" + }, + "$regex": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$eq": { + "type": "string" + }, + "$ne": { + "type": "string" } }, "additionalProperties": false @@ -3928,39 +3798,13 @@ "$in": { "type": "array", "items": { - "type": "array", - "items": { - "type": "object", - "properties": { - "Source": { - "type": "string" - }, - "Value": { - "type": "string" - } - }, - "required": ["Source", "Value"], - "additionalProperties": false - } + "type": "string" } }, "$nin": { "type": "array", "items": { - "type": "array", - "items": { - "type": "object", - "properties": { - "Source": { - "type": "string" - }, - "Value": { - "type": "string" - } - }, - "required": ["Source", "Value"], - "additionalProperties": false - } + "type": "string" } } }, @@ -3968,7 +3812,7 @@ } ] }, - "Metascore": { + "Genre": { "anyOf": [ { "type": "object", @@ -4020,7 +3864,7 @@ } ] }, - "imdbRating": { + "Director": { "anyOf": [ { "type": "object", @@ -4072,7 +3916,7 @@ } ] }, - "imdbVotes": { + "Writer": { "anyOf": [ { "type": "object", @@ -4124,7 +3968,7 @@ } ] }, - "imdbID": { + "Actors": { "anyOf": [ { "type": "object", @@ -4176,7 +4020,7 @@ } ] }, - "Type": { + "Plot": { "anyOf": [ { "type": "object", @@ -4228,7 +4072,7 @@ } ] }, - "totalSeasons": { + "Language": { "anyOf": [ { "type": "object", @@ -4280,7 +4124,7 @@ } ] }, - "Response": { + "Country": { "anyOf": [ { "type": "object", @@ -4332,7 +4176,7 @@ } ] }, - "createdAt": { + "Awards": { "anyOf": [ { "type": "object", @@ -4384,7 +4228,7 @@ } ] }, - "updatedAt": { + "Poster": { "anyOf": [ { "type": "object", @@ -4435,38 +4279,94 @@ "additionalProperties": false } ] - } - } - }, - "FilterType": { - "type": "object", - "additionalProperties": false, - "properties": { - "$and": { - "type": "array", - "items": { - "$ref": "#/definitions/FilterType%3CSeries%3E" - } - }, - "$not": { - "type": "array", - "items": { - "$ref": "#/definitions/FilterType%3CSeries%3E" - } - }, - "$nor": { - "type": "array", - "items": { - "$ref": "#/definitions/FilterType%3CSeries%3E" - } }, - "$or": { - "type": "array", - "items": { - "$ref": "#/definitions/FilterType%3CSeries%3E" - } + "Ratings": { + "anyOf": [ + { + "type": "object", + "properties": { + "$eq": { + "type": "array", + "items": { + "type": "object", + "properties": { + "Source": { + "type": "string" + }, + "Value": { + "type": "string" + } + }, + "required": ["Source", "Value"], + "additionalProperties": false + } + }, + "$ne": { + "type": "array", + "items": { + "type": "object", + "properties": { + "Source": { + "type": "string" + }, + "Value": { + "type": "string" + } + }, + "required": ["Source", "Value"], + "additionalProperties": false + } + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "Source": { + "type": "string" + }, + "Value": { + "type": "string" + } + }, + "required": ["Source", "Value"], + "additionalProperties": false + } + } + }, + "$nin": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "Source": { + "type": "string" + }, + "Value": { + "type": "string" + } + }, + "required": ["Source", "Value"], + "additionalProperties": false + } + } + } + }, + "additionalProperties": false + } + ] }, - "imdbId": { + "Metascore": { "anyOf": [ { "type": "object", @@ -4518,7 +4418,7 @@ } ] }, - "title": { + "imdbRating": { "anyOf": [ { "type": "object", @@ -4570,7 +4470,7 @@ } ] }, - "year": { + "imdbVotes": { "anyOf": [ { "type": "object", @@ -4622,8 +4522,26 @@ } ] }, - "thumbnailImageUrl": { + "imdbID": { "anyOf": [ + { + "type": "object", + "properties": { + "$startsWith": { + "type": "string" + }, + "$endsWith": { + "type": "string" + }, + "$like": { + "type": "string" + }, + "$regex": { + "type": "string" + } + }, + "additionalProperties": false + }, { "type": "object", "properties": { @@ -4642,27 +4560,13 @@ "$in": { "type": "array", "items": { - "anyOf": [ - { - "type": "string" - }, - { - "not": {} - } - ] + "type": "string" } }, "$nin": { "type": "array", "items": { - "anyOf": [ - { - "type": "string" - }, - { - "not": {} - } - ] + "type": "string" } } }, @@ -4670,7 +4574,7 @@ } ] }, - "plot": { + "Type": { "anyOf": [ { "type": "object", @@ -4722,7 +4626,7 @@ } ] }, - "createdAt": { + "totalSeasons": { "anyOf": [ { "type": "object", @@ -4774,7 +4678,7 @@ } ] }, - "updatedAt": { + "Response": { "anyOf": [ { "type": "object", @@ -4825,38 +4729,8 @@ "additionalProperties": false } ] - } - } - }, - "FilterType": { - "type": "object", - "additionalProperties": false, - "properties": { - "$and": { - "type": "array", - "items": { - "$ref": "#/definitions/FilterType%3CWatchHistoryEntry%3E" - } }, - "$not": { - "type": "array", - "items": { - "$ref": "#/definitions/FilterType%3CWatchHistoryEntry%3E" - } - }, - "$nor": { - "type": "array", - "items": { - "$ref": "#/definitions/FilterType%3CWatchHistoryEntry%3E" - } - }, - "$or": { - "type": "array", - "items": { - "$ref": "#/definitions/FilterType%3CWatchHistoryEntry%3E" - } - }, - "id": { + "createdAt": { "anyOf": [ { "type": "object", @@ -4908,7 +4782,7 @@ } ] }, - "userName": { + "updatedAt": { "anyOf": [ { "type": "object", @@ -4959,8 +4833,38 @@ "additionalProperties": false } ] + } + } + }, + "FilterType": { + "type": "object", + "additionalProperties": false, + "properties": { + "$and": { + "type": "array", + "items": { + "$ref": "#/definitions/FilterType%3CSeries%3E" + } }, - "driveLetter": { + "$not": { + "type": "array", + "items": { + "$ref": "#/definitions/FilterType%3CSeries%3E" + } + }, + "$nor": { + "type": "array", + "items": { + "$ref": "#/definitions/FilterType%3CSeries%3E" + } + }, + "$or": { + "type": "array", + "items": { + "$ref": "#/definitions/FilterType%3CSeries%3E" + } + }, + "imdbId": { "anyOf": [ { "type": "object", @@ -5012,7 +4916,7 @@ } ] }, - "path": { + "year": { "anyOf": [ { "type": "object", @@ -5064,22 +4968,70 @@ } ] }, - "watchedSeconds": { + "numberOfSeasons": { "anyOf": [ { "type": "object", "properties": { - "$gt": { + "$eq": { "type": "number" }, - "$gte": { + "$ne": { "type": "number" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "number" + }, + { + "not": {} + } + ] + } }, - "$lt": { - "type": "number" + "$nin": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "number" + }, + { + "not": {} + } + ] + } + } + }, + "additionalProperties": false + } + ] + }, + "createdAt": { + "anyOf": [ + { + "type": "object", + "properties": { + "$startsWith": { + "type": "string" }, - "$lte": { - "type": "number" + "$endsWith": { + "type": "string" + }, + "$like": { + "type": "string" + }, + "$regex": { + "type": "string" } }, "additionalProperties": false @@ -5088,10 +5040,10 @@ "type": "object", "properties": { "$eq": { - "type": "number" + "type": "string" }, "$ne": { - "type": "number" + "type": "string" } }, "additionalProperties": false @@ -5102,13 +5054,13 @@ "$in": { "type": "array", "items": { - "type": "number" + "type": "string" } }, "$nin": { "type": "array", "items": { - "type": "number" + "type": "string" } } }, @@ -5116,16 +5068,34 @@ } ] }, - "completed": { + "updatedAt": { "anyOf": [ + { + "type": "object", + "properties": { + "$startsWith": { + "type": "string" + }, + "$endsWith": { + "type": "string" + }, + "$like": { + "type": "string" + }, + "$regex": { + "type": "string" + } + }, + "additionalProperties": false + }, { "type": "object", "properties": { "$eq": { - "type": "boolean" + "type": "string" }, "$ne": { - "type": "boolean" + "type": "string" } }, "additionalProperties": false @@ -5136,21 +5106,51 @@ "$in": { "type": "array", "items": { - "type": "boolean" + "type": "string" } }, "$nin": { "type": "array", "items": { - "type": "boolean" + "type": "string" } } }, "additionalProperties": false } ] + } + } + }, + "FilterType": { + "type": "object", + "additionalProperties": false, + "properties": { + "$and": { + "type": "array", + "items": { + "$ref": "#/definitions/FilterType%3CSeriesMetadataLocalized%3E" + } }, - "createdAt": { + "$not": { + "type": "array", + "items": { + "$ref": "#/definitions/FilterType%3CSeriesMetadataLocalized%3E" + } + }, + "$nor": { + "type": "array", + "items": { + "$ref": "#/definitions/FilterType%3CSeriesMetadataLocalized%3E" + } + }, + "$or": { + "type": "array", + "items": { + "$ref": "#/definitions/FilterType%3CSeriesMetadataLocalized%3E" + } + }, + "id": { "anyOf": [ { "type": "object", @@ -5202,7 +5202,7 @@ } ] }, - "updatedAt": { + "seriesImdbId": { "anyOf": [ { "type": "object", @@ -5253,144 +5253,3530 @@ "additionalProperties": false } ] - } - } - }, - "FindOptions": { - "type": "object", - "properties": { - "top": { - "type": "number", - "description": "Limits the hits" - }, - "skip": { - "type": "number", - "description": "Skips the first N hit" }, - "order": { - "type": "object", - "properties": { - "imdbId": { - "type": "string", - "enum": ["ASC", "DESC"] - }, - "title": { - "type": "string", - "enum": ["ASC", "DESC"] - }, - "year": { - "type": "string", - "enum": ["ASC", "DESC"] - }, - "duration": { - "type": "string", - "enum": ["ASC", "DESC"] - }, - "genre": { - "type": "string", - "enum": ["ASC", "DESC"] - }, - "thumbnailImageUrl": { - "type": "string", - "enum": ["ASC", "DESC"] - }, - "plot": { - "type": "string", - "enum": ["ASC", "DESC"] - }, - "type": { - "type": "string", - "enum": ["ASC", "DESC"] + "language": { + "anyOf": [ + { + "type": "object", + "properties": { + "$startsWith": { + "type": "string" + }, + "$endsWith": { + "type": "string" + }, + "$like": { + "type": "string" + }, + "$regex": { + "type": "string" + } + }, + "additionalProperties": false }, - "seriesId": { - "type": "string", - "enum": ["ASC", "DESC"] + { + "type": "object", + "properties": { + "$eq": { + "type": "string" + }, + "$ne": { + "type": "string" + } + }, + "additionalProperties": false }, - "season": { + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "type": "string" + } + }, + "$nin": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + ] + }, + "title": { + "anyOf": [ + { + "type": "object", + "properties": { + "$startsWith": { + "type": "string" + }, + "$endsWith": { + "type": "string" + }, + "$like": { + "type": "string" + }, + "$regex": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$eq": { + "type": "string" + }, + "$ne": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "type": "string" + } + }, + "$nin": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + ] + }, + "plot": { + "anyOf": [ + { + "type": "object", + "properties": { + "$eq": { + "type": "string" + }, + "$ne": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "not": {} + } + ] + } + }, + "$nin": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "not": {} + } + ] + } + } + }, + "additionalProperties": false + } + ] + }, + "posterUrl": { + "anyOf": [ + { + "type": "object", + "properties": { + "$eq": { + "type": "string" + }, + "$ne": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "not": {} + } + ] + } + }, + "$nin": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "not": {} + } + ] + } + } + }, + "additionalProperties": false + } + ] + }, + "source": { + "anyOf": [ + { + "type": "object", + "properties": { + "$startsWith": { + "type": "string", + "enum": ["omdb", "tmdb"] + }, + "$endsWith": { + "type": "string", + "enum": ["omdb", "tmdb"] + }, + "$like": { + "type": "string", + "enum": ["omdb", "tmdb"] + }, + "$regex": { + "type": "string", + "enum": ["omdb", "tmdb"] + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$eq": { + "type": "string", + "enum": ["omdb", "tmdb"] + }, + "$ne": { + "type": "string", + "enum": ["omdb", "tmdb"] + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "type": "string", + "enum": ["omdb", "tmdb"] + } + }, + "$nin": { + "type": "array", + "items": { + "type": "string", + "enum": ["omdb", "tmdb"] + } + } + }, + "additionalProperties": false + } + ] + }, + "sourceId": { + "anyOf": [ + { + "type": "object", + "properties": { + "$eq": { + "type": "string" + }, + "$ne": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "not": {} + } + ] + } + }, + "$nin": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "not": {} + } + ] + } + } + }, + "additionalProperties": false + } + ] + }, + "createdAt": { + "anyOf": [ + { + "type": "object", + "properties": { + "$startsWith": { + "type": "string" + }, + "$endsWith": { + "type": "string" + }, + "$like": { + "type": "string" + }, + "$regex": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$eq": { + "type": "string" + }, + "$ne": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "type": "string" + } + }, + "$nin": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + ] + }, + "updatedAt": { + "anyOf": [ + { + "type": "object", + "properties": { + "$startsWith": { + "type": "string" + }, + "$endsWith": { + "type": "string" + }, + "$like": { + "type": "string" + }, + "$regex": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$eq": { + "type": "string" + }, + "$ne": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "type": "string" + } + }, + "$nin": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + ] + } + } + }, + "FilterType": { + "type": "object", + "additionalProperties": false, + "properties": { + "$and": { + "type": "array", + "items": { + "$ref": "#/definitions/FilterType%3CTmdbMovieMetadata%3E" + } + }, + "$not": { + "type": "array", + "items": { + "$ref": "#/definitions/FilterType%3CTmdbMovieMetadata%3E" + } + }, + "$nor": { + "type": "array", + "items": { + "$ref": "#/definitions/FilterType%3CTmdbMovieMetadata%3E" + } + }, + "$or": { + "type": "array", + "items": { + "$ref": "#/definitions/FilterType%3CTmdbMovieMetadata%3E" + } + }, + "id": { + "anyOf": [ + { + "type": "object", + "properties": { + "$gt": { + "type": "number" + }, + "$gte": { + "type": "number" + }, + "$lt": { + "type": "number" + }, + "$lte": { + "type": "number" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$eq": { + "type": "number" + }, + "$ne": { + "type": "number" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "type": "number" + } + }, + "$nin": { + "type": "array", + "items": { + "type": "number" + } + } + }, + "additionalProperties": false + } + ] + }, + "imdbId": { + "anyOf": [ + { + "type": "object", + "properties": { + "$eq": { + "type": "string" + }, + "$ne": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "not": {} + } + ] + } + }, + "$nin": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "not": {} + } + ] + } + } + }, + "additionalProperties": false + } + ] + }, + "title": { + "anyOf": [ + { + "type": "object", + "properties": { + "$startsWith": { + "type": "string" + }, + "$endsWith": { + "type": "string" + }, + "$like": { + "type": "string" + }, + "$regex": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$eq": { + "type": "string" + }, + "$ne": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "type": "string" + } + }, + "$nin": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + ] + }, + "originalTitle": { + "anyOf": [ + { + "type": "object", + "properties": { + "$startsWith": { + "type": "string" + }, + "$endsWith": { + "type": "string" + }, + "$like": { + "type": "string" + }, + "$regex": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$eq": { + "type": "string" + }, + "$ne": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "type": "string" + } + }, + "$nin": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + ] + }, + "overview": { + "anyOf": [ + { + "type": "object", + "properties": { + "$startsWith": { + "type": "string" + }, + "$endsWith": { + "type": "string" + }, + "$like": { + "type": "string" + }, + "$regex": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$eq": { + "type": "string" + }, + "$ne": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "type": "string" + } + }, + "$nin": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + ] + }, + "releaseDate": { + "anyOf": [ + { + "type": "object", + "properties": { + "$eq": { + "type": "string" + }, + "$ne": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "not": {} + } + ] + } + }, + "$nin": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "not": {} + } + ] + } + } + }, + "additionalProperties": false + } + ] + }, + "runtime": { + "anyOf": [ + { + "type": "object", + "properties": { + "$eq": { + "type": "number" + }, + "$ne": { + "type": "number" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "number" + }, + { + "not": {} + } + ] + } + }, + "$nin": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "number" + }, + { + "not": {} + } + ] + } + } + }, + "additionalProperties": false + } + ] + }, + "posterPath": { + "anyOf": [ + { + "type": "object", + "properties": { + "$eq": { + "type": "string" + }, + "$ne": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "not": {} + } + ] + } + }, + "$nin": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "not": {} + } + ] + } + } + }, + "additionalProperties": false + } + ] + }, + "backdropPath": { + "anyOf": [ + { + "type": "object", + "properties": { + "$eq": { + "type": "string" + }, + "$ne": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "not": {} + } + ] + } + }, + "$nin": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "not": {} + } + ] + } + } + }, + "additionalProperties": false + } + ] + }, + "genres": { + "anyOf": [ + { + "type": "object", + "properties": { + "$eq": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "name": { + "type": "string" + } + }, + "required": ["id", "name"], + "additionalProperties": false + } + }, + "$ne": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "name": { + "type": "string" + } + }, + "required": ["id", "name"], + "additionalProperties": false + } + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "name": { + "type": "string" + } + }, + "required": ["id", "name"], + "additionalProperties": false + } + } + }, + "$nin": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "name": { + "type": "string" + } + }, + "required": ["id", "name"], + "additionalProperties": false + } + } + } + }, + "additionalProperties": false + } + ] + }, + "voteAverage": { + "anyOf": [ + { + "type": "object", + "properties": { + "$eq": { + "type": "number" + }, + "$ne": { + "type": "number" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "number" + }, + { + "not": {} + } + ] + } + }, + "$nin": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "number" + }, + { + "not": {} + } + ] + } + } + }, + "additionalProperties": false + } + ] + }, + "voteCount": { + "anyOf": [ + { + "type": "object", + "properties": { + "$eq": { + "type": "number" + }, + "$ne": { + "type": "number" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "number" + }, + { + "not": {} + } + ] + } + }, + "$nin": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "number" + }, + { + "not": {} + } + ] + } + } + }, + "additionalProperties": false + } + ] + }, + "popularity": { + "anyOf": [ + { + "type": "object", + "properties": { + "$eq": { + "type": "number" + }, + "$ne": { + "type": "number" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "number" + }, + { + "not": {} + } + ] + } + }, + "$nin": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "number" + }, + { + "not": {} + } + ] + } + } + }, + "additionalProperties": false + } + ] + }, + "originalLanguage": { + "anyOf": [ + { + "type": "object", + "properties": { + "$startsWith": { + "type": "string" + }, + "$endsWith": { + "type": "string" + }, + "$like": { + "type": "string" + }, + "$regex": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$eq": { + "type": "string" + }, + "$ne": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "type": "string" + } + }, + "$nin": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + ] + }, + "spokenLanguages": { + "anyOf": [ + { + "type": "object", + "properties": { + "$eq": { + "type": "array", + "items": { + "type": "object", + "properties": { + "iso_639_1": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": ["iso_639_1", "name"], + "additionalProperties": false + } + }, + "$ne": { + "type": "array", + "items": { + "type": "object", + "properties": { + "iso_639_1": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": ["iso_639_1", "name"], + "additionalProperties": false + } + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "object", + "properties": { + "iso_639_1": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": ["iso_639_1", "name"], + "additionalProperties": false + } + }, + { + "not": {} + } + ] + } + }, + "$nin": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "object", + "properties": { + "iso_639_1": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": ["iso_639_1", "name"], + "additionalProperties": false + } + }, + { + "not": {} + } + ] + } + } + }, + "additionalProperties": false + } + ] + }, + "productionCountries": { + "anyOf": [ + { + "type": "object", + "properties": { + "$eq": { + "type": "array", + "items": { + "type": "object", + "properties": { + "iso_3166_1": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": ["iso_3166_1", "name"], + "additionalProperties": false + } + }, + "$ne": { + "type": "array", + "items": { + "type": "object", + "properties": { + "iso_3166_1": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": ["iso_3166_1", "name"], + "additionalProperties": false + } + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "object", + "properties": { + "iso_3166_1": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": ["iso_3166_1", "name"], + "additionalProperties": false + } + }, + { + "not": {} + } + ] + } + }, + "$nin": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "object", + "properties": { + "iso_3166_1": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": ["iso_3166_1", "name"], + "additionalProperties": false + } + }, + { + "not": {} + } + ] + } + } + }, + "additionalProperties": false + } + ] + }, + "status": { + "anyOf": [ + { + "type": "object", + "properties": { + "$eq": { + "type": "string" + }, + "$ne": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "not": {} + } + ] + } + }, + "$nin": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "not": {} + } + ] + } + } + }, + "additionalProperties": false + } + ] + }, + "tagline": { + "anyOf": [ + { + "type": "object", + "properties": { + "$eq": { + "type": "string" + }, + "$ne": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "not": {} + } + ] + } + }, + "$nin": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "not": {} + } + ] + } + } + }, + "additionalProperties": false + } + ] + }, + "budget": { + "anyOf": [ + { + "type": "object", + "properties": { + "$eq": { + "type": "number" + }, + "$ne": { + "type": "number" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "number" + }, + { + "not": {} + } + ] + } + }, + "$nin": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "number" + }, + { + "not": {} + } + ] + } + } + }, + "additionalProperties": false + } + ] + }, + "revenue": { + "anyOf": [ + { + "type": "object", + "properties": { + "$eq": { + "type": "number" + }, + "$ne": { + "type": "number" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "number" + }, + { + "not": {} + } + ] + } + }, + "$nin": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "number" + }, + { + "not": {} + } + ] + } + } + }, + "additionalProperties": false + } + ] + }, + "language": { + "anyOf": [ + { + "type": "object", + "properties": { + "$startsWith": { + "type": "string" + }, + "$endsWith": { + "type": "string" + }, + "$like": { + "type": "string" + }, + "$regex": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$eq": { + "type": "string" + }, + "$ne": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "type": "string" + } + }, + "$nin": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + ] + }, + "createdAt": { + "anyOf": [ + { + "type": "object", + "properties": { + "$startsWith": { + "type": "string" + }, + "$endsWith": { + "type": "string" + }, + "$like": { + "type": "string" + }, + "$regex": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$eq": { + "type": "string" + }, + "$ne": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "type": "string" + } + }, + "$nin": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + ] + }, + "updatedAt": { + "anyOf": [ + { + "type": "object", + "properties": { + "$startsWith": { + "type": "string" + }, + "$endsWith": { + "type": "string" + }, + "$like": { + "type": "string" + }, + "$regex": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$eq": { + "type": "string" + }, + "$ne": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "type": "string" + } + }, + "$nin": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + ] + } + } + }, + "FilterType": { + "type": "object", + "additionalProperties": false, + "properties": { + "$and": { + "type": "array", + "items": { + "$ref": "#/definitions/FilterType%3CTmdbSeriesMetadata%3E" + } + }, + "$not": { + "type": "array", + "items": { + "$ref": "#/definitions/FilterType%3CTmdbSeriesMetadata%3E" + } + }, + "$nor": { + "type": "array", + "items": { + "$ref": "#/definitions/FilterType%3CTmdbSeriesMetadata%3E" + } + }, + "$or": { + "type": "array", + "items": { + "$ref": "#/definitions/FilterType%3CTmdbSeriesMetadata%3E" + } + }, + "id": { + "anyOf": [ + { + "type": "object", + "properties": { + "$gt": { + "type": "number" + }, + "$gte": { + "type": "number" + }, + "$lt": { + "type": "number" + }, + "$lte": { + "type": "number" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$eq": { + "type": "number" + }, + "$ne": { + "type": "number" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "type": "number" + } + }, + "$nin": { + "type": "array", + "items": { + "type": "number" + } + } + }, + "additionalProperties": false + } + ] + }, + "imdbId": { + "anyOf": [ + { + "type": "object", + "properties": { + "$eq": { + "type": "string" + }, + "$ne": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "not": {} + } + ] + } + }, + "$nin": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "not": {} + } + ] + } + } + }, + "additionalProperties": false + } + ] + }, + "name": { + "anyOf": [ + { + "type": "object", + "properties": { + "$startsWith": { + "type": "string" + }, + "$endsWith": { + "type": "string" + }, + "$like": { + "type": "string" + }, + "$regex": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$eq": { + "type": "string" + }, + "$ne": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "type": "string" + } + }, + "$nin": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + ] + }, + "originalName": { + "anyOf": [ + { + "type": "object", + "properties": { + "$startsWith": { + "type": "string" + }, + "$endsWith": { + "type": "string" + }, + "$like": { + "type": "string" + }, + "$regex": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$eq": { + "type": "string" + }, + "$ne": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "type": "string" + } + }, + "$nin": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + ] + }, + "overview": { + "anyOf": [ + { + "type": "object", + "properties": { + "$startsWith": { + "type": "string" + }, + "$endsWith": { + "type": "string" + }, + "$like": { + "type": "string" + }, + "$regex": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$eq": { + "type": "string" + }, + "$ne": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "type": "string" + } + }, + "$nin": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + ] + }, + "firstAirDate": { + "anyOf": [ + { + "type": "object", + "properties": { + "$eq": { + "type": "string" + }, + "$ne": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "not": {} + } + ] + } + }, + "$nin": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "not": {} + } + ] + } + } + }, + "additionalProperties": false + } + ] + }, + "posterPath": { + "anyOf": [ + { + "type": "object", + "properties": { + "$eq": { + "type": "string" + }, + "$ne": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "not": {} + } + ] + } + }, + "$nin": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "not": {} + } + ] + } + } + }, + "additionalProperties": false + } + ] + }, + "backdropPath": { + "anyOf": [ + { + "type": "object", + "properties": { + "$eq": { + "type": "string" + }, + "$ne": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "not": {} + } + ] + } + }, + "$nin": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "not": {} + } + ] + } + } + }, + "additionalProperties": false + } + ] + }, + "genres": { + "anyOf": [ + { + "type": "object", + "properties": { + "$eq": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "name": { + "type": "string" + } + }, + "required": ["id", "name"], + "additionalProperties": false + } + }, + "$ne": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "name": { + "type": "string" + } + }, + "required": ["id", "name"], + "additionalProperties": false + } + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "name": { + "type": "string" + } + }, + "required": ["id", "name"], + "additionalProperties": false + } + } + }, + "$nin": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "name": { + "type": "string" + } + }, + "required": ["id", "name"], + "additionalProperties": false + } + } + } + }, + "additionalProperties": false + } + ] + }, + "voteAverage": { + "anyOf": [ + { + "type": "object", + "properties": { + "$eq": { + "type": "number" + }, + "$ne": { + "type": "number" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "number" + }, + { + "not": {} + } + ] + } + }, + "$nin": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "number" + }, + { + "not": {} + } + ] + } + } + }, + "additionalProperties": false + } + ] + }, + "voteCount": { + "anyOf": [ + { + "type": "object", + "properties": { + "$eq": { + "type": "number" + }, + "$ne": { + "type": "number" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "number" + }, + { + "not": {} + } + ] + } + }, + "$nin": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "number" + }, + { + "not": {} + } + ] + } + } + }, + "additionalProperties": false + } + ] + }, + "numberOfSeasons": { + "anyOf": [ + { + "type": "object", + "properties": { + "$eq": { + "type": "number" + }, + "$ne": { + "type": "number" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "number" + }, + { + "not": {} + } + ] + } + }, + "$nin": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "number" + }, + { + "not": {} + } + ] + } + } + }, + "additionalProperties": false + } + ] + }, + "numberOfEpisodes": { + "anyOf": [ + { + "type": "object", + "properties": { + "$eq": { + "type": "number" + }, + "$ne": { + "type": "number" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "number" + }, + { + "not": {} + } + ] + } + }, + "$nin": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "number" + }, + { + "not": {} + } + ] + } + } + }, + "additionalProperties": false + } + ] + }, + "status": { + "anyOf": [ + { + "type": "object", + "properties": { + "$eq": { + "type": "string" + }, + "$ne": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "not": {} + } + ] + } + }, + "$nin": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "not": {} + } + ] + } + } + }, + "additionalProperties": false + } + ] + }, + "originalLanguage": { + "anyOf": [ + { + "type": "object", + "properties": { + "$startsWith": { + "type": "string" + }, + "$endsWith": { + "type": "string" + }, + "$like": { + "type": "string" + }, + "$regex": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$eq": { + "type": "string" + }, + "$ne": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "type": "string" + } + }, + "$nin": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + ] + }, + "languages": { + "anyOf": [ + { + "type": "object", + "properties": { + "$eq": { + "type": "array", + "items": { + "type": "string" + } + }, + "$ne": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "not": {} + } + ] + } + }, + "$nin": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "not": {} + } + ] + } + } + }, + "additionalProperties": false + } + ] + }, + "language": { + "anyOf": [ + { + "type": "object", + "properties": { + "$startsWith": { + "type": "string" + }, + "$endsWith": { + "type": "string" + }, + "$like": { + "type": "string" + }, + "$regex": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$eq": { + "type": "string" + }, + "$ne": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "type": "string" + } + }, + "$nin": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + ] + }, + "createdAt": { + "anyOf": [ + { + "type": "object", + "properties": { + "$startsWith": { + "type": "string" + }, + "$endsWith": { + "type": "string" + }, + "$like": { + "type": "string" + }, + "$regex": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$eq": { + "type": "string" + }, + "$ne": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "type": "string" + } + }, + "$nin": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + ] + }, + "updatedAt": { + "anyOf": [ + { + "type": "object", + "properties": { + "$startsWith": { + "type": "string" + }, + "$endsWith": { + "type": "string" + }, + "$like": { + "type": "string" + }, + "$regex": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$eq": { + "type": "string" + }, + "$ne": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "type": "string" + } + }, + "$nin": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + ] + } + } + }, + "FilterType": { + "type": "object", + "additionalProperties": false, + "properties": { + "$and": { + "type": "array", + "items": { + "$ref": "#/definitions/FilterType%3CWatchHistoryEntry%3E" + } + }, + "$not": { + "type": "array", + "items": { + "$ref": "#/definitions/FilterType%3CWatchHistoryEntry%3E" + } + }, + "$nor": { + "type": "array", + "items": { + "$ref": "#/definitions/FilterType%3CWatchHistoryEntry%3E" + } + }, + "$or": { + "type": "array", + "items": { + "$ref": "#/definitions/FilterType%3CWatchHistoryEntry%3E" + } + }, + "id": { + "anyOf": [ + { + "type": "object", + "properties": { + "$startsWith": { + "type": "string" + }, + "$endsWith": { + "type": "string" + }, + "$like": { + "type": "string" + }, + "$regex": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$eq": { + "type": "string" + }, + "$ne": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "type": "string" + } + }, + "$nin": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + ] + }, + "userName": { + "anyOf": [ + { + "type": "object", + "properties": { + "$startsWith": { + "type": "string" + }, + "$endsWith": { + "type": "string" + }, + "$like": { + "type": "string" + }, + "$regex": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$eq": { + "type": "string" + }, + "$ne": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "type": "string" + } + }, + "$nin": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + ] + }, + "driveLetter": { + "anyOf": [ + { + "type": "object", + "properties": { + "$startsWith": { + "type": "string" + }, + "$endsWith": { + "type": "string" + }, + "$like": { + "type": "string" + }, + "$regex": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$eq": { + "type": "string" + }, + "$ne": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "type": "string" + } + }, + "$nin": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + ] + }, + "path": { + "anyOf": [ + { + "type": "object", + "properties": { + "$startsWith": { + "type": "string" + }, + "$endsWith": { + "type": "string" + }, + "$like": { + "type": "string" + }, + "$regex": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$eq": { + "type": "string" + }, + "$ne": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "type": "string" + } + }, + "$nin": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + ] + }, + "watchedSeconds": { + "anyOf": [ + { + "type": "object", + "properties": { + "$gt": { + "type": "number" + }, + "$gte": { + "type": "number" + }, + "$lt": { + "type": "number" + }, + "$lte": { + "type": "number" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$eq": { + "type": "number" + }, + "$ne": { + "type": "number" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "type": "number" + } + }, + "$nin": { + "type": "array", + "items": { + "type": "number" + } + } + }, + "additionalProperties": false + } + ] + }, + "completed": { + "anyOf": [ + { + "type": "object", + "properties": { + "$eq": { + "type": "boolean" + }, + "$ne": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "type": "boolean" + } + }, + "$nin": { + "type": "array", + "items": { + "type": "boolean" + } + } + }, + "additionalProperties": false + } + ] + }, + "createdAt": { + "anyOf": [ + { + "type": "object", + "properties": { + "$startsWith": { + "type": "string" + }, + "$endsWith": { + "type": "string" + }, + "$like": { + "type": "string" + }, + "$regex": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$eq": { + "type": "string" + }, + "$ne": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "type": "string" + } + }, + "$nin": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + ] + }, + "updatedAt": { + "anyOf": [ + { + "type": "object", + "properties": { + "$startsWith": { + "type": "string" + }, + "$endsWith": { + "type": "string" + }, + "$like": { + "type": "string" + }, + "$regex": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$eq": { + "type": "string" + }, + "$ne": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "type": "string" + } + }, + "$nin": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + ] + } + } + }, + "FindOptions": { + "type": "object", + "properties": { + "top": { + "type": "number", + "description": "Limits the hits" + }, + "skip": { + "type": "number", + "description": "Skips the first N hit" + }, + "order": { + "type": "object", + "properties": { + "imdbId": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "year": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "duration": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "type": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "seriesId": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "season": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "episode": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "createdAt": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "updatedAt": { + "type": "string", + "enum": ["ASC", "DESC"] + } + }, + "additionalProperties": false, + "description": "Sets up an order by a field and a direction" + }, + "select": { + "type": "array", + "items": { + "type": "string", + "enum": ["imdbId", "year", "duration", "type", "seriesId", "season", "episode", "createdAt", "updatedAt"] + }, + "description": "The result set will be limited to these fields" + }, + "filter": { + "$ref": "#/definitions/FilterType%3CMovie%3E", + "description": "The fields should match this filter" + } + }, + "additionalProperties": false, + "description": "Type for default filtering model" + }, + "FindOptions": { + "type": "object", + "properties": { + "top": { + "type": "number", + "description": "Limits the hits" + }, + "skip": { + "type": "number", + "description": "Skips the first N hit" + }, + "order": { + "type": "object", + "properties": { + "id": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "imdbId": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "driveLetter": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "path": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "ffprobe": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "relatedFiles": { + "type": "string", + "enum": ["ASC", "DESC"] + } + }, + "additionalProperties": false, + "description": "Sets up an order by a field and a direction" + }, + "select": { + "type": "array", + "items": { + "type": "string", + "enum": ["id", "imdbId", "driveLetter", "path", "ffprobe", "relatedFiles"] + }, + "description": "The result set will be limited to these fields" + }, + "filter": { + "$ref": "#/definitions/FilterType%3CMovieFile%3E", + "description": "The fields should match this filter" + } + }, + "additionalProperties": false, + "description": "Type for default filtering model" + }, + "FindOptions": { + "type": "object", + "properties": { + "top": { + "type": "number", + "description": "Limits the hits" + }, + "skip": { + "type": "number", + "description": "Skips the first N hit" + }, + "order": { + "type": "object", + "properties": { + "id": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "movieImdbId": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "language": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "title": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "plot": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "posterUrl": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "genre": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "source": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "sourceId": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "createdAt": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "updatedAt": { + "type": "string", + "enum": ["ASC", "DESC"] + } + }, + "additionalProperties": false, + "description": "Sets up an order by a field and a direction" + }, + "select": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "id", + "movieImdbId", + "language", + "title", + "plot", + "posterUrl", + "genre", + "source", + "sourceId", + "createdAt", + "updatedAt" + ] + }, + "description": "The result set will be limited to these fields" + }, + "filter": { + "$ref": "#/definitions/FilterType%3CMovieMetadataLocalized%3E", + "description": "The fields should match this filter" + } + }, + "additionalProperties": false, + "description": "Type for default filtering model" + }, + "FindOptions": { + "type": "object", + "properties": { + "top": { + "type": "number", + "description": "Limits the hits" + }, + "skip": { + "type": "number", + "description": "Skips the first N hit" + }, + "order": { + "type": "object", + "properties": { + "Title": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "Year": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "Rated": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "Released": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "Runtime": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "Genre": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "Director": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "Writer": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "Actors": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "Plot": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "Language": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "Country": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "Awards": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "Poster": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "Ratings": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "Metascore": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "imdbRating": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "imdbVotes": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "imdbID": { "type": "string", "enum": ["ASC", "DESC"] }, - "episode": { + "Type": { "type": "string", "enum": ["ASC", "DESC"] }, - "createdAt": { + "DVD": { "type": "string", "enum": ["ASC", "DESC"] }, - "updatedAt": { + "BoxOffice": { "type": "string", "enum": ["ASC", "DESC"] - } - }, - "additionalProperties": false, - "description": "Sets up an order by a field and a direction" - }, - "select": { - "type": "array", - "items": { - "type": "string", - "enum": [ - "imdbId", - "title", - "year", - "duration", - "genre", - "thumbnailImageUrl", - "plot", - "type", - "seriesId", - "season", - "episode", - "createdAt", - "updatedAt" - ] - }, - "description": "The result set will be limited to these fields" - }, - "filter": { - "$ref": "#/definitions/FilterType%3CMovie%3E", - "description": "The fields should match this filter" - } - }, - "additionalProperties": false, - "description": "Type for default filtering model" - }, - "FindOptions": { - "type": "object", - "properties": { - "top": { - "type": "number", - "description": "Limits the hits" - }, - "skip": { - "type": "number", - "description": "Skips the first N hit" - }, - "order": { - "type": "object", - "properties": { - "id": { + }, + "Production": { "type": "string", "enum": ["ASC", "DESC"] }, - "imdbId": { + "Website": { "type": "string", "enum": ["ASC", "DESC"] }, - "driveLetter": { + "Response": { "type": "string", "enum": ["ASC", "DESC"] }, - "path": { + "seriesID": { "type": "string", "enum": ["ASC", "DESC"] }, - "ffprobe": { + "Season": { "type": "string", "enum": ["ASC", "DESC"] }, - "relatedFiles": { + "Episode": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "createdAt": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "updatedAt": { "type": "string", "enum": ["ASC", "DESC"] } @@ -5402,19 +8788,50 @@ "type": "array", "items": { "type": "string", - "enum": ["id", "imdbId", "driveLetter", "path", "ffprobe", "relatedFiles"] + "enum": [ + "Title", + "Year", + "Rated", + "Released", + "Runtime", + "Genre", + "Director", + "Writer", + "Actors", + "Plot", + "Language", + "Country", + "Awards", + "Poster", + "Ratings", + "Metascore", + "imdbRating", + "imdbVotes", + "imdbID", + "Type", + "DVD", + "BoxOffice", + "Production", + "Website", + "Response", + "seriesID", + "Season", + "Episode", + "createdAt", + "updatedAt" + ] }, "description": "The result set will be limited to these fields" }, "filter": { - "$ref": "#/definitions/FilterType%3CMovieFile%3E", + "$ref": "#/definitions/FilterType%3COmdbMovieMetadata%3E", "description": "The fields should match this filter" } }, "additionalProperties": false, "description": "Type for default filtering model" }, - "FindOptions": { + "FindOptions": { "type": "object", "properties": { "top": { @@ -5508,19 +8925,7 @@ "type": "string", "enum": ["ASC", "DESC"] }, - "DVD": { - "type": "string", - "enum": ["ASC", "DESC"] - }, - "BoxOffice": { - "type": "string", - "enum": ["ASC", "DESC"] - }, - "Production": { - "type": "string", - "enum": ["ASC", "DESC"] - }, - "Website": { + "totalSeasons": { "type": "string", "enum": ["ASC", "DESC"] }, @@ -5528,18 +8933,6 @@ "type": "string", "enum": ["ASC", "DESC"] }, - "seriesID": { - "type": "string", - "enum": ["ASC", "DESC"] - }, - "Season": { - "type": "string", - "enum": ["ASC", "DESC"] - }, - "Episode": { - "type": "string", - "enum": ["ASC", "DESC"] - }, "createdAt": { "type": "string", "enum": ["ASC", "DESC"] @@ -5577,14 +8970,8 @@ "imdbVotes", "imdbID", "Type", - "DVD", - "BoxOffice", - "Production", - "Website", + "totalSeasons", "Response", - "seriesID", - "Season", - "Episode", "createdAt", "updatedAt" ] @@ -5592,14 +8979,153 @@ "description": "The result set will be limited to these fields" }, "filter": { - "$ref": "#/definitions/FilterType%3COmdbMovieMetadata%3E", + "$ref": "#/definitions/FilterType%3COmdbSeriesMetadata%3E", + "description": "The fields should match this filter" + } + }, + "additionalProperties": false, + "description": "Type for default filtering model" + }, + "FindOptions": { + "type": "object", + "properties": { + "top": { + "type": "number", + "description": "Limits the hits" + }, + "skip": { + "type": "number", + "description": "Skips the first N hit" + }, + "order": { + "type": "object", + "properties": { + "imdbId": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "year": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "numberOfSeasons": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "createdAt": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "updatedAt": { + "type": "string", + "enum": ["ASC", "DESC"] + } + }, + "additionalProperties": false, + "description": "Sets up an order by a field and a direction" + }, + "select": { + "type": "array", + "items": { + "type": "string", + "enum": ["imdbId", "year", "numberOfSeasons", "createdAt", "updatedAt"] + }, + "description": "The result set will be limited to these fields" + }, + "filter": { + "$ref": "#/definitions/FilterType%3CSeries%3E", + "description": "The fields should match this filter" + } + }, + "additionalProperties": false, + "description": "Type for default filtering model" + }, + "FindOptions": { + "type": "object", + "properties": { + "top": { + "type": "number", + "description": "Limits the hits" + }, + "skip": { + "type": "number", + "description": "Skips the first N hit" + }, + "order": { + "type": "object", + "properties": { + "id": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "seriesImdbId": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "language": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "title": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "plot": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "posterUrl": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "source": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "sourceId": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "createdAt": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "updatedAt": { + "type": "string", + "enum": ["ASC", "DESC"] + } + }, + "additionalProperties": false, + "description": "Sets up an order by a field and a direction" + }, + "select": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "id", + "seriesImdbId", + "language", + "title", + "plot", + "posterUrl", + "source", + "sourceId", + "createdAt", + "updatedAt" + ] + }, + "description": "The result set will be limited to these fields" + }, + "filter": { + "$ref": "#/definitions/FilterType%3CSeriesMetadataLocalized%3E", "description": "The fields should match this filter" } }, "additionalProperties": false, "description": "Type for default filtering model" }, - "FindOptions": { + "FindOptions": { "type": "object", "properties": { "top": { @@ -5613,91 +9139,87 @@ "order": { "type": "object", "properties": { - "Title": { - "type": "string", - "enum": ["ASC", "DESC"] - }, - "Year": { + "id": { "type": "string", "enum": ["ASC", "DESC"] }, - "Rated": { + "imdbId": { "type": "string", "enum": ["ASC", "DESC"] }, - "Released": { + "title": { "type": "string", "enum": ["ASC", "DESC"] }, - "Runtime": { + "originalTitle": { "type": "string", "enum": ["ASC", "DESC"] }, - "Genre": { + "overview": { "type": "string", "enum": ["ASC", "DESC"] }, - "Director": { + "releaseDate": { "type": "string", "enum": ["ASC", "DESC"] }, - "Writer": { + "runtime": { "type": "string", "enum": ["ASC", "DESC"] }, - "Actors": { + "posterPath": { "type": "string", "enum": ["ASC", "DESC"] }, - "Plot": { + "backdropPath": { "type": "string", "enum": ["ASC", "DESC"] }, - "Language": { + "genres": { "type": "string", "enum": ["ASC", "DESC"] }, - "Country": { + "voteAverage": { "type": "string", "enum": ["ASC", "DESC"] }, - "Awards": { + "voteCount": { "type": "string", "enum": ["ASC", "DESC"] }, - "Poster": { + "popularity": { "type": "string", "enum": ["ASC", "DESC"] }, - "Ratings": { + "originalLanguage": { "type": "string", "enum": ["ASC", "DESC"] }, - "Metascore": { + "spokenLanguages": { "type": "string", "enum": ["ASC", "DESC"] }, - "imdbRating": { + "productionCountries": { "type": "string", "enum": ["ASC", "DESC"] }, - "imdbVotes": { + "status": { "type": "string", "enum": ["ASC", "DESC"] }, - "imdbID": { + "tagline": { "type": "string", "enum": ["ASC", "DESC"] }, - "Type": { + "budget": { "type": "string", "enum": ["ASC", "DESC"] }, - "totalSeasons": { + "revenue": { "type": "string", "enum": ["ASC", "DESC"] }, - "Response": { + "language": { "type": "string", "enum": ["ASC", "DESC"] }, @@ -5718,28 +9240,27 @@ "items": { "type": "string", "enum": [ - "Title", - "Year", - "Rated", - "Released", - "Runtime", - "Genre", - "Director", - "Writer", - "Actors", - "Plot", - "Language", - "Country", - "Awards", - "Poster", - "Ratings", - "Metascore", - "imdbRating", - "imdbVotes", - "imdbID", - "Type", - "totalSeasons", - "Response", + "id", + "imdbId", + "title", + "originalTitle", + "overview", + "releaseDate", + "runtime", + "posterPath", + "backdropPath", + "genres", + "voteAverage", + "voteCount", + "popularity", + "originalLanguage", + "spokenLanguages", + "productionCountries", + "status", + "tagline", + "budget", + "revenue", + "language", "createdAt", "updatedAt" ] @@ -5747,14 +9268,14 @@ "description": "The result set will be limited to these fields" }, "filter": { - "$ref": "#/definitions/FilterType%3COmdbSeriesMetadata%3E", + "$ref": "#/definitions/FilterType%3CTmdbMovieMetadata%3E", "description": "The fields should match this filter" } }, "additionalProperties": false, "description": "Type for default filtering model" }, - "FindOptions": { + "FindOptions": { "type": "object", "properties": { "top": { @@ -5768,23 +9289,71 @@ "order": { "type": "object", "properties": { + "id": { + "type": "string", + "enum": ["ASC", "DESC"] + }, "imdbId": { "type": "string", "enum": ["ASC", "DESC"] }, - "title": { + "name": { "type": "string", "enum": ["ASC", "DESC"] }, - "year": { + "originalName": { "type": "string", "enum": ["ASC", "DESC"] }, - "thumbnailImageUrl": { + "overview": { "type": "string", "enum": ["ASC", "DESC"] }, - "plot": { + "firstAirDate": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "posterPath": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "backdropPath": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "genres": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "voteAverage": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "voteCount": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "numberOfSeasons": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "numberOfEpisodes": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "status": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "originalLanguage": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "languages": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "language": { "type": "string", "enum": ["ASC", "DESC"] }, @@ -5804,12 +9373,32 @@ "type": "array", "items": { "type": "string", - "enum": ["imdbId", "title", "year", "thumbnailImageUrl", "plot", "createdAt", "updatedAt"] + "enum": [ + "id", + "imdbId", + "name", + "originalName", + "overview", + "firstAirDate", + "posterPath", + "backdropPath", + "genres", + "voteAverage", + "voteCount", + "numberOfSeasons", + "numberOfEpisodes", + "status", + "originalLanguage", + "languages", + "language", + "createdAt", + "updatedAt" + ] }, "description": "The result set will be limited to these fields" }, "filter": { - "$ref": "#/definitions/FilterType%3CSeries%3E", + "$ref": "#/definitions/FilterType%3CTmdbSeriesMetadata%3E", "description": "The fields should match this filter" } }, @@ -5889,7 +9478,7 @@ "type": "object", "properties": { "findOptions": { - "$ref": "#/definitions/FindOptions%3CMovie%2C(%22imdbId%22%7C%22title%22%7C%22year%22%7C%22duration%22%7C%22genre%22%7C%22thumbnailImageUrl%22%7C%22plot%22%7C%22type%22%7C%22seriesId%22%7C%22season%22%7C%22episode%22%7C%22createdAt%22%7C%22updatedAt%22)%5B%5D%3E" + "$ref": "#/definitions/FindOptions%3CMovie%2C(%22imdbId%22%7C%22year%22%7C%22duration%22%7C%22type%22%7C%22seriesId%22%7C%22season%22%7C%22episode%22%7C%22createdAt%22%7C%22updatedAt%22)%5B%5D%3E" } }, "additionalProperties": false @@ -5922,6 +9511,26 @@ "additionalProperties": false, "description": "Rest endpoint model for getting / querying collections" }, + "GetCollectionEndpoint": { + "type": "object", + "properties": { + "query": { + "type": "object", + "properties": { + "findOptions": { + "$ref": "#/definitions/FindOptions%3CMovieMetadataLocalized%2C(%22id%22%7C%22movieImdbId%22%7C%22language%22%7C%22title%22%7C%22plot%22%7C%22posterUrl%22%7C%22genre%22%7C%22source%22%7C%22sourceId%22%7C%22createdAt%22%7C%22updatedAt%22)%5B%5D%3E" + } + }, + "additionalProperties": false + }, + "result": { + "$ref": "#/definitions/GetCollectionResult%3CMovieMetadataLocalized%3E" + } + }, + "required": ["query", "result"], + "additionalProperties": false, + "description": "Rest endpoint model for getting / querying collections" + }, "GetCollectionEndpoint": { "type": "object", "properties": { @@ -5969,7 +9578,7 @@ "type": "object", "properties": { "findOptions": { - "$ref": "#/definitions/FindOptions%3CSeries%2C(%22imdbId%22%7C%22title%22%7C%22year%22%7C%22thumbnailImageUrl%22%7C%22plot%22%7C%22createdAt%22%7C%22updatedAt%22)%5B%5D%3E" + "$ref": "#/definitions/FindOptions%3CSeries%2C(%22imdbId%22%7C%22year%22%7C%22numberOfSeasons%22%7C%22createdAt%22%7C%22updatedAt%22)%5B%5D%3E" } }, "additionalProperties": false @@ -5982,6 +9591,66 @@ "additionalProperties": false, "description": "Rest endpoint model for getting / querying collections" }, + "GetCollectionEndpoint": { + "type": "object", + "properties": { + "query": { + "type": "object", + "properties": { + "findOptions": { + "$ref": "#/definitions/FindOptions%3CSeriesMetadataLocalized%2C(%22id%22%7C%22seriesImdbId%22%7C%22language%22%7C%22title%22%7C%22plot%22%7C%22posterUrl%22%7C%22source%22%7C%22sourceId%22%7C%22createdAt%22%7C%22updatedAt%22)%5B%5D%3E" + } + }, + "additionalProperties": false + }, + "result": { + "$ref": "#/definitions/GetCollectionResult%3CSeriesMetadataLocalized%3E" + } + }, + "required": ["query", "result"], + "additionalProperties": false, + "description": "Rest endpoint model for getting / querying collections" + }, + "GetCollectionEndpoint": { + "type": "object", + "properties": { + "query": { + "type": "object", + "properties": { + "findOptions": { + "$ref": "#/definitions/FindOptions%3CTmdbMovieMetadata%2C(%22id%22%7C%22imdbId%22%7C%22title%22%7C%22originalTitle%22%7C%22overview%22%7C%22releaseDate%22%7C%22runtime%22%7C%22posterPath%22%7C%22backdropPath%22%7C%22genres%22%7C%22voteAverage%22%7C%22voteCount%22%7C%22popularity%22%7C%22originalLanguage%22%7C%22spokenLanguages%22%7C%22productionCountries%22%7C%22status%22%7C%22tagline%22%7C%22budget%22%7C%22revenue%22%7C%22language%22%7C%22createdAt%22%7C%22updatedAt%22)%5B%5D%3E" + } + }, + "additionalProperties": false + }, + "result": { + "$ref": "#/definitions/GetCollectionResult%3CTmdbMovieMetadata%3E" + } + }, + "required": ["query", "result"], + "additionalProperties": false, + "description": "Rest endpoint model for getting / querying collections" + }, + "GetCollectionEndpoint": { + "type": "object", + "properties": { + "query": { + "type": "object", + "properties": { + "findOptions": { + "$ref": "#/definitions/FindOptions%3CTmdbSeriesMetadata%2C(%22id%22%7C%22imdbId%22%7C%22name%22%7C%22originalName%22%7C%22overview%22%7C%22firstAirDate%22%7C%22posterPath%22%7C%22backdropPath%22%7C%22genres%22%7C%22voteAverage%22%7C%22voteCount%22%7C%22numberOfSeasons%22%7C%22numberOfEpisodes%22%7C%22status%22%7C%22originalLanguage%22%7C%22languages%22%7C%22language%22%7C%22createdAt%22%7C%22updatedAt%22)%5B%5D%3E" + } + }, + "additionalProperties": false + }, + "result": { + "$ref": "#/definitions/GetCollectionResult%3CTmdbSeriesMetadata%3E" + } + }, + "required": ["query", "result"], + "additionalProperties": false, + "description": "Rest endpoint model for getting / querying collections" + }, "GetCollectionEndpoint": { "type": "object", "properties": { @@ -6002,7 +9671,83 @@ "additionalProperties": false, "description": "Rest endpoint model for getting / querying collections" }, - "GetCollectionResult": { + "GetCollectionResult": { + "type": "object", + "properties": { + "count": { + "type": "number", + "description": "The Total count of entities" + }, + "entries": { + "type": "array", + "items": { + "$ref": "#/definitions/Movie" + }, + "description": "List of the selected entities" + } + }, + "required": ["count", "entries"], + "additionalProperties": false, + "description": "Response Model for GetCollection" + }, + "GetCollectionResult": { + "type": "object", + "properties": { + "count": { + "type": "number", + "description": "The Total count of entities" + }, + "entries": { + "type": "array", + "items": { + "$ref": "#/definitions/MovieFile" + }, + "description": "List of the selected entities" + } + }, + "required": ["count", "entries"], + "additionalProperties": false, + "description": "Response Model for GetCollection" + }, + "GetCollectionResult": { + "type": "object", + "properties": { + "count": { + "type": "number", + "description": "The Total count of entities" + }, + "entries": { + "type": "array", + "items": { + "$ref": "#/definitions/MovieMetadataLocalized" + }, + "description": "List of the selected entities" + } + }, + "required": ["count", "entries"], + "additionalProperties": false, + "description": "Response Model for GetCollection" + }, + "GetCollectionResult": { + "type": "object", + "properties": { + "count": { + "type": "number", + "description": "The Total count of entities" + }, + "entries": { + "type": "array", + "items": { + "$ref": "#/definitions/OmdbMovieMetadata" + }, + "description": "List of the selected entities" + } + }, + "required": ["count", "entries"], + "additionalProperties": false, + "description": "Response Model for GetCollection" + }, + "GetCollectionResult": { "type": "object", "properties": { "count": { @@ -6012,7 +9757,7 @@ "entries": { "type": "array", "items": { - "$ref": "#/definitions/Movie" + "$ref": "#/definitions/OmdbSeriesMetadata" }, "description": "List of the selected entities" } @@ -6021,7 +9766,7 @@ "additionalProperties": false, "description": "Response Model for GetCollection" }, - "GetCollectionResult": { + "GetCollectionResult": { "type": "object", "properties": { "count": { @@ -6031,7 +9776,7 @@ "entries": { "type": "array", "items": { - "$ref": "#/definitions/MovieFile" + "$ref": "#/definitions/Series" }, "description": "List of the selected entities" } @@ -6040,7 +9785,7 @@ "additionalProperties": false, "description": "Response Model for GetCollection" }, - "GetCollectionResult": { + "GetCollectionResult": { "type": "object", "properties": { "count": { @@ -6050,7 +9795,7 @@ "entries": { "type": "array", "items": { - "$ref": "#/definitions/OmdbMovieMetadata" + "$ref": "#/definitions/SeriesMetadataLocalized" }, "description": "List of the selected entities" } @@ -6059,7 +9804,7 @@ "additionalProperties": false, "description": "Response Model for GetCollection" }, - "GetCollectionResult": { + "GetCollectionResult": { "type": "object", "properties": { "count": { @@ -6069,7 +9814,7 @@ "entries": { "type": "array", "items": { - "$ref": "#/definitions/OmdbSeriesMetadata" + "$ref": "#/definitions/TmdbMovieMetadata" }, "description": "List of the selected entities" } @@ -6078,7 +9823,7 @@ "additionalProperties": false, "description": "Response Model for GetCollection" }, - "GetCollectionResult": { + "GetCollectionResult": { "type": "object", "properties": { "count": { @@ -6088,7 +9833,7 @@ "entries": { "type": "array", "items": { - "$ref": "#/definitions/Series" + "$ref": "#/definitions/TmdbSeriesMetadata" }, "description": "List of the selected entities" } @@ -6128,12 +9873,8 @@ "type": "string", "enum": [ "imdbId", - "title", "year", "duration", - "genre", - "thumbnailImageUrl", - "plot", "type", "seriesId", "season", @@ -6202,6 +9943,54 @@ "additionalProperties": false, "description": "Endpoint model for getting a single entity" }, + "GetEntityEndpoint": { + "type": "object", + "properties": { + "query": { + "type": "object", + "properties": { + "select": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "id", + "movieImdbId", + "language", + "title", + "plot", + "posterUrl", + "genre", + "source", + "sourceId", + "createdAt", + "updatedAt" + ] + }, + "description": "The list of fields to select" + } + }, + "additionalProperties": false + }, + "url": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The entity's unique identifier" + } + }, + "required": ["id"], + "additionalProperties": false + }, + "result": { + "$ref": "#/definitions/MovieMetadataLocalized" + } + }, + "required": ["query", "url", "result"], + "additionalProperties": false, + "description": "Endpoint model for getting a single entity" + }, "GetEntityEndpoint": { "type": "object", "properties": { @@ -6340,7 +10129,7 @@ "type": "array", "items": { "type": "string", - "enum": ["imdbId", "title", "year", "thumbnailImageUrl", "plot", "createdAt", "updatedAt"] + "enum": ["imdbId", "year", "numberOfSeasons", "createdAt", "updatedAt"] }, "description": "The list of fields to select" } @@ -6366,6 +10155,169 @@ "additionalProperties": false, "description": "Endpoint model for getting a single entity" }, + "GetEntityEndpoint": { + "type": "object", + "properties": { + "query": { + "type": "object", + "properties": { + "select": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "id", + "seriesImdbId", + "language", + "title", + "plot", + "posterUrl", + "source", + "sourceId", + "createdAt", + "updatedAt" + ] + }, + "description": "The list of fields to select" + } + }, + "additionalProperties": false + }, + "url": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The entity's unique identifier" + } + }, + "required": ["id"], + "additionalProperties": false + }, + "result": { + "$ref": "#/definitions/SeriesMetadataLocalized" + } + }, + "required": ["query", "url", "result"], + "additionalProperties": false, + "description": "Endpoint model for getting a single entity" + }, + "GetEntityEndpoint": { + "type": "object", + "properties": { + "query": { + "type": "object", + "properties": { + "select": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "id", + "imdbId", + "title", + "originalTitle", + "overview", + "releaseDate", + "runtime", + "posterPath", + "backdropPath", + "genres", + "voteAverage", + "voteCount", + "popularity", + "originalLanguage", + "spokenLanguages", + "productionCountries", + "status", + "tagline", + "budget", + "revenue", + "language", + "createdAt", + "updatedAt" + ] + }, + "description": "The list of fields to select" + } + }, + "additionalProperties": false + }, + "url": { + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "The entity's unique identifier" + } + }, + "required": ["id"], + "additionalProperties": false + }, + "result": { + "$ref": "#/definitions/TmdbMovieMetadata" + } + }, + "required": ["query", "url", "result"], + "additionalProperties": false, + "description": "Endpoint model for getting a single entity" + }, + "GetEntityEndpoint": { + "type": "object", + "properties": { + "query": { + "type": "object", + "properties": { + "select": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "id", + "imdbId", + "name", + "originalName", + "overview", + "firstAirDate", + "posterPath", + "backdropPath", + "genres", + "voteAverage", + "voteCount", + "numberOfSeasons", + "numberOfEpisodes", + "status", + "originalLanguage", + "languages", + "language", + "createdAt", + "updatedAt" + ] + }, + "description": "The list of fields to select" + } + }, + "additionalProperties": false + }, + "url": { + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "The entity's unique identifier" + } + }, + "required": ["id"], + "additionalProperties": false + }, + "result": { + "$ref": "#/definitions/TmdbSeriesMetadata" + } + }, + "required": ["query", "url", "result"], + "additionalProperties": false, + "description": "Endpoint model for getting a single entity" + }, "GetEntityEndpoint": { "type": "object", "properties": { @@ -6498,6 +10450,9 @@ }, "resolution": { "type": "string" + }, + "startTime": { + "type": "number" } }, "additionalProperties": false @@ -6537,6 +10492,12 @@ }, "containers": { "type": "string" + }, + "audioTrack": { + "type": "number" + }, + "startTime": { + "type": "number" } }, "additionalProperties": false @@ -6576,6 +10537,9 @@ }, "audioTrack": { "type": "number" + }, + "startTime": { + "type": "number" } }, "additionalProperties": false @@ -6612,6 +10576,9 @@ }, "audioTrack": { "type": "number" + }, + "startTime": { + "type": "number" } }, "additionalProperties": false @@ -6657,6 +10624,9 @@ }, "audioTrack": { "type": "number" + }, + "startTime": { + "type": "number" } }, "additionalProperties": false @@ -6684,8 +10654,8 @@ "not-movie-file", "rate-limited", "metadata-not-found", - "omdb-not-configured", - "omdb-error" + "provider-not-configured", + "provider-error" ] }, "error": {} @@ -6706,8 +10676,8 @@ "not-movie-file", "rate-limited", "metadata-not-found", - "omdb-not-configured", - "omdb-error" + "provider-not-configured", + "provider-error" ] }, "MediaApi": { @@ -6752,6 +10722,30 @@ "/omdb-series-metadata/:id": { "$ref": "#/definitions/GetEntityEndpoint%3COmdbSeriesMetadata%2C%22imdbID%22%3E" }, + "/tmdb-movie-metadata": { + "$ref": "#/definitions/GetCollectionEndpoint%3CTmdbMovieMetadata%3E" + }, + "/tmdb-movie-metadata/:id": { + "$ref": "#/definitions/GetEntityEndpoint%3CTmdbMovieMetadata%2C%22id%22%3E" + }, + "/tmdb-series-metadata": { + "$ref": "#/definitions/GetCollectionEndpoint%3CTmdbSeriesMetadata%3E" + }, + "/tmdb-series-metadata/:id": { + "$ref": "#/definitions/GetEntityEndpoint%3CTmdbSeriesMetadata%2C%22id%22%3E" + }, + "/movie-metadata-localized": { + "$ref": "#/definitions/GetCollectionEndpoint%3CMovieMetadataLocalized%3E" + }, + "/movie-metadata-localized/:id": { + "$ref": "#/definitions/GetEntityEndpoint%3CMovieMetadataLocalized%2C%22id%22%3E" + }, + "/series-metadata-localized": { + "$ref": "#/definitions/GetCollectionEndpoint%3CSeriesMetadataLocalized%3E" + }, + "/series-metadata-localized/:id": { + "$ref": "#/definitions/GetEntityEndpoint%3CSeriesMetadataLocalized%2C%22id%22%3E" + }, "/movie-files": { "$ref": "#/definitions/GetCollectionEndpoint%3CMovieFile%3E" }, @@ -6784,6 +10778,14 @@ "/omdb-movie-metadata/:id", "/omdb-series-metadata", "/omdb-series-metadata/:id", + "/tmdb-movie-metadata", + "/tmdb-movie-metadata/:id", + "/tmdb-series-metadata", + "/tmdb-series-metadata/:id", + "/movie-metadata-localized", + "/movie-metadata-localized/:id", + "/series-metadata-localized", + "/series-metadata-localized/:id", "/movie-files", "/movie-files/:id", "/files/:letter/:path/master.m3u8", @@ -6851,7 +10853,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false @@ -6890,7 +10907,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false @@ -6905,7 +10937,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false @@ -6920,7 +10967,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false @@ -6935,7 +10997,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false @@ -6951,27 +11028,12 @@ "imdbId": { "type": "string" }, - "title": { - "type": "string" - }, - "year": { - "type": "number" - }, - "duration": { - "type": "number" - }, - "genre": { - "type": "array", - "items": { - "type": "string" - } - }, - "thumbnailImageUrl": { - "type": "string" - }, - "plot": { - "type": "string" - }, + "year": { + "type": "number" + }, + "duration": { + "type": "number" + }, "type": { "type": "string", "enum": ["movie", "episode"] @@ -6992,7 +11054,7 @@ "type": "string" } }, - "required": ["imdbId", "title", "createdAt", "updatedAt"], + "required": ["imdbId", "createdAt", "updatedAt"], "additionalProperties": false }, "MovieFile": { @@ -7034,6 +11096,50 @@ "required": ["id", "driveLetter", "path", "ffprobe"], "additionalProperties": false }, + "MovieMetadataLocalized": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "movieImdbId": { + "type": "string" + }, + "language": { + "type": "string" + }, + "title": { + "type": "string" + }, + "plot": { + "type": "string" + }, + "posterUrl": { + "type": "string" + }, + "genre": { + "type": "array", + "items": { + "type": "string" + } + }, + "source": { + "type": "string", + "enum": ["omdb", "tmdb"] + }, + "sourceId": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": ["id", "movieImdbId", "language", "title", "source", "createdAt", "updatedAt"], + "additionalProperties": false + }, "OmdbMovieMetadata": { "type": "object", "properties": { @@ -7288,7 +11394,7 @@ "additionalProperties": false }, "Omit": { - "$ref": "#/definitions/Pick%3CMovie%2CExclude%3C(%22imdbId%22%7C%22title%22%7C%22year%22%7C%22duration%22%7C%22genre%22%7C%22thumbnailImageUrl%22%7C%22plot%22%7C%22type%22%7C%22seriesId%22%7C%22season%22%7C%22episode%22%7C%22createdAt%22%7C%22updatedAt%22)%2C(%22createdAt%22%7C%22updatedAt%22)%3E%3E" + "$ref": "#/definitions/Pick%3CMovie%2CExclude%3C(%22imdbId%22%7C%22year%22%7C%22duration%22%7C%22type%22%7C%22seriesId%22%7C%22season%22%7C%22episode%22%7C%22createdAt%22%7C%22updatedAt%22)%2C(%22createdAt%22%7C%22updatedAt%22)%3E%3E" }, "Omit": { "$ref": "#/definitions/Pick%3CWatchHistoryEntry%2CExclude%3C(%22id%22%7C%22userName%22%7C%22driveLetter%22%7C%22path%22%7C%22watchedSeconds%22%7C%22completed%22%7C%22createdAt%22%7C%22updatedAt%22)%2C(%22userName%22%7C%22createdAt%22%7C%22updatedAt%22%7C%22id%22)%3E%3E" @@ -7337,27 +11443,12 @@ "imdbId": { "type": "string" }, - "title": { - "type": "string" - }, "year": { "type": "number" }, "duration": { "type": "number" }, - "genre": { - "type": "array", - "items": { - "type": "string" - } - }, - "thumbnailImageUrl": { - "type": "string" - }, - "plot": { - "type": "string" - }, "type": { "type": "string", "enum": ["movie", "episode"] @@ -7422,33 +11513,18 @@ "additionalProperties": false, "description": "Endpoint model for updating entities" }, - "Pick>": { + "Pick>": { "type": "object", "properties": { "imdbId": { "type": "string" }, - "title": { - "type": "string" - }, "year": { "type": "number" }, "duration": { "type": "number" }, - "genre": { - "type": "array", - "items": { - "type": "string" - } - }, - "thumbnailImageUrl": { - "type": "string" - }, - "plot": { - "type": "string" - }, "type": { "type": "string", "enum": ["movie", "episode"] @@ -7463,7 +11539,7 @@ "type": "number" } }, - "required": ["imdbId", "title"], + "required": ["imdbId"], "additionalProperties": false }, "Pick>": { @@ -7666,10 +11742,10 @@ "metadataNotFound": { "type": "number" }, - "omdbNotConfigured": { + "providerNotConfigured": { "type": "number" }, - "omdbError": { + "providerError": { "type": "number" }, "skipped": { @@ -7683,8 +11759,8 @@ "failed", "rateLimited", "metadataNotFound", - "omdbNotConfigured", - "omdbError", + "providerNotConfigured", + "providerError", "skipped" ], "additionalProperties": false @@ -7695,18 +11771,50 @@ "imdbId": { "type": "string" }, - "title": { + "year": { "type": "string" }, - "year": { + "numberOfSeasons": { + "type": "number" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": ["imdbId", "year", "createdAt", "updatedAt"], + "additionalProperties": false + }, + "SeriesMetadataLocalized": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "seriesImdbId": { + "type": "string" + }, + "language": { "type": "string" }, - "thumbnailImageUrl": { + "title": { "type": "string" }, "plot": { "type": "string" }, + "posterUrl": { + "type": "string" + }, + "source": { + "type": "string", + "enum": ["omdb", "tmdb"] + }, + "sourceId": { + "type": "string" + }, "createdAt": { "type": "string" }, @@ -7714,7 +11822,7 @@ "type": "string" } }, - "required": ["imdbId", "title", "year", "plot", "createdAt", "updatedAt"], + "required": ["id", "seriesImdbId", "language", "title", "source", "createdAt", "updatedAt"], "additionalProperties": false }, "SubtitleTrackInfo": { @@ -7747,6 +11855,221 @@ "required": ["index", "label", "language", "format", "source", "requiresBurnIn"], "additionalProperties": false }, + "TmdbMovieMetadata": { + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "imdbId": { + "type": "string" + }, + "title": { + "type": "string" + }, + "originalTitle": { + "type": "string" + }, + "overview": { + "type": "string" + }, + "releaseDate": { + "type": "string" + }, + "runtime": { + "type": "number" + }, + "posterPath": { + "type": "string" + }, + "backdropPath": { + "type": "string" + }, + "genres": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "name": { + "type": "string" + } + }, + "required": ["id", "name"], + "additionalProperties": false + } + }, + "voteAverage": { + "type": "number" + }, + "voteCount": { + "type": "number" + }, + "popularity": { + "type": "number" + }, + "originalLanguage": { + "type": "string" + }, + "spokenLanguages": { + "type": "array", + "items": { + "type": "object", + "properties": { + "iso_639_1": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": ["iso_639_1", "name"], + "additionalProperties": false + } + }, + "productionCountries": { + "type": "array", + "items": { + "type": "object", + "properties": { + "iso_3166_1": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": ["iso_3166_1", "name"], + "additionalProperties": false + } + }, + "status": { + "type": "string" + }, + "tagline": { + "type": "string" + }, + "budget": { + "type": "number" + }, + "revenue": { + "type": "number" + }, + "language": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "id", + "title", + "originalTitle", + "overview", + "genres", + "originalLanguage", + "language", + "createdAt", + "updatedAt" + ], + "additionalProperties": false + }, + "TmdbSeriesMetadata": { + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "imdbId": { + "type": "string" + }, + "name": { + "type": "string" + }, + "originalName": { + "type": "string" + }, + "overview": { + "type": "string" + }, + "firstAirDate": { + "type": "string" + }, + "posterPath": { + "type": "string" + }, + "backdropPath": { + "type": "string" + }, + "genres": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "name": { + "type": "string" + } + }, + "required": ["id", "name"], + "additionalProperties": false + } + }, + "voteAverage": { + "type": "number" + }, + "voteCount": { + "type": "number" + }, + "numberOfSeasons": { + "type": "number" + }, + "numberOfEpisodes": { + "type": "number" + }, + "status": { + "type": "string" + }, + "originalLanguage": { + "type": "string" + }, + "languages": { + "type": "array", + "items": { + "type": "string" + } + }, + "language": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "originalName", + "overview", + "genres", + "originalLanguage", + "language", + "createdAt", + "updatedAt" + ], + "additionalProperties": false + }, "updateScanProgress": { "$comment": "(progress: ScanProgress, status: LinkMovieStatus | 'skipped' | 'failed') =>undefined", "type": "object", diff --git a/common/schemas/media-entities.json b/common/schemas/media-entities.json index b61dcae9..7d6cbbcb 100644 --- a/common/schemas/media-entities.json +++ b/common/schemas/media-entities.json @@ -299,27 +299,12 @@ "imdbId": { "type": "string" }, - "title": { - "type": "string" - }, "year": { "type": "number" }, "duration": { "type": "number" }, - "genre": { - "type": "array", - "items": { - "type": "string" - } - }, - "thumbnailImageUrl": { - "type": "string" - }, - "plot": { - "type": "string" - }, "type": { "type": "string", "enum": ["movie", "episode"] @@ -340,7 +325,7 @@ "type": "string" } }, - "required": ["imdbId", "title", "createdAt", "updatedAt"], + "required": ["imdbId", "createdAt", "updatedAt"], "additionalProperties": false }, "MovieFile": { @@ -382,6 +367,50 @@ "required": ["id", "driveLetter", "path", "ffprobe"], "additionalProperties": false }, + "MovieMetadataLocalized": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "movieImdbId": { + "type": "string" + }, + "language": { + "type": "string" + }, + "title": { + "type": "string" + }, + "plot": { + "type": "string" + }, + "posterUrl": { + "type": "string" + }, + "genre": { + "type": "array", + "items": { + "type": "string" + } + }, + "source": { + "type": "string", + "enum": ["omdb", "tmdb"] + }, + "sourceId": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": ["id", "movieImdbId", "language", "title", "source", "createdAt", "updatedAt"], + "additionalProperties": false + }, "OmdbMovieMetadata": { "type": "object", "properties": { @@ -641,18 +670,165 @@ "imdbId": { "type": "string" }, - "title": { + "year": { "type": "string" }, - "year": { + "numberOfSeasons": { + "type": "number" + }, + "createdAt": { "type": "string" }, - "thumbnailImageUrl": { + "updatedAt": { + "type": "string" + } + }, + "required": ["imdbId", "year", "createdAt", "updatedAt"], + "additionalProperties": false + }, + "SeriesMetadataLocalized": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "seriesImdbId": { + "type": "string" + }, + "language": { + "type": "string" + }, + "title": { "type": "string" }, "plot": { "type": "string" }, + "posterUrl": { + "type": "string" + }, + "source": { + "type": "string", + "enum": ["omdb", "tmdb"] + }, + "sourceId": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": ["id", "seriesImdbId", "language", "title", "source", "createdAt", "updatedAt"], + "additionalProperties": false + }, + "TmdbMovieMetadata": { + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "imdbId": { + "type": "string" + }, + "title": { + "type": "string" + }, + "originalTitle": { + "type": "string" + }, + "overview": { + "type": "string" + }, + "releaseDate": { + "type": "string" + }, + "runtime": { + "type": "number" + }, + "posterPath": { + "type": "string" + }, + "backdropPath": { + "type": "string" + }, + "genres": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "name": { + "type": "string" + } + }, + "required": ["id", "name"], + "additionalProperties": false + } + }, + "voteAverage": { + "type": "number" + }, + "voteCount": { + "type": "number" + }, + "popularity": { + "type": "number" + }, + "originalLanguage": { + "type": "string" + }, + "spokenLanguages": { + "type": "array", + "items": { + "type": "object", + "properties": { + "iso_639_1": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": ["iso_639_1", "name"], + "additionalProperties": false + } + }, + "productionCountries": { + "type": "array", + "items": { + "type": "object", + "properties": { + "iso_3166_1": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": ["iso_3166_1", "name"], + "additionalProperties": false + } + }, + "status": { + "type": "string" + }, + "tagline": { + "type": "string" + }, + "budget": { + "type": "number" + }, + "revenue": { + "type": "number" + }, + "language": { + "type": "string" + }, "createdAt": { "type": "string" }, @@ -660,7 +836,107 @@ "type": "string" } }, - "required": ["imdbId", "title", "year", "plot", "createdAt", "updatedAt"], + "required": [ + "id", + "title", + "originalTitle", + "overview", + "genres", + "originalLanguage", + "language", + "createdAt", + "updatedAt" + ], + "additionalProperties": false + }, + "TmdbSeriesMetadata": { + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "imdbId": { + "type": "string" + }, + "name": { + "type": "string" + }, + "originalName": { + "type": "string" + }, + "overview": { + "type": "string" + }, + "firstAirDate": { + "type": "string" + }, + "posterPath": { + "type": "string" + }, + "backdropPath": { + "type": "string" + }, + "genres": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "name": { + "type": "string" + } + }, + "required": ["id", "name"], + "additionalProperties": false + } + }, + "voteAverage": { + "type": "number" + }, + "voteCount": { + "type": "number" + }, + "numberOfSeasons": { + "type": "number" + }, + "numberOfEpisodes": { + "type": "number" + }, + "status": { + "type": "string" + }, + "originalLanguage": { + "type": "string" + }, + "languages": { + "type": "array", + "items": { + "type": "string" + } + }, + "language": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "originalName", + "overview", + "genres", + "originalLanguage", + "language", + "createdAt", + "updatedAt" + ], "additionalProperties": false }, "WatchHistoryEntry": { diff --git a/common/src/apis/install.ts b/common/src/apis/install.ts index 45e7614e..759928ef 100644 --- a/common/src/apis/install.ts +++ b/common/src/apis/install.ts @@ -8,6 +8,10 @@ export type ServiceStatusResponse = { * OMDB API Installation Status for metadata fetching */ omdb: boolean + /** + * TMDB API Installation Status for metadata fetching + */ + tmdb: boolean /** * Github API Installation Status for external authentication */ diff --git a/common/src/apis/media.ts b/common/src/apis/media.ts index ceae4e3f..1911a045 100644 --- a/common/src/apis/media.ts +++ b/common/src/apis/media.ts @@ -9,9 +9,13 @@ import type { import type { Movie, MovieFile, + MovieMetadataLocalized, OmdbMovieMetadata, OmdbSeriesMetadata, Series, + SeriesMetadataLocalized, + TmdbMovieMetadata, + TmdbSeriesMetadata, WatchHistoryEntry, } from '../models/media/index.js' import type { PiRatFile } from '../models/pirat-file.js' @@ -26,8 +30,8 @@ export type LinkMovie = { | 'not-movie-file' | 'rate-limited' | 'metadata-not-found' - | 'omdb-not-configured' - | 'omdb-error' + | 'provider-not-configured' + | 'provider-error' error?: unknown } } @@ -56,8 +60,8 @@ export type ScanProgress = { failed: number rateLimited: number metadataNotFound: number - omdbNotConfigured: number - omdbError: number + providerNotConfigured: number + providerError: number skipped: number } @@ -77,11 +81,11 @@ export const updateScanProgress = (progress: ScanProgress, status: LinkMovieStat case 'metadata-not-found': progress.metadataNotFound++ break - case 'omdb-not-configured': - progress.omdbNotConfigured++ + case 'provider-not-configured': + progress.providerNotConfigured++ break - case 'omdb-error': - progress.omdbError++ + case 'provider-error': + progress.providerError++ break case 'failed': progress.failed++ @@ -99,8 +103,8 @@ export const createScanProgress = (total: number): ScanProgress => ({ failed: 0, rateLimited: 0, metadataNotFound: 0, - omdbNotConfigured: 0, - omdbError: 0, + providerNotConfigured: 0, + providerError: 0, skipped: 0, }) @@ -110,8 +114,8 @@ export const getProcessedCount = (progress: ScanProgress): number => progress.failed + progress.rateLimited + progress.metadataNotFound + - progress.omdbNotConfigured + - progress.omdbError + + progress.providerNotConfigured + + progress.providerError + progress.skipped export type ScanForMoviesEndpoint = { @@ -173,31 +177,38 @@ export type PlaybackInfoResponse = { export type HlsMasterEndpoint = { url: { letter: string; path: string } - query: { mode?: PlaybackMode; videoCodecs?: string; audioCodecs?: string; containers?: string } + query: { + mode?: PlaybackMode + videoCodecs?: string + audioCodecs?: string + containers?: string + audioTrack?: number + startTime?: number + } result: unknown } export type HlsStreamEndpoint = { url: { letter: string; path: string } - query: { mode?: PlaybackMode; resolution?: string; audioTrack?: number } + query: { mode?: PlaybackMode; resolution?: string; audioTrack?: number; startTime?: number } result: unknown } export type HlsSegmentEndpoint = { url: { letter: string; path: string; index: string } - query: { mode?: PlaybackMode; resolution?: string; audioTrack?: number } + query: { mode?: PlaybackMode; resolution?: string; audioTrack?: number; startTime?: number } result: unknown } export type HlsInitEndpoint = { url: { letter: string; path: string } - query: { mode?: PlaybackMode; audioTrack?: number; resolution?: string } + query: { mode?: PlaybackMode; audioTrack?: number; resolution?: string; startTime?: number } result: unknown } export type HlsSessionTeardownEndpoint = { url: { letter: string; path: string } - query: { mode?: PlaybackMode; resolution?: string; audioTrack?: number } + query: { mode?: PlaybackMode; resolution?: string; audioTrack?: number; startTime?: number } result: { success: boolean } } @@ -225,6 +236,14 @@ export interface MediaApi extends RestApi { '/omdb-movie-metadata/:id': GetEntityEndpoint '/omdb-series-metadata': GetCollectionEndpoint '/omdb-series-metadata/:id': GetEntityEndpoint + '/tmdb-movie-metadata': GetCollectionEndpoint + '/tmdb-movie-metadata/:id': GetEntityEndpoint + '/tmdb-series-metadata': GetCollectionEndpoint + '/tmdb-series-metadata/:id': GetEntityEndpoint + '/movie-metadata-localized': GetCollectionEndpoint + '/movie-metadata-localized/:id': GetEntityEndpoint + '/series-metadata-localized': GetCollectionEndpoint + '/series-metadata-localized/:id': GetEntityEndpoint '/movie-files': GetCollectionEndpoint '/movie-files/:id': GetEntityEndpoint '/files/:letter/:path/master.m3u8': HlsMasterEndpoint diff --git a/common/src/models/config/config.ts b/common/src/models/config/config.ts index 97676324..b934d64b 100644 --- a/common/src/models/config/config.ts +++ b/common/src/models/config/config.ts @@ -1,10 +1,19 @@ import type { GithubConfig } from './github-config.js' import type { IotConfig } from './iot-config.js' +import type { MetadataProviderConfig } from './metadata-provider-config.js' import type { MoviesConfig } from './movies-config.js' import type { OllamaConfig } from './ollama-config.js' import type { OmdbConfig } from './omdb-config.js' +import type { TmdbConfig } from './tmdb-config.js' -export type ConfigType = OmdbConfig | GithubConfig | IotConfig | MoviesConfig | OllamaConfig +export type ConfigType = + | OmdbConfig + | GithubConfig + | IotConfig + | MoviesConfig + | OllamaConfig + | TmdbConfig + | MetadataProviderConfig export class Config { id!: ConfigType['id'] diff --git a/common/src/models/config/index.ts b/common/src/models/config/index.ts index 7fac89b0..bc8e7a41 100644 --- a/common/src/models/config/index.ts +++ b/common/src/models/config/index.ts @@ -1,6 +1,8 @@ export * from './config.js' export * from './github-config.js' export * from './iot-config.js' +export * from './metadata-provider-config.js' export * from './movies-config.js' export * from './ollama-config.js' export * from './omdb-config.js' +export * from './tmdb-config.js' diff --git a/common/src/models/config/metadata-provider-config.ts b/common/src/models/config/metadata-provider-config.ts new file mode 100644 index 00000000..e11471b9 --- /dev/null +++ b/common/src/models/config/metadata-provider-config.ts @@ -0,0 +1,10 @@ +export type MetadataProviderConfig = { + id: 'METADATA_PROVIDER_CONFIG' + value: { + /** + * Ordered list of metadata providers to try when linking movies. + * The first available provider that returns a result wins. + */ + priority: Array<'omdb' | 'tmdb'> + } +} diff --git a/common/src/models/config/tmdb-config.ts b/common/src/models/config/tmdb-config.ts new file mode 100644 index 00000000..7552dbba --- /dev/null +++ b/common/src/models/config/tmdb-config.ts @@ -0,0 +1,18 @@ +export type TmdbConfig = { + id: 'TMDB_CONFIG' + value: { + /** + * The API key (v3 auth) or Read Access Token (v4 auth / Bearer) for the TMDB API. + * Can be obtained at https://www.themoviedb.org/settings/api + */ + apiKey: string + /** + * Primary language for metadata fetches, in TMDB locale format (e.g. 'en-US', 'fr-FR'). + */ + defaultLanguage: string + /** + * Additional languages to fetch alongside the default (e.g. ['fr-FR', 'de-DE']). + */ + additionalLanguages: string[] + } +} diff --git a/common/src/models/media/index.ts b/common/src/models/media/index.ts index e3696da7..8bbfe7e3 100644 --- a/common/src/models/media/index.ts +++ b/common/src/models/media/index.ts @@ -1,7 +1,11 @@ export * from './ffprobe-data.js' export * from './movie-file.js' +export * from './movie-metadata-localized.js' export * from './movie-watch-history.js' export * from './movie.js' export * from './omdb-movie-metadata.js' export * from './omdb-series-metadata.js' +export * from './series-metadata-localized.js' export * from './series.js' +export * from './tmdb-movie-metadata.js' +export * from './tmdb-series-metadata.js' diff --git a/common/src/models/media/movie-metadata-localized.ts b/common/src/models/media/movie-metadata-localized.ts new file mode 100644 index 00000000..477a2de7 --- /dev/null +++ b/common/src/models/media/movie-metadata-localized.ts @@ -0,0 +1,13 @@ +export class MovieMetadataLocalized { + id!: string + movieImdbId!: string + language!: string + title!: string + plot?: string + posterUrl?: string + genre?: string[] + source!: 'omdb' | 'tmdb' + sourceId?: string + createdAt!: string + updatedAt!: string +} diff --git a/common/src/models/media/movie.ts b/common/src/models/media/movie.ts index 4fdc5f34..71d1b9dd 100644 --- a/common/src/models/media/movie.ts +++ b/common/src/models/media/movie.ts @@ -1,11 +1,7 @@ export class Movie { imdbId!: string - title!: string year?: number duration?: number - genre?: string[] - thumbnailImageUrl?: string - plot?: string type?: 'movie' | 'episode' seriesId?: string season?: number diff --git a/common/src/models/media/series-metadata-localized.ts b/common/src/models/media/series-metadata-localized.ts new file mode 100644 index 00000000..d0b1bf16 --- /dev/null +++ b/common/src/models/media/series-metadata-localized.ts @@ -0,0 +1,12 @@ +export class SeriesMetadataLocalized { + id!: string + seriesImdbId!: string + language!: string + title!: string + plot?: string + posterUrl?: string + source!: 'omdb' | 'tmdb' + sourceId?: string + createdAt!: string + updatedAt!: string +} diff --git a/common/src/models/media/series.ts b/common/src/models/media/series.ts index 3212710e..a792845f 100644 --- a/common/src/models/media/series.ts +++ b/common/src/models/media/series.ts @@ -1,9 +1,7 @@ export class Series { imdbId!: string - public title!: string //'Supernatural' - public year!: string // '2005–2020' - public thumbnailImageUrl?: string - public plot!: string + year!: string + numberOfSeasons?: number createdAt!: string updatedAt!: string } diff --git a/common/src/models/media/tmdb-movie-metadata.ts b/common/src/models/media/tmdb-movie-metadata.ts new file mode 100644 index 00000000..3c8b6e1c --- /dev/null +++ b/common/src/models/media/tmdb-movie-metadata.ts @@ -0,0 +1,25 @@ +export class TmdbMovieMetadata { + public id!: number + public imdbId?: string + public title!: string + public originalTitle!: string + public overview!: string + public releaseDate?: string + public runtime?: number + public posterPath?: string + public backdropPath?: string + public genres!: Array<{ id: number; name: string }> + public voteAverage?: number + public voteCount?: number + public popularity?: number + public originalLanguage!: string + public spokenLanguages?: Array<{ iso_639_1: string; name: string }> + public productionCountries?: Array<{ iso_3166_1: string; name: string }> + public status?: string + public tagline?: string + public budget?: number + public revenue?: number + public language!: string + public createdAt!: string + public updatedAt!: string +} diff --git a/common/src/models/media/tmdb-series-metadata.ts b/common/src/models/media/tmdb-series-metadata.ts new file mode 100644 index 00000000..85ace9d0 --- /dev/null +++ b/common/src/models/media/tmdb-series-metadata.ts @@ -0,0 +1,21 @@ +export class TmdbSeriesMetadata { + public id!: number + public imdbId?: string + public name!: string + public originalName!: string + public overview!: string + public firstAirDate?: string + public posterPath?: string + public backdropPath?: string + public genres!: Array<{ id: number; name: string }> + public voteAverage?: number + public voteCount?: number + public numberOfSeasons?: number + public numberOfEpisodes?: number + public status?: string + public originalLanguage!: string + public languages?: string[] + public language!: string + public createdAt!: string + public updatedAt!: string +} diff --git a/common/src/utils/media/hls-constants.ts b/common/src/utils/media/hls-constants.ts new file mode 100644 index 00000000..4e0d47e7 --- /dev/null +++ b/common/src/utils/media/hls-constants.ts @@ -0,0 +1 @@ +export const HLS_SEGMENT_DURATION = 6 diff --git a/common/src/utils/media/index.ts b/common/src/utils/media/index.ts index cb4ad0f8..fbe6f97e 100644 --- a/common/src/utils/media/index.ts +++ b/common/src/utils/media/index.ts @@ -1,3 +1,4 @@ +export * from './hls-constants.js' export * from './is-movie-file.js' export * from './is-sample-file.js' export * from './get-fallback-metadata.js' diff --git a/common/src/utils/media/is-movie-file.spec.ts b/common/src/utils/media/is-movie-file.spec.ts index 0edf610c..34bcaeb7 100644 --- a/common/src/utils/media/is-movie-file.spec.ts +++ b/common/src/utils/media/is-movie-file.spec.ts @@ -10,6 +10,23 @@ describe('isMovieFile', () => { expect(isMovieFile('alma.webm')).toBeTruthy() }) + it('should indicate true if the extension is .avi', () => { + expect(isMovieFile('alma.avi')).toBeTruthy() + }) + + it('should indicate true if the extension is .mp4', () => { + expect(isMovieFile('alma.mp4')).toBeTruthy() + }) + + it('should indicate true if the extension is .mov', () => { + expect(isMovieFile('alma.mov')).toBeTruthy() + }) + + it('should be case-insensitive', () => { + expect(isMovieFile('alma.MKV')).toBeTruthy() + expect(isMovieFile('alma.Mp4')).toBeTruthy() + }) + it('should indicate false for unknown extensions', () => { expect(isMovieFile('alma.zip')).toBeFalsy() }) diff --git a/common/src/utils/media/is-movie-file.ts b/common/src/utils/media/is-movie-file.ts index d88da75d..4e0adb03 100644 --- a/common/src/utils/media/is-movie-file.ts +++ b/common/src/utils/media/is-movie-file.ts @@ -1,6 +1,8 @@ +const movieExtensions = ['.mkv', '.webm', '.avi', '.mp4', '.mov'] + export const isMovieFile = (path: string) => { const pathToLower = path.toLowerCase() - if (pathToLower.endsWith('.mkv') || pathToLower.endsWith('.webm') || pathToLower.endsWith('.avi')) { + if (movieExtensions.some((extension) => pathToLower.endsWith(extension))) { return true } return false diff --git a/e2e/file-browser.spec.ts b/e2e/file-browser.spec.ts index 407a3ee3..dcb9f3cf 100644 --- a/e2e/file-browser.spec.ts +++ b/e2e/file-browser.spec.ts @@ -58,7 +58,12 @@ const deleteFile = async (page: Page, fileName: string) => { await expect(file).toBeVisible() await file.click() await page.keyboard.press('Delete') - await assertAndDismissNoty(page, 'The file is deleted succesfully') + + const confirmButton = page.locator('shade-dialog button', { hasText: /Delete/i }) + await expect(confirmButton).toBeVisible() + await confirmButton.click() + + await assertAndDismissNoty(page, 'item(s) deleted successfully') } test.describe('File Browser', () => { diff --git a/e2e/video-playback.spec.ts b/e2e/video-playback.spec.ts index 01ce7d99..6bfef424 100644 --- a/e2e/video-playback.spec.ts +++ b/e2e/video-playback.spec.ts @@ -91,11 +91,11 @@ const navigateToMovieAndPlay = async (page: Page, browserName: string, wIndex: n } const openSettingsSubmenu = async (page: Page, menuItemText: string) => { - const settingsButton = page.locator('media-settings-menu-button').first() + const settingsButton = page.locator('[data-testid="settings-menu-button"]').first() await expect(settingsButton).toBeVisible({ timeout: 5_000 }) await settingsButton.click() - const menuItem = page.locator('media-settings-menu-item').filter({ hasText: menuItemText }) + const menuItem = page.locator('[data-testid="settings-menu-item"]').filter({ hasText: menuItemText }) await expect(menuItem).toBeVisible({ timeout: 5_000 }) await menuItem.click() } @@ -219,7 +219,7 @@ test.describe('Video Playback @media', () => { await navigateToMovieAndPlay(page, browserName, workerIndex) await openSettingsSubmenu(page, 'Captions') - const captionOptions = page.locator('media-captions-menu media-chrome-menu-item') + const captionOptions = page.locator('[data-testid="caption-track-item"]') await expect(captionOptions.first()).toBeVisible({ timeout: 5_000 }) const optionCount = await captionOptions.count() expect(optionCount).toBeGreaterThan(0) @@ -231,7 +231,7 @@ test.describe('Video Playback @media', () => { const video = await navigateToMovieAndPlay(page, browserName, workerIndex) await openSettingsSubmenu(page, 'Audio') - const audioOptions = page.locator('media-audio-track-menu media-chrome-menu-item') + const audioOptions = page.locator('[data-testid="audio-track-item"]') await expect(audioOptions.first()).toBeVisible({ timeout: 5_000 }) const audioCount = await audioOptions.count() expect(audioCount).toBeGreaterThanOrEqual(2) @@ -245,21 +245,7 @@ test.describe('Video Playback @media', () => { }).toPass({ timeout: 30_000, intervals: [2_000, 3_000, 5_000] }) }) - test('Quality switching (HLS): quality options are listed and selectable', async ({ page, browserName }) => { - const video = await navigateToMovieAndPlay(page, browserName, workerIndex) - await openSettingsSubmenu(page, 'Quality') - - const qualityOptions = page.locator('media-rendition-menu media-chrome-menu-item') - await expect(qualityOptions.first()).toBeVisible({ timeout: 5_000 }) - const qualityCount = await qualityOptions.count() - expect(qualityCount).toBeGreaterThanOrEqual(2) - - await qualityOptions.last().click() - - // Verify playback continues after quality switch - await expect(async () => { - const currentTime = await video.evaluate((el: HTMLVideoElement) => el.currentTime) - expect(currentTime).toBeGreaterThan(0) - }).toPass({ timeout: 30_000, intervals: [2_000, 3_000, 5_000] }) - }) + // Quality/resolution switching was removed in favour of server-side mode + // selection (transcode / remux / direct-play). No client-side resolution + // observable exists anymore, so the former E2E test is intentionally omitted. }) diff --git a/eslint.config.js b/eslint.config.js index ac435801..37352f95 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -5,9 +5,10 @@ import furystack from '@furystack/eslint-plugin' import prettierConfig from 'eslint-config-prettier' import jsdoc from 'eslint-plugin-jsdoc' import playwright from 'eslint-plugin-playwright' +import { defineConfig } from 'eslint/config' import tseslint from 'typescript-eslint' -export default tseslint.config( +export default defineConfig( { ...playwright.configs['flat/recommended'], files: ['e2e'], @@ -15,10 +16,10 @@ export default tseslint.config( { ignores: [ 'coverage', - '*/node_modules/*', - '*/esm/*', - '*/types/*', - '*/dist/*', + '**/node_modules/**', + '**/esm/**', + '**/types/**', + '**/dist/**', '.yarn/*', 'eslint.config.js', 'prettier.config.js', @@ -29,7 +30,9 @@ export default tseslint.config( eslint.configs.recommended, ...tseslint.configs.recommendedTypeChecked, { - plugins: { furystack }, + plugins: { + furystack, + }, ...furystack.configs.recommendedStrict, }, prettierConfig, diff --git a/frontend/package.json b/frontend/package.json index 2d85dd03..b833a865 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -36,7 +36,6 @@ "@xterm/xterm": "^6.0.0", "common": "workspace:^", "hls.js": "^1.6.15", - "media-chrome": "^4.18.0", "ollama": "^0.6.3", "path-to-regexp": "^8.3.0", "video.js": "8.23.8" diff --git a/frontend/src/components/command-palette/command-providers/app-settings.tsx b/frontend/src/components/command-palette/command-providers/app-settings.tsx index dfc453f7..dc6fe14a 100644 --- a/frontend/src/components/command-palette/command-providers/app-settings.tsx +++ b/frontend/src/components/command-palette/command-providers/app-settings.tsx @@ -2,7 +2,7 @@ import { getCurrentUser } from '@furystack/core' import { createComponent } from '@furystack/shades' import type { CommandProvider } from '@furystack/shades-common-components' import { Icon, icons } from '@furystack/shades-common-components' -import { navigateToRoute } from '../../../navigate-to-route.js' +import { navigateToRoute } from '../../../utils/navigate-to-route.js' import type { SuggestionOptions } from './create-suggestion.js' import { createSuggestion, distinctByName } from './create-suggestion.js' diff --git a/frontend/src/components/command-palette/command-providers/entities.tsx b/frontend/src/components/command-palette/command-providers/entities.tsx index d35d2675..1b2f31e1 100644 --- a/frontend/src/components/command-palette/command-providers/entities.tsx +++ b/frontend/src/components/command-palette/command-providers/entities.tsx @@ -2,7 +2,7 @@ import { getCurrentUser } from '@furystack/core' import { createComponent } from '@furystack/shades' import type { CommandProvider } from '@furystack/shades-common-components' import { Icon, icons } from '@furystack/shades-common-components' -import { navigateToRoute } from '../../../navigate-to-route.js' +import { navigateToRoute } from '../../../utils/navigate-to-route.js' import type { SuggestionOptions } from './create-suggestion.js' import { createSuggestion, distinctByName } from './create-suggestion.js' diff --git a/frontend/src/components/command-palette/command-providers/search-movie.tsx b/frontend/src/components/command-palette/command-providers/search-movie.tsx index ec47e50b..05a71428 100644 --- a/frontend/src/components/command-palette/command-providers/search-movie.tsx +++ b/frontend/src/components/command-palette/command-providers/search-movie.tsx @@ -1,40 +1,35 @@ import type { CommandProvider } from '@furystack/shades-common-components' import { createSuggestion } from './create-suggestion.js' -import { navigateToRoute } from '../../../navigate-to-route.js' +import { navigateToRoute } from '../../../utils/navigate-to-route.js' import { createComponent } from '@furystack/shades' -import { MoviesService } from '../../../services/movies-service.js' +import { MediaApiClient } from '../../../services/api-clients/media-api-client.js' export const searchMovieCommandProvider: CommandProvider = async ({ term, injector }) => { if (term.length > 4) { - const movieService = injector.getInstance(MoviesService) - const relatedMovies = await movieService.findMovie({ - filter: { - $or: [ - { - title: { - $like: `%${term}%`, - }, + const mediaApiClient = injector.getInstance(MediaApiClient) + const { result: relatedLocalized } = await mediaApiClient.call({ + method: 'GET', + action: '/movie-metadata-localized', + query: { + findOptions: { + filter: { + title: { $like: `%${term}%` }, }, - { - imdbId: { - $like: `%${term}%`, - }, - }, - ], + }, }, }) - return relatedMovies.entries.map((movie) => + return relatedLocalized.entries.map((entry) => createSuggestion({ - icon: movie.thumbnailImageUrl ? ( - {movie.title} + icon: entry.posterUrl ? ( + {entry.title} ) : ( '🎥' ), - name: movie.title, - description: movie.plot || '', + name: entry.title, + description: entry.plot || '', score: 5, onSelected: () => { - navigateToRoute(injector, '/movies/:imdbId/overview', { imdbId: movie.imdbId }) + navigateToRoute(injector, '/movies/:imdbId/overview', { imdbId: entry.movieImdbId }) }, }), ) diff --git a/frontend/src/components/command-palette/command-providers/search-series.tsx b/frontend/src/components/command-palette/command-providers/search-series.tsx index 7dfdf992..ea120098 100644 --- a/frontend/src/components/command-palette/command-providers/search-series.tsx +++ b/frontend/src/components/command-palette/command-providers/search-series.tsx @@ -1,45 +1,35 @@ import type { CommandProvider } from '@furystack/shades-common-components' -import { SeriesService } from '../../../services/series-service.js' import { createSuggestion } from './create-suggestion.js' -import { navigateToRoute } from '../../../navigate-to-route.js' +import { navigateToRoute } from '../../../utils/navigate-to-route.js' import { createComponent } from '@furystack/shades' +import { MediaApiClient } from '../../../services/api-clients/media-api-client.js' export const searchSeriesCommandProvider: CommandProvider = async ({ term, injector }) => { if (term.length > 4) { - const seriesService = injector.getInstance(SeriesService) - const relatedSeries = await seriesService.findSeries({ - filter: { - $or: [ - { - title: { - $like: `%${term}%`, - }, + const mediaApiClient = injector.getInstance(MediaApiClient) + const { result: relatedLocalized } = await mediaApiClient.call({ + method: 'GET', + action: '/series-metadata-localized', + query: { + findOptions: { + filter: { + title: { $like: `%${term}%` }, }, - { - imdbId: { - $like: `%${term}%`, - }, - }, - { - plot: { - $like: `%${term}%`, - }, - }, - ], + }, }, }) - return relatedSeries.entries.map((series) => + return relatedLocalized.entries.map((entry) => createSuggestion({ - icon: series.thumbnailImageUrl ? ( - {series.title} + icon: entry.posterUrl ? ( + {entry.title} ) : ( '🎥' ), - name: series.title, - description: series.plot, + name: entry.title, + description: entry.plot || '', score: 5, onSelected: () => { - navigateToRoute(injector, '/series/:imdbId', { imdbId: series.imdbId }) + navigateToRoute(injector, '/series/:imdbId', { imdbId: entry.seriesImdbId }) }, }), ) diff --git a/frontend/src/components/dashboard/device-availability.tsx b/frontend/src/components/dashboard/device-availability.tsx index c8787379..f3979f53 100644 --- a/frontend/src/components/dashboard/device-availability.tsx +++ b/frontend/src/components/dashboard/device-availability.tsx @@ -1,12 +1,11 @@ import type { CacheWithValue } from '@furystack/cache' -import { serializeToQueryString } from '@furystack/rest' import { Shade, createComponent } from '@furystack/shades' import { CacheView, Skeleton } from '@furystack/shades-common-components' import type { Device, DeviceAvailability as DeviceAvailabilityProps, Icon as IconType } from 'common' import { AppLink } from '../../routes/index.js' -import { navigateToRoute } from '../../navigate-to-route.js' import { IotDevicesService } from '../../services/iot-devices-service.js' import { SessionService } from '../../services/session.js' +import { navigateToRoute } from '../../utils/navigate-to-route.js' import { DynamicIcon } from '../dynamic-icon.js' import { DeviceAvailabilityPanel } from '../iot-devices/device-availability-panel.js' import { WidgetCard } from './widget-card.js' @@ -42,16 +41,7 @@ const DeviceAvailabilityContent = Shade<{ onclick={(ev) => { ev.preventDefault() ev.stopImmediatePropagation() - navigateToRoute( - injector, - '/entities/iot-devices', - {}, - { - queryString: serializeToQueryString({ - gedst: { mode: 'edit', currentId: device.name }, - }), - }, - ) + navigateToRoute(injector, '/entities/iot-devices/edit/:id', { id: device.name }) }} title="Edit device details" > diff --git a/frontend/src/components/dashboard/index.tsx b/frontend/src/components/dashboard/index.tsx index 78ef84c3..9fea60b3 100644 --- a/frontend/src/components/dashboard/index.tsx +++ b/frontend/src/components/dashboard/index.tsx @@ -1,10 +1,9 @@ -import { serializeToQueryString } from '@furystack/rest' import { Shade, createComponent } from '@furystack/shades' -import { ContextMenu, ContextMenuManager } from '@furystack/shades-common-components' import type { ContextMenuItem } from '@furystack/shades-common-components' +import { ContextMenu, ContextMenuManager } from '@furystack/shades-common-components' import type { Dashboard as DashboardData } from 'common' -import { navigateToRoute } from '../../navigate-to-route.js' import { SessionService } from '../../services/session.js' +import { navigateToRoute } from '../../utils/navigate-to-route.js' import { Widget } from './widget.js' export const Dashboard = Shade({ @@ -22,14 +21,7 @@ export const Dashboard = Shade({ icon: 📝, label: 'Edit this dashboard', data: () => { - navigateToRoute( - injector, - '/entities/dashboards', - {}, - { - queryString: serializeToQueryString({ gedst: { currentId: props.id, mode: 'edit' } }), - }, - ) + navigateToRoute(injector, '/entities/dashboards/edit/:id', { id: props.id }) }, }, ] diff --git a/frontend/src/components/dashboard/movie-widget.tsx b/frontend/src/components/dashboard/movie-widget.tsx index 96696fae..58ecf176 100644 --- a/frontend/src/components/dashboard/movie-widget.tsx +++ b/frontend/src/components/dashboard/movie-widget.tsx @@ -1,15 +1,15 @@ import type { CacheWithValue } from '@furystack/cache' import { isLoadedCacheResult } from '@furystack/cache' -import { serializeToQueryString } from '@furystack/rest' -import { LazyLoad, Shade, createComponent } from '@furystack/shades' +import { createComponent, LazyLoad, Shade } from '@furystack/shades' import { CacheView, cssVariableTheme, Skeleton } from '@furystack/shades-common-components' -import type { Movie } from 'common' +import type { Movie, MovieMetadataLocalized } from 'common' import { AppLink } from '../../routes/index.js' -import { navigateToRoute } from '../../navigate-to-route.js' +import { LocalizedMetadataService } from '../../services/localized-metadata-service.js' import { MovieFilesService } from '../../services/movie-files-service.js' import { MoviesService } from '../../services/movies-service.js' import { SessionService } from '../../services/session.js' import { WatchProgressService } from '../../services/watch-progress-service.js' +import { navigateToRoute } from '../../utils/navigate-to-route.js' import { WidgetCard } from './widget-card.js' const MovieWidgetContent = Shade<{ @@ -25,15 +25,22 @@ const MovieWidgetContent = Shade<{ const movieFileService = injector.getInstance(MovieFilesService) const watchProgressService = injector.getInstance(WatchProgressService) + const localizedService = injector.getInstance(LocalizedMetadataService) const [currentUser] = useObservable('currentUser', injector.getInstance(SessionService).currentUser) const [movieFile] = useObservable( 'movieFile', movieFileService.findMovieFileAsObservable({ filter: { imdbId: { $eq: imdbId } } }), ) + const [localized] = useObservable('localized', localizedService.getMovieLocalizedAsObservable(imdbId)) + + const localizedData = (localized as CacheWithValue | undefined)?.value + const title = localizedData?.title ?? imdbId + const plot = localizedData?.plot + const posterUrl = localizedData?.posterUrl return ( - +
{ ev.preventDefault() ev.stopImmediatePropagation() - navigateToRoute( - injector, - '/entities/movies', - {}, - { - queryString: serializeToQueryString({ gedst: { mode: 'edit', currentId: imdbId } }), - }, - ) + navigateToRoute(injector, '/entities/movies/edit/:id', { id: imdbId }) }} title="Edit movie details" > @@ -80,14 +80,18 @@ const MovieWidgetContent = Shade<{
) : null} - {movie.title} + {posterUrl ? ( + {title} + ) : ( +
+ )}
- {movie.title} + {title} } component={async () => { diff --git a/frontend/src/components/dashboard/series-widget.tsx b/frontend/src/components/dashboard/series-widget.tsx index 569aab4d..115e1478 100644 --- a/frontend/src/components/dashboard/series-widget.tsx +++ b/frontend/src/components/dashboard/series-widget.tsx @@ -1,8 +1,9 @@ import type { CacheWithValue } from '@furystack/cache' import { Shade, createComponent } from '@furystack/shades' import { CacheView, cssVariableTheme, Skeleton } from '@furystack/shades-common-components' -import type { Series } from 'common' +import type { Series, SeriesMetadataLocalized } from 'common' import { AppLink } from '../../routes/index.js' +import { LocalizedMetadataService } from '../../services/localized-metadata-service.js' import { SeriesService } from '../../services/series-service.js' import { WidgetCard } from './widget-card.js' @@ -12,21 +13,33 @@ const SeriesWidgetContent = Shade<{ size?: number }>({ customElementName: 'pi-rat-series-widget-content', - render: ({ props }) => { + render: ({ props, injector, useObservable }) => { const { size = 256 } = props const series = props.data.value const { imdbId } = series + const localizedService = injector.getInstance(LocalizedMetadataService) + const [localized] = useObservable('localized', localizedService.getSeriesLocalizedAsObservable(imdbId)) + + const localizedData = (localized as CacheWithValue | undefined)?.value + const title = localizedData?.title ?? imdbId + const plot = localizedData?.plot + const posterUrl = localizedData?.posterUrl + return ( - + - {series.title} -
{series.title}
+ {posterUrl ? ( + {title} + ) : ( +
+ )} +
{title}
) diff --git a/frontend/src/components/header.tsx b/frontend/src/components/header.tsx index 42aa04b7..3da0a7d6 100644 --- a/frontend/src/components/header.tsx +++ b/frontend/src/components/header.tsx @@ -1,7 +1,7 @@ import { createComponent, Shade } from '@furystack/shades' import { AppBar, Button, Icon, icons } from '@furystack/shades-common-components' import { AppBarAppLink } from '../routes/index.js' -import { environmentOptions } from '../environment-options.js' +import { environmentOptions } from '../utils/environment-options.js' import { SessionService } from '../services/session.js' import { AiIcon } from './ai/ai-icon.js' import { ChatIcon } from './chat/chat-icon.js' diff --git a/frontend/src/components/movie-file-management/related-movies-modal-content.tsx b/frontend/src/components/movie-file-management/related-movies-modal-content.tsx index 484c9a85..94c56caf 100644 --- a/frontend/src/components/movie-file-management/related-movies-modal-content.tsx +++ b/frontend/src/components/movie-file-management/related-movies-modal-content.tsx @@ -33,7 +33,8 @@ const RelatedMoviesContent = Shade<{ No related movie is linked to this file <> - {serviceStatus.status === 'loaded' && serviceStatus.value.services.omdb ? ( + {serviceStatus.status === 'loaded' && + (serviceStatus.value.services.omdb || serviceStatus.value.services.tmdb) ? ( ) : ( - + )} diff --git a/frontend/src/components/movie-picker.tsx b/frontend/src/components/movie-picker.tsx index 62bbdcae..744a2089 100644 --- a/frontend/src/components/movie-picker.tsx +++ b/frontend/src/components/movie-picker.tsx @@ -13,14 +13,14 @@ export const MoviePicker = Shade({ const result = await moviesService.findMovie({ top: 10, filter: { - $or: [{ title: { $like: `%${term}%` } }], + $or: [{ imdbId: { $like: `%${term}%` } }], }, }) return result.entries }} defaultPrefix="" getSuggestionEntry={(entry) => ({ - element:
{entry.title}
, + element:
{entry.imdbId}
, score: 1, })} onSelectSuggestion={(entry: Movie) => { diff --git a/frontend/src/components/user-avatar-menu.tsx b/frontend/src/components/user-avatar-menu.tsx index 5f6fc5dc..a6e2d52a 100644 --- a/frontend/src/components/user-avatar-menu.tsx +++ b/frontend/src/components/user-avatar-menu.tsx @@ -1,7 +1,7 @@ import { createComponent, Shade } from '@furystack/shades' import type { MenuEntry } from '@furystack/shades-common-components' import { Avatar, cssVariableTheme, Dropdown, Icon, icons } from '@furystack/shades-common-components' -import { navigateToRoute } from '../navigate-to-route.js' +import { navigateToRoute } from '../utils/navigate-to-route.js' import { SessionService } from '../services/session.js' export const UserAvatarMenu = Shade({ diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index b2f1b57a..293dc081 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -8,9 +8,9 @@ import { createComponent, initializeShadeRoot } from '@furystack/shades' import { ThemeProviderService } from '@furystack/shades-common-components' import { AiChatMessage, Chat, ChatMessage, LogEntry } from 'common' import { Layout } from './components/layout.js' -import { environmentOptions } from './environment-options.js' +import { environmentOptions } from './utils/environment-options.js' import { SessionService } from './services/session.js' -import { registerThemeSwitchCheat } from './theme-switch-cheat.js' +import { registerThemeSwitchCheat } from './utils/theme-switch-cheat.js' import { darkTheme } from './themes/dark.js' const shadeInjector = new Injector() diff --git a/frontend/src/pages/admin/app-settings.tsx b/frontend/src/pages/admin/app-settings.tsx index ac737754..8baabca7 100644 --- a/frontend/src/pages/admin/app-settings.tsx +++ b/frontend/src/pages/admin/app-settings.tsx @@ -2,7 +2,7 @@ import { createComponent, LocationService, Shade } from '@furystack/shades' import { Drawer, Icon, icons, Menu, type MenuEntry } from '@furystack/shades-common-components' import { match } from 'path-to-regexp' import type { AppPaths } from '../../routes/index.js' -import { navigateToRoute } from '../../navigate-to-route.js' +import { navigateToRoute } from '../../utils/navigate-to-route.js' type AppSettingsPageProps = { outlet?: JSX.Element @@ -15,6 +15,7 @@ const getMenuItems = (): Array => [ label: 'Media', children: [ { key: '/app-settings/omdb', label: 'OMDB Settings', icon: }, + { key: '/app-settings/tmdb', label: 'TMDB Settings', icon: }, { key: '/app-settings/streaming', label: 'Streaming Settings', icon: }, ], }, diff --git a/frontend/src/pages/admin/tmdb-settings.spec.ts b/frontend/src/pages/admin/tmdb-settings.spec.ts new file mode 100644 index 00000000..6255b23d --- /dev/null +++ b/frontend/src/pages/admin/tmdb-settings.spec.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest' +import { isTmdbRawFormData } from './tmdb-settings.js' + +describe('isTmdbRawFormData', () => { + describe('valid data', () => { + it('returns true for valid payload with all fields', () => { + expect( + isTmdbRawFormData({ + apiKey: 'test-key', + defaultLanguage: 'en-US', + additionalLanguages: 'fr-FR, de-DE', + }), + ).toBe(true) + }) + + it('returns true for minimal valid payload', () => { + expect(isTmdbRawFormData({ apiKey: 'key' })).toBe(true) + }) + + it('returns true for empty apiKey string', () => { + expect(isTmdbRawFormData({ apiKey: '' })).toBe(true) + }) + + it('returns true for payload with extra fields', () => { + expect(isTmdbRawFormData({ apiKey: 'key', extra: 'field' })).toBe(true) + }) + }) + + describe('primitives and nullish', () => { + it('returns false for null', () => { + expect(isTmdbRawFormData(null)).toBe(false) + }) + + it('returns false for undefined', () => { + expect(isTmdbRawFormData(undefined)).toBe(false) + }) + + it('returns false for string', () => { + expect(isTmdbRawFormData('hello')).toBe(false) + }) + + it('returns false for number', () => { + expect(isTmdbRawFormData(42)).toBe(false) + }) + }) + + describe('missing or wrong types', () => { + it('returns false when apiKey is missing', () => { + expect(isTmdbRawFormData({ defaultLanguage: 'en-US' })).toBe(false) + }) + + it('returns false when apiKey is not a string', () => { + expect(isTmdbRawFormData({ apiKey: 123 })).toBe(false) + }) + + it('returns false when apiKey is null', () => { + expect(isTmdbRawFormData({ apiKey: null })).toBe(false) + }) + + it('returns false for empty object', () => { + expect(isTmdbRawFormData({})).toBe(false) + }) + }) +}) diff --git a/frontend/src/pages/admin/tmdb-settings.tsx b/frontend/src/pages/admin/tmdb-settings.tsx new file mode 100644 index 00000000..f8c39ee6 --- /dev/null +++ b/frontend/src/pages/admin/tmdb-settings.tsx @@ -0,0 +1,212 @@ +import type { CacheWithValue } from '@furystack/cache' +import { createComponent, Shade } from '@furystack/shades' +import { + Button, + CacheView, + cssVariableTheme, + Form, + Icon, + icons, + Input, + NotyService, + PageContainer, + PageHeader, + Paper, + Skeleton, + Typography, +} from '@furystack/shades-common-components' +import { ObservableValue } from '@furystack/utils' +import type { Config, TmdbConfig } from 'common' +import { GenericErrorPage } from '../../components/generic-error.js' +import { ConfigService } from '../../services/config-service.js' + +type TmdbFormData = TmdbConfig['value'] + +type TmdbRawFormData = { + apiKey: string + defaultLanguage: string + additionalLanguages: string +} + +export const isTmdbRawFormData = (data: unknown): data is TmdbRawFormData => { + if (typeof data !== 'object' || data === null) return false + const d = data as Record + return typeof d.apiKey === 'string' +} + +const TmdbSettingsContent = Shade<{ data: CacheWithValue }>({ + customElementName: 'tmdb-settings-content', + css: { + '& .page-description': { + marginBottom: '24px', + color: cssVariableTheme.text.secondary, + }, + '& .form-field': { + marginBottom: '24px', + }, + '& .api-key-row': { + display: 'flex', + alignItems: 'flex-end', + gap: '8px', + }, + '& .field-hint': { + color: cssVariableTheme.text.secondary, + display: 'block', + marginTop: '4px', + }, + '& .field-hint a': { + color: cssVariableTheme.palette.primary.main, + }, + '& .form-footer': { + borderTop: `1px solid ${cssVariableTheme.background.default}`, + paddingTop: '16px', + }, + }, + render: ({ props, injector, useObservable, useDisposable, useState }) => { + const configService = injector.getInstance(ConfigService) + const notyService = injector.getInstance(NotyService) + + const isLoadingObservable = useDisposable('isLoading', () => new ObservableValue(false)) + const [isLoading] = useObservable('isLoadingValue', isLoadingObservable) + const [isApiKeyVisible, setApiKeyVisible] = useState('apiKeyVisible', false) + + const toggleApiKeyVisibility = () => { + setApiKeyVisible(!isApiKeyVisible) + } + + const handleSubmit = async (formData: TmdbRawFormData) => { + const additionalLanguages = formData.additionalLanguages + .split(',') + .map((lang) => lang.trim()) + .filter(Boolean) + + const data: TmdbFormData = { + apiKey: formData.apiKey, + defaultLanguage: formData.defaultLanguage || 'en-US', + additionalLanguages, + } + + isLoadingObservable.setValue(true) + try { + await configService.saveConfig('TMDB_CONFIG', data) + notyService.emit('onNotyAdded', { + title: 'Success', + body: 'TMDB settings saved successfully', + type: 'success', + }) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Failed to save settings' + notyService.emit('onNotyAdded', { + title: 'Error', + body: errorMessage, + type: 'error', + }) + } finally { + isLoadingObservable.setValue(false) + } + } + + const currentValues: TmdbFormData = props.data.value.value + ? (props.data.value.value as TmdbFormData) + : { apiKey: '', defaultLanguage: 'en-US', additionalLanguages: [] } + + return ( + <> + + Configure the TMDB API integration for fetching movie and series metadata with multi-language support. + + + + validate={isTmdbRawFormData} onSubmit={(data) => void handleSubmit(data)}> +
+
+ + +
+ + Get your API key at{' '} + + themoviedb.org + + +
+ +
+ + + Primary language for metadata (TMDB locale format, e.g. en-US, fr-FR, de-DE) + +
+ +
+ + + Comma-separated list of additional languages to fetch alongside the default + +
+ +
+ +
+ +
+ + ) + }, +}) + +export const TmdbSettingsPage = Shade({ + customElementName: 'tmdb-settings-page', + render: ({ injector }) => { + const configService = injector.getInstance(ConfigService) + + return ( + + } title="TMDB Settings" /> + } + error={(err, retry) => retry()} />} + /> + + ) + }, +}) diff --git a/frontend/src/pages/admin/user-list.tsx b/frontend/src/pages/admin/user-list.tsx index 5734a66a..7d34f806 100644 --- a/frontend/src/pages/admin/user-list.tsx +++ b/frontend/src/pages/admin/user-list.tsx @@ -13,7 +13,7 @@ import { Skeleton, } from '@furystack/shades-common-components' import type { User } from 'common' -import { navigateToRoute } from '../../navigate-to-route.js' +import { navigateToRoute } from '../../utils/navigate-to-route.js' import { RoleTag } from '../../components/role-tag/index.js' import { GenericErrorPage } from '../../components/generic-error.js' import { UsersService } from '../../services/users-service.js' diff --git a/frontend/src/pages/ai/ai-chat-input.tsx b/frontend/src/pages/ai/ai-chat-input.tsx index a0ed90f0..14b505a7 100644 --- a/frontend/src/pages/ai/ai-chat-input.tsx +++ b/frontend/src/pages/ai/ai-chat-input.tsx @@ -1,6 +1,6 @@ import { createComponent, Shade } from '@furystack/shades' import { Button, Form, Input } from '@furystack/shades-common-components' -import { SessionService } from '../../services/session.js' +import { getUser } from '../../utils/session-helpers.js' import { AiChatMessageService } from './ai-chat-message-service.js' import { AiChatService } from './ai-chat-service.js' @@ -24,7 +24,6 @@ export const AiChatInput = Shade<{ selectedChatId: string }>({ render: ({ props, injector, useObservable, useRef }) => { const aiChatMessageService = injector.getInstance(AiChatMessageService) const aiChatService = injector.getInstance(AiChatService) - const sessionService = injector.getInstance(SessionService) const formRef = useRef('form') const [selectedChat] = useObservable( @@ -47,7 +46,7 @@ export const AiChatInput = Shade<{ selectedChatId: string }>({ role: 'user', createdAt: new Date(), id: crypto.randomUUID(), - owner: sessionService.currentUser.getValue()!.username, + owner: getUser(injector).username, visibility: selectedChat?.value?.entries[0]?.visibility ?? 'private', }) .then(() => { diff --git a/frontend/src/pages/ai/create-ai-chat-button.tsx b/frontend/src/pages/ai/create-ai-chat-button.tsx index cdf10d8e..3bb2fb9e 100644 --- a/frontend/src/pages/ai/create-ai-chat-button.tsx +++ b/frontend/src/pages/ai/create-ai-chat-button.tsx @@ -11,7 +11,7 @@ import { } from '@furystack/shades-common-components' import type { AiChat } from 'common' import { ErrorDisplay } from '../../components/error-display.js' -import { SessionService } from '../../services/session.js' +import { getUser } from '../../utils/session-helpers.js' import { AiChatService } from './ai-chat-service.js' import { AiModelSelector } from './ai-model-selector.js' @@ -34,7 +34,6 @@ export const CreateAiChatButton = Shade({ render: ({ injector, useState }) => { const aiChatService = injector.getInstance(AiChatService) const [isModalOpen, setIsModalOpen] = useState('isModalOpen', false) - const session = injector.getInstance(SessionService) const noty = injector.getInstance(NotyService) @@ -74,7 +73,7 @@ export const CreateAiChatButton = Shade({ ...chat, id: crypto.randomUUID(), createdAt: new Date(), - owner: session.currentUser.getValue()!.username, + owner: getUser(injector).username, status: 'active', visibility: 'private', description: chat.description, diff --git a/frontend/src/pages/chat/add-chat-button.tsx b/frontend/src/pages/chat/add-chat-button.tsx index c9aabee5..d570931f 100644 --- a/frontend/src/pages/chat/add-chat-button.tsx +++ b/frontend/src/pages/chat/add-chat-button.tsx @@ -1,6 +1,6 @@ import { createComponent, Shade } from '@furystack/shades' import { Button, cssVariableTheme, Form, Input, Modal, Paper, Typography } from '@furystack/shades-common-components' -import { SessionService } from '../../services/session.js' +import { getUser } from '../../utils/session-helpers.js' import { ChatService } from './chat-service.js' export type AddChatPayload = { @@ -23,8 +23,6 @@ export const AddChatButton = Shade({ render: ({ useState, injector }) => { const [isModalOpen, setIsModalOpen] = useState('isModalOpen', false) - const session = injector.getInstance(SessionService) - const chats = injector.getInstance(ChatService) return ( @@ -58,7 +56,7 @@ export const AddChatButton = Shade({ id: crypto.randomUUID(), participants: [], createdAt: new Date(), - owner: session.currentUser.getValue()?.username || '', + owner: getUser(injector).username, }) .then(() => { setIsModalOpen(false) diff --git a/frontend/src/pages/chat/chat-invitation-list.tsx b/frontend/src/pages/chat/chat-invitation-list.tsx index 4e9e4468..abb4beb0 100644 --- a/frontend/src/pages/chat/chat-invitation-list.tsx +++ b/frontend/src/pages/chat/chat-invitation-list.tsx @@ -2,7 +2,7 @@ import { createComponent, Shade } from '@furystack/shades' import { Button, NotyService, Paper, Skeleton, Typography } from '@furystack/shades-common-components' import { ErrorDisplay } from '../../components/error-display.js' import { GenericErrorPage } from '../../components/generic-error.js' -import { SessionService } from '../../services/session.js' +import { getUser } from '../../utils/session-helpers.js' import { ChatInvitationService } from './chat-intivation-service.js' export const ChatInvitationList = Shade({ @@ -16,7 +16,7 @@ export const ChatInvitationList = Shade({ const filter = { filter: { status: { $eq: 'pending' } } } as const const chatInvitationService = injector.getInstance(ChatInvitationService) - const currentUser = injector.getInstance(SessionService).currentUser.getValue() + const currentUsername = getUser(injector).username const reloadChatInvitations = () => { void chatInvitationService.getChatInvitations(filter) @@ -57,11 +57,11 @@ export const ChatInvitationList = Shade({ const noty = injector.getInstance(NotyService) const received = invitations.value.entries.filter( - (invitation) => invitation.userId === currentUser?.username && invitation.status === 'pending', + (invitation) => invitation.userId === currentUsername && invitation.status === 'pending', ) const sent = invitations.value.entries.filter( - (invitation) => invitation.createdBy === currentUser?.username && invitation.status === 'pending', + (invitation) => invitation.createdBy === currentUsername && invitation.status === 'pending', ) return ( @@ -80,7 +80,7 @@ export const ChatInvitationList = Shade({ justifyContent: 'space-between', }} > - {invitation.userId === currentUser?.username ? ( + {invitation.userId === currentUsername ? (
{invitation.chatName} + + + } + > +

+ Are you sure you want to delete the following {entriesToDelete.length} item(s)? +

+
    + {entriesToDelete.map((e) => ( +
  • + {e.name} +
  • + ))} +
+ + ) }, }) diff --git a/frontend/src/pages/file-browser/file-upload-handler.tsx b/frontend/src/pages/file-browser/file-upload-handler.tsx new file mode 100644 index 00000000..16e5ce6c --- /dev/null +++ b/frontend/src/pages/file-browser/file-upload-handler.tsx @@ -0,0 +1,61 @@ +import { createComponent } from '@furystack/shades' +import type { NotyService } from '@furystack/shades-common-components' + +import type { SessionService } from '../../services/session.js' +import { getErrorMessage } from '../../services/get-error-message.js' +import { environmentOptions } from '../../utils/environment-options.js' + +export const handleFileDrop = async ({ + ev, + sessionService, + notyService, + currentDriveLetter, + currentPath, +}: { + ev: DragEvent + sessionService: SessionService + notyService: NotyService + currentDriveLetter: string + currentPath: string +}) => { + ev.preventDefault() + if (!ev.dataTransfer?.files) return + + if (!(await sessionService.isAuthorized('admin'))) { + return notyService.emit('onNotyAdded', { + type: 'warning', + title: 'Not authorized', + body: <>You are not authorized to upload files, + }) + } + + const formData = new FormData() + for (const file of ev.dataTransfer.files) { + formData.append('uploads', file) + } + + await fetch( + `${environmentOptions.serviceUrl}/drives/volumes/${encodeURIComponent( + currentDriveLetter, + )}/${encodeURIComponent(currentPath)}/upload`, + { + method: 'POST', + credentials: 'include', + body: formData, + }, + ) + .then(() => { + notyService.emit('onNotyAdded', { + type: 'success', + title: 'Upload completed', + body: <>The files are upploaded succesfully, + }) + }) + .catch((err) => + notyService.emit('onNotyAdded', { + title: 'Upload failed', + body: <>{getErrorMessage(err)}, + type: 'error', + }), + ) +} diff --git a/frontend/src/pages/file-browser/folder-panel.tsx b/frontend/src/pages/file-browser/folder-panel.tsx index 41a5bcea..ce5f21a0 100644 --- a/frontend/src/pages/file-browser/folder-panel.tsx +++ b/frontend/src/pages/file-browser/folder-panel.tsx @@ -4,7 +4,7 @@ import { CollectionService, Paper } from '@furystack/shades-common-components' import { PathHelper } from '@furystack/utils' import type { DirectoryEntry } from 'common' import { encode, getFullPath } from 'common' -import { navigateToRoute } from '../../navigate-to-route.js' +import { navigateToRoute } from '../../utils/navigate-to-route.js' import { DrivesService } from '../../services/drives-service.js' import { DriveSelector } from './drive-selector.js' import { FileList } from './file-list.js' @@ -23,6 +23,7 @@ const upEntry: DirectoryEntry = { export const FolderPanel = Shade<{ searchStateKey: string defaultDriveLetter: string + availableDriveLetters: string[] focused?: boolean }>({ customElementName: 'folder-panel', @@ -53,6 +54,11 @@ export const FolderPanel = Shade<{ return null } + if (!props.availableDriveLetters.includes(letter)) { + setCurrentDrive({ path: '/', letter: props.defaultDriveLetter }) + return null + } + const service = useDisposable(`service-${letter}-${path}`, () => new CollectionService()) const onFileListChange = (result: CacheResult>>) => { diff --git a/frontend/src/pages/file-browser/index.tsx b/frontend/src/pages/file-browser/index.tsx index 0e1c7b1c..d7465da6 100644 --- a/frontend/src/pages/file-browser/index.tsx +++ b/frontend/src/pages/file-browser/index.tsx @@ -59,6 +59,7 @@ export const DrivesPage = Shade({ focused={focused === 'ld'} searchStateKey="ld" defaultDriveLetter={drives.value.entries[0].letter} + availableDriveLetters={drives.value.entries.map((e) => e.letter)} onclick={() => setFocused('ld')} onkeyup={(ev) => { if (ev.key === 'Tab') { @@ -72,6 +73,7 @@ export const DrivesPage = Shade({ focused={focused === 'rd'} searchStateKey="rd" defaultDriveLetter={drives.value.entries[0].letter} + availableDriveLetters={drives.value.entries.map((e) => e.letter)} onclick={() => setFocused('rd')} onkeyup={(ev) => { if (ev.key === 'Tab') { diff --git a/frontend/src/pages/files/image-viewer.tsx b/frontend/src/pages/files/image-viewer.tsx index 1987fb11..0f7ea340 100644 --- a/frontend/src/pages/files/image-viewer.tsx +++ b/frontend/src/pages/files/image-viewer.tsx @@ -1,6 +1,6 @@ import { createComponent, Shade } from '@furystack/shades' import { Typography } from '@furystack/shades-common-components' -import { environmentOptions } from '../../environment-options.js' +import { environmentOptions } from '../../utils/environment-options.js' export const ImageViewer = Shade<{ letter: string; path: string }>({ customElementName: 'drives-files-image-viewer', diff --git a/frontend/src/pages/files/monaco-file-editor.tsx b/frontend/src/pages/files/monaco-file-editor.tsx index de684f0d..2eb70a77 100644 --- a/frontend/src/pages/files/monaco-file-editor.tsx +++ b/frontend/src/pages/files/monaco-file-editor.tsx @@ -3,7 +3,7 @@ import { Button, NotyService } from '@furystack/shades-common-components' import { ObservableValue } from '@furystack/utils' import { LazyMonacoEditor } from '../../components/lazy-monaco-editor.js' import { PiRatLazyLoad } from '../../components/pirat-lazy-load.js' -import { environmentOptions } from '../../environment-options.js' +import { environmentOptions } from '../../utils/environment-options.js' import { DrivesApiClient } from '../../services/api-clients/drives-api-client.js' import { getErrorMessage } from '../../services/get-error-message.js' diff --git a/frontend/src/pages/files/unknown-type.tsx b/frontend/src/pages/files/unknown-type.tsx index c805c3b2..44df914a 100644 --- a/frontend/src/pages/files/unknown-type.tsx +++ b/frontend/src/pages/files/unknown-type.tsx @@ -1,6 +1,6 @@ import { Shade, createComponent } from '@furystack/shades' import { Paper, Typography } from '@furystack/shades-common-components' -import { environmentOptions } from '../../environment-options.js' +import { environmentOptions } from '../../utils/environment-options.js' export const UnknownType = Shade<{ letter: string; path: string }>({ customElementName: 'drives-file-unknown-type-page', diff --git a/frontend/src/pages/logging/log-entries-terminal.tsx b/frontend/src/pages/logging/log-entries-terminal.tsx index 55a6282f..cbf0aa9a 100644 --- a/frontend/src/pages/logging/log-entries-terminal.tsx +++ b/frontend/src/pages/logging/log-entries-terminal.tsx @@ -8,7 +8,7 @@ import { Terminal } from '@xterm/xterm' import '@xterm/xterm/css/xterm.css' import { LogEntry } from 'common' import { compile, match, type MatchResult } from 'path-to-regexp' -import { navigateToRoute } from '../../navigate-to-route.js' +import { navigateToRoute } from '../../utils/navigate-to-route.js' const useDisposableTerminal = ( { useDisposable, injector }: Pick, 'useDisposable' | 'injector'>, diff --git a/frontend/src/pages/login.tsx b/frontend/src/pages/login.tsx index 90baf814..0470ef87 100644 --- a/frontend/src/pages/login.tsx +++ b/frontend/src/pages/login.tsx @@ -1,7 +1,7 @@ import { Shade, createComponent } from '@furystack/shades' import { Button, Form, Input, cssVariableTheme, promisifyAnimation } from '@furystack/shades-common-components' import { PiRatLogo } from '../components/pi-rat-logo.js' -import { navigateToRoute } from '../navigate-to-route.js' +import { navigateToRoute } from '../utils/navigate-to-route.js' import { SessionService } from '../services/session.js' export type LoginPayload = { @@ -144,7 +144,6 @@ export const Login = Shade({ validate={isLoginPayload} className="login-form" onSubmit={({ userName, password }) => { - sessionService.loginError.setValue('') void sessionService.login(userName, password) }} > diff --git a/frontend/src/pages/movies/movie-overview.tsx b/frontend/src/pages/movies/movie-overview.tsx index f45f192f..cc7f3fc9 100644 --- a/frontend/src/pages/movies/movie-overview.tsx +++ b/frontend/src/pages/movies/movie-overview.tsx @@ -3,9 +3,10 @@ import { isLoadedCacheResult } from '@furystack/cache' import { serializeToQueryString } from '@furystack/rest' import { createComponent, Shade } from '@furystack/shades' import { Button, CacheView, Skeleton, Typography } from '@furystack/shades-common-components' -import type { Movie } from 'common' +import type { Movie, MovieMetadataLocalized } from 'common' import { GenericErrorPage } from '../../components/generic-error.js' -import { navigateToRoute } from '../../navigate-to-route.js' +import { navigateToRoute } from '../../utils/navigate-to-route.js' +import { LocalizedMetadataService } from '../../services/localized-metadata-service.js' import { MovieFilesService } from '../../services/movie-files-service.js' import { MoviesService } from '../../services/movies-service.js' import { SessionService } from '../../services/session.js' @@ -99,14 +100,23 @@ const MovieOverviewContent = Shade<{ data: CacheWithValue }>({ const [currentUser] = useObservable('currentUser', injector.getInstance(SessionService).currentUser) const movie = props.data.value + const localizedService = injector.getInstance(LocalizedMetadataService) + const [localized] = useObservable('localized', localizedService.getMovieLocalizedAsObservable(movie.imdbId)) + + const localizedData = (localized as CacheWithValue | undefined)?.value + const title = localizedData?.title ?? movie.imdbId + const plot = localizedData?.plot + const posterUrl = localizedData?.posterUrl + const genre = localizedData?.genre + return ( - - {movie.title} + + {title} - {movie.year?.toString()}   {movie.genre} + {movie.year?.toString()}   {genre?.join(', ')} - {movie.plot} + {plot}
diff --git a/frontend/src/pages/movies/movie-player-v2/control-area.tsx b/frontend/src/pages/movies/movie-player-v2/control-area.tsx deleted file mode 100644 index 09127e76..00000000 --- a/frontend/src/pages/movies/movie-player-v2/control-area.tsx +++ /dev/null @@ -1,124 +0,0 @@ -import { Shade, createComponent, styledShade } from '@furystack/shades' -import type { IconDefinition } from '@furystack/shades-common-components' -import { Button, Icon, icons, Input } from '@furystack/shades-common-components' -import type { ObservableValue } from '@furystack/utils' - -const maximizeIcon: IconDefinition = { - name: 'Maximize', - description: 'Expand to full screen', - keywords: ['fullscreen', 'maximize', 'expand'], - category: 'Actions', - paths: [{ d: 'M8 3H5a2 2 0 00-2 2v3m18 0V5a2 2 0 00-2-2h-3m0 18h3a2 2 0 002-2v-3M3 16v3a2 2 0 002 2h3' }], -} - -const minimizeIcon: IconDefinition = { - name: 'Minimize', - description: 'Exit full screen', - keywords: ['fullscreen', 'minimize', 'shrink'], - category: 'Actions', - paths: [{ d: 'M4 14h6v6m10-10h-6V4m0 6l7-7M3 21l7-7' }], -} - -type ControlAreaProps = { - isPlaying: ObservableValue - isFullScreen: ObservableValue - isMuted: ObservableValue - volume: ObservableValue - watchedSeconds: ObservableValue - lengthSeconds: number - seekTo: (seconds: number) => void -} - -const ControlButton = styledShade(Button, { - fontSize: '2em', - padding: '.3em 1em', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - lineHeight: '100%', -}) - -export const SoundControl = Shade<{ - isMuted: ObservableValue - volume: ObservableValue -}>({ - customElementName: 'pirat-movie-player-v2-sound-control', - css: { - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - }, - render: ({ props, useObservable }) => { - const [isMuted, setIsMuted] = useObservable('isMuted', props.isMuted) - const [volume] = useObservable('volume', props.volume) - - return ( - <> - setIsMuted(!isMuted)}> - {isMuted ? '🔇' : '🔊'} - - props.volume.setValue((e.target as HTMLInputElement).value as unknown as number)} - /> - - ) - }, -}) - -export const ControlArea = Shade({ - customElementName: 'pirat-movie-player-v2-control-area', - css: { - '& .control-bar': { - position: 'absolute', - bottom: '0', - background: 'linear-gradient(0deg, rgba(0,0,0,0.5) 0%, rgba(0,0,0,0.2) 80%, rgba(0,0,0,0) 100%)', - width: '100%', - height: '4em', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - zIndex: '2147483647', - }, - '& .progress-bar': { - position: 'absolute', - top: '-15px', - left: '10px', - width: 'calc(100% - 20px)', - }, - }, - render: ({ props, useObservable }) => { - const [isPlaying, setIsPlaying] = useObservable('isPlaying', props.isPlaying) - const [progress] = useObservable('progress', props.watchedSeconds) - const [isFullScreen, setFullScreen] = useObservable('isFullScreen', props.isFullScreen) - - return ( -
- props.seekTo(parseInt(e, 10))} - /> - {isPlaying ? ( - setIsPlaying(false)}> - - - ) : ( - setIsPlaying(true)}> - - - )} - setFullScreen(!isFullScreen)}> - {isFullScreen ? : } - - -
- ) - }, -}) diff --git a/frontend/src/pages/movies/movie-player-v2/controls/captions-button.tsx b/frontend/src/pages/movies/movie-player-v2/controls/captions-button.tsx new file mode 100644 index 00000000..c89bba83 --- /dev/null +++ b/frontend/src/pages/movies/movie-player-v2/controls/captions-button.tsx @@ -0,0 +1,53 @@ +import { Shade, createComponent } from '@furystack/shades' +import { Icon } from '@furystack/shades-common-components' + +import type { MoviePlayerService } from '../movie-player-service.js' +import { captionsIcon } from './player-icons.js' + +type CaptionsButtonProps = { + mediaService: MoviePlayerService +} + +export const CaptionsButton = Shade({ + customElementName: 'pirat-player-captions-button', + render: ({ props, useObservable }) => { + const [activeTrack] = useObservable('activeTrack', props.mediaService.activeSubtitleTrack) + + return ( + + ) + }, +}) diff --git a/frontend/src/pages/movies/movie-player-v2/controls/control-bar.tsx b/frontend/src/pages/movies/movie-player-v2/controls/control-bar.tsx new file mode 100644 index 00000000..828e4172 --- /dev/null +++ b/frontend/src/pages/movies/movie-player-v2/controls/control-bar.tsx @@ -0,0 +1,46 @@ +import type { RefObject } from '@furystack/shades' +import { Shade, createComponent } from '@furystack/shades' +import { cssVariableTheme } from '@furystack/shades-common-components' + +import type { MoviePlayerService } from '../movie-player-service.js' +import { CaptionsButton } from './captions-button.js' +import { FullscreenButton } from './fullscreen-button.js' +import { PipButton } from './pip-button.js' +import { PlayButton } from './play-button.js' +import { SeekBar } from './seek-bar.js' +import { SettingsMenu } from './settings-menu.js' +import { TimeDisplay } from './time-display.js' +import { VolumeControl } from './volume-control.js' + +type ControlBarProps = { + mediaService: MoviePlayerService + playerContainerRef: RefObject +} + +export const ControlBar = Shade({ + customElementName: 'pirat-player-control-bar', + css: { + display: 'flex', + alignItems: 'center', + gap: '4px', + padding: '8px 12px', + background: `linear-gradient(transparent, ${cssVariableTheme.background.paper})`, + color: cssVariableTheme.text.primary, + width: '100%', + boxSizing: 'border-box', + }, + render: ({ props }) => { + return ( + <> + + + + + + + + + + ) + }, +}) diff --git a/frontend/src/pages/movies/movie-player-v2/controls/error-overlay.tsx b/frontend/src/pages/movies/movie-player-v2/controls/error-overlay.tsx new file mode 100644 index 00000000..481a4716 --- /dev/null +++ b/frontend/src/pages/movies/movie-player-v2/controls/error-overlay.tsx @@ -0,0 +1,80 @@ +import { Shade, createComponent } from '@furystack/shades' + +import type { MoviePlayerService } from '../movie-player-service.js' + +type ErrorOverlayProps = { + mediaService: MoviePlayerService +} + +/** + * Waits for `mediaService.videoElement` to be set, then calls `callback`. + * Uses rAF as a single-frame deferral since the video element is attached + * synchronously during the same render cycle. + */ +const whenVideoReady = ( + mediaService: MoviePlayerService, + callback: (video: HTMLVideoElement) => Disposable | void, +): Disposable => { + const video = mediaService.videoElement + if (video) { + const cleanup = callback(video) + return cleanup ?? { [Symbol.dispose]: () => {} } + } + + let cleanup: Disposable | null = null + const frameId = requestAnimationFrame(() => { + const deferred = mediaService.videoElement + if (deferred) { + cleanup = callback(deferred) ?? null + } + }) + return { + [Symbol.dispose]: () => { + cancelAnimationFrame(frameId) + cleanup?.[Symbol.dispose]() + }, + } +} + +export const ErrorOverlay = Shade({ + customElementName: 'pirat-player-error-overlay', + render: ({ props, useState, useDisposable }) => { + const [error, setError] = useState('error', null) + + useDisposable('errorListener', () => + whenVideoReady(props.mediaService, (video) => { + const handler = () => { + const mediaError = video.error + if (mediaError) { + setError(`Playback error: ${mediaError.message || `code ${mediaError.code}`}`) + } + } + video.addEventListener('error', handler) + return { [Symbol.dispose]: () => video.removeEventListener('error', handler) } + }), + ) + + if (!error) return
+ + return ( +
+ {error} +
+ ) + }, +}) diff --git a/frontend/src/pages/movies/movie-player-v2/controls/fullscreen-button.tsx b/frontend/src/pages/movies/movie-player-v2/controls/fullscreen-button.tsx new file mode 100644 index 00000000..f24e3d4a --- /dev/null +++ b/frontend/src/pages/movies/movie-player-v2/controls/fullscreen-button.tsx @@ -0,0 +1,42 @@ +import type { RefObject } from '@furystack/shades' +import { Shade, createComponent } from '@furystack/shades' +import { Icon } from '@furystack/shades-common-components' + +import type { MoviePlayerService } from '../movie-player-service.js' +import { fullscreenEnterIcon, fullscreenExitIcon } from './player-icons.js' + +type FullscreenButtonProps = { + mediaService: MoviePlayerService + playerContainerRef: RefObject +} + +export const FullscreenButton = Shade({ + customElementName: 'pirat-player-fullscreen-button', + render: ({ props, useObservable }) => { + const [isFullscreen] = useObservable('isFullscreen', props.mediaService.isFullscreen) + + return ( + + ) + }, +}) diff --git a/frontend/src/pages/movies/movie-player-v2/controls/index.ts b/frontend/src/pages/movies/movie-player-v2/controls/index.ts new file mode 100644 index 00000000..80c17e72 --- /dev/null +++ b/frontend/src/pages/movies/movie-player-v2/controls/index.ts @@ -0,0 +1,12 @@ +export { VideoContainer } from './video-container.js' +export { ControlBar } from './control-bar.js' +export { PlayButton } from './play-button.js' +export { TimeDisplay } from './time-display.js' +export { SeekBar } from './seek-bar.js' +export { VolumeControl } from './volume-control.js' +export { FullscreenButton } from './fullscreen-button.js' +export { PipButton } from './pip-button.js' +export { CaptionsButton } from './captions-button.js' +export { SettingsMenu } from './settings-menu.js' +export { LoadingOverlay } from './loading-overlay.js' +export { ErrorOverlay } from './error-overlay.js' diff --git a/frontend/src/pages/movies/movie-player-v2/controls/loading-overlay.tsx b/frontend/src/pages/movies/movie-player-v2/controls/loading-overlay.tsx new file mode 100644 index 00000000..21c1ed44 --- /dev/null +++ b/frontend/src/pages/movies/movie-player-v2/controls/loading-overlay.tsx @@ -0,0 +1,44 @@ +import { Shade, createComponent } from '@furystack/shades' + +import type { MoviePlayerService } from '../movie-player-service.js' + +type LoadingOverlayProps = { + mediaService: MoviePlayerService +} + +export const LoadingOverlay = Shade({ + customElementName: 'pirat-player-loading-overlay', + render: ({ props, useObservable }) => { + const [isSwitching] = useObservable('isSwitching', props.mediaService.isSwitching) + + if (!isSwitching) return
+ + return ( +
+
+ +
+ ) + }, +}) diff --git a/frontend/src/pages/movies/movie-player-v2/controls/pip-button.tsx b/frontend/src/pages/movies/movie-player-v2/controls/pip-button.tsx new file mode 100644 index 00000000..8f308ebc --- /dev/null +++ b/frontend/src/pages/movies/movie-player-v2/controls/pip-button.tsx @@ -0,0 +1,34 @@ +import { Shade, createComponent } from '@furystack/shades' +import { Icon } from '@furystack/shades-common-components' + +import type { MoviePlayerService } from '../movie-player-service.js' +import { pipIcon } from './player-icons.js' + +type PipButtonProps = { + mediaService: MoviePlayerService +} + +export const PipButton = Shade({ + customElementName: 'pirat-player-pip-button', + render: ({ props }) => { + return ( + + ) + }, +}) diff --git a/frontend/src/pages/movies/movie-player-v2/controls/play-button.tsx b/frontend/src/pages/movies/movie-player-v2/controls/play-button.tsx new file mode 100644 index 00000000..bcd81f24 --- /dev/null +++ b/frontend/src/pages/movies/movie-player-v2/controls/play-button.tsx @@ -0,0 +1,36 @@ +import { Shade, createComponent } from '@furystack/shades' +import { Icon, icons } from '@furystack/shades-common-components' + +import type { MoviePlayerService } from '../movie-player-service.js' + +type PlayButtonProps = { + mediaService: MoviePlayerService +} + +export const PlayButton = Shade({ + customElementName: 'pirat-player-play-button', + render: ({ props, useObservable }) => { + const [isPlaying] = useObservable('isPlaying', props.mediaService.isPlaying) + + return ( + + ) + }, +}) diff --git a/frontend/src/pages/movies/movie-player-v2/controls/player-icons.ts b/frontend/src/pages/movies/movie-player-v2/controls/player-icons.ts new file mode 100644 index 00000000..3d7d8f06 --- /dev/null +++ b/frontend/src/pages/movies/movie-player-v2/controls/player-icons.ts @@ -0,0 +1,44 @@ +import type { IconDefinition } from '@furystack/shades-common-components' + +export const volumeHighIcon: IconDefinition = { + paths: [{ d: 'M11 5L6 9H2v6h4l5 4V5z' }, { d: 'M19.07 4.93a10 10 0 010 14.14M15.54 8.46a5 5 0 010 7.07' }], +} + +export const volumeLowIcon: IconDefinition = { + paths: [{ d: 'M11 5L6 9H2v6h4l5 4V5z' }, { d: 'M15.54 8.46a5 5 0 010 7.07' }], +} + +export const volumeMuteIcon: IconDefinition = { + paths: [{ d: 'M11 5L6 9H2v6h4l5 4V5z' }, { d: 'M23 9l-6 6M17 9l6 6' }], +} + +export const fullscreenEnterIcon: IconDefinition = { + paths: [{ d: 'M8 3H5a2 2 0 00-2 2v3m18 0V5a2 2 0 00-2-2h-3m0 18h3a2 2 0 002-2v-3M3 16v3a2 2 0 002 2h3' }], +} + +export const fullscreenExitIcon: IconDefinition = { + paths: [{ d: 'M4 14h6v6m10-10h-6V4m0 6l7-7M3 21l7-7' }], +} + +export const pipIcon: IconDefinition = { + paths: [ + { d: 'M2 4.5A2.5 2.5 0 014.5 2h15A2.5 2.5 0 0122 4.5v15a2.5 2.5 0 01-2.5 2.5h-15A2.5 2.5 0 012 19.5v-15z' }, + { d: 'M12 13.5a1 1 0 011-1h6a1 1 0 011 1V18a1 1 0 01-1 1h-6a1 1 0 01-1-1v-4.5z' }, + ], +} + +export const captionsIcon: IconDefinition = { + paths: [ + { d: 'M2 6a2 2 0 012-2h16a2 2 0 012 2v12a2 2 0 01-2 2H4a2 2 0 01-2-2V6z' }, + { d: 'M10 9.5a2.5 2.5 0 00-4.5 0v5a2.5 2.5 0 004.5 0M18.5 9.5a2.5 2.5 0 00-4.5 0v5a2.5 2.5 0 004.5 0' }, + ], +} + +export const gearIcon: IconDefinition = { + paths: [ + { + d: 'M12.22 2h-.44a2 2 0 00-2 2v.18a2 2 0 01-1 1.73l-.43.25a2 2 0 01-2 0l-.15-.08a2 2 0 00-2.73.73l-.22.38a2 2 0 00.73 2.73l.15.1a2 2 0 011 1.72v.51a2 2 0 01-1 1.74l-.15.09a2 2 0 00-.73 2.73l.22.38a2 2 0 002.73.73l.15-.08a2 2 0 012 0l.43.25a2 2 0 011 1.73V20a2 2 0 002 2h.44a2 2 0 002-2v-.18a2 2 0 011-1.73l.43-.25a2 2 0 012 0l.15.08a2 2 0 002.73-.73l.22-.39a2 2 0 00-.73-2.73l-.15-.08a2 2 0 01-1-1.74v-.5a2 2 0 011-1.74l.15-.09a2 2 0 00.73-2.73l-.22-.38a2 2 0 00-2.73-.73l-.15.08a2 2 0 01-2 0l-.43-.25a2 2 0 01-1-1.73V4a2 2 0 00-2-2z', + }, + { d: 'M12 15a3 3 0 100-6 3 3 0 000 6z' }, + ], +} diff --git a/frontend/src/pages/movies/movie-player-v2/controls/seek-bar.tsx b/frontend/src/pages/movies/movie-player-v2/controls/seek-bar.tsx new file mode 100644 index 00000000..fde57607 --- /dev/null +++ b/frontend/src/pages/movies/movie-player-v2/controls/seek-bar.tsx @@ -0,0 +1,84 @@ +import { Shade, createComponent } from '@furystack/shades' +import { cssVariableTheme } from '@furystack/shades-common-components' + +import type { MoviePlayerService } from '../movie-player-service.js' + +type SeekBarProps = { + mediaService: MoviePlayerService +} + +export const SeekBar = Shade({ + customElementName: 'pirat-player-seek-bar', + css: { + display: 'flex', + alignItems: 'center', + flex: '1', + position: 'relative', + height: '20px', + cursor: 'pointer', + '& .seek-track': { + position: 'absolute', + width: '100%', + height: '4px', + borderRadius: '2px', + background: 'rgba(255, 255, 255, 0.2)', + overflow: 'hidden', + }, + '& .seek-buffered': { + position: 'absolute', + height: '100%', + background: 'rgba(255, 255, 255, 0.35)', + }, + '& .seek-played': { + position: 'absolute', + height: '100%', + background: cssVariableTheme.palette.primary.main, + }, + '& input[type="range"]': { + position: 'absolute', + width: '100%', + height: '100%', + margin: '0', + opacity: '0', + cursor: 'pointer', + zIndex: '1', + }, + }, + render: ({ props, useObservable }) => { + const [progress] = useObservable('progress', props.mediaService.progress) + const [duration] = useObservable('duration', props.mediaService.duration) + const [buffered] = useObservable('buffered', props.mediaService.buffered) + + const playedPercent = duration > 0 ? (progress / duration) * 100 : 0 + + let bufferedPercent = 0 + if (buffered && buffered.length > 0 && duration > 0) { + const end = buffered.end(buffered.length - 1) + const absoluteBuffered = props.mediaService.streamTimeToAbsolute(end) + bufferedPercent = (absoluteBuffered / duration) * 100 + } + + return ( +
+
+
+
+
+ { + const target = ev.currentTarget as HTMLInputElement + const targetTime = parseFloat(target.value) + if (!isNaN(targetTime)) { + props.mediaService.seekToTime(targetTime) + } + }} + /> +
+ ) + }, +}) diff --git a/frontend/src/pages/movies/movie-player-v2/controls/settings-menu.spec.ts b/frontend/src/pages/movies/movie-player-v2/controls/settings-menu.spec.ts new file mode 100644 index 00000000..16fe37d3 --- /dev/null +++ b/frontend/src/pages/movies/movie-player-v2/controls/settings-menu.spec.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' + +/** + * SettingsMenu is a Shades component that renders submenus for speed, + * audio, and captions. The logic it contains: + * + * - **Submenu navigation** — useState toggling, a view concern best + * tested via E2E. + * - **Track switching** — delegates to MoviePlayerService.switchAudioTrack + * and activeSubtitleTrack, both tested in movie-player-service.spec.ts. + * - **Click-outside close** — renders a backdrop div when open, verified + * here via export check and in E2E tests. + * + * The SPEED_OPTIONS constant and component export are verified below. + */ + +describe('SettingsMenu', () => { + it('should export the component', async () => { + const mod = await import('./settings-menu.js') + expect(mod.SettingsMenu).toBeDefined() + }, 15_000) +}) + +describe('SPEED_OPTIONS', () => { + it('should include expected playback rates', async () => { + const { SPEED_OPTIONS } = await import('./settings-menu.js') + expect(SPEED_OPTIONS).toContain(1) + expect(Array.from(SPEED_OPTIONS).every((r) => r > 0 && r <= 4)).toBe(true) + expect(SPEED_OPTIONS).toHaveLength(6) + }, 15_000) +}) diff --git a/frontend/src/pages/movies/movie-player-v2/controls/settings-menu.tsx b/frontend/src/pages/movies/movie-player-v2/controls/settings-menu.tsx new file mode 100644 index 00000000..ad4b0d98 --- /dev/null +++ b/frontend/src/pages/movies/movie-player-v2/controls/settings-menu.tsx @@ -0,0 +1,256 @@ +import { Shade, createComponent } from '@furystack/shades' +import { Icon, cssVariableTheme } from '@furystack/shades-common-components' + +import type { MoviePlayerService } from '../movie-player-service.js' +import { gearIcon } from './player-icons.js' + +type SettingsMenuProps = { + mediaService: MoviePlayerService +} + +type SubmenuId = 'speed' | 'audio' | 'captions' | null + +const getTagString = (tags: unknown, key: string): string | undefined => { + if (tags && typeof tags === 'object') { + const value = (tags as Record)[key] + return typeof value === 'string' ? value : undefined + } + return undefined +} + +export const SPEED_OPTIONS = [0.5, 0.75, 1, 1.25, 1.5, 2] as const + +export const SettingsMenu = Shade({ + customElementName: 'pirat-player-settings-menu', + css: { + position: 'relative', + '& .settings-panel': { + position: 'absolute', + bottom: '100%', + right: '0', + marginBottom: '8px', + background: cssVariableTheme.background.paper, + borderRadius: '8px', + border: `1px solid ${cssVariableTheme.action.subtleBorder}`, + boxShadow: cssVariableTheme.shadows.lg, + minWidth: '200px', + maxHeight: '300px', + overflowY: 'auto', + zIndex: '10', + }, + '& .menu-item': { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + padding: '10px 16px', + cursor: 'pointer', + fontSize: '14px', + color: cssVariableTheme.text.primary, + border: 'none', + background: 'none', + width: '100%', + textAlign: 'left', + }, + '& .menu-item:hover': { + background: cssVariableTheme.action.hoverBackground, + }, + '& .menu-item.active': { + color: cssVariableTheme.palette.primary.main, + }, + '& .menu-header': { + display: 'flex', + alignItems: 'center', + gap: '8px', + padding: '10px 16px', + borderBottom: `1px solid ${cssVariableTheme.action.subtleBorder}`, + fontSize: '14px', + fontWeight: 'bold', + color: cssVariableTheme.text.primary, + cursor: 'pointer', + background: 'none', + border: 'none', + width: '100%', + textAlign: 'left', + }, + }, + render: ({ props, useObservable, useState }) => { + const [isOpen, setIsOpen] = useState('isOpen', false) + const [activeSubmenu, setActiveSubmenu] = useState('activeSubmenu', null) + const [playbackRate] = useObservable('playbackRate', props.mediaService.playbackRate) + const [activeTrack] = useObservable('activeTrack', props.mediaService.activeSubtitleTrack) + + const audioTracks = props.mediaService.getAudioTrackInfoFromPlaybackInfo() + const ffprobeAudioTracks = + audioTracks.length === 0 + ? props.mediaService.getAudioTracks().map((t) => ({ + index: t.id, + label: getTagString(t.stream.tags, 'title') || getTagString(t.stream.tags, 'language') || 'Audio Track', + language: getTagString(t.stream.tags, 'language') || 'unknown', + codecName: t.codecName ?? 'unknown', + channels: t.stream.channels ?? 2, + isDefault: t.stream.disposition?.default === 1, + })) + : [] + const allAudioTracks = audioTracks.length > 0 ? audioTracks : ffprobeAudioTracks + + const subtitleTracks = props.mediaService.getSubtitleTrackInfoFromPlaybackInfo() + + const toggleMenu = () => { + if (isOpen) { + setIsOpen(false) + setActiveSubmenu(null) + } else { + setIsOpen(true) + } + } + + const renderMainMenu = () => ( +
+ + {allAudioTracks.length > 1 && ( + + )} + {subtitleTracks.length > 0 && ( + + )} +
+ ) + + const renderSpeedSubmenu = () => ( +
+ + {SPEED_OPTIONS.map((rate) => ( + + ))} +
+ ) + + const renderAudioSubmenu = () => ( +
+ + {allAudioTracks.map((track, index) => ( + + ))} +
+ ) + + const renderCaptionsSubmenu = () => ( +
+ + + {subtitleTracks.map((track, index) => ( + + ))} +
+ ) + + return ( + <> + + {isOpen && ( + <> +
{ + setIsOpen(false) + setActiveSubmenu(null) + }} + /> + {activeSubmenu === null && renderMainMenu()} + {activeSubmenu === 'speed' && renderSpeedSubmenu()} + {activeSubmenu === 'audio' && renderAudioSubmenu()} + {activeSubmenu === 'captions' && renderCaptionsSubmenu()} + + )} + + ) + }, +}) diff --git a/frontend/src/pages/movies/movie-player-v2/controls/time-display.spec.ts b/frontend/src/pages/movies/movie-player-v2/controls/time-display.spec.ts new file mode 100644 index 00000000..7eeead72 --- /dev/null +++ b/frontend/src/pages/movies/movie-player-v2/controls/time-display.spec.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' +import { formatTime } from './time-display.js' + +describe('formatTime', () => { + it('should format zero seconds', () => { + expect(formatTime(0)).toBe('0:00') + }) + + it('should format seconds under a minute', () => { + expect(formatTime(45)).toBe('0:45') + }) + + it('should format minutes and seconds', () => { + expect(formatTime(125)).toBe('2:05') + }) + + it('should format hours, minutes, and seconds', () => { + expect(formatTime(3661)).toBe('1:01:01') + }) + + it('should pad minutes and seconds in hour format', () => { + expect(formatTime(3600)).toBe('1:00:00') + }) + + it('should handle large durations', () => { + expect(formatTime(36000)).toBe('10:00:00') + }) + + it('should return 0:00 for negative values', () => { + expect(formatTime(-5)).toBe('0:00') + }) + + it('should return 0:00 for NaN', () => { + expect(formatTime(NaN)).toBe('0:00') + }) + + it('should return 0:00 for Infinity', () => { + expect(formatTime(Infinity)).toBe('0:00') + }) + + it('should floor fractional seconds', () => { + expect(formatTime(90.7)).toBe('1:30') + }) +}) diff --git a/frontend/src/pages/movies/movie-player-v2/controls/time-display.tsx b/frontend/src/pages/movies/movie-player-v2/controls/time-display.tsx new file mode 100644 index 00000000..748f92ce --- /dev/null +++ b/frontend/src/pages/movies/movie-player-v2/controls/time-display.tsx @@ -0,0 +1,39 @@ +import { Shade, createComponent } from '@furystack/shades' + +import type { MoviePlayerService } from '../movie-player-service.js' + +type TimeDisplayProps = { + mediaService: MoviePlayerService +} + +export const formatTime = (seconds: number): string => { + if (!isFinite(seconds) || seconds < 0) return '0:00' + const h = Math.floor(seconds / 3600) + const m = Math.floor((seconds % 3600) / 60) + const s = Math.floor(seconds % 60) + const pad = (n: number) => n.toString().padStart(2, '0') + return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}` +} + +export const TimeDisplay = Shade({ + customElementName: 'pirat-player-time-display', + render: ({ props, useObservable }) => { + const [progress] = useObservable('progress', props.mediaService.progress) + const [duration] = useObservable('duration', props.mediaService.duration) + + return ( + + {formatTime(progress)} / {formatTime(duration)} + + ) + }, +}) diff --git a/frontend/src/pages/movies/movie-player-v2/controls/video-container.spec.ts b/frontend/src/pages/movies/movie-player-v2/controls/video-container.spec.ts new file mode 100644 index 00000000..d314ac4f --- /dev/null +++ b/frontend/src/pages/movies/movie-player-v2/controls/video-container.spec.ts @@ -0,0 +1,30 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('./control-bar.js', () => ({ ControlBar: {} })) +vi.mock('./error-overlay.js', () => ({ ErrorOverlay: {} })) +vi.mock('./loading-overlay.js', () => ({ LoadingOverlay: {} })) + +/** + * VideoContainer is a Shades component whose logic is integration-level: + * + * - **Keyboard shortcuts** delegate to MoviePlayerService methods + * (togglePlay, seekToTime, setMuted, toggleFullscreen), all covered in + * movie-player-service.spec.ts. + * + * - **Idle timer** toggles a CSS class via mouse events — best tested + * through E2E (video-playback.spec.ts). + * + * - **Auto-focus** on mount uses whenRefReady — tested in + * when-ref-ready.spec.ts. + * + * Rendering-level behavior (overlay visibility, controls wrapper class + * toggling) requires the full Shades rendering environment and is verified + * by E2E tests. + */ + +describe('VideoContainer', () => { + it('should export the component', async () => { + const mod = await import('./video-container.js') + expect(mod.VideoContainer).toBeDefined() + }) +}) diff --git a/frontend/src/pages/movies/movie-player-v2/controls/video-container.tsx b/frontend/src/pages/movies/movie-player-v2/controls/video-container.tsx new file mode 100644 index 00000000..6ebe7242 --- /dev/null +++ b/frontend/src/pages/movies/movie-player-v2/controls/video-container.tsx @@ -0,0 +1,125 @@ +import type { RefObject } from '@furystack/shades' +import { Shade, createComponent } from '@furystack/shades' + +import { whenRefReady } from '../../../../utils/when-ref-ready.js' +import type { MoviePlayerService } from '../movie-player-service.js' +import { ControlBar } from './control-bar.js' +import { ErrorOverlay } from './error-overlay.js' +import { LoadingOverlay } from './loading-overlay.js' + +type VideoContainerProps = { + mediaService: MoviePlayerService + playerContainerRef: RefObject +} + +const IDLE_TIMEOUT_MS = 3000 +const SEEK_STEP_SECONDS = 10 + +export const VideoContainer = Shade({ + customElementName: 'pirat-player-video-container', + css: { + display: 'block', + position: 'absolute', + inset: '0', + '& .controls-wrapper': { + position: 'absolute', + bottom: '0', + left: '0', + right: '0', + transition: 'opacity 0.3s ease', + zIndex: '10', + }, + '& .controls-wrapper.hidden': { + opacity: '0', + pointerEvents: 'none', + }, + }, + render: ({ props, useDisposable, useState, useRef }) => { + const containerRef = useRef('container') + const [controlsHidden, setControlsHidden] = useState('controlsHidden', false) + + useDisposable('idleTimer', () => { + let timerId: ReturnType | null = null + + const resetTimer = () => { + setControlsHidden(false) + if (timerId) clearTimeout(timerId) + timerId = setTimeout(() => setControlsHidden(true), IDLE_TIMEOUT_MS) + } + + const onMouseLeave = () => { + setControlsHidden(true) + if (timerId) { + clearTimeout(timerId) + timerId = null + } + } + + return whenRefReady(containerRef, (container) => { + container.addEventListener('mousemove', resetTimer) + container.addEventListener('mouseleave', onMouseLeave) + container.addEventListener('mouseenter', resetTimer) + resetTimer() + + return { + [Symbol.dispose]: () => { + if (timerId) clearTimeout(timerId) + container.removeEventListener('mousemove', resetTimer) + container.removeEventListener('mouseleave', onMouseLeave) + container.removeEventListener('mouseenter', resetTimer) + }, + } + }) + }) + + useDisposable('keyboardShortcuts', () => + whenRefReady(containerRef, (container) => { + container.focus() + + const onKeyDown = (ev: KeyboardEvent) => { + switch (ev.key) { + case ' ': + case 'k': + ev.preventDefault() + props.mediaService.togglePlay() + break + case 'ArrowLeft': + ev.preventDefault() + props.mediaService.seekToTime(Math.max(0, props.mediaService.progress.getValue() - SEEK_STEP_SECONDS)) + break + case 'ArrowRight': + ev.preventDefault() + props.mediaService.seekToTime(props.mediaService.progress.getValue() + SEEK_STEP_SECONDS) + break + case 'm': + case 'M': + ev.preventDefault() + props.mediaService.setMuted(!props.mediaService.isMuted.getValue()) + break + case 'f': + case 'F': + ev.preventDefault() + if (props.playerContainerRef.current) { + props.mediaService.toggleFullscreen(props.playerContainerRef.current) + } + break + default: + break + } + } + container.addEventListener('keydown', onKeyDown) + return { [Symbol.dispose]: () => container.removeEventListener('keydown', onKeyDown) } + }), + ) + + return ( +
+ + +
+ +
+
+ ) + }, +}) diff --git a/frontend/src/pages/movies/movie-player-v2/controls/volume-control.tsx b/frontend/src/pages/movies/movie-player-v2/controls/volume-control.tsx new file mode 100644 index 00000000..8d2eae39 --- /dev/null +++ b/frontend/src/pages/movies/movie-player-v2/controls/volume-control.tsx @@ -0,0 +1,72 @@ +import { Shade, createComponent } from '@furystack/shades' +import { Icon } from '@furystack/shades-common-components' + +import type { MoviePlayerService } from '../movie-player-service.js' +import { volumeHighIcon, volumeLowIcon, volumeMuteIcon } from './player-icons.js' + +type VolumeControlProps = { + mediaService: MoviePlayerService +} + +export const VolumeControl = Shade({ + customElementName: 'pirat-player-volume-control', + css: { + display: 'flex', + alignItems: 'center', + gap: '4px', + '& input[type="range"]': { + width: '80px', + cursor: 'pointer', + accentColor: 'white', + }, + }, + render: ({ props, useObservable }) => { + const [isMuted] = useObservable('isMuted', props.mediaService.isMuted) + const [volume] = useObservable('volume', props.mediaService.volume) + + const getVolumeIcon = () => { + if (isMuted || volume === 0) return volumeMuteIcon + if (volume < 0.5) return volumeLowIcon + return volumeHighIcon + } + + return ( + <> + + { + const target = ev.currentTarget as HTMLInputElement + const val = parseFloat(target.value) + if (!isNaN(val)) { + if (val > 0 && isMuted) { + props.mediaService.setMuted(false) + } + props.mediaService.setVolume(val) + } + }} + /> + + ) + }, +}) diff --git a/frontend/src/pages/movies/movie-player-v2/get-subtitle-tracks.tsx b/frontend/src/pages/movies/movie-player-v2/get-subtitle-tracks.tsx index a1690069..ae6471c5 100644 --- a/frontend/src/pages/movies/movie-player-v2/get-subtitle-tracks.tsx +++ b/frontend/src/pages/movies/movie-player-v2/get-subtitle-tracks.tsx @@ -2,7 +2,7 @@ import { createComponent } from '@furystack/shades' import type { FfprobeData, PiRatFile, SubtitleTrackInfo } from 'common' import { getFileName, getParentPath } from 'common' -import { environmentOptions } from '../../../environment-options.js' +import { environmentOptions } from '../../../utils/environment-options.js' /** * Builds subtitle track elements from playback-info response data when available, diff --git a/frontend/src/pages/movies/movie-player-v2/media-chrome.ts b/frontend/src/pages/movies/movie-player-v2/media-chrome.ts deleted file mode 100644 index 16922ef6..00000000 --- a/frontend/src/pages/movies/movie-player-v2/media-chrome.ts +++ /dev/null @@ -1,93 +0,0 @@ -import type { PartialElement } from '@furystack/shades' -import 'media-chrome/all' -import type { - MediaAirplayButton, - MediaCaptionsButton, - MediaCaptionsMenu, - MediaCastButton, - MediaChromeButton, - MediaChromeDialog, - MediaChromeMenu, - MediaChromeMenuButton, - MediaChromeMenuItem, - MediaChromeRange, - MediaContainer, - MediaControlBar, - MediaController, - MediaDurationDisplay, - MediaErrorDialog, - MediaFullscreenButton, - MediaGestureReceiver, - MediaLiveButton, - MediaLoadingIndicator, - MediaMuteButton, - MediaPipButton, - MediaPlaybackRateButton, - MediaPlayButton, - MediaPosterImage, - MediaPreviewChapterDisplay, - MediaPreviewThumbnail, - MediaPreviewTimeDisplay, - MediaRenditionMenu, - MediaRenditionMenuButton, - MediaSeekBackwardButton, - MediaSeekForwardButton, - MediaSettingsMenu, - MediaSettingsMenuButton, - MediaSettingsMenuItem, - MediaTextDisplay, - MediaTimeDisplay, - MediaTimeRange, - MediaTooltip, - MediaVolumeRange, -} from 'media-chrome/all' - -declare global { - // eslint-disable-next-line @typescript-eslint/no-namespace - namespace JSX { - interface IntrinsicElements { - 'media-controller': PartialElement - 'media-play-button': PartialElement - 'media-airplay-button': PartialElement - 'media-captions-button': PartialElement - 'media-cast-button': PartialElement - 'media-chrome-button': PartialElement - 'media-chrome-dialog': PartialElement - 'media-chrome-range': PartialElement - 'media-container': PartialElement - 'media-control-bar': PartialElement - 'media-chrome': PartialElement - 'media-duration-display': PartialElement - 'media-error-dialog': PartialElement - 'media-fullscreen-button': PartialElement - 'media-gesture-receiver': PartialElement - 'media-live-button': PartialElement - 'media-loading-indicator': PartialElement - 'media-mute-button': PartialElement - 'media-pip-button': PartialElement - 'media-poster-image': PartialElement - 'media-playback-rate-button': PartialElement - 'media-preview-chapter-display': PartialElement - 'media-preview-thumbnail': PartialElement - 'media-preview-time-display': PartialElement - 'media-seek-backward-button': PartialElement - 'media-seek-forward-button': PartialElement - 'media-text-display': PartialElement - 'media-time-display': PartialElement - 'media-time-range': PartialElement - 'media-tooltip': PartialElement - 'media-volume-range': PartialElement - 'media-chrome-menu': PartialElement - 'media-chrome-menu-button': PartialElement - 'media-chrome-menu-item': PartialElement - 'media-captions-menu': PartialElement - 'media-settings-menu': PartialElement - 'media-settings-menu-button': PartialElement - 'media-settings-menu-item': PartialElement - 'media-rendition-menu': PartialElement - 'media-rendition-menu-button': PartialElement - 'media-audio-track-menu': PartialElement - 'media-playback-rate-menu': PartialElement - } - } -} diff --git a/frontend/src/pages/movies/movie-player-v2/movie-player-service.spec.ts b/frontend/src/pages/movies/movie-player-v2/movie-player-service.spec.ts index e235806d..3ce4df05 100644 --- a/frontend/src/pages/movies/movie-player-v2/movie-player-service.spec.ts +++ b/frontend/src/pages/movies/movie-player-v2/movie-player-service.spec.ts @@ -3,14 +3,6 @@ import { describe, expect, it, vi, beforeEach } from 'vitest' import { usingAsync } from '@furystack/utils' import { MoviePlayerService, videoCodecs, audioCodecs } from './movie-player-service.js' -vi.mock('hls.js', () => ({ - default: { - isSupported: () => true, - Events: { ERROR: 'hlsError', MANIFEST_PARSED: 'hlsManifestParsed' }, - ErrorTypes: { NETWORK_ERROR: 'networkError', MEDIA_ERROR: 'mediaError' }, - }, -})) - const mockLogger = { verbose: vi.fn().mockResolvedValue(undefined), error: vi.fn().mockResolvedValue(undefined), @@ -75,7 +67,6 @@ describe('MoviePlayerService', () => { expect(service.audioTrackId.getValue()).toBe(0) expect(service.playbackMode.getValue()).toBe('transcode') expect(service.progress.getValue()).toBe(0) - expect(service.resolution.getValue()).toBeUndefined() }, ) }) @@ -227,6 +218,15 @@ describe('MoviePlayerService', () => { src: '', currentTime: 0, canPlayType: vi.fn().mockReturnValue(''), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + volume: 1, + muted: false, + paused: true, + playbackRate: 1, + duration: 0, + buffered: { length: 0, start: vi.fn(), end: vi.fn() }, + textTracks: [], } as unknown as HTMLVideoElement service.attachToVideo(mockVideo) @@ -252,3 +252,159 @@ describe('audioCodecs', () => { expect(audioCodecs.opus).toBe('opus') }) }) + +describe('seekToTime', () => { + let api: ReturnType + + beforeEach(() => { + api = createMockApi() + vi.clearAllMocks() + }) + + it('should not seek in direct-play mode', async () => { + const directPlayResponse: PlaybackInfoResponse = { + ...mockPlaybackInfoResponse, + mode: 'direct-play', + } + api.call.mockResolvedValue({ result: directPlayResponse }) + + await usingAsync( + new MoviePlayerService(mockFile, mockFfprobe, api as never, 0, mockLogger as never), + async (service) => { + await vi.waitFor(() => { + expect(service.playbackInfo.getValue()).not.toBeNull() + }) + + api.call.mockClear() + service.seekToTime(3600) + expect(api.call).not.toHaveBeenCalled() + }, + ) + }) + + it('should not seek when already switching', async () => { + await usingAsync( + new MoviePlayerService(mockFile, mockFfprobe, api as never, 0, mockLogger as never), + async (service) => { + await vi.waitFor(() => { + expect(service.playbackInfo.getValue()).not.toBeNull() + }) + + await service.switchAudioTrack(2) + api.call.mockClear() + service.seekToTime(3600) + expect(api.call).not.toHaveBeenCalled() + }, + ) + }) + + it('should not restart if target is within buffered range', async () => { + await usingAsync( + new MoviePlayerService(mockFile, mockFfprobe, api as never, 0, mockLogger as never), + async (service) => { + await vi.waitFor(() => { + expect(service.playbackInfo.getValue()).not.toBeNull() + }) + + const mockVideo = { + currentTime: 10, + buffered: { + length: 1, + start: () => 0, + end: () => 30, + }, + } as unknown as HTMLVideoElement + service.videoElement = mockVideo + + api.call.mockClear() + service.seekToTime(15) + expect(api.call).not.toHaveBeenCalled() + }, + ) + }) + + it('should not restart if quantized startTime is the same', async () => { + await usingAsync( + new MoviePlayerService(mockFile, mockFfprobe, api as never, 0, mockLogger as never), + async (service) => { + await vi.waitFor(() => { + expect(service.playbackInfo.getValue()).not.toBeNull() + }) + + const mockVideo = { + currentTime: 0, + buffered: { length: 0, start: () => 0, end: () => 0 }, + } as unknown as HTMLVideoElement + service.videoElement = mockVideo + + api.call.mockClear() + service.seekToTime(3) + expect(api.call).not.toHaveBeenCalled() + }, + ) + }) + + it('should restart HLS session when seeking backward past hlsStartTime', async () => { + await usingAsync( + new MoviePlayerService(mockFile, mockFfprobe, api as never, 3605, mockLogger as never), + async (service) => { + await vi.waitFor(() => { + expect(service.playbackInfo.getValue()).not.toBeNull() + }) + + const mockVideo = { + currentTime: 5, + src: '', + buffered: { length: 0, start: vi.fn(), end: vi.fn() }, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + } as unknown as HTMLVideoElement + service.videoElement = mockVideo + + api.call.mockClear() + service.seekToTime(100) + + await vi.waitFor(() => { + expect(api.call).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'DELETE', + action: '/files/:letter/:path/hls-session', + }), + ) + expect(service.progress.getValue()).toBe(100) + }) + }, + ) + }) + + it('should set video.currentTime for forward seeks', async () => { + await usingAsync( + new MoviePlayerService(mockFile, mockFfprobe, api as never, 0, mockLogger as never), + async (service) => { + await vi.waitFor(() => { + expect(service.playbackInfo.getValue()).not.toBeNull() + }) + + const mockVideo = { + currentTime: 10, + buffered: { length: 0, start: vi.fn(), end: vi.fn() }, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + } as unknown as HTMLVideoElement + service.videoElement = mockVideo + + service.seekToTime(3600) + expect(mockVideo.currentTime).toBe(3600) + }, + ) + }) + + it('should initialize hlsStartTime from watch progress', async () => { + await usingAsync( + new MoviePlayerService(mockFile, mockFfprobe, api as never, 3605, mockLogger as never), + async (service) => { + expect(service.progress.getValue()).toBe(3605) + }, + ) + }) +}) diff --git a/frontend/src/pages/movies/movie-player-v2/movie-player-service.ts b/frontend/src/pages/movies/movie-player-v2/movie-player-service.ts index 16a6566d..de8dbf5d 100644 --- a/frontend/src/pages/movies/movie-player-v2/movie-player-service.ts +++ b/frontend/src/pages/movies/movie-player-v2/movie-player-service.ts @@ -1,16 +1,18 @@ import type { ScopedLogger } from '@furystack/logging' import { ObservableValue } from '@furystack/utils' -import type { - AudioTrackInfo, - FfprobeData, - PiRatFile, - PlaybackInfoResponse, - PlaybackMode, - SubtitleTrackInfo, +import { + encode, + HLS_SEGMENT_DURATION, + type AudioTrackInfo, + type FfprobeData, + type PiRatFile, + type PlaybackInfoResponse, + type PlaybackMode, + type SubtitleTrackInfo, } from 'common' -import type Hls from 'hls.js' -import { environmentOptions } from '../../../environment-options.js' +import Hls from 'hls.js' import type { MediaApiClient } from '../../../services/api-clients/media-api-client.js' +import { environmentOptions } from '../../../utils/environment-options.js' export const videoCodecs = { h264: 'avc1.42E01E', @@ -27,11 +29,6 @@ export const audioCodecs = { dts: 'dts+', } -const loadHls = async () => { - const mod = await import('hls.js') - return mod.default -} - const buildCodecSupportMap = () => { const supportedVideo: string[] = [] const supportedAudio: string[] = [] @@ -66,30 +63,80 @@ export class MoviePlayerService implements AsyncDisposable { private readonly logger: ScopedLogger, ) { this.progress = new ObservableValue(this.currentProgress) + this.hlsStartTime = Math.floor(this.currentProgress / HLS_SEGMENT_DURATION) * HLS_SEGMENT_DURATION void this.initialize() } - private hls: Hls | null = null + public isSwitching = new ObservableValue(false) + private seekGeneration = 0 + private hlsStartTime = 0 + private pendingCanPlayCleanup: (() => void) | null = null public videoElement: HTMLVideoElement | null = null public audioTrackId = new ObservableValue(0) public playbackInfo = new ObservableValue(null) public playbackMode = new ObservableValue('transcode') - public resolution = new ObservableValue<'4k' | '1080p' | '720p' | '480p' | '360p' | undefined>(undefined) public progress: ObservableValue + public isPlaying = new ObservableValue(false) + public duration = new ObservableValue(0) + public volume = new ObservableValue(1) + public isMuted = new ObservableValue(false) + public isFullscreen = new ObservableValue(false) + public playbackRate = new ObservableValue(1) + public buffered = new ObservableValue(null) + public activeSubtitleTrack = new ObservableValue(null) + + private videoEventCleanup: Disposable | null = null + private hlsInstance: Hls | null = null + private isProgrammaticSeek = false + + /** + * Converts a 0-based stream time (from video.currentTime during HLS) + * to the absolute file position. For direct-play, the value is returned + * unchanged because the video element already uses absolute timestamps. + */ + public streamTimeToAbsolute(streamTime: number): number { + const mode = this.playbackMode.getValue() + if (mode === 'direct-play') return streamTime + return streamTime + this.hlsStartTime + } + + private getAbsoluteProgress(): number { + const video = this.videoElement + if (!video) return this.progress.getValue() + return this.streamTimeToAbsolute(video.currentTime) + } + public async [Symbol.asyncDispose]() { + if (this.seekDebounceTimer) { + clearTimeout(this.seekDebounceTimer) + this.seekDebounceTimer = null + } + + if (this.pendingCanPlayCleanup) { + this.pendingCanPlayCleanup() + } + + this.destroyHlsInstance() + this.videoEventCleanup?.[Symbol.dispose]() + this.videoEventCleanup = null + await this.teardownHlsSession() this.progress[Symbol.dispose]() - this.resolution[Symbol.dispose]() this.playbackInfo[Symbol.dispose]() this.playbackMode[Symbol.dispose]() this.audioTrackId[Symbol.dispose]() - if (this.hls) { - this.hls.destroy() - this.hls = null - } + this.isSwitching[Symbol.dispose]() + this.isPlaying[Symbol.dispose]() + this.duration[Symbol.dispose]() + this.volume[Symbol.dispose]() + this.isMuted[Symbol.dispose]() + this.isFullscreen[Symbol.dispose]() + this.playbackRate[Symbol.dispose]() + this.buffered[Symbol.dispose]() + this.activeSubtitleTrack[Symbol.dispose]() } private async teardownHlsSession() { @@ -104,11 +151,7 @@ export class MoviePlayerService implements AsyncDisposable { letter: this.file.driveLetter, path: this.file.path, }, - query: { - mode, - audioTrack: this.audioTrackId.getValue(), - resolution: this.resolution.getValue(), - }, + query: {}, }) } catch (error) { void this.logger.warning({ message: 'Failed to tear down HLS session', data: { error } }) @@ -132,8 +175,8 @@ export class MoviePlayerService implements AsyncDisposable { selectedSubtitleTrackIndex, }, }) - this.playbackInfo.setValue(result) this.playbackMode.setValue(result.mode) + this.playbackInfo.setValue(result) void this.logger.verbose({ message: `Playback info received: mode=${result.mode}`, @@ -148,10 +191,13 @@ export class MoviePlayerService implements AsyncDisposable { } /** - * Attaches to a video element and starts playback using HLS or direct source + * Attaches to a video element, binds event listeners for observable state, + * and starts playback using native HLS or direct source. */ public attachToVideo(videoElement: HTMLVideoElement) { this.videoElement = videoElement + this.bindVideoEvents(videoElement) + const info = this.playbackInfo.getValue() if (!info) { @@ -168,16 +214,121 @@ export class MoviePlayerService implements AsyncDisposable { this.startPlayback(videoElement, info) } - private startPlayback(videoElement: HTMLVideoElement, info: PlaybackInfoResponse) { - if (this.hls) { - this.hls.destroy() - this.hls = null + private bindVideoEvents(video: HTMLVideoElement) { + const onPlay = () => this.isPlaying.setValue(true) + const onPause = () => this.isPlaying.setValue(false) + const onVolumeChange = () => { + this.volume.setValue(video.volume) + this.isMuted.setValue(video.muted) + } + const updateDuration = () => { + const playbackDuration = this.playbackInfo.getValue()?.duration + if (playbackDuration && playbackDuration > 0) { + this.duration.setValue(playbackDuration) + } else if (video.duration && isFinite(video.duration)) { + this.duration.setValue(video.duration) + } + } + const onDurationChange = updateDuration + const onLoadedMetadata = updateDuration + const onRateChange = () => this.playbackRate.setValue(video.playbackRate) + const onProgress = () => this.buffered.setValue(video.buffered) + const onTimeUpdate = () => { + if (this.isSwitching.getValue()) return + this.progress.setValue(this.streamTimeToAbsolute(video.currentTime) || 0) + } + const onSeeking = () => { + if (this.isProgrammaticSeek) return + this.seekToTime(this.streamTimeToAbsolute(video.currentTime)) + } + const onFullscreenChange = () => { + this.isFullscreen.setValue(!!document.fullscreenElement) + } + + video.addEventListener('play', onPlay) + video.addEventListener('pause', onPause) + video.addEventListener('volumechange', onVolumeChange) + video.addEventListener('durationchange', onDurationChange) + video.addEventListener('loadedmetadata', onLoadedMetadata) + video.addEventListener('ratechange', onRateChange) + video.addEventListener('progress', onProgress) + video.addEventListener('timeupdate', onTimeUpdate) + video.addEventListener('seeking', onSeeking) + document.addEventListener('fullscreenchange', onFullscreenChange) + + const playbackInfoDuration = this.playbackInfo.getValue()?.duration + if (playbackInfoDuration && playbackInfoDuration > 0) { + this.duration.setValue(playbackInfoDuration) } - if (info.mode === 'direct-play') { + this.videoEventCleanup = { + [Symbol.dispose]: () => { + video.removeEventListener('play', onPlay) + video.removeEventListener('pause', onPause) + video.removeEventListener('volumechange', onVolumeChange) + video.removeEventListener('durationchange', onDurationChange) + video.removeEventListener('loadedmetadata', onLoadedMetadata) + video.removeEventListener('ratechange', onRateChange) + video.removeEventListener('progress', onProgress) + video.removeEventListener('timeupdate', onTimeUpdate) + video.removeEventListener('seeking', onSeeking) + document.removeEventListener('fullscreenchange', onFullscreenChange) + }, + } + } + + public togglePlay() { + const video = this.videoElement + if (!video) return + if (video.paused) { + void video.play().catch(() => {}) + } else { + video.pause() + } + } + + public setVolume(value: number) { + if (this.videoElement) { + this.videoElement.volume = Math.max(0, Math.min(1, value)) + } + } + + public setMuted(muted: boolean) { + if (this.videoElement) { + this.videoElement.muted = muted + } + } + + public setPlaybackRate(rate: number) { + if (this.videoElement) { + this.videoElement.playbackRate = rate + } + } + + public toggleFullscreen(container: HTMLElement) { + if (document.fullscreenElement) { + void document.exitFullscreen() + } else { + void container.requestFullscreen() + } + } + + public togglePip() { + const video = this.videoElement + if (!video) return + if (document.pictureInPictureElement) { + void document.exitPictureInPicture() + } else { + void video.requestPictureInPicture() + } + } + + private startPlayback(videoElement: HTMLVideoElement, info: PlaybackInfoResponse) { + const mode = this.playbackMode.getValue() + if (mode === 'direct-play') { this.startDirectPlayback(videoElement, info) } else { - void this.startHlsPlayback(videoElement) + this.startHlsPlayback(videoElement) } } @@ -194,101 +345,187 @@ export class MoviePlayerService implements AsyncDisposable { } } - private async startHlsPlayback(videoElement: HTMLVideoElement) { + private buildHlsUrl(): string { const mode = this.playbackMode.getValue() - const hlsUrl = this.toServiceUrl( - `/api/media/files/${encodeURIComponent(this.file.driveLetter)}/${encodeURIComponent(this.file.path)}/master.m3u8?mode=${encodeURIComponent(mode)}`, + const audioTrack = this.audioTrackId.getValue() + const audioParam = audioTrack ? `&audioTrack=${encode(String(audioTrack))}` : '' + const startTimeParam = this.hlsStartTime > 0 ? `&startTime=${encode(String(this.hlsStartTime))}` : '' + return this.toServiceUrl( + `/api/media/files/${encodeURIComponent(this.file.driveLetter)}/${encodeURIComponent(this.file.path)}/master.m3u8?mode=${encode(mode)}${audioParam}${startTimeParam}`, ) + } - const HlsModule = await loadHls() - - // Prefer hls.js over native HLS — many browsers (including Chromium) report - // canPlayType('application/vnd.apple.mpegurl') as 'maybe' without full support. - // hls.js also handles missing alternative renditions more gracefully. - if (!HlsModule.isSupported()) { - if (videoElement.canPlayType('application/vnd.apple.mpegurl')) { - void this.logger.verbose({ message: 'Using native HLS playback' }) - videoElement.src = hlsUrl - if (this.currentProgress > 0) { - videoElement.currentTime = this.currentProgress - } - return - } - void this.logger.error({ message: 'HLS is not supported in this browser' }) - return + private destroyHlsInstance() { + if (this.hlsInstance) { + this.hlsInstance.destroy() + this.hlsInstance = null } + } - void this.logger.verbose({ message: 'Starting HLS playback via hls.js' }) - - this.hls = new HlsModule({ - xhrSetup: (xhr) => { - xhr.withCredentials = true - }, - startPosition: this.currentProgress > 0 ? this.currentProgress : -1, - }) + private startHlsPlayback(videoElement: HTMLVideoElement) { + this.destroyHlsInstance() + const hlsUrl = this.buildHlsUrl() - this.hls.on(HlsModule.Events.ERROR, (_event, data) => { - if (data.fatal) { - void this.logger.error({ - message: `HLS fatal error: ${data.type}`, - data: { details: data.details }, - }) + if (Hls.isSupported()) { + const hls = new Hls({ + xhrSetup: (xhr) => { + xhr.withCredentials = true + }, + enableWebVTT: false, + enableIMSC1: false, + enableCEA708Captions: false, + }) + this.hlsInstance = hls - if (data.type === HlsModule.ErrorTypes.NETWORK_ERROR) { - this.hls?.startLoad() - } else if (data.type === HlsModule.ErrorTypes.MEDIA_ERROR) { - this.hls?.recoverMediaError() + hls.on(Hls.Events.MANIFEST_PARSED, () => { + hls.subtitleTrack = -1 + if (this.currentProgress > this.hlsStartTime) { + videoElement.currentTime = this.currentProgress - this.hlsStartTime } - } - }) - - const { hls } = this - - hls.on(HlsModule.Events.MANIFEST_PARSED, () => { - void this.logger.verbose({ message: 'HLS manifest parsed' }) + }) - const resolutionHeightMap: Record = { - '4k': 2160, - '1080p': 1080, - '720p': 720, - '480p': 480, - '360p': 360, - } + hls.on(Hls.Events.ERROR, (_event, data) => { + if (data.details === Hls.ErrorDetails.SUBTITLE_LOAD_ERROR) return - const sub = this.resolution.subscribe((value) => { - if (!value) { - hls.currentLevel = -1 - return + void this.logger.error({ + message: 'HLS playback error', + data: { type: data.type, details: data.details, fatal: data.fatal }, + }) + if (data.fatal) { + if (data.type === Hls.ErrorTypes.MEDIA_ERROR) { + hls.recoverMediaError() + } else { + this.destroyHlsInstance() + } } - const targetHeight = resolutionHeightMap[value] - if (!targetHeight) return - const levelIndex = hls.levels.findIndex((l) => l.height === targetHeight) - hls.currentLevel = levelIndex >= 0 ? levelIndex : -1 }) - hls.on(HlsModule.Events.DESTROYING, () => sub[Symbol.dispose]()) - }) - - this.hls.loadSource(hlsUrl) - this.hls.attachMedia(videoElement) + hls.loadSource(hlsUrl) + hls.attachMedia(videoElement) + void this.logger.verbose({ message: 'Starting hls.js playback' }) + } else { + void this.logger.verbose({ message: 'Starting native HLS playback' }) + videoElement.src = hlsUrl + if (this.currentProgress > this.hlsStartTime) { + videoElement.currentTime = this.currentProgress - this.hlsStartTime + } + } } /** * Switches audio track and reloads from current position */ public async switchAudioTrack(trackIndex: number) { - const previousProgress = this.videoElement?.currentTime ?? this.progress.getValue() + const previousProgress = this.getAbsoluteProgress() + this.isSwitching.setValue(true) await this.teardownHlsSession() this.audioTrackId.setValue(trackIndex) this.currentProgress = previousProgress + this.progress.setValue(previousProgress) + this.hlsStartTime = Math.floor(previousProgress / HLS_SEGMENT_DURATION) * HLS_SEGMENT_DURATION await this.fetchPlaybackInfo() const info = this.playbackInfo.getValue() if (this.videoElement && info) { this.startPlayback(this.videoElement, info) + this.waitForCanPlay(this.videoElement) + } else { + this.isSwitching.setValue(false) + } + } + + private seekDebounceTimer: ReturnType | null = null + + /** + * Handles a seek to a new absolute file time position. For forward seeks + * within the current playlist, sets video.currentTime directly. If the + * target is before the current playlist start, tears down the current + * session and starts a new one with server-side seeking via `-ss`. + */ + public seekToTime(targetSeconds: number) { + const mode = this.playbackMode.getValue() + if (mode === 'direct-play' || this.isSwitching.getValue()) return + + const video = this.videoElement + if (!video) return + + const streamTime = targetSeconds - this.hlsStartTime + if (streamTime >= 0 && this.isTimeBuffered(video, streamTime)) return + + if (targetSeconds >= this.hlsStartTime) { + this.isProgrammaticSeek = true + video.currentTime = targetSeconds - this.hlsStartTime + this.isProgrammaticSeek = false + return + } + + const quantizedStart = Math.floor(targetSeconds / HLS_SEGMENT_DURATION) * HLS_SEGMENT_DURATION + + if (this.seekDebounceTimer) { + clearTimeout(this.seekDebounceTimer) + } + + this.seekDebounceTimer = setTimeout(() => { + this.seekDebounceTimer = null + void this.restartHlsAtTime(targetSeconds, quantizedStart) + }, 300) + } + + private isTimeBuffered(video: HTMLVideoElement, time: number): boolean { + const { buffered } = video + for (let i = 0; i < buffered.length; i++) { + if (time >= buffered.start(i) && time <= buffered.end(i)) { + return true + } + } + return false + } + + private async restartHlsAtTime(targetSeconds: number, quantizedStart: number) { + this.isSwitching.setValue(true) + const generation = ++this.seekGeneration + + try { + await this.teardownHlsSession() + + if (generation !== this.seekGeneration) return + + this.hlsStartTime = quantizedStart + this.currentProgress = targetSeconds + this.progress.setValue(targetSeconds) + + if (this.videoElement) { + this.startHlsPlayback(this.videoElement) + this.waitForCanPlay(this.videoElement) + } else { + this.isSwitching.setValue(false) + } + } catch { + if (generation === this.seekGeneration) { + this.isSwitching.setValue(false) + } + } + } + + private waitForCanPlay(video: HTMLVideoElement) { + if (this.pendingCanPlayCleanup) { + this.pendingCanPlayCleanup() + } + + const onCanPlay = () => { + video.removeEventListener('canplay', onCanPlay) + this.pendingCanPlayCleanup = null + this.isSwitching.setValue(false) + // play() can reject with AbortError when navigation interrupts playback; this is benign + void video.play().catch(() => {}) + } + video.addEventListener('canplay', onCanPlay) + this.pendingCanPlayCleanup = () => { + video.removeEventListener('canplay', onCanPlay) + this.pendingCanPlayCleanup = null } } diff --git a/frontend/src/pages/movies/movie-player-v2/movie-player-v2-component.spec.ts b/frontend/src/pages/movies/movie-player-v2/movie-player-v2-component.spec.ts index 4d9fb164..27eb063a 100644 --- a/frontend/src/pages/movies/movie-player-v2/movie-player-v2-component.spec.ts +++ b/frontend/src/pages/movies/movie-player-v2/movie-player-v2-component.spec.ts @@ -1,107 +1,17 @@ -import type { AudioTrackInfo, FfprobeData, PlaybackInfoResponse } from 'common' +import type { FfprobeData, PlaybackInfoResponse } from 'common' import { describe, expect, it } from 'vitest' import { getSubtitleTracks, getSubtitleTracksFromPlaybackInfo } from './get-subtitle-tracks.js' /** - * The MoviePlayerV2 component is a Shades component tightly coupled to - * media-chrome custom elements and HTMLVideoElement. Its testable logic - * is exercised here via the data-mapping helpers it uses inline. + * The MoviePlayerV2 component is a Shades component that uses custom + * Shades controls bound to MoviePlayerService observables. Its testable + * logic is exercised here via the data-mapping helpers it uses inline. * * The core playback logic is covered in movie-player-service.spec.ts * and get-subtitle-tracks.spec.tsx. */ -const buildRenditionList = (height: number, currentValue?: string) => { - type Rendition = { id: string; width: number; height: number; src: string; selected: boolean } - const renditions: Rendition[] = [ - ...(height >= 2160 ? [{ id: '4k', width: 3840, height: 2160, src: '', selected: currentValue === '4k' }] : []), - ...(height >= 1080 - ? [{ id: '1080p', width: 1920, height: 1080, src: '', selected: currentValue === '1080p' }] - : []), - ...(height >= 720 ? [{ id: '720p', width: 1280, height: 720, src: '', selected: currentValue === '720p' }] : []), - ...(height >= 480 ? [{ id: '480p', width: 854, height: 480, src: '', selected: currentValue === '480p' }] : []), - { id: '360p', width: 640, height: 360, src: '', selected: currentValue === '360p' }, - ] - return renditions -} - -const mapAudioTracksForMediaChrome = (audioTracks: AudioTrackInfo[]) => - audioTracks.map((track, index) => ({ - id: track.index.toFixed(0), - label: track.label || track.language || `Audio Track ${index + 1}`, - language: track.language, - enabled: index === 0, - kind: track.label, - })) - describe('MoviePlayerV2 component logic', () => { - describe('buildRenditionList', () => { - it('should include all resolutions for 4K source', () => { - const renditions = buildRenditionList(2160) - expect(renditions).toHaveLength(5) - expect(renditions.map((r) => r.id)).toEqual(['4k', '1080p', '720p', '480p', '360p']) - }) - - it('should exclude resolutions above source height', () => { - const renditions = buildRenditionList(720) - expect(renditions).toHaveLength(3) - expect(renditions.map((r) => r.id)).toEqual(['720p', '480p', '360p']) - }) - - it('should always include 360p', () => { - const renditions = buildRenditionList(240) - expect(renditions).toHaveLength(1) - expect(renditions[0].id).toBe('360p') - }) - - it('should mark the selected rendition', () => { - const renditions = buildRenditionList(1080, '720p') - const selected = renditions.find((r) => r.selected) - expect(selected?.id).toBe('720p') - }) - - it('should not mark any rendition when no value is selected', () => { - const renditions = buildRenditionList(1080) - expect(renditions.every((r) => !r.selected)).toBe(true) - }) - }) - - describe('mapAudioTracksForMediaChrome', () => { - it('should map audio tracks with correct ids and labels', () => { - const tracks: AudioTrackInfo[] = [ - { index: 1, label: 'English', language: 'eng', codecName: 'aac', channels: 2, isDefault: true }, - { index: 2, label: 'French', language: 'fra', codecName: 'ac3', channels: 6, isDefault: false }, - ] - - const mapped = mapAudioTracksForMediaChrome(tracks) - expect(mapped).toHaveLength(2) - expect(mapped[0].id).toBe('1') - expect(mapped[0].label).toBe('English') - expect(mapped[0].enabled).toBe(true) - expect(mapped[1].id).toBe('2') - expect(mapped[1].label).toBe('French') - expect(mapped[1].enabled).toBe(false) - }) - - it('should fall back to language when label is empty', () => { - const tracks: AudioTrackInfo[] = [ - { index: 1, label: '', language: 'jpn', codecName: 'aac', channels: 2, isDefault: false }, - ] - - const mapped = mapAudioTracksForMediaChrome(tracks) - expect(mapped[0].label).toBe('jpn') - }) - - it('should fall back to generic label when both label and language are empty', () => { - const tracks: AudioTrackInfo[] = [ - { index: 1, label: '', language: '', codecName: 'aac', channels: 2, isDefault: false }, - ] - - const mapped = mapAudioTracksForMediaChrome(tracks) - expect(mapped[0].label).toBe('Audio Track 1') - }) - }) - describe('subtitle source selection', () => { const selectSubtitleElements = ( playbackInfo: PlaybackInfoResponse | null, diff --git a/frontend/src/pages/movies/movie-player-v2/movie-player-v2-component.tsx b/frontend/src/pages/movies/movie-player-v2/movie-player-v2-component.tsx index 59f37a54..d5e36de6 100644 --- a/frontend/src/pages/movies/movie-player-v2/movie-player-v2-component.tsx +++ b/frontend/src/pages/movies/movie-player-v2/movie-player-v2-component.tsx @@ -1,13 +1,14 @@ import { getLogger } from '@furystack/logging' import { Shade, createComponent } from '@furystack/shades' import { type FfprobeData, type Movie, type PiRatFile, type WatchHistoryEntry } from 'common' -import type { AudioTrack, Rendition } from 'media-chrome/dist/media-store/state-mediator.js' + import { MediaApiClient } from '../../../services/api-clients/media-api-client.js' import { WatchProgressService } from '../../../services/watch-progress-service.js' import { WatchProgressUpdater } from '../../../services/watch-progress-updater.js' +import { whenRefReady } from '../../../utils/when-ref-ready.js' +import { VideoContainer } from './controls/index.js' import { getChaptersTrack } from './get-chapters-track.js' import { getSubtitleTracks, getSubtitleTracksFromPlaybackInfo } from './get-subtitle-tracks.js' -import './media-chrome.js' import { MoviePlayerService } from './movie-player-service.js' type MoviePlayerProps = { @@ -21,28 +22,46 @@ export const MoviePlayerV2 = Shade({ customElementName: 'pirat-movie-player-v2', render: ({ props, useDisposable, useObservable, useRef, injector }) => { const videoRef = useRef('video') - const containerRef = useRef('container') + const playerContainerRef = useRef('playerContainer') const { driveLetter, path } = props.file const watchProgressService = injector.getInstance(WatchProgressService) useDisposable('watchProgressUpdater', () => { + const createUpdater = (video: HTMLVideoElement) => + new WatchProgressUpdater({ + intervalMs: 10 * 1000, + onSave: async (progress) => { + void watchProgressService.updateWatchEntry({ + completed: (mediaService.playbackInfo.getValue()?.duration ?? video.duration) - progress < 10, + driveLetter, + path, + watchedSeconds: progress, + }) + }, + saveTresholdSeconds: 10, + videoElement: video, + }) + const video = videoRef.current - if (!video) { - return { [Symbol.asyncDispose]: async () => {} } + if (video) { + return createUpdater(video) } - return new WatchProgressUpdater({ - intervalMs: 10 * 1000, - onSave: async (progress) => { - void watchProgressService.updateWatchEntry({ - completed: video.duration - progress < 10, - driveLetter, - path, - watchedSeconds: progress, - }) - }, - saveTresholdSeconds: 10, - videoElement: video, + + let updater: WatchProgressUpdater | null = null + const frameId = requestAnimationFrame(() => { + const deferredVideo = videoRef.current + if (deferredVideo) { + updater = createUpdater(deferredVideo) + } }) + return { + [Symbol.asyncDispose]: async () => { + cancelAnimationFrame(frameId) + if (updater) { + await updater[Symbol.asyncDispose]() + } + }, + } }) const { watchProgress, file } = props @@ -54,20 +73,11 @@ export const MoviePlayerV2 = Shade({ () => new MoviePlayerService(file, props.ffprobe, api, watchProgress?.watchedSeconds || 0, logger), ) - useDisposable('videoAttachment', () => { - const video = videoRef.current - if (video) { + useDisposable('videoAttachment', () => + whenRefReady(videoRef, (video) => { mediaService.attachToVideo(video) - return { [Symbol.dispose]: () => {} } - } - const frameId = requestAnimationFrame(() => { - const deferredVideo = videoRef.current - if (deferredVideo) { - mediaService.attachToVideo(deferredVideo) - } - }) - return { [Symbol.dispose]: () => cancelAnimationFrame(frameId) } - }) + }), + ) const [playbackInfo] = useObservable('playbackInfo', mediaService.playbackInfo) const subtitleElements = @@ -77,7 +87,7 @@ export const MoviePlayerV2 = Shade({ return (
({ overflow: 'hidden', }} > - - - - - - - - - - - - - - - - - - - - - + {...subtitleElements} + {getChaptersTrack(props.ffprobe)} + +
) }, diff --git a/frontend/src/pages/movies/plain-hls-player.tsx b/frontend/src/pages/movies/plain-hls-player.tsx index efa73cfe..9d26d520 100644 --- a/frontend/src/pages/movies/plain-hls-player.tsx +++ b/frontend/src/pages/movies/plain-hls-player.tsx @@ -1,10 +1,10 @@ import { Shade, createComponent } from '@furystack/shades' -import type Hls from 'hls.js' -import { environmentOptions } from '../../environment-options.js' + +import { environmentOptions } from '../../utils/environment-options.js' /** - * Minimal HLS player for debugging — no media-chrome, no MoviePlayerService, - * no watch progress, no audio/subtitle track management. Just hls.js + a