diff --git a/.claude/agents/tv-validator.md b/.claude/agents/tv-validator.md new file mode 100644 index 000000000..a38dd751a --- /dev/null +++ b/.claude/agents/tv-validator.md @@ -0,0 +1,103 @@ +--- +name: tv-validator +description: Use this agent to review TV platform code for correct patterns and conventions. Use proactively after writing or modifying TV components. Validates focus handling, modal patterns, typography, list components, and other TV-specific requirements. +tools: Read, Glob, Grep +model: haiku +color: blue +--- + +You are a TV platform code reviewer for Streamyfin, a React Native app with Apple TV and Android TV support. Review code for correct TV patterns and flag violations. + +## Critical Rules to Check + +### 1. No .tv.tsx File Suffix +The `.tv.tsx` suffix does NOT work in this project. Metro bundler doesn't resolve it. + +**Violation**: Creating files like `MyComponent.tv.tsx` expecting auto-resolution +**Correct**: Use `Platform.isTV` conditional rendering in the main file: +```typescript +if (Platform.isTV) { + return ; +} +return ; +``` + +### 2. No FlashList on TV +FlashList has focus issues on TV. Use FlatList instead. + +**Violation**: ` +) : ( + +)} +``` + +### 3. Modal Pattern +Never use overlay/absolute-positioned modals on TV. They break back button handling. + +**Violation**: `position: "absolute"` or `Modal` component for TV overlays +**Correct**: Use navigation-based pattern: +- Create Jotai atom for state +- Hook that sets atom and calls `router.push()` +- Page in `app/(auth)/` that reads atom +- `Stack.Screen` with `presentation: "transparentModal"` + +### 4. Typography +All TV text must use `TVTypography` component. + +**Violation**: Raw `` in TV components +**Correct**: `...` + +### 5. No Purple Accent Colors +TV uses white for focus states, not purple. + +**Violation**: Purple/violet colors in TV focused states +**Correct**: White (`#fff`, `white`) for focused states with `expo-blur` for backgrounds + +### 6. Focus Handling +- Only ONE element should have `hasTVPreferredFocus={true}` +- Focusable items need `disabled={isModalOpen}` when overlays are visible +- Use `onFocus`/`onBlur` with scale animations +- Add padding for scale animations (focus scale clips without it) + +### 7. List Configuration +TV lists need: +- `removeClippedSubviews={false}` +- `overflow: "visible"` on containers +- Sufficient padding for focus scale animations + +### 8. Horizontal Padding +Use `TV_HORIZONTAL_PADDING` constant (60), not old `TV_SCALE_PADDING` (20). + +### 9. Focus Guide Navigation +For non-adjacent sections, use `TVFocusGuideView` with `destinations` prop. +Use `useState` for refs (not `useRef`) to trigger re-renders. + +## Review Process + +1. Read the file(s) to review +2. Check each rule above +3. Report violations with: + - Line number + - What's wrong + - How to fix it +4. If no violations, confirm the code follows TV patterns + +## Output Format + +``` +## TV Validation Results + +### ✓ Passes +- [List of rules that pass] + +### ✗ Violations +- **[Rule Name]** (line X): [Description] + Fix: [How to correct it] + +### Recommendations +- [Optional suggestions for improvement] +``` diff --git a/.claude/commands/reflect.md b/.claude/commands/reflect.md new file mode 100644 index 000000000..deedf8d4f --- /dev/null +++ b/.claude/commands/reflect.md @@ -0,0 +1,70 @@ +--- +description: Reflect on this session to extract and store learned facts about the codebase +--- + +Analyze the current conversation to extract useful facts that should be remembered for future sessions. Focus on: + +1. **Corrections**: Things the user corrected you about +2. **Clarifications**: Misunderstandings about how the codebase works +3. **Patterns**: Important conventions or patterns you learned +4. **Gotchas**: Surprising behaviors or edge cases discovered +5. **Locations**: Files or code that was hard to find + +## Instructions + +1. Read the Learned Facts Index section in `CLAUDE.md` and scan existing files in `.claude/learned-facts/` to understand what's already recorded +2. Review this conversation for learnings worth preserving +3. For each new fact: + - Create a new file in `.claude/learned-facts/[kebab-case-name].md` using the template below + - Append a new entry to the appropriate category in the **Learned Facts Index** section of `CLAUDE.md` +4. Skip facts that duplicate existing entries +5. If a new category is needed, add it to the index in `CLAUDE.md` + +## Fact File Template + +Create each file at `.claude/learned-facts/[kebab-case-name].md`: + +```markdown +# [Title] + +**Date**: YYYY-MM-DD +**Category**: navigation | tv | native-modules | state-management | ui +**Key files**: `relevant/paths.ts` + +## Detail + +[Full description of the fact, including context for why it matters] +``` + +## Index Entry Format + +Append to the appropriate category in the Learned Facts Index section of `CLAUDE.md`: + +``` +- `kebab-case-name` | Brief one-line summary of the fact +``` + +Categories: Navigation, UI/Headers, State/Data, Native Modules, TV Platform + +## Example + +File `.claude/learned-facts/state-management-pattern.md`: +```markdown +# State Management Pattern + +**Date**: 2025-01-09 +**Category**: state-management +**Key files**: `utils/atoms/` + +## Detail + +Use Jotai atoms for global state, NOT React Context. Atoms are defined in `utils/atoms/`. +``` + +Index entry in `CLAUDE.md`: +``` +State/Data: +- `state-management-pattern` | Use Jotai atoms for global state, not React Context +``` + +After updating, summarize what facts you added (or note if nothing new was learned this session). diff --git a/.claude/learned-facts.md b/.claude/learned-facts.md new file mode 100644 index 000000000..ab5d6eecc --- /dev/null +++ b/.claude/learned-facts.md @@ -0,0 +1,48 @@ +# Learned Facts (DEPRECATED) + +> **DEPRECATED**: This file has been replaced by individual fact files in `.claude/learned-facts/`. +> The compressed index is now inline in `CLAUDE.md` under "Learned Facts Index". +> New facts should be added as individual files using the `/reflect` command. +> This file is kept for reference only and is no longer auto-imported. + +This file previously contained facts about the codebase learned from past sessions. + +## Facts + + + +- **Native bottom tabs + useRouter conflict**: When using `@bottom-tabs/react-navigation` with Expo Router, avoid using the `useRouter()` hook in components rendered at the provider level (outside the tab navigator). The hook subscribes to navigation state changes and can cause unexpected tab switches. Use the static `router` import from `expo-router` instead. _(2025-01-09)_ + +- **IntroSheet rendering location**: The `IntroSheet` component is rendered inside `IntroSheetProvider` which wraps the entire navigation stack. Any hooks in IntroSheet that interact with navigation state can affect the native bottom tabs. _(2025-01-09)_ + +- **Intro modal trigger location**: The intro modal trigger logic should be in the `Home.tsx` component, not in the tabs `_layout.tsx`. Triggering modals from tab layout can interfere with native bottom tabs navigation. _(2025-01-09)_ + +- **Tab folder naming**: The tab folders use underscore prefix naming like `(_home)` instead of just `(home)` based on the project's file structure conventions. _(2025-01-09)_ + +- **macOS header buttons fix**: Header buttons (`headerRight`/`headerLeft`) don't respond to touches on macOS Catalyst builds when using standard React Native `TouchableOpacity`. Fix by using `Pressable` from `react-native-gesture-handler` instead. The library is already installed and `GestureHandlerRootView` wraps the app. _(2026-01-10)_ + +- **Header button locations**: Header buttons are defined in multiple places: `app/(auth)/(tabs)/(home)/_layout.tsx` (SettingsButton, SessionsButton, back buttons), `components/common/HeaderBackButton.tsx` (reusable), `components/Chromecast.tsx`, `components/RoundButton.tsx`, and dynamically via `navigation.setOptions()` in `components/home/Home.tsx` and `app/(auth)/(tabs)/(home)/downloads/index.tsx`. _(2026-01-10)_ + +- **useNetworkAwareQueryClient limitations**: The `useNetworkAwareQueryClient` hook uses `Object.create(queryClient)` which breaks QueryClient methods that use JavaScript private fields (like `getQueriesData`, `setQueriesData`, `setQueryData`). Only use it when you ONLY need `invalidateQueries`. For cache manipulation, use standard `useQueryClient` from `@tanstack/react-query`. _(2026-01-10)_ + +- **Mark as played flow**: The "mark as played" button uses `PlayedStatus` component → `useMarkAsPlayed` hook → `usePlaybackManager.markItemPlayed()`. The hook does optimistic updates via `setQueriesData` before calling the API. Located in `components/PlayedStatus.tsx` and `hooks/useMarkAsPlayed.ts`. _(2026-01-10)_ + +- **Stack screen header configuration**: Sub-pages under `(home)` need explicit `Stack.Screen` entries in `app/(auth)/(tabs)/(home)/_layout.tsx` with `headerTransparent: Platform.OS === "ios"`, `headerBlurEffect: "none"`, and a back button. Without this, pages show with wrong header styling. _(2026-01-10)_ + +- **MPV tvOS player exit freeze**: On tvOS, `mpv_terminate_destroy` can deadlock if called while blocking the main thread (e.g., via `queue.sync`). The fix is to run `mpv_terminate_destroy` on `DispatchQueue.global()` asynchronously, allowing it to access main thread for AVFoundation/GPU cleanup. Send `quit` command and drain events first. Located in `modules/mpv-player/ios/MPVLayerRenderer.swift`. _(2026-01-22)_ + +- **MPV avfoundation-composite-osd ordering**: On tvOS, the `avfoundation-composite-osd` option MUST be set immediately after `vo=avfoundation`, before any `hwdec` options. Skipping or reordering this causes the app to freeze when exiting the player. Set to "no" on tvOS (prevents gray tint), "yes" on iOS (for PiP subtitle support). _(2026-01-22)_ + +- **Thread-safe state for stop flags**: When using flags like `isStopping` that control loop termination across threads, the setter must be synchronous (`stateQueue.sync`) not async, otherwise the value may not be visible to other threads in time. _(2026-01-22)_ + +- **TV modals must use navigation pattern**: On TV, never use overlay/absolute-positioned modals (like `TVOptionSelector` at the page level). They don't handle the back button correctly. Always use the navigation-based modal pattern: Jotai atom + hook that calls `router.push()` + page in `app/(auth)/`. Use the existing `useTVOptionModal` hook and `tv-option-modal.tsx` page for option selection. `TVOptionSelector` is only appropriate as a sub-selector *within* a navigation-based modal page. _(2026-01-24)_ + +- **TV grid layout pattern**: For TV grids, use ScrollView with flexWrap instead of FlatList/FlashList with numColumns. FlatList's numColumns divides width evenly among columns which causes inconsistent item sizing. Use `flexDirection: "row"`, `flexWrap: "wrap"`, `justifyContent: "center"`, and `gap` for spacing. _(2026-01-25)_ + +- **TV horizontal padding standard**: TV pages should use `TV_HORIZONTAL_PADDING = 60` to match other TV pages like Home, Search, etc. The old `TV_SCALE_PADDING = 20` was too small. _(2026-01-25)_ + +- **Native SwiftUI view sizing**: When creating Expo native modules with SwiftUI views, the view needs explicit dimensions. Use a `width` prop passed from React Native, set an explicit `.frame(width:height:)` in SwiftUI, and override `intrinsicContentSize` in the ExpoView wrapper to report the correct size to React Native's layout system. Using `.aspectRatio(contentMode: .fit)` alone causes inconsistent sizing. _(2026-01-25)_ + +- **Streamystats components location**: Streamystats TV components are at `components/home/StreamystatsRecommendations.tv.tsx` and `components/home/StreamystatsPromotedWatchlists.tv.tsx`. The watchlist detail page (which shows items in a grid) is at `app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx`. _(2026-01-25)_ + +- **Platform-specific file suffix (.tv.tsx) does NOT work**: The `.tv.tsx` file suffix does NOT work for either pages or components in this project. Metro bundler doesn't resolve platform-specific suffixes. Instead, use `Platform.isTV` conditional rendering within a single file. For pages: check `Platform.isTV` at the top and return the TV component early. For components: create separate `MyComponent.tsx` and `TVMyComponent.tsx` files and use `Platform.isTV` to choose which to render. _(2026-01-26)_ \ No newline at end of file diff --git a/.claude/learned-facts/header-button-locations.md b/.claude/learned-facts/header-button-locations.md new file mode 100644 index 000000000..269b51f15 --- /dev/null +++ b/.claude/learned-facts/header-button-locations.md @@ -0,0 +1,9 @@ +# Header Button Locations + +**Date**: 2026-01-10 +**Category**: ui +**Key files**: `app/(auth)/(tabs)/(home)/_layout.tsx`, `components/common/HeaderBackButton.tsx`, `components/Chromecast.tsx`, `components/RoundButton.tsx`, `components/home/Home.tsx`, `app/(auth)/(tabs)/(home)/downloads/index.tsx` + +## Detail + +Header buttons are defined in multiple places: `app/(auth)/(tabs)/(home)/_layout.tsx` (SettingsButton, SessionsButton, back buttons), `components/common/HeaderBackButton.tsx` (reusable), `components/Chromecast.tsx`, `components/RoundButton.tsx`, and dynamically via `navigation.setOptions()` in `components/home/Home.tsx` and `app/(auth)/(tabs)/(home)/downloads/index.tsx`. diff --git a/.claude/learned-facts/intro-modal-trigger-location.md b/.claude/learned-facts/intro-modal-trigger-location.md new file mode 100644 index 000000000..4409db06a --- /dev/null +++ b/.claude/learned-facts/intro-modal-trigger-location.md @@ -0,0 +1,9 @@ +# Intro Modal Trigger Location + +**Date**: 2025-01-09 +**Category**: navigation +**Key files**: `components/home/Home.tsx`, `app/(auth)/(tabs)/_layout.tsx` + +## Detail + +The intro modal trigger logic should be in the `Home.tsx` component, not in the tabs `_layout.tsx`. Triggering modals from tab layout can interfere with native bottom tabs navigation. diff --git a/.claude/learned-facts/introsheet-rendering-location.md b/.claude/learned-facts/introsheet-rendering-location.md new file mode 100644 index 000000000..b9575cd72 --- /dev/null +++ b/.claude/learned-facts/introsheet-rendering-location.md @@ -0,0 +1,9 @@ +# IntroSheet Rendering Location + +**Date**: 2025-01-09 +**Category**: navigation +**Key files**: `providers/IntroSheetProvider`, `components/IntroSheet` + +## Detail + +The `IntroSheet` component is rendered inside `IntroSheetProvider` which wraps the entire navigation stack. Any hooks in IntroSheet that interact with navigation state can affect the native bottom tabs. diff --git a/.claude/learned-facts/macos-header-buttons-fix.md b/.claude/learned-facts/macos-header-buttons-fix.md new file mode 100644 index 000000000..45d5f31a5 --- /dev/null +++ b/.claude/learned-facts/macos-header-buttons-fix.md @@ -0,0 +1,9 @@ +# macOS Header Buttons Fix + +**Date**: 2026-01-10 +**Category**: ui +**Key files**: `components/common/HeaderBackButton.tsx`, `app/(auth)/(tabs)/(home)/_layout.tsx` + +## Detail + +Header buttons (`headerRight`/`headerLeft`) don't respond to touches on macOS Catalyst builds when using standard React Native `TouchableOpacity`. Fix by using `Pressable` from `react-native-gesture-handler` instead. The library is already installed and `GestureHandlerRootView` wraps the app. diff --git a/.claude/learned-facts/mark-as-played-flow.md b/.claude/learned-facts/mark-as-played-flow.md new file mode 100644 index 000000000..48603cd0c --- /dev/null +++ b/.claude/learned-facts/mark-as-played-flow.md @@ -0,0 +1,9 @@ +# Mark as Played Flow + +**Date**: 2026-01-10 +**Category**: state-management +**Key files**: `components/PlayedStatus.tsx`, `hooks/useMarkAsPlayed.ts` + +## Detail + +The "mark as played" button uses `PlayedStatus` component → `useMarkAsPlayed` hook → `usePlaybackManager.markItemPlayed()`. The hook does optimistic updates via `setQueriesData` before calling the API. Located in `components/PlayedStatus.tsx` and `hooks/useMarkAsPlayed.ts`. diff --git a/.claude/learned-facts/mpv-avfoundation-composite-osd-ordering.md b/.claude/learned-facts/mpv-avfoundation-composite-osd-ordering.md new file mode 100644 index 000000000..418f862ae --- /dev/null +++ b/.claude/learned-facts/mpv-avfoundation-composite-osd-ordering.md @@ -0,0 +1,9 @@ +# MPV avfoundation-composite-osd Ordering + +**Date**: 2026-01-22 +**Category**: native-modules +**Key files**: `modules/mpv-player/ios/MPVLayerRenderer.swift` + +## Detail + +On tvOS, the `avfoundation-composite-osd` option MUST be set immediately after `vo=avfoundation`, before any `hwdec` options. Skipping or reordering this causes the app to freeze when exiting the player. Set to "no" on tvOS (prevents gray tint), "yes" on iOS (for PiP subtitle support). diff --git a/.claude/learned-facts/mpv-tvos-player-exit-freeze.md b/.claude/learned-facts/mpv-tvos-player-exit-freeze.md new file mode 100644 index 000000000..7dfb20178 --- /dev/null +++ b/.claude/learned-facts/mpv-tvos-player-exit-freeze.md @@ -0,0 +1,9 @@ +# MPV tvOS Player Exit Freeze + +**Date**: 2026-01-22 +**Category**: native-modules +**Key files**: `modules/mpv-player/ios/MPVLayerRenderer.swift` + +## Detail + +On tvOS, `mpv_terminate_destroy` can deadlock if called while blocking the main thread (e.g., via `queue.sync`). The fix is to run `mpv_terminate_destroy` on `DispatchQueue.global()` asynchronously, allowing it to access main thread for AVFoundation/GPU cleanup. Send `quit` command and drain events first. diff --git a/.claude/learned-facts/native-bottom-tabs-userouter-conflict.md b/.claude/learned-facts/native-bottom-tabs-userouter-conflict.md new file mode 100644 index 000000000..eda49ef05 --- /dev/null +++ b/.claude/learned-facts/native-bottom-tabs-userouter-conflict.md @@ -0,0 +1,9 @@ +# Native Bottom Tabs + useRouter Conflict + +**Date**: 2025-01-09 +**Category**: navigation +**Key files**: `providers/`, `app/_layout.tsx` + +## Detail + +When using `@bottom-tabs/react-navigation` with Expo Router, avoid using the `useRouter()` hook in components rendered at the provider level (outside the tab navigator). The hook subscribes to navigation state changes and can cause unexpected tab switches. Use the static `router` import from `expo-router` instead. diff --git a/.claude/learned-facts/native-swiftui-view-sizing.md b/.claude/learned-facts/native-swiftui-view-sizing.md new file mode 100644 index 000000000..f36a18374 --- /dev/null +++ b/.claude/learned-facts/native-swiftui-view-sizing.md @@ -0,0 +1,9 @@ +# Native SwiftUI View Sizing + +**Date**: 2026-01-25 +**Category**: native-modules +**Key files**: `modules/` + +## Detail + +When creating Expo native modules with SwiftUI views, the view needs explicit dimensions. Use a `width` prop passed from React Native, set an explicit `.frame(width:height:)` in SwiftUI, and override `intrinsicContentSize` in the ExpoView wrapper to report the correct size to React Native's layout system. Using `.aspectRatio(contentMode: .fit)` alone causes inconsistent sizing. diff --git a/.claude/learned-facts/platform-specific-file-suffix-does-not-work.md b/.claude/learned-facts/platform-specific-file-suffix-does-not-work.md new file mode 100644 index 000000000..d52dca9b9 --- /dev/null +++ b/.claude/learned-facts/platform-specific-file-suffix-does-not-work.md @@ -0,0 +1,9 @@ +# Platform-Specific File Suffix (.tv.tsx) Does NOT Work + +**Date**: 2026-01-26 +**Category**: tv +**Key files**: `app/`, `components/` + +## Detail + +The `.tv.tsx` file suffix does NOT work for either pages or components in this project. Metro bundler doesn't resolve platform-specific suffixes. Instead, use `Platform.isTV` conditional rendering within a single file. For pages: check `Platform.isTV` at the top and return the TV component early. For components: create separate `MyComponent.tsx` and `TVMyComponent.tsx` files and use `Platform.isTV` to choose which to render. diff --git a/.claude/learned-facts/stack-screen-header-configuration.md b/.claude/learned-facts/stack-screen-header-configuration.md new file mode 100644 index 000000000..24ca01fcd --- /dev/null +++ b/.claude/learned-facts/stack-screen-header-configuration.md @@ -0,0 +1,9 @@ +# Stack Screen Header Configuration + +**Date**: 2026-01-10 +**Category**: ui +**Key files**: `app/(auth)/(tabs)/(home)/_layout.tsx` + +## Detail + +Sub-pages under `(home)` need explicit `Stack.Screen` entries in `app/(auth)/(tabs)/(home)/_layout.tsx` with `headerTransparent: Platform.OS === "ios"`, `headerBlurEffect: "none"`, and a back button. Without this, pages show with wrong header styling. diff --git a/.claude/learned-facts/streamystats-components-location.md b/.claude/learned-facts/streamystats-components-location.md new file mode 100644 index 000000000..41652a528 --- /dev/null +++ b/.claude/learned-facts/streamystats-components-location.md @@ -0,0 +1,9 @@ +# Streamystats Components Location + +**Date**: 2026-01-25 +**Category**: tv +**Key files**: `components/home/StreamystatsRecommendations.tv.tsx`, `components/home/StreamystatsPromotedWatchlists.tv.tsx`, `app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx` + +## Detail + +Streamystats TV components are at `components/home/StreamystatsRecommendations.tv.tsx` and `components/home/StreamystatsPromotedWatchlists.tv.tsx`. The watchlist detail page (which shows items in a grid) is at `app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx`. diff --git a/.claude/learned-facts/tab-folder-naming.md b/.claude/learned-facts/tab-folder-naming.md new file mode 100644 index 000000000..7663a6099 --- /dev/null +++ b/.claude/learned-facts/tab-folder-naming.md @@ -0,0 +1,9 @@ +# Tab Folder Naming + +**Date**: 2025-01-09 +**Category**: navigation +**Key files**: `app/(auth)/(tabs)/` + +## Detail + +The tab folders use underscore prefix naming like `(_home)` instead of just `(home)` based on the project's file structure conventions. diff --git a/.claude/learned-facts/thread-safe-state-for-stop-flags.md b/.claude/learned-facts/thread-safe-state-for-stop-flags.md new file mode 100644 index 000000000..eaa0d84d0 --- /dev/null +++ b/.claude/learned-facts/thread-safe-state-for-stop-flags.md @@ -0,0 +1,9 @@ +# Thread-Safe State for Stop Flags + +**Date**: 2026-01-22 +**Category**: native-modules +**Key files**: `modules/mpv-player/ios/MPVLayerRenderer.swift` + +## Detail + +When using flags like `isStopping` that control loop termination across threads, the setter must be synchronous (`stateQueue.sync`) not async, otherwise the value may not be visible to other threads in time. diff --git a/.claude/learned-facts/tv-grid-layout-pattern.md b/.claude/learned-facts/tv-grid-layout-pattern.md new file mode 100644 index 000000000..6f9b234a2 --- /dev/null +++ b/.claude/learned-facts/tv-grid-layout-pattern.md @@ -0,0 +1,9 @@ +# TV Grid Layout Pattern + +**Date**: 2026-01-25 +**Category**: tv +**Key files**: `components/tv/` + +## Detail + +For TV grids, use ScrollView with flexWrap instead of FlatList/FlashList with numColumns. FlatList's numColumns divides width evenly among columns which causes inconsistent item sizing. Use `flexDirection: "row"`, `flexWrap: "wrap"`, `justifyContent: "center"`, and `gap` for spacing. diff --git a/.claude/learned-facts/tv-horizontal-padding-standard.md b/.claude/learned-facts/tv-horizontal-padding-standard.md new file mode 100644 index 000000000..e9ddc0c88 --- /dev/null +++ b/.claude/learned-facts/tv-horizontal-padding-standard.md @@ -0,0 +1,9 @@ +# TV Horizontal Padding Standard + +**Date**: 2026-01-25 +**Category**: tv +**Key files**: `components/tv/`, `app/(auth)/(tabs)/` + +## Detail + +TV pages should use `TV_HORIZONTAL_PADDING = 60` to match other TV pages like Home, Search, etc. The old `TV_SCALE_PADDING = 20` was too small. diff --git a/.claude/learned-facts/tv-modals-must-use-navigation-pattern.md b/.claude/learned-facts/tv-modals-must-use-navigation-pattern.md new file mode 100644 index 000000000..c6c837d5a --- /dev/null +++ b/.claude/learned-facts/tv-modals-must-use-navigation-pattern.md @@ -0,0 +1,9 @@ +# TV Modals Must Use Navigation Pattern + +**Date**: 2026-01-24 +**Category**: tv +**Key files**: `hooks/useTVOptionModal.ts`, `app/(auth)/tv-option-modal.tsx` + +## Detail + +On TV, never use overlay/absolute-positioned modals (like `TVOptionSelector` at the page level). They don't handle the back button correctly. Always use the navigation-based modal pattern: Jotai atom + hook that calls `router.push()` + page in `app/(auth)/`. Use the existing `useTVOptionModal` hook and `tv-option-modal.tsx` page for option selection. `TVOptionSelector` is only appropriate as a sub-selector *within* a navigation-based modal page. diff --git a/.claude/learned-facts/use-network-aware-query-client-limitations.md b/.claude/learned-facts/use-network-aware-query-client-limitations.md new file mode 100644 index 000000000..36e8f2d82 --- /dev/null +++ b/.claude/learned-facts/use-network-aware-query-client-limitations.md @@ -0,0 +1,9 @@ +# useNetworkAwareQueryClient Limitations + +**Date**: 2026-01-10 +**Category**: state-management +**Key files**: `hooks/useNetworkAwareQueryClient.ts` + +## Detail + +The `useNetworkAwareQueryClient` hook uses `Object.create(queryClient)` which breaks QueryClient methods that use JavaScript private fields (like `getQueriesData`, `setQueriesData`, `setQueryData`). Only use it when you ONLY need `invalidateQueries`. For cache manipulation, use standard `useQueryClient` from `@tanstack/react-query`. diff --git a/.eas/build/android-production-apk.yml b/.eas/build/android-production-apk.yml new file mode 100644 index 000000000..757192bb6 --- /dev/null +++ b/.eas/build/android-production-apk.yml @@ -0,0 +1,25 @@ +# Custom EAS Build config for Android phone APK (downloadable artifact). +# Same bun-forcing flow as android-production.yml, but builds an APK +# (assembleRelease) instead of an AAB — for sideloading / GitHub artifact. +# Referenced from eas.json: build.production-apk.android.config +build: + name: Android phone APK (bun) + steps: + - eas/checkout + + - run: + name: Install dependencies (bun, frozen) + command: bun install --frozen-lockfile + + - run: + name: Prebuild (Android, bun) + command: bunx expo prebuild --platform android --no-install + + - eas/configure_android_version + - eas/inject_android_credentials + + - eas/run_gradle: + inputs: + command: :app:assembleRelease + + - eas/find_and_upload_build_artifacts diff --git a/.eas/build/android-production-tv.yml b/.eas/build/android-production-tv.yml new file mode 100644 index 000000000..a3fc9effe --- /dev/null +++ b/.eas/build/android-production-tv.yml @@ -0,0 +1,27 @@ +# Custom EAS Build config for Android TV APK (downloadable artifact). +# Same bun-forcing flow, with EXPO_TV=1 (set via the profile env in +# eas.json) so prebuild generates the TV variant. Builds an APK for +# sideloading onto Android TV devices. +# Referenced from eas.json: build.production-apk-tv.android.config +build: + name: Android TV APK (bun) + steps: + - eas/checkout + + - run: + name: Install dependencies (bun, frozen) + command: bun install --frozen-lockfile + + # EXPO_TV=1 comes from the profile env, so prebuild targets Android TV. + - run: + name: Prebuild (Android TV, bun) + command: bunx expo prebuild --platform android --no-install + + - eas/configure_android_version + - eas/inject_android_credentials + + - eas/run_gradle: + inputs: + command: :app:assembleRelease + + - eas/find_and_upload_build_artifacts diff --git a/.eas/build/android-production.yml b/.eas/build/android-production.yml new file mode 100644 index 000000000..651ff2b66 --- /dev/null +++ b/.eas/build/android-production.yml @@ -0,0 +1,38 @@ +# Custom EAS Build config for Android (production AAB). +# +# Why this exists: EAS's managed build can't detect Bun's text lockfile +# (bun.lock) and falls back to yarn, which breaks our install. The managed +# steps `eas/install_node_modules` and `eas/prebuild` both use "the package +# manager detected based on your project", so we replace them with explicit +# `bun` commands. Everything else uses EAS's built-in functions so we still +# get remote versioning, credentials, and artifact upload. +# +# Referenced from eas.json: build.production.android.config = android-production.yml +build: + name: Android production (bun) + steps: + - eas/checkout + + - run: + name: Install dependencies (bun, frozen) + command: bun install --frozen-lockfile + + # android/ is gitignored, so generate native code fresh. --no-install + # because deps are already installed above; bunx keeps it on bun. + - run: + name: Prebuild (Android, bun) + command: bunx expo prebuild --platform android --no-install + + # Applies the EAS-resolved remote versionCode/versionName (autoIncrement + # in eas.json) into the freshly prebuilt android/ project. + - eas/configure_android_version + + # Injects the remote Android keystore / signing config. + - eas/inject_android_credentials + + # Build the Play Store app bundle (.aab). + - eas/run_gradle: + inputs: + command: :app:bundleRelease + + - eas/find_and_upload_build_artifacts diff --git a/.eas/build/ios-production.yml b/.eas/build/ios-production.yml new file mode 100644 index 000000000..f9e228f72 --- /dev/null +++ b/.eas/build/ios-production.yml @@ -0,0 +1,44 @@ +# Custom EAS Build config for iOS + tvOS (App Store), forcing bun. +# +# Shared by both the iPhone profile (production) and the tvOS profile +# (production_tv). The profile decides the rest: +# - production_tv sets EXPO_TV=1 (env) so prebuild targets tvOS, and +# credentialsSource: local (EAS can't manage tvOS creds remotely). +# - production uses remote-managed iOS credentials. +# +# Like the Android configs, this replaces eas/install_node_modules and +# eas/prebuild (both auto-detect the wrong package manager) with explicit +# bun commands, and keeps EAS built-ins for credentials/version/fastlane. +build: + name: iOS/tvOS App Store (bun) + steps: + - eas/checkout + + - run: + name: Install dependencies (bun, frozen) + command: bun install --frozen-lockfile + + - eas/resolve_apple_team_id_from_credentials: + id: resolve_team + + # android/ + ios/ are gitignored, so generate native code fresh. + # EXPO_TV (from the profile env) selects iPhone vs tvOS. --no-install + # skips JS + pod install; we install pods explicitly below with bun deps. + - run: + name: Prebuild (iOS/tvOS, bun) + command: bunx expo prebuild --platform ios --no-install + + - run: + name: Install CocoaPods + working_directory: ./ios + command: pod install + + - eas/configure_ios_credentials + - eas/configure_ios_version + + - eas/generate_gymfile_from_template: + inputs: + credentials: ${ eas.job.secrets.buildCredentials } + + - eas/run_fastlane + - eas/find_and_upload_build_artifacts diff --git a/.gitattributes b/.gitattributes index 56dea9663..4d651aeb4 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,28 @@ -.modules/vlc-player/Frameworks/*.xcframework filter=lfs diff=lfs merge=lfs -text +# Normalise line endings to LF for everyone. Files are stored as LF in git and +# checked out as LF on every OS, so Windows clones stop producing CRLF churn +# (no more "LF will be replaced by CRLF" warnings) regardless of core.autocrlf. +* text=auto eol=lf + +# Windows-only scripts must stay CRLF +*.bat text eol=crlf +*.cmd text eol=crlf + +# Binary assets — never touched / never normalised +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.webp binary +*.ico binary +*.icns binary +*.ttf binary +*.otf binary +*.woff binary +*.woff2 binary +*.mp3 binary +*.mp4 binary +*.mov binary +*.pdf binary +*.keystore binary +*.jks binary +*.p12 binary diff --git a/.github/ISSUE_TEMPLATE/issue_report.yml b/.github/ISSUE_TEMPLATE/issue_report.yml index 29904bd3c..365afca23 100644 --- a/.github/ISSUE_TEMPLATE/issue_report.yml +++ b/.github/ISSUE_TEMPLATE/issue_report.yml @@ -1,5 +1,5 @@ name: "🐛 Bug Report" -description: Create a report to help us improve +description: Create a report to help Streamyfin improve title: "[Bug]: " labels: - "🐛 bug" @@ -36,7 +36,7 @@ body: attributes: label: What happened? description: A clear and concise description of what the bug is. - placeholder: Describe what happened in detail. + placeholder: Describe what happened in detail, the more precise the better. validations: required: true @@ -67,7 +67,7 @@ body: attributes: label: Which device and operating system are you using? description: Please provide your device model and OS version - placeholder: e.g. iPhone 15 Pro, iOS 18.1.1 or Samsung Galaxy S24, Android 14 + placeholder: e.g. iPhone 17 Pro / iOS 26.5.1, Samsung Galaxy S25 / Android 16, Apple TV / tvOS 26.5 validations: required: true @@ -75,16 +75,14 @@ body: id: version attributes: label: Streamyfin Version - description: What version of Streamyfin are you running? + description: What version of Streamyfin are you running? On a TestFlight or development build, choose "TestFlight/Development build" and include the exact version string shown in the app's Settings. options: + - 0.54.1 + - 0.51.0 + - 0.47.1 - 0.30.2 - - 0.29.0 - 0.28.0 - - 0.27.0 - - 0.26.1 - - 0.26.0 - - 0.25.0 - - older + - Older - TestFlight/Development build validations: required: true @@ -95,9 +93,9 @@ body: label: Jellyfin Server Information description: Please provide details about your Jellyfin server placeholder: | - - Jellyfin Server Version: e.g. 10.10.7 - - Server OS: e.g. Ubuntu 22.04, Windows 11, Docker - - Connection: e.g. Local network, Remote via domain, VPN + - Jellyfin Server Version: e.g. 10.11.10 + - Server OS: e.g. Ubuntu 26.04, Windows 11, Docker, Proxmox + - Connection: e.g. Local network, remote via domain, VPN - type: textarea id: screenshots @@ -109,11 +107,11 @@ body: id: logs attributes: label: Relevant logs (if available) - description: If you have access to app logs or crash reports, please include them here. **Remember to remove any personal information like server URLs or usernames.** + description: If you have access to app logs or crash reports, please include them here. **Remember to remove any personal information like server URL, API keys or usernames.** render: shell - type: textarea id: additional-info attributes: label: Additional information - description: Any additional context that might help us understand and reproduce the issue. \ No newline at end of file + description: Any additional context that might help us understand and reproduce the issue. diff --git a/.github/actions/refresh-pr-comment/action.yml b/.github/actions/refresh-pr-comment/action.yml new file mode 100644 index 000000000..3149ea420 --- /dev/null +++ b/.github/actions/refresh-pr-comment/action.yml @@ -0,0 +1,21 @@ +name: Refresh PR build comment +description: >- + Nudge artifact-comment.yml (via workflow_dispatch) so the PR build-status + comment reflects live per-platform progress as each build job finishes. + +runs: + using: composite + steps: + # workflow_dispatch fires even when triggered by the GITHUB_TOKEN, and + # artifact-comment's concurrency group collapses simultaneous nudges, so + # this can't spam the comment. Skipped on forks (their read-only token + # cannot dispatch). github.token is used because composite actions cannot + # read the secrets context. + - if: always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository + continue-on-error: true + shell: bash + env: + GH_TOKEN: ${{ github.token }} + HEAD_REF: ${{ github.head_ref }} + REPO: ${{ github.repository }} + run: gh workflow run artifact-comment.yml --ref "$HEAD_REF" -R "$REPO" diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 2111dd2e6..d3b750c28 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -42,7 +42,7 @@ and provides seamless media streaming with offline capabilities and Chromecast s ## Coding Standards -- Use TypeScript for ALL files (no .js files) +- Use TypeScript for ALL files (no .js files). Tooling-required exceptions: `babel.config.js`, `metro.config.js`, `react-native.config.js`, `tailwind.config.js` (their loaders cannot parse TypeScript) - Use descriptive English names for variables, functions, and components - Prefer functional React components with hooks - Use Jotai atoms for global state management diff --git a/.github/crowdin.yml b/.github/crowdin.yml deleted file mode 100644 index 4acc32089..000000000 --- a/.github/crowdin.yml +++ /dev/null @@ -1,12 +0,0 @@ -"project_id_env": "CROWDIN_PROJECT_ID" -"api_token_env": "CROWDIN_PERSONAL_TOKEN" -"base_path": "." - -"preserve_hierarchy": true - -"files": [ - { - "source": "translations/en.json", - "translation": "translations/%two_letters_code%.json" - } -] \ No newline at end of file diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index e76ebb70d..95978bab3 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,91 +1,54 @@ - # 📦 Pull Request -## 🔖 Summary + + + +## 📝 Description ## 🏷️ Ticket / Issue -## 🛠️ What’s Changed - - -- Type: feat | fix | docs | style | refactor | perf | test | chore | build | ci | revert -- Scope (optional): e.g., auth, billing, mobile -- Short summary: what changed and why (1–2 lines) ---> - -## 📋 Details - - -### ⚠️ Breaking Changes - - -### 🔐 Security & Privacy Impact - - -### ⚡ Performance Impact - - ### 🖼️ Screenshots / GIFs (if UI) - + ## ✅ Checklist - [ ] I’ve read the [contribution guidelines](CONTRIBUTING.md) -- [ ] Code follows project style and passes lint/format (`npm|pnpm|yarn|bun` scripts) -- [ ] Type checks pass (tsc/biome/etc.) -- [ ] Docs updated (README/ADR/usage/API) -- [ ] No secrets/credentials included; env vars documented -- [ ] Release notes/CHANGELOG entry added (if applicable) -- [ ] Verified locally that changes behave as expected +- [ ] Verified that changes behave as expected for all platforms +- [ ] Code passes lint/formatting and type checks (`tsc`/`biome`) +- [ ] No secrets, hardcoded credentials, or private config files are included +- [ ] I've declared if AI was used to assist with this PR (by uncommenting the line at the bottom, or not) ## 🔍 Testing Instructions -## ⚙️ Deployment Notes - - -## 📝 Additional Notes - \ No newline at end of file diff --git a/.github/renovate.json b/.github/renovate.json index 364842311..21c5b931b 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -25,22 +25,69 @@ "osvVulnerabilityAlerts": true, "configMigration": true, "separateMinorPatch": true, - "lockFileMaintenance": { - "vulnerabilityAlerts": { - "enabled": true, - "addLabels": ["security", "vulnerability"], - "assigneesFromCodeOwners": true, - "commitMessageSuffix": " [SECURITY]" + "customManagers": [ + { + "customType": "regex", + "managerFilePatterns": ["/\\.ya?ml$/"], + "matchStrings": [ + "# renovate: datasource=(?\\S+) depName=(?\\S+)(?: versioning=(?\\S+))?\\s+[A-Za-z0-9._-]+:\\s*[\"']?(?[^\"'\\s]+)" + ], + "versioningTemplate": "{{#if versioning}}{{{versioning}}}{{else}}loose{{/if}}" }, - "packageRules": [ - { - "description": "Group minor and patch GitHub Action updates into a single PR", - "matchManagers": ["github-actions"], - "groupName": "CI dependencies", - "groupSlug": "ci-deps", - "matchUpdateTypes": ["minor", "patch", "digest", "pin"], - "automerge": true - } - ] - } + { + "customType": "regex", + "description": "Track the Bun version pinned in eas.json build profiles (strict JSON can't hold inline annotations)", + "managerFilePatterns": ["/(^|/)eas\\.json$/"], + "matchStrings": ["\"bun\"\\s*:\\s*\"(?[^\"]+)\""], + "datasourceTemplate": "npm", + "depNameTemplate": "bun" + } + ], + "customDatasources": { + "xcode": { + "defaultRegistryUrlTemplate": "https://xcodereleases.com/data.json", + "format": "json", + "transformTemplates": [ + "{ \"releases\": [$[version.release.release=true].{\"version\": version.number}] }" + ] + } + }, + "vulnerabilityAlerts": { + "enabled": true, + "addLabels": ["security", "vulnerability"], + "assigneesFromCodeOwners": true, + "commitMessageSuffix": " [SECURITY]" + }, + "packageRules": [ + { + "description": "Expo SDK coherence: expo, react, react-native and Expo-managed modules are pinned by the Expo SDK and must move together (via `expo install --fix`), so do not raise individual update PRs — group them and require manual approval from the Dependency Dashboard", + "matchPackageNames": [ + "expo", + "react", + "react-dom", + "react-native", + "react-native-web", + "expo-*", + "@expo/*" + ], + "groupName": "Expo SDK", + "dependencyDashboardApproval": true + }, + { + "description": "Group minor and patch GitHub Action updates into a single PR", + "matchManagers": ["github-actions"], + "groupName": "CI dependencies", + "groupSlug": "ci-deps", + "matchUpdateTypes": ["minor", "patch", "digest", "pin"], + "automerge": true + }, + { + "description": "androidx and other Google-hosted Maven packages resolve from Google's Maven repository (not Maven Central)", + "matchDatasources": ["maven"], + "registryUrls": [ + "https://dl.google.com/dl/android/maven2/", + "https://repo.maven.apache.org/maven2/" + ] + } + ] } diff --git a/.github/workflows/artifact-comment.yml b/.github/workflows/artifact-comment.yml index 8528a95bd..80c7119e1 100644 --- a/.github/workflows/artifact-comment.yml +++ b/.github/workflows/artifact-comment.yml @@ -18,7 +18,7 @@ jobs: comment-artifacts: if: github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request' || (github.event_name == 'workflow_run' && github.event.workflow_run.event == 'pull_request') name: 📦 Post Build Artifacts - runs-on: ubuntu-latest + runs-on: ubuntu-26.04 permissions: contents: read pull-requests: write @@ -26,7 +26,7 @@ jobs: steps: - name: 🔍 Get PR and Artifacts - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 with: script: | // Check if we're running from a fork (more precise detection) @@ -144,7 +144,7 @@ jobs: ) .sort((a, b) => new Date(b.created_at) - new Date(a.created_at)); - console.log(`Found ${buildRuns.length} non-cancelled build workflow runs for this commit`); + console.log(`Found ${buildRuns.length} build workflow runs for this commit`); // Log current status of each build for debugging buildRuns.forEach(run => { @@ -184,6 +184,31 @@ jobs: const latestAndroidRun = findBestRun('Android APK Build'); const latestIOSRun = findBestRun('iOS IPA Build'); + // Map our build targets to their job display names. Exact name is + // tried first so a signed target never collides with its + // "(Unsigned)" sibling (whose name contains the signed name). + const jobMappings = { + 'Android Phone': ['🤖 Build Android APK (Phone)'], + 'Android TV': ['🤖 Build Android APK (TV)'], + 'iOS': ['🍎 Build iOS IPA (Phone)'], + 'iOS Unsigned': ['🍎 Build iOS IPA (Phone - Unsigned)'], + 'tvOS': ['🍎 Build tvOS IPA'], + 'tvOS Unsigned': ['🍎 Build tvOS IPA (Unsigned)'] + }; + + // Prefer an exact name match over a substring match so + // '...(Phone)' doesn't swallow '...(Phone - Unsigned)'. + const findJobForTarget = (jobs, jobNames) => + jobs.find(j => jobNames.some(name => j.name === name)) || + jobs.find(j => jobNames.some(name => j.name.includes(name))); + + // Format a millisecond duration as "Xm Ys". + const fmtDuration = (ms) => { + const min = Math.floor(ms / 60000); + const sec = Math.floor((ms % 60000) / 1000); + return `${min}m ${sec}s`; + }; + // For the consolidated workflow, get individual job statuses if (latestAppsRun) { console.log(`Getting individual job statuses for run ${latestAppsRun.id} (status: ${latestAppsRun.status}, conclusion: ${latestAppsRun.conclusion || 'none'})`); @@ -216,19 +241,10 @@ jobs: return; // Exit early } - // Map job names to our build targets - const jobMappings = { - 'Android Phone': ['🤖 Build Android APK (Phone)', 'build-android-phone'], - 'Android TV': ['🤖 Build Android APK (TV)', 'build-android-tv'], - 'iOS Phone': ['🍎 Build iOS IPA (Phone)', 'build-ios-phone'] - }; - // Create individual status for each job for (const [platform, jobNames] of Object.entries(jobMappings)) { - const job = jobs.jobs.find(j => - jobNames.some(name => j.name.includes(name) || j.name === name) - ); - + const job = findJobForTarget(jobs.jobs, jobNames); + if (job) { buildStatuses[platform] = { name: job.name, @@ -236,7 +252,9 @@ jobs: conclusion: job.conclusion, url: job.html_url, runId: latestAppsRun.id, - created_at: job.started_at || latestAppsRun.created_at + created_at: job.started_at || latestAppsRun.created_at, + started_at: job.started_at, + completed_at: job.completed_at }; console.log(`Mapped ${platform} to job: ${job.name} (${job.status}/${job.conclusion || 'none'})`); } else { @@ -247,22 +265,30 @@ jobs: conclusion: latestAppsRun.conclusion, url: latestAppsRun.html_url, runId: latestAppsRun.id, - created_at: latestAppsRun.created_at + created_at: latestAppsRun.created_at, + started_at: latestAppsRun.run_started_at, + completed_at: latestAppsRun.updated_at }; } } } catch (error) { console.log(`Failed to get jobs for run ${latestAppsRun.id}:`, error.message); - // Fallback to workflow-level status - buildStatuses['Android Phone'] = buildStatuses['Android TV'] = buildStatuses['iOS Phone'] = { + // Fallback to workflow-level status for every build target. + // Keys must match jobMappings / buildTargets statusKey values. + const fallbackStatus = { name: latestAppsRun.name, status: latestAppsRun.status, conclusion: latestAppsRun.conclusion, url: latestAppsRun.html_url, runId: latestAppsRun.id, - created_at: latestAppsRun.created_at + created_at: latestAppsRun.created_at, + started_at: latestAppsRun.run_started_at, + completed_at: latestAppsRun.updated_at }; + for (const platform of Object.keys(jobMappings)) { + buildStatuses[platform] = fallbackStatus; + } } // Collect artifacts if any job has completed successfully @@ -344,6 +370,43 @@ jobs: console.log(`- Artifact: ${artifact.name} (from run ${artifact.workflow_run.id})`); }); + // Pull per-job durations from the latest successful develop build so + // in-progress / queued targets can show a realistic ETA instead of + // an open-ended spinner. Best-effort: any failure just drops the ETA. + let referenceDurations = {}; + try { + const { data: devRuns } = await github.rest.actions.listWorkflowRuns({ + owner: context.repo.owner, + repo: context.repo.repo, + workflow_id: 'build-apps.yml', + branch: 'develop', + status: 'success', + per_page: 1 + }); + + if (devRuns.workflow_runs.length > 0) { + const refRun = devRuns.workflow_runs[0]; + const { data: refJobs } = await github.rest.actions.listJobsForWorkflowRun({ + owner: context.repo.owner, + repo: context.repo.repo, + run_id: refRun.id + }); + + for (const [platform, jobNames] of Object.entries(jobMappings)) { + const job = findJobForTarget(refJobs.jobs, jobNames); + if (job && job.conclusion === 'success' && job.started_at && job.completed_at) { + referenceDurations[platform] = new Date(job.completed_at) - new Date(job.started_at); + } + } + console.log(`Reference durations from develop run ${refRun.id}:`, + Object.fromEntries(Object.entries(referenceDurations).map(([k, v]) => [k, fmtDuration(v)]))); + } else { + console.log('No successful develop build found for ETA reference'); + } + } catch (error) { + console.log('Failed to fetch develop reference durations:', error.message); + } + // Build comment body with progressive status for individual builds let commentBody = `## 🔧 Build Status for PR #${pr.number}\n\n`; commentBody += `🔗 **Commit**: [\`${targetCommitSha.substring(0, 7)}\`](https://github.com/${context.repo.owner}/${context.repo.repo}/commit/${targetCommitSha})\n\n`; // Progressive build status and downloads table @@ -353,10 +416,12 @@ jobs: // Process each expected build target individually const buildTargets = [ - { name: 'Android Phone', platform: '🤖', device: '📱', statusKey: 'Android Phone', artifactPattern: /android.*phone/i }, - { name: 'Android TV', platform: '🤖', device: '📺', statusKey: 'Android TV', artifactPattern: /android.*tv/i }, - { name: 'iOS Phone', platform: '🍎', device: '📱', statusKey: 'iOS Phone', artifactPattern: /ios.*phone/i }, - { name: 'iOS TV', platform: '🍎', device: '📺', statusKey: 'iOS TV', artifactPattern: /ios.*tv/i } + { name: 'Android Phone', platform: '🤖', device: '📱 Phone', statusKey: 'Android Phone', artifactPattern: /android.*phone/i }, + { name: 'Android TV', platform: '🤖', device: '📺 TV', statusKey: 'Android TV', artifactPattern: /android.*tv/i }, + { name: 'iOS', platform: '🍎', device: '📱 Phone', statusKey: 'iOS', artifactPattern: /^(?!.*unsigned).*ios.*phone.*ipa/i }, + { name: 'iOS Unsigned', platform: '🍎', device: '📱 Phone Unsigned', statusKey: 'iOS Unsigned', artifactPattern: /ios.*phone.*unsigned/i }, + { name: 'tvOS', platform: '🍎', device: '📺 TV', statusKey: 'tvOS', artifactPattern: /^(?!.*unsigned).*ios.*tv.*ipa/i }, + { name: 'tvOS Unsigned', platform: '🍎', device: '📺 TV Unsigned', statusKey: 'tvOS Unsigned', artifactPattern: /ios.*tv.*unsigned/i } ]; for (const target of buildTargets) { @@ -371,16 +436,30 @@ jobs: let status = '⏳ Pending'; let downloadLink = '*Waiting for build...*'; - // Special case for iOS TV - show as disabled - if (target.name === 'iOS TV') { + // Signed tvOS stays disabled until EAS has tvOS provisioning + // profiles (app + TopShelf targets); non-interactive builds can't + // create them. Unsigned tvOS builds, so it flows through normally. + if (target.name === 'tvOS') { status = '💤 Disabled'; - downloadLink = '*Disabled for now*'; + downloadLink = '*Disabled — signed tvOS needs EAS provisioning profiles*'; } else if (matchingStatus) { if (matchingStatus.conclusion === 'success' && matchingArtifact) { status = '✅ Complete'; const directLink = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${matchingArtifact.workflow_run.id}/artifacts/${matchingArtifact.id}`; const fileType = target.name.includes('Android') ? 'APK' : 'IPA'; - downloadLink = `[📥 Download ${fileType}](${directLink})`; + + // Format file size + const sizeInMB = (matchingArtifact.size_in_bytes / (1024 * 1024)).toFixed(1); + const sizeInfo = `(${sizeInMB} MB)`; + + // Calculate build duration + let durationInfo = ''; + if (matchingStatus.started_at && matchingStatus.completed_at) { + const durationMs = new Date(matchingStatus.completed_at) - new Date(matchingStatus.started_at); + durationInfo = ` - ${fmtDuration(durationMs)}`; + } + + downloadLink = `[📥 Download ${fileType}](${directLink}) ${sizeInfo}${durationInfo}`; } else if (matchingStatus.conclusion === 'failure') { status = `❌ [Failed](${matchingStatus.url})`; downloadLink = '*Build failed*'; @@ -389,10 +468,16 @@ jobs: downloadLink = '*Build cancelled*'; } else if (matchingStatus.status === 'in_progress') { status = `🔄 [Building...](${matchingStatus.url})`; - downloadLink = '*Build in progress...*'; + const ref = referenceDurations[target.statusKey]; + downloadLink = ref + ? `*Building… ~${fmtDuration(ref)} (avg on develop)*` + : '*Build in progress...*'; } else if (matchingStatus.status === 'queued') { status = `⏳ [Queued](${matchingStatus.url})`; - downloadLink = '*Waiting to start...*'; + const ref = referenceDurations[target.statusKey]; + downloadLink = ref + ? `*Waiting to start… ~${fmtDuration(ref)} once running (avg on develop)*` + : '*Waiting to start...*'; } else if (matchingStatus.status === 'completed' && !matchingStatus.conclusion) { // Workflow completed but conclusion not yet available (rare edge case) status = `🔄 [Finishing...](${matchingStatus.url})`; @@ -408,12 +493,27 @@ jobs: } } - commentBody += `| ${target.platform} ${target.name.split(' ')[0]} | ${target.device} ${target.name.split(' ')[1]} | ${status} | ${downloadLink} |\n`; + commentBody += `| ${target.platform} ${target.name} | ${target.device} | ${status} | ${downloadLink} |\n`; } commentBody += `\n`; - // Show installation instructions if we have any artifacts + // Static rundown of the build optimisations + what each artifact + // installs on. Always shown (even mid-build) so testers know what + // to expect before downloads are ready. + commentBody += `
\n`; + commentBody += `📦 Build details & device compatibility\n\n`; + commentBody += `These CI builds are trimmed for size and speed. What that means for installing them:\n\n`; + commentBody += `| Artifact | Architectures | Installs on |\n`; + commentBody += `|---|---|---|\n`; + commentBody += `| 🤖 Android Phone APK | \`arm64-v8a\` | Every 64-bit Android phone (all since ~2017). **Not** an x86_64 emulator or a 32-bit device. |\n`; + commentBody += `| 📺 Android TV APK | \`arm64-v8a\` + \`armeabi-v7a\` | Modern boxes **and** older / cheap 32-bit Android TV sticks. No x86_64. |\n`; + commentBody += `| 🍎 iOS / tvOS IPA | \`arm64\` | iPhone / Apple TV (all current devices). |\n\n`; + commentBody += `**Why no x86_64?** That slice only runs on Android emulators / Chromebooks, never a real phone or TV box — dropping it shrinks the APK and speeds up the build. Local \`bun run android\` is unaffected (it still builds x86_64 from \`app.json\`).\n\n`; + commentBody += `**Runners:** Android on \`ubuntu-26.04\`; iOS / tvOS on Apple Silicon (\`macos-26\`). The size/speed win comes from the ABI trim above, not the runner.\n`; + commentBody += `
\n\n`; + + // Installation instructions only matter once something is downloadable. if (allArtifacts.length > 0) { commentBody += `### 🔧 Installation Instructions\n\n`; commentBody += `- **Android APK**: Download and install directly on your device (enable "Install from unknown sources")\n`; diff --git a/.github/workflows/build-apps.yml b/.github/workflows/build-apps.yml index c0aae509e..0be3084f6 100644 --- a/.github/workflows/build-apps.yml +++ b/.github/workflows/build-apps.yml @@ -11,177 +11,263 @@ on: push: branches: [develop, master] +# Exposed to `expo prebuild` (app.config.ts → extra.build) so Settings can show the +# branch + commit + Actions run a CI build was made from. EAS cloud builds use +# EAS_BUILD_* instead. The run number maps a sideloaded build back to its Actions +# run (artifacts + logs) without needing Expo access. +env: + EXPO_PUBLIC_GIT_BRANCH: ${{ github.head_ref || github.ref_name }} + EXPO_PUBLIC_GIT_COMMIT: ${{ github.sha }} + EXPO_PUBLIC_GIT_RUN_NUMBER: ${{ github.run_number }} + jobs: build-android-phone: if: (!contains(github.event.head_commit.message, '[skip ci]')) - runs-on: ubuntu-24.04 + runs-on: ubuntu-26.04 name: 🤖 Build Android APK (Phone) permissions: contents: read + actions: write # dispatch artifact-comment.yml to refresh the PR comment steps: - - name: 📥 Checkout code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - with: - ref: ${{ github.event.pull_request.head.sha || github.sha }} - fetch-depth: 0 - submodules: recursive - show-progress: false + - parallel: + - name: 🗑️ Free Disk Space + uses: BRAINSia/free-disk-space@7048ffbf50819342ac964ef3998a51c2564a8a75 # v2.1.3 + with: + tool-cache: false + mandb: true + android: false + dotnet: true + haskell: true + large-packages: false + docker-images: true + swap-storage: false - - name: 🍞 Setup Bun - uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2 - with: - bun-version: latest + - name: 📥 Checkout code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + fetch-depth: 0 + submodules: recursive + show-progress: false - - name: 💾 Cache Bun dependencies - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 - with: - path: ~/.bun/install/cache - key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }} - restore-keys: | - ${{ runner.os }}-${{ runner.arch }}-bun-develop - ${{ runner.os }}-bun-develop + - name: 🍞 Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + # renovate: datasource=npm depName=bun + bun-version: "1.3.14" + + - name: ☕ Set up JDK 17 + # ubuntu-26.04 defaults to JDK 25, which breaks the RN/AGP native build + # (Kotlin falls back to JVM_23, the foojay toolchain + CMake configure + # fail). Pin Temurin 17 for a deterministic Android build. + uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0 + with: + distribution: temurin + java-version: "17" + + - parallel: + - name: 💾 Cache Bun dependencies + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 + with: + path: ~/.bun/install/cache + key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }} + restore-keys: | + ${{ runner.os }}-${{ runner.arch }}-bun- + + - name: 💾 Cache Gradle global + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 + with: + path: | + ~/.gradle/caches/modules-2 + ~/.gradle/wrapper + key: ${{ runner.os }}-${{ runner.arch }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-${{ runner.arch }}-gradle- - name: 📦 Install dependencies and reload submodules run: | bun install --frozen-lockfile bun run submodule-reload - - name: 💾 Cache Gradle global - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - restore-keys: | - ${{ runner.os }}-gradle- - - name: 🛠️ Generate project files run: bun run prebuild - name: 💾 Cache project Gradle (.gradle) - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: android/.gradle - key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }} - restore-keys: ${{ runner.os }}-android-gradle-develop + key: ${{ runner.os }}-${{ runner.arch }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }} + restore-keys: ${{ runner.os }}-${{ runner.arch }}-android-gradle-develop - name: 🚀 Build APK env: EXPO_TV: 0 + # CI artifact ships arm64 only (phones; emulators/Chromebooks not a + # sideload target). Overrides app.json buildArchs for this build only, + # so local `bun run android` (x86_64 emulator) is unaffected. + ORG_GRADLE_PROJECT_reactNativeArchitectures: arm64-v8a run: bun run build:android:local - name: 📅 Set date tag run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV - name: 📤 Upload APK artifact - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: streamyfin-android-phone-apk-${{ env.DATE_TAG }} path: | android/app/build/outputs/apk/release/*.apk retention-days: 7 + - name: 🔄 Refresh PR build comment + uses: ./.github/actions/refresh-pr-comment + build-android-tv: if: (!contains(github.event.head_commit.message, '[skip ci]')) - runs-on: ubuntu-24.04 + runs-on: ubuntu-26.04 name: 🤖 Build Android APK (TV) permissions: contents: read + actions: write # dispatch artifact-comment.yml to refresh the PR comment steps: - - name: 📥 Checkout code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - with: - ref: ${{ github.event.pull_request.head.sha || github.sha }} - fetch-depth: 0 - submodules: recursive - show-progress: false + - parallel: + - name: 🗑️ Free Disk Space + uses: BRAINSia/free-disk-space@7048ffbf50819342ac964ef3998a51c2564a8a75 # v2.1.3 + with: + tool-cache: false + mandb: true + android: false + dotnet: true + haskell: true + large-packages: false + docker-images: true + swap-storage: false - - name: 🍞 Setup Bun - uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2 - with: - bun-version: latest + - name: 📥 Checkout code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + fetch-depth: 0 + submodules: recursive + show-progress: false - - name: 💾 Cache Bun dependencies - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 - with: - path: ~/.bun/install/cache - key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }} - restore-keys: | - ${{ runner.os }}-${{ runner.arch }}-bun-develop - ${{ runner.os }}-bun-develop + - name: 🍞 Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + # renovate: datasource=npm depName=bun + bun-version: "1.3.14" + + - name: ☕ Set up JDK 17 + # ubuntu-26.04 defaults to JDK 25, which breaks the RN/AGP native build + # (Kotlin falls back to JVM_23, the foojay toolchain + CMake configure + # fail). Pin Temurin 17 for a deterministic Android build. + uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0 + with: + distribution: temurin + java-version: "17" + + - parallel: + - name: 💾 Cache Bun dependencies + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 + with: + path: ~/.bun/install/cache + key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }} + restore-keys: | + ${{ runner.os }}-${{ runner.arch }}-bun- + + - name: 💾 Cache Gradle global + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 + with: + path: | + ~/.gradle/caches/modules-2 + ~/.gradle/wrapper + key: ${{ runner.os }}-${{ runner.arch }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-${{ runner.arch }}-gradle- - name: 📦 Install dependencies and reload submodules run: | bun install --frozen-lockfile bun run submodule-reload - - name: 💾 Cache Gradle global - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - restore-keys: | - ${{ runner.os }}-gradle- - - name: 🛠️ Generate project files run: bun run prebuild:tv - name: 💾 Cache project Gradle (.gradle) - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: android/.gradle - key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }} - restore-keys: ${{ runner.os }}-android-gradle-develop + key: ${{ runner.os }}-${{ runner.arch }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }} + restore-keys: ${{ runner.os }}-${{ runner.arch }}-android-gradle-develop - name: 🚀 Build APK env: EXPO_TV: 1 + # TV artifact keeps armeabi-v7a too: many older/cheap Android TV boxes + # and sticks are still 32-bit ARM. Drops only x86_64. CI build only. + ORG_GRADLE_PROJECT_reactNativeArchitectures: arm64-v8a,armeabi-v7a run: bun run build:android:local - name: 📅 Set date tag run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV - name: 📤 Upload APK artifact - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: streamyfin-android-tv-apk-${{ env.DATE_TAG }} path: | android/app/build/outputs/apk/release/*.apk retention-days: 7 + - name: 🔄 Refresh PR build comment + uses: ./.github/actions/refresh-pr-comment + build-ios-phone: if: (!contains(github.event.head_commit.message, '[skip ci]') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin')) runs-on: macos-26 name: 🍎 Build iOS IPA (Phone) permissions: contents: read + actions: write # dispatch artifact-comment.yml to refresh the PR comment steps: - - name: 📥 Checkout code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - with: - ref: ${{ github.event.pull_request.head.sha || github.sha }} - fetch-depth: 0 - submodules: recursive - show-progress: false + - parallel: + - name: 📥 Checkout code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + fetch-depth: 0 + submodules: recursive + show-progress: false - - name: 🍞 Setup Bun - uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2 - with: - bun-version: latest + - name: 🍞 Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + # renovate: datasource=npm depName=bun + bun-version: "1.3.14" + + - name: 🔧 Setup Xcode + uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1 + with: + # renovate: datasource=custom.xcode depName=xcode versioning=loose + xcode-version: "26.6" + + - name: 🏗️ Setup EAS + uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main + with: + eas-version: latest + token: ${{ secrets.EXPO_TOKEN }} + eas-cache: true - name: 💾 Cache Bun dependencies - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: ~/.bun/install/cache - key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }} + key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }} restore-keys: | - ${{ runner.os }}-bun-cache + ${{ runner.os }}-${{ runner.arch }}-bun- - name: 📦 Install dependencies and reload submodules run: | @@ -191,100 +277,225 @@ jobs: - name: 🛠️ Generate project files run: bun run prebuild - - name: 🔧 Setup Xcode - uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1 - with: - xcode-version: "26.0.1" - - - name: 🏗️ Setup EAS - uses: expo/expo-github-action@main - with: - eas-version: latest - token: ${{ secrets.EXPO_TOKEN }} - eas-cache: true - - - name: ⚙️ Ensure iOS SDKs installed - run: xcodebuild -downloadPlatform iOS - - name: 🚀 Build iOS app env: EXPO_TV: 0 - run: eas build -p ios --local --non-interactive + # `ci` profile (extends production, autoIncrement off): keeps CI builds out of + # the production version tier and stops them inflating the store build counter. + run: eas build -p ios --local --non-interactive --profile ci - name: 📅 Set date tag run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV - name: 📤 Upload IPA artifact - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: streamyfin-ios-phone-ipa-${{ env.DATE_TAG }} path: build-*.ipa retention-days: 7 - # Disabled for now - uncomment when ready to build iOS TV - # build-ios-tv: - # if: (!contains(github.event.head_commit.message, '[skip ci]') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin')) - # runs-on: macos-26 - # name: 🍎 Build iOS IPA (TV) - # permissions: - # contents: read - # - # steps: - # - name: 📥 Checkout code - # uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - # with: - # ref: ${{ github.event.pull_request.head.sha || github.sha }} - # fetch-depth: 0 - # submodules: recursive - # show-progress: false - # - # - name: 🍞 Setup Bun - # uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2 - # with: - # bun-version: latest - # - # - name: 💾 Cache Bun dependencies - # uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 - # with: - # path: ~/.bun/install/cache - # key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }} - # restore-keys: | - # ${{ runner.os }}-bun-cache - # - # - name: 📦 Install dependencies and reload submodules - # run: | - # bun install --frozen-lockfile - # bun run submodule-reload - # - # - name: 🛠️ Generate project files - # run: bun run prebuild:tv - # - # - name: 🔧 Setup Xcode - # uses: maxim-lobanov/setup-xcode@v1 - # with: - # xcode-version: '26.0.1' - # - # - name: 🏗️ Setup EAS - # uses: expo/expo-github-action@main - # with: - # eas-version: latest - # token: ${{ secrets.EXPO_TOKEN }} - # eas-cache: true - # - # - name: ⚙️ Ensure tvOS SDKs installed - # run: xcodebuild -downloadPlatform tvOS - # - # - name: 🚀 Build iOS app - # env: - # EXPO_TV: 1 - # run: eas build -p ios --local --non-interactive - # - # - name: 📅 Set date tag - # run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV - # - # - name: 📤 Upload IPA artifact - # uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - # with: - # name: streamyfin-ios-tv-ipa-${{ env.DATE_TAG }} - # path: build-*.ipa - # retention-days: 7 + - name: 🔄 Refresh PR build comment + uses: ./.github/actions/refresh-pr-comment + + build-ios-phone-unsigned: + if: (!contains(github.event.head_commit.message, '[skip ci]')) + runs-on: macos-26 + name: 🍎 Build iOS IPA (Phone - Unsigned) + permissions: + contents: read + actions: write # dispatch artifact-comment.yml to refresh the PR comment + + steps: + - parallel: + - name: 📥 Checkout code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + fetch-depth: 0 + submodules: recursive + show-progress: false + + - name: 🍞 Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + # renovate: datasource=npm depName=bun + bun-version: "1.3.14" + + - name: 🔧 Setup Xcode + uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1 + with: + # renovate: datasource=custom.xcode depName=xcode versioning=loose + xcode-version: "26.6" + + - name: 💾 Cache Bun dependencies + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 + with: + path: ~/.bun/install/cache + key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }} + restore-keys: | + ${{ runner.os }}-${{ runner.arch }}-bun- + + - name: 📦 Install dependencies and reload submodules + run: | + bun install --frozen-lockfile + bun run submodule-reload + + - name: 🛠️ Generate project files + run: bun run prebuild + + - name: 🚀 Build iOS app + env: + EXPO_TV: 0 + run: bun run ios:unsigned-build ${{ github.event_name == 'pull_request' && '-- --verbose' || '' }} + + - name: 📅 Set date tag + run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV + + - name: 📤 Upload IPA artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: streamyfin-ios-phone-unsigned-ipa-${{ env.DATE_TAG }} + path: build/*.ipa + retention-days: 7 + + - name: 🔄 Refresh PR build comment + uses: ./.github/actions/refresh-pr-comment + + build-ios-tv: + # Disabled: EAS has no provisioning profiles / distribution cert for the tvOS + # targets (app + StreamyfinTopShelf extension), so non-interactive signed + # builds fail. Set up tvOS credentials in EAS (`eas credentials`), then remove + # the `false &&` prefix below. Unsigned tvOS builds run (see job below). + if: false && (!contains(github.event.head_commit.message, '[skip ci]') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin')) + runs-on: macos-26 + name: 🍎 Build tvOS IPA + permissions: + contents: read + actions: write # dispatch artifact-comment.yml to refresh the PR comment + + steps: + - parallel: + - name: 📥 Checkout code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + fetch-depth: 0 + submodules: recursive + show-progress: false + + - name: 🍞 Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + # renovate: datasource=npm depName=bun + bun-version: "1.3.14" + + - name: 🔧 Setup Xcode + uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1 + with: + # renovate: datasource=custom.xcode depName=xcode versioning=loose + xcode-version: "26.6" + + - name: 🏗️ Setup EAS + uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main + with: + eas-version: latest + token: ${{ secrets.EXPO_TOKEN }} + eas-cache: true + + - name: 💾 Cache Bun dependencies + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 + with: + path: ~/.bun/install/cache + key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }} + restore-keys: | + ${{ runner.os }}-${{ runner.arch }}-bun- + + - name: 📦 Install dependencies and reload submodules + run: | + bun install --frozen-lockfile + bun run submodule-reload + + - name: 🛠️ Generate project files + run: bun run prebuild:tv + + - name: 🚀 Build iOS app + env: + EXPO_TV: 1 + run: eas build -p ios --local --non-interactive --profile ci_tv + + - name: 📅 Set date tag + run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV + + - name: 📤 Upload IPA artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: streamyfin-ios-tv-ipa-${{ env.DATE_TAG }} + path: build-*.ipa + retention-days: 7 + + build-ios-tv-unsigned: + # Unsigned tvOS build is enabled (compiles without Apple credentials). + # The signed tvOS job above stays disabled until tvOS provisioning + # profiles are set up in EAS (app + TopShelf targets). + if: (!contains(github.event.head_commit.message, '[skip ci]')) + runs-on: macos-26 + name: 🍎 Build tvOS IPA (Unsigned) + permissions: + contents: read + actions: write # dispatch artifact-comment.yml to refresh the PR comment + + steps: + - parallel: + - name: 📥 Checkout code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + fetch-depth: 0 + submodules: recursive + show-progress: false + + - name: 🍞 Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + # renovate: datasource=npm depName=bun + bun-version: "1.3.14" + + - name: 🔧 Setup Xcode + uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1 + with: + # renovate: datasource=custom.xcode depName=xcode versioning=loose + xcode-version: "26.6" + + - name: 💾 Cache Bun dependencies + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 + with: + path: ~/.bun/install/cache + key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }} + restore-keys: | + ${{ runner.os }}-${{ runner.arch }}-bun- + + - name: 📦 Install dependencies and reload submodules + run: | + bun install --frozen-lockfile + bun run submodule-reload + + - name: 🛠️ Generate project files + run: bun run prebuild:tv + + - name: 🚀 Build iOS app + env: + EXPO_TV: 1 + run: bun run ios:unsigned-build:tv ${{ github.event_name == 'pull_request' && '-- --verbose' || '' }} + + - name: 📅 Set date tag + run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV + + - name: 📤 Upload IPA artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: streamyfin-ios-tv-unsigned-ipa-${{ env.DATE_TAG }} + path: build/*.ipa + retention-days: 7 + + - name: 🔄 Refresh PR build comment + uses: ./.github/actions/refresh-pr-comment diff --git a/.github/workflows/check-lockfile.yml b/.github/workflows/check-lockfile.yml index cc606b196..d06699306 100644 --- a/.github/workflows/check-lockfile.yml +++ b/.github/workflows/check-lockfile.yml @@ -13,30 +13,33 @@ concurrency: jobs: check-lockfile: name: 🔍 Check bun.lock and package.json consistency - runs-on: ubuntu-24.04 + runs-on: ubuntu-26.04 permissions: contents: read steps: - - name: 📥 Checkout repository - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - with: - ref: ${{ github.event.pull_request.head.sha || github.sha }} - show-progress: false - submodules: recursive - fetch-depth: 0 + - parallel: + - name: 📥 Checkout repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + show-progress: false + submodules: recursive - - name: 🍞 Setup Bun - uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2 - with: - bun-version: latest + - name: 🍞 Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + # renovate: datasource=npm depName=bun + bun-version: "1.3.14" - name: 💾 Cache Bun dependencies - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: | ~/.bun/install/cache - key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }} + key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }} + restore-keys: | + ${{ runner.os }}-${{ runner.arch }}-bun- - name: 🛡️ Verify lockfile consistency run: | diff --git a/.github/workflows/ci-codeql.yml b/.github/workflows/ci-codeql.yml index d1a442395..b9921780d 100644 --- a/.github/workflows/ci-codeql.yml +++ b/.github/workflows/ci-codeql.yml @@ -8,11 +8,14 @@ on: schedule: - cron: '24 2 * * *' +concurrency: + group: codeql-${{ github.ref }} + cancel-in-progress: true jobs: analyze: name: 🔎 Analyze with CodeQL - runs-on: ubuntu-24.04 + runs-on: ubuntu-26.04 permissions: contents: read security-events: write @@ -24,20 +27,16 @@ jobs: steps: - name: 📥 Checkout repository - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - with: - ref: ${{ github.event.pull_request.head.sha || github.sha }} - show-progress: false - fetch-depth: 0 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: 🏁 Initialize CodeQL - uses: github/codeql-action/init@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3 + uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 with: languages: ${{ matrix.language }} queries: +security-extended,security-and-quality - name: 🛠️ Autobuild - uses: github/codeql-action/autobuild@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3 + uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 - name: 🧪 Perform CodeQL Analysis - uses: github/codeql-action/analyze@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3 + uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 diff --git a/.github/workflows/conflict.yml b/.github/workflows/conflict.yml index 7793851c2..125ad7717 100644 --- a/.github/workflows/conflict.yml +++ b/.github/workflows/conflict.yml @@ -10,14 +10,14 @@ on: jobs: label: name: 🏷️ Labeling Merge Conflicts - runs-on: ubuntu-24.04 + runs-on: ubuntu-26.04 if: ${{ github.repository == 'streamyfin/streamyfin' }} permissions: contents: read pull-requests: write steps: - name: 🚩 Apply merge conflict label - uses: eps1lon/actions-label-merge-conflict@1df065ebe6e3310545d4f4c4e862e43bdca146f0 # v3.0.3 + uses: eps1lon/actions-label-merge-conflict@0273be72a0bbd58fcd71d0d6c02c209b50d1e5e1 # v3.1.0 with: dirtyLabel: '⚔️ merge-conflict' commentOnDirty: 'This pull request has merge conflicts. Please resolve the conflicts so the PR can be successfully reviewed and merged.' diff --git a/.github/workflows/crowdin.yml b/.github/workflows/crowdin.yml index a49669ad8..c14fe48fa 100644 --- a/.github/workflows/crowdin.yml +++ b/.github/workflows/crowdin.yml @@ -1,50 +1,51 @@ -name: 🌐 Translation Sync - -on: - push: - branches: [develop] - paths: - - "translations/**" - - "crowdin.yml" - - "i18n.ts" - - ".github/workflows/crowdin.yml" - # Run weekly to pull new translations - schedule: - - cron: "0 2 * * 1" # Every Monday at 2 AM UTC - workflow_dispatch: - -permissions: - contents: write - pull-requests: write - -jobs: - sync-translations: - runs-on: ubuntu-latest - - steps: - - name: 📥 Checkout Repository - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - with: - fetch-depth: 0 - - - name: 🌐 Sync Translations with Crowdin - uses: crowdin/github-action@08713f00a50548bfe39b37e8f44afb53e7a802d4 # v2.12.0 - with: - upload_sources: true - upload_translations: true - download_translations: true - localization_branch_name: I10n_crowdin_translations - create_pull_request: true - pull_request_title: "feat: New Crowdin Translations" - pull_request_body: "New Crowdin translations by [Crowdin GH Action](https://github.com/crowdin/github-action)" - pull_request_base_branch_name: "develop" - pull_request_labels: "🌐 translation" - # Quality control options - skip_untranslated_strings: true - export_only_approved: false - # Commit customization - commit_message: "feat(i18n): update translations from Crowdin" - env: - GITHUB_TOKEN: ${{ secrets.CROWDIN_GITHUB_TOKEN }} - CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }} - CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }} +name: 🌐 Translation Sync + +on: + push: + branches: [develop] + paths: + - "translations/**" + - "crowdin.yml" + - "i18n.ts" + - ".github/workflows/crowdin.yml" + # Run weekly to pull new translations + schedule: + - cron: "0 2 * * 1" # Every Monday at 2 AM UTC + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +jobs: + sync-translations: + runs-on: ubuntu-26.04 + + steps: + - name: 📥 Checkout Repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + + - name: 🌐 Sync Translations with Crowdin + uses: crowdin/github-action@52aa776766211d83d975df51f3b9c53c2f8ba35f # v2.16.3 + with: + upload_sources: true + upload_translations: true + download_translations: true + localization_branch_name: I10n_crowdin_translations + create_pull_request: true + pull_request_title: "feat: New Crowdin Translations" + pull_request_body: "New Crowdin translations by [Crowdin GH Action](https://github.com/crowdin/github-action)" + pull_request_base_branch_name: "develop" + pull_request_labels: "🌐 translation" + # Quality control options + skip_untranslated_strings: false + skip_untranslated_files: false + export_only_approved: false + # Commit customization + commit_message: "feat(i18n): update translations from Crowdin" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }} + CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }} diff --git a/.github/workflows/detect-duplicate.yml b/.github/workflows/detect-duplicate.yml new file mode 100644 index 000000000..a70b6ffb3 --- /dev/null +++ b/.github/workflows/detect-duplicate.yml @@ -0,0 +1,40 @@ +name: 🔁 Detect Duplicate Issues + +on: + issues: + types: [opened] + +permissions: + contents: read + +concurrency: + group: detect-duplicate-${{ github.event.issue.number }} + cancel-in-progress: true + +jobs: + detect: + name: 🔍 Find similar issues + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-26.04 + permissions: + issues: write + contents: read + steps: + - parallel: + - name: 📥 Checkout repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: 🍞 Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + # renovate: datasource=npm depName=bun + bun-version: "1.3.14" + + - name: 🔍 Detect duplicate issues + run: bun scripts/detect-duplicate-issue.ts + env: + GH_TOKEN: ${{ github.token }} + GITHUB_REPOSITORY: ${{ github.repository }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + ISSUE_TITLE: ${{ github.event.issue.title }} + ISSUE_BODY: ${{ github.event.issue.body }} diff --git a/.github/workflows/linting.yml b/.github/workflows/linting.yml index 550019587..992455862 100644 --- a/.github/workflows/linting.yml +++ b/.github/workflows/linting.yml @@ -2,7 +2,7 @@ name: 🚦 Security & Quality Gate on: pull_request: - types: [opened, edited, synchronize, reopened] + types: [opened, synchronize, reopened] branches: [develop, master] workflow_dispatch: push: @@ -11,53 +11,25 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: - validate_pr_title: - name: "📝 Validate PR Title" - if: github.event_name == 'pull_request' - runs-on: ubuntu-24.04 - permissions: - pull-requests: write - contents: read - steps: - - uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1 - id: lint_pr_title - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2.9.4 - if: always() && (steps.lint_pr_title.outputs.error_message != null) - with: - header: pr-title-lint-error - message: | - Hey there and thank you for opening this pull request! 👋🏼 - We require pull request titles to follow the [Conventional Commits specification](https://www.conventionalcommits.org/en/v1.0.0/). - - **Error details:** - ``` - ${{ steps.lint_pr_title.outputs.error_message }} - ``` - - - if: ${{ steps.lint_pr_title.outputs.error_message == null }} - uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2.9.4 - with: - header: pr-title-lint-error - delete: true - dependency-review: name: 🔍 Vulnerable Dependencies - runs-on: ubuntu-24.04 + runs-on: ubuntu-26.04 permissions: contents: read steps: - name: Checkout Repository - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ github.event.pull_request.head.sha || github.sha }} fetch-depth: 0 - name: Dependency Review - uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 # v4.8.2 + uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0 with: fail-on-severity: high base-ref: ${{ github.event.pull_request.base.sha || 'develop' }} @@ -65,30 +37,41 @@ jobs: expo-doctor: name: 🚑 Expo Doctor Check - if: false - runs-on: ubuntu-24.04 + runs-on: ubuntu-26.04 steps: - - name: 🛒 Checkout repository - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - with: - ref: ${{ github.event.pull_request.head.sha || github.sha }} - submodules: recursive - fetch-depth: 0 + - parallel: + - name: 🛒 Checkout repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + submodules: recursive - - name: 🍞 Setup Bun - uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2 + - name: 🍞 Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + # renovate: datasource=npm depName=bun + bun-version: "1.3.14" + + - name: 💾 Cache Bun dependencies + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: - bun-version: latest + path: ~/.bun/install/cache + key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }} + restore-keys: | + ${{ runner.os }}-${{ runner.arch }}-bun- - name: 📦 Install dependencies (bun) run: bun install --frozen-lockfile - name: 🚑 Run Expo Doctor + # Re-enabled but non-blocking: surfaces doctor warnings in the logs + # without failing the gate (some checks are known-noisy for this setup). + continue-on-error: true run: bun expo-doctor code_quality: name: "🔍 Lint & Test (${{ matrix.command }})" - runs-on: ubuntu-24.04 + runs-on: ubuntu-26.04 strategy: fail-fast: false matrix: @@ -97,24 +80,29 @@ jobs: - "check" - "format" - "typecheck" + - "i18n:check" steps: - - name: "📥 Checkout PR code" - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - with: - ref: ${{ github.event.pull_request.head.sha || github.sha }} - submodules: recursive - fetch-depth: 0 + - parallel: + - name: "📥 Checkout PR code" + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + submodules: recursive - - name: "🟢 Setup Node.js" - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 - with: - node-version: '24.x' + - name: "🍞 Setup Bun" + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + # renovate: datasource=npm depName=bun + bun-version: "1.3.14" - - name: "🍞 Setup Bun" - uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2 + - name: 💾 Cache Bun dependencies + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: - bun-version: latest + path: ~/.bun/install/cache + key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }} + restore-keys: | + ${{ runner.os }}-${{ runner.arch }}-bun- - name: "📦 Install dependencies" run: bun install --frozen-lockfile diff --git a/.github/workflows/notification.yml b/.github/workflows/notification.yml index 92bb4a5db..df9e4fa56 100644 --- a/.github/workflows/notification.yml +++ b/.github/workflows/notification.yml @@ -1,4 +1,5 @@ name: 🛎️ Discord Notification +permissions: {} on: pull_request: @@ -11,7 +12,7 @@ on: jobs: notify: - runs-on: ubuntu-24.04 + runs-on: ubuntu-26.04 if: github.event_name == 'pull_request' steps: - name: 🛎️ Notify Discord @@ -28,7 +29,7 @@ jobs: 🔗 ${{ github.event.pull_request.html_url }} notify-on-failure: - runs-on: ubuntu-24.04 + runs-on: ubuntu-26.04 if: github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'failure' steps: - name: 🚨 Notify Discord on Failure diff --git a/.github/workflows/pr-title-comment.yml b/.github/workflows/pr-title-comment.yml new file mode 100644 index 000000000..648eb1c44 --- /dev/null +++ b/.github/workflows/pr-title-comment.yml @@ -0,0 +1,81 @@ +name: 📝 PR Title Comment + +# Posts / updates / deletes the PR-title lint sticky comment with a write token, +# so it works on FORK PRs too (the pull_request-triggered pr-title.yml only has a +# read-only token on forks). workflow_run runs from the base branch with the base +# repo's token. This is comment-only — the required check stays pr-title.yml. +on: + workflow_run: + workflows: ["📝 PR Title"] + types: [completed] + +permissions: + contents: read + actions: read # download the artifact from the triggering run + pull-requests: write # post/update/delete the sticky comment + +concurrency: + group: pr-title-comment-${{ github.event.workflow_run.id }} + cancel-in-progress: true + +jobs: + comment: + # Only run when the lint run actually produced a result artifact: a run + # cancelled by concurrency (rapid pushes to the same PR) completes with + # conclusion "cancelled" before uploading it, and would fail the download. + if: > + github.event.workflow_run.event == 'pull_request' && + contains(fromJSON('["success", "failure"]'), github.event.workflow_run.conclusion) + runs-on: ubuntu-26.04 + steps: + - name: ⬇️ Download lint result + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh run download "${{ github.event.workflow_run.id }}" \ + --repo "${{ github.repository }}" \ + --name pr-title-result \ + --dir result + + - name: 🔎 Read result + id: result + run: | + echo "number=$(cat result/number)" >> "$GITHUB_OUTPUT" + if [ -s result/error ]; then + echo "has_error=true" >> "$GITHUB_OUTPUT" + else + echo "has_error=false" >> "$GITHUB_OUTPUT" + fi + + - name: 📥 Load error message + if: steps.result.outputs.has_error == 'true' + run: | + { + echo "LINT_ERROR<> "$GITHUB_ENV" + + - name: 💬 Post title error + if: steps.result.outputs.has_error == 'true' + uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4 + with: + number: ${{ steps.result.outputs.number }} + header: pr-title-lint-error + message: | + Hey there and thank you for opening this pull request! 👋🏼 + We require pull request titles to follow the [Conventional Commits specification](https://www.conventionalcommits.org/en/v1.0.0/). + + **Error details:** + ``` + ${{ env.LINT_ERROR }} + ``` + + - name: 🧹 Clear title error + if: steps.result.outputs.has_error == 'false' + uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4 + with: + number: ${{ steps.result.outputs.number }} + header: pr-title-lint-error + delete: true diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml new file mode 100644 index 000000000..68c503d67 --- /dev/null +++ b/.github/workflows/pr-title.yml @@ -0,0 +1,42 @@ +name: 📝 PR Title + +on: + pull_request: + types: [opened, edited, synchronize, reopened] + branches: [develop, master] + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + validate_pr_title: + name: "📝 Validate PR Title" + runs-on: ubuntu-26.04 + steps: + - uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1 + id: lint_pr_title + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # Fork PRs only get a read-only GITHUB_TOKEN here, so the sticky comment + # is posted from the privileged pr-title-comment.yml (workflow_run). Hand + # off the result (PR number + lint error) as an artifact for it to read. + - if: always() + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + LINT_ERROR: ${{ steps.lint_pr_title.outputs.error_message }} + run: | + mkdir -p pr-title-result + printf '%s' "$PR_NUMBER" > pr-title-result/number + printf '%s' "$LINT_ERROR" > pr-title-result/error + + - if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: pr-title-result + path: pr-title-result/ + retention-days: 1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..9860d6f53 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,221 @@ +name: 🚀 Release (EAS build + submit) + +# On merge to main (gated by the `production` GitHub Environment approval), +# build all targets on EAS in parallel via custom bun build configs: +# 1. iOS phone → App Store (auto-submit) +# 2. tvOS → App Store (auto-submit) +# 3. Android AAB → Google Play (auto-submit) +# 4. Android phone APK→ downloadable artifact +# 5. Android TV APK → downloadable artifact +# Note: EAS queues builds based on your plan's concurrency; parallel jobs +# here just submit them — EAS may still run them serially. + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + # Queue successive releases in order instead of dropping the extra pending run. + queue: max + +on: + push: + branches: [main] + workflow_dispatch: + +jobs: + approve: + name: 🔐 Approve release + runs-on: ubuntu-26.04 + environment: production + permissions: {} + steps: + - name: ✅ Release approved + run: echo "Release approved for ${{ github.sha }}" + + build: + name: 🚀 ${{ matrix.name }} + needs: approve + runs-on: ubuntu-26.04 + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: + - name: 🍎 iOS + platform: ios + profile: production + submit: true + - name: 📺 tvOS + platform: ios + profile: production_tv + submit: true + - name: 🤖 Android AAB + platform: android + profile: production + submit: true + - name: 🤖 Android APK + platform: android + profile: production-apk + submit: false + artifact_name: streamyfin-android-phone-apk + - name: 📺 Android TV APK + platform: android + profile: production-apk-tv + submit: false + artifact_name: streamyfin-android-tv-apk + + steps: + - parallel: + - name: 📥 Checkout code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + submodules: recursive + show-progress: false + + - name: 🍞 Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + # renovate: datasource=npm depName=bun + bun-version: "1.3.14" + + - name: 🏗️ Setup EAS + uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main + with: + eas-version: latest + token: ${{ secrets.EXPO_TOKEN }} + eas-cache: true + + - name: 💾 Cache Bun dependencies + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 + with: + path: ~/.bun/install/cache + key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }} + restore-keys: | + ${{ runner.os }}-${{ runner.arch }}-bun- + + - name: 📦 Install dependencies and reload submodules + run: | + bun install --frozen-lockfile + bun run submodule-reload + + # tvOS uses credentialsSource: local — restore the gitignored + # credentials.json + cert + provisioning profiles from secrets. + - name: 🔐 Restore tvOS signing credentials + if: matrix.profile == 'production_tv' + env: + EAS_CREDENTIALS_JSON: ${{ secrets.EAS_CREDENTIALS_JSON }} + TVOS_DIST_CERT_P12_BASE64: ${{ secrets.TVOS_DIST_CERT_P12_BASE64 }} + TVOS_APP_PROFILE_BASE64: ${{ secrets.TVOS_APP_PROFILE_BASE64 }} + TVOS_TOPSHELF_PROFILE_BASE64: ${{ secrets.TVOS_TOPSHELF_PROFILE_BASE64 }} + run: | + mkdir -p certs profiles + printf '%s' "$EAS_CREDENTIALS_JSON" > credentials.json + echo "$TVOS_DIST_CERT_P12_BASE64" | base64 -d > certs/distribution.p12 + echo "$TVOS_APP_PROFILE_BASE64" | base64 -d > profiles/Streamyfin_tvOS_App_Store.mobileprovision + echo "$TVOS_TOPSHELF_PROFILE_BASE64" | base64 -d > profiles/Streamyfin_TopShelf_tvOS_App_Store.mobileprovision + + # Android Play submit needs the Google Play service account JSON. + - name: 🔐 Restore Google Play service account + if: matrix.platform == 'android' && matrix.submit + env: + GOOGLE_SERVICE_ACCOUNT_KEY: ${{ secrets.GOOGLE_SERVICE_ACCOUNT_KEY }} + run: printf '%s' "$GOOGLE_SERVICE_ACCOUNT_KEY" > google-service-account.json + + # App Store Connect API key for iOS/tvOS submit (raw-PEM or base64). + - name: 🔐 Restore App Store Connect API key + if: matrix.platform == 'ios' + env: + APPLE_KEY_CONTENT: ${{ secrets.APPLE_KEY_CONTENT }} + run: | + if printf '%s' "$APPLE_KEY_CONTENT" | grep -q "BEGIN PRIVATE KEY"; then + printf '%s' "$APPLE_KEY_CONTENT" > "$RUNNER_TEMP/asc_api_key.p8" + else + printf '%s' "$APPLE_KEY_CONTENT" | base64 -d > "$RUNNER_TEMP/asc_api_key.p8" + fi + + # ── Submit builds: cloud build + auto-submit to the store ── + - name: 🚀 Build & submit (${{ matrix.name }}) + if: matrix.submit + env: + EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + EXPO_ASC_API_KEY_PATH: ${{ runner.temp }}/asc_api_key.p8 + EXPO_ASC_KEY_ID: ${{ secrets.APPLE_KEY_ID }} + EXPO_ASC_ISSUER_ID: ${{ secrets.APPLE_KEY_ISSUER_ID }} + run: | + eas build \ + --platform ${{ matrix.platform }} \ + --profile ${{ matrix.profile }} \ + --auto-submit \ + --non-interactive \ + --wait + + # ── Artifact builds: cloud build, then download + upload the APK ── + - name: 🏗️ Build artifact (${{ matrix.name }}) + if: ${{ !matrix.submit }} + env: + EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + run: | + eas build \ + --platform ${{ matrix.platform }} \ + --profile ${{ matrix.profile }} \ + --non-interactive \ + --wait \ + --json > build-result.json + URL=$(node -e "const b=require('./build-result.json'); const x=Array.isArray(b)?b[0]:b; console.log(x.artifacts.applicationArchiveUrl)") + echo "Downloading artifact: $URL" + curl -fL "$URL" -o "${{ matrix.artifact_name }}.apk" + + - name: 📤 Upload APK artifact (${{ matrix.name }}) + if: ${{ !matrix.submit }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ${{ matrix.artifact_name }} + path: ${{ matrix.artifact_name }}.apk + retention-days: 14 + + # Draft a GitHub Release with the two APKs attached. The tag comes from the + # merged-in app version (app.json → expo.version), NOT the auto-incremented + # build number — so cutting a release is a deliberate version bump via PR. + github-release: + name: 📦 Draft GitHub Release + needs: build + if: ${{ !cancelled() }} + runs-on: ubuntu-26.04 + permissions: + contents: write + actions: read # required for `gh run download` to list/fetch this run's artifacts + steps: + - name: 📥 Checkout code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + show-progress: false + + - name: 📦 Download APK artifacts from this run + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + mkdir -p apks + gh run download ${{ github.run_id }} --name streamyfin-android-phone-apk --dir apks + gh run download ${{ github.run_id }} --name streamyfin-android-tv-apk --dir apks + ls -la apks + + - name: 📝 Draft release (tag = app.json version, not auto-bumped) + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + VERSION=$(node -e "console.log(require('./app.json').expo.version)") + TAG="v$VERSION" + echo "Release tag from merged app version: $TAG" + if gh release view "$TAG" >/dev/null 2>&1; then + echo "Release $TAG exists — updating APK assets" + gh release upload "$TAG" apks/*.apk --clobber + else + echo "Creating draft release $TAG" + gh release create "$TAG" \ + --draft \ + --generate-notes \ + --title "$TAG" \ + apks/*.apk + fi diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml deleted file mode 100644 index 345370973..000000000 --- a/.github/workflows/stale.yml +++ /dev/null @@ -1,49 +0,0 @@ -name: 🕒 Handle Stale Issues - -on: - schedule: - # Runs daily at 1:30 AM UTC (3:30 AM CEST - France time) - - cron: "30 1 * * *" - -jobs: - stale-issues: - name: 🗑️ Cleanup Stale Issues - runs-on: ubuntu-24.04 - permissions: - issues: write - pull-requests: write - - steps: - - name: 🔄 Mark/Close Stale Issues - uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0 - with: - # Global settings - repo-token: ${{ secrets.GITHUB_TOKEN }} - operations-per-run: 500 # Increase if you have >1000 issues - enable-statistics: true - - # Issue configuration - days-before-issue-stale: 90 - days-before-issue-close: 7 - stale-issue-label: "🕰️ stale" - exempt-issue-labels: "Roadmap v1,help needed,enhancement" - - # Notifications messages - stale-issue-message: | - ⏳ This issue has been automatically marked as **stale** because it has had no activity for 90 days. - - **Next steps:** - - If this is still relevant, add a comment to keep it open - - Otherwise, it will be closed in 7 days - - Thank you for your contributions! 🙌 - - close-issue-message: | - 🚮 This issue has been automatically closed due to inactivity (7 days since being marked stale). - - **Need to reopen?** - Click "Reopen" and add a comment explaining why this should stay open. - - # Disable PR handling - days-before-pr-stale: -1 - days-before-pr-close: -1 diff --git a/.github/workflows/trivy-scan.yml b/.github/workflows/trivy-scan.yml new file mode 100644 index 000000000..2e0f307b4 --- /dev/null +++ b/.github/workflows/trivy-scan.yml @@ -0,0 +1,50 @@ +name: 🛡️ Trivy Security Scan + +# Filesystem scan (Streamyfin ships no container image): finds vulnerable dependencies, +# leaked secrets and misconfigurations, and reports them to GitHub code scanning. +# Runs post-merge + weekly (not on PRs — dependency-review already gates PRs, and SARIF +# upload needs a write token that fork PRs don't get). +on: + push: + branches: [develop, master] + schedule: + - cron: "50 7 * * 5" # Weekly, Friday 07:50 UTC + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: trivy-${{ github.ref }} + cancel-in-progress: true + +jobs: + trivy: + name: 🔎 Filesystem scan + runs-on: ubuntu-26.04 + permissions: + contents: read + security-events: write # upload SARIF to code scanning + steps: + - name: 📥 Checkout repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + # Trivy's own action caches the vulnerability DB + binary internally + # (cache-trivy-* / trivy-binary-* entries), so no manual ~/.cache/trivy + # step is needed — it only duplicated the cache. + - name: 🔎 Run Trivy filesystem scan + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + scan-type: fs + scan-ref: . + scanners: vuln,secret,misconfig + ignore-unfixed: true + severity: CRITICAL,HIGH + format: sarif + output: trivy-results.sarif + + - name: 📤 Upload results to code scanning + uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 + with: + sarif_file: trivy-results.sarif + category: trivy-fs diff --git a/.github/workflows/update-issue-form.yml b/.github/workflows/update-issue-form.yml index dc76a0741..db111d560 100644 --- a/.github/workflows/update-issue-form.yml +++ b/.github/workflows/update-issue-form.yml @@ -1,67 +1,104 @@ -name: 🐛 Update Bug Report Template +name: 🐛 Update Issue Form Versions on: release: - types: [published] # Run on every published release on any branch + # Only full releases populate the dropdown (no drafts/prereleases). + types: [released] + schedule: + - cron: "0 3 * * 1" # Weekly safety net (Mondays 03:00 UTC) in case a release event was missed + workflow_dispatch: +# Fixed group so a release event and the weekly cron can't race on the same +# ci/update-issue-form branch — runs queue instead of force-pushing over each other. concurrency: - group: update-issue-form-${{ github.event.release.tag_name || github.run_id }} - cancel-in-progress: true + group: update-issue-form + cancel-in-progress: false + +permissions: + contents: read jobs: - update-bug-report: + update-issue-form: + name: 🔢 Populate version dropdown + runs-on: ubuntu-26.04 permissions: contents: write pull-requests: write - issues: write - runs-on: ubuntu-24.04 - steps: - - name: 📥 Checkout repository - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - parallel: + - name: 📥 Checkout repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + # On `release` events GITHUB_SHA is the tagged commit — without this the + # script would regenerate the form from the tag's (stale) copy and the bot + # PR would revert any form edits made on develop since that release. + ref: develop - - name: "🟢 Setup Node.js" - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 - with: - node-version: '24.x' - cache: 'npm' + - name: 🍞 Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + # renovate: datasource=npm depName=bun + bun-version: "1.3.14" - - name: 🔍 Extract minor version from app.json - id: minor - uses: actions/github-script@main - with: - result-encoding: string - script: | - const fs = require('fs-extra'); - const semver = require('semver'); - const content = fs.readJsonSync('./app.json'); - const version = content.expo.version; - const minorVersion = semver.minor(version); - return minorVersion.toString(); + - name: 🔢 Populate version dropdown from GitHub releases + id: populate + run: bun scripts/update-issue-form.mjs + env: + GH_TOKEN: ${{ github.token }} + GITHUB_REPOSITORY: ${{ github.repository }} - - name: 📝 Update bug report version - uses: ShaMan123/gha-populate-form-version@be012141ca560dbb92156e3fe098c46035f6260d #v2.0.5 + - name: 📬 Create pull request + id: cpr + uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 with: - semver: '^0.${{ steps.minor.outputs.result }}.0' - dry_run: no-push - - - name: ⚙️ Update bug report node version dropdown - uses: ShaMan123/gha-populate-form-version@be012141ca560dbb92156e3fe098c46035f6260d #v2.0.5 - with: - dropdown: _node_version - package: node - semver: '>=24.0.0' - dry_run: no-push - - - name: 📬 Commit and create pull request - uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e #v7.0.8 - with: - add-paths: .github/ISSUE_TEMPLATE/bug_report.yml - branch: ci-update-bug-report + add-paths: .github/ISSUE_TEMPLATE/issue_report.yml + branch: ci/update-issue-form base: develop delete-branch: true labels: ⚙️ ci, 🤖 github-actions - title: 'chore(): Update bug report template to match release version' + commit-message: "chore: update issue form version dropdown" + title: "chore: update issue form version dropdown" + # Follows .github/pull_request_template.md so the bot PR isn't flagged by PR validation. body: | - Automated update to `.github/ISSUE_TEMPLATE/bug_report.yml` - Triggered by workflow run [${{ github.run_id }}](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}) + # 📦 Pull Request + + ## 📝 Description + + Automated update of the **Streamyfin Version** dropdown in `.github/ISSUE_TEMPLATE/issue_report.yml`, populated from the latest published GitHub releases by `scripts/update-issue-form.mjs`. + + **Version dropdown now lists:** ${{ steps.populate.outputs.versions }} + + Triggered by `${{ github.event_name }}`${{ github.event.release.tag_name && format(' — release {0}', github.event.release.tag_name) || '' }} · [run ${{ github.run_id }}](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}). + + ## 🏷️ Ticket / Issue + + N/A — automated maintenance. + + ### 🖼️ Screenshots / GIFs (if UI) + + N/A — issue-template metadata only, no app UI. + + ## ✅ Checklist + + - [x] I’ve read the [contribution guidelines](CONTRIBUTING.md) + - [x] Verified that changes behave as expected for all platforms + - [x] Code passes lint/formatting and type checks (`tsc`/`biome`) + - [x] No secrets, hardcoded credentials, or private config files are included + - [x] I've declared if AI was used to assist with this PR (by uncommenting the line at the bottom, or not) + + ## 🔍 Testing Instructions + + N/A — generated by CI from published releases; review the dropdown diff in `issue_report.yml`. + + - name: 🔀 Enable auto-merge + if: steps.cpr.outputs.pull-request-operation == 'created' + env: + GH_TOKEN: ${{ github.token }} + # Known limitation: PRs created with GITHUB_TOKEN don't trigger CI workflows + # (GitHub anti-recursion), so the required checks stay "Expected" until a + # maintainer kicks them (close/reopen the PR, or push an empty commit). + # Auto-merge is still worth enabling: once checks run and reviews land, + # the PR merges itself. + run: | + gh pr merge --squash --auto "${{ steps.cpr.outputs.pull-request-number }}" \ + || echo "::warning::Could not enable auto-merge — enable 'Allow auto-merge' in repo settings (and branch protection); merge the PR manually for now." diff --git a/.gitignore b/.gitignore index 2813f7706..92bdf316d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,5 @@ # Dependencies and Package Managers node_modules/ -bun.lock bun.lockb package-lock.json @@ -13,15 +12,12 @@ web-build/ # Platform-specific Build Directories /ios /android -/iostv -/iosmobile -/androidmobile -/androidtv -# Module-specific Builds -modules/vlc-player/android/build -modules/player/android -modules/hls-downloader/android/build +# Gradle caches (top-level + per-module native projects) +**/.gradle/ + +# Native module build outputs (any module) +modules/*/android/build/ # Generated Applications Streamyfin.app @@ -50,7 +46,6 @@ npm-debug.* .idea/ .ruby-lsp .cursor/ -.claude/ # Environment and Configuration expo-env.d.ts @@ -62,7 +57,20 @@ expo-env.d.ts pc-api-7079014811501811218-719-3b9f15aeccf8.json credentials.json streamyfin-4fec1-firebase-adminsdk.json +/profiles/ +certs/ # Version and Backup Files /version-backup-* -modules/background-downloader/android/build/* + +# ios:unsigned-build Artifacts +build/ +# but keep EAS custom build configs (the generic build/ rule above matches .eas/build/) +!.eas/build/ +!.eas/build/** +.claude/ +.agents/skills/** +skills-lock.json + +# CI-injected Google Play service account key (written at build time) +google-service-account.json diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..6b3ef5e52 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,290 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Learned Facts Index + +IMPORTANT: When encountering issues related to these topics, or when implementing new features that touch these areas, prefer retrieval-led reasoning -- read the relevant fact file in `.claude/learned-facts/` before relying on assumptions. + +Navigation: +- `native-bottom-tabs-userouter-conflict` | useRouter() at provider level causes tab switches; use static router import +- `introsheet-rendering-location` | IntroSheet in IntroSheetProvider affects native bottom tabs via nav state hooks +- `intro-modal-trigger-location` | Trigger in Home.tsx, not tabs _layout.tsx +- `tab-folder-naming` | Use underscore prefix: (_home) not (home) + +UI/Headers: +- `macos-header-buttons-fix` | macOS Catalyst: use RNGH Pressable, not RN TouchableOpacity +- `header-button-locations` | Defined in _layout.tsx, HeaderBackButton, Chromecast, RoundButton, etc. +- `stack-screen-header-configuration` | Sub-pages need explicit Stack.Screen with headerTransparent + back button + +State/Data: +- `use-network-aware-query-client-limitations` | Object.create breaks private fields; only for invalidateQueries +- `mark-as-played-flow` | PlayedStatus→useMarkAsPlayed→playbackManager with optimistic updates + +Native Modules: +- `mpv-tvos-player-exit-freeze` | mpv_terminate_destroy deadlocks main thread; use DispatchQueue.global() +- `mpv-avfoundation-composite-osd-ordering` | MUST follow vo=avfoundation, before hwdec options +- `thread-safe-state-for-stop-flags` | Stop flags need synchronous setter (stateQueue.sync not async) +- `native-swiftui-view-sizing` | Need explicit frame + intrinsicContentSize override in ExpoView + +TV Platform: +- `tv-modals-must-use-navigation-pattern` | Use atom+router.push(), never overlay/absolute modals +- `tv-grid-layout-pattern` | ScrollView+flexWrap, not FlatList numColumns +- `tv-horizontal-padding-standard` | TV_HORIZONTAL_PADDING=60, not old TV_SCALE_PADDING=20 +- `streamystats-components-location` | components/home/Streamystats*.tv.tsx, watchlists/[watchlistId].tsx +- `platform-specific-file-suffix-does-not-work` | .tv.tsx doesn't work; use Platform.isTV conditional rendering + +## Project Overview + +Streamyfin is a cross-platform Jellyfin video streaming client built with Expo (React Native). It supports mobile (iOS/Android) and TV platforms, with features including offline downloads, Chromecast support, and Jellyseerr integration. + +## Development Commands + +**CRITICAL: Always use `bun` for package management. Never use `npm`, `yarn`, or `npx`.** + +```bash +# Setup +bun i && bun run submodule-reload + +# Development builds +bun run prebuild # Mobile prebuild +bun run ios # Run iOS +bun run android # Run Android + +# TV builds (suffix with :tv) +bun run prebuild:tv +bun run ios:tv +bun run android:tv + +# Code quality +bun run typecheck # TypeScript check +bun run check # BiomeJS check +bun run lint # BiomeJS lint + fix +bun run format # BiomeJS format +bun run test # Run all checks (typecheck, lint, format, doctor) + +# iOS-specific +bun run ios:install-metal-toolchain # Fix "missing Metal Toolchain" build errors +``` + +## Tech Stack + +- **Runtime**: Bun +- **Framework**: React Native (Expo SDK 54) +- **Language**: TypeScript (strict mode) +- **State Management**: Jotai (global state atoms) + React Query (server state) +- **API**: Jellyfin SDK (`@jellyfin/sdk`) +- **Navigation**: Expo Router (file-based) +- **Linting/Formatting**: BiomeJS +- **Storage**: react-native-mmkv + +## Architecture + +### File Structure + +- `app/` - Expo Router screens with file-based routing +- `components/` - Reusable UI components +- `providers/` - React Context providers +- `hooks/` - Custom React hooks +- `utils/` - Utilities including Jotai atoms +- `modules/` - Native modules (vlc-player, mpv-player, background-downloader) +- `translations/` - i18n translation files + +### Key Patterns + +**State Management**: +- Global state uses Jotai atoms in `utils/atoms/` +- `settingsAtom` in `utils/atoms/settings.ts` for app settings + - **IMPORTANT**: When adding a setting to the settings atom, ensure it's toggleable in the settings view (either TV or mobile, depending on the feature scope) +- `apiAtom` and `userAtom` in `providers/JellyfinProvider.tsx` for auth state +- Server state uses React Query with `@tanstack/react-query` + +**Jellyfin API Access**: +- Use `apiAtom` from `JellyfinProvider` for authenticated API calls +- Access user via `userAtom` +- Use Jellyfin SDK utilities from `@jellyfin/sdk/lib/utils/api` + +**Navigation**: +- File-based routing in `app/` directory +- Tab navigation: `(home)`, `(search)`, `(favorites)`, `(libraries)`, `(watchlists)` +- Shared routes use parenthesized groups like `(home,libraries,search,favorites,watchlists)` +- **IMPORTANT**: Always use `useAppRouter` from `@/hooks/useAppRouter` instead of `useRouter` from `expo-router`. This custom hook automatically handles offline mode state preservation across navigation: + ```typescript + // ✅ Correct + import useRouter from "@/hooks/useAppRouter"; + const router = useRouter(); + + // ❌ Never use this + import { useRouter } from "expo-router"; + import { router } from "expo-router"; + ``` + +**Offline Mode**: +- Use `OfflineModeProvider` from `@/providers/OfflineModeProvider` to wrap pages that support offline content +- Use `useOfflineMode()` hook to check if current context is offline +- The `useAppRouter` hook automatically injects `offline=true` param when navigating within an offline context + +**Providers** (wrapping order in `app/_layout.tsx`): +1. JotaiProvider +2. QueryClientProvider +3. JellyfinProvider (auth, API) +4. NetworkStatusProvider +5. PlaySettingsProvider +6. WebSocketProvider +7. DownloadProvider +8. MusicPlayerProvider + +### Native Modules + +Located in `modules/`: +- `vlc-player` - VLC video player integration +- `mpv-player` - MPV video player integration (iOS) +- `background-downloader` - Background download functionality +- `sf-player` - Swift player module + +### Path Aliases + +Use `@/` prefix for imports (configured in `tsconfig.json`): +```typescript +import { useSettings } from "@/utils/atoms/settings"; +import { apiAtom } from "@/providers/JellyfinProvider"; +``` + +## Coding Standards + +- Use TypeScript for all files (no .js). Tooling-required exceptions: `babel.config.js`, `metro.config.js`, `react-native.config.js`, `tailwind.config.js` (their loaders cannot parse TypeScript) +- Use functional React components with hooks +- Use Jotai atoms for global state, React Query for server state +- Follow BiomeJS formatting rules (2-space indent, semicolons, LF line endings) +- Handle both mobile and TV navigation patterns +- Use existing atoms, hooks, and utilities before creating new ones +- Use Conventional Commits: `feat(scope):`, `fix(scope):`, `chore(scope):` +- **Translations**: When adding a translation key to a Text component, ensure the key exists in both `translations/en.json` and `translations/sv.json`. Before adding new keys, check if an existing key already covers the use case. + +## Platform Considerations + +- TV version uses `:tv` suffix for scripts +- Platform checks: `Platform.isTV`, `Platform.OS === "android"` or `"ios"` +- Some features disabled on TV (e.g., notifications, Chromecast) +- **TV Design**: Don't use purple accent colors on TV. Use white for focused states and `expo-blur` (`BlurView`) for backgrounds/overlays. +- **TV Typography**: Use `TVTypography` from `@/components/tv/TVTypography` for all text on TV. It provides consistent font sizes optimized for TV viewing distance. +- **TV Button Sizing**: Ensure buttons placed next to each other have the same size for visual consistency. +- **TV Focus Scale Padding**: Add sufficient padding around focusable items in tables/rows/columns/lists. The focus scale animation (typically 1.05x) will clip against parent containers without proper padding. Use `overflow: "visible"` on containers and add padding to prevent clipping. +- **TV Modals**: Never use React Native's `Modal` component or overlay/absolute-positioned modals for full-screen modals on TV. Use the navigation-based modal pattern instead. **See [docs/tv-modal-guide.md](docs/tv-modal-guide.md) for detailed documentation.** + +### TV Component Rendering Pattern + +**IMPORTANT**: The `.tv.tsx` file suffix does NOT work in this project - neither for pages nor components. Metro bundler doesn't resolve platform-specific suffixes. Always use `Platform.isTV` conditional rendering instead. + +**Pattern for TV-specific pages and components**: +```typescript +// In page file (e.g., app/login.tsx) +import { Platform } from "react-native"; +import { Login } from "@/components/login/Login"; +import { TVLogin } from "@/components/login/TVLogin"; + +const LoginPage: React.FC = () => { + if (Platform.isTV) { + return ; + } + return ; +}; + +export default LoginPage; +``` + +- Create separate component files for mobile and TV (e.g., `MyComponent.tsx` and `TVMyComponent.tsx`) +- Use `Platform.isTV` to conditionally render the appropriate component +- TV components typically use `TVInput`, `TVServerCard`, and other TV-prefixed components with focus handling +- **Never use `.tv.tsx` file suffix** - it will not be resolved correctly + +### TV Option Selectors and Focus Management + +For dropdown/select components, bottom sheets, and overlay focus management on TV, see [docs/tv-modal-guide.md](docs/tv-modal-guide.md). + +### TV Focus Flickering Between Zones (Lists with Headers) + +When you have a page with multiple focusable zones (e.g., a filter bar above a grid), the TV focus engine can rapidly flicker between elements when navigating between zones. This is a known issue with React Native TV. + +**Solutions:** + +1. **Use FlatList instead of FlashList for TV** - FlashList has known focus issues on TV platforms. Use regular FlatList with `Platform.isTV` check: +```typescript +{Platform.isTV ? ( + +) : ( + +)} +``` + +2. **Add `removeClippedSubviews={false}`** - Prevents the list from unmounting off-screen items, which can cause focus to "fall through" to other elements. + +3. **Only ONE element should have `hasTVPreferredFocus`** - Never have multiple elements competing for initial focus. Choose one element (usually the first filter button or first list item) to have preferred focus: +```typescript +// ✅ Good - only first filter button has preferred focus + + // No hasTVPreferredFocus + +// ❌ Bad - both compete for focus + + +``` + +4. **Keep headers/filter bars outside the list** - Instead of using `ListHeaderComponent`, render the filter bar as a separate View above the FlatList: +```typescript + + {/* Filter bar - separate from list */} + + + + + + {/* Grid */} + + +``` + +5. **Avoid multiple scrollable containers** - Don't use ScrollView for the filter bar if you have a FlatList below. Use a simple View instead to prevent focus conflicts between scrollable containers. + +**Reference implementation**: See `app/(auth)/(tabs)/(libraries)/[libraryId].tsx` for the TV filter bar + grid pattern. + +### TV Focus Guide Navigation (Non-Adjacent Sections) + +When you need focus to navigate between sections that aren't geometrically aligned (e.g., left-aligned buttons to a horizontal ScrollView), use `TVFocusGuideView` with the `destinations` prop: + +```typescript +// 1. Track destination with useState (NOT useRef - won't trigger re-renders) +const [firstCardRef, setFirstCardRef] = useState(null); + +// 2. Place invisible focus guide between sections +{firstCardRef && ( + +)} + +// 3. Target component must use forwardRef +const MyCard = React.forwardRef(({ ... }, ref) => ( + + ... + +)); + +// 4. Pass state setter as callback ref to first item +{items.map((item, index) => ( + +))} +``` + +**For detailed documentation and bidirectional navigation patterns, see [docs/tv-focus-guide.md](docs/tv-focus-guide.md)** + +**Reference implementation**: See `components/ItemContent.tv.tsx` for bidirectional focus navigation between playback options and cast list. diff --git a/GLOBAL_MODAL_GUIDE.md b/GLOBAL_MODAL_GUIDE.md index 5426ca722..11a05f105 100644 --- a/GLOBAL_MODAL_GUIDE.md +++ b/GLOBAL_MODAL_GUIDE.md @@ -143,14 +143,6 @@ interface ModalOptions { } ``` -## Examples - -See `components/ExampleGlobalModalUsage.tsx` for comprehensive examples including: -- Simple content modal -- Modal with custom snap points -- Complex component in modal -- Success/error modals triggered from functions - ## Default Styling The modal uses these default styles (can be overridden via options): diff --git a/README.md b/README.md index 90349db40..3d4221f40 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,12 @@ Streamyfin

+

+ + Streamyfin Discord + +

+ **Streamyfin is a user-friendly Jellyfin video streaming client built with Expo. Designed as an alternative to other Jellyfin clients, it aims to offer a smooth and reliable streaming experience. We hope you'll find it a valuable addition to your media streaming toolbox.** --- @@ -54,6 +60,11 @@ The Jellyfin Plugin for Streamyfin is a plugin you install into Jellyfin that ho Chromecast support is currently under development. Video casting is already available, and we're actively working on adding subtitle support and additional features. +### 🎬 MPV Player + +Streamyfin uses [MPV](https://mpv.io/) as its primary video player on all platforms, powered by [MPVKit](https://github.com/mpvkit/MPVKit). MPV is a powerful, open-source media player known for its wide format support and high-quality playback. +Thanks to [@Alexk2309](https://github.com/Alexk2309) for the hard work building the native MPV module in Streamyfin. + ### 🔍 Jellysearch [Jellysearch](https://gitlab.com/DomiStyle/jellysearch) works with Streamyfin @@ -62,7 +73,7 @@ Chromecast support is currently under development. Video casting is already avai ## 🛣️ Roadmap -Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) To see what we're working on next, we are always open to feedback and suggestions. Please let us know if you have any ideas or feature requests. +Check out our [Roadmap](https://github.com/orgs/streamyfin/projects/3/views/1) to see what we're working on next, we are always open to feedback and suggestions. Please let us know if you have any ideas or feature requests. ## 📥 Download Streamyfin @@ -70,6 +81,7 @@ Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) To Get Streamyfin on App Store Get Streamyfin on Google Play Store Get Streamyfin on Github + Add Streamyfin to Obtainium ### 🧪 Beta Testing @@ -104,6 +116,7 @@ You can contribute translations directly on our [Crowdin project page](https://c 1. Use node `>20` 2. Install dependencies `bun i && bun run submodule-reload` 3. Make sure you have xcode and/or android studio installed. (follow the guides for expo: https://docs.expo.dev/workflow/android-studio-emulator/) + - If iOS builds fail with `missing Metal Toolchain` (KSPlayer shaders), run `npm run ios:install-metal-toolchain` once 4. Install BiomeJS extension in VSCode/Your IDE (https://biomejs.dev/) 4. run `npm run prebuild` 5. Create an expo dev build by running `npm run ios` or `npm run android`. This will open a simulator on your computer and run the app @@ -113,6 +126,10 @@ For the TV version suffix the npm commands with `:tv`. `npm run prebuild:tv` `npm run ios:tv or npm run android:tv` +TV platform integration notes: + +- [TV Discovery](./docs/tv-discovery.md) + ## 👋 Get in Touch with Us Need assistance or have any questions? @@ -228,6 +245,7 @@ We also thank all other developers who have contributed to Streamyfin, your effo A special mention to the following people and projects for their contributions: +- [@Alexk2309](https://github.com/Alexk2309) for building the native MPV module that integrates [MPVKit](https://github.com/mpvkit/MPVKit) with React Native - [Reiverr](https://github.com/aleksilassila/reiverr) for invaluable help with understanding the Jellyfin API - [Jellyfin TS SDK](https://github.com/jellyfin/jellyfin-sdk-typescript) for providing the TypeScript SDK - [Seerr](https://github.com/seerr-team/seerr) for enabling API integration with their project diff --git a/app.config.js b/app.config.js deleted file mode 100644 index 2e37927bc..000000000 --- a/app.config.js +++ /dev/null @@ -1,21 +0,0 @@ -module.exports = ({ config }) => { - if (process.env.EXPO_TV !== "1") { - config.plugins.push("expo-background-task"); - - config.plugins.push([ - "react-native-google-cast", - { useDefaultExpandedMediaControls: true }, - ]); - } - - // Only override googleServicesFile if env var is set - const androidConfig = {}; - if (process.env.GOOGLE_SERVICES_JSON) { - androidConfig.googleServicesFile = process.env.GOOGLE_SERVICES_JSON; - } - - return { - ...(Object.keys(androidConfig).length > 0 && { android: androidConfig }), - ...config, - }; -}; diff --git a/app.config.ts b/app.config.ts new file mode 100644 index 000000000..ac2b433ab --- /dev/null +++ b/app.config.ts @@ -0,0 +1,79 @@ +// Registers the tsx require hook so the TypeScript config plugins referenced +// from app.json ("./plugins/*.ts") can be loaded by Node during config evaluation. +import "tsx/cjs"; +import { execFileSync } from "node:child_process"; +import type { ConfigContext, ExpoConfig } from "expo/config"; + +// Build metadata, injected into `extra.build` and read at runtime via +// expo-constants (see utils/version.ts). Sources in priority order: +// EAS cloud build → GitHub Actions → explicit EXPO_PUBLIC_* → local git → null. +const git = (args: string[]): string | null => { + try { + return execFileSync("git", args, { stdio: ["ignore", "pipe", "ignore"] }) + .toString() + .trim(); + } catch { + return null; + } +}; + +const buildMeta = { + commit: + ( + process.env.EAS_BUILD_GIT_COMMIT_HASH || + process.env.GITHUB_SHA || + process.env.EXPO_PUBLIC_GIT_COMMIT || + git(["rev-parse", "HEAD"]) || + "" + ).slice(0, 7) || null, + branch: + process.env.EAS_BUILD_GIT_BRANCH || + process.env.GITHUB_HEAD_REF || + process.env.GITHUB_REF_NAME || + process.env.EXPO_PUBLIC_GIT_BRANCH || + git(["rev-parse", "--abbrev-ref", "HEAD"]) || + null, + profile: + process.env.EAS_BUILD_PROFILE || + process.env.EXPO_PUBLIC_BUILD_PROFILE || + null, + // GitHub Actions run number (#2098) — lets anyone map a sideloaded CI build back + // to its Actions run (artifacts + logs) without Expo access. Null outside CI. + runNumber: + process.env.GITHUB_RUN_NUMBER || + process.env.EXPO_PUBLIC_GIT_RUN_NUMBER || + null, + builtAt: new Date().toISOString(), +}; + +export default ({ config }: ConfigContext): ExpoConfig => { + if (process.env.EXPO_TV !== "1") { + config.plugins?.push("expo-background-task"); + + config.plugins?.push([ + "react-native-google-cast", + { useDefaultExpandedMediaControls: true }, + ]); + + config.plugins?.push([ + "expo-camera", + { + cameraPermission: + "Allow Streamyfin to access the camera to scan QR codes for TV login.", + }, + ]); + } + + // Only override googleServicesFile if env var is set + const androidConfig: { googleServicesFile?: string } = {}; + if (process.env.GOOGLE_SERVICES_JSON) { + androidConfig.googleServicesFile = process.env.GOOGLE_SERVICES_JSON; + } + + config.extra = { ...config.extra, build: buildMeta }; + + return { + ...(Object.keys(androidConfig).length > 0 && { android: androidConfig }), + ...config, + } as ExpoConfig; +}; diff --git a/app.json b/app.json index 5d4e9f4a0..42efc22c9 100644 --- a/app.json +++ b/app.json @@ -2,13 +2,11 @@ "expo": { "name": "Streamyfin", "slug": "streamyfin", - "version": "0.48.0", + "version": "0.54.1", "orientation": "default", "icon": "./assets/images/icon.png", "scheme": "streamyfin", "userInterfaceStyle": "dark", - "jsEngine": "hermes", - "newArchEnabled": true, "assetBundlePatterns": ["**/*"], "ios": { "requireFullScreen": true, @@ -17,24 +15,27 @@ "NSMicrophoneUsageDescription": "The app needs access to your microphone.", "UIBackgroundModes": ["audio", "fetch"], "NSLocalNetworkUsageDescription": "The app needs access to your local network to connect to your Jellyfin server.", + "NSLocationWhenInUseUsageDescription": "Streamyfin uses your location to detect your home WiFi network for automatic local server switching.", "NSAppTransportSecurity": { "NSAllowsArbitraryLoads": true }, "UISupportsTrueScreenSizeOnMac": true, "UIFileSharingEnabled": true, - "LSSupportsOpeningDocumentsInPlace": true + "LSSupportsOpeningDocumentsInPlace": true, + "AVInitialRouteSharingPolicy": "LongFormAudio" }, "config": { "usesNonExemptEncryption": false }, "supportsTablet": true, + "entitlements": { + "com.apple.developer.networking.wifi-info": true + }, "bundleIdentifier": "com.fredrikburmester.streamyfin", "icon": "./assets/images/icon-ios-liquid-glass.icon", "appleTeamId": "MWD5K362T8" }, "android": { - "jsEngine": "hermes", - "versionCode": 85, "adaptiveIcon": { "foregroundImage": "./assets/images/icon-android-plain.png", "monochromeImage": "./assets/images/icon-android-themed.png", @@ -44,42 +45,49 @@ "permissions": [ "android.permission.FOREGROUND_SERVICE", "android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK", - "android.permission.WRITE_SETTINGS" + "android.permission.WRITE_SETTINGS", + "android.permission.ACCESS_FINE_LOCATION" ], "blockedPermissions": ["android.permission.ACTIVITY_RECOGNITION"], "googleServicesFile": "./google-services.json" }, "plugins": [ - "@react-native-tvos/config-tv", - "expo-router", - "expo-font", [ - "react-native-video", + "@react-native-tvos/config-tv", { - "enableNotificationControls": true, - "enableBackgroundAudio": true, - "androidExtensions": { - "useExoplayerRtsp": false, - "useExoplayerSmoothStreaming": false, - "useExoplayerHls": true, - "useExoplayerDash": false + "appleTVImages": { + "icon": "./assets/images/icon-tvos.png", + "iconSmall": "./assets/images/icon-tvos-small.png", + "iconSmall2x": "./assets/images/icon-tvos-small-2x.png", + "topShelf": "./assets/images/icon-tvos-topshelf.png", + "topShelf2x": "./assets/images/icon-tvos-topshelf-2x.png", + "topShelfWide": "./assets/images/icon-tvos-topshelf-wide.png", + "topShelfWide2x": "./assets/images/icon-tvos-topshelf-wide-2x.png" + }, + "infoPlist": { + "UIAppSupportsHDR": true } } ], + "expo-router", + "expo-font", + "./plugins/withExcludeMedia3Dash.ts", + "./plugins/withTVUserManagement.ts", [ "expo-build-properties", { "ios": { - "deploymentTarget": "15.6", - "useFrameworks": "static" + "deploymentTarget": "16.4", + "useFrameworks": "static", + "forceStaticLinking": ["ExpoUI", "GlassEffectView", "GlassPoster"] }, "android": { - "buildArchs": ["arm64-v8a", "x86_64"], - "compileSdkVersion": 35, + "buildArchs": ["arm64-v8a", "x86_64", "armeabi-v7a"], + "compileSdkVersion": 36, "targetSdkVersion": 35, "buildToolsVersion": "35.0.0", - "kotlinVersion": "2.0.21", - "minSdkVersion": 24, + "kotlinVersion": "2.1.20", + "minSdkVersion": 26, "usesCleartextTraffic": true, "packagingOptions": { "jniLibs": { @@ -97,14 +105,11 @@ "initialOrientation": "DEFAULT" } ], - [ - "expo-sensors", - { - "motionPermission": "Allow Streamyfin to access your device motion for landscape video watching." - } - ], "expo-localization", "expo-asset", + "expo-audio", + "expo-image", + "expo-sharing", [ "react-native-edge-to-edge", { @@ -129,11 +134,22 @@ } ], "expo-web-browser", - ["./plugins/with-runtime-framework-headers.js"], - ["./plugins/withChangeNativeAndroidTextToWhite.js"], - ["./plugins/withAndroidManifest.js"], - ["./plugins/withTrustLocalCerts.js"], - ["./plugins/withGradleProperties.js"] + ["./plugins/with-runtime-framework-headers.ts"], + ["./plugins/withChangeNativeAndroidTextToWhite.ts"], + ["./plugins/withAndroidAlertColors.ts"], + ["./plugins/withAndroidManifest.ts"], + ["./plugins/withTrustLocalCerts.ts"], + ["./plugins/withGradleProperties.ts"], + ["./plugins/withTVOSAppIcon.ts"], + ["./plugins/withTVOSTopShelf.ts"], + ["./plugins/withTVXcodeEnv.ts"], + [ + "./plugins/withGitPod.ts", + { + "podName": "MPVKit", + "podspecUrl": "https://raw.githubusercontent.com/mpv-ios/MPVKit/0.41.0-av/MPVKit.podspec" + } + ] ], "experiments": { "typedRoutes": true diff --git a/app/(auth)/(tabs)/(custom-links)/_layout.tsx b/app/(auth)/(tabs)/(custom-links)/_layout.tsx index 3b8a58e20..67648e05a 100644 --- a/app/(auth)/(tabs)/(custom-links)/_layout.tsx +++ b/app/(auth)/(tabs)/(custom-links)/_layout.tsx @@ -9,7 +9,7 @@ export default function CustomMenuLayout() { ([]); @@ -29,7 +29,7 @@ export default function menuLinks() { ); const config = response?.data; - if (!config && !Object.hasOwn(config, "menuLinks")) { + if (!config || !Object.hasOwn(config, "menuLinks")) { console.error("Menu links not found"); return; } diff --git a/app/(auth)/(tabs)/(favorites)/index.tsx b/app/(auth)/(tabs)/(favorites)/index.tsx index 198695a8c..10fffe9d0 100644 --- a/app/(auth)/(tabs)/(favorites)/index.tsx +++ b/app/(auth)/(tabs)/(favorites)/index.tsx @@ -2,9 +2,10 @@ import { useCallback, useState } from "react"; import { Platform, RefreshControl, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Favorites } from "@/components/home/Favorites"; +import { Favorites as TVFavorites } from "@/components/home/Favorites.tv"; import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache"; -export default function favorites() { +export default function FavoritesPage() { const invalidateCache = useInvalidatePlaybackProgressCache(); const [loading, setLoading] = useState(false); @@ -15,6 +16,10 @@ export default function favorites() { }, []); const insets = useSafeAreaInsets(); + if (Platform.isTV) { + return ; + } + return ( (); + const typeParam = searchParams.type; + const titleParam = searchParams.title; + + const itemType = useMemo(() => { + if (!isFavoriteType(typeParam)) return null; + return typeParam as BaseItemKind; + }, [typeParam]); + + const headerTitle = useMemo(() => { + if (typeof titleParam === "string" && titleParam.trim().length > 0) + return titleParam; + return ""; + }, [titleParam]); + + const pageSize = 50; + + const fetchItems = useCallback( + async ({ pageParam }: { pageParam: number }): Promise => { + if (!api || !user?.Id || !itemType) return []; + + const response = await getItemsApi(api as Api).getItems({ + userId: user.Id, + sortBy: ["SeriesSortName", "SortName"], + sortOrder: ["Ascending"], + filters: ["IsFavorite"], + recursive: true, + fields: ["PrimaryImageAspectRatio"], + collapseBoxSetItems: false, + excludeLocationTypes: ["Virtual"], + enableTotalRecordCount: true, + startIndex: pageParam, + limit: pageSize, + includeItemTypes: [itemType], + }); + + return response.data.Items || []; + }, + [api, itemType, user?.Id], + ); + + const { data, isFetching, fetchNextPage, hasNextPage, isLoading } = + useInfiniteQuery({ + queryKey: ["favorites", "see-all", itemType], + queryFn: ({ pageParam = 0 }) => fetchItems({ pageParam }), + getNextPageParam: (lastPage, pages) => { + if (!lastPage || lastPage.length < pageSize) return undefined; + return pages.reduce((acc, page) => acc + page.length, 0); + }, + initialPageParam: 0, + enabled: !!api && !!user?.Id && !!itemType, + }); + + const flatData = useMemo(() => data?.pages.flat() ?? [], [data]); + + const nrOfCols = useMemo(() => { + if (screenWidth < 350) return 2; + if (screenWidth < 600) return 3; + if (screenWidth < 900) return 5; + return 6; + }, [screenWidth]); + + const renderItem = useCallback( + ({ item, index }: { item: BaseItemDto; index: number }) => ( + + + + + + + ), + [nrOfCols], + ); + + const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []); + + const handleEndReached = useCallback(() => { + if (hasNextPage) { + fetchNextPage(); + } + }, [fetchNextPage, hasNextPage]); + + return ( + <> + + {!itemType ? ( + + {t("favorites.noData")} + + ) : isLoading ? ( + + + + ) : ( + ( + + )} + ListEmptyComponent={ + + + {t("home.no_items")} + + + } + ListFooterComponent={ + isFetching ? ( + + + + ) : null + } + /> + )} + + ); +} diff --git a/app/(auth)/(tabs)/(home)/_layout.tsx b/app/(auth)/(tabs)/(home)/_layout.tsx index bfbebed54..de5545d62 100644 --- a/app/(auth)/(tabs)/(home)/_layout.tsx +++ b/app/(auth)/(tabs)/(home)/_layout.tsx @@ -1,12 +1,15 @@ import { Feather, Ionicons } from "@expo/vector-icons"; -import { Stack, useRouter } from "expo-router"; +import { Stack } from "expo-router"; import { useTranslation } from "react-i18next"; -import { Platform, TouchableOpacity, View } from "react-native"; +import { Platform, View } from "react-native"; +import { Pressable } from "react-native-gesture-handler"; import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack"; +import useRouter from "@/hooks/useAppRouter"; const Chromecast = Platform.isTV ? null : require("@/components/Chromecast"); import { useAtom } from "jotai"; +import { HeaderBackButton } from "@/components/common/HeaderBackButton"; import { useSessions, type useSessionsProps } from "@/hooks/useSessions"; import { userAtom } from "@/providers/JellyfinProvider"; @@ -41,164 +44,125 @@ export default function IndexLayout() { ( - _router.back()} - className='pl-0.5' - style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} - > - - - ), - }} - /> - ( - _router.back()} - className='pl-0.5' - style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} - > - - - ), + headerLeft: () => , }} /> ( - _router.back()} - className='pl-0.5' - style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} - > - - - ), + headerLeft: () => , }} /> ( - _router.back()} className='pl-0.5' style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} > - + ), }} /> + , + }} + /> ( - _router.back()} - className='pl-0.5' - style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} - > - - - ), + headerLeft: () => , }} /> ( - _router.back()} - className='pl-0.5' - style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} - > - - - ), + headerLeft: () => , }} /> ( - _router.back()} - className='pl-0.5' - style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} - > - - - ), + headerLeft: () => , + }} + /> + , }} /> ( - _router.back()} - className='pl-0.5' - style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} - > - - - ), + headerLeft: () => , }} /> ( - _router.back()} className='pl-0.5' style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} > - + ), }} /> @@ -206,85 +170,77 @@ export default function IndexLayout() { name='settings/plugins/marlin-search/page' options={{ title: "Marlin Search", + headerShown: !Platform.isTV, headerBlurEffect: "none", headerTransparent: Platform.OS === "ios", headerShadowVisible: false, - headerLeft: () => ( - _router.back()} - className='pl-0.5' - style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} - > - - - ), + headerLeft: () => , }} /> ( - _router.back()} - className='pl-0.5' - style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} - > - - - ), + headerLeft: () => , + }} + /> + , + }} + /> + , }} /> ( - _router.back()} - className='pl-0.5' - style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} - > - - - ), + headerLeft: () => , }} /> ( - _router.back()} - className='pl-0.5' - style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} - > - - - ), + headerLeft: () => , }} /> ( - _router.back()} className='pl-0.5'> - - - ), - presentation: "modal", + title: t("home.settings.network.title"), + headerShown: !Platform.isTV, + headerBlurEffect: "none", + headerTransparent: Platform.OS === "ios", + headerShadowVisible: false, + headerLeft: () => , }} /> {Object.entries(nestedTabPageScreenOptions).map(([name, options]) => ( @@ -294,12 +250,8 @@ export default function IndexLayout() { name='collections/[collectionId]' options={{ title: "", - headerLeft: () => ( - _router.back()} className='pl-0.5'> - - - ), - headerShown: true, + headerLeft: () => , + headerShown: !Platform.isTV, headerBlurEffect: "prominent", headerTransparent: Platform.OS === "ios", headerShadowVisible: false, @@ -313,13 +265,13 @@ const SettingsButton = () => { const router = useRouter(); return ( - { router.push("/(auth)/settings"); }} > - + ); }; @@ -328,7 +280,7 @@ const SessionsButton = () => { const { sessions = [] } = useSessions({} as useSessionsProps); return ( - { router.push("/(auth)/sessions"); }} @@ -339,6 +291,6 @@ const SessionsButton = () => { color={sessions.length === 0 ? "white" : "#9333ea"} size={28} /> - + ); }; diff --git a/app/(auth)/(tabs)/(home)/companion-login.tsx b/app/(auth)/(tabs)/(home)/companion-login.tsx new file mode 100644 index 000000000..68dd2254c --- /dev/null +++ b/app/(auth)/(tabs)/(home)/companion-login.tsx @@ -0,0 +1,7 @@ +import { Platform } from "react-native"; +import { CompanionLoginScreen } from "@/components/companion/CompanionLoginScreen"; + +export default function CompanionLoginPage() { + if (Platform.isTV) return null; + return ; +} diff --git a/app/(auth)/(tabs)/(home)/downloads/[seriesId].tsx b/app/(auth)/(tabs)/(home)/downloads/[seriesId].tsx deleted file mode 100644 index e870f5c0c..000000000 --- a/app/(auth)/(tabs)/(home)/downloads/[seriesId].tsx +++ /dev/null @@ -1,181 +0,0 @@ -import { Ionicons } from "@expo/vector-icons"; -import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; -import { FlashList } from "@shopify/flash-list"; -import { router, useLocalSearchParams, useNavigation } from "expo-router"; -import { useCallback, useEffect, useMemo, useState } from "react"; -import { Alert, Platform, TouchableOpacity, View } from "react-native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { Text } from "@/components/common/Text"; -import { EpisodeCard } from "@/components/downloads/EpisodeCard"; -import { - SeasonDropdown, - type SeasonIndexState, -} from "@/components/series/SeasonDropdown"; -import { useDownload } from "@/providers/DownloadProvider"; -import { storage } from "@/utils/mmkv"; - -export default function page() { - const navigation = useNavigation(); - const local = useLocalSearchParams(); - const { seriesId, episodeSeasonIndex } = local as { - seriesId: string; - episodeSeasonIndex: number | string | undefined; - }; - - const [seasonIndexState, setSeasonIndexState] = useState( - {}, - ); - const { downloadedItems, deleteItems } = useDownload(); - const insets = useSafeAreaInsets(); - - const series = useMemo(() => { - try { - return ( - downloadedItems - ?.filter((f) => f.item.SeriesId === seriesId) - ?.sort( - (a, b) => - (a.item.ParentIndexNumber ?? 0) - (b.item.ParentIndexNumber ?? 0), - ) || [] - ); - } catch { - return []; - } - }, [downloadedItems, seriesId]); - - // Group episodes by season in a single pass - const seasonGroups = useMemo(() => { - const groups: Record = {}; - - series.forEach((episode) => { - const seasonNumber = episode.item.ParentIndexNumber; - if (seasonNumber !== undefined && seasonNumber !== null) { - if (!groups[seasonNumber]) { - groups[seasonNumber] = []; - } - groups[seasonNumber].push(episode.item); - } - }); - - // Sort episodes within each season - Object.values(groups).forEach((episodes) => { - episodes.sort((a, b) => (a.IndexNumber || 0) - (b.IndexNumber || 0)); - }); - - return groups; - }, [series]); - - // Get unique seasons (just the season numbers, sorted) - const uniqueSeasons = useMemo(() => { - const seasonNumbers = Object.keys(seasonGroups) - .map(Number) - .sort((a, b) => a - b); - return seasonNumbers.map((seasonNum) => seasonGroups[seasonNum][0]); // First episode of each season - }, [seasonGroups]); - - const seasonIndex = - seasonIndexState[series?.[0]?.item?.ParentId ?? ""] ?? - episodeSeasonIndex ?? - series?.[0]?.item?.ParentIndexNumber ?? - ""; - - const groupBySeason = useMemo(() => { - return seasonGroups[Number(seasonIndex)] ?? []; - }, [seasonGroups, seasonIndex]); - - const initialSeasonIndex = useMemo( - () => - groupBySeason?.[0]?.ParentIndexNumber ?? - series?.[0]?.item?.ParentIndexNumber, - [groupBySeason, series], - ); - - useEffect(() => { - if (series.length > 0) { - navigation.setOptions({ - title: series[0].item.SeriesName, - }); - } else { - storage.remove(seriesId); - router.back(); - } - }, [series]); - - const deleteSeries = useCallback(() => { - Alert.alert( - "Delete season", - "Are you sure you want to delete the entire season?", - [ - { - text: "Cancel", - style: "cancel", - }, - { - text: "Delete", - onPress: () => - deleteItems( - groupBySeason - .map((item) => item.Id) - .filter((id) => id !== undefined), - ), - style: "destructive", - }, - ], - ); - }, [groupBySeason, deleteItems]); - - const ListHeaderComponent = useCallback(() => { - if (series.length === 0) return null; - - return ( - - { - setSeasonIndexState((prev) => ({ - ...prev, - [series[0].item.ParentId ?? ""]: season.ParentIndexNumber, - })); - }} - /> - - {groupBySeason.length} - - - - - - - - ); - }, [ - series, - uniqueSeasons, - seasonIndexState, - initialSeasonIndex, - groupBySeason, - deleteSeries, - ]); - - return ( - - } - keyExtractor={(item, index) => item.Id ?? `episode-${index}`} - ListHeaderComponent={ListHeaderComponent} - contentInsetAdjustmentBehavior='automatic' - contentContainerStyle={{ - paddingHorizontal: 16, - paddingLeft: insets.left + 16, - paddingRight: insets.right + 16, - paddingTop: Platform.OS === "android" ? 10 : 8, - }} - /> - - ); -} diff --git a/app/(auth)/(tabs)/(home)/downloads/index.tsx b/app/(auth)/(tabs)/(home)/downloads/index.tsx index c71bebd85..68340e6bf 100644 --- a/app/(auth)/(tabs)/(home)/downloads/index.tsx +++ b/app/(auth)/(tabs)/(home)/downloads/index.tsx @@ -1,29 +1,32 @@ -import { BottomSheetModal } from "@gorhom/bottom-sheet"; -import { useNavigation, useRouter } from "expo-router"; +import { + BottomSheetBackdrop, + type BottomSheetBackdropProps, + BottomSheetModal, + BottomSheetView, +} from "@gorhom/bottom-sheet"; +import { useNavigation } from "expo-router"; import { useAtom } from "jotai"; import { useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { - Alert, - Platform, - ScrollView, - TouchableOpacity, - View, -} from "react-native"; +import { Alert, Platform, ScrollView, View } from "react-native"; +import { Pressable } from "react-native-gesture-handler"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { toast } from "sonner-native"; +import { Button } from "@/components/Button"; import { Text } from "@/components/common/Text"; import { TouchableItemRouter } from "@/components/common/TouchableItemRouter"; import ActiveDownloads from "@/components/downloads/ActiveDownloads"; import { DownloadSize } from "@/components/downloads/DownloadSize"; import { MovieCard } from "@/components/downloads/MovieCard"; import { SeriesCard } from "@/components/downloads/SeriesCard"; +import useRouter from "@/hooks/useAppRouter"; import { useDownload } from "@/providers/DownloadProvider"; import { type DownloadedItem } from "@/providers/Downloads/types"; +import { OfflineModeProvider } from "@/providers/OfflineModeProvider"; import { queueAtom } from "@/utils/atoms/queue"; import { writeToLog } from "@/utils/log"; -export default function page() { +export default function DownloadsPage() { const navigation = useNavigation(); const { t } = useTranslation(); const [_queue, _setQueue] = useAtom(queueAtom); @@ -103,12 +106,12 @@ export default function page() { useEffect(() => { navigation.setOptions({ headerRight: () => ( - bottomSheetModalRef.current?.present()} className='px-2' > f.item) || []} /> - + ), }); }, [downloadedFiles]); @@ -119,7 +122,7 @@ export default function page() { } }, [showMigration]); - const _deleteMovies = () => + const deleteMovies = () => deleteFileByType("Movie") .then(() => toast.success( @@ -130,18 +133,18 @@ export default function page() { writeToLog("ERROR", reason); toast.error(t("home.downloads.toasts.failed_to_delete_all_movies")); }); - const _deleteShows = () => + const deleteShows = () => deleteFileByType("Episode") .then(() => toast.success( - t("home.downloads.toasts.deleted_all_tvseries_successfully"), + t("home.downloads.toasts.deleted_all_series_successfully"), ), ) .catch((reason) => { writeToLog("ERROR", reason); - toast.error(t("home.downloads.toasts.failed_to_delete_all_tvseries")); + toast.error(t("home.downloads.toasts.failed_to_delete_all_series")); }); - const _deleteOtherMedia = () => + const deleteOtherMedia = () => Promise.all( otherMedia .filter((item) => item.item.Type) @@ -165,146 +168,139 @@ export default function page() { ), ); + const deleteAllMedia = async () => + await Promise.all([deleteMovies(), deleteShows(), deleteOtherMedia()]); + return ( - - - - {/* Queue card - hidden */} - {/* + + + + + + + + {movies.length > 0 && ( + + - {t("home.downloads.queue")} + {t("home.downloads.movies")} - - {t("home.downloads.queue_hint")} - - - {queue.map((q, index) => ( - - router.push(`/(auth)/items/page?id=${q.item.Id}`) - } - className='relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between' - key={index} - > - - {q.item.Name} - - {q.item.Type} - - - { - removeProcess(q.id); - setQueue((prev) => { - if (!prev) return []; - return [...prev.filter((i) => i.id !== q.id)]; - }); - }} - > - - - + + {movies?.length} + + + + + {movies?.map((item) => ( + + + ))} - - {queue.length === 0 && ( - - {t("home.downloads.no_items_in_queue")} - - )} - */} - - - - - {movies.length > 0 && ( - - - - {t("home.downloads.movies")} - - - {movies?.length} - + - - - {movies?.map((item) => ( - - - - ))} - - - - )} - {groupedBySeries.length > 0 && ( - - - - {t("home.downloads.tvseries")} - - - - {groupedBySeries?.length} + )} + {groupedBySeries.length > 0 && ( + + + + {t("home.downloads.series")} + + + {groupedBySeries?.length} + + - - - - {groupedBySeries?.map((items) => ( - - i.item)} + + + {groupedBySeries?.map((items) => ( + - - ))} - - - - )} - - {otherMedia.length > 0 && ( - - - - {t("home.downloads.other_media")} - - - {otherMedia?.length} - + > + i.item)} + key={items[0].item.SeriesId} + /> + + ))} + + - - - {otherMedia?.map((item) => ( - - - - ))} + )} + + {otherMedia.length > 0 && ( + + + + {t("home.downloads.other_media")} + + + + {otherMedia?.length} + + - - + + + {otherMedia?.map((item) => ( + + + + ))} + + + + )} + {downloadedFiles?.length === 0 && ( + + + {t("home.downloads.no_downloaded_items")} + + + )} + + + ( + )} - {downloadedFiles?.length === 0 && ( - - - {t("home.downloads.no_downloaded_items")} - + > + + + + + {otherMedia.length > 0 && ( + + )} + - )} - - + + + ); } diff --git a/app/(auth)/(tabs)/(home)/index.tsx b/app/(auth)/(tabs)/(home)/index.tsx index ad951c36f..9586d465d 100644 --- a/app/(auth)/(tabs)/(home)/index.tsx +++ b/app/(auth)/(tabs)/(home)/index.tsx @@ -1,15 +1,6 @@ -import { useSettings } from "@/utils/atoms/settings"; import { Home } from "../../../../components/home/Home"; -import { HomeWithCarousel } from "../../../../components/home/HomeWithCarousel"; const Index = () => { - const { settings } = useSettings(); - const showLargeHomeCarousel = settings.showLargeHomeCarousel ?? false; - - if (showLargeHomeCarousel) { - return ; - } - return ; }; diff --git a/app/(auth)/(tabs)/(home)/intro/page.tsx b/app/(auth)/(tabs)/(home)/intro/page.tsx deleted file mode 100644 index 572a4980f..000000000 --- a/app/(auth)/(tabs)/(home)/intro/page.tsx +++ /dev/null @@ -1,154 +0,0 @@ -import { Feather, Ionicons } from "@expo/vector-icons"; -import { Image } from "expo-image"; -import { useFocusEffect, useRouter } from "expo-router"; -import { useCallback } from "react"; -import { useTranslation } from "react-i18next"; -import { Linking, Platform, TouchableOpacity, View } from "react-native"; -import { Button } from "@/components/Button"; -import { Text } from "@/components/common/Text"; -import { storage } from "@/utils/mmkv"; - -export default function page() { - const router = useRouter(); - const { t } = useTranslation(); - - useFocusEffect( - useCallback(() => { - storage.set("hasShownIntro", true); - }, []), - ); - - return ( - - - - {t("home.intro.welcome_to_streamyfin")} - - - {t("home.intro.a_free_and_open_source_client_for_jellyfin")} - - - - - - {t("home.intro.features_title")} - - {t("home.intro.features_description")} - - - - Jellyseerr - - {t("home.intro.jellyseerr_feature_description")} - - - - {!Platform.isTV && ( - <> - - - - - - - {t("home.intro.downloads_feature_title")} - - - {t("home.intro.downloads_feature_description")} - - - - - - - - - Chromecast - - {t("home.intro.chromecast_feature_description")} - - - - - )} - - - - - - - {t("home.intro.centralised_settings_plugin_title")} - - - - {t("home.intro.centralised_settings_plugin_description")}{" "} - - { - Linking.openURL( - "https://github.com/streamyfin/jellyfin-plugin-streamyfin", - ); - }} - > - - {t("home.intro.read_more")} - - - - - - - - - { - router.back(); - router.push("/settings"); - }} - className='mt-4' - > - - {t("home.intro.go_to_settings_button")} - - - - - ); -} diff --git a/app/(auth)/(tabs)/(home)/sessions/index.tsx b/app/(auth)/(tabs)/(home)/sessions/index.tsx index 0ed8fc940..d8a3590d5 100644 --- a/app/(auth)/(tabs)/(home)/sessions/index.tsx +++ b/app/(auth)/(tabs)/(home)/sessions/index.tsx @@ -23,7 +23,7 @@ import { formatBitrate } from "@/utils/bitrate"; import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; import { formatTimeString } from "@/utils/time"; -export default function page() { +export default function SessionsPage() { const { sessions, isLoading } = useSessions({} as useSessionsProps); const { t } = useTranslation(); @@ -72,7 +72,7 @@ const SessionCard = ({ session }: SessionCardProps) => { }; const getProgressPercentage = () => { - if (!session.NowPlayingItem || !session.NowPlayingItem.RunTimeTicks) { + if (!session.NowPlayingItem?.RunTimeTicks) { return 0; } diff --git a/app/(auth)/(tabs)/(home)/settings.tsx b/app/(auth)/(tabs)/(home)/settings.tsx index 4bd917d65..69b980d3e 100644 --- a/app/(auth)/(tabs)/(home)/settings.tsx +++ b/app/(auth)/(tabs)/(home)/settings.tsx @@ -1,4 +1,4 @@ -import { useNavigation, useRouter } from "expo-router"; +import { useNavigation } from "expo-router"; import { t } from "i18next"; import { useAtom } from "jotai"; import { useEffect } from "react"; @@ -11,9 +11,14 @@ import { AppLanguageSelector } from "@/components/settings/AppLanguageSelector"; import { QuickConnect } from "@/components/settings/QuickConnect"; import { StorageSettings } from "@/components/settings/StorageSettings"; import { UserInfo } from "@/components/settings/UserInfo"; +import useRouter from "@/hooks/useAppRouter"; import { useJellyfin, userAtom } from "@/providers/JellyfinProvider"; -export default function settings() { +// TV-specific settings component +const SettingsTV = Platform.isTV ? require("./settings.tv").default : null; + +// Mobile settings component +function SettingsMobile() { const router = useRouter(); const insets = useSafeAreaInsets(); const [_user] = useAtom(userAtom); @@ -54,6 +59,20 @@ export default function settings() { + {Platform.OS !== "ios" && ( + + + + router.push("/(auth)/(tabs)/(home)/companion-login") + } + title={t("pairing.pair_with_phone")} + textColor='blue' + /> + + + )} + @@ -70,6 +89,11 @@ export default function settings() { showArrow title={t("home.settings.audio_subtitles.title")} /> + router.push("/settings/music/page")} + showArrow + title={t("home.settings.music.title")} + /> router.push("/settings/appearance/page")} showArrow @@ -85,6 +109,11 @@ export default function settings() { showArrow title={t("home.settings.intro.title")} /> + router.push("/settings/network/page")} + showArrow + title={t("home.settings.network.title")} + /> router.push("/settings/logs/page")} showArrow @@ -93,8 +122,17 @@ export default function settings() {
- {!Platform.isTV && } + ); } + +export default function settings() { + // Use TV settings component on TV platforms + if (Platform.isTV && SettingsTV) { + return ; + } + + return ; +} diff --git a/app/(auth)/(tabs)/(home)/settings.tv.tsx b/app/(auth)/(tabs)/(home)/settings.tv.tsx new file mode 100644 index 000000000..a9a2e2fbb --- /dev/null +++ b/app/(auth)/(tabs)/(home)/settings.tv.tsx @@ -0,0 +1,943 @@ +import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client"; +import { useQueryClient } from "@tanstack/react-query"; +import { Directory, Paths } from "expo-file-system"; +import { Image } from "expo-image"; +import { useAtom } from "jotai"; +import { useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Alert, ScrollView, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Text } from "@/components/common/Text"; +import { TVPasswordEntryModal } from "@/components/login/TVPasswordEntryModal"; +import { TVPINEntryModal } from "@/components/login/TVPINEntryModal"; +import type { TVOptionItem } from "@/components/tv"; +import { + TVLogoutButton, + TVSectionHeader, + TVSettingsOptionButton, + TVSettingsRow, + TVSettingsStepper, + TVSettingsTextInput, + TVSettingsToggle, +} from "@/components/tv"; +import { useScaledTVTypography } from "@/constants/TVTypography"; +import { useTVOptionModal } from "@/hooks/useTVOptionModal"; +import { useTVUserSwitchModal } from "@/hooks/useTVUserSwitchModal"; +import { APP_LANGUAGES } from "@/i18n"; +import { clearCache as clearAudioCache } from "@/providers/AudioStorage"; +import { + apiAtom, + cacheVersionAtom, + useJellyfin, + userAtom, +} from "@/providers/JellyfinProvider"; +import { + AudioTranscodeMode, + InactivityTimeout, + type MpvCacheMode, + type MpvVoDriver, + TVTypographyScale, + useSettings, +} from "@/utils/atoms/settings"; +import { storage } from "@/utils/mmkv"; +import { + getPreviousServers, + type SavedServer, + type SavedServerAccount, +} from "@/utils/secureCredentials"; +import { clearTopShelfCacheSafely } from "@/utils/topshelf/cache"; + +export default function SettingsTV() { + const { t } = useTranslation(); + const insets = useSafeAreaInsets(); + const { settings, updateSettings } = useSettings(); + const { logout, loginWithSavedCredential, loginWithPassword } = useJellyfin(); + const [user] = useAtom(userAtom); + const [api] = useAtom(apiAtom); + const [, setCacheVersion] = useAtom(cacheVersionAtom); + const { showOptions } = useTVOptionModal(); + const { showUserSwitchModal } = useTVUserSwitchModal(); + const typography = useScaledTVTypography(); + const queryClient = useQueryClient(); + + // Local state for OpenSubtitles API key (only commit on blur) + const [openSubtitlesApiKey, setOpenSubtitlesApiKey] = useState( + settings.openSubtitlesApiKey || "", + ); + + // PIN/Password modal state for user switching + const [pinModalVisible, setPinModalVisible] = useState(false); + const [passwordModalVisible, setPasswordModalVisible] = useState(false); + const [selectedServer, setSelectedServer] = useState( + null, + ); + const [selectedAccount, setSelectedAccount] = + useState(null); + + // Track if any modal is open to disable background focus + const isAnyModalOpen = pinModalVisible || passwordModalVisible; + + // Get current server and other accounts + const currentServer = useMemo(() => { + if (!api?.basePath) return null; + const servers = getPreviousServers(); + return servers.find((s) => s.address === api.basePath) || null; + }, [api?.basePath]); + + const otherAccounts = useMemo(() => { + if (!currentServer || !user?.Id) return []; + return currentServer.accounts.filter( + (account) => account.userId !== user.Id, + ); + }, [currentServer, user?.Id]); + + const hasOtherAccounts = otherAccounts.length > 0; + + // Handle account selection from modal + const handleAccountSelect = async (account: SavedServerAccount) => { + if (!currentServer) return; + + if (account.securityType === "none") { + // Direct login with saved credential + try { + await loginWithSavedCredential(currentServer.address, account.userId); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : t("server.session_expired"); + const isSessionExpired = errorMessage.includes( + t("server.session_expired"), + ); + Alert.alert( + isSessionExpired + ? t("server.session_expired") + : t("login.connection_failed"), + isSessionExpired ? t("server.please_login_again") : errorMessage, + ); + } + } else if (account.securityType === "pin") { + // Show PIN modal + setSelectedServer(currentServer); + setSelectedAccount(account); + setPinModalVisible(true); + } else if (account.securityType === "password") { + // Show password modal + setSelectedServer(currentServer); + setSelectedAccount(account); + setPasswordModalVisible(true); + } + }; + + // Handle successful PIN entry + const handlePinSuccess = async () => { + setPinModalVisible(false); + if (selectedServer && selectedAccount) { + try { + await loginWithSavedCredential( + selectedServer.address, + selectedAccount.userId, + ); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : t("server.session_expired"); + const isSessionExpired = errorMessage.includes( + t("server.session_expired"), + ); + Alert.alert( + isSessionExpired + ? t("server.session_expired") + : t("login.connection_failed"), + isSessionExpired ? t("server.please_login_again") : errorMessage, + ); + } + } + setSelectedServer(null); + setSelectedAccount(null); + }; + + // Handle password submission + const handlePasswordSubmit = async (password: string) => { + if (selectedServer && selectedAccount) { + await loginWithPassword( + selectedServer.address, + selectedAccount.username, + password, + ); + } + setPasswordModalVisible(false); + setSelectedServer(null); + setSelectedAccount(null); + }; + + // Handle switch user button press + const handleSwitchUser = () => { + if (!currentServer || !user?.Id) return; + showUserSwitchModal(currentServer, user.Id, { + onAccountSelect: handleAccountSelect, + }); + }; + + // Handle clearing all cache in the entire app + const handleClearCache = async () => { + Alert.alert( + t("home.settings.storage.clear_all_cache_confirm"), + t("home.settings.storage.clear_all_cache_confirm_desc"), + [ + { + text: t("common.cancel"), + style: "cancel", + }, + { + text: t("common.ok"), + onPress: async () => { + try { + // 1. Clear React Query Cache (memory & MMKV) + storage.remove("REACT_QUERY_OFFLINE_CACHE"); + await queryClient.resetQueries(); + + // 2. Clear expo-image cache (memory & disk) + await Image.clearDiskCache(); + Image.clearMemoryCache(); + + // 3. Clear AudioStorage (music) cache + await clearAudioCache(); + + // 4. Clear TopShelf cache + clearTopShelfCacheSafely(); + + // 5. Clear Subtitle Cache + storage.remove("downloadedSubtitles.json"); + const subtitlesDir = new Directory( + Paths.cache, + "streamyfin-subtitles", + ); + if (subtitlesDir.exists) { + await subtitlesDir.delete(); + } + + // 6. Clear MMKV caches like extracted image colors and other non-essential storage keys + const keysToKeep = [ + "settings", + "serverUrl", + "token", + "user", + "deviceId", + "previousServers", + "hasAskedForNotificationPermission", + "hasShownIntro", + "multiAccountMigrated", + "selectedTVServer", + "downloads.v2.json", + ]; + const allKeys = storage.getAllKeys(); + for (const key of allKeys) { + if (!keysToKeep.includes(key)) { + storage.remove(key); + } + } + + // 7. Increment cache version to force remount of components + setCacheVersion((v) => v + 1); + } catch (error) { + console.error("Failed to clear cache:", error); + Alert.alert( + t("home.settings.toasts.error_deleting_files"), + t("home.settings.storage.clear_all_cache_error_desc"), + ); + } + }, + }, + ], + ); + }; + + const currentAudioTranscode = + settings.audioTranscodeMode || AudioTranscodeMode.Auto; + const currentSubtitleMode = + settings.subtitleMode || SubtitlePlaybackMode.Default; + const currentAlignX = settings.mpvSubtitleAlignX ?? "center"; + const currentAlignY = settings.mpvSubtitleAlignY ?? "bottom"; + const currentTypographyScale = + settings.tvTypographyScale || TVTypographyScale.Default; + const currentCacheMode = settings.mpvCacheEnabled ?? "auto"; + const currentVoDriver = settings.mpvVoDriver ?? "gpu-next"; + const currentLanguage = settings.preferedLanguage; + + // Audio transcoding options + const audioTranscodeModeOptions: TVOptionItem[] = useMemo( + () => [ + { + label: t("home.settings.audio.transcode_mode.auto"), + value: AudioTranscodeMode.Auto, + selected: currentAudioTranscode === AudioTranscodeMode.Auto, + }, + { + label: t("home.settings.audio.transcode_mode.stereo"), + value: AudioTranscodeMode.ForceStereo, + selected: currentAudioTranscode === AudioTranscodeMode.ForceStereo, + }, + { + label: t("home.settings.audio.transcode_mode.5_1"), + value: AudioTranscodeMode.Allow51, + selected: currentAudioTranscode === AudioTranscodeMode.Allow51, + }, + { + label: t("home.settings.audio.transcode_mode.passthrough"), + value: AudioTranscodeMode.AllowAll, + selected: currentAudioTranscode === AudioTranscodeMode.AllowAll, + }, + ], + [t, currentAudioTranscode], + ); + + // Subtitle mode options + const subtitleModeOptions: TVOptionItem[] = useMemo( + () => [ + { + label: t("home.settings.subtitles.modes.Default"), + value: SubtitlePlaybackMode.Default, + selected: currentSubtitleMode === SubtitlePlaybackMode.Default, + }, + { + label: t("home.settings.subtitles.modes.Smart"), + value: SubtitlePlaybackMode.Smart, + selected: currentSubtitleMode === SubtitlePlaybackMode.Smart, + }, + { + label: t("home.settings.subtitles.modes.OnlyForced"), + value: SubtitlePlaybackMode.OnlyForced, + selected: currentSubtitleMode === SubtitlePlaybackMode.OnlyForced, + }, + { + label: t("home.settings.subtitles.modes.Always"), + value: SubtitlePlaybackMode.Always, + selected: currentSubtitleMode === SubtitlePlaybackMode.Always, + }, + { + label: t("home.settings.subtitles.modes.None"), + value: SubtitlePlaybackMode.None, + selected: currentSubtitleMode === SubtitlePlaybackMode.None, + }, + ], + [t, currentSubtitleMode], + ); + + // MPV alignment options + const alignXOptions: TVOptionItem[] = useMemo( + () => [ + { label: "Left", value: "left", selected: currentAlignX === "left" }, + { + label: "Center", + value: "center", + selected: currentAlignX === "center", + }, + { label: "Right", value: "right", selected: currentAlignX === "right" }, + ], + [currentAlignX], + ); + + const alignYOptions: TVOptionItem[] = useMemo( + () => [ + { label: "Top", value: "top", selected: currentAlignY === "top" }, + { + label: "Center", + value: "center", + selected: currentAlignY === "center", + }, + { + label: "Bottom", + value: "bottom", + selected: currentAlignY === "bottom", + }, + ], + [currentAlignY], + ); + + // Cache mode options + const cacheModeOptions: TVOptionItem[] = useMemo( + () => [ + { + label: t("home.settings.buffer.cache_auto"), + value: "auto", + selected: currentCacheMode === "auto", + }, + { + label: t("home.settings.buffer.cache_yes"), + value: "yes", + selected: currentCacheMode === "yes", + }, + { + label: t("home.settings.buffer.cache_no"), + value: "no", + selected: currentCacheMode === "no", + }, + ], + [t, currentCacheMode], + ); + + // VO driver options + const voDriverOptions: TVOptionItem[] = useMemo( + () => [ + { + label: t("home.settings.vo_driver.gpu_next"), + value: "gpu-next", + selected: currentVoDriver === "gpu-next", + }, + { + label: t("home.settings.vo_driver.gpu"), + value: "gpu", + selected: currentVoDriver === "gpu", + }, + ], + [t, currentVoDriver], + ); + + // Typography scale options + const typographyScaleOptions: TVOptionItem[] = useMemo( + () => [ + { + label: t("home.settings.appearance.display_size_small"), + value: TVTypographyScale.Small, + selected: currentTypographyScale === TVTypographyScale.Small, + }, + { + label: t("home.settings.appearance.display_size_default"), + value: TVTypographyScale.Default, + selected: currentTypographyScale === TVTypographyScale.Default, + }, + { + label: t("home.settings.appearance.display_size_large"), + value: TVTypographyScale.Large, + selected: currentTypographyScale === TVTypographyScale.Large, + }, + { + label: t("home.settings.appearance.display_size_extra_large"), + value: TVTypographyScale.ExtraLarge, + selected: currentTypographyScale === TVTypographyScale.ExtraLarge, + }, + ], + [t, currentTypographyScale], + ); + + // Language options + const languageOptions: TVOptionItem[] = useMemo( + () => [ + { + label: t("home.settings.languages.system"), + value: undefined, + selected: !currentLanguage, + }, + ...APP_LANGUAGES.map((lang) => ({ + label: lang.label, + value: lang.value, + selected: currentLanguage === lang.value, + })), + ], + [t, currentLanguage], + ); + + // Inactivity timeout options (TV security feature) + const currentInactivityTimeout = + settings.inactivityTimeout ?? InactivityTimeout.Disabled; + + const inactivityTimeoutOptions: TVOptionItem[] = useMemo( + () => [ + { + label: t("home.settings.security.inactivity_timeout.disabled"), + value: InactivityTimeout.Disabled, + selected: currentInactivityTimeout === InactivityTimeout.Disabled, + }, + { + label: t("home.settings.security.inactivity_timeout.1_minute"), + value: InactivityTimeout.OneMinute, + selected: currentInactivityTimeout === InactivityTimeout.OneMinute, + }, + { + label: t("home.settings.security.inactivity_timeout.5_minutes"), + value: InactivityTimeout.FiveMinutes, + selected: currentInactivityTimeout === InactivityTimeout.FiveMinutes, + }, + { + label: t("home.settings.security.inactivity_timeout.15_minutes"), + value: InactivityTimeout.FifteenMinutes, + selected: currentInactivityTimeout === InactivityTimeout.FifteenMinutes, + }, + { + label: t("home.settings.security.inactivity_timeout.30_minutes"), + value: InactivityTimeout.ThirtyMinutes, + selected: currentInactivityTimeout === InactivityTimeout.ThirtyMinutes, + }, + { + label: t("home.settings.security.inactivity_timeout.1_hour"), + value: InactivityTimeout.OneHour, + selected: currentInactivityTimeout === InactivityTimeout.OneHour, + }, + { + label: t("home.settings.security.inactivity_timeout.4_hours"), + value: InactivityTimeout.FourHours, + selected: currentInactivityTimeout === InactivityTimeout.FourHours, + }, + { + label: t("home.settings.security.inactivity_timeout.24_hours"), + value: InactivityTimeout.TwentyFourHours, + selected: + currentInactivityTimeout === InactivityTimeout.TwentyFourHours, + }, + ], + [t, currentInactivityTimeout], + ); + + // Get display labels for option buttons + const audioTranscodeLabel = useMemo(() => { + const option = audioTranscodeModeOptions.find((o) => o.selected); + return option?.label || t("home.settings.audio.transcode_mode.auto"); + }, [audioTranscodeModeOptions, t]); + + const subtitleModeLabel = useMemo(() => { + const option = subtitleModeOptions.find((o) => o.selected); + return option?.label || t("home.settings.subtitles.modes.Default"); + }, [subtitleModeOptions, t]); + + const alignXLabel = useMemo(() => { + const option = alignXOptions.find((o) => o.selected); + return option?.label || "Center"; + }, [alignXOptions]); + + const alignYLabel = useMemo(() => { + const option = alignYOptions.find((o) => o.selected); + return option?.label || "Bottom"; + }, [alignYOptions]); + + const typographyScaleLabel = useMemo(() => { + const option = typographyScaleOptions.find((o) => o.selected); + return option?.label || t("home.settings.appearance.display_size_default"); + }, [typographyScaleOptions, t]); + + const cacheModeLabel = useMemo(() => { + const option = cacheModeOptions.find((o) => o.selected); + return option?.label || t("home.settings.buffer.cache_auto"); + }, [cacheModeOptions, t]); + + const voDriverLabel = useMemo(() => { + const option = voDriverOptions.find((o) => o.selected); + return option?.label || t("home.settings.vo_driver.gpu_next"); + }, [voDriverOptions, t]); + + const languageLabel = useMemo(() => { + if (!currentLanguage) return t("home.settings.languages.system"); + const option = APP_LANGUAGES.find((l) => l.value === currentLanguage); + return option?.label || t("home.settings.languages.system"); + }, [currentLanguage, t]); + + const inactivityTimeoutLabel = useMemo(() => { + const option = inactivityTimeoutOptions.find((o) => o.selected); + return ( + option?.label || t("home.settings.security.inactivity_timeout.disabled") + ); + }, [inactivityTimeoutOptions, t]); + + return ( + + + + {/* Header */} + + {t("home.settings.settings_title")} + + + {/* Account Section */} + + + + {/* Security Section */} + + + showOptions({ + title: t("home.settings.security.inactivity_timeout.title"), + options: inactivityTimeoutOptions, + onSelect: (value) => + updateSettings({ inactivityTimeout: value }), + }) + } + /> + + {/* Audio Section */} + + + showOptions({ + title: t("home.settings.audio.transcode_mode.title"), + options: audioTranscodeModeOptions, + onSelect: (value) => + updateSettings({ audioTranscodeMode: value }), + }) + } + /> + + {/* Subtitles Section */} + + + showOptions({ + title: t("home.settings.subtitles.subtitle_mode"), + options: subtitleModeOptions, + onSelect: (value) => updateSettings({ subtitleMode: value }), + }) + } + /> + + updateSettings({ rememberSubtitleSelections: value }) + } + /> + { + const newValue = Math.max( + 0.1, + (settings.mpvSubtitleScale ?? 1.0) - 0.1, + ); + updateSettings({ + mpvSubtitleScale: Math.round(newValue * 10) / 10, + }); + }} + onIncrease={() => { + const newValue = Math.min( + 3.0, + (settings.mpvSubtitleScale ?? 1.0) + 0.1, + ); + updateSettings({ + mpvSubtitleScale: Math.round(newValue * 10) / 10, + }); + }} + formatValue={(v) => `${v.toFixed(1)}x`} + /> + { + const newValue = Math.max( + 0, + (settings.mpvSubtitleMarginY ?? 0) - 5, + ); + updateSettings({ mpvSubtitleMarginY: newValue }); + }} + onIncrease={() => { + const newValue = Math.min( + 100, + (settings.mpvSubtitleMarginY ?? 0) + 5, + ); + updateSettings({ mpvSubtitleMarginY: newValue }); + }} + /> + + showOptions({ + title: "Horizontal Alignment", + options: alignXOptions, + onSelect: (value) => + updateSettings({ + mpvSubtitleAlignX: value as "left" | "center" | "right", + }), + }) + } + /> + + showOptions({ + title: "Vertical Alignment", + options: alignYOptions, + onSelect: (value) => + updateSettings({ + mpvSubtitleAlignY: value as "top" | "center" | "bottom", + }), + }) + } + /> + + {/* OpenSubtitles Section */} + + + {t("home.settings.subtitles.opensubtitles_hint") || + "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured."} + + updateSettings({ openSubtitlesApiKey })} + secureTextEntry + /> + + {t("home.settings.subtitles.opensubtitles_get_key") || + "Get your free API key at opensubtitles.com/en/consumers"} + + + {/* Buffer Settings Section */} + + + showOptions({ + title: t("home.settings.buffer.cache_mode"), + options: cacheModeOptions, + onSelect: (value) => updateSettings({ mpvCacheEnabled: value }), + }) + } + /> + + {/* Video Output Section */} + + + showOptions({ + title: t("home.settings.vo_driver.vo_mode"), + options: voDriverOptions, + onSelect: (value) => updateSettings({ mpvVoDriver: value }), + }) + } + /> + { + const newValue = Math.max( + 5, + (settings.mpvCacheSeconds ?? 10) - 5, + ); + updateSettings({ mpvCacheSeconds: newValue }); + }} + onIncrease={() => { + const newValue = Math.min( + 120, + (settings.mpvCacheSeconds ?? 10) + 5, + ); + updateSettings({ mpvCacheSeconds: newValue }); + }} + formatValue={(v) => `${v}s`} + /> + { + const newValue = Math.max( + 50, + (settings.mpvDemuxerMaxBytes ?? 150) - 25, + ); + updateSettings({ mpvDemuxerMaxBytes: newValue }); + }} + onIncrease={() => { + const newValue = Math.min( + 500, + (settings.mpvDemuxerMaxBytes ?? 150) + 25, + ); + updateSettings({ mpvDemuxerMaxBytes: newValue }); + }} + formatValue={(v) => `${v} MB`} + /> + { + const newValue = Math.max( + 25, + (settings.mpvDemuxerMaxBackBytes ?? 50) - 25, + ); + updateSettings({ mpvDemuxerMaxBackBytes: newValue }); + }} + onIncrease={() => { + const newValue = Math.min( + 200, + (settings.mpvDemuxerMaxBackBytes ?? 50) + 25, + ); + updateSettings({ mpvDemuxerMaxBackBytes: newValue }); + }} + formatValue={(v) => `${v} MB`} + /> + + {/* Appearance Section */} + + + showOptions({ + title: t("home.settings.appearance.display_size"), + options: typographyScaleOptions, + onSelect: (value) => + updateSettings({ tvTypographyScale: value }), + }) + } + /> + + showOptions({ + title: t("home.settings.languages.app_language"), + options: languageOptions, + onSelect: (value) => + updateSettings({ preferedLanguage: value }), + }) + } + /> + + updateSettings({ mergeNextUpAndContinueWatching: value }) + } + /> + updateSettings({ showHomeBackdrop: value })} + /> + updateSettings({ showTVHeroCarousel: value })} + /> + + updateSettings({ showSeriesPosterOnEpisode: value }) + } + /> + updateSettings({ tvThemeMusicEnabled: value })} + /> + + {/* Storage Section */} + + + + {/* User Section */} + + + + + {/* Logout Button */} + + + + + + + {/* PIN Entry Modal */} + { + setPinModalVisible(false); + setSelectedAccount(null); + setSelectedServer(null); + }} + onSuccess={handlePinSuccess} + onForgotPIN={() => { + setPinModalVisible(false); + setSelectedAccount(null); + setSelectedServer(null); + }} + serverUrl={selectedServer?.address || ""} + userId={selectedAccount?.userId || ""} + username={selectedAccount?.username || ""} + /> + + {/* Password Entry Modal */} + { + setPasswordModalVisible(false); + setSelectedAccount(null); + setSelectedServer(null); + }} + onSubmit={handlePasswordSubmit} + username={selectedAccount?.username || ""} + /> + + ); +} diff --git a/app/(auth)/(tabs)/(home)/settings/appearance/hide-libraries/page.tsx b/app/(auth)/(tabs)/(home)/settings/appearance/hide-libraries/page.tsx index a0b3bab9b..d0109fb7e 100644 --- a/app/(auth)/(tabs)/(home)/settings/appearance/hide-libraries/page.tsx +++ b/app/(auth)/(tabs)/(home)/settings/appearance/hide-libraries/page.tsx @@ -12,7 +12,7 @@ import DisabledSetting from "@/components/settings/DisabledSetting"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { useSettings } from "@/utils/atoms/settings"; -export default function page() { +export default function AppearanceHideLibrariesPage() { const { settings, updateSettings, pluginSettings } = useSettings(); const user = useAtomValue(userAtom); const api = useAtomValue(apiAtom); @@ -71,7 +71,7 @@ export default function page() { ))} - {t("home.settings.other.select_liraries_you_want_to_hide")} + {t("home.settings.other.select_libraries_you_want_to_hide")} diff --git a/app/(auth)/(tabs)/(home)/settings/audio-subtitles/page.tsx b/app/(auth)/(tabs)/(home)/settings/audio-subtitles/page.tsx index 58415127e..7c5f38d85 100644 --- a/app/(auth)/(tabs)/(home)/settings/audio-subtitles/page.tsx +++ b/app/(auth)/(tabs)/(home)/settings/audio-subtitles/page.tsx @@ -2,6 +2,7 @@ import { Platform, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AudioToggles } from "@/components/settings/AudioToggles"; import { MediaProvider } from "@/components/settings/MediaContext"; +import { MpvSubtitleSettings } from "@/components/settings/MpvSubtitleSettings"; import { SubtitleToggles } from "@/components/settings/SubtitleToggles"; export default function AudioSubtitlesPage() { @@ -22,6 +23,7 @@ export default function AudioSubtitlesPage() { + diff --git a/app/(auth)/(tabs)/(home)/settings/hide-libraries/page.tsx b/app/(auth)/(tabs)/(home)/settings/hide-libraries/page.tsx index e1c8b56b6..fe777462f 100644 --- a/app/(auth)/(tabs)/(home)/settings/hide-libraries/page.tsx +++ b/app/(auth)/(tabs)/(home)/settings/hide-libraries/page.tsx @@ -11,7 +11,7 @@ import DisabledSetting from "@/components/settings/DisabledSetting"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { useSettings } from "@/utils/atoms/settings"; -export default function page() { +export default function HideLibrariesPage() { const { settings, updateSettings, pluginSettings } = useSettings(); const user = useAtomValue(userAtom); const api = useAtomValue(apiAtom); @@ -60,7 +60,7 @@ export default function page() { ))} - {t("home.settings.other.select_liraries_you_want_to_hide")} + {t("home.settings.other.select_libraries_you_want_to_hide")} ); diff --git a/app/(auth)/(tabs)/(home)/settings/intro/page.tsx b/app/(auth)/(tabs)/(home)/settings/intro/page.tsx index 4cb43e6d6..6e04777b9 100644 --- a/app/(auth)/(tabs)/(home)/settings/intro/page.tsx +++ b/app/(auth)/(tabs)/(home)/settings/intro/page.tsx @@ -1,13 +1,13 @@ -import { useRouter } from "expo-router"; import { useTranslation } from "react-i18next"; import { Platform, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { ListGroup } from "@/components/list/ListGroup"; import { ListItem } from "@/components/list/ListItem"; +import { useIntroSheet } from "@/providers/IntroSheetProvider"; import { storage } from "@/utils/mmkv"; export default function IntroPage() { - const router = useRouter(); + const { showIntro } = useIntroSheet(); const insets = useSafeAreaInsets(); const { t } = useTranslation(); @@ -26,7 +26,7 @@ export default function IntroPage() { { - router.push("/intro/page"); + showIntro(); }} title={t("home.settings.intro.show_intro")} /> diff --git a/app/(auth)/(tabs)/(home)/settings/logs/page.tsx b/app/(auth)/(tabs)/(home)/settings/logs/page.tsx index 878319256..4ce5736cd 100644 --- a/app/(auth)/(tabs)/(home)/settings/logs/page.tsx +++ b/app/(auth)/(tabs)/(home)/settings/logs/page.tsx @@ -61,7 +61,10 @@ export default function Page() { setLoading(true); try { logsFile.write(JSON.stringify(filteredLogs)); - await Sharing.shareAsync(logsFile.uri, { mimeType: "txt", UTI: "txt" }); + await Sharing.shareAsync(logsFile.uri, { + mimeType: "text/plain", + UTI: "public.plain-text", + }); } catch (e: any) { writeErrorLog("Something went wrong attempting to export", e); } finally { @@ -85,12 +88,7 @@ export default function Page() { }, [share, loading]); return ( - + - + {filteredLogs?.map((log, index) => ( diff --git a/app/(auth)/(tabs)/(home)/settings/music/page.tsx b/app/(auth)/(tabs)/(home)/settings/music/page.tsx new file mode 100644 index 000000000..70467b4dd --- /dev/null +++ b/app/(auth)/(tabs)/(home)/settings/music/page.tsx @@ -0,0 +1,251 @@ +import { Ionicons } from "@expo/vector-icons"; +import { useQuery } from "@tanstack/react-query"; +import { useCallback, useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { Platform, ScrollView, View } from "react-native"; +import { Switch } from "react-native-gesture-handler"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { toast } from "sonner-native"; +import { Text } from "@/components/common/Text"; +import { ListGroup } from "@/components/list/ListGroup"; +import { ListItem } from "@/components/list/ListItem"; +import { PlatformDropdown } from "@/components/PlatformDropdown"; +import { useHaptic } from "@/hooks/useHaptic"; +import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient"; +import { + clearCache, + clearPermanentDownloads, + getStorageStats, +} from "@/providers/AudioStorage"; +import { useSettings } from "@/utils/atoms/settings"; + +const CACHE_SIZE_OPTIONS = [ + { label: "100 MB", value: 100 }, + { label: "250 MB", value: 250 }, + { label: "500 MB", value: 500 }, + { label: "1 GB", value: 1024 }, + { label: "2 GB", value: 2048 }, +]; + +const LOOKAHEAD_COUNT_OPTIONS = [ + { label: "1 song", value: 1 }, + { label: "2 songs", value: 2 }, + { label: "3 songs", value: 3 }, + { label: "5 songs", value: 5 }, +]; + +export default function MusicSettingsPage() { + const insets = useSafeAreaInsets(); + const { settings, updateSettings, pluginSettings } = useSettings(); + const { t } = useTranslation(); + const queryClient = useNetworkAwareQueryClient(); + const successHapticFeedback = useHaptic("success"); + const errorHapticFeedback = useHaptic("error"); + + const { data: musicCacheStats } = useQuery({ + queryKey: ["musicCacheStats"], + queryFn: () => getStorageStats(), + }); + + const onClearMusicCacheClicked = useCallback(async () => { + try { + await clearCache(); + queryClient.invalidateQueries({ queryKey: ["musicCacheStats"] }); + queryClient.invalidateQueries({ queryKey: ["appSize"] }); + successHapticFeedback(); + toast.success(t("home.settings.storage.music_cache_cleared")); + } catch (_e) { + errorHapticFeedback(); + toast.error(t("home.settings.toasts.error_deleting_files")); + } + }, [queryClient, successHapticFeedback, errorHapticFeedback, t]); + + const onDeleteDownloadedSongsClicked = useCallback(async () => { + try { + await clearPermanentDownloads(); + queryClient.invalidateQueries({ queryKey: ["musicCacheStats"] }); + queryClient.invalidateQueries({ queryKey: ["appSize"] }); + successHapticFeedback(); + toast.success(t("home.settings.storage.downloaded_songs_deleted")); + } catch (_e) { + errorHapticFeedback(); + toast.error(t("home.settings.toasts.error_deleting_files")); + } + }, [queryClient, successHapticFeedback, errorHapticFeedback, t]); + + const cacheSizeOptions = useMemo( + () => [ + { + options: CACHE_SIZE_OPTIONS.map((option) => ({ + type: "radio" as const, + label: option.label, + value: String(option.value), + selected: option.value === settings?.audioMaxCacheSizeMB, + onPress: () => updateSettings({ audioMaxCacheSizeMB: option.value }), + })), + }, + ], + [settings?.audioMaxCacheSizeMB, updateSettings], + ); + + const currentCacheSizeLabel = + CACHE_SIZE_OPTIONS.find((o) => o.value === settings?.audioMaxCacheSizeMB) + ?.label ?? `${settings?.audioMaxCacheSizeMB} MB`; + + const lookaheadCountOptions = useMemo( + () => [ + { + options: LOOKAHEAD_COUNT_OPTIONS.map((option) => ({ + type: "radio" as const, + label: option.label, + value: String(option.value), + selected: option.value === settings?.audioLookaheadCount, + onPress: () => updateSettings({ audioLookaheadCount: option.value }), + })), + }, + ], + [settings?.audioLookaheadCount, updateSettings], + ); + + const currentLookaheadLabel = + LOOKAHEAD_COUNT_OPTIONS.find( + (o) => o.value === settings?.audioLookaheadCount, + )?.label ?? `${settings?.audioLookaheadCount} songs`; + + return ( + + + + {t("home.settings.music.playback_description")} +
+ } + > + + + updateSettings({ preferLocalAudio: value }) + } + /> + + + + + + {t("home.settings.music.caching_description")} + + } + > + + + updateSettings({ audioLookaheadEnabled: value }) + } + /> + + + + + {currentLookaheadLabel} + + + + } + title={t("home.settings.music.lookahead_count")} + /> + + + + + {currentCacheSizeLabel} + + + + } + title={t("home.settings.music.max_cache_size")} + /> + + + + + {!Platform.isTV && ( + + + {t("home.settings.storage.music_cache_description")} + + } + > + + + + + + + )} + + + ); +} diff --git a/app/(auth)/(tabs)/(home)/settings/network/page.tsx b/app/(auth)/(tabs)/(home)/settings/network/page.tsx new file mode 100644 index 000000000..9f3727e42 --- /dev/null +++ b/app/(auth)/(tabs)/(home)/settings/network/page.tsx @@ -0,0 +1,48 @@ +import { useAtomValue } from "jotai"; +import { useTranslation } from "react-i18next"; +import { Platform, ScrollView, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { ListGroup } from "@/components/list/ListGroup"; +import { ListItem } from "@/components/list/ListItem"; +import { LocalNetworkSettings } from "@/components/settings/LocalNetworkSettings"; +import { apiAtom } from "@/providers/JellyfinProvider"; +import { storage } from "@/utils/mmkv"; + +export default function NetworkSettingsPage() { + const { t } = useTranslation(); + const insets = useSafeAreaInsets(); + const api = useAtomValue(apiAtom); + + const remoteUrl = storage.getString("serverUrl"); + + return ( + + + + + + + + + + + + + ); +} diff --git a/app/(auth)/(tabs)/(home)/settings/playback-controls/page.tsx b/app/(auth)/(tabs)/(home)/settings/playback-controls/page.tsx index 370247c15..2771117f9 100644 --- a/app/(auth)/(tabs)/(home)/settings/playback-controls/page.tsx +++ b/app/(auth)/(tabs)/(home)/settings/playback-controls/page.tsx @@ -3,6 +3,8 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { GestureControls } from "@/components/settings/GestureControls"; import { MediaProvider } from "@/components/settings/MediaContext"; import { MediaToggles } from "@/components/settings/MediaToggles"; +import { MpvBufferSettings } from "@/components/settings/MpvBufferSettings"; +import { MpvVoSettings } from "@/components/settings/MpvVoSettings"; import { PlaybackControlsSettings } from "@/components/settings/PlaybackControlsSettings"; import { ChromecastSettings } from "../../../../../../components/settings/ChromecastSettings"; @@ -26,6 +28,8 @@ export default function PlaybackControlsPage() { + + {!Platform.isTV && } diff --git a/app/(auth)/(tabs)/(home)/settings/plugins/jellyseerr/page.tsx b/app/(auth)/(tabs)/(home)/settings/plugins/jellyseerr/page.tsx index cd1efca54..84041fd01 100644 --- a/app/(auth)/(tabs)/(home)/settings/plugins/jellyseerr/page.tsx +++ b/app/(auth)/(tabs)/(home)/settings/plugins/jellyseerr/page.tsx @@ -4,7 +4,7 @@ import DisabledSetting from "@/components/settings/DisabledSetting"; import { JellyseerrSettings } from "@/components/settings/Jellyseerr"; import { useSettings } from "@/utils/atoms/settings"; -export default function page() { +export default function JellyseerrPluginPage() { const { pluginSettings } = useSettings(); const insets = useSafeAreaInsets(); @@ -18,7 +18,7 @@ export default function page() { > diff --git a/app/(auth)/(tabs)/(home)/settings/plugins/kefinTweaks/page.tsx b/app/(auth)/(tabs)/(home)/settings/plugins/kefinTweaks/page.tsx new file mode 100644 index 000000000..e9af145fd --- /dev/null +++ b/app/(auth)/(tabs)/(home)/settings/plugins/kefinTweaks/page.tsx @@ -0,0 +1,27 @@ +import { ScrollView } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import DisabledSetting from "@/components/settings/DisabledSetting"; +import { KefinTweaksSettings } from "@/components/settings/KefinTweaks"; +import { useSettings } from "@/utils/atoms/settings"; + +export default function KefinTweaksPage() { + const { pluginSettings } = useSettings(); + const insets = useSafeAreaInsets(); + + return ( + + + + + + ); +} diff --git a/app/(auth)/(tabs)/(home)/settings/plugins/marlin-search/page.tsx b/app/(auth)/(tabs)/(home)/settings/plugins/marlin-search/page.tsx index 4eb36aefd..3ce2c81c3 100644 --- a/app/(auth)/(tabs)/(home)/settings/plugins/marlin-search/page.tsx +++ b/app/(auth)/(tabs)/(home)/settings/plugins/marlin-search/page.tsx @@ -1,4 +1,3 @@ -import { useQueryClient } from "@tanstack/react-query"; import { useNavigation } from "expo-router"; import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -16,9 +15,10 @@ import { Text } from "@/components/common/Text"; import { ListGroup } from "@/components/list/ListGroup"; import { ListItem } from "@/components/list/ListItem"; import DisabledSetting from "@/components/settings/DisabledSetting"; +import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient"; import { useSettings } from "@/utils/atoms/settings"; -export default function page() { +export default function MarlinSearchPage() { const navigation = useNavigation(); const { t } = useTranslation(); @@ -26,7 +26,7 @@ export default function page() { const insets = useSafeAreaInsets(); const { settings, updateSettings, pluginSettings } = useSettings(); - const queryClient = useQueryClient(); + const queryClient = useNetworkAwareQueryClient(); const [value, setValue] = useState(settings?.marlinServerUrl || ""); @@ -60,7 +60,7 @@ export default function page() { ), }); } - }, [navigation, value]); + }, [navigation, value, pluginSettings?.marlinServerUrl?.locked, t]); if (!settings) return null; @@ -75,7 +75,10 @@ export default function page() { { updateSettings({ searchEngine: value ? "Marlin" : "Jellyfin", diff --git a/app/(auth)/(tabs)/(home)/settings/plugins/streamystats/page.tsx b/app/(auth)/(tabs)/(home)/settings/plugins/streamystats/page.tsx new file mode 100644 index 000000000..8f0a2c931 --- /dev/null +++ b/app/(auth)/(tabs)/(home)/settings/plugins/streamystats/page.tsx @@ -0,0 +1,262 @@ +import { useNavigation } from "expo-router"; +import { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + Linking, + ScrollView, + Switch, + TextInput, + TouchableOpacity, + View, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { toast } from "sonner-native"; +import { Text } from "@/components/common/Text"; +import { ListGroup } from "@/components/list/ListGroup"; +import { ListItem } from "@/components/list/ListItem"; +import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient"; +import { useSettings } from "@/utils/atoms/settings"; + +export default function StreamystatsPage() { + const { t } = useTranslation(); + const navigation = useNavigation(); + const insets = useSafeAreaInsets(); + + const { + settings, + updateSettings, + pluginSettings, + refreshStreamyfinPluginSettings, + } = useSettings(); + const queryClient = useNetworkAwareQueryClient(); + + // Local state for all editable fields + const [url, setUrl] = useState(settings?.streamyStatsServerUrl || ""); + const [useForSearch, setUseForSearch] = useState( + settings?.searchEngine === "Streamystats", + ); + const [movieRecs, setMovieRecs] = useState( + settings?.streamyStatsMovieRecommendations ?? false, + ); + const [seriesRecs, setSeriesRecs] = useState( + settings?.streamyStatsSeriesRecommendations ?? false, + ); + const [promotedWatchlists, setPromotedWatchlists] = useState( + settings?.streamyStatsPromotedWatchlists ?? false, + ); + const [hideWatchlistsTab, setHideWatchlistsTab] = useState( + settings?.hideWatchlistsTab ?? false, + ); + + const isUrlLocked = pluginSettings?.streamyStatsServerUrl?.locked === true; + const isStreamystatsEnabled = !!url; + + const onSave = useCallback(() => { + const cleanUrl = url.endsWith("/") ? url.slice(0, -1) : url; + updateSettings({ + streamyStatsServerUrl: cleanUrl, + searchEngine: useForSearch ? "Streamystats" : "Jellyfin", + streamyStatsMovieRecommendations: movieRecs, + streamyStatsSeriesRecommendations: seriesRecs, + streamyStatsPromotedWatchlists: promotedWatchlists, + hideWatchlistsTab: hideWatchlistsTab, + }); + queryClient.invalidateQueries({ queryKey: ["search"] }); + queryClient.invalidateQueries({ queryKey: ["streamystats"] }); + toast.success(t("home.settings.plugins.streamystats.toasts.saved")); + }, [ + url, + useForSearch, + movieRecs, + seriesRecs, + promotedWatchlists, + hideWatchlistsTab, + updateSettings, + queryClient, + t, + ]); + + // Set up header save button + useEffect(() => { + navigation.setOptions({ + headerRight: () => ( + + + {t("home.settings.plugins.streamystats.save")} + + + ), + }); + }, [navigation, onSave, t]); + + const handleClearStreamystats = useCallback(() => { + setUrl(""); + setUseForSearch(false); + setMovieRecs(false); + setSeriesRecs(false); + setPromotedWatchlists(false); + setHideWatchlistsTab(false); + updateSettings({ + streamyStatsServerUrl: "", + searchEngine: "Jellyfin", + streamyStatsMovieRecommendations: false, + streamyStatsSeriesRecommendations: false, + streamyStatsPromotedWatchlists: false, + hideWatchlistsTab: false, + }); + queryClient.invalidateQueries({ queryKey: ["streamystats"] }); + queryClient.invalidateQueries({ queryKey: ["search"] }); + toast.success(t("home.settings.plugins.streamystats.toasts.disabled")); + }, [updateSettings, queryClient, t]); + + const handleOpenLink = () => { + Linking.openURL("https://github.com/fredrikburmester/streamystats"); + }; + + const handleRefreshFromServer = useCallback(async () => { + const newPluginSettings = await refreshStreamyfinPluginSettings(); + // Update local state with new values + const newUrl = newPluginSettings?.streamyStatsServerUrl?.value || ""; + setUrl(newUrl); + if (newUrl) { + setUseForSearch(true); + } + toast.success(t("home.settings.plugins.streamystats.toasts.refreshed")); + }, [refreshStreamyfinPluginSettings, t]); + + if (!settings) return null; + + return ( + + + + + + + + + + {t("home.settings.plugins.streamystats.streamystats_search_hint")}{" "} + + {t( + "home.settings.plugins.streamystats.read_more_about_streamystats", + )} + + + + + + + + + + + + + + + + + + + + + + {t("home.settings.plugins.streamystats.home_sections_hint")} + + + + + {t("home.settings.plugins.streamystats.refresh_from_server")} + + + + {/* Disable button - only show if URL is not locked and Streamystats is enabled */} + {!isUrlLocked && isStreamystatsEnabled && ( + + + {t("home.settings.plugins.streamystats.disable_streamystats")} + + + )} + + + ); +} diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/collections/[collectionId].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites)/collections/[collectionId].tsx deleted file mode 100644 index 1723fe4b2..000000000 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites)/collections/[collectionId].tsx +++ /dev/null @@ -1,420 +0,0 @@ -import type { - BaseItemDto, - BaseItemDtoQueryResult, - ItemSortBy, -} from "@jellyfin/sdk/lib/generated-client/models"; -import { - getFilterApi, - getItemsApi, - getUserLibraryApi, -} from "@jellyfin/sdk/lib/utils/api"; -import { FlashList } from "@shopify/flash-list"; -import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; -import { useLocalSearchParams, useNavigation } from "expo-router"; -import { useAtom } from "jotai"; -import type React from "react"; -import { useCallback, useEffect, useMemo, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { FlatList, View } from "react-native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { Text } from "@/components/common/Text"; -import { TouchableItemRouter } from "@/components/common/TouchableItemRouter"; -import { FilterButton } from "@/components/filters/FilterButton"; -import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton"; -import { ItemCardText } from "@/components/ItemCardText"; -import { ItemPoster } from "@/components/posters/ItemPoster"; -import * as ScreenOrientation from "@/packages/expo-screen-orientation"; -import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; -import { - genreFilterAtom, - SortByOption, - SortOrderOption, - sortByAtom, - sortOptions, - sortOrderAtom, - sortOrderOptions, - tagsFilterAtom, - yearFilterAtom, -} from "@/utils/atoms/filters"; - -const page: React.FC = () => { - const searchParams = useLocalSearchParams(); - const { collectionId } = searchParams as { collectionId: string }; - - const [api] = useAtom(apiAtom); - const [user] = useAtom(userAtom); - const navigation = useNavigation(); - const [orientation, _setOrientation] = useState( - ScreenOrientation.Orientation.PORTRAIT_UP, - ); - - const { t } = useTranslation(); - - const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom); - const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom); - const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom); - const [sortBy, setSortBy] = useAtom(sortByAtom); - const [sortOrder, setSortOrder] = useAtom(sortOrderAtom); - - const { data: collection } = useQuery({ - queryKey: ["collection", collectionId], - queryFn: async () => { - if (!api) return null; - const response = await getUserLibraryApi(api).getItem({ - itemId: collectionId, - userId: user?.Id, - }); - const data = response.data; - return data; - }, - enabled: !!api && !!user?.Id && !!collectionId, - staleTime: 60 * 1000, - }); - - useEffect(() => { - navigation.setOptions({ title: collection?.Name || "" }); - setSortOrder([SortOrderOption.Ascending]); - - if (!collection) return; - - // Convert the DisplayOrder to SortByOption - const displayOrder = collection.DisplayOrder as ItemSortBy; - const sortByOption = displayOrder - ? SortByOption[displayOrder as keyof typeof SortByOption] || - SortByOption.PremiereDate - : SortByOption.PremiereDate; - - setSortBy([sortByOption]); - }, [navigation, collection]); - - const fetchItems = useCallback( - async ({ - pageParam, - }: { - pageParam: number; - }): Promise => { - if (!api || !collection) return null; - - const response = await getItemsApi(api).getItems({ - userId: user?.Id, - parentId: collectionId, - limit: 18, - startIndex: pageParam, - // Set one ordering at a time. As collections do not work with correctly with multiple. - sortBy: [sortBy[0]], - sortOrder: [sortOrder[0]], - fields: [ - "ItemCounts", - "PrimaryImageAspectRatio", - "CanDelete", - "MediaSourceCount", - ], - // true is needed for merged versions - recursive: true, - genres: selectedGenres, - tags: selectedTags, - years: selectedYears.map((year) => Number.parseInt(year, 10)), - includeItemTypes: ["Movie", "Series"], - }); - - return response.data || null; - }, - [ - api, - user?.Id, - collection, - selectedGenres, - selectedYears, - selectedTags, - sortBy, - sortOrder, - ], - ); - - const { data, isFetching, fetchNextPage, hasNextPage } = useInfiniteQuery({ - queryKey: [ - "collection-items", - collection, - selectedGenres, - selectedYears, - selectedTags, - sortBy, - sortOrder, - ], - queryFn: fetchItems, - getNextPageParam: (lastPage, pages) => { - if ( - !lastPage?.Items || - !lastPage?.TotalRecordCount || - lastPage?.TotalRecordCount === 0 - ) - return undefined; - - const totalItems = lastPage.TotalRecordCount; - const accumulatedItems = pages.reduce( - (acc, curr) => acc + (curr?.Items?.length || 0), - 0, - ); - - if (accumulatedItems < totalItems) { - return lastPage?.Items?.length * pages.length; - } - return undefined; - }, - initialPageParam: 0, - enabled: !!api && !!user?.Id && !!collection, - }); - - const flatData = useMemo(() => { - return ( - (data?.pages.flatMap((p) => p?.Items).filter(Boolean) as BaseItemDto[]) || - [] - ); - }, [data]); - - const renderItem = useCallback( - ({ item, index }: { item: BaseItemDto; index: number }) => ( - - - - {/* */} - - - - ), - [orientation], - ); - - const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []); - - const _insets = useSafeAreaInsets(); - - const ListHeaderComponent = useCallback( - () => ( - , - }, - { - key: "genre", - component: ( - { - if (!api) return null; - const response = await getFilterApi( - api, - ).getQueryFiltersLegacy({ - userId: user?.Id, - parentId: collectionId, - }); - return response.data.Genres || []; - }} - set={setSelectedGenres} - values={selectedGenres} - title={t("library.filters.genres")} - renderItemLabel={(item) => item.toString()} - searchFilter={(item, search) => - item.toLowerCase().includes(search.toLowerCase()) - } - /> - ), - }, - { - key: "year", - component: ( - { - if (!api) return null; - const response = await getFilterApi( - api, - ).getQueryFiltersLegacy({ - userId: user?.Id, - parentId: collectionId, - }); - return response.data.Years || []; - }} - set={setSelectedYears} - values={selectedYears} - title={t("library.filters.years")} - renderItemLabel={(item) => item.toString()} - searchFilter={(item, search) => item.includes(search)} - /> - ), - }, - { - key: "tags", - component: ( - { - if (!api) return null; - const response = await getFilterApi( - api, - ).getQueryFiltersLegacy({ - userId: user?.Id, - parentId: collectionId, - }); - return response.data.Tags || []; - }} - set={setSelectedTags} - values={selectedTags} - title={t("library.filters.tags")} - renderItemLabel={(item) => item.toString()} - searchFilter={(item, search) => - item.toLowerCase().includes(search.toLowerCase()) - } - /> - ), - }, - { - key: "sortBy", - component: ( - sortOptions.map((s) => s.key)} - set={setSortBy} - values={sortBy} - title={t("library.filters.sort_by")} - renderItemLabel={(item) => - sortOptions.find((i) => i.key === item)?.value || "" - } - searchFilter={(item, search) => - item.toLowerCase().includes(search.toLowerCase()) - } - /> - ), - }, - { - key: "sortOrder", - component: ( - sortOrderOptions.map((s) => s.key)} - set={setSortOrder} - values={sortOrder} - title={t("library.filters.sort_order")} - renderItemLabel={(item) => - sortOrderOptions.find((i) => i.key === item)?.value || "" - } - searchFilter={(item, search) => - item.toLowerCase().includes(search.toLowerCase()) - } - /> - ), - }, - ]} - renderItem={({ item }) => item.component} - keyExtractor={(item) => item.key} - /> - ), - [ - collectionId, - api, - user?.Id, - selectedGenres, - setSelectedGenres, - selectedYears, - setSelectedYears, - selectedTags, - setSelectedTags, - sortBy, - setSortBy, - sortOrder, - setSortOrder, - isFetching, - ], - ); - - if (!collection) return null; - - return ( - - - {t("search.no_results")} - - - } - extraData={[ - selectedGenres, - selectedYears, - selectedTags, - sortBy, - sortOrder, - ]} - contentInsetAdjustmentBehavior='automatic' - data={flatData} - renderItem={renderItem} - keyExtractor={keyExtractor} - numColumns={ - orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 3 : 5 - } - onEndReached={() => { - if (hasNextPage) { - fetchNextPage(); - } - }} - onEndReachedThreshold={0.5} - ListHeaderComponent={ListHeaderComponent} - contentContainerStyle={{ paddingBottom: 24 }} - ItemSeparatorComponent={() => ( - - )} - /> - ); -}; - -export default page; diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/items/page.tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites)/items/page.tsx deleted file mode 100644 index 259424729..000000000 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites)/items/page.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import { ItemFields } from "@jellyfin/sdk/lib/generated-client/models"; -import { useLocalSearchParams } from "expo-router"; -import type React from "react"; -import { useEffect } from "react"; -import { useTranslation } from "react-i18next"; -import { View } from "react-native"; -import Animated, { - runOnJS, - useAnimatedStyle, - useSharedValue, - withTiming, -} from "react-native-reanimated"; -import { Text } from "@/components/common/Text"; -import { ItemContent } from "@/components/ItemContent"; -import { useItemQuery } from "@/hooks/useItemQuery"; - -const Page: React.FC = () => { - const { id } = useLocalSearchParams() as { id: string }; - const { t } = useTranslation(); - - const { offline } = useLocalSearchParams() as { offline?: string }; - const isOffline = offline === "true"; - - const { data: item, isError } = useItemQuery(id, false, undefined, [ - ItemFields.MediaSources, - ItemFields.MediaSourceCount, - ItemFields.MediaStreams, - ]); - - const opacity = useSharedValue(1); - const animatedStyle = useAnimatedStyle(() => { - return { - opacity: opacity.value, - }; - }); - - const fadeOut = (callback: any) => { - setTimeout(() => { - opacity.value = withTiming(0, { duration: 500 }, (finished) => { - if (finished) { - runOnJS(callback)(); - } - }); - }, 100); - }; - - const fadeIn = (callback: any) => { - setTimeout(() => { - opacity.value = withTiming(1, { duration: 500 }, (finished) => { - if (finished) { - runOnJS(callback)(); - } - }); - }, 100); - }; - - useEffect(() => { - if (item) { - fadeOut(() => {}); - } else { - fadeIn(() => {}); - } - }, [item]); - - if (isError) - return ( - - {t("item_card.could_not_load_item")} - - ); - - return ( - - - - - - - - - - - - - - - - - {item && } - - ); -}; - -export default Page; diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/series/[id].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites)/series/[id].tsx deleted file mode 100644 index 2636d5c01..000000000 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites)/series/[id].tsx +++ /dev/null @@ -1,163 +0,0 @@ -import { Ionicons } from "@expo/vector-icons"; -import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api"; -import { useQuery } from "@tanstack/react-query"; -import { Image } from "expo-image"; -import { useLocalSearchParams, useNavigation } from "expo-router"; -import { useAtom } from "jotai"; -import type React from "react"; -import { useEffect, useMemo } from "react"; -import { useTranslation } from "react-i18next"; -import { Platform, View } from "react-native"; -import { AddToFavorites } from "@/components/AddToFavorites"; -import { DownloadItems } from "@/components/DownloadItem"; -import { ParallaxScrollView } from "@/components/ParallaxPage"; -import { NextUp } from "@/components/series/NextUp"; -import { SeasonPicker } from "@/components/series/SeasonPicker"; -import { SeriesHeader } from "@/components/series/SeriesHeader"; -import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; -import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl"; -import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById"; -import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData"; - -const page: React.FC = () => { - const navigation = useNavigation(); - const { t } = useTranslation(); - const params = useLocalSearchParams(); - const { id: seriesId, seasonIndex } = params as { - id: string; - seasonIndex: string; - }; - - const [api] = useAtom(apiAtom); - const [user] = useAtom(userAtom); - - const { data: item } = useQuery({ - queryKey: ["series", seriesId], - queryFn: async () => - await getUserItemData({ - api, - userId: user?.Id, - itemId: seriesId, - }), - staleTime: 60 * 1000, - }); - - const backdropUrl = useMemo( - () => - getBackdropUrl({ - api, - item, - quality: 90, - width: 1000, - }), - [item], - ); - - const logoUrl = useMemo( - () => - getLogoImageUrlById({ - api, - item, - }), - [item], - ); - - const { data: allEpisodes, isLoading } = useQuery({ - queryKey: ["AllEpisodes", item?.Id], - queryFn: async () => { - if (!api || !user?.Id || !item?.Id) return []; - - const res = await getTvShowsApi(api).getEpisodes({ - seriesId: item.Id, - userId: user.Id, - enableUserData: true, - // Note: Including trick play is necessary to enable trick play downloads - fields: ["MediaSources", "MediaStreams", "Overview", "Trickplay"], - }); - return res?.data.Items || []; - }, - select: (data) => - // This needs to be sorted by parent index number and then index number, that way we can download the episodes in the correct order. - [...(data || [])].sort( - (a, b) => - (a.ParentIndexNumber ?? 0) - (b.ParentIndexNumber ?? 0) || - (a.IndexNumber ?? 0) - (b.IndexNumber ?? 0), - ), - staleTime: 60, - enabled: !!api && !!user?.Id && !!item?.Id, - }); - - useEffect(() => { - navigation.setOptions({ - headerRight: () => - !isLoading && - item && - allEpisodes && - allEpisodes.length > 0 && ( - - - {!Platform.isTV && ( - ( - - )} - DownloadedIconComponent={() => ( - - )} - /> - )} - - ), - }); - }, [allEpisodes, isLoading, item]); - - if (!item || !backdropUrl) return null; - - return ( - - } - logo={ - logoUrl ? ( - - ) : undefined - } - > - - - - - - - - - ); -}; - -export default page; diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/collections/[collectionId].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/collections/[collectionId].tsx new file mode 100644 index 000000000..5fd125c96 --- /dev/null +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/collections/[collectionId].tsx @@ -0,0 +1,785 @@ +import type { + BaseItemDto, + BaseItemDtoQueryResult, + ItemSortBy, +} from "@jellyfin/sdk/lib/generated-client/models"; +import { + getFilterApi, + getItemsApi, + getUserLibraryApi, +} from "@jellyfin/sdk/lib/utils/api"; +import { FlashList } from "@shopify/flash-list"; +import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; +import { useLocalSearchParams, useNavigation } from "expo-router"; +import { useAtom } from "jotai"; +import type React from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { FlatList, Platform, useWindowDimensions, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Text } from "@/components/common/Text"; +import { + getItemNavigation, + TouchableItemRouter, +} from "@/components/common/TouchableItemRouter"; +import { FilterButton } from "@/components/filters/FilterButton"; +import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton"; +import { ItemCardText } from "@/components/ItemCardText"; +import { Loader } from "@/components/Loader"; +import { ItemPoster } from "@/components/posters/ItemPoster"; +import { TVFilterButton } from "@/components/tv"; +import { TVPosterCard } from "@/components/tv/TVPosterCard"; +import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes"; +import useRouter from "@/hooks/useAppRouter"; +import { useTVItemActionModal } from "@/hooks/useTVItemActionModal"; +import { useTVOptionModal } from "@/hooks/useTVOptionModal"; +import * as ScreenOrientation from "@/packages/expo-screen-orientation"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { + genreFilterAtom, + SortByOption, + SortOrderOption, + sortByAtom, + sortOptions, + sortOrderAtom, + sortOrderOptions, + tagsFilterAtom, + yearFilterAtom, +} from "@/utils/atoms/filters"; +import type { TVOptionItem } from "@/utils/atoms/tvOptionModal"; + +const TV_ITEM_GAP = 16; +const TV_SCALE_PADDING = 20; + +const page: React.FC = () => { + const searchParams = useLocalSearchParams(); + const { collectionId } = searchParams as { collectionId: string }; + + const posterSizes = useScaledTVPosterSizes(); + const [api] = useAtom(apiAtom); + const [user] = useAtom(userAtom); + const navigation = useNavigation(); + const router = useRouter(); + const { showOptions } = useTVOptionModal(); + const { showItemActions } = useTVItemActionModal(); + const { width: screenWidth } = useWindowDimensions(); + const [orientation, _setOrientation] = useState( + ScreenOrientation.Orientation.PORTRAIT_UP, + ); + + const { t } = useTranslation(); + const insets = useSafeAreaInsets(); + + const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom); + const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom); + const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom); + const [sortBy, setSortBy] = useAtom(sortByAtom); + const [sortOrder, setSortOrder] = useAtom(sortOrderAtom); + + const { data: collection, isLoading: isCollectionLoading } = useQuery({ + queryKey: ["collection", collectionId], + queryFn: async () => { + if (!api) return null; + const response = await getUserLibraryApi(api).getItem({ + itemId: collectionId, + userId: user?.Id, + }); + const data = response.data; + return data; + }, + enabled: !!api && !!user?.Id && !!collectionId, + staleTime: 60 * 1000, + }); + + // TV Filter queries + const { data: tvGenreOptions } = useQuery({ + queryKey: ["filters", "Genres", "tvGenreFilter", collectionId], + queryFn: async () => { + if (!api) return []; + const response = await getFilterApi(api).getQueryFiltersLegacy({ + userId: user?.Id, + parentId: collectionId, + }); + return response.data.Genres || []; + }, + enabled: Platform.isTV && !!api && !!user?.Id && !!collectionId, + }); + + const { data: tvYearOptions } = useQuery({ + queryKey: ["filters", "Years", "tvYearFilter", collectionId], + queryFn: async () => { + if (!api) return []; + const response = await getFilterApi(api).getQueryFiltersLegacy({ + userId: user?.Id, + parentId: collectionId, + }); + return response.data.Years || []; + }, + enabled: Platform.isTV && !!api && !!user?.Id && !!collectionId, + }); + + const { data: tvTagOptions } = useQuery({ + queryKey: ["filters", "Tags", "tvTagFilter", collectionId], + queryFn: async () => { + if (!api) return []; + const response = await getFilterApi(api).getQueryFiltersLegacy({ + userId: user?.Id, + parentId: collectionId, + }); + return response.data.Tags || []; + }, + enabled: Platform.isTV && !!api && !!user?.Id && !!collectionId, + }); + + useEffect(() => { + navigation.setOptions({ title: collection?.Name || "" }); + setSortOrder([SortOrderOption.Ascending]); + + if (!collection) return; + + // Convert the DisplayOrder to SortByOption + const displayOrder = collection.DisplayOrder as ItemSortBy; + const sortByOption = displayOrder + ? SortByOption[displayOrder as keyof typeof SortByOption] || + SortByOption.PremiereDate + : SortByOption.PremiereDate; + + setSortBy([sortByOption]); + }, [navigation, collection]); + + // Calculate columns for TV grid + const nrOfCols = useMemo(() => { + if (Platform.isTV) { + const itemWidth = posterSizes.poster + TV_ITEM_GAP; + return Math.max( + 1, + Math.floor((screenWidth - TV_SCALE_PADDING * 2) / itemWidth), + ); + } + return orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 3 : 5; + }, [screenWidth, orientation]); + + const fetchItems = useCallback( + async ({ + pageParam, + }: { + pageParam: number; + }): Promise => { + if (!api || !collection) return null; + + const response = await getItemsApi(api).getItems({ + userId: user?.Id, + parentId: collectionId, + limit: Platform.isTV ? 36 : 18, + startIndex: pageParam, + // Set one ordering at a time. As collections do not work with correctly with multiple. + sortBy: [sortBy[0]], + sortOrder: [sortOrder[0]], + fields: [ + "ItemCounts", + "PrimaryImageAspectRatio", + "CanDelete", + "MediaSourceCount", + ], + // true is needed for merged versions + recursive: true, + genres: selectedGenres, + tags: selectedTags, + years: selectedYears.map((year) => Number.parseInt(year, 10)), + includeItemTypes: ["Movie", "Series", "Season"], + }); + + return response.data || null; + }, + [ + api, + user?.Id, + collection, + collectionId, + selectedGenres, + selectedYears, + selectedTags, + sortBy, + sortOrder, + ], + ); + + const { data, isFetching, fetchNextPage, hasNextPage, isLoading } = + useInfiniteQuery({ + queryKey: [ + "collection-items", + collectionId, + selectedGenres, + selectedYears, + selectedTags, + sortBy, + sortOrder, + ], + queryFn: fetchItems, + getNextPageParam: (lastPage, pages) => { + if ( + !lastPage?.Items || + !lastPage?.TotalRecordCount || + lastPage?.TotalRecordCount === 0 + ) + return undefined; + + const totalItems = lastPage.TotalRecordCount; + const accumulatedItems = pages.reduce( + (acc, curr) => acc + (curr?.Items?.length || 0), + 0, + ); + + if (accumulatedItems < totalItems) { + return lastPage?.Items?.length * pages.length; + } + return undefined; + }, + initialPageParam: 0, + enabled: !!api && !!user?.Id && !!collection, + }); + + const flatData = useMemo(() => { + return ( + (data?.pages.flatMap((p) => p?.Items).filter(Boolean) as BaseItemDto[]) || + [] + ); + }, [data]); + + const renderItem = useCallback( + ({ item, index }: { item: BaseItemDto; index: number }) => ( + + + + + + + ), + [orientation], + ); + + const renderTVItem = useCallback( + ({ item }: { item: BaseItemDto }) => { + const handlePress = () => { + const navTarget = getItemNavigation(item, "(home)"); + router.push(navTarget as any); + }; + + return ( + + showItemActions(item)} + width={posterSizes.poster} + /> + + ); + }, + [router, showItemActions, posterSizes.poster], + ); + + const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []); + + const ListHeaderComponent = useCallback( + () => ( + , + }, + { + key: "genre", + component: ( + { + if (!api) return null; + const response = await getFilterApi( + api, + ).getQueryFiltersLegacy({ + userId: user?.Id, + parentId: collectionId, + }); + return response.data.Genres || []; + }} + set={setSelectedGenres} + values={selectedGenres} + title={t("library.filters.genres")} + renderItemLabel={(item) => item.toString()} + searchFilter={(item, search) => + item.toLowerCase().includes(search.toLowerCase()) + } + /> + ), + }, + { + key: "year", + component: ( + { + if (!api) return null; + const response = await getFilterApi( + api, + ).getQueryFiltersLegacy({ + userId: user?.Id, + parentId: collectionId, + }); + return response.data.Years || []; + }} + set={setSelectedYears} + values={selectedYears} + title={t("library.filters.years")} + renderItemLabel={(item) => item.toString()} + searchFilter={(item, search) => item.includes(search)} + /> + ), + }, + { + key: "tags", + component: ( + { + if (!api) return null; + const response = await getFilterApi( + api, + ).getQueryFiltersLegacy({ + userId: user?.Id, + parentId: collectionId, + }); + return response.data.Tags || []; + }} + set={setSelectedTags} + values={selectedTags} + title={t("library.filters.tags")} + renderItemLabel={(item) => item.toString()} + searchFilter={(item, search) => + item.toLowerCase().includes(search.toLowerCase()) + } + /> + ), + }, + { + key: "sortBy", + component: ( + sortOptions.map((s) => s.key)} + set={setSortBy} + values={sortBy} + title={t("library.filters.sort_by")} + renderItemLabel={(item) => + sortOptions.find((i) => i.key === item)?.value || "" + } + searchFilter={(item, search) => + item.toLowerCase().includes(search.toLowerCase()) + } + /> + ), + }, + { + key: "sortOrder", + component: ( + sortOrderOptions.map((s) => s.key)} + set={setSortOrder} + values={sortOrder} + title={t("library.filters.sort_order")} + renderItemLabel={(item) => + sortOrderOptions.find((i) => i.key === item)?.value || "" + } + searchFilter={(item, search) => + item.toLowerCase().includes(search.toLowerCase()) + } + /> + ), + }, + ]} + renderItem={({ item }) => item.component} + keyExtractor={(item) => item.key} + /> + ), + [ + collectionId, + api, + user?.Id, + selectedGenres, + setSelectedGenres, + selectedYears, + setSelectedYears, + selectedTags, + setSelectedTags, + sortBy, + setSortBy, + sortOrder, + setSortOrder, + isFetching, + ], + ); + + // TV Filter options - with "All" option for clearable filters + const tvGenreFilterOptions = useMemo( + (): TVOptionItem[] => [ + { + label: t("library.filters.all"), + value: "__all__", + selected: selectedGenres.length === 0, + }, + ...(tvGenreOptions || []).map((genre) => ({ + label: genre, + value: genre, + selected: selectedGenres.includes(genre), + })), + ], + [tvGenreOptions, selectedGenres, t], + ); + + const tvYearFilterOptions = useMemo( + (): TVOptionItem[] => [ + { + label: t("library.filters.all"), + value: "__all__", + selected: selectedYears.length === 0, + }, + ...(tvYearOptions || []).map((year) => ({ + label: String(year), + value: String(year), + selected: selectedYears.includes(String(year)), + })), + ], + [tvYearOptions, selectedYears, t], + ); + + const tvTagFilterOptions = useMemo( + (): TVOptionItem[] => [ + { + label: t("library.filters.all"), + value: "__all__", + selected: selectedTags.length === 0, + }, + ...(tvTagOptions || []).map((tag) => ({ + label: tag, + value: tag, + selected: selectedTags.includes(tag), + })), + ], + [tvTagOptions, selectedTags, t], + ); + + const tvSortByOptions = useMemo( + (): TVOptionItem[] => + sortOptions.map((option) => ({ + label: option.value, + value: option.key, + selected: sortBy[0] === option.key, + })), + [sortBy], + ); + + const tvSortOrderOptions = useMemo( + (): TVOptionItem[] => + sortOrderOptions.map((option) => ({ + label: option.value, + value: option.key, + selected: sortOrder[0] === option.key, + })), + [sortOrder], + ); + + // TV Filter handlers using navigation-based modal + const handleShowGenreFilter = useCallback(() => { + showOptions({ + title: t("library.filters.genres"), + options: tvGenreFilterOptions, + onSelect: (value: string) => { + if (value === "__all__") { + setSelectedGenres([]); + } else if (selectedGenres.includes(value)) { + setSelectedGenres(selectedGenres.filter((g) => g !== value)); + } else { + setSelectedGenres([...selectedGenres, value]); + } + }, + }); + }, [showOptions, t, tvGenreFilterOptions, selectedGenres, setSelectedGenres]); + + const handleShowYearFilter = useCallback(() => { + showOptions({ + title: t("library.filters.years"), + options: tvYearFilterOptions, + onSelect: (value: string) => { + if (value === "__all__") { + setSelectedYears([]); + } else if (selectedYears.includes(value)) { + setSelectedYears(selectedYears.filter((y) => y !== value)); + } else { + setSelectedYears([...selectedYears, value]); + } + }, + }); + }, [showOptions, t, tvYearFilterOptions, selectedYears, setSelectedYears]); + + const handleShowTagFilter = useCallback(() => { + showOptions({ + title: t("library.filters.tags"), + options: tvTagFilterOptions, + onSelect: (value: string) => { + if (value === "__all__") { + setSelectedTags([]); + } else if (selectedTags.includes(value)) { + setSelectedTags(selectedTags.filter((tag) => tag !== value)); + } else { + setSelectedTags([...selectedTags, value]); + } + }, + }); + }, [showOptions, t, tvTagFilterOptions, selectedTags, setSelectedTags]); + + const handleShowSortByFilter = useCallback(() => { + showOptions({ + title: t("library.filters.sort_by"), + options: tvSortByOptions, + onSelect: (value: SortByOption) => { + setSortBy([value]); + }, + }); + }, [showOptions, t, tvSortByOptions, setSortBy]); + + const handleShowSortOrderFilter = useCallback(() => { + showOptions({ + title: t("library.filters.sort_order"), + options: tvSortOrderOptions, + onSelect: (value: SortOrderOption) => { + setSortOrder([value]); + }, + }); + }, [showOptions, t, tvSortOrderOptions, setSortOrder]); + + // TV filter bar state + const hasActiveFilters = + selectedGenres.length > 0 || + selectedYears.length > 0 || + selectedTags.length > 0; + + const resetAllFilters = useCallback(() => { + setSelectedGenres([]); + setSelectedYears([]); + setSelectedTags([]); + }, [setSelectedGenres, setSelectedYears, setSelectedTags]); + + if (isLoading || isCollectionLoading) { + return ( + + + + ); + } + + if (!collection) return null; + + // Mobile return + if (!Platform.isTV) { + return ( + + + {t("search.no_results")} + + + } + extraData={[ + selectedGenres, + selectedYears, + selectedTags, + sortBy, + sortOrder, + ]} + contentInsetAdjustmentBehavior='automatic' + data={flatData} + renderItem={renderItem} + keyExtractor={keyExtractor} + numColumns={nrOfCols} + onEndReached={() => { + if (hasNextPage) { + fetchNextPage(); + } + }} + onEndReachedThreshold={0.5} + ListHeaderComponent={ListHeaderComponent} + contentContainerStyle={{ paddingBottom: 24 }} + ItemSeparatorComponent={() => ( + + )} + /> + ); + } + + // TV return with filter bar + return ( + + {/* Filter bar */} + + {hasActiveFilters && ( + + )} + 0 + ? `${selectedGenres.length} selected` + : t("library.filters.all") + } + onPress={handleShowGenreFilter} + hasTVPreferredFocus={!hasActiveFilters} + hasActiveFilter={selectedGenres.length > 0} + /> + 0 + ? `${selectedYears.length} selected` + : t("library.filters.all") + } + onPress={handleShowYearFilter} + hasActiveFilter={selectedYears.length > 0} + /> + 0 + ? `${selectedTags.length} selected` + : t("library.filters.all") + } + onPress={handleShowTagFilter} + hasActiveFilter={selectedTags.length > 0} + /> + o.key === sortBy[0])?.value || ""} + onPress={handleShowSortByFilter} + /> + o.key === sortOrder[0])?.value || "" + } + onPress={handleShowSortOrderFilter} + /> + + + {/* Grid */} + + + {t("search.no_results")} + + + } + contentInsetAdjustmentBehavior='automatic' + data={flatData} + renderItem={renderTVItem} + extraData={[orientation, nrOfCols]} + keyExtractor={keyExtractor} + numColumns={nrOfCols} + removeClippedSubviews={false} + onEndReached={() => { + if (hasNextPage) { + fetchNextPage(); + } + }} + onEndReachedThreshold={1} + contentContainerStyle={{ + paddingBottom: 24, + paddingLeft: TV_SCALE_PADDING, + paddingRight: TV_SCALE_PADDING, + paddingTop: 20, + }} + ItemSeparatorComponent={() => ( + + )} + /> + + ); +}; + +export default page; diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/items/page.tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/items/page.tsx new file mode 100644 index 000000000..54fe92126 --- /dev/null +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/items/page.tsx @@ -0,0 +1,116 @@ +import { ItemFields } from "@jellyfin/sdk/lib/generated-client/models"; +import { useLocalSearchParams } from "expo-router"; +import type React from "react"; +import { useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { Platform, View } from "react-native"; +import Animated, { + useAnimatedStyle, + useSharedValue, + withTiming, +} from "react-native-reanimated"; +import { Text } from "@/components/common/Text"; +import { ItemContent } from "@/components/ItemContent"; +import { useItemQuery } from "@/hooks/useItemQuery"; +import { OfflineModeProvider } from "@/providers/OfflineModeProvider"; + +const ItemContentSkeletonTV = Platform.isTV + ? require("@/components/ItemContentSkeleton.tv").ItemContentSkeletonTV + : null; + +const Page: React.FC = () => { + const { id } = useLocalSearchParams() as { id: string }; + const { t } = useTranslation(); + + const { offline } = useLocalSearchParams() as { offline?: string }; + const isOffline = offline === "true"; + + // Exclude MediaSources/MediaStreams from initial fetch for faster loading + // (especially important for plugins like Gelato) + const { + data: item, + isError, + isLoading, + } = useItemQuery(id, isOffline, undefined, [ + ItemFields.MediaSources, + ItemFields.MediaSourceCount, + ItemFields.MediaStreams, + ]); + + // Lazily preload item with full media sources in background — never cache + const { data: itemWithSources } = useItemQuery(id, isOffline, undefined, [], { + gcTime: 0, + }); + + const opacity = useSharedValue(1); + const animatedStyle = useAnimatedStyle(() => { + return { + opacity: opacity.value, + }; + }); + + // Fast fade out when item loads (no setTimeout delay) + useEffect(() => { + if (item) { + opacity.value = withTiming(0, { duration: 150 }); + } else { + opacity.value = withTiming(1, { duration: 150 }); + } + }, [item, opacity]); + + if (isError) + return ( + + {t("item_card.could_not_load_item")} + + ); + + return ( + + + {/* Always render ItemContent - it handles loading state internally on TV */} + + + {/* Skeleton overlay - fades out when content loads */} + {!item && ( + + {Platform.isTV && ItemContentSkeletonTV ? ( + + ) : ( + + + + + + + + + + + + + + + + )} + + )} + + + ); +}; + +export default Page; diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/company/[companyId].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/jellyseerr/company/[companyId].tsx similarity index 90% rename from app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/company/[companyId].tsx rename to app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/jellyseerr/company/[companyId].tsx index cd9cc3cc5..34d83446c 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/company/[companyId].tsx +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/jellyseerr/company/[companyId].tsx @@ -13,7 +13,7 @@ import { } from "@/utils/jellyseerr/server/models/Search"; import { COMPANY_LOGO_IMAGE_FILTER } from "@/utils/jellyseerr/src/components/Discover/NetworkSlider"; -export default function page() { +export default function JellyseerrCompanyPage() { const local = useLocalSearchParams(); const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr(); @@ -21,19 +21,18 @@ export default function page() { companyId: string; name: string; image: string; - type: DiscoverSliderType; + type: DiscoverSliderType; //This gets converted to a string because it's a url param }; - const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({ + const { data, fetchNextPage, hasNextPage, isLoading } = useInfiniteQuery({ queryKey: ["jellyseerr", "company", type, companyId], queryFn: async ({ pageParam }) => { const params: any = { page: Number(pageParam), }; - return jellyseerrApi?.discover( `${ - type === DiscoverSliderType.NETWORKS + Number(type) === DiscoverSliderType.NETWORKS ? Endpoints.DISCOVER_TV_NETWORK : Endpoints.DISCOVER_MOVIES_STUDIO }/${companyId}`, @@ -86,6 +85,7 @@ export default function page() { fetchNextPage(); } }} + isLoading={isLoading} logo={ { +// Mobile page component +const MobilePage: React.FC = () => { const insets = useSafeAreaInsets(); const params = useLocalSearchParams(); const { t } = useTranslation(); @@ -58,13 +71,13 @@ const Page: React.FC = () => { } & Partial; const navigation = useNavigation(); - const { jellyseerrApi, requestMedia } = useJellyseerr(); + const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr(); const [issueType, setIssueType] = useState(); const [issueMessage, setIssueMessage] = useState(); const [requestBody, _setRequestBody] = useState(); const [issueTypeDropdownOpen, setIssueTypeDropdownOpen] = useState(false); - const advancedReqModalRef = useRef(null); + const advancedReqModalRef = useRef(null); const bottomSheetModalRef = useRef(null); const { @@ -91,6 +104,46 @@ const Page: React.FC = () => { const [canRequest, hasAdvancedRequestPermission] = useJellyseerrCanRequest(details); + const canManageRequests = useMemo(() => { + if (!jellyseerrUser) return false; + return hasPermission( + Permission.MANAGE_REQUESTS, + jellyseerrUser.permissions, + ); + }, [jellyseerrUser]); + + const pendingRequest = useMemo(() => { + return details?.mediaInfo?.requests?.find( + (r: MediaRequest) => r.status === MediaRequestStatus.PENDING, + ); + }, [details]); + + const handleApproveRequest = useCallback(async () => { + if (!pendingRequest?.id) return; + + try { + await jellyseerrApi?.approveRequest(pendingRequest.id); + toast.success(t("jellyseerr.toasts.request_approved")); + refetch(); + } catch (error) { + toast.error(t("jellyseerr.toasts.failed_to_approve_request")); + console.error("Failed to approve request:", error); + } + }, [jellyseerrApi, pendingRequest, refetch, t]); + + const handleDeclineRequest = useCallback(async () => { + if (!pendingRequest?.id) return; + + try { + await jellyseerrApi?.declineRequest(pendingRequest.id); + toast.success(t("jellyseerr.toasts.request_declined")); + refetch(); + } catch (error) { + toast.error(t("jellyseerr.toasts.failed_to_decline_request")); + console.error("Failed to decline request:", error); + } + }, [jellyseerrApi, pendingRequest, refetch, t]); + const renderBackdrop = useCallback( (props: BottomSheetBackdropProps) => ( { ) )} + {canManageRequests && pendingRequest && ( + + + + + {t("jellyseerr.requested_by", { + user: + pendingRequest.requestedBy?.displayName || + pendingRequest.requestedBy?.username || + pendingRequest.requestedBy?.jellyfinUsername || + t("jellyseerr.unknown_user"), + })} + + + + + + + + )} @@ -438,4 +545,12 @@ const Page: React.FC = () => { ); }; +// Platform-conditional page component +const Page: React.FC = () => { + if (Platform.isTV) { + return ; + } + return ; +}; + export default Page; diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/person/[personId].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/jellyseerr/person/[personId].tsx similarity index 98% rename from app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/person/[personId].tsx rename to app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/jellyseerr/person/[personId].tsx index a29e12809..c94a71bd4 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/person/[personId].tsx +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/jellyseerr/person/[personId].tsx @@ -11,7 +11,7 @@ import JellyseerrPoster from "@/components/posters/JellyseerrPoster"; import { useJellyseerr } from "@/hooks/useJellyseerr"; import type { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person"; -export default function page() { +export default function JellyseerrPersonPage() { const local = useLocalSearchParams(); const { t } = useTranslation(); diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/livetv/_layout.tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/livetv/_layout.tsx similarity index 72% rename from app/(auth)/(tabs)/(home,libraries,search,favorites)/livetv/_layout.tsx rename to app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/livetv/_layout.tsx index 072b2f931..f5bdc3e3b 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites)/livetv/_layout.tsx +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/livetv/_layout.tsx @@ -1,13 +1,14 @@ +import { Slot, Stack, withLayoutContext } from "expo-router"; import { createMaterialTopTabNavigator, MaterialTopTabNavigationEventMap, MaterialTopTabNavigationOptions, -} from "@react-navigation/material-top-tabs"; +} from "expo-router/js-top-tabs"; import type { ParamListBase, TabNavigationState, -} from "@react-navigation/native"; -import { Stack, withLayoutContext } from "expo-router"; +} from "expo-router/react-navigation"; +import { Platform } from "react-native"; const { Navigator } = createMaterialTopTabNavigator(); @@ -19,6 +20,17 @@ export const Tab = withLayoutContext< >(Navigator); const Layout = () => { + // On TV, skip the Material Top Tab Navigator and render children directly + // The TV version handles its own tab navigation internally + if (Platform.isTV) { + return ( + <> + + + + ); + } + return ( <> diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/livetv/channels.tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/livetv/channels.tsx similarity index 97% rename from app/(auth)/(tabs)/(home,libraries,search,favorites)/livetv/channels.tsx rename to app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/livetv/channels.tsx index 6c9790b59..98b712acc 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites)/livetv/channels.tsx +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/livetv/channels.tsx @@ -8,7 +8,7 @@ import { ItemImage } from "@/components/common/ItemImage"; import { Text } from "@/components/common/Text"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; -export default function page() { +export default function LiveTvChannelsPage() { const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); const _insets = useSafeAreaInsets(); diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/livetv/guide.tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/livetv/guide.tsx similarity index 99% rename from app/(auth)/(tabs)/(home,libraries,search,favorites)/livetv/guide.tsx rename to app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/livetv/guide.tsx index 390e8eb60..a69318e68 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites)/livetv/guide.tsx +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/livetv/guide.tsx @@ -17,7 +17,7 @@ const ITEMS_PER_PAGE = 20; const MemoizedLiveTVGuideRow = React.memo(LiveTVGuideRow); -export default function page() { +export default function LiveTvGuidePage() { const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); const insets = useSafeAreaInsets(); diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/livetv/programs.tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/livetv/programs.tsx similarity index 95% rename from app/(auth)/(tabs)/(home,libraries,search,favorites)/livetv/programs.tsx rename to app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/livetv/programs.tsx index 812d084d9..f1471e3a0 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites)/livetv/programs.tsx +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/livetv/programs.tsx @@ -2,12 +2,21 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api"; import { useAtom } from "jotai"; import { useTranslation } from "react-i18next"; -import { ScrollView, View } from "react-native"; +import { Platform, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList"; +import { TVLiveTVPage } from "@/components/livetv/TVLiveTVPage"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; export default function page() { + if (Platform.isTV) { + return ; + } + + return ; +} + +function MobileLiveTVPrograms() { const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); const insets = useSafeAreaInsets(); diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/livetv/recordings.tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/livetv/recordings.tsx similarity index 86% rename from app/(auth)/(tabs)/(home,libraries,search,favorites)/livetv/recordings.tsx rename to app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/livetv/recordings.tsx index 9a390162a..cc482c557 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites)/livetv/recordings.tsx +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/livetv/recordings.tsx @@ -2,7 +2,7 @@ import { useTranslation } from "react-i18next"; import { View } from "react-native"; import { Text } from "@/components/common/Text"; -export default function page() { +export default function LiveTvRecordingsPage() { const { t } = useTranslation(); return ( diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/music/album/[albumId].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/music/album/[albumId].tsx new file mode 100644 index 000000000..1d1b7620e --- /dev/null +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/music/album/[albumId].tsx @@ -0,0 +1,300 @@ +import { Ionicons } from "@expo/vector-icons"; +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import { getItemsApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api"; +import { FlashList } from "@shopify/flash-list"; +import { useQuery } from "@tanstack/react-query"; +import { Image } from "expo-image"; +import { useLocalSearchParams, useNavigation } from "expo-router"; +import { useAtom } from "jotai"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + ActivityIndicator, + Dimensions, + TouchableOpacity, + View, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Text } from "@/components/common/Text"; +import { Loader } from "@/components/Loader"; +import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal"; +import { MusicTrackItem } from "@/components/music/MusicTrackItem"; +import { PlaylistPickerSheet } from "@/components/music/PlaylistPickerSheet"; +import { TrackOptionsSheet } from "@/components/music/TrackOptionsSheet"; +import { + downloadTrack, + isPermanentlyDownloaded, +} from "@/providers/AudioStorage"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { useMusicPlayer } from "@/providers/MusicPlayerProvider"; +import { getAudioStreamUrl } from "@/utils/jellyfin/audio/getAudioStreamUrl"; +import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; +import { runtimeTicksToMinutes } from "@/utils/time"; + +const { width: SCREEN_WIDTH } = Dimensions.get("window"); +const ARTWORK_SIZE = SCREEN_WIDTH * 0.5; + +export default function AlbumDetailScreen() { + const { albumId } = useLocalSearchParams<{ albumId: string }>(); + const [api] = useAtom(apiAtom); + const [user] = useAtom(userAtom); + const insets = useSafeAreaInsets(); + const navigation = useNavigation(); + const { t } = useTranslation(); + const { playQueue } = useMusicPlayer(); + + const [selectedTrack, setSelectedTrack] = useState(null); + const [trackOptionsOpen, setTrackOptionsOpen] = useState(false); + const [playlistPickerOpen, setPlaylistPickerOpen] = useState(false); + const [createPlaylistOpen, setCreatePlaylistOpen] = useState(false); + const [isDownloading, setIsDownloading] = useState(false); + + const handleTrackOptionsPress = useCallback((track: BaseItemDto) => { + setSelectedTrack(track); + setTrackOptionsOpen(true); + }, []); + + const handleAddToPlaylist = useCallback(() => { + setPlaylistPickerOpen(true); + }, []); + + const handleCreateNewPlaylist = useCallback(() => { + setCreatePlaylistOpen(true); + }, []); + + const { data: album, isLoading: loadingAlbum } = useQuery({ + queryKey: ["music-album", albumId, user?.Id], + queryFn: async () => { + const response = await getUserLibraryApi(api!).getItem({ + userId: user?.Id, + itemId: albumId!, + }); + return response.data; + }, + enabled: !!api && !!user?.Id && !!albumId, + }); + + const { data: tracks, isLoading: loadingTracks } = useQuery({ + queryKey: ["music-album-tracks", albumId, user?.Id], + queryFn: async () => { + const response = await getItemsApi(api!).getItems({ + userId: user?.Id, + parentId: albumId, + sortBy: ["IndexNumber"], + sortOrder: ["Ascending"], + }); + return response.data.Items || []; + }, + enabled: !!api && !!user?.Id && !!albumId, + }); + + useEffect(() => { + navigation.setOptions({ + title: album?.Name ?? "", + headerTransparent: true, + headerStyle: { backgroundColor: "transparent" }, + headerShadowVisible: false, + }); + }, [album?.Name, navigation]); + + const imageUrl = useMemo( + () => (album ? getPrimaryImageUrl({ api, item: album }) : null), + [api, album], + ); + + const totalDuration = useMemo(() => { + if (!tracks) return ""; + const totalTicks = tracks.reduce( + (acc, track) => acc + (track.RunTimeTicks || 0), + 0, + ); + return runtimeTicksToMinutes(totalTicks); + }, [tracks]); + + const handlePlayAll = useCallback(() => { + if (tracks && tracks.length > 0) { + playQueue(tracks, 0); + } + }, [playQueue, tracks]); + + const handleShuffle = useCallback(() => { + if (tracks && tracks.length > 0) { + const shuffled = [...tracks].sort(() => Math.random() - 0.5); + playQueue(shuffled, 0); + } + }, [playQueue, tracks]); + + // Check if all tracks are already permanently downloaded + const allTracksDownloaded = useMemo(() => { + if (!tracks || tracks.length === 0) return false; + return tracks.every((track) => isPermanentlyDownloaded(track.Id)); + }, [tracks]); + + const handleDownloadAlbum = useCallback(async () => { + if (!tracks || !api || !user?.Id || isDownloading) return; + + setIsDownloading(true); + try { + for (const track of tracks) { + if (!track.Id || isPermanentlyDownloaded(track.Id)) continue; + const result = await getAudioStreamUrl(api, user.Id, track.Id); + if (result?.url && !result.isTranscoding) { + await downloadTrack(track.Id, result.url, { + permanent: true, + container: result.mediaSource?.Container || undefined, + }); + } + } + } catch { + // Silent fail + } + setIsDownloading(false); + }, [tracks, api, user?.Id, isDownloading]); + + const isLoading = loadingAlbum || loadingTracks; + + // Only show loading if we have no cached data to display + if (isLoading && !album) { + return ( + + + + ); + } + + if (!album) { + return ( + + {t("music.album_not_found")} + + ); + } + + return ( + + {/* Album artwork */} + + {imageUrl ? ( + + ) : ( + + + + )} + + + {/* Album info */} + + {album.Name} + + + {album.AlbumArtist || album.Artists?.join(", ")} + + + {album.ProductionYear && `${album.ProductionYear} • `} + {tracks?.length} tracks • {totalDuration} + + + {/* Play buttons */} + + + + + {t("music.play")} + + + + + + {t("music.shuffle")} + + + + {isDownloading ? ( + + ) : ( + + )} + + + + } + renderItem={({ item, index }) => ( + + )} + keyExtractor={(item) => item.Id!} + ListFooterComponent={ + <> + + + + + } + /> + ); +} diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/music/artist/[artistId].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/music/artist/[artistId].tsx new file mode 100644 index 000000000..fe2631e9d --- /dev/null +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/music/artist/[artistId].tsx @@ -0,0 +1,273 @@ +import { Ionicons } from "@expo/vector-icons"; +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import { getItemsApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api"; +import { FlashList } from "@shopify/flash-list"; +import { useQuery } from "@tanstack/react-query"; +import { Image } from "expo-image"; +import { useLocalSearchParams, useNavigation } from "expo-router"; +import { useAtom } from "jotai"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Dimensions, TouchableOpacity, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { HorizontalScroll } from "@/components/common/HorizontalScroll"; +import { Text } from "@/components/common/Text"; +import { Loader } from "@/components/Loader"; +import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal"; +import { MusicAlbumCard } from "@/components/music/MusicAlbumCard"; +import { MusicTrackItem } from "@/components/music/MusicTrackItem"; +import { PlaylistPickerSheet } from "@/components/music/PlaylistPickerSheet"; +import { TrackOptionsSheet } from "@/components/music/TrackOptionsSheet"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { useMusicPlayer } from "@/providers/MusicPlayerProvider"; +import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; + +const { width: SCREEN_WIDTH } = Dimensions.get("window"); +const ARTWORK_SIZE = SCREEN_WIDTH * 0.4; + +export default function ArtistDetailScreen() { + const { artistId } = useLocalSearchParams<{ artistId: string }>(); + const [api] = useAtom(apiAtom); + const [user] = useAtom(userAtom); + const insets = useSafeAreaInsets(); + const navigation = useNavigation(); + const { t } = useTranslation(); + const { playQueue } = useMusicPlayer(); + + const [selectedTrack, setSelectedTrack] = useState(null); + const [trackOptionsOpen, setTrackOptionsOpen] = useState(false); + const [playlistPickerOpen, setPlaylistPickerOpen] = useState(false); + const [createPlaylistOpen, setCreatePlaylistOpen] = useState(false); + + const handleTrackOptionsPress = useCallback((track: BaseItemDto) => { + setSelectedTrack(track); + setTrackOptionsOpen(true); + }, []); + + const handleAddToPlaylist = useCallback(() => { + setPlaylistPickerOpen(true); + }, []); + + const handleCreateNewPlaylist = useCallback(() => { + setCreatePlaylistOpen(true); + }, []); + + const { data: artist, isLoading: loadingArtist } = useQuery({ + queryKey: ["music-artist", artistId, user?.Id], + queryFn: async () => { + const response = await getUserLibraryApi(api!).getItem({ + userId: user?.Id, + itemId: artistId!, + }); + return response.data; + }, + enabled: !!api && !!user?.Id && !!artistId, + }); + + const { data: albums, isLoading: loadingAlbums } = useQuery({ + queryKey: ["music-artist-albums", artistId, user?.Id], + queryFn: async () => { + const response = await getItemsApi(api!).getItems({ + userId: user?.Id, + artistIds: [artistId!], + includeItemTypes: ["MusicAlbum"], + sortBy: ["ProductionYear", "SortName"], + sortOrder: ["Descending", "Ascending"], + recursive: true, + }); + return response.data.Items || []; + }, + enabled: !!api && !!user?.Id && !!artistId, + }); + + const { data: topTracks, isLoading: loadingTracks } = useQuery({ + queryKey: ["music-artist-top-tracks", artistId, user?.Id], + queryFn: async () => { + const response = await getItemsApi(api!).getItems({ + userId: user?.Id, + artistIds: [artistId!], + includeItemTypes: ["Audio"], + sortBy: ["PlayCount"], + sortOrder: ["Descending"], + limit: 10, + recursive: true, + filters: ["IsPlayed"], + }); + return response.data.Items || []; + }, + enabled: !!api && !!user?.Id && !!artistId, + }); + + useEffect(() => { + navigation.setOptions({ + title: artist?.Name ?? "", + headerTransparent: true, + headerStyle: { backgroundColor: "transparent" }, + headerShadowVisible: false, + }); + }, [artist?.Name, navigation]); + + const imageUrl = useMemo( + () => (artist ? getPrimaryImageUrl({ api, item: artist }) : null), + [api, artist], + ); + + const handlePlayAllTracks = useCallback(() => { + if (topTracks && topTracks.length > 0) { + playQueue(topTracks, 0); + } + }, [playQueue, topTracks]); + + const isLoading = loadingArtist || loadingAlbums || loadingTracks; + + // Only show loading if we have no cached data to display + if (isLoading && !artist) { + return ( + + + + ); + } + + if (!artist) { + return ( + + {t("music.artist_not_found")} + + ); + } + + const sections = []; + + // Top tracks section + if (topTracks && topTracks.length > 0) { + sections.push({ + id: "top-tracks", + title: t("music.top_tracks"), + type: "tracks" as const, + data: topTracks, + }); + } + + // Albums section + if (albums && albums.length > 0) { + sections.push({ + id: "albums", + title: t("music.tabs.albums"), + type: "albums" as const, + data: albums, + }); + } + + return ( + + {/* Artist image */} + + {imageUrl ? ( + + ) : ( + + + + )} + + + {/* Artist info */} + + {artist.Name} + + + {albums?.length || 0} {t("music.tabs.albums").toLowerCase()} + + + {/* Play button */} + {topTracks && topTracks.length > 0 && ( + + + + {t("music.play_top_tracks")} + + + )} + + } + renderItem={({ item: section }) => ( + + {section.title} + {section.type === "albums" ? ( + item.Id!} + renderItem={(item) => } + /> + ) : ( + section.data + .slice(0, 5) + .map((track, index) => ( + + )) + )} + + )} + keyExtractor={(item) => item.id} + ListFooterComponent={ + <> + + + + + } + /> + ); +} diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/music/playlist/[playlistId].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/music/playlist/[playlistId].tsx new file mode 100644 index 000000000..346e40aa0 --- /dev/null +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/music/playlist/[playlistId].tsx @@ -0,0 +1,315 @@ +import { Ionicons } from "@expo/vector-icons"; +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import { getItemsApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api"; +import { FlashList } from "@shopify/flash-list"; +import { useQuery } from "@tanstack/react-query"; +import { Image } from "expo-image"; +import { useLocalSearchParams, useNavigation } from "expo-router"; +import { useAtom } from "jotai"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { ActivityIndicator, TouchableOpacity, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Text } from "@/components/common/Text"; +import { Loader } from "@/components/Loader"; +import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal"; +import { MusicTrackItem } from "@/components/music/MusicTrackItem"; +import { PlaylistOptionsSheet } from "@/components/music/PlaylistOptionsSheet"; +import { PlaylistPickerSheet } from "@/components/music/PlaylistPickerSheet"; +import { TrackOptionsSheet } from "@/components/music/TrackOptionsSheet"; +import { useRemoveFromPlaylist } from "@/hooks/usePlaylistMutations"; +import { downloadTrack, getLocalPath } from "@/providers/AudioStorage"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { useMusicPlayer } from "@/providers/MusicPlayerProvider"; +import { getAudioStreamUrl } from "@/utils/jellyfin/audio/getAudioStreamUrl"; +import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; +import { runtimeTicksToMinutes } from "@/utils/time"; + +const ARTWORK_SIZE = 120; + +export default function PlaylistDetailScreen() { + const { playlistId } = useLocalSearchParams<{ playlistId: string }>(); + const [api] = useAtom(apiAtom); + const [user] = useAtom(userAtom); + const insets = useSafeAreaInsets(); + const navigation = useNavigation(); + const { t } = useTranslation(); + const { playQueue } = useMusicPlayer(); + + const [selectedTrack, setSelectedTrack] = useState(null); + const [trackOptionsOpen, setTrackOptionsOpen] = useState(false); + const [playlistPickerOpen, setPlaylistPickerOpen] = useState(false); + const [createPlaylistOpen, setCreatePlaylistOpen] = useState(false); + const [playlistOptionsOpen, setPlaylistOptionsOpen] = useState(false); + const [isDownloading, setIsDownloading] = useState(false); + + const removeFromPlaylist = useRemoveFromPlaylist(); + + const handleTrackOptionsPress = useCallback((track: BaseItemDto) => { + setSelectedTrack(track); + setTrackOptionsOpen(true); + }, []); + + const handleAddToPlaylist = useCallback(() => { + setPlaylistPickerOpen(true); + }, []); + + const handleCreateNewPlaylist = useCallback(() => { + setCreatePlaylistOpen(true); + }, []); + + const handleRemoveFromPlaylist = useCallback(() => { + if (selectedTrack?.Id && playlistId) { + removeFromPlaylist.mutate({ + playlistId, + entryIds: [selectedTrack.PlaylistItemId ?? selectedTrack.Id], + }); + } + }, [selectedTrack, playlistId, removeFromPlaylist]); + + const { data: playlist, isLoading: loadingPlaylist } = useQuery({ + queryKey: ["music-playlist", playlistId, user?.Id], + queryFn: async () => { + const response = await getUserLibraryApi(api!).getItem({ + userId: user?.Id, + itemId: playlistId!, + }); + return response.data; + }, + enabled: !!api && !!user?.Id && !!playlistId, + }); + + const { data: tracks, isLoading: loadingTracks } = useQuery({ + queryKey: ["music-playlist-tracks", playlistId, user?.Id], + queryFn: async () => { + const response = await getItemsApi(api!).getItems({ + userId: user?.Id, + parentId: playlistId, + }); + return response.data.Items || []; + }, + enabled: !!api && !!user?.Id && !!playlistId, + }); + + useEffect(() => { + navigation.setOptions({ + title: playlist?.Name ?? "", + headerTransparent: true, + headerStyle: { backgroundColor: "transparent" }, + headerShadowVisible: false, + headerRight: () => ( + setPlaylistOptionsOpen(true)} + className='p-1.5' + > + + + ), + }); + }, [playlist?.Name, navigation]); + + const imageUrl = useMemo( + () => (playlist ? getPrimaryImageUrl({ api, item: playlist }) : null), + [api, playlist], + ); + + const totalDuration = useMemo(() => { + if (!tracks) return ""; + const totalTicks = tracks.reduce( + (acc, track) => acc + (track.RunTimeTicks || 0), + 0, + ); + return runtimeTicksToMinutes(totalTicks); + }, [tracks]); + + const handlePlayAll = useCallback(() => { + if (tracks && tracks.length > 0) { + playQueue(tracks, 0); + } + }, [playQueue, tracks]); + + const handleShuffle = useCallback(() => { + if (tracks && tracks.length > 0) { + const shuffled = [...tracks].sort(() => Math.random() - 0.5); + playQueue(shuffled, 0); + } + }, [playQueue, tracks]); + + // Check if all tracks are already downloaded + const allTracksDownloaded = useMemo(() => { + if (!tracks || tracks.length === 0) return false; + return tracks.every((track) => !!getLocalPath(track.Id)); + }, [tracks]); + + const handleDownloadPlaylist = useCallback(async () => { + if (!tracks || !api || !user?.Id || isDownloading) return; + + setIsDownloading(true); + try { + for (const track of tracks) { + if (!track.Id || getLocalPath(track.Id)) continue; + const result = await getAudioStreamUrl(api, user.Id, track.Id); + if (result?.url && !result.isTranscoding) { + await downloadTrack(track.Id, result.url, { + permanent: true, + container: result.mediaSource?.Container || undefined, + }); + } + } + } catch { + // Silent fail + } + setIsDownloading(false); + }, [tracks, api, user?.Id, isDownloading]); + + const isLoading = loadingPlaylist || loadingTracks; + + // Only show loading if we have no cached data to display + if (isLoading && !playlist) { + return ( + + + + ); + } + + if (!playlist) { + return ( + + + {t("music.playlist_not_found")} + + + ); + } + + return ( + + {/* Playlist artwork */} + + {imageUrl ? ( + + ) : ( + + + + )} + + + {/* Playlist info */} + + {playlist.Name} + + + {tracks?.length} tracks • {totalDuration} + + + {/* Play buttons */} + + + + + {t("music.play")} + + + + + + {t("music.shuffle")} + + + + {isDownloading ? ( + + ) : ( + + )} + + + + } + renderItem={({ item, index }) => ( + + )} + keyExtractor={(item) => item.Id!} + ListFooterComponent={ + <> + + + + + + } + /> + ); +} diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/persons/[personId].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/persons/[personId].tsx similarity index 91% rename from app/(auth)/(tabs)/(home,libraries,search,favorites)/persons/[personId].tsx rename to app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/persons/[personId].tsx index f2f8dcafe..a6dde1e0a 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites)/persons/[personId].tsx +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/persons/[personId].tsx @@ -6,7 +6,7 @@ import { useLocalSearchParams } from "expo-router"; import { useAtom } from "jotai"; import { useCallback, useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { View } from "react-native"; +import { Platform, View } from "react-native"; import { InfiniteHorizontalScroll } from "@/components/common/InfiniteHorizontalScroll"; import { Text } from "@/components/common/Text"; import { TouchableItemRouter } from "@/components/common/TouchableItemRouter"; @@ -15,6 +15,7 @@ import { Loader } from "@/components/Loader"; import { MoviesTitleHeader } from "@/components/movies/MoviesTitleHeader"; import { OverviewText } from "@/components/OverviewText"; import { ParallaxScrollView } from "@/components/ParallaxPage"; +import { TVActorPage } from "@/components/persons/TVActorPage"; import MoviePoster from "@/components/posters/MoviePoster"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl"; @@ -23,6 +24,16 @@ import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData"; const page: React.FC = () => { const local = useLocalSearchParams(); const { personId } = local as { personId: string }; + + // Render TV-optimized page on TV platforms + if (Platform.isTV) { + return ; + } + + return ; +}; + +const MobileActorPage: React.FC<{ personId: string }> = ({ personId }) => { const { t } = useTranslation(); const [api] = useAtom(apiAtom); diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/series/[id].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/series/[id].tsx new file mode 100644 index 000000000..cc4b21b98 --- /dev/null +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/series/[id].tsx @@ -0,0 +1,232 @@ +import { Ionicons } from "@expo/vector-icons"; +import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api"; +import { useQuery } from "@tanstack/react-query"; +import { Image } from "expo-image"; +import { useLocalSearchParams, useNavigation } from "expo-router"; +import { useAtom } from "jotai"; +import type React from "react"; +import { useEffect, useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { Platform, View } from "react-native"; +import { AddToFavorites } from "@/components/AddToFavorites"; +import { DownloadItems } from "@/components/DownloadItem"; +import { ParallaxScrollView } from "@/components/ParallaxPage"; +import { NextUp } from "@/components/series/NextUp"; +import { SeasonPicker } from "@/components/series/SeasonPicker"; +import { SeriesHeader } from "@/components/series/SeriesHeader"; +import { TVSeriesPage } from "@/components/series/TVSeriesPage"; +import { useDownload } from "@/providers/DownloadProvider"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { OfflineModeProvider } from "@/providers/OfflineModeProvider"; +import { + buildOfflineSeriesFromEpisodes, + getDownloadedEpisodesForSeries, +} from "@/utils/downloads/offline-series"; +import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl"; +import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById"; +import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData"; +import { storage } from "@/utils/mmkv"; + +const page: React.FC = () => { + const navigation = useNavigation(); + const { t } = useTranslation(); + const params = useLocalSearchParams(); + const { + id: seriesId, + seasonIndex, + offline: offlineParam, + } = params as { + id: string; + seasonIndex: string; + offline?: string; + }; + + const isOffline = offlineParam === "true"; + + const [api] = useAtom(apiAtom); + const [user] = useAtom(userAtom); + const { getDownloadedItems, downloadedItems } = useDownload(); + + // For offline mode, construct series data from downloaded episodes + // Include downloadedItems.length so query refetches when items are deleted + const { data: item } = useQuery({ + queryKey: ["series", seriesId, isOffline, downloadedItems.length], + queryFn: async () => { + if (isOffline) { + return buildOfflineSeriesFromEpisodes(getDownloadedItems(), seriesId); + } + return await getUserItemData({ + api, + userId: user?.Id, + itemId: seriesId, + }); + }, + staleTime: isOffline ? Infinity : 60 * 1000, + refetchInterval: !isOffline && Platform.isTV ? 60 * 1000 : undefined, + enabled: isOffline || (!!api && !!user?.Id), + }); + + // For offline mode, use stored base64 image + const base64Image = useMemo(() => { + if (isOffline) { + return storage.getString(seriesId); + } + return null; + }, [isOffline, seriesId]); + + const backdropUrl = useMemo(() => { + if (isOffline && base64Image) { + return `data:image/jpeg;base64,${base64Image}`; + } + return getBackdropUrl({ + api, + item, + quality: 90, + width: 1000, + }); + }, [isOffline, base64Image, api, item]); + + const logoUrl = useMemo(() => { + if (isOffline) { + return null; // No logo in offline mode + } + return getLogoImageUrlById({ + api, + item, + }); + }, [isOffline, api, item]); + + const { data: allEpisodes, isLoading } = useQuery({ + queryKey: ["AllEpisodes", seriesId, isOffline, downloadedItems.length], + queryFn: async () => { + if (isOffline) { + return getDownloadedEpisodesForSeries(getDownloadedItems(), seriesId); + } + if (!api || !user?.Id) return []; + + const res = await getTvShowsApi(api).getEpisodes({ + seriesId: seriesId, + userId: user.Id, + enableUserData: true, + fields: ["MediaSources", "MediaStreams", "Overview", "Trickplay"], + }); + return res?.data.Items || []; + }, + select: (data) => + [...(data || [])].sort( + (a, b) => + (a.ParentIndexNumber ?? 0) - (b.ParentIndexNumber ?? 0) || + (a.IndexNumber ?? 0) - (b.IndexNumber ?? 0), + ), + staleTime: isOffline ? Infinity : 60 * 1000, + refetchInterval: !isOffline && Platform.isTV ? 60 * 1000 : undefined, + enabled: isOffline || (!!api && !!user?.Id), + }); + + useEffect(() => { + // Don't show header buttons in offline mode + if (isOffline) { + navigation.setOptions({ + headerRight: () => null, + }); + return; + } + + navigation.setOptions({ + headerRight: () => + !isLoading && item && allEpisodes && allEpisodes.length > 0 ? ( + + + {!Platform.isTV && ( + ( + + )} + DownloadedIconComponent={() => ( + + )} + /> + )} + + ) : null, + }); + }, [allEpisodes, isLoading, item, isOffline]); + + // For offline mode, we can show the page even without backdropUrl + if (!item || (!isOffline && !backdropUrl)) return null; + + // TV version + if (Platform.isTV) { + return ( + + + + ); + } + + return ( + + + ) : ( + + ) + } + logo={ + logoUrl ? ( + + ) : undefined + } + > + + + {!isOffline && ( + + + + )} + + + + + ); +}; + +export default page; diff --git a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx index 481881fcd..cdb1a181f 100644 --- a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx +++ b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx @@ -2,6 +2,7 @@ import type { BaseItemDto, BaseItemDtoQueryResult, BaseItemKind, + ItemFilter, } from "@jellyfin/sdk/lib/generated-client/models"; import { getFilterApi, @@ -10,24 +11,51 @@ import { } from "@jellyfin/sdk/lib/utils/api"; import { FlashList } from "@shopify/flash-list"; import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; -import { useLocalSearchParams, useNavigation } from "expo-router"; +import { Image } from "expo-image"; +import { + useFocusEffect, + useLocalSearchParams, + useNavigation, +} from "expo-router"; import { useAtom } from "jotai"; -import React, { useCallback, useEffect, useMemo } from "react"; +import React, { useCallback, useEffect, useMemo, useRef } from "react"; import { useTranslation } from "react-i18next"; -import { FlatList, useWindowDimensions, View } from "react-native"; +import { + BackHandler, + FlatList, + Platform, + ScrollView, + useWindowDimensions, + View, +} from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Text } from "@/components/common/Text"; -import { TouchableItemRouter } from "@/components/common/TouchableItemRouter"; +import { + getItemNavigation, + TouchableItemRouter, +} from "@/components/common/TouchableItemRouter"; import { FilterButton } from "@/components/filters/FilterButton"; import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton"; import { ItemCardText } from "@/components/ItemCardText"; import { Loader } from "@/components/Loader"; import { ItemPoster } from "@/components/posters/ItemPoster"; +import { TVFilterButton, TVFocusablePoster } from "@/components/tv"; +import { TVPosterCard } from "@/components/tv/TVPosterCard"; +import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes"; +import { useScaledTVTypography } from "@/constants/TVTypography"; +import useRouter from "@/hooks/useAppRouter"; import { useOrientation } from "@/hooks/useOrientation"; +import { useRefreshLibraryOnFocus } from "@/hooks/useRefreshLibraryOnFocus"; +import { useTVItemActionModal } from "@/hooks/useTVItemActionModal"; +import { useTVOptionModal } from "@/hooks/useTVOptionModal"; import * as ScreenOrientation from "@/packages/expo-screen-orientation"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { + FilterByOption, + FilterByPreferenceAtom, + filterByAtom, genreFilterAtom, + getFilterByPreference, getSortByPreference, getSortOrderPreference, SortByOption, @@ -39,13 +67,30 @@ import { sortOrderOptions, sortOrderPreferenceAtom, tagsFilterAtom, + useFilterOptions, yearFilterAtom, } from "@/utils/atoms/filters"; +import { useSettings } from "@/utils/atoms/settings"; +import type { TVOptionItem } from "@/utils/atoms/tvOptionModal"; +import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; + +const TV_ITEM_GAP = 20; +const TV_HORIZONTAL_PADDING = 60; +const _TV_SCALE_PADDING = 20; +const TV_PLAYLIST_SQUARE_SIZE = 180; const Page = () => { - const searchParams = useLocalSearchParams(); - const { libraryId } = searchParams as { libraryId: string }; + const searchParams = useLocalSearchParams() as { + libraryId: string; + sortBy?: string; + sortOrder?: string; + filterBy?: string; + fromSeeAll?: string; + }; + const { libraryId, fromSeeAll } = searchParams; + const typography = useScaledTVTypography(); + const posterSizes = useScaledTVPosterSizes(); const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); const { width: screenWidth } = useWindowDimensions(); @@ -54,28 +99,111 @@ const Page = () => { const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom); const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom); const [sortBy, _setSortBy] = useAtom(sortByAtom); + const [filterBy, _setFilterBy] = useAtom(filterByAtom); const [sortOrder, _setSortOrder] = useAtom(sortOrderAtom); const [sortByPreference, setSortByPreference] = useAtom(sortByPreferenceAtom); - const [sortOrderPreference, setOderByPreference] = useAtom( + const [filterByPreference, setFilterByPreference] = useAtom( + FilterByPreferenceAtom, + ); + const [sortOrderPreference, setOrderByPreference] = useAtom( sortOrderPreferenceAtom, ); const { orientation } = useOrientation(); + // Fallback refresh for newly added content when returning to the library + // (primary path is the LibraryChanged WebSocket event). + useRefreshLibraryOnFocus(); + const { t } = useTranslation(); + const router = useRouter(); + const { showOptions } = useTVOptionModal(); + + // When this library detail was opened from the home "See All" button, its + // libraries stack is just [detail], so the default TV Back would exit to home. + // Intercept Back (scoped to while this screen is focused via useFocusEffect) and + // route to the library list instead, so the user can switch libraries. Normal + // entries from the list keep their native pop-to-list behavior. + useFocusEffect( + useCallback(() => { + if (!Platform.isTV || fromSeeAll !== "true") return; + const sub = BackHandler.addEventListener("hardwareBackPress", () => { + router.replace("/(auth)/(tabs)/(libraries)"); + return true; + }); + return () => sub.remove(); + }, [fromSeeAll, router]), + ); + const { showItemActions } = useTVItemActionModal(); + + // TV Filter queries + const { data: tvGenreOptions } = useQuery({ + queryKey: ["filters", "Genres", "tvGenreFilter", libraryId], + queryFn: async () => { + if (!api) return []; + const response = await getFilterApi(api).getQueryFiltersLegacy({ + userId: user?.Id, + parentId: libraryId, + }); + return response.data.Genres || []; + }, + enabled: Platform.isTV && !!api && !!user?.Id && !!libraryId, + }); + + const { data: tvYearOptions } = useQuery({ + queryKey: ["filters", "Years", "tvYearFilter", libraryId], + queryFn: async () => { + if (!api) return []; + const response = await getFilterApi(api).getQueryFiltersLegacy({ + userId: user?.Id, + parentId: libraryId, + }); + return response.data.Years || []; + }, + enabled: Platform.isTV && !!api && !!user?.Id && !!libraryId, + }); + + const { data: tvTagOptions } = useQuery({ + queryKey: ["filters", "Tags", "tvTagFilter", libraryId], + queryFn: async () => { + if (!api) return []; + const response = await getFilterApi(api).getQueryFiltersLegacy({ + userId: user?.Id, + parentId: libraryId, + }); + return response.data.Tags || []; + }, + enabled: Platform.isTV && !!api && !!user?.Id && !!libraryId, + }); useEffect(() => { - const sop = getSortOrderPreference(libraryId, sortOrderPreference); - if (sop) { - _setSortOrder([sop]); + // Check for URL params first (from "See All" navigation) + const urlSortBy = searchParams.sortBy as SortByOption | undefined; + const urlSortOrder = searchParams.sortOrder as SortOrderOption | undefined; + const urlFilterBy = searchParams.filterBy as FilterByOption | undefined; + + // Apply sortOrder: URL param > saved preference > default + if (urlSortOrder && Object.values(SortOrderOption).includes(urlSortOrder)) { + _setSortOrder([urlSortOrder]); } else { - _setSortOrder([SortOrderOption.Ascending]); + const sop = getSortOrderPreference(libraryId, sortOrderPreference); + _setSortOrder([sop || SortOrderOption.Ascending]); } - const obp = getSortByPreference(libraryId, sortByPreference); - if (obp) { - _setSortBy([obp]); + + // Apply sortBy: URL param > saved preference > default + if (urlSortBy && Object.values(SortByOption).includes(urlSortBy)) { + _setSortBy([urlSortBy]); } else { - _setSortBy([SortByOption.SortName]); + const obp = getSortByPreference(libraryId, sortByPreference); + _setSortBy([obp || SortByOption.SortName]); + } + + // Apply filterBy: URL param > saved preference > default + if (urlFilterBy && Object.values(FilterByOption).includes(urlFilterBy)) { + _setFilterBy([urlFilterBy]); + } else { + const fp = getFilterByPreference(libraryId, filterByPreference); + _setFilterBy(fp ? [fp] : []); } }, [ libraryId, @@ -83,6 +211,11 @@ const Page = () => { sortByPreference, _setSortOrder, _setSortBy, + filterByPreference, + _setFilterBy, + searchParams.sortBy, + searchParams.sortOrder, + searchParams.filterBy, ]); const setSortBy = useCallback( @@ -100,17 +233,35 @@ const Page = () => { (sortOrder: SortOrderOption[]) => { const sop = getSortOrderPreference(libraryId, sortOrderPreference); if (sortOrder[0] !== sop) { - setOderByPreference({ + setOrderByPreference({ ...sortOrderPreference, [libraryId]: sortOrder[0], }); } _setSortOrder(sortOrder); }, - [libraryId, sortOrderPreference, setOderByPreference, _setSortOrder], + [libraryId, sortOrderPreference, setOrderByPreference, _setSortOrder], + ); + + const setFilter = useCallback( + (filterBy: FilterByOption[]) => { + const fp = getFilterByPreference(libraryId, filterByPreference); + if (filterBy[0] !== fp) { + setFilterByPreference({ + ...filterByPreference, + [libraryId]: filterBy[0], + }); + } + _setFilterBy(filterBy); + }, + [libraryId, filterByPreference, setFilterByPreference, _setFilterBy], ); const nrOfCols = useMemo(() => { + if (Platform.isTV) { + // TV uses flexWrap, so nrOfCols is just for mobile + return 1; + } if (screenWidth < 300) return 2; if (screenWidth < 500) return 3; if (screenWidth < 800) return 5; @@ -140,6 +291,23 @@ const Page = () => { }); }, [library]); + // If this See-All detail was deep-linked on top of the libraries index, collapse + // the libraries stack to just this screen. Otherwise the stack is [index, detail], + // which the native bottom tab reliably auto-pops back to the index (the detail + // "bounces" to the library list ~0.5s after opening). With [detail] alone it stays + // put, and Back is handled explicitly by the fromSeeAll interceptor above. + const didCollapseRef = useRef(false); + useEffect(() => { + if (!Platform.isTV || fromSeeAll !== "true" || didCollapseRef.current) + return; + const state = navigation.getState(); + if (state?.routes && state.routes.length > 1) { + didCollapseRef.current = true; + const top = state.routes[state.routes.length - 1]; + navigation.reset({ index: 0, routes: [top] } as any); + } + }, [navigation, fromSeeAll]); + const fetchItems = useCallback( async ({ pageParam, @@ -158,6 +326,12 @@ const Page = () => { itemType = "Series"; } else if (library.CollectionType === "boxsets") { itemType = "BoxSet"; + } else if (library.CollectionType === "homevideos") { + itemType = "Video"; + } else if (library.CollectionType === "musicvideos") { + itemType = "MusicVideo"; + } else if (library.CollectionType === "playlists") { + itemType = "Playlist"; } const response = await getItemsApi(api).getItems({ @@ -168,6 +342,7 @@ const Page = () => { sortBy: [sortBy[0], "SortName", "ProductionYear"], sortOrder: [sortOrder[0]], enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"], + filters: filterBy as ItemFilter[], // true is needed for merged versions recursive: true, imageTypeLimit: 1, @@ -176,6 +351,9 @@ const Page = () => { tags: selectedTags, years: selectedYears.map((year) => Number.parseInt(year, 10)), includeItemTypes: itemType ? [itemType] : undefined, + ...(Platform.isTV && library.CollectionType === "playlists" + ? { mediaTypes: ["Video"] } + : {}), }); return response.data || null; @@ -190,6 +368,7 @@ const Page = () => { selectedTags, sortBy, sortOrder, + filterBy, ], ); @@ -203,6 +382,7 @@ const Page = () => { selectedTags, sortBy, sortOrder, + filterBy, ], queryFn: fetchItems, getNextPageParam: (lastPage, pages) => { @@ -264,11 +444,93 @@ const Page = () => { ), - [orientation], + [orientation, nrOfCols], + ); + + const renderTVItem = useCallback( + (item: BaseItemDto) => { + const handlePress = () => { + if (item.Type === "Playlist") { + router.push({ + pathname: "/(auth)/(tabs)/(libraries)/[libraryId]", + params: { libraryId: item.Id! }, + }); + return; + } + const navTarget = getItemNavigation(item, "(libraries)"); + router.push(navTarget as any); + }; + + // Special rendering for Playlist items (square thumbnails) + if (item.Type === "Playlist") { + const playlistImageUrl = getPrimaryImageUrl({ + api, + item, + width: TV_PLAYLIST_SQUARE_SIZE * 2, + }); + + return ( + + showItemActions(item)} + > + + + + + + + {item.Name} + + + + ); + } + + return ( + showItemActions(item)} + width={posterSizes.poster} + /> + ); + }, + [router, showItemActions, api, typography], ); const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []); - + const generalFilters = useFilterOptions(); + const settings = useSettings(); const ListHeaderComponent = useCallback( () => ( { /> ), }, + { + key: "filterOptions", + component: ( + generalFilters.map((s) => s.key)} + set={setFilter} + values={filterBy} + title={t("library.filters.filter_by")} + renderItemLabel={(item) => + generalFilters.find((i) => i.key === item)?.value || "" + } + searchFilter={(item, search) => + item.toLowerCase().includes(search.toLowerCase()) + } + /> + ), + }, ]} renderItem={({ item }) => item.component} keyExtractor={(item) => item.key} @@ -424,9 +706,194 @@ const Page = () => { sortOrder, setSortOrder, isFetching, + filterBy, + setFilter, + settings, ], ); + // TV Filter bar header + const hasActiveFilters = + selectedGenres.length > 0 || + selectedYears.length > 0 || + selectedTags.length > 0 || + filterBy.length > 0; + + const resetAllFilters = useCallback(() => { + setSelectedGenres([]); + setSelectedYears([]); + setSelectedTags([]); + _setFilterBy([]); + }, [setSelectedGenres, setSelectedYears, setSelectedTags, _setFilterBy]); + + // TV Filter options - with "All" option for clearable filters + const tvGenreFilterOptions = useMemo( + (): TVOptionItem[] => [ + { + label: t("library.filters.all"), + value: "__all__", + selected: selectedGenres.length === 0, + }, + ...(tvGenreOptions || []).map((genre) => ({ + label: genre, + value: genre, + selected: selectedGenres.includes(genre), + })), + ], + [tvGenreOptions, selectedGenres, t], + ); + + const tvYearFilterOptions = useMemo( + (): TVOptionItem[] => [ + { + label: t("library.filters.all"), + value: "__all__", + selected: selectedYears.length === 0, + }, + ...(tvYearOptions || []).map((year) => ({ + label: String(year), + value: String(year), + selected: selectedYears.includes(String(year)), + })), + ], + [tvYearOptions, selectedYears, t], + ); + + const tvTagFilterOptions = useMemo( + (): TVOptionItem[] => [ + { + label: t("library.filters.all"), + value: "__all__", + selected: selectedTags.length === 0, + }, + ...(tvTagOptions || []).map((tag) => ({ + label: tag, + value: tag, + selected: selectedTags.includes(tag), + })), + ], + [tvTagOptions, selectedTags, t], + ); + + const tvSortByOptions = useMemo( + (): TVOptionItem[] => + sortOptions.map((option) => ({ + label: option.value, + value: option.key, + selected: sortBy[0] === option.key, + })), + [sortBy], + ); + + const tvSortOrderOptions = useMemo( + (): TVOptionItem[] => + sortOrderOptions.map((option) => ({ + label: option.value, + value: option.key, + selected: sortOrder[0] === option.key, + })), + [sortOrder], + ); + + const tvFilterByOptions = useMemo( + (): TVOptionItem[] => [ + { + label: t("library.filters.all"), + value: "__all__", + selected: filterBy.length === 0, + }, + ...generalFilters.map((option) => ({ + label: option.value, + value: option.key, + selected: filterBy.includes(option.key), + })), + ], + [filterBy, generalFilters, t], + ); + + // TV Filter handlers using navigation-based modal + const handleShowGenreFilter = useCallback(() => { + showOptions({ + title: t("library.filters.genres"), + options: tvGenreFilterOptions, + onSelect: (value: string) => { + if (value === "__all__") { + setSelectedGenres([]); + } else if (selectedGenres.includes(value)) { + setSelectedGenres(selectedGenres.filter((g) => g !== value)); + } else { + setSelectedGenres([...selectedGenres, value]); + } + }, + }); + }, [showOptions, t, tvGenreFilterOptions, selectedGenres, setSelectedGenres]); + + const handleShowYearFilter = useCallback(() => { + showOptions({ + title: t("library.filters.years"), + options: tvYearFilterOptions, + onSelect: (value: string) => { + if (value === "__all__") { + setSelectedYears([]); + } else if (selectedYears.includes(value)) { + setSelectedYears(selectedYears.filter((y) => y !== value)); + } else { + setSelectedYears([...selectedYears, value]); + } + }, + }); + }, [showOptions, t, tvYearFilterOptions, selectedYears, setSelectedYears]); + + const handleShowTagFilter = useCallback(() => { + showOptions({ + title: t("library.filters.tags"), + options: tvTagFilterOptions, + onSelect: (value: string) => { + if (value === "__all__") { + setSelectedTags([]); + } else if (selectedTags.includes(value)) { + setSelectedTags(selectedTags.filter((tag) => tag !== value)); + } else { + setSelectedTags([...selectedTags, value]); + } + }, + }); + }, [showOptions, t, tvTagFilterOptions, selectedTags, setSelectedTags]); + + const handleShowSortByFilter = useCallback(() => { + showOptions({ + title: t("library.filters.sort_by"), + options: tvSortByOptions, + onSelect: (value: SortByOption) => { + setSortBy([value]); + }, + }); + }, [showOptions, t, tvSortByOptions, setSortBy]); + + const handleShowSortOrderFilter = useCallback(() => { + showOptions({ + title: t("library.filters.sort_order"), + options: tvSortOrderOptions, + onSelect: (value: SortOrderOption) => { + setSortOrder([value]); + }, + }); + }, [showOptions, t, tvSortOrderOptions, setSortOrder]); + + const handleShowFilterByFilter = useCallback(() => { + showOptions({ + title: t("library.filters.filter_by"), + options: tvFilterByOptions, + onSelect: (value: string) => { + if (value === "__all__") { + _setFilterBy([]); + } else { + setFilter([value as FilterByOption]); + } + }, + }); + }, [showOptions, t, tvFilterByOptions, setFilter, _setFilterBy]); + const insets = useSafeAreaInsets(); if (isLoading || isLibraryLoading) @@ -436,43 +903,176 @@ const Page = () => { ); + // Mobile return + if (!Platform.isTV) { + return ( + + + {t("library.no_results")} + + + } + contentInsetAdjustmentBehavior='automatic' + data={flatData} + renderItem={renderItem} + extraData={[orientation, nrOfCols]} + keyExtractor={keyExtractor} + numColumns={nrOfCols} + onEndReached={() => { + if (hasNextPage) { + fetchNextPage(); + } + }} + onEndReachedThreshold={1} + ListHeaderComponent={ListHeaderComponent} + contentContainerStyle={{ + paddingBottom: 24, + paddingLeft: insets.left, + paddingRight: insets.right, + }} + ItemSeparatorComponent={() => ( + + )} + /> + ); + } + + // TV return with filter bar return ( - - - {t("library.no_results")} - - - } - contentInsetAdjustmentBehavior='automatic' - data={flatData} - renderItem={renderItem} - extraData={[orientation, nrOfCols]} - keyExtractor={keyExtractor} - numColumns={nrOfCols} - onEndReached={() => { - if (hasNextPage) { + { + // Load more when near bottom + const { layoutMeasurement, contentOffset, contentSize } = nativeEvent; + const isNearBottom = + layoutMeasurement.height + contentOffset.y >= + contentSize.height - 500; + if (isNearBottom && hasNextPage && !isFetching) { fetchNextPage(); } }} - onEndReachedThreshold={1} - ListHeaderComponent={ListHeaderComponent} - contentContainerStyle={{ - paddingBottom: 24, - paddingLeft: insets.left, - paddingRight: insets.right, - }} - ItemSeparatorComponent={() => ( + scrollEventThrottle={400} + > + {/* Filter bar */} + + {hasActiveFilters && ( + + )} + 0 + ? `${selectedGenres.length} selected` + : t("library.filters.all") + } + onPress={handleShowGenreFilter} + hasTVPreferredFocus={!hasActiveFilters} + hasActiveFilter={selectedGenres.length > 0} + /> + 0 + ? `${selectedYears.length} selected` + : t("library.filters.all") + } + onPress={handleShowYearFilter} + hasActiveFilter={selectedYears.length > 0} + /> + 0 + ? `${selectedTags.length} selected` + : t("library.filters.all") + } + onPress={handleShowTagFilter} + hasActiveFilter={selectedTags.length > 0} + /> + o.key === sortBy[0])?.value || ""} + onPress={handleShowSortByFilter} + /> + o.key === sortOrder[0])?.value || "" + } + onPress={handleShowSortOrderFilter} + /> + 0 + ? generalFilters.find((o) => o.key === filterBy[0])?.value || "" + : t("library.filters.all") + } + onPress={handleShowFilterByFilter} + hasActiveFilter={filterBy.length > 0} + /> + + + {/* Grid with flexWrap */} + {flatData.length === 0 ? ( + > + + {t("library.no_results")} + + + ) : ( + + {flatData.map((item) => renderTVItem(item))} + )} - /> + + {/* Loading indicator */} + {isFetching && ( + + + + )} + ); }; diff --git a/app/(auth)/(tabs)/(libraries)/_layout.tsx b/app/(auth)/(tabs)/(libraries)/_layout.tsx index acc7f8173..ed31b438f 100644 --- a/app/(auth)/(tabs)/(libraries)/_layout.tsx +++ b/app/(auth)/(tabs)/(libraries)/_layout.tsx @@ -166,7 +166,7 @@ export default function IndexLayout() { open={dropdownOpen} onOpenChange={setDropdownOpen} trigger={ - + ; + } - const { t } = useTranslation(); - - const { data, isLoading } = useQuery({ - queryKey: ["user-views", user?.Id], - queryFn: async () => { - const response = await getUserViewsApi(api!).getUserViews({ - userId: user?.Id, - }); - - return response.data.Items || null; - }, - staleTime: 60, - }); - - const libraries = useMemo( - () => - data - ?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!)) - .filter((l) => l.CollectionType !== "music") - .filter((l) => l.CollectionType !== "books") || [], - [data, settings?.hiddenLibraries], - ); - - useEffect(() => { - for (const item of data || []) { - queryClient.prefetchQuery({ - queryKey: ["library", item.Id], - queryFn: async () => { - if (!item.Id || !user?.Id || !api) return null; - const response = await getUserLibraryApi(api).getItem({ - itemId: item.Id, - userId: user?.Id, - }); - return response.data; - }, - staleTime: 60 * 1000, - }); - } - }, [data]); - - const insets = useSafeAreaInsets(); - - if (isLoading) - return ( - - - - ); - - if (!libraries) - return ( - - - {t("library.no_libraries_found")} - - - ); - - return ( - } - keyExtractor={(item) => item.Id || ""} - ItemSeparatorComponent={() => - settings?.libraryOptions?.display === "row" ? ( - - ) : ( - - ) - } - /> - ); + return ; } diff --git a/app/(auth)/(tabs)/(libraries)/music/[libraryId]/_layout.tsx b/app/(auth)/(tabs)/(libraries)/music/[libraryId]/_layout.tsx new file mode 100644 index 000000000..bf21238ad --- /dev/null +++ b/app/(auth)/(tabs)/(libraries)/music/[libraryId]/_layout.tsx @@ -0,0 +1,87 @@ +import { Stack, useLocalSearchParams, withLayoutContext } from "expo-router"; +import { + createMaterialTopTabNavigator, + MaterialTopTabNavigationEventMap, + MaterialTopTabNavigationOptions, +} from "expo-router/js-top-tabs"; +import type { + ParamListBase, + TabNavigationState, +} from "expo-router/react-navigation"; +import { useTranslation } from "react-i18next"; + +const { Navigator } = createMaterialTopTabNavigator(); + +const TAB_LABEL_FONT_SIZE = 13; +const TAB_ITEM_HORIZONTAL_PADDING = 12; + +export const Tab = withLayoutContext< + MaterialTopTabNavigationOptions, + typeof Navigator, + TabNavigationState, + MaterialTopTabNavigationEventMap +>(Navigator); + +const Layout = () => { + const { libraryId } = useLocalSearchParams<{ libraryId: string }>(); + const { t } = useTranslation(); + + return ( + <> + + + + + + + + + ); +}; + +export default Layout; diff --git a/app/(auth)/(tabs)/(libraries)/music/[libraryId]/albums.tsx b/app/(auth)/(tabs)/(libraries)/music/[libraryId]/albums.tsx new file mode 100644 index 000000000..10fffbe71 --- /dev/null +++ b/app/(auth)/(tabs)/(libraries)/music/[libraryId]/albums.tsx @@ -0,0 +1,120 @@ +import { getItemsApi } from "@jellyfin/sdk/lib/utils/api"; +import { FlashList } from "@shopify/flash-list"; +import { useInfiniteQuery } from "@tanstack/react-query"; +import { useLocalSearchParams } from "expo-router"; +import { useRoute } from "expo-router/react-navigation"; +import { useAtom } from "jotai"; +import { useCallback, useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { RefreshControl, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Text } from "@/components/common/Text"; +import { Loader } from "@/components/Loader"; +import { MusicAlbumRowCard } from "@/components/music/MusicAlbumRowCard"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; + +const ITEMS_PER_PAGE = 40; + +export default function AlbumsScreen() { + const localParams = useLocalSearchParams<{ libraryId?: string | string[] }>(); + const route = useRoute(); + const libraryId = + (Array.isArray(localParams.libraryId) + ? localParams.libraryId[0] + : localParams.libraryId) ?? route?.params?.libraryId; + const [api] = useAtom(apiAtom); + const [user] = useAtom(userAtom); + const insets = useSafeAreaInsets(); + const { t } = useTranslation(); + + const { + data, + isLoading, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + refetch, + } = useInfiniteQuery({ + queryKey: ["music-albums", libraryId, user?.Id], + queryFn: async ({ pageParam = 0 }) => { + const response = await getItemsApi(api!).getItems({ + userId: user?.Id, + parentId: libraryId, + includeItemTypes: ["MusicAlbum"], + sortBy: ["SortName"], + sortOrder: ["Ascending"], + limit: ITEMS_PER_PAGE, + startIndex: pageParam, + recursive: true, + }); + return { + items: response.data.Items || [], + totalCount: response.data.TotalRecordCount || 0, + startIndex: pageParam, + }; + }, + getNextPageParam: (lastPage) => { + const nextStart = lastPage.startIndex + ITEMS_PER_PAGE; + return nextStart < lastPage.totalCount ? nextStart : undefined; + }, + initialPageParam: 0, + enabled: !!api && !!user?.Id && !!libraryId, + }); + + const albums = useMemo(() => { + return data?.pages.flatMap((page) => page.items) || []; + }, [data]); + + const handleEndReached = useCallback(() => { + if (hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, [hasNextPage, isFetchingNextPage, fetchNextPage]); + + if (isLoading) { + return ( + + + + ); + } + + if (albums.length === 0) { + return ( + + {t("music.no_albums")} + + ); + } + + return ( + + + } + onEndReached={handleEndReached} + onEndReachedThreshold={0.5} + renderItem={({ item }) => } + keyExtractor={(item) => item.Id!} + ListFooterComponent={ + isFetchingNextPage ? ( + + + + ) : null + } + /> + + ); +} diff --git a/app/(auth)/(tabs)/(libraries)/music/[libraryId]/artists.tsx b/app/(auth)/(tabs)/(libraries)/music/[libraryId]/artists.tsx new file mode 100644 index 000000000..f268e0b24 --- /dev/null +++ b/app/(auth)/(tabs)/(libraries)/music/[libraryId]/artists.tsx @@ -0,0 +1,157 @@ +import { getArtistsApi } from "@jellyfin/sdk/lib/utils/api"; +import { FlashList } from "@shopify/flash-list"; +import { useInfiniteQuery } from "@tanstack/react-query"; +import { useLocalSearchParams } from "expo-router"; +import { useRoute } from "expo-router/react-navigation"; +import { useAtom } from "jotai"; +import { useCallback, useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { RefreshControl, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Text } from "@/components/common/Text"; +import { Loader } from "@/components/Loader"; +import { MusicArtistCard } from "@/components/music/MusicArtistCard"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; + +// Web uses Limit=100 +const ITEMS_PER_PAGE = 100; + +export default function ArtistsScreen() { + const localParams = useLocalSearchParams<{ libraryId?: string | string[] }>(); + const route = useRoute(); + const libraryId = + (Array.isArray(localParams.libraryId) + ? localParams.libraryId[0] + : localParams.libraryId) ?? route?.params?.libraryId; + const [api] = useAtom(apiAtom); + const [user] = useAtom(userAtom); + const insets = useSafeAreaInsets(); + const { t } = useTranslation(); + + const isReady = Boolean(api && user?.Id && libraryId); + + const { + data, + isLoading, + isError, + error, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + refetch, + } = useInfiniteQuery({ + queryKey: ["music-artists", libraryId, user?.Id], + queryFn: async ({ pageParam = 0 }) => { + const response = await getArtistsApi(api!).getArtists({ + userId: user?.Id, + parentId: libraryId, + sortBy: ["SortName"], + sortOrder: ["Ascending"], + fields: ["PrimaryImageAspectRatio", "SortName"], + imageTypeLimit: 1, + enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"], + limit: ITEMS_PER_PAGE, + startIndex: pageParam, + }); + return { + items: response.data.Items || [], + totalCount: response.data.TotalRecordCount || 0, + startIndex: pageParam, + }; + }, + getNextPageParam: (lastPage) => { + const nextStart = lastPage.startIndex + ITEMS_PER_PAGE; + return nextStart < lastPage.totalCount ? nextStart : undefined; + }, + initialPageParam: 0, + enabled: isReady, + }); + + const artists = useMemo(() => { + return data?.pages.flatMap((page) => page.items) || []; + }, [data]); + + const handleEndReached = useCallback(() => { + if (hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, [hasNextPage, isFetchingNextPage, fetchNextPage]); + + if (!api || !user?.Id) { + return ( + + + + ); + } + + if (!libraryId) { + return ( + + + Missing music library id. + + + ); + } + + // Only show loading if we have no cached data to display + if (isLoading && artists.length === 0) { + return ( + + + + ); + } + + // Only show error if we have no cached data to display + // This allows offline access to previously cached artists + if (isError && artists.length === 0) { + return ( + + + Failed to load artists: {(error as Error)?.message || "Unknown error"} + + + ); + } + + if (artists.length === 0) { + return ( + + {t("music.no_artists")} + + ); + } + + return ( + + + } + onEndReached={handleEndReached} + onEndReachedThreshold={0.5} + renderItem={({ item }) => } + keyExtractor={(item) => item.Id!} + ListFooterComponent={ + isFetchingNextPage ? ( + + + + ) : null + } + /> + + ); +} diff --git a/app/(auth)/(tabs)/(libraries)/music/[libraryId]/playlists.tsx b/app/(auth)/(tabs)/(libraries)/music/[libraryId]/playlists.tsx new file mode 100644 index 000000000..85b4be5fd --- /dev/null +++ b/app/(auth)/(tabs)/(libraries)/music/[libraryId]/playlists.tsx @@ -0,0 +1,234 @@ +import { Ionicons } from "@expo/vector-icons"; +import { getItemsApi } from "@jellyfin/sdk/lib/utils/api"; +import { FlashList } from "@shopify/flash-list"; +import { useInfiniteQuery } from "@tanstack/react-query"; +import { useLocalSearchParams } from "expo-router"; +import { useNavigation, useRoute } from "expo-router/react-navigation"; +import { useAtom } from "jotai"; +import { useCallback, useLayoutEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { RefreshControl, TouchableOpacity, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Text } from "@/components/common/Text"; +import { Loader } from "@/components/Loader"; +import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal"; +import { MusicPlaylistCard } from "@/components/music/MusicPlaylistCard"; +import { + type PlaylistSortOption, + type PlaylistSortOrder, + PlaylistSortSheet, +} from "@/components/music/PlaylistSortSheet"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; + +const ITEMS_PER_PAGE = 40; + +export default function PlaylistsScreen() { + const localParams = useLocalSearchParams<{ libraryId?: string | string[] }>(); + const route = useRoute(); + const navigation = useNavigation(); + const libraryId = + (Array.isArray(localParams.libraryId) + ? localParams.libraryId[0] + : localParams.libraryId) ?? route?.params?.libraryId; + const [api] = useAtom(apiAtom); + const [user] = useAtom(userAtom); + const insets = useSafeAreaInsets(); + const { t } = useTranslation(); + + const [createModalOpen, setCreateModalOpen] = useState(false); + const [sortSheetOpen, setSortSheetOpen] = useState(false); + const [sortBy, setSortBy] = useState("SortName"); + const [sortOrder, setSortOrder] = useState("Ascending"); + + const isReady = Boolean(api && user?.Id && libraryId); + + const handleSortChange = useCallback( + (newSortBy: PlaylistSortOption, newSortOrder: PlaylistSortOrder) => { + setSortBy(newSortBy); + setSortOrder(newSortOrder); + }, + [], + ); + + useLayoutEffect(() => { + navigation.setOptions({ + headerRight: () => ( + setCreateModalOpen(true)} + className='mr-4' + hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} + > + + + ), + }); + }, [navigation]); + + const { + data, + isLoading, + isError, + error, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + refetch, + } = useInfiniteQuery({ + queryKey: ["music-playlists", libraryId, user?.Id, sortBy, sortOrder], + queryFn: async ({ pageParam = 0 }) => { + const response = await getItemsApi(api!).getItems({ + userId: user?.Id, + includeItemTypes: ["Playlist"], + sortBy: [sortBy], + sortOrder: [sortOrder], + limit: ITEMS_PER_PAGE, + startIndex: pageParam, + recursive: true, + mediaTypes: ["Audio"], + }); + return { + items: response.data.Items || [], + totalCount: response.data.TotalRecordCount || 0, + startIndex: pageParam, + }; + }, + getNextPageParam: (lastPage) => { + const nextStart = lastPage.startIndex + ITEMS_PER_PAGE; + return nextStart < lastPage.totalCount ? nextStart : undefined; + }, + initialPageParam: 0, + enabled: isReady, + }); + + const playlists = useMemo(() => { + return data?.pages.flatMap((page) => page.items) || []; + }, [data]); + + const handleEndReached = useCallback(() => { + if (hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, [hasNextPage, isFetchingNextPage, fetchNextPage]); + + if (!api || !user?.Id) { + return ( + + + + ); + } + + if (!libraryId) { + return ( + + + Missing music library id. + + + ); + } + + // Only show loading if we have no cached data to display + if (isLoading && playlists.length === 0) { + return ( + + + + ); + } + + // Only show error if we have no cached data to display + // This allows offline access to previously cached playlists + if (isError && playlists.length === 0) { + return ( + + + Failed to load playlists:{" "} + {(error as Error)?.message || "Unknown error"} + + + ); + } + + if (playlists.length === 0) { + return ( + + {t("music.no_playlists")} + setCreateModalOpen(true)} + className='flex-row items-center bg-purple-600 px-6 py-3 rounded-full' + > + + + {t("music.playlists.create_playlist")} + + + + + ); + } + + return ( + + + } + onEndReached={handleEndReached} + onEndReachedThreshold={0.5} + ListHeaderComponent={ + setSortSheetOpen(true)} + className='flex-row items-center mb-2 py-1' + > + + + {t( + `music.sort.${sortBy === "SortName" ? "alphabetical" : "date_created"}`, + )} + + + + } + renderItem={({ item }) => } + keyExtractor={(item) => item.Id!} + ListFooterComponent={ + isFetchingNextPage ? ( + + + + ) : null + } + /> + + + + ); +} diff --git a/app/(auth)/(tabs)/(libraries)/music/[libraryId]/suggestions.tsx b/app/(auth)/(tabs)/(libraries)/music/[libraryId]/suggestions.tsx new file mode 100644 index 000000000..81c2272f8 --- /dev/null +++ b/app/(auth)/(tabs)/(libraries)/music/[libraryId]/suggestions.tsx @@ -0,0 +1,333 @@ +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import { getItemsApi } from "@jellyfin/sdk/lib/utils/api"; +import { FlashList } from "@shopify/flash-list"; +import { useQuery } from "@tanstack/react-query"; +import { useLocalSearchParams } from "expo-router"; +import { useRoute } from "expo-router/react-navigation"; +import { useAtom } from "jotai"; +import { useCallback, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { RefreshControl, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { HorizontalScroll } from "@/components/common/HorizontalScroll"; +import { Text } from "@/components/common/Text"; +import { Loader } from "@/components/Loader"; +import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal"; +import { MusicAlbumCard } from "@/components/music/MusicAlbumCard"; +import { MusicTrackItem } from "@/components/music/MusicTrackItem"; +import { PlaylistPickerSheet } from "@/components/music/PlaylistPickerSheet"; +import { TrackOptionsSheet } from "@/components/music/TrackOptionsSheet"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { writeDebugLog } from "@/utils/log"; + +export default function SuggestionsScreen() { + const localParams = useLocalSearchParams<{ libraryId?: string | string[] }>(); + const route = useRoute(); + const libraryId = + (Array.isArray(localParams.libraryId) + ? localParams.libraryId[0] + : localParams.libraryId) ?? route?.params?.libraryId; + const [api] = useAtom(apiAtom); + const [user] = useAtom(userAtom); + const insets = useSafeAreaInsets(); + const { t } = useTranslation(); + + const [selectedTrack, setSelectedTrack] = useState(null); + const [trackOptionsOpen, setTrackOptionsOpen] = useState(false); + const [playlistPickerOpen, setPlaylistPickerOpen] = useState(false); + const [createPlaylistOpen, setCreatePlaylistOpen] = useState(false); + + const handleTrackOptionsPress = useCallback((track: BaseItemDto) => { + setSelectedTrack(track); + setTrackOptionsOpen(true); + }, []); + + const handleAddToPlaylist = useCallback(() => { + setPlaylistPickerOpen(true); + }, []); + + const handleCreateNewPlaylist = useCallback(() => { + setCreatePlaylistOpen(true); + }, []); + + const isReady = Boolean(api && user?.Id && libraryId); + + writeDebugLog("Music suggestions params", { + libraryId, + localParams, + routeParams: route?.params, + isReady, + }); + + // Latest audio - uses the same endpoint as web: /Users/{userId}/Items/Latest + // This returns the most recently added albums + const { + data: latestAlbums, + isLoading: loadingLatest, + isError: isLatestError, + error: latestError, + refetch: refetchLatest, + } = useQuery({ + queryKey: ["music-latest", libraryId, user?.Id], + queryFn: async () => { + // Prefer the exact endpoint the Web client calls (HAR): + // /Users/{userId}/Items/Latest?IncludeItemTypes=Audio&ParentId=... + // IMPORTANT: must use api.get(...) (not axiosInstance.get(fullUrl)) so the auth header is attached. + const res = await api!.get( + `/Users/${user!.Id}/Items/Latest`, + { + params: { + IncludeItemTypes: "Audio", + Limit: 20, + Fields: "PrimaryImageAspectRatio", + ParentId: libraryId, + ImageTypeLimit: 1, + EnableImageTypes: "Primary,Backdrop,Banner,Thumb", + EnableTotalRecordCount: false, + }, + }, + ); + + if (Array.isArray(res.data) && res.data.length > 0) { + return res.data; + } + + // Fallback: ask for albums directly via /Items (more reliable across server variants) + const fallback = await getItemsApi(api!).getItems({ + userId: user!.Id, + parentId: libraryId, + includeItemTypes: ["MusicAlbum"], + sortBy: ["DateCreated"], + sortOrder: ["Descending"], + limit: 20, + recursive: true, + fields: ["PrimaryImageAspectRatio", "SortName"], + imageTypeLimit: 1, + enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"], + enableTotalRecordCount: false, + }); + return fallback.data.Items || []; + }, + enabled: isReady, + }); + + // Recently played - matches web: SortBy=DatePlayed, Filters=IsPlayed + const { + data: recentlyPlayed, + isLoading: loadingRecentlyPlayed, + isError: isRecentlyPlayedError, + error: recentlyPlayedError, + refetch: refetchRecentlyPlayed, + } = useQuery({ + queryKey: ["music-recently-played", libraryId, user?.Id], + queryFn: async () => { + const response = await getItemsApi(api!).getItems({ + userId: user?.Id, + parentId: libraryId, + includeItemTypes: ["Audio"], + sortBy: ["DatePlayed"], + sortOrder: ["Descending"], + limit: 10, + recursive: true, + fields: ["PrimaryImageAspectRatio", "SortName"], + filters: ["IsPlayed"], + imageTypeLimit: 1, + enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"], + enableTotalRecordCount: false, + }); + return response.data.Items || []; + }, + enabled: isReady, + }); + + // Frequently played - matches web: SortBy=PlayCount, Filters=IsPlayed + const { + data: frequentlyPlayed, + isLoading: loadingFrequent, + isError: isFrequentError, + error: frequentError, + refetch: refetchFrequent, + } = useQuery({ + queryKey: ["music-frequently-played", libraryId, user?.Id], + queryFn: async () => { + const response = await getItemsApi(api!).getItems({ + userId: user?.Id, + parentId: libraryId, + includeItemTypes: ["Audio"], + sortBy: ["PlayCount"], + sortOrder: ["Descending"], + limit: 10, + recursive: true, + fields: ["PrimaryImageAspectRatio", "SortName"], + filters: ["IsPlayed"], + imageTypeLimit: 1, + enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"], + enableTotalRecordCount: false, + }); + return response.data.Items || []; + }, + enabled: isReady, + }); + + const isLoading = loadingLatest || loadingRecentlyPlayed || loadingFrequent; + + const handleRefresh = useCallback(() => { + refetchLatest(); + refetchRecentlyPlayed(); + refetchFrequent(); + }, [refetchLatest, refetchRecentlyPlayed, refetchFrequent]); + + const sections = useMemo(() => { + const result: { + title: string; + data: BaseItemDto[]; + type: "albums" | "tracks"; + }[] = []; + + // Latest albums section + if (latestAlbums && latestAlbums.length > 0) { + result.push({ + title: t("music.recently_added"), + data: latestAlbums, + type: "albums", + }); + } + + // Recently played tracks + if (recentlyPlayed && recentlyPlayed.length > 0) { + result.push({ + title: t("music.recently_played"), + data: recentlyPlayed, + type: "tracks", + }); + } + + // Frequently played tracks + if (frequentlyPlayed && frequentlyPlayed.length > 0) { + result.push({ + title: t("music.frequently_played"), + data: frequentlyPlayed, + type: "tracks", + }); + } + + return result; + }, [latestAlbums, frequentlyPlayed, recentlyPlayed, t]); + + if (!api || !user?.Id) { + return ( + + + + ); + } + + if (!libraryId) { + return ( + + + Missing music library id. + + + ); + } + + // Only show loading if we have no cached data to display + if (isLoading && sections.length === 0) { + return ( + + + + ); + } + + // Only show error if we have no cached data to display + // This allows offline access to previously cached suggestions + if ( + (isLatestError || isRecentlyPlayedError || isFrequentError) && + sections.length === 0 + ) { + const msg = + (latestError as Error | undefined)?.message || + (recentlyPlayedError as Error | undefined)?.message || + (frequentError as Error | undefined)?.message || + "Unknown error"; + return ( + + + Failed to load music: {msg} + + + ); + } + + if (sections.length === 0) { + return ( + + {t("music.no_suggestions")} + + ); + } + + return ( + + + } + renderItem={({ item: section }) => ( + + {section.title} + {section.type === "albums" ? ( + item.Id!} + renderItem={(item) => } + /> + ) : ( + section.data + .slice(0, 5) + .map((track, index, _tracks) => ( + + )) + )} + + )} + keyExtractor={(item) => item.title} + /> + + + + + ); +} diff --git a/app/(auth)/(tabs)/(search)/index.tsx b/app/(auth)/(tabs)/(search)/index.tsx index bedf5ffa0..759dae858 100644 --- a/app/(auth)/(tabs)/(search)/index.tsx +++ b/app/(auth)/(tabs)/(search)/index.tsx @@ -3,10 +3,13 @@ import type { BaseItemKind, } from "@jellyfin/sdk/lib/generated-client/models"; import { getItemsApi } from "@jellyfin/sdk/lib/utils/api"; +import { useAsyncDebouncer } from "@tanstack/react-pacer"; import { useQuery } from "@tanstack/react-query"; import axios from "axios"; -import { router, useLocalSearchParams, useNavigation } from "expo-router"; +import { Image } from "expo-image"; +import { useLocalSearchParams, useNavigation, useSegments } from "expo-router"; import { useAtom } from "jotai"; +import { orderBy, uniqBy } from "lodash"; import { useCallback, useEffect, @@ -19,11 +22,12 @@ import { import { useTranslation } from "react-i18next"; import { Platform, ScrollView, TouchableOpacity, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { useDebounce } from "use-debounce"; import ContinueWatchingPoster from "@/components/ContinueWatchingPoster"; -import { Input } from "@/components/common/Input"; import { Text } from "@/components/common/Text"; -import { TouchableItemRouter } from "@/components/common/TouchableItemRouter"; +import { + getItemNavigation, + TouchableItemRouter, +} from "@/components/common/TouchableItemRouter"; import { ItemCardText } from "@/components/ItemCardText"; import { JellyseerrSearchSort, @@ -35,10 +39,21 @@ import { DiscoverFilters } from "@/components/search/DiscoverFilters"; import { LoadingSkeleton } from "@/components/search/LoadingSkeleton"; import { SearchItemWrapper } from "@/components/search/SearchItemWrapper"; import { SearchTabButtons } from "@/components/search/SearchTabButtons"; +import { TVSearchPage } from "@/components/search/TVSearchPage"; +import useRouter from "@/hooks/useAppRouter"; import { useJellyseerr } from "@/hooks/useJellyseerr"; +import { useTVItemActionModal } from "@/hooks/useTVItemActionModal"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { useSettings } from "@/utils/atoms/settings"; import { eventBus } from "@/utils/eventBus"; +import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; +import { MediaType } from "@/utils/jellyseerr/server/constants/media"; +import type { + MovieResult, + PersonResult, + TvResult, +} from "@/utils/jellyseerr/server/models/Search"; +import { createStreamystatsApi } from "@/utils/streamystats"; type SearchType = "Library" | "Discover"; @@ -51,9 +66,13 @@ const exampleSearches = [ "The Mandalorian", ]; -export default function search() { +export default function SearchPage() { const params = useLocalSearchParams(); const insets = useSafeAreaInsets(); + const router = useRouter(); + const { showItemActions } = useTVItemActionModal(); + const segments = useSegments(); + const from = (segments as string[])[2] || "(search)"; const [user] = useAtom(userAtom); @@ -67,7 +86,23 @@ export default function search() { const [searchType, setSearchType] = useState("Library"); const [search, setSearch] = useState(""); - const [debouncedSearch] = useDebounce(search, 500); + const [debouncedSearch, setDebouncedSearch] = useState(""); + const abortControllerRef = useRef(null); + + const searchDebouncer = useAsyncDebouncer( + async (query: string) => { + // Cancel previous in-flight requests + abortControllerRef.current?.abort(); + abortControllerRef.current = new AbortController(); + setDebouncedSearch(query); + return query; + }, + { wait: 200 }, + ); + + useEffect(() => { + searchDebouncer.maybeExecute(search); + }, [search]); const [api] = useAtom(apiAtom); @@ -97,9 +132,11 @@ export default function search() { async ({ types, query, + signal, }: { types: BaseItemKind[]; query: string; + signal?: AbortSignal; }): Promise => { if (!api || !query) { return []; @@ -107,46 +144,144 @@ export default function search() { try { if (searchEngine === "Jellyfin") { - const searchApi = await getItemsApi(api).getItems({ + const searchApi = await getItemsApi(api).getItems( + { + searchTerm: query, + limit: 10, + includeItemTypes: types, + recursive: true, + userId: user?.Id, + }, + { signal }, + ); + + return (searchApi.data.Items as BaseItemDto[]) || []; + } + + if (searchEngine === "Streamystats") { + if (!settings?.streamyStatsServerUrl || !api.accessToken) { + return []; + } + + const streamyStatsApi = createStreamystatsApi({ + serverUrl: settings.streamyStatsServerUrl, + jellyfinToken: api.accessToken, + }); + + const typeMap: Record = { + Movie: "movies", + Series: "series", + Episode: "episodes", + Person: "actors", + BoxSet: "movies", + Audio: "audio", + } as Record; + + const searchType = types.length === 1 ? typeMap[types[0]] : "media"; + const response = await streamyStatsApi.searchIds( + query, + searchType as "movies" | "series" | "episodes" | "actors" | "media", + 10, + signal, + ); + + const allIds: string[] = [ + ...(response.data.movies || []), + ...(response.data.series || []), + ...(response.data.episodes || []), + ...(response.data.actors || []), + ...(response.data.audio || []), + ]; + + if (!allIds.length) { + return []; + } + + const itemsResponse = await getItemsApi(api).getItems( + { + ids: allIds, + enableImageTypes: ["Primary", "Backdrop", "Thumb"], + }, + { signal }, + ); + + return (itemsResponse.data.Items as BaseItemDto[]) || []; + } + + // Marlin search + if (!settings?.marlinServerUrl) { + return []; + } + + const url = `${settings.marlinServerUrl}/search?q=${encodeURIComponent(query)}&includeItemTypes=${types + .map((type) => encodeURIComponent(type)) + .join("&includeItemTypes=")}`; + + const response1 = await axios.get(url, { signal }); + + const ids = response1.data.ids; + + if (!ids?.length) { + return []; + } + + const response2 = await getItemsApi(api).getItems( + { + ids, + enableImageTypes: ["Primary", "Backdrop", "Thumb"], + }, + { signal }, + ); + + return (response2.data.Items as BaseItemDto[]) || []; + } catch (error) { + // Silently handle aborted requests + if (error instanceof Error && error.name === "AbortError") { + return []; + } + return []; + } + }, + [api, searchEngine, settings, user?.Id], + ); + + // Separate search function for music types - always uses Jellyfin since Streamystats doesn't support music + const jellyfinSearchFn = useCallback( + async ({ + types, + query, + signal, + }: { + types: BaseItemKind[]; + query: string; + signal?: AbortSignal; + }): Promise => { + if (!api || !query) { + return []; + } + + try { + const searchApi = await getItemsApi(api).getItems( + { searchTerm: query, limit: 10, includeItemTypes: types, recursive: true, userId: user?.Id, - }); + }, + { signal }, + ); - return (searchApi.data.Items as BaseItemDto[]) || []; - } - if (!settings?.marlinServerUrl) { - return []; - } - - const url = `${ - settings.marlinServerUrl - }/search?q=${encodeURIComponent(query)}&includeItemTypes=${types - .map((type) => encodeURIComponent(type)) - .join("&includeItemTypes=")}`; - - const response1 = await axios.get(url); - - const ids = response1.data.ids; - - if (!ids || !ids.length) { - return []; - } - - const response2 = await getItemsApi(api).getItems({ - ids, - enableImageTypes: ["Primary", "Backdrop", "Thumb"], - }); - - return (response2.data.Items as BaseItemDto[]) || []; + return (searchApi.data.Items as BaseItemDto[]) || []; } catch (error) { - console.error("Error during search:", error); - return []; // Ensure an empty array is returned in case of an error + // Silently handle aborted requests + if (error instanceof Error && error.name === "AbortError") { + return []; + } + return []; } }, - [api, searchEngine, settings], + [api, user?.Id], ); type HeaderSearchBarRef = { @@ -170,6 +305,11 @@ export default function search() { }, hideWhenScrolling: false, autoFocus: false, + // Android: color of the user-typed text (was dark and unreadable on the dark header) + textColor: "#fff", + // Android: placeholder and icon color + hintTextColor: "#fff", + headerIconColor: "#fff", }, }); }, [navigation]); @@ -195,6 +335,7 @@ export default function search() { searchFn({ query: debouncedSearch, types: ["Movie"], + signal: abortControllerRef.current?.signal, }), enabled: searchType === "Library" && debouncedSearch.length > 0, }); @@ -205,6 +346,7 @@ export default function search() { searchFn({ query: debouncedSearch, types: ["Series"], + signal: abortControllerRef.current?.signal, }), enabled: searchType === "Library" && debouncedSearch.length > 0, }); @@ -215,6 +357,7 @@ export default function search() { searchFn({ query: debouncedSearch, types: ["Episode"], + signal: abortControllerRef.current?.signal, }), enabled: searchType === "Library" && debouncedSearch.length > 0, }); @@ -225,6 +368,7 @@ export default function search() { searchFn({ query: debouncedSearch, types: ["BoxSet"], + signal: abortControllerRef.current?.signal, }), enabled: searchType === "Library" && debouncedSearch.length > 0, }); @@ -235,6 +379,52 @@ export default function search() { searchFn({ query: debouncedSearch, types: ["Person"], + signal: abortControllerRef.current?.signal, + }), + enabled: searchType === "Library" && debouncedSearch.length > 0, + }); + + // Music search queries - always use Jellyfin since Streamystats doesn't support music + const { data: artists, isFetching: l9 } = useQuery({ + queryKey: ["search", "artists", debouncedSearch], + queryFn: () => + jellyfinSearchFn({ + query: debouncedSearch, + types: ["MusicArtist"], + signal: abortControllerRef.current?.signal, + }), + enabled: searchType === "Library" && debouncedSearch.length > 0, + }); + + const { data: albums, isFetching: l10 } = useQuery({ + queryKey: ["search", "albums", debouncedSearch], + queryFn: () => + jellyfinSearchFn({ + query: debouncedSearch, + types: ["MusicAlbum"], + signal: abortControllerRef.current?.signal, + }), + enabled: searchType === "Library" && debouncedSearch.length > 0, + }); + + const { data: songs, isFetching: l11 } = useQuery({ + queryKey: ["search", "songs", debouncedSearch], + queryFn: () => + jellyfinSearchFn({ + query: debouncedSearch, + types: ["Audio"], + signal: abortControllerRef.current?.signal, + }), + enabled: searchType === "Library" && debouncedSearch.length > 0, + }); + + const { data: playlists, isFetching: l12 } = useQuery({ + queryKey: ["search", "playlists", debouncedSearch], + queryFn: () => + jellyfinSearchFn({ + query: debouncedSearch, + types: ["Playlist"], + signal: abortControllerRef.current?.signal, }), enabled: searchType === "Library" && debouncedSearch.length > 0, }); @@ -245,13 +435,201 @@ export default function search() { episodes?.length || series?.length || collections?.length || - actors?.length + actors?.length || + artists?.length || + albums?.length || + songs?.length || + playlists?.length ); - }, [episodes, movies, series, collections, actors]); + }, [ + episodes, + movies, + series, + collections, + actors, + artists, + albums, + songs, + playlists, + ]); const loading = useMemo(() => { - return l1 || l2 || l3 || l7 || l8; - }, [l1, l2, l3, l7, l8]); + return l1 || l2 || l3 || l7 || l8 || l9 || l10 || l11 || l12; + }, [l1, l2, l3, l7, l8, l9, l10, l11, l12]); + + // TV item press handler + const handleItemPress = useCallback( + (item: BaseItemDto) => { + const navigation = getItemNavigation(item, from); + router.push(navigation as any); + }, + [from, router], + ); + + // Jellyseerr search for TV + const { data: jellyseerrTVResults, isFetching: jellyseerrTVLoading } = + useQuery({ + queryKey: ["search", "jellyseerr", "tv", debouncedSearch], + queryFn: async () => { + const params = { + query: new URLSearchParams(debouncedSearch || "").toString(), + }; + return await Promise.all([ + jellyseerrApi?.search({ ...params, page: 1 }), + jellyseerrApi?.search({ ...params, page: 2 }), + jellyseerrApi?.search({ ...params, page: 3 }), + jellyseerrApi?.search({ ...params, page: 4 }), + ]).then((all) => + uniqBy( + all.flatMap((v) => v?.results || []), + "id", + ), + ); + }, + enabled: + Platform.isTV && + !!jellyseerrApi && + searchType === "Discover" && + debouncedSearch.length > 0, + }); + + // Process Jellyseerr results for TV + const jellyseerrMovieResults = useMemo( + () => + orderBy( + jellyseerrTVResults?.filter( + (r) => r.mediaType === MediaType.MOVIE, + ) as MovieResult[], + [(m) => m?.title?.toLowerCase() === debouncedSearch.toLowerCase()], + "desc", + ), + [jellyseerrTVResults, debouncedSearch], + ); + + const jellyseerrTvResults = useMemo( + () => + orderBy( + jellyseerrTVResults?.filter( + (r) => r.mediaType === MediaType.TV, + ) as TvResult[], + [(t) => t?.name?.toLowerCase() === debouncedSearch.toLowerCase()], + "desc", + ), + [jellyseerrTVResults, debouncedSearch], + ); + + const jellyseerrPersonResults = useMemo( + () => + orderBy( + jellyseerrTVResults?.filter( + (r) => r.mediaType === "person", + ) as PersonResult[], + [(p) => p?.name?.toLowerCase() === debouncedSearch.toLowerCase()], + "desc", + ), + [jellyseerrTVResults, debouncedSearch], + ); + + const jellyseerrTVNoResults = useMemo(() => { + return ( + !jellyseerrMovieResults?.length && + !jellyseerrTvResults?.length && + !jellyseerrPersonResults?.length + ); + }, [jellyseerrMovieResults, jellyseerrTvResults, jellyseerrPersonResults]); + + // Fetch discover settings for TV (when no search query in Discover mode) + const { data: discoverSliders } = useQuery({ + queryKey: ["search", "jellyseerr", "discoverSettings", "tv"], + queryFn: async () => jellyseerrApi?.discoverSettings(), + enabled: + Platform.isTV && + !!jellyseerrApi && + searchType === "Discover" && + debouncedSearch.length === 0, + }); + + // TV Jellyseerr press handlers + const handleJellyseerrMoviePress = useCallback( + (item: MovieResult) => { + router.push({ + pathname: "/(auth)/(tabs)/(search)/jellyseerr/page", + params: { + mediaTitle: item.title, + releaseYear: String(new Date(item.releaseDate || "").getFullYear()), + canRequest: "true", + posterSrc: jellyseerrApi?.imageProxy(item.posterPath) || "", + mediaType: MediaType.MOVIE, + id: String(item.id), + backdropPath: item.backdropPath || "", + overview: item.overview || "", + }, + }); + }, + [router, jellyseerrApi], + ); + + const handleJellyseerrTvPress = useCallback( + (item: TvResult) => { + router.push({ + pathname: "/(auth)/(tabs)/(search)/jellyseerr/page", + params: { + mediaTitle: item.name, + releaseYear: String(new Date(item.firstAirDate || "").getFullYear()), + canRequest: "true", + posterSrc: jellyseerrApi?.imageProxy(item.posterPath) || "", + mediaType: MediaType.TV, + id: String(item.id), + backdropPath: item.backdropPath || "", + overview: item.overview || "", + }, + }); + }, + [router, jellyseerrApi], + ); + + const handleJellyseerrPersonPress = useCallback( + (item: PersonResult) => { + router.push(`/(auth)/jellyseerr/person/${item.id}` as any); + }, + [router], + ); + + // Render TV search page + if (Platform.isTV) { + return ( + + ); + } return ( - {/* */} - {Platform.isTV && ( - { - router.setParams({ q: "" }); - setSearch(text); - }} - keyboardType='default' - returnKeyType='done' - autoCapitalize='none' - clearButtonMode='while-editing' - maxLength={500} - /> - )} )} /> + {/* Music search results */} + { + const imageUrl = getPrimaryImageUrl({ api, item }); + return ( + + + {imageUrl ? ( + + ) : ( + + 👤 + + )} + + + {item.Name} + + + ); + }} + /> + { + const imageUrl = getPrimaryImageUrl({ api, item }); + return ( + + + {imageUrl ? ( + + ) : ( + + 🎵 + + )} + + + {item.Name} + + + {item.AlbumArtist || item.Artists?.join(", ")} + + + ); + }} + /> + { + const imageUrl = getPrimaryImageUrl({ api, item }); + return ( + + + {imageUrl ? ( + + ) : ( + + 🎵 + + )} + + + {item.Name} + + + {item.Artists?.join(", ") || item.AlbumArtist} + + + ); + }} + /> + { + const imageUrl = getPrimaryImageUrl({ api, item }); + return ( + + + {imageUrl ? ( + + ) : ( + + 🎶 + + )} + + + {item.Name} + + + {item.ChildCount} tracks + + + ); + }} + /> ) : ( ) : debouncedSearch.length === 0 ? ( - + {exampleSearches.map((e) => ( { diff --git a/app/(auth)/(tabs)/(settings)/_layout.tsx b/app/(auth)/(tabs)/(settings)/_layout.tsx new file mode 100644 index 000000000..4f1ce0354 --- /dev/null +++ b/app/(auth)/(tabs)/(settings)/_layout.tsx @@ -0,0 +1,21 @@ +import { Stack } from "expo-router"; +import { useTranslation } from "react-i18next"; +import { Platform } from "react-native"; + +export default function SettingsLayout() { + const { t } = useTranslation(); + return ( + + + + ); +} diff --git a/app/(auth)/(tabs)/(settings)/index.tsx b/app/(auth)/(tabs)/(settings)/index.tsx new file mode 100644 index 000000000..52b86fb47 --- /dev/null +++ b/app/(auth)/(tabs)/(settings)/index.tsx @@ -0,0 +1,5 @@ +import SettingsTV from "@/app/(auth)/(tabs)/(home)/settings.tv"; + +export default function SettingsTabScreen() { + return ; +} diff --git a/app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx b/app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx new file mode 100644 index 000000000..c649bdf62 --- /dev/null +++ b/app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx @@ -0,0 +1,451 @@ +import { Ionicons } from "@expo/vector-icons"; +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import { FlashList } from "@shopify/flash-list"; +import { useLocalSearchParams, useNavigation } from "expo-router"; +import { useAtomValue } from "jotai"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + ActivityIndicator, + Alert, + Platform, + RefreshControl, + ScrollView, + TouchableOpacity, + useWindowDimensions, + View, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { HeaderBackButton } from "@/components/common/HeaderBackButton"; +import { Text } from "@/components/common/Text"; +import { + getItemNavigation, + TouchableItemRouter, +} from "@/components/common/TouchableItemRouter"; +import { ItemCardText } from "@/components/ItemCardText"; +import { ItemPoster } from "@/components/posters/ItemPoster"; +import { TVPosterCard } from "@/components/tv/TVPosterCard"; +import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes"; +import { useScaledTVTypography } from "@/constants/TVTypography"; +import useRouter from "@/hooks/useAppRouter"; +import { useOrientation } from "@/hooks/useOrientation"; +import { useTVItemActionModal } from "@/hooks/useTVItemActionModal"; +import { + useDeleteWatchlist, + useRemoveFromWatchlist, +} from "@/hooks/useWatchlistMutations"; +import { + useWatchlistDetailQuery, + useWatchlistItemsQuery, +} from "@/hooks/useWatchlists"; +import * as ScreenOrientation from "@/packages/expo-screen-orientation"; +import { userAtom } from "@/providers/JellyfinProvider"; + +const TV_ITEM_GAP = 20; +const TV_HORIZONTAL_PADDING = 60; + +export default function WatchlistDetailScreen() { + const typography = useScaledTVTypography(); + const posterSizes = useScaledTVPosterSizes(); + const { t } = useTranslation(); + const router = useRouter(); + const { showItemActions } = useTVItemActionModal(); + const navigation = useNavigation(); + const insets = useSafeAreaInsets(); + const { watchlistId } = useLocalSearchParams<{ watchlistId: string }>(); + const user = useAtomValue(userAtom); + const { width: screenWidth } = useWindowDimensions(); + const { orientation } = useOrientation(); + + const watchlistIdNum = watchlistId + ? Number.parseInt(watchlistId, 10) + : undefined; + + const nrOfCols = useMemo(() => { + // TV uses flexWrap, so nrOfCols is just for mobile + if (Platform.isTV) return 1; + if (screenWidth < 300) return 2; + if (screenWidth < 500) return 3; + if (screenWidth < 800) return 5; + if (screenWidth < 1000) return 6; + if (screenWidth < 1500) return 7; + return 6; + }, [screenWidth]); + + const { + data: watchlist, + isLoading: watchlistLoading, + refetch: refetchWatchlist, + } = useWatchlistDetailQuery(watchlistIdNum); + + const { + data: items, + isLoading: itemsLoading, + refetch: refetchItems, + } = useWatchlistItemsQuery(watchlistIdNum); + + const deleteWatchlist = useDeleteWatchlist(); + const removeFromWatchlist = useRemoveFromWatchlist(); + const [refreshing, setRefreshing] = useState(false); + + const isOwner = useMemo( + () => watchlist?.userId === user?.Id, + [watchlist?.userId, user?.Id], + ); + + // Set up header + useEffect(() => { + navigation.setOptions({ + headerTitle: watchlist?.name || "", + headerLeft: () => , + headerRight: isOwner + ? () => ( + + + router.push(`/(auth)/(tabs)/(watchlists)/edit/${watchlistId}`) + } + className='p-2' + > + + + + + + + ) + : undefined, + }); + }, [navigation, watchlist?.name, isOwner, watchlistId]); + + const handleRefresh = useCallback(async () => { + setRefreshing(true); + await Promise.all([refetchWatchlist(), refetchItems()]); + setRefreshing(false); + }, [refetchWatchlist, refetchItems]); + + const handleDelete = useCallback(() => { + Alert.alert( + t("watchlists.delete_confirm_title"), + t("watchlists.delete_confirm_message", { name: watchlist?.name }), + [ + { text: t("watchlists.cancel_button"), style: "cancel" }, + { + text: t("watchlists.delete_button"), + style: "destructive", + onPress: async () => { + if (watchlistIdNum) { + await deleteWatchlist.mutateAsync(watchlistIdNum); + router.back(); + } + }, + }, + ], + ); + }, [deleteWatchlist, watchlistIdNum, watchlist?.name, router, t]); + + const handleRemoveItem = useCallback( + (item: BaseItemDto) => { + if (!watchlistIdNum || !item.Id) return; + + Alert.alert( + t("watchlists.remove_item_title"), + t("watchlists.remove_item_message", { name: item.Name }), + [ + { text: t("watchlists.cancel_button"), style: "cancel" }, + { + text: t("watchlists.remove_button"), + style: "destructive", + onPress: async () => { + await removeFromWatchlist.mutateAsync({ + watchlistId: watchlistIdNum, + itemId: item.Id!, + watchlistName: watchlist?.name, + }); + }, + }, + ], + ); + }, + [removeFromWatchlist, watchlistIdNum, watchlist?.name, t], + ); + + const renderTVItem = useCallback( + (item: BaseItemDto, index: number) => { + const handlePress = () => { + const navigation = getItemNavigation(item, "(watchlists)"); + router.push(navigation as any); + }; + + return ( + showItemActions(item)} + hasTVPreferredFocus={index === 0} + width={posterSizes.poster} + /> + ); + }, + [router, showItemActions, posterSizes.poster], + ); + + const renderItem = useCallback( + ({ item, index }: { item: BaseItemDto; index: number }) => ( + handleRemoveItem(item) : undefined} + > + + + + + + ), + [isOwner, handleRemoveItem, orientation, nrOfCols], + ); + + const ListHeader = useMemo( + () => + watchlist ? ( + + {watchlist.description && ( + + {watchlist.description} + + )} + + + + + {items?.length ?? 0}{" "} + {(items?.length ?? 0) === 1 + ? t("watchlists.item") + : t("watchlists.items")} + + + + + + {watchlist.isPublic + ? t("watchlists.public") + : t("watchlists.private")} + + + {!isOwner && ( + + {t("watchlists.by_owner")} + + )} + + + ) : null, + [watchlist, items?.length, isOwner, t], + ); + + const EmptyComponent = useMemo( + () => ( + + + + {t("watchlists.empty_watchlist")} + + {isOwner && ( + + {t("watchlists.empty_watchlist_hint")} + + )} + + ), + [isOwner, t], + ); + + const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []); + + if (watchlistLoading || itemsLoading) { + return ( + + + + ); + } + + if (!watchlist) { + return ( + + + {t("watchlists.not_found")} + + + ); + } + + // TV layout with ScrollView + flexWrap + if (Platform.isTV) { + return ( + + {/* Header */} + + {watchlist.description && ( + + {watchlist.description} + + )} + + + + + {items?.length ?? 0}{" "} + {(items?.length ?? 0) === 1 + ? t("watchlists.item") + : t("watchlists.items")} + + + + + + {watchlist.isPublic + ? t("watchlists.public") + : t("watchlists.private")} + + + {!isOwner && ( + + {t("watchlists.by_owner")} + + )} + + + + {/* Grid with flexWrap */} + {!items || items.length === 0 ? ( + + + + {t("watchlists.empty_watchlist")} + + + ) : ( + + {items.map((item, index) => renderTVItem(item, index))} + + )} + + ); + } + + // Mobile layout with FlashList + return ( + + } + renderItem={renderItem} + ItemSeparatorComponent={() => ( + + )} + /> + ); +} diff --git a/app/(auth)/(tabs)/(watchlists)/_layout.tsx b/app/(auth)/(tabs)/(watchlists)/_layout.tsx new file mode 100644 index 000000000..c1ad57882 --- /dev/null +++ b/app/(auth)/(tabs)/(watchlists)/_layout.tsx @@ -0,0 +1,76 @@ +import { Ionicons } from "@expo/vector-icons"; +import { Stack } from "expo-router"; +import { useTranslation } from "react-i18next"; +import { Platform } from "react-native"; +import { Pressable } from "react-native-gesture-handler"; +import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack"; +import useRouter from "@/hooks/useAppRouter"; +import { useStreamystatsEnabled } from "@/hooks/useWatchlists"; + +export default function WatchlistsLayout() { + const { t } = useTranslation(); + const router = useRouter(); + const streamystatsEnabled = useStreamystatsEnabled(); + + return ( + + ( + + router.push("/(auth)/(tabs)/(watchlists)/create") + } + className='p-1.5' + > + + + ) + : undefined, + }} + /> + + + + {Object.entries(nestedTabPageScreenOptions).map(([name, options]) => ( + + ))} + + ); +} diff --git a/app/(auth)/(tabs)/(watchlists)/create.tsx b/app/(auth)/(tabs)/(watchlists)/create.tsx new file mode 100644 index 000000000..af77d5dbc --- /dev/null +++ b/app/(auth)/(tabs)/(watchlists)/create.tsx @@ -0,0 +1,221 @@ +import { Ionicons } from "@expo/vector-icons"; +import { useCallback, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + ActivityIndicator, + KeyboardAvoidingView, + Platform, + ScrollView, + Switch, + TextInput, + TouchableOpacity, + View, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Button } from "@/components/Button"; +import { Text } from "@/components/common/Text"; +import useRouter from "@/hooks/useAppRouter"; +import { useCreateWatchlist } from "@/hooks/useWatchlistMutations"; +import type { + StreamystatsWatchlistAllowedItemType, + StreamystatsWatchlistSortOrder, +} from "@/utils/streamystats/types"; + +const ITEM_TYPES: Array<{ + value: StreamystatsWatchlistAllowedItemType; + label: string; +}> = [ + { value: null, label: "All Types" }, + { value: "Movie", label: "Movies Only" }, + { value: "Series", label: "Series Only" }, + { value: "Episode", label: "Episodes Only" }, +]; + +const SORT_OPTIONS: Array<{ + value: StreamystatsWatchlistSortOrder; + label: string; +}> = [ + { value: "custom", label: "Custom Order" }, + { value: "name", label: "Name" }, + { value: "dateAdded", label: "Date Added" }, + { value: "releaseDate", label: "Release Date" }, +]; + +export default function CreateWatchlistScreen() { + const { t } = useTranslation(); + const router = useRouter(); + const insets = useSafeAreaInsets(); + const createWatchlist = useCreateWatchlist(); + + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [isPublic, setIsPublic] = useState(false); + const [allowedItemType, setAllowedItemType] = + useState(null); + const [defaultSortOrder, setDefaultSortOrder] = + useState("custom"); + + const handleCreate = useCallback(async () => { + if (!name.trim()) return; + + try { + await createWatchlist.mutateAsync({ + name: name.trim(), + description: description.trim() || undefined, + isPublic, + allowedItemType, + defaultSortOrder, + }); + router.back(); + } catch { + // Error handled by mutation + } + }, [ + name, + description, + isPublic, + allowedItemType, + defaultSortOrder, + createWatchlist, + router, + ]); + + return ( + + + {/* Name */} + + + {t("watchlists.name_label")} * + + + + + {/* Description */} + + + {t("watchlists.description_label")} + + + + + {/* Public Toggle */} + + + + {t("watchlists.is_public_label")} + + + {t("watchlists.is_public_description")} + + + + + + {/* Content Type */} + + + {t("watchlists.allowed_type_label")} + + + {ITEM_TYPES.map((type) => ( + setAllowedItemType(type.value)} + className={`px-4 py-2 rounded-lg ${allowedItemType === type.value ? "bg-purple-600" : "bg-neutral-800"}`} + > + + {type.label} + + + ))} + + + + {/* Sort Order */} + + + {t("watchlists.sort_order_label")} + + + {SORT_OPTIONS.map((sort) => ( + setDefaultSortOrder(sort.value)} + className={`px-4 py-2 rounded-lg ${defaultSortOrder === sort.value ? "bg-purple-600" : "bg-neutral-800"}`} + > + + {sort.label} + + + ))} + + + + {/* Create Button */} + + + + + + ); +} diff --git a/app/(auth)/(tabs)/(watchlists)/edit/[watchlistId].tsx b/app/(auth)/(tabs)/(watchlists)/edit/[watchlistId].tsx new file mode 100644 index 000000000..5329d6a69 --- /dev/null +++ b/app/(auth)/(tabs)/(watchlists)/edit/[watchlistId].tsx @@ -0,0 +1,274 @@ +import { Ionicons } from "@expo/vector-icons"; +import { useLocalSearchParams } from "expo-router"; +import { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + ActivityIndicator, + KeyboardAvoidingView, + Platform, + ScrollView, + Switch, + TextInput, + TouchableOpacity, + View, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Button } from "@/components/Button"; +import { Text } from "@/components/common/Text"; +import useRouter from "@/hooks/useAppRouter"; +import { useUpdateWatchlist } from "@/hooks/useWatchlistMutations"; +import { useWatchlistDetailQuery } from "@/hooks/useWatchlists"; +import type { + StreamystatsWatchlistAllowedItemType, + StreamystatsWatchlistSortOrder, +} from "@/utils/streamystats/types"; + +const ITEM_TYPES: Array<{ + value: StreamystatsWatchlistAllowedItemType; + label: string; +}> = [ + { value: null, label: "All Types" }, + { value: "Movie", label: "Movies Only" }, + { value: "Series", label: "Series Only" }, + { value: "Episode", label: "Episodes Only" }, +]; + +const SORT_OPTIONS: Array<{ + value: StreamystatsWatchlistSortOrder; + label: string; +}> = [ + { value: "custom", label: "Custom Order" }, + { value: "name", label: "Name" }, + { value: "dateAdded", label: "Date Added" }, + { value: "releaseDate", label: "Release Date" }, +]; + +export default function EditWatchlistScreen() { + const { t } = useTranslation(); + const router = useRouter(); + const insets = useSafeAreaInsets(); + const { watchlistId } = useLocalSearchParams<{ watchlistId: string }>(); + const watchlistIdNum = watchlistId + ? Number.parseInt(watchlistId, 10) + : undefined; + + const { data: watchlist, isLoading } = + useWatchlistDetailQuery(watchlistIdNum); + const updateWatchlist = useUpdateWatchlist(); + + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [isPublic, setIsPublic] = useState(false); + const [allowedItemType, setAllowedItemType] = + useState(null); + const [defaultSortOrder, setDefaultSortOrder] = + useState("custom"); + + // Initialize form with watchlist data + useEffect(() => { + if (watchlist) { + setName(watchlist.name); + setDescription(watchlist.description ?? ""); + setIsPublic(watchlist.isPublic); + setAllowedItemType( + (watchlist.allowedItemType as StreamystatsWatchlistAllowedItemType) ?? + null, + ); + setDefaultSortOrder( + (watchlist.defaultSortOrder as StreamystatsWatchlistSortOrder) ?? + "custom", + ); + } + }, [watchlist]); + + const handleSave = useCallback(async () => { + if (!name.trim() || !watchlistIdNum) return; + + try { + await updateWatchlist.mutateAsync({ + watchlistId: watchlistIdNum, + data: { + name: name.trim(), + description: description.trim() || undefined, + isPublic, + allowedItemType, + defaultSortOrder, + }, + }); + router.back(); + } catch { + // Error handled by mutation + } + }, [ + name, + description, + isPublic, + allowedItemType, + defaultSortOrder, + watchlistIdNum, + updateWatchlist, + router, + ]); + + if (isLoading) { + return ( + + + + ); + } + + if (!watchlist) { + return ( + + + {t("watchlists.not_found")} + + + ); + } + + return ( + + + {/* Name */} + + + {t("watchlists.name_label")} * + + + + + {/* Description */} + + + {t("watchlists.description_label")} + + + + + {/* Public Toggle */} + + + + {t("watchlists.is_public_label")} + + + {t("watchlists.is_public_description")} + + + + + + {/* Content Type */} + + + {t("watchlists.allowed_type_label")} + + + {ITEM_TYPES.map((type) => ( + setAllowedItemType(type.value)} + className={`px-4 py-2 rounded-lg ${allowedItemType === type.value ? "bg-purple-600" : "bg-neutral-800"}`} + > + + {type.label} + + + ))} + + + + {/* Sort Order */} + + + {t("watchlists.sort_order_label")} + + + {SORT_OPTIONS.map((sort) => ( + setDefaultSortOrder(sort.value)} + className={`px-4 py-2 rounded-lg ${defaultSortOrder === sort.value ? "bg-purple-600" : "bg-neutral-800"}`} + > + + {sort.label} + + + ))} + + + + {/* Save Button */} + + + + + + ); +} diff --git a/app/(auth)/(tabs)/(watchlists)/index.tsx b/app/(auth)/(tabs)/(watchlists)/index.tsx new file mode 100644 index 000000000..76d446d49 --- /dev/null +++ b/app/(auth)/(tabs)/(watchlists)/index.tsx @@ -0,0 +1,239 @@ +import { Ionicons } from "@expo/vector-icons"; +import { FlashList } from "@shopify/flash-list"; +import { useAtomValue } from "jotai"; +import { useCallback, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Platform, RefreshControl, TouchableOpacity, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Button } from "@/components/Button"; +import { Text } from "@/components/common/Text"; +import useRouter from "@/hooks/useAppRouter"; +import { + useStreamystatsEnabled, + useWatchlistsQuery, +} from "@/hooks/useWatchlists"; +import { userAtom } from "@/providers/JellyfinProvider"; +import type { StreamystatsWatchlist } from "@/utils/streamystats/types"; + +interface WatchlistCardProps { + watchlist: StreamystatsWatchlist; + isOwner: boolean; + onPress: () => void; +} + +const WatchlistCard: React.FC = ({ + watchlist, + isOwner, + onPress, +}) => { + const { t } = useTranslation(); + + return ( + + + + {watchlist.name} + + + {isOwner && ( + + + {t("watchlists.you")} + + + )} + + + + + {watchlist.description && ( + + {watchlist.description} + + )} + + + + + + {watchlist.itemCount ?? 0}{" "} + {(watchlist.itemCount ?? 0) === 1 + ? t("watchlists.item") + : t("watchlists.items")} + + + {watchlist.allowedItemType && ( + + + {watchlist.allowedItemType} + + + )} + + + ); +}; + +const EmptyState: React.FC<{ onCreatePress: () => void }> = ({ + onCreatePress: _onCreatePress, +}) => { + const { t } = useTranslation(); + + return ( + + + + {t("watchlists.empty_title")} + + + {t("watchlists.empty_description")} + + + ); +}; + +const NotConfiguredState: React.FC = () => { + const { t } = useTranslation(); + const router = useRouter(); + + return ( + + + + {t("watchlists.not_configured_title")} + + + {t("watchlists.not_configured_description")} + + + + ); +}; + +export default function WatchlistsScreen() { + const { t } = useTranslation(); + const router = useRouter(); + const insets = useSafeAreaInsets(); + const user = useAtomValue(userAtom); + const streamystatsEnabled = useStreamystatsEnabled(); + const { data: watchlists, isLoading, refetch } = useWatchlistsQuery(); + const [refreshing, setRefreshing] = useState(false); + + const handleRefresh = useCallback(async () => { + setRefreshing(true); + await refetch(); + setRefreshing(false); + }, [refetch]); + + const handleCreatePress = useCallback(() => { + router.push("/(auth)/(tabs)/(watchlists)/create"); + }, [router]); + + const handleWatchlistPress = useCallback( + (watchlistId: number) => { + router.push(`/(auth)/(tabs)/(watchlists)/${watchlistId}`); + }, + [router], + ); + + // Separate watchlists into "mine" and "public" + const { myWatchlists, publicWatchlists } = useMemo(() => { + if (!watchlists) return { myWatchlists: [], publicWatchlists: [] }; + + const mine: StreamystatsWatchlist[] = []; + const pub: StreamystatsWatchlist[] = []; + + for (const w of watchlists) { + if (w.userId === user?.Id) { + mine.push(w); + } else { + pub.push(w); + } + } + + return { myWatchlists: mine, publicWatchlists: pub }; + }, [watchlists, user?.Id]); + + // Combine into sections for FlashList + const sections = useMemo(() => { + const result: Array< + | { type: "header"; title: string } + | { type: "watchlist"; data: StreamystatsWatchlist; isOwner: boolean } + > = []; + + if (myWatchlists.length > 0) { + result.push({ type: "header", title: t("watchlists.my_watchlists") }); + for (const w of myWatchlists) { + result.push({ type: "watchlist", data: w, isOwner: true }); + } + } + + if (publicWatchlists.length > 0) { + result.push({ type: "header", title: t("watchlists.public_watchlists") }); + for (const w of publicWatchlists) { + result.push({ type: "watchlist", data: w, isOwner: false }); + } + } + + return result; + }, [myWatchlists, publicWatchlists, t]); + + if (!streamystatsEnabled) { + return ; + } + + if (!isLoading && (!watchlists || watchlists.length === 0)) { + return ; + } + + return ( + + } + renderItem={({ item }) => { + if (item.type === "header") { + return ( + + {item.title} + + ); + } + + return ( + handleWatchlistPress(item.data.id)} + /> + ); + }} + getItemType={(item) => item.type} + /> + ); +} diff --git a/app/(auth)/(tabs)/_layout.tsx b/app/(auth)/(tabs)/_layout.tsx index e8a797281..45f246a5b 100644 --- a/app/(auth)/(tabs)/_layout.tsx +++ b/app/(auth)/(tabs)/_layout.tsx @@ -3,19 +3,34 @@ import { type NativeBottomTabNavigationEventMap, type NativeBottomTabNavigationOptions, } from "@bottom-tabs/react-navigation"; +import { Stack, useSegments, withLayoutContext } from "expo-router"; import type { ParamListBase, TabNavigationState, -} from "@react-navigation/native"; -import { useFocusEffect, useRouter, withLayoutContext } from "expo-router"; -import { useCallback } from "react"; +} from "expo-router/react-navigation"; +import { useCallback, useEffect, useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { Platform } from "react-native"; +import { Platform, View } from "react-native"; import { SystemBars } from "react-native-edge-to-edge"; +import type { TVNavBarTab } from "@/components/tv/TVNavBar"; +import { TVNavBar } from "@/components/tv/TVNavBar"; import { Colors } from "@/constants/Colors"; +import useRouter from "@/hooks/useAppRouter"; +import { + isTabRoute, + useTVHomeBackHandler, + useTVTabRootBackHandler, +} from "@/hooks/useTVBackHandler"; import { useSettings } from "@/utils/atoms/settings"; import { eventBus } from "@/utils/eventBus"; -import { storage } from "@/utils/mmkv"; + +// Music components are not available on tvOS (TrackPlayer not supported) +const MiniPlayerBar = Platform.isTV + ? () => null + : require("@/components/music/MiniPlayerBar").MiniPlayerBar; +const MusicPlaybackEngine = Platform.isTV + ? () => null + : require("@/components/music/MusicPlaybackEngine").MusicPlaybackEngine; const { Navigator } = createNativeBottomTabNavigator(); @@ -26,28 +41,110 @@ export const NativeTabs = withLayoutContext< NativeBottomTabNavigationEventMap >(Navigator); +const IS_ANDROID_TV = Platform.isTV && Platform.OS === "android"; + +function TVTabLayout() { + const { settings } = useSettings(); + const { t } = useTranslation(); + const segments = useSegments(); + const router = useRouter(); + + const currentTab = segments.find(isTabRoute); + const lastSegment = segments[segments.length - 1] ?? ""; + const atTabRoot = isTabRoute(lastSegment) || lastSegment === "index"; + + const tabs: TVNavBarTab[] = useMemo( + () => + [ + { key: "(home)", label: t("tabs.home") }, + { key: "(search)", label: t("tabs.search") }, + { key: "(favorites)", label: t("tabs.favorites") }, + !settings?.streamyStatsServerUrl || settings?.hideWatchlistsTab + ? null + : { key: "(watchlists)", label: t("watchlists.title") }, + { key: "(libraries)", label: t("tabs.library") }, + !settings?.showCustomMenuLinks + ? null + : { key: "(custom-links)", label: t("tabs.custom_links") }, + { key: "(settings)", label: t("tabs.settings") }, + ].filter((tab): tab is TVNavBarTab => tab !== null), + [ + settings?.streamyStatsServerUrl, + settings?.hideWatchlistsTab, + settings?.showCustomMenuLinks, + t, + ], + ); + + const activeTabKey = currentTab ?? "(home)"; + + const visibleKeys = useMemo( + () => new Set(tabs.map((tab) => tab.key)), + [tabs], + ); + + const handleTabChange = useCallback( + (key: string) => { + if (key === currentTab) return; + + if (key === "(home)") eventBus.emit("scrollToTop"); + if (key === "(search)") eventBus.emit("searchTabPressed"); + + router.replace(`/(auth)/(tabs)/${key}`); + }, + [currentTab, router], + ); + + const navigateHome = useCallback(() => { + router.replace("/(auth)/(tabs)/(home)"); + }, [router]); + useTVTabRootBackHandler(navigateHome, atTabRoot, currentTab); + + // If current tab is no longer visible (setting changed), navigate to home + useEffect(() => { + if (!visibleKeys.has(activeTabKey) && activeTabKey !== "(home)") { + router.replace("/(auth)/(tabs)/(home)"); + } + }, [visibleKeys, activeTabKey, router]); + + return ( + + + ); +} + export default function TabLayout() { const { settings } = useSettings(); const { t } = useTranslation(); - const router = useRouter(); - useFocusEffect( - useCallback(() => { - const hasShownIntro = storage.getBoolean("hasShownIntro"); - if (!hasShownIntro) { - const timer = setTimeout(() => { - router.push("/intro/page"); - }, 1000); + // Must be called before any conditional return (rules of hooks) + useTVHomeBackHandler(); - return () => { - clearTimeout(timer); - }; - } - }, []), - ); + if (IS_ANDROID_TV) { + return ; + } return ( - <> + ); } diff --git a/app/(auth)/now-playing.tsx b/app/(auth)/now-playing.tsx new file mode 100644 index 000000000..934175fb8 --- /dev/null +++ b/app/(auth)/now-playing.tsx @@ -0,0 +1,845 @@ +import { ExpoAvRoutePickerView } from "@douglowder/expo-av-route-picker-view"; +import { Ionicons } from "@expo/vector-icons"; +import { BottomSheetModalProvider } from "@gorhom/bottom-sheet"; +import type { + BaseItemDto, + MediaSourceInfo, +} from "@jellyfin/sdk/lib/generated-client/models"; +import { Image } from "expo-image"; +import { useAtom } from "jotai"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { + ActivityIndicator, + Dimensions, + Platform, + ScrollView, + TouchableOpacity, + View, +} from "react-native"; +import { Slider } from "react-native-awesome-slider"; +import DraggableFlatList, { + type RenderItemParams, + ScaleDecorator, +} from "react-native-draggable-flatlist"; +import { CastButton, CastState } from "react-native-google-cast"; +import { useSharedValue } from "react-native-reanimated"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import TextTicker from "react-native-text-ticker"; +import type { VolumeResult } from "react-native-volume-manager"; +import { Badge } from "@/components/Badge"; +import { Text } from "@/components/common/Text"; +import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal"; +import { PlaylistPickerSheet } from "@/components/music/PlaylistPickerSheet"; +import { TrackOptionsSheet } from "@/components/music/TrackOptionsSheet"; +import useRouter from "@/hooks/useAppRouter"; +import { useFavorite } from "@/hooks/useFavorite"; +import { useMusicCast } from "@/hooks/useMusicCast"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { + type RepeatMode, + useMusicPlayer, +} from "@/providers/MusicPlayerProvider"; +import { formatBitrate } from "@/utils/bitrate"; +import { formatDuration } from "@/utils/time"; + +// Conditionally require VolumeManager (not available on TV) +const VolumeManager = Platform.isTV + ? null + : require("react-native-volume-manager"); + +const formatFileSize = (bytes?: number | null) => { + if (!bytes) return null; + const sizes = ["B", "KB", "MB", "GB"]; + if (bytes === 0) return "0 B"; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + return `${Math.round((bytes / 1024 ** i) * 100) / 100} ${sizes[i]}`; +}; + +const formatSampleRate = (sampleRate?: number | null) => { + if (!sampleRate) return null; + return `${(sampleRate / 1000).toFixed(1)} kHz`; +}; + +const { width: SCREEN_WIDTH } = Dimensions.get("window"); +const ARTWORK_SIZE = SCREEN_WIDTH - 80; + +type ViewMode = "player" | "queue"; + +export default function NowPlayingScreen() { + const [api] = useAtom(apiAtom); + const [user] = useAtom(userAtom); + const router = useRouter(); + const insets = useSafeAreaInsets(); + const [viewMode, setViewMode] = useState("player"); + const [trackOptionsOpen, setTrackOptionsOpen] = useState(false); + const [playlistPickerOpen, setPlaylistPickerOpen] = useState(false); + const [createPlaylistOpen, setCreatePlaylistOpen] = useState(false); + + const { + isConnected: isCastConnected, + castQueue, + castState, + } = useMusicCast({ + api, + userId: user?.Id, + }); + + const { + currentTrack, + queue, + queueIndex, + isPlaying, + isLoading, + progress, + duration, + repeatMode, + shuffleEnabled, + mediaSource, + isTranscoding, + togglePlayPause, + next, + previous, + seek, + setRepeatMode, + toggleShuffle, + jumpToIndex, + removeFromQueue, + reorderQueue, + stop, + pause, + } = useMusicPlayer(); + + const { isFavorite, toggleFavorite } = useFavorite( + currentTrack ?? ({ Id: "" } as BaseItemDto), + ); + + const sliderProgress = useSharedValue(0); + const sliderMin = useSharedValue(0); + const sliderMax = useSharedValue(1); + + useEffect(() => { + sliderProgress.value = progress; + }, [progress, sliderProgress]); + + useEffect(() => { + sliderMax.value = duration > 0 ? duration : 1; + }, [duration, sliderMax]); + + // Auto-cast queue when Chromecast becomes connected and pause local playback + const prevCastState = useRef(null); + useEffect(() => { + if ( + castState === CastState.CONNECTED && + prevCastState.current !== CastState.CONNECTED && + queue.length > 0 + ) { + // Just connected - pause local playback and cast the queue + pause(); + castQueue({ queue, startIndex: queueIndex }); + } + prevCastState.current = castState; + }, [castState, queue, queueIndex, castQueue, pause]); + + const imageUrl = useMemo(() => { + if (!api || !currentTrack) return null; + const albumId = currentTrack.AlbumId || currentTrack.ParentId; + if (albumId) { + return `${api.basePath}/Items/${albumId}/Images/Primary?maxHeight=600&maxWidth=600`; + } + return `${api.basePath}/Items/${currentTrack.Id}/Images/Primary?maxHeight=600&maxWidth=600`; + }, [api, currentTrack]); + + const progressText = useMemo(() => { + const progressTicks = progress * 10000000; + return formatDuration(progressTicks); + }, [progress]); + + const _durationText = useMemo(() => { + const durationTicks = duration * 10000000; + return formatDuration(durationTicks); + }, [duration]); + + const remainingText = useMemo(() => { + const remaining = Math.max(0, duration - progress); + const remainingTicks = remaining * 10000000; + return `-${formatDuration(remainingTicks)}`; + }, [duration, progress]); + + const handleSliderComplete = useCallback( + (value: number) => { + seek(value); + }, + [seek], + ); + + const handleClose = useCallback(() => { + router.back(); + }, [router]); + + const _handleStop = useCallback(() => { + stop(); + router.back(); + }, [stop, router]); + + const cycleRepeatMode = useCallback(() => { + const modes: RepeatMode[] = ["off", "all", "one"]; + const currentIndex = modes.indexOf(repeatMode); + const nextMode = modes[(currentIndex + 1) % modes.length]; + setRepeatMode(nextMode); + }, [repeatMode, setRepeatMode]); + + const handleOptionsPress = useCallback(() => { + setTrackOptionsOpen(true); + }, []); + + const handleAddToPlaylist = useCallback(() => { + setPlaylistPickerOpen(true); + }, []); + + const handleCreateNewPlaylist = useCallback(() => { + setCreatePlaylistOpen(true); + }, []); + + const getRepeatIcon = (): string => { + switch (repeatMode) { + case "one": + return "repeat"; + case "all": + return "repeat"; + default: + return "repeat"; + } + }; + + const canGoNext = queueIndex < queue.length - 1 || repeatMode === "all"; + const canGoPrevious = queueIndex > 0 || progress > 3 || repeatMode === "all"; + + if (!currentTrack) { + return ( + + + No track playing + + + ); + } + + return ( + + + {/* Header */} + + + + + + + setViewMode("player")} + className='px-3 py-1' + > + + Now Playing + + + setViewMode("queue")} + className='px-3 py-1' + > + + Queue ({queue.length}) + + + + {/* Empty placeholder to balance header layout */} + + + + {viewMode === "player" ? ( + + ) : ( + + )} + + + + + + + ); +} + +interface PlayerViewProps { + api: any; + currentTrack: BaseItemDto; + imageUrl: string | null; + sliderProgress: any; + sliderMin: any; + sliderMax: any; + progressText: string; + remainingText: string; + isPlaying: boolean; + isLoading: boolean; + repeatMode: RepeatMode; + shuffleEnabled: boolean; + canGoNext: boolean; + canGoPrevious: boolean; + onSliderComplete: (value: number) => void; + onTogglePlayPause: () => void; + onNext: () => void; + onPrevious: () => void; + onCycleRepeat: () => void; + onToggleShuffle: () => void; + getRepeatIcon: () => string; + mediaSource: MediaSourceInfo | null; + isTranscoding: boolean; + isFavorite: boolean | undefined; + onToggleFavorite: () => void; + onOptionsPress: () => void; + isCastConnected: boolean; +} + +const PlayerView: React.FC = ({ + currentTrack, + imageUrl, + sliderProgress, + sliderMin, + sliderMax, + progressText, + remainingText, + isPlaying, + isLoading, + repeatMode, + shuffleEnabled, + canGoNext, + canGoPrevious, + onSliderComplete, + onTogglePlayPause, + onNext, + onPrevious, + onCycleRepeat, + onToggleShuffle, + getRepeatIcon, + mediaSource, + isTranscoding, + isFavorite, + onToggleFavorite, + onOptionsPress, + isCastConnected, +}) => { + const audioStream = useMemo(() => { + return mediaSource?.MediaStreams?.find((stream) => stream.Type === "Audio"); + }, [mediaSource]); + + // Volume slider state + const volumeProgress = useSharedValue(0); + const volumeMin = useSharedValue(0); + const volumeMax = useSharedValue(1); + const isTv = Platform.isTV; + + useEffect(() => { + if (isTv || !VolumeManager) return; + // Get initial volume + VolumeManager.getVolume().then(({ volume }: { volume: number }) => { + volumeProgress.value = volume; + }); + // Listen to volume changes + const listener = VolumeManager.addVolumeListener((result: VolumeResult) => { + volumeProgress.value = result.volume; + }); + return () => listener.remove(); + }, [isTv, volumeProgress]); + + const handleVolumeChange = useCallback((value: number) => { + if (VolumeManager) { + VolumeManager.setVolume(value); + } + }, []); + + const fileSize = formatFileSize(mediaSource?.Size); + const codec = audioStream?.Codec?.toUpperCase(); + const bitrate = formatBitrate(audioStream?.BitRate); + const sampleRate = formatSampleRate(audioStream?.SampleRate); + const playbackMethod = isTranscoding ? "Transcoding" : "Direct"; + + const hasAudioStats = + mediaSource && (fileSize || codec || bitrate || sampleRate); + return ( + + {/* Album artwork */} + + {imageUrl ? ( + + ) : ( + + + + )} + + + {/* Track info with actions */} + + + + t} + > + {currentTrack.Name} + + t} + > + {currentTrack.Artists?.join(", ") || currentTrack.AlbumArtist} + + + + + + + + + + + {/* Audio Stats */} + {hasAudioStats && ( + + {fileSize && } + {codec && } + + } + /> + {bitrate && bitrate !== "N/A" && ( + + )} + {sampleRate && } + + )} + + + {/* Progress slider */} + + null} + sliderHeight={8} + containerStyle={{ borderRadius: 100 }} + renderBubble={() => null} + /> + + {progressText} + {remainingText} + + + + {/* Main Controls with Shuffle & Repeat */} + + + + + + + + + + + {isLoading ? ( + + ) : ( + + )} + + + + + + + + + {repeatMode === "one" && ( + + 1 + + )} + + + + {/* Volume Slider */} + {!isTv && VolumeManager && ( + + + + null} + sliderHeight={8} + containerStyle={{ borderRadius: 100 }} + renderBubble={() => null} + /> + + + + )} + + {/* AirPlay & Chromecast Buttons */} + {!isTv && ( + + {/* AirPlay (iOS only) */} + {Platform.OS === "ios" && ( + + + + )} + {/* Chromecast */} + + + )} + + ); +}; + +interface QueueViewProps { + api: any; + queue: BaseItemDto[]; + queueIndex: number; + onJumpToIndex: (index: number) => void; + onRemoveFromQueue: (index: number) => void; + onReorderQueue: (newQueue: BaseItemDto[]) => void; +} + +const QueueView: React.FC = ({ + api, + queue, + queueIndex, + onJumpToIndex, + onRemoveFromQueue, + onReorderQueue, +}) => { + const renderQueueItem = useCallback( + ({ item, drag, isActive, getIndex }: RenderItemParams) => { + const index = getIndex() ?? 0; + const isCurrentTrack = index === queueIndex; + const isPast = index < queueIndex; + + const albumId = item.AlbumId || item.ParentId; + const imageUrl = api + ? albumId + ? `${api.basePath}/Items/${albumId}/Images/Primary?maxHeight=80&maxWidth=80` + : `${api.basePath}/Items/${item.Id}/Images/Primary?maxHeight=80&maxWidth=80` + : null; + + return ( + + onJumpToIndex(index)} + onLongPress={drag} + disabled={isActive} + className='flex-row items-center px-4 py-3' + style={{ + opacity: isPast && !isActive ? 0.5 : 1, + backgroundColor: isActive + ? "#2a2a2a" + : isCurrentTrack + ? "rgba(147, 52, 233, 0.3)" + : "#121212", + }} + > + {/* Drag handle */} + + + + + {/* Album art */} + + {imageUrl ? ( + + ) : ( + + + + )} + + + {/* Track info */} + + + {item.Name} + + + {item.Artists?.join(", ") || item.AlbumArtist} + + + + {/* Now playing indicator */} + {isCurrentTrack && ( + + )} + + {/* Remove button (not for current track) */} + {!isCurrentTrack && ( + onRemoveFromQueue(index)} + hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} + className='p-2' + > + + + )} + + + ); + }, + [api, queueIndex, onJumpToIndex, onRemoveFromQueue], + ); + + const handleDragEnd = useCallback( + ({ data }: { data: BaseItemDto[] }) => { + onReorderQueue(data); + }, + [onReorderQueue], + ); + + const history = queue.slice(0, queueIndex); + + return ( + `${item.Id}-${index}`} + renderItem={renderQueueItem} + onDragEnd={handleDragEnd} + showsVerticalScrollIndicator={false} + ListHeaderComponent={ + + + {history.length > 0 ? "Playing from queue" : "Up next"} + + + } + ListEmptyComponent={ + + Queue is empty + + } + /> + ); +}; diff --git a/app/(auth)/player/_layout.tsx b/app/(auth)/player/_layout.tsx index 97a3f7eb2..5604160e5 100644 --- a/app/(auth)/player/_layout.tsx +++ b/app/(auth)/player/_layout.tsx @@ -1,7 +1,33 @@ import { Stack } from "expo-router"; +import { useEffect } from "react"; +import { AppState } from "react-native"; import { SystemBars } from "react-native-edge-to-edge"; +import { useOrientation } from "@/hooks/useOrientation"; +import { useSettings } from "@/utils/atoms/settings"; + export default function Layout() { + const { settings } = useSettings(); + const { lockOrientation, unlockOrientation } = useOrientation(); + + useEffect(() => { + if (settings?.defaultVideoOrientation) { + lockOrientation(settings.defaultVideoOrientation); + } + + // Re-apply orientation lock when app returns to foreground (iOS resets it) + const subscription = AppState.addEventListener("change", (nextAppState) => { + if (nextAppState === "active" && settings?.defaultVideoOrientation) { + lockOrientation(settings.defaultVideoOrientation); + } + }); + + return () => { + subscription.remove(); + unlockOrientation(); + }; + }, [settings?.defaultVideoOrientation, lockOrientation, unlockOrientation]); + return ( <> + ); } diff --git a/components/ContextMenu.tv.ts b/components/ContextMenu.tv.ts deleted file mode 100644 index e69de29bb..000000000 diff --git a/components/DownloadItem.tsx b/components/DownloadItem.tsx index 2f2268b6a..b1f759b57 100644 --- a/components/DownloadItem.tsx +++ b/components/DownloadItem.tsx @@ -9,13 +9,15 @@ import type { BaseItemDto, MediaSourceInfo, } from "@jellyfin/sdk/lib/generated-client/models"; -import { type Href, router } from "expo-router"; +import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api"; +import { type Href } from "expo-router"; import { t } from "i18next"; import { useAtom } from "jotai"; import type React from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Alert, Platform, Switch, View, type ViewProps } from "react-native"; import { toast } from "sonner-native"; +import useRouter from "@/hooks/useAppRouter"; import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings"; import { useDownload } from "@/providers/DownloadProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; @@ -62,6 +64,7 @@ export const DownloadItems: React.FC = ({ const [user] = useAtom(userAtom); const [queue, _setQueue] = useAtom(queueAtom); const { settings } = useSettings(); + const router = useRouter(); const [downloadUnwatchedOnly, setDownloadUnwatchedOnly] = useState(false); const { processes, startBackgroundDownload, downloadedItems } = useDownload(); @@ -71,12 +74,16 @@ export const DownloadItems: React.FC = ({ SelectedOptions | undefined >(undefined); + const playSettingsOptions = useMemo( + () => ({ applyLanguagePreferences: true }), + [], + ); const { defaultAudioIndex, defaultBitrate, defaultMediaSource, defaultSubtitleIndex, - } = useDefaultPlaySettings(items[0], settings); + } = useDefaultPlaySettings(items[0], settings, playSettingsOptions); const userCanDownload = useMemo( () => user?.Policy?.EnableContentDownloading, @@ -109,7 +116,7 @@ export const DownloadItems: React.FC = ({ useEffect(() => { setSelectedOptions(() => ({ bitrate: defaultBitrate, - mediaSource: defaultMediaSource, + mediaSource: defaultMediaSource ?? undefined, subtitleIndex: defaultSubtitleIndex ?? -1, audioIndex: defaultAudioIndex, })); @@ -170,9 +177,11 @@ export const DownloadItems: React.FC = ({ firstItem.Type !== "Episode" ? "/downloads" : ({ - pathname: `/downloads/${firstItem.SeriesId}`, + pathname: "/series/[id]", params: { - episodeSeasonIndex: firstItem.ParentIndexNumber, + id: firstItem.SeriesId!, + seasonIndex: firstItem.ParentIndexNumber?.toString(), + offline: "true", }, } as Href), ); @@ -191,9 +200,30 @@ export const DownloadItems: React.FC = ({ ); } const downloadDetailsPromises = items.map(async (item) => { + // Ensure the snapshot we store offline carries the Chapters array. + // Page-level fetches sometimes use a fields filter that omits it; the + // offline player would then render no chapter ticks / list. + let itemForDownload = item; + if (!itemForDownload.Chapters && itemForDownload.Id) { + try { + const enriched = await getUserLibraryApi(api).getItem({ + itemId: itemForDownload.Id, + userId: user.Id!, + }); + if (enriched.data) { + itemForDownload = enriched.data; + } + } catch (e) { + console.warn( + "[DownloadItem] failed to refresh item for Chapters, falling back to original", + e, + ); + } + } + const { mediaSource, audioIndex, subtitleIndex } = itemsNotDownloaded.length > 1 - ? getDefaultPlaySettings(item, settings!) + ? getDefaultPlaySettings(itemForDownload, settings!) : { mediaSource: selectedOptions?.mediaSource, audioIndex: selectedOptions?.audioIndex, @@ -202,18 +232,19 @@ export const DownloadItems: React.FC = ({ const downloadDetails = await getDownloadUrl({ api, - item, + item: itemForDownload, userId: user.Id!, mediaSource: mediaSource!, audioStreamIndex: audioIndex ?? -1, subtitleStreamIndex: subtitleIndex ?? -1, maxBitrate: selectedOptions?.bitrate || defaultBitrate, deviceId: api.deviceInfo.id, + audioMode: settings?.audioTranscodeMode, }); return { url: downloadDetails?.url, - item, + item: itemForDownload, mediaSource: downloadDetails?.mediaSource, }; }); @@ -236,11 +267,23 @@ export const DownloadItems: React.FC = ({ ); continue; } + // Get the audio/subtitle indices that were used for this download + const downloadAudioIndex = + itemsNotDownloaded.length > 1 + ? getDefaultPlaySettings(item, settings!).audioIndex + : selectedOptions?.audioIndex; + const downloadSubtitleIndex = + itemsNotDownloaded.length > 1 + ? getDefaultPlaySettings(item, settings!).subtitleIndex + : selectedOptions?.subtitleIndex; + await startBackgroundDownload( url, item, mediaSource, selectedOptions?.bitrate || defaultBitrate, + downloadAudioIndex, + downloadSubtitleIndex, ); } }, diff --git a/components/ExampleGlobalModalUsage.tsx b/components/ExampleGlobalModalUsage.tsx deleted file mode 100644 index ccebb823d..000000000 --- a/components/ExampleGlobalModalUsage.tsx +++ /dev/null @@ -1,203 +0,0 @@ -/** - * Example Usage of Global Modal - * - * This file demonstrates how to use the global modal system from anywhere in your app. - * You can delete this file after understanding how it works. - */ - -import { Ionicons } from "@expo/vector-icons"; -import { TouchableOpacity, View } from "react-native"; -import { Text } from "@/components/common/Text"; -import { useGlobalModal } from "@/providers/GlobalModalProvider"; - -/** - * Example 1: Simple Content Modal - */ -export const SimpleModalExample = () => { - const { showModal } = useGlobalModal(); - - const handleOpenModal = () => { - showModal( - - Simple Modal - - This is a simple modal with just some text content. - - - Swipe down or tap outside to close. - - , - ); - }; - - return ( - - Open Simple Modal - - ); -}; - -/** - * Example 2: Modal with Custom Snap Points - */ -export const CustomSnapPointsExample = () => { - const { showModal } = useGlobalModal(); - - const handleOpenModal = () => { - showModal( - - - Custom Snap Points - - - This modal has custom snap points (25%, 50%, 90%). - - - - Try dragging the modal to different heights! - - - , - { - snapPoints: ["25%", "50%", "90%"], - enableDynamicSizing: false, - }, - ); - }; - - return ( - - Custom Snap Points - - ); -}; - -/** - * Example 3: Complex Component in Modal - */ -const SettingsModalContent = () => { - const { hideModal } = useGlobalModal(); - - const settings = [ - { - id: 1, - title: "Notifications", - icon: "notifications-outline" as const, - enabled: true, - }, - { id: 2, title: "Dark Mode", icon: "moon-outline" as const, enabled: true }, - { - id: 3, - title: "Auto-play", - icon: "play-outline" as const, - enabled: false, - }, - ]; - - return ( - - Settings - - {settings.map((setting, index) => ( - - - - {setting.title} - - - - - - ))} - - - Close - - - ); -}; - -export const ComplexModalExample = () => { - const { showModal } = useGlobalModal(); - - const handleOpenModal = () => { - showModal(); - }; - - return ( - - Complex Component - - ); -}; - -/** - * Example 4: Modal Triggered from Function (e.g., API response) - */ -export const useShowSuccessModal = () => { - const { showModal } = useGlobalModal(); - - return (message: string) => { - showModal( - - - - - Success! - {message} - , - ); - }; -}; - -/** - * Main Demo Component - */ -export const GlobalModalDemo = () => { - const showSuccess = useShowSuccessModal(); - - return ( - - - Global Modal Examples - - - - - - - showSuccess("Operation completed successfully!")} - className='bg-orange-600 px-4 py-2 rounded-lg' - > - Show Success Modal - - - ); -}; diff --git a/components/GenreTags.tsx b/components/GenreTags.tsx index 6708269d7..29f1cb303 100644 --- a/components/GenreTags.tsx +++ b/components/GenreTags.tsx @@ -1,11 +1,16 @@ // GenreTags.tsx +import { BlurView } from "expo-blur"; import type React from "react"; import { + Platform, type StyleProp, + StyleSheet, type TextStyle, View, type ViewProps, } from "react-native"; +import { GlassEffectView } from "react-native-glass-effect-view"; +import { useScaledTVTypography } from "@/constants/TVTypography"; import { Text } from "./common/Text"; interface TagProps { @@ -20,6 +25,52 @@ export const Tag: React.FC< textStyle?: StyleProp; } & ViewProps > = ({ text, textClass, textStyle, ...props }) => { + // Hook must be called at the top level, before any conditional returns + const typography = useScaledTVTypography(); + + if (Platform.OS === "ios" && !Platform.isTV) { + return ( + + + + {text} + + + + ); + } + + // TV-specific styling with blur background + if (Platform.isTV) { + return ( + + + + {text} + + + + ); + } + return ( @@ -29,6 +80,16 @@ export const Tag: React.FC< ); }; +const styles = StyleSheet.create({ + container: { + overflow: "hidden", + borderRadius: 50, + }, + glass: { + borderRadius: 50, + }, +}); + export const Tags: React.FC< TagProps & { tagProps?: ViewProps } & ViewProps > = ({ tags, textClass = "text-xs", tagProps, ...props }) => { @@ -36,7 +97,8 @@ export const Tags: React.FC< return ( {tags.map((tag, idx) => ( diff --git a/components/GlobalModal.tsx b/components/GlobalModal.tsx index 361321d35..107f78e40 100644 --- a/components/GlobalModal.tsx +++ b/components/GlobalModal.tsx @@ -3,7 +3,7 @@ import { type BottomSheetBackdropProps, BottomSheetModal, } from "@gorhom/bottom-sheet"; -import { useCallback } from "react"; +import { useCallback, useEffect } from "react"; import { useGlobalModal } from "@/providers/GlobalModalProvider"; /** @@ -16,7 +16,13 @@ import { useGlobalModal } from "@/providers/GlobalModalProvider"; * after BottomSheetModalProvider. */ export const GlobalModal = () => { - const { hideModal, modalState, modalRef } = useGlobalModal(); + const { hideModal, modalState, modalRef, isVisible } = useGlobalModal(); + + useEffect(() => { + if (isVisible && modalState.content) { + modalRef.current?.present(); + } + }, [isVisible, modalState.content, modalRef]); const handleSheetChanges = useCallback( (index: number) => { diff --git a/components/IntroSheet.tsx b/components/IntroSheet.tsx new file mode 100644 index 000000000..2b04b114f --- /dev/null +++ b/components/IntroSheet.tsx @@ -0,0 +1,203 @@ +import { Feather, Ionicons } from "@expo/vector-icons"; +import { + BottomSheetBackdrop, + type BottomSheetBackdropProps, + BottomSheetModal, + BottomSheetScrollView, +} from "@gorhom/bottom-sheet"; +import { Image } from "expo-image"; +import { forwardRef, useCallback, useImperativeHandle, useRef } from "react"; +import { useTranslation } from "react-i18next"; +import { Linking, Platform, TouchableOpacity, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Button } from "@/components/Button"; +import { Text } from "@/components/common/Text"; +import useRouter from "@/hooks/useAppRouter"; +import { storage } from "@/utils/mmkv"; + +export interface IntroSheetRef { + present: () => void; + dismiss: () => void; +} + +export const IntroSheet = forwardRef((_, ref) => { + const bottomSheetRef = useRef(null); + const { t } = useTranslation(); + const insets = useSafeAreaInsets(); + const router = useRouter(); + + useImperativeHandle(ref, () => ({ + present: () => { + storage.set("hasShownIntro", true); + bottomSheetRef.current?.present(); + }, + dismiss: () => { + bottomSheetRef.current?.dismiss(); + }, + })); + + const renderBackdrop = useCallback( + (props: BottomSheetBackdropProps) => ( + + ), + [], + ); + + const handleDismiss = useCallback(() => { + bottomSheetRef.current?.dismiss(); + }, []); + + const handleGoToSettings = useCallback(() => { + bottomSheetRef.current?.dismiss(); + router.push("/settings"); + }, []); + + return ( + + + + + + {t("home.intro.welcome_to_streamyfin")} + + + {t("home.intro.a_free_and_open_source_client_for_jellyfin")} + + + + + + {t("home.intro.features_title")} + + + {t("home.intro.features_description")} + + + + + Seerr + + {t("home.intro.jellyseerr_feature_description")} + + + + {!Platform.isTV && ( + <> + + + + + + + {t("home.intro.downloads_feature_title")} + + + {t("home.intro.downloads_feature_description")} + + + + + + + + + Chromecast + + {t("home.intro.chromecast_feature_description")} + + + + + )} + + + + + + + {t("home.intro.centralised_settings_plugin_title")} + + + + {t( + "home.intro.centralised_settings_plugin_description", + )}{" "} + + { + Linking.openURL( + "https://github.com/streamyfin/jellyfin-plugin-streamyfin", + ); + }} + > + + {t("home.intro.read_more")} + + + + + + + + + + + + {t("home.intro.go_to_settings_button")} + + + + + + + + + ); +}); + +IntroSheet.displayName = "IntroSheet"; diff --git a/components/ItemContent.tsx b/components/ItemContent.tsx index 4c537e961..4869a75e5 100644 --- a/components/ItemContent.tsx +++ b/components/ItemContent.tsx @@ -6,36 +6,38 @@ import { Image } from "expo-image"; import { useNavigation } from "expo-router"; import { useAtom } from "jotai"; import React, { useEffect, useMemo, useState } from "react"; -import { useTranslation } from "react-i18next"; import { Platform, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { type Bitrate } from "@/components/BitrateSelector"; import { ItemImage } from "@/components/common/ItemImage"; import { DownloadSingleItem } from "@/components/DownloadItem"; +import { ItemPeopleSections } from "@/components/item/ItemPeopleSections"; import { MediaSourceButton } from "@/components/MediaSourceButton"; import { OverviewText } from "@/components/OverviewText"; import { ParallaxScrollView } from "@/components/ParallaxPage"; -// const PlayButton = !Platform.isTV ? require("@/components/PlayButton") : null; import { PlayButton } from "@/components/PlayButton"; import { PlayedStatus } from "@/components/PlayedStatus"; import { SimilarItems } from "@/components/SimilarItems"; -import { CastAndCrew } from "@/components/series/CastAndCrew"; import { CurrentSeries } from "@/components/series/CurrentSeries"; import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel"; import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings"; import { useImageColorsReturn } from "@/hooks/useImageColorsReturn"; -import { useItemQuery } from "@/hooks/useItemQuery"; import { useOrientation } from "@/hooks/useOrientation"; import * as ScreenOrientation from "@/packages/expo-screen-orientation"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { useOfflineMode } from "@/providers/OfflineModeProvider"; import { useSettings } from "@/utils/atoms/settings"; import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById"; import { AddToFavorites } from "./AddToFavorites"; +import { AddToWatchlist } from "./AddToWatchlist"; import { ItemHeader } from "./ItemHeader"; -import { MoreMoviesWithActor } from "./MoreMoviesWithActor"; +import { ItemTechnicalDetails } from "./ItemTechnicalDetails"; import { PlayInRemoteSessionButton } from "./PlayInRemoteSession"; const Chromecast = !Platform.isTV ? require("./Chromecast") : null; +const ItemContentTV = Platform.isTV + ? require("./ItemContent.tv").ItemContentTV + : null; export type SelectedOptions = { bitrate: Bitrate; @@ -45,223 +47,251 @@ export type SelectedOptions = { }; interface ItemContentProps { - item: BaseItemDto; - isOffline: boolean; + item?: BaseItemDto | null; + itemWithSources?: BaseItemDto | null; + isLoading?: boolean; } -export const ItemContent: React.FC = React.memo( - ({ item, isOffline }) => { - const [api] = useAtom(apiAtom); - const { settings } = useSettings(); - const { orientation } = useOrientation(); - const navigation = useNavigation(); - const insets = useSafeAreaInsets(); - const [user] = useAtom(userAtom); - const { t } = useTranslation(); +// Mobile-specific implementation +const ItemContentMobile: React.FC = ({ + item, + itemWithSources, +}) => { + const [api] = useAtom(apiAtom); + const isOffline = useOfflineMode(); + const { settings } = useSettings(); + const { orientation } = useOrientation(); + const navigation = useNavigation(); + const insets = useSafeAreaInsets(); + const [user] = useAtom(userAtom); - const itemColors = useImageColorsReturn({ item }); + const itemColors = useImageColorsReturn({ item }); - const [loadingLogo, setLoadingLogo] = useState(true); - const [headerHeight, setHeaderHeight] = useState(350); + const [loadingLogo, setLoadingLogo] = useState(true); + const [headerHeight, setHeaderHeight] = useState(350); - const [selectedOptions, setSelectedOptions] = useState< - SelectedOptions | undefined - >(undefined); + const [selectedOptions, setSelectedOptions] = useState< + SelectedOptions | undefined + >(undefined); - // preload media sources - useItemQuery(item.Id, false, undefined, []); + // Use itemWithSources for play settings since it has MediaSources data + const playSettingsOptions = useMemo( + () => ({ applyLanguagePreferences: true }), + [], + ); + const { + defaultAudioIndex, + defaultBitrate, + defaultMediaSource, + defaultSubtitleIndex, + } = useDefaultPlaySettings( + itemWithSources ?? item, + settings, + playSettingsOptions, + ); - const { - defaultAudioIndex, - defaultBitrate, - defaultMediaSource, - defaultSubtitleIndex, - } = useDefaultPlaySettings(item!, settings); + const logoUrl = useMemo( + () => (item ? getLogoImageUrlById({ api, item }) : null), + [api, item], + ); - const logoUrl = useMemo( - () => (item ? getLogoImageUrlById({ api, item }) : null), - [api, item], - ); + const onLogoLoad = React.useCallback(() => { + setLoadingLogo(false); + }, []); - const loading = useMemo(() => { - return Boolean(logoUrl && loadingLogo); - }, [loadingLogo, logoUrl]); + const loading = useMemo(() => { + return Boolean(logoUrl && loadingLogo); + }, [loadingLogo, logoUrl]); - // Needs to automatically change the selected to the default values for default indexes. - useEffect(() => { - setSelectedOptions(() => ({ - bitrate: defaultBitrate, - mediaSource: defaultMediaSource, - subtitleIndex: defaultSubtitleIndex ?? -1, - audioIndex: defaultAudioIndex, - })); - }, [ - defaultAudioIndex, - defaultBitrate, - defaultSubtitleIndex, - defaultMediaSource, - ]); + // Needs to automatically change the selected to the default values for default indexes. + useEffect(() => { + setSelectedOptions(() => ({ + bitrate: defaultBitrate, + mediaSource: defaultMediaSource ?? undefined, + subtitleIndex: defaultSubtitleIndex ?? -1, + audioIndex: defaultAudioIndex, + })); + }, [ + defaultAudioIndex, + defaultBitrate, + defaultSubtitleIndex, + defaultMediaSource, + ]); - useEffect(() => { - if (!Platform.isTV) { - navigation.setOptions({ - headerRight: () => - item && - (Platform.OS === "ios" ? ( - - - {item.Type !== "Program" && ( - - {!Platform.isTV && ( - - )} - {user?.Policy?.IsAdministrator && ( + useEffect(() => { + if (!Platform.isTV && itemWithSources) { + navigation.setOptions({ + headerRight: () => + item && + (Platform.OS === "ios" ? ( + + + {item.Type !== "Program" && ( + + {!Platform.isTV && ( + + )} + {user?.Policy?.IsAdministrator && + !settings.hideRemoteSessionButton && ( )} - - - - )} - - ) : ( - - - {item.Type !== "Program" && ( - - {!Platform.isTV && ( - + + + {settings.streamyStatsServerUrl && + !settings.hideWatchlistsTab && ( + )} - {user?.Policy?.IsAdministrator && ( - - )} - - - - - )} - - )), - }); - } - }, [item, navigation, user]); - - useEffect(() => { - if (item) { - if (orientation !== ScreenOrientation.OrientationLock.PORTRAIT_UP) - setHeaderHeight(230); - else if (item.Type === "Movie") setHeaderHeight(500); - else setHeaderHeight(350); - } - }, [item, orientation]); - - if (!item || !selectedOptions) return null; - - return ( - - - + + )} - } - logo={ - logoUrl ? ( - setLoadingLogo(false)} - onError={() => setLoadingLogo(false)} - /> - ) : ( - - ) - } - > - - - + ) : ( + + + {item.Type !== "Program" && ( + + {!Platform.isTV && ( + + )} + {user?.Policy?.IsAdministrator && + !settings.hideRemoteSessionButton && ( + + )} - - + + {settings.streamyStatsServerUrl && + !settings.hideWatchlistsTab && ( + + )} + + )} + + )), + }); + } + }, [ + item, + navigation, + user, + itemWithSources, + settings.hideRemoteSessionButton, + settings.streamyStatsServerUrl, + settings.hideWatchlistsTab, + ]); + + useEffect(() => { + if (item) { + if (orientation !== ScreenOrientation.OrientationLock.PORTRAIT_UP) + setHeaderHeight(230); + else if (item.Type === "Movie") setHeaderHeight(500); + else setHeaderHeight(350); + } + }, [item, orientation]); + + if (!item || !selectedOptions) return null; + + return ( + + + + + } + logo={ + logoUrl ? ( + + ) : ( + + ) + } + > + + + + + + + + {!isOffline && ( + - - {!isOffline && ( - - )} - + )} - {item.Type === "Episode" && ( - - )} - - - - {item.Type !== "Program" && ( - <> - {item.Type === "Episode" && !isOffline && ( - - )} - - {!isOffline && ( - - )} - - {item.People && item.People.length > 0 && !isOffline && ( - - {item.People.slice(0, 3).map((person, idx) => ( - - ))} - - )} - - {!isOffline && } - - )} - - - ); - }, -); + {item.Type === "Episode" && ( + + )} + + {!isOffline && + selectedOptions.mediaSource?.MediaStreams && + selectedOptions.mediaSource.MediaStreams.length > 0 && ( + + )} + + + + {item.Type !== "Program" && ( + <> + {item.Type === "Episode" && !isOffline && ( + + )} + + + + {!isOffline && } + + )} + + + + ); +}; + +// Memoize the mobile component +const MemoizedItemContentMobile = React.memo(ItemContentMobile); + +// Exported component that renders TV or mobile version based on platform +export const ItemContent: React.FC = (props) => { + if (Platform.isTV && ItemContentTV) { + return ; + } + return ; +}; diff --git a/components/ItemContent.tv.tsx b/components/ItemContent.tv.tsx new file mode 100644 index 000000000..05d0d1ce2 --- /dev/null +++ b/components/ItemContent.tv.tsx @@ -0,0 +1,967 @@ +import { Ionicons } from "@expo/vector-icons"; +import type { + BaseItemDto, + MediaSourceInfo, + MediaStream, +} from "@jellyfin/sdk/lib/generated-client/models"; +import { getTvShowsApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { BlurView } from "expo-blur"; +import { File } from "expo-file-system"; +import { Image } from "expo-image"; +import { useAtom } from "jotai"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { useTranslation } from "react-i18next"; +import { Alert, Dimensions, ScrollView, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { BITRATES, type Bitrate } from "@/components/BitrateSelector"; +import { ItemImage } from "@/components/common/ItemImage"; +import { Text } from "@/components/common/Text"; +import { getItemNavigation } from "@/components/common/TouchableItemRouter"; +import { GenreTags } from "@/components/GenreTags"; +import { TVEpisodeList } from "@/components/series/TVEpisodeList"; +import { + TVBackdrop, + TVButton, + TVCastCrewText, + TVCastSection, + TVFavoriteButton, + TVMetadataBadges, + TVOptionButton, + TVPlayedButton, + TVProgressBar, + TVRefreshButton, + TVSeriesNavigation, + TVTechnicalDetails, +} from "@/components/tv"; +import type { Track } from "@/components/video-player/controls/types"; +import { useScaledTVTypography } from "@/constants/TVTypography"; +import useRouter from "@/hooks/useAppRouter"; +import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings"; +import { useImageColorsReturn } from "@/hooks/useImageColorsReturn"; +import { useTVItemActionModal } from "@/hooks/useTVItemActionModal"; +import { useTVOptionModal } from "@/hooks/useTVOptionModal"; +import { useTVSubtitleModal } from "@/hooks/useTVSubtitleModal"; +import { useTVThemeMusic } from "@/hooks/useTVThemeMusic"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { useOfflineMode } from "@/providers/OfflineModeProvider"; +import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles"; +import { useSettings } from "@/utils/atoms/settings"; +import type { TVOptionItem } from "@/utils/atoms/tvOptionModal"; +import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById"; +import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById"; +import { formatDuration, runtimeTicksToMinutes } from "@/utils/time"; + +const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window"); + +export type SelectedOptions = { + bitrate: Bitrate; + mediaSource: MediaSourceInfo | undefined; + audioIndex: number | undefined; + subtitleIndex: number; +}; + +interface ItemContentTVProps { + item?: BaseItemDto | null; + itemWithSources?: BaseItemDto | null; + isLoading?: boolean; +} + +// Export as both ItemContentTV (for direct requires) and ItemContent (for platform-resolved imports) +export const ItemContentTV: React.FC = React.memo( + ({ item, itemWithSources }) => { + const typography = useScaledTVTypography(); + const [api] = useAtom(apiAtom); + const [user] = useAtom(userAtom); + const isOffline = useOfflineMode(); + const { settings } = useSettings(); + const insets = useSafeAreaInsets(); + const router = useRouter(); + const { showItemActions } = useTVItemActionModal(); + const { t } = useTranslation(); + const queryClient = useQueryClient(); + + const _itemColors = useImageColorsReturn({ item }); + + // Auto-play theme music (handles fade in/out and cleanup) + useTVThemeMusic(item?.Id); + + // State for first episode card ref (used for focus guide) + const [_firstEpisodeRef, setFirstEpisodeRef] = useState(null); + + // Fetch season episodes for episodes + const { data: seasonEpisodes = [] } = useQuery({ + queryKey: ["episodes", item?.SeasonId], + queryFn: async () => { + if (!api || !user?.Id || !item?.SeriesId || !item?.SeasonId) return []; + const res = await getTvShowsApi(api).getEpisodes({ + seriesId: item.SeriesId, + userId: user.Id, + seasonId: item.SeasonId, + enableUserData: true, + fields: ["MediaSources", "Overview"], + }); + return res.data.Items || []; + }, + enabled: + !!api && + !!user?.Id && + !!item?.SeriesId && + !!item?.SeasonId && + item?.Type === "Episode", + }); + + const [selectedOptions, setSelectedOptions] = useState< + SelectedOptions | undefined + >(undefined); + + // Enable language preference application for TV + const playSettingsOptions = useMemo( + () => ({ applyLanguagePreferences: true }), + [], + ); + + const { + defaultAudioIndex, + defaultBitrate, + defaultMediaSource, + defaultSubtitleIndex, + } = useDefaultPlaySettings( + itemWithSources ?? item, + settings, + playSettingsOptions, + ); + + const logoUrl = useMemo( + () => (item ? getLogoImageUrlById({ api, item }) : null), + [api, item], + ); + + // Set default play options + useEffect(() => { + setSelectedOptions(() => ({ + bitrate: defaultBitrate, + mediaSource: defaultMediaSource ?? undefined, + subtitleIndex: defaultSubtitleIndex ?? -1, + audioIndex: defaultAudioIndex, + })); + }, [ + defaultAudioIndex, + defaultBitrate, + defaultSubtitleIndex, + defaultMediaSource, + ]); + + const navigateToPlayer = useCallback( + (playbackPosition: string) => { + if (!item || !selectedOptions) return; + + const queryParams = new URLSearchParams({ + itemId: item.Id!, + audioIndex: selectedOptions.audioIndex?.toString() ?? "", + subtitleIndex: selectedOptions.subtitleIndex?.toString() ?? "", + mediaSourceId: selectedOptions.mediaSource?.Id ?? "", + bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "", + playbackPosition, + offline: isOffline ? "true" : "false", + }); + + router.push(`/player/direct-player?${queryParams.toString()}`); + }, + [item, selectedOptions, isOffline, router], + ); + + const handlePlay = () => { + if (!item || !selectedOptions) return; + + const hasPlaybackProgress = + (item.UserData?.PlaybackPositionTicks ?? 0) > 0; + + if (hasPlaybackProgress) { + Alert.alert( + t("item_card.resume_playback"), + t("item_card.resume_playback_description"), + [ + { + text: t("common.cancel"), + style: "cancel", + }, + { + text: t("item_card.play_from_start"), + onPress: () => navigateToPlayer("0"), + }, + { + text: t("item_card.continue_from", { + time: formatDuration(item.UserData?.PlaybackPositionTicks), + }), + onPress: () => + navigateToPlayer( + item.UserData?.PlaybackPositionTicks?.toString() ?? "0", + ), + isPreferred: true, + }, + ], + ); + } else { + navigateToPlayer("0"); + } + }; + + // TV Option Modal hook for quality, audio, media source selectors + const { showOptions } = useTVOptionModal(); + + // TV Subtitle Modal hook + const { showSubtitleModal } = useTVSubtitleModal(); + + // State for first actor card ref (used for focus guide) + const [_firstActorCardRef, setFirstActorCardRef] = useState( + null, + ); + + // Get available audio tracks + const audioTracks = useMemo(() => { + const streams = selectedOptions?.mediaSource?.MediaStreams?.filter( + (s) => s.Type === "Audio", + ); + return streams ?? []; + }, [selectedOptions?.mediaSource]); + + // Get available subtitle tracks (raw MediaStream[] for label lookup) + const subtitleStreams = useMemo(() => { + const streams = selectedOptions?.mediaSource?.MediaStreams?.filter( + (s) => s.Type === "Subtitle", + ); + return streams ?? []; + }, [selectedOptions?.mediaSource]); + + // Store handleSubtitleChange in a ref for stable callback reference + const handleSubtitleChangeRef = useRef<((index: number) => void) | null>( + null, + ); + + // State to trigger refresh of local subtitles list + const [localSubtitlesRefreshKey, setLocalSubtitlesRefreshKey] = useState(0); + + // Starting index for local (client-downloaded) subtitles + const LOCAL_SUBTITLE_INDEX_START = -100; + + // Convert MediaStream[] to Track[] for the modal (with setTrack callbacks) + // Also includes locally downloaded subtitles from OpenSubtitles + const subtitleTracksForModal = useMemo((): Track[] => { + const tracks: Track[] = subtitleStreams.map((stream) => ({ + name: + stream.DisplayTitle || + `${stream.Language || "Unknown"} (${stream.Codec})`, + index: stream.Index ?? -1, + setTrack: () => { + handleSubtitleChangeRef.current?.(stream.Index ?? -1); + }, + })); + + // Add locally downloaded subtitles (from OpenSubtitles) + if (item?.Id) { + const localSubs = getSubtitlesForItem(item.Id); + let localIdx = 0; + for (const localSub of localSubs) { + // Verify file still exists (cache may have been cleared) + const subtitleFile = new File(localSub.filePath); + if (!subtitleFile.exists) { + continue; + } + + const localIndex = LOCAL_SUBTITLE_INDEX_START - localIdx; + tracks.push({ + name: localSub.name, + index: localIndex, + isLocal: true, + localPath: localSub.filePath, + setTrack: () => { + // For ItemContent (outside player), just update the selected index + // The actual subtitle will be loaded when playback starts + handleSubtitleChangeRef.current?.(localIndex); + }, + }); + localIdx++; + } + } + + return tracks; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [subtitleStreams, item?.Id, localSubtitlesRefreshKey]); + + // Get available media sources + const mediaSources = useMemo(() => { + return (itemWithSources ?? item)?.MediaSources ?? []; + }, [item, itemWithSources]); + + // Audio options for selector + const audioOptions: TVOptionItem[] = useMemo(() => { + return audioTracks.map((track) => ({ + label: + track.DisplayTitle || + `${track.Language || "Unknown"} (${track.Codec})`, + value: track.Index!, + selected: track.Index === selectedOptions?.audioIndex, + })); + }, [audioTracks, selectedOptions?.audioIndex]); + + // Media source options for selector + const mediaSourceOptions: TVOptionItem[] = useMemo(() => { + return mediaSources.map((source) => { + const videoStream = source.MediaStreams?.find( + (s) => s.Type === "Video", + ); + const displayName = + videoStream?.DisplayTitle || source.Name || `Source ${source.Id}`; + return { + label: displayName, + value: source, + selected: source.Id === selectedOptions?.mediaSource?.Id, + }; + }); + }, [mediaSources, selectedOptions?.mediaSource?.Id]); + + // Quality/bitrate options for selector + const qualityOptions: TVOptionItem[] = useMemo(() => { + return BITRATES.map((bitrate) => ({ + label: bitrate.key, + value: bitrate, + selected: bitrate.value === selectedOptions?.bitrate?.value, + })); + }, [selectedOptions?.bitrate?.value]); + + // Handlers for option changes + const handleAudioChange = useCallback((audioIndex: number) => { + setSelectedOptions((prev) => + prev ? { ...prev, audioIndex } : undefined, + ); + }, []); + + const handleSubtitleChange = useCallback((subtitleIndex: number) => { + setSelectedOptions((prev) => + prev ? { ...prev, subtitleIndex } : undefined, + ); + }, []); + + // Keep the ref updated with the latest callback + handleSubtitleChangeRef.current = handleSubtitleChange; + + const handleMediaSourceChange = useCallback( + (mediaSource: MediaSourceInfo) => { + const defaultAudio = mediaSource.MediaStreams?.find( + (s) => s.Type === "Audio" && s.IsDefault, + ); + const defaultSubtitle = mediaSource.MediaStreams?.find( + (s) => s.Type === "Subtitle" && s.IsDefault, + ); + setSelectedOptions((prev) => + prev + ? { + ...prev, + mediaSource, + audioIndex: defaultAudio?.Index ?? prev.audioIndex, + subtitleIndex: defaultSubtitle?.Index ?? -1, + } + : undefined, + ); + }, + [], + ); + + const handleQualityChange = useCallback((bitrate: Bitrate) => { + setSelectedOptions((prev) => (prev ? { ...prev, bitrate } : undefined)); + }, []); + + // Handle server-side subtitle download - invalidate queries to refresh tracks + const handleServerSubtitleDownloaded = useCallback(() => { + if (item?.Id) { + queryClient.invalidateQueries({ queryKey: ["item", item.Id] }); + } + }, [item?.Id, queryClient]); + + // Handle local subtitle download - trigger refresh of subtitle tracks + const handleLocalSubtitleDownloaded = useCallback((_path: string) => { + // Increment the refresh key to trigger re-computation of subtitleTracksForModal + setLocalSubtitlesRefreshKey((prev) => prev + 1); + }, []); + + // Refresh subtitle tracks by fetching fresh item data from Jellyfin + const refreshSubtitleTracks = useCallback(async (): Promise => { + if (!api || !item?.Id) return []; + + try { + // Fetch fresh item data with media sources + const response = await getUserLibraryApi(api).getItem({ + itemId: item.Id, + }); + + const freshItem = response.data; + const mediaSourceId = selectedOptions?.mediaSource?.Id; + + // Find the matching media source + const mediaSource = mediaSourceId + ? freshItem.MediaSources?.find( + (s: MediaSourceInfo) => s.Id === mediaSourceId, + ) + : freshItem.MediaSources?.[0]; + + // Get subtitle streams from the fresh data + const streams = + mediaSource?.MediaStreams?.filter( + (s: MediaStream) => s.Type === "Subtitle", + ) ?? []; + + // Convert to Track[] with setTrack callbacks + const tracks: Track[] = streams.map((stream) => ({ + name: + stream.DisplayTitle || + `${stream.Language || "Unknown"} (${stream.Codec})`, + index: stream.Index ?? -1, + setTrack: () => { + handleSubtitleChangeRef.current?.(stream.Index ?? -1); + }, + })); + + // Add locally downloaded subtitles + if (item?.Id) { + const localSubs = getSubtitlesForItem(item.Id); + let localIdx = 0; + for (const localSub of localSubs) { + const subtitleFile = new File(localSub.filePath); + if (!subtitleFile.exists) continue; + + const localIndex = LOCAL_SUBTITLE_INDEX_START - localIdx; + tracks.push({ + name: localSub.name, + index: localIndex, + isLocal: true, + localPath: localSub.filePath, + setTrack: () => { + handleSubtitleChangeRef.current?.(localIndex); + }, + }); + localIdx++; + } + } + + return tracks; + } catch (error) { + console.error("Failed to refresh subtitle tracks:", error); + return []; + } + }, [api, item?.Id, selectedOptions?.mediaSource?.Id]); + + // Get display values for buttons + const selectedAudioLabel = useMemo(() => { + const track = audioTracks.find( + (t) => t.Index === selectedOptions?.audioIndex, + ); + return track?.DisplayTitle || track?.Language || t("item_card.audio"); + }, [audioTracks, selectedOptions?.audioIndex, t]); + + const selectedSubtitleLabel = useMemo(() => { + if (selectedOptions?.subtitleIndex === -1) + return t("item_card.subtitles.none"); + + // Check if it's a local subtitle (negative index starting at -100) + if ( + selectedOptions?.subtitleIndex !== undefined && + selectedOptions.subtitleIndex <= LOCAL_SUBTITLE_INDEX_START + ) { + const localTrack = subtitleTracksForModal.find( + (t) => t.index === selectedOptions.subtitleIndex, + ); + return localTrack?.name || t("item_card.subtitles.label"); + } + + const track = subtitleStreams.find( + (t) => t.Index === selectedOptions?.subtitleIndex, + ); + return ( + track?.DisplayTitle || track?.Language || t("item_card.subtitles.label") + ); + }, [ + subtitleStreams, + subtitleTracksForModal, + selectedOptions?.subtitleIndex, + t, + ]); + + const selectedMediaSourceLabel = useMemo(() => { + const source = selectedOptions?.mediaSource; + if (!source) return t("item_card.video"); + const videoStream = source.MediaStreams?.find((s) => s.Type === "Video"); + return videoStream?.DisplayTitle || source.Name || t("item_card.video"); + }, [selectedOptions?.mediaSource, t]); + + const selectedQualityLabel = useMemo(() => { + return selectedOptions?.bitrate?.key || t("item_card.quality"); + }, [selectedOptions?.bitrate?.key, t]); + + // Format year and duration + const year = item?.ProductionYear; + const duration = item?.RunTimeTicks + ? runtimeTicksToMinutes(item.RunTimeTicks) + : null; + const hasProgress = (item?.UserData?.PlaybackPositionTicks ?? 0) > 0; + const remainingTime = hasProgress + ? runtimeTicksToMinutes( + (item?.RunTimeTicks || 0) - + (item?.UserData?.PlaybackPositionTicks || 0), + ) + : null; + + // Get director + const director = item?.People?.find((p) => p.Type === "Director"); + + // Get cast (first 3 for text display) + const cast = item?.People?.filter((p) => p.Type === "Actor")?.slice(0, 3); + + // Get full cast for visual display (up to 10 actors) + const fullCast = useMemo(() => { + return ( + item?.People?.filter((p) => p.Type === "Actor")?.slice(0, 10) ?? [] + ); + }, [item?.People]); + + // Whether to show visual cast section + const showVisualCast = + (item?.Type === "Movie" || + item?.Type === "Series" || + item?.Type === "Episode") && + fullCast.length > 0; + + // Series/Season image URLs for episodes + const seriesImageUrl = useMemo(() => { + if (item?.Type !== "Episode" || !item.SeriesId) return null; + return getPrimaryImageUrlById({ api, id: item.SeriesId, width: 300 }); + }, [api, item?.Type, item?.SeriesId]); + + const seasonImageUrl = useMemo(() => { + if (item?.Type !== "Episode") return null; + const seasonId = item.SeasonId || item.ParentId; + if (!seasonId) return null; + return getPrimaryImageUrlById({ api, id: seasonId, width: 300 }); + }, [api, item?.Type, item?.SeasonId, item?.ParentId]); + + // Episode thumbnail URL - episode's own primary image (16:9 for episodes) + const episodeThumbnailUrl = useMemo(() => { + if (item?.Type !== "Episode" || !api) return null; + return `${api.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`; + }, [api, item]); + + // Series thumb URL - used when showSeriesPosterOnEpisode setting is enabled + const seriesThumbUrl = useMemo(() => { + if (item?.Type !== "Episode" || !item.SeriesId || !api) return null; + return `${api.basePath}/Items/${item.SeriesId}/Images/Thumb?fillHeight=700&quality=80`; + }, [api, item]); + + // Navigation handlers + const handleActorPress = useCallback( + (personId: string) => { + router.push(`/(auth)/persons/${personId}`); + }, + [router], + ); + + const handleSeriesPress = useCallback(() => { + if (item?.SeriesId) { + router.push(`/(auth)/series/${item.SeriesId}`); + } + }, [router, item?.SeriesId]); + + const handleSeasonPress = useCallback(() => { + if (item?.SeriesId && item?.ParentIndexNumber) { + router.push( + `/(auth)/series/${item.SeriesId}?seasonIndex=${item.ParentIndexNumber}`, + ); + } + }, [router, item?.SeriesId, item?.ParentIndexNumber]); + + const handleEpisodePress = useCallback( + (episode: BaseItemDto) => { + const navigation = getItemNavigation(episode, "(home)"); + router.replace(navigation as any); + }, + [router], + ); + + if (!item || !selectedOptions) return null; + + return ( + + {/* Full-screen backdrop */} + + + {/* Main content area */} + + {/* Top section - Logo/Title + Metadata */} + + {/* Left side - Content */} + + {/* Logo or Title */} + {logoUrl ? ( + + ) : ( + + {item.Name} + + )} + + {/* Episode info for TV shows */} + {item.Type === "Episode" && ( + + + {item.SeriesName} + + + S{item.ParentIndexNumber} E{item.IndexNumber} · {item.Name} + + + )} + + {/* Metadata badges row */} + + + {/* Genres */} + {item.Genres && item.Genres.length > 0 && ( + + + + )} + + {/* Overview */} + {item.Overview && ( + + + + {item.Overview} + + + + )} + + {/* Action buttons */} + + + + + {hasProgress + ? `${remainingTime} ${t("item_card.left")}` + : t("common.play")} + + + + + + + + {/* Playback options */} + + {/* Quality selector */} + + showOptions({ + title: t("item_card.quality"), + options: qualityOptions, + onSelect: handleQualityChange, + }) + } + /> + + {/* Media source selector (only if multiple sources) */} + {mediaSources.length > 1 && ( + + showOptions({ + title: t("item_card.video"), + options: mediaSourceOptions, + onSelect: handleMediaSourceChange, + }) + } + /> + )} + + {/* Audio selector */} + {audioTracks.length > 0 && ( + + showOptions({ + title: t("item_card.audio"), + options: audioOptions, + onSelect: handleAudioChange, + }) + } + /> + )} + + {/* Subtitle selector */} + {(subtitleStreams.length > 0 || + selectedOptions?.subtitleIndex !== undefined) && ( + + showSubtitleModal({ + item, + mediaSourceId: selectedOptions?.mediaSource?.Id, + subtitleTracks: subtitleTracksForModal, + currentSubtitleIndex: + selectedOptions?.subtitleIndex ?? -1, + onDisableSubtitles: () => handleSubtitleChange(-1), + onServerSubtitleDownloaded: + handleServerSubtitleDownloaded, + onLocalSubtitleDownloaded: + handleLocalSubtitleDownloaded, + refreshSubtitleTracks, + }) + } + /> + )} + + + {/* Progress bar (if partially watched) */} + {hasProgress && item.RunTimeTicks != null && ( + + )} + + + {/* Right side - Poster */} + + + {item.Type === "Episode" ? ( + + ) : ( + + )} + + + + + {/* Additional info section */} + + {/* Season Episodes - Episode only */} + {item.Type === "Episode" && seasonEpisodes.length > 1 && ( + + + {t("item_card.more_from_this_season")} + + + + + )} + + {/* From this Series - Episode only */} + + + {/* Visual Cast Section - Movies/Series/Episodes with circular actor cards */} + {showVisualCast && ( + + )} + + {/* Cast & Crew (text version - director, etc.) */} + + + {/* Technical details */} + {selectedOptions.mediaSource?.MediaStreams && + selectedOptions.mediaSource.MediaStreams.length > 0 && ( + + )} + + + + ); + }, +); + +// Alias for platform-resolved imports (tvOS auto-resolves .tv.tsx files) +export const ItemContent = ItemContentTV; diff --git a/components/ItemContentSkeleton.tv.tsx b/components/ItemContentSkeleton.tv.tsx new file mode 100644 index 000000000..e81864344 --- /dev/null +++ b/components/ItemContentSkeleton.tv.tsx @@ -0,0 +1,163 @@ +import React from "react"; +import { Dimensions, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +const { width: SCREEN_WIDTH } = Dimensions.get("window"); + +export const ItemContentSkeletonTV: React.FC = () => { + const insets = useSafeAreaInsets(); + + return ( + + {/* Left side - Content placeholders */} + + {/* Logo placeholder */} + + + {/* Metadata badges row */} + + + + + + + {/* Genres placeholder */} + + + + + + + {/* Overview placeholder */} + + + + + + + {/* Play button placeholder */} + + + + {/* Right side - Poster placeholder */} + + + + + ); +}; diff --git a/components/ItemTechnicalDetails.tsx b/components/ItemTechnicalDetails.tsx index c0d5e54d9..5708318d7 100644 --- a/components/ItemTechnicalDetails.tsx +++ b/components/ItemTechnicalDetails.tsx @@ -77,7 +77,7 @@ export const ItemTechnicalDetails: React.FC = ({ source }) => { - {t("item_card.subtitles")} + {t("item_card.subtitles.label")} { if (!source || !videoStream) return null; + // Dolby Vision video check + const isDolbyVision = + videoStream.VideoRangeType === "DOVI" || + videoStream.DvVersionMajor != null || + videoStream.DvVersionMinor != null; + return ( { iconLeft={} text={`${videoStream.Width}x${videoStream.Height}`} /> + {isDolbyVision && ( + + } + text={"DV"} + /> + )} { - item: BaseItemDto; + item?: BaseItemDto | null; selectedOptions: SelectedOptions; setSelectedOptions: React.Dispatch< React.SetStateAction @@ -29,12 +28,6 @@ export const MediaSourceButton: React.FC = ({ }: Props) => { const { t } = useTranslation(); const [open, setOpen] = useState(false); - const { data: itemWithSources, isLoading } = useItemQuery( - item.Id, - false, - undefined, - [], - ); const effectiveColors = colors || { primary: "#7c3aed", @@ -42,7 +35,7 @@ export const MediaSourceButton: React.FC = ({ }; useEffect(() => { - const firstMediaSource = itemWithSources?.MediaSources?.[0]; + const firstMediaSource = item?.MediaSources?.[0]; if (!firstMediaSource) return; setSelectedOptions((prev) => { if (!prev) return prev; @@ -51,7 +44,7 @@ export const MediaSourceButton: React.FC = ({ mediaSource: firstMediaSource, }; }); - }, [itemWithSources, setSelectedOptions]); + }, [item, setSelectedOptions]); const getMediaSourceDisplayName = useCallback((source: MediaSourceInfo) => { const videoStream = source.MediaStreams?.find((x) => x.Type === "Video"); @@ -93,13 +86,10 @@ export const MediaSourceButton: React.FC = ({ }); // Media Source group (only if multiple sources) - if ( - itemWithSources?.MediaSources && - itemWithSources.MediaSources.length > 1 - ) { + if (item?.MediaSources && item.MediaSources.length > 1) { groups.push({ title: t("item_card.video"), - options: itemWithSources.MediaSources.map((source) => ({ + options: item.MediaSources.map((source) => ({ type: "radio" as const, label: getMediaSourceDisplayName(source), value: source, @@ -152,14 +142,14 @@ export const MediaSourceButton: React.FC = ({ })); groups.push({ - title: t("item_card.subtitles"), + title: t("item_card.subtitles.label"), options: [noneOption, ...subtitleOptions], }); } return groups; }, [ - itemWithSources, + item, selectedOptions, audioStreams, subtitleStreams, @@ -170,7 +160,7 @@ export const MediaSourceButton: React.FC = ({ const trigger = ( setOpen(true)} className='relative' > @@ -179,7 +169,7 @@ export const MediaSourceButton: React.FC = ({ className='absolute w-12 h-12 rounded-full' /> - {isLoading ? ( + {!item ? ( ) : ( diff --git a/components/MoreMoviesWithActor.tsx b/components/MoreMoviesWithActor.tsx index 35d80d939..c3e4d0cab 100644 --- a/components/MoreMoviesWithActor.tsx +++ b/components/MoreMoviesWithActor.tsx @@ -3,6 +3,7 @@ import { getItemsApi } from "@jellyfin/sdk/lib/utils/api"; import { useQuery } from "@tanstack/react-query"; import { useAtom } from "jotai"; import type React from "react"; +import { useCallback } from "react"; import { useTranslation } from "react-i18next"; import { View, type ViewProps } from "react-native"; import { HorizontalScroll } from "@/components/common/HorizontalScroll"; @@ -10,16 +11,18 @@ import { Text } from "@/components/common/Text"; import { TouchableItemRouter } from "@/components/common/TouchableItemRouter"; import { ItemCardText } from "@/components/ItemCardText"; import MoviePoster from "@/components/posters/MoviePoster"; +import { POSTER_CAROUSEL_HEIGHT } from "@/constants/Values"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; -import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData"; interface Props extends ViewProps { actorId: string; + actorName?: string | null; currentItem: BaseItemDto; } export const MoreMoviesWithActor: React.FC = ({ actorId, + actorName, currentItem, ...props }) => { @@ -27,19 +30,6 @@ export const MoreMoviesWithActor: React.FC = ({ const [user] = useAtom(userAtom); const { t } = useTranslation(); - const { data: actor } = useQuery({ - queryKey: ["actor", actorId], - queryFn: async () => { - if (!api || !user?.Id) return null; - return await getUserItemData({ - api, - userId: user.Id, - itemId: actorId, - }); - }, - enabled: !!api && !!user?.Id && !!actorId, - }); - const { data: items, isLoading } = useQuery({ queryKey: ["actor", "movies", actorId, currentItem.Id], queryFn: async () => { @@ -72,29 +62,34 @@ export const MoreMoviesWithActor: React.FC = ({ enabled: !!api && !!user?.Id && !!actorId, }); + const renderItem = useCallback( + (item: BaseItemDto, idx: number) => ( + + + + + + + ), + [], + ); + if (items?.length === 0) return null; return ( - {t("item_card.more_with", { name: actor?.Name })} + {t("item_card.more_with", { name: actorName ?? "" })} ( - - - - - - - )} + height={POSTER_CAROUSEL_HEIGHT} + renderItem={renderItem} /> ); diff --git a/components/PINEntryModal.tsx b/components/PINEntryModal.tsx new file mode 100644 index 000000000..f450f5eb8 --- /dev/null +++ b/components/PINEntryModal.tsx @@ -0,0 +1,231 @@ +import { + BottomSheetBackdrop, + type BottomSheetBackdropProps, + BottomSheetModal, + BottomSheetView, +} from "@gorhom/bottom-sheet"; +import type React from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + Alert, + Animated, + Keyboard, + Platform, + TouchableOpacity, + View, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { useHaptic } from "@/hooks/useHaptic"; +import { verifyAccountPIN } from "@/utils/secureCredentials"; +import { Button } from "./Button"; +import { Text } from "./common/Text"; +import { PinInput } from "./inputs/PinInput"; + +interface PINEntryModalProps { + visible: boolean; + onClose: () => void; + onSuccess: () => void; + onForgotPIN?: () => void; + serverUrl: string; + userId: string; + username: string; +} + +export const PINEntryModal: React.FC = ({ + visible, + onClose, + onSuccess, + onForgotPIN, + serverUrl, + userId, + username, +}) => { + const { t } = useTranslation(); + const insets = useSafeAreaInsets(); + const bottomSheetModalRef = useRef(null); + const [pinCode, setPinCode] = useState(""); + const [error, setError] = useState(null); + const [isVerifying, setIsVerifying] = useState(false); + const shakeAnimation = useRef(new Animated.Value(0)).current; + const errorHaptic = useHaptic("error"); + const successHaptic = useHaptic("success"); + + const isAndroid = Platform.OS === "android"; + const snapPoints = useMemo( + () => (isAndroid ? ["100%"] : ["50%"]), + [isAndroid], + ); + + useEffect(() => { + if (visible) { + bottomSheetModalRef.current?.present(); + setPinCode(""); + setError(null); + } else { + bottomSheetModalRef.current?.dismiss(); + } + }, [visible]); + + const handleSheetChanges = useCallback( + (index: number) => { + if (index === -1) { + setPinCode(""); + setError(null); + onClose(); + } + }, + [onClose], + ); + + const renderBackdrop = useCallback( + (props: BottomSheetBackdropProps) => ( + + ), + [], + ); + + const shake = () => { + Animated.sequence([ + Animated.timing(shakeAnimation, { + toValue: 10, + duration: 50, + useNativeDriver: true, + }), + Animated.timing(shakeAnimation, { + toValue: -10, + duration: 50, + useNativeDriver: true, + }), + Animated.timing(shakeAnimation, { + toValue: 10, + duration: 50, + useNativeDriver: true, + }), + Animated.timing(shakeAnimation, { + toValue: 0, + duration: 50, + useNativeDriver: true, + }), + ]).start(); + }; + + const handlePinChange = async (value: string) => { + setPinCode(value); + setError(null); + + // Auto-verify when 4 digits entered + if (value.length === 4) { + setIsVerifying(true); + try { + const isValid = await verifyAccountPIN(serverUrl, userId, value); + if (isValid) { + Keyboard.dismiss(); + successHaptic(); + onSuccess(); + setPinCode(""); + } else { + errorHaptic(); + setError(t("pin.invalid_pin")); + shake(); + setPinCode(""); + } + } catch { + errorHaptic(); + setError(t("pin.invalid_pin")); + shake(); + setPinCode(""); + } finally { + setIsVerifying(false); + } + } + }; + + const handleForgotPIN = () => { + Alert.alert(t("pin.forgot_pin"), t("pin.forgot_pin_desc"), [ + { text: t("common.cancel"), style: "cancel" }, + { + text: t("common.continue"), + style: "destructive", + onPress: () => { + onClose(); + onForgotPIN?.(); + }, + }, + ]); + }; + + return ( + + + + {/* Header */} + + + {t("pin.enter_pin")} + + + {t("pin.enter_pin_for", { username })} + + + + {/* PIN Input */} + + + {error && ( + {error} + )} + {isVerifying && ( + + {t("common.verifying") || "Verifying..."} + + )} + + + {/* Forgot PIN */} + + + {t("pin.forgot_pin")} + + + + {/* Cancel Button */} + + + + + ); +}; diff --git a/components/PasswordEntryModal.tsx b/components/PasswordEntryModal.tsx new file mode 100644 index 000000000..efd1cc49d --- /dev/null +++ b/components/PasswordEntryModal.tsx @@ -0,0 +1,185 @@ +import { + BottomSheetBackdrop, + type BottomSheetBackdropProps, + BottomSheetModal, + BottomSheetTextInput, + BottomSheetView, +} from "@gorhom/bottom-sheet"; +import type React from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { ActivityIndicator, Platform, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { useHaptic } from "@/hooks/useHaptic"; +import { Button } from "./Button"; +import { Text } from "./common/Text"; + +interface PasswordEntryModalProps { + visible: boolean; + onClose: () => void; + onSubmit: (password: string) => Promise; + username: string; +} + +export const PasswordEntryModal: React.FC = ({ + visible, + onClose, + onSubmit, + username, +}) => { + const { t } = useTranslation(); + const insets = useSafeAreaInsets(); + const bottomSheetModalRef = useRef(null); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const errorHaptic = useHaptic("error"); + + const isAndroid = Platform.OS === "android"; + const snapPoints = useMemo( + () => (isAndroid ? ["100%"] : ["50%"]), + [isAndroid], + ); + + useEffect(() => { + if (visible) { + bottomSheetModalRef.current?.present(); + setPassword(""); + setError(null); + } else { + bottomSheetModalRef.current?.dismiss(); + } + }, [visible]); + + const handleSheetChanges = useCallback( + (index: number) => { + if (index === -1) { + setPassword(""); + setError(null); + onClose(); + } + }, + [onClose], + ); + + const renderBackdrop = useCallback( + (props: BottomSheetBackdropProps) => ( + + ), + [], + ); + + const handleSubmit = async () => { + if (!password) { + setError(t("password.enter_password")); + return; + } + + setIsLoading(true); + setError(null); + + try { + await onSubmit(password); + setPassword(""); + } catch { + errorHaptic(); + setError(t("password.invalid_password")); + } finally { + setIsLoading(false); + } + }; + + return ( + + + + {/* Header */} + + + {t("password.enter_password")} + + + {t("password.enter_password_for", { username })} + + + + {/* Password Input */} + + + {t("login.password_placeholder")} + + { + setPassword(text); + setError(null); + }} + placeholder={t("login.password_placeholder")} + placeholderTextColor='#6B7280' + secureTextEntry + autoFocus + autoCapitalize='none' + autoCorrect={false} + style={{ + backgroundColor: "#1F2937", + borderRadius: 8, + padding: 12, + color: "white", + fontSize: 16, + }} + onSubmitEditing={handleSubmit} + returnKeyType='done' + /> + {error && {error}} + + + {/* Buttons */} + + + + + + + + ); +}; diff --git a/components/PlatformDropdown.tsx b/components/PlatformDropdown.tsx index 43ecb6c92..5487393dd 100644 --- a/components/PlatformDropdown.tsx +++ b/components/PlatformDropdown.tsx @@ -1,4 +1,3 @@ -import { Button, ContextMenu, Host, Picker } from "@expo/ui/swift-ui"; import { Ionicons } from "@expo/vector-icons"; import { BottomSheetScrollView } from "@gorhom/bottom-sheet"; import React, { useEffect } from "react"; @@ -7,6 +6,17 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Text } from "@/components/common/Text"; import { useGlobalModal } from "@/providers/GlobalModalProvider"; +// @expo/ui's SwiftUI native module (ExpoUI) does not exist in tvOS builds. +// A static top-level import evaluates requireNativeModule('ExpoUI') at module +// load and crashes the entire route tree on tvOS (expo-router requires every +// route file). Load it lazily and only off-TV; TV never renders these. +const { Button, Host, Menu } = Platform.isTV + ? ({} as typeof import("@expo/ui/swift-ui")) + : require("@expo/ui/swift-ui"); +const { disabled } = Platform.isTV + ? ({} as typeof import("@expo/ui/swift-ui/modifiers")) + : require("@expo/ui/swift-ui/modifiers"); + // Option types export type RadioOption = { type: "radio"; @@ -25,7 +35,14 @@ export type ToggleOption = { disabled?: boolean; }; -export type Option = RadioOption | ToggleOption; +export type ActionOption = { + type: "action"; + label: string; + onPress: () => void; + disabled?: boolean; +}; + +export type Option = RadioOption | ToggleOption | ActionOption; // Option group structure export type OptionGroup = { @@ -54,9 +71,7 @@ const ToggleSwitch: React.FC<{ value: boolean }> = ({ value }) => ( className={`w-12 h-7 rounded-full ${value ? "bg-purple-600" : "bg-neutral-600"} flex-row items-center`} > ); @@ -66,21 +81,22 @@ const OptionItem: React.FC<{ option: Option; isLast?: boolean }> = ({ isLast, }) => { const isToggle = option.type === "toggle"; - const handlePress = isToggle ? option.onToggle : option.onPress; + const isAction = option.type === "action"; + const handlePress = isToggle + ? option.onToggle + : (option as RadioOption | ActionOption).onPress; return ( <> {option.label} {isToggle ? ( - ) : option.selected ? ( + ) : isAction ? null : (option as RadioOption).selected ? ( ) : ( @@ -154,6 +170,15 @@ const BottomSheetContent: React.FC<{ }, }; } + if (option.type === "action") { + return { + ...option, + onPress: () => { + option.onPress(); + onClose?.(); + }, + }; + } return option; }), })); @@ -215,16 +240,16 @@ const PlatformDropdownComponent = ({ } }, [isVisible, controlledOpen, controlledOnOpenChange]); - if (Platform.OS === "ios") { + if (Platform.OS === "ios" && !Platform.isTV) { + // @expo/ui's can't size to content, so an in-flow invisible copy of + // the trigger sizes the wrapper while the Host overlays the real Menu. return ( - - - - - {trigger || } - - - + + + {trigger} + + + {groups.flatMap((group, groupIndex) => { // Check if this group has radio options const radioOptions = group.options.filter( @@ -233,30 +258,46 @@ const PlatformDropdownComponent = ({ const toggleOptions = group.options.filter( (opt) => opt.type === "toggle", ) as ToggleOption[]; + const actionOptions = group.options.filter( + (opt) => opt.type === "action", + ) as ActionOption[]; const items = []; - // Add Picker for radio options ONLY if there's a group title + // Group radio options under a submenu ONLY if there's a title // Otherwise render as individual buttons if (radioOptions.length > 0) { if (group.title) { - // Use Picker for grouped options + // Use a nested Menu as a submenu for grouped options. This + // reads as "Title: Selected" and expands to the choices on + // tap, keeping the nested look while staying a dropdown. + // (Menu opens on a single tap and nests cleanly; ContextMenu + // would require a long-press and read as a context menu.) + const selectedOption = radioOptions.find( + (opt) => opt.selected, + ); + const displayTitle = selectedOption + ? `${group.title}: ${selectedOption.label}` + : group.title; items.push( - opt.label)} - variant='menu' - selectedIndex={radioOptions.findIndex( - (opt) => opt.selected, - )} - onOptionSelected={(event: any) => { - const index = event.nativeEvent.index; - const selectedOption = radioOptions[index]; - selectedOption?.onPress(); - onOptionSelect?.(selectedOption?.value); - }} - />, + + {radioOptions.map((option, optionIndex) => ( + , ); } else { // Render radio options as direct buttons @@ -264,17 +305,18 @@ const PlatformDropdownComponent = ({ items.push( , + />, ); }); } @@ -285,25 +327,38 @@ const PlatformDropdownComponent = ({ items.push( , + />, + ); + }); + + // Add Buttons for action options (no icon) + actionOptions.forEach((option, optionIndex) => { + items.push( + + + ); } diff --git a/components/PlayButton.tsx b/components/PlayButton.tsx index 6ec287c2f..462705d63 100644 --- a/components/PlayButton.tsx +++ b/components/PlayButton.tsx @@ -1,13 +1,14 @@ import { useActionSheet } from "@expo/react-native-action-sheet"; -import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons"; +import { Feather, Ionicons } from "@expo/vector-icons"; +import { BottomSheetView } from "@gorhom/bottom-sheet"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; -import { useRouter } from "expo-router"; import { useAtom, useAtomValue } from "jotai"; import { useCallback, useEffect } from "react"; import { useTranslation } from "react-i18next"; -import { Alert, TouchableOpacity, View } from "react-native"; +import { Alert, Platform, TouchableOpacity, View } from "react-native"; import CastContext, { CastButton, + MediaStreamType, PlayServicesState, useMediaStatus, useRemoteMediaClient, @@ -22,23 +23,28 @@ import Animated, { useSharedValue, withTiming, } from "react-native-reanimated"; +import useRouter from "@/hooks/useAppRouter"; import { useHaptic } from "@/hooks/useHaptic"; import type { ThemeColors } from "@/hooks/useImageColorsReturn"; +import { getDownloadedItemById } from "@/providers/Downloads/database"; +import { useGlobalModal } from "@/providers/GlobalModalProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { useOfflineMode } from "@/providers/OfflineModeProvider"; import { itemThemeColorAtom } from "@/utils/atoms/primaryColor"; import { useSettings } from "@/utils/atoms/settings"; import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl"; import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; -import { chromecast } from "@/utils/profiles/chromecast"; -import { chromecasth265 } from "@/utils/profiles/chromecasth265"; import { runtimeTicksToMinutes } from "@/utils/time"; +import { chromecast } from "../utils/profiles/chromecast"; +import { chromecasth265 } from "../utils/profiles/chromecasth265"; +import { Button } from "./Button"; +import { Text } from "./common/Text"; import type { SelectedOptions } from "./ItemContent"; interface Props extends React.ComponentProps { item: BaseItemDto; selectedOptions: SelectedOptions; - isOffline?: boolean; colors?: ThemeColors; } @@ -48,13 +54,14 @@ const MIN_PLAYBACK_WIDTH = 15; export const PlayButton: React.FC = ({ item, selectedOptions, - isOffline, colors, }: Props) => { + const isOffline = useOfflineMode(); const { showActionSheetWithOptions } = useActionSheet(); const client = useRemoteMediaClient(); const mediaStatus = useMediaStatus(); const { t } = useTranslation(); + const { showModal, hideModal } = useGlobalModal(); const [globalColorAtom] = useAtom(itemThemeColorAtom); const api = useAtomValue(apiAtom); @@ -84,12 +91,9 @@ export const PlayButton: React.FC = ({ [router, isOffline], ); - const onPress = useCallback(async () => { - console.log("onPress"); + const handleNormalPlayFlow = useCallback(async () => { if (!item) return; - lightHapticFeedback(); - const queryParams = new URLSearchParams({ itemId: item.Id!, audioIndex: selectedOptions.audioIndex?.toString() ?? "", @@ -182,11 +186,23 @@ export const PlayButton: React.FC = ({ return; } + // Calculate start time in seconds from playback position + const startTimeSeconds = + (item?.UserData?.PlaybackPositionTicks ?? 0) / 10000000; + + // Calculate stream duration in seconds from runtime + const streamDurationSeconds = item.RunTimeTicks + ? item.RunTimeTicks / 10000000 + : undefined; + client .loadMedia({ mediaInfo: { + contentId: item.Id, contentUrl: data?.url, contentType: "video/mp4", + streamType: MediaStreamType.BUFFERED, + streamDuration: streamDurationSeconds, metadata: item.Type === "Episode" ? { @@ -238,7 +254,7 @@ export const PlayButton: React.FC = ({ ], }, }, - startTime: 0, + startTime: startTimeSeconds, }) .then(() => { // state is already set when reopening current media, so skip it here. @@ -271,10 +287,134 @@ export const PlayButton: React.FC = ({ showActionSheetWithOptions, mediaStatus, selectedOptions, + goToPlayer, + isOffline, + t, + ]); + + const onPress = useCallback(async () => { + if (!item) return; + + lightHapticFeedback(); + + // Check if item is downloaded + const downloadedItem = item.Id ? getDownloadedItemById(item.Id) : undefined; + + // If already in offline mode, play downloaded file directly + if (isOffline && downloadedItem) { + const queryParams = new URLSearchParams({ + itemId: item.Id!, + offline: "true", + playbackPosition: + item.UserData?.PlaybackPositionTicks?.toString() ?? "0", + }); + goToPlayer(queryParams.toString()); + return; + } + + // If online but file is downloaded, ask user which version to play + if (downloadedItem) { + if (Platform.OS === "android") { + // Show bottom sheet for Android + showModal( + + + + + {t("player.downloaded_file_title")} + + + {t("player.downloaded_file_message")} + + + + + + + + , + { + snapPoints: ["35%"], + enablePanDownToClose: true, + }, + ); + } else { + // Show alert for iOS + Alert.alert( + t("player.downloaded_file_title"), + t("player.downloaded_file_message"), + [ + { + text: t("player.downloaded_file_yes"), + onPress: () => { + const queryParams = new URLSearchParams({ + itemId: item.Id!, + offline: "true", + playbackPosition: + item.UserData?.PlaybackPositionTicks?.toString() ?? "0", + }); + goToPlayer(queryParams.toString()); + }, + isPreferred: true, + }, + { + text: t("player.downloaded_file_no"), + onPress: () => { + handleNormalPlayFlow(); + }, + }, + { + text: t("player.downloaded_file_cancel"), + style: "cancel", + }, + ], + ); + } + return; + } + + // If not downloaded, proceed with normal flow + handleNormalPlayFlow(); + }, [ + item, + lightHapticFeedback, + handleNormalPlayFlow, + goToPlayer, + t, + showModal, + hideModal, + effectiveColors, ]); const derivedTargetWidth = useDerivedValue(() => { - if (!item || !item.RunTimeTicks) return 0; + if (!item?.RunTimeTicks) return 0; const userData = item.UserData; if (userData?.PlaybackPositionTicks) { return userData.PlaybackPositionTicks > 0 @@ -359,52 +499,6 @@ export const PlayButton: React.FC = ({ ), })); - // if (Platform.OS === "ios") - // return ( - // - // - // - // ); - return ( = ({ )} - {!client && settings?.openInVLC && ( - - - - )} diff --git a/components/PlayButton.tv.tsx b/components/PlayButton.tv.tsx index 8e3b9811a..c8b6b76e3 100644 --- a/components/PlayButton.tv.tsx +++ b/components/PlayButton.tv.tsx @@ -1,6 +1,5 @@ -import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons"; +import { Ionicons } from "@expo/vector-icons"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; -import { useRouter } from "expo-router"; import { useAtom } from "jotai"; import { useCallback, useEffect } from "react"; import { TouchableOpacity, View } from "react-native"; @@ -14,10 +13,10 @@ import Animated, { useSharedValue, withTiming, } from "react-native-reanimated"; +import useRouter from "@/hooks/useAppRouter"; import { useHaptic } from "@/hooks/useHaptic"; import type { ThemeColors } from "@/hooks/useImageColorsReturn"; import { itemThemeColorAtom } from "@/utils/atoms/primaryColor"; -import { useSettings } from "@/utils/atoms/settings"; import { runtimeTicksToMinutes } from "@/utils/time"; import type { Button } from "./Button"; import type { SelectedOptions } from "./ItemContent"; @@ -50,7 +49,6 @@ export const PlayButton: React.FC = ({ const startColor = useSharedValue(effectiveColors); const widthProgress = useSharedValue(0); const colorChangeProgress = useSharedValue(0); - const { settings } = useSettings(); const lightHapticFeedback = useHaptic("light"); const goToPlayer = useCallback( @@ -61,7 +59,6 @@ export const PlayButton: React.FC = ({ ); const onPress = () => { - console.log("onpress"); if (!item) return; lightHapticFeedback(); @@ -72,6 +69,7 @@ export const PlayButton: React.FC = ({ subtitleIndex: selectedOptions.subtitleIndex?.toString() ?? "", mediaSourceId: selectedOptions.mediaSource?.Id ?? "", bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "", + playbackPosition: item.UserData?.PlaybackPositionTicks?.toString() ?? "0", }); const queryString = queryParams.toString(); @@ -80,7 +78,7 @@ export const PlayButton: React.FC = ({ }; const derivedTargetWidth = useDerivedValue(() => { - if (!item || !item.RunTimeTicks) return 0; + if (!item?.RunTimeTicks) return 0; const userData = item.UserData; if (userData?.PlaybackPositionTicks) { return userData.PlaybackPositionTicks > 0 @@ -207,15 +205,6 @@ export const PlayButton: React.FC = ({ - {settings?.openInVLC && ( - - - - )} diff --git a/components/PlaybackSpeedSelector.tsx b/components/PlaybackSpeedSelector.tsx new file mode 100644 index 000000000..70b69ba4a --- /dev/null +++ b/components/PlaybackSpeedSelector.tsx @@ -0,0 +1,180 @@ +import { Ionicons } from "@expo/vector-icons"; +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Platform, View } from "react-native"; +import { useSettings } from "@/utils/atoms/settings"; +import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown"; +import { PlaybackSpeedScope } from "./video-player/controls/utils/playback-speed-settings"; + +export const PLAYBACK_SPEEDS = [ + { label: "0.25x", value: 0.25 }, + { label: "0.5x", value: 0.5 }, + { label: "0.75x", value: 0.75 }, + { label: "1x", value: 1.0 }, + { label: "1.25x", value: 1.25 }, + { label: "1.5x", value: 1.5 }, + { label: "1.75x", value: 1.75 }, + { label: "2x", value: 2.0 }, + { label: "2.25x", value: 2.25 }, + { label: "2.5x", value: 2.5 }, + { label: "2.75x", value: 2.75 }, + { label: "3x", value: 3.0 }, +]; + +interface Props extends React.ComponentProps { + onChange: (value: number, scope: PlaybackSpeedScope) => void; + selected: number; + item?: BaseItemDto; + open?: boolean; + onOpenChange?: (open: boolean) => void; +} + +export const PlaybackSpeedSelector: React.FC = ({ + onChange, + selected, + item, + open: controlledOpen, + onOpenChange, + ...props +}) => { + const isTv = Platform.isTV; + const { t } = useTranslation(); + const { settings } = useSettings(); + const [internalOpen, setInternalOpen] = useState(false); + + // Determine initial scope based on existing settings + const initialScope = useMemo(() => { + if (!item || !settings) return PlaybackSpeedScope.All; + + const itemId = item?.Id; + if (!itemId) return PlaybackSpeedScope.All; + + // Check for media-specific speed preference + if (settings?.playbackSpeedPerMedia?.[itemId] !== undefined) { + return PlaybackSpeedScope.Media; + } + + // Check for show-specific speed preference (only for episodes) + const seriesId = item?.SeriesId; + const perShowSettings = settings?.playbackSpeedPerShow; + if ( + seriesId && + perShowSettings && + perShowSettings[seriesId] !== undefined + ) { + return PlaybackSpeedScope.Show; + } + + // If no custom setting exists, check default playback speed + // Show "All" if speed is not 1x, otherwise show "Media" + return (settings?.defaultPlaybackSpeed ?? 1.0) !== 1.0 + ? PlaybackSpeedScope.All + : PlaybackSpeedScope.Media; + }, [item?.Id, item?.SeriesId, settings]); + + const [selectedScope, setSelectedScope] = + useState(initialScope); + + // Update selectedScope when initialScope changes + useEffect(() => { + setSelectedScope(initialScope); + }, [initialScope]); + + const open = controlledOpen !== undefined ? controlledOpen : internalOpen; + const setOpen = onOpenChange || setInternalOpen; + + const scopeLabels = useMemo>(() => { + const labels: Record = { + [PlaybackSpeedScope.Media]: t("playback_speed.scope.media"), + }; + + if (item?.SeriesId) { + labels[PlaybackSpeedScope.Show] = t("playback_speed.scope.show"); + } + + labels[PlaybackSpeedScope.All] = t("playback_speed.scope.all"); + + return labels as Record; + }, [item?.SeriesId, t]); + + const availableScopes = useMemo(() => { + const scopes = [PlaybackSpeedScope.Media]; + if (item?.SeriesId) { + scopes.push(PlaybackSpeedScope.Show); + } + scopes.push(PlaybackSpeedScope.All); + return scopes; + }, [item?.SeriesId]); + + const handleSpeedSelect = useCallback( + (speed: number) => { + onChange(speed, selectedScope); + setOpen(false); + }, + [onChange, selectedScope, setOpen], + ); + + const optionGroups = useMemo(() => { + const groups: OptionGroup[] = []; + + // Scope selection group + groups.push({ + title: t("playback_speed.apply_to"), + options: availableScopes.map((scope) => ({ + type: "radio" as const, + label: scopeLabels[scope], + value: scope, + selected: selectedScope === scope, + onPress: () => setSelectedScope(scope), + })), + }); + + // Speed selection group + groups.push({ + title: t("playback_speed.speed"), + options: PLAYBACK_SPEEDS.map((speed) => ({ + type: "radio" as const, + label: speed.label, + value: speed.value, + selected: selected === speed.value, + onPress: () => handleSpeedSelect(speed.value), + })), + }); + + return groups; + }, [ + t, + availableScopes, + scopeLabels, + selectedScope, + selected, + handleSpeedSelect, + ]); + + const trigger = useMemo( + () => ( + + + + ), + [], + ); + + if (isTv) return null; + + return ( + + + + ); +}; diff --git a/components/PlayedStatus.tsx b/components/PlayedStatus.tsx index 0023b0141..dd2198cde 100644 --- a/components/PlayedStatus.tsx +++ b/components/PlayedStatus.tsx @@ -1,12 +1,12 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import type React from "react"; +import { useCallback } from "react"; import { View, type ViewProps } from "react-native"; import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed"; import { RoundButton } from "./RoundButton"; interface Props extends ViewProps { items: BaseItemDto[]; - isOffline?: boolean; size?: "default" | "large"; } @@ -14,14 +14,16 @@ export const PlayedStatus: React.FC = ({ items, ...props }) => { const allPlayed = items.every((item) => item.UserData?.Played); const toggle = useMarkAsPlayed(items); + const handlePress = useCallback(() => { + void toggle(!allPlayed); + }, [allPlayed, toggle]); + return ( { - await toggle(!allPlayed); - }} + onPress={handlePress} size={props.size} /> diff --git a/components/PreviousServersList.tsx b/components/PreviousServersList.tsx index ffa310d31..251a6ca3d 100644 --- a/components/PreviousServersList.tsx +++ b/components/PreviousServersList.tsx @@ -1,42 +1,282 @@ +import { Ionicons } from "@expo/vector-icons"; import type React from "react"; -import { useMemo } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { View } from "react-native"; +import { ActivityIndicator, Alert, TouchableOpacity, View } from "react-native"; +import { Swipeable } from "react-native-gesture-handler"; import { useMMKVString } from "react-native-mmkv"; +import { Colors } from "@/constants/Colors"; +import { + deleteAccountCredential, + getPreviousServers, + removeServerFromList, + type SavedServer, + type SavedServerAccount, +} from "@/utils/secureCredentials"; +import { AccountsSheet } from "./AccountsSheet"; +import { Text } from "./common/Text"; import { ListGroup } from "./list/ListGroup"; import { ListItem } from "./list/ListItem"; - -interface Server { - address: string; -} +import { PasswordEntryModal } from "./PasswordEntryModal"; +import { PINEntryModal } from "./PINEntryModal"; interface PreviousServersListProps { - onServerSelect: (server: Server) => void; + onServerSelect: (server: SavedServer) => void; + onQuickLogin?: (serverUrl: string, userId: string) => Promise; + onPasswordLogin?: ( + serverUrl: string, + username: string, + password: string, + ) => Promise; + onAddAccount?: (server: SavedServer) => void; } export const PreviousServersList: React.FC = ({ onServerSelect, + onQuickLogin, + onPasswordLogin, + onAddAccount, }) => { const [_previousServers, setPreviousServers] = useMMKVString("previousServers"); + const [loadingServer, setLoadingServer] = useState(null); + + // Modal states + const [accountsSheetOpen, setAccountsSheetOpen] = useState(false); + const [selectedServer, setSelectedServer] = useState( + null, + ); + const [pinModalVisible, setPinModalVisible] = useState(false); + const [passwordModalVisible, setPasswordModalVisible] = useState(false); + const [selectedAccount, setSelectedAccount] = + useState(null); const previousServers = useMemo(() => { - return JSON.parse(_previousServers || "[]") as Server[]; + return JSON.parse(_previousServers || "[]") as SavedServer[]; }, [_previousServers]); const { t } = useTranslation(); + const refreshServers = () => { + const servers = getPreviousServers(); + setPreviousServers(JSON.stringify(servers)); + }; + + const handleAccountLogin = async ( + server: SavedServer, + account: SavedServerAccount, + ) => { + switch (account.securityType) { + case "none": + // Quick login without protection + if (onQuickLogin) { + setLoadingServer(server.address); + try { + await onQuickLogin(server.address, account.userId); + } catch (error) { + const errorMessage = + error instanceof Error + ? error.message + : t("server.session_expired"); + const isSessionExpired = errorMessage.includes( + t("server.session_expired"), + ); + Alert.alert( + isSessionExpired + ? t("server.session_expired") + : t("login.connection_failed"), + isSessionExpired ? t("server.please_login_again") : errorMessage, + [{ text: t("common.ok"), onPress: () => onServerSelect(server) }], + ); + } finally { + setLoadingServer(null); + } + } + break; + + case "pin": + // Show PIN entry modal + setSelectedServer(server); + setSelectedAccount(account); + setPinModalVisible(true); + break; + + case "password": + // Show password entry modal + setSelectedServer(server); + setSelectedAccount(account); + setPasswordModalVisible(true); + break; + } + }; + + const handleServerPress = async (server: SavedServer) => { + if (loadingServer) return; // Prevent double-tap + + const accountCount = server.accounts?.length || 0; + + if (accountCount === 0) { + // No saved accounts, go to manual login + onServerSelect(server); + } else { + // Has accounts, show account sheet (allows adding new account too) + setSelectedServer(server); + setAccountsSheetOpen(true); + } + }; + + const handlePinSuccess = async () => { + setPinModalVisible(false); + if (selectedServer && selectedAccount && onQuickLogin) { + setLoadingServer(selectedServer.address); + try { + await onQuickLogin(selectedServer.address, selectedAccount.userId); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : t("server.session_expired"); + const isSessionExpired = errorMessage.includes( + t("server.session_expired"), + ); + Alert.alert( + isSessionExpired + ? t("server.session_expired") + : t("login.connection_failed"), + isSessionExpired ? t("server.please_login_again") : errorMessage, + [ + { + text: t("common.ok"), + onPress: () => onServerSelect(selectedServer), + }, + ], + ); + } finally { + setLoadingServer(null); + setSelectedAccount(null); + setSelectedServer(null); + } + } + }; + + const handlePasswordSubmit = async (password: string) => { + if (selectedServer && selectedAccount && onPasswordLogin) { + await onPasswordLogin( + selectedServer.address, + selectedAccount.username, + password, + ); + setPasswordModalVisible(false); + setSelectedAccount(null); + setSelectedServer(null); + } + }; + + const handleForgotPIN = async () => { + if (selectedServer && selectedAccount) { + await deleteAccountCredential( + selectedServer.address, + selectedAccount.userId, + ); + refreshServers(); + // Go to manual login + onServerSelect(selectedServer); + setSelectedAccount(null); + setSelectedServer(null); + } + }; + + const handleRemoveFirstCredential = async (serverUrl: string) => { + const server = previousServers.find((s) => s.address === serverUrl); + if (!server || server.accounts.length === 0) return; + + Alert.alert( + t("server.remove_saved_login"), + t("server.remove_saved_login_description"), + [ + { text: t("common.cancel"), style: "cancel" }, + { + text: t("common.remove"), + style: "destructive", + onPress: async () => { + // Remove first account + await deleteAccountCredential(serverUrl, server.accounts[0].userId); + refreshServers(); + }, + }, + ], + ); + }; + + const handleRemoveServer = useCallback( + async (serverUrl: string) => { + await removeServerFromList(serverUrl); + refreshServers(); + }, + [setPreviousServers], + ); + + const renderRightActions = useCallback( + (serverUrl: string, swipeableRef: React.RefObject) => ( + { + swipeableRef.current?.close(); + handleRemoveServer(serverUrl); + }} + className='bg-red-600 justify-center items-center px-5' + > + + + ), + [handleRemoveServer], + ); + + const getServerSubtitle = (server: SavedServer): string | undefined => { + const accountCount = server.accounts?.length || 0; + + if (accountCount > 1) { + return t("server.accounts_count", { count: accountCount }); + } + if (accountCount === 1) { + return `${server.accounts[0].username} • ${t("server.saved")}`; + } + return server.name ? server.address : undefined; + }; + + const getSecurityIcon = ( + server: SavedServer, + ): keyof typeof Ionicons.glyphMap | null => { + const accountCount = server.accounts?.length || 0; + if (accountCount === 0) return null; + + if (accountCount > 1) { + return "people"; + } + + const account = server.accounts[0]; + switch (account.securityType) { + case "pin": + return "keypad"; + case "password": + return "lock-closed"; + default: + return "key"; + } + }; + if (!previousServers.length) return null; return ( {previousServers.map((s) => ( - onServerSelect(s)} - title={s.address} - showArrow + server={s} + loadingServer={loadingServer} + onPress={() => handleServerPress(s)} + onRemoveCredential={() => handleRemoveFirstCredential(s.address)} + renderRightActions={renderRightActions} + subtitle={getServerSubtitle(s)} + securityIcon={getSecurityIcon(s)} /> ))} = ({ textColor='red' /> + + {t("server.swipe_to_remove")} + + + {/* Account Selection Sheet */} + { + if (selectedServer) { + handleAccountLogin(selectedServer, account); + } + }} + onAddAccount={() => { + if (selectedServer && onAddAccount) { + onAddAccount(selectedServer); + } + }} + onAccountDeleted={refreshServers} + /> + + {/* PIN Entry Modal */} + { + setPinModalVisible(false); + setSelectedAccount(null); + setSelectedServer(null); + }} + onSuccess={handlePinSuccess} + onForgotPIN={handleForgotPIN} + serverUrl={selectedServer?.address || ""} + userId={selectedAccount?.userId || ""} + username={selectedAccount?.username || ""} + /> + + {/* Password Entry Modal */} + { + setPasswordModalVisible(false); + setSelectedAccount(null); + setSelectedServer(null); + }} + onSubmit={handlePasswordSubmit} + username={selectedAccount?.username || ""} + /> ); }; + +interface ServerItemProps { + server: SavedServer; + loadingServer: string | null; + onPress: () => void; + onRemoveCredential: () => void; + renderRightActions: ( + serverUrl: string, + swipeableRef: React.RefObject, + ) => React.ReactNode; + subtitle?: string; + securityIcon: keyof typeof Ionicons.glyphMap | null; +} + +const ServerItem: React.FC = ({ + server, + loadingServer, + onPress, + onRemoveCredential, + renderRightActions, + subtitle, + securityIcon, +}) => { + const swipeableRef = useRef(null); + const hasAccounts = server.accounts?.length > 0; + + return ( + + renderRightActions(server.address, swipeableRef) + } + overshootRight={false} + > + + {loadingServer === server.address ? ( + + ) : hasAccounts && securityIcon ? ( + { + e.stopPropagation(); + onRemoveCredential(); + }} + className='p-1' + hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} + > + + + ) : null} + + + ); +}; diff --git a/components/Ratings.tsx b/components/Ratings.tsx index 5741233fb..2e06403fb 100644 --- a/components/Ratings.tsx +++ b/components/Ratings.tsx @@ -40,8 +40,8 @@ export const Ratings: React.FC = ({ item, ...props }) => { > = ({ if (Platform.OS === "ios") { return ( - > = ({ /> ) : null} {children ? children : null} - + ); } if (fillColor) return ( - > = ({ /> ) : null} {children ? children : null} - + ); if (background === false) return ( - > = ({ /> ) : null} {children ? children : null} - + ); if (Platform.OS === "android") return ( - > = ({ ) : null} {children ? children : null} - + ); return ( - + > = ({ ) : null} {children ? children : null} - + ); }; diff --git a/components/SaveAccountModal.tsx b/components/SaveAccountModal.tsx new file mode 100644 index 000000000..81508adef --- /dev/null +++ b/components/SaveAccountModal.tsx @@ -0,0 +1,258 @@ +import { Ionicons } from "@expo/vector-icons"; +import { + BottomSheetBackdrop, + type BottomSheetBackdropProps, + BottomSheetModal, + BottomSheetView, +} from "@gorhom/bottom-sheet"; +import type React from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Platform, TouchableOpacity, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import type { AccountSecurityType } from "@/utils/secureCredentials"; +import { Button } from "./Button"; +import { Text } from "./common/Text"; +import { PinInput } from "./inputs/PinInput"; + +interface SaveAccountModalProps { + visible: boolean; + onClose: () => void; + onSave: (securityType: AccountSecurityType, pinCode?: string) => void; + username: string; +} + +interface SecurityOption { + type: AccountSecurityType; + titleKey: string; + descriptionKey: string; + icon: keyof typeof Ionicons.glyphMap; +} + +const SECURITY_OPTIONS: SecurityOption[] = [ + { + type: "none", + titleKey: "save_account.no_protection", + descriptionKey: "save_account.no_protection_desc", + icon: "flash-outline", + }, + { + type: "pin", + titleKey: "save_account.pin_code", + descriptionKey: "save_account.pin_code_desc", + icon: "keypad-outline", + }, + { + type: "password", + titleKey: "save_account.password", + descriptionKey: "save_account.password_desc", + icon: "lock-closed-outline", + }, +]; + +export const SaveAccountModal: React.FC = ({ + visible, + onClose, + onSave, + username, +}) => { + const { t } = useTranslation(); + const insets = useSafeAreaInsets(); + const bottomSheetModalRef = useRef(null); + const [selectedType, setSelectedType] = useState("none"); + const [pinCode, setPinCode] = useState(""); + const [pinError, setPinError] = useState(null); + + const isAndroid = Platform.OS === "android"; + const snapPoints = useMemo( + () => (isAndroid ? ["100%"] : ["70%"]), + [isAndroid], + ); + + const isPresentedRef = useRef(false); + + useEffect(() => { + if (visible) { + bottomSheetModalRef.current?.present(); + } else if (isPresentedRef.current) { + bottomSheetModalRef.current?.dismiss(); + isPresentedRef.current = false; + } + }, [visible]); + + const handleSheetChanges = useCallback( + (index: number) => { + if (index >= 0) { + isPresentedRef.current = true; + } else if (index === -1 && isPresentedRef.current) { + isPresentedRef.current = false; + resetState(); + onClose(); + } + }, + [onClose], + ); + + const resetState = () => { + setSelectedType("none"); + setPinCode(""); + setPinError(null); + }; + + const renderBackdrop = useCallback( + (props: BottomSheetBackdropProps) => ( + + ), + [], + ); + + const handleOptionSelect = (type: AccountSecurityType) => { + setSelectedType(type); + setPinCode(""); + setPinError(null); + }; + + const handleSave = () => { + if (selectedType === "pin") { + if (pinCode.length !== 4) { + setPinError(t("pin.enter_4_digits") || "Enter 4 digits"); + return; + } + onSave("pin", pinCode); + } else { + onSave(selectedType); + } + resetState(); + }; + + const handleCancel = () => { + resetState(); + onClose(); + }; + + const canSave = () => { + if (selectedType === "pin") { + return pinCode.length === 4; + } + return true; + }; + + return ( + + + + {/* Header */} + + + {t("save_account.title")} + + {username} + + + {/* PIN Entry Step */} + {selectedType === "pin" ? ( + + + + {t("pin.setup_pin")} + + + {pinError && ( + + {pinError} + + )} + + + ) : ( + /* Security Options */ + + + {t("save_account.security_option")} + + + {SECURITY_OPTIONS.map((option, index) => ( + handleOptionSelect(option.type)} + className={`flex-row items-center p-4 ${ + index < SECURITY_OPTIONS.length - 1 + ? "border-b border-neutral-700" + : "" + }`} + > + + + + + + {t(option.titleKey)} + + + {t(option.descriptionKey)} + + + + {selectedType === option.type && ( + + )} + + + ))} + + + )} + + {/* Buttons */} + + + + + + + + ); +}; diff --git a/components/SimilarItems.tsx b/components/SimilarItems.tsx index b73e55183..ce09cbcf9 100644 --- a/components/SimilarItems.tsx +++ b/components/SimilarItems.tsx @@ -6,6 +6,7 @@ import { useMemo } from "react"; import { useTranslation } from "react-i18next"; import { View, type ViewProps } from "react-native"; import MoviePoster from "@/components/posters/MoviePoster"; +import { POSTER_CAROUSEL_HEIGHT } from "@/constants/Values"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { HorizontalScroll } from "./common/HorizontalScroll"; import { Text } from "./common/Text"; @@ -53,7 +54,7 @@ export const SimilarItems: React.FC = ({ ( = ({ const trigger = ( - {t("item_card.subtitles")} + {t("item_card.subtitles.label")} = ({ { - source?: MediaSourceInfo; - onChange: (value: number) => void; - selected?: number | undefined; - streamType?: string; - title: string; -} - -export const TrackSheet: React.FC = ({ - source, - onChange, - selected, - streamType, - title, - ...props -}) => { - const isTv = Platform.isTV; - const { t } = useTranslation(); - - const streams = useMemo( - () => source?.MediaStreams?.filter((x) => x.Type === streamType), - [source], - ); - - const selectedSteam = useMemo( - () => streams?.find((x) => x.Index === selected), - [streams, selected], - ); - - const noneOption = useMemo( - () => ({ Index: -1, DisplayTitle: t("common.none") }), - [t], - ); - - // Creates a modified data array that includes a "None" option for subtitles - // We might want to possibly do this for other places, like audio? - const addNoneToSubtitles = useMemo(() => { - if (streamType === "Subtitle") { - const result = streams ? [noneOption, ...streams] : [noneOption]; - return result; - } - return streams; - }, [streams, streamType, noneOption]); - const [open, setOpen] = useState(false); - - if (isTv || (streams && streams.length === 0)) return null; - - return ( - - - {title} - setOpen(true)} - > - - {selected === -1 && streamType === "Subtitle" - ? t("common.none") - : selectedSteam?.DisplayTitle || t("common.select", "Select")} - - - - { - const label = (item as any).DisplayTitle || ""; - return label.toLowerCase().includes(query.toLowerCase()); - }} - renderItemLabel={(item) => ( - {(item as any).DisplayTitle || ""} - )} - set={(vals) => { - const chosen = vals[0] as any; - if (chosen && chosen.Index !== null && chosen.Index !== undefined) { - onChange(chosen.Index); - } - }} - /> - - ); -}; diff --git a/components/WatchedIndicator.tsx b/components/WatchedIndicator.tsx index c815eaf98..145ee7e0e 100644 --- a/components/WatchedIndicator.tsx +++ b/components/WatchedIndicator.tsx @@ -1,14 +1,119 @@ +import { Ionicons } from "@expo/vector-icons"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; -import type React from "react"; -import { View } from "react-native"; +import React from "react"; +import { Platform, View, type ViewStyle } from "react-native"; +import { Text } from "@/components/common/Text"; +import { scaleSize } from "@/utils/scaleSize"; + +const isAggregateType = (item: BaseItemDto) => + item.Type === "Series" || item.Type === "BoxSet"; + +// TV sizes are scaled relative to a 1920×1080 reference (see scaleSize). +const tvBadgeBase: ViewStyle = { + position: "absolute", + top: scaleSize(8), + right: scaleSize(8), + height: scaleSize(28), + borderRadius: scaleSize(14), + backgroundColor: "rgba(255,255,255,0.92)", + alignItems: "center", + justifyContent: "center", +}; + +// Mobile uses raw dp — no scaling. +const mobileBadgeBase: ViewStyle = { + position: "absolute", + top: 4, + right: 4, + height: 20, + borderRadius: 10, + backgroundColor: "#9333ea", + alignItems: "center", + justifyContent: "center", +}; + +/** + * Renders the unplayed-episode count badge for Series/BoxSet items that still + * have episodes left to watch. Returns null for non-aggregate types, fully + * watched items, or items with no unplayed count, so it is safe to mount + * unconditionally as an overlay (e.g. on top of the tvOS glass poster, where + * the watched checkmark is drawn natively and only the count needs RN). + */ +export const UnplayedCountBadge: React.FC<{ item: BaseItemDto }> = React.memo( + ({ item }) => { + if (!isAggregateType(item)) return null; + if (item.UserData?.Played) return null; + const unplayed = item.UserData?.UnplayedItemCount ?? 0; + if (unplayed <= 0) return null; + // Cap at 1k+ to keep the badge compact (jellyfin-web caps at 99+). + const label = unplayed >= 1000 ? "1k+" : String(unplayed); + + if (Platform.isTV) { + return ( + + + {label} + + + ); + } + + return ( + + + {label} + + + ); + }, +); export const WatchedIndicator: React.FC<{ item: BaseItemDto }> = ({ item }) => { + const isMovieOrEpisode = item.Type === "Movie" || item.Type === "Episode"; + const isAggregate = isAggregateType(item); + const isPlayed = item.UserData?.Played === true; + + if (Platform.isTV) { + // Fully watched → white checkmark badge (top-right) + if (isPlayed && (isMovieOrEpisode || isAggregate)) { + return ( + + + + ); + } + // Series/BoxSet with remaining episodes → count badge + return ; + } + + // Mobile: purple corner ribbon for unwatched Movie/Episode (existing behavior) return ( <> - {item.UserData?.Played === false && - (item.Type === "Movie" || item.Type === "Episode") && ( - - )} + {/* Strict === false: items without UserData (unknown state) get no ribbon */} + {isMovieOrEpisode && item.UserData?.Played === false && ( + + )} + + {/* Fully watched Series/BoxSet → small purple checkmark */} + {isAggregate && isPlayed && ( + + + + )} + + {/* Series/BoxSet with remaining episodes → count badge */} + ); }; diff --git a/components/apple-tv-carousel/AppleTVCarousel.tsx b/components/apple-tv-carousel/AppleTVCarousel.tsx deleted file mode 100644 index c30711e8a..000000000 --- a/components/apple-tv-carousel/AppleTVCarousel.tsx +++ /dev/null @@ -1,909 +0,0 @@ -import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; -import { - getItemsApi, - getTvShowsApi, - getUserLibraryApi, -} from "@jellyfin/sdk/lib/utils/api"; -import { useQuery } from "@tanstack/react-query"; -import { Image } from "expo-image"; -import { LinearGradient } from "expo-linear-gradient"; -import { useRouter } from "expo-router"; -import { useAtomValue } from "jotai"; -import { useCallback, useEffect, useMemo, useState } from "react"; -import { - Pressable, - TouchableOpacity, - useWindowDimensions, - View, -} from "react-native"; -import { Gesture, GestureDetector } from "react-native-gesture-handler"; -import Animated, { - Easing, - interpolate, - runOnJS, - type SharedValue, - useAnimatedStyle, - useSharedValue, - withTiming, -} from "react-native-reanimated"; -import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings"; -import { useImageColorsReturn } from "@/hooks/useImageColorsReturn"; -import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed"; -import { useNetworkStatus } from "@/hooks/useNetworkStatus"; -import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; -import { useSettings } from "@/utils/atoms/settings"; -import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById"; -import { ItemImage } from "../common/ItemImage"; -import { getItemNavigation } from "../common/TouchableItemRouter"; -import type { SelectedOptions } from "../ItemContent"; -import { PlayButton } from "../PlayButton"; -import { MarkAsPlayedLargeButton } from "./MarkAsPlayedLargeButton"; - -interface AppleTVCarouselProps { - initialIndex?: number; - onItemChange?: (index: number) => void; - scrollOffset?: SharedValue; -} - -// Layout Constants -const GRADIENT_HEIGHT_TOP = 150; -const GRADIENT_HEIGHT_BOTTOM = 150; -const LOGO_HEIGHT = 80; - -// Position Constants -const LOGO_BOTTOM_POSITION = 260; -const GENRES_BOTTOM_POSITION = 220; -const OVERVIEW_BOTTOM_POSITION = 165; -const CONTROLS_BOTTOM_POSITION = 80; -const DOTS_BOTTOM_POSITION = 40; - -// Size Constants -const DOT_HEIGHT = 6; -const DOT_ACTIVE_WIDTH = 20; -const DOT_INACTIVE_WIDTH = 12; -const PLAY_BUTTON_SKELETON_HEIGHT = 50; -const PLAYED_STATUS_SKELETON_SIZE = 40; -const TEXT_SKELETON_HEIGHT = 20; -const TEXT_SKELETON_WIDTH = 250; -const OVERVIEW_SKELETON_HEIGHT = 16; -const OVERVIEW_SKELETON_WIDTH = 400; -const _EMPTY_STATE_ICON_SIZE = 64; - -// Spacing Constants -const HORIZONTAL_PADDING = 40; -const DOT_PADDING = 2; -const DOT_GAP = 4; -const CONTROLS_GAP = 10; -const _TEXT_MARGIN_TOP = 16; - -// Border Radius Constants -const DOT_BORDER_RADIUS = 3; -const LOGO_SKELETON_BORDER_RADIUS = 8; -const TEXT_SKELETON_BORDER_RADIUS = 4; -const PLAY_BUTTON_BORDER_RADIUS = 25; -const PLAYED_STATUS_BORDER_RADIUS = 20; - -// Animation Constants -const DOT_ANIMATION_DURATION = 300; -const CAROUSEL_TRANSITION_DURATION = 250; -const PAN_ACTIVE_OFFSET = 10; -const TRANSLATION_THRESHOLD = 0.2; -const VELOCITY_THRESHOLD = 400; - -// Text Constants -const GENRES_FONT_SIZE = 16; -const OVERVIEW_FONT_SIZE = 14; -const _EMPTY_STATE_FONT_SIZE = 18; -const TEXT_SHADOW_RADIUS = 2; -const MAX_GENRES_COUNT = 2; -const MAX_BUTTON_WIDTH = 300; -const OVERVIEW_MAX_LINES = 2; -const OVERVIEW_MAX_WIDTH = "80%"; - -// Opacity Constants -const OVERLAY_OPACITY = 0.3; -const DOT_INACTIVE_OPACITY = 0.6; -const TEXT_OPACITY = 0.9; - -// Color Constants -const SKELETON_BACKGROUND_COLOR = "#1a1a1a"; -const SKELETON_ELEMENT_COLOR = "#333"; -const SKELETON_ACTIVE_DOT_COLOR = "#666"; -const _EMPTY_STATE_COLOR = "#666"; -const TEXT_SHADOW_COLOR = "rgba(0, 0, 0, 0.8)"; -const LOGO_WIDTH_PERCENTAGE = "80%"; - -const DotIndicator = ({ - index, - currentIndex, - onPress, -}: { - index: number; - currentIndex: number; - onPress: (index: number) => void; -}) => { - const isActive = index === currentIndex; - - const animatedStyle = useAnimatedStyle(() => ({ - width: withTiming(isActive ? DOT_ACTIVE_WIDTH : DOT_INACTIVE_WIDTH, { - duration: DOT_ANIMATION_DURATION, - easing: Easing.out(Easing.quad), - }), - opacity: withTiming(isActive ? 1 : DOT_INACTIVE_OPACITY, { - duration: DOT_ANIMATION_DURATION, - easing: Easing.out(Easing.quad), - }), - })); - - return ( - onPress(index)} - style={{ - padding: DOT_PADDING, // Increase touch area - }} - > - - - ); -}; - -export const AppleTVCarousel: React.FC = ({ - initialIndex = 0, - onItemChange, - scrollOffset, -}) => { - const { settings } = useSettings(); - const api = useAtomValue(apiAtom); - const user = useAtomValue(userAtom); - const { isConnected, serverConnected } = useNetworkStatus(); - const router = useRouter(); - const { width: screenWidth, height: screenHeight } = useWindowDimensions(); - const isLandscape = screenWidth >= screenHeight; - const carouselHeight = useMemo( - () => (isLandscape ? screenHeight * 0.9 : screenHeight / 1.45), - [isLandscape, screenHeight], - ); - const [currentIndex, setCurrentIndex] = useState(initialIndex); - const translateX = useSharedValue(-initialIndex * screenWidth); - - const isQueryEnabled = - !!api && !!user?.Id && isConnected && serverConnected === true; - - const { data: continueWatchingData, isLoading: continueWatchingLoading } = - useQuery({ - queryKey: ["appleTVCarousel", "continueWatching", user?.Id], - queryFn: async () => { - if (!api || !user?.Id) return []; - const response = await getItemsApi(api).getResumeItems({ - userId: user.Id, - enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"], - includeItemTypes: ["Movie", "Series", "Episode"], - fields: ["Genres", "Overview"], - limit: 2, - }); - return response.data.Items || []; - }, - enabled: isQueryEnabled, - staleTime: 60 * 1000, - }); - - const { data: nextUpData, isLoading: nextUpLoading } = useQuery({ - queryKey: ["appleTVCarousel", "nextUp", user?.Id], - queryFn: async () => { - if (!api || !user?.Id) return []; - const response = await getTvShowsApi(api).getNextUp({ - userId: user.Id, - fields: ["MediaSourceCount", "Genres", "Overview"], - limit: 2, - enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"], - enableResumable: false, - }); - return response.data.Items || []; - }, - enabled: isQueryEnabled, - staleTime: 60 * 1000, - }); - - const { data: recentlyAddedData, isLoading: recentlyAddedLoading } = useQuery( - { - queryKey: ["appleTVCarousel", "recentlyAdded", user?.Id], - queryFn: async () => { - if (!api || !user?.Id) return []; - const response = await getUserLibraryApi(api).getLatestMedia({ - userId: user.Id, - limit: 2, - fields: ["PrimaryImageAspectRatio", "Path", "Genres", "Overview"], - imageTypeLimit: 1, - enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"], - }); - return response.data || []; - }, - enabled: isQueryEnabled, - staleTime: 60 * 1000, - }, - ); - - const items = useMemo(() => { - const continueItems = continueWatchingData ?? []; - const nextItems = nextUpData ?? []; - const recentItems = recentlyAddedData ?? []; - - const allItems = [ - ...continueItems.slice(0, 2), - ...nextItems.slice(0, 2), - ...recentItems.slice(0, 2), - ]; - - // Deduplicate by item ID to prevent duplicate keys - const seen = new Set(); - return allItems.filter((item) => { - if (item.Id && !seen.has(item.Id)) { - seen.add(item.Id); - return true; - } - return false; - }); - }, [continueWatchingData, nextUpData, recentlyAddedData]); - - const isLoading = - continueWatchingLoading || nextUpLoading || recentlyAddedLoading; - const hasItems = items.length > 0; - - // Only get play settings if we have valid items - const currentItem = hasItems ? items[currentIndex] : null; - - // Extract colors for the current item only (for performance) - const currentItemColors = useImageColorsReturn({ item: currentItem }); - - // Create a fallback empty item for useDefaultPlaySettings when no item is available - const itemForPlaySettings = currentItem || { MediaSources: [] }; - const { - defaultAudioIndex, - defaultBitrate, - defaultMediaSource, - defaultSubtitleIndex, - } = useDefaultPlaySettings(itemForPlaySettings as BaseItemDto, settings); - - const [selectedOptions, setSelectedOptions] = useState< - SelectedOptions | undefined - >(undefined); - - useEffect(() => { - // Only set options if we have valid current item - if (currentItem) { - setSelectedOptions({ - bitrate: defaultBitrate, - mediaSource: defaultMediaSource, - subtitleIndex: defaultSubtitleIndex ?? -1, - audioIndex: defaultAudioIndex, - }); - } else { - setSelectedOptions(undefined); - } - }, [ - defaultAudioIndex, - defaultBitrate, - defaultSubtitleIndex, - defaultMediaSource, - currentIndex, - currentItem, - ]); - - useEffect(() => { - if (!hasItems) { - setCurrentIndex(initialIndex); - translateX.value = -initialIndex * screenWidth; - return; - } - - setCurrentIndex((prev) => { - const newIndex = Math.min(prev, items.length - 1); - translateX.value = -newIndex * screenWidth; - return newIndex; - }); - }, [hasItems, items, initialIndex, screenWidth, translateX]); - - useEffect(() => { - translateX.value = -currentIndex * screenWidth; - }, [currentIndex, screenWidth, translateX]); - - useEffect(() => { - if (hasItems) { - onItemChange?.(currentIndex); - } - }, [hasItems, currentIndex, onItemChange]); - - const goToIndex = useCallback( - (index: number) => { - if (!hasItems || index < 0 || index >= items.length) return; - - translateX.value = withTiming(-index * screenWidth, { - duration: CAROUSEL_TRANSITION_DURATION, // Slightly longer for smoother feel - easing: Easing.bezier(0.25, 0.46, 0.45, 0.94), // iOS-like smooth deceleration curve - }); - - setCurrentIndex(index); - onItemChange?.(index); - }, - [hasItems, items, onItemChange, screenWidth, translateX], - ); - - const navigateToItem = useCallback( - (item: BaseItemDto) => { - const navigation = getItemNavigation(item, "(home)"); - router.push(navigation as any); - }, - [router], - ); - - const panGesture = Gesture.Pan() - .activeOffsetX([-PAN_ACTIVE_OFFSET, PAN_ACTIVE_OFFSET]) - .onUpdate((event) => { - translateX.value = -currentIndex * screenWidth + event.translationX; - }) - .onEnd((event) => { - const velocity = event.velocityX; - const translation = event.translationX; - - let newIndex = currentIndex; - - // Improved thresholds for more responsive navigation - if ( - Math.abs(translation) > screenWidth * TRANSLATION_THRESHOLD || - Math.abs(velocity) > VELOCITY_THRESHOLD - ) { - if (translation > 0 && currentIndex > 0) { - newIndex = currentIndex - 1; - } else if ( - translation < 0 && - items && - currentIndex < items.length - 1 - ) { - newIndex = currentIndex + 1; - } - } - - runOnJS(goToIndex)(newIndex); - }); - - const containerAnimatedStyle = useAnimatedStyle(() => { - return { - transform: [{ translateX: translateX.value }], - }; - }); - - const togglePlayedStatus = useMarkAsPlayed(items); - - const headerAnimatedStyle = useAnimatedStyle(() => { - if (!scrollOffset) return {}; - return { - transform: [ - { - translateY: interpolate( - scrollOffset.value, - [-carouselHeight, 0, carouselHeight], - [-carouselHeight / 2, 0, carouselHeight * 0.75], - ), - }, - { - scale: interpolate( - scrollOffset.value, - [-carouselHeight, 0, carouselHeight], - [2, 1, 1], - ), - }, - ], - }; - }); - - const renderDots = () => { - if (!hasItems || items.length <= 1) return null; - - return ( - - {items.map((_, index) => ( - - ))} - - ); - }; - - const renderSkeletonLoader = () => { - return ( - - {/* Background Skeleton */} - - - {/* Dark Overlay Skeleton */} - - - {/* Gradient Fade to Black Top Skeleton */} - - - {/* Gradient Fade to Black Bottom Skeleton */} - - - {/* Logo Skeleton */} - - - - - {/* Type and Genres Skeleton */} - - - - - {/* Overview Skeleton */} - - - - - - {/* Controls Skeleton */} - - {/* Play Button Skeleton */} - - - {/* Played Status Skeleton */} - - - - {/* Dots Skeleton */} - - {[1, 2, 3].map((_, index) => ( - - ))} - - - ); - }; - - const renderItem = (item: BaseItemDto, _index: number) => { - const itemLogoUrl = api ? getLogoImageUrlById({ api, item }) : null; - - return ( - - {/* Background Backdrop */} - - - - - {/* Dark Overlay */} - - - {/* Gradient Fade to Black at Top */} - - - {/* Gradient Fade to Black at Bottom */} - - - {/* Logo Section */} - {itemLogoUrl && ( - navigateToItem(item)} - style={{ - position: "absolute", - bottom: LOGO_BOTTOM_POSITION, - left: 0, - right: 0, - paddingHorizontal: HORIZONTAL_PADDING, - alignItems: "center", - }} - > - - - )} - - {/* Type and Genres Section */} - - navigateToItem(item)}> - - {(() => { - let typeLabel = ""; - - if (item.Type === "Episode") { - // For episodes, show season and episode number - const season = item.ParentIndexNumber; - const episode = item.IndexNumber; - if (season && episode) { - typeLabel = `S${season} • E${episode}`; - } else { - typeLabel = "Episode"; - } - } else { - typeLabel = - item.Type === "Series" - ? "TV Show" - : item.Type === "Movie" - ? "Movie" - : item.Type || ""; - } - - const genres = - item.Genres && item.Genres.length > 0 - ? item.Genres.slice(0, MAX_GENRES_COUNT).join(" • ") - : ""; - - if (typeLabel && genres) { - return `${typeLabel} • ${genres}`; - } else if (typeLabel) { - return typeLabel; - } else if (genres) { - return genres; - } else { - return ""; - } - })()} - - - - - {/* Overview Section - for Episodes and Movies */} - {(item.Type === "Episode" || item.Type === "Movie") && - item.Overview && ( - - navigateToItem(item)}> - - {item.Overview} - - - - )} - - {/* Controls Section */} - - - {/* Play Button */} - - {selectedOptions && ( - - )} - - - {/* Mark as Played */} - - - - - ); - }; - - // Handle loading state - if (isLoading) { - return ( - - {renderSkeletonLoader()} - - ); - } - - // Handle empty items - if (!hasItems) { - return null; - } - - return ( - - - - {items.map((item, index) => renderItem(item, index))} - - - - {/* Animated Dots Indicator */} - {renderDots()} - - ); -}; diff --git a/components/apple-tv-carousel/MarkAsPlayedLargeButton.tsx b/components/apple-tv-carousel/MarkAsPlayedLargeButton.tsx deleted file mode 100644 index ea9bd98df..000000000 --- a/components/apple-tv-carousel/MarkAsPlayedLargeButton.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { Button, Host } from "@expo/ui/swift-ui"; -import { Ionicons } from "@expo/vector-icons"; -import { Platform, View } from "react-native"; -import { RoundButton } from "../RoundButton"; - -interface MarkAsPlayedLargeButtonProps { - isPlayed: boolean; - onToggle: (isPlayed: boolean) => void; -} - -export const MarkAsPlayedLargeButton: React.FC< - MarkAsPlayedLargeButtonProps -> = ({ isPlayed, onToggle }) => { - if (Platform.OS === "ios") - return ( - - - - ); - - return ( - - onToggle(isPlayed)} - /> - - ); -}; diff --git a/components/chapters/ChapterList.tsx b/components/chapters/ChapterList.tsx new file mode 100644 index 000000000..e44332095 --- /dev/null +++ b/components/chapters/ChapterList.tsx @@ -0,0 +1,209 @@ +/** + * A modal listing an item's chapters. Each row shows the chapter name and its + * timestamp; the current chapter is highlighted. Tapping a row seeks to that + * chapter and closes the modal. Player-agnostic — the seek is injected. + */ + +import { Ionicons } from "@expo/vector-icons"; +import type { ChapterInfo } from "@jellyfin/sdk/lib/generated-client/models"; +import { memo, useEffect, useMemo, useRef } from "react"; +import { useTranslation } from "react-i18next"; +import { FlatList, Modal, Pressable, StyleSheet, View } from "react-native"; +import { Text } from "@/components/common/Text"; +import { Colors } from "@/constants/Colors"; +import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets"; +import { + type ChapterEntry, + chapterStartsMs, + formatChapterTime, + sortedChapters, +} from "@/utils/chapters"; + +interface ChapterListProps { + visible: boolean; + chapters: ChapterInfo[] | null | undefined; + /** Current playback position in milliseconds (to highlight the row). */ + currentPositionMs: number; + /** Seek the player to this millisecond position. */ + onSeek: (positionMs: number) => void; + onClose: () => void; +} + +const ROW_HEIGHT = 48; + +function ChapterListComponent({ + visible, + chapters, + currentPositionMs, + onSeek, + onClose, +}: ChapterListProps) { + const { t } = useTranslation(); + const safeArea = useControlsSafeAreaInsets(); + const listRef = useRef>(null); + + const entries = useMemo(() => sortedChapters(chapters), [chapters]); + // Memoize starts so currentChapterIndex computation doesn't re-sort/filter + // every tick — chapters is the only input that drives the underlying array. + const starts = useMemo(() => chapterStartsMs(chapters), [chapters]); + const activeIndex = useMemo(() => { + let idx = -1; + for (let i = 0; i < starts.length; i++) { + if (currentPositionMs >= starts[i]) idx = i; + else break; + } + return idx; + }, [currentPositionMs, starts]); + + // FlatList.initialScrollIndex only fires at first mount; keeps its + // children mounted across visible toggles, so subsequent opens never scroll. + // Trigger an imperative scroll each time the sheet becomes visible. + useEffect(() => { + if (!visible || activeIndex < 0 || entries.length === 0) return; + const raf = requestAnimationFrame(() => { + listRef.current?.scrollToIndex({ + index: activeIndex, + animated: false, + viewPosition: 0.5, + }); + }); + return () => cancelAnimationFrame(raf); + }, [visible, activeIndex, entries.length]); + + return ( + to portrait-only; without this it rotates the app + // back to portrait when opened from the landscape player. Android ignores it. + supportedOrientations={["portrait", "landscape"]} + > + + e.stopPropagation()} + style={[ + styles.sheet, + { + marginLeft: safeArea.left, + marginRight: safeArea.right, + paddingBottom: safeArea.bottom, + }, + ]} + > + + {t("chapters.title")} + + + + + `${item.positionMs}-${index}`} + getItemLayout={(_, index) => ({ + length: ROW_HEIGHT, + offset: ROW_HEIGHT * index, + index, + })} + onScrollToIndexFailed={(info) => { + // Required when getItemLayout is provided and the target index + // is outside the currently rendered window. Fallback to an + // offset-based scroll, then retry the precise scroll once a + // frame has elapsed. + listRef.current?.scrollToOffset({ + offset: info.averageItemLength * info.index, + animated: false, + }); + setTimeout(() => { + listRef.current?.scrollToIndex({ + index: info.index, + animated: false, + viewPosition: 0.5, + }); + }, 50); + }} + renderItem={({ item, index }) => { + const positionMs = item.positionMs; + const isActive = index === activeIndex; + return ( + { + onSeek(positionMs); + onClose(); + }} + style={[ + styles.row, + isActive && { backgroundColor: `${Colors.primary}33` }, + ]} + > + + {item.chapter.Name || + t("chapters.chapter_number", { number: index + 1 })} + + + {formatChapterTime(positionMs)} + + + ); + }} + /> + + + + ); +} + +export const ChapterList = memo(ChapterListComponent); + +const styles = StyleSheet.create({ + backdrop: { + flex: 1, + justifyContent: "flex-end", + }, + sheet: { + backgroundColor: Colors.background, + borderTopLeftRadius: 16, + borderTopRightRadius: 16, + maxHeight: "70%", + }, + header: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + padding: 16, + }, + title: { + color: Colors.text, + fontSize: 17, + fontWeight: "700", + }, + row: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + paddingHorizontal: 16, + height: ROW_HEIGHT, + }, + rowText: { + fontSize: 15, + flex: 1, + }, + rowTime: { + color: Colors.icon, + fontSize: 13, + marginLeft: 12, + }, +}); diff --git a/components/chapters/ChapterTicks.tsx b/components/chapters/ChapterTicks.tsx new file mode 100644 index 000000000..850c63bf0 --- /dev/null +++ b/components/chapters/ChapterTicks.tsx @@ -0,0 +1,87 @@ +/** + * Chapter tick marks drawn as an absolute overlay over a progress slider. + * Renders nothing for media with one or zero chapters. `pointerEvents: "none"` + * so the slider underneath still receives touches. + */ + +import { memo, useState } from "react"; +import { type LayoutChangeEvent, PixelRatio, View } from "react-native"; +import type { ChapterMarker } from "@/utils/chapters"; + +interface ChapterTicksProps { + /** Pre-computed markers (caller memoizes — avoids double-computing here). */ + markers: ChapterMarker[]; + /** Tick colour. */ + color?: string; + /** Tick height in px — slightly less than the slider track thickness. */ + height?: number; + /** Tick width in px — integer to avoid sub-pixel anti-aliasing. */ + width?: number; +} + +function ChapterTicksComponent({ + markers, + // Semi-transparent black contrasts against both the filled progress + // (#fff) and the unfilled track (rgba(255,255,255,0.2)) so the ticks + // stay visible across the whole bar as playback advances. + color = "rgba(0,0,0,0.55)", + height = 14, + width = 2, +}: ChapterTicksProps) { + // Hooks must run unconditionally — keep them before any early return. + const [sliderWidth, setSliderWidth] = useState(0); + + const handleLayout = (e: LayoutChangeEvent) => { + setSliderWidth(e.nativeEvent.layout.width); + }; + + // One chapter (typically a single marker at 0) is not worth marking. + if (markers.length <= 1) return null; + + return ( + + {sliderWidth > 0 && + markers + // Skip the leading 0ms marker — it overlaps the slider start and + // adds visual noise at an already-rendered boundary. + .filter((marker) => marker.positionMs > 0) + .map((marker, index) => { + // Align both the position AND the width onto the device's + // physical pixel grid. Without this, fractional dp values land + // at different sub-pixel fractions per tick — Android samples + // each one differently and some ticks render visibly thicker. + const centerDp = (marker.percent / 100) * sliderWidth; + const left = PixelRatio.roundToNearestPixel(centerDp - width / 2); + const snappedWidth = PixelRatio.roundToNearestPixel(width); + return ( + + ); + })} + + ); +} + +export const ChapterTicks = memo(ChapterTicksComponent); diff --git a/components/common/HeaderBackButton.tsx b/components/common/HeaderBackButton.tsx index 686cab5d6..8818a9a09 100644 --- a/components/common/HeaderBackButton.tsx +++ b/components/common/HeaderBackButton.tsx @@ -1,42 +1,36 @@ import { Ionicons } from "@expo/vector-icons"; import { BlurView, type BlurViewProps } from "expo-blur"; -import { useRouter } from "expo-router"; -import { - Platform, - TouchableOpacity, - type TouchableOpacityProps, -} from "react-native"; +import { Platform } from "react-native"; +import { Pressable, type PressableProps } from "react-native-gesture-handler"; +import useRouter from "@/hooks/useAppRouter"; interface Props extends BlurViewProps { background?: "blur" | "transparent"; - touchableOpacityProps?: TouchableOpacityProps; + pressableProps?: Omit; } export const HeaderBackButton: React.FC = ({ background = "transparent", - touchableOpacityProps, + pressableProps, ...props }) => { const router = useRouter(); if (Platform.OS === "ios") { return ( - router.back()} className='flex items-center justify-center w-9 h-9' - {...touchableOpacityProps} + {...pressableProps} > - + ); } if (background === "transparent" && Platform.OS !== "android") return ( - router.back()} - {...touchableOpacityProps} - > + router.back()} {...pressableProps}> = ({ color='white' /> - + ); return ( - router.back()} className=' rounded-full p-2' - {...touchableOpacityProps} + {...pressableProps} > = ({ size={24} color='white' /> - + ); }; diff --git a/components/common/HorizontalScroll.tsx b/components/common/HorizontalScroll.tsx index 6c6af8e72..e907f52f0 100644 --- a/components/common/HorizontalScroll.tsx +++ b/components/common/HorizontalScroll.tsx @@ -58,7 +58,7 @@ export const HorizontalScroll = ( if (!data || loading) { return ( - + diff --git a/components/common/Input.tsx b/components/common/Input.tsx index 8d7f602f8..7770f556c 100644 --- a/components/common/Input.tsx +++ b/components/common/Input.tsx @@ -1,50 +1,146 @@ -import React, { useState } from "react"; +import { Ionicons } from "@expo/vector-icons"; +import { BlurView } from "expo-blur"; +import { useRef, useState } from "react"; import { + Animated, + Easing, Platform, + Pressable, TextInput, type TextInputProps, - TouchableOpacity, + View, } from "react-native"; +import { useScaledTVTypography } from "@/constants/TVTypography"; interface InputProps extends TextInputProps { - extraClassName?: string; // new prop for additional classes + extraClassName?: string; } export function Input(props: InputProps) { const { style, extraClassName = "", ...otherProps } = props; - const inputRef = React.useRef(null); + const inputRef = useRef(null); const [isFocused, setIsFocused] = useState(false); + const scale = useRef(new Animated.Value(1)).current; + // TV-only: scales the input font with the tvTypographyScale setting. + // Not consumed by the mobile branch below. + const tvTypography = useScaledTVTypography(); - return Platform.isTV ? ( - inputRef?.current?.focus?.()} - activeOpacity={1} - > - setIsFocused(true)} - onBlur={() => setIsFocused(false)} - {...otherProps} - /> - - ) : ( + const animateFocus = (focused: boolean) => { + Animated.timing(scale, { + toValue: focused ? 1.02 : 1, + duration: 150, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }).start(); + }; + + const handleFocus = () => { + setIsFocused(true); + animateFocus(true); + }; + + const handleBlur = () => { + setIsFocused(false); + animateFocus(false); + }; + + if (Platform.isTV) { + // Scale the whole input (box height, padding, icon) proportionally with the + // font so the component grows/shrinks with the tvTypographyScale setting. + // Uses the `body` token (primary reading size); it resolves to 28 at Default. + const fontSize = tvTypography.body; + const factor = fontSize / 28; + const height = Math.round(56 * factor); + const paddingLeft = Math.round(24 * factor); + const iconSize = Math.round(26 * factor); + const iconMarginRight = Math.round(14 * factor); + + const containerStyle = { + height, + borderRadius: 50, + borderWidth: isFocused ? 1.5 : 1, + borderColor: isFocused + ? "rgba(255, 255, 255, 0.3)" + : "rgba(255, 255, 255, 0.1)", + overflow: "hidden" as const, + flexDirection: "row" as const, + alignItems: "center" as const, + paddingLeft, + }; + + const inputElement = ( + <> + + + + ); + + return ( + inputRef.current?.focus()} + onFocus={handleFocus} + onBlur={handleBlur} + > + + {Platform.OS === "ios" ? ( + + {inputElement} + + ) : ( + + {inputElement} + + )} + + + ); + } + + // Mobile version unchanged + return ( = ({ url }) => { - if (!url) - return ( - - - - ); - - return ( - - - - ); -}; diff --git a/components/common/ProgressBar.tsx b/components/common/ProgressBar.tsx index 1a47327e6..9c754cdc8 100644 --- a/components/common/ProgressBar.tsx +++ b/components/common/ProgressBar.tsx @@ -1,6 +1,6 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import React, { useMemo } from "react"; -import { View } from "react-native"; +import { Platform, View } from "react-native"; interface ProgressBarProps { item: BaseItemDto; @@ -37,10 +37,12 @@ export const ProgressBar: React.FC = ({ item }) => { } /> ); diff --git a/components/common/SectionHeader.tsx b/components/common/SectionHeader.tsx new file mode 100644 index 000000000..aa9efc434 --- /dev/null +++ b/components/common/SectionHeader.tsx @@ -0,0 +1,42 @@ +import { TouchableOpacity, View } from "react-native"; +import { Colors } from "@/constants/Colors"; +import { Text } from "./Text"; + +type Props = { + title: string; + actionLabel?: string; + actionDisabled?: boolean; + onPressAction?: () => void; +}; + +export const SectionHeader: React.FC = ({ + title, + actionLabel, + actionDisabled = false, + onPressAction, +}) => { + const shouldShowAction = Boolean(actionLabel) && Boolean(onPressAction); + + return ( + + {title} + {shouldShowAction && ( + + + {actionLabel} + + + )} + + ); +}; diff --git a/components/common/TouchableItemRouter.tsx b/components/common/TouchableItemRouter.tsx index e95fd5e72..fed45dc99 100644 --- a/components/common/TouchableItemRouter.tsx +++ b/components/common/TouchableItemRouter.tsx @@ -1,14 +1,21 @@ import { useActionSheet } from "@expo/react-native-action-sheet"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; -import { useRouter, useSegments } from "expo-router"; +import { useSegments } from "expo-router"; import { type PropsWithChildren, useCallback } from "react"; -import { TouchableOpacity, type TouchableOpacityProps } from "react-native"; +import { useTranslation } from "react-i18next"; +import { + Platform, + TouchableOpacity, + type TouchableOpacityProps, +} from "react-native"; +import useRouter from "@/hooks/useAppRouter"; import { useFavorite } from "@/hooks/useFavorite"; import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed"; +import { useDownload } from "@/providers/DownloadProvider"; +import { useOfflineMode } from "@/providers/OfflineModeProvider"; interface Props extends TouchableOpacityProps { item: BaseItemDto; - isOffline?: boolean; } export const itemRouter = (item: BaseItemDto, from: string) => { @@ -16,6 +23,10 @@ export const itemRouter = (item: BaseItemDto, from: string) => { return `/(auth)/(tabs)/${from}/livetv`; } + if ("CollectionType" in item && item.CollectionType === "music") { + return `/(auth)/(tabs)/(libraries)/music/${item.Id}`; + } + if (item.Type === "Series") { return `/(auth)/(tabs)/${from}/series/${item.Id}`; } @@ -50,6 +61,13 @@ export const getItemNavigation = (item: BaseItemDto, _from: string) => { }; } + if ("CollectionType" in item && item.CollectionType === "music") { + return { + pathname: "/music/[libraryId]" as const, + params: { libraryId: item.Id! }, + }; + } + if (item.Type === "Series") { return { pathname: "/series/[id]" as const, @@ -71,13 +89,55 @@ export const getItemNavigation = (item: BaseItemDto, _from: string) => { }; } - if (item.Type === "CollectionFolder" || item.Type === "Playlist") { + if (item.Type === "CollectionFolder") { return { pathname: "/[libraryId]" as const, params: { libraryId: item.Id! }, }; } + // Music types - use shared routes for proper back navigation + if (item.Type === "MusicArtist") { + return { + pathname: "/music/artist/[artistId]" as const, + params: { artistId: item.Id! }, + }; + } + + if (item.Type === "MusicAlbum") { + return { + pathname: "/music/album/[albumId]" as const, + params: { albumId: item.Id! }, + }; + } + + if (item.Type === "Audio") { + // Navigate to the album if available, otherwise to the item page + if (item.AlbumId) { + return { + pathname: "/music/album/[albumId]" as const, + params: { albumId: item.AlbumId }, + }; + } + return { + pathname: "/items/page" as const, + params: { id: item.Id! }, + }; + } + + if (item.Type === "Playlist") { + if (Platform.isTV) { + return { + pathname: "/[libraryId]" as const, + params: { libraryId: item.Id! }, + }; + } + return { + pathname: "/music/playlist/[playlistId]" as const, + params: { playlistId: item.Id! }, + }; + } + // Default case - items page return { pathname: "/items/page" as const, @@ -87,18 +147,32 @@ export const getItemNavigation = (item: BaseItemDto, _from: string) => { export const TouchableItemRouter: React.FC> = ({ item, - isOffline = false, children, ...props }) => { - const router = useRouter(); + const { t } = useTranslation(); const segments = useSegments(); const { showActionSheetWithOptions } = useActionSheet(); const markAsPlayedStatus = useMarkAsPlayed([item]); const { isFavorite, toggleFavorite } = useFavorite(item); + const router = useRouter(); + const isOffline = useOfflineMode(); + const { deleteFile } = useDownload(); const from = (segments as string[])[2] || "(home)"; + const handlePress = useCallback(() => { + // Force music libraries to navigate via the explicit string route. + // This avoids losing the dynamic [libraryId] param when going through a nested navigator. + if ("CollectionType" in item && item.CollectionType === "music") { + router.push(itemRouter(item, from) as any); + return; + } + + const navigation = getItemNavigation(item, from); + router.push(navigation as any); + }, [from, item, router]); + const showActionSheet = useCallback(() => { if ( !( @@ -108,18 +182,26 @@ export const TouchableItemRouter: React.FC> = ({ ) ) return; - const options = [ - "Mark as Played", - "Mark as Not Played", - isFavorite ? "Unmark as Favorite" : "Mark as Favorite", - "Cancel", + + const options: string[] = [ + t("common.mark_as_played"), + t("common.mark_as_not_played"), + isFavorite + ? t("music.track_options.remove_from_favorites") + : t("music.track_options.add_to_favorites"), + ...(isOffline ? [t("home.downloads.delete_download")] : []), + t("common.cancel"), ]; - const cancelButtonIndex = 3; + const cancelButtonIndex = options.length - 1; + const destructiveButtonIndex = isOffline + ? cancelButtonIndex - 1 + : undefined; showActionSheetWithOptions( { options, cancelButtonIndex, + destructiveButtonIndex, }, async (selectedIndex) => { if (selectedIndex === 0) { @@ -128,31 +210,33 @@ export const TouchableItemRouter: React.FC> = ({ await markAsPlayedStatus(false); } else if (selectedIndex === 2) { toggleFavorite(); + } else if (isOffline && selectedIndex === 3 && item.Id) { + deleteFile(item.Id); } }, ); - }, [showActionSheetWithOptions, isFavorite, markAsPlayedStatus]); + }, [ + showActionSheetWithOptions, + isFavorite, + markAsPlayedStatus, + toggleFavorite, + isOffline, + deleteFile, + item.Id, + t, + ]); if ( from === "(home)" || from === "(search)" || from === "(libraries)" || - from === "(favorites)" + from === "(favorites)" || + from === "(watchlists)" ) return ( { - if (isOffline) { - // For offline mode, we still need to use query params - const url = `${itemRouter(item, from)}&offline=true`; - router.push(url as any); - return; - } - - const navigation = getItemNavigation(item, from); - router.push(navigation as any); - }} + onPress={handlePress} {...props} > {children} diff --git a/components/common/VerticalSkeleton.tsx b/components/common/VerticalSkeleton.tsx deleted file mode 100644 index 02a8a2567..000000000 --- a/components/common/VerticalSkeleton.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { View, type ViewProps } from "react-native"; - -interface Props extends ViewProps { - index: number; -} - -export const VerticalSkeleton: React.FC = ({ index, ...props }) => { - return ( - - - - - - - ); -}; diff --git a/components/companion/CompanionLoginScreen.tsx b/components/companion/CompanionLoginScreen.tsx new file mode 100644 index 000000000..95e92dffb --- /dev/null +++ b/components/companion/CompanionLoginScreen.tsx @@ -0,0 +1,532 @@ +import { useAtom } from "jotai"; +import React, { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + KeyboardAvoidingView, + Linking, + Platform, + ScrollView, + TextInput, + TouchableOpacity, + View, +} from "react-native"; +import { Button } from "@/components/Button"; +import { Text } from "@/components/common/Text"; +import useRouter from "@/hooks/useAppRouter"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { sendCredentialsToTV } from "@/utils/pairingService"; + +type ScreenState = + | "scanning" + | "no-permission" + | "confirm" + | "form" + | "sending" + | "success" + | "error"; + +interface ParsedPairingCode { + code: string; +} + +type ExpoCameraModule = typeof import("expo-camera"); + +const ExpoCamera: ExpoCameraModule | null = Platform.isTV + ? null + : require("expo-camera"); + +export const CompanionLoginScreen: React.FC = () => { + const { t } = useTranslation(); + const router = useRouter(); + const [api] = useAtom(apiAtom); + const [user] = useAtom(userAtom); + + const [screenState, setScreenState] = useState( + Platform.isTV ? "form" : "scanning", + ); + const [pairingCode, setPairingCode] = useState(""); + const [serverUrl, setServerUrl] = useState(""); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [errorMessage, setErrorMessage] = useState(null); + + // Pre-fill server URL and username from current session + useEffect(() => { + if (api?.basePath) { + setServerUrl(api.basePath); + } + + if (user?.Name) { + setUsername(user.Name); + } + }, [api?.basePath, user?.Name]); + + // Request camera permission + useEffect(() => { + if (!ExpoCamera) return; + + ExpoCamera.Camera.getCameraPermissionsAsync().then((response) => { + if (!response.granted) { + ExpoCamera.Camera.requestCameraPermissionsAsync().then((result) => { + if (!result.granted) { + setScreenState("no-permission"); + } + }); + } + }); + }, []); + + const validateAndParseQR = useCallback( + (data: string): ParsedPairingCode | null => { + try { + const parsed = JSON.parse(data); + + if ( + parsed.action === "streamyfin-pair" && + typeof parsed.code === "string" && + parsed.code.length > 0 + ) { + return { code: parsed.code }; + } + + return null; + } catch { + return null; + } + }, + [], + ); + + const handleBarCodeScanned = useCallback( + ({ data }: { data: string }) => { + if (screenState !== "scanning") return; + + const parsed = validateAndParseQR(data); + + if (!parsed) { + setErrorMessage(t("companion_login.error_invalid_qr")); + setScreenState("error"); + return; + } + + setPairingCode(parsed.code); + + // If user is logged in, show confirmation screen (still needs password) + // Otherwise, go straight to the full form + if (user?.Name && api?.basePath) { + setScreenState("confirm"); + } else { + setScreenState("form"); + } + }, + [screenState, validateAndParseQR, t, user?.Name, api?.basePath], + ); + + const handleSendCredentials = useCallback(async () => { + if ( + !serverUrl.trim() || + !username.trim() || + !password.trim() || + !pairingCode + ) { + return; + } + + setScreenState("sending"); + + try { + await sendCredentialsToTV( + pairingCode, + serverUrl.trim(), + username.trim(), + password, + ); + + setScreenState("success"); + } catch { + setErrorMessage(t("companion_login.error_generic")); + setScreenState("error"); + } + }, [pairingCode, serverUrl, username, password, t]); + + const handleScanAgain = useCallback(() => { + setPairingCode(""); + setErrorMessage(null); + setPassword(""); + setScreenState("scanning"); + }, []); + + const handleDone = useCallback(() => { + router.back(); + }, [router]); + + const handleUseDifferentUser = useCallback(() => { + setUsername(""); + setPassword(""); + setScreenState("form"); + }, []); + + const handleEnterCodeManually = useCallback(() => { + setScreenState("form"); + }, []); + + if (screenState === "no-permission") { + return ( + + + + {t("companion_login.error_permission_denied")} + + + {Platform.OS === "ios" && ( + Linking.openSettings()} + className='mt-4 rounded-lg bg-purple-600 px-6 py-3' + > + + {t("companion_login.open_settings")} + + + )} + + + + + ); + } + + if (screenState === "success") { + return ( + + + + {t("companion_login.success_title")} + + + + {t("companion_login.pairing_tv_connecting")} + + + + + + ); + } + + if (screenState === "error") { + return ( + + + + {t("companion_login.error_title")} + + + + {errorMessage} + + + + + + + + + + ); + } + + if (screenState === "sending") { + return ( + + + + {t("companion_login.authorizing")} + + + + ); + } + + if (screenState === "confirm") { + return ( + + + + {t("companion_login.login_as", { username })} + + + + {t("companion_login.on_server", { + server: serverUrl.replace(/^https?:\/\//, ""), + })} + + + + + {t("companion_login.pairing_code_label")} + + + + {pairingCode} + + + + + + {t("login.password_placeholder")} + + + + + + + + + + + + + {t("companion_login.use_different_user")} + + + + + + {t("companion_login.scan_again")} + + + + + + ); + } + + if (screenState === "form") { + return ( + + + + {t("companion_login.pairing_enter_credentials")} + + + + + {t("companion_login.pairing_code_label")} + + + + + + + + {t("companion_login.server")} + + + + + + + + {t("login.username_placeholder")} + + + + + + + + {t("login.password_placeholder")} + + + + + + + + + + + + + ); + } + + const CameraView = ExpoCamera?.CameraView; + + if (!CameraView) { + return ( + + + + ); + } + + return ( + + {/* Camera full screen */} + + + {/* Dark overlay */} + + + {/* Center scan area */} + + + + + {t("companion_login.align_qr")} + + + + + {t("companion_login.enter_code_manually")} + + + + + ); +}; diff --git a/components/downloads/DownloadCard.tsx b/components/downloads/DownloadCard.tsx index 5453f32a6..c67f60583 100644 --- a/components/downloads/DownloadCard.tsx +++ b/components/downloads/DownloadCard.tsx @@ -1,7 +1,5 @@ import { Ionicons } from "@expo/vector-icons"; -import { useQueryClient } from "@tanstack/react-query"; import { Image } from "expo-image"; -import { useRouter } from "expo-router"; import { t } from "i18next"; import { useMemo } from "react"; import { @@ -12,6 +10,8 @@ import { } from "react-native"; import { toast } from "sonner-native"; import { Text } from "@/components/common/Text"; +import useRouter from "@/hooks/useAppRouter"; +import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient"; import { useDownload } from "@/providers/DownloadProvider"; import { calculateSmoothedETA } from "@/providers/Downloads/hooks/useDownloadSpeedCalculator"; import { JobStatus } from "@/providers/Downloads/types"; @@ -37,7 +37,7 @@ interface DownloadCardProps extends TouchableOpacityProps { export const DownloadCard = ({ process, ...props }: DownloadCardProps) => { const { cancelDownload } = useDownload(); const router = useRouter(); - const queryClient = useQueryClient(); + const queryClient = useNetworkAwareQueryClient(); const handleDelete = async (id: string) => { try { @@ -116,7 +116,7 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => { }, [process?.progress]); // Return null after all hooks have been called - if (!process || !process.item || !process.item.Id) { + if (!process?.item?.Id) { return null; } diff --git a/components/downloads/EpisodeCard.tsx b/components/downloads/EpisodeCard.tsx index 3c57a17a7..f907bacfc 100644 --- a/components/downloads/EpisodeCard.tsx +++ b/components/downloads/EpisodeCard.tsx @@ -61,7 +61,6 @@ export const EpisodeCard: React.FC = ({ item }) => { return ( diff --git a/components/downloads/MovieCard.tsx b/components/downloads/MovieCard.tsx index f02fe7962..9d805ddb6 100644 --- a/components/downloads/MovieCard.tsx +++ b/components/downloads/MovieCard.tsx @@ -67,7 +67,7 @@ export const MovieCard: React.FC = ({ item }) => { }, [showActionSheetWithOptions, handleDeleteFile]); return ( - + {base64Image ? ( = ({ items }) => { const { deleteItems } = useDownload(); const { showActionSheetWithOptions } = useActionSheet(); + const router = useRouter(); + // Keyed on SeriesId so recycled FlashList cells re-read the correct poster + // instead of freezing the first-rendered series' image (empty deps bug). const base64Image = useMemo(() => { - return storage.getString(items[0].SeriesId!); - }, []); + const seriesId = items[0]?.SeriesId; + return seriesId ? storage.getString(seriesId) : undefined; + }, [items[0]?.SeriesId]); const deleteSeries = useCallback( async () => @@ -46,7 +50,12 @@ export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => { return ( router.push(`/downloads/${items[0].SeriesId}`)} + onPress={() => + router.push({ + pathname: "/series/[id]", + params: { id: items[0].SeriesId!, offline: "true" }, + }) + } onLongPress={showActionSheet} > {base64Image ? ( diff --git a/components/filters/ResetFiltersButton.tsx b/components/filters/ResetFiltersButton.tsx index c0cc6d687..856ccd3ba 100644 --- a/components/filters/ResetFiltersButton.tsx +++ b/components/filters/ResetFiltersButton.tsx @@ -2,6 +2,7 @@ import { Ionicons } from "@expo/vector-icons"; import { useAtom } from "jotai"; import { TouchableOpacity, type TouchableOpacityProps } from "react-native"; import { + filterByAtom, genreFilterAtom, tagsFilterAtom, yearFilterAtom, @@ -13,11 +14,13 @@ export const ResetFiltersButton: React.FC = ({ ...props }) => { const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom); const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom); const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom); + const [selectedFilters, setSelectedFilters] = useAtom(filterByAtom); if ( selectedGenres.length === 0 && selectedTags.length === 0 && - selectedYears.length === 0 + selectedYears.length === 0 && + selectedFilters.length === 0 ) { return null; } @@ -28,6 +31,7 @@ export const ResetFiltersButton: React.FC = ({ ...props }) => { setSelectedGenres([]); setSelectedTags([]); setSelectedYears([]); + setSelectedFilters([]); }} className='bg-purple-600 rounded-full w-[30px] h-[30px] flex items-center justify-center mr-1' {...props} diff --git a/components/home/Favorites.tsx b/components/home/Favorites.tsx index b1b32d959..84fa36b99 100644 --- a/components/home/Favorites.tsx +++ b/components/home/Favorites.tsx @@ -9,6 +9,7 @@ import { Text, View } from "react-native"; // PNG ASSET import heart from "@/assets/icons/heart.fill.png"; import { Colors } from "@/constants/Colors"; +import useRouter from "@/hooks/useAppRouter"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { InfiniteScrollingCollectionList } from "./InfiniteScrollingCollectionList"; @@ -22,8 +23,10 @@ type FavoriteTypes = type EmptyState = Record; export const Favorites = () => { + const router = useRouter(); const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); + const pageSize = 20; const [emptyState, setEmptyState] = useState({ Series: false, Movie: false, @@ -91,35 +94,77 @@ export const Favorites = () => { const fetchFavoriteSeries = useCallback( ({ pageParam }: { pageParam: number }) => - fetchFavoritesByType("Series", pageParam), - [fetchFavoritesByType], + fetchFavoritesByType("Series", pageParam, pageSize), + [fetchFavoritesByType, pageSize], ); const fetchFavoriteMovies = useCallback( ({ pageParam }: { pageParam: number }) => - fetchFavoritesByType("Movie", pageParam), - [fetchFavoritesByType], + fetchFavoritesByType("Movie", pageParam, pageSize), + [fetchFavoritesByType, pageSize], ); const fetchFavoriteEpisodes = useCallback( ({ pageParam }: { pageParam: number }) => - fetchFavoritesByType("Episode", pageParam), - [fetchFavoritesByType], + fetchFavoritesByType("Episode", pageParam, pageSize), + [fetchFavoritesByType, pageSize], ); const fetchFavoriteVideos = useCallback( ({ pageParam }: { pageParam: number }) => - fetchFavoritesByType("Video", pageParam), - [fetchFavoritesByType], + fetchFavoritesByType("Video", pageParam, pageSize), + [fetchFavoritesByType, pageSize], ); const fetchFavoriteBoxsets = useCallback( ({ pageParam }: { pageParam: number }) => - fetchFavoritesByType("BoxSet", pageParam), - [fetchFavoritesByType], + fetchFavoritesByType("BoxSet", pageParam, pageSize), + [fetchFavoritesByType, pageSize], ); const fetchFavoritePlaylists = useCallback( ({ pageParam }: { pageParam: number }) => - fetchFavoritesByType("Playlist", pageParam), - [fetchFavoritesByType], + fetchFavoritesByType("Playlist", pageParam, pageSize), + [fetchFavoritesByType, pageSize], ); + const handleSeeAllSeries = useCallback(() => { + router.push({ + pathname: "/(auth)/(tabs)/(favorites)/see-all", + params: { type: "Series", title: t("favorites.series") }, + } as any); + }, [router]); + + const handleSeeAllMovies = useCallback(() => { + router.push({ + pathname: "/(auth)/(tabs)/(favorites)/see-all", + params: { type: "Movie", title: t("favorites.movies") }, + } as any); + }, [router]); + + const handleSeeAllEpisodes = useCallback(() => { + router.push({ + pathname: "/(auth)/(tabs)/(favorites)/see-all", + params: { type: "Episode", title: t("favorites.episodes") }, + } as any); + }, [router]); + + const handleSeeAllVideos = useCallback(() => { + router.push({ + pathname: "/(auth)/(tabs)/(favorites)/see-all", + params: { type: "Video", title: t("favorites.videos") }, + } as any); + }, [router]); + + const handleSeeAllBoxsets = useCallback(() => { + router.push({ + pathname: "/(auth)/(tabs)/(favorites)/see-all", + params: { type: "BoxSet", title: t("favorites.boxsets") }, + } as any); + }, [router]); + + const handleSeeAllPlaylists = useCallback(() => { + router.push({ + pathname: "/(auth)/(tabs)/(favorites)/see-all", + params: { type: "Playlist", title: t("favorites.playlists") }, + } as any); + }, [router]); + return ( {areAllEmpty() && ( @@ -143,6 +188,8 @@ export const Favorites = () => { queryKey={["home", "favorites", "series"]} title={t("favorites.series")} hideIfEmpty + pageSize={pageSize} + onPressSeeAll={handleSeeAllSeries} /> { title={t("favorites.movies")} hideIfEmpty orientation='vertical' + pageSize={pageSize} + onPressSeeAll={handleSeeAllMovies} /> ); diff --git a/components/home/Favorites.tv.tsx b/components/home/Favorites.tv.tsx new file mode 100644 index 000000000..b76a7fe83 --- /dev/null +++ b/components/home/Favorites.tv.tsx @@ -0,0 +1,231 @@ +import type { Api } from "@jellyfin/sdk"; +import type { BaseItemKind } from "@jellyfin/sdk/lib/generated-client"; +import { getItemsApi } from "@jellyfin/sdk/lib/utils/api"; +import { Image } from "expo-image"; +import { useAtom } from "jotai"; +import { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { ScrollView, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import heart from "@/assets/icons/heart.fill.png"; +import { Text } from "@/components/common/Text"; +import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList.tv"; +import { Colors } from "@/constants/Colors"; +import { useScaledTVTypography } from "@/constants/TVTypography"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; + +const HORIZONTAL_PADDING = 60; +const TOP_PADDING = 100; +const SECTION_GAP = 10; + +type FavoriteTypes = + | "Series" + | "Movie" + | "Episode" + | "Video" + | "BoxSet" + | "Playlist"; +type EmptyState = Record; + +export const Favorites = () => { + const typography = useScaledTVTypography(); + const { t } = useTranslation(); + const insets = useSafeAreaInsets(); + const [api] = useAtom(apiAtom); + const [user] = useAtom(userAtom); + const pageSize = 20; + const [emptyState, setEmptyState] = useState({ + Series: false, + Movie: false, + Episode: false, + Video: false, + BoxSet: false, + Playlist: false, + }); + + const fetchFavoritesByType = useCallback( + async ( + itemType: BaseItemKind, + startIndex: number = 0, + limit: number = 20, + ) => { + const response = await getItemsApi(api as Api).getItems({ + userId: user?.Id, + sortBy: ["SeriesSortName", "SortName"], + sortOrder: ["Ascending"], + filters: ["IsFavorite"], + recursive: true, + fields: ["PrimaryImageAspectRatio"], + collapseBoxSetItems: false, + excludeLocationTypes: ["Virtual"], + enableTotalRecordCount: false, + startIndex: startIndex, + limit: limit, + includeItemTypes: [itemType], + }); + const items = response.data.Items || []; + + if (startIndex === 0) { + setEmptyState((prev) => ({ + ...prev, + [itemType as FavoriteTypes]: items.length === 0, + })); + } + + return items; + }, + [api, user], + ); + + useEffect(() => { + setEmptyState({ + Series: false, + Movie: false, + Episode: false, + Video: false, + BoxSet: false, + Playlist: false, + }); + }, [api, user]); + + const areAllEmpty = () => { + const loadedCategories = Object.values(emptyState); + return ( + loadedCategories.length > 0 && + loadedCategories.every((isEmpty) => isEmpty) + ); + }; + + const fetchFavoriteSeries = useCallback( + ({ pageParam }: { pageParam: number }) => + fetchFavoritesByType("Series", pageParam, pageSize), + [fetchFavoritesByType, pageSize], + ); + const fetchFavoriteMovies = useCallback( + ({ pageParam }: { pageParam: number }) => + fetchFavoritesByType("Movie", pageParam, pageSize), + [fetchFavoritesByType, pageSize], + ); + const fetchFavoriteEpisodes = useCallback( + ({ pageParam }: { pageParam: number }) => + fetchFavoritesByType("Episode", pageParam, pageSize), + [fetchFavoritesByType, pageSize], + ); + const fetchFavoriteVideos = useCallback( + ({ pageParam }: { pageParam: number }) => + fetchFavoritesByType("Video", pageParam, pageSize), + [fetchFavoritesByType, pageSize], + ); + const fetchFavoriteBoxsets = useCallback( + ({ pageParam }: { pageParam: number }) => + fetchFavoritesByType("BoxSet", pageParam, pageSize), + [fetchFavoritesByType, pageSize], + ); + const fetchFavoritePlaylists = useCallback( + ({ pageParam }: { pageParam: number }) => + fetchFavoritesByType("Playlist", pageParam, pageSize), + [fetchFavoritesByType, pageSize], + ); + + if (areAllEmpty()) { + return ( + + + + {t("favorites.noDataTitle")} + + + {t("favorites.noData")} + + + ); + } + + return ( + + + + + + + + + + + ); +}; diff --git a/components/home/Home.tsx b/components/home/Home.tsx index 5803083d5..060dafedb 100644 --- a/components/home/Home.tsx +++ b/components/home/Home.tsx @@ -12,7 +12,7 @@ import { getUserViewsApi, } from "@jellyfin/sdk/lib/utils/api"; import { type QueryFunction, useQuery } from "@tanstack/react-query"; -import { useNavigation, useRouter, useSegments } from "expo-router"; +import { useNavigation, useSegments } from "expo-router"; import { useAtomValue } from "jotai"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -21,22 +21,32 @@ import { Platform, RefreshControl, ScrollView, - TouchableOpacity, View, } from "react-native"; +import { Pressable } from "react-native-gesture-handler"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Button } from "@/components/Button"; import { Text } from "@/components/common/Text"; import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList"; +import { StreamystatsPromotedWatchlists } from "@/components/home/StreamystatsPromotedWatchlists"; +import { StreamystatsRecommendations } from "@/components/home/StreamystatsRecommendations"; import { Loader } from "@/components/Loader"; import { MediaListSection } from "@/components/medialists/MediaListSection"; import { Colors } from "@/constants/Colors"; +import useRouter from "@/hooks/useAppRouter"; import { useNetworkStatus } from "@/hooks/useNetworkStatus"; +import { useRefreshLibraryOnFocus } from "@/hooks/useRefreshLibraryOnFocus"; import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache"; import { useDownload } from "@/providers/DownloadProvider"; +import { useIntroSheet } from "@/providers/IntroSheetProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { SortByOption, SortOrderOption } from "@/utils/atoms/filters"; import { useSettings } from "@/utils/atoms/settings"; import { eventBus } from "@/utils/eventBus"; +import { storage } from "@/utils/mmkv"; + +// Conditionally load TV version +const HomeTV = Platform.isTV ? require("./Home.tv").Home : null; type InfiniteScrollingCollectionListSection = { type: "InfiniteScrollingCollectionList"; @@ -45,17 +55,20 @@ type InfiniteScrollingCollectionListSection = { queryFn: QueryFunction; orientation?: "horizontal" | "vertical"; pageSize?: number; + priority?: 1 | 2; // 1 = high priority (loads first), 2 = low priority + parentId?: string; // Library ID for "See All" navigation }; type MediaListSectionType = { type: "MediaListSection"; queryKey: (string | undefined)[]; queryFn: QueryFunction; + priority?: 1 | 2; }; type Section = InfiniteScrollingCollectionListSection | MediaListSectionType; -export const Home = () => { +const HomeMobile = () => { const router = useRouter(); const { t } = useTranslation(); const api = useAtomValue(apiAtom); @@ -74,7 +87,26 @@ export const Home = () => { retryCheck, } = useNetworkStatus(); const invalidateCache = useInvalidatePlaybackProgressCache(); - const [scrollY, setScrollY] = useState(0); + const [loadedSections, setLoadedSections] = useState>(new Set()); + const { showIntro } = useIntroSheet(); + + // Fallback refresh for newly added content when returning to the home screen + // (primary path is the LibraryChanged WebSocket event). + useRefreshLibraryOnFocus(); + + // Show intro modal on first launch + useEffect(() => { + const hasShownIntro = storage.getBoolean("hasShownIntro"); + if (!hasShownIntro) { + const timer = setTimeout(() => { + showIntro(); + }, 1000); + + return () => { + clearTimeout(timer); + }; + } + }, [showIntro]); useEffect(() => { if (isConnected && !prevIsConnected.current) { @@ -97,11 +129,10 @@ export const Home = () => { } navigation.setOptions({ headerLeft: () => ( - { router.push("/(auth)/downloads"); }} - className='ml-1.5' style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} > { color={hasDownloads ? Colors.primary : "white"} size={24} /> - + ), }); }, [navigation, router, hasDownloads]); @@ -172,6 +203,7 @@ export const Home = () => { const refetch = async () => { setLoading(true); + setLoadedSections(new Set()); await refreshStreamyfinPluginSettings(); await invalidateCache(); setLoading(false); @@ -194,10 +226,10 @@ export const Home = () => { ( await getUserLibraryApi(api).getLatestMedia({ userId: user?.Id, - limit: 100, // Fetch a larger set for pagination - fields: ["PrimaryImageAspectRatio", "Path", "Genres"], + limit: 10, + fields: ["PrimaryImageAspectRatio"], imageTypeLimit: 1, - enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"], + enableImageTypes: ["Primary", "Backdrop", "Thumb"], includeItemTypes, parentId, }) @@ -208,6 +240,7 @@ export const Home = () => { }, type: "InfiniteScrollingCollectionList", pageSize, + parentId, }), [api, user?.Id], ); @@ -236,64 +269,143 @@ export const Home = () => { ); }); + // Helper to sort items by most recent activity + const sortByRecentActivity = (items: BaseItemDto[]): BaseItemDto[] => { + return items.sort((a, b) => { + const dateA = a.UserData?.LastPlayedDate || a.DateCreated || ""; + const dateB = b.UserData?.LastPlayedDate || b.DateCreated || ""; + return new Date(dateB).getTime() - new Date(dateA).getTime(); + }); + }; + + // Helper to deduplicate items by ID + const deduplicateById = (items: BaseItemDto[]): BaseItemDto[] => { + const seen = new Set(); + return items.filter((item) => { + if (!item.Id || seen.has(item.Id)) return false; + seen.add(item.Id); + return true; + }); + }; + + // Build the first sections based on merge setting + const firstSections: Section[] = settings.mergeNextUpAndContinueWatching + ? [ + { + title: t("home.continue_and_next_up"), + queryKey: ["home", "continueAndNextUp"], + queryFn: async ({ pageParam = 0 }) => { + // Fetch both in parallel + const [resumeResponse, nextUpResponse] = await Promise.all([ + getItemsApi(api).getResumeItems({ + userId: user.Id, + enableImageTypes: ["Primary", "Backdrop", "Thumb"], + includeItemTypes: ["Movie", "Series", "Episode"], + startIndex: 0, + limit: 20, + }), + getTvShowsApi(api).getNextUp({ + userId: user?.Id, + startIndex: 0, + limit: 20, + enableImageTypes: ["Primary", "Backdrop", "Thumb"], + enableResumable: false, + }), + ]); + + const resumeItems = resumeResponse.data.Items || []; + const nextUpItems = nextUpResponse.data.Items || []; + + // Combine, sort by recent activity, deduplicate + const combined = [...resumeItems, ...nextUpItems]; + const sorted = sortByRecentActivity(combined); + const deduplicated = deduplicateById(sorted); + + // Paginate client-side + return deduplicated.slice(pageParam, pageParam + 10); + }, + type: "InfiniteScrollingCollectionList", + orientation: "horizontal", + pageSize: 10, + priority: 1, + }, + ] + : [ + { + title: t("home.continue_watching"), + queryKey: ["home", "resumeItems"], + queryFn: async ({ pageParam = 0 }) => + ( + await getItemsApi(api).getResumeItems({ + userId: user.Id, + enableImageTypes: ["Primary", "Backdrop", "Thumb"], + includeItemTypes: ["Movie", "Series", "Episode"], + startIndex: pageParam, + limit: 10, + }) + ).data.Items || [], + type: "InfiniteScrollingCollectionList", + orientation: "horizontal", + pageSize: 10, + priority: 1, + }, + { + title: t("home.next_up"), + queryKey: ["home", "nextUp-all"], + queryFn: async ({ pageParam = 0 }) => + ( + await getTvShowsApi(api).getNextUp({ + userId: user?.Id, + startIndex: pageParam, + limit: 10, + enableImageTypes: ["Primary", "Backdrop", "Thumb"], + enableResumable: false, + }) + ).data.Items || [], + type: "InfiniteScrollingCollectionList", + orientation: "horizontal", + pageSize: 10, + priority: 1, + }, + ]; + const ss: Section[] = [ - { - title: t("home.continue_watching"), - queryKey: ["home", "resumeItems"], - queryFn: async ({ pageParam = 0 }) => - ( - await getItemsApi(api).getResumeItems({ - userId: user.Id, - enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"], - includeItemTypes: ["Movie", "Series", "Episode"], - fields: ["Genres"], - startIndex: pageParam, - limit: 10, - }) - ).data.Items || [], - type: "InfiniteScrollingCollectionList", - orientation: "horizontal", - pageSize: 10, - }, - { - title: t("home.next_up"), - queryKey: ["home", "nextUp-all"], - queryFn: async ({ pageParam = 0 }) => - ( - await getTvShowsApi(api).getNextUp({ - userId: user?.Id, - fields: ["MediaSourceCount", "Genres"], - startIndex: pageParam, - limit: 10, - enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"], - enableResumable: false, - }) - ).data.Items || [], - type: "InfiniteScrollingCollectionList", - orientation: "horizontal", - pageSize: 10, - }, - ...latestMediaViews, - { - title: t("home.suggested_movies"), - queryKey: ["home", "suggestedMovies", user?.Id], - queryFn: async ({ pageParam = 0 }) => - ( - await getSuggestionsApi(api).getSuggestions({ - userId: user?.Id, - startIndex: pageParam, - limit: 10, - mediaType: ["Video"], - type: ["Movie"], - }) - ).data.Items || [], - type: "InfiniteScrollingCollectionList", - orientation: "vertical", - pageSize: 10, - }, + ...firstSections, + ...latestMediaViews.map((s) => ({ ...s, priority: 2 as const })), + // Only show Jellyfin suggested movies if StreamyStats recommendations are disabled + ...(!settings?.streamyStatsMovieRecommendations + ? [ + { + title: t("home.suggested_movies"), + queryKey: ["home", "suggestedMovies", user?.Id], + queryFn: async ({ pageParam = 0 }: { pageParam?: number }) => + ( + await getSuggestionsApi(api).getSuggestions({ + userId: user?.Id, + startIndex: pageParam, + limit: 10, + mediaType: ["Video"], + type: ["Movie"], + }) + ).data.Items || [], + type: "InfiniteScrollingCollectionList" as const, + orientation: "vertical" as const, + pageSize: 10, + priority: 2 as const, + }, + ] + : []), ]; return ss; - }, [api, user?.Id, collections, t, createCollectionConfig]); + }, [ + api, + user?.Id, + collections, + t, + createCollectionConfig, + settings?.streamyStatsMovieRecommendations, + settings.mergeNextUpAndContinueWatching, + ]); const customSections = useMemo(() => { if (!api || !user?.Id || !settings?.home?.sections) return []; @@ -322,10 +434,9 @@ export const Home = () => { if (section.nextUp) { const response = await getTvShowsApi(api).getNextUp({ userId: user?.Id, - fields: ["MediaSourceCount", "Genres"], startIndex: pageParam, limit: section.nextUp?.limit || pageSize, - enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"], + enableImageTypes: ["Primary", "Backdrop", "Thumb"], enableResumable: section.nextUp?.enableResumable, enableRewatching: section.nextUp?.enableRewatching, }); @@ -338,7 +449,7 @@ export const Home = () => { await getUserLibraryApi(api).getLatestMedia({ userId: user?.Id, includeItemTypes: section.latest?.includeItemTypes, - limit: section.latest?.limit || 100, // Fetch larger set + limit: section.latest?.limit || 10, isPlayed: section.latest?.isPlayed, groupItems: section.latest?.groupItems, }) @@ -367,6 +478,8 @@ export const Home = () => { type: "InfiniteScrollingCollectionList", orientation: section?.orientation || "vertical", pageSize, + // First 2 custom sections are high priority + priority: index < 2 ? 1 : 2, }); }); return ss; @@ -374,6 +487,25 @@ export const Home = () => { const sections = settings?.home?.sections ? customSections : defaultSections; + // Get all high priority section keys and check if all have loaded + const highPrioritySectionKeys = useMemo(() => { + return sections + .filter((s) => s.priority === 1) + .map((s) => s.queryKey.join("-")); + }, [sections]); + + const allHighPriorityLoaded = useMemo(() => { + return highPrioritySectionKeys.every((key) => loadedSections.has(key)); + }, [highPrioritySectionKeys, loadedSections]); + + const markSectionLoaded = useCallback( + (queryKey: (string | undefined | null)[]) => { + const key = queryKey.join("-"); + setLoadedSections((prev) => new Set(prev).add(key)); + }, + [], + ); + if (!isConnected || serverConnected !== true) { let title = ""; let subtitle = ""; @@ -451,10 +583,6 @@ export const Home = () => { ref={scrollRef} nestedScrollEnabled contentInsetAdjustmentBehavior='automatic' - onScroll={(event) => { - setScrollY(event.nativeEvent.contentOffset.y - 500); - }} - scrollEventThrottle={16} refreshControl={ { style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }} > {sections.map((section, index) => { + // Render Streamystats sections after Recently Added sections + // For default sections: place after Recently Added, before Suggested Movies (if present) + // For custom sections: place at the very end + const hasSuggestedMovies = + !settings?.streamyStatsMovieRecommendations && + !settings?.home?.sections; + const streamystatsIndex = + sections.length - 1 - (hasSuggestedMovies ? 1 : 0); + const hasStreamystatsContent = + settings.streamyStatsMovieRecommendations || + settings.streamyStatsSeriesRecommendations || + settings.streamyStatsPromotedWatchlists; + const streamystatsSections = + index === streamystatsIndex && hasStreamystatsContent ? ( + + {settings.streamyStatsMovieRecommendations && ( + + )} + {settings.streamyStatsSeriesRecommendations && ( + + )} + {settings.streamyStatsPromotedWatchlists && ( + + )} + + ) : null; if (section.type === "InfiniteScrollingCollectionList") { + const isHighPriority = section.priority === 1; + const handleSeeAll = section.parentId + ? () => { + router.push({ + pathname: "/(auth)/(tabs)/(libraries)/[libraryId]", + params: { + libraryId: section.parentId!, + sortBy: SortByOption.DateCreated, + sortOrder: SortOrderOption.Descending, + }, + } as any); + } + : undefined; return ( - + + markSectionLoaded(section.queryKey) + : undefined + } + onPressSeeAll={handleSeeAll} + /> + {streamystatsSections} + ); } if (section.type === "MediaListSection") { return ( - + + + {streamystatsSections} + ); } return null; @@ -504,3 +697,11 @@ export const Home = () => { ); }; + +// Exported component that renders TV or mobile version based on platform +export const Home = () => { + if (Platform.isTV && HomeTV) { + return ; + } + return ; +}; diff --git a/components/home/Home.tv.tsx b/components/home/Home.tv.tsx new file mode 100644 index 000000000..8b20fe288 --- /dev/null +++ b/components/home/Home.tv.tsx @@ -0,0 +1,839 @@ +import { Ionicons } from "@expo/vector-icons"; +import type { + BaseItemDto, + BaseItemDtoQueryResult, + BaseItemKind, +} from "@jellyfin/sdk/lib/generated-client/models"; +import { + getItemsApi, + getSuggestionsApi, + getTvShowsApi, + getUserLibraryApi, + getUserViewsApi, +} from "@jellyfin/sdk/lib/utils/api"; +import { type QueryFunction, useQuery } from "@tanstack/react-query"; +import { Image } from "expo-image"; +import { LinearGradient } from "expo-linear-gradient"; +import { useAtomValue } from "jotai"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + ActivityIndicator, + Animated, + Easing, + ScrollView, + View, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Button } from "@/components/Button"; +import { Text } from "@/components/common/Text"; +import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList.tv"; +import { StreamystatsPromotedWatchlists } from "@/components/home/StreamystatsPromotedWatchlists.tv"; +import { StreamystatsRecommendations } from "@/components/home/StreamystatsRecommendations.tv"; +import { TVHeroCarousel } from "@/components/home/TVHeroCarousel"; +import { Loader } from "@/components/Loader"; +import { useScaledTVTypography } from "@/constants/TVTypography"; +import useRouter from "@/hooks/useAppRouter"; +import { useNetworkStatus } from "@/hooks/useNetworkStatus"; +import { useRefreshLibraryOnFocus } from "@/hooks/useRefreshLibraryOnFocus"; +import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache"; +import { useTVItemActionModal } from "@/hooks/useTVItemActionModal"; +import { + apiAtom, + cacheVersionAtom, + userAtom, +} from "@/providers/JellyfinProvider"; +import { useSettings } from "@/utils/atoms/settings"; +import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl"; +import { scaleSize } from "@/utils/scaleSize"; +import { updateTVDiscovery } from "@/utils/tvDiscovery/sync"; + +const HORIZONTAL_PADDING = scaleSize(60); +const TOP_PADDING = scaleSize(100); +// Generous gap between sections for Apple TV+ aesthetic +const SECTION_GAP = scaleSize(24); + +type InfiniteScrollingCollectionListSection = { + type: "InfiniteScrollingCollectionList"; + title?: string; + queryKey: (string | undefined | null)[]; + queryFn: QueryFunction; + orientation?: "horizontal" | "vertical"; + pageSize?: number; + parentId?: string; +}; + +type Section = InfiniteScrollingCollectionListSection; + +// Debounce delay in ms - prevents rapid backdrop changes when scrolling fast +const BACKDROP_DEBOUNCE_MS = 300; + +export const Home = () => { + const typography = useScaledTVTypography(); + const _router = useRouter(); + const { t } = useTranslation(); + const api = useAtomValue(apiAtom); + const user = useAtomValue(userAtom); + const cacheVersion = useAtomValue(cacheVersionAtom); + const insets = useSafeAreaInsets(); + const { settings } = useSettings(); + const scrollRef = useRef(null); + const { + isConnected, + serverConnected, + loading: retryLoading, + retryCheck, + } = useNetworkStatus(); + const _invalidateCache = useInvalidatePlaybackProgressCache(); + const { showItemActions } = useTVItemActionModal(); + + // Fallback refresh for newly added content when returning to the home screen + // (primary path is the LibraryChanged WebSocket event). + useRefreshLibraryOnFocus(); + + // Dynamic backdrop state with debounce + const [focusedItem, setFocusedItem] = useState(null); + const debounceTimerRef = useRef | null>(null); + + // Handle item focus with debounce + const handleItemFocus = useCallback((item: BaseItemDto) => { + // Clear any pending debounce timer + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + // Set new timer to update focused item after debounce delay + debounceTimerRef.current = setTimeout(() => { + setFocusedItem(item); + }, BACKDROP_DEBOUNCE_MS); + }, []); + + // Cleanup debounce timer on unmount + useEffect(() => { + return () => { + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + }; + }, []); + + // Get backdrop URL from focused item (only if setting is enabled) + const backdropUrl = useMemo(() => { + if (!settings.showHomeBackdrop || !focusedItem) return null; + return getBackdropUrl({ + api, + item: focusedItem, + quality: 90, + width: 1920, + }); + }, [api, focusedItem, settings.showHomeBackdrop]); + + // Crossfade animation for backdrop transitions + const [activeLayer, setActiveLayer] = useState<0 | 1>(0); + const [layer0Url, setLayer0Url] = useState(null); + const [layer1Url, setLayer1Url] = useState(null); + const layer0Opacity = useRef(new Animated.Value(0)).current; + const layer1Opacity = useRef(new Animated.Value(0)).current; + + useEffect(() => { + if (!backdropUrl) return; + + let isCancelled = false; + + const performCrossfade = async () => { + // Prefetch to disk only - the full-size 1920x1080 backdrop (~8MB + // decoded ARGB) is too large to pin in the memory cache on every + // focus change. Disk cache is fast enough for a 500ms crossfade. + try { + await Image.prefetch(backdropUrl, "disk"); + } catch { + // Continue even if prefetch fails + } + + if (isCancelled) return; + + // Determine which layer to fade in + const incomingLayer = activeLayer === 0 ? 1 : 0; + const incomingOpacity = + incomingLayer === 0 ? layer0Opacity : layer1Opacity; + const outgoingOpacity = + incomingLayer === 0 ? layer1Opacity : layer0Opacity; + + // Set the new URL on the incoming layer + if (incomingLayer === 0) { + setLayer0Url(backdropUrl); + } else { + setLayer1Url(backdropUrl); + } + + // Small delay to ensure image component has the new URL + await new Promise((resolve) => setTimeout(resolve, 50)); + + if (isCancelled) return; + + // Crossfade: fade in the incoming layer, fade out the outgoing + Animated.parallel([ + Animated.timing(incomingOpacity, { + toValue: 1, + duration: 500, + easing: Easing.inOut(Easing.quad), + useNativeDriver: true, + }), + Animated.timing(outgoingOpacity, { + toValue: 0, + duration: 500, + easing: Easing.inOut(Easing.quad), + useNativeDriver: true, + }), + ]).start(() => { + if (!isCancelled) { + setActiveLayer(incomingLayer); + } + }); + }; + + performCrossfade(); + + return () => { + isCancelled = true; + }; + }, [backdropUrl]); + + const { + data, + isError: e1, + isLoading: l1, + } = useQuery({ + queryKey: ["home", "userViews", user?.Id], + queryFn: async () => { + if (!api || !user?.Id) { + return null; + } + + const response = await getUserViewsApi(api).getUserViews({ + userId: user.Id, + }); + + return response.data.Items || null; + }, + enabled: !!api && !!user?.Id, + staleTime: 60 * 1000, + refetchInterval: 60 * 1000, + }); + + // Fetch hero items (Continue Watching + Next Up combined) + const { data: heroItems } = useQuery({ + queryKey: ["home", "heroItems", user?.Id], + queryFn: async () => { + if (!api || !user?.Id) return []; + + const [resumeResponse, nextUpResponse] = await Promise.all([ + getItemsApi(api).getResumeItems({ + userId: user.Id, + enableImageTypes: ["Primary", "Backdrop", "Thumb"], + includeItemTypes: ["Movie", "Series", "Episode"], + fields: ["Overview"], + startIndex: 0, + limit: 10, + }), + getTvShowsApi(api).getNextUp({ + userId: user.Id, + startIndex: 0, + limit: 10, + fields: ["Overview"], + enableImageTypes: ["Primary", "Backdrop", "Thumb"], + enableResumable: false, + }), + ]); + + const resumeItems = resumeResponse.data.Items || []; + const nextUpItems = nextUpResponse.data.Items || []; + + // Combine, sort by recent activity, and dedupe + const combined = [...resumeItems, ...nextUpItems]; + const sorted = combined.sort((a, b) => { + const dateA = a.UserData?.LastPlayedDate || a.DateCreated || ""; + const dateB = b.UserData?.LastPlayedDate || b.DateCreated || ""; + return new Date(dateB).getTime() - new Date(dateA).getTime(); + }); + + const seen = new Set(); + const deduped: BaseItemDto[] = []; + for (const item of sorted) { + if (!item.Id || seen.has(item.Id)) continue; + seen.add(item.Id); + deduped.push(item); + } + + return deduped.slice(0, 15); + }, + enabled: !!api && !!user?.Id, + staleTime: 60 * 1000, + refetchInterval: 60 * 1000, + }); + + useEffect(() => { + updateTVDiscovery({ + api, + sections: [ + { + title: t("home.continue_and_next_up"), + items: heroItems, + }, + ], + }); + }, [api, heroItems, t]); + + const userViews = useMemo( + () => data?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!)), + [data, settings?.hiddenLibraries], + ); + + const collections = useMemo(() => { + const allow = ["movies", "tvshows"]; + return ( + userViews?.filter( + (c) => c.CollectionType && allow.includes(c.CollectionType), + ) || [] + ); + }, [userViews]); + + const createCollectionConfig = useCallback( + ( + title: string, + queryKey: string[], + includeItemTypes: BaseItemKind[], + parentId: string | undefined, + pageSize = 10, + ): InfiniteScrollingCollectionListSection => ({ + title, + queryKey, + queryFn: async ({ pageParam = 0 }) => { + if (!api) return []; + const allData = + ( + await getUserLibraryApi(api).getLatestMedia({ + userId: user?.Id, + limit: 10, + fields: ["PrimaryImageAspectRatio"], + imageTypeLimit: 1, + enableImageTypes: ["Primary", "Backdrop", "Thumb"], + includeItemTypes, + parentId, + }) + ).data || []; + + return allData.slice(pageParam, pageParam + pageSize); + }, + type: "InfiniteScrollingCollectionList", + pageSize, + parentId, + }), + [api, user?.Id], + ); + + const defaultSections = useMemo(() => { + if (!api || !user?.Id) return []; + + const latestMediaViews = collections.map((c) => { + const includeItemTypes: BaseItemKind[] = + c.CollectionType === "tvshows" || c.CollectionType === "movies" + ? [] + : ["Movie"]; + const title = t("home.recently_added_in", { libraryName: c.Name }); + const queryKey: string[] = [ + "home", + `recentlyAddedIn${c.CollectionType}`, + user.Id!, + c.Id!, + ]; + return createCollectionConfig( + title || "", + queryKey, + includeItemTypes, + c.Id, + 10, + ); + }); + + const sortByRecentActivity = (items: BaseItemDto[]): BaseItemDto[] => { + return items.sort((a, b) => { + const dateA = a.UserData?.LastPlayedDate || a.DateCreated || ""; + const dateB = b.UserData?.LastPlayedDate || b.DateCreated || ""; + return new Date(dateB).getTime() - new Date(dateA).getTime(); + }); + }; + + const deduplicateById = (items: BaseItemDto[]): BaseItemDto[] => { + const seen = new Set(); + return items.filter((item) => { + if (!item.Id || seen.has(item.Id)) return false; + seen.add(item.Id); + return true; + }); + }; + + const firstSections: Section[] = settings.mergeNextUpAndContinueWatching + ? [ + { + title: t("home.continue_and_next_up"), + queryKey: ["home", "continueAndNextUp"], + queryFn: async ({ pageParam = 0 }) => { + const [resumeResponse, nextUpResponse] = await Promise.all([ + getItemsApi(api).getResumeItems({ + userId: user.Id, + enableImageTypes: ["Primary", "Backdrop", "Thumb"], + includeItemTypes: ["Movie", "Series", "Episode"], + startIndex: 0, + limit: 20, + }), + getTvShowsApi(api).getNextUp({ + userId: user?.Id, + startIndex: 0, + limit: 20, + enableImageTypes: ["Primary", "Backdrop", "Thumb"], + enableResumable: false, + }), + ]); + + const resumeItems = resumeResponse.data.Items || []; + const nextUpItems = nextUpResponse.data.Items || []; + + const combined = [...resumeItems, ...nextUpItems]; + const sorted = sortByRecentActivity(combined); + const deduplicated = deduplicateById(sorted); + + return deduplicated.slice(pageParam, pageParam + 10); + }, + type: "InfiniteScrollingCollectionList", + orientation: "horizontal", + pageSize: 10, + }, + ] + : [ + { + title: t("home.continue_watching"), + queryKey: ["home", "resumeItems"], + queryFn: async ({ pageParam = 0 }) => + ( + await getItemsApi(api).getResumeItems({ + userId: user.Id, + enableImageTypes: ["Primary", "Backdrop", "Thumb"], + includeItemTypes: ["Movie", "Series", "Episode"], + startIndex: pageParam, + limit: 10, + }) + ).data.Items || [], + type: "InfiniteScrollingCollectionList", + orientation: "horizontal", + pageSize: 10, + }, + { + title: t("home.next_up"), + queryKey: ["home", "nextUp-all"], + queryFn: async ({ pageParam = 0 }) => + ( + await getTvShowsApi(api).getNextUp({ + userId: user?.Id, + startIndex: pageParam, + limit: 10, + enableImageTypes: ["Primary", "Backdrop", "Thumb"], + enableResumable: false, + }) + ).data.Items || [], + type: "InfiniteScrollingCollectionList", + orientation: "horizontal", + pageSize: 10, + }, + ]; + + const ss: Section[] = [ + ...firstSections, + ...latestMediaViews, + ...(!settings?.streamyStatsMovieRecommendations + ? [ + { + title: t("home.suggested_movies"), + queryKey: ["home", "suggestedMovies", user?.Id], + queryFn: async ({ pageParam = 0 }: { pageParam?: number }) => + ( + await getSuggestionsApi(api).getSuggestions({ + userId: user?.Id, + startIndex: pageParam, + limit: 10, + mediaType: ["Video"], + type: ["Movie"], + }) + ).data.Items || [], + type: "InfiniteScrollingCollectionList" as const, + orientation: "vertical" as const, + pageSize: 10, + }, + ] + : []), + ]; + return ss; + }, [ + api, + user?.Id, + collections, + t, + createCollectionConfig, + settings?.streamyStatsMovieRecommendations, + settings.mergeNextUpAndContinueWatching, + ]); + + const customSections = useMemo(() => { + if (!api || !user?.Id || !settings?.home?.sections) return []; + const ss: Section[] = []; + settings.home.sections.forEach((section, index) => { + const id = section.title || `section-${index}`; + const pageSize = 10; + ss.push({ + title: t(`${id}`), + queryKey: ["home", "custom", String(index), section.title ?? null], + queryFn: async ({ pageParam = 0 }) => { + if (section.items) { + const response = await getItemsApi(api).getItems({ + userId: user?.Id, + startIndex: pageParam, + limit: section.items?.limit || pageSize, + recursive: true, + includeItemTypes: section.items?.includeItemTypes, + sortBy: section.items?.sortBy, + sortOrder: section.items?.sortOrder, + filters: section.items?.filters, + parentId: section.items?.parentId, + }); + return response.data.Items || []; + } + if (section.nextUp) { + const response = await getTvShowsApi(api).getNextUp({ + userId: user?.Id, + startIndex: pageParam, + limit: section.nextUp?.limit || pageSize, + enableImageTypes: ["Primary", "Backdrop", "Thumb"], + enableResumable: section.nextUp?.enableResumable, + enableRewatching: section.nextUp?.enableRewatching, + }); + return response.data.Items || []; + } + if (section.latest) { + const allData = + ( + await getUserLibraryApi(api).getLatestMedia({ + userId: user?.Id, + includeItemTypes: section.latest?.includeItemTypes, + limit: section.latest?.limit || 10, + isPlayed: section.latest?.isPlayed, + groupItems: section.latest?.groupItems, + }) + ).data || []; + + return allData.slice(pageParam, pageParam + pageSize); + } + if (section.custom) { + const response = await api.get( + section.custom.endpoint, + { + params: { + ...(section.custom.query || {}), + userId: user?.Id, + startIndex: pageParam, + limit: pageSize, + }, + headers: section.custom.headers || {}, + }, + ); + return response.data.Items || []; + } + return []; + }, + type: "InfiniteScrollingCollectionList", + orientation: section?.orientation || "vertical", + pageSize, + }); + }); + return ss; + }, [api, user?.Id, settings?.home?.sections, t]); + + const sections = settings?.home?.sections ? customSections : defaultSections; + + // Determine if hero should be shown (separate setting from backdrop) + // We need this early to calculate which sections will actually be rendered + const showHero = useMemo(() => { + return heroItems && heroItems.length > 0 && settings.showTVHeroCarousel; + }, [heroItems, settings.showTVHeroCarousel]); + + // Get sections that will actually be rendered (accounting for hero slicing) + // When hero is shown, skip the first sections since hero already displays that content + // - If mergeNextUpAndContinueWatching: skip 1 section (combined Continue & Next Up) + // - Otherwise: skip 2 sections (separate Continue Watching + Next Up) + const renderedSections = useMemo(() => { + if (!showHero) return sections; + const sectionsToSkip = settings.mergeNextUpAndContinueWatching ? 1 : 2; + return sections.slice(sectionsToSkip); + }, [sections, showHero, settings.mergeNextUpAndContinueWatching]); + + if (!isConnected || serverConnected !== true) { + let title = ""; + let subtitle = ""; + + if (!isConnected) { + title = t("home.no_internet"); + subtitle = t("home.no_internet_message"); + } else if (serverConnected === null) { + title = t("home.checking_server_connection"); + subtitle = t("home.checking_server_connection_message"); + } else if (!serverConnected) { + title = t("home.server_unreachable"); + subtitle = t("home.server_unreachable_message"); + } + return ( + + + {title} + + + {subtitle} + + + + + + + ); + } + + if (e1) + return ( + + + {t("home.oops")} + + + {t("home.error_message")} + + + ); + + if (l1) + return ( + + + + ); + + return ( + + {/* Dynamic backdrop with crossfade - only shown when hero is disabled */} + {!showHero && settings.showHomeBackdrop && ( + + {/* Layer 0 */} + + {layer0Url && ( + + )} + + {/* Layer 1 */} + + {layer1Url && ( + + )} + + {/* Gradient overlays for readability */} + + + )} + + + {/* Hero Carousel - Apple TV+ style featured content */} + {showHero && heroItems && ( + + )} + + + {/* Skip first section (Continue Watching) when hero is shown since hero displays that content */} + {renderedSections.map((section, index) => { + // Render Streamystats sections after Recently Added sections + // For default sections: place after Recently Added, before Suggested Movies (if present) + // For custom sections: place at the very end + const hasSuggestedMovies = + !settings?.streamyStatsMovieRecommendations && + !settings?.home?.sections; + const displayedSectionsLength = renderedSections.length; + const streamystatsIndex = + displayedSectionsLength - 1 - (hasSuggestedMovies ? 1 : 0); + const hasStreamystatsContent = + settings.streamyStatsMovieRecommendations || + settings.streamyStatsSeriesRecommendations || + settings.streamyStatsPromotedWatchlists; + const streamystatsSections = + index === streamystatsIndex && hasStreamystatsContent ? ( + + {settings.streamyStatsMovieRecommendations && ( + + )} + {settings.streamyStatsSeriesRecommendations && ( + + )} + {settings.streamyStatsPromotedWatchlists && ( + + )} + + ) : null; + + if (section.type === "InfiniteScrollingCollectionList") { + // First section only gets preferred focus if hero is not shown + const isFirstSection = index === 0 && !showHero; + return ( + + + {streamystatsSections} + + ); + } + return null; + })} + + + + ); +}; diff --git a/components/home/HomeWithCarousel.tsx b/components/home/HomeWithCarousel.tsx deleted file mode 100644 index 43e7fefbe..000000000 --- a/components/home/HomeWithCarousel.tsx +++ /dev/null @@ -1,511 +0,0 @@ -import { Feather, Ionicons } from "@expo/vector-icons"; -import type { - BaseItemDto, - BaseItemDtoQueryResult, - BaseItemKind, -} from "@jellyfin/sdk/lib/generated-client/models"; -import { - getItemsApi, - getSuggestionsApi, - getTvShowsApi, - getUserLibraryApi, - getUserViewsApi, -} from "@jellyfin/sdk/lib/utils/api"; -import { type QueryFunction, useQuery } from "@tanstack/react-query"; -import { useNavigation, useRouter, useSegments } from "expo-router"; -import { useAtomValue } from "jotai"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { - ActivityIndicator, - Platform, - TouchableOpacity, - View, -} from "react-native"; -import Animated, { - useAnimatedRef, - useScrollViewOffset, -} from "react-native-reanimated"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { Button } from "@/components/Button"; -import { Text } from "@/components/common/Text"; -import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList"; -import { Loader } from "@/components/Loader"; -import { MediaListSection } from "@/components/medialists/MediaListSection"; -import { Colors } from "@/constants/Colors"; -import { useNetworkStatus } from "@/hooks/useNetworkStatus"; -import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache"; -import { useDownload } from "@/providers/DownloadProvider"; -import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; -import { useSettings } from "@/utils/atoms/settings"; -import { eventBus } from "@/utils/eventBus"; -import { AppleTVCarousel } from "../apple-tv-carousel/AppleTVCarousel"; - -type InfiniteScrollingCollectionListSection = { - type: "InfiniteScrollingCollectionList"; - title?: string; - queryKey: (string | undefined | null)[]; - queryFn: QueryFunction; - orientation?: "horizontal" | "vertical"; - pageSize?: number; -}; - -type MediaListSectionType = { - type: "MediaListSection"; - queryKey: (string | undefined)[]; - queryFn: QueryFunction; -}; - -type Section = InfiniteScrollingCollectionListSection | MediaListSectionType; - -export const HomeWithCarousel = () => { - const router = useRouter(); - const { t } = useTranslation(); - const api = useAtomValue(apiAtom); - const user = useAtomValue(userAtom); - const insets = useSafeAreaInsets(); - const [_loading, setLoading] = useState(false); - const { settings, refreshStreamyfinPluginSettings } = useSettings(); - const headerOverlayOffset = Platform.isTV ? 0 : 60; - const navigation = useNavigation(); - const animatedScrollRef = useAnimatedRef(); - const scrollOffset = useScrollViewOffset(animatedScrollRef); - const { downloadedItems, cleanCacheDirectory } = useDownload(); - const prevIsConnected = useRef(false); - const { - isConnected, - serverConnected, - loading: retryLoading, - retryCheck, - } = useNetworkStatus(); - const invalidateCache = useInvalidatePlaybackProgressCache(); - const [scrollY, setScrollY] = useState(0); - - useEffect(() => { - if (isConnected && !prevIsConnected.current) { - invalidateCache(); - } - prevIsConnected.current = isConnected; - }, [isConnected, invalidateCache]); - - const hasDownloads = useMemo(() => { - if (Platform.isTV) return false; - return downloadedItems.length > 0; - }, [downloadedItems]); - - useEffect(() => { - if (Platform.isTV) { - navigation.setOptions({ - headerLeft: () => null, - }); - return; - } - navigation.setOptions({ - headerLeft: () => ( - { - router.push("/(auth)/downloads"); - }} - className='ml-1.5' - style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} - > - - - ), - }); - }, [navigation, router, hasDownloads]); - - useEffect(() => { - cleanCacheDirectory().catch((_e) => - console.error("Something went wrong cleaning cache directory"), - ); - }, []); - - const segments = useSegments(); - useEffect(() => { - const unsubscribe = eventBus.on("scrollToTop", () => { - if ((segments as string[])[2] === "(home)") - animatedScrollRef.current?.scrollTo({ - y: Platform.isTV ? -152 : -100, - animated: true, - }); - }); - - return () => { - unsubscribe(); - }; - }, [segments]); - - const { - data, - isError: e1, - isLoading: l1, - } = useQuery({ - queryKey: ["home", "userViews", user?.Id], - queryFn: async () => { - if (!api || !user?.Id) { - return null; - } - - const response = await getUserViewsApi(api).getUserViews({ - userId: user.Id, - }); - - return response.data.Items || null; - }, - enabled: !!api && !!user?.Id, - staleTime: 60 * 1000, - }); - - const userViews = useMemo( - () => data?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!)), - [data, settings?.hiddenLibraries], - ); - - const collections = useMemo(() => { - const allow = ["movies", "tvshows"]; - return ( - userViews?.filter( - (c) => c.CollectionType && allow.includes(c.CollectionType), - ) || [] - ); - }, [userViews]); - - const _refetch = async () => { - setLoading(true); - await refreshStreamyfinPluginSettings(); - await invalidateCache(); - setLoading(false); - }; - - const createCollectionConfig = useCallback( - ( - title: string, - queryKey: string[], - includeItemTypes: BaseItemKind[], - parentId: string | undefined, - pageSize: number = 10, - ): InfiniteScrollingCollectionListSection => ({ - title, - queryKey, - queryFn: async ({ pageParam = 0 }) => { - if (!api) return []; - // getLatestMedia doesn't support startIndex, so we fetch all and slice client-side - const allData = - ( - await getUserLibraryApi(api).getLatestMedia({ - userId: user?.Id, - limit: 100, // Fetch a larger set for pagination - fields: ["PrimaryImageAspectRatio", "Path", "Genres"], - imageTypeLimit: 1, - enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"], - includeItemTypes, - parentId, - }) - ).data || []; - - // Simulate pagination by slicing - return allData.slice(pageParam, pageParam + pageSize); - }, - type: "InfiniteScrollingCollectionList", - pageSize, - }), - [api, user?.Id], - ); - - const defaultSections = useMemo(() => { - if (!api || !user?.Id) return []; - - const latestMediaViews = collections.map((c) => { - const includeItemTypes: BaseItemKind[] = - c.CollectionType === "tvshows" || c.CollectionType === "movies" - ? [] - : ["Movie"]; - const title = t("home.recently_added_in", { libraryName: c.Name }); - const queryKey: string[] = [ - "home", - `recentlyAddedIn${c.CollectionType}`, - user.Id!, - c.Id!, - ]; - return createCollectionConfig( - title || "", - queryKey, - includeItemTypes, - c.Id, - 10, - ); - }); - - const ss: Section[] = [ - { - title: t("home.continue_watching"), - queryKey: ["home", "resumeItems"], - queryFn: async ({ pageParam = 0 }) => - ( - await getItemsApi(api).getResumeItems({ - userId: user.Id, - enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"], - includeItemTypes: ["Movie", "Series", "Episode"], - fields: ["Genres"], - startIndex: pageParam, - limit: 10, - }) - ).data.Items || [], - type: "InfiniteScrollingCollectionList", - orientation: "horizontal", - pageSize: 10, - }, - { - title: t("home.next_up"), - queryKey: ["home", "nextUp-all"], - queryFn: async ({ pageParam = 0 }) => - ( - await getTvShowsApi(api).getNextUp({ - userId: user?.Id, - fields: ["MediaSourceCount", "Genres"], - startIndex: pageParam, - limit: 10, - enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"], - enableResumable: false, - }) - ).data.Items || [], - type: "InfiniteScrollingCollectionList", - orientation: "horizontal", - pageSize: 10, - }, - ...latestMediaViews, - { - title: t("home.suggested_movies"), - queryKey: ["home", "suggestedMovies", user?.Id], - queryFn: async ({ pageParam = 0 }) => - ( - await getSuggestionsApi(api).getSuggestions({ - userId: user?.Id, - startIndex: pageParam, - limit: 10, - mediaType: ["Video"], - type: ["Movie"], - }) - ).data.Items || [], - type: "InfiniteScrollingCollectionList", - orientation: "vertical", - pageSize: 10, - }, - ]; - return ss; - }, [api, user?.Id, collections, t, createCollectionConfig]); - - const customSections = useMemo(() => { - if (!api || !user?.Id || !settings?.home?.sections) return []; - const ss: Section[] = []; - settings.home.sections.forEach((section, index) => { - const id = section.title || `section-${index}`; - const pageSize = 10; - ss.push({ - title: t(`${id}`), - queryKey: ["home", "custom", String(index), section.title ?? null], - queryFn: async ({ pageParam = 0 }) => { - if (section.items) { - const response = await getItemsApi(api).getItems({ - userId: user?.Id, - startIndex: pageParam, - limit: section.items?.limit || pageSize, - recursive: true, - includeItemTypes: section.items?.includeItemTypes, - sortBy: section.items?.sortBy, - sortOrder: section.items?.sortOrder, - filters: section.items?.filters, - parentId: section.items?.parentId, - }); - return response.data.Items || []; - } - if (section.nextUp) { - const response = await getTvShowsApi(api).getNextUp({ - userId: user?.Id, - fields: ["MediaSourceCount", "Genres"], - startIndex: pageParam, - limit: section.nextUp?.limit || pageSize, - enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"], - enableResumable: section.nextUp?.enableResumable, - enableRewatching: section.nextUp?.enableRewatching, - }); - return response.data.Items || []; - } - if (section.latest) { - // getLatestMedia doesn't support startIndex, so we fetch all and slice client-side - const allData = - ( - await getUserLibraryApi(api).getLatestMedia({ - userId: user?.Id, - includeItemTypes: section.latest?.includeItemTypes, - limit: section.latest?.limit || 100, // Fetch larger set - isPlayed: section.latest?.isPlayed, - groupItems: section.latest?.groupItems, - }) - ).data || []; - - // Simulate pagination by slicing - return allData.slice(pageParam, pageParam + pageSize); - } - if (section.custom) { - const response = await api.get( - section.custom.endpoint, - { - params: { - ...(section.custom.query || {}), - userId: user?.Id, - startIndex: pageParam, - limit: pageSize, - }, - headers: section.custom.headers || {}, - }, - ); - return response.data.Items || []; - } - return []; - }, - type: "InfiniteScrollingCollectionList", - orientation: section?.orientation || "vertical", - pageSize, - }); - }); - return ss; - }, [api, user?.Id, settings?.home?.sections, t]); - - const sections = settings?.home?.sections ? customSections : defaultSections; - - if (!isConnected || serverConnected !== true) { - let title = ""; - let subtitle = ""; - - if (!isConnected) { - title = t("home.no_internet"); - subtitle = t("home.no_internet_message"); - } else if (serverConnected === null) { - title = t("home.checking_server_connection"); - subtitle = t("home.checking_server_connection_message"); - } else if (!serverConnected) { - title = t("home.server_unreachable"); - subtitle = t("home.server_unreachable_message"); - } - return ( - - {title} - {subtitle} - - - {!Platform.isTV && ( - - )} - - - - - ); - } - - if (e1) - return ( - - {t("home.oops")} - - {t("home.error_message")} - - - ); - - if (l1) - return ( - - - - ); - - return ( - { - setScrollY(event.nativeEvent.contentOffset.y); - }} - > - - - - {sections.map((section, index) => { - if (section.type === "InfiniteScrollingCollectionList") { - return ( - - ); - } - if (section.type === "MediaListSection") { - return ( - - ); - } - return null; - })} - - - - - ); -}; diff --git a/components/home/InfiniteScrollingCollectionList.tsx b/components/home/InfiniteScrollingCollectionList.tsx index 30464b639..de4c64621 100644 --- a/components/home/InfiniteScrollingCollectionList.tsx +++ b/components/home/InfiniteScrollingCollectionList.tsx @@ -4,6 +4,7 @@ import { type QueryKey, useInfiniteQuery, } from "@tanstack/react-query"; +import { useEffect, useMemo, useRef } from "react"; import { useTranslation } from "react-i18next"; import { ActivityIndicator, @@ -11,6 +12,7 @@ import { View, type ViewProps, } from "react-native"; +import { SectionHeader } from "@/components/common/SectionHeader"; import { Text } from "@/components/common/Text"; import MoviePoster from "@/components/posters/MoviePoster"; import { Colors } from "../../constants/Colors"; @@ -27,6 +29,9 @@ interface Props extends ViewProps { queryFn: QueryFunction; hideIfEmpty?: boolean; pageSize?: number; + onPressSeeAll?: () => void; + enabled?: boolean; + onLoaded?: () => void; } export const InfiniteScrollingCollectionList: React.FC = ({ @@ -37,32 +42,71 @@ export const InfiniteScrollingCollectionList: React.FC = ({ queryKey, hideIfEmpty = false, pageSize = 10, + onPressSeeAll, + enabled = true, + onLoaded, ...props }) => { - const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } = - useInfiniteQuery({ - queryKey: queryKey, - queryFn: ({ pageParam = 0, ...context }) => - queryFn({ ...context, queryKey, pageParam }), - getNextPageParam: (lastPage, allPages) => { - // If the last page has fewer items than pageSize, we've reached the end - if (lastPage.length < pageSize) { - return undefined; - } - // Otherwise, return the next start index - return allPages.length * pageSize; - }, - initialPageParam: 0, - staleTime: 60 * 1000, // 1 minute - refetchOnMount: false, - refetchOnWindowFocus: false, - refetchOnReconnect: true, - }); + const effectivePageSize = Math.max(1, pageSize); + const hasCalledOnLoaded = useRef(false); + const { + data, + isLoading, + isFetchingNextPage, + hasNextPage, + fetchNextPage, + isSuccess, + } = useInfiniteQuery({ + queryKey: queryKey, + queryFn: ({ pageParam = 0, ...context }) => + queryFn({ ...context, queryKey, pageParam }), + getNextPageParam: (lastPage, allPages) => { + // If the last page has fewer items than pageSize, we've reached the end + if (lastPage.length < effectivePageSize) { + return undefined; + } + // Otherwise, return the next start index based on how many items we already loaded. + // This avoids overlaps if the server/page size differs from our configured page size. + return allPages.reduce((acc, page) => acc + page.length, 0); + }, + initialPageParam: 0, + staleTime: 60 * 1000, // 1 minute + refetchOnWindowFocus: false, + refetchOnReconnect: true, + enabled, + }); + + // Notify parent when data has loaded + useEffect(() => { + if (isSuccess && !hasCalledOnLoaded.current && onLoaded) { + hasCalledOnLoaded.current = true; + onLoaded(); + } + }, [isSuccess, onLoaded]); const { t } = useTranslation(); - // Flatten all pages into a single array - const allItems = data?.pages.flat() || []; + // Flatten all pages into a single array (and de-dupe by Id to avoid UI duplicates) + const allItems = useMemo(() => { + const items = data?.pages.flat() ?? []; + const seen = new Set(); + const deduped: BaseItemDto[] = []; + + for (const item of items) { + const id = item.Id; + if (!id) continue; + if (seen.has(id)) continue; + seen.add(id); + deduped.push(item); + } + + return deduped; + }, [data]); + + const snapOffsets = useMemo(() => { + const itemWidth = orientation === "horizontal" ? 184 : 120; // w-44 (176px) + mr-2 (8px) or w-28 (112px) + mr-2 (8px) + return allItems.map((_, index) => index * itemWidth); + }, [allItems, orientation]); if (hideIfEmpty === true && allItems.length === 0 && !isLoading) return null; if (disabled || !title) return null; @@ -84,9 +128,12 @@ export const InfiniteScrollingCollectionList: React.FC = ({ return ( - - {title} - + {isLoading === false && allItems.length === 0 && ( {t("home.no_items")} @@ -126,13 +173,15 @@ export const InfiniteScrollingCollectionList: React.FC = ({ showsHorizontalScrollIndicator={false} onScroll={handleScroll} scrollEventThrottle={16} + snapToOffsets={snapOffsets} + decelerationRate='fast' > - {allItems.map((item) => ( + {allItems.map((item, index) => ( diff --git a/components/home/InfiniteScrollingCollectionList.tv.tsx b/components/home/InfiniteScrollingCollectionList.tv.tsx new file mode 100644 index 000000000..1db314f2b --- /dev/null +++ b/components/home/InfiniteScrollingCollectionList.tv.tsx @@ -0,0 +1,397 @@ +import { Ionicons } from "@expo/vector-icons"; +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import { + type QueryFunction, + type QueryKey, + useInfiniteQuery, +} from "@tanstack/react-query"; +import { useSegments } from "expo-router"; +import { useCallback, useMemo, useRef } from "react"; +import { useTranslation } from "react-i18next"; +import { + ActivityIndicator, + FlatList, + View, + type ViewProps, +} from "react-native"; +import { Text } from "@/components/common/Text"; +import { getItemNavigation } from "@/components/common/TouchableItemRouter"; +import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster"; +import { TVPosterCard } from "@/components/tv/TVPosterCard"; +import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes"; +import { useScaledTVSizes } from "@/constants/TVSizes"; +import { useScaledTVTypography } from "@/constants/TVTypography"; +import useRouter from "@/hooks/useAppRouter"; +import { useTVItemActionModal } from "@/hooks/useTVItemActionModal"; +import { SortByOption, SortOrderOption } from "@/utils/atoms/filters"; +import { scaleSize } from "@/utils/scaleSize"; + +// Extra padding to accommodate scale animation (1.05x) and glow shadow +const _SCALE_PADDING = scaleSize(20); + +interface Props extends ViewProps { + title?: string | null; + orientation?: "horizontal" | "vertical"; + disabled?: boolean; + queryKey: QueryKey; + queryFn: QueryFunction; + hideIfEmpty?: boolean; + pageSize?: number; + onPressSeeAll?: () => void; + enabled?: boolean; + isFirstSection?: boolean; + onItemFocus?: (item: BaseItemDto) => void; + parentId?: string; +} + +type Typography = ReturnType; +type PosterSizes = ReturnType; + +// TV-specific "See All" card for end of lists +const TVSeeAllCard: React.FC<{ + onPress: () => void; + orientation: "horizontal" | "vertical"; + disabled?: boolean; + onFocus?: () => void; + onBlur?: () => void; + typography: Typography; + posterSizes: PosterSizes; +}> = ({ + onPress, + orientation, + disabled, + onFocus, + onBlur, + typography, + posterSizes, +}) => { + const { t } = useTranslation(); + const width = + orientation === "horizontal" ? posterSizes.episode : posterSizes.poster; + const aspectRatio = orientation === "horizontal" ? 16 / 9 : 10 / 15; + + return ( + + + + + + {t("common.seeAll", { defaultValue: "See all" })} + + + + + ); +}; + +export const InfiniteScrollingCollectionList: React.FC = ({ + title, + orientation = "vertical", + disabled = false, + queryFn, + queryKey, + hideIfEmpty = false, + pageSize = 10, + enabled = true, + isFirstSection = false, + onItemFocus, + parentId, + ...props +}) => { + const typography = useScaledTVTypography(); + const posterSizes = useScaledTVPosterSizes(); + const sizes = useScaledTVSizes(); + const ITEM_GAP = sizes.gaps.item; + const effectivePageSize = Math.max(1, pageSize); + const router = useRouter(); + const { showItemActions } = useTVItemActionModal(); + const segments = useSegments(); + const from = (segments as string[])[2] || "(home)"; + + const flatListRef = useRef>(null); + + // Pass through focus callbacks without tracking internal state + const handleItemFocus = useCallback( + (item: BaseItemDto) => { + onItemFocus?.(item); + }, + [onItemFocus], + ); + + const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } = + useInfiniteQuery({ + queryKey: queryKey, + queryFn: ({ pageParam = 0, ...context }) => + queryFn({ ...context, queryKey, pageParam }), + getNextPageParam: (lastPage, allPages) => { + if (lastPage.length < effectivePageSize) { + return undefined; + } + return allPages.reduce((acc, page) => acc + page.length, 0); + }, + initialPageParam: 0, + staleTime: 60 * 1000, + refetchInterval: 60 * 1000, + refetchOnWindowFocus: false, + refetchOnReconnect: true, + enabled, + }); + + const { t } = useTranslation(); + + const allItems = useMemo(() => { + const items = data?.pages.flat() ?? []; + const seen = new Set(); + const deduped: BaseItemDto[] = []; + + for (const item of items) { + const id = item.Id; + if (!id) continue; + if (seen.has(id)) continue; + seen.add(id); + deduped.push(item); + } + + return deduped; + }, [data]); + + const itemWidth = + orientation === "horizontal" ? posterSizes.episode : posterSizes.poster; + + const handleItemPress = useCallback( + (item: BaseItemDto) => { + const navigation = getItemNavigation(item, from); + router.push(navigation as any); + }, + [from, router], + ); + + const handleEndReached = useCallback(() => { + if (hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, [hasNextPage, isFetchingNextPage, fetchNextPage]); + + const handleSeeAllPress = useCallback(() => { + if (!parentId) return; + // Navigate into the library detail (lives in the libraries tab) sorted by most + // recently added. The `fromSeeAll` flag tells the detail page to (a) collapse + // the libraries stack so the native tab can't auto-pop it back to the list, and + // (b) intercept Back to route to the library list so the user can switch + // libraries. See app/(auth)/(tabs)/(libraries)/[libraryId].tsx. + router.push({ + pathname: "/[libraryId]", + params: { + libraryId: parentId, + sortBy: SortByOption.DateCreated, + sortOrder: SortOrderOption.Descending, + fromSeeAll: "true", + }, + } as any); + }, [router, parentId]); + + const renderItem = useCallback( + ({ item, index }: { item: BaseItemDto; index: number }) => { + const isFirstItem = isFirstSection && index === 0; + + return ( + + handleItemPress(item)} + onLongPress={() => showItemActions(item)} + hasTVPreferredFocus={isFirstItem} + onFocus={() => handleItemFocus(item)} + width={itemWidth} + /> + + ); + }, + [ + orientation, + isFirstSection, + itemWidth, + handleItemPress, + showItemActions, + handleItemFocus, + ITEM_GAP, + ], + ); + + if (hideIfEmpty === true && allItems.length === 0 && !isLoading) return null; + if (disabled || !title) return null; + + return ( + + {/* Section Header */} + + {title} + + + {isLoading === false && allItems.length === 0 && ( + + {t("home.no_items")} + + )} + + {isLoading ? ( + + {[1, 2, 3, 4, 5].map((i) => ( + + + + + Placeholder text here + + + + ))} + + ) : ( + item.Id!} + renderItem={renderItem} + showsHorizontalScrollIndicator={false} + onEndReached={handleEndReached} + onEndReachedThreshold={0.5} + initialNumToRender={4} + maxToRenderPerBatch={2} + windowSize={3} + removeClippedSubviews={false} + maintainVisibleContentPosition={{ minIndexForVisible: 0 }} + style={{ overflow: "visible" }} + contentContainerStyle={{ + paddingVertical: sizes.gaps.small, + paddingLeft: sizes.padding.horizontal, + paddingRight: sizes.padding.horizontal, + }} + // Below is a work around with the contentInset, same in TVHeroCarousel, if okay on apple remove + // ListHeaderComponent={ + // + // } + // contentInset={{ + // left: sizes.padding.horizontal, + // right: sizes.padding.horizontal, + // }} + // contentOffset={{ x: -sizes.padding.horizontal, y: 0 }} + // contentContainerStyle={{ paddingVertical: SCALE_PADDING }} + ListFooterComponent={ + // No fixed width: the footer must size to the "See All" card so the + // FlatList's scrollable content extends to fully reveal it. A fixed + // (narrow) width clipped the card at the right edge. Trailing space is + // provided by contentContainerStyle.paddingRight. + + {isFetchingNextPage && ( + + + + )} + {parentId && allItems.length > 0 && ( + + )} + + } + /> + )} + + ); +}; diff --git a/components/home/LargeMovieCarousel.tsx b/components/home/LargeMovieCarousel.tsx index 913e024b1..cf79af73b 100644 --- a/components/home/LargeMovieCarousel.tsx +++ b/components/home/LargeMovieCarousel.tsx @@ -2,7 +2,7 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { getItemsApi } from "@jellyfin/sdk/lib/utils/api"; import { useQuery } from "@tanstack/react-query"; import { Image } from "expo-image"; -import { useRouter, useSegments } from "expo-router"; +import { useSegments } from "expo-router"; import { useAtom } from "jotai"; import React, { useCallback, useMemo } from "react"; import { Dimensions, View, type ViewProps } from "react-native"; @@ -16,6 +16,7 @@ import Carousel, { type ICarouselInstance, Pagination, } from "react-native-reanimated-carousel"; +import useRouter from "@/hooks/useAppRouter"; import { useHaptic } from "@/hooks/useHaptic"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { useSettings } from "@/utils/atoms/settings"; diff --git a/components/home/ScrollingCollectionList.tsx b/components/home/ScrollingCollectionList.tsx index 54542c443..185ad8c2f 100644 --- a/components/home/ScrollingCollectionList.tsx +++ b/components/home/ScrollingCollectionList.tsx @@ -21,7 +21,6 @@ interface Props extends ViewProps { queryKey: QueryKey; queryFn: QueryFunction; hideIfEmpty?: boolean; - isOffline?: boolean; scrollY?: number; // For lazy loading enableLazyLoading?: boolean; // Enable/disable lazy loading } @@ -33,7 +32,6 @@ export const ScrollingCollectionList: React.FC = ({ queryFn, queryKey, hideIfEmpty = false, - isOffline = false, scrollY = 0, enableLazyLoading = false, ...props @@ -46,7 +44,6 @@ export const ScrollingCollectionList: React.FC = ({ queryKey: queryKey, queryFn, staleTime: 60 * 1000, // 1 minute - refetchOnMount: false, refetchOnWindowFocus: false, refetchOnReconnect: true, enabled: enableLazyLoading ? isInView : true, @@ -106,7 +103,6 @@ export const ScrollingCollectionList: React.FC = ({ = ({ + watchlist, + jellyfinServerId, + ...props +}) => { + const api = useAtomValue(apiAtom); + const user = useAtomValue(userAtom); + const { settings } = useSettings(); + const router = useRouter(); + const { t } = useTranslation(); + + const { data: items, isLoading } = useQuery({ + queryKey: [ + "streamystats", + "watchlist", + watchlist.id, + jellyfinServerId, + settings?.streamyStatsServerUrl, + ], + queryFn: async (): Promise => { + if (!settings?.streamyStatsServerUrl || !api?.accessToken || !user?.Id) { + return []; + } + + const streamystatsApi = createStreamystatsApi({ + serverUrl: settings.streamyStatsServerUrl, + jellyfinToken: api.accessToken, + }); + + const watchlistDetail = await streamystatsApi.getWatchlistItemIds({ + watchlistId: watchlist.id, + jellyfinServerId, + }); + + const itemIds = watchlistDetail.data?.items; + if (!itemIds?.length) { + return []; + } + + const response = await getItemsApi(api).getItems({ + userId: user.Id, + ids: itemIds, + fields: ["PrimaryImageAspectRatio", "Genres"], + enableImageTypes: ["Primary", "Backdrop", "Thumb"], + }); + + return response.data.Items || []; + }, + enabled: + Boolean(settings?.streamyStatsServerUrl) && + Boolean(api?.accessToken) && + Boolean(user?.Id), + staleTime: 5 * 60 * 1000, + refetchOnWindowFocus: false, + }); + + const snapOffsets = useMemo(() => { + return items?.map((_, index) => index * ITEM_WIDTH) ?? []; + }, [items]); + + const handleSeeAll = () => { + router.push({ + pathname: "/(auth)/(tabs)/(watchlists)/[watchlistId]", + params: { watchlistId: watchlist.id.toString() }, + } as any); + }; + + if (!isLoading && (!items || items.length === 0)) return null; + + return ( + + + {isLoading ? ( + + {[1, 2, 3].map((i) => ( + + + + + Loading... + + + + ))} + + ) : ( + + + {items?.map((item) => ( + + {item.Type === "Movie" && } + {item.Type === "Series" && } + + + ))} + + + )} + + ); +}; + +interface StreamystatsPromotedWatchlistsProps extends ViewProps { + enabled?: boolean; +} + +export const StreamystatsPromotedWatchlists: React.FC< + StreamystatsPromotedWatchlistsProps +> = ({ enabled = true, ...props }) => { + const api = useAtomValue(apiAtom); + const user = useAtomValue(userAtom); + const { settings } = useSettings(); + + const streamyStatsEnabled = useMemo(() => { + return Boolean(settings?.streamyStatsServerUrl); + }, [settings?.streamyStatsServerUrl]); + + // Fetch server info to get the Jellyfin server ID + const { data: serverInfo } = useQuery({ + queryKey: ["jellyfin", "serverInfo"], + queryFn: async (): Promise => { + if (!api) return null; + const response = await getSystemApi(api).getPublicSystemInfo(); + return response.data; + }, + enabled: enabled && Boolean(api) && streamyStatsEnabled, + staleTime: 60 * 60 * 1000, + }); + + const jellyfinServerId = serverInfo?.Id; + + const { + data: watchlists, + isLoading, + isError, + } = useQuery({ + queryKey: [ + "streamystats", + "promotedWatchlists", + jellyfinServerId, + settings?.streamyStatsServerUrl, + ], + queryFn: async (): Promise => { + if ( + !settings?.streamyStatsServerUrl || + !api?.accessToken || + !jellyfinServerId + ) { + return []; + } + + const streamystatsApi = createStreamystatsApi({ + serverUrl: settings.streamyStatsServerUrl, + jellyfinToken: api.accessToken, + }); + + const response = await streamystatsApi.getPromotedWatchlists({ + jellyfinServerId, + includePreview: false, + }); + + return response.data || []; + }, + enabled: + enabled && + streamyStatsEnabled && + Boolean(api?.accessToken) && + Boolean(jellyfinServerId) && + Boolean(user?.Id), + staleTime: 5 * 60 * 1000, + refetchOnWindowFocus: false, + }); + + if (!streamyStatsEnabled) return null; + if (isError) return null; + if (!isLoading && (!watchlists || watchlists.length === 0)) return null; + + if (isLoading) { + return ( + + + + {[1, 2, 3].map((i) => ( + + + + + Loading... + + + + ))} + + + ); + } + + return ( + <> + {watchlists?.map((watchlist) => ( + + ))} + + ); +}; diff --git a/components/home/StreamystatsPromotedWatchlists.tv.tsx b/components/home/StreamystatsPromotedWatchlists.tv.tsx new file mode 100644 index 000000000..6b3c32aea --- /dev/null +++ b/components/home/StreamystatsPromotedWatchlists.tv.tsx @@ -0,0 +1,382 @@ +import type { + BaseItemDto, + PublicSystemInfo, +} from "@jellyfin/sdk/lib/generated-client/models"; +import { getItemsApi, getSystemApi } from "@jellyfin/sdk/lib/utils/api"; +import { useQuery } from "@tanstack/react-query"; +import { useSegments } from "expo-router"; +import { useAtomValue } from "jotai"; +import { useCallback, useMemo } from "react"; +import { FlatList, View, type ViewProps } from "react-native"; + +import { Text } from "@/components/common/Text"; +import { getItemNavigation } from "@/components/common/TouchableItemRouter"; +import { TVPosterCard } from "@/components/tv/TVPosterCard"; +import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes"; +import { useScaledTVSizes } from "@/constants/TVSizes"; +import { useScaledTVTypography } from "@/constants/TVTypography"; +import useRouter from "@/hooks/useAppRouter"; +import { useTVItemActionModal } from "@/hooks/useTVItemActionModal"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { useSettings } from "@/utils/atoms/settings"; +import { scaleSize } from "@/utils/scaleSize"; +import { createStreamystatsApi } from "@/utils/streamystats/api"; +import type { StreamystatsWatchlist } from "@/utils/streamystats/types"; + +const SCALE_PADDING = scaleSize(20); + +interface WatchlistSectionProps extends ViewProps { + watchlist: StreamystatsWatchlist; + jellyfinServerId: string; + onItemFocus?: (item: BaseItemDto) => void; +} + +const WatchlistSection: React.FC = ({ + watchlist, + jellyfinServerId, + onItemFocus, + ...props +}) => { + const typography = useScaledTVTypography(); + const posterSizes = useScaledTVPosterSizes(); + const sizes = useScaledTVSizes(); + const ITEM_GAP = sizes.gaps.item; + const api = useAtomValue(apiAtom); + const user = useAtomValue(userAtom); + const { settings } = useSettings(); + const router = useRouter(); + const { showItemActions } = useTVItemActionModal(); + const segments = useSegments(); + const from = (segments as string[])[2] || "(home)"; + + const { data: items, isLoading } = useQuery({ + queryKey: [ + "streamystats", + "watchlist", + watchlist.id, + jellyfinServerId, + settings?.streamyStatsServerUrl, + ], + queryFn: async (): Promise => { + if (!settings?.streamyStatsServerUrl || !api?.accessToken || !user?.Id) { + return []; + } + + const streamystatsApi = createStreamystatsApi({ + serverUrl: settings.streamyStatsServerUrl, + jellyfinToken: api.accessToken, + }); + + const watchlistDetail = await streamystatsApi.getWatchlistItemIds({ + watchlistId: watchlist.id, + jellyfinServerId, + }); + + const itemIds = watchlistDetail.data?.items; + if (!itemIds?.length) { + return []; + } + + const response = await getItemsApi(api).getItems({ + userId: user.Id, + ids: itemIds, + fields: ["PrimaryImageAspectRatio", "Genres"], + enableImageTypes: ["Primary", "Backdrop", "Thumb"], + }); + + return response.data.Items || []; + }, + enabled: + Boolean(settings?.streamyStatsServerUrl) && + Boolean(api?.accessToken) && + Boolean(user?.Id), + staleTime: 60 * 1000, + refetchInterval: 60 * 1000, + refetchOnWindowFocus: false, + }); + + const handleItemPress = useCallback( + (item: BaseItemDto) => { + const navigation = getItemNavigation(item, from); + router.push(navigation as any); + }, + [from, router], + ); + + const getItemLayout = useCallback( + (_data: ArrayLike | null | undefined, index: number) => ({ + length: posterSizes.poster + ITEM_GAP, + offset: (posterSizes.poster + ITEM_GAP) * index, + index, + }), + [posterSizes.poster, ITEM_GAP], + ); + + const renderItem = useCallback( + ({ item }: { item: BaseItemDto }) => { + return ( + + handleItemPress(item)} + onLongPress={() => showItemActions(item)} + onFocus={() => onItemFocus?.(item)} + width={posterSizes.poster} + /> + + ); + }, + [ + ITEM_GAP, + posterSizes.poster, + handleItemPress, + showItemActions, + onItemFocus, + ], + ); + + if (!isLoading && (!items || items.length === 0)) return null; + + return ( + + + {watchlist.name} + + + {isLoading ? ( + + {[1, 2, 3, 4, 5].map((i) => ( + + + + + Placeholder text here + + + + ))} + + ) : ( + item.Id!} + renderItem={renderItem} + showsHorizontalScrollIndicator={false} + initialNumToRender={5} + maxToRenderPerBatch={3} + windowSize={5} + removeClippedSubviews={false} + getItemLayout={getItemLayout} + style={{ overflow: "visible" }} + contentInset={{ + left: sizes.padding.horizontal, + right: sizes.padding.horizontal, + }} + contentOffset={{ x: -sizes.padding.horizontal, y: 0 }} + contentContainerStyle={{ + paddingVertical: SCALE_PADDING, + }} + /> + )} + + ); +}; + +interface StreamystatsPromotedWatchlistsProps extends ViewProps { + enabled?: boolean; + onItemFocus?: (item: BaseItemDto) => void; +} + +export const StreamystatsPromotedWatchlists: React.FC< + StreamystatsPromotedWatchlistsProps +> = ({ enabled = true, onItemFocus, ...props }) => { + const posterSizes = useScaledTVPosterSizes(); + const sizes = useScaledTVSizes(); + const ITEM_GAP = sizes.gaps.item; + const api = useAtomValue(apiAtom); + const user = useAtomValue(userAtom); + const { settings } = useSettings(); + const typography = useScaledTVTypography(); + + const streamyStatsEnabled = useMemo(() => { + return Boolean(settings?.streamyStatsServerUrl); + }, [settings?.streamyStatsServerUrl]); + + const { data: serverInfo } = useQuery({ + queryKey: ["jellyfin", "serverInfo"], + queryFn: async (): Promise => { + if (!api) return null; + const response = await getSystemApi(api).getPublicSystemInfo(); + return response.data; + }, + enabled: enabled && Boolean(api) && streamyStatsEnabled, + staleTime: 60 * 60 * 1000, + }); + + const jellyfinServerId = serverInfo?.Id; + + const { + data: watchlists, + isLoading, + isError, + } = useQuery({ + queryKey: [ + "streamystats", + "promotedWatchlists", + jellyfinServerId, + settings?.streamyStatsServerUrl, + ], + queryFn: async (): Promise => { + if ( + !settings?.streamyStatsServerUrl || + !api?.accessToken || + !jellyfinServerId + ) { + return []; + } + + const streamystatsApi = createStreamystatsApi({ + serverUrl: settings.streamyStatsServerUrl, + jellyfinToken: api.accessToken, + }); + + const response = await streamystatsApi.getPromotedWatchlists({ + jellyfinServerId, + includePreview: false, + }); + + return response.data || []; + }, + enabled: + enabled && + streamyStatsEnabled && + Boolean(api?.accessToken) && + Boolean(jellyfinServerId) && + Boolean(user?.Id), + staleTime: 60 * 1000, + refetchInterval: 60 * 1000, + refetchOnWindowFocus: false, + }); + + if (!streamyStatsEnabled) return null; + if (isError) return null; + if (!isLoading && (!watchlists || watchlists.length === 0)) return null; + + if (isLoading) { + return ( + + + + {[1, 2, 3, 4, 5].map((i) => ( + + + + + Placeholder text here + + + + ))} + + + ); + } + + return ( + <> + {watchlists?.map((watchlist) => ( + + ))} + + ); +}; diff --git a/components/home/StreamystatsRecommendations.tsx b/components/home/StreamystatsRecommendations.tsx new file mode 100644 index 000000000..73b7231d9 --- /dev/null +++ b/components/home/StreamystatsRecommendations.tsx @@ -0,0 +1,195 @@ +import type { + BaseItemDto, + PublicSystemInfo, +} from "@jellyfin/sdk/lib/generated-client/models"; +import { getItemsApi, getSystemApi } from "@jellyfin/sdk/lib/utils/api"; +import { useQuery } from "@tanstack/react-query"; +import { useAtomValue } from "jotai"; +import { useMemo } from "react"; +import { ScrollView, View, type ViewProps } from "react-native"; + +import { SectionHeader } from "@/components/common/SectionHeader"; +import { Text } from "@/components/common/Text"; +import MoviePoster from "@/components/posters/MoviePoster"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { useSettings } from "@/utils/atoms/settings"; +import { createStreamystatsApi } from "@/utils/streamystats/api"; +import type { StreamystatsRecommendationsIdsResponse } from "@/utils/streamystats/types"; +import { TouchableItemRouter } from "../common/TouchableItemRouter"; +import { ItemCardText } from "../ItemCardText"; +import SeriesPoster from "../posters/SeriesPoster"; + +const ITEM_WIDTH = 120; // w-28 (112px) + mr-2 (8px) + +interface Props extends ViewProps { + title: string; + type: "Movie" | "Series"; + limit?: number; + enabled?: boolean; +} + +export const StreamystatsRecommendations: React.FC = ({ + title, + type, + limit = 20, + enabled = true, + ...props +}) => { + const api = useAtomValue(apiAtom); + const user = useAtomValue(userAtom); + const { settings } = useSettings(); + + const streamyStatsEnabled = useMemo(() => { + return Boolean(settings?.streamyStatsServerUrl); + }, [settings?.streamyStatsServerUrl]); + + // Fetch server info to get the Jellyfin server ID + const { data: serverInfo } = useQuery({ + queryKey: ["jellyfin", "serverInfo"], + queryFn: async (): Promise => { + if (!api) return null; + const response = await getSystemApi(api).getPublicSystemInfo(); + return response.data; + }, + enabled: enabled && Boolean(api) && streamyStatsEnabled, + staleTime: 60 * 60 * 1000, // 1 hour - server info rarely changes + }); + + const jellyfinServerId = serverInfo?.Id; + + const { + data: recommendationIds, + isLoading: isLoadingRecommendations, + isError: isRecommendationsError, + } = useQuery({ + queryKey: [ + "streamystats", + "recommendations", + type, + jellyfinServerId, + settings?.streamyStatsServerUrl, + ], + queryFn: async (): Promise => { + if ( + !settings?.streamyStatsServerUrl || + !api?.accessToken || + !jellyfinServerId + ) { + return []; + } + + const streamyStatsApi = createStreamystatsApi({ + serverUrl: settings.streamyStatsServerUrl, + jellyfinToken: api.accessToken, + }); + + const response = await streamyStatsApi.getRecommendationIds( + jellyfinServerId, + type, + limit, + ); + + const data = response as StreamystatsRecommendationsIdsResponse; + + if (type === "Movie") { + return data.data.movies || []; + } + return data.data.series || []; + }, + enabled: + enabled && + streamyStatsEnabled && + Boolean(api?.accessToken) && + Boolean(jellyfinServerId) && + Boolean(user?.Id), + staleTime: 5 * 60 * 1000, // 5 minutes + refetchOnWindowFocus: false, + }); + + const { + data: items, + isLoading: isLoadingItems, + isError: isItemsError, + } = useQuery({ + queryKey: [ + "streamystats", + "recommendations", + "items", + type, + recommendationIds, + ], + queryFn: async (): Promise => { + if (!api || !user?.Id || !recommendationIds?.length) { + return []; + } + + const response = await getItemsApi(api).getItems({ + userId: user.Id, + ids: recommendationIds, + fields: ["PrimaryImageAspectRatio", "Genres"], + enableImageTypes: ["Primary", "Backdrop", "Thumb"], + }); + + return response.data.Items || []; + }, + enabled: + Boolean(recommendationIds?.length) && Boolean(api) && Boolean(user?.Id), + staleTime: 5 * 60 * 1000, + refetchOnWindowFocus: false, + }); + + const isLoading = isLoadingRecommendations || isLoadingItems; + const isError = isRecommendationsError || isItemsError; + + const snapOffsets = useMemo(() => { + return items?.map((_, index) => index * ITEM_WIDTH) ?? []; + }, [items]); + + if (!streamyStatsEnabled) return null; + if (isError) return null; + if (!isLoading && (!items || items.length === 0)) return null; + + return ( + + + {isLoading ? ( + + {[1, 2, 3].map((i) => ( + + + + + Loading title... + + + + ))} + + ) : ( + + + {items?.map((item) => ( + + {item.Type === "Movie" && } + {item.Type === "Series" && } + + + ))} + + + )} + + ); +}; diff --git a/components/home/StreamystatsRecommendations.tv.tsx b/components/home/StreamystatsRecommendations.tv.tsx new file mode 100644 index 000000000..ad9e388fd --- /dev/null +++ b/components/home/StreamystatsRecommendations.tv.tsx @@ -0,0 +1,278 @@ +import type { + BaseItemDto, + PublicSystemInfo, +} from "@jellyfin/sdk/lib/generated-client/models"; +import { getItemsApi, getSystemApi } from "@jellyfin/sdk/lib/utils/api"; +import { useQuery } from "@tanstack/react-query"; +import { useSegments } from "expo-router"; +import { useAtomValue } from "jotai"; +import { useCallback, useMemo } from "react"; +import { FlatList, View, type ViewProps } from "react-native"; + +import { Text } from "@/components/common/Text"; +import { getItemNavigation } from "@/components/common/TouchableItemRouter"; +import { TVPosterCard } from "@/components/tv/TVPosterCard"; +import { useScaledTVSizes } from "@/constants/TVSizes"; +import { useScaledTVTypography } from "@/constants/TVTypography"; +import useRouter from "@/hooks/useAppRouter"; +import { useTVItemActionModal } from "@/hooks/useTVItemActionModal"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { useSettings } from "@/utils/atoms/settings"; +import { scaleSize } from "@/utils/scaleSize"; +import { createStreamystatsApi } from "@/utils/streamystats/api"; +import type { StreamystatsRecommendationsIdsResponse } from "@/utils/streamystats/types"; + +interface Props extends ViewProps { + title: string; + type: "Movie" | "Series"; + limit?: number; + enabled?: boolean; + onItemFocus?: (item: BaseItemDto) => void; +} + +export const StreamystatsRecommendations: React.FC = ({ + title, + type, + limit = 20, + enabled = true, + onItemFocus, + ...props +}) => { + const typography = useScaledTVTypography(); + const sizes = useScaledTVSizes(); + const api = useAtomValue(apiAtom); + const user = useAtomValue(userAtom); + const { settings } = useSettings(); + const router = useRouter(); + const { showItemActions } = useTVItemActionModal(); + const segments = useSegments(); + const from = (segments as string[])[2] || "(home)"; + + const streamyStatsEnabled = useMemo(() => { + return Boolean(settings?.streamyStatsServerUrl); + }, [settings?.streamyStatsServerUrl]); + + const { data: serverInfo } = useQuery({ + queryKey: ["jellyfin", "serverInfo"], + queryFn: async (): Promise => { + if (!api) return null; + const response = await getSystemApi(api).getPublicSystemInfo(); + return response.data; + }, + enabled: enabled && Boolean(api) && streamyStatsEnabled, + staleTime: 60 * 60 * 1000, + }); + + const jellyfinServerId = serverInfo?.Id; + + const { + data: recommendationIds, + isLoading: isLoadingRecommendations, + isError: isRecommendationsError, + } = useQuery({ + queryKey: [ + "streamystats", + "recommendations", + type, + jellyfinServerId, + settings?.streamyStatsServerUrl, + ], + queryFn: async (): Promise => { + if ( + !settings?.streamyStatsServerUrl || + !api?.accessToken || + !jellyfinServerId + ) { + return []; + } + + const streamyStatsApi = createStreamystatsApi({ + serverUrl: settings.streamyStatsServerUrl, + jellyfinToken: api.accessToken, + }); + + const response = await streamyStatsApi.getRecommendationIds( + jellyfinServerId, + type, + limit, + ); + + const data = response as StreamystatsRecommendationsIdsResponse; + + if (type === "Movie") { + return data.data.movies || []; + } + return data.data.series || []; + }, + enabled: + enabled && + streamyStatsEnabled && + Boolean(api?.accessToken) && + Boolean(jellyfinServerId) && + Boolean(user?.Id), + staleTime: 60 * 1000, + refetchInterval: 60 * 1000, + refetchOnWindowFocus: false, + }); + + const { + data: items, + isLoading: isLoadingItems, + isError: isItemsError, + } = useQuery({ + queryKey: [ + "streamystats", + "recommendations", + "items", + type, + recommendationIds, + ], + queryFn: async (): Promise => { + if (!api || !user?.Id || !recommendationIds?.length) { + return []; + } + + const response = await getItemsApi(api).getItems({ + userId: user.Id, + ids: recommendationIds, + fields: ["PrimaryImageAspectRatio", "Genres"], + enableImageTypes: ["Primary", "Backdrop", "Thumb"], + }); + + return response.data.Items || []; + }, + enabled: + Boolean(recommendationIds?.length) && Boolean(api) && Boolean(user?.Id), + staleTime: 60 * 1000, + refetchInterval: 60 * 1000, + refetchOnWindowFocus: false, + }); + + const isLoading = isLoadingRecommendations || isLoadingItems; + const isError = isRecommendationsError || isItemsError; + + const handleItemPress = useCallback( + (item: BaseItemDto) => { + const navigation = getItemNavigation(item, from); + router.push(navigation as any); + }, + [from, router], + ); + + const getItemLayout = useCallback( + (_data: ArrayLike | null | undefined, index: number) => ({ + length: sizes.posters.poster + sizes.gaps.item, + offset: (sizes.posters.poster + sizes.gaps.item) * index, + index, + }), + [sizes], + ); + + const renderItem = useCallback( + ({ item }: { item: BaseItemDto }) => { + return ( + + handleItemPress(item)} + onLongPress={() => showItemActions(item)} + onFocus={() => onItemFocus?.(item)} + width={sizes.posters.poster} + /> + + ); + }, + [sizes, handleItemPress, showItemActions, onItemFocus], + ); + + if (!streamyStatsEnabled) return null; + if (isError) return null; + if (!isLoading && (!items || items.length === 0)) return null; + + return ( + + + {title} + + + {isLoading ? ( + + {[1, 2, 3, 4, 5].map((i) => ( + + + + + Placeholder text here + + + + ))} + + ) : ( + item.Id!} + renderItem={renderItem} + showsHorizontalScrollIndicator={false} + initialNumToRender={5} + maxToRenderPerBatch={3} + windowSize={5} + removeClippedSubviews={false} + getItemLayout={getItemLayout} + style={{ overflow: "visible" }} + contentInset={{ + left: sizes.padding.horizontal, + right: sizes.padding.horizontal, + }} + contentOffset={{ x: -sizes.padding.horizontal, y: 0 }} + contentContainerStyle={{ + paddingVertical: sizes.padding.scale, + }} + /> + )} + + ); +}; diff --git a/components/home/TVHeroCarousel.tsx b/components/home/TVHeroCarousel.tsx new file mode 100644 index 000000000..596f2d369 --- /dev/null +++ b/components/home/TVHeroCarousel.tsx @@ -0,0 +1,675 @@ +import { Ionicons } from "@expo/vector-icons"; +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import { Image } from "expo-image"; +import { LinearGradient } from "expo-linear-gradient"; +import { useAtomValue } from "jotai"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { + Animated, + Dimensions, + Easing, + FlatList, + Platform, + Pressable, + View, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { ProgressBar } from "@/components/common/ProgressBar"; +import { Text } from "@/components/common/Text"; +import { getItemNavigation } from "@/components/common/TouchableItemRouter"; +import { type ScaledTVSizes, useScaledTVSizes } from "@/constants/TVSizes"; +import { useScaledTVTypography } from "@/constants/TVTypography"; +import useRouter from "@/hooks/useAppRouter"; +import { + GlassPosterView, + isGlassEffectAvailable, +} from "@/modules/glass-poster"; +import { apiAtom } from "@/providers/JellyfinProvider"; +import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl"; +import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById"; +import { scaleSize } from "@/utils/scaleSize"; +import { runtimeTicksToMinutes } from "@/utils/time"; + +const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window"); + +interface TVHeroCarouselProps { + items: BaseItemDto[]; + onItemFocus?: (item: BaseItemDto) => void; + onItemLongPress?: (item: BaseItemDto) => void; +} + +interface HeroCardProps { + item: BaseItemDto; + isFirst: boolean; + sizes: ScaledTVSizes; + onFocus: (item: BaseItemDto) => void; + onPress: (item: BaseItemDto) => void; + onLongPress?: (item: BaseItemDto) => void; +} + +const HeroCard: React.FC = React.memo( + ({ item, isFirst, sizes, onFocus, onPress, onLongPress }) => { + const api = useAtomValue(apiAtom); + const [focused, setFocused] = useState(false); + const scale = useRef(new Animated.Value(1)).current; + + // Check if glass effect is available (tvOS 26+) + const useGlass = Platform.OS === "ios" && isGlassEffectAvailable(); + + const posterUrl = useMemo(() => { + if (!api) return null; + + // For episodes, always use series thumb + if (item.Type === "Episode") { + if (item.ParentThumbImageTag) { + return `${api.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=400&quality=80&tag=${item.ParentThumbImageTag}`; + } + if (item.SeriesId) { + return `${api.basePath}/Items/${item.SeriesId}/Images/Thumb?fillHeight=400&quality=80`; + } + } + + // For non-episodes, use item's own thumb/primary + if (item.ImageTags?.Thumb) { + return `${api.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=400&quality=80&tag=${item.ImageTags.Thumb}`; + } + if (item.ImageTags?.Primary) { + return `${api.basePath}/Items/${item.Id}/Images/Primary?fillHeight=400&quality=80&tag=${item.ImageTags.Primary}`; + } + return null; + }, [api, item]); + + const animateTo = useCallback( + (value: number) => + Animated.timing(scale, { + toValue: value, + duration: 150, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }).start(), + [scale], + ); + + const handleFocus = useCallback(() => { + setFocused(true); + animateTo(sizes.animation.focusScale); + onFocus(item); + }, [animateTo, onFocus, item, sizes.animation.focusScale]); + + const handleBlur = useCallback(() => { + setFocused(false); + animateTo(1); + }, [animateTo]); + + const handlePress = useCallback(() => { + onPress(item); + }, [onPress, item]); + + const handleLongPress = useCallback(() => { + onLongPress?.(item); + }, [onLongPress, item]); + + // Use glass poster for tvOS 26+ + if (useGlass && posterUrl) { + const progress = item.UserData?.PlayedPercentage || 0; + return ( + + + + ); + } + + // Fallback for non-tvOS or older tvOS + return ( + + + {posterUrl ? ( + + ) : ( + + + + )} + + + + ); + }, +); + +// Debounce delay to prevent rapid backdrop changes when navigating fast +const BACKDROP_DEBOUNCE_MS = 300; + +export const TVHeroCarousel: React.FC = ({ + items, + onItemFocus, + onItemLongPress, +}) => { + const typography = useScaledTVTypography(); + const sizes = useScaledTVSizes(); + const api = useAtomValue(apiAtom); + const insets = useSafeAreaInsets(); + const router = useRouter(); + + // Active item for featured display (debounced) + const [activeItem, setActiveItem] = useState( + items[0] || null, + ); + const debounceTimerRef = useRef | null>(null); + + // Cleanup debounce timer on unmount + useEffect(() => { + return () => { + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + }; + }, []); + + // Crossfade animation state + const [activeLayer, setActiveLayer] = useState<0 | 1>(0); + const [layer0Url, setLayer0Url] = useState(null); + const [layer1Url, setLayer1Url] = useState(null); + const layer0Opacity = useRef(new Animated.Value(0)).current; + const layer1Opacity = useRef(new Animated.Value(0)).current; + + // Get backdrop URL for active item + const backdropUrl = useMemo(() => { + if (!activeItem) return null; + return getBackdropUrl({ + api, + item: activeItem, + quality: 90, + width: 1920, + }); + }, [api, activeItem]); + + // Get logo URL for active item + const logoUrl = useMemo(() => { + if (!activeItem) return null; + return getLogoImageUrlById({ api, item: activeItem }); + }, [api, activeItem]); + + // Crossfade effect for backdrop + useEffect(() => { + if (!backdropUrl) return; + + let isCancelled = false; + + const performCrossfade = async () => { + // Disk-only prefetch: backdrops are ~8MB decoded ARGB; keeping them + // out of the memory cache avoids bloat when the user cycles through + // hero items quickly. + try { + await Image.prefetch(backdropUrl, "disk"); + } catch { + // Continue even if prefetch fails + } + + if (isCancelled) return; + + const incomingLayer = activeLayer === 0 ? 1 : 0; + const incomingOpacity = + incomingLayer === 0 ? layer0Opacity : layer1Opacity; + const outgoingOpacity = + incomingLayer === 0 ? layer1Opacity : layer0Opacity; + + if (incomingLayer === 0) { + setLayer0Url(backdropUrl); + } else { + setLayer1Url(backdropUrl); + } + + await new Promise((resolve) => setTimeout(resolve, 50)); + + if (isCancelled) return; + + Animated.parallel([ + Animated.timing(incomingOpacity, { + toValue: 1, + duration: 500, + easing: Easing.inOut(Easing.quad), + useNativeDriver: true, + }), + Animated.timing(outgoingOpacity, { + toValue: 0, + duration: 500, + easing: Easing.inOut(Easing.quad), + useNativeDriver: true, + }), + ]).start(() => { + if (!isCancelled) { + setActiveLayer(incomingLayer); + } + }); + }; + + performCrossfade(); + + return () => { + isCancelled = true; + }; + }, [backdropUrl]); + + // Handle card focus with debounce + const handleCardFocus = useCallback( + (item: BaseItemDto) => { + // Clear any pending debounce timer + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + // Set new timer to update active item after debounce delay + debounceTimerRef.current = setTimeout(() => { + setActiveItem(item); + onItemFocus?.(item); + }, BACKDROP_DEBOUNCE_MS); + }, + [onItemFocus], + ); + + // Handle card press - navigate to item + const handleCardPress = useCallback( + (item: BaseItemDto) => { + const navigation = getItemNavigation(item, "(home)"); + router.push(navigation as any); + }, + [router], + ); + + // Get metadata for active item + const year = activeItem?.ProductionYear; + const duration = activeItem?.RunTimeTicks + ? runtimeTicksToMinutes(activeItem.RunTimeTicks) + : null; + const hasProgress = (activeItem?.UserData?.PlaybackPositionTicks ?? 0) > 0; + const playedPercent = activeItem?.UserData?.PlayedPercentage ?? 0; + + // Get display title + const displayTitle = useMemo(() => { + if (!activeItem) return ""; + if (activeItem.Type === "Episode") { + return activeItem.SeriesName || activeItem.Name || ""; + } + return activeItem.Name || ""; + }, [activeItem]); + + // Get subtitle for episodes + const episodeSubtitle = useMemo(() => { + if (activeItem?.Type !== "Episode") return null; + return `S${activeItem.ParentIndexNumber} E${activeItem.IndexNumber} · ${activeItem.Name}`; + }, [activeItem]); + + // Memoize hero items to prevent re-renders + const heroItems = useMemo(() => items.slice(0, 8), [items]); + + // Memoize renderItem for FlatList + const renderHeroCard = useCallback( + ({ item, index }: { item: BaseItemDto; index: number }) => ( + + ), + [handleCardFocus, handleCardPress, onItemLongPress, sizes], + ); + + // Memoize keyExtractor + const keyExtractor = useCallback((item: BaseItemDto) => item.Id!, []); + + if (items.length === 0) return null; + + // Extra top padding for tvOS to clear the menu bar + const tvosTopPadding = scaleSize(145); + const heroHeight = SCREEN_HEIGHT * sizes.padding.heroHeight; + + return ( + + {/* Backdrop layers with crossfade */} + + {/* Layer 0 */} + + {layer0Url && ( + + )} + + {/* Layer 1 */} + + {layer1Url && ( + + )} + + + {/* Gradient overlays */} + + + {/* Horizontal gradient for left side text contrast */} + + + + {/* Content overlay - text elements with padding */} + + {/* Logo or Title */} + {logoUrl ? ( + + ) : ( + + {displayTitle} + + )} + + {/* Episode subtitle */} + {episodeSubtitle && ( + + {episodeSubtitle} + + )} + + {/* Description */} + {activeItem?.Overview && ( + + {activeItem.Overview} + + )} + + {/* Metadata badges */} + + {year && ( + + {year} + + )} + {duration && ( + + {duration} + + )} + {activeItem?.OfficialRating && ( + + + {activeItem.OfficialRating} + + + )} + {hasProgress && ( + + + + + + {Math.round(playedPercent)}% + + + )} + + + + {/* Thumbnail carousel - edge-to-edge */} + + + // } + // contentInset={{ + // left: sizes.padding.horizontal, + // right: sizes.padding.horizontal, + // }} + // contentOffset={{ x: -sizes.padding.horizontal, y: 0 }} + // contentContainerStyle={{ paddingVertical: sizes.gaps.small }} + renderItem={renderHeroCard} + removeClippedSubviews={false} + initialNumToRender={8} + maxToRenderPerBatch={8} + windowSize={3} + /> + + + ); +}; diff --git a/components/inputs/TVPinInput.tsx b/components/inputs/TVPinInput.tsx new file mode 100644 index 000000000..d55970ebd --- /dev/null +++ b/components/inputs/TVPinInput.tsx @@ -0,0 +1,141 @@ +import React, { useRef, useState } from "react"; +import { + Animated, + Easing, + Pressable, + StyleSheet, + TextInput, + type TextInputProps, +} from "react-native"; +import { Text } from "@/components/common/Text"; + +interface TVPinInputProps + extends Omit { + value: string; + onChangeText: (text: string) => void; + length?: number; + label?: string; + hasTVPreferredFocus?: boolean; +} + +export interface TVPinInputRef { + focus: () => void; +} + +const TVPinInputComponent = React.forwardRef( + (props, ref) => { + const { + value, + onChangeText, + length = 4, + label, + hasTVPreferredFocus, + placeholder, + ...rest + } = props; + + const inputRef = useRef(null); + const [isFocused, setIsFocused] = useState(false); + const scale = useRef(new Animated.Value(1)).current; + + React.useImperativeHandle( + ref, + () => ({ + focus: () => inputRef.current?.focus(), + }), + [], + ); + + const animateFocus = (focused: boolean) => { + Animated.timing(scale, { + toValue: focused ? 1.02 : 1, + duration: 150, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }).start(); + }; + + const handleChangeText = (text: string) => { + // Only allow numeric input and limit to length + const numericText = text.replace(/[^0-9]/g, "").slice(0, length); + onChangeText(numericText); + }; + + return ( + inputRef.current?.focus()} + onFocus={() => { + setIsFocused(true); + animateFocus(true); + inputRef.current?.focus(); + }} + onBlur={() => { + setIsFocused(false); + animateFocus(false); + }} + hasTVPreferredFocus={hasTVPreferredFocus} + > + + {label && {label}} + { + setIsFocused(true); + animateFocus(true); + }} + onBlur={() => { + setIsFocused(false); + animateFocus(false); + }} + {...rest} + /> + + + ); + }, +); + +TVPinInputComponent.displayName = "TVPinInput"; + +export const TVPinInput = TVPinInputComponent; + +const styles = StyleSheet.create({ + container: { + backgroundColor: "#1F2937", + borderRadius: 12, + borderWidth: 2, + paddingHorizontal: 20, + paddingVertical: 4, + minWidth: 280, + }, + label: { + fontSize: 14, + color: "rgba(255,255,255,0.6)", + marginBottom: 4, + marginTop: 8, + }, + input: { + fontSize: 24, + color: "#fff", + fontWeight: "500", + textAlign: "center", + height: 56, + letterSpacing: 8, + }, +}); diff --git a/components/item/ItemPeopleSections.tsx b/components/item/ItemPeopleSections.tsx new file mode 100644 index 000000000..456276da2 --- /dev/null +++ b/components/item/ItemPeopleSections.tsx @@ -0,0 +1,90 @@ +import type { + BaseItemDto, + BaseItemPerson, +} from "@jellyfin/sdk/lib/generated-client/models"; +import type React from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { InteractionManager, View, type ViewProps } from "react-native"; +import { MoreMoviesWithActor } from "@/components/MoreMoviesWithActor"; +import { CastAndCrew } from "@/components/series/CastAndCrew"; +import { useItemPeopleQuery } from "@/hooks/useItemPeopleQuery"; +import { useOfflineMode } from "@/providers/OfflineModeProvider"; + +interface Props extends ViewProps { + item: BaseItemDto; +} + +export const ItemPeopleSections: React.FC = ({ item, ...props }) => { + const isOffline = useOfflineMode(); + const [enabled, setEnabled] = useState(false); + + useEffect(() => { + if (isOffline) return; + const task = InteractionManager.runAfterInteractions(() => + setEnabled(true), + ); + return () => task.cancel(); + }, [isOffline]); + + const { data, isLoading } = useItemPeopleQuery( + item.Id, + enabled && !isOffline, + ); + + const people = useMemo(() => (Array.isArray(data) ? data : []), [data]); + + const itemWithPeople = useMemo(() => { + return { ...item, People: people } as BaseItemDto; + }, [item, people]); + + // Jellyfin can list the same person several times (e.g. an actor also + // credited as writer). Dedupe by Id so the same actor section isn't rendered + // twice and we still surface 3 distinct people. + const topPeople = useMemo(() => { + const seen = new Set(); + const unique: BaseItemPerson[] = []; + for (const person of people) { + if (!person.Id || seen.has(person.Id)) continue; + seen.add(person.Id); + unique.push(person); + if (unique.length >= 3) break; + } + return unique; + }, [people]); + + const renderActorSection = useCallback( + (person: BaseItemPerson, idx: number, total: number) => { + if (!person.Id) return null; + + const spacingClassName = idx === total - 1 ? undefined : "mb-2"; + + return ( + + ); + }, + [item], + ); + + if (isOffline || !enabled) return null; + + const shouldSpaceCastAndCrew = topPeople.length > 0; + + return ( + + + {topPeople.map((person, idx) => + renderActorSection(person, idx, topPeople.length), + )} + + ); +}; diff --git a/components/jellyseerr/GridSkeleton.tsx b/components/jellyseerr/GridSkeleton.tsx new file mode 100644 index 000000000..75431cd7b --- /dev/null +++ b/components/jellyseerr/GridSkeleton.tsx @@ -0,0 +1,21 @@ +import { View } from "react-native"; + +interface Props { + index: number; +} +// Dev note might be a good idea to standardize skeletons across the app and have one "file" for it. +export const GridSkeleton: React.FC = ({ index }) => { + return ( + + + + + + + + ); +}; diff --git a/components/jellyseerr/ParallaxSlideShow.tsx b/components/jellyseerr/ParallaxSlideShow.tsx index b4b75592b..261da73dd 100644 --- a/components/jellyseerr/ParallaxSlideShow.tsx +++ b/components/jellyseerr/ParallaxSlideShow.tsx @@ -11,6 +11,7 @@ import { Animated, View, type ViewProps } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Text } from "@/components/common/Text"; import { ParallaxScrollView } from "@/components/ParallaxPage"; +import { GridSkeleton } from "./GridSkeleton"; const ANIMATION_ENTER = 250; const ANIMATION_EXIT = 250; @@ -28,6 +29,7 @@ interface Props { renderItem: (item: T, index: number) => Render; keyExtractor: (item: T) => string; onEndReached?: (() => void) | null | undefined; + isLoading?: boolean; } const ParallaxSlideShow = ({ @@ -40,6 +42,7 @@ const ParallaxSlideShow = ({ renderItem, keyExtractor, onEndReached, + isLoading = false, }: PropsWithChildren & ViewProps>) => { const insets = useSafeAreaInsets(); @@ -124,27 +127,40 @@ const ParallaxSlideShow = ({ {MainContent?.()} - - - No results - - - } - contentInsetAdjustmentBehavior='automatic' - ListHeaderComponent={ + {isLoading ? ( + {listHeader} - } - nestedScrollEnabled - showsVerticalScrollIndicator={false} - //@ts-expect-error - renderItem={({ item, index }) => renderItem(item, index)} - keyExtractor={keyExtractor} - numColumns={3} - ItemSeparatorComponent={() => } - /> + + + {Array.from({ length: 9 }, (_, i) => ( + + ))} + + + + ) : ( + + + No results + + + } + contentInsetAdjustmentBehavior='automatic' + ListHeaderComponent={ + {listHeader} + } + nestedScrollEnabled + showsVerticalScrollIndicator={false} + //@ts-expect-error + renderItem={({ item, index }) => renderItem(item, index)} + keyExtractor={keyExtractor} + numColumns={3} + ItemSeparatorComponent={() => } + /> + )} diff --git a/components/jellyseerr/PersonPoster.tsx b/components/jellyseerr/PersonPoster.tsx index 70e44d351..e926b093d 100644 --- a/components/jellyseerr/PersonPoster.tsx +++ b/components/jellyseerr/PersonPoster.tsx @@ -1,8 +1,9 @@ -import { useRouter, useSegments } from "expo-router"; +import { useSegments } from "expo-router"; import type React from "react"; import { TouchableOpacity, View, type ViewProps } from "react-native"; import { Text } from "@/components/common/Text"; import Poster from "@/components/posters/Poster"; +import useRouter from "@/hooks/useAppRouter"; import { useJellyseerr } from "@/hooks/useJellyseerr"; interface Props { diff --git a/components/jellyseerr/discover/CompanySlide.tsx b/components/jellyseerr/discover/CompanySlide.tsx index 9643f48e7..abba1d312 100644 --- a/components/jellyseerr/discover/CompanySlide.tsx +++ b/components/jellyseerr/discover/CompanySlide.tsx @@ -1,9 +1,10 @@ -import { router, useSegments } from "expo-router"; +import { useSegments } from "expo-router"; import type React from "react"; import { useCallback } from "react"; import { TouchableOpacity, type ViewProps } from "react-native"; import GenericSlideCard from "@/components/jellyseerr/discover/GenericSlideCard"; import Slide, { type SlideProps } from "@/components/jellyseerr/discover/Slide"; +import useRouter from "@/hooks/useAppRouter"; import { useJellyseerr } from "@/hooks/useJellyseerr"; import { COMPANY_LOGO_IMAGE_FILTER, @@ -16,6 +17,7 @@ const CompanySlide: React.FC< > = ({ slide, data, ...props }) => { const segments = useSegments(); const { jellyseerrApi } = useJellyseerr(); + const router = useRouter(); const from = (segments as string[])[2] || "(home)"; const navigate = useCallback( diff --git a/components/jellyseerr/discover/GenreSlide.tsx b/components/jellyseerr/discover/GenreSlide.tsx index 8edaf4c3d..312485407 100644 --- a/components/jellyseerr/discover/GenreSlide.tsx +++ b/components/jellyseerr/discover/GenreSlide.tsx @@ -1,10 +1,11 @@ import { useQuery } from "@tanstack/react-query"; -import { router, useSegments } from "expo-router"; +import { useSegments } from "expo-router"; import type React from "react"; import { useCallback } from "react"; import { TouchableOpacity, type ViewProps } from "react-native"; import GenericSlideCard from "@/components/jellyseerr/discover/GenericSlideCard"; import Slide, { type SlideProps } from "@/components/jellyseerr/discover/Slide"; +import useRouter from "@/hooks/useAppRouter"; import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr"; import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover"; import type { GenreSliderItem } from "@/utils/jellyseerr/server/interfaces/api/discoverInterfaces"; @@ -13,6 +14,7 @@ import { genreColorMap } from "@/utils/jellyseerr/src/components/Discover/consta const GenreSlide: React.FC = ({ slide, ...props }) => { const segments = useSegments(); const { jellyseerrApi } = useJellyseerr(); + const router = useRouter(); const from = (segments as string[])[2] || "(home)"; const navigate = useCallback( diff --git a/components/jellyseerr/discover/TVDiscover.tsx b/components/jellyseerr/discover/TVDiscover.tsx new file mode 100644 index 000000000..9dc55fe7e --- /dev/null +++ b/components/jellyseerr/discover/TVDiscover.tsx @@ -0,0 +1,47 @@ +import { sortBy } from "lodash"; +import React, { useMemo } from "react"; +import { View } from "react-native"; +import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover"; +import type DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider"; +import { TVDiscoverSlide } from "./TVDiscoverSlide"; + +interface TVDiscoverProps { + sliders?: DiscoverSlider[]; +} + +// Only show movie/TV slides on TV - skip genres, networks, studios for now +const SUPPORTED_SLIDE_TYPES = [ + DiscoverSliderType.TRENDING, + DiscoverSliderType.POPULAR_MOVIES, + DiscoverSliderType.UPCOMING_MOVIES, + DiscoverSliderType.POPULAR_TV, + DiscoverSliderType.UPCOMING_TV, +]; + +export const TVDiscover: React.FC = ({ sliders }) => { + const sortedSliders = useMemo( + () => + sortBy( + (sliders ?? []).filter( + (s) => s.enabled && SUPPORTED_SLIDE_TYPES.includes(s.type), + ), + "order", + "asc", + ), + [sliders], + ); + + if (!sliders || sortedSliders.length === 0) return null; + + return ( + + {sortedSliders.map((slide, index) => ( + + ))} + + ); +}; diff --git a/components/jellyseerr/discover/TVDiscoverSlide.tsx b/components/jellyseerr/discover/TVDiscoverSlide.tsx new file mode 100644 index 000000000..5f98fcf53 --- /dev/null +++ b/components/jellyseerr/discover/TVDiscoverSlide.tsx @@ -0,0 +1,270 @@ +import { Ionicons } from "@expo/vector-icons"; +import { useInfiniteQuery } from "@tanstack/react-query"; +import { Image } from "expo-image"; +import { uniqBy } from "lodash"; +import React, { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { Animated, FlatList, Pressable, View } from "react-native"; +import { Text } from "@/components/common/Text"; +import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation"; +import { useScaledTVTypography } from "@/constants/TVTypography"; +import useRouter from "@/hooks/useAppRouter"; +import { + type DiscoverEndpoint, + Endpoints, + useJellyseerr, +} from "@/hooks/useJellyseerr"; +import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover"; +import { MediaStatus } from "@/utils/jellyseerr/server/constants/media"; +import type DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider"; +import type { + MovieResult, + TvResult, +} from "@/utils/jellyseerr/server/models/Search"; + +const SCALE_PADDING = 20; + +interface TVDiscoverPosterProps { + item: MovieResult | TvResult; + isFirstItem?: boolean; +} + +const TVDiscoverPoster: React.FC = ({ + item, + isFirstItem = false, +}) => { + const typography = useScaledTVTypography(); + const router = useRouter(); + const { jellyseerrApi, getTitle, getYear } = useJellyseerr(); + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation({ scaleAmount: 1.05 }); + + const posterUrl = item.posterPath + ? jellyseerrApi?.imageProxy(item.posterPath, "w342") + : null; + + const title = getTitle(item); + const year = getYear(item); + + const isInLibrary = + item.mediaInfo?.status === MediaStatus.AVAILABLE || + item.mediaInfo?.status === MediaStatus.PARTIALLY_AVAILABLE; + + const handlePress = () => { + router.push({ + pathname: "/(auth)/(tabs)/(search)/jellyseerr/page", + params: { + id: String(item.id), + mediaType: item.mediaType, + }, + }); + }; + + return ( + + + + {posterUrl ? ( + + ) : ( + + + + )} + {isInLibrary && ( + + + + )} + + + {title} + + {year && ( + + {year} + + )} + + + ); +}; + +interface TVDiscoverSlideProps { + slide: DiscoverSlider; + isFirstSlide?: boolean; +} + +export const TVDiscoverSlide: React.FC = ({ + slide, + isFirstSlide = false, +}) => { + const typography = useScaledTVTypography(); + const { t } = useTranslation(); + const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr(); + + const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({ + queryKey: ["jellyseerr", "discover", "tv", slide.id], + queryFn: async ({ pageParam }) => { + let endpoint: DiscoverEndpoint | undefined; + let params: Record = { + page: Number(pageParam), + }; + + switch (slide.type) { + case DiscoverSliderType.TRENDING: + endpoint = Endpoints.DISCOVER_TRENDING; + break; + case DiscoverSliderType.POPULAR_MOVIES: + case DiscoverSliderType.UPCOMING_MOVIES: + endpoint = Endpoints.DISCOVER_MOVIES; + if (slide.type === DiscoverSliderType.UPCOMING_MOVIES) + params = { + ...params, + primaryReleaseDateGte: new Date().toISOString().split("T")[0], + }; + break; + case DiscoverSliderType.POPULAR_TV: + case DiscoverSliderType.UPCOMING_TV: + endpoint = Endpoints.DISCOVER_TV; + if (slide.type === DiscoverSliderType.UPCOMING_TV) + params = { + ...params, + firstAirDateGte: new Date().toISOString().split("T")[0], + }; + break; + } + + return endpoint ? jellyseerrApi?.discover(endpoint, params) : null; + }, + initialPageParam: 1, + getNextPageParam: (lastPage, pages) => + (lastPage?.page || pages?.findLast((p) => p?.results.length)?.page || 1) + + 1, + enabled: !!jellyseerrApi, + staleTime: 0, + }); + + const flatData = useMemo( + () => + uniqBy( + data?.pages + ?.filter((p) => p?.results.length) + .flatMap((p) => + p?.results.filter((r) => isJellyseerrMovieOrTvResult(r)), + ), + "id", + ) as (MovieResult | TvResult)[], + [data, isJellyseerrMovieOrTvResult], + ); + + const slideTitle = t( + `search.${DiscoverSliderType[slide.type].toString().toLowerCase()}`, + ); + + if (!flatData || flatData.length === 0) return null; + + return ( + + + {slideTitle} + + item.id.toString()} + showsHorizontalScrollIndicator={false} + contentContainerStyle={{ + paddingHorizontal: SCALE_PADDING, + paddingVertical: SCALE_PADDING, + gap: 20, + }} + style={{ overflow: "visible" }} + onEndReached={() => { + if (hasNextPage) fetchNextPage(); + }} + onEndReachedThreshold={0.5} + renderItem={({ item, index }) => ( + + )} + /> + + ); +}; diff --git a/components/jellyseerr/tv/TVJellyseerrPage.tsx b/components/jellyseerr/tv/TVJellyseerrPage.tsx new file mode 100644 index 000000000..f44c7f8e9 --- /dev/null +++ b/components/jellyseerr/tv/TVJellyseerrPage.tsx @@ -0,0 +1,877 @@ +import { Ionicons } from "@expo/vector-icons"; +import { useQuery } from "@tanstack/react-query"; +import { BlurView } from "expo-blur"; +import { Image } from "expo-image"; +import { LinearGradient } from "expo-linear-gradient"; +import { useLocalSearchParams } from "expo-router"; +import React, { useCallback, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + Animated, + Dimensions, + Pressable, + ScrollView, + TVFocusGuideView, + View, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { toast } from "sonner-native"; +import { Text } from "@/components/common/Text"; +import { GenreTags } from "@/components/GenreTags"; +import { Loader } from "@/components/Loader"; +import { JellyserrRatings } from "@/components/Ratings"; +import { TVButton } from "@/components/tv"; +import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation"; +import { useScaledTVTypography } from "@/constants/TVTypography"; +import useRouter from "@/hooks/useAppRouter"; +import { useJellyseerr } from "@/hooks/useJellyseerr"; +import { useTVRequestModal } from "@/hooks/useTVRequestModal"; +import { useTVSeasonSelectModal } from "@/hooks/useTVSeasonSelectModal"; +import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest"; +import { + MediaRequestStatus, + MediaStatus, + MediaType, +} from "@/utils/jellyseerr/server/constants/media"; +import type MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest"; +import type Season from "@/utils/jellyseerr/server/entity/Season"; +import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces"; +import { + hasPermission, + Permission, +} from "@/utils/jellyseerr/server/lib/permissions"; +import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie"; +import type { + MovieResult, + TvResult, +} from "@/utils/jellyseerr/server/models/Search"; +import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv"; + +const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window"); + +// Cast card component +interface TVCastCardProps { + person: { + id: number; + name: string; + character?: string; + profilePath?: string; + }; + imageProxy: (path: string, size?: string) => string; + onPress: () => void; + refSetter?: (ref: View | null) => void; +} + +const TVCastCard: React.FC = ({ + person, + imageProxy, + onPress, + refSetter, +}) => { + const typography = useScaledTVTypography(); + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation({ scaleAmount: 1.08 }); + + const profileUrl = person.profilePath + ? imageProxy(person.profilePath, "w185") + : null; + + return ( + + + + {profileUrl ? ( + + ) : ( + + + + )} + + + {person.name} + + {person.character && ( + + {person.character} + + )} + + + ); +}; + +export const TVJellyseerrPage: React.FC = () => { + const typography = useScaledTVTypography(); + const insets = useSafeAreaInsets(); + const params = useLocalSearchParams(); + const { t } = useTranslation(); + const router = useRouter(); + + const { mediaTitle, releaseYear, posterSrc, mediaType, ...result } = + params as unknown as { + mediaTitle: string; + releaseYear: number; + canRequest: string; + posterSrc: string; + mediaType: MediaType; + } & Partial; + + const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr(); + const { showRequestModal } = useTVRequestModal(); + const { showSeasonSelectModal } = useTVSeasonSelectModal(); + + // Refs for TVFocusGuideView destinations (useState triggers re-render when set) + const [playButtonRef, setPlayButtonRef] = useState(null); + const [firstCastCardRef, setFirstCastCardRef] = useState(null); + + const { + data: details, + isFetching, + isLoading, + refetch, + } = useQuery({ + enabled: !!jellyseerrApi && !!result && !!result.id, + queryKey: ["jellyseerr", "detail", mediaType, result.id], + staleTime: 0, + refetchOnMount: true, + queryFn: async () => { + return mediaType === MediaType.MOVIE + ? jellyseerrApi?.movieDetails(result.id!) + : jellyseerrApi?.tvDetails(result.id!); + }, + }); + + const [canRequest, hasAdvancedRequestPermission] = + useJellyseerrCanRequest(details); + + const canManageRequests = useMemo(() => { + if (!jellyseerrUser) return false; + return hasPermission( + Permission.MANAGE_REQUESTS, + jellyseerrUser.permissions, + ); + }, [jellyseerrUser]); + + const pendingRequest = useMemo(() => { + return details?.mediaInfo?.requests?.find( + (r: MediaRequest) => r.status === MediaRequestStatus.PENDING, + ); + }, [details]); + + // Get seasons with status for TV shows + const seasons = useMemo(() => { + if (!details || mediaType !== MediaType.TV) return []; + const tvDetails = details as TvDetails; + const mediaInfoSeasons = tvDetails.mediaInfo?.seasons?.filter( + (s: Season) => s.seasonNumber !== 0, + ); + const requestedSeasons = + tvDetails.mediaInfo?.requests?.flatMap((r: MediaRequest) => r.seasons) ?? + []; + return ( + tvDetails.seasons?.map((season) => ({ + ...season, + status: + mediaInfoSeasons?.find( + (mediaSeason: Season) => + mediaSeason.seasonNumber === season.seasonNumber, + )?.status ?? + requestedSeasons?.find( + (s: Season) => s.seasonNumber === season.seasonNumber, + )?.status ?? + MediaStatus.UNKNOWN, + })) ?? [] + ); + }, [details, mediaType]); + + const _allSeasonsAvailable = useMemo( + () => seasons.every((season) => season.status === MediaStatus.AVAILABLE), + [seasons], + ); + + // Check if there are any requestable seasons (status === UNKNOWN) + const hasRequestableSeasons = useMemo( + () => + seasons.some( + (season) => + season.seasonNumber !== 0 && season.status === MediaStatus.UNKNOWN, + ), + [seasons], + ); + + // Get cast + const cast = useMemo(() => { + return details?.credits?.cast?.slice(0, 10) ?? []; + }, [details]); + + // Backdrop URL + const backdropUrl = useMemo(() => { + const path = details?.backdropPath || result.backdropPath; + return path + ? jellyseerrApi?.imageProxy(path, "w1920_and_h800_multi_faces") + : null; + }, [details, result.backdropPath, jellyseerrApi]); + + // Poster URL + const posterUrl = useMemo(() => { + if (posterSrc) return posterSrc; + const path = details?.posterPath; + return path ? jellyseerrApi?.imageProxy(path, "w342") : null; + }, [posterSrc, details, jellyseerrApi]); + + // Handlers + const handleApproveRequest = useCallback(async () => { + if (!pendingRequest?.id) return; + try { + await jellyseerrApi?.approveRequest(pendingRequest.id); + toast.success(t("jellyseerr.toasts.request_approved")); + refetch(); + } catch (_error) { + toast.error(t("jellyseerr.toasts.failed_to_approve_request")); + } + }, [jellyseerrApi, pendingRequest, refetch, t]); + + const handleDeclineRequest = useCallback(async () => { + if (!pendingRequest?.id) return; + try { + await jellyseerrApi?.declineRequest(pendingRequest.id); + toast.success(t("jellyseerr.toasts.request_declined")); + refetch(); + } catch (_error) { + toast.error(t("jellyseerr.toasts.failed_to_decline_request")); + } + }, [jellyseerrApi, pendingRequest, refetch, t]); + + const handleRequest = useCallback(async () => { + const body: MediaRequestBody = { + mediaId: Number(result.id!), + mediaType: mediaType!, + tvdbId: details?.externalIds?.tvdbId, + ...(mediaType === MediaType.TV && { + seasons: (details as TvDetails)?.seasons + ?.filter?.((s) => s.seasonNumber !== 0) + ?.map?.((s) => s.seasonNumber), + }), + }; + + if (hasAdvancedRequestPermission) { + showRequestModal({ + requestBody: body, + title: mediaTitle, + id: result.id!, + mediaType: mediaType!, + onRequested: refetch, + }); + return; + } + + requestMedia(mediaTitle, body, refetch); + }, [ + details, + result, + requestMedia, + hasAdvancedRequestPermission, + mediaTitle, + refetch, + mediaType, + showRequestModal, + ]); + + const handleRequestAll = useCallback(() => { + const body: MediaRequestBody = { + mediaId: Number(result.id!), + mediaType: MediaType.TV, + tvdbId: details?.externalIds?.tvdbId, + seasons: seasons + .filter((s) => s.status === MediaStatus.UNKNOWN && s.seasonNumber !== 0) + .map((s) => s.seasonNumber), + }; + + if (hasAdvancedRequestPermission) { + showRequestModal({ + requestBody: body, + title: mediaTitle, + id: result.id!, + mediaType: MediaType.TV, + onRequested: refetch, + }); + return; + } + + requestMedia(`${mediaTitle}, ${t("jellyseerr.season_all")}`, body, refetch); + }, [ + details, + result, + seasons, + hasAdvancedRequestPermission, + requestMedia, + mediaTitle, + refetch, + t, + showRequestModal, + ]); + + const handleOpenSeasonSelectModal = useCallback(() => { + showSeasonSelectModal({ + seasons: seasons.filter((s) => s.seasonNumber !== 0), + title: mediaTitle, + mediaId: Number(result.id!), + tvdbId: details?.externalIds?.tvdbId, + hasAdvancedRequestPermission, + onRequested: refetch, + }); + }, [ + seasons, + mediaTitle, + result, + details, + hasAdvancedRequestPermission, + refetch, + showSeasonSelectModal, + ]); + + const handlePlay = useCallback(() => { + const jellyfinMediaId = details?.mediaInfo?.jellyfinMediaId; + if (!jellyfinMediaId) return; + router.push({ + pathname: + mediaType === MediaType.MOVIE + ? "/(auth)/(tabs)/(search)/items/page" + : "/(auth)/(tabs)/(search)/series/[id]", + params: { id: jellyfinMediaId }, + }); + }, [details, mediaType, router]); + + const handleCastPress = useCallback( + (personId: number) => { + router.push(`/(auth)/jellyseerr/person/${personId}` as any); + }, + [router], + ); + + const hasJellyfinMedia = !!details?.mediaInfo?.jellyfinMediaId; + const requestedByName = + pendingRequest?.requestedBy?.displayName || + pendingRequest?.requestedBy?.username || + pendingRequest?.requestedBy?.jellyfinUsername || + t("jellyseerr.unknown_user"); + + if (isLoading || isFetching) { + return ( + + + + ); + } + + return ( + + {/* Full-screen backdrop */} + + {backdropUrl ? ( + + ) : ( + + )} + {/* Bottom gradient */} + + {/* Left gradient */} + + + + {/* Main content */} + + {/* Top section - Poster + Content */} + + {/* Left side - Poster */} + + + {posterUrl ? ( + + ) : ( + + + + )} + + + + {/* Right side - Content */} + + {/* Ratings */} + {details && ( + + )} + + {/* Title */} + + {mediaTitle} + + + {/* Year */} + + {releaseYear} + + + {/* Genres */} + {details?.genres && details.genres.length > 0 && ( + + g.name)} /> + + )} + + {/* Overview */} + {(details?.overview || result.overview) && ( + + + + {details?.overview || result.overview} + + + + )} + + {/* Action buttons */} + + {hasJellyfinMedia && ( + + + + {t("common.play")} + + + )} + + {/* Request button - only show for movies, TV series use Request All + season cards */} + {canRequest && mediaType === MediaType.MOVIE && ( + + + + {t("jellyseerr.request_button")} + + + )} + + {/* Request All button for TV series */} + {mediaType === MediaType.TV && + seasons.filter((s) => s.seasonNumber !== 0).length > 0 && + hasRequestableSeasons && ( + + + + + {t("jellyseerr.request_all")} + + + + )} + + {/* Request Seasons button for TV series */} + {mediaType === MediaType.TV && + seasons.filter((s) => s.seasonNumber !== 0).length > 0 && + hasRequestableSeasons && ( + + + + + {t("jellyseerr.request_seasons")} + + + + )} + + + {/* Approve/Decline for managers */} + {canManageRequests && pendingRequest && ( + + + + + {t("jellyseerr.requested_by", { user: requestedByName })} + + + + + + + + {t("jellyseerr.approve")} + + + + + + + {t("jellyseerr.decline")} + + + + + )} + + + + {/* Cast section */} + {cast.length > 0 && jellyseerrApi && ( + + + {t("jellyseerr.cast")} + + + {/* Focus guides for bidirectional navigation - stacked together */} + {/* Downward: action buttons → first cast card */} + {firstCastCardRef && ( + + )} + {/* Upward: cast → action buttons */} + {playButtonRef && ( + + )} + + + {cast.map((person, index) => ( + + jellyseerrApi.imageProxy(path, size || "w185") + } + onPress={() => handleCastPress(person.id)} + refSetter={index === 0 ? setFirstCastCardRef : undefined} + /> + ))} + + + )} + + + ); +}; diff --git a/components/jellyseerr/tv/TVRequestModal.tsx b/components/jellyseerr/tv/TVRequestModal.tsx new file mode 100644 index 000000000..72e99e22c --- /dev/null +++ b/components/jellyseerr/tv/TVRequestModal.tsx @@ -0,0 +1,519 @@ +import { Ionicons } from "@expo/vector-icons"; +import { useQuery } from "@tanstack/react-query"; +import { BlurView } from "expo-blur"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { useTranslation } from "react-i18next"; +import { + Animated, + BackHandler, + Easing, + ScrollView, + TVFocusGuideView, + View, +} from "react-native"; +import { Text } from "@/components/common/Text"; +import { TVButton, TVOptionSelector } from "@/components/tv"; +import type { TVOptionItem } from "@/components/tv/TVOptionSelector"; +import { useScaledTVTypography } from "@/constants/TVTypography"; +import { useJellyseerr } from "@/hooks/useJellyseerr"; +import type { + QualityProfile, + RootFolder, + Tag, +} from "@/utils/jellyseerr/server/api/servarr/base"; +import type { MediaType } from "@/utils/jellyseerr/server/constants/media"; +import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces"; +import { TVRequestOptionRow } from "./TVRequestOptionRow"; +import { TVToggleOptionRow } from "./TVToggleOptionRow"; + +interface TVRequestModalProps { + visible: boolean; + requestBody?: MediaRequestBody; + title: string; + id: number; + mediaType: MediaType; + onClose: () => void; + onRequested: () => void; +} + +export const TVRequestModal: React.FC = ({ + visible, + requestBody, + title, + id, + mediaType, + onClose, + onRequested, +}) => { + const typography = useScaledTVTypography(); + const { t } = useTranslation(); + const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr(); + + const [requestOverrides, setRequestOverrides] = useState({ + mediaId: Number(id), + mediaType, + userId: jellyseerrUser?.id, + }); + + const [activeSelector, setActiveSelector] = useState< + "profile" | "folder" | "user" | null + >(null); + + const overlayOpacity = useRef(new Animated.Value(0)).current; + const sheetTranslateY = useRef(new Animated.Value(200)).current; + + useEffect(() => { + if (visible) { + overlayOpacity.setValue(0); + sheetTranslateY.setValue(200); + + Animated.parallel([ + Animated.timing(overlayOpacity, { + toValue: 1, + duration: 250, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }), + Animated.timing(sheetTranslateY, { + toValue: 0, + duration: 300, + easing: Easing.out(Easing.cubic), + useNativeDriver: true, + }), + ]).start(); + } + }, [visible, overlayOpacity, sheetTranslateY]); + + // Handle back button to close modal + useEffect(() => { + if (!visible) return; + + const handleBackPress = () => { + // If a sub-selector is open, close it first + if (activeSelector) { + setActiveSelector(null); + } else { + onClose(); + } + return true; // Prevent default back behavior + }; + + const subscription = BackHandler.addEventListener( + "hardwareBackPress", + handleBackPress, + ); + + return () => subscription.remove(); + }, [visible, activeSelector, onClose]); + + const { data: serviceSettings } = useQuery({ + queryKey: ["jellyseerr", "request", mediaType, "service"], + queryFn: async () => + jellyseerrApi?.service(mediaType === "movie" ? "radarr" : "sonarr"), + enabled: !!jellyseerrApi && !!jellyseerrUser && visible, + }); + + const { data: users } = useQuery({ + queryKey: ["jellyseerr", "users"], + queryFn: async () => + jellyseerrApi?.user({ take: 1000, sort: "displayname" }), + enabled: !!jellyseerrApi && !!jellyseerrUser && visible, + }); + + const defaultService = useMemo( + () => serviceSettings?.find?.((v) => v.isDefault), + [serviceSettings], + ); + + const { data: defaultServiceDetails } = useQuery({ + queryKey: [ + "jellyseerr", + "request", + mediaType, + "service", + "details", + defaultService?.id, + ], + queryFn: async () => { + setRequestOverrides((prev) => ({ + ...prev, + serverId: defaultService?.id, + })); + return jellyseerrApi?.serviceDetails( + mediaType === "movie" ? "radarr" : "sonarr", + defaultService!.id, + ); + }, + enabled: !!jellyseerrApi && !!jellyseerrUser && !!defaultService && visible, + }); + + const defaultProfile: QualityProfile | undefined = useMemo( + () => + defaultServiceDetails?.profiles.find( + (p) => p.id === defaultServiceDetails.server?.activeProfileId, + ), + [defaultServiceDetails], + ); + + const defaultFolder: RootFolder | undefined = useMemo( + () => + defaultServiceDetails?.rootFolders.find( + (f) => f.path === defaultServiceDetails.server?.activeDirectory, + ), + [defaultServiceDetails], + ); + + const defaultTags: Tag[] = useMemo(() => { + return ( + defaultServiceDetails?.tags.filter((t) => + defaultServiceDetails?.server.activeTags?.includes(t.id), + ) ?? [] + ); + }, [defaultServiceDetails]); + + const pathTitleExtractor = (item: RootFolder) => + `${item.path} (${item.freeSpace.bytesToReadable()})`; + + // Option builders + const qualityProfileOptions: TVOptionItem[] = useMemo( + () => + defaultServiceDetails?.profiles.map((profile) => ({ + label: profile.name, + value: profile.id, + selected: + (requestOverrides.profileId || defaultProfile?.id) === profile.id, + })) || [], + [ + defaultServiceDetails?.profiles, + defaultProfile, + requestOverrides.profileId, + ], + ); + + const rootFolderOptions: TVOptionItem[] = useMemo( + () => + defaultServiceDetails?.rootFolders.map((folder) => ({ + label: pathTitleExtractor(folder), + value: folder.path, + selected: + (requestOverrides.rootFolder || defaultFolder?.path) === folder.path, + })) || [], + [ + defaultServiceDetails?.rootFolders, + defaultFolder, + requestOverrides.rootFolder, + ], + ); + + const userOptions: TVOptionItem[] = useMemo( + () => + users?.map((user) => ({ + label: user.displayName, + value: user.id, + selected: (requestOverrides.userId || jellyseerrUser?.id) === user.id, + })) || [], + [users, jellyseerrUser, requestOverrides.userId], + ); + + const tagItems = useMemo(() => { + return ( + defaultServiceDetails?.tags.map((tag) => ({ + id: tag.id, + label: tag.label, + selected: + requestOverrides.tags?.includes(tag.id) || + defaultTags.some((dt) => dt.id === tag.id), + })) ?? [] + ); + }, [defaultServiceDetails?.tags, defaultTags, requestOverrides.tags]); + + // Selected display values + const selectedProfileName = useMemo(() => { + const profile = defaultServiceDetails?.profiles.find( + (p) => p.id === (requestOverrides.profileId || defaultProfile?.id), + ); + return profile?.name || defaultProfile?.name || t("jellyseerr.select"); + }, [ + defaultServiceDetails?.profiles, + requestOverrides.profileId, + defaultProfile, + t, + ]); + + const selectedFolderName = useMemo(() => { + const folder = defaultServiceDetails?.rootFolders.find( + (f) => f.path === (requestOverrides.rootFolder || defaultFolder?.path), + ); + return folder + ? pathTitleExtractor(folder) + : defaultFolder + ? pathTitleExtractor(defaultFolder) + : t("jellyseerr.select"); + }, [ + defaultServiceDetails?.rootFolders, + requestOverrides.rootFolder, + defaultFolder, + t, + ]); + + const selectedUserName = useMemo(() => { + const user = users?.find( + (u) => u.id === (requestOverrides.userId || jellyseerrUser?.id), + ); + return ( + user?.displayName || jellyseerrUser?.displayName || t("jellyseerr.select") + ); + }, [users, requestOverrides.userId, jellyseerrUser, t]); + + // Handlers + const handleProfileChange = useCallback((profileId: number) => { + setRequestOverrides((prev) => ({ ...prev, profileId })); + setActiveSelector(null); + }, []); + + const handleFolderChange = useCallback((rootFolder: string) => { + setRequestOverrides((prev) => ({ ...prev, rootFolder })); + setActiveSelector(null); + }, []); + + const handleUserChange = useCallback((userId: number) => { + setRequestOverrides((prev) => ({ ...prev, userId })); + setActiveSelector(null); + }, []); + + const handleTagToggle = useCallback( + (tagId: number) => { + setRequestOverrides((prev) => { + const currentTags = prev.tags || defaultTags.map((t) => t.id); + const hasTag = currentTags.includes(tagId); + return { + ...prev, + tags: hasTag + ? currentTags.filter((id) => id !== tagId) + : [...currentTags, tagId], + }; + }); + }, + [defaultTags], + ); + + const handleRequest = useCallback(() => { + const body = { + is4k: defaultService?.is4k || defaultServiceDetails?.server.is4k, + profileId: defaultProfile?.id, + rootFolder: defaultFolder?.path, + tags: defaultTags.map((t) => t.id), + ...requestBody, + ...requestOverrides, + }; + + const seasonTitle = + requestBody?.seasons?.length === 1 + ? t("jellyseerr.season_number", { + season_number: requestBody.seasons[0], + }) + : requestBody?.seasons && requestBody.seasons.length > 1 + ? t("jellyseerr.season_all") + : undefined; + + requestMedia( + seasonTitle ? `${title}, ${seasonTitle}` : title, + body, + onRequested, + ); + }, [ + requestBody, + requestOverrides, + defaultProfile, + defaultFolder, + defaultTags, + defaultService, + defaultServiceDetails, + title, + requestMedia, + onRequested, + t, + ]); + + if (!visible) return null; + + const isDataLoaded = defaultService && defaultServiceDetails && users; + + return ( + <> + + + + + + {t("jellyseerr.advanced")} + + + {title} + + + {isDataLoaded ? ( + + + setActiveSelector("profile")} + hasTVPreferredFocus + /> + setActiveSelector("folder")} + /> + setActiveSelector("user")} + /> + + {tagItems.length > 0 && ( + + )} + + + ) : ( + + + {t("common.loading")} + + + )} + + + + + + {t("jellyseerr.request_button")} + + + + + + + + + {/* Sub-selectors */} + setActiveSelector(null)} + cancelLabel={t("jellyseerr.cancel")} + /> + setActiveSelector(null)} + cancelLabel={t("jellyseerr.cancel")} + cardWidth={280} + /> + setActiveSelector(null)} + cancelLabel={t("jellyseerr.cancel")} + /> + + ); +}; diff --git a/components/jellyseerr/tv/TVRequestOptionRow.tsx b/components/jellyseerr/tv/TVRequestOptionRow.tsx new file mode 100644 index 000000000..f7c25c5ba --- /dev/null +++ b/components/jellyseerr/tv/TVRequestOptionRow.tsx @@ -0,0 +1,86 @@ +import { Ionicons } from "@expo/vector-icons"; +import React from "react"; +import { Animated, Pressable, View } from "react-native"; +import { Text } from "@/components/common/Text"; +import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation"; +import { useScaledTVTypography } from "@/constants/TVTypography"; + +interface TVRequestOptionRowProps { + label: string; + value: string; + onPress: () => void; + hasTVPreferredFocus?: boolean; + disabled?: boolean; +} + +export const TVRequestOptionRow: React.FC = ({ + label, + value, + onPress, + hasTVPreferredFocus = false, + disabled = false, +}) => { + const typography = useScaledTVTypography(); + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation({ + scaleAmount: 1.02, + }); + + return ( + + + + {label} + + + + {value} + + + + + + ); +}; diff --git a/components/jellyseerr/tv/TVToggleOptionRow.tsx b/components/jellyseerr/tv/TVToggleOptionRow.tsx new file mode 100644 index 000000000..296dff6e7 --- /dev/null +++ b/components/jellyseerr/tv/TVToggleOptionRow.tsx @@ -0,0 +1,117 @@ +import React from "react"; +import { Animated, Pressable, ScrollView, View } from "react-native"; +import { Text } from "@/components/common/Text"; +import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation"; +import { useScaledTVTypography } from "@/constants/TVTypography"; + +interface ToggleItem { + id: number; + label: string; + selected: boolean; +} + +interface TVToggleChipProps { + item: ToggleItem; + onToggle: (id: number) => void; + disabled?: boolean; +} + +const TVToggleChip: React.FC = ({ + item, + onToggle, + disabled = false, +}) => { + const typography = useScaledTVTypography(); + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation({ + scaleAmount: 1.08, + }); + + return ( + onToggle(item.id)} + onFocus={handleFocus} + onBlur={handleBlur} + disabled={disabled} + focusable={!disabled} + > + + + {item.label} + + + + ); +}; + +interface TVToggleOptionRowProps { + label: string; + items: ToggleItem[]; + onToggle: (id: number) => void; + disabled?: boolean; +} + +export const TVToggleOptionRow: React.FC = ({ + label, + items, + onToggle, + disabled = false, +}) => { + const typography = useScaledTVTypography(); + if (items.length === 0) return null; + + return ( + + + {label} + + + {items.map((item) => ( + + ))} + + + ); +}; diff --git a/components/jellyseerr/tv/index.ts b/components/jellyseerr/tv/index.ts new file mode 100644 index 000000000..b20700c4b --- /dev/null +++ b/components/jellyseerr/tv/index.ts @@ -0,0 +1,4 @@ +export { TVJellyseerrPage } from "./TVJellyseerrPage"; +export { TVRequestModal } from "./TVRequestModal"; +export { TVRequestOptionRow } from "./TVRequestOptionRow"; +export { TVToggleOptionRow } from "./TVToggleOptionRow"; diff --git a/components/library/Libraries.tsx b/components/library/Libraries.tsx new file mode 100644 index 000000000..8be2436c9 --- /dev/null +++ b/components/library/Libraries.tsx @@ -0,0 +1,109 @@ +import { + getUserLibraryApi, + getUserViewsApi, +} from "@jellyfin/sdk/lib/utils/api"; +import { FlashList } from "@shopify/flash-list"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useAtom } from "jotai"; +import { useEffect, useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { Platform, StyleSheet, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Text } from "@/components/common/Text"; +import { Loader } from "@/components/Loader"; +import { LibraryItemCard } from "@/components/library/LibraryItemCard"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { useSettings } from "@/utils/atoms/settings"; + +export const Libraries: React.FC = () => { + const [api] = useAtom(apiAtom); + const [user] = useAtom(userAtom); + const queryClient = useQueryClient(); + const { settings } = useSettings(); + + const { t } = useTranslation(); + + const { data, isLoading } = useQuery({ + queryKey: ["user-views", user?.Id], + queryFn: async () => { + const response = await getUserViewsApi(api!).getUserViews({ + userId: user?.Id, + }); + + return response.data.Items || null; + }, + staleTime: 60, + }); + + const libraries = useMemo( + () => + data + ?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!)) + .filter((l) => l.CollectionType !== "books") || [], + [data, settings?.hiddenLibraries], + ); + + useEffect(() => { + for (const item of data || []) { + queryClient.prefetchQuery({ + queryKey: ["library", item.Id], + queryFn: async () => { + if (!item.Id || !user?.Id || !api) return null; + const response = await getUserLibraryApi(api).getItem({ + itemId: item.Id, + userId: user?.Id, + }); + return response.data; + }, + staleTime: 60 * 1000, + }); + } + }, [data, api, queryClient, user?.Id]); + + const insets = useSafeAreaInsets(); + + if (isLoading) + return ( + + + + ); + + if (!libraries) + return ( + + + {t("library.no_libraries_found")} + + + ); + + return ( + } + keyExtractor={(item) => item.Id || ""} + ItemSeparatorComponent={() => + settings?.libraryOptions?.display === "row" ? ( + + ) : ( + + ) + } + /> + ); +}; diff --git a/components/library/LibraryItemCard.tsx b/components/library/LibraryItemCard.tsx index efb29013d..c84364ce1 100644 --- a/components/library/LibraryItemCard.tsx +++ b/components/library/LibraryItemCard.tsx @@ -51,7 +51,7 @@ export const LibraryItemCard: React.FC = ({ library, ...props }) => { api, item: library, }), - [library], + [api, library], ); const itemType = useMemo(() => { @@ -63,6 +63,10 @@ export const LibraryItemCard: React.FC = ({ library, ...props }) => { _itemType = "Series"; } else if (library.CollectionType === "boxsets") { _itemType = "BoxSet"; + } else if (library.CollectionType === "homevideos") { + _itemType = "Video"; + } else if (library.CollectionType === "musicvideos") { + _itemType = "MusicVideo"; } return _itemType; diff --git a/components/library/TVLibraries.tsx b/components/library/TVLibraries.tsx new file mode 100644 index 000000000..8bcff9da3 --- /dev/null +++ b/components/library/TVLibraries.tsx @@ -0,0 +1,406 @@ +import { Ionicons } from "@expo/vector-icons"; +import type { + BaseItemDto, + CollectionType, +} from "@jellyfin/sdk/lib/generated-client/models"; +import { getItemsApi, getUserViewsApi } from "@jellyfin/sdk/lib/utils/api"; +import { useQuery } from "@tanstack/react-query"; +import { BlurView } from "expo-blur"; +import { Image } from "expo-image"; +import { LinearGradient } from "expo-linear-gradient"; +import { useAtom } from "jotai"; +import { useCallback, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Animated, Easing, FlatList, Pressable, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Text } from "@/components/common/Text"; +import { Loader } from "@/components/Loader"; +import { useScaledTVTypography } from "@/constants/TVTypography"; +import useRouter from "@/hooks/useAppRouter"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { useSettings } from "@/utils/atoms/settings"; +import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl"; + +const HORIZONTAL_PADDING = 80; +const CARD_HEIGHT = 220; +const CARD_GAP = 24; +const SCALE_PADDING = 20; + +type IconName = React.ComponentProps["name"]; + +const icons: Record = { + movies: "film", + tvshows: "tv", + music: "musical-notes", + books: "book", + homevideos: "videocam", + boxsets: "albums", + playlists: "list", + folders: "folder", + livetv: "tv", + musicvideos: "musical-notes", + photos: "images", + trailers: "videocam", + unknown: "help-circle", +} as const; + +interface LibraryWithPreview extends BaseItemDto { + previewItems?: BaseItemDto[]; + itemCount?: number; +} + +const TVLibraryRow: React.FC<{ + library: LibraryWithPreview; + isFirst: boolean; + onPress: () => void; +}> = ({ library, isFirst, onPress }) => { + const [api] = useAtom(apiAtom); + const { t } = useTranslation(); + const typography = useScaledTVTypography(); + const [focused, setFocused] = useState(false); + const scale = useRef(new Animated.Value(1)).current; + const opacity = useRef(new Animated.Value(0.7)).current; + + const animateTo = (toScale: number, toOpacity: number) => { + Animated.parallel([ + Animated.timing(scale, { + toValue: toScale, + duration: 200, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }), + Animated.timing(opacity, { + toValue: toOpacity, + duration: 200, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }), + ]).start(); + }; + + const backdropUrl = useMemo(() => { + // Try to get backdrop from library or first preview item + if (library.previewItems?.[0]) { + return getBackdropUrl({ + api, + item: library.previewItems[0], + width: 1920, + }); + } + return getBackdropUrl({ + api, + item: library, + width: 1920, + }); + }, [api, library]); + + const iconName = icons[library.CollectionType!] || "folder"; + + const itemTypeName = useMemo(() => { + if (library.CollectionType === "movies") + return t("library.item_types.movies"); + if (library.CollectionType === "tvshows") + return t("library.item_types.series"); + if (library.CollectionType === "boxsets") + return t("library.item_types.boxsets"); + if (library.CollectionType === "playlists") + return t("library.item_types.playlists"); + if (library.CollectionType === "music") + return t("library.item_types.items"); + return t("library.item_types.items"); + }, [library.CollectionType, t]); + + return ( + { + setFocused(true); + animateTo(1.02, 1); + }} + onBlur={() => { + setFocused(false); + animateTo(1, 0.7); + }} + hasTVPreferredFocus={isFirst} + > + + {/* Background Image */} + {backdropUrl && ( + + )} + + {/* Gradient Overlay */} + + + {/* Content */} + + {/* Icon Container */} + + + + + {/* Text Content */} + + + {library.Name} + + {library.itemCount !== undefined && ( + + {library.itemCount} {itemTypeName} + + )} + + + {/* Arrow Indicator */} + + + + + + + ); +}; + +export const TVLibraries: React.FC = () => { + const [api] = useAtom(apiAtom); + const [user] = useAtom(userAtom); + const { settings } = useSettings(); + const insets = useSafeAreaInsets(); + const router = useRouter(); + const { t } = useTranslation(); + const typography = useScaledTVTypography(); + + const { data: userViews, isLoading: viewsLoading } = useQuery({ + queryKey: ["user-views", user?.Id], + queryFn: async () => { + const response = await getUserViewsApi(api!).getUserViews({ + userId: user?.Id, + }); + return response.data.Items || []; + }, + staleTime: 60 * 1000, + enabled: !!api && !!user?.Id, + }); + + const libraries = useMemo( + () => + userViews + ?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!)) + .filter((l) => l.CollectionType !== "books") + .filter((l) => l.CollectionType !== "music") || [], + [userViews, settings?.hiddenLibraries], + ); + + // Fetch item counts and preview items for each library + const { data: librariesWithData, isLoading: dataLoading } = useQuery({ + queryKey: ["library-data", libraries.map((l) => l.Id).join(",")], + queryFn: async () => { + const results: LibraryWithPreview[] = await Promise.all( + libraries.map(async (library) => { + let itemType: string | undefined; + if (library.CollectionType === "movies") itemType = "Movie"; + else if (library.CollectionType === "tvshows") itemType = "Series"; + else if (library.CollectionType === "boxsets") itemType = "BoxSet"; + else if (library.CollectionType === "playlists") + itemType = "Playlist"; + + const isPlaylistsLib = library.CollectionType === "playlists"; + + // Fetch count + const countResponse = await getItemsApi(api!).getItems({ + userId: user?.Id, + parentId: library.Id, + recursive: true, + limit: 0, + includeItemTypes: itemType ? [itemType as any] : undefined, + ...(isPlaylistsLib ? { mediaTypes: ["Video"] } : {}), + }); + + // Fetch preview items with backdrops + const previewResponse = await getItemsApi(api!).getItems({ + userId: user?.Id, + parentId: library.Id, + recursive: true, + limit: 1, + sortBy: ["Random"], + includeItemTypes: itemType ? [itemType as any] : undefined, + imageTypes: ["Backdrop"], + ...(isPlaylistsLib ? { mediaTypes: ["Video"] } : {}), + }); + + return { + ...library, + itemCount: countResponse.data.TotalRecordCount, + previewItems: previewResponse.data.Items || [], + }; + }), + ); + return results; + }, + enabled: !!api && !!user?.Id && libraries.length > 0, + staleTime: 60 * 1000, + }); + + const handleLibraryPress = useCallback( + (library: BaseItemDto) => { + if (library.CollectionType === "livetv") { + router.push("/(auth)/(tabs)/(libraries)/livetv/programs"); + return; + } + if (library.CollectionType === "music") { + router.push({ + pathname: `/(auth)/(tabs)/(libraries)/music/[libraryId]/suggestions`, + params: { libraryId: library.Id! }, + }); + } else { + router.push({ + pathname: "/(auth)/(tabs)/(libraries)/[libraryId]", + params: { libraryId: library.Id! }, + }); + } + }, + [router], + ); + + const renderItem = useCallback( + ({ item, index }: { item: LibraryWithPreview; index: number }) => ( + + handleLibraryPress(item)} + /> + + ), + [handleLibraryPress], + ); + + const isLoading = viewsLoading || dataLoading; + const displayLibraries = librariesWithData || libraries; + + if (isLoading && libraries.length === 0) { + return ( + + + + ); + } + + if (!displayLibraries || displayLibraries.length === 0) { + return ( + + + {t("library.no_libraries_found")} + + + ); + } + + return ( + + item.Id || ""} + renderItem={renderItem} + showsVerticalScrollIndicator={false} + removeClippedSubviews={false} + contentContainerStyle={{ + paddingBottom: 40, + paddingHorizontal: insets.left + HORIZONTAL_PADDING, + paddingVertical: SCALE_PADDING, + }} + /> + + ); +}; diff --git a/components/library/TVLibraryCard.tsx b/components/library/TVLibraryCard.tsx new file mode 100644 index 000000000..ef607b743 --- /dev/null +++ b/components/library/TVLibraryCard.tsx @@ -0,0 +1,176 @@ +import { Ionicons } from "@expo/vector-icons"; +import type { + BaseItemDto, + BaseItemKind, + CollectionType, +} from "@jellyfin/sdk/lib/generated-client/models"; +import { getItemsApi } from "@jellyfin/sdk/lib/utils/api"; +import { useQuery } from "@tanstack/react-query"; +import { Image } from "expo-image"; +import { useAtom } from "jotai"; +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { View } from "react-native"; +import { Text } from "@/components/common/Text"; +import { useScaledTVTypography } from "@/constants/TVTypography"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; + +export const TV_LIBRARY_CARD_WIDTH = 280; +export const TV_LIBRARY_CARD_HEIGHT = 180; + +interface Props { + library: BaseItemDto; +} + +type IconName = React.ComponentProps["name"]; + +const icons: Record = { + movies: "film", + tvshows: "tv", + music: "musical-notes", + books: "book", + homevideos: "videocam", + boxsets: "albums", + playlists: "list", + folders: "folder", + livetv: "tv", + musicvideos: "musical-notes", + photos: "images", + trailers: "videocam", + unknown: "help-circle", +} as const; + +export const TVLibraryCard: React.FC = ({ library }) => { + const [api] = useAtom(apiAtom); + const [user] = useAtom(userAtom); + const { t } = useTranslation(); + const typography = useScaledTVTypography(); + + const url = useMemo( + () => + getPrimaryImageUrl({ + api, + item: library, + }), + [api, library], + ); + + const itemType = useMemo(() => { + let _itemType: BaseItemKind | undefined; + + if (library.CollectionType === "movies") { + _itemType = "Movie"; + } else if (library.CollectionType === "tvshows") { + _itemType = "Series"; + } else if (library.CollectionType === "boxsets") { + _itemType = "BoxSet"; + } else if (library.CollectionType === "homevideos") { + _itemType = "Video"; + } else if (library.CollectionType === "musicvideos") { + _itemType = "MusicVideo"; + } + + return _itemType; + }, [library.CollectionType]); + + const itemTypeName = useMemo(() => { + let nameStr: string; + + if (library.CollectionType === "movies") { + nameStr = t("library.item_types.movies"); + } else if (library.CollectionType === "tvshows") { + nameStr = t("library.item_types.series"); + } else if (library.CollectionType === "boxsets") { + nameStr = t("library.item_types.boxsets"); + } else { + nameStr = t("library.item_types.items"); + } + + return nameStr; + }, [library.CollectionType, t]); + + const { data: itemsCount } = useQuery({ + queryKey: ["library-count", library.Id], + queryFn: async () => { + const response = await getItemsApi(api!).getItems({ + userId: user?.Id, + parentId: library.Id, + recursive: true, + limit: 0, + includeItemTypes: itemType ? [itemType] : undefined, + }); + return response.data.TotalRecordCount; + }, + enabled: !!api && !!user?.Id && !!library.Id, + }); + + const iconName = icons[library.CollectionType!] || "folder"; + + return ( + + {url && ( + + )} + + + + + {library.Name} + + {itemsCount !== undefined && ( + + {itemsCount} {itemTypeName} + + )} + + + ); +}; diff --git a/components/list/ListItem.tsx b/components/list/ListItem.tsx index 7ce339868..fed62315c 100644 --- a/components/list/ListItem.tsx +++ b/components/list/ListItem.tsx @@ -6,6 +6,7 @@ import { Text } from "../common/Text"; interface Props extends ViewProps { title?: string | null | undefined; subtitle?: string | null | undefined; + subtitleColor?: "default" | "red"; value?: string | null | undefined; children?: ReactNode; iconAfter?: ReactNode; @@ -14,6 +15,7 @@ interface Props extends ViewProps { textColor?: "default" | "blue" | "red"; onPress?: () => void; disabled?: boolean; + disabledByAdmin?: boolean; } export const ListItem: React.FC> = ({ @@ -27,21 +29,23 @@ export const ListItem: React.FC> = ({ textColor = "default", onPress, disabled = false, + disabledByAdmin = false, ...viewProps }) => { + const effectiveSubtitle = disabledByAdmin ? "Disabled by admin" : subtitle; + const isDisabled = disabled || disabledByAdmin; if (onPress) return ( > = ({ ); return ( > = ({ const ListItemContent = ({ title, subtitle, + subtitleColor, textColor, icon, value, @@ -107,7 +111,7 @@ const ListItemContent = ({ {subtitle && ( {subtitle} diff --git a/components/livetv/TVChannelCard.tsx b/components/livetv/TVChannelCard.tsx new file mode 100644 index 000000000..95f337db5 --- /dev/null +++ b/components/livetv/TVChannelCard.tsx @@ -0,0 +1,183 @@ +import type { Api } from "@jellyfin/sdk"; +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; +import React from "react"; +import { Animated, Image, Pressable, StyleSheet, View } from "react-native"; +import { Text } from "@/components/common/Text"; +import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation"; +import { useScaledTVTypography } from "@/constants/TVTypography"; +import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; + +interface TVChannelCardProps { + channel: BaseItemDto; + api: Api | null; + onPress: () => void; + hasTVPreferredFocus?: boolean; + disabled?: boolean; +} + +const CARD_WIDTH = 200; +const CARD_HEIGHT = 160; + +export const TVChannelCard: React.FC = ({ + channel, + api, + onPress, + hasTVPreferredFocus = false, + disabled = false, +}) => { + const typography = useScaledTVTypography(); + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation({ + scaleAmount: 1.05, + duration: 120, + }); + + const imageUrl = getPrimaryImageUrl({ + api, + item: channel, + quality: 80, + width: 200, + }); + + return ( + + + {/* Channel logo or number */} + + {imageUrl ? ( + + ) : ( + + + {channel.ChannelNumber || "?"} + + + )} + + + {/* Channel name */} + + {channel.Name} + + + {/* Channel number (if name is shown) */} + {channel.ChannelNumber && ( + + Ch. {channel.ChannelNumber} + + )} + + + ); +}; + +const styles = StyleSheet.create({ + pressable: { + width: CARD_WIDTH, + height: CARD_HEIGHT, + }, + container: { + flex: 1, + borderRadius: 12, + borderWidth: 1, + padding: 12, + alignItems: "center", + justifyContent: "center", + }, + focusedShadow: { + shadowColor: "#FFFFFF", + shadowOffset: { width: 0, height: 0 }, + shadowOpacity: 0.4, + shadowRadius: 12, + }, + logoContainer: { + width: 80, + height: 60, + marginBottom: 8, + justifyContent: "center", + alignItems: "center", + }, + logo: { + width: "100%", + height: "100%", + }, + numberFallback: { + width: 60, + height: 60, + borderRadius: 30, + justifyContent: "center", + alignItems: "center", + }, + numberText: { + fontWeight: "bold", + }, + channelName: { + fontWeight: "600", + textAlign: "center", + marginBottom: 4, + }, + channelNumber: { + fontWeight: "400", + }, +}); + +export { CARD_HEIGHT, CARD_WIDTH }; diff --git a/components/livetv/TVChannelsGrid.tsx b/components/livetv/TVChannelsGrid.tsx new file mode 100644 index 000000000..f93beb35c --- /dev/null +++ b/components/livetv/TVChannelsGrid.tsx @@ -0,0 +1,136 @@ +import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api"; +import { useQuery } from "@tanstack/react-query"; +import { useAtomValue } from "jotai"; +import React, { useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import { ActivityIndicator, ScrollView, StyleSheet, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Text } from "@/components/common/Text"; +import { useScaledTVTypography } from "@/constants/TVTypography"; +import useRouter from "@/hooks/useAppRouter"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { TVChannelCard } from "./TVChannelCard"; + +const HORIZONTAL_PADDING = 60; +const GRID_GAP = 16; + +export const TVChannelsGrid: React.FC = () => { + const { t } = useTranslation(); + const typography = useScaledTVTypography(); + const insets = useSafeAreaInsets(); + const router = useRouter(); + const api = useAtomValue(apiAtom); + const user = useAtomValue(userAtom); + + // Fetch all channels + const { data: channelsData, isLoading } = useQuery({ + queryKey: ["livetv", "channels-grid", "all"], + queryFn: async () => { + if (!api || !user?.Id) return null; + const res = await getLiveTvApi(api).getLiveTvChannels({ + enableFavoriteSorting: true, + userId: user.Id, + addCurrentProgram: false, + enableUserData: false, + enableImageTypes: ["Primary"], + }); + return res.data; + }, + enabled: !!api && !!user?.Id, + staleTime: 5 * 60 * 1000, // 5 minutes + }); + + const channels = channelsData?.Items ?? []; + + const handleChannelPress = useCallback( + (channelId: string | undefined) => { + if (channelId) { + // Navigate directly to the player to start the channel + const queryParams = new URLSearchParams({ + itemId: channelId, + audioIndex: "", + subtitleIndex: "", + mediaSourceId: "", + bitrateValue: "", + }); + router.push(`/player/direct-player?${queryParams.toString()}`); + } + }, + [router], + ); + + if (isLoading) { + return ( + + + + ); + } + + if (channels.length === 0) { + return ( + + + {t("live_tv.no_channels")} + + + ); + } + + return ( + + + {channels.map((channel, index) => ( + handleChannelPress(channel.Id)} + // No hasTVPreferredFocus - tab buttons handle initial focus + /> + ))} + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + contentContainer: { + paddingTop: 24, + }, + grid: { + flexDirection: "row", + flexWrap: "wrap", + justifyContent: "flex-start", + gap: GRID_GAP, + overflow: "visible", + paddingVertical: 10, // Extra padding for focus scale animation + }, + loadingContainer: { + flex: 1, + justifyContent: "center", + alignItems: "center", + }, + emptyContainer: { + flex: 1, + justifyContent: "center", + alignItems: "center", + }, + emptyText: { + color: "rgba(255, 255, 255, 0.6)", + }, +}); diff --git a/components/livetv/TVGuideChannelRow.tsx b/components/livetv/TVGuideChannelRow.tsx new file mode 100644 index 000000000..73ba74915 --- /dev/null +++ b/components/livetv/TVGuideChannelRow.tsx @@ -0,0 +1,146 @@ +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; +import React, { useMemo } from "react"; +import { StyleSheet, View } from "react-native"; +import { TVGuideProgramCell } from "./TVGuideProgramCell"; + +interface TVGuideChannelRowProps { + programs: BaseItemDto[]; + baseTime: Date; + pixelsPerHour: number; + minProgramWidth: number; + hoursToShow: number; + onProgramPress: (program: BaseItemDto) => void; + disabled?: boolean; + firstProgramRefSetter?: (ref: View | null) => void; +} + +export const TVGuideChannelRow: React.FC = ({ + programs, + baseTime, + pixelsPerHour, + minProgramWidth, + hoursToShow, + onProgramPress, + disabled = false, + firstProgramRefSetter, +}) => { + const isCurrentlyAiring = (program: BaseItemDto): boolean => { + if (!program.StartDate || !program.EndDate) return false; + const now = new Date(); + const start = new Date(program.StartDate); + const end = new Date(program.EndDate); + return now >= start && now <= end; + }; + + const getTimeOffset = (startDate: string): number => { + const start = new Date(startDate); + const diffMinutes = (start.getTime() - baseTime.getTime()) / 60000; + return Math.max(0, (diffMinutes / 60) * pixelsPerHour); + }; + + // Filter programs for this channel and within the time window + const filteredPrograms = useMemo(() => { + const endTime = new Date(baseTime.getTime() + hoursToShow * 60 * 60 * 1000); + + return programs + .filter((p) => { + if (!p.StartDate || !p.EndDate) return false; + const start = new Date(p.StartDate); + const end = new Date(p.EndDate); + // Program overlaps with our time window + return end > baseTime && start < endTime; + }) + .sort((a, b) => { + const dateA = new Date(a.StartDate || 0); + const dateB = new Date(b.StartDate || 0); + return dateA.getTime() - dateB.getTime(); + }); + }, [programs, baseTime, hoursToShow]); + + // Calculate program cells with positions (absolute positioning) + const programCells = useMemo(() => { + return filteredPrograms.map((program) => { + if (!program.StartDate || !program.EndDate) { + return { program, width: minProgramWidth, left: 0 }; + } + + // Clamp the start time to baseTime if program started earlier + const programStart = new Date(program.StartDate); + const effectiveStart = programStart < baseTime ? baseTime : programStart; + + // Clamp the end time to the window end + const windowEnd = new Date( + baseTime.getTime() + hoursToShow * 60 * 60 * 1000, + ); + const programEnd = new Date(program.EndDate); + const effectiveEnd = programEnd > windowEnd ? windowEnd : programEnd; + + const durationMinutes = + (effectiveEnd.getTime() - effectiveStart.getTime()) / 60000; + const width = Math.max( + (durationMinutes / 60) * pixelsPerHour - 4, + minProgramWidth, + ); // -4 for gap + + const left = getTimeOffset(effectiveStart.toISOString()); + + return { + program, + width, + left, + }; + }); + }, [filteredPrograms, baseTime, pixelsPerHour, minProgramWidth, hoursToShow]); + + const totalWidth = hoursToShow * pixelsPerHour; + + return ( + + {programCells.map(({ program, width, left }, index) => ( + + onProgramPress(program)} + disabled={disabled} + refSetter={index === 0 ? firstProgramRefSetter : undefined} + /> + + ))} + + {/* Empty state */} + {programCells.length === 0 && ( + + {/* Empty row indicator */} + + )} + + ); +}; + +const styles = StyleSheet.create({ + container: { + height: 80, + position: "relative", + borderBottomWidth: 1, + borderBottomColor: "rgba(255, 255, 255, 0.2)", + backgroundColor: "rgba(20, 20, 20, 1)", + }, + programCellWrapper: { + position: "absolute", + top: 4, + bottom: 4, + }, + noPrograms: { + position: "absolute", + left: 4, + top: 4, + bottom: 4, + backgroundColor: "rgba(255, 255, 255, 0.05)", + borderRadius: 8, + }, +}); diff --git a/components/livetv/TVGuidePageNavigation.tsx b/components/livetv/TVGuidePageNavigation.tsx new file mode 100644 index 000000000..5188c54ea --- /dev/null +++ b/components/livetv/TVGuidePageNavigation.tsx @@ -0,0 +1,154 @@ +import { Ionicons } from "@expo/vector-icons"; +import React from "react"; +import { useTranslation } from "react-i18next"; +import { Animated, Pressable, StyleSheet, View } from "react-native"; +import { Text } from "@/components/common/Text"; +import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation"; +import { useScaledTVTypography } from "@/constants/TVTypography"; + +interface TVGuidePageNavigationProps { + currentPage: number; + totalPages: number; + onPrevious: () => void; + onNext: () => void; + disabled?: boolean; + prevButtonRefSetter?: (ref: View | null) => void; +} + +interface NavButtonProps { + onPress: () => void; + icon: keyof typeof Ionicons.glyphMap; + label: string; + isDisabled: boolean; + disabled?: boolean; + refSetter?: (ref: View | null) => void; +} + +const NavButton: React.FC = ({ + onPress, + icon, + label, + isDisabled, + disabled = false, + refSetter, +}) => { + const typography = useScaledTVTypography(); + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation({ + scaleAmount: 1.05, + duration: 120, + }); + + const visuallyDisabled = isDisabled || disabled; + + const handlePress = () => { + if (!visuallyDisabled) { + onPress(); + } + }; + + return ( + + + + + {label} + + + + ); +}; + +export const TVGuidePageNavigation: React.FC = ({ + currentPage, + totalPages, + onPrevious, + onNext, + disabled = false, + prevButtonRefSetter, +}) => { + const { t } = useTranslation(); + const typography = useScaledTVTypography(); + + return ( + + + + + = totalPages} + disabled={disabled} + /> + + + + {t("live_tv.page_of", { current: currentPage, total: totalPages })} + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + paddingVertical: 16, + }, + buttonsContainer: { + flexDirection: "row", + alignItems: "center", + gap: 12, + }, + navButton: { + flexDirection: "row", + alignItems: "center", + gap: 8, + paddingHorizontal: 20, + paddingVertical: 12, + borderRadius: 8, + }, + navButtonText: { + fontWeight: "600", + }, + pageText: { + color: "rgba(255, 255, 255, 0.6)", + }, +}); diff --git a/components/livetv/TVGuideProgramCell.tsx b/components/livetv/TVGuideProgramCell.tsx new file mode 100644 index 000000000..e8287132e --- /dev/null +++ b/components/livetv/TVGuideProgramCell.tsx @@ -0,0 +1,148 @@ +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; +import React from "react"; +import { Animated, Pressable, StyleSheet, View } from "react-native"; +import { Text } from "@/components/common/Text"; +import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation"; +import { useScaledTVTypography } from "@/constants/TVTypography"; + +interface TVGuideProgramCellProps { + program: BaseItemDto; + width: number; + isCurrentlyAiring: boolean; + onPress: () => void; + disabled?: boolean; + refSetter?: (ref: View | null) => void; +} + +export const TVGuideProgramCell: React.FC = ({ + program, + width, + isCurrentlyAiring, + onPress, + disabled = false, + refSetter, +}) => { + const typography = useScaledTVTypography(); + const { focused, handleFocus, handleBlur } = useTVFocusAnimation({ + scaleAmount: 1, + duration: 120, + }); + + const formatTime = (date: string | null | undefined) => { + if (!date) return ""; + const d = new Date(date); + return d.toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); + }; + + return ( + + + {/* LIVE badge */} + {isCurrentlyAiring && ( + + + LIVE + + + )} + + {/* Program name */} + + {program.Name} + + + {/* Time range */} + + {formatTime(program.StartDate)} - {formatTime(program.EndDate)} + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + height: 70, + borderRadius: 8, + borderWidth: 1, + paddingHorizontal: 12, + paddingVertical: 8, + justifyContent: "center", + overflow: "hidden", + }, + focusedShadow: { + shadowColor: "#FFFFFF", + shadowOffset: { width: 0, height: 0 }, + shadowOpacity: 0.4, + shadowRadius: 12, + }, + liveBadge: { + position: "absolute", + top: 6, + right: 6, + backgroundColor: "#EF4444", + paddingHorizontal: 6, + paddingVertical: 2, + borderRadius: 4, + zIndex: 10, + elevation: 10, + }, + liveBadgeText: { + color: "#FFFFFF", + fontWeight: "bold", + }, + programName: { + fontWeight: "600", + marginBottom: 4, + }, + timeText: { + fontWeight: "400", + }, +}); diff --git a/components/livetv/TVGuideTimeHeader.tsx b/components/livetv/TVGuideTimeHeader.tsx new file mode 100644 index 000000000..a3ca8348a --- /dev/null +++ b/components/livetv/TVGuideTimeHeader.tsx @@ -0,0 +1,64 @@ +import { BlurView } from "expo-blur"; +import React from "react"; +import { StyleSheet, View } from "react-native"; +import { Text } from "@/components/common/Text"; +import { useScaledTVTypography } from "@/constants/TVTypography"; + +interface TVGuideTimeHeaderProps { + baseTime: Date; + hoursToShow: number; + pixelsPerHour: number; +} + +export const TVGuideTimeHeader: React.FC = ({ + baseTime, + hoursToShow, + pixelsPerHour, +}) => { + const typography = useScaledTVTypography(); + + const hours: Date[] = []; + for (let i = 0; i < hoursToShow; i++) { + const hour = new Date(baseTime); + hour.setMinutes(0, 0, 0); + hour.setHours(baseTime.getHours() + i); + hours.push(hour); + } + + const formatHour = (date: Date) => { + return date.toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); + }; + + return ( + + {hours.map((hour, index) => ( + + + {formatHour(hour)} + + + ))} + + ); +}; + +const styles = StyleSheet.create({ + container: { + flexDirection: "row", + height: 44, + }, + hourCell: { + justifyContent: "center", + paddingLeft: 12, + borderLeftWidth: 1, + borderLeftColor: "rgba(255, 255, 255, 0.1)", + }, + hourText: { + color: "rgba(255, 255, 255, 0.6)", + fontWeight: "500", + }, +}); diff --git a/components/livetv/TVLiveTVGuide.tsx b/components/livetv/TVLiveTVGuide.tsx new file mode 100644 index 000000000..ad99626f6 --- /dev/null +++ b/components/livetv/TVLiveTVGuide.tsx @@ -0,0 +1,433 @@ +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; +import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api"; +import { useQuery } from "@tanstack/react-query"; +import { useAtomValue } from "jotai"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { useTranslation } from "react-i18next"; +import { + ActivityIndicator, + NativeScrollEvent, + NativeSyntheticEvent, + ScrollView, + StyleSheet, + TVFocusGuideView, + View, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Text } from "@/components/common/Text"; +import { useScaledTVTypography } from "@/constants/TVTypography"; +import useRouter from "@/hooks/useAppRouter"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { TVGuideChannelRow } from "./TVGuideChannelRow"; +import { TVGuidePageNavigation } from "./TVGuidePageNavigation"; +import { TVGuideTimeHeader } from "./TVGuideTimeHeader"; + +// Design constants +const CHANNEL_COLUMN_WIDTH = 240; +const PIXELS_PER_HOUR = 250; +const ROW_HEIGHT = 80; +const TIME_HEADER_HEIGHT = 44; +const CHANNELS_PER_PAGE = 20; +const MIN_PROGRAM_WIDTH = 80; +const HORIZONTAL_PADDING = 60; + +// Channel label component +const ChannelLabel: React.FC<{ + channel: BaseItemDto; + typography: ReturnType; +}> = ({ channel, typography }) => ( + + + {channel.ChannelNumber} + + + {channel.Name} + + +); + +export const TVLiveTVGuide: React.FC = () => { + const { t } = useTranslation(); + const typography = useScaledTVTypography(); + const insets = useSafeAreaInsets(); + const router = useRouter(); + const api = useAtomValue(apiAtom); + const user = useAtomValue(userAtom); + + const [currentPage, setCurrentPage] = useState(1); + + // Scroll refs for synchronization + const channelListRef = useRef(null); + const mainVerticalRef = useRef(null); + + // Focus guide refs for bidirectional navigation + const [firstProgramRef, setFirstProgramRef] = useState(null); + const [prevButtonRef, setPrevButtonRef] = useState(null); + + // Base time - start of current hour, end time - end of day + const [{ baseTime, endOfDay, hoursToShow }] = useState(() => { + const now = new Date(); + now.setMinutes(0, 0, 0); + + const endOfDayTime = new Date(now); + endOfDayTime.setHours(23, 59, 59, 999); + + const hoursUntilEndOfDay = Math.ceil( + (endOfDayTime.getTime() - now.getTime()) / (60 * 60 * 1000), + ); + + return { + baseTime: now, + endOfDay: endOfDayTime, + hoursToShow: Math.max(hoursUntilEndOfDay, 1), // At least 1 hour + }; + }); + + // Current time indicator position (relative to program grid start) + const [currentTimeOffset, setCurrentTimeOffset] = useState(0); + + // Update current time indicator every minute + useEffect(() => { + const updateCurrentTime = () => { + const now = new Date(); + const diffMinutes = (now.getTime() - baseTime.getTime()) / 60000; + const offset = (diffMinutes / 60) * PIXELS_PER_HOUR; + setCurrentTimeOffset(offset); + }; + + updateCurrentTime(); + const interval = setInterval(updateCurrentTime, 60000); + return () => clearInterval(interval); + }, [baseTime]); + + // Sync vertical scroll between channel list and main grid + const handleVerticalScroll = useCallback( + (event: NativeSyntheticEvent) => { + const offsetY = event.nativeEvent.contentOffset.y; + channelListRef.current?.scrollTo({ y: offsetY, animated: false }); + }, + [], + ); + + // Fetch channels + const { data: channelsData, isLoading: isLoadingChannels } = useQuery({ + queryKey: ["livetv", "tv-guide", "channels"], + queryFn: async () => { + if (!api || !user?.Id) return null; + const res = await getLiveTvApi(api).getLiveTvChannels({ + enableFavoriteSorting: true, + userId: user.Id, + addCurrentProgram: false, + enableUserData: false, + enableImageTypes: ["Primary"], + }); + return res.data; + }, + enabled: !!api && !!user?.Id, + staleTime: 5 * 60 * 1000, // 5 minutes + }); + + const totalChannels = channelsData?.TotalRecordCount ?? 0; + const totalPages = Math.ceil(totalChannels / CHANNELS_PER_PAGE); + const allChannels = channelsData?.Items ?? []; + + // Get channels for current page + const paginatedChannels = useMemo(() => { + const startIndex = (currentPage - 1) * CHANNELS_PER_PAGE; + return allChannels.slice(startIndex, startIndex + CHANNELS_PER_PAGE); + }, [allChannels, currentPage]); + + const channelIds = useMemo( + () => paginatedChannels.map((c) => c.Id).filter(Boolean) as string[], + [paginatedChannels], + ); + + // Fetch programs for visible channels + const { data: programsData } = useQuery({ + queryKey: [ + "livetv", + "tv-guide", + "programs", + channelIds, + baseTime.toISOString(), + endOfDay.toISOString(), + ], + queryFn: async () => { + if (!api || channelIds.length === 0) return null; + const res = await getLiveTvApi(api).getPrograms({ + getProgramsDto: { + MaxStartDate: endOfDay.toISOString(), + MinEndDate: baseTime.toISOString(), + ChannelIds: channelIds, + ImageTypeLimit: 1, + EnableImages: false, + SortBy: ["StartDate"], + EnableTotalRecordCount: false, + EnableUserData: false, + }, + }); + return res.data; + }, + enabled: channelIds.length > 0, + staleTime: 2 * 60 * 1000, // 2 minutes + }); + + const programs = programsData?.Items ?? []; + + // Group programs by channel + const programsByChannel = useMemo(() => { + const grouped: Record = {}; + for (const program of programs) { + const channelId = program.ChannelId; + if (channelId) { + if (!grouped[channelId]) { + grouped[channelId] = []; + } + grouped[channelId].push(program); + } + } + return grouped; + }, [programs]); + + const handleProgramPress = useCallback( + (program: BaseItemDto) => { + // Navigate to play the program/channel + const queryParams = new URLSearchParams({ + itemId: program.Id ?? "", + audioIndex: "", + subtitleIndex: "", + mediaSourceId: "", + bitrateValue: "", + }); + router.push(`/player/direct-player?${queryParams.toString()}`); + }, + [router], + ); + + const handlePreviousPage = useCallback(() => { + if (currentPage > 1) { + setCurrentPage((p) => p - 1); + } + }, [currentPage]); + + const handleNextPage = useCallback(() => { + if (currentPage < totalPages) { + setCurrentPage((p) => p + 1); + } + }, [currentPage, totalPages]); + + const isLoading = isLoadingChannels; + const totalWidth = hoursToShow * PIXELS_PER_HOUR; + + if (isLoading) { + return ( + + + + ); + } + + if (paginatedChannels.length === 0) { + return ( + + + {t("live_tv.no_programs")} + + + ); + } + + return ( + + {/* Page Navigation */} + {totalPages > 1 && ( + + + + )} + + {/* Bidirectional focus guides */} + {firstProgramRef && ( + + )} + {prevButtonRef && ( + + )} + + {/* Main grid container */} + + {/* Fixed channel column */} + + {/* Spacer for time header */} + + + {/* Channel labels - synced with main scroll */} + + {paginatedChannels.map((channel, index) => ( + + ))} + + + + {/* Scrollable programs area */} + + + {/* Time header */} + + + {/* Programs grid - vertical scroll */} + + {paginatedChannels.map((channel, index) => { + const channelPrograms = channel.Id + ? (programsByChannel[channel.Id] ?? []) + : []; + return ( + + ); + })} + + + {/* Current time indicator */} + {currentTimeOffset > 0 && currentTimeOffset < totalWidth && ( + + )} + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + loadingContainer: { + flex: 1, + justifyContent: "center", + alignItems: "center", + }, + emptyContainer: { + flex: 1, + justifyContent: "center", + alignItems: "center", + }, + emptyText: { + color: "rgba(255, 255, 255, 0.6)", + }, + gridWrapper: { + flex: 1, + flexDirection: "row", + }, + channelColumn: { + backgroundColor: "rgba(40, 40, 40, 1)", + borderRightWidth: 1, + borderRightColor: "rgba(255, 255, 255, 0.2)", + }, + channelLabel: { + height: ROW_HEIGHT, + justifyContent: "center", + paddingHorizontal: 12, + borderBottomWidth: 1, + borderBottomColor: "rgba(255, 255, 255, 0.2)", + }, + channelNumber: { + color: "rgba(255, 255, 255, 0.5)", + fontWeight: "400", + marginBottom: 2, + }, + channelName: { + color: "#FFFFFF", + fontWeight: "600", + }, + horizontalScroll: { + flex: 1, + }, + currentTimeIndicator: { + position: "absolute", + width: 2, + backgroundColor: "#EF4444", + zIndex: 10, + pointerEvents: "none", + }, +}); diff --git a/components/livetv/TVLiveTVPage.tsx b/components/livetv/TVLiveTVPage.tsx new file mode 100644 index 000000000..a3f3ed45d --- /dev/null +++ b/components/livetv/TVLiveTVPage.tsx @@ -0,0 +1,265 @@ +import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api"; +import { useAtomValue } from "jotai"; +import React, { useCallback, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { ScrollView, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Text } from "@/components/common/Text"; +import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList.tv"; +import { TVChannelsGrid } from "@/components/livetv/TVChannelsGrid"; +import { TVLiveTVGuide } from "@/components/livetv/TVLiveTVGuide"; +import { TVLiveTVPlaceholder } from "@/components/livetv/TVLiveTVPlaceholder"; +import { TVTabButton } from "@/components/tv/TVTabButton"; +import { useScaledTVTypography } from "@/constants/TVTypography"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; + +const HORIZONTAL_PADDING = 60; +const TOP_PADDING = 100; +const SECTION_GAP = 24; + +type TabId = + | "programs" + | "guide" + | "channels" + | "recordings" + | "schedule" + | "series"; + +interface Tab { + id: TabId; + labelKey: string; +} + +const TABS: Tab[] = [ + { id: "programs", labelKey: "live_tv.tabs.programs" }, + { id: "guide", labelKey: "live_tv.tabs.guide" }, + { id: "channels", labelKey: "live_tv.tabs.channels" }, + { id: "recordings", labelKey: "live_tv.tabs.recordings" }, + { id: "schedule", labelKey: "live_tv.tabs.schedule" }, + { id: "series", labelKey: "live_tv.tabs.series" }, +]; + +export const TVLiveTVPage: React.FC = () => { + const { t } = useTranslation(); + const typography = useScaledTVTypography(); + const insets = useSafeAreaInsets(); + const api = useAtomValue(apiAtom); + const user = useAtomValue(userAtom); + + const [activeTab, setActiveTab] = useState("programs"); + + // Section configurations for Programs tab + const sections = useMemo(() => { + if (!api || !user?.Id) return []; + + return [ + { + title: t("live_tv.on_now"), + queryKey: ["livetv", "tv", "onNow"], + queryFn: async ({ pageParam = 0 }: { pageParam?: number }) => { + const res = await getLiveTvApi(api).getRecommendedPrograms({ + userId: user.Id, + isAiring: true, + limit: 24, + imageTypeLimit: 1, + enableImageTypes: ["Primary", "Thumb", "Backdrop"], + enableTotalRecordCount: false, + fields: ["ChannelInfo", "PrimaryImageAspectRatio"], + }); + const items = res.data.Items || []; + return items.slice(pageParam, pageParam + 10); + }, + }, + { + title: t("live_tv.shows"), + queryKey: ["livetv", "tv", "shows"], + queryFn: async ({ pageParam = 0 }: { pageParam?: number }) => { + const res = await getLiveTvApi(api).getLiveTvPrograms({ + userId: user.Id, + hasAired: false, + limit: 24, + isMovie: false, + isSeries: true, + isSports: false, + isNews: false, + isKids: false, + enableTotalRecordCount: false, + fields: ["ChannelInfo", "PrimaryImageAspectRatio"], + enableImageTypes: ["Primary", "Thumb", "Backdrop"], + }); + const items = res.data.Items || []; + return items.slice(pageParam, pageParam + 10); + }, + }, + { + title: t("live_tv.movies"), + queryKey: ["livetv", "tv", "movies"], + queryFn: async ({ pageParam = 0 }: { pageParam?: number }) => { + const res = await getLiveTvApi(api).getLiveTvPrograms({ + userId: user.Id, + hasAired: false, + limit: 24, + isMovie: true, + enableTotalRecordCount: false, + fields: ["ChannelInfo"], + enableImageTypes: ["Primary", "Thumb", "Backdrop"], + }); + const items = res.data.Items || []; + return items.slice(pageParam, pageParam + 10); + }, + }, + { + title: t("live_tv.sports"), + queryKey: ["livetv", "tv", "sports"], + queryFn: async ({ pageParam = 0 }: { pageParam?: number }) => { + const res = await getLiveTvApi(api).getLiveTvPrograms({ + userId: user.Id, + hasAired: false, + limit: 24, + isSports: true, + enableTotalRecordCount: false, + fields: ["ChannelInfo"], + enableImageTypes: ["Primary", "Thumb", "Backdrop"], + }); + const items = res.data.Items || []; + return items.slice(pageParam, pageParam + 10); + }, + }, + { + title: t("live_tv.for_kids"), + queryKey: ["livetv", "tv", "kids"], + queryFn: async ({ pageParam = 0 }: { pageParam?: number }) => { + const res = await getLiveTvApi(api).getLiveTvPrograms({ + userId: user.Id, + hasAired: false, + limit: 24, + isKids: true, + enableTotalRecordCount: false, + fields: ["ChannelInfo"], + enableImageTypes: ["Primary", "Thumb", "Backdrop"], + }); + const items = res.data.Items || []; + return items.slice(pageParam, pageParam + 10); + }, + }, + { + title: t("live_tv.news"), + queryKey: ["livetv", "tv", "news"], + queryFn: async ({ pageParam = 0 }: { pageParam?: number }) => { + const res = await getLiveTvApi(api).getLiveTvPrograms({ + userId: user.Id, + hasAired: false, + limit: 24, + isNews: true, + enableTotalRecordCount: false, + fields: ["ChannelInfo"], + enableImageTypes: ["Primary", "Thumb", "Backdrop"], + }); + const items = res.data.Items || []; + return items.slice(pageParam, pageParam + 10); + }, + }, + ]; + }, [api, user?.Id, t]); + + const handleTabSelect = useCallback((tabId: TabId) => { + setActiveTab(tabId); + }, []); + + const renderProgramsContent = () => ( + + + {sections.map((section) => ( + + ))} + + + ); + + const renderTabContent = () => { + if (activeTab === "programs") { + return renderProgramsContent(); + } + + if (activeTab === "guide") { + return ; + } + + if (activeTab === "channels") { + return ; + } + + // Placeholder for other tabs + const tab = TABS.find((t) => t.id === activeTab); + return ; + }; + + return ( + + {/* Header with Title and Tabs */} + + {/* Title */} + + Live TV + + + {/* Tab Bar */} + + {TABS.map((tab) => ( + handleTabSelect(tab.id)} + hasTVPreferredFocus={activeTab === tab.id} + switchOnFocus={true} + /> + ))} + + + + {/* Tab Content */} + {renderTabContent()} + + ); +}; diff --git a/components/livetv/TVLiveTVPlaceholder.tsx b/components/livetv/TVLiveTVPlaceholder.tsx new file mode 100644 index 000000000..2880cbedd --- /dev/null +++ b/components/livetv/TVLiveTVPlaceholder.tsx @@ -0,0 +1,46 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { View } from "react-native"; +import { Text } from "@/components/common/Text"; +import { useScaledTVTypography } from "@/constants/TVTypography"; + +interface TVLiveTVPlaceholderProps { + tabName: string; +} + +export const TVLiveTVPlaceholder: React.FC = ({ + tabName, +}) => { + const { t } = useTranslation(); + const typography = useScaledTVTypography(); + + return ( + + + {tabName} + + + {t("live_tv.coming_soon")} + + + ); +}; diff --git a/components/login/Login.tsx b/components/login/Login.tsx new file mode 100644 index 000000000..da634ad09 --- /dev/null +++ b/components/login/Login.tsx @@ -0,0 +1,458 @@ +import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons"; +import type { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client"; +import { Image } from "expo-image"; +import { useLocalSearchParams, useNavigation } from "expo-router"; +import { t } from "i18next"; +import { useAtomValue } from "jotai"; +import { useCallback, useEffect, useState } from "react"; +import { + Alert, + Keyboard, + KeyboardAvoidingView, + Platform, + Switch, + TouchableOpacity, + View, +} from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import { z } from "zod"; +import { Button } from "@/components/Button"; +import { Input } from "@/components/common/Input"; +import { Text } from "@/components/common/Text"; +import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery"; +import { PreviousServersList } from "@/components/PreviousServersList"; +import { SaveAccountModal } from "@/components/SaveAccountModal"; +import { Colors } from "@/constants/Colors"; +import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider"; +import type { + AccountSecurityType, + SavedServer, +} from "@/utils/secureCredentials"; + +const CredentialsSchema = z.object({ + username: z.string().min(1, t("login.username_required")), +}); + +export const Login: React.FC = () => { + const api = useAtomValue(apiAtom); + const navigation = useNavigation(); + const params = useLocalSearchParams(); + const { + setServer, + login, + removeServer, + initiateQuickConnect, + loginWithSavedCredential, + loginWithPassword, + } = useJellyfin(); + + const { + apiUrl: _apiUrl, + username: _username, + password: _password, + } = params as { apiUrl: string; username: string; password: string }; + + const [loadingServerCheck, setLoadingServerCheck] = useState(false); + const [loading, setLoading] = useState(false); + const [serverURL, setServerURL] = useState(_apiUrl || ""); + const [serverName, setServerName] = useState(""); + const [credentials, setCredentials] = useState<{ + username: string; + password: string; + }>({ + username: _username || "", + password: _password || "", + }); + + // Save account state + const [saveAccount, setSaveAccount] = useState(false); + const [showSaveModal, setShowSaveModal] = useState(false); + const [pendingLogin, setPendingLogin] = useState<{ + username: string; + password: string; + } | null>(null); + + // Handle URL params for server connection + useEffect(() => { + (async () => { + if (_apiUrl) { + await setServer({ + address: _apiUrl, + }); + } + })(); + }, [_apiUrl]); + + // Handle auto-login when api is ready and credentials are provided via URL params + useEffect(() => { + if (api?.basePath && _apiUrl && _username && _password) { + setCredentials({ username: _username, password: _password }); + login(_username, _password); + } + }, [api?.basePath, _apiUrl, _username, _password]); + + useEffect(() => { + navigation.setOptions({ + headerTitle: serverName, + headerLeft: () => + api?.basePath ? ( + { + removeServer(); + }} + className='flex flex-row items-center pr-2 pl-1' + > + + + {t("login.change_server")} + + + ) : null, + }); + }, [serverName, navigation, api?.basePath]); + + const handleLogin = async () => { + Keyboard.dismiss(); + + const result = CredentialsSchema.safeParse(credentials); + if (!result.success) return; + + if (saveAccount) { + setPendingLogin({ + username: credentials.username, + password: credentials.password, + }); + setShowSaveModal(true); + } else { + await performLogin(credentials.username, credentials.password); + } + }; + + const performLogin = async ( + username: string, + password: string, + options?: { + saveAccount?: boolean; + securityType?: AccountSecurityType; + pinCode?: string; + }, + ) => { + setLoading(true); + try { + await login(username, password, serverName, options); + } catch (error) { + if (error instanceof Error) { + Alert.alert(t("login.connection_failed"), error.message); + } else { + Alert.alert( + t("login.connection_failed"), + t("login.an_unexpected_error_occurred"), + ); + } + } finally { + setLoading(false); + setPendingLogin(null); + } + }; + + const handleSaveAccountConfirm = async ( + securityType: AccountSecurityType, + pinCode?: string, + ) => { + setShowSaveModal(false); + if (pendingLogin) { + await performLogin(pendingLogin.username, pendingLogin.password, { + saveAccount: true, + securityType, + pinCode, + }); + } + }; + + const handleQuickLoginWithSavedCredential = async ( + serverUrl: string, + userId: string, + ) => { + await loginWithSavedCredential(serverUrl, userId); + }; + + const handlePasswordLogin = async ( + serverUrl: string, + username: string, + password: string, + ) => { + await loginWithPassword(serverUrl, username, password); + }; + + const handleAddAccount = (server: SavedServer) => { + setServer({ address: server.address }); + if (server.name) { + setServerName(server.name); + } + }; + + const checkUrl = useCallback(async (url: string) => { + setLoadingServerCheck(true); + const baseUrl = url.replace(/^https?:\/\//i, ""); + const protocols = ["https", "http"]; + try { + return checkHttp(baseUrl, protocols); + } catch (e) { + if (e instanceof Error && e.message === "Server too old") { + throw e; + } + return undefined; + } finally { + setLoadingServerCheck(false); + } + }, []); + + async function checkHttp(baseUrl: string, protocols: string[]) { + for (const protocol of protocols) { + try { + const response = await fetch( + `${protocol}://${baseUrl}/System/Info/Public`, + { + mode: "cors", + }, + ); + if (response.ok) { + const data = (await response.json()) as PublicSystemInfo; + const serverVersion = data.Version?.split("."); + if (serverVersion && +serverVersion[0] <= 10) { + if (+serverVersion[1] < 10) { + Alert.alert( + t("login.too_old_server_text"), + t("login.too_old_server_description"), + ); + throw new Error("Server too old"); + } + } + setServerName(data.ServerName || ""); + return `${protocol}://${baseUrl}`; + } + } catch (e) { + if (e instanceof Error && e.message === "Server too old") { + throw e; + } + } + } + return undefined; + } + + const handleConnect = useCallback(async (url: string) => { + url = url.trim().replace(/\/$/, ""); + try { + const result = await checkUrl(url); + if (result === undefined) { + Alert.alert( + t("login.connection_failed"), + t("login.could_not_connect_to_server"), + ); + return; + } + await setServer({ address: result }); + } catch {} + }, []); + + const handleQuickConnect = async () => { + try { + const code = await initiateQuickConnect(); + if (code) { + Alert.alert( + t("login.quick_connect"), + t("login.enter_code_to_login", { code: code }), + [ + { + text: t("login.got_it"), + }, + ], + ); + } + } catch (_error) { + Alert.alert( + t("login.error_title"), + t("login.failed_to_initiate_quick_connect"), + ); + } + }; + + return ( + + + {api?.basePath ? ( + + + + + {serverName ? ( + <> + {`${t("login.login_to_title")} `} + {serverName} + + ) : ( + t("login.login_title") + )} + + {api.basePath} + + setCredentials((prev) => ({ ...prev, username: text })) + } + onEndEditing={(e) => { + const newValue = e.nativeEvent.text; + if (newValue && newValue !== credentials.username) { + setCredentials((prev) => ({ + ...prev, + username: newValue, + })); + } + }} + value={credentials.username} + keyboardType='default' + returnKeyType='done' + autoCapitalize='none' + autoCorrect={false} + textContentType='username' + clearButtonMode='while-editing' + maxLength={500} + /> + + + setCredentials((prev) => ({ ...prev, password: text })) + } + onEndEditing={(e) => { + const newValue = e.nativeEvent.text; + if (newValue && newValue !== credentials.password) { + setCredentials((prev) => ({ + ...prev, + password: newValue, + })); + } + }} + value={credentials.password} + secureTextEntry + keyboardType='default' + returnKeyType='done' + autoCapitalize='none' + textContentType='password' + clearButtonMode='while-editing' + maxLength={500} + /> + setSaveAccount(!saveAccount)} + className='flex flex-row items-center py-2' + activeOpacity={0.7} + > + + + {t("save_account.save_for_later")} + + + + + + + + + + + + + + ) : ( + + + + Streamyfin + + {t("server.enter_url_to_jellyfin_server")} + + + + { + setServerURL(server.address); + if (server.serverName) { + setServerName(server.serverName); + } + await handleConnect(server.address); + }} + /> + { + await handleConnect(s.address); + }} + onQuickLogin={handleQuickLoginWithSavedCredential} + onPasswordLogin={handlePasswordLogin} + onAddAccount={handleAddAccount} + /> + + + )} + + + { + setShowSaveModal(false); + setPendingLogin(null); + }} + onSave={handleSaveAccountConfirm} + username={pendingLogin?.username || credentials.username} + /> + + ); +}; diff --git a/components/login/TVAccountCard.tsx b/components/login/TVAccountCard.tsx new file mode 100644 index 000000000..d555536d1 --- /dev/null +++ b/components/login/TVAccountCard.tsx @@ -0,0 +1,155 @@ +import { Ionicons } from "@expo/vector-icons"; +import React, { useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Animated, Easing, Pressable, View } from "react-native"; +import { Text } from "@/components/common/Text"; +import { scaleSize } from "@/utils/scaleSize"; +import type { SavedServerAccount } from "@/utils/secureCredentials"; + +interface TVAccountCardProps { + account: SavedServerAccount; + onPress: () => void; + onLongPress?: () => void; + hasTVPreferredFocus?: boolean; +} + +export const TVAccountCard: React.FC = ({ + account, + onPress, + onLongPress, + hasTVPreferredFocus, +}) => { + const { t } = useTranslation(); + const [isFocused, setIsFocused] = useState(false); + const scale = useRef(new Animated.Value(1)).current; + const glowOpacity = useRef(new Animated.Value(0)).current; + + const animateFocus = (focused: boolean) => { + Animated.parallel([ + Animated.timing(scale, { + toValue: focused ? 1.03 : 1, + duration: 150, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }), + Animated.timing(glowOpacity, { + toValue: focused ? 0.6 : 0, + duration: 150, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }), + ]).start(); + }; + + const handleFocus = () => { + setIsFocused(true); + animateFocus(true); + }; + + const handleBlur = () => { + setIsFocused(false); + animateFocus(false); + }; + + const getSecurityIcon = (): keyof typeof Ionicons.glyphMap => { + switch (account.securityType) { + case "pin": + return "keypad"; + case "password": + return "lock-closed"; + default: + return "key"; + } + }; + + const getSecurityText = (): string => { + switch (account.securityType) { + case "pin": + return t("save_account.pin_code"); + case "password": + return t("save_account.password"); + default: + return t("save_account.no_protection"); + } + }; + + return ( + + + + {/* Avatar */} + + + + + {/* Account Info */} + + + {account.username} + + + {getSecurityText()} + + + + {/* Security Icon */} + + + + + ); +}; diff --git a/components/login/TVAddIcon.tsx b/components/login/TVAddIcon.tsx new file mode 100644 index 000000000..45b5f0415 --- /dev/null +++ b/components/login/TVAddIcon.tsx @@ -0,0 +1,83 @@ +import { Ionicons } from "@expo/vector-icons"; +import React from "react"; +import { Animated, Pressable, View } from "react-native"; +import { Text } from "@/components/common/Text"; +import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation"; +import { useScaledTVTypography } from "@/constants/TVTypography"; +import { scaleSize } from "@/utils/scaleSize"; + +export interface TVAddIconProps { + label: string; + onPress: () => void; + hasTVPreferredFocus?: boolean; + disabled?: boolean; +} + +export const TVAddIcon = React.forwardRef( + ({ label, onPress, hasTVPreferredFocus, disabled = false }, ref) => { + const typography = useScaledTVTypography(); + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation(); + + return ( + + + + + + + + {label} + + + + ); + }, +); diff --git a/components/login/TVAddServerForm.tsx b/components/login/TVAddServerForm.tsx new file mode 100644 index 000000000..cf38fa54c --- /dev/null +++ b/components/login/TVAddServerForm.tsx @@ -0,0 +1,123 @@ +import { t } from "i18next"; +import React, { useCallback, useState } from "react"; +import { Platform, ScrollView, View } from "react-native"; +import { Button } from "@/components/Button"; +import { Text } from "@/components/common/Text"; +import { useScaledTVTypography } from "@/constants/TVTypography"; +import { useTVBackPress } from "@/hooks/useTVBackPress"; +import { scaleSize } from "@/utils/scaleSize"; +import { TVInput } from "./TVInput"; + +interface TVAddServerFormProps { + onConnect: (url: string) => Promise; + onStartPairing?: () => void; + onBack: () => void; + loading?: boolean; + disabled?: boolean; +} + +export const TVAddServerForm: React.FC = ({ + onConnect, + onStartPairing, + onBack, + loading = false, + disabled = false, +}) => { + const typography = useScaledTVTypography(); + const [serverURL, setServerURL] = useState(""); + + const handleConnect = async () => { + if (serverURL.trim()) { + await onConnect(serverURL.trim()); + } + }; + + const isDisabled = disabled || loading; + + const handleBack = useCallback(() => { + if (isDisabled) return false; + onBack(); + return true; + }, [isDisabled, onBack]); + + useTVBackPress(() => handleBack(), [handleBack]); + + return ( + + + {/* Title */} + + {t("server.enter_url_to_jellyfin_server")} + + + {/* Server URL Input */} + + + + + {/* Connect Button */} + + + + + {/* Pair with Phone */} + {Platform.OS !== "ios" && onStartPairing && ( + + + + )} + + + ); +}; diff --git a/components/login/TVAddUserForm.tsx b/components/login/TVAddUserForm.tsx new file mode 100644 index 000000000..3801f623f --- /dev/null +++ b/components/login/TVAddUserForm.tsx @@ -0,0 +1,185 @@ +import { t } from "i18next"; +import React, { useCallback, useState } from "react"; +import { ScrollView, View } from "react-native"; +import { Button } from "@/components/Button"; +import { Text } from "@/components/common/Text"; +import { useScaledTVTypography } from "@/constants/TVTypography"; +import { useTVBackPress } from "@/hooks/useTVBackPress"; +import { scaleSize } from "@/utils/scaleSize"; +import { TVInput } from "./TVInput"; +import { TVSaveAccountToggle } from "./TVSaveAccountToggle"; + +interface TVAddUserFormProps { + serverName: string; + serverAddress: string; + onLogin: ( + username: string, + password: string, + saveAccount: boolean, + ) => Promise; + onQuickConnect: () => Promise; + onBack: () => void; + loading?: boolean; + disabled?: boolean; +} + +export const TVAddUserForm: React.FC = ({ + serverName, + serverAddress, + onLogin, + onQuickConnect, + onBack, + loading = false, + disabled = false, +}) => { + const typography = useScaledTVTypography(); + const [credentials, setCredentials] = useState({ + username: "", + password: "", + }); + const [saveAccount, setSaveAccount] = useState(false); + + const handleLogin = async () => { + if (credentials.username.trim()) { + await onLogin(credentials.username, credentials.password, saveAccount); + } + }; + + const isDisabled = disabled || loading; + + const handleBack = useCallback(() => { + if (isDisabled) return false; + onBack(); + return true; + }, [isDisabled, onBack]); + + useTVBackPress(() => handleBack(), [handleBack]); + + return ( + + + {/* Title */} + + {serverName ? ( + <> + {`${t("login.login_to_title")} `} + {serverName} + + ) : ( + t("login.login_title") + )} + + + {serverAddress} + + + {/* Username Input */} + + + setCredentials((prev) => ({ ...prev, username: text })) + } + autoCapitalize='none' + autoCorrect={false} + textContentType='username' + returnKeyType='next' + hasTVPreferredFocus + disabled={isDisabled} + /> + + + {/* Password Input */} + + + setCredentials((prev) => ({ ...prev, password: text })) + } + secureTextEntry + autoCapitalize='none' + textContentType='password' + returnKeyType='done' + disabled={isDisabled} + /> + + + {/* Save Account Toggle */} + + + + + {/* Login Button */} + + + + + {/* Quick Connect Button */} + + + + ); +}; diff --git a/components/login/TVBackIcon.tsx b/components/login/TVBackIcon.tsx new file mode 100644 index 000000000..48dc8f3f2 --- /dev/null +++ b/components/login/TVBackIcon.tsx @@ -0,0 +1,83 @@ +import { Ionicons } from "@expo/vector-icons"; +import React from "react"; +import { Animated, Pressable, View } from "react-native"; +import { Text } from "@/components/common/Text"; +import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation"; +import { useScaledTVTypography } from "@/constants/TVTypography"; +import { scaleSize } from "@/utils/scaleSize"; + +export interface TVBackIconProps { + label: string; + onPress: () => void; + hasTVPreferredFocus?: boolean; + disabled?: boolean; +} + +export const TVBackIcon = React.forwardRef( + ({ label, onPress, hasTVPreferredFocus, disabled = false }, ref) => { + const typography = useScaledTVTypography(); + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation(); + + return ( + + + + + + + + {label} + + + + ); + }, +); diff --git a/components/login/TVInput.tsx b/components/login/TVInput.tsx new file mode 100644 index 000000000..8c5c75316 --- /dev/null +++ b/components/login/TVInput.tsx @@ -0,0 +1,91 @@ +import React, { useRef, useState } from "react"; +import { + Animated, + Easing, + Pressable, + TextInput, + type TextInputProps, +} from "react-native"; +import { scaleSize } from "@/utils/scaleSize"; + +interface TVInputProps extends TextInputProps { + label?: string; + hasTVPreferredFocus?: boolean; + disabled?: boolean; +} + +export const TVInput: React.FC = ({ + label, + placeholder, + hasTVPreferredFocus, + disabled = false, + style, + ...props +}) => { + const [isFocused, setIsFocused] = useState(false); + const inputRef = useRef(null); + const scale = useRef(new Animated.Value(1)).current; + + const animateFocus = (focused: boolean) => { + Animated.timing(scale, { + toValue: focused ? 1.02 : 1, + duration: 200, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }).start(); + }; + + const handleFocus = () => { + setIsFocused(true); + animateFocus(true); + }; + + const handleBlur = () => { + setIsFocused(false); + animateFocus(false); + }; + + const displayPlaceholder = placeholder || label; + + return ( + inputRef.current?.focus()} + onFocus={handleFocus} + onBlur={handleBlur} + hasTVPreferredFocus={hasTVPreferredFocus && !disabled} + disabled={disabled} + focusable={!disabled} + > + + + + + ); +}; diff --git a/components/login/TVLogin.tsx b/components/login/TVLogin.tsx new file mode 100644 index 000000000..00bd7f65f --- /dev/null +++ b/components/login/TVLogin.tsx @@ -0,0 +1,809 @@ +import type { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client"; +import { useLocalSearchParams, useNavigation } from "expo-router"; +import { t } from "i18next"; +import { useAtom, useAtomValue } from "jotai"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Alert, View } from "react-native"; +import { useMMKVString } from "react-native-mmkv"; +import { Text } from "@/components/common/Text"; +import { useTVMenuKeyInterception } from "@/hooks/useTVBackPress"; +import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider"; +import { selectedTVServerAtom } from "@/utils/atoms/selectedTVServer"; +import { storage } from "@/utils/mmkv"; +import { + generatePairingCode, + type PairingCredentials, + startPairingListener, +} from "@/utils/pairingService"; +import { scaleSize } from "@/utils/scaleSize"; +import { + type AccountSecurityType, + getPreviousServers, + hashPIN, + removeServerFromList, + type SavedServer, + type SavedServerAccount, + saveAccountCredential, +} from "@/utils/secureCredentials"; +import { TVAddServerForm } from "./TVAddServerForm"; +import { TVAddUserForm } from "./TVAddUserForm"; +import { TVPasswordEntryModal } from "./TVPasswordEntryModal"; +import { TVPINEntryModal } from "./TVPINEntryModal"; +import { TVQRCodeDisplay } from "./TVQRCodeDisplay"; +import { TVSaveAccountModal } from "./TVSaveAccountModal"; +import { TVServerSelectionScreen } from "./TVServerSelectionScreen"; +import { TVUserSelectionScreen } from "./TVUserSelectionScreen"; + +type TVLoginScreen = + | "server-selection" + | "qr-code-display" + | "loading" + | "user-selection" + | "add-server" + | "add-user"; + +export const TVLogin: React.FC = () => { + const api = useAtomValue(apiAtom); + const navigation = useNavigation(); + const params = useLocalSearchParams(); + const { + setServer, + login, + removeServer, + initiateQuickConnect, + stopQuickConnectPolling, + loginWithSavedCredential, + loginWithPassword, + } = useJellyfin(); + + const { + apiUrl: _apiUrl, + username: _username, + password: _password, + } = params as { apiUrl: string; username: string; password: string }; + + // Selected server persistence + const [selectedTVServer, setSelectedTVServer] = useAtom(selectedTVServerAtom); + const [_previousServers, setPreviousServers] = + useMMKVString("previousServers"); + + // Get current servers list + const previousServers = useMemo(() => { + try { + return JSON.parse(_previousServers || "[]") as SavedServer[]; + } catch { + return []; + } + }, [_previousServers]); + + // Current screen state + const [currentScreen, setCurrentScreen] = + useState("server-selection"); + // No interception on server-selection so that it can go back to home screen on tvOS + useTVMenuKeyInterception(currentScreen !== "server-selection"); + + // Current selected server for user selection screen + const [currentServer, setCurrentServer] = useState(null); + const [serverName, setServerName] = useState(""); + + // Loading states + const [loadingServerCheck, setLoadingServerCheck] = useState(false); + const [loading, setLoading] = useState(false); + + // Save account state + const [showSaveModal, setShowSaveModal] = useState(false); + const [pendingLogin, setPendingLogin] = useState<{ + username: string; + password: string; + } | null>(null); + + // PIN/Password entry for saved accounts + const [pinModalVisible, setPinModalVisible] = useState(false); + const [passwordModalVisible, setPasswordModalVisible] = useState(false); + const [selectedAccount, setSelectedAccount] = + useState(null); + + // Track if any modal is open to disable background focus + const isAnyModalOpen = + showSaveModal || pinModalVisible || passwordModalVisible; + + // Pairing state (companion login via phone) + const [showPairingQR, setShowPairingQR] = useState(false); + const [pairingCode, setPairingCode] = useState(""); + const [pendingPairingCredentials, setPendingPairingCredentials] = useState<{ + serverUrl: string; + username: string; + password: string; + } | null>(null); + // Ref to prevent double-handling when onSave and onClose both fire + const pairingHandledRef = useRef(false); + + // Refresh servers list helper + const refreshServers = () => { + const servers = getPreviousServers(); + setPreviousServers(JSON.stringify(servers)); + }; + + // Initialize on mount - check if we have a persisted server + useEffect(() => { + if (selectedTVServer) { + // Find the full server data from previousServers + const server = previousServers.find( + (s) => s.address === selectedTVServer.address, + ); + if (server) { + setCurrentServer(server); + setServerName(selectedTVServer.name || ""); + setCurrentScreen("user-selection"); + } else { + // Server no longer exists, clear persistence + setSelectedTVServer(null); + } + } + }, []); + + // Stop Quick Connect polling when leaving the login page + useEffect(() => { + return () => { + stopQuickConnectPolling(); + setShowPairingQR(false); + }; + }, [stopQuickConnectPolling]); + + // Handle URL params for server connection + useEffect(() => { + (async () => { + if (_apiUrl) { + await setServer({ address: _apiUrl }); + } + })(); + }, [_apiUrl]); + + // Handle auto-login when api is ready and credentials are provided via URL params + useEffect(() => { + if (api?.basePath && _apiUrl && _username && _password) { + login(_username, _password); + } + }, [api?.basePath, _apiUrl, _username, _password]); + + // Update header + useEffect(() => { + navigation.setOptions({ + headerTitle: serverName, + headerShown: false, + }); + }, [serverName, navigation]); + + // Server URL checking + const checkUrl = useCallback(async (url: string) => { + setLoadingServerCheck(true); + const baseUrl = url.replace(/^https?:\/\//i, ""); + const protocols = ["https", "http"]; + try { + return checkHttp(baseUrl, protocols); + } catch (e) { + if (e instanceof Error && e.message === "Server too old") { + throw e; + } + return undefined; + } finally { + setLoadingServerCheck(false); + } + }, []); + + async function checkHttp(baseUrl: string, protocols: string[]) { + for (const protocol of protocols) { + try { + const response = await fetch( + `${protocol}://${baseUrl}/System/Info/Public`, + { mode: "cors" }, + ); + if (response.ok) { + const data = (await response.json()) as PublicSystemInfo; + const serverVersion = data.Version?.split("."); + if (serverVersion && +serverVersion[0] <= 10) { + if (+serverVersion[1] < 10) { + Alert.alert( + t("login.too_old_server_text"), + t("login.too_old_server_description"), + ); + throw new Error("Server too old"); + } + } + setServerName(data.ServerName || ""); + return `${protocol}://${baseUrl}`; + } + } catch (e) { + if (e instanceof Error && e.message === "Server too old") { + throw e; + } + } + } + return undefined; + } + + // Handle connecting to a new server + const handleConnect = useCallback( + async (url: string) => { + url = url.trim().replace(/\/$/, ""); + try { + const result = await checkUrl(url); + if (result === undefined) { + Alert.alert( + t("login.connection_failed"), + t("login.could_not_connect_to_server"), + ); + return; + } + await setServer({ address: result }); + + // Update server list and get the new server data + refreshServers(); + + // Find or create server entry + const servers = getPreviousServers(); + const server = servers.find((s) => s.address === result); + + if (server) { + setCurrentServer(server); + setSelectedTVServer({ address: result, name: serverName }); + setCurrentScreen("user-selection"); + } + } catch (error) { + if (__DEV__) console.error("[TVLogin] Error in handleConnect:", error); + } + }, + [checkUrl, setServer, serverName, setSelectedTVServer], + ); + + // Handle selecting an existing server + const handleServerSelect = (server: SavedServer) => { + setCurrentServer(server); + setServerName(server.name || ""); + setSelectedTVServer({ address: server.address, name: server.name }); + setCurrentScreen("user-selection"); + }; + + // Handle changing server (back from user selection) + const handleChangeServer = () => { + setSelectedTVServer(null); + setCurrentServer(null); + setServerName(""); + removeServer(); + setCurrentScreen("server-selection"); + }; + + // Handle deleting a server + const handleDeleteServer = async (server: SavedServer) => { + await removeServerFromList(server.address); + refreshServers(); + // If we deleted the currently selected server, clear it + if (selectedTVServer?.address === server.address) { + setSelectedTVServer(null); + setCurrentServer(null); + } + }; + + // Handle user selection + const handleUserSelect = async (account: SavedServerAccount) => { + if (!currentServer) return; + + switch (account.securityType) { + case "none": + setCurrentScreen("loading"); + setLoading(true); + try { + await loginWithSavedCredential(currentServer.address, account.userId); + } catch (error) { + const errorMessage = + error instanceof Error + ? error.message + : t("server.session_expired"); + const isSessionExpired = errorMessage.includes( + t("server.session_expired"), + ); + Alert.alert( + isSessionExpired + ? t("server.session_expired") + : t("login.connection_failed"), + isSessionExpired ? t("server.please_login_again") : errorMessage, + [ + { + text: t("common.ok"), + onPress: () => setCurrentScreen("user-selection"), + }, + ], + ); + } finally { + setLoading(false); + } + break; + + case "pin": + setSelectedAccount(account); + setPinModalVisible(true); + break; + + case "password": + setSelectedAccount(account); + setPasswordModalVisible(true); + break; + } + }; + + // Handle PIN success + const handlePinSuccess = async () => { + setPinModalVisible(false); + if (currentServer && selectedAccount) { + setCurrentScreen("loading"); + setLoading(true); + try { + await loginWithSavedCredential( + currentServer.address, + selectedAccount.userId, + ); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : t("server.session_expired"); + const isSessionExpired = errorMessage.includes( + t("server.session_expired"), + ); + Alert.alert( + isSessionExpired + ? t("server.session_expired") + : t("login.connection_failed"), + isSessionExpired ? t("server.please_login_again") : errorMessage, + [ + { + text: t("common.ok"), + onPress: () => setCurrentScreen("user-selection"), + }, + ], + ); + } finally { + setLoading(false); + } + } + setSelectedAccount(null); + }; + + // Handle password submit + const handlePasswordSubmit = async (password: string) => { + if (currentServer && selectedAccount) { + setCurrentScreen("loading"); + setLoading(true); + try { + await loginWithPassword( + currentServer.address, + selectedAccount.username, + password, + ); + } catch { + Alert.alert( + t("login.connection_failed"), + t("login.invalid_username_or_password"), + [ + { + text: t("common.ok"), + onPress: () => setCurrentScreen("user-selection"), + }, + ], + ); + } finally { + setLoading(false); + } + } + setPasswordModalVisible(false); + setSelectedAccount(null); + }; + + // Handle forgot PIN + const handleForgotPIN = async () => { + setSelectedAccount(null); + setPinModalVisible(false); + }; + + // Handle login with credentials (from add user form) + const handleLogin = async ( + username: string, + password: string, + saveAccount: boolean, + ) => { + if (!currentServer) return; + + if (saveAccount) { + setPendingLogin({ username, password }); + setShowSaveModal(true); + } else { + await performLogin(username, password); + } + }; + + const performLogin = async ( + username: string, + password: string, + options?: { + saveAccount?: boolean; + securityType?: AccountSecurityType; + pinCode?: string; + }, + ) => { + setLoading(true); + try { + await login(username, password, serverName, options); + } catch (error) { + if (error instanceof Error) { + Alert.alert(t("login.connection_failed"), error.message); + } else { + Alert.alert( + t("login.connection_failed"), + t("login.an_unexpected_error_occurred"), + ); + } + } finally { + setLoading(false); + setPendingLogin(null); + } + }; + + const handleSaveAccountConfirm = async ( + securityType: AccountSecurityType, + pinCode?: string, + ) => { + setShowSaveModal(false); + const pairingCreds = pendingPairingCredentials; + + if (pairingCreds) { + // Pairing flow: mark as handled, login, then save credential + pairingHandledRef.current = true; + setPendingPairingCredentials(null); + setPendingLogin(null); + setLoading(true); + try { + await loginWithPassword( + pairingCreds.serverUrl, + pairingCreds.username, + pairingCreds.password, + ); + // Save credential after successful login + try { + const token = storage.getString("token"); + const userJson = storage.getString("user"); + const storedServerUrl = storage.getString("serverUrl"); + if (token && userJson && storedServerUrl) { + const user = JSON.parse(userJson); + let pinHash: string | undefined; + if (securityType === "pin" && pinCode) { + pinHash = await hashPIN(pinCode); + } + await saveAccountCredential({ + serverUrl: storedServerUrl, + serverName: storedServerUrl, + token, + userId: user.Id || "", + username: pairingCreds.username, + savedAt: Date.now(), + securityType, + pinHash, + primaryImageTag: user.PrimaryImageTag ?? undefined, + }); + } + } catch (saveError) { + if (__DEV__) + console.error( + "[TVLogin] Failed to save pairing credential:", + saveError, + ); + } + } catch (error) { + const message = + error instanceof Error + ? error.message + : t("login.an_unexpected_error_occurred"); + Alert.alert(t("login.connection_failed"), message); + goToQRScreen(); + } finally { + setLoading(false); + } + return; + } + + // Normal login flow + if (pendingLogin && currentServer) { + setLoading(true); + try { + await login(pendingLogin.username, pendingLogin.password, serverName, { + saveAccount: true, + securityType, + pinCode, + }); + } catch (error) { + if (error instanceof Error) { + Alert.alert(t("login.connection_failed"), error.message); + } else { + Alert.alert( + t("login.connection_failed"), + t("login.an_unexpected_error_occurred"), + ); + } + } finally { + setLoading(false); + setPendingLogin(null); + } + } + }; + + // Handle quick connect + const handleQuickConnect = async () => { + try { + const code = await initiateQuickConnect(); + if (code) { + Alert.alert( + t("login.quick_connect"), + t("login.enter_code_to_login", { code: code }), + [{ text: t("login.got_it") }], + ); + } + } catch (_error) { + Alert.alert( + t("login.error_title"), + t("login.failed_to_initiate_quick_connect"), + ); + } + }; + + // Navigate to QR screen with a fresh code and active listener + const goToQRScreen = useCallback(() => { + const code = generatePairingCode(); + setPairingCode(code); + setShowPairingQR(true); + setCurrentScreen("qr-code-display"); + }, []); + + // Handle pairing with companion phone + const handleStartPairing = useCallback(() => { + goToQRScreen(); + }, [goToQRScreen]); + + // Handle credentials received from companion + const handlePairingCredentials = useCallback( + (credentials: PairingCredentials) => { + setShowPairingQR(false); + setCurrentScreen("loading"); + + // Store credentials and show save modal (same UX as normal login) + setPendingPairingCredentials({ + serverUrl: credentials.serverUrl, + username: credentials.username, + password: credentials.password, + }); + setPendingLogin({ + username: credentials.username, + password: credentials.password, + }); + setShowSaveModal(true); + }, + [], + ); + + // Listen for pairing credentials when QR is shown + useEffect(() => { + if (!showPairingQR || !pairingCode) return; + + const cleanup = startPairingListener( + pairingCode, + handlePairingCredentials, + (error) => { + if (__DEV__) console.error("[TVLogin] Pairing error:", error); + setShowPairingQR(false); + Alert.alert(t("login.error_title"), t("companion_login.error_generic")); + }, + ); + + // Auto-dismiss after 5 minutes + const timeout = setTimeout( + () => { + setShowPairingQR(false); + }, + 5 * 60 * 1000, + ); + + return () => { + cleanup(); + clearTimeout(timeout); + }; + }, [showPairingQR, pairingCode, handlePairingCredentials]); + + // Render current screen + const renderScreen = () => { + // If API is connected but we're on server/user selection, + // it means we need to show add-user form + if ( + api?.basePath && + currentScreen !== "add-user" && + currentScreen !== "loading" + ) { + // API is ready, show add-user form + return ( + + ); + } + + switch (currentScreen) { + case "server-selection": + return ( + setCurrentScreen("add-server")} + onDeleteServer={handleDeleteServer} + disabled={isAnyModalOpen} + /> + ); + + case "user-selection": + if (!currentServer) { + setCurrentScreen("server-selection"); + return null; + } + return ( + { + // Set the server in JellyfinProvider and go to add-user + setServer({ address: currentServer.address }); + setCurrentScreen("add-user"); + }} + onChangeServer={handleChangeServer} + disabled={isAnyModalOpen || loading} + /> + ); + + case "add-server": + return ( + setCurrentScreen("server-selection")} + loading={loadingServerCheck} + disabled={isAnyModalOpen} + /> + ); + + case "qr-code-display": + return ( + { + setShowPairingQR(false); + setCurrentScreen("add-server"); + }} + /> + ); + + case "loading": + return ( + + + {t("pairing.logging_in")} + + + {t("pairing.logging_in_description")} + + + ); + + case "add-user": + return ( + { + removeServer(); + setCurrentScreen("user-selection"); + }} + loading={loading} + disabled={isAnyModalOpen} + /> + ); + + default: + return null; + } + }; + + return ( + + {renderScreen()} + + {/* Save Account Modal */} + { + // If onSave already handled this, just clean up + if (pairingHandledRef.current) { + pairingHandledRef.current = false; + return; + } + setShowSaveModal(false); + if (pendingPairingCredentials) { + // Pairing: user dismissed without saving, login anyway + const creds = pendingPairingCredentials; + setPendingPairingCredentials(null); + setPendingLogin(null); + loginWithPassword( + creds.serverUrl, + creds.username, + creds.password, + ).catch((error) => { + const message = + error instanceof Error + ? error.message + : t("login.an_unexpected_error_occurred"); + Alert.alert(t("login.connection_failed"), message); + goToQRScreen(); + }); + return; + } + setPendingLogin(null); + }} + onSave={handleSaveAccountConfirm} + username={pendingLogin?.username || ""} + /> + + {/* PIN Entry Modal */} + { + setPinModalVisible(false); + setSelectedAccount(null); + }} + onSuccess={handlePinSuccess} + onForgotPIN={handleForgotPIN} + serverUrl={currentServer?.address || ""} + userId={selectedAccount?.userId || ""} + username={selectedAccount?.username || ""} + /> + + {/* Password Entry Modal */} + { + setPasswordModalVisible(false); + setSelectedAccount(null); + }} + onSubmit={handlePasswordSubmit} + username={selectedAccount?.username || ""} + /> + + ); +}; diff --git a/components/login/TVPINEntryModal.tsx b/components/login/TVPINEntryModal.tsx new file mode 100644 index 000000000..9428850da --- /dev/null +++ b/components/login/TVPINEntryModal.tsx @@ -0,0 +1,488 @@ +import { Ionicons } from "@expo/vector-icons"; +import { BlurView } from "expo-blur"; +import React, { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + Alert, + Animated, + Easing, + Pressable, + StyleSheet, + TVFocusGuideView, + View, +} from "react-native"; +import { Text } from "@/components/common/Text"; +import { scaleSize } from "@/utils/scaleSize"; +import { verifyAccountPIN } from "@/utils/secureCredentials"; + +interface TVPINEntryModalProps { + visible: boolean; + onClose: () => void; + onSuccess: () => void; + onForgotPIN?: () => void; + serverUrl: string; + userId: string; + username: string; +} + +// Number pad button +const NumberPadButton: React.FC<{ + value: string; + onPress: () => void; + hasTVPreferredFocus?: boolean; + isBackspace?: boolean; + disabled?: boolean; +}> = ({ value, onPress, hasTVPreferredFocus, isBackspace, disabled }) => { + const [focused, setFocused] = useState(false); + const scale = useRef(new Animated.Value(1)).current; + + const animateTo = (v: number) => + Animated.timing(scale, { + toValue: v, + duration: 100, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }).start(); + + return ( + { + setFocused(true); + animateTo(1.1); + }} + onBlur={() => { + setFocused(false); + animateTo(1); + }} + hasTVPreferredFocus={hasTVPreferredFocus} + disabled={disabled} + focusable={!disabled} + > + + {isBackspace ? ( + + ) : ( + + {value} + + )} + + + ); +}; + +// PIN dot indicator +const PinDot: React.FC<{ filled: boolean; error: boolean }> = ({ + filled, + error, +}) => ( + +); + +// Forgot PIN link +const ForgotPINLink: React.FC<{ + onPress: () => void; + label: string; +}> = ({ onPress, label }) => { + const [focused, setFocused] = useState(false); + const scale = useRef(new Animated.Value(1)).current; + + const animateTo = (v: number) => + Animated.timing(scale, { + toValue: v, + duration: 100, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }).start(); + + return ( + { + setFocused(true); + animateTo(1.05); + }} + onBlur={() => { + setFocused(false); + animateTo(1); + }} + > + + + {label} + + + + ); +}; + +export const TVPINEntryModal: React.FC = ({ + visible, + onClose, + onSuccess, + onForgotPIN, + serverUrl, + userId, + username, +}) => { + const { t } = useTranslation(); + const [isReady, setIsReady] = useState(false); + const [pinCode, setPinCode] = useState(""); + const [error, setError] = useState(false); + const [isVerifying, setIsVerifying] = useState(false); + + const overlayOpacity = useRef(new Animated.Value(0)).current; + const contentScale = useRef(new Animated.Value(0.9)).current; + const shakeAnimation = useRef(new Animated.Value(0)).current; + + useEffect(() => { + if (visible) { + setPinCode(""); + setError(false); + setIsVerifying(false); + + overlayOpacity.setValue(0); + contentScale.setValue(0.9); + + Animated.parallel([ + Animated.timing(overlayOpacity, { + toValue: 1, + duration: 250, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }), + Animated.timing(contentScale, { + toValue: 1, + duration: 300, + easing: Easing.out(Easing.cubic), + useNativeDriver: true, + }), + ]).start(); + + const timer = setTimeout(() => setIsReady(true), 100); + return () => clearTimeout(timer); + } + setIsReady(false); + }, [visible, overlayOpacity, contentScale]); + + const shake = () => { + Animated.sequence([ + Animated.timing(shakeAnimation, { + toValue: 15, + duration: 50, + useNativeDriver: true, + }), + Animated.timing(shakeAnimation, { + toValue: -15, + duration: 50, + useNativeDriver: true, + }), + Animated.timing(shakeAnimation, { + toValue: 15, + duration: 50, + useNativeDriver: true, + }), + Animated.timing(shakeAnimation, { + toValue: 0, + duration: 50, + useNativeDriver: true, + }), + ]).start(); + }; + + const handleNumberPress = async (num: string) => { + if (isVerifying || pinCode.length >= 4) return; + + setError(false); + const newPin = pinCode + num; + setPinCode(newPin); + + // Auto-verify when 4 digits entered + if (newPin.length === 4) { + setIsVerifying(true); + try { + const isValid = await verifyAccountPIN(serverUrl, userId, newPin); + if (isValid) { + onSuccess(); + setPinCode(""); + } else { + setError(true); + shake(); + setTimeout(() => setPinCode(""), 300); + } + } catch { + setError(true); + shake(); + setTimeout(() => setPinCode(""), 300); + } finally { + setIsVerifying(false); + } + } + }; + + const handleBackspace = () => { + if (isVerifying) return; + setError(false); + setPinCode((prev) => prev.slice(0, -1)); + }; + + const handleForgotPIN = () => { + Alert.alert(t("pin.forgot_pin"), t("pin.forgot_pin_desc"), [ + { text: t("common.cancel"), style: "cancel" }, + { + text: t("common.continue"), + style: "destructive", + onPress: () => { + onClose(); + onForgotPIN?.(); + }, + }, + ]); + }; + + if (!visible) return null; + + return ( + + + + + {/* Header */} + {t("pin.enter_pin")} + {username} + + {/* PIN Dots */} + + {[0, 1, 2, 3].map((i) => ( + i} error={error} /> + ))} + + + {/* Number Pad */} + {isReady && ( + + {/* Row 1: 1-3 */} + + handleNumberPress("1")} + hasTVPreferredFocus + disabled={isVerifying} + /> + handleNumberPress("2")} + disabled={isVerifying} + /> + handleNumberPress("3")} + disabled={isVerifying} + /> + + {/* Row 2: 4-6 */} + + handleNumberPress("4")} + disabled={isVerifying} + /> + handleNumberPress("5")} + disabled={isVerifying} + /> + handleNumberPress("6")} + disabled={isVerifying} + /> + + {/* Row 3: 7-9 */} + + handleNumberPress("7")} + disabled={isVerifying} + /> + handleNumberPress("8")} + disabled={isVerifying} + /> + handleNumberPress("9")} + disabled={isVerifying} + /> + + {/* Row 4: empty, 0, backspace */} + + + handleNumberPress("0")} + disabled={isVerifying} + /> + + + + )} + + {/* Forgot PIN */} + {isReady && onForgotPIN && ( + + + + )} + + + + + ); +}; + +const styles = StyleSheet.create({ + overlay: { + position: "absolute", + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: "rgba(0, 0, 0, 0.8)", + justifyContent: "center", + alignItems: "center", + zIndex: 1000, + }, + contentContainer: { + width: "100%", + maxWidth: 400, + }, + blurContainer: { + borderRadius: scaleSize(24), + overflow: "hidden", + }, + content: { + padding: scaleSize(40), + alignItems: "center", + }, + title: { + fontSize: scaleSize(28), + fontWeight: "bold", + color: "#fff", + marginBottom: scaleSize(8), + textAlign: "center", + }, + subtitle: { + fontSize: scaleSize(18), + color: "rgba(255,255,255,0.6)", + marginBottom: scaleSize(32), + textAlign: "center", + }, + pinDotsContainer: { + flexDirection: "row", + gap: scaleSize(16), + marginBottom: scaleSize(32), + }, + pinDot: { + width: scaleSize(20), + height: scaleSize(20), + borderRadius: scaleSize(10), + borderWidth: scaleSize(2), + borderColor: "rgba(255,255,255,0.4)", + backgroundColor: "transparent", + }, + pinDotFilled: { + backgroundColor: "#fff", + borderColor: "#fff", + }, + pinDotError: { + borderColor: "#ef4444", + backgroundColor: "#ef4444", + }, + numberPad: { + gap: scaleSize(12), + marginBottom: scaleSize(24), + }, + numberRow: { + flexDirection: "row", + gap: scaleSize(12), + }, + numberButton: { + width: scaleSize(72), + height: scaleSize(72), + borderRadius: scaleSize(36), + justifyContent: "center", + alignItems: "center", + }, + numberButtonPlaceholder: { + width: scaleSize(72), + height: scaleSize(72), + }, + numberText: { + fontSize: scaleSize(28), + fontWeight: "600", + }, + forgotContainer: { + marginTop: 8, + }, +}); diff --git a/components/login/TVPasswordEntryModal.tsx b/components/login/TVPasswordEntryModal.tsx new file mode 100644 index 000000000..7db1671e9 --- /dev/null +++ b/components/login/TVPasswordEntryModal.tsx @@ -0,0 +1,352 @@ +import { Ionicons } from "@expo/vector-icons"; +import { BlurView } from "expo-blur"; +import React, { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + ActivityIndicator, + Animated, + Easing, + Pressable, + StyleSheet, + TextInput, + TVFocusGuideView, + View, +} from "react-native"; +import { Text } from "@/components/common/Text"; +import { useTVFocusAnimation } from "@/components/tv"; +import { useTVBackPress } from "@/hooks/useTVBackPress"; +import { scaleSize } from "@/utils/scaleSize"; + +interface TVPasswordEntryModalProps { + visible: boolean; + onClose: () => void; + onSubmit: (password: string) => Promise; + username: string; +} + +// TV Submit Button +const TVSubmitButton: React.FC<{ + onPress: () => void; + label: string; + loading?: boolean; + disabled?: boolean; +}> = ({ onPress, label, loading = false, disabled = false }) => { + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation({ scaleAmount: 1.05, duration: 120 }); + + const isDisabled = disabled || loading; + + return ( + + + {loading ? ( + + ) : ( + <> + + + {label} + + + )} + + + ); +}; + +// TV Focusable Password Input +const TVPasswordInput: React.FC<{ + value: string; + onChangeText: (text: string) => void; + placeholder: string; + onSubmitEditing: () => void; + hasTVPreferredFocus?: boolean; +}> = ({ + value, + onChangeText, + placeholder, + onSubmitEditing, + hasTVPreferredFocus, +}) => { + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation({ scaleAmount: 1.02, duration: 120 }); + const inputRef = useRef(null); + + return ( + inputRef.current?.focus()} + onFocus={() => { + handleFocus(); + inputRef.current?.focus(); + }} + onBlur={handleBlur} + hasTVPreferredFocus={hasTVPreferredFocus} + > + + + + + ); +}; + +export const TVPasswordEntryModal: React.FC = ({ + visible, + onClose, + onSubmit, + username, +}) => { + const { t } = useTranslation(); + const [isReady, setIsReady] = useState(false); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + const overlayOpacity = useRef(new Animated.Value(0)).current; + const sheetTranslateY = useRef(new Animated.Value(200)).current; + + useEffect(() => { + if (visible) { + // Reset state when opening + setPassword(""); + setError(null); + setIsLoading(false); + + overlayOpacity.setValue(0); + sheetTranslateY.setValue(200); + + Animated.parallel([ + Animated.timing(overlayOpacity, { + toValue: 1, + duration: 250, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }), + Animated.timing(sheetTranslateY, { + toValue: 0, + duration: 300, + easing: Easing.out(Easing.cubic), + useNativeDriver: true, + }), + ]).start(); + } + }, [visible, overlayOpacity, sheetTranslateY]); + + useEffect(() => { + if (visible) { + const timer = setTimeout(() => setIsReady(true), 100); + return () => clearTimeout(timer); + } + setIsReady(false); + }, [visible]); + + // Close the modal on the TV remote back/menu button while it is open. + useTVBackPress(() => { + if (!visible) return false; + onClose(); + return true; + }, [visible, onClose]); + + const handleSubmit = async () => { + if (!password) { + setError(t("password.enter_password")); + return; + } + + setIsLoading(true); + setError(null); + + try { + await onSubmit(password); + setPassword(""); + } catch { + setError(t("password.invalid_password")); + } finally { + setIsLoading(false); + } + }; + + if (!visible) return null; + + return ( + + + + + {/* Header */} + + {t("password.enter_password")} + + {t("password.enter_password_for", { username })} + + + + {/* Password Input */} + {isReady && ( + + + {t("login.password_placeholder")} + + { + setPassword(text); + setError(null); + }} + placeholder={t("login.password_placeholder")} + onSubmitEditing={handleSubmit} + hasTVPreferredFocus + /> + {error && {error}} + + )} + + {/* Submit Button */} + {isReady && ( + + + + )} + + + + + ); +}; + +const styles = StyleSheet.create({ + overlay: { + position: "absolute", + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: "rgba(0, 0, 0, 0.5)", + justifyContent: "flex-end", + zIndex: 1000, + }, + sheetContainer: { + width: "100%", + }, + blurContainer: { + borderTopLeftRadius: scaleSize(24), + borderTopRightRadius: scaleSize(24), + overflow: "hidden", + }, + content: { + paddingTop: scaleSize(24), + paddingBottom: scaleSize(50), + overflow: "visible", + }, + header: { + paddingHorizontal: scaleSize(48), + marginBottom: scaleSize(24), + }, + title: { + fontSize: scaleSize(28), + fontWeight: "bold", + color: "#fff", + marginBottom: scaleSize(4), + }, + subtitle: { + fontSize: scaleSize(16), + color: "rgba(255,255,255,0.6)", + }, + inputContainer: { + paddingHorizontal: scaleSize(48), + marginBottom: scaleSize(20), + }, + inputLabel: { + fontSize: scaleSize(14), + color: "rgba(255,255,255,0.6)", + marginBottom: scaleSize(8), + }, + errorText: { + color: "#ef4444", + fontSize: scaleSize(14), + marginTop: scaleSize(8), + }, + buttonContainer: { + paddingHorizontal: scaleSize(48), + alignItems: "flex-start", + }, +}); diff --git a/components/login/TVQRCodeDisplay.tsx b/components/login/TVQRCodeDisplay.tsx new file mode 100644 index 000000000..16cced246 --- /dev/null +++ b/components/login/TVQRCodeDisplay.tsx @@ -0,0 +1,115 @@ +import { t } from "i18next"; +import React, { useCallback } from "react"; +import { View } from "react-native"; +import QRCode from "react-native-qrcode-svg"; +import { Text } from "@/components/common/Text"; +import { useScaledTVTypography } from "@/constants/TVTypography"; +import { useTVBackPress } from "@/hooks/useTVBackPress"; +import { scaleSize } from "@/utils/scaleSize"; + +interface TVQRCodeDisplayProps { + code: string; + onBack?: () => void; +} + +export const TVQRCodeDisplay: React.FC = ({ + code, + onBack, +}) => { + const typography = useScaledTVTypography(); + + const qrSize = scaleSize(280); + const cardPadding = scaleSize(16); + const sectionPadding = scaleSize(32); + const outerPadding = scaleSize(60); + + const qrData = JSON.stringify({ + action: "streamyfin-pair", + code, + }); + + const handleBack = useCallback(() => { + if (!onBack) return false; + onBack(); + return true; + }, [onBack]); + + useTVBackPress(() => handleBack(), [handleBack]); + + return ( + + + {/* QR Code */} + + + {t("pairing.waiting_for_phone")} + + + + + + + + {code} + + + + {t("pairing.scan_with_phone")} + + + + + ); +}; diff --git a/components/login/TVSaveAccountModal.tsx b/components/login/TVSaveAccountModal.tsx new file mode 100644 index 000000000..dd224b5b9 --- /dev/null +++ b/components/login/TVSaveAccountModal.tsx @@ -0,0 +1,440 @@ +import { Ionicons } from "@expo/vector-icons"; +import { BlurView } from "expo-blur"; +import React, { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + Animated, + Easing, + Pressable, + ScrollView, + StyleSheet, + TVFocusGuideView, + View, +} from "react-native"; +import { Text } from "@/components/common/Text"; +import { TVPinInput, type TVPinInputRef } from "@/components/inputs/TVPinInput"; +import { TVOptionCard, useTVFocusAnimation } from "@/components/tv"; +import { scaleSize } from "@/utils/scaleSize"; +import type { AccountSecurityType } from "@/utils/secureCredentials"; + +interface TVSaveAccountModalProps { + visible: boolean; + onClose: () => void; + onSave: (securityType: AccountSecurityType, pinCode?: string) => void; + username: string; +} + +interface SecurityOption { + type: AccountSecurityType; + titleKey: string; + descriptionKey: string; + icon: keyof typeof Ionicons.glyphMap; +} + +const SECURITY_OPTIONS: SecurityOption[] = [ + { + type: "none", + titleKey: "save_account.no_protection", + descriptionKey: "save_account.no_protection_desc", + icon: "flash-outline", + }, + { + type: "pin", + titleKey: "save_account.pin_code", + descriptionKey: "save_account.pin_code_desc", + icon: "keypad-outline", + }, + { + type: "password", + titleKey: "save_account.password", + descriptionKey: "save_account.password_desc", + icon: "lock-closed-outline", + }, +]; + +// Custom Save Button with TV focus +const TVSaveButton: React.FC<{ + onPress: () => void; + label: string; + disabled?: boolean; + hasTVPreferredFocus?: boolean; +}> = ({ onPress, label, disabled = false, hasTVPreferredFocus = false }) => { + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation({ scaleAmount: 1.05, duration: 120 }); + + return ( + + + + + {label} + + + + ); +}; + +// Back Button for PIN step +const TVBackButton: React.FC<{ + onPress: () => void; + label: string; + hasTVPreferredFocus?: boolean; +}> = ({ onPress, label, hasTVPreferredFocus = false }) => { + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation({ scaleAmount: 1.05, duration: 120 }); + + return ( + + + + + {label} + + + + ); +}; + +export const TVSaveAccountModal: React.FC = ({ + visible, + onClose, + onSave, + username, +}) => { + const { t } = useTranslation(); + const [isReady, setIsReady] = useState(false); + const [step, setStep] = useState<"select" | "pin">("select"); + const [selectedType, setSelectedType] = useState("none"); + const [pinCode, setPinCode] = useState(""); + const [pinError, setPinError] = useState(null); + const pinInputRef = useRef(null); + + // Use useState for focus tracking (per TV focus guide) + const [firstCardRef, setFirstCardRef] = useState(null); + + const overlayOpacity = useRef(new Animated.Value(0)).current; + const sheetTranslateY = useRef(new Animated.Value(200)).current; + + useEffect(() => { + if (visible) { + // Reset state when opening + setStep("select"); + setSelectedType("none"); + setPinCode(""); + setPinError(null); + + overlayOpacity.setValue(0); + sheetTranslateY.setValue(200); + + Animated.parallel([ + Animated.timing(overlayOpacity, { + toValue: 1, + duration: 250, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }), + Animated.timing(sheetTranslateY, { + toValue: 0, + duration: 300, + easing: Easing.out(Easing.cubic), + useNativeDriver: true, + }), + ]).start(); + } + }, [visible, overlayOpacity, sheetTranslateY]); + + useEffect(() => { + if (visible) { + const timer = setTimeout(() => setIsReady(true), 100); + return () => clearTimeout(timer); + } + setIsReady(false); + }, [visible]); + + // Focus the first card when ready + useEffect(() => { + if (isReady && firstCardRef && step === "select") { + const timer = setTimeout(() => { + (firstCardRef as any)?.requestTVFocus?.(); + }, 50); + return () => clearTimeout(timer); + } + }, [isReady, firstCardRef, step]); + + useEffect(() => { + if (step === "pin" && isReady) { + const timer = setTimeout(() => { + pinInputRef.current?.focus(); + }, 150); + return () => clearTimeout(timer); + } + }, [step, isReady]); + + const handleOptionSelect = (type: AccountSecurityType) => { + setSelectedType(type); + if (type === "pin") { + setStep("pin"); + setPinCode(""); + setPinError(null); + } else { + // For "none" or "password", save immediately + onSave(type); + resetAndClose(); + } + }; + + const handlePinSave = () => { + if (pinCode.length !== 4) { + setPinError(t("pin.enter_4_digits")); + return; + } + onSave("pin", pinCode); + resetAndClose(); + }; + + const handleBack = () => { + setStep("select"); + setPinCode(""); + setPinError(null); + }; + + const resetAndClose = () => { + setStep("select"); + setSelectedType("none"); + setPinCode(""); + setPinError(null); + onClose(); + }; + + if (!visible) return null; + + return ( + + + + + {/* Header */} + + {t("save_account.title")} + {username} + + + {step === "select" ? ( + // Security selection step + <> + + {t("save_account.security_option")} + + {isReady && ( + + {SECURITY_OPTIONS.map((option, index) => ( + handleOptionSelect(option.type)} + width={220} + height={100} + /> + ))} + + )} + + ) : ( + // PIN entry step + <> + {t("pin.setup_pin")} + + { + setPinCode(text); + setPinError(null); + }} + length={4} + autoFocus + /> + {pinError && {pinError}} + + + {isReady && ( + + + + + )} + + )} + + + + + ); +}; + +const styles = StyleSheet.create({ + overlay: { + position: "absolute", + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: "rgba(0, 0, 0, 0.5)", + justifyContent: "flex-end", + zIndex: 1000, + }, + sheetContainer: { + width: "100%", + }, + blurContainer: { + borderTopLeftRadius: scaleSize(24), + borderTopRightRadius: scaleSize(24), + overflow: "hidden", + }, + content: { + paddingTop: scaleSize(24), + paddingBottom: scaleSize(50), + overflow: "visible", + }, + header: { + paddingHorizontal: scaleSize(48), + marginBottom: scaleSize(20), + }, + title: { + fontSize: scaleSize(28), + fontWeight: "bold", + color: "#fff", + marginBottom: scaleSize(4), + }, + subtitle: { + fontSize: scaleSize(16), + color: "rgba(255,255,255,0.6)", + }, + sectionTitle: { + fontSize: scaleSize(16), + fontWeight: "500", + color: "rgba(255,255,255,0.6)", + marginBottom: scaleSize(16), + paddingHorizontal: scaleSize(48), + textTransform: "uppercase", + letterSpacing: 1, + }, + scrollView: { + overflow: "visible", + }, + scrollContent: { + paddingHorizontal: scaleSize(48), + paddingVertical: scaleSize(10), + gap: scaleSize(12), + }, + buttonRow: { + marginTop: scaleSize(20), + paddingHorizontal: scaleSize(48), + flexDirection: "row", + gap: scaleSize(16), + alignItems: "center", + }, + pinContainer: { + paddingHorizontal: scaleSize(48), + alignItems: "center", + marginBottom: scaleSize(10), + }, + errorText: { + color: "#ef4444", + fontSize: scaleSize(14), + marginTop: scaleSize(12), + textAlign: "center", + }, +}); diff --git a/components/login/TVSaveAccountToggle.tsx b/components/login/TVSaveAccountToggle.tsx new file mode 100644 index 000000000..4591a8871 --- /dev/null +++ b/components/login/TVSaveAccountToggle.tsx @@ -0,0 +1,119 @@ +import React, { useRef, useState } from "react"; +import { Animated, Easing, Pressable, View } from "react-native"; +import { Text } from "@/components/common/Text"; +import { scaleSize } from "@/utils/scaleSize"; + +interface TVSaveAccountToggleProps { + value: boolean; + onValueChange: (value: boolean) => void; + label: string; + hasTVPreferredFocus?: boolean; + disabled?: boolean; +} + +export const TVSaveAccountToggle: React.FC = ({ + value, + onValueChange, + label, + hasTVPreferredFocus, + disabled = false, +}) => { + const [isFocused, setIsFocused] = useState(false); + const scale = useRef(new Animated.Value(1)).current; + const glowOpacity = useRef(new Animated.Value(0)).current; + + const animateFocus = (focused: boolean) => { + Animated.parallel([ + Animated.timing(scale, { + toValue: focused ? 1.02 : 1, + duration: 150, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }), + Animated.timing(glowOpacity, { + toValue: focused ? 0.6 : 0, + duration: 150, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }), + ]).start(); + }; + + const handleFocus = () => { + setIsFocused(true); + animateFocus(true); + }; + + const handleBlur = () => { + setIsFocused(false); + animateFocus(false); + }; + + return ( + onValueChange(!value)} + onFocus={handleFocus} + onBlur={handleBlur} + hasTVPreferredFocus={hasTVPreferredFocus && !disabled} + disabled={disabled} + focusable={!disabled} + > + + + + {label} + + + + + + + + ); +}; diff --git a/components/login/TVServerIcon.tsx b/components/login/TVServerIcon.tsx new file mode 100644 index 000000000..10d17171e --- /dev/null +++ b/components/login/TVServerIcon.tsx @@ -0,0 +1,212 @@ +import { LinearGradient } from "expo-linear-gradient"; +import React, { useMemo } from "react"; +import { Animated, Pressable, View } from "react-native"; +import { Text } from "@/components/common/Text"; +import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation"; +import { useScaledTVTypography } from "@/constants/TVTypography"; +import { scaleSize } from "@/utils/scaleSize"; + +// Sci-fi gradient color pairs (from, to) - cyberpunk/neon vibes +const SERVER_GRADIENTS: [string, string][] = [ + ["#00D4FF", "#0066FF"], // Cyan to Blue + ["#FF00E5", "#7B00FF"], // Magenta to Purple + ["#00FF94", "#00B4D8"], // Neon Green to Cyan + ["#FF6B35", "#F72585"], // Orange to Pink + ["#4CC9F0", "#7209B7"], // Sky Blue to Violet + ["#06D6A0", "#118AB2"], // Mint to Ocean Blue + ["#FFD60A", "#FF006E"], // Yellow to Hot Pink + ["#8338EC", "#3A86FF"], // Purple to Blue + ["#FB5607", "#FFBE0B"], // Orange to Gold + ["#00F5D4", "#00BBF9"], // Aqua to Azure + ["#F15BB5", "#9B5DE5"], // Pink to Lavender + ["#00C49A", "#00509D"], // Teal to Navy + ["#E63946", "#F4A261"], // Red to Peach + ["#2EC4B6", "#011627"], // Turquoise to Dark Blue + ["#FF0099", "#493240"], // Hot Pink to Plum + ["#11998E", "#38EF7D"], // Teal to Lime + ["#FC466B", "#3F5EFB"], // Pink to Indigo + ["#C471ED", "#12C2E9"], // Orchid to Sky + ["#F857A6", "#FF5858"], // Pink to Coral + ["#00B09B", "#96C93D"], // Emerald to Lime + ["#7F00FF", "#E100FF"], // Violet to Magenta + ["#1FA2FF", "#12D8FA"], // Blue to Cyan + ["#F09819", "#EDDE5D"], // Orange to Yellow + ["#FF416C", "#FF4B2B"], // Pink to Red Orange + ["#654EA3", "#EAAFC8"], // Purple to Rose + ["#00C6FF", "#0072FF"], // Light Blue to Blue + ["#F7971E", "#FFD200"], // Orange to Gold + ["#56AB2F", "#A8E063"], // Green to Lime + ["#DA22FF", "#9733EE"], // Magenta to Purple + ["#02AAB0", "#00CDAC"], // Teal variations + ["#ED213A", "#93291E"], // Red to Dark Red + ["#FDC830", "#F37335"], // Yellow to Orange + ["#00B4DB", "#0083B0"], // Ocean Blue + ["#C33764", "#1D2671"], // Berry to Navy + ["#E55D87", "#5FC3E4"], // Pink to Sky Blue + ["#403B4A", "#E7E9BB"], // Dark to Cream + ["#F2709C", "#FF9472"], // Rose to Peach + ["#1D976C", "#93F9B9"], // Forest to Mint + ["#CC2B5E", "#753A88"], // Crimson to Purple + ["#42275A", "#734B6D"], // Plum shades + ["#BDC3C7", "#2C3E50"], // Silver to Slate + ["#DE6262", "#FFB88C"], // Salmon to Apricot + ["#06BEB6", "#48B1BF"], // Teal shades + ["#EB3349", "#F45C43"], // Red to Orange Red + ["#DD5E89", "#F7BB97"], // Pink to Tan + ["#56CCF2", "#2F80ED"], // Sky to Blue + ["#007991", "#78FFD6"], // Deep Teal to Mint + ["#C6FFDD", "#FBD786"], // Mint to Yellow + ["#F953C6", "#B91D73"], // Pink to Magenta + ["#B24592", "#F15F79"], // Purple to Coral +]; + +// Generate a consistent gradient index based on URL (deterministic hash) +// Uses cyrb53 hash - fast and good distribution +const getGradientForString = (str: string): [string, string] => { + let h1 = 0xdeadbeef; + let h2 = 0x41c6ce57; + for (let i = 0; i < str.length; i++) { + const ch = str.charCodeAt(i); + h1 = Math.imul(h1 ^ ch, 2654435761); + h2 = Math.imul(h2 ^ ch, 1597334677); + } + h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507); + h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909); + h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507); + h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909); + const hash = 4294967296 * (2097151 & h2) + (h1 >>> 0); + const index = Math.abs(hash) % SERVER_GRADIENTS.length; + return SERVER_GRADIENTS[index]; +}; + +export interface TVServerIconProps { + name: string; + address: string; + onPress: () => void; + onLongPress?: () => void; + hasTVPreferredFocus?: boolean; + disabled?: boolean; +} + +export const TVServerIcon = React.forwardRef( + ( + { + name, + address, + onPress, + onLongPress, + hasTVPreferredFocus, + disabled = false, + }, + ref, + ) => { + const typography = useScaledTVTypography(); + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation(); + + // Get the first letter of the server name (or address if no name) + const displayName = name || address; + const initial = displayName.charAt(0).toUpperCase(); + + // Get a consistent gradient based on the server URL (deterministic) + // Use address as primary key, fallback to name + displayName for uniqueness + const hashKey = address || name || displayName; + const [gradientStart, gradientEnd] = useMemo( + () => getGradientForString(hashKey), + [hashKey], + ); + + return ( + + + + + + {initial} + + + + + + {displayName} + + + {name && ( + + {address.replace(/^https?:\/\//, "")} + + )} + + + ); + }, +); diff --git a/components/login/TVServerSelectionScreen.tsx b/components/login/TVServerSelectionScreen.tsx new file mode 100644 index 000000000..4e68eed50 --- /dev/null +++ b/components/login/TVServerSelectionScreen.tsx @@ -0,0 +1,138 @@ +import { Image } from "expo-image"; +import { t } from "i18next"; +import React, { useMemo } from "react"; +import { Alert, ScrollView, View } from "react-native"; +import { useMMKVString } from "react-native-mmkv"; +import { Text } from "@/components/common/Text"; +import { useScaledTVTypography } from "@/constants/TVTypography"; +import { scaleSize } from "@/utils/scaleSize"; +import type { SavedServer } from "@/utils/secureCredentials"; +import { TVAddIcon } from "./TVAddIcon"; +import { TVServerIcon } from "./TVServerIcon"; + +interface TVServerSelectionScreenProps { + onServerSelect: (server: SavedServer) => void; + onAddServer: () => void; + onDeleteServer: (server: SavedServer) => void; + disabled?: boolean; +} + +export const TVServerSelectionScreen: React.FC< + TVServerSelectionScreenProps +> = ({ onServerSelect, onAddServer, onDeleteServer, disabled = false }) => { + const typography = useScaledTVTypography(); + const [_previousServers] = useMMKVString("previousServers"); + + const previousServers = useMemo(() => { + try { + return JSON.parse(_previousServers || "[]") as SavedServer[]; + } catch { + return []; + } + }, [_previousServers]); + + const hasServers = previousServers.length > 0; + + const handleDeleteServer = (server: SavedServer) => { + Alert.alert( + t("server.remove_server"), + t("server.remove_server_description", { + server: server.name || server.address, + }), + [ + { text: t("common.cancel"), style: "cancel" }, + { + text: t("common.delete"), + style: "destructive", + onPress: () => onDeleteServer(server), + }, + ], + ); + }; + + return ( + + + {/* Logo */} + + + + + {/* Title */} + + Streamyfin + + + {hasServers + ? t("server.select_your_server") + : t("server.add_server_to_get_started")} + + + {/* Server Icons Grid */} + + {previousServers.map((server, index) => ( + onServerSelect(server)} + onLongPress={() => handleDeleteServer(server)} + hasTVPreferredFocus={index === 0} + disabled={disabled} + /> + ))} + + {/* Add Server Button */} + + + + + ); +}; diff --git a/components/login/TVUserIcon.tsx b/components/login/TVUserIcon.tsx new file mode 100644 index 000000000..60c8ec07f --- /dev/null +++ b/components/login/TVUserIcon.tsx @@ -0,0 +1,167 @@ +import { Ionicons } from "@expo/vector-icons"; +import { Image } from "expo-image"; +import React, { useState } from "react"; +import { Animated, Pressable, View } from "react-native"; +import { Text } from "@/components/common/Text"; +import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation"; +import { useScaledTVTypography } from "@/constants/TVTypography"; +import { getUserImageUrl } from "@/utils/jellyfin/image/getUserImageUrl"; +import { scaleSize } from "@/utils/scaleSize"; +import type { AccountSecurityType } from "@/utils/secureCredentials"; + +export interface TVUserIconProps { + username: string; + securityType: AccountSecurityType; + onPress: () => void; + hasTVPreferredFocus?: boolean; + disabled?: boolean; + serverAddress?: string; + userId?: string; + primaryImageTag?: string; +} + +export const TVUserIcon = React.forwardRef( + ( + { + username, + securityType, + onPress, + hasTVPreferredFocus, + disabled = false, + serverAddress, + userId, + primaryImageTag, + }, + ref, + ) => { + const typography = useScaledTVTypography(); + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation(); + const [imageError, setImageError] = useState(false); + + const getSecurityIcon = (): keyof typeof Ionicons.glyphMap => { + switch (securityType) { + case "pin": + return "keypad"; + case "password": + return "lock-closed"; + default: + return "key"; + } + }; + + const hasSecurityProtection = securityType !== "none"; + + const imageUrl = + serverAddress && userId && primaryImageTag && !imageError + ? getUserImageUrl({ + serverAddress, + userId, + primaryImageTag, + width: 280, + }) + : null; + + return ( + + + + + {imageUrl ? ( + setImageError(true)} + /> + ) : ( + + )} + + + {/* Security badge */} + {hasSecurityProtection && ( + + + + )} + + + + {username} + + + + ); + }, +); diff --git a/components/login/TVUserSelectionScreen.tsx b/components/login/TVUserSelectionScreen.tsx new file mode 100644 index 000000000..78fbd8927 --- /dev/null +++ b/components/login/TVUserSelectionScreen.tsx @@ -0,0 +1,143 @@ +import { t } from "i18next"; +import React, { useCallback } from "react"; +import { ScrollView, View } from "react-native"; +import { Text } from "@/components/common/Text"; +import { useScaledTVTypography } from "@/constants/TVTypography"; +import { useTVBackPress } from "@/hooks/useTVBackPress"; +import { scaleSize } from "@/utils/scaleSize"; +import type { + SavedServer, + SavedServerAccount, +} from "@/utils/secureCredentials"; +import { TVAddIcon } from "./TVAddIcon"; +import { TVBackIcon } from "./TVBackIcon"; +import { TVUserIcon } from "./TVUserIcon"; + +interface TVUserSelectionScreenProps { + server: SavedServer; + onUserSelect: (account: SavedServerAccount) => void; + onAddUser: () => void; + onChangeServer: () => void; + disabled?: boolean; +} + +export const TVUserSelectionScreen: React.FC = ({ + server, + onUserSelect, + onAddUser, + onChangeServer, + disabled = false, +}) => { + const typography = useScaledTVTypography(); + + const accounts = server.accounts || []; + const hasAccounts = accounts.length > 0; + + const handleBackPress = useCallback(() => { + if (disabled) return false; + onChangeServer(); + return true; + }, [disabled, onChangeServer]); + + useTVBackPress(handleBackPress, [handleBackPress]); + + return ( + + + {/* Server Info Header */} + + + {server.name || server.address} + + {server.name && ( + + {server.address.replace(/^https?:\/\//, "")} + + )} + + {hasAccounts + ? t("login.select_user") + : t("login.add_user_to_login")} + + + + {/* User Icons Grid with Back and Add buttons */} + + {/* Back/Change Server Button (left) */} + + + {/* User Icons */} + {accounts.map((account, index) => ( + onUserSelect(account)} + hasTVPreferredFocus={index === 0} + disabled={disabled} + serverAddress={server.address} + userId={account.userId} + primaryImageTag={account.primaryImageTag} + /> + ))} + + {/* Add User Button (right) */} + + + + + ); +}; diff --git a/components/medialists/MediaListSection.tsx b/components/medialists/MediaListSection.tsx index 2c11c5cee..0ada9dc58 100644 --- a/components/medialists/MediaListSection.tsx +++ b/components/medialists/MediaListSection.tsx @@ -9,7 +9,7 @@ import { useQuery, } from "@tanstack/react-query"; import { useAtom } from "jotai"; -import { useCallback } from "react"; +import { useCallback, useMemo } from "react"; import { View, type ViewProps } from "react-native"; import { useInView } from "@/hooks/useInView"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; @@ -67,6 +67,12 @@ export const MediaListSection: React.FC = ({ [api, user?.Id, collection?.Id], ); + const snapOffsets = useMemo(() => { + const itemWidth = 120; // w-28 (112px) + mr-2 (8px) + // Generate offsets for a reasonable number of items + return Array.from({ length: 50 }, (_, index) => index * itemWidth); + }, []); + if (!collection) return null; return ( @@ -92,6 +98,8 @@ export const MediaListSection: React.FC = ({ )} queryFn={fetchItems} queryKey={["media-list", collection.Id!]} + snapToOffsets={snapOffsets} + decelerationRate='fast' /> ); diff --git a/components/music/AnimatedEqualizer.tsx b/components/music/AnimatedEqualizer.tsx new file mode 100644 index 000000000..350929318 --- /dev/null +++ b/components/music/AnimatedEqualizer.tsx @@ -0,0 +1,98 @@ +import React, { useEffect } from "react"; +import { View } from "react-native"; +import Animated, { + Easing, + interpolate, + useAnimatedStyle, + useSharedValue, + withDelay, + withRepeat, + withTiming, +} from "react-native-reanimated"; + +interface Props { + color?: string; + barWidth?: number; + barCount?: number; + height?: number; + gap?: number; +} + +const MIN_SCALE = 0.35; +const MAX_SCALE = 1; +const DURATIONS = [800, 650, 750]; +const DELAYS = [0, 200, 100]; + +const Bar: React.FC<{ + color: string; + barWidth: number; + height: number; + duration: number; + delay: number; +}> = ({ color, barWidth, height, duration, delay }) => { + const progress = useSharedValue(0); + + useEffect(() => { + progress.value = withDelay( + delay, + withRepeat( + withTiming(1, { duration, easing: Easing.inOut(Easing.ease) }), + -1, + true, + ), + ); + }, []); + + const animatedStyle = useAnimatedStyle(() => ({ + transform: [ + { + scaleY: interpolate(progress.value, [0, 1], [MIN_SCALE, MAX_SCALE]), + }, + ], + })); + + return ( + + ); +}; + +export const AnimatedEqualizer: React.FC = ({ + color = "#9334E9", + barWidth = 3, + barCount = 3, + height = 12, + gap = 2, +}) => { + return ( + + {Array.from({ length: barCount }).map((_, index) => ( + + ))} + + ); +}; diff --git a/components/music/CreatePlaylistModal.tsx b/components/music/CreatePlaylistModal.tsx new file mode 100644 index 000000000..fea1421f7 --- /dev/null +++ b/components/music/CreatePlaylistModal.tsx @@ -0,0 +1,157 @@ +import { + BottomSheetBackdrop, + type BottomSheetBackdropProps, + BottomSheetModal, + BottomSheetTextInput, + BottomSheetView, +} from "@gorhom/bottom-sheet"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { useTranslation } from "react-i18next"; +import { ActivityIndicator, Keyboard } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Button } from "@/components/Button"; +import { Text } from "@/components/common/Text"; +import { useCreatePlaylist } from "@/hooks/usePlaylistMutations"; + +interface Props { + open: boolean; + setOpen: (open: boolean) => void; + onPlaylistCreated?: (playlistId: string) => void; + initialTrackId?: string; +} + +export const CreatePlaylistModal: React.FC = ({ + open, + setOpen, + onPlaylistCreated, + initialTrackId, +}) => { + const bottomSheetModalRef = useRef(null); + const insets = useSafeAreaInsets(); + const { t } = useTranslation(); + const createPlaylist = useCreatePlaylist(); + + const [name, setName] = useState(""); + const snapPoints = useMemo(() => ["40%"], []); + + useEffect(() => { + if (open) { + setName(""); + bottomSheetModalRef.current?.present(); + } else { + bottomSheetModalRef.current?.dismiss(); + } + }, [open]); + + const handleSheetChanges = useCallback( + (index: number) => { + if (index === -1) { + setOpen(false); + Keyboard.dismiss(); + } + }, + [setOpen], + ); + + const renderBackdrop = useCallback( + (props: BottomSheetBackdropProps) => ( + + ), + [], + ); + + const handleCreate = useCallback(async () => { + if (!name.trim()) return; + + const result = await createPlaylist.mutateAsync({ + name: name.trim(), + trackIds: initialTrackId ? [initialTrackId] : undefined, + }); + + if (result) { + onPlaylistCreated?.(result); + } + setOpen(false); + }, [name, createPlaylist, initialTrackId, onPlaylistCreated, setOpen]); + + const isValid = name.trim().length > 0; + + return ( + + + + {t("music.playlists.create_playlist")} + + + + {t("music.playlists.playlist_name")} + + + + + + + ); +}; diff --git a/components/music/MiniPlayerBar.tsx b/components/music/MiniPlayerBar.tsx new file mode 100644 index 000000000..c0ffd094d --- /dev/null +++ b/components/music/MiniPlayerBar.tsx @@ -0,0 +1,359 @@ +import { Ionicons } from "@expo/vector-icons"; +import { Image } from "expo-image"; +import { useAtom } from "jotai"; +import React, { useCallback, useMemo } from "react"; +import { + ActivityIndicator, + Platform, + StyleSheet, + TouchableOpacity, + View, +} from "react-native"; +import { Gesture, GestureDetector } from "react-native-gesture-handler"; +import { GlassEffectView } from "react-native-glass-effect-view"; +import Animated, { + Easing, + Extrapolation, + interpolate, + runOnJS, + useAnimatedStyle, + useSharedValue, + withTiming, +} from "react-native-reanimated"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Text } from "@/components/common/Text"; +import useRouter from "@/hooks/useAppRouter"; +import { apiAtom } from "@/providers/JellyfinProvider"; +import { useMusicPlayer } from "@/providers/MusicPlayerProvider"; + +const HORIZONTAL_MARGIN = Platform.OS === "android" ? 12 : 20; +const BOTTOM_TAB_HEIGHT = Platform.OS === "android" ? 56 : 52; +const BAR_HEIGHT = Platform.OS === "android" ? 58 : 50; + +// Gesture thresholds +const VELOCITY_THRESHOLD = 1000; + +// Logarithmic slowdown - never stops, just gets progressively slower +const rubberBand = (distance: number, scale: number = 8): number => { + "worklet"; + const absDistance = Math.abs(distance); + const sign = distance < 0 ? -1 : 1; + // Logarithmic: keeps growing but slower and slower + return sign * scale * Math.log(1 + absDistance / scale); +}; + +export const MiniPlayerBar: React.FC = () => { + const [api] = useAtom(apiAtom); + const insets = useSafeAreaInsets(); + const router = useRouter(); + const { + currentTrack, + isPlaying, + isLoading, + progress, + duration, + togglePlayPause, + next, + stop, + } = useMusicPlayer(); + + // Gesture state + const translateY = useSharedValue(0); + + const imageUrl = useMemo(() => { + if (!api || !currentTrack) return null; + const albumId = currentTrack.AlbumId || currentTrack.ParentId; + if (albumId) { + return `${api.basePath}/Items/${albumId}/Images/Primary?maxHeight=100&maxWidth=100`; + } + return `${api.basePath}/Items/${currentTrack.Id}/Images/Primary?maxHeight=100&maxWidth=100`; + }, [api, currentTrack]); + + const _progressPercentage = useMemo(() => { + if (!duration || duration === 0) return 0; + return (progress / duration) * 100; + }, [progress, duration]); + + const handlePress = useCallback(() => { + router.push("/(auth)/now-playing"); + }, [router]); + + const handlePlayPause = useCallback( + (e: any) => { + e.stopPropagation(); + togglePlayPause(); + }, + [togglePlayPause], + ); + + const handleNext = useCallback( + (e: any) => { + e.stopPropagation(); + next(); + }, + [next], + ); + + const handleDismiss = useCallback(() => { + stop(); + }, [stop]); + + // Pan gesture for swipe up (open modal) and swipe down (dismiss) + const panGesture = Gesture.Pan() + .activeOffsetY([-15, 15]) + .onUpdate((event) => { + // Logarithmic slowdown - keeps moving but progressively slower + translateY.value = rubberBand(event.translationY, 6); + }) + .onEnd((event) => { + const velocity = event.velocityY; + const currentPosition = translateY.value; + + // Swipe up - open modal (check position OR velocity) + if (currentPosition < -16 || velocity < -VELOCITY_THRESHOLD) { + // Slow return animation - won't jank with navigation + translateY.value = withTiming(0, { + duration: 600, + easing: Easing.out(Easing.cubic), + }); + runOnJS(handlePress)(); + return; + } + // Swipe down - stop playback and dismiss (check position OR velocity) + if (currentPosition > 16 || velocity > VELOCITY_THRESHOLD) { + // No need to reset - component will unmount + runOnJS(handleDismiss)(); + return; + } + + // Only animate back if no action was triggered + translateY.value = withTiming(0, { + duration: 200, + easing: Easing.out(Easing.cubic), + }); + }); + + // Animated styles for the container + const animatedContainerStyle = useAnimatedStyle(() => ({ + transform: [{ translateY: translateY.value }], + })); + + // Animated styles for the inner bar + const animatedBarStyle = useAnimatedStyle(() => ({ + height: interpolate( + translateY.value, + [-50, 0, 50], + [BAR_HEIGHT + 12, BAR_HEIGHT, BAR_HEIGHT], + Extrapolation.EXTEND, + ), + opacity: interpolate( + translateY.value, + [0, 30], + [1, 0.6], + Extrapolation.CLAMP, + ), + })); + + if (!currentTrack) return null; + + const content = ( + <> + {/* Tappable area: Album art + Track info */} + + {/* Album art */} + + {imageUrl ? ( + + ) : ( + + + + )} + + + {/* Track info */} + + + {currentTrack.Name} + + + {currentTrack.Artists?.join(", ") || currentTrack.AlbumArtist} + + + + + {/* Controls */} + + {isLoading ? ( + + ) : ( + <> + + + + + + + + )} + + + {/* Progress bar at bottom */} + {/* + + */} + + ); + + return ( + + + + {Platform.OS === "ios" && !Platform.isTV ? ( + + + {content} + + + ) : ( + {content} + )} + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + position: "absolute", + left: HORIZONTAL_MARGIN, + right: HORIZONTAL_MARGIN, + shadowColor: "#000", + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.3, + shadowRadius: 8, + elevation: 8, + }, + touchable: { + borderRadius: 50, + overflow: "hidden", + }, + blurContainer: { + flex: 1, + }, + androidContainer: { + flex: 1, + flexDirection: "row", + alignItems: "center", + paddingHorizontal: 10, + paddingVertical: 8, + backgroundColor: "rgba(28, 28, 30, 0.97)", + borderRadius: 14, + borderWidth: 0.5, + borderColor: "rgba(255, 255, 255, 0.1)", + }, + tappableArea: { + flex: 1, + flexDirection: "row", + alignItems: "center", + }, + albumArt: { + width: 32, + height: 32, + borderRadius: 8, + overflow: "hidden", + backgroundColor: "#333", + }, + albumImage: { + width: "100%", + height: "100%", + }, + albumPlaceholder: { + flex: 1, + alignItems: "center", + justifyContent: "center", + backgroundColor: "#2a2a2a", + }, + trackInfo: { + flex: 1, + marginLeft: 12, + marginRight: 8, + justifyContent: "center", + }, + trackTitle: { + color: "white", + fontSize: 14, + fontWeight: "600", + }, + artistName: { + color: "rgba(255, 255, 255, 0.6)", + fontSize: 12, + }, + controls: { + flexDirection: "row", + alignItems: "center", + }, + controlButton: { + padding: 8, + }, + loader: { + marginHorizontal: 16, + }, + progressContainer: { + position: "absolute", + bottom: 0, + left: 10, + right: 10, + height: 3, + backgroundColor: "rgba(255, 255, 255, 0.15)", + borderRadius: 1.5, + }, + progressFill: { + height: "100%", + backgroundColor: "white", + borderRadius: 1.5, + }, +}); diff --git a/components/music/MusicAlbumCard.tsx b/components/music/MusicAlbumCard.tsx new file mode 100644 index 000000000..1a747c8ec --- /dev/null +++ b/components/music/MusicAlbumCard.tsx @@ -0,0 +1,68 @@ +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import { Image } from "expo-image"; +import { useAtom } from "jotai"; +import React, { useCallback, useMemo } from "react"; +import { TouchableOpacity, View } from "react-native"; +import { Text } from "@/components/common/Text"; +import useRouter from "@/hooks/useAppRouter"; +import { apiAtom } from "@/providers/JellyfinProvider"; +import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; + +interface Props { + album: BaseItemDto; + width?: number; +} + +export const MusicAlbumCard: React.FC = ({ album, width = 130 }) => { + const [api] = useAtom(apiAtom); + const router = useRouter(); + + const imageUrl = useMemo( + () => getPrimaryImageUrl({ api, item: album }), + [api, album], + ); + + const handlePress = useCallback(() => { + router.push({ + pathname: "/music/album/[albumId]", + params: { albumId: album.Id! }, + }); + }, [router, album.Id]); + + return ( + + + {imageUrl ? ( + + ) : ( + + 🎵 + + )} + + + {album.Name} + + + {album.AlbumArtist || album.Artists?.join(", ")} + + + ); +}; diff --git a/components/music/MusicAlbumRowCard.tsx b/components/music/MusicAlbumRowCard.tsx new file mode 100644 index 000000000..6612a3922 --- /dev/null +++ b/components/music/MusicAlbumRowCard.tsx @@ -0,0 +1,70 @@ +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import { Image } from "expo-image"; +import { useAtom } from "jotai"; +import React, { useCallback, useMemo } from "react"; +import { TouchableOpacity, View } from "react-native"; +import { Text } from "@/components/common/Text"; +import useRouter from "@/hooks/useAppRouter"; +import { apiAtom } from "@/providers/JellyfinProvider"; +import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; + +interface Props { + album: BaseItemDto; +} + +const IMAGE_SIZE = 56; + +export const MusicAlbumRowCard: React.FC = ({ album }) => { + const [api] = useAtom(apiAtom); + const router = useRouter(); + + const imageUrl = useMemo( + () => getPrimaryImageUrl({ api, item: album }), + [api, album], + ); + + const handlePress = useCallback(() => { + router.push({ + pathname: "/music/album/[albumId]", + params: { albumId: album.Id! }, + }); + }, [router, album.Id]); + + return ( + + + {imageUrl ? ( + + ) : ( + + 🎵 + + )} + + + + {album.Name} + + + {album.AlbumArtist || album.Artists?.join(", ")} + + + + ); +}; diff --git a/components/music/MusicArtistCard.tsx b/components/music/MusicArtistCard.tsx new file mode 100644 index 000000000..c98e3fce9 --- /dev/null +++ b/components/music/MusicArtistCard.tsx @@ -0,0 +1,69 @@ +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import { Image } from "expo-image"; +import { useAtom } from "jotai"; +import React, { useCallback, useMemo } from "react"; +import { TouchableOpacity, View } from "react-native"; +import { Text } from "@/components/common/Text"; +import useRouter from "@/hooks/useAppRouter"; +import { apiAtom } from "@/providers/JellyfinProvider"; +import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; + +interface Props { + artist: BaseItemDto; + size?: number; +} + +const IMAGE_SIZE = 48; + +export const MusicArtistCard: React.FC = ({ artist }) => { + const [api] = useAtom(apiAtom); + const router = useRouter(); + + const imageUrl = useMemo( + () => getPrimaryImageUrl({ api, item: artist }), + [api, artist], + ); + + const handlePress = useCallback(() => { + router.push({ + pathname: "/music/artist/[artistId]", + params: { artistId: artist.Id! }, + }); + }, [router, artist.Id]); + + return ( + + + {imageUrl ? ( + + ) : ( + + 👤 + + )} + + + {artist.Name} + + + ); +}; diff --git a/components/music/MusicPlaybackEngine.tsx b/components/music/MusicPlaybackEngine.tsx new file mode 100644 index 000000000..ae1b07cdf --- /dev/null +++ b/components/music/MusicPlaybackEngine.tsx @@ -0,0 +1,222 @@ +import { useEffect, useRef } from "react"; +import TrackPlayer, { + Event, + type PlaybackActiveTrackChangedEvent, + State, + useActiveTrack, + usePlaybackState, + useProgress, +} from "react-native-track-player"; +import { + audioStorageEvents, + deleteTrack, + getLocalPath, +} from "@/providers/AudioStorage"; +import { useMusicPlayer } from "@/providers/MusicPlayerProvider"; + +export const MusicPlaybackEngine: React.FC = () => { + const { position, duration } = useProgress(1000); + const playbackState = usePlaybackState(); + const activeTrack = useActiveTrack(); + const { + setProgress, + setDuration, + setIsPlaying, + reportProgress, + onTrackEnd, + syncFromTrackPlayer, + triggerLookahead, + } = useMusicPlayer(); + + const lastReportedProgressRef = useRef(0); + + // Sync progress from TrackPlayer to our state + useEffect(() => { + if (position > 0) { + setProgress(position); + } + }, [position, setProgress]); + + // Sync duration from TrackPlayer to our state + useEffect(() => { + if (duration > 0) { + setDuration(duration); + } + }, [duration, setDuration]); + + // Sync playback state from TrackPlayer to our state + useEffect(() => { + const isPlaying = playbackState.state === State.Playing; + setIsPlaying(isPlaying); + }, [playbackState.state, setIsPlaying]); + + // Sync active track changes + useEffect(() => { + if (activeTrack) { + syncFromTrackPlayer(); + } + }, [activeTrack?.id, syncFromTrackPlayer]); + + // Report progress every ~10 seconds + useEffect(() => { + if ( + Math.floor(position) - Math.floor(lastReportedProgressRef.current) >= + 10 + ) { + lastReportedProgressRef.current = position; + reportProgress(); + } + }, [position, reportProgress]); + + // Listen for track changes (native -> JS) + // This triggers look-ahead caching, checks for cached versions, and handles track end + useEffect(() => { + const subscription = + TrackPlayer.addEventListener( + Event.PlaybackActiveTrackChanged, + async (event) => { + // Trigger look-ahead caching when a new track starts playing + if (event.track) { + triggerLookahead(); + + // Check if there's a cached version we should use instead + const trackId = event.track.id; + const currentUrl = event.track.url as string; + + // Only check if currently using a remote URL + if (trackId && currentUrl && !currentUrl.startsWith("file://")) { + const cachedPath = getLocalPath(trackId); + if (cachedPath) { + console.log( + `[AudioCache] Switching to cached version for ${trackId}`, + ); + try { + // Load the cached version, preserving position if any + const currentIndex = await TrackPlayer.getActiveTrackIndex(); + if (currentIndex !== undefined && currentIndex >= 0) { + const queue = await TrackPlayer.getQueue(); + const track = queue[currentIndex]; + // Remove and re-add with cached URL + await TrackPlayer.remove(currentIndex); + await TrackPlayer.add( + { ...track, url: cachedPath }, + currentIndex, + ); + await TrackPlayer.skip(currentIndex); + await TrackPlayer.play(); + } + } catch (error) { + console.warn( + "[AudioCache] Failed to switch to cached version:", + error, + ); + } + } + } + } + + // If there's no next track and the previous track ended, call onTrackEnd + if (event.lastTrack && !event.track) { + onTrackEnd(); + } + }, + ); + + return () => subscription.remove(); + }, [onTrackEnd, triggerLookahead]); + + // Listen for audio cache download completion and update queue URLs + useEffect(() => { + const onComplete = async ({ + itemId, + localPath, + }: { + itemId: string; + localPath: string; + }) => { + console.log(`[AudioCache] Track ${itemId} cached successfully`); + + try { + const queue = await TrackPlayer.getQueue(); + const currentIndex = await TrackPlayer.getActiveTrackIndex(); + + // Find the track in the queue + const trackIndex = queue.findIndex((t) => t.id === itemId); + + // Only update if track is in queue and not currently playing + if (trackIndex >= 0 && trackIndex !== currentIndex) { + const track = queue[trackIndex]; + const localUrl = localPath.startsWith("file://") + ? localPath + : `file://${localPath}`; + + // Skip if already using local URL + if (track.url === localUrl) return; + + console.log( + `[AudioCache] Updating queue track ${trackIndex} to use cached file`, + ); + + // Remove old track and insert updated one at same position + await TrackPlayer.remove(trackIndex); + await TrackPlayer.add({ ...track, url: localUrl }, trackIndex); + } + } catch (error) { + console.warn("[AudioCache] Failed to update queue:", error); + } + }; + + audioStorageEvents.on("complete", onComplete); + return () => { + audioStorageEvents.off("complete", onComplete); + }; + }, []); + + // Listen for playback errors (corrupted cache files) + useEffect(() => { + const subscription = TrackPlayer.addEventListener( + Event.PlaybackError, + async (event) => { + const activeTrack = await TrackPlayer.getActiveTrack(); + if (!activeTrack?.url) return; + + // Only handle local file errors + const url = activeTrack.url as string; + if (!url.startsWith("file://")) return; + + console.warn( + `[MusicPlayer] Playback error for cached file: ${activeTrack.id}`, + event, + ); + + // Delete corrupted cache file + if (activeTrack.id) { + try { + await deleteTrack(activeTrack.id); + console.log( + `[MusicPlayer] Deleted corrupted cache file: ${activeTrack.id}`, + ); + } catch (error) { + console.warn( + "[MusicPlayer] Failed to delete corrupted file:", + error, + ); + } + } + + // Skip to next track + try { + await TrackPlayer.skipToNext(); + } catch { + // No next track available, stop playback + await TrackPlayer.stop(); + } + }, + ); + + return () => subscription.remove(); + }, []); + + // No visual component needed - TrackPlayer is headless + return null; +}; diff --git a/components/music/MusicPlaylistCard.tsx b/components/music/MusicPlaylistCard.tsx new file mode 100644 index 000000000..93caf7a44 --- /dev/null +++ b/components/music/MusicPlaylistCard.tsx @@ -0,0 +1,120 @@ +import { Ionicons } from "@expo/vector-icons"; +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import { getItemsApi } from "@jellyfin/sdk/lib/utils/api"; +import { useQuery } from "@tanstack/react-query"; +import { Image } from "expo-image"; +import { useAtom } from "jotai"; +import React, { useCallback, useMemo } from "react"; +import { TouchableOpacity, View } from "react-native"; +import { Text } from "@/components/common/Text"; +import useRouter from "@/hooks/useAppRouter"; +import { getLocalPath } from "@/providers/AudioStorage"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; + +interface Props { + playlist: BaseItemDto; + width?: number; +} + +const IMAGE_SIZE = 56; + +export const MusicPlaylistCard: React.FC = ({ playlist }) => { + const [api] = useAtom(apiAtom); + const [user] = useAtom(userAtom); + const router = useRouter(); + + const imageUrl = useMemo( + () => getPrimaryImageUrl({ api, item: playlist }), + [api, playlist], + ); + + // Fetch playlist tracks to check download status + const { data: tracks } = useQuery({ + queryKey: ["playlist-tracks-status", playlist.Id, user?.Id], + queryFn: async () => { + const response = await getItemsApi(api!).getItems({ + userId: user?.Id, + parentId: playlist.Id, + fields: ["MediaSources"], + }); + return response.data.Items || []; + }, + enabled: !!api && !!user?.Id && !!playlist.Id, + staleTime: 5 * 60 * 1000, // 5 minutes + }); + + // Calculate download status + const downloadStatus = useMemo(() => { + if (!tracks || tracks.length === 0) { + return { downloaded: 0, total: playlist.ChildCount || 0 }; + } + const downloaded = tracks.filter( + (track) => !!getLocalPath(track.Id), + ).length; + return { downloaded, total: tracks.length }; + }, [tracks, playlist.ChildCount]); + + const allDownloaded = + downloadStatus.total > 0 && + downloadStatus.downloaded === downloadStatus.total; + const hasDownloads = downloadStatus.downloaded > 0; + + const handlePress = useCallback(() => { + router.push({ + pathname: "/music/playlist/[playlistId]", + params: { playlistId: playlist.Id! }, + }); + }, [router, playlist.Id]); + + return ( + + + {imageUrl ? ( + + ) : ( + + 🎶 + + )} + + + + {playlist.Name} + + + {playlist.ChildCount} tracks + + + {/* Download status indicator */} + {allDownloaded ? ( + + ) : hasDownloads ? ( + + {downloadStatus.downloaded}/{downloadStatus.total} + + ) : null} + + ); +}; diff --git a/components/music/MusicTrackItem.tsx b/components/music/MusicTrackItem.tsx new file mode 100644 index 000000000..a3f80efce --- /dev/null +++ b/components/music/MusicTrackItem.tsx @@ -0,0 +1,226 @@ +import { Ionicons } from "@expo/vector-icons"; +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import { Image } from "expo-image"; +import { useAtom } from "jotai"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { ActivityIndicator, TouchableOpacity, View } from "react-native"; +import { Text } from "@/components/common/Text"; +import { AnimatedEqualizer } from "@/components/music/AnimatedEqualizer"; +import { useHaptic } from "@/hooks/useHaptic"; +import { useNetworkStatus } from "@/hooks/useNetworkStatus"; +import { + audioStorageEvents, + getLocalPath, + isCached, + isPermanentDownloading, + isPermanentlyDownloaded, +} from "@/providers/AudioStorage"; +import { apiAtom } from "@/providers/JellyfinProvider"; +import { useMusicPlayer } from "@/providers/MusicPlayerProvider"; +import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; +import { formatDuration } from "@/utils/time"; + +interface Props { + track: BaseItemDto; + index?: number; + queue?: BaseItemDto[]; + showArtwork?: boolean; + onOptionsPress?: (track: BaseItemDto) => void; +} + +export const MusicTrackItem: React.FC = ({ + track, + index: _index, + queue, + showArtwork = true, + onOptionsPress, +}) => { + const [api] = useAtom(apiAtom); + const { playTrack, currentTrack, isPlaying, loadingTrackId } = + useMusicPlayer(); + const { isConnected, serverConnected } = useNetworkStatus(); + const haptic = useHaptic("light"); + + const imageUrl = useMemo(() => { + const albumId = track.AlbumId || track.ParentId; + if (albumId) { + return `${api?.basePath}/Items/${albumId}/Images/Primary?maxHeight=100&maxWidth=100`; + } + return getPrimaryImageUrl({ api, item: track }); + }, [api, track]); + + const isCurrentTrack = currentTrack?.Id === track.Id; + const isTrackLoading = loadingTrackId === track.Id; + + // Track download status with reactivity to completion events + const [downloadStatus, setDownloadStatus] = useState< + "none" | "downloading" | "downloaded" | "cached" + >(() => { + if (isPermanentlyDownloaded(track.Id)) return "downloaded"; + if (isPermanentDownloading(track.Id)) return "downloading"; + if (isCached(track.Id)) return "cached"; + return "none"; + }); + + // Listen for download completion/error events + useEffect(() => { + const onComplete = (event: { itemId: string; permanent: boolean }) => { + if (event.itemId === track.Id) { + setDownloadStatus(event.permanent ? "downloaded" : "cached"); + } + }; + const onError = (event: { itemId: string }) => { + if (event.itemId === track.Id) { + setDownloadStatus("none"); + } + }; + + audioStorageEvents.on("complete", onComplete); + audioStorageEvents.on("error", onError); + + return () => { + audioStorageEvents.off("complete", onComplete); + audioStorageEvents.off("error", onError); + }; + }, [track.Id]); + + // Re-check status when track changes (for list item recycling) + useEffect(() => { + if (isPermanentlyDownloaded(track.Id)) { + setDownloadStatus("downloaded"); + } else if (isPermanentDownloading(track.Id)) { + setDownloadStatus("downloading"); + } else if (isCached(track.Id)) { + setDownloadStatus("cached"); + } else { + setDownloadStatus("none"); + } + }, [track.Id]); + + const _isDownloaded = downloadStatus === "downloaded"; + // Check if available locally (either cached or permanently downloaded) + const isAvailableLocally = !!getLocalPath(track.Id); + // Consider offline if either no network connection OR server is unreachable + const isOffline = !isConnected || serverConnected === false; + const isUnavailableOffline = isOffline && !isAvailableLocally; + + const duration = useMemo(() => { + if (!track.RunTimeTicks) return ""; + return formatDuration(track.RunTimeTicks); + }, [track.RunTimeTicks]); + + const handlePress = useCallback(() => { + if (isUnavailableOffline) return; + playTrack(track, queue); + }, [playTrack, track, queue, isUnavailableOffline]); + + const handleLongPress = useCallback(() => { + onOptionsPress?.(track); + }, [onOptionsPress, track]); + + const handleOptionsPress = useCallback(() => { + haptic(); + onOptionsPress?.(track); + }, [haptic, onOptionsPress, track]); + + return ( + + {/* Album artwork */} + {showArtwork && ( + + {imageUrl ? ( + + ) : ( + + + + )} + {isTrackLoading && ( + + + + )} + + )} + + {/* Track info */} + + + {isCurrentTrack && isPlaying && } + + {track.Name} + + + + {track.Artists?.join(", ") || track.AlbumArtist} + + + + {/* Download/cache status indicator */} + {downloadStatus === "downloading" && ( + + )} + {downloadStatus === "downloaded" && ( + + )} + + {/* Duration */} + {duration} + + {/* Options button */} + {onOptionsPress && ( + + + + )} + + ); +}; diff --git a/components/music/PlaylistOptionsSheet.tsx b/components/music/PlaylistOptionsSheet.tsx new file mode 100644 index 000000000..ab2b3a24d --- /dev/null +++ b/components/music/PlaylistOptionsSheet.tsx @@ -0,0 +1,136 @@ +import { Ionicons } from "@expo/vector-icons"; +import { + BottomSheetBackdrop, + type BottomSheetBackdropProps, + BottomSheetModal, + BottomSheetView, +} from "@gorhom/bottom-sheet"; +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import React, { useCallback, useEffect, useMemo, useRef } from "react"; +import { useTranslation } from "react-i18next"; +import { Alert, StyleSheet, TouchableOpacity, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Text } from "@/components/common/Text"; +import useRouter from "@/hooks/useAppRouter"; +import { useDeletePlaylist } from "@/hooks/usePlaylistMutations"; + +interface Props { + open: boolean; + setOpen: (open: boolean) => void; + playlist: BaseItemDto | null; +} + +export const PlaylistOptionsSheet: React.FC = ({ + open, + setOpen, + playlist, +}) => { + const bottomSheetModalRef = useRef(null); + const router = useRouter(); + const insets = useSafeAreaInsets(); + const { t } = useTranslation(); + const deletePlaylist = useDeletePlaylist(); + + const snapPoints = useMemo(() => ["25%"], []); + + useEffect(() => { + if (open) bottomSheetModalRef.current?.present(); + else bottomSheetModalRef.current?.dismiss(); + }, [open]); + + const handleSheetChanges = useCallback( + (index: number) => { + if (index === -1) { + setOpen(false); + } + }, + [setOpen], + ); + + const renderBackdrop = useCallback( + (props: BottomSheetBackdropProps) => ( + + ), + [], + ); + + const handleDeletePlaylist = useCallback(() => { + if (!playlist?.Id) return; + + Alert.alert( + t("music.playlists.delete_playlist"), + t("music.playlists.delete_confirm", { name: playlist.Name }), + [ + { + text: t("common.cancel"), + style: "cancel", + }, + { + text: t("common.delete"), + style: "destructive", + onPress: () => { + deletePlaylist.mutate( + { playlistId: playlist.Id! }, + { + onSuccess: () => { + setOpen(false); + router.back(); + }, + }, + ); + }, + }, + ], + ); + }, [playlist, deletePlaylist, setOpen, router, t]); + + if (!playlist) return null; + + return ( + + + + + + + {t("music.playlists.delete_playlist")} + + + + + + ); +}; + +const _styles = StyleSheet.create({ + separator: { + height: StyleSheet.hairlineWidth, + backgroundColor: "#404040", + }, +}); diff --git a/components/music/PlaylistPickerSheet.tsx b/components/music/PlaylistPickerSheet.tsx new file mode 100644 index 000000000..6cd7dc1b9 --- /dev/null +++ b/components/music/PlaylistPickerSheet.tsx @@ -0,0 +1,262 @@ +import { Ionicons } from "@expo/vector-icons"; +import { + BottomSheetBackdrop, + type BottomSheetBackdropProps, + BottomSheetModal, + BottomSheetScrollView, +} from "@gorhom/bottom-sheet"; +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import { getItemsApi } from "@jellyfin/sdk/lib/utils/api"; +import { useQuery } from "@tanstack/react-query"; +import { Image } from "expo-image"; +import { useAtom } from "jotai"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { useTranslation } from "react-i18next"; +import { + ActivityIndicator, + StyleSheet, + TouchableOpacity, + View, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Input } from "@/components/common/Input"; +import { Text } from "@/components/common/Text"; +import { useAddToPlaylist } from "@/hooks/usePlaylistMutations"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; + +interface Props { + open: boolean; + setOpen: (open: boolean) => void; + trackToAdd: BaseItemDto | null; + onCreateNew: () => void; +} + +export const PlaylistPickerSheet: React.FC = ({ + open, + setOpen, + trackToAdd, + onCreateNew, +}) => { + const bottomSheetModalRef = useRef(null); + const [api] = useAtom(apiAtom); + const [user] = useAtom(userAtom); + const insets = useSafeAreaInsets(); + const { t } = useTranslation(); + const addToPlaylist = useAddToPlaylist(); + + const [search, setSearch] = useState(""); + const snapPoints = useMemo(() => ["75%"], []); + + // Fetch all playlists + const { data: playlists, isLoading } = useQuery({ + queryKey: ["music-playlists-picker", user?.Id], + queryFn: async () => { + if (!api || !user?.Id) return []; + + const response = await getItemsApi(api).getItems({ + userId: user.Id, + includeItemTypes: ["Playlist"], + sortBy: ["SortName"], + sortOrder: ["Ascending"], + recursive: true, + mediaTypes: ["Audio"], + }); + + return response.data.Items || []; + }, + enabled: Boolean(api && user?.Id && open), + }); + + const filteredPlaylists = useMemo(() => { + if (!playlists) return []; + if (!search) return playlists; + return playlists.filter((playlist) => + playlist.Name?.toLowerCase().includes(search.toLowerCase()), + ); + }, [playlists, search]); + + const showSearch = (playlists?.length || 0) > 10; + + useEffect(() => { + if (open) { + setSearch(""); + bottomSheetModalRef.current?.present(); + } else { + bottomSheetModalRef.current?.dismiss(); + } + }, [open]); + + const handleSheetChanges = useCallback( + (index: number) => { + if (index === -1) { + setOpen(false); + } + }, + [setOpen], + ); + + const renderBackdrop = useCallback( + (props: BottomSheetBackdropProps) => ( + + ), + [], + ); + + const handleSelectPlaylist = useCallback( + async (playlist: BaseItemDto) => { + if (!trackToAdd?.Id || !playlist.Id) return; + + await addToPlaylist.mutateAsync({ + playlistId: playlist.Id, + trackIds: [trackToAdd.Id], + playlistName: playlist.Name || undefined, + }); + + setOpen(false); + }, + [trackToAdd, addToPlaylist, setOpen], + ); + + const handleCreateNew = useCallback(() => { + setOpen(false); + setTimeout(() => { + onCreateNew(); + }, 300); + }, [onCreateNew, setOpen]); + + const getPlaylistImageUrl = useCallback( + (playlist: BaseItemDto) => { + if (!api) return null; + return `${api.basePath}/Items/${playlist.Id}/Images/Primary?maxHeight=100&maxWidth=100`; + }, + [api], + ); + + return ( + + + + {t("music.track_options.add_to_playlist")} + + {trackToAdd?.Name} + + {showSearch && ( + + )} + + {/* Create New Playlist Button */} + + + + + + {t("music.playlists.create_new")} + + + + {isLoading ? ( + + + + ) : filteredPlaylists.length === 0 ? ( + + + {search ? t("search.no_results") : t("music.no_playlists")} + + + ) : ( + + {filteredPlaylists.map((playlist, index) => ( + + handleSelectPlaylist(playlist)} + className='flex-row items-center px-4 py-3' + disabled={addToPlaylist.isPending} + > + + + + + + {playlist.Name} + + + {playlist.ChildCount} {t("music.tabs.tracks")} + + + {addToPlaylist.isPending && ( + + )} + + {index < filteredPlaylists.length - 1 && ( + + )} + + ))} + + )} + + + ); +}; + +const styles = StyleSheet.create({ + separator: { + height: StyleSheet.hairlineWidth, + backgroundColor: "#404040", + }, +}); diff --git a/components/music/PlaylistSortSheet.tsx b/components/music/PlaylistSortSheet.tsx new file mode 100644 index 000000000..07c8467cf --- /dev/null +++ b/components/music/PlaylistSortSheet.tsx @@ -0,0 +1,173 @@ +import { Ionicons } from "@expo/vector-icons"; +import { + BottomSheetBackdrop, + type BottomSheetBackdropProps, + BottomSheetModal, + BottomSheetView, +} from "@gorhom/bottom-sheet"; +import React, { useCallback, useEffect, useMemo, useRef } from "react"; +import { useTranslation } from "react-i18next"; +import { StyleSheet, TouchableOpacity, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Text } from "@/components/common/Text"; + +export type PlaylistSortOption = "SortName" | "DateCreated"; + +export type PlaylistSortOrder = "Ascending" | "Descending"; + +interface Props { + open: boolean; + setOpen: (open: boolean) => void; + sortBy: PlaylistSortOption; + sortOrder: PlaylistSortOrder; + onSortChange: ( + sortBy: PlaylistSortOption, + sortOrder: PlaylistSortOrder, + ) => void; +} + +const SORT_OPTIONS: { key: PlaylistSortOption; label: string; icon: string }[] = + [ + { key: "SortName", label: "music.sort.alphabetical", icon: "text-outline" }, + { + key: "DateCreated", + label: "music.sort.date_created", + icon: "time-outline", + }, + ]; + +export const PlaylistSortSheet: React.FC = ({ + open, + setOpen, + sortBy, + sortOrder, + onSortChange, +}) => { + const bottomSheetModalRef = useRef(null); + const insets = useSafeAreaInsets(); + const { t } = useTranslation(); + + const snapPoints = useMemo(() => ["40%"], []); + + useEffect(() => { + if (open) bottomSheetModalRef.current?.present(); + else bottomSheetModalRef.current?.dismiss(); + }, [open]); + + const handleSheetChanges = useCallback( + (index: number) => { + if (index === -1) { + setOpen(false); + } + }, + [setOpen], + ); + + const renderBackdrop = useCallback( + (props: BottomSheetBackdropProps) => ( + + ), + [], + ); + + const handleSortSelect = useCallback( + (option: PlaylistSortOption) => { + // If selecting same option, toggle order; otherwise use sensible default + if (option === sortBy) { + onSortChange( + option, + sortOrder === "Ascending" ? "Descending" : "Ascending", + ); + } else { + // Default order based on sort type + const defaultOrder: PlaylistSortOrder = + option === "SortName" ? "Ascending" : "Descending"; + onSortChange(option, defaultOrder); + } + setOpen(false); + }, + [sortBy, sortOrder, onSortChange, setOpen], + ); + + return ( + + + + {t("music.sort.title")} + + + {SORT_OPTIONS.map((option, index) => { + const isSelected = sortBy === option.key; + return ( + + {index > 0 && } + handleSortSelect(option.key)} + className='flex-row items-center px-4 py-3.5' + > + + + {t(option.label)} + + {isSelected && ( + + + + + )} + + + ); + })} + + + + ); +}; + +const styles = StyleSheet.create({ + separator: { + height: StyleSheet.hairlineWidth, + backgroundColor: "#404040", + }, +}); diff --git a/components/music/TrackOptionsSheet.tsx b/components/music/TrackOptionsSheet.tsx new file mode 100644 index 000000000..a0b78472c --- /dev/null +++ b/components/music/TrackOptionsSheet.tsx @@ -0,0 +1,464 @@ +import { Ionicons } from "@expo/vector-icons"; +import { + BottomSheetBackdrop, + type BottomSheetBackdropProps, + BottomSheetModal, + BottomSheetView, +} from "@gorhom/bottom-sheet"; +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import { Image } from "expo-image"; +import { useAtom } from "jotai"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { useTranslation } from "react-i18next"; +import { + ActivityIndicator, + StyleSheet, + TouchableOpacity, + View, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Text } from "@/components/common/Text"; +import useRouter from "@/hooks/useAppRouter"; +import { useFavorite } from "@/hooks/useFavorite"; +import { + audioStorageEvents, + deleteTrack, + downloadTrack, + isCached, + isPermanentDownloading, + isPermanentlyDownloaded, +} from "@/providers/AudioStorage"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { useMusicPlayer } from "@/providers/MusicPlayerProvider"; +import { getAudioStreamUrl } from "@/utils/jellyfin/audio/getAudioStreamUrl"; +import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; + +interface Props { + open: boolean; + setOpen: (open: boolean) => void; + track: BaseItemDto | null; + onAddToPlaylist: () => void; + playlistId?: string; + onRemoveFromPlaylist?: () => void; +} + +export const TrackOptionsSheet: React.FC = ({ + open, + setOpen, + track, + onAddToPlaylist, + playlistId, + onRemoveFromPlaylist, +}) => { + const bottomSheetModalRef = useRef(null); + const [api] = useAtom(apiAtom); + const [user] = useAtom(userAtom); + const router = useRouter(); + const { playNext, addToQueue } = useMusicPlayer(); + const insets = useSafeAreaInsets(); + const { t } = useTranslation(); + const [isDownloadingTrack, setIsDownloadingTrack] = useState(false); + // Counter to trigger re-evaluation of download status when storage changes + const [storageUpdateCounter, setStorageUpdateCounter] = useState(0); + + // Listen for storage events to update download status + useEffect(() => { + const handleComplete = (event: { itemId: string }) => { + if (event.itemId === track?.Id) { + setStorageUpdateCounter((c) => c + 1); + } + }; + + audioStorageEvents.on("complete", handleComplete); + return () => { + audioStorageEvents.off("complete", handleComplete); + }; + }, [track?.Id]); + + // Force re-evaluation of cache status when track changes + useEffect(() => { + setStorageUpdateCounter((c) => c + 1); + }, [track?.Id]); + + // Use a placeholder item for useFavorite when track is null + const { isFavorite, toggleFavorite } = useFavorite( + track ?? ({ Id: "", UserData: { IsFavorite: false } } as BaseItemDto), + ); + + // Check download status (storageUpdateCounter triggers re-evaluation when download completes) + const isAlreadyDownloaded = useMemo( + () => isPermanentlyDownloaded(track?.Id), + [track?.Id, storageUpdateCounter], + ); + const isOnlyCached = useMemo( + () => isCached(track?.Id), + [track?.Id, storageUpdateCounter], + ); + const isCurrentlyDownloading = useMemo( + () => isPermanentDownloading(track?.Id), + [track?.Id, storageUpdateCounter], + ); + + const imageUrl = useMemo(() => { + if (!track) return null; + const albumId = track.AlbumId || track.ParentId; + if (albumId) { + return `${api?.basePath}/Items/${albumId}/Images/Primary?maxHeight=200&maxWidth=200`; + } + return getPrimaryImageUrl({ api, item: track }); + }, [api, track]); + + useEffect(() => { + if (open) bottomSheetModalRef.current?.present(); + else bottomSheetModalRef.current?.dismiss(); + }, [open]); + + const handleSheetChanges = useCallback( + (index: number) => { + if (index === -1) { + setOpen(false); + } + }, + [setOpen], + ); + + const renderBackdrop = useCallback( + (props: BottomSheetBackdropProps) => ( + + ), + [], + ); + + const handlePlayNext = useCallback(() => { + if (track) { + playNext(track); + setOpen(false); + } + }, [track, playNext, setOpen]); + + const handleAddToQueue = useCallback(() => { + if (track) { + addToQueue(track); + setOpen(false); + } + }, [track, addToQueue, setOpen]); + + const handleAddToPlaylist = useCallback(() => { + setOpen(false); + setTimeout(() => { + onAddToPlaylist(); + }, 300); + }, [onAddToPlaylist, setOpen]); + + const handleRemoveFromPlaylist = useCallback(() => { + if (onRemoveFromPlaylist) { + onRemoveFromPlaylist(); + setOpen(false); + } + }, [onRemoveFromPlaylist, setOpen]); + + const handleDownload = useCallback(async () => { + if (!track?.Id || !api || !user?.Id || isAlreadyDownloaded) return; + + setIsDownloadingTrack(true); + try { + const result = await getAudioStreamUrl(api, user.Id, track.Id); + if (result?.url && !result.isTranscoding) { + await downloadTrack(track.Id, result.url, { + permanent: true, + container: result.mediaSource?.Container || undefined, + }); + } + } catch { + // Silent fail + } + setIsDownloadingTrack(false); + setOpen(false); + }, [track?.Id, api, user?.Id, isAlreadyDownloaded, setOpen]); + + const handleDelete = useCallback(async () => { + if (!track?.Id) return; + await deleteTrack(track.Id); + setStorageUpdateCounter((c) => c + 1); + setOpen(false); + }, [track?.Id, setOpen]); + + const handleGoToArtist = useCallback(() => { + const artistId = track?.ArtistItems?.[0]?.Id; + if (artistId) { + setOpen(false); + router.push({ + pathname: "/music/artist/[artistId]", + params: { artistId }, + }); + } + }, [track?.ArtistItems, router, setOpen]); + + const handleGoToAlbum = useCallback(() => { + const albumId = track?.AlbumId || track?.ParentId; + if (albumId) { + setOpen(false); + router.push({ + pathname: "/music/album/[albumId]", + params: { albumId }, + }); + } + }, [track?.AlbumId, track?.ParentId, router, setOpen]); + + const handleToggleFavorite = useCallback(() => { + if (track) { + toggleFavorite(); + setOpen(false); + } + }, [track, toggleFavorite, setOpen]); + + // Check if navigation options are available + const hasArtist = !!track?.ArtistItems?.[0]?.Id; + const hasAlbum = !!(track?.AlbumId || track?.ParentId); + + if (!track) return null; + + return ( + + + {/* Track Info Header */} + + + {imageUrl ? ( + + ) : ( + + + + )} + + + + {track.Name} + + + {track.Artists?.join(", ") || track.AlbumArtist} + + + + + {/* Playback Options */} + + + + + {t("music.track_options.play_next")} + + + + + + + + + {t("music.track_options.add_to_queue")} + + + + + {/* Library Options */} + + + + + {isFavorite + ? t("music.track_options.remove_from_favorites") + : t("music.track_options.add_to_favorites")} + + + + + + + + + {t("music.track_options.add_to_playlist")} + + + + {playlistId && ( + <> + + + + + {t("music.track_options.remove_from_playlist")} + + + + )} + + + + + {isCurrentlyDownloading || isDownloadingTrack ? ( + + ) : ( + + )} + + {isCurrentlyDownloading || isDownloadingTrack + ? t("music.track_options.downloading") + : isAlreadyDownloaded + ? t("music.track_options.downloaded") + : t("music.track_options.download")} + + + + {isOnlyCached && !isAlreadyDownloaded && ( + <> + + + + + {t("music.track_options.cached")} + + + + )} + + {(isAlreadyDownloaded || isOnlyCached) && ( + <> + + + + + {isAlreadyDownloaded + ? t("music.track_options.delete_download") + : t("music.track_options.delete_cache")} + + + + )} + + + {/* Navigation Options */} + {(hasArtist || hasAlbum) && ( + + {hasArtist && ( + <> + + + + {t("music.track_options.go_to_artist")} + + + {hasAlbum && } + + )} + + {hasAlbum && ( + + + + {t("music.track_options.go_to_album")} + + + )} + + )} + + + ); +}; + +const styles = StyleSheet.create({ + separator: { + height: StyleSheet.hairlineWidth, + backgroundColor: "#404040", + }, +}); diff --git a/components/music/index.ts b/components/music/index.ts new file mode 100644 index 000000000..3d0767ad2 --- /dev/null +++ b/components/music/index.ts @@ -0,0 +1,6 @@ +export * from "./MiniPlayerBar"; +export * from "./MusicAlbumCard"; +export * from "./MusicArtistCard"; +export * from "./MusicPlaybackEngine"; +export * from "./MusicPlaylistCard"; +export * from "./MusicTrackItem"; diff --git a/components/navigation/TabBarIcon.tsx b/components/navigation/TabBarIcon.tsx deleted file mode 100644 index a28bba840..000000000 --- a/components/navigation/TabBarIcon.tsx +++ /dev/null @@ -1,12 +0,0 @@ -// You can explore the built-in icon families and icons on the web at https://icons.expo.fyi/ - -import type { IconProps } from "@expo/vector-icons/build/createIconSet"; -import Ionicons from "@expo/vector-icons/Ionicons"; -import type { ComponentProps } from "react"; - -export function TabBarIcon({ - style, - ...rest -}: IconProps["name"]>) { - return ; -} diff --git a/components/persons/TVActorPage.tsx b/components/persons/TVActorPage.tsx new file mode 100644 index 000000000..df8ee8ffa --- /dev/null +++ b/components/persons/TVActorPage.tsx @@ -0,0 +1,570 @@ +import { Ionicons } from "@expo/vector-icons"; +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import { getItemsApi } from "@jellyfin/sdk/lib/utils/api"; +import { useQuery } from "@tanstack/react-query"; +import { Image } from "expo-image"; +import { LinearGradient } from "expo-linear-gradient"; +import { useSegments } from "expo-router"; +import { useAtom } from "jotai"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { useTranslation } from "react-i18next"; +import { + Animated, + Dimensions, + Easing, + FlatList, + ScrollView, + View, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Text } from "@/components/common/Text"; +import { getItemNavigation } from "@/components/common/TouchableItemRouter"; +import { Loader } from "@/components/Loader"; +import { TVPosterCard } from "@/components/tv/TVPosterCard"; +import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes"; +import { useScaledTVSizes } from "@/constants/TVSizes"; +import { useScaledTVTypography } from "@/constants/TVTypography"; +import useRouter from "@/hooks/useAppRouter"; +import { useTVItemActionModal } from "@/hooks/useTVItemActionModal"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl"; +import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData"; + +const { width: SCREEN_WIDTH } = Dimensions.get("window"); + +const HORIZONTAL_PADDING = 80; +const TOP_PADDING = 140; +const ACTOR_IMAGE_SIZE = 250; +const SCALE_PADDING = 20; + +interface TVActorPageProps { + personId: string; +} + +export const TVActorPage: React.FC = ({ personId }) => { + const { t } = useTranslation(); + const insets = useSafeAreaInsets(); + const router = useRouter(); + const { showItemActions } = useTVItemActionModal(); + const segments = useSegments(); + const from = (segments as string[])[2] || "(home)"; + const posterSizes = useScaledTVPosterSizes(); + const typography = useScaledTVTypography(); + const sizes = useScaledTVSizes(); + const ITEM_GAP = sizes.gaps.item; + + const [api] = useAtom(apiAtom); + const [user] = useAtom(userAtom); + + // Track which filmography item is currently focused for dynamic backdrop + const [focusedItem, setFocusedItem] = useState(null); + + // Fetch actor details + const { data: item, isLoading: isLoadingActor } = useQuery({ + queryKey: ["item", personId], + queryFn: async () => + await getUserItemData({ + api, + userId: user?.Id, + itemId: personId, + }), + enabled: !!personId && !!api, + staleTime: 60, + }); + + // Fetch movies + const { data: movies = [], isLoading: isLoadingMovies } = useQuery({ + queryKey: ["actor", "movies", personId], + queryFn: async () => { + if (!api || !user?.Id) return []; + + const response = await getItemsApi(api).getItems({ + userId: user.Id, + personIds: [personId], + startIndex: 0, + limit: 20, + sortOrder: ["Descending", "Descending", "Ascending"], + includeItemTypes: ["Movie"], + recursive: true, + fields: ["ParentId", "PrimaryImageAspectRatio"], + sortBy: ["PremiereDate", "ProductionYear", "SortName"], + collapseBoxSetItems: false, + }); + + return response.data.Items || []; + }, + enabled: !!personId && !!api && !!user?.Id, + staleTime: 60, + }); + + // Fetch series + const { data: series = [], isLoading: isLoadingSeries } = useQuery({ + queryKey: ["actor", "series", personId], + queryFn: async () => { + if (!api || !user?.Id) return []; + + const response = await getItemsApi(api).getItems({ + userId: user.Id, + personIds: [personId], + startIndex: 0, + limit: 20, + sortOrder: ["Descending", "Descending", "Ascending"], + includeItemTypes: ["Series"], + recursive: true, + fields: ["ParentId", "PrimaryImageAspectRatio"], + sortBy: ["PremiereDate", "ProductionYear", "SortName"], + collapseBoxSetItems: false, + }); + + return response.data.Items || []; + }, + enabled: !!personId && !!api && !!user?.Id, + staleTime: 60, + }); + + // Get backdrop URL from the currently focused filmography item + // Changes dynamically as user navigates through the list + const backdropUrl = useMemo(() => { + // Use focused item if available, otherwise fall back to first movie or series + const itemForBackdrop = focusedItem ?? movies[0] ?? series[0]; + if (!itemForBackdrop) return null; + return getBackdropUrl({ + api, + item: itemForBackdrop, + quality: 90, + width: 1920, + }); + }, [api, focusedItem, movies, series]); + + // Crossfade animation for backdrop transitions + // Use two alternating layers for smooth crossfade + const [activeLayer, setActiveLayer] = useState<0 | 1>(0); + const [layer0Url, setLayer0Url] = useState(null); + const [layer1Url, setLayer1Url] = useState(null); + const layer0Opacity = useRef(new Animated.Value(1)).current; + const layer1Opacity = useRef(new Animated.Value(0)).current; + + useEffect(() => { + if (!backdropUrl) return; + + let isCancelled = false; + + const performCrossfade = async () => { + // Disk-only prefetch to avoid pinning large backdrops in memory cache. + try { + await Image.prefetch(backdropUrl, "disk"); + } catch { + // Continue even if prefetch fails + } + + if (isCancelled) return; + + // Determine which layer to fade in + const incomingLayer = activeLayer === 0 ? 1 : 0; + const incomingOpacity = + incomingLayer === 0 ? layer0Opacity : layer1Opacity; + const outgoingOpacity = + incomingLayer === 0 ? layer1Opacity : layer0Opacity; + + // Set the new URL on the incoming layer + if (incomingLayer === 0) { + setLayer0Url(backdropUrl); + } else { + setLayer1Url(backdropUrl); + } + + // Small delay to ensure image component has the new URL + await new Promise((resolve) => setTimeout(resolve, 50)); + + if (isCancelled) return; + + // Crossfade: fade in the incoming layer, fade out the outgoing + Animated.parallel([ + Animated.timing(incomingOpacity, { + toValue: 1, + duration: 500, + easing: Easing.inOut(Easing.quad), + useNativeDriver: true, + }), + Animated.timing(outgoingOpacity, { + toValue: 0, + duration: 500, + easing: Easing.inOut(Easing.quad), + useNativeDriver: true, + }), + ]).start(() => { + if (!isCancelled) { + // After animation completes, switch the active layer + setActiveLayer(incomingLayer); + } + }); + }; + + performCrossfade(); + + return () => { + isCancelled = true; + }; + }, [backdropUrl]); + + // Get actor image URL + const actorImageUrl = useMemo(() => { + if (!item?.Id || !api?.basePath) return null; + return `${api.basePath}/Items/${item.Id}/Images/Primary?fillWidth=${ACTOR_IMAGE_SIZE * 2}&fillHeight=${ACTOR_IMAGE_SIZE * 2}&quality=90`; + }, [api?.basePath, item?.Id]); + + // Handle filmography item press + const handleItemPress = useCallback( + (filmItem: BaseItemDto) => { + const navigation = getItemNavigation(filmItem, from); + router.push(navigation as any); + }, + [from, router], + ); + + // List item layout + const getItemLayout = useCallback( + (_data: ArrayLike | null | undefined, index: number) => ({ + length: posterSizes.poster + ITEM_GAP, + offset: (posterSizes.poster + ITEM_GAP) * index, + index, + }), + [], + ); + + // Render movie filmography item + const renderMovieItem = useCallback( + ({ item: filmItem, index }: { item: BaseItemDto; index: number }) => ( + + handleItemPress(filmItem)} + onLongPress={() => showItemActions(filmItem)} + onFocus={() => setFocusedItem(filmItem)} + hasTVPreferredFocus={index === 0} + width={posterSizes.poster} + /> + + ), + [handleItemPress, showItemActions, posterSizes.poster], + ); + + // Render series filmography item + const renderSeriesItem = useCallback( + ({ item: filmItem, index }: { item: BaseItemDto; index: number }) => ( + + handleItemPress(filmItem)} + onLongPress={() => showItemActions(filmItem)} + onFocus={() => setFocusedItem(filmItem)} + hasTVPreferredFocus={movies.length === 0 && index === 0} + width={posterSizes.poster} + /> + + ), + [handleItemPress, showItemActions, posterSizes.poster, movies.length], + ); + + if (isLoadingActor) { + return ( + + + + ); + } + + if (!item?.Id) return null; + + return ( + + {/* Full-screen backdrop with crossfade - two alternating layers */} + + {/* Layer 0 */} + + {layer0Url ? ( + + ) : ( + + )} + + {/* Layer 1 */} + + {layer1Url ? ( + + ) : ( + + )} + + {/* Gradient overlay for readability */} + + + + {/* Main content area */} + + {/* Top section - Actor image + Info */} + + {/* Left side - Circular actor image */} + + {actorImageUrl ? ( + + ) : ( + + + + )} + + + {/* Right side - Info */} + + {/* Actor name */} + + {item.Name} + + + {/* Production year / Birth year */} + {item.ProductionYear && ( + + {item.ProductionYear} + + )} + + {/* Biography */} + {item.Overview && ( + + {item.Overview} + + )} + + + + {/* Filmography sections */} + + {/* Movies Section */} + {isLoadingMovies ? ( + + + + ) : ( + movies.length > 0 && ( + + + {t("item_card.movies")} + + filmItem.Id!} + renderItem={renderMovieItem} + showsHorizontalScrollIndicator={false} + initialNumToRender={6} + maxToRenderPerBatch={4} + windowSize={5} + removeClippedSubviews={false} + getItemLayout={getItemLayout} + style={{ overflow: "visible" }} + contentContainerStyle={{ + paddingVertical: SCALE_PADDING, + paddingHorizontal: SCALE_PADDING, + }} + /> + + ) + )} + + {/* Series Section */} + {isLoadingSeries ? ( + + + + ) : ( + series.length > 0 && ( + + + {t("item_card.shows")} + + filmItem.Id!} + renderItem={renderSeriesItem} + showsHorizontalScrollIndicator={false} + initialNumToRender={6} + maxToRenderPerBatch={4} + windowSize={5} + removeClippedSubviews={false} + getItemLayout={getItemLayout} + style={{ overflow: "visible" }} + contentContainerStyle={{ + paddingVertical: SCALE_PADDING, + paddingHorizontal: SCALE_PADDING, + }} + /> + + ) + )} + + {/* Empty state - only show if both sections are empty and not loading */} + {!isLoadingMovies && + !isLoadingSeries && + movies.length === 0 && + series.length === 0 && ( + + {t("common.no_results")} + + )} + + + + ); +}; diff --git a/components/posters/EpisodePoster.tsx b/components/posters/EpisodePoster.tsx deleted file mode 100644 index af42989b9..000000000 --- a/components/posters/EpisodePoster.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; -import { Image } from "expo-image"; -import { useAtom } from "jotai"; -import { useMemo, useState } from "react"; -import { View } from "react-native"; -import { WatchedIndicator } from "@/components/WatchedIndicator"; -import { apiAtom } from "@/providers/JellyfinProvider"; - -type MoviePosterProps = { - item: BaseItemDto; - showProgress?: boolean; -}; - -export const EpisodePoster: React.FC = ({ - item, - showProgress = false, -}) => { - const [api] = useAtom(apiAtom); - - const url = useMemo(() => { - if (item.Type === "Episode") { - return `${api?.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ParentThumbImageTag}`; - } - }, [item]); - - const [progress, _setProgress] = useState( - item.UserData?.PlayedPercentage || 0, - ); - - const blurhash = useMemo(() => { - const key = item.ImageTags?.Primary as string; - return item.ImageBlurHashes?.Primary?.[key]; - }, [item]); - - return ( - - - - {showProgress && progress > 0 && ( - - )} - - ); -}; diff --git a/components/posters/ItemPoster.tsx b/components/posters/ItemPoster.tsx index bcd857e79..9990727be 100644 --- a/components/posters/ItemPoster.tsx +++ b/components/posters/ItemPoster.tsx @@ -1,8 +1,8 @@ import { type BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { useState } from "react"; import { View, type ViewProps } from "react-native"; +import { WatchedIndicator } from "@/components/WatchedIndicator"; import { ItemImage } from "../common/ItemImage"; -import { WatchedIndicator } from "../WatchedIndicator"; interface Props extends ViewProps { item: BaseItemDto; diff --git a/components/posters/ParentPoster.tsx b/components/posters/ParentPoster.tsx deleted file mode 100644 index 47b62e4c3..000000000 --- a/components/posters/ParentPoster.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { Image } from "expo-image"; -import { useAtom } from "jotai"; -import { useMemo } from "react"; -import { View } from "react-native"; -import { apiAtom } from "@/providers/JellyfinProvider"; - -type PosterProps = { - id?: string; - showProgress?: boolean; -}; - -const ParentPoster: React.FC = ({ id }) => { - const [api] = useAtom(apiAtom); - - const url = useMemo( - () => `${api?.basePath}/Items/${id}/Images/Primary`, - [id], - ); - - if (!url || !id) - return ( - - ); - - return ( - - - - ); -}; - -export default ParentPoster; diff --git a/components/posters/SeriesPoster.tsx b/components/posters/SeriesPoster.tsx index 07f212d7f..82cf146c7 100644 --- a/components/posters/SeriesPoster.tsx +++ b/components/posters/SeriesPoster.tsx @@ -3,6 +3,7 @@ import { Image } from "expo-image"; import { useAtom } from "jotai"; import { useMemo } from "react"; import { View } from "react-native"; +import { WatchedIndicator } from "@/components/WatchedIndicator"; import { apiAtom } from "@/providers/JellyfinProvider"; import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; @@ -52,6 +53,7 @@ const SeriesPoster: React.FC = ({ item }) => { width: "100%", }} /> + ); }; diff --git a/components/search/DiscoverFilters.tsx b/components/search/DiscoverFilters.tsx index 2e844c881..3f70c968d 100644 --- a/components/search/DiscoverFilters.tsx +++ b/components/search/DiscoverFilters.tsx @@ -1,8 +1,17 @@ -import { Button, ContextMenu, Host, Picker } from "@expo/ui/swift-ui"; import { Platform, View } from "react-native"; import { FilterButton } from "@/components/filters/FilterButton"; import { JellyseerrSearchSort } from "@/components/jellyseerr/JellyseerrIndexPage"; +// @expo/ui's SwiftUI native module (ExpoUI) does not exist in tvOS builds. +// A static top-level import crashes the route tree on tvOS at module load. +// Load it lazily and only off-TV; TV never renders this component. +const { Button, Host, Menu } = Platform.isTV + ? ({} as typeof import("@expo/ui/swift-ui")) + : require("@expo/ui/swift-ui"); +const { buttonStyle } = Platform.isTV + ? ({} as typeof import("@expo/ui/swift-ui/modifiers")) + : require("@expo/ui/swift-ui/modifiers"); + interface DiscoverFiltersProps { searchFilterId: string; orderFilterId: string; @@ -28,7 +37,7 @@ export const DiscoverFilters: React.FC = ({ setJellyseerrSortOrder, t, }) => { - if (Platform.OS === "ios") { + if (Platform.OS === "ios" && !Platform.isTV) { return ( = ({ marginLeft: "auto", }} > - - + - - - - t(`home.settings.plugins.jellyseerr.order_by.${item}`), - )} - variant='menu' - selectedIndex={sortOptions.indexOf( - jellyseerrOrderBy as unknown as string, - )} - onOptionSelected={(event: any) => { - const index = event.nativeEvent.index; - setJellyseerrOrderBy( - sortOptions[index] as unknown as JellyseerrSearchSort, - ); - }} /> - t(`library.filters.${item}`))} - variant='menu' - selectedIndex={orderOptions.indexOf(jellyseerrSortOrder)} - onOptionSelected={(event: any) => { - const index = event.nativeEvent.index; - setJellyseerrSortOrder(orderOptions[index]); - }} - /> - - + } + > + + {sortOptions.map((item) => { + const isSelected = + jellyseerrOrderBy === (item as unknown as JellyseerrSearchSort); + return ( + + + {orderOptions.map((item) => { + const isSelected = jellyseerrSortOrder === item; + return ( + + ); } diff --git a/components/search/SearchTabButtons.tsx b/components/search/SearchTabButtons.tsx index b312b82e4..a790ee529 100644 --- a/components/search/SearchTabButtons.tsx +++ b/components/search/SearchTabButtons.tsx @@ -1,7 +1,16 @@ -import { Button, Host } from "@expo/ui/swift-ui"; import { Platform, TouchableOpacity, View } from "react-native"; import { Tag } from "@/components/GenreTags"; +// @expo/ui's SwiftUI native module (ExpoUI) does not exist in tvOS builds. +// A static top-level import crashes the route tree on tvOS at module load. +// Load it lazily and only off-TV; TV never renders this component. +const { Button, Host, HStack, Spacer } = Platform.isTV + ? ({} as typeof import("@expo/ui/swift-ui")) + : require("@expo/ui/swift-ui"); +const { buttonStyle } = Platform.isTV + ? ({} as typeof import("@expo/ui/swift-ui/modifiers")) + : require("@expo/ui/swift-ui/modifiers"); + type SearchType = "Library" | "Discover"; interface SearchTabButtonsProps { @@ -15,42 +24,31 @@ export const SearchTabButtons: React.FC = ({ setSearchType, t, }) => { - if (Platform.OS === "ios") { + if (Platform.OS === "ios" && !Platform.isTV) { return ( - <> - + + - - + label={t("search.library")} + /> - - + label={t("search.discover")} + /> + + + ); } diff --git a/components/search/TVJellyseerrSearchResults.tsx b/components/search/TVJellyseerrSearchResults.tsx new file mode 100644 index 000000000..699c50401 --- /dev/null +++ b/components/search/TVJellyseerrSearchResults.tsx @@ -0,0 +1,454 @@ +import { Ionicons } from "@expo/vector-icons"; +import { Image } from "expo-image"; +import React from "react"; +import { useTranslation } from "react-i18next"; +import { Animated, FlatList, Pressable, View } from "react-native"; +import { Text } from "@/components/common/Text"; +import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation"; +import { useScaledTVTypography } from "@/constants/TVTypography"; +import { useJellyseerr } from "@/hooks/useJellyseerr"; +import { MediaStatus } from "@/utils/jellyseerr/server/constants/media"; +import type { + MovieResult, + PersonResult, + TvResult, +} from "@/utils/jellyseerr/server/models/Search"; + +const SCALE_PADDING = 20; + +interface TVJellyseerrPosterProps { + item: MovieResult | TvResult; + onPress: () => void; + isFirstItem?: boolean; +} + +const TVJellyseerrPoster: React.FC = ({ + item, + onPress, + isFirstItem = false, +}) => { + const typography = useScaledTVTypography(); + const { jellyseerrApi, getTitle, getYear } = useJellyseerr(); + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation({ scaleAmount: 1.05 }); + + const posterUrl = item.posterPath + ? jellyseerrApi?.imageProxy(item.posterPath, "w342") + : null; + + const title = getTitle(item); + const year = getYear(item); + + const isInLibrary = + item.mediaInfo?.status === MediaStatus.AVAILABLE || + item.mediaInfo?.status === MediaStatus.PARTIALLY_AVAILABLE; + + return ( + + + + {posterUrl ? ( + + ) : ( + + + + )} + {isInLibrary && ( + + + + )} + + + {title} + + {year && ( + + {year} + + )} + + + ); +}; + +interface TVJellyseerrPersonPosterProps { + item: PersonResult; + onPress: () => void; +} + +const TVJellyseerrPersonPoster: React.FC = ({ + item, + onPress, +}) => { + const typography = useScaledTVTypography(); + const { jellyseerrApi } = useJellyseerr(); + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation(); + + const posterUrl = item.profilePath + ? jellyseerrApi?.imageProxy(item.profilePath, "w185") + : null; + + return ( + + + + {posterUrl ? ( + + ) : ( + + + + )} + + + {item.name} + + + + ); +}; + +interface TVJellyseerrMovieSectionProps { + title: string; + items: MovieResult[]; + isFirstSection?: boolean; + onItemPress: (item: MovieResult) => void; +} + +const TVJellyseerrMovieSection: React.FC = ({ + title, + items, + isFirstSection = false, + onItemPress, +}) => { + const typography = useScaledTVTypography(); + if (!items || items.length === 0) return null; + + return ( + + + {title} + + item.id.toString()} + showsHorizontalScrollIndicator={false} + contentContainerStyle={{ + paddingHorizontal: SCALE_PADDING, + paddingVertical: SCALE_PADDING, + gap: 20, + }} + style={{ overflow: "visible" }} + renderItem={({ item, index }) => ( + onItemPress(item)} + isFirstItem={isFirstSection && index === 0} + /> + )} + /> + + ); +}; + +interface TVJellyseerrTvSectionProps { + title: string; + items: TvResult[]; + isFirstSection?: boolean; + onItemPress: (item: TvResult) => void; +} + +const TVJellyseerrTvSection: React.FC = ({ + title, + items, + isFirstSection = false, + onItemPress, +}) => { + const typography = useScaledTVTypography(); + if (!items || items.length === 0) return null; + + return ( + + + {title} + + item.id.toString()} + showsHorizontalScrollIndicator={false} + contentContainerStyle={{ + paddingHorizontal: SCALE_PADDING, + paddingVertical: SCALE_PADDING, + gap: 20, + }} + style={{ overflow: "visible" }} + renderItem={({ item, index }) => ( + onItemPress(item)} + isFirstItem={isFirstSection && index === 0} + /> + )} + /> + + ); +}; + +interface TVJellyseerrPersonSectionProps { + title: string; + items: PersonResult[]; + isFirstSection?: boolean; + onItemPress: (item: PersonResult) => void; +} + +const TVJellyseerrPersonSection: React.FC = ({ + title, + items, + isFirstSection: _isFirstSection = false, + onItemPress, +}) => { + const typography = useScaledTVTypography(); + if (!items || items.length === 0) return null; + + return ( + + + {title} + + item.id.toString()} + showsHorizontalScrollIndicator={false} + contentContainerStyle={{ + paddingHorizontal: SCALE_PADDING, + paddingVertical: SCALE_PADDING, + gap: 20, + }} + style={{ overflow: "visible" }} + renderItem={({ item }) => ( + onItemPress(item)} + /> + )} + /> + + ); +}; + +export interface TVJellyseerrSearchResultsProps { + movieResults: MovieResult[]; + tvResults: TvResult[]; + personResults: PersonResult[]; + loading: boolean; + noResults: boolean; + searchQuery: string; + onMoviePress: (item: MovieResult) => void; + onTvPress: (item: TvResult) => void; + onPersonPress: (item: PersonResult) => void; +} + +export const TVJellyseerrSearchResults: React.FC< + TVJellyseerrSearchResultsProps +> = ({ + movieResults, + tvResults, + personResults, + loading, + noResults, + searchQuery, + onMoviePress, + onTvPress, + onPersonPress, +}) => { + const { t } = useTranslation(); + + if (loading) { + return null; + } + + if (noResults && searchQuery.length > 0) { + return ( + + + {t("search.no_results_found_for")} + + + "{searchQuery}" + + + ); + } + + return ( + + {/* No section requests `hasTVPreferredFocus`: the native search field + keeps focus while typing, otherwise the first result would re-grab + focus on every keystroke as results re-render. The user navigates + down to the grid manually. */} + + + + + ); +}; diff --git a/components/search/TVSearchBadge.tsx b/components/search/TVSearchBadge.tsx new file mode 100644 index 000000000..61b47d648 --- /dev/null +++ b/components/search/TVSearchBadge.tsx @@ -0,0 +1,56 @@ +import React from "react"; +import { Animated, Pressable } from "react-native"; +import { Text } from "@/components/common/Text"; +import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation"; +import { useScaledTVTypography } from "@/constants/TVTypography"; + +export interface TVSearchBadgeProps { + label: string; + onPress: () => void; + hasTVPreferredFocus?: boolean; +} + +export const TVSearchBadge: React.FC = ({ + label, + onPress, + hasTVPreferredFocus = false, +}) => { + const typography = useScaledTVTypography(); + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation({ duration: 150 }); + + return ( + + + + {label} + + + + ); +}; diff --git a/components/search/TVSearchPage.tsx b/components/search/TVSearchPage.tsx new file mode 100644 index 000000000..24e6120d1 --- /dev/null +++ b/components/search/TVSearchPage.tsx @@ -0,0 +1,381 @@ +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import { useAtom } from "jotai"; +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { Platform, ScrollView, TextInput, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Text } from "@/components/common/Text"; +import { TVDiscover } from "@/components/jellyseerr/discover/TVDiscover"; +import { useScaledTVSizes } from "@/constants/TVSizes"; +import { useScaledTVTypography } from "@/constants/TVTypography"; +import { TvSearchView } from "@/modules/tv-search"; +import { apiAtom } from "@/providers/JellyfinProvider"; +import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; +import type DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider"; +import type { + MovieResult, + PersonResult, + TvResult, +} from "@/utils/jellyseerr/server/models/Search"; +import { scaleSize } from "@/utils/scaleSize"; +import { TVJellyseerrSearchResults } from "./TVJellyseerrSearchResults"; +import { TVSearchSection } from "./TVSearchSection"; +import { TVSearchTabBadges } from "./TVSearchTabBadges"; + +const HORIZONTAL_PADDING = 60; +const TOP_PADDING = 100; +// Height of the native search bar itself. The tvOS grid keyboard presents as +// its own overlay when the field is focused, so we only reserve the bar height +// here — not the whole keyboard. Tunable once seen on device. +const SEARCH_AREA_HEIGHT = 250; +const SECTION_GAP = 10; +const SCALE_PADDING = 20; + +// Loading skeleton for TV. +// Mirrors TVSearchSection's scaled layout (poster width, item gap, edge +// padding, heading typography, poster radius) so the placeholder lines up with +// the real content that replaces it. +const TVLoadingSkeleton: React.FC = () => { + const typography = useScaledTVTypography(); + const sizes = useScaledTVSizes(); + const itemWidth = sizes.posters.poster; + return ( + + {/* Section header placeholder — matches the heading typography + margins */} + + + {[1, 2, 3, 4, 5].map((i) => ( + + + + + Placeholder text here + + + + ))} + + + ); +}; + +type SearchType = "Library" | "Discover"; + +interface TVSearchPageProps { + search: string; + setSearch: (text: string) => void; + debouncedSearch: string; + // Library search results + movies?: BaseItemDto[]; + series?: BaseItemDto[]; + episodes?: BaseItemDto[]; + collections?: BaseItemDto[]; + actors?: BaseItemDto[]; + artists?: BaseItemDto[]; + albums?: BaseItemDto[]; + songs?: BaseItemDto[]; + playlists?: BaseItemDto[]; + loading: boolean; + noResults: boolean; + onItemPress: (item: BaseItemDto) => void; + onItemLongPress?: (item: BaseItemDto) => void; + // Jellyseerr/Discover props + searchType: SearchType; + setSearchType: (type: SearchType) => void; + showDiscover: boolean; + jellyseerrMovies?: MovieResult[]; + jellyseerrTv?: TvResult[]; + jellyseerrPersons?: PersonResult[]; + jellyseerrLoading?: boolean; + jellyseerrNoResults?: boolean; + onJellyseerrMoviePress?: (item: MovieResult) => void; + onJellyseerrTvPress?: (item: TvResult) => void; + onJellyseerrPersonPress?: (item: PersonResult) => void; + // Discover sliders for empty state + discoverSliders?: DiscoverSlider[]; +} + +export const TVSearchPage: React.FC = ({ + setSearch, + debouncedSearch, + movies, + series, + episodes, + collections, + actors, + artists, + albums, + songs, + playlists, + loading, + noResults, + onItemPress, + onItemLongPress, + searchType, + setSearchType, + showDiscover, + jellyseerrMovies = [], + jellyseerrTv = [], + jellyseerrPersons = [], + jellyseerrLoading = false, + jellyseerrNoResults = false, + onJellyseerrMoviePress, + onJellyseerrTvPress, + onJellyseerrPersonPress, + discoverSliders, +}) => { + const typography = useScaledTVTypography(); + const { t } = useTranslation(); + const insets = useSafeAreaInsets(); + const [api] = useAtom(apiAtom); + + // Image URL getter for music items + const getImageUrl = useMemo(() => { + return (item: BaseItemDto): string | undefined => { + if (!api) return undefined; + const url = getPrimaryImageUrl({ api, item }); + return url ?? undefined; + }; + }, [api]); + + // Determine which section should have initial focus + const sections = useMemo(() => { + const allSections: { + key: string; + title: string; + items: BaseItemDto[] | undefined; + orientation?: "horizontal" | "vertical"; + }[] = [ + { key: "movies", title: t("search.movies"), items: movies }, + { key: "series", title: t("search.series"), items: series }, + { + key: "episodes", + title: t("search.episodes"), + items: episodes, + orientation: "horizontal" as const, + }, + { + key: "collections", + title: t("search.collections"), + items: collections, + }, + { key: "actors", title: t("search.actors"), items: actors }, + { key: "artists", title: t("search.artists"), items: artists }, + { key: "albums", title: t("search.albums"), items: albums }, + { key: "songs", title: t("search.songs"), items: songs }, + { key: "playlists", title: t("search.playlists"), items: playlists }, + ]; + + return allSections.filter((s) => s.items && s.items.length > 0); + }, [ + movies, + series, + episodes, + collections, + actors, + artists, + albums, + songs, + playlists, + t, + ]); + + const isLibraryMode = searchType === "Library"; + const isDiscoverMode = searchType === "Discover"; + const currentLoading = isLibraryMode ? loading : jellyseerrLoading; + const currentNoResults = isLibraryMode ? noResults : jellyseerrNoResults; + + return ( + + {/* Sticky header: search field stays pinned while results scroll below. */} + + {/* Search bar: native tvOS SwiftUI `.searchable` on Apple TV, standard + TextInput fallback on Android TV (the native module is Apple-only). */} + {Platform.OS === "ios" ? ( + + {/* No horizontal margin here: the native tvOS search bar centers + itself and renders a trailing "Hold to Dictate" hint. */} + setSearch(e.nativeEvent.text)} + /> + + ) : ( + + + + )} + + + + {/* Search Type Tab Badges */} + {showDiscover && ( + + + + )} + + {/* Loading State */} + {currentLoading && ( + + + + + )} + + {/* Library Search Results */} + {isLibraryMode && !loading && ( + + {sections.map((section) => ( + + ))} + + )} + + {/* Jellyseerr/Discover Search Results */} + {isDiscoverMode && !jellyseerrLoading && debouncedSearch.length > 0 && ( + {})} + onTvPress={onJellyseerrTvPress || (() => {})} + onPersonPress={onJellyseerrPersonPress || (() => {})} + /> + )} + + {/* Discover Content (when no search query in Discover mode) */} + {isDiscoverMode && + !jellyseerrLoading && + debouncedSearch.length === 0 && ( + + )} + + {/* No Results State */} + {!currentLoading && currentNoResults && debouncedSearch.length > 0 && ( + + + {t("search.no_results_found_for")} + + + "{debouncedSearch}" + + + )} + + + ); +}; diff --git a/components/search/TVSearchSection.tsx b/components/search/TVSearchSection.tsx new file mode 100644 index 000000000..ffaef2366 --- /dev/null +++ b/components/search/TVSearchSection.tsx @@ -0,0 +1,311 @@ +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import { Image } from "expo-image"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { FlatList, View, type ViewProps } from "react-native"; +import { Text } from "@/components/common/Text"; +import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster"; +import { TVPosterCard } from "@/components/tv/TVPosterCard"; +import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes"; +import { useScaledTVSizes } from "@/constants/TVSizes"; +import { useScaledTVTypography } from "@/constants/TVTypography"; + +const SCALE_PADDING = 20; + +interface TVSearchSectionProps extends ViewProps { + title: string; + items: BaseItemDto[]; + orientation?: "horizontal" | "vertical"; + disabled?: boolean; + isFirstSection?: boolean; + onItemPress: (item: BaseItemDto) => void; + onItemLongPress?: (item: BaseItemDto) => void; + imageUrlGetter?: (item: BaseItemDto) => string | undefined; + /** Override the horizontal edge padding (defaults to the scaled TV padding). */ + horizontalPadding?: number; +} + +export const TVSearchSection: React.FC = ({ + title, + items, + orientation = "vertical", + disabled = false, + isFirstSection = false, + onItemPress, + onItemLongPress, + imageUrlGetter, + horizontalPadding, + ...props +}) => { + const typography = useScaledTVTypography(); + const posterSizes = useScaledTVPosterSizes(); + const sizes = useScaledTVSizes(); + const ITEM_GAP = sizes.gaps.item; + const edgePadding = horizontalPadding ?? sizes.padding.horizontal; + const flatListRef = useRef>(null); + const [focusedCount, setFocusedCount] = useState(0); + const prevFocusedCount = useRef(0); + + // Track focus count for section + useEffect(() => { + prevFocusedCount.current = focusedCount; + }, [focusedCount]); + + const handleItemFocus = useCallback(() => { + setFocusedCount((c) => c + 1); + }, []); + + const handleItemBlur = useCallback(() => { + setFocusedCount((c) => Math.max(0, c - 1)); + }, []); + + const itemWidth = + orientation === "horizontal" ? posterSizes.landscape : posterSizes.poster; + + const getItemLayout = useCallback( + (_data: ArrayLike | null | undefined, index: number) => ({ + length: itemWidth + ITEM_GAP, + offset: (itemWidth + ITEM_GAP) * index, + index, + }), + [itemWidth, ITEM_GAP], + ); + + const renderItem = useCallback( + ({ item, index }: { item: BaseItemDto; index: number }) => { + const isFirstItem = isFirstSection && index === 0; + + // Special handling for MusicArtist (circular avatar) + if (item.Type === "MusicArtist") { + const imageUrl = imageUrlGetter?.(item); + return ( + + onItemPress(item)} + onLongPress={ + onItemLongPress ? () => onItemLongPress(item) : undefined + } + hasTVPreferredFocus={isFirstItem && !disabled} + onFocus={handleItemFocus} + onBlur={handleItemBlur} + disabled={disabled} + > + + {imageUrl ? ( + + ) : ( + + 👤 + + )} + + + + + {item.Name} + + + + ); + } + + // Special handling for MusicAlbum, Audio, Playlist (square images) + if ( + item.Type === "MusicAlbum" || + item.Type === "Audio" || + item.Type === "Playlist" + ) { + const imageUrl = imageUrlGetter?.(item); + const icon = + item.Type === "Playlist" ? "🎶" : item.Type === "Audio" ? "🎵" : "🎵"; + return ( + + onItemPress(item)} + onLongPress={ + onItemLongPress ? () => onItemLongPress(item) : undefined + } + hasTVPreferredFocus={isFirstItem && !disabled} + onFocus={handleItemFocus} + onBlur={handleItemBlur} + disabled={disabled} + > + + {imageUrl ? ( + + ) : ( + + {icon} + + )} + + + + + {item.Name} + + {item.Type === "MusicAlbum" && ( + + {item.AlbumArtist || item.Artists?.join(", ")} + + )} + {item.Type === "Audio" && ( + + {item.Artists?.join(", ") || item.AlbumArtist} + + )} + {item.Type === "Playlist" && ( + + {item.ChildCount} tracks + + )} + + + ); + } + + // Use TVPosterCard for all other item types + return ( + + onItemPress(item)} + onLongPress={ + onItemLongPress ? () => onItemLongPress(item) : undefined + } + hasTVPreferredFocus={isFirstItem && !disabled} + onFocus={handleItemFocus} + onBlur={handleItemBlur} + disabled={disabled} + width={itemWidth} + /> + + ); + }, + [ + orientation, + isFirstSection, + itemWidth, + onItemPress, + onItemLongPress, + handleItemFocus, + handleItemBlur, + disabled, + imageUrlGetter, + posterSizes.poster, + typography.callout, + ITEM_GAP, + ], + ); + + if (!items || items.length === 0) return null; + + return ( + + {/* Section Header */} + + {title} + + + item.Id!} + renderItem={renderItem} + showsHorizontalScrollIndicator={false} + initialNumToRender={5} + maxToRenderPerBatch={3} + windowSize={5} + removeClippedSubviews={false} + getItemLayout={getItemLayout} + style={{ overflow: "visible" }} + // Edge padding via contentContainerStyle, NOT contentInset+contentOffset. + // contentOffset only applies on initial mount; since this FlatList is + // reused across searches (stable key), a second search left the inset + // without the offset and the grid snapped flush to the left edge. + contentContainerStyle={{ + paddingHorizontal: edgePadding, + paddingVertical: SCALE_PADDING, + }} + /> + + ); +}; diff --git a/components/search/TVSearchTabBadges.tsx b/components/search/TVSearchTabBadges.tsx new file mode 100644 index 000000000..d15d43ce2 --- /dev/null +++ b/components/search/TVSearchTabBadges.tsx @@ -0,0 +1,117 @@ +import React from "react"; +import { Animated, Pressable, View } from "react-native"; +import { Text } from "@/components/common/Text"; +import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation"; +import { useScaledTVTypography } from "@/constants/TVTypography"; + +type SearchType = "Library" | "Discover"; + +interface TVSearchTabBadgeProps { + label: string; + isSelected: boolean; + onPress: () => void; + hasTVPreferredFocus?: boolean; + disabled?: boolean; +} + +const TVSearchTabBadge: React.FC = ({ + label, + isSelected, + onPress, + hasTVPreferredFocus = false, + disabled = false, +}) => { + const typography = useScaledTVTypography(); + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation({ duration: 150 }); + + // Design language: white for focused/selected, transparent white for unfocused + const getBackgroundColor = () => { + if (focused) return "#fff"; + if (isSelected) return "rgba(255,255,255,0.25)"; + return "rgba(255,255,255,0.1)"; + }; + + const getTextColor = () => { + if (focused) return "#000"; + return "#fff"; + }; + + return ( + + + + {label} + + + + ); +}; + +export interface TVSearchTabBadgesProps { + searchType: SearchType; + setSearchType: (type: SearchType) => void; + showDiscover: boolean; + disabled?: boolean; +} + +export const TVSearchTabBadges: React.FC = ({ + searchType, + setSearchType, + showDiscover, + disabled = false, +}) => { + if (!showDiscover) { + return null; + } + + return ( + + setSearchType("Library")} + disabled={disabled} + /> + setSearchType("Discover")} + disabled={disabled} + /> + + ); +}; diff --git a/components/series/CastAndCrew.tsx b/components/series/CastAndCrew.tsx index 037206e9d..06bf47be0 100644 --- a/components/series/CastAndCrew.tsx +++ b/components/series/CastAndCrew.tsx @@ -2,12 +2,14 @@ import type { BaseItemDto, BaseItemPerson, } from "@jellyfin/sdk/lib/generated-client/models"; -import { router, useSegments } from "expo-router"; +import { useSegments } from "expo-router"; import { useAtom } from "jotai"; import type React from "react"; import { useMemo } from "react"; import { useTranslation } from "react-i18next"; import { TouchableOpacity, View, type ViewProps } from "react-native"; +import { POSTER_CAROUSEL_HEIGHT } from "@/constants/Values"; +import useRouter from "@/hooks/useAppRouter"; import { apiAtom } from "@/providers/JellyfinProvider"; import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; import { HorizontalScroll } from "../common/HorizontalScroll"; @@ -23,6 +25,7 @@ export const CastAndCrew: React.FC = ({ item, loading, ...props }) => { const [api] = useAtom(apiAtom); const segments = useSegments(); const { t } = useTranslation(); + const router = useRouter(); const from = (segments as string[])[2]; const destinctPeople = useMemo(() => { @@ -50,7 +53,7 @@ export const CastAndCrew: React.FC = ({ item, loading, ...props }) => { i.Id?.toString() || ""} - height={247} + height={POSTER_CAROUSEL_HEIGHT} data={destinctPeople} renderItem={(i) => ( = ({ item, loading, ...props }) => { className='flex flex-col w-28' > - {i.Name} - {i.Role} + + {i.Name} + + + {i.Role} + )} /> diff --git a/components/series/CurrentSeries.tsx b/components/series/CurrentSeries.tsx index c3d0e0d92..d8c49e258 100644 --- a/components/series/CurrentSeries.tsx +++ b/components/series/CurrentSeries.tsx @@ -1,9 +1,10 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; -import { router } from "expo-router"; import { useAtom } from "jotai"; import type React from "react"; import { useTranslation } from "react-i18next"; import { TouchableOpacity, View, type ViewProps } from "react-native"; +import { POSTER_CAROUSEL_HEIGHT } from "@/constants/Values"; +import useRouter from "@/hooks/useAppRouter"; import { apiAtom } from "@/providers/JellyfinProvider"; import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById"; import { HorizontalScroll } from "../common/HorizontalScroll"; @@ -17,6 +18,7 @@ interface Props extends ViewProps { export const CurrentSeries: React.FC = ({ item, ...props }) => { const [api] = useAtom(apiAtom); const { t } = useTranslation(); + const router = useRouter(); return ( @@ -25,7 +27,7 @@ export const CurrentSeries: React.FC = ({ item, ...props }) => { ( = ({ item, ...props }) => { id={item?.Id} url={getPrimaryImageUrlById({ api, id: item?.ParentId })} /> - {item?.SeriesName} + {item?.SeriesName} )} /> diff --git a/components/series/EpisodeTitleHeader.tsx b/components/series/EpisodeTitleHeader.tsx index e9f2b1aa4..fe473a923 100644 --- a/components/series/EpisodeTitleHeader.tsx +++ b/components/series/EpisodeTitleHeader.tsx @@ -1,7 +1,7 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; -import { useRouter } from "expo-router"; import { TouchableOpacity, View, type ViewProps } from "react-native"; import { Text } from "@/components/common/Text"; +import useRouter from "@/hooks/useAppRouter"; interface Props extends ViewProps { item: BaseItemDto; diff --git a/components/series/NextItemButton.tsx b/components/series/NextItemButton.tsx index a2aae63fc..5d6497c36 100644 --- a/components/series/NextItemButton.tsx +++ b/components/series/NextItemButton.tsx @@ -2,9 +2,9 @@ import { Ionicons } from "@expo/vector-icons"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { getItemsApi } from "@jellyfin/sdk/lib/utils/api"; import { useQuery } from "@tanstack/react-query"; -import { useRouter } from "expo-router"; import { useAtom } from "jotai"; import { useMemo } from "react"; +import useRouter from "@/hooks/useAppRouter"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { Button } from "../Button"; diff --git a/components/series/SeasonDropdown.tsx b/components/series/SeasonDropdown.tsx index 5f6b64cd4..b59b74a32 100644 --- a/components/series/SeasonDropdown.tsx +++ b/components/series/SeasonDropdown.tsx @@ -54,29 +54,28 @@ export const SeasonDropdown: React.FC = ({ [state, item, keys], ); + // Always use IndexNumber for Season objects (not keys.index which is for the item) const sortByIndex = (a: BaseItemDto, b: BaseItemDto) => - Number(a[keys.index]) - Number(b[keys.index]); + Number(a.IndexNumber) - Number(b.IndexNumber); const optionGroups = useMemo( () => [ { options: seasons?.sort(sortByIndex).map((season: any) => { - const title = - season[keys.title] || - season.Name || - `Season ${season.IndexNumber}`; + const title = season.Name || `Season ${season.IndexNumber}`; return { type: "radio" as const, label: title, value: season.Id || season.IndexNumber, - selected: Number(season[keys.index]) === Number(seasonIndex), + // Compare season's IndexNumber with the selected seasonIndex + selected: Number(season.IndexNumber) === Number(seasonIndex), onPress: () => onSelect(season), }; }) || [], }, ], - [seasons, keys, seasonIndex, onSelect], + [seasons, seasonIndex, onSelect], ); useEffect(() => { diff --git a/components/series/SeasonEpisodesCarousel.tsx b/components/series/SeasonEpisodesCarousel.tsx index cf763b8d9..e3bba0a98 100644 --- a/components/series/SeasonEpisodesCarousel.tsx +++ b/components/series/SeasonEpisodesCarousel.tsx @@ -1,12 +1,14 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api"; import { useQuery } from "@tanstack/react-query"; -import { router } from "expo-router"; import { useAtom } from "jotai"; import { useEffect, useMemo, useRef } from "react"; import { TouchableOpacity, type ViewStyle } from "react-native"; +import useRouter from "@/hooks/useAppRouter"; import { useDownload } from "@/providers/DownloadProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { useOfflineMode } from "@/providers/OfflineModeProvider"; +import { getDownloadedEpisodesBySeasonId } from "@/utils/downloads/offline-series"; import ContinueWatchingPoster from "../ContinueWatchingPoster"; import { HorizontalScroll, @@ -17,7 +19,6 @@ import { ItemCardText } from "../ItemCardText"; interface Props { item?: BaseItemDto | null; loading?: boolean; - isOffline?: boolean; style?: ViewStyle; containerStyle?: ViewStyle; } @@ -25,22 +26,23 @@ interface Props { export const SeasonEpisodesCarousel: React.FC = ({ item, loading, - isOffline, style, containerStyle, }) => { const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); + const router = useRouter(); + const isOffline = useOfflineMode(); + // Read the live (cached) downloads DB inside the query rather than the + // provider's downloadedItems snapshot, so refetches after + // updateDownloadedItem() reflect the latest state instead of a stale + // refreshKey-gated snapshot. getAllDownloadedItems() is cached, so this stays cheap. const { getDownloadedItems } = useDownload(); - const downloadedFiles = useMemo( - () => getDownloadedItems(), - [getDownloadedItems], - ); const scrollRef = useRef(null); const scrollToIndex = (index: number) => { - scrollRef.current?.scrollToIndex(index, 16); + scrollRef.current?.scrollToIndex(index, -16); }; const seasonId = useMemo(() => { @@ -51,11 +53,7 @@ export const SeasonEpisodesCarousel: React.FC = ({ queryKey: ["episodes", seasonId, isOffline], queryFn: async () => { if (isOffline) { - return downloadedFiles - ?.filter( - (f) => f.item.Type === "Episode" && f.item.SeasonId === seasonId, - ) - .map((f) => f.item); + return getDownloadedEpisodesBySeasonId(getDownloadedItems(), seasonId!); } if (!api || !user?.Id || !item?.SeriesId) return []; const response = await getTvShowsApi(api).getEpisodes({ @@ -73,7 +71,7 @@ export const SeasonEpisodesCarousel: React.FC = ({ }); return response.data.Items as BaseItemDto[]; }, - enabled: !!api && !!user?.Id && !!seasonId, + enabled: !!seasonId && (isOffline || (!!api && !!user?.Id)), }); useEffect(() => { @@ -87,6 +85,11 @@ export const SeasonEpisodesCarousel: React.FC = ({ } }, [episodes, item]); + const snapOffsets = useMemo(() => { + const itemWidth = 184; // w-44 (176px) + mr-2 (8px) + return episodes?.map((_, index) => index * itemWidth) || []; + }, [episodes]); + return ( = ({ onPress={() => { router.setParams({ id: _item.Id }); }} - className={`flex flex-col w-44 + className={`flex flex-col w-44 ${item?.Id === _item.Id ? "" : "opacity-50"} `} > @@ -109,6 +112,8 @@ export const SeasonEpisodesCarousel: React.FC = ({ )} + snapToOffsets={snapOffsets} + decelerationRate='fast' /> ); }; diff --git a/components/series/SeasonPicker.tsx b/components/series/SeasonPicker.tsx index f97f6c0ec..01a81545c 100644 --- a/components/series/SeasonPicker.tsx +++ b/components/series/SeasonPicker.tsx @@ -10,7 +10,13 @@ import { SeasonDropdown, type SeasonIndexState, } from "@/components/series/SeasonDropdown"; +import { useDownload } from "@/providers/DownloadProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { useOfflineMode } from "@/providers/OfflineModeProvider"; +import { + buildOfflineSeasons, + getDownloadedEpisodesForSeason, +} from "@/utils/downloads/offline-series"; import { runtimeTicksToSeconds } from "@/utils/time"; import ContinueWatchingPoster from "../ContinueWatchingPoster"; import { Text } from "../common/Text"; @@ -31,6 +37,8 @@ export const SeasonPicker: React.FC = ({ item }) => { const [user] = useAtom(userAtom); const [seasonIndexState, setSeasonIndexState] = useAtom(seasonIndexAtom); const { t } = useTranslation(); + const isOffline = useOfflineMode(); + const { getDownloadedItems, downloadedItems } = useDownload(); const seasonIndex = useMemo( () => seasonIndexState[item.Id ?? ""], @@ -38,8 +46,12 @@ export const SeasonPicker: React.FC = ({ item }) => { ); const { data: seasons } = useQuery({ - queryKey: ["seasons", item.Id], + queryKey: ["seasons", item.Id, isOffline, downloadedItems.length], queryFn: async () => { + if (isOffline) { + return buildOfflineSeasons(getDownloadedItems(), item.Id!); + } + if (!api || !user?.Id || !item.Id) return []; const response = await api.axiosInstance.get( `${api.basePath}/Shows/${item.Id}/Seasons`, @@ -58,8 +70,8 @@ export const SeasonPicker: React.FC = ({ item }) => { return response.data.Items; }, - staleTime: 60, - enabled: !!api && !!user?.Id && !!item.Id, + staleTime: isOffline ? Infinity : 60, + enabled: isOffline || (!!api && !!user?.Id && !!item.Id), }); const selectedSeasonId: string | null = useMemo(() => { @@ -73,9 +85,33 @@ export const SeasonPicker: React.FC = ({ item }) => { return season.Id!; }, [seasons, seasonIndex]); + // For offline mode, we use season index number instead of ID + const selectedSeasonNumber = useMemo(() => { + if (!isOffline) return null; + const season = seasons?.find( + (s: BaseItemDto) => + s.IndexNumber === seasonIndex || s.Name === seasonIndex, + ); + return season?.IndexNumber ?? null; + }, [isOffline, seasons, seasonIndex]); + const { data: episodes, isPending } = useQuery({ - queryKey: ["episodes", item.Id, selectedSeasonId], + queryKey: [ + "episodes", + item.Id, + isOffline ? selectedSeasonNumber : selectedSeasonId, + isOffline, + downloadedItems.length, + ], queryFn: async () => { + if (isOffline) { + return getDownloadedEpisodesForSeason( + getDownloadedItems(), + item.Id!, + selectedSeasonNumber!, + ); + } + if (!api || !user?.Id || !item.Id || !selectedSeasonId) { return []; } @@ -85,7 +121,6 @@ export const SeasonPicker: React.FC = ({ item }) => { userId: user.Id, seasonId: selectedSeasonId, enableUserData: true, - // Note: Including trick play is necessary to enable trick play downloads fields: ["MediaSources", "MediaStreams", "Overview", "Trickplay"], }); @@ -97,7 +132,10 @@ export const SeasonPicker: React.FC = ({ item }) => { return res.data.Items; }, - enabled: !!api && !!user?.Id && !!item.Id && !!selectedSeasonId, + staleTime: isOffline ? Infinity : 0, + enabled: isOffline + ? !!item.Id && selectedSeasonNumber !== null + : !!api && !!user?.Id && !!item.Id && !!selectedSeasonId, }); // Used for height calculation @@ -127,7 +165,7 @@ export const SeasonPicker: React.FC = ({ item }) => { })); }} /> - {episodes?.length ? ( + {episodes?.length && !isOffline ? ( = ({ item }) => { {runtimeTicksToSeconds(e.RunTimeTicks)} - - - + {!isOffline && ( + + + + )} void; + /** Called when any episode is long-pressed */ + onEpisodeLongPress?: (episode: BaseItemDto) => void; + /** Called when any episode gains focus */ + onFocus?: () => void; + /** Called when any episode loses focus */ + onBlur?: () => void; + /** Ref for programmatic scrolling */ + scrollViewRef?: React.RefObject; + /** Setter for the first episode ref (for focus guide destinations) */ + firstEpisodeRefSetter?: (ref: View | null) => void; + /** Text to show when episodes array is empty */ + emptyText?: string; + /** Horizontal padding for the list content */ + horizontalPadding?: number; +} + +export const TVEpisodeList: React.FC = ({ + episodes, + currentEpisodeId, + disabled = false, + onEpisodePress, + onEpisodeLongPress, + onFocus, + onBlur, + scrollViewRef, + firstEpisodeRefSetter, + emptyText, + horizontalPadding, +}) => { + const renderItem = useCallback( + ({ item: episode, index }: { item: BaseItemDto; index: number }) => { + const isCurrent = currentEpisodeId + ? episode.Id === currentEpisodeId + : false; + return ( + onEpisodePress(episode)} + onLongPress={ + onEpisodeLongPress ? () => onEpisodeLongPress(episode) : undefined + } + onFocus={onFocus} + onBlur={onBlur} + disabled={isCurrent || disabled} + focusableWhenDisabled={isCurrent} + isCurrent={isCurrent} + refSetter={index === 0 ? firstEpisodeRefSetter : undefined} + /> + ); + }, + [ + currentEpisodeId, + disabled, + firstEpisodeRefSetter, + onBlur, + onEpisodeLongPress, + onEpisodePress, + onFocus, + ], + ); + + const keyExtractor = useCallback((episode: BaseItemDto) => episode.Id!, []); + + return ( + + ); +}; diff --git a/components/series/TVSeriesHeader.tsx b/components/series/TVSeriesHeader.tsx new file mode 100644 index 000000000..b76bcd68d --- /dev/null +++ b/components/series/TVSeriesHeader.tsx @@ -0,0 +1,140 @@ +import { Ionicons } from "@expo/vector-icons"; +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import { BlurView } from "expo-blur"; +import { Image } from "expo-image"; +import { useAtomValue } from "jotai"; +import React, { useMemo } from "react"; +import { Dimensions, View } from "react-native"; +import { Badge } from "@/components/Badge"; +import { Text } from "@/components/common/Text"; +import { GenreTags } from "@/components/GenreTags"; +import { useScaledTVTypography } from "@/constants/TVTypography"; +import { apiAtom } from "@/providers/JellyfinProvider"; +import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById"; + +const { width: SCREEN_WIDTH } = Dimensions.get("window"); + +interface TVSeriesHeaderProps { + item: BaseItemDto; +} + +export const TVSeriesHeader: React.FC = ({ item }) => { + const typography = useScaledTVTypography(); + const api = useAtomValue(apiAtom); + + const logoUrl = useMemo(() => { + if (!api || !item) return null; + return getLogoImageUrlById({ api, item }); + }, [api, item]); + + const yearString = useMemo(() => { + const startYear = item.StartDate + ? new Date(item.StartDate).getFullYear() + : item.ProductionYear; + + const endYear = item.EndDate ? new Date(item.EndDate).getFullYear() : null; + + if (startYear && endYear) { + if (startYear === endYear) return String(startYear); + return `${startYear} - ${endYear}`; + } + if (startYear) return String(startYear); + return null; + }, [item.StartDate, item.EndDate, item.ProductionYear]); + + return ( + + {/* Logo or Title */} + {logoUrl ? ( + + ) : ( + + {item.Name} + + )} + + {/* Metadata badges row */} + + {yearString && ( + + {yearString} + + )} + {item.OfficialRating && ( + + )} + {item.CommunityRating != null && ( + } + /> + )} + + + {/* Genres */} + {item.Genres && item.Genres.length > 0 && ( + + + + )} + + {/* Overview */} + {item.Overview && ( + + + + {item.Overview} + + + + )} + + ); +}; diff --git a/components/series/TVSeriesPage.tsx b/components/series/TVSeriesPage.tsx new file mode 100644 index 000000000..26f4447f0 --- /dev/null +++ b/components/series/TVSeriesPage.tsx @@ -0,0 +1,635 @@ +import { Ionicons } from "@expo/vector-icons"; +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api"; +import { useQuery } from "@tanstack/react-query"; +import { LinearGradient } from "expo-linear-gradient"; +import { useSegments } from "expo-router"; +import { useAtom, useAtomValue } from "jotai"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { useTranslation } from "react-i18next"; +import { + Animated, + Dimensions, + Easing, + Pressable, + ScrollView, + TVFocusGuideView, + View, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { ItemImage } from "@/components/common/ItemImage"; +import { Text } from "@/components/common/Text"; +import { getItemNavigation } from "@/components/common/TouchableItemRouter"; +import { seasonIndexAtom } from "@/components/series/SeasonPicker"; +import { TVEpisodeList } from "@/components/series/TVEpisodeList"; +import { TVSeriesHeader } from "@/components/series/TVSeriesHeader"; +import { TVFavoriteButton } from "@/components/tv/TVFavoriteButton"; +import { useScaledTVTypography } from "@/constants/TVTypography"; +import useRouter from "@/hooks/useAppRouter"; +import { useTVItemActionModal } from "@/hooks/useTVItemActionModal"; +import { useTVSeriesSeasonModal } from "@/hooks/useTVSeriesSeasonModal"; +import { useTVThemeMusic } from "@/hooks/useTVThemeMusic"; +import { useDownload } from "@/providers/DownloadProvider"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { useOfflineMode } from "@/providers/OfflineModeProvider"; +import { tvSeriesSeasonModalAtom } from "@/utils/atoms/tvSeriesSeasonModal"; +import { + buildOfflineSeasons, + getDownloadedEpisodesForSeason, +} from "@/utils/downloads/offline-series"; + +const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window"); + +const HORIZONTAL_PADDING = 80; +const TOP_PADDING = 140; +const POSTER_WIDTH_PERCENT = 0.22; +const SCALE_PADDING = 20; + +interface TVSeriesPageProps { + item: BaseItemDto; + allEpisodes?: BaseItemDto[]; + isLoading?: boolean; +} + +// Focusable button component for TV +const TVFocusableButton: React.FC<{ + onPress: () => void; + children: React.ReactNode; + hasTVPreferredFocus?: boolean; + disabled?: boolean; + variant?: "primary" | "secondary"; + refSetter?: (ref: View | null) => void; +}> = ({ + onPress, + children, + hasTVPreferredFocus, + disabled = false, + variant = "primary", + refSetter, +}) => { + const [focused, setFocused] = useState(false); + const scale = useRef(new Animated.Value(1)).current; + + const animateTo = (v: number) => + Animated.timing(scale, { + toValue: v, + duration: 150, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }).start(); + + const isPrimary = variant === "primary"; + + return ( + { + setFocused(true); + animateTo(1.05); + }} + onBlur={() => { + setFocused(false); + animateTo(1); + }} + hasTVPreferredFocus={hasTVPreferredFocus && !disabled} + disabled={disabled} + focusable={!disabled} + > + + + {children} + + + + ); +}; + +// Season selector button +const TVSeasonButton: React.FC<{ + seasonName: string; + onPress: () => void; + disabled?: boolean; +}> = ({ seasonName, onPress, disabled = false }) => { + const typography = useScaledTVTypography(); + const [focused, setFocused] = useState(false); + const scale = useRef(new Animated.Value(1)).current; + + const animateTo = (v: number) => + Animated.timing(scale, { + toValue: v, + duration: 150, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }).start(); + + return ( + { + setFocused(true); + animateTo(1.05); + }} + onBlur={() => { + setFocused(false); + animateTo(1); + }} + disabled={disabled} + focusable={!disabled} + > + + + + {seasonName} + + + + + + ); +}; + +export const TVSeriesPage: React.FC = ({ + item, + allEpisodes = [], + isLoading: _isLoading, +}) => { + const typography = useScaledTVTypography(); + const { t } = useTranslation(); + const insets = useSafeAreaInsets(); + const router = useRouter(); + const segments = useSegments(); + const from = (segments as string[])[2] || "(home)"; + const isOffline = useOfflineMode(); + const [api] = useAtom(apiAtom); + const [user] = useAtom(userAtom); + const { getDownloadedItems, downloadedItems } = useDownload(); + const { showSeasonModal } = useTVSeriesSeasonModal(); + const { showItemActions } = useTVItemActionModal(); + const seasonModalState = useAtomValue(tvSeriesSeasonModalAtom); + const isSeasonModalVisible = seasonModalState !== null; + + // Auto-play theme music (handles fade in/out and cleanup) + useTVThemeMusic(item.Id); + + // Season state + const [seasonIndexState, setSeasonIndexState] = useAtom(seasonIndexAtom); + const selectedSeasonIndex = useMemo( + () => seasonIndexState[item.Id ?? ""] ?? 1, + [item.Id, seasonIndexState], + ); + + // Focus guide refs (using useState to trigger re-renders when refs are set) + const [playButtonRef, setPlayButtonRef] = useState(null); + const [firstEpisodeRef, setFirstEpisodeRef] = useState(null); + + // ScrollView ref for page scrolling + const mainScrollRef = useRef(null); + // ScrollView ref for scrolling back + const episodeListRef = useRef(null); + const [focusedCount, setFocusedCount] = useState(0); + const prevFocusedCount = useRef(0); + + // Track focus count for episode list + useEffect(() => { + prevFocusedCount.current = focusedCount; + }, [focusedCount]); + + const handleEpisodeFocus = useCallback(() => { + setFocusedCount((c) => c + 1); + }, []); + + const handleEpisodeBlur = useCallback(() => { + setFocusedCount((c) => Math.max(0, c - 1)); + }, []); + + // Fetch seasons + const { data: seasons = [] } = useQuery({ + queryKey: ["seasons", item.Id, isOffline, downloadedItems.length], + queryFn: async () => { + if (isOffline) { + return buildOfflineSeasons(getDownloadedItems(), item.Id!); + } + if (!api || !user?.Id || !item.Id) return []; + + const response = await api.axiosInstance.get( + `${api.basePath}/Shows/${item.Id}/Seasons`, + { + params: { + userId: user.Id, + itemId: item.Id, + Fields: "ItemCounts,PrimaryImageAspectRatio", + }, + headers: { + Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`, + }, + }, + ); + return response.data.Items || []; + }, + staleTime: isOffline ? Infinity : 60 * 1000, + refetchInterval: !isOffline ? 60 * 1000 : undefined, + enabled: isOffline || (!!api && !!user?.Id && !!item.Id), + }); + + // Get selected season ID + const selectedSeasonId = useMemo(() => { + const season = seasons.find( + (s: BaseItemDto) => + s.IndexNumber === selectedSeasonIndex || + s.Name === String(selectedSeasonIndex), + ); + return season?.Id ?? null; + }, [seasons, selectedSeasonIndex]); + + // Get selected season number for offline mode + const selectedSeasonNumber = useMemo(() => { + if (!isOffline) return null; + const season = seasons.find( + (s: BaseItemDto) => + s.IndexNumber === selectedSeasonIndex || + s.Name === String(selectedSeasonIndex), + ); + return season?.IndexNumber ?? null; + }, [isOffline, seasons, selectedSeasonIndex]); + + // Fetch episodes for selected season + const { data: episodesForSeason = [] } = useQuery({ + queryKey: [ + "episodes", + item.Id, + isOffline ? selectedSeasonNumber : selectedSeasonId, + isOffline, + downloadedItems.length, + ], + queryFn: async () => { + if (isOffline) { + return getDownloadedEpisodesForSeason( + getDownloadedItems(), + item.Id!, + selectedSeasonNumber!, + ); + } + if (!api || !user?.Id || !item.Id || !selectedSeasonId) return []; + + const res = await getTvShowsApi(api).getEpisodes({ + seriesId: item.Id, + userId: user.Id, + seasonId: selectedSeasonId, + enableUserData: true, + fields: ["MediaSources", "MediaStreams", "Overview", "Trickplay"], + }); + return res.data.Items || []; + }, + staleTime: isOffline ? Infinity : 60 * 1000, + refetchInterval: !isOffline ? 60 * 1000 : undefined, + enabled: isOffline + ? !!item.Id && selectedSeasonNumber !== null + : !!api && !!user?.Id && !!item.Id && !!selectedSeasonId, + }); + + // Find next unwatched episode + const nextUnwatchedEpisode = useMemo(() => { + // First check all episodes for a "next up" candidate + for (const ep of allEpisodes) { + if (!ep.UserData?.Played) { + // Check if it has progress (continue watching) + if ((ep.UserData?.PlaybackPositionTicks ?? 0) > 0) { + return ep; + } + } + } + + // Find first unwatched + return allEpisodes.find((ep) => !ep.UserData?.Played) || allEpisodes[0]; + }, [allEpisodes]); + + // Get season name for button + const selectedSeasonName = useMemo(() => { + const season = seasons.find( + (s: BaseItemDto) => + s.IndexNumber === selectedSeasonIndex || + s.Name === String(selectedSeasonIndex), + ); + return season?.Name || `Season ${selectedSeasonIndex}`; + }, [seasons, selectedSeasonIndex]); + + // Handle episode press + const handleEpisodePress = useCallback( + (episode: BaseItemDto) => { + const navigation = getItemNavigation(episode, from); + router.push(navigation as any); + }, + [from, router], + ); + + // Handle play next episode + const handlePlayNextEpisode = useCallback(() => { + if (nextUnwatchedEpisode) { + const navigation = getItemNavigation(nextUnwatchedEpisode, from); + router.push(navigation as any); + } + }, [nextUnwatchedEpisode, from, router]); + + // Handle season selection + const handleSeasonSelect = useCallback( + (seasonIdx: number) => { + if (!item.Id) return; + setSeasonIndexState((prev) => ({ + ...prev, + [item.Id!]: seasonIdx, + })); + }, + [item.Id, setSeasonIndexState], + ); + + // Season options for the modal + const seasonOptions = useMemo(() => { + return seasons.map((season: BaseItemDto) => ({ + label: season.Name || `Season ${season.IndexNumber}`, + value: season.IndexNumber ?? 0, + selected: + season.IndexNumber === selectedSeasonIndex || + season.Name === String(selectedSeasonIndex), + })); + }, [seasons, selectedSeasonIndex]); + + // Open season modal + const handleOpenSeasonModal = useCallback(() => { + if (!item.Id) return; + showSeasonModal({ + seasons: seasonOptions, + selectedSeasonIndex, + itemId: item.Id, + onSeasonSelect: handleSeasonSelect, + }); + }, [ + item.Id, + seasonOptions, + selectedSeasonIndex, + handleSeasonSelect, + showSeasonModal, + ]); + + // Get play button text + const playButtonText = useMemo(() => { + if (!nextUnwatchedEpisode) return t("common.play"); + + const season = nextUnwatchedEpisode.ParentIndexNumber; + const episode = nextUnwatchedEpisode.IndexNumber; + const hasProgress = + (nextUnwatchedEpisode.UserData?.PlaybackPositionTicks ?? 0) > 0; + + if (hasProgress) { + return `${t("home.continue")} S${season}:E${episode}`; + } + return `${t("common.play")} S${season}:E${episode}`; + }, [nextUnwatchedEpisode, t]); + + if (!item) return null; + + return ( + + {/* Full-screen backdrop */} + + + {/* Gradient overlays for readability */} + + + + + {/* Main content */} + + {/* Top section - Content + Poster */} + + {/* Left side - Content */} + + + + {/* Action buttons */} + + + + + {playButtonText} + + + + {seasons.length > 1 && ( + + )} + + + + + + {/* Right side - Poster */} + + + + + + + + {/* Episodes section */} + + + {selectedSeasonName} + + + {/* Bidirectional focus guides - stacked together above the list */} + {/* Downward: Play button → first episode */} + {firstEpisodeRef && ( + + )} + {/* Upward: episodes → Play button */} + {playButtonRef && ( + + )} + + + + + + ); +}; diff --git a/components/settings/AppearanceSettings.tsx b/components/settings/AppearanceSettings.tsx index 3953c73f4..844096177 100644 --- a/components/settings/AppearanceSettings.tsx +++ b/components/settings/AppearanceSettings.tsx @@ -1,9 +1,9 @@ -import { useRouter } from "expo-router"; import type React from "react"; import { useMemo } from "react"; import { useTranslation } from "react-i18next"; import { Linking, Switch } from "react-native"; import DisabledSetting from "@/components/settings/DisabledSetting"; +import useRouter from "@/hooks/useAppRouter"; import { useSettings } from "@/utils/atoms/settings"; import { ListGroup } from "../list/ListGroup"; import { ListItem } from "../list/ListItem"; @@ -42,11 +42,13 @@ export const AppearanceSettings: React.FC = () => { } /> - + - updateSettings({ showLargeHomeCarousel: value }) + updateSettings({ mergeNextUpAndContinueWatching: value }) } /> @@ -57,6 +59,16 @@ export const AppearanceSettings: React.FC = () => { title={t("home.settings.other.hide_libraries")} showArrow /> + + + updateSettings({ hideRemoteSessionButton: value }) + } + /> + ); diff --git a/components/settings/AudioToggles.tsx b/components/settings/AudioToggles.tsx index ef7b42f39..93a267bdb 100644 --- a/components/settings/AudioToggles.tsx +++ b/components/settings/AudioToggles.tsx @@ -3,7 +3,7 @@ import { useMemo } from "react"; import { useTranslation } from "react-i18next"; import { Platform, View, type ViewProps } from "react-native"; import { Switch } from "react-native-gesture-handler"; -import { useSettings } from "@/utils/atoms/settings"; +import { AudioTranscodeMode, useSettings } from "@/utils/atoms/settings"; import { Text } from "../common/Text"; import { ListGroup } from "../list/ListGroup"; import { ListItem } from "../list/ListItem"; @@ -54,6 +54,70 @@ export const AudioToggles: React.FC = ({ ...props }) => { ]; }, [cultures, settings?.defaultAudioLanguage, t, updateSettings]); + const audioTranscodeModeLabels: Record = { + [AudioTranscodeMode.Auto]: t("home.settings.audio.transcode_mode.auto"), + [AudioTranscodeMode.ForceStereo]: t( + "home.settings.audio.transcode_mode.stereo", + ), + [AudioTranscodeMode.Allow51]: t("home.settings.audio.transcode_mode.5_1"), + [AudioTranscodeMode.AllowAll]: t( + "home.settings.audio.transcode_mode.passthrough", + ), + }; + + const audioTranscodeModeOptions = useMemo( + () => [ + { + options: [ + { + type: "radio" as const, + label: t("home.settings.audio.transcode_mode.auto"), + value: AudioTranscodeMode.Auto, + selected: + settings?.audioTranscodeMode === AudioTranscodeMode.Auto || + !settings?.audioTranscodeMode, + onPress: () => + updateSettings({ audioTranscodeMode: AudioTranscodeMode.Auto }), + }, + { + type: "radio" as const, + label: t("home.settings.audio.transcode_mode.stereo"), + value: AudioTranscodeMode.ForceStereo, + selected: + settings?.audioTranscodeMode === AudioTranscodeMode.ForceStereo, + onPress: () => + updateSettings({ + audioTranscodeMode: AudioTranscodeMode.ForceStereo, + }), + }, + { + type: "radio" as const, + label: t("home.settings.audio.transcode_mode.5_1"), + value: AudioTranscodeMode.Allow51, + selected: + settings?.audioTranscodeMode === AudioTranscodeMode.Allow51, + onPress: () => + updateSettings({ + audioTranscodeMode: AudioTranscodeMode.Allow51, + }), + }, + { + type: "radio" as const, + label: t("home.settings.audio.transcode_mode.passthrough"), + value: AudioTranscodeMode.AllowAll, + selected: + settings?.audioTranscodeMode === AudioTranscodeMode.AllowAll, + onPress: () => + updateSettings({ + audioTranscodeMode: AudioTranscodeMode.AllowAll, + }), + }, + ], + }, + ], + [settings?.audioTranscodeMode, t, updateSettings], + ); + if (isTv) return null; if (!settings) return null; @@ -98,6 +162,31 @@ export const AudioToggles: React.FC = ({ ...props }) => { title={t("home.settings.audio.language")} /> + + + + { + audioTranscodeModeLabels[ + settings?.audioTranscodeMode || AudioTranscodeMode.Auto + ] + } + + + + } + title={t("home.settings.audio.transcode_mode.title")} + /> + ); diff --git a/components/settings/Dashboard.tsx b/components/settings/Dashboard.tsx deleted file mode 100644 index 768c6e0bc..000000000 --- a/components/settings/Dashboard.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { useRouter } from "expo-router"; -import { useTranslation } from "react-i18next"; -import { View } from "react-native"; -import { useSessions, type useSessionsProps } from "@/hooks/useSessions"; -import { useSettings } from "@/utils/atoms/settings"; -import { ListGroup } from "../list/ListGroup"; -import { ListItem } from "../list/ListItem"; - -export const Dashboard = () => { - const { settings } = useSettings(); - const { sessions = [] } = useSessions({} as useSessionsProps); - const router = useRouter(); - - const { t } = useTranslation(); - - if (!settings) return null; - return ( - - - router.push("/settings/dashboard/sessions")} - title={t("home.settings.dashboard.sessions_title")} - showArrow - /> - - - ); -}; diff --git a/components/settings/DisabledSetting.tsx b/components/settings/DisabledSetting.tsx index 04e24f867..2775331be 100644 --- a/components/settings/DisabledSetting.tsx +++ b/components/settings/DisabledSetting.tsx @@ -11,12 +11,12 @@ const DisabledSetting: React.FC< }} > + {children} {disabled && showText && ( - - {text ?? "Currently disabled by admin."} + + {text ?? "Disabled by admin"} )} - {children} ); diff --git a/components/settings/DownloadSettings.tsx b/components/settings/DownloadSettings.tsx deleted file mode 100644 index 3a0017ac5..000000000 --- a/components/settings/DownloadSettings.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function DownloadSettings() { - return null; -} diff --git a/components/settings/DownloadSettings.tv.tsx b/components/settings/DownloadSettings.tv.tsx deleted file mode 100644 index 3a0017ac5..000000000 --- a/components/settings/DownloadSettings.tv.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function DownloadSettings() { - return null; -} diff --git a/components/settings/GestureControls.tsx b/components/settings/GestureControls.tsx index 55aadaa17..b9c39ef47 100644 --- a/components/settings/GestureControls.tsx +++ b/components/settings/GestureControls.tsx @@ -19,7 +19,9 @@ export const GestureControls: React.FC = ({ ...props }) => { () => pluginSettings?.enableHorizontalSwipeSkip?.locked === true && pluginSettings?.enableLeftSideBrightnessSwipe?.locked === true && - pluginSettings?.enableRightSideVolumeSwipe?.locked === true, + pluginSettings?.enableRightSideVolumeSwipe?.locked === true && + pluginSettings?.hideVolumeSlider?.locked === true && + pluginSettings?.hideBrightnessSlider?.locked === true, [pluginSettings], ); @@ -77,6 +79,38 @@ export const GestureControls: React.FC = ({ ...props }) => { } /> + + + + updateSettings({ hideVolumeSlider }) + } + /> + + + + + updateSettings({ hideBrightnessSlider }) + } + /> + ); diff --git a/components/settings/Jellyseerr.tsx b/components/settings/Jellyseerr.tsx index 470d40a2e..436b46c43 100644 --- a/components/settings/Jellyseerr.tsx +++ b/components/settings/Jellyseerr.tsx @@ -115,9 +115,6 @@ export const JellyseerrSettings = () => { ) : ( - - {t("home.settings.plugins.jellyseerr.jellyseerr_warning")} - {t("home.settings.plugins.jellyseerr.server_url")} diff --git a/components/settings/KefinTweaks.tsx b/components/settings/KefinTweaks.tsx new file mode 100644 index 000000000..228b46921 --- /dev/null +++ b/components/settings/KefinTweaks.tsx @@ -0,0 +1,33 @@ +import { useTranslation } from "react-i18next"; +import { Switch, Text, View } from "react-native"; +import { useSettings } from "@/utils/atoms/settings"; + +export const KefinTweaksSettings = () => { + const { settings, updateSettings } = useSettings(); + const { t } = useTranslation(); + + const isEnabled = settings?.useKefinTweaks ?? false; + + return ( + + + + {t("home.settings.plugins.kefinTweaks.watchlist_enabler")} + + + + + {isEnabled ? t("Watchlist On") : t("Watchlist Off")} + + + updateSettings({ useKefinTweaks: value })} + trackColor={{ false: "#555", true: "purple" }} + thumbColor={isEnabled ? "#fff" : "#ccc"} + /> + + + + ); +}; diff --git a/components/settings/LibraryOptionsSheet.tsx b/components/settings/LibraryOptionsSheet.tsx index c84989b58..e02e22fa1 100644 --- a/components/settings/LibraryOptionsSheet.tsx +++ b/components/settings/LibraryOptionsSheet.tsx @@ -229,7 +229,7 @@ export const LibraryOptionsSheet: React.FC = ({ /> - + string; +} + +function StatusDisplay({ + currentSSID, + isUsingLocalUrl, + t, +}: StatusDisplayProps): React.ReactElement { + const wifiStatus = currentSSID ?? t("home.settings.network.not_connected"); + const urlType = isUsingLocalUrl + ? t("home.settings.network.local") + : t("home.settings.network.remote"); + const urlTypeColor = isUsingLocalUrl ? "text-green-500" : "text-blue-500"; + + return ( + + + + {t("home.settings.network.current_wifi")} + + {wifiStatus} + + + + {t("home.settings.network.using_url")} + + {urlType} + + + ); +} + +export function LocalNetworkSettings(): React.ReactElement | null { + const { t } = useTranslation(); + const { permissionStatus, requestPermission } = useWifiSSID(); + const { isUsingLocalUrl, currentSSID, refreshUrlState } = useServerUrl(); + + const remoteUrl = storage.getString("serverUrl"); + const [config, setConfig] = useState(DEFAULT_CONFIG); + + useEffect(() => { + if (remoteUrl) { + const existingConfig = getServerLocalConfig(remoteUrl); + if (existingConfig) { + setConfig(existingConfig); + } + } + }, [remoteUrl]); + + const saveConfig = useCallback( + (newConfig: LocalNetworkConfig) => { + if (!remoteUrl) return; + setConfig(newConfig); + updateServerLocalConfig(remoteUrl, newConfig); + // Trigger URL re-evaluation after config change + refreshUrlState(); + }, + [remoteUrl, refreshUrlState], + ); + + const handleToggleEnabled = useCallback( + async (enabled: boolean) => { + if (enabled && permissionStatus !== "granted") { + const granted = await requestPermission(); + if (!granted) { + toast.error(t("home.settings.network.permission_denied")); + return; + } + } + saveConfig({ ...config, enabled }); + }, + [config, permissionStatus, requestPermission, saveConfig, t], + ); + + const handleLocalUrlChange = useCallback( + (localUrl: string) => { + saveConfig({ ...config, localUrl }); + }, + [config, saveConfig], + ); + + const handleAddCurrentNetwork = useCallback(() => { + if (!currentSSID) { + toast.error(t("home.settings.network.no_wifi_connected")); + return; + } + if (config.homeWifiSSIDs.includes(currentSSID)) { + toast.info(t("home.settings.network.network_already_added")); + return; + } + saveConfig({ + ...config, + homeWifiSSIDs: [...config.homeWifiSSIDs, currentSSID], + }); + toast.success(t("home.settings.network.network_added")); + }, [config, currentSSID, saveConfig, t]); + + const handleRemoveNetwork = useCallback( + (ssidToRemove: string) => { + saveConfig({ + ...config, + homeWifiSSIDs: config.homeWifiSSIDs.filter((s) => s !== ssidToRemove), + }); + }, + [config, saveConfig], + ); + + if (!remoteUrl) return null; + + const addNetworkButtonText = currentSSID + ? t("home.settings.network.add_current_network", { ssid: currentSSID }) + : t("home.settings.network.not_connected_to_wifi"); + + return ( + + + + + + + + {config.enabled && ( + + + {t("home.settings.network.local_url_hint")} + + } + > + + + + + + + {config.homeWifiSSIDs.map((wifiSSID) => ( + + handleRemoveNetwork(wifiSSID)} + hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} + > + + + + ))} + {config.homeWifiSSIDs.length === 0 && ( + + )} + + + + + + + + + )} + + {permissionStatus === "denied" && ( + + + {t("home.settings.network.permission_denied_explanation")} + + + )} + + ); +} diff --git a/components/settings/MediaContext.tsx b/components/settings/MediaContext.tsx index c6c7856e7..67bcf8ea0 100644 --- a/components/settings/MediaContext.tsx +++ b/components/settings/MediaContext.tsx @@ -4,9 +4,10 @@ import type { UserDto, } from "@jellyfin/sdk/lib/generated-client/models"; import { getLocalizationApi, getUserApi } from "@jellyfin/sdk/lib/utils/api"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useQuery } from "@tanstack/react-query"; import { useAtomValue } from "jotai"; import { createContext, type ReactNode, useContext, useEffect } from "react"; +import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient"; import { apiAtom } from "@/providers/JellyfinProvider"; import { type Settings, useSettings } from "@/utils/atoms/settings"; @@ -30,7 +31,7 @@ export const useMedia = () => { export const MediaProvider = ({ children }: { children: ReactNode }) => { const { settings, updateSettings } = useSettings(); const api = useAtomValue(apiAtom); - const queryClient = useQueryClient(); + const queryClient = useNetworkAwareQueryClient(); const updateSetingsWrapper = (update: Partial) => { const updateUserConfiguration = async ( diff --git a/components/settings/MpvBufferSettings.tsx b/components/settings/MpvBufferSettings.tsx new file mode 100644 index 000000000..6df374123 --- /dev/null +++ b/components/settings/MpvBufferSettings.tsx @@ -0,0 +1,100 @@ +import { Ionicons } from "@expo/vector-icons"; +import type React from "react"; +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { View } from "react-native"; +import { Stepper } from "@/components/inputs/Stepper"; +import { PlatformDropdown } from "@/components/PlatformDropdown"; +import { type MpvCacheMode, useSettings } from "@/utils/atoms/settings"; +import { Text } from "../common/Text"; +import { ListGroup } from "../list/ListGroup"; +import { ListItem } from "../list/ListItem"; + +const CACHE_MODE_OPTIONS: { key: string; value: MpvCacheMode }[] = [ + { key: "home.settings.buffer.cache_auto", value: "auto" }, + { key: "home.settings.buffer.cache_yes", value: "yes" }, + { key: "home.settings.buffer.cache_no", value: "no" }, +]; + +export const MpvBufferSettings: React.FC = () => { + const { settings, updateSettings } = useSettings(); + const { t } = useTranslation(); + + const cacheModeOptions = useMemo( + () => [ + { + options: CACHE_MODE_OPTIONS.map((option) => ({ + type: "radio" as const, + label: t(option.key), + value: option.value, + selected: option.value === (settings?.mpvCacheEnabled ?? "auto"), + onPress: () => updateSettings({ mpvCacheEnabled: option.value }), + })), + }, + ], + [settings?.mpvCacheEnabled, t, updateSettings], + ); + + const currentCacheModeLabel = useMemo(() => { + const option = CACHE_MODE_OPTIONS.find( + (o) => o.value === (settings?.mpvCacheEnabled ?? "auto"), + ); + return option ? t(option.key) : t("home.settings.buffer.cache_auto"); + }, [settings?.mpvCacheEnabled, t]); + + if (!settings) return null; + + return ( + + + + + {currentCacheModeLabel} + + + + } + title={t("home.settings.buffer.cache_mode")} + /> + + + + updateSettings({ mpvCacheSeconds: value })} + appendValue='s' + /> + + + + updateSettings({ mpvDemuxerMaxBytes: value })} + appendValue=' MB' + /> + + + + + updateSettings({ mpvDemuxerMaxBackBytes: value }) + } + appendValue=' MB' + /> + + + ); +}; diff --git a/components/settings/MpvSubtitleSettings.tsx b/components/settings/MpvSubtitleSettings.tsx new file mode 100644 index 000000000..4ef2a8001 --- /dev/null +++ b/components/settings/MpvSubtitleSettings.tsx @@ -0,0 +1,150 @@ +import { Ionicons } from "@expo/vector-icons"; +import { useMemo } from "react"; +import { Platform, Switch, View, type ViewProps } from "react-native"; +import { Stepper } from "@/components/inputs/Stepper"; +import { Text } from "../common/Text"; +import { ListGroup } from "../list/ListGroup"; +import { ListItem } from "../list/ListItem"; +import { PlatformDropdown } from "../PlatformDropdown"; +import { useMedia } from "./MediaContext"; + +interface Props extends ViewProps {} + +type AlignX = "left" | "center" | "right"; +type AlignY = "top" | "center" | "bottom"; + +export const MpvSubtitleSettings: React.FC = ({ ...props }) => { + const isTv = Platform.isTV; + const media = useMedia(); + const { settings, updateSettings } = media; + + const alignXOptions: AlignX[] = ["left", "center", "right"]; + const alignYOptions: AlignY[] = ["top", "center", "bottom"]; + + const alignXLabels: Record = { + left: "Left", + center: "Center", + right: "Right", + }; + + const alignYLabels: Record = { + top: "Top", + center: "Center", + bottom: "Bottom", + }; + + const alignXOptionGroups = useMemo(() => { + const options = alignXOptions.map((align) => ({ + type: "radio" as const, + label: alignXLabels[align], + value: align, + selected: align === (settings?.mpvSubtitleAlignX ?? "center"), + onPress: () => updateSettings({ mpvSubtitleAlignX: align }), + })); + return [{ options }]; + }, [settings?.mpvSubtitleAlignX, updateSettings]); + + const alignYOptionGroups = useMemo(() => { + const options = alignYOptions.map((align) => ({ + type: "radio" as const, + label: alignYLabels[align], + value: align, + selected: align === (settings?.mpvSubtitleAlignY ?? "bottom"), + onPress: () => updateSettings({ mpvSubtitleAlignY: align }), + })); + return [{ options }]; + }, [settings?.mpvSubtitleAlignY, updateSettings]); + + if (!settings) return null; + + return ( + + + Advanced subtitle customization for MPV player + + } + > + {!isTv && ( + <> + + + updateSettings({ mpvSubtitleMarginY: value }) + } + /> + + + + + + {alignXLabels[settings?.mpvSubtitleAlignX ?? "center"]} + + + + } + title='Horizontal Alignment' + /> + + + + + + {alignYLabels[settings?.mpvSubtitleAlignY ?? "bottom"]} + + + + } + title='Vertical Alignment' + /> + + + )} + + + + updateSettings({ mpvSubtitleBackgroundEnabled: value }) + } + /> + + + {settings.mpvSubtitleBackgroundEnabled && ( + + + updateSettings({ mpvSubtitleBackgroundOpacity: value }) + } + /> + + )} + + + ); +}; diff --git a/components/settings/MpvVoSettings.tsx b/components/settings/MpvVoSettings.tsx new file mode 100644 index 000000000..164829c41 --- /dev/null +++ b/components/settings/MpvVoSettings.tsx @@ -0,0 +1,66 @@ +import { Ionicons } from "@expo/vector-icons"; +import type React from "react"; +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { Platform, View } from "react-native"; +import { PlatformDropdown } from "@/components/PlatformDropdown"; +import { type MpvVoDriver, useSettings } from "@/utils/atoms/settings"; +import { Text } from "../common/Text"; +import { ListGroup } from "../list/ListGroup"; +import { ListItem } from "../list/ListItem"; + +const VO_DRIVER_OPTIONS: { key: string; value: MpvVoDriver }[] = [ + { key: "home.settings.vo_driver.gpu_next", value: "gpu-next" }, + { key: "home.settings.vo_driver.gpu", value: "gpu" }, +]; + +export const MpvVoSettings: React.FC = () => { + const { settings, updateSettings } = useSettings(); + const { t } = useTranslation(); + + const voDriverOptions = useMemo( + () => [ + { + options: VO_DRIVER_OPTIONS.map((option) => ({ + type: "radio" as const, + label: t(option.key), + value: option.value, + selected: option.value === (settings?.mpvVoDriver ?? "gpu-next"), + onPress: () => updateSettings({ mpvVoDriver: option.value }), + })), + }, + ], + [settings?.mpvVoDriver, t, updateSettings], + ); + + const currentVoDriverLabel = useMemo(() => { + const option = VO_DRIVER_OPTIONS.find( + (o) => o.value === (settings?.mpvVoDriver ?? "gpu-next"), + ); + return option ? t(option.key) : t("home.settings.vo_driver.gpu_next"); + }, [settings?.mpvVoDriver, t]); + + // Only show on Android + if (Platform.OS !== "android") return null; + + if (!settings) return null; + + return ( + + + + + {currentVoDriverLabel} + + + + } + title={t("home.settings.vo_driver.vo_mode")} + /> + + + ); +}; diff --git a/components/settings/OtherSettings.tsx b/components/settings/OtherSettings.tsx index 452edab0a..bd339c543 100644 --- a/components/settings/OtherSettings.tsx +++ b/components/settings/OtherSettings.tsx @@ -1,5 +1,4 @@ import { Ionicons } from "@expo/vector-icons"; -import { useRouter } from "expo-router"; import { TFunction } from "i18next"; import type React from "react"; import { useMemo } from "react"; @@ -8,6 +7,7 @@ import { Linking, Switch, View } from "react-native"; import { BITRATES } from "@/components/BitrateSelector"; import { PlatformDropdown } from "@/components/PlatformDropdown"; import DisabledSetting from "@/components/settings/DisabledSetting"; +import useRouter from "@/hooks/useAppRouter"; import * as ScreenOrientation from "@/packages/expo-screen-orientation"; import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings"; import { Text } from "../common/Text"; @@ -141,36 +141,6 @@ export const OtherSettings: React.FC = () => { /> - {/* {(Platform.OS === "ios" || Platform.isTVOS)&& ( - - t(`home.settings.other.video_players.${VideoPlayer[item]}`)} - title={ - - - {t(`home.settings.other.video_players.${VideoPlayer[settings.defaultPlayer]}`)} - - - - } - label={t("home.settings.other.orientation")} - onSelected={(defaultPlayer) => - updateSettings({ defaultPlayer }) - } - /> - - )} */} - { } /> - - - updateSettings({ showLargeHomeCarousel: value }) - } - /> - router.push("/settings/hide-libraries/page")} title={t("home.settings.other.hide_libraries")} @@ -234,7 +196,10 @@ export const OtherSettings: React.FC = () => { } /> - + { const orientations = [ ScreenOrientation.OrientationLock.DEFAULT, ScreenOrientation.OrientationLock.PORTRAIT_UP, + ScreenOrientation.OrientationLock.LANDSCAPE, ScreenOrientation.OrientationLock.LANDSCAPE_LEFT, ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT, ]; @@ -38,6 +40,8 @@ export const PlaybackControlsSettings: React.FC = () => { "home.settings.other.orientations.DEFAULT", [ScreenOrientation.OrientationLock.PORTRAIT_UP]: "home.settings.other.orientations.PORTRAIT_UP", + [ScreenOrientation.OrientationLock.LANDSCAPE]: + "home.settings.other.orientations.LANDSCAPE", [ScreenOrientation.OrientationLock.LANDSCAPE_LEFT]: "home.settings.other.orientations.LANDSCAPE_LEFT", [ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT]: @@ -92,6 +96,21 @@ export const PlaybackControlsSettings: React.FC = () => { [settings?.maxAutoPlayEpisodeCount?.key, t, updateSettings], ); + const playbackSpeedOptions = useMemo( + () => [ + { + options: PLAYBACK_SPEEDS.map((speed) => ({ + type: "radio" as const, + label: speed.label, + value: speed.value, + selected: speed.value === settings?.defaultPlaybackSpeed, + onPress: () => updateSettings({ defaultPlaybackSpeed: speed.value }), + })), + }, + ], + [settings?.defaultPlaybackSpeed, updateSettings], + ); + if (!settings) return null; return ( @@ -158,6 +177,30 @@ export const PlaybackControlsSettings: React.FC = () => { /> + + + + {PLAYBACK_SPEEDS.find( + (s) => s.value === settings.defaultPlaybackSpeed, + )?.label ?? "1x"} + + + + } + title={t("home.settings.other.default_playback_speed")} + /> + + { /> - + + + updateSettings({ autoPlayNextEpisode }) + } + /> + + + { title='Marlin Search' showArrow /> + router.push("/settings/plugins/streamystats/page")} + title='Streamystats' + showArrow + /> + router.push("/settings/plugins/kefinTweaks/page")} + title='KefinTweaks' + showArrow + /> ); }; diff --git a/components/settings/QuickConnect.tsx b/components/settings/QuickConnect.tsx index d0cbf85e0..643a902d2 100644 --- a/components/settings/QuickConnect.tsx +++ b/components/settings/QuickConnect.tsx @@ -7,7 +7,7 @@ import { import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api"; import { useAtom } from "jotai"; import type React from "react"; -import { useCallback, useRef, useState } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Alert, Platform, View, type ViewProps } from "react-native"; import { useHaptic } from "@/hooks/useHaptic"; @@ -28,6 +28,11 @@ export const QuickConnect: React.FC = ({ ...props }) => { const bottomSheetModalRef = useRef(null); const successHapticFeedback = useHaptic("success"); const errorHapticFeedback = useHaptic("error"); + const snapPoints = useMemo( + () => (Platform.OS === "android" ? ["100%"] : ["40%"]), + [], + ); + const isAndroid = Platform.OS === "android"; const { t } = useTranslation(); @@ -53,7 +58,7 @@ export const QuickConnect: React.FC = ({ ...props }) => { successHapticFeedback(); Alert.alert( t("home.settings.quick_connect.success"), - t("home.settings.quick_connect.quick_connect_autorized"), + t("home.settings.quick_connect.quick_connect_authorized"), ); setQuickConnectCode(undefined); bottomSheetModalRef?.current?.close(); @@ -92,7 +97,7 @@ export const QuickConnect: React.FC = ({ ...props }) => { = ({ ...props }) => { backgroundColor: "#171717", }} backdropComponent={renderBackdrop} - keyboardBehavior='interactive' + keyboardBehavior={isAndroid ? "fillParent" : "interactive"} keyboardBlurBehavior='restore' android_keyboardInputMode='adjustResize' + topInset={isAndroid ? 0 : undefined} > diff --git a/components/settings/StorageSettings.tsx b/components/settings/StorageSettings.tsx index da985da51..b7f5d8554 100644 --- a/components/settings/StorageSettings.tsx +++ b/components/settings/StorageSettings.tsx @@ -1,8 +1,8 @@ import { BottomSheetModal } from "@gorhom/bottom-sheet"; -import { useQuery } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useRef } from "react"; import { useTranslation } from "react-i18next"; -import { Platform, View } from "react-native"; +import { Alert, Platform, View } from "react-native"; import { toast } from "sonner-native"; import { Text } from "@/components/common/Text"; import { Colors } from "@/constants/Colors"; @@ -18,6 +18,7 @@ export const StorageSettings = () => { const { deleteAllFiles, appSizeUsage } = useDownload(); const { settings } = useSettings(); const { t } = useTranslation(); + const queryClient = useQueryClient(); const successHapticFeedback = useHaptic("success"); const errorHapticFeedback = useHaptic("error"); const bottomSheetModalRef = useRef(null); @@ -34,6 +35,8 @@ export const StorageSettings = () => { used: (app.total - app.remaining) / app.total, }; }, + // Keep the bar moving while a download is writing to disk. + refetchInterval: 10 * 1000, }); const { data: storageLabel } = useQuery({ @@ -42,14 +45,34 @@ export const StorageSettings = () => { enabled: Platform.OS === "android", }); - const onDeleteClicked = async () => { - try { - await deleteAllFiles(); - successHapticFeedback(); - } catch (_e) { - errorHapticFeedback(); - toast.error(t("home.settings.toasts.error_deleting_files")); - } + const onDeleteClicked = () => { + Alert.alert( + t("home.settings.storage.delete_all_downloaded_files_confirm"), + t("home.settings.storage.delete_all_downloaded_files_confirm_desc"), + [ + { + text: t("common.cancel"), + style: "cancel", + }, + { + text: t("common.ok"), + style: "destructive", + onPress: async () => { + try { + await deleteAllFiles(); + successHapticFeedback(); + } catch (_e) { + errorHapticFeedback(); + toast.error(t("home.settings.toasts.error_deleting_files")); + } finally { + // Reflect the freed space immediately instead of waiting for + // the next poll. + queryClient.invalidateQueries({ queryKey: ["appSize"] }); + } + }, + }, + ], + ); }; const calculatePercentage = (value: number, total: number) => { diff --git a/components/settings/SubtitleToggles.tsx b/components/settings/SubtitleToggles.tsx index 5a6bfee33..77f5453e5 100644 --- a/components/settings/SubtitleToggles.tsx +++ b/components/settings/SubtitleToggles.tsx @@ -1,16 +1,11 @@ import { Ionicons } from "@expo/vector-icons"; import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client"; -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { Platform, View, type ViewProps } from "react-native"; import { Switch } from "react-native-gesture-handler"; +import { Input } from "@/components/common/Input"; import { Stepper } from "@/components/inputs/Stepper"; -import { - OUTLINE_THICKNESS, - type OutlineThickness, - VLC_COLORS, - type VLCColor, -} from "@/constants/SubtitleConstants"; import { useSettings } from "@/utils/atoms/settings"; import { Text } from "../common/Text"; import { ListGroup } from "../list/ListGroup"; @@ -29,6 +24,11 @@ export const SubtitleToggles: React.FC = ({ ...props }) => { const cultures = media.cultures; const { t } = useTranslation(); + // Local state for OpenSubtitles API key (only commit on blur) + const [openSubtitlesApiKey, setOpenSubtitlesApiKey] = useState( + settings?.openSubtitlesApiKey || "", + ); + const subtitleModes = [ SubtitlePlaybackMode.Default, SubtitlePlaybackMode.Smart, @@ -92,84 +92,6 @@ export const SubtitleToggles: React.FC = ({ ...props }) => { ]; }, [settings?.subtitleMode, t, updateSettings]); - const textColorOptionGroups = useMemo(() => { - const colors = Object.keys(VLC_COLORS) as VLCColor[]; - const options = colors.map((color) => ({ - type: "radio" as const, - label: t(`home.settings.subtitles.colors.${color}`), - value: color, - selected: (settings?.vlcTextColor || "White") === color, - onPress: () => updateSettings({ vlcTextColor: color }), - })); - - return [{ options }]; - }, [settings?.vlcTextColor, t, updateSettings]); - - const backgroundColorOptionGroups = useMemo(() => { - const colors = Object.keys(VLC_COLORS) as VLCColor[]; - const options = colors.map((color) => ({ - type: "radio" as const, - label: t(`home.settings.subtitles.colors.${color}`), - value: color, - selected: (settings?.vlcBackgroundColor || "Black") === color, - onPress: () => updateSettings({ vlcBackgroundColor: color }), - })); - - return [{ options }]; - }, [settings?.vlcBackgroundColor, t, updateSettings]); - - const outlineColorOptionGroups = useMemo(() => { - const colors = Object.keys(VLC_COLORS) as VLCColor[]; - const options = colors.map((color) => ({ - type: "radio" as const, - label: t(`home.settings.subtitles.colors.${color}`), - value: color, - selected: (settings?.vlcOutlineColor || "Black") === color, - onPress: () => updateSettings({ vlcOutlineColor: color }), - })); - - return [{ options }]; - }, [settings?.vlcOutlineColor, t, updateSettings]); - - const outlineThicknessOptionGroups = useMemo(() => { - const thicknesses = Object.keys(OUTLINE_THICKNESS) as OutlineThickness[]; - const options = thicknesses.map((thickness) => ({ - type: "radio" as const, - label: t(`home.settings.subtitles.thickness.${thickness}`), - value: thickness, - selected: (settings?.vlcOutlineThickness || "Normal") === thickness, - onPress: () => updateSettings({ vlcOutlineThickness: thickness }), - })); - - return [{ options }]; - }, [settings?.vlcOutlineThickness, t, updateSettings]); - - const backgroundOpacityOptionGroups = useMemo(() => { - const opacities = [0, 32, 64, 96, 128, 160, 192, 224, 255]; - const options = opacities.map((opacity) => ({ - type: "radio" as const, - label: `${Math.round((opacity / 255) * 100)}%`, - value: opacity, - selected: (settings?.vlcBackgroundOpacity ?? 128) === opacity, - onPress: () => updateSettings({ vlcBackgroundOpacity: opacity }), - })); - - return [{ options }]; - }, [settings?.vlcBackgroundOpacity, updateSettings]); - - const outlineOpacityOptionGroups = useMemo(() => { - const opacities = [0, 32, 64, 96, 128, 160, 192, 224, 255]; - const options = opacities.map((opacity) => ({ - type: "radio" as const, - label: `${Math.round((opacity / 255) * 100)}%`, - value: opacity, - selected: (settings?.vlcOutlineOpacity ?? 255) === opacity, - onPress: () => updateSettings({ vlcOutlineOpacity: opacity }), - })); - - return [{ options }]; - }, [settings?.vlcOutlineOpacity, updateSettings]); - if (isTv) return null; if (!settings) return null; @@ -244,132 +166,54 @@ export const SubtitleToggles: React.FC = ({ ...props }) => { disabled={pluginSettings?.subtitleSize?.locked} > updateSettings({ subtitleSize })} - /> - - - - - {t( - `home.settings.subtitles.colors.${settings?.vlcTextColor || "White"}`, - )} - - - + step={0.1} + min={0.1} + max={3.0} + onUpdate={(value) => + updateSettings({ mpvSubtitleScale: Math.round(value * 10) / 10 }) } - title={t("home.settings.subtitles.text_color")} /> - - - - {t( - `home.settings.subtitles.colors.${settings?.vlcBackgroundColor || "Black"}`, - )} - - - + + + {/* OpenSubtitles API Key for client-side subtitle fetching */} + + {t("home.settings.subtitles.opensubtitles_hint") || + "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured."} + + } + > + + + {t("home.settings.subtitles.opensubtitles_api_key") || "API Key"} + + { + updateSettings({ openSubtitlesApiKey }); + }} + autoCapitalize='none' + autoCorrect={false} + secureTextEntry /> - - - - - {t( - `home.settings.subtitles.colors.${settings?.vlcOutlineColor || "Black"}`, - )} - - - - } - title={t("home.settings.subtitles.outline_color")} - /> - - - - - {t( - `home.settings.subtitles.thickness.${settings?.vlcOutlineThickness || "Normal"}`, - )} - - - - } - title={t("home.settings.subtitles.outline_thickness")} - /> - - - - {`${Math.round(((settings?.vlcBackgroundOpacity ?? 128) / 255) * 100)}%`} - - - } - title={t("home.settings.subtitles.background_opacity")} - /> - - - - {`${Math.round(((settings?.vlcOutlineOpacity ?? 255) / 255) * 100)}%`} - - - } - title={t("home.settings.subtitles.outline_opacity")} - /> - - - updateSettings({ vlcIsBold: value })} - /> - + + {t("home.settings.subtitles.opensubtitles_get_key") || + "Get your free API key at opensubtitles.com/en/consumers"} + + ); diff --git a/components/settings/UserInfo.tsx b/components/settings/UserInfo.tsx index 56b6413d2..9c55293d8 100644 --- a/components/settings/UserInfo.tsx +++ b/components/settings/UserInfo.tsx @@ -1,8 +1,8 @@ -import * as Application from "expo-application"; import { useAtom } from "jotai"; import { useTranslation } from "react-i18next"; import { View, type ViewProps } from "react-native"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { getVersionInfo } from "@/utils/version"; import { ListGroup } from "../list/ListGroup"; import { ListItem } from "../list/ListItem"; @@ -13,10 +13,9 @@ export const UserInfo: React.FC = ({ ...props }) => { const [user] = useAtom(userAtom); const { t } = useTranslation(); - const version = - Application?.nativeApplicationVersion || - Application?.nativeBuildVersion || - "N/A"; + // Graduated build identifier — see utils/version.ts: + // dev → "0.54.1 · branch · commit", develop/CI → "0.54.1 · commit · #run", production → "0.54.1". + const { display: version } = getVersionInfo(); return ( diff --git a/components/stacks/NestedTabPageStack.tsx b/components/stacks/NestedTabPageStack.tsx index 87f92db60..3c5fa57f1 100644 --- a/components/stacks/NestedTabPageStack.tsx +++ b/components/stacks/NestedTabPageStack.tsx @@ -1,25 +1,27 @@ -import type { ParamListBase, RouteProp } from "@react-navigation/native"; -import type { NativeStackNavigationOptions } from "@react-navigation/native-stack"; +import { Stack } from "expo-router"; +import type { ComponentProps } from "react"; import { Platform } from "react-native"; import { HeaderBackButton } from "../common/HeaderBackButton"; -type ICommonScreenOptions = - | NativeStackNavigationOptions - | ((prop: { - route: RouteProp; - navigation: any; - }) => NativeStackNavigationOptions); +type ICommonScreenOptions = ComponentProps["options"]; export const commonScreenOptions: ICommonScreenOptions = { title: "", - headerShown: true, + headerShown: !Platform.isTV, headerTransparent: Platform.OS === "ios", headerShadowVisible: false, headerBlurEffect: "none", headerLeft: () => , }; -const routes = ["persons/[personId]", "items/page", "series/[id]"]; +const routes = [ + "persons/[personId]", + "items/page", + "series/[id]", + "music/album/[albumId]", + "music/artist/[artistId]", + "music/playlist/[playlistId]", +]; export const nestedTabPageScreenOptions: Record = Object.fromEntries(routes.map((route) => [route, commonScreenOptions])); diff --git a/components/tv/TVActorCard.tsx b/components/tv/TVActorCard.tsx new file mode 100644 index 000000000..31e4be636 --- /dev/null +++ b/components/tv/TVActorCard.tsx @@ -0,0 +1,119 @@ +import { Ionicons } from "@expo/vector-icons"; +import { Image } from "expo-image"; +import React from "react"; +import { Animated, Pressable, View } from "react-native"; +import { Text } from "@/components/common/Text"; +import { useScaledTVTypography } from "@/constants/TVTypography"; +import { scaleSize } from "@/utils/scaleSize"; +import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation"; + +export interface TVActorCardProps { + person: { + Id?: string | null; + Name?: string | null; + Role?: string | null; + }; + apiBasePath?: string; + onPress: () => void; + hasTVPreferredFocus?: boolean; +} + +export const TVActorCard = React.forwardRef( + ({ person, apiBasePath, onPress, hasTVPreferredFocus }, ref) => { + const typography = useScaledTVTypography(); + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation(); + + const imageUrl = person.Id + ? `${apiBasePath}/Items/${person.Id}/Images/Primary?fillWidth=280&fillHeight=280&quality=90` + : null; + + return ( + + + + {imageUrl ? ( + + ) : ( + + + + )} + + + + {person.Name} + + + {person.Role && ( + + {person.Role} + + )} + + + ); + }, +); diff --git a/components/tv/TVBackdrop.tsx b/components/tv/TVBackdrop.tsx new file mode 100644 index 000000000..315afe41a --- /dev/null +++ b/components/tv/TVBackdrop.tsx @@ -0,0 +1,56 @@ +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import { LinearGradient } from "expo-linear-gradient"; +import React from "react"; +import { View } from "react-native"; +import { ItemImage } from "@/components/common/ItemImage"; + +export interface TVBackdropProps { + item: BaseItemDto; +} + +export const TVBackdrop: React.FC = React.memo(({ item }) => { + return ( + + + {/* Gradient overlays for readability */} + + + + ); +}); diff --git a/components/tv/TVButton.tsx b/components/tv/TVButton.tsx new file mode 100644 index 000000000..606b21c84 --- /dev/null +++ b/components/tv/TVButton.tsx @@ -0,0 +1,116 @@ +import React from "react"; +import { Animated, Pressable, View, type ViewStyle } from "react-native"; +import { scaleSize } from "@/utils/scaleSize"; +import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation"; + +export interface TVButtonProps { + onPress: () => void; + children: React.ReactNode; + variant?: "primary" | "secondary" | "glass"; + hasTVPreferredFocus?: boolean; + disabled?: boolean; + style?: ViewStyle; + scaleAmount?: number; + square?: boolean; + refSetter?: (ref: View | null) => void; + nextFocusDown?: number; + nextFocusUp?: number; +} + +const getButtonStyles = ( + variant: "primary" | "secondary" | "glass", + focused: boolean, +) => { + switch (variant) { + case "glass": + return { + backgroundColor: focused + ? "rgba(255, 255, 255, 0.25)" + : "rgba(255, 255, 255, 0.1)", + shadowColor: "#fff", + borderWidth: 1, + borderColor: focused + ? "rgba(255, 255, 255, 0.4)" + : "rgba(255, 255, 255, 0.15)", + }; + case "secondary": + return { + backgroundColor: focused + ? "rgba(255, 255, 255, 0.3)" + : "rgba(255, 255, 255, 0.15)", + shadowColor: "#fff", + borderWidth: 2, + borderColor: focused ? "#fff" : "rgba(255, 255, 255, 0.2)", + }; + default: + return { + backgroundColor: focused ? "#ffffff" : "rgba(255, 255, 255, 0.9)", + shadowColor: "#fff", + borderWidth: 1, + borderColor: "transparent", + }; + } +}; + +export const TVButton: React.FC = ({ + onPress, + children, + variant = "primary", + hasTVPreferredFocus = false, + disabled = false, + style, + scaleAmount = 1.04, + square = false, + refSetter, + nextFocusDown, + nextFocusUp, +}) => { + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation({ scaleAmount }); + + const buttonStyles = getButtonStyles(variant, focused); + + return ( + + + + {children} + + + + ); +}; diff --git a/components/tv/TVCancelButton.tsx b/components/tv/TVCancelButton.tsx new file mode 100644 index 000000000..46316b7a2 --- /dev/null +++ b/components/tv/TVCancelButton.tsx @@ -0,0 +1,63 @@ +import { Ionicons } from "@expo/vector-icons"; +import React from "react"; +import { Animated, Pressable } from "react-native"; +import { Text } from "@/components/common/Text"; +import { useScaledTVTypography } from "@/constants/TVTypography"; +import { scaleSize } from "@/utils/scaleSize"; +import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation"; + +export interface TVCancelButtonProps { + onPress: () => void; + label?: string; + disabled?: boolean; +} + +export const TVCancelButton: React.FC = ({ + onPress, + label = "Cancel", + disabled = false, +}) => { + const typography = useScaledTVTypography(); + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation({ scaleAmount: 1.05, duration: 120 }); + + return ( + + + + + {label} + + + + ); +}; diff --git a/components/tv/TVCastCrewText.tsx b/components/tv/TVCastCrewText.tsx new file mode 100644 index 000000000..2c07b497b --- /dev/null +++ b/components/tv/TVCastCrewText.tsx @@ -0,0 +1,78 @@ +import type { BaseItemPerson } from "@jellyfin/sdk/lib/generated-client/models"; +import React from "react"; +import { useTranslation } from "react-i18next"; +import { View } from "react-native"; +import { Text } from "@/components/common/Text"; +import { useScaledTVTypography } from "@/constants/TVTypography"; +import { scaleSize } from "@/utils/scaleSize"; + +export interface TVCastCrewTextProps { + director?: BaseItemPerson | null; + cast?: BaseItemPerson[]; + /** Hide the cast section (e.g., when visual cast section is shown) */ + hideCast?: boolean; +} + +export const TVCastCrewText: React.FC = React.memo( + ({ director, cast, hideCast = false }) => { + const typography = useScaledTVTypography(); + const { t } = useTranslation(); + + if (!director && (!cast || cast.length === 0)) { + return null; + } + + return ( + + + {t("item_card.cast_and_crew")} + + + {director && ( + + + {t("item_card.director")} + + + {director.Name} + + + )} + {!hideCast && cast && cast.length > 0 && ( + + + {t("item_card.cast")} + + + {cast.map((c) => c.Name).join(", ")} + + + )} + + + ); + }, +); diff --git a/components/tv/TVCastSection.tsx b/components/tv/TVCastSection.tsx new file mode 100644 index 000000000..fa84103b3 --- /dev/null +++ b/components/tv/TVCastSection.tsx @@ -0,0 +1,85 @@ +import type { BaseItemPerson } from "@jellyfin/sdk/lib/generated-client/models"; +import React from "react"; +import { useTranslation } from "react-i18next"; +import { ScrollView, TVFocusGuideView, View } from "react-native"; +import { Text } from "@/components/common/Text"; +import { useScaledTVSizes } from "@/constants/TVSizes"; +import { useScaledTVTypography } from "@/constants/TVTypography"; +import { TVActorCard } from "./TVActorCard"; + +export interface TVCastSectionProps { + cast: BaseItemPerson[]; + apiBasePath?: string; + onActorPress: (personId: string) => void; + /** Setter function for the first actor card ref (for focus guide) */ + firstActorRefSetter?: (ref: View | null) => void; + /** Ref to focus guide destination for upward navigation */ + upwardFocusDestination?: View | null; + /** Custom horizontal padding (overrides default 80) */ + horizontalPadding?: number; +} + +export const TVCastSection: React.FC = React.memo( + ({ + cast, + apiBasePath, + onActorPress, + firstActorRefSetter, + upwardFocusDestination, + horizontalPadding = 80, + }) => { + const typography = useScaledTVTypography(); + const sizes = useScaledTVSizes(); + const { t } = useTranslation(); + + if (cast.length === 0) { + return null; + } + + return ( + + + {t("item_card.cast")} + + {/* Focus guide to direct upward navigation from cast back to options */} + {upwardFocusDestination && ( + + )} + + {cast.map((person, index) => ( + { + if (person.Id) { + onActorPress(person.Id); + } + }} + /> + ))} + + + ); + }, +); diff --git a/components/tv/TVControlButton.tsx b/components/tv/TVControlButton.tsx new file mode 100644 index 000000000..9870a6eda --- /dev/null +++ b/components/tv/TVControlButton.tsx @@ -0,0 +1,82 @@ +import { Ionicons } from "@expo/vector-icons"; +import type { FC } from "react"; +import { + Pressable, + Animated as RNAnimated, + StyleSheet, + type View, +} from "react-native"; +import { scaleSize } from "@/utils/scaleSize"; +import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation"; + +export interface TVControlButtonProps { + icon: keyof typeof Ionicons.glyphMap; + onPress: () => void; + onLongPress?: () => void; + onPressOut?: () => void; + disabled?: boolean; + hasTVPreferredFocus?: boolean; + size?: number; + delayLongPress?: number; + /** Callback ref setter for focus guide destination pattern */ + refSetter?: (ref: View | null) => void; +} + +export const TVControlButton: FC = ({ + icon, + onPress, + onLongPress, + onPressOut, + disabled, + hasTVPreferredFocus, + size = 32, + delayLongPress = 300, + refSetter, +}) => { + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation({ scaleAmount: 1.15, duration: 120 }); + + return ( + + + + + + ); +}; + +const styles = StyleSheet.create({ + button: { + width: scaleSize(64), + height: scaleSize(64), + borderRadius: scaleSize(32), + borderWidth: scaleSize(2), + justifyContent: "center", + alignItems: "center", + }, +}); diff --git a/components/tv/TVFavoriteButton.tsx b/components/tv/TVFavoriteButton.tsx new file mode 100644 index 000000000..330934e04 --- /dev/null +++ b/components/tv/TVFavoriteButton.tsx @@ -0,0 +1,32 @@ +import { Ionicons } from "@expo/vector-icons"; +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; +import React from "react"; +import { useFavorite } from "@/hooks/useFavorite"; +import { TVButton } from "./TVButton"; + +export interface TVFavoriteButtonProps { + item: BaseItemDto; + disabled?: boolean; +} + +export const TVFavoriteButton: React.FC = ({ + item, + disabled, +}) => { + const { isFavorite, toggleFavorite } = useFavorite(item); + + return ( + + + + ); +}; diff --git a/components/tv/TVFilterButton.tsx b/components/tv/TVFilterButton.tsx new file mode 100644 index 000000000..2075495a8 --- /dev/null +++ b/components/tv/TVFilterButton.tsx @@ -0,0 +1,80 @@ +import React from "react"; +import { Animated, Pressable, View } from "react-native"; +import { Text } from "@/components/common/Text"; +import { useScaledTVTypography } from "@/constants/TVTypography"; +import { scaleSize } from "@/utils/scaleSize"; +import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation"; + +export interface TVFilterButtonProps { + label: string; + value: string; + onPress: () => void; + hasTVPreferredFocus?: boolean; + disabled?: boolean; + hasActiveFilter?: boolean; +} + +export const TVFilterButton: React.FC = ({ + label, + value, + onPress, + hasTVPreferredFocus = false, + disabled = false, + hasActiveFilter = false, +}) => { + const typography = useScaledTVTypography(); + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation({ scaleAmount: 1.04, duration: 120 }); + + return ( + + + + {label ? ( + + {label} + + ) : null} + + {value} + + + + + ); +}; diff --git a/components/tv/TVFocusablePoster.tsx b/components/tv/TVFocusablePoster.tsx new file mode 100644 index 000000000..337cbc2ab --- /dev/null +++ b/components/tv/TVFocusablePoster.tsx @@ -0,0 +1,89 @@ +import React, { useRef, useState } from "react"; +import { + Animated, + Easing, + Pressable, + View, + type ViewStyle, +} from "react-native"; + +export interface TVFocusablePosterProps { + children: React.ReactNode; + onPress: () => void; + onLongPress?: () => void; + hasTVPreferredFocus?: boolean; + glowColor?: "white" | "purple"; + scaleAmount?: number; + style?: ViewStyle; + onFocus?: () => void; + onBlur?: () => void; + disabled?: boolean; + /** When true, the item remains focusable even when disabled (for navigation purposes) */ + focusableWhenDisabled?: boolean; + /** Setter function for the ref (for focus guide destinations) */ + refSetter?: (ref: View | null) => void; +} + +export const TVFocusablePoster: React.FC = ({ + children, + onPress, + onLongPress, + hasTVPreferredFocus = false, + glowColor = "white", + scaleAmount = 1.05, + style, + onFocus: onFocusProp, + onBlur: onBlurProp, + disabled = false, + focusableWhenDisabled = false, + refSetter, +}) => { + const [focused, setFocused] = useState(false); + const scale = useRef(new Animated.Value(1)).current; + + const animateTo = (value: number) => + Animated.timing(scale, { + toValue: value, + duration: 150, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }).start(); + + const shadowColor = glowColor === "white" ? "#ffffff" : "#a855f7"; + + return ( + { + setFocused(true); + animateTo(scaleAmount); + onFocusProp?.(); + }} + onBlur={() => { + setFocused(false); + animateTo(1); + onBlurProp?.(); + }} + hasTVPreferredFocus={hasTVPreferredFocus && !disabled} + disabled={disabled} + focusable={!disabled || focusableWhenDisabled} + > + + {children} + + + ); +}; diff --git a/components/tv/TVFocusableProgressBar.tsx b/components/tv/TVFocusableProgressBar.tsx new file mode 100644 index 000000000..b93eb03d5 --- /dev/null +++ b/components/tv/TVFocusableProgressBar.tsx @@ -0,0 +1,190 @@ +import React from "react"; +import { + Animated, + Pressable, + StyleSheet, + View, + type ViewStyle, +} from "react-native"; +import type { SharedValue } from "react-native-reanimated"; +import ReanimatedModule, { useAnimatedStyle } from "react-native-reanimated"; +import { scaleSize } from "@/utils/scaleSize"; +import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation"; + +const ReanimatedView = ReanimatedModule.View; + +export interface TVFocusableProgressBarProps { + /** Progress value (SharedValue) in milliseconds */ + progress: SharedValue; + /** Maximum value in milliseconds */ + max: SharedValue; + /** Cache progress value (SharedValue) in milliseconds */ + cacheProgress?: SharedValue; + /** Chapter positions as percentages (0-100) for tick marks */ + chapterPositions?: number[]; + /** Callback when the progress bar receives focus */ + onFocus?: () => void; + /** Callback when the progress bar loses focus */ + onBlur?: () => void; + /** Callback ref setter for focus guide destination pattern */ + refSetter?: (ref: View | null) => void; + /** Whether this component is disabled */ + disabled?: boolean; + /** Whether this component should receive initial focus */ + hasTVPreferredFocus?: boolean; + /** Optional style overrides */ + style?: ViewStyle; +} + +const PROGRESS_BAR_HEIGHT = scaleSize(14); + +export const TVFocusableProgressBar: React.FC = + React.memo( + ({ + progress, + max, + cacheProgress, + chapterPositions = [], + onFocus, + onBlur, + refSetter, + disabled = false, + hasTVPreferredFocus = false, + style, + }) => { + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation({ + scaleAmount: 1.02, + duration: 120, + onFocus, + onBlur, + }); + + const progressFillStyle = useAnimatedStyle(() => ({ + width: `${max.value > 0 ? (progress.value / max.value) * 100 : 0}%`, + })); + + const cacheProgressStyle = useAnimatedStyle(() => ({ + width: `${max.value > 0 && cacheProgress ? (cacheProgress.value / max.value) * 100 : 0}%`, + })); + + return ( + + + + + {cacheProgress && ( + + )} + + + {/* Chapter markers - positioned outside track to extend above */} + {chapterPositions.length > 0 && ( + + {chapterPositions.map((position, index) => ( + + ))} + + )} + + + + ); + }, + ); + +const styles = StyleSheet.create({ + pressableContainer: { + // Add padding for focus scale animation to not clip + paddingVertical: scaleSize(8), + paddingHorizontal: scaleSize(4), + }, + animatedContainer: { + height: PROGRESS_BAR_HEIGHT + scaleSize(8), + justifyContent: "center", + borderRadius: scaleSize(12), + paddingHorizontal: scaleSize(4), + }, + animatedContainerFocused: { + // Subtle glow effect when focused + shadowColor: "#fff", + shadowOffset: { width: 0, height: 0 }, + shadowOpacity: 0.5, + shadowRadius: scaleSize(12), + }, + progressTrackWrapper: { + position: "relative", + height: PROGRESS_BAR_HEIGHT, + }, + progressTrack: { + height: PROGRESS_BAR_HEIGHT, + backgroundColor: "rgba(255,255,255,0.2)", + borderRadius: scaleSize(8), + overflow: "hidden", + }, + progressTrackFocused: { + // Brighter track when focused + backgroundColor: "rgba(255,255,255,0.35)", + }, + cacheProgress: { + position: "absolute", + top: 0, + left: 0, + height: "100%", + backgroundColor: "rgba(255,255,255,0.3)", + borderRadius: scaleSize(8), + }, + progressFill: { + position: "absolute", + top: 0, + left: 0, + height: "100%", + backgroundColor: "#fff", + borderRadius: scaleSize(8), + }, + chapterMarkersContainer: { + position: "absolute", + top: 0, + left: 0, + right: 0, + bottom: 0, + }, + chapterMarker: { + position: "absolute", + width: scaleSize(2), + height: PROGRESS_BAR_HEIGHT + scaleSize(5), + bottom: 0, + backgroundColor: "rgba(255, 255, 255, 0.6)", + borderRadius: scaleSize(1), + transform: [{ translateX: -scaleSize(1) }], + }, +}); diff --git a/components/tv/TVHorizontalList.tsx b/components/tv/TVHorizontalList.tsx new file mode 100644 index 000000000..4bb61168e --- /dev/null +++ b/components/tv/TVHorizontalList.tsx @@ -0,0 +1,222 @@ +import React, { useCallback } from "react"; +import { FlatList, ScrollView, View } from "react-native"; +import { Text } from "@/components/common/Text"; +import { useScaledTVSizes } from "@/constants/TVSizes"; +import { useScaledTVTypography } from "@/constants/TVTypography"; +import { scaleSize } from "@/utils/scaleSize"; + +interface TVHorizontalListProps { + /** Data items to render */ + data: T[]; + /** Unique key extractor */ + keyExtractor: (item: T, index: number) => string; + /** Render function for each item */ + renderItem: (info: { item: T; index: number }) => React.ReactElement | null; + /** Optional section title */ + title?: string; + /** Text to show when data array is empty */ + emptyText?: string; + /** Whether to use FlatList (for large/infinite lists) or ScrollView (for small lists) */ + useFlatList?: boolean; + /** Called when end is reached (only for FlatList) */ + onEndReached?: () => void; + /** Ref for the scroll view */ + scrollViewRef?: React.RefObject | null>; + /** Footer component (only for FlatList) */ + ListFooterComponent?: React.ReactElement | null; + /** Whether this is the first section (for initial focus) */ + isFirstSection?: boolean; + /** Loading state */ + isLoading?: boolean; + /** Skeleton item count when loading */ + skeletonCount?: number; + /** Skeleton render function */ + renderSkeleton?: () => React.ReactElement; + /** + * Custom horizontal padding (overrides default sizes.padding.scale). + * Use this when the list needs to extend beyond its parent's padding. + * The list will use negative margin to extend beyond the parent, + * then add this padding inside to align content properly. + */ + horizontalPadding?: number; +} + +/** + * TVHorizontalList - A unified horizontal list component for TV. + * + * Provides consistent spacing and layout for horizontal lists: + * - Uses `sizes.gaps.item` (24px default) for gap between items + * - Uses `sizes.padding.scale` (20px default) for padding to accommodate focus scale + * - Supports both ScrollView (small lists) and FlatList (large/infinite lists) + */ +export function TVHorizontalList({ + data, + keyExtractor, + renderItem, + title, + emptyText, + useFlatList = false, + onEndReached, + scrollViewRef, + ListFooterComponent, + isLoading = false, + skeletonCount = 5, + renderSkeleton, + horizontalPadding, +}: TVHorizontalListProps) { + const sizes = useScaledTVSizes(); + const typography = useScaledTVTypography(); + + // Use custom horizontal padding if provided, otherwise use default scale padding + const effectiveHorizontalPadding = horizontalPadding ?? sizes.padding.scale; + // Apply negative margin when using custom padding to extend beyond parent + const marginHorizontal = horizontalPadding ? -horizontalPadding : 0; + + // Wrap renderItem to add consistent gap + const renderItemWithGap = useCallback( + ({ item, index }: { item: T; index: number }) => { + const isLast = index === data.length - 1; + return ( + + {renderItem({ item, index })} + + ); + }, + [data.length, renderItem, sizes.gaps.item], + ); + + // Empty state + if (!isLoading && data.length === 0 && emptyText) { + return ( + + {title && ( + + {title} + + )} + + {emptyText} + + + ); + } + + // Loading state + if (isLoading && renderSkeleton) { + return ( + + {title && ( + + {title} + + )} + + {Array.from({ length: skeletonCount }).map((_, i) => ( + {renderSkeleton()} + ))} + + + ); + } + + const contentContainerStyle = { + paddingHorizontal: effectiveHorizontalPadding, + paddingVertical: sizes.padding.scale, + }; + + const listStyle = { + overflow: "visible" as const, + marginHorizontal, + }; + + return ( + + {title && ( + + {title} + + )} + + {useFlatList ? ( + >} + horizontal + data={data} + keyExtractor={keyExtractor} + renderItem={renderItemWithGap} + showsHorizontalScrollIndicator={false} + removeClippedSubviews={false} + style={listStyle} + contentContainerStyle={contentContainerStyle} + onEndReached={onEndReached} + onEndReachedThreshold={0.5} + initialNumToRender={5} + maxToRenderPerBatch={3} + windowSize={5} + maintainVisibleContentPosition={{ minIndexForVisible: 0 }} + ListFooterComponent={ListFooterComponent} + /> + ) : ( + } + horizontal + showsHorizontalScrollIndicator={false} + style={listStyle} + contentContainerStyle={contentContainerStyle} + > + {data.map((item, index) => ( + + {renderItem({ item, index })} + + ))} + {ListFooterComponent} + + )} + + ); +} diff --git a/components/tv/TVItemCardText.tsx b/components/tv/TVItemCardText.tsx new file mode 100644 index 000000000..c55110b36 --- /dev/null +++ b/components/tv/TVItemCardText.tsx @@ -0,0 +1,34 @@ +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import React from "react"; +import { View } from "react-native"; +import { Text } from "@/components/common/Text"; +import { useScaledTVTypography } from "@/constants/TVTypography"; +import { scaleSize } from "@/utils/scaleSize"; + +export interface TVItemCardTextProps { + item: BaseItemDto; +} + +export const TVItemCardText: React.FC = ({ item }) => { + const typography = useScaledTVTypography(); + + return ( + + + {item.Name} + + + {item.ProductionYear} + + + ); +}; diff --git a/components/tv/TVLanguageCard.tsx b/components/tv/TVLanguageCard.tsx new file mode 100644 index 000000000..027a1ada0 --- /dev/null +++ b/components/tv/TVLanguageCard.tsx @@ -0,0 +1,101 @@ +import { Ionicons } from "@expo/vector-icons"; +import React from "react"; +import { Animated, Pressable, StyleSheet, View } from "react-native"; +import { Text } from "@/components/common/Text"; +import { useScaledTVTypography } from "@/constants/TVTypography"; +import { scaleSize } from "@/utils/scaleSize"; +import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation"; + +export interface TVLanguageCardProps { + code: string; + name: string; + selected: boolean; + hasTVPreferredFocus?: boolean; + onPress: () => void; +} + +export const TVLanguageCard = React.forwardRef( + ({ code, name, selected, hasTVPreferredFocus, onPress }, ref) => { + const typography = useScaledTVTypography(); + const styles = createStyles(typography); + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation({ scaleAmount: 1.05 }); + + return ( + + + + {name} + + + {code.toUpperCase()} + + {selected && !focused && ( + + + + )} + + + ); + }, +); + +const createStyles = (typography: ReturnType) => + StyleSheet.create({ + languageCard: { + width: scaleSize(120), + height: scaleSize(60), + borderRadius: scaleSize(12), + justifyContent: "center", + alignItems: "center", + paddingHorizontal: scaleSize(12), + }, + languageCardText: { + fontSize: typography.callout, + fontWeight: "500", + }, + languageCardCode: { + fontSize: typography.callout, + marginTop: scaleSize(2), + }, + checkmark: { + position: "absolute", + top: scaleSize(8), + right: scaleSize(8), + }, + }); diff --git a/components/tv/TVMetadataBadges.tsx b/components/tv/TVMetadataBadges.tsx new file mode 100644 index 000000000..a3c889e7c --- /dev/null +++ b/components/tv/TVMetadataBadges.tsx @@ -0,0 +1,51 @@ +import { Ionicons } from "@expo/vector-icons"; +import React from "react"; +import { View } from "react-native"; +import { Badge } from "@/components/Badge"; +import { Text } from "@/components/common/Text"; +import { useScaledTVTypography } from "@/constants/TVTypography"; +import { scaleSize } from "@/utils/scaleSize"; + +export interface TVMetadataBadgesProps { + year?: number | null; + duration?: string | null; + officialRating?: string | null; + communityRating?: number | null; +} + +export const TVMetadataBadges: React.FC = React.memo( + ({ year, duration, officialRating, communityRating }) => { + const typography = useScaledTVTypography(); + + return ( + + {year != null && ( + + {year} + + )} + {duration && ( + + {duration} + + )} + {officialRating && } + {communityRating != null && ( + } + /> + )} + + ); + }, +); diff --git a/components/tv/TVNavBar.tsx b/components/tv/TVNavBar.tsx new file mode 100644 index 000000000..759c283a2 --- /dev/null +++ b/components/tv/TVNavBar.tsx @@ -0,0 +1,155 @@ +import React from "react"; +import { + Animated, + Pressable, + ScrollView, + StyleProp, + View, + ViewStyle, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Text } from "@/components/common/Text"; +import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation"; +import { TVPadding } from "@/constants/TVSizes"; +import { useScaledTVTypography } from "@/constants/TVTypography"; +import { scaleSize } from "@/utils/scaleSize"; + +export interface TVNavBarTab { + key: string; + label: string; +} + +export interface TVNavBarProps { + tabs: TVNavBarTab[]; + activeTabKey: string; + onTabChange: (key: string) => void; + style?: StyleProp; +} + +const TVNavBarTabItem: React.FC<{ + label: string; + isActive: boolean; + onSelect: () => void; + onLayout: (e: { + nativeEvent: { layout: { x: number; width: number } }; + }) => void; + hasTVPreferredFocus: boolean; +}> = ({ label, isActive, onSelect, onLayout, hasTVPreferredFocus }) => { + const typography = useScaledTVTypography(); + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation({ + scaleAmount: 1.05, + duration: 120, + }); + + const bg = focused + ? "rgba(255, 255, 255, 0.95)" + : isActive + ? "rgba(255, 255, 255, 0.15)" + : "transparent"; + + const textColor = focused + ? "#000" + : isActive + ? "#fff" + : "rgba(255, 255, 255, 0.7)"; + + return ( + + + + {label} + + + + ); +}; + +export const TVNavBar: React.FC = ({ + tabs, + activeTabKey, + onTabChange, + style, +}) => { + const scrollRef = React.useRef(null); + const tabLayouts = React.useRef>( + {}, + ); + const insets = useSafeAreaInsets(); + + const handleTabLayout = React.useCallback( + (key: string) => + (e: { nativeEvent: { layout: { x: number; width: number } } }) => { + tabLayouts.current[key] = e.nativeEvent.layout; + }, + [], + ); + + const handleTabChange = React.useCallback( + (key: string) => { + onTabChange(key); + + const layout = tabLayouts.current[key]; + if (layout && scrollRef.current) { + scrollRef.current.scrollTo({ + x: Math.max(0, layout.x - TVPadding.horizontal / 2), + animated: true, + }); + } + }, + [onTabChange], + ); + + if (tabs.length === 0) return null; + + return ( + + + {tabs.map((tab) => ( + handleTabChange(tab.key)} + onLayout={handleTabLayout(tab.key)} + hasTVPreferredFocus={tab.key === activeTabKey} + /> + ))} + + + ); +}; diff --git a/components/tv/TVNextEpisodeCountdown.tsx b/components/tv/TVNextEpisodeCountdown.tsx new file mode 100644 index 000000000..47193b0e1 --- /dev/null +++ b/components/tv/TVNextEpisodeCountdown.tsx @@ -0,0 +1,270 @@ +import type { Api } from "@jellyfin/sdk"; +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; +import { BlurView } from "expo-blur"; +import { type FC, useEffect, useMemo, useRef } from "react"; +import { useTranslation } from "react-i18next"; +import { + Image, + Pressable, + Animated as RNAnimated, + type View as RNView, + StyleSheet, + TVFocusGuideView, + View, +} from "react-native"; +import Animated, { + cancelAnimation, + Easing, + runOnJS, + useAnimatedStyle, + useSharedValue, + withTiming, +} from "react-native-reanimated"; +import { Text } from "@/components/common/Text"; +import { useScaledTVTypography } from "@/constants/TVTypography"; +import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; +import { scaleSize } from "@/utils/scaleSize"; +import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation"; + +export interface TVNextEpisodeCountdownProps { + nextItem: BaseItemDto; + api: Api | null; + show: boolean; + isPlaying: boolean; + onFinish: () => void; + /** Called when user presses the card to skip to next episode */ + onPlayNext?: () => void; + /** Whether controls are visible - affects card position */ + controlsVisible?: boolean; + /** Callback ref setter for focus guide destination pattern */ + refSetter?: (ref: RNView | null) => void; + /** Whether this component should receive initial focus */ + hasTVPreferredFocus?: boolean; + /** Destination used when moving down from this card */ + playButtonRef?: RNView | null; +} + +// Position constants +const BOTTOM_WITH_CONTROLS = scaleSize(300); +const BOTTOM_WITHOUT_CONTROLS = scaleSize(120); + +export const TVNextEpisodeCountdown: FC = ({ + nextItem, + api, + show, + isPlaying, + onFinish, + onPlayNext, + controlsVisible = false, + refSetter, + hasTVPreferredFocus = true, + playButtonRef: downDestination, +}) => { + const typography = useScaledTVTypography(); + const { t } = useTranslation(); + const progress = useSharedValue(0); + const cancelled = useSharedValue(false); + const onFinishRef = useRef(onFinish); + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation({ + scaleAmount: 1.05, + duration: 120, + }); + + onFinishRef.current = onFinish; + + const imageUrl = getPrimaryImageUrl({ + api, + item: nextItem, + width: scaleSize(360), + quality: 80, + }); + + // Animated position based on controls visibility + const bottomPosition = useSharedValue( + controlsVisible ? BOTTOM_WITH_CONTROLS : BOTTOM_WITHOUT_CONTROLS, + ); + + useEffect(() => { + const target = controlsVisible + ? BOTTOM_WITH_CONTROLS + : BOTTOM_WITHOUT_CONTROLS; + bottomPosition.value = withTiming(target, { + duration: 300, + easing: Easing.out(Easing.quad), + }); + }, [controlsVisible, bottomPosition]); + + const containerAnimatedStyle = useAnimatedStyle(() => ({ + bottom: bottomPosition.value, + })); + + // Progress animation - pause/resume without resetting + const prevShowRef = useRef(false); + + useEffect(() => { + const justStartedShowing = show && !prevShowRef.current; + prevShowRef.current = show; + + if (!show) { + cancelAnimation(progress); + progress.value = 0; + return; + } + + if (justStartedShowing) { + progress.value = 0; + } + + if (!isPlaying) { + cancelAnimation(progress); + return; + } + + cancelled.value = false; + + // Resume from current position + const remainingDuration = (1 - progress.value) * 8000; + progress.value = withTiming( + 1, + { duration: remainingDuration, easing: Easing.linear }, + (finished) => { + if (finished && !cancelled.value) { + runOnJS(onFinishRef.current)(); + } + }, + ); + + // Cancel animation on unmount to prevent onFinish from firing after exit + return () => { + cancelled.value = true; + cancelAnimation(progress); + }; + }, [show, isPlaying, progress, cancelled]); + + const progressStyle = useAnimatedStyle(() => ({ + width: `${progress.value * 100}%`, + })); + + const styles = useMemo(() => createStyles(typography), [typography]); + + if (!show) return null; + + return ( + + + + + + {imageUrl && ( + + )} + + + {t("player.next_episode")} + + + {nextItem.SeriesName} + + + + S{nextItem.ParentIndexNumber}E{nextItem.IndexNumber} -{" "} + {nextItem.Name} + + + + + + + + + + + {downDestination && ( + + )} + + ); +}; + +const createStyles = (typography: ReturnType) => + StyleSheet.create({ + container: { + position: "absolute", + right: scaleSize(80), + zIndex: 100, + }, + focusedCard: { + shadowColor: "#fff", + shadowOffset: { width: 0, height: 0 }, + shadowOpacity: 0.6, + shadowRadius: scaleSize(16), + }, + blur: { + borderRadius: scaleSize(16), + overflow: "hidden", + }, + innerContainer: { + flexDirection: "row", + alignItems: "stretch", + }, + thumbnail: { + width: scaleSize(180), + backgroundColor: "rgba(0,0,0,0.3)", + }, + content: { + padding: scaleSize(16), + justifyContent: "center", + width: scaleSize(280), + }, + label: { + fontSize: typography.callout, + color: "rgba(255,255,255,0.5)", + textTransform: "uppercase", + letterSpacing: 1, + marginBottom: scaleSize(4), + }, + seriesName: { + fontSize: typography.callout, + color: "rgba(255,255,255,0.7)", + marginBottom: scaleSize(2), + }, + episodeInfo: { + fontSize: typography.body, + color: "#fff", + fontWeight: "600", + marginBottom: scaleSize(12), + }, + progressContainer: { + height: scaleSize(4), + backgroundColor: "rgba(255,255,255,0.2)", + borderRadius: scaleSize(2), + overflow: "hidden", + }, + progressBar: { + height: "100%", + backgroundColor: "#fff", + borderRadius: scaleSize(2), + }, + returnFocusGuide: { + height: 1, + width: "100%", + }, + }); diff --git a/components/tv/TVOptionButton.tsx b/components/tv/TVOptionButton.tsx new file mode 100644 index 000000000..1a3ee51d0 --- /dev/null +++ b/components/tv/TVOptionButton.tsx @@ -0,0 +1,123 @@ +import { BlurView } from "expo-blur"; +import React from "react"; +import { Animated, Pressable, View } from "react-native"; +import { Text } from "@/components/common/Text"; +import { useScaledTVTypography } from "@/constants/TVTypography"; +import { scaleSize } from "@/utils/scaleSize"; +import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation"; + +export interface TVOptionButtonProps { + label: string; + value: string; + onPress: () => void; + hasTVPreferredFocus?: boolean; + maxWidth?: number; +} + +export const TVOptionButton = React.forwardRef( + ({ label, value, onPress, hasTVPreferredFocus, maxWidth }, ref) => { + const typography = useScaledTVTypography(); + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation({ scaleAmount: 1.02, duration: 120 }); + + return ( + + + {focused ? ( + + + {label} + + + {value} + + + ) : ( + + + + {label} + + + {value} + + + + )} + + + ); + }, +); diff --git a/components/tv/TVOptionCard.tsx b/components/tv/TVOptionCard.tsx new file mode 100644 index 000000000..200f2a9f7 --- /dev/null +++ b/components/tv/TVOptionCard.tsx @@ -0,0 +1,107 @@ +import { Ionicons } from "@expo/vector-icons"; +import React from "react"; +import { Animated, Pressable, View } from "react-native"; +import { Text } from "@/components/common/Text"; +import { useScaledTVTypography } from "@/constants/TVTypography"; +import { scaleSize } from "@/utils/scaleSize"; +import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation"; + +export interface TVOptionCardProps { + label: string; + sublabel?: string; + selected: boolean; + hasTVPreferredFocus?: boolean; + onPress: () => void; + width?: number; + height?: number; +} + +export const TVOptionCard = React.forwardRef( + ( + { + label, + sublabel, + selected, + hasTVPreferredFocus = false, + onPress, + width = 160, + height = 75, + }, + ref, + ) => { + const typography = useScaledTVTypography(); + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation({ scaleAmount: 1.05 }); + + return ( + + + + {label} + + {sublabel && ( + + {sublabel} + + )} + {selected && !focused && ( + + + + )} + + + ); + }, +); diff --git a/components/tv/TVOptionSelector.tsx b/components/tv/TVOptionSelector.tsx new file mode 100644 index 000000000..ee9ba16cd --- /dev/null +++ b/components/tv/TVOptionSelector.tsx @@ -0,0 +1,205 @@ +import { BlurView } from "expo-blur"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { + Animated, + Easing, + ScrollView, + StyleSheet, + TVFocusGuideView, + View, +} from "react-native"; +import { Text } from "@/components/common/Text"; +import { useScaledTVTypography } from "@/constants/TVTypography"; +import { scaleSize } from "@/utils/scaleSize"; +import { TVCancelButton } from "./TVCancelButton"; +import { TVOptionCard } from "./TVOptionCard"; + +export type TVOptionItem = { + label: string; + sublabel?: string; + value: T; + selected: boolean; +}; + +export interface TVOptionSelectorProps { + visible: boolean; + title: string; + options: TVOptionItem[]; + onSelect: (value: T) => void; + onClose: () => void; + cancelLabel?: string; + cardWidth?: number; + cardHeight?: number; +} + +export const TVOptionSelector = ({ + visible, + title, + options, + onSelect, + onClose, + cancelLabel = "Cancel", + cardWidth = 160, + cardHeight = 75, +}: TVOptionSelectorProps) => { + const typography = useScaledTVTypography(); + const [isReady, setIsReady] = useState(false); + const firstCardRef = useRef(null); + + const overlayOpacity = useRef(new Animated.Value(0)).current; + const sheetTranslateY = useRef(new Animated.Value(200)).current; + + const initialSelectedIndex = useMemo(() => { + const idx = options.findIndex((o) => o.selected); + return idx >= 0 ? idx : 0; + }, [options]); + + useEffect(() => { + if (visible) { + overlayOpacity.setValue(0); + sheetTranslateY.setValue(200); + + Animated.parallel([ + Animated.timing(overlayOpacity, { + toValue: 1, + duration: 250, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }), + Animated.timing(sheetTranslateY, { + toValue: 0, + duration: 300, + easing: Easing.out(Easing.cubic), + useNativeDriver: true, + }), + ]).start(); + } + }, [visible, overlayOpacity, sheetTranslateY]); + + useEffect(() => { + if (visible) { + const timer = setTimeout(() => setIsReady(true), 100); + return () => clearTimeout(timer); + } + setIsReady(false); + }, [visible]); + + useEffect(() => { + if (isReady && firstCardRef.current) { + const timer = setTimeout(() => { + (firstCardRef.current as any)?.requestTVFocus?.(); + }, 50); + return () => clearTimeout(timer); + } + }, [isReady]); + + const styles = useMemo(() => createStyles(typography), [typography]); + + if (!visible) return null; + + return ( + + + + + {title} + {isReady && ( + + {options.map((option, index) => ( + { + onSelect(option.value); + onClose(); + }} + width={cardWidth} + height={cardHeight} + /> + ))} + + )} + + {isReady && ( + + + + )} + + + + + ); +}; + +const createStyles = (typography: ReturnType) => + StyleSheet.create({ + overlay: { + position: "absolute", + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: "rgba(0, 0, 0, 0.5)", + justifyContent: "flex-end", + zIndex: 1000, + }, + sheetContainer: { + width: "100%", + }, + blurContainer: { + borderTopLeftRadius: scaleSize(24), + borderTopRightRadius: scaleSize(24), + overflow: "hidden", + }, + content: { + paddingTop: scaleSize(24), + paddingBottom: scaleSize(50), + overflow: "visible", + }, + title: { + fontSize: typography.callout, + fontWeight: "500", + color: "rgba(255,255,255,0.6)", + marginBottom: scaleSize(16), + paddingHorizontal: scaleSize(48), + textTransform: "uppercase", + letterSpacing: 1, + }, + scrollView: { + overflow: "visible", + }, + scrollContent: { + paddingHorizontal: scaleSize(48), + paddingVertical: scaleSize(20), + gap: scaleSize(12), + }, + cancelButtonContainer: { + marginTop: scaleSize(16), + paddingHorizontal: scaleSize(48), + alignItems: "flex-start", + }, + }); diff --git a/components/tv/TVPlayedButton.tsx b/components/tv/TVPlayedButton.tsx new file mode 100644 index 000000000..8ab8e4bb5 --- /dev/null +++ b/components/tv/TVPlayedButton.tsx @@ -0,0 +1,33 @@ +import { Ionicons } from "@expo/vector-icons"; +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; +import React from "react"; +import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed"; +import { TVButton } from "./TVButton"; + +export interface TVPlayedButtonProps { + item: BaseItemDto; + disabled?: boolean; +} + +export const TVPlayedButton: React.FC = ({ + item, + disabled, +}) => { + const isPlayed = item.UserData?.Played ?? false; + const toggle = useMarkAsPlayed([item]); + + return ( + toggle(!isPlayed)} + variant='glass' + square + disabled={disabled} + > + + + ); +}; diff --git a/components/tv/TVPosterCard.tsx b/components/tv/TVPosterCard.tsx new file mode 100644 index 000000000..9a24a5034 --- /dev/null +++ b/components/tv/TVPosterCard.tsx @@ -0,0 +1,602 @@ +import { Ionicons } from "@expo/vector-icons"; +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import { Image } from "expo-image"; +import { useAtomValue } from "jotai"; +import React, { useMemo, useRef, useState } from "react"; +import { + Animated, + Easing, + Pressable, + View, + type ViewStyle, +} from "react-native"; +import { ProgressBar } from "@/components/common/ProgressBar"; +import { Text } from "@/components/common/Text"; +import { + UnplayedCountBadge, + WatchedIndicator, +} from "@/components/WatchedIndicator"; +import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes"; +import { useScaledTVTypography } from "@/constants/TVTypography"; +import { + GlassPosterView, + isGlassEffectAvailable, +} from "@/modules/glass-poster"; +import { apiAtom } from "@/providers/JellyfinProvider"; +import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; +import { scaleSize } from "@/utils/scaleSize"; +import { runtimeTicksToMinutes } from "@/utils/time"; + +export interface TVPosterCardProps { + item: BaseItemDto; + /** Poster orientation: vertical = 10:15 (portrait), horizontal = 16:9 (landscape) */ + orientation?: "vertical" | "horizontal"; + /** Show text below the poster (title, subtitle) - default: true */ + showText?: boolean; + /** Show progress bar - default: true for items with progress */ + showProgress?: boolean; + /** Show watched indicator - default: true */ + showWatchedIndicator?: boolean; + + // Focus props + hasTVPreferredFocus?: boolean; + disabled?: boolean; + /** When true, the item remains focusable even when disabled (for navigation purposes) */ + focusableWhenDisabled?: boolean; + + /** Shows a "Now Playing" badge on the card */ + isCurrent?: boolean; + /** Show a play button overlay */ + showPlayButton?: boolean; + + // Handlers + onPress: () => void; + onLongPress?: () => void; + onFocus?: () => void; + onBlur?: () => void; + + /** Setter function for the ref (for focus guide destinations) */ + refSetter?: (ref: View | null) => void; + + /** Custom width - overrides default based on orientation */ + width?: number; + + /** Custom style for the outer container */ + style?: ViewStyle; + + /** Glow color for focus state */ + glowColor?: "white" | "purple"; + + /** Scale amount for focus animation */ + scaleAmount?: number; + + /** Custom image URL getter - if not provided, uses smart URL logic */ + imageUrlGetter?: (item: BaseItemDto) => string | undefined; +} + +/** + * TVPosterCard - Unified poster component for TV interface. + * + * Combines image rendering, focus handling, and text display into a single component. + * Supports both portrait (10:15) and landscape (16:9) orientations. + * + * Features: + * - Glass effect on tvOS 26+ with fallback + * - Focus handling with scale animation and glow + * - Progress bar and watched indicator + * - Smart subtitle text based on item type + * - "Now Playing" badge for current items + */ +export const TVPosterCard: React.FC = ({ + item, + orientation = "vertical", + showText = true, + showProgress = true, + showWatchedIndicator = true, + hasTVPreferredFocus = false, + disabled = false, + focusableWhenDisabled = false, + isCurrent = false, + showPlayButton = false, + onPress, + onLongPress, + onFocus: onFocusProp, + onBlur: onBlurProp, + refSetter, + width: customWidth, + style, + glowColor = "white", + scaleAmount = 1.05, + imageUrlGetter, +}) => { + const api = useAtomValue(apiAtom); + const posterSizes = useScaledTVPosterSizes(); + const typography = useScaledTVTypography(); + + const [focused, setFocused] = useState(false); + const scale = useRef(new Animated.Value(1)).current; + + // Determine width based on orientation + const width = useMemo(() => { + if (customWidth) return customWidth; + return orientation === "horizontal" + ? posterSizes.episode + : posterSizes.poster; + }, [customWidth, orientation, posterSizes]); + + const aspectRatio = orientation === "horizontal" ? 16 / 9 : 10 / 15; + + // Smart image URL selection + const imageUrl = useMemo(() => { + // Use custom getter if provided + if (imageUrlGetter) { + return imageUrlGetter(item) ?? null; + } + + if (!api) return null; + + // Horizontal orientation: prefer thumbs/backdrops for landscape images + if (orientation === "horizontal") { + // Episode: prefer series thumb image for consistent look (like hero section) + if (item.Type === "Episode") { + // First try parent/series thumb (horizontal series artwork) + if (item.ParentBackdropItemId && item.ParentThumbImageTag) { + return `${api.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ParentThumbImageTag}`; + } + // Fall back to episode's own primary image + if (item.ImageTags?.Primary) { + return `${api.basePath}/Items/${item.Id}/Images/Primary?fillHeight=600&quality=80&tag=${item.ImageTags.Primary}`; + } + // Last resort: try primary without tag + return `${api.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`; + } + + // Movie/Series/Program: prefer thumb over primary + if (item.ImageTags?.Thumb) { + return `${api.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ImageTags.Thumb}`; + } + return `${api.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`; + } + + // Vertical orientation: use primary image + // For episodes, get the series primary image + if ( + item.Type === "Episode" && + item.SeriesId && + item.SeriesPrimaryImageTag + ) { + return `${api.basePath}/Items/${item.SeriesId}/Images/Primary?fillHeight=${width * 3}&quality=80&tag=${item.SeriesPrimaryImageTag}`; + } + + return getPrimaryImageUrl({ + api, + item, + width: width * 2, // 2x for quality on large screens + }); + }, [api, item, orientation, width, imageUrlGetter]); + + // Progress calculation + const progress = useMemo(() => { + if (!showProgress) return 0; + + if (item.Type === "Program") { + if (!item.StartDate || !item.EndDate) return 0; + const startDate = new Date(item.StartDate); + const endDate = new Date(item.EndDate); + const now = new Date(); + const total = endDate.getTime() - startDate.getTime(); + if (total <= 0) return 0; + const elapsed = now.getTime() - startDate.getTime(); + return (elapsed / total) * 100; + } + return item.UserData?.PlayedPercentage || 0; + }, [item, showProgress]); + + const isWatched = showWatchedIndicator && item.UserData?.Played === true; + + // Blurhash for placeholder + const blurhash = useMemo(() => { + const key = item.ImageTags?.Primary as string; + return item.ImageBlurHashes?.Primary?.[key]; + }, [item]); + + // Glass effect availability + const useGlass = isGlassEffectAvailable(); + + // Focus animation + const animateTo = (value: number) => + Animated.timing(scale, { + toValue: value, + duration: 150, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }).start(); + + const shadowColor = glowColor === "white" ? "#ffffff" : "#a855f7"; + + // Text rendering helpers + const renderSubtitle = () => { + if (!showText) return null; + + // Episode: S#:E# • duration + if (item.Type === "Episode") { + const season = item.ParentIndexNumber; + const ep = item.IndexNumber; + const episodeLabel = + season !== undefined && ep !== undefined ? `S${season}:E${ep}` : null; + const duration = item.RunTimeTicks + ? runtimeTicksToMinutes(item.RunTimeTicks) + : null; + + return ( + + {episodeLabel && ( + + {episodeLabel} + + )} + {duration && ( + <> + + • + + + {duration} + + + )} + + ); + } + + // Program: channel name + if (item.Type === "Program" && item.ChannelName) { + return ( + + {item.ChannelName} + + ); + } + + // MusicAlbum: artist + if (item.Type === "MusicAlbum") { + const artist = item.AlbumArtist || item.Artists?.join(", "); + if (artist) { + return ( + + {artist} + + ); + } + } + + // Audio: artist + if (item.Type === "Audio") { + const artist = item.Artists?.join(", ") || item.AlbumArtist; + if (artist) { + return ( + + {artist} + + ); + } + } + + // Playlist: track count + if (item.Type === "Playlist" && item.ChildCount) { + return ( + + {item.ChildCount} tracks + + ); + } + + // Default: production year + if (item.ProductionYear) { + return ( + + {item.ProductionYear} + + ); + } + + return null; + }; + + // Now Playing badge component + const NowPlayingBadge = isCurrent ? ( + + + + Now Playing + + + ) : null; + + // Play button overlay component + const PlayButtonOverlay = showPlayButton ? ( + + + + ) : null; + + // Render poster image + const renderPosterImage = () => { + // Empty placeholder when no URL + if (!imageUrl) { + return ( + + ); + } + + // Glass effect rendering (tvOS 26+) + if (useGlass) { + return ( + + + {PlayButtonOverlay} + {NowPlayingBadge} + {/* + The glass view draws the watched checkmark natively but cannot show + an unplayed-episode count, so render it as an RN overlay on top. + Returns null when not applicable (non-series / fully watched). + */} + {showWatchedIndicator && } + + ); + } + + // Fallback rendering for older tvOS versions + return ( + + + {PlayButtonOverlay} + {NowPlayingBadge} + {showWatchedIndicator && } + + + ); + }; + + // Render title based on item type + const renderTitle = () => { + if (!showText) return null; + + // Episode: show episode name as title + if (item.Type === "Episode") { + return ( + + {item.Name} + + ); + } + + // MusicArtist: centered text + if (item.Type === "MusicArtist") { + return ( + + {item.Name} + + ); + } + + // Default: show name + return ( + + {item.Name} + + ); + }; + + return ( + + { + setFocused(true); + // Only animate scale when not using glass effect (glass handles its own focus visual) + if (!useGlass) { + animateTo(scaleAmount); + } + onFocusProp?.(); + }} + onBlur={() => { + setFocused(false); + if (!useGlass) { + animateTo(1); + } + onBlurProp?.(); + }} + hasTVPreferredFocus={hasTVPreferredFocus && !disabled} + disabled={disabled && !focusableWhenDisabled} + focusable={!disabled || focusableWhenDisabled} + > + + {renderPosterImage()} + + + + {/* Text below poster */} + {showText && ( + + {item.Type === "Episode" ? ( + <> + {renderSubtitle()} + {renderTitle()} + + ) : ( + <> + {renderTitle()} + {renderSubtitle()} + + )} + + )} + + ); +}; diff --git a/components/tv/TVProgressBar.tsx b/components/tv/TVProgressBar.tsx new file mode 100644 index 000000000..e8ee60f86 --- /dev/null +++ b/components/tv/TVProgressBar.tsx @@ -0,0 +1,52 @@ +import React from "react"; +import { View } from "react-native"; +import { scaleSize } from "@/utils/scaleSize"; + +export interface TVProgressBarProps { + /** Progress value between 0 and 1 */ + progress: number; + /** Background color of the track */ + trackColor?: string; + /** Color of the progress fill */ + fillColor?: string; + /** Maximum width of the progress bar */ + maxWidth?: number; + /** Height of the progress bar */ + height?: number; +} + +export const TVProgressBar: React.FC = React.memo( + ({ + progress, + trackColor = "rgba(255,255,255,0.2)", + fillColor = "#ffffff", + maxWidth = 400, + height = 4, + }) => { + const clampedProgress = Math.max(0, Math.min(1, progress)); + const scaledMaxWidth = scaleSize(maxWidth); + const scaledHeight = scaleSize(height); + + return ( + + + + + + ); + }, +); diff --git a/components/tv/TVRefreshButton.tsx b/components/tv/TVRefreshButton.tsx new file mode 100644 index 000000000..5e44dd943 --- /dev/null +++ b/components/tv/TVRefreshButton.tsx @@ -0,0 +1,70 @@ +import { Ionicons } from "@expo/vector-icons"; +import { type QueryClient, useQueryClient } from "@tanstack/react-query"; +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { Animated, Easing } from "react-native"; +import { TVButton } from "./TVButton"; + +export interface TVRefreshButtonProps { + itemId: string | undefined; + queryClient?: QueryClient; +} + +export const TVRefreshButton: React.FC = ({ + itemId, + queryClient: externalQueryClient, +}) => { + const defaultQueryClient = useQueryClient(); + const queryClient = externalQueryClient ?? defaultQueryClient; + const [isRefreshing, setIsRefreshing] = useState(false); + const spinValue = useRef(new Animated.Value(0)).current; + + useEffect(() => { + if (isRefreshing) { + spinValue.setValue(0); + Animated.loop( + Animated.timing(spinValue, { + toValue: 1, + duration: 1000, + easing: Easing.linear, + useNativeDriver: true, + }), + ).start(); + } else { + spinValue.stopAnimation(); + spinValue.setValue(0); + } + }, [isRefreshing, spinValue]); + + const spin = spinValue.interpolate({ + inputRange: [0, 1], + outputRange: ["0deg", "360deg"], + }); + + const handleRefresh = useCallback(async () => { + if (!itemId || isRefreshing) return; + + setIsRefreshing(true); + const minSpinTime = new Promise((resolve) => setTimeout(resolve, 1000)); + try { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["item", itemId] }), + minSpinTime, + ]); + } finally { + setIsRefreshing(false); + } + }, [itemId, queryClient, isRefreshing]); + + return ( + + + + + + ); +}; diff --git a/components/tv/TVSeriesNavigation.tsx b/components/tv/TVSeriesNavigation.tsx new file mode 100644 index 000000000..4f828a4e6 --- /dev/null +++ b/components/tv/TVSeriesNavigation.tsx @@ -0,0 +1,78 @@ +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import React from "react"; +import { useTranslation } from "react-i18next"; +import { ScrollView, View } from "react-native"; +import { Text } from "@/components/common/Text"; +import { useScaledTVSizes } from "@/constants/TVSizes"; +import { useScaledTVTypography } from "@/constants/TVTypography"; +import { scaleSize } from "@/utils/scaleSize"; +import { TVSeriesSeasonCard } from "./TVSeriesSeasonCard"; + +export interface TVSeriesNavigationProps { + item: BaseItemDto; + seriesImageUrl?: string | null; + seasonImageUrl?: string | null; + onSeriesPress: () => void; + onSeasonPress: () => void; +} + +export const TVSeriesNavigation: React.FC = React.memo( + ({ item, seriesImageUrl, seasonImageUrl, onSeriesPress, onSeasonPress }) => { + const typography = useScaledTVTypography(); + const sizes = useScaledTVSizes(); + const { t } = useTranslation(); + + // Only show for episodes with a series + if (item.Type !== "Episode" || !item.SeriesId) { + return null; + } + + return ( + + + {t("item_card.from_this_series") || "From this Series"} + + + {/* Series card */} + + + {/* Season card */} + {(item.SeasonId || item.ParentId) && ( + + )} + + + ); + }, +); diff --git a/components/tv/TVSeriesSeasonCard.tsx b/components/tv/TVSeriesSeasonCard.tsx new file mode 100644 index 000000000..64535cc92 --- /dev/null +++ b/components/tv/TVSeriesSeasonCard.tsx @@ -0,0 +1,164 @@ +import { Ionicons } from "@expo/vector-icons"; +import { Image } from "expo-image"; +import React, { useRef, useState } from "react"; +import { Animated, Easing, Platform, Pressable, View } from "react-native"; +import { Text } from "@/components/common/Text"; +import { useScaledTVSizes } from "@/constants/TVSizes"; +import { useScaledTVTypography } from "@/constants/TVTypography"; +import { + GlassPosterView, + isGlassEffectAvailable, +} from "@/modules/glass-poster"; +import { scaleSize } from "@/utils/scaleSize"; + +export interface TVSeriesSeasonCardProps { + title: string; + subtitle?: string; + imageUrl: string | null; + onPress: () => void; + hasTVPreferredFocus?: boolean; + /** Setter function for the ref (for focus guide destinations) */ + refSetter?: (ref: View | null) => void; +} + +export const TVSeriesSeasonCard: React.FC = ({ + title, + subtitle, + imageUrl, + onPress, + hasTVPreferredFocus, + refSetter, +}) => { + const typography = useScaledTVTypography(); + const sizes = useScaledTVSizes(); + const [focused, setFocused] = useState(false); + + // Check if glass effect is available (tvOS 26+) + const useGlass = Platform.OS === "ios" && isGlassEffectAvailable(); + + // Scale animation for focus (only used when NOT using glass effect) + const scale = useRef(new Animated.Value(1)).current; + const animateTo = (value: number) => + Animated.timing(scale, { + toValue: value, + duration: 150, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }).start(); + + const renderPoster = () => { + if (useGlass) { + return ( + + ); + } + + return ( + + {imageUrl ? ( + + ) : ( + + + + )} + + ); + }; + + return ( + + { + setFocused(true); + // Only animate scale when not using glass effect (glass handles its own focus visual) + if (!useGlass) { + animateTo(1.05); + } + }} + onBlur={() => { + setFocused(false); + if (!useGlass) { + animateTo(1); + } + }} + hasTVPreferredFocus={hasTVPreferredFocus} + > + + {renderPoster()} + + + + + + {title} + + + {subtitle && ( + + {subtitle} + + )} + + + ); +}; diff --git a/components/tv/TVSkipSegmentCard.tsx b/components/tv/TVSkipSegmentCard.tsx new file mode 100644 index 000000000..140fa317f --- /dev/null +++ b/components/tv/TVSkipSegmentCard.tsx @@ -0,0 +1,144 @@ +import { Ionicons } from "@expo/vector-icons"; +import type { FC } from "react"; +import { useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { + Pressable, + Animated as RNAnimated, + StyleSheet, + TVFocusGuideView, + type View, +} from "react-native"; +import Animated, { + Easing, + useAnimatedStyle, + useSharedValue, + withTiming, +} from "react-native-reanimated"; +import { Text } from "@/components/common/Text"; +import { scaleSize } from "@/utils/scaleSize"; +import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation"; + +export interface TVSkipSegmentCardProps { + show: boolean; + onPress: () => void; + type: "intro" | "credits"; + /** Whether controls are visible - affects card position */ + controlsVisible?: boolean; + /** Callback ref setter for focus guide destination pattern */ + refSetter?: (ref: View | null) => void; + /** Whether this component should receive initial focus */ + hasTVPreferredFocus?: boolean; + /** Destination used when moving down from this card */ + playButtonRef?: View | null; +} + +// Position constants - same as TVNextEpisodeCountdown (they're mutually exclusive) +const BOTTOM_WITH_CONTROLS = 300; +const BOTTOM_WITHOUT_CONTROLS = 120; + +export const TVSkipSegmentCard: FC = ({ + show, + onPress, + type, + controlsVisible = false, + refSetter, + hasTVPreferredFocus = true, + playButtonRef: downDestination, +}) => { + const { t } = useTranslation(); + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation({ + scaleAmount: 1.1, + duration: 120, + }); + + // Animated position based on controls visibility + const bottomPosition = useSharedValue( + controlsVisible ? BOTTOM_WITH_CONTROLS : BOTTOM_WITHOUT_CONTROLS, + ); + + useEffect(() => { + const target = controlsVisible + ? BOTTOM_WITH_CONTROLS + : BOTTOM_WITHOUT_CONTROLS; + bottomPosition.value = withTiming(target, { + duration: 300, + easing: Easing.out(Easing.quad), + }); + }, [controlsVisible, bottomPosition]); + + const containerAnimatedStyle = useAnimatedStyle(() => ({ + bottom: bottomPosition.value, + })); + + const labelText = + type === "intro" ? t("player.skip_intro") : t("player.skip_credits"); + + if (!show) return null; + + return ( + + + + + {labelText} + + + {downDestination && ( + + )} + + ); +}; + +const styles = StyleSheet.create({ + container: { + position: "absolute", + right: scaleSize(80), + zIndex: 100, + }, + button: { + flexDirection: "row", + alignItems: "center", + paddingVertical: scaleSize(10), + paddingHorizontal: scaleSize(18), + borderRadius: scaleSize(12), + borderWidth: scaleSize(2), + gap: scaleSize(8), + }, + label: { + fontSize: scaleSize(20), + color: "#fff", + fontWeight: "600", + }, + returnFocusGuide: { + height: 1, + width: "100%", + }, +}); diff --git a/components/tv/TVSubtitleResultCard.tsx b/components/tv/TVSubtitleResultCard.tsx new file mode 100644 index 000000000..f1f3ccf92 --- /dev/null +++ b/components/tv/TVSubtitleResultCard.tsx @@ -0,0 +1,272 @@ +import { Ionicons } from "@expo/vector-icons"; +import React from "react"; +import { + ActivityIndicator, + Animated, + Pressable, + StyleSheet, + View, +} from "react-native"; +import { Text } from "@/components/common/Text"; +import { useScaledTVTypography } from "@/constants/TVTypography"; +import type { SubtitleSearchResult } from "@/hooks/useRemoteSubtitles"; +import { scaleSize } from "@/utils/scaleSize"; +import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation"; + +export interface TVSubtitleResultCardProps { + result: SubtitleSearchResult; + hasTVPreferredFocus?: boolean; + isDownloading?: boolean; + onPress: () => void; +} + +export const TVSubtitleResultCard = React.forwardRef< + View, + TVSubtitleResultCardProps +>(({ result, hasTVPreferredFocus, isDownloading, onPress }, ref) => { + const typography = useScaledTVTypography(); + const styles = createStyles(typography); + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation({ scaleAmount: 1.03 }); + + return ( + + + {/* Provider/Source badge */} + + + {result.providerName} + + + + {/* Name */} + + {result.name} + + + {/* Meta info row */} + + {/* Format */} + + {result.format?.toUpperCase()} + + + {/* Rating if available */} + {result.communityRating !== undefined && + result.communityRating > 0 && ( + + + + {result.communityRating.toFixed(1)} + + + )} + + {/* Download count if available */} + {result.downloadCount !== undefined && result.downloadCount > 0 && ( + + + + {result.downloadCount.toLocaleString()} + + + )} + + + {/* Flags */} + + {result.isHashMatch && ( + + Hash Match + + )} + {result.hearingImpaired && ( + + + + )} + {result.aiTranslated && ( + + AI + + )} + + + {/* Loading indicator when downloading */} + {isDownloading && ( + + + + )} + + + ); +}); + +const createStyles = (typography: ReturnType) => + StyleSheet.create({ + resultCard: { + width: scaleSize(220), + minHeight: scaleSize(120), + borderRadius: scaleSize(14), + padding: scaleSize(14), + borderWidth: scaleSize(1), + }, + providerBadge: { + alignSelf: "flex-start", + paddingHorizontal: scaleSize(8), + paddingVertical: scaleSize(3), + borderRadius: scaleSize(6), + marginBottom: scaleSize(8), + }, + providerText: { + fontSize: typography.callout, + fontWeight: "600", + textTransform: "uppercase", + letterSpacing: 0.5, + }, + resultName: { + fontSize: typography.callout, + fontWeight: "500", + marginBottom: scaleSize(8), + lineHeight: scaleSize(18), + }, + resultMeta: { + flexDirection: "row", + alignItems: "center", + gap: scaleSize(12), + marginBottom: scaleSize(8), + }, + resultMetaText: { + fontSize: typography.callout, + }, + ratingContainer: { + flexDirection: "row", + alignItems: "center", + gap: scaleSize(3), + }, + downloadCountContainer: { + flexDirection: "row", + alignItems: "center", + gap: scaleSize(3), + }, + flagsContainer: { + flexDirection: "row", + gap: scaleSize(6), + flexWrap: "wrap", + }, + flag: { + paddingHorizontal: scaleSize(6), + paddingVertical: scaleSize(2), + borderRadius: scaleSize(4), + }, + flagText: { + fontSize: typography.callout, + fontWeight: "600", + color: "#fff", + }, + downloadingOverlay: { + ...StyleSheet.absoluteFill, + backgroundColor: "rgba(0,0,0,0.5)", + borderRadius: scaleSize(14), + justifyContent: "center", + alignItems: "center", + }, + }); diff --git a/components/tv/TVTabButton.tsx b/components/tv/TVTabButton.tsx new file mode 100644 index 000000000..be8ea8c2d --- /dev/null +++ b/components/tv/TVTabButton.tsx @@ -0,0 +1,71 @@ +import React from "react"; +import { Animated, Pressable } from "react-native"; +import { Text } from "@/components/common/Text"; +import { useScaledTVTypography } from "@/constants/TVTypography"; +import { scaleSize } from "@/utils/scaleSize"; +import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation"; + +export interface TVTabButtonProps { + label: string; + active: boolean; + onSelect: () => void; + hasTVPreferredFocus?: boolean; + switchOnFocus?: boolean; + disabled?: boolean; +} + +export const TVTabButton: React.FC = ({ + label, + active, + onSelect, + hasTVPreferredFocus = false, + switchOnFocus = false, + disabled = false, +}) => { + const typography = useScaledTVTypography(); + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation({ + scaleAmount: 1.05, + duration: 120, + onFocus: switchOnFocus ? onSelect : undefined, + }); + + return ( + + + + {label} + + + + ); +}; diff --git a/components/tv/TVTechnicalDetails.tsx b/components/tv/TVTechnicalDetails.tsx new file mode 100644 index 000000000..0a0fc970a --- /dev/null +++ b/components/tv/TVTechnicalDetails.tsx @@ -0,0 +1,80 @@ +import type { MediaStream } from "@jellyfin/sdk/lib/generated-client/models"; +import React from "react"; +import { useTranslation } from "react-i18next"; +import { View } from "react-native"; +import { Text } from "@/components/common/Text"; +import { useScaledTVTypography } from "@/constants/TVTypography"; +import { scaleSize } from "@/utils/scaleSize"; + +export interface TVTechnicalDetailsProps { + mediaStreams: MediaStream[]; +} + +export const TVTechnicalDetails: React.FC = React.memo( + ({ mediaStreams }) => { + const typography = useScaledTVTypography(); + const { t } = useTranslation(); + + const videoStream = mediaStreams.find((s) => s.Type === "Video"); + const audioStream = mediaStreams.find((s) => s.Type === "Audio"); + + if (!videoStream && !audioStream) { + return null; + } + + return ( + + + {t("item_card.technical_details")} + + + {videoStream && ( + + + {t("common.video")} + + + {videoStream.DisplayTitle || + `${videoStream.Codec?.toUpperCase()} ${videoStream.Width}x${videoStream.Height}`} + + + )} + {audioStream && ( + + + {t("common.audio")} + + + {audioStream.DisplayTitle || + `${audioStream.Codec?.toUpperCase()} ${audioStream.Channels}ch`} + + + )} + + + ); + }, +); diff --git a/components/tv/TVThemeMusicIndicator.tsx b/components/tv/TVThemeMusicIndicator.tsx new file mode 100644 index 000000000..88fd1e1a5 --- /dev/null +++ b/components/tv/TVThemeMusicIndicator.tsx @@ -0,0 +1,79 @@ +import { Ionicons } from "@expo/vector-icons"; +import React, { useRef, useState } from "react"; +import { Animated, Easing, Pressable, View } from "react-native"; +import { AnimatedEqualizer } from "@/components/music/AnimatedEqualizer"; +import { scaleSize } from "@/utils/scaleSize"; + +interface TVThemeMusicIndicatorProps { + isPlaying: boolean; + isMuted: boolean; + hasThemeMusic: boolean; + onToggleMute: () => void; + disabled?: boolean; +} + +export const TVThemeMusicIndicator: React.FC = ({ + isPlaying, + isMuted, + hasThemeMusic, + onToggleMute, + disabled = false, +}) => { + const [focused, setFocused] = useState(false); + const scale = useRef(new Animated.Value(1)).current; + + const animateTo = (v: number) => + Animated.timing(scale, { + toValue: v, + duration: 150, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }).start(); + + if (!hasThemeMusic || !isPlaying) return null; + + return ( + { + setFocused(true); + animateTo(1.15); + }} + onBlur={() => { + setFocused(false); + animateTo(1); + }} + disabled={disabled} + focusable={!disabled} + > + + {isMuted ? ( + + ) : ( + + + + )} + + + ); +}; diff --git a/components/tv/TVTrackCard.tsx b/components/tv/TVTrackCard.tsx new file mode 100644 index 000000000..b2b451625 --- /dev/null +++ b/components/tv/TVTrackCard.tsx @@ -0,0 +1,106 @@ +import { Ionicons } from "@expo/vector-icons"; +import React from "react"; +import { Animated, Pressable, StyleSheet, View } from "react-native"; +import { Text } from "@/components/common/Text"; +import { useScaledTVTypography } from "@/constants/TVTypography"; +import { scaleSize } from "@/utils/scaleSize"; +import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation"; + +export interface TVTrackCardProps { + label: string; + sublabel?: string; + selected: boolean; + hasTVPreferredFocus?: boolean; + onPress: () => void; +} + +export const TVTrackCard = React.forwardRef( + ({ label, sublabel, selected, hasTVPreferredFocus, onPress }, ref) => { + const typography = useScaledTVTypography(); + const styles = createStyles(typography); + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation({ scaleAmount: 1.05 }); + + return ( + + + + {label} + + {sublabel && ( + + {sublabel} + + )} + {selected && !focused && ( + + + + )} + + + ); + }, +); + +const createStyles = (typography: ReturnType) => + StyleSheet.create({ + trackCard: { + width: scaleSize(180), + height: scaleSize(80), + borderRadius: scaleSize(14), + justifyContent: "center", + alignItems: "center", + paddingHorizontal: scaleSize(12), + }, + trackCardText: { + fontSize: typography.callout, + textAlign: "center", + }, + trackCardSublabel: { + fontSize: typography.callout, + marginTop: scaleSize(2), + }, + checkmark: { + position: "absolute", + top: scaleSize(8), + right: scaleSize(8), + }, + }); diff --git a/components/tv/TVUserCard.tsx b/components/tv/TVUserCard.tsx new file mode 100644 index 000000000..b687bf757 --- /dev/null +++ b/components/tv/TVUserCard.tsx @@ -0,0 +1,183 @@ +import { Ionicons } from "@expo/vector-icons"; +import React from "react"; +import { useTranslation } from "react-i18next"; +import { Animated, Pressable, View } from "react-native"; +import { Text } from "@/components/common/Text"; +import { useScaledTVTypography } from "@/constants/TVTypography"; +import { scaleSize } from "@/utils/scaleSize"; +import type { AccountSecurityType } from "@/utils/secureCredentials"; +import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation"; + +export interface TVUserCardProps { + username: string; + securityType: AccountSecurityType; + hasTVPreferredFocus?: boolean; + isCurrent?: boolean; + onPress: () => void; +} + +export const TVUserCard = React.forwardRef( + ( + { + username, + securityType, + hasTVPreferredFocus = false, + isCurrent = false, + onPress, + }, + ref, + ) => { + const { t } = useTranslation(); + const typography = useScaledTVTypography(); + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation({ scaleAmount: isCurrent ? 1.02 : 1.05 }); + + const getSecurityIcon = (): keyof typeof Ionicons.glyphMap => { + switch (securityType) { + case "pin": + return "keypad"; + case "password": + return "lock-closed"; + default: + return "key"; + } + }; + + const getSecurityText = (): string => { + switch (securityType) { + case "pin": + return t("save_account.pin_code"); + case "password": + return t("save_account.password"); + default: + return t("save_account.no_protection"); + } + }; + + const getBackgroundColor = () => { + if (isCurrent) { + return focused ? "rgba(255,255,255,0.15)" : "rgba(255,255,255,0.04)"; + } + return focused ? "#fff" : "rgba(255,255,255,0.08)"; + }; + + const getTextColor = () => { + if (isCurrent) { + return "rgba(255,255,255,0.4)"; + } + return focused ? "#000" : "#fff"; + }; + + const getSecondaryColor = () => { + if (isCurrent) { + return "rgba(255,255,255,0.25)"; + } + return focused ? "rgba(0,0,0,0.5)" : "rgba(255,255,255,0.5)"; + }; + + return ( + + + {/* User Avatar */} + + + + + {/* Text column */} + + {/* Username */} + + + {username} + + {isCurrent && ( + + ({t("home.settings.switch_user.current")}) + + )} + + + {/* Security indicator */} + + + + {getSecurityText()} + + + + + + ); + }, +); diff --git a/components/tv/hooks/useTVFocusAnimation.ts b/components/tv/hooks/useTVFocusAnimation.ts new file mode 100644 index 000000000..b3418c8cb --- /dev/null +++ b/components/tv/hooks/useTVFocusAnimation.ts @@ -0,0 +1,64 @@ +import { useCallback, useRef, useState } from "react"; +import { Animated, Easing } from "react-native"; +import { useInactivity } from "@/providers/InactivityProvider"; + +export interface UseTVFocusAnimationOptions { + scaleAmount?: number; + duration?: number; + onFocus?: () => void; + onBlur?: () => void; +} + +export interface UseTVFocusAnimationReturn { + focused: boolean; + scale: Animated.Value; + handleFocus: () => void; + handleBlur: () => void; + animatedStyle: { transform: { scale: Animated.Value }[] }; +} + +export const useTVFocusAnimation = ({ + scaleAmount = 1.05, + duration = 150, + onFocus, + onBlur, +}: UseTVFocusAnimationOptions = {}): UseTVFocusAnimationReturn => { + const [focused, setFocused] = useState(false); + const scale = useRef(new Animated.Value(1)).current; + const { resetInactivityTimer } = useInactivity(); + + const animateTo = useCallback( + (value: number) => { + Animated.timing(scale, { + toValue: value, + duration, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }).start(); + }, + [scale, duration], + ); + + const handleFocus = useCallback(() => { + setFocused(true); + animateTo(scaleAmount); + resetInactivityTimer(); + onFocus?.(); + }, [animateTo, scaleAmount, resetInactivityTimer, onFocus]); + + const handleBlur = useCallback(() => { + setFocused(false); + animateTo(1); + onBlur?.(); + }, [animateTo, onBlur]); + + const animatedStyle = { transform: [{ scale }] }; + + return { + focused, + scale, + handleFocus, + handleBlur, + animatedStyle, + }; +}; diff --git a/components/tv/index.ts b/components/tv/index.ts new file mode 100644 index 000000000..99f626a0f --- /dev/null +++ b/components/tv/index.ts @@ -0,0 +1,72 @@ +// Hooks +export type { + UseTVFocusAnimationOptions, + UseTVFocusAnimationReturn, +} from "./hooks/useTVFocusAnimation"; +export { useTVFocusAnimation } from "./hooks/useTVFocusAnimation"; +// Settings components (re-export from settings/) +export * from "./settings"; +// Item content components +export type { TVActorCardProps } from "./TVActorCard"; +export { TVActorCard } from "./TVActorCard"; +export type { TVBackdropProps } from "./TVBackdrop"; +export { TVBackdrop } from "./TVBackdrop"; +// Core components +export type { TVButtonProps } from "./TVButton"; +export { TVButton } from "./TVButton"; +export type { TVCancelButtonProps } from "./TVCancelButton"; +export { TVCancelButton } from "./TVCancelButton"; +export type { TVCastCrewTextProps } from "./TVCastCrewText"; +export { TVCastCrewText } from "./TVCastCrewText"; +export type { TVCastSectionProps } from "./TVCastSection"; +export { TVCastSection } from "./TVCastSection"; +// Player control components +export type { TVControlButtonProps } from "./TVControlButton"; +export { TVControlButton } from "./TVControlButton"; +export type { TVFavoriteButtonProps } from "./TVFavoriteButton"; +export { TVFavoriteButton } from "./TVFavoriteButton"; +export type { TVFilterButtonProps } from "./TVFilterButton"; +export { TVFilterButton } from "./TVFilterButton"; +export type { TVFocusablePosterProps } from "./TVFocusablePoster"; +export { TVFocusablePoster } from "./TVFocusablePoster"; +export type { TVItemCardTextProps } from "./TVItemCardText"; +export { TVItemCardText } from "./TVItemCardText"; +export type { TVLanguageCardProps } from "./TVLanguageCard"; +export { TVLanguageCard } from "./TVLanguageCard"; +export type { TVMetadataBadgesProps } from "./TVMetadataBadges"; +export { TVMetadataBadges } from "./TVMetadataBadges"; +export type { TVNavBarProps, TVNavBarTab } from "./TVNavBar"; +export { TVNavBar } from "./TVNavBar"; +export type { TVNextEpisodeCountdownProps } from "./TVNextEpisodeCountdown"; +export { TVNextEpisodeCountdown } from "./TVNextEpisodeCountdown"; +export type { TVOptionButtonProps } from "./TVOptionButton"; +export { TVOptionButton } from "./TVOptionButton"; +export type { TVOptionCardProps } from "./TVOptionCard"; +export { TVOptionCard } from "./TVOptionCard"; +export type { TVOptionItem, TVOptionSelectorProps } from "./TVOptionSelector"; +export { TVOptionSelector } from "./TVOptionSelector"; +export type { TVPlayedButtonProps } from "./TVPlayedButton"; +export { TVPlayedButton } from "./TVPlayedButton"; +export type { TVProgressBarProps } from "./TVProgressBar"; +export { TVProgressBar } from "./TVProgressBar"; +export type { TVRefreshButtonProps } from "./TVRefreshButton"; +export { TVRefreshButton } from "./TVRefreshButton"; +export type { TVSeriesNavigationProps } from "./TVSeriesNavigation"; +export { TVSeriesNavigation } from "./TVSeriesNavigation"; +export type { TVSeriesSeasonCardProps } from "./TVSeriesSeasonCard"; +export { TVSeriesSeasonCard } from "./TVSeriesSeasonCard"; +export type { TVSkipSegmentCardProps } from "./TVSkipSegmentCard"; +export { TVSkipSegmentCard } from "./TVSkipSegmentCard"; +export type { TVSubtitleResultCardProps } from "./TVSubtitleResultCard"; +export { TVSubtitleResultCard } from "./TVSubtitleResultCard"; +export type { TVTabButtonProps } from "./TVTabButton"; +export { TVTabButton } from "./TVTabButton"; +export type { TVTechnicalDetailsProps } from "./TVTechnicalDetails"; +export { TVTechnicalDetails } from "./TVTechnicalDetails"; +export { TVThemeMusicIndicator } from "./TVThemeMusicIndicator"; +// Subtitle sheet components +export type { TVTrackCardProps } from "./TVTrackCard"; +export { TVTrackCard } from "./TVTrackCard"; +// User switching +export type { TVUserCardProps } from "./TVUserCard"; +export { TVUserCard } from "./TVUserCard"; diff --git a/components/tv/settings/TVLogoutButton.tsx b/components/tv/settings/TVLogoutButton.tsx new file mode 100644 index 000000000..b001e5071 --- /dev/null +++ b/components/tv/settings/TVLogoutButton.tsx @@ -0,0 +1,65 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { Animated, Pressable, View } from "react-native"; +import { Text } from "@/components/common/Text"; +import { useScaledTVTypography } from "@/constants/TVTypography"; +import { scaleSize } from "@/utils/scaleSize"; +import { useTVFocusAnimation } from "../hooks/useTVFocusAnimation"; + +export interface TVLogoutButtonProps { + onPress: () => void; + disabled?: boolean; +} + +export const TVLogoutButton: React.FC = ({ + onPress, + disabled, +}) => { + const { t } = useTranslation(); + const typography = useScaledTVTypography(); + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation({ scaleAmount: 1.05 }); + + return ( + + + + + {t("home.settings.log_out_button")} + + + + + ); +}; diff --git a/components/tv/settings/TVSectionHeader.tsx b/components/tv/settings/TVSectionHeader.tsx new file mode 100644 index 000000000..44e240e1a --- /dev/null +++ b/components/tv/settings/TVSectionHeader.tsx @@ -0,0 +1,28 @@ +import React from "react"; +import { Text } from "@/components/common/Text"; +import { useScaledTVTypography } from "@/constants/TVTypography"; + +export interface TVSectionHeaderProps { + title: string; +} + +export const TVSectionHeader: React.FC = ({ title }) => { + const typography = useScaledTVTypography(); + + return ( + + {title} + + ); +}; diff --git a/components/tv/settings/TVSettingsOptionButton.tsx b/components/tv/settings/TVSettingsOptionButton.tsx new file mode 100644 index 000000000..6248cf2ce --- /dev/null +++ b/components/tv/settings/TVSettingsOptionButton.tsx @@ -0,0 +1,77 @@ +import { Ionicons } from "@expo/vector-icons"; +import React from "react"; +import { Animated, Pressable, View } from "react-native"; +import { Text } from "@/components/common/Text"; +import { useScaledTVTypography } from "@/constants/TVTypography"; +import { scaleSize } from "@/utils/scaleSize"; +import { useTVFocusAnimation } from "../hooks/useTVFocusAnimation"; + +export interface TVSettingsOptionButtonProps { + label: string; + value: string; + onPress: () => void; + isFirst?: boolean; + disabled?: boolean; +} + +export const TVSettingsOptionButton: React.FC = ({ + label, + value, + onPress, + isFirst, + disabled, +}) => { + const typography = useScaledTVTypography(); + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation({ scaleAmount: 1.02 }); + + return ( + + + + {label} + + + + {value} + + + + + + ); +}; diff --git a/components/tv/settings/TVSettingsRow.tsx b/components/tv/settings/TVSettingsRow.tsx new file mode 100644 index 000000000..1b8422d1c --- /dev/null +++ b/components/tv/settings/TVSettingsRow.tsx @@ -0,0 +1,80 @@ +import { Ionicons } from "@expo/vector-icons"; +import React from "react"; +import { Animated, Pressable, View } from "react-native"; +import { Text } from "@/components/common/Text"; +import { useScaledTVTypography } from "@/constants/TVTypography"; +import { scaleSize } from "@/utils/scaleSize"; +import { useTVFocusAnimation } from "../hooks/useTVFocusAnimation"; + +export interface TVSettingsRowProps { + label: string; + value: string; + onPress?: () => void; + isFirst?: boolean; + showChevron?: boolean; + disabled?: boolean; +} + +export const TVSettingsRow: React.FC = ({ + label, + value, + onPress, + isFirst, + showChevron = true, + disabled, +}) => { + const typography = useScaledTVTypography(); + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation({ scaleAmount: 1.02 }); + + return ( + + + + {label} + + + + {value} + + {showChevron && ( + + )} + + + + ); +}; diff --git a/components/tv/settings/TVSettingsStepper.tsx b/components/tv/settings/TVSettingsStepper.tsx new file mode 100644 index 000000000..d0ba7b2cb --- /dev/null +++ b/components/tv/settings/TVSettingsStepper.tsx @@ -0,0 +1,134 @@ +import { Ionicons } from "@expo/vector-icons"; +import React from "react"; +import { Animated, Pressable, View } from "react-native"; +import { Text } from "@/components/common/Text"; +import { useScaledTVTypography } from "@/constants/TVTypography"; +import { scaleSize } from "@/utils/scaleSize"; +import { useTVFocusAnimation } from "../hooks/useTVFocusAnimation"; + +export interface TVSettingsStepperProps { + label: string; + value: number; + onDecrease: () => void; + onIncrease: () => void; + formatValue?: (value: number) => string; + isFirst?: boolean; + disabled?: boolean; +} + +export const TVSettingsStepper: React.FC = ({ + label, + value, + onDecrease, + onIncrease, + formatValue, + isFirst, + disabled, +}) => { + const typography = useScaledTVTypography(); + const labelAnim = useTVFocusAnimation({ scaleAmount: 1.02 }); + const minusAnim = useTVFocusAnimation({ scaleAmount: 1.1 }); + const plusAnim = useTVFocusAnimation({ scaleAmount: 1.1 }); + + const displayValue = formatValue ? formatValue(value) : String(value); + + return ( + + + + + {label} + + + + + + + + + + + {displayValue} + + + + + + + + + ); +}; diff --git a/components/tv/settings/TVSettingsTextInput.tsx b/components/tv/settings/TVSettingsTextInput.tsx new file mode 100644 index 000000000..c5d89ecee --- /dev/null +++ b/components/tv/settings/TVSettingsTextInput.tsx @@ -0,0 +1,92 @@ +import React, { useRef } from "react"; +import { Animated, Pressable, TextInput } from "react-native"; +import { Text } from "@/components/common/Text"; +import { useScaledTVTypography } from "@/constants/TVTypography"; +import { scaleSize } from "@/utils/scaleSize"; +import { useTVFocusAnimation } from "../hooks/useTVFocusAnimation"; + +export interface TVSettingsTextInputProps { + label: string; + value: string; + placeholder?: string; + onChangeText: (text: string) => void; + onBlur?: () => void; + secureTextEntry?: boolean; + disabled?: boolean; +} + +export const TVSettingsTextInput: React.FC = ({ + label, + value, + placeholder, + onChangeText, + onBlur, + secureTextEntry, + disabled, +}) => { + const typography = useScaledTVTypography(); + const inputRef = useRef(null); + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation({ scaleAmount: 1.02 }); + + const handleInputBlur = () => { + handleBlur(); + onBlur?.(); + }; + + return ( + inputRef.current?.focus()} + onFocus={handleFocus} + onBlur={handleInputBlur} + disabled={disabled} + focusable={!disabled} + > + + + {label} + + + + + ); +}; diff --git a/components/tv/settings/TVSettingsToggle.tsx b/components/tv/settings/TVSettingsToggle.tsx new file mode 100644 index 000000000..57f59a489 --- /dev/null +++ b/components/tv/settings/TVSettingsToggle.tsx @@ -0,0 +1,79 @@ +import React from "react"; +import { Animated, Pressable, View } from "react-native"; +import { Text } from "@/components/common/Text"; +import { useScaledTVTypography } from "@/constants/TVTypography"; +import { scaleSize } from "@/utils/scaleSize"; +import { useTVFocusAnimation } from "../hooks/useTVFocusAnimation"; + +export interface TVSettingsToggleProps { + label: string; + value: boolean; + onToggle: (value: boolean) => void; + isFirst?: boolean; + disabled?: boolean; +} + +export const TVSettingsToggle: React.FC = ({ + label, + value, + onToggle, + isFirst, + disabled, +}) => { + const typography = useScaledTVTypography(); + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation({ scaleAmount: 1.02 }); + + return ( + onToggle(!value)} + onFocus={handleFocus} + onBlur={handleBlur} + hasTVPreferredFocus={isFirst && !disabled} + disabled={disabled} + focusable={!disabled} + > + + + {label} + + + + + + + ); +}; diff --git a/components/tv/settings/index.ts b/components/tv/settings/index.ts new file mode 100644 index 000000000..43ff2f63b --- /dev/null +++ b/components/tv/settings/index.ts @@ -0,0 +1,14 @@ +export type { TVLogoutButtonProps } from "./TVLogoutButton"; +export { TVLogoutButton } from "./TVLogoutButton"; +export type { TVSectionHeaderProps } from "./TVSectionHeader"; +export { TVSectionHeader } from "./TVSectionHeader"; +export type { TVSettingsOptionButtonProps } from "./TVSettingsOptionButton"; +export { TVSettingsOptionButton } from "./TVSettingsOptionButton"; +export type { TVSettingsRowProps } from "./TVSettingsRow"; +export { TVSettingsRow } from "./TVSettingsRow"; +export type { TVSettingsStepperProps } from "./TVSettingsStepper"; +export { TVSettingsStepper } from "./TVSettingsStepper"; +export type { TVSettingsTextInputProps } from "./TVSettingsTextInput"; +export { TVSettingsTextInput } from "./TVSettingsTextInput"; +export type { TVSettingsToggleProps } from "./TVSettingsToggle"; +export { TVSettingsToggle } from "./TVSettingsToggle"; diff --git a/components/video-player/controls/AudioSlider.tsx b/components/video-player/controls/AudioSlider.tsx index 9f70fbba9..31c90483c 100644 --- a/components/video-player/controls/AudioSlider.tsx +++ b/components/video-player/controls/AudioSlider.tsx @@ -105,14 +105,14 @@ const AudioSlider: React.FC = ({ setVisibility }) => { maximumValue={max} thumbWidth={0} onValueChange={handleValueChange} + renderBubble={() => null} + renderThumb={() => null} containerStyle={{ borderRadius: 50, }} theme={{ minimumTrackTintColor: "#FDFDFD", maximumTrackTintColor: "#5A5A5A", - bubbleBackgroundColor: "transparent", // Hide the value bubble - bubbleTextColor: "transparent", // Hide the value text }} /> void; skipCredit: () => void; nextItem?: BaseItemDto | null; @@ -38,6 +54,8 @@ interface BottomControlsProps { handleSliderChange: (value: number) => void; handleTouchStart: () => void; handleTouchEnd: () => void; + /** Programmatic seek (chapter list, hotkeys) — bypasses slide gesture state. */ + seekTo: (value: number) => void; // Trickplay props trickPlayUrl: { @@ -61,14 +79,16 @@ interface BottomControlsProps { export const BottomControls: FC = ({ item, + chapters, + durationMs, showControls, isSliding, showRemoteBubble, currentTime, remainingTime, - isVlc, showSkipButton, showSkipCreditButton, + hasContentAfterCredits, skipIntro, skipCredit, nextItem, @@ -84,30 +104,54 @@ export const BottomControls: FC = ({ handleSliderChange, handleTouchStart, handleTouchEnd, + seekTo, trickPlayUrl, trickplayInfo, time, }) => { const { settings } = useSettings(); - const insets = useSafeAreaInsets(); + const { t } = useTranslation(); + const insets = useControlsSafeAreaInsets(); + const [chapterListVisible, setChapterListVisible] = useState(false); + + // Only expose chapter UI when there are at least two real markers. + const chapterMarkerList = useMemo( + () => chapterMarkers(chapters, durationMs), + [chapters, durationMs], + ); + const hasChapters = chapterMarkerList.length > 1; + + // Current chapter name for the always-visible header label (live playback). + const currentChapterName = useMemo( + () => (hasChapters ? chapterNameAt(currentTime, chapters) : null), + [hasChapters, currentTime, chapters], + ); + + // Chapter name at the scrubbed position for the trickplay bubble. `time` is + // an {h,m,s} object derived from the slider's dragged value — convert back + // to ms for the lookup. Only useful while actively scrubbing. + const scrubChapterName = useMemo(() => { + if (!hasChapters) return null; + const scrubMs = + (time.hours * 3600 + time.minutes * 60 + time.seconds) * 1000; + return chapterNameAt(scrubMs, chapters); + }, [hasChapters, time.hours, time.minutes, time.seconds, chapters]); return ( = ({ {item?.Type === "Audio" && ( {item?.Album} )} + {currentChapterName ? ( + + {currentChapterName} + + ) : null} - + + {/* Smart Skip Credits behavior: + - Show "Skip Credits" if there's content after credits OR no next episode + - Show "Next Episode" if credits extend to video end AND next episode exists */} - {(settings.maxAutoPlayEpisodeCount.value === -1 || - settings.autoPlayEpisodeCount < - settings.maxAutoPlayEpisodeCount.value) && ( - + {settings.autoPlayNextEpisode !== false && + (settings.maxAutoPlayEpisodeCount.value === -1 || + settings.autoPlayEpisodeCount < + settings.maxAutoPlayEpisodeCount.value) && ( + + )} + {hasChapters && ( + setChapterListVisible(true)} + hitSlop={10} + className='justify-center ml-4' + accessibilityRole='button' + accessibilityLabel={t("chapters.open")} + > + + )} @@ -168,6 +234,9 @@ export const BottomControls: FC = ({ height: 10, justifyContent: "center", alignItems: "stretch", + // Allow chapter ticks taller than the 10px track to bleed out + // top/bottom (RN defaults to overflow: "hidden" on Android). + overflow: "visible", }} onTouchStart={handleTouchStart} onTouchEnd={handleTouchEnd} @@ -195,6 +264,7 @@ export const BottomControls: FC = ({ trickPlayUrl={trickPlayUrl} trickplayInfo={trickplayInfo} time={time} + chapterName={scrubChapterName} /> ) } @@ -204,14 +274,21 @@ export const BottomControls: FC = ({ minimumValue={min} maximumValue={max} /> + + setChapterListVisible(false)} + /> ); }; diff --git a/components/video-player/controls/BrightnessSlider.tsx b/components/video-player/controls/BrightnessSlider.tsx index db92bd10a..dadb38386 100644 --- a/components/video-player/controls/BrightnessSlider.tsx +++ b/components/video-player/controls/BrightnessSlider.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; import { Platform, StyleSheet, View } from "react-native"; import { Slider } from "react-native-awesome-slider"; import { useSharedValue } from "react-native-reanimated"; @@ -16,10 +16,19 @@ const BrightnessSlider = () => { const max = useSharedValue(100); const isUserInteracting = useRef(false); const lastKnownBrightness = useRef(50); + const brightnessSupportedRef = useRef(true); + const [brightnessSupported, setBrightnessSupported] = useState(true); // Update brightness from device const updateBrightnessFromDevice = async () => { - if (isTv || !Brightness || isUserInteracting.current) return; + // Check ref (not state) to avoid stale closure in setInterval + if ( + isTv || + !Brightness || + isUserInteracting.current || + !brightnessSupportedRef.current + ) + return; try { const currentBrightness = await Brightness.getBrightnessAsync(); @@ -31,7 +40,10 @@ const BrightnessSlider = () => { lastKnownBrightness.current = brightnessPercent; } } catch (error) { - console.error("Error fetching brightness:", error); + console.warn("Brightness not supported on this device:", error); + // Update both ref (stops interval) and state (triggers re-render to hide) + brightnessSupportedRef.current = false; + setBrightnessSupported(false); } }; @@ -66,7 +78,7 @@ const BrightnessSlider = () => { }, 100); }; - if (isTv) return null; + if (isTv || !brightnessSupported) return null; return ( @@ -76,14 +88,14 @@ const BrightnessSlider = () => { maximumValue={max} thumbWidth={0} onValueChange={handleValueChange} + renderBubble={() => null} + renderThumb={() => null} containerStyle={{ borderRadius: 50, }} theme={{ minimumTrackTintColor: "#FDFDFD", maximumTrackTintColor: "#5A5A5A", - bubbleBackgroundColor: "transparent", // Hide the value bubble - bubbleTextColor: "transparent", // Hide the value text }} /> void; handleSkipBackward: () => void; handleSkipForward: () => void; + // Chapter navigation props + hasChapters?: boolean; + hasPreviousChapter?: boolean; + hasNextChapter?: boolean; + goToPreviousChapter?: () => void; + goToNextChapter?: () => void; } export const CenterControls: FC = ({ @@ -29,36 +35,43 @@ export const CenterControls: FC = ({ togglePlay, handleSkipBackward, handleSkipForward, + hasChapters = false, + hasPreviousChapter = false, + hasNextChapter = false, + goToPreviousChapter, + goToNextChapter, }) => { const { settings } = useSettings(); - const insets = useSafeAreaInsets(); + const insets = useControlsSafeAreaInsets(); return ( - - - + {!settings?.hideBrightnessSlider && ( + + + + )} {!Platform.isTV && ( @@ -92,6 +105,20 @@ export const CenterControls: FC = ({ )} + {!Platform.isTV && hasChapters && ( + + + + )} + {!isBuffering ? ( @@ -106,6 +133,20 @@ export const CenterControls: FC = ({ + {!Platform.isTV && hasChapters && ( + + + + )} + {!Platform.isTV && ( = ({ )} - - - + {!settings?.hideVolumeSlider && ( + + + + )} ); }; diff --git a/components/video-player/controls/ChapterMarkers.tsx b/components/video-player/controls/ChapterMarkers.tsx new file mode 100644 index 000000000..f6ed9628a --- /dev/null +++ b/components/video-player/controls/ChapterMarkers.tsx @@ -0,0 +1,65 @@ +import React from "react"; +import { StyleSheet, View, type ViewStyle } from "react-native"; + +export interface ChapterMarkersProps { + /** Array of chapter positions as percentages (0-100) */ + chapterPositions: number[]; + /** Optional style overrides for the container */ + style?: ViewStyle; + /** Height of the marker lines (should be > track height to extend above) */ + markerHeight?: number; + /** Color of the marker lines */ + markerColor?: string; +} + +/** + * Renders vertical tick marks on the progress bar at chapter positions + * Should be overlaid on the slider track + */ +export const ChapterMarkers: React.FC = React.memo( + ({ + chapterPositions, + style, + markerHeight = 15, + markerColor = "rgba(255, 255, 255, 0.6)", + }) => { + if (!chapterPositions.length) { + return null; + } + + return ( + + {chapterPositions.map((position, index) => ( + + ))} + + ); + }, +); + +const styles = StyleSheet.create({ + container: { + position: "absolute", + top: 0, + left: 0, + right: 0, + bottom: 0, + }, + marker: { + position: "absolute", + width: 2, + borderRadius: 1, + transform: [{ translateX: -1 }], // Center the marker on its position + }, +}); diff --git a/components/video-player/controls/ContinueWatchingOverlay.tsx b/components/video-player/controls/ContinueWatchingOverlay.tsx index 4c353deea..26f82484d 100644 --- a/components/video-player/controls/ContinueWatchingOverlay.tsx +++ b/components/video-player/controls/ContinueWatchingOverlay.tsx @@ -1,9 +1,9 @@ -import { useRouter } from "expo-router"; import { t } from "i18next"; import React from "react"; import { View } from "react-native"; import { Button } from "@/components/Button"; import { Text } from "@/components/common/Text"; +import useRouter from "@/hooks/useAppRouter"; import { useSettings } from "@/utils/atoms/settings"; export interface ContinueWatchingOverlayProps { @@ -23,7 +23,7 @@ const ContinueWatchingOverlay: React.FC = ({ settings.maxAutoPlayEpisodeCount.value ? ( diff --git a/components/video-player/controls/Controls.tsx b/components/video-player/controls/Controls.tsx index fb62fcef7..3651876a4 100644 --- a/components/video-player/controls/Controls.tsx +++ b/components/video-player/controls/Controls.tsx @@ -3,17 +3,9 @@ import type { BaseItemDto, MediaSourceInfo, } from "@jellyfin/sdk/lib/generated-client"; -import { useLocalSearchParams, useRouter } from "expo-router"; -import { - type Dispatch, - type FC, - type MutableRefObject, - type SetStateAction, - useCallback, - useEffect, - useState, -} from "react"; -import { useWindowDimensions } from "react-native"; +import { useLocalSearchParams } from "expo-router"; +import { type FC, useCallback, useEffect, useState } from "react"; +import { StyleSheet, useWindowDimensions, View } from "react-native"; import Animated, { Easing, type SharedValue, @@ -23,65 +15,64 @@ import Animated, { withTiming, } from "react-native-reanimated"; import ContinueWatchingOverlay from "@/components/video-player/controls/ContinueWatchingOverlay"; +import useRouter from "@/hooks/useAppRouter"; import { useCreditSkipper } from "@/hooks/useCreditSkipper"; import { useHaptic } from "@/hooks/useHaptic"; import { useIntroSkipper } from "@/hooks/useIntroSkipper"; import { usePlaybackManager } from "@/hooks/usePlaybackManager"; import { useTrickplay } from "@/hooks/useTrickplay"; -import type { TrackInfo, VlcPlayerViewRef } from "@/modules/VlcPlayer.types"; +import type { TechnicalInfo } from "@/modules/mpv-player"; import { DownloadedItem } from "@/providers/Downloads/types"; +import { useOfflineMode } from "@/providers/OfflineModeProvider"; import { useSettings } from "@/utils/atoms/settings"; import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings"; import { ticksToMs } from "@/utils/time"; import { BottomControls } from "./BottomControls"; import { CenterControls } from "./CenterControls"; import { CONTROLS_CONSTANTS } from "./constants"; -import { ControlProvider } from "./contexts/ControlContext"; import { EpisodeList } from "./EpisodeList"; import { GestureOverlay } from "./GestureOverlay"; import { HeaderControls } from "./HeaderControls"; +import { useChapterNavigation } from "./hooks/useChapterNavigation"; import { useRemoteControl } from "./hooks/useRemoteControl"; import { useVideoNavigation } from "./hooks/useVideoNavigation"; import { useVideoSlider } from "./hooks/useVideoSlider"; import { useVideoTime } from "./hooks/useVideoTime"; -import { type ScaleFactor } from "./ScaleFactorSelector"; +import { TechnicalInfoOverlay } from "./TechnicalInfoOverlay"; import { useControlsTimeout } from "./useControlsTimeout"; +import { PlaybackSpeedScope } from "./utils/playback-speed-settings"; import { type AspectRatio } from "./VideoScalingModeSelector"; interface Props { item: BaseItemDto; - videoRef: MutableRefObject; isPlaying: boolean; isSeeking: SharedValue; cacheProgress: SharedValue; progress: SharedValue; isBuffering: boolean; showControls: boolean; - enableTrickplay?: boolean; togglePlay: () => void; setShowControls: (shown: boolean) => void; - offline?: boolean; - isVideoLoaded?: boolean; mediaSource?: MediaSourceInfo | null; seek: (ticks: number) => void; startPictureInPicture?: () => Promise; play: () => void; pause: () => void; - getAudioTracks?: (() => Promise) | (() => TrackInfo[]); - getSubtitleTracks?: (() => Promise) | (() => TrackInfo[]); - setSubtitleURL?: (url: string, customName: string) => void; - setSubtitleTrack?: (index: number) => void; - setAudioTrack?: (index: number) => void; - setVideoAspectRatio?: (aspectRatio: string | null) => Promise; - setVideoScaleFactor?: (scaleFactor: number) => Promise; aspectRatio?: AspectRatio; - scaleFactor?: ScaleFactor; - setAspectRatio?: Dispatch>; - setScaleFactor?: Dispatch>; - isVlc?: boolean; + isZoomedToFill?: boolean; + onZoomToggle?: () => void; api?: Api | null; downloadedFiles?: DownloadedItem[]; + // Playback speed props + playbackSpeed?: number; + setPlaybackSpeed?: (speed: number, scope: PlaybackSpeedScope) => void; + // Technical info props + showTechnicalInfo?: boolean; + onToggleTechnicalInfo?: () => void; + getTechnicalInfo?: () => Promise; + playMethod?: "DirectPlay" | "DirectStream" | "Transcode"; + transcodeReasons?: string[]; } export const Controls: FC = ({ @@ -99,23 +90,20 @@ export const Controls: FC = ({ showControls, setShowControls, mediaSource, - isVideoLoaded, - getAudioTracks, - getSubtitleTracks, - setSubtitleURL, - setSubtitleTrack, - setAudioTrack, - setVideoAspectRatio, - setVideoScaleFactor, aspectRatio = "default", - scaleFactor = 1.0, - setAspectRatio, - setScaleFactor, - offline = false, - isVlc = false, + isZoomedToFill = false, + onZoomToggle, api = null, downloadedFiles = undefined, + playbackSpeed = 1.0, + setPlaybackSpeed, + showTechnicalInfo = false, + onToggleTechnicalInfo, + getTechnicalInfo, + playMethod, + transcodeReasons, }) => { + const offline = useOfflineMode(); const { settings, updateSettings } = useSettings(); const router = useRouter(); const lightHapticFeedback = useHaptic("light"); @@ -137,7 +125,9 @@ export const Controls: FC = ({ } = useTrickplay(item); const min = useSharedValue(0); - const max = useSharedValue(item.RunTimeTicks || 0); + // Regular value for use during render (avoids Reanimated warning) + const maxMs = ticksToMs(item.RunTimeTicks || 0); + const max = useSharedValue(maxMs); // Animation values for controls const controlsOpacity = useSharedValue(showControls ? 1 : 0); @@ -194,17 +184,13 @@ export const Controls: FC = ({ zIndex: 10, })); - // Initialize progress values + // Initialize progress values - MPV uses milliseconds useEffect(() => { if (item) { - progress.value = isVlc - ? ticksToMs(item?.UserData?.PlaybackPositionTicks) - : item?.UserData?.PlaybackPositionTicks || 0; - max.value = isVlc - ? ticksToMs(item.RunTimeTicks || 0) - : item.RunTimeTicks || 0; + progress.value = ticksToMs(item?.UserData?.PlaybackPositionTicks); + max.value = ticksToMs(item.RunTimeTicks || 0); } - }, [item, isVlc, progress, max]); + }, [item, progress, max]); // Navigation hooks const { @@ -215,7 +201,6 @@ export const Controls: FC = ({ } = useVideoNavigation({ progress, isPlaying, - isVlc, seek, play, }); @@ -225,7 +210,20 @@ export const Controls: FC = ({ progress, max, isSeeking, - isVlc, + }); + + // Chapter navigation hook + const { + hasChapters, + hasPreviousChapter, + hasNextChapter, + goToPreviousChapter, + goToNextChapter, + } = useChapterNavigation({ + chapters: item.Chapters, + progress, + maxMs, + seek, }); const toggleControls = useCallback(() => { @@ -248,7 +246,6 @@ export const Controls: FC = ({ progress, min, max, - isVlc, showControls, isPlaying, seek, @@ -269,11 +266,11 @@ export const Controls: FC = ({ handleTouchEnd, handleSliderComplete, handleSliderChange, + seekTo, } = useVideoSlider({ progress, isSeeking, isPlaying, - isVlc, seek, play, pause, @@ -302,9 +299,8 @@ export const Controls: FC = ({ : current.actual; } else { // When not scrubbing, only update if progress changed significantly (1 second) - const progressUnit = isVlc - ? CONTROLS_CONSTANTS.PROGRESS_UNIT_MS - : CONTROLS_CONSTANTS.PROGRESS_UNIT_TICKS; + // MPV uses milliseconds + const progressUnit = CONTROLS_CONSTANTS.PROGRESS_UNIT_MS; const progressDiff = Math.abs(current.actual - effectiveProgress.value); if (progressDiff >= progressUnit) { effectiveProgress.value = current.actual; @@ -325,22 +321,22 @@ export const Controls: FC = ({ currentTime, seek, play, - isVlc, offline, api, downloadedFiles, ); - const { showSkipCreditButton, skipCredit } = useCreditSkipper( - item.Id!, - currentTime, - seek, - play, - isVlc, - offline, - api, - downloadedFiles, - ); + const { showSkipCreditButton, skipCredit, hasContentAfterCredits } = + useCreditSkipper( + item.Id!, + currentTime, + seek, + play, + offline, + api, + downloadedFiles, + maxMs, + ); const goToItemCommon = useCallback( (item: BaseItemDto) => { @@ -362,11 +358,16 @@ export const Controls: FC = ({ } = getDefaultPlaySettings( item, settings, - previousIndexes, - mediaSource ?? undefined, + { + indexes: previousIndexes, + source: mediaSource ?? undefined, + }, + { applyLanguagePreferences: true }, ); - const queryParams = new URLSearchParams({ + // Use setParams instead of replace to avoid unmounting/remounting the player, + // which would create a new MPV native view and crash with "mp_initialize already initialized". + router.setParams({ ...(offline && { offline: "true" }), itemId: item.Id ?? "", audioIndex: defaultAudioIndex?.toString() ?? "", @@ -375,13 +376,17 @@ export const Controls: FC = ({ bitrateValue: bitrateValue?.toString(), playbackPosition: item.UserData?.PlaybackPositionTicks?.toString() ?? "", - }).toString(); - - console.log("queryParams", queryParams); - - router.replace(`player/direct-player?${queryParams}` as any); + }); }, - [settings, subtitleIndex, audioIndex, mediaSource, bitrateValue, router], + [ + settings, + subtitleIndex, + audioIndex, + mediaSource, + bitrateValue, + router, + offline, + ], ); const goToPreviousItem = useCallback(() => { @@ -469,6 +474,7 @@ export const Controls: FC = ({ episodeView, onHideControls: hideControls, timeout: CONTROLS_CONSTANTS.TIMEOUT, + disabled: true, }); const switchOnEpisodeMode = useCallback(() => { @@ -479,11 +485,7 @@ export const Controls: FC = ({ }, [isPlaying, togglePlay]); return ( - + {episodeView ? ( = ({ onSkipForward={handleSkipForward} onSkipBackward={handleSkipBackward} /> + {/* Technical Info Overlay - rendered outside animated views to stay visible */} + {getTechnicalInfo && ( + + )} = ({ goToNextItem={goToNextItem} previousItem={previousItem} nextItem={nextItem} - getAudioTracks={getAudioTracks} - getSubtitleTracks={getSubtitleTracks} - setAudioTrack={setAudioTrack} - setSubtitleTrack={setSubtitleTrack} - setSubtitleURL={setSubtitleURL} aspectRatio={aspectRatio} - scaleFactor={scaleFactor} - setAspectRatio={setAspectRatio} - setScaleFactor={setScaleFactor} - setVideoAspectRatio={setVideoAspectRatio} - setVideoScaleFactor={setVideoScaleFactor} + isZoomedToFill={isZoomedToFill} + onZoomToggle={onZoomToggle} + playbackSpeed={playbackSpeed} + setPlaybackSpeed={setPlaybackSpeed} + showTechnicalInfo={showTechnicalInfo} + onToggleTechnicalInfo={onToggleTechnicalInfo} /> = ({ togglePlay={togglePlay} handleSkipBackward={handleSkipBackward} handleSkipForward={handleSkipForward} + hasChapters={hasChapters} + hasPreviousChapter={hasPreviousChapter} + hasNextChapter={hasNextChapter} + goToPreviousChapter={goToPreviousChapter} + goToNextChapter={goToNextChapter} /> = ({ > = ({ handleSliderChange={handleSliderChange} handleTouchStart={handleTouchStart} handleTouchEnd={handleTouchEnd} + seekTo={seekTo} trickPlayUrl={trickPlayUrl} trickplayInfo={trickplayInfo} time={isSliding || showRemoteBubble ? time : remoteTime} @@ -582,6 +599,16 @@ export const Controls: FC = ({ {settings.maxAutoPlayEpisodeCount.value !== -1 && ( )} - + ); }; + +const styles = StyleSheet.create({ + controlsContainer: { + position: "absolute", + top: 0, + left: 0, + right: 0, + bottom: 0, + }, +}); diff --git a/components/video-player/controls/Controls.tv.tsx b/components/video-player/controls/Controls.tv.tsx new file mode 100644 index 000000000..29e5d1f03 --- /dev/null +++ b/components/video-player/controls/Controls.tv.tsx @@ -0,0 +1,1626 @@ +import type { + BaseItemDto, + MediaSourceInfo, +} from "@jellyfin/sdk/lib/generated-client"; +import { useLocalSearchParams } from "expo-router"; +import { useAtomValue } from "jotai"; +import { + type FC, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { useTranslation } from "react-i18next"; +import { + Pressable, + StyleSheet, + TVFocusGuideView, + useWindowDimensions, + View, +} from "react-native"; +import Animated, { + Easing, + type SharedValue, + useAnimatedReaction, + useAnimatedStyle, + useSharedValue, + withTiming, +} from "react-native-reanimated"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Text } from "@/components/common/Text"; +import { + TVControlButton, + TVNextEpisodeCountdown, + TVSkipSegmentCard, +} from "@/components/tv"; +import { TVFocusableProgressBar } from "@/components/tv/TVFocusableProgressBar"; +import { useScaledTVTypography } from "@/constants/TVTypography"; +import useRouter from "@/hooks/useAppRouter"; +import { useCreditSkipper } from "@/hooks/useCreditSkipper"; +import { useIntroSkipper } from "@/hooks/useIntroSkipper"; +import { usePlaybackManager } from "@/hooks/usePlaybackManager"; +import { useTrickplay } from "@/hooks/useTrickplay"; +import { useTVOptionModal } from "@/hooks/useTVOptionModal"; +import { useTVSubtitleModal } from "@/hooks/useTVSubtitleModal"; +import type { TechnicalInfo } from "@/modules/mpv-player"; +import type { DownloadedItem } from "@/providers/Downloads/types"; +import { apiAtom } from "@/providers/JellyfinProvider"; +import { useOfflineMode } from "@/providers/OfflineModeProvider"; +import { useSettings } from "@/utils/atoms/settings"; +import type { TVOptionItem } from "@/utils/atoms/tvOptionModal"; +import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings"; +import { formatTimeString, msToTicks, ticksToMs } from "@/utils/time"; +import { CONTROLS_CONSTANTS } from "./constants"; +import { useVideoContext } from "./contexts/VideoContext"; +import { useChapterNavigation } from "./hooks/useChapterNavigation"; +import { useRemoteControl } from "./hooks/useRemoteControl"; +import { useVideoTime } from "./hooks/useVideoTime"; +import { TechnicalInfoOverlay } from "./TechnicalInfoOverlay"; +import { TrickplayBubble } from "./TrickplayBubble"; +import type { Track } from "./types"; +import { useControlsTimeout } from "./useControlsTimeout"; + +interface Props { + item: BaseItemDto; + isPlaying: boolean; + isSeeking: SharedValue; + cacheProgress: SharedValue; + progress: SharedValue; + isBuffering?: boolean; + showControls: boolean; + togglePlay: () => void; + setShowControls: (shown: boolean) => void; + mediaSource?: MediaSourceInfo | null; + seek: (ticks: number) => void; + play: () => void; + pause: () => void; + audioIndex?: number; + subtitleIndex?: number; + onAudioIndexChange?: (index: number) => void; + onSubtitleIndexChange?: (index: number) => void; + previousItem?: BaseItemDto | null; + nextItem?: BaseItemDto | null; + goToPreviousItem?: () => void; + goToNextItem?: () => void; + onRefreshSubtitleTracks?: () => Promise< + import("@jellyfin/sdk/lib/generated-client").MediaStream[] + >; + addSubtitleFile?: (path: string) => void; + showTechnicalInfo?: boolean; + onToggleTechnicalInfo?: () => void; + getTechnicalInfo?: () => Promise; + playMethod?: "DirectPlay" | "DirectStream" | "Transcode"; + transcodeReasons?: string[]; + downloadedFiles?: DownloadedItem[]; +} + +const TV_SEEKBAR_HEIGHT = 14; +const TV_AUTO_HIDE_TIMEOUT = 5000; + +// Trickplay bubble positioning constants +const TV_TRICKPLAY_SCALE = 2; +const TV_TRICKPLAY_BUBBLE_BASE_WIDTH = CONTROLS_CONSTANTS.TILE_WIDTH * 1.5; +const TV_TRICKPLAY_BUBBLE_WIDTH = + TV_TRICKPLAY_BUBBLE_BASE_WIDTH * TV_TRICKPLAY_SCALE; +const TV_TRICKPLAY_INTERNAL_OFFSET = 62 * TV_TRICKPLAY_SCALE; +const TV_TRICKPLAY_CENTERING_OFFSET = 98 * TV_TRICKPLAY_SCALE; +const TV_TRICKPLAY_RIGHT_PADDING = 150; +const TV_TRICKPLAY_FADE_DURATION = 200; + +interface TVTrickplayBubbleProps { + trickPlayUrl: { + x: number; + y: number; + url: string; + } | null; + trickplayInfo: { + aspectRatio?: number; + data: { + TileWidth?: number; + TileHeight?: number; + }; + } | null; + time: { + hours: number; + minutes: number; + seconds: number; + }; + progress: SharedValue; + max: SharedValue; + progressBarWidth: number; + visible: boolean; +} + +const TVTrickplayBubblePositioned: FC = ({ + trickPlayUrl, + trickplayInfo, + time, + progress, + max, + progressBarWidth, + visible, +}) => { + const opacity = useSharedValue(0); + + useEffect(() => { + opacity.value = withTiming(visible ? 1 : 0, { + duration: TV_TRICKPLAY_FADE_DURATION, + easing: Easing.out(Easing.quad), + }); + }, [visible, opacity]); + + const minX = TV_TRICKPLAY_INTERNAL_OFFSET; + const maxX = + progressBarWidth - + TV_TRICKPLAY_BUBBLE_WIDTH + + TV_TRICKPLAY_INTERNAL_OFFSET + + TV_TRICKPLAY_RIGHT_PADDING; + + const animatedStyle = useAnimatedStyle(() => { + const progressPercent = max.value > 0 ? progress.value / max.value : 0; + + const xPosition = Math.max( + minX, + Math.min( + maxX, + progressPercent * progressBarWidth - + TV_TRICKPLAY_BUBBLE_WIDTH / 2 + + TV_TRICKPLAY_CENTERING_OFFSET, + ), + ); + + return { + transform: [{ translateX: xPosition }], + opacity: opacity.value, + }; + }); + + return ( + + + + ); +}; + +export const Controls: FC = ({ + item, + seek, + play: _play, + pause: _pause, + togglePlay, + isPlaying, + isSeeking, + progress, + cacheProgress, + showControls, + setShowControls, + mediaSource, + audioIndex, + subtitleIndex, + onAudioIndexChange, + onSubtitleIndexChange, + previousItem, + nextItem: nextItemProp, + goToPreviousItem, + goToNextItem: goToNextItemProp, + onRefreshSubtitleTracks, + addSubtitleFile, + showTechnicalInfo, + onToggleTechnicalInfo, + getTechnicalInfo, + playMethod, + transcodeReasons, + downloadedFiles, +}) => { + const typography = useScaledTVTypography(); + const insets = useSafeAreaInsets(); + const { width: screenWidth } = useWindowDimensions(); + const { t } = useTranslation(); + + // Calculate progress bar width (matches the padding used in bottomInner) + const progressBarWidth = useMemo(() => { + const leftPadding = Math.max(insets.left, 48); + const rightPadding = Math.max(insets.right, 48); + return screenWidth - leftPadding - rightPadding; + }, [screenWidth, insets.left, insets.right]); + const api = useAtomValue(apiAtom); + const { settings } = useSettings(); + const router = useRouter(); + const { bitrateValue } = useLocalSearchParams<{ + bitrateValue: string; + }>(); + + const { nextItem: internalNextItem } = usePlaybackManager({ + item, + isOffline: false, + }); + + const nextItem = nextItemProp ?? internalNextItem; + + // TV Option Modal hook for audio selector + const { showOptions } = useTVOptionModal(); + + // TV Subtitle Modal hook + const { showSubtitleModal } = useTVSubtitleModal(); + + // Get subtitle tracks from VideoContext (with proper MPV index mapping) + const { subtitleTracks: videoContextSubtitleTracks } = useVideoContext(); + + // Track which button should have preferred focus when controls show + type LastModalType = "audio" | "subtitle" | "techInfo" | null; + const [lastOpenedModal, setLastOpenedModal] = useState(null); + + // Track if play button should have focus (when showing controls via up/down D-pad) + const [focusPlayButton, setFocusPlayButton] = useState(false); + + // State for progress bar focus and focus guide refs + const [isProgressBarFocused, setIsProgressBarFocused] = useState(false); + const [playButtonRef, setPlayButtonRef] = useState(null); + const [progressBarRef, setProgressBarRef] = useState(null); + const [skipSegmentRef, setSkipSegmentRef] = useState(null); + const [nextEpisodeRef, setNextEpisodeRef] = useState(null); + + // Minimal seek bar state (shows only progress bar when seeking while controls hidden) + const [showMinimalSeekBar, setShowMinimalSeekBar] = useState(false); + const minimalSeekBarOpacity = useSharedValue(0); + const minimalSeekBarTimeoutRef = useRef | null>( + null, + ); + + // Ref for the invisible focus-stealing overlay (prevents hidden buttons from receiving select events) + const focusOverlayRef = useRef(null); + + const audioTracks = useMemo(() => { + return mediaSource?.MediaStreams?.filter((s) => s.Type === "Audio") ?? []; + }, [mediaSource]); + + const _subtitleTracks = useMemo(() => { + return ( + mediaSource?.MediaStreams?.filter((s) => s.Type === "Subtitle") ?? [] + ); + }, [mediaSource]); + + const audioOptions: TVOptionItem[] = useMemo(() => { + return audioTracks.map((track) => ({ + label: + track.DisplayTitle || `${track.Language || "Unknown"} (${track.Codec})`, + value: track.Index!, + selected: track.Index === audioIndex, + })); + }, [audioTracks, audioIndex]); + + const handleAudioChange = useCallback( + (index: number) => { + onAudioIndexChange?.(index); + }, + [onAudioIndexChange], + ); + + const _handleSubtitleChange = useCallback( + (index: number) => { + onSubtitleIndexChange?.(index); + }, + [onSubtitleIndexChange], + ); + + // Re-fetch subtitle streams from the server (e.g. after a server-side + // download) and map them to the modal's Track shape. setTrack drives the + // player through the same handler used for manual subtitle selection. + const refreshSubtitleTracks = useCallback(async (): Promise => { + try { + const streams = (await onRefreshSubtitleTracks?.()) ?? []; + // Skip streams without a real index: `?? -1` would alias them to the + // "disable subtitles" sentinel and mis-route selection. + return streams + .filter((stream) => typeof stream.Index === "number") + .map((stream) => { + const index = stream.Index as number; + return { + name: + stream.DisplayTitle || + `${stream.Language || "Unknown"} (${stream.Codec})`, + index, + setTrack: () => onSubtitleIndexChange?.(index), + }; + }); + } catch { + return []; + } + }, [onRefreshSubtitleTracks, onSubtitleIndexChange]); + + const { + trickPlayUrl, + calculateTrickplayUrl, + trickplayInfo, + prefetchAllTrickplayImages, + } = useTrickplay(item); + + const min = useSharedValue(0); + const maxMs = ticksToMs(item.RunTimeTicks || 0); + const max = useSharedValue(maxMs); + + const controlsOpacity = useSharedValue(showControls ? 1 : 0); + const bottomTranslateY = useSharedValue(showControls ? 0 : 50); + + useEffect(() => { + prefetchAllTrickplayImages(); + }, [prefetchAllTrickplayImages]); + + useEffect(() => { + const animationConfig = { + duration: 300, + easing: Easing.out(Easing.quad), + }; + + controlsOpacity.value = withTiming(showControls ? 1 : 0, animationConfig); + bottomTranslateY.value = withTiming(showControls ? 0 : 30, animationConfig); + + // Hide minimal seek bar immediately when normal controls show + if (showControls) { + setShowMinimalSeekBar(false); + if (minimalSeekBarTimeoutRef.current) { + clearTimeout(minimalSeekBarTimeoutRef.current); + minimalSeekBarTimeoutRef.current = null; + } + } + }, [showControls, controlsOpacity, bottomTranslateY]); + + // Overlay only fades, no slide + const overlayAnimatedStyle = useAnimatedStyle(() => ({ + opacity: controlsOpacity.value, + })); + + // Bottom controls fade and slide up + const bottomAnimatedStyle = useAnimatedStyle(() => ({ + opacity: controlsOpacity.value, + transform: [{ translateY: bottomTranslateY.value }], + })); + + // Minimal seek bar animation + useEffect(() => { + const animationConfig = { + duration: 200, + easing: Easing.out(Easing.quad), + }; + minimalSeekBarOpacity.value = withTiming( + showMinimalSeekBar ? 1 : 0, + animationConfig, + ); + }, [showMinimalSeekBar, minimalSeekBarOpacity]); + + const minimalSeekBarAnimatedStyle = useAnimatedStyle(() => ({ + opacity: minimalSeekBarOpacity.value, + })); + + useEffect(() => { + if (item) { + progress.value = ticksToMs(item?.UserData?.PlaybackPositionTicks); + max.value = ticksToMs(item.RunTimeTicks || 0); + } + }, [item, progress, max]); + + const { currentTime, remainingTime } = useVideoTime({ + progress, + max, + isSeeking, + }); + + // Chapter navigation hook + const { + hasChapters, + hasPreviousChapter, + hasNextChapter, + goToPreviousChapter, + goToNextChapter, + chapterPositions, + } = useChapterNavigation({ + chapters: item.Chapters, + progress, + maxMs, + seek, + }); + + // Skip intro/credits hooks + // Note: hooks expect seek callback that takes ms, and seek prop already expects ms + const offline = useOfflineMode(); + const { showSkipButton, skipIntro } = useIntroSkipper( + item.Id!, + currentTime, + seek, + _play, + offline, + api, + downloadedFiles, + ); + + const { showSkipCreditButton, skipCredit, hasContentAfterCredits } = + useCreditSkipper( + item.Id!, + currentTime, + seek, + _play, + offline, + api, + downloadedFiles, + max.value, + ); + + // Countdown logic + const isCountdownActive = useMemo(() => { + if (!nextItem) return false; + if (item?.Type !== "Episode") return false; + return remainingTime > 0 && remainingTime <= 10000; + }, [nextItem, item, remainingTime]); + + // Simple boolean - when skip cards or countdown are visible, they have focus + const isSkipOrCountdownVisible = useMemo(() => { + const skipIntroVisible = showSkipButton && !isCountdownActive; + const skipCreditsVisible = + showSkipCreditButton && + (hasContentAfterCredits || !nextItem) && + !isCountdownActive; + return skipIntroVisible || skipCreditsVisible || isCountdownActive; + }, [ + showSkipButton, + showSkipCreditButton, + hasContentAfterCredits, + nextItem, + isCountdownActive, + ]); + + // Live TV detection - check for both Program (when playing from guide) and TvChannel (when playing from channels) + const isLiveTV = item?.Type === "Program" || item?.Type === "TvChannel"; + + // For live TV, determine if we're at the live edge (within 5 seconds of max) + const LIVE_EDGE_THRESHOLD = 5000; // 5 seconds in ms + + const getFinishTime = () => { + const now = new Date(); + const finishTime = new Date(now.getTime() + remainingTime); + return finishTime.toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); + }; + + const toggleControls = useCallback(() => { + if (isSkipOrCountdownVisible) return; // Skip/countdown has focus, don't toggle + setShowControls(!showControls); + }, [showControls, setShowControls, isSkipOrCountdownVisible]); + + const [showSeekBubble, setShowSeekBubble] = useState(false); + const [seekBubbleTime, setSeekBubbleTime] = useState({ + hours: 0, + minutes: 0, + seconds: 0, + }); + const seekBubbleTimeoutRef = useRef | null>( + null, + ); + const continuousSeekRef = useRef | null>(null); + const seekAccelerationRef = useRef(1); + const controlsInteractionRef = useRef<() => void>(() => {}); + const goToNextItemRef = useRef<(opts?: { isAutoPlay?: boolean }) => void>( + () => {}, + ); + const exitingRef = useRef(false); + const [isExiting, setIsExiting] = useState(false); + + const updateSeekBubbleTime = useCallback((ms: number) => { + const totalSeconds = Math.floor(ms / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + setSeekBubbleTime({ hours, minutes, seconds }); + }, []); + + // Show minimal seek bar (only progress bar, no buttons) + const showMinimalSeek = useCallback(() => { + setShowMinimalSeekBar(true); + + // Clear existing timeout + if (minimalSeekBarTimeoutRef.current) { + clearTimeout(minimalSeekBarTimeoutRef.current); + } + + // Auto-hide after timeout + minimalSeekBarTimeoutRef.current = setTimeout(() => { + setShowMinimalSeekBar(false); + }, 2500); + }, []); + + // Show minimal seek bar without auto-hide (for continuous seeking) + const showMinimalSeekPersistent = useCallback(() => { + setShowMinimalSeekBar(true); + + // Clear existing timeout - don't set a new one + if (minimalSeekBarTimeoutRef.current) { + clearTimeout(minimalSeekBarTimeoutRef.current); + minimalSeekBarTimeoutRef.current = null; + } + }, []); + + // Start the minimal seek bar hide timeout + const startMinimalSeekHideTimeout = useCallback(() => { + if (minimalSeekBarTimeoutRef.current) { + clearTimeout(minimalSeekBarTimeoutRef.current); + } + minimalSeekBarTimeoutRef.current = setTimeout(() => { + setShowMinimalSeekBar(false); + }, 2500); + }, []); + + const handleOpenAudioSheet = useCallback(() => { + setLastOpenedModal("audio"); + showOptions({ + title: t("item_card.audio"), + options: audioOptions, + onSelect: handleAudioChange, + // In-player audio selection navigates (replacePlayer while transcoding); + // apply it after the modal is dismissed so it isn't swallowed. + deferApplyUntilDismissed: true, + }); + controlsInteractionRef.current(); + }, [showOptions, t, audioOptions, handleAudioChange]); + + const handleLocalSubtitleDownloaded = useCallback( + (path: string) => { + addSubtitleFile?.(path); + }, + [addSubtitleFile], + ); + + const handleOpenSubtitleSheet = useCallback(() => { + setLastOpenedModal("subtitle"); + // Filter out the "Disable" option from VideoContext tracks since the modal adds its own "None" option. + // Wrap each setTrack so selecting a subtitle ALSO updates the player's live + // index via onSubtitleIndexChange. The modal is a separate route, so the + // VideoContext router.setParams inside setTrack targets the modal — not the + // player — leaving currentSubtitleIndex stale. Without this sync, the next + // episode carries the previously-shown subtitle instead of the one the user + // just picked. (The audio sheet already uses onAudioIndexChange directly.) + const tracksWithoutDisable = (videoContextSubtitleTracks ?? []) + .filter((track) => track.index !== -1) + .map((track) => ({ + ...track, + setTrack: () => { + track.setTrack(); + onSubtitleIndexChange?.(track.index); + }, + })); + showSubtitleModal({ + item, + mediaSourceId: mediaSource?.Id, + subtitleTracks: tracksWithoutDisable, + currentSubtitleIndex: subtitleIndex ?? -1, + onDisableSubtitles: () => { + // Find and call the "Disable" track's setTrack from VideoContext + const disableTrack = videoContextSubtitleTracks?.find( + (t) => t.index === -1, + ); + disableTrack?.setTrack(); + onSubtitleIndexChange?.(-1); + }, + onLocalSubtitleDownloaded: handleLocalSubtitleDownloaded, + refreshSubtitleTracks: onRefreshSubtitleTracks + ? refreshSubtitleTracks + : undefined, + }); + controlsInteractionRef.current(); + }, [ + showSubtitleModal, + item, + mediaSource?.Id, + videoContextSubtitleTracks, + subtitleIndex, + onSubtitleIndexChange, + handleLocalSubtitleDownloaded, + onRefreshSubtitleTracks, + refreshSubtitleTracks, + ]); + + const handleToggleTechnicalInfo = useCallback(() => { + setLastOpenedModal("techInfo"); + onToggleTechnicalInfo?.(); + controlsInteractionRef.current(); + }, [onToggleTechnicalInfo]); + + const effectiveProgress = useSharedValue(0); + + const SEEK_THRESHOLD_MS = 5000; + + useAnimatedReaction( + () => progress.value, + (current, _previous) => { + const progressUnit = CONTROLS_CONSTANTS.PROGRESS_UNIT_MS; + const progressDiff = Math.abs(current - effectiveProgress.value); + if (progressDiff >= progressUnit) { + if (progressDiff >= SEEK_THRESHOLD_MS) { + effectiveProgress.value = withTiming(current, { + duration: 200, + easing: Easing.out(Easing.quad), + }); + } else { + effectiveProgress.value = current; + } + } + }, + [], + ); + + const handleSeekForwardButton = useCallback(() => { + // For live TV, check if we're already at the live edge + if (isLiveTV && max.value - progress.value < LIVE_EDGE_THRESHOLD) { + // Already at live edge, don't seek further + controlsInteractionRef.current(); + return; + } + + const newPosition = Math.min(max.value, progress.value + 30 * 1000); + progress.value = newPosition; + seek(newPosition); + + calculateTrickplayUrl(msToTicks(newPosition)); + updateSeekBubbleTime(newPosition); + setShowSeekBubble(true); + + if (seekBubbleTimeoutRef.current) { + clearTimeout(seekBubbleTimeoutRef.current); + } + seekBubbleTimeoutRef.current = setTimeout(() => { + setShowSeekBubble(false); + }, 2000); + + controlsInteractionRef.current(); + }, [ + progress, + max, + seek, + calculateTrickplayUrl, + updateSeekBubbleTime, + isLiveTV, + ]); + + const handleSeekBackwardButton = useCallback(() => { + const newPosition = Math.max(min.value, progress.value - 30 * 1000); + progress.value = newPosition; + seek(newPosition); + + calculateTrickplayUrl(msToTicks(newPosition)); + updateSeekBubbleTime(newPosition); + setShowSeekBubble(true); + + if (seekBubbleTimeoutRef.current) { + clearTimeout(seekBubbleTimeoutRef.current); + } + seekBubbleTimeoutRef.current = setTimeout(() => { + setShowSeekBubble(false); + }, 2000); + + controlsInteractionRef.current(); + }, [progress, min, seek, calculateTrickplayUrl, updateSeekBubbleTime]); + + // Progress bar D-pad seeking (10s increments for finer control) + const handleProgressSeekRight = useCallback(() => { + // For live TV, check if we're already at the live edge + if (isLiveTV && max.value - progress.value < LIVE_EDGE_THRESHOLD) { + // Already at live edge, don't seek further + controlsInteractionRef.current(); + return; + } + + const newPosition = Math.min(max.value, progress.value + 10 * 1000); + progress.value = newPosition; + seek(newPosition); + + calculateTrickplayUrl(msToTicks(newPosition)); + updateSeekBubbleTime(newPosition); + setShowSeekBubble(true); + + if (seekBubbleTimeoutRef.current) { + clearTimeout(seekBubbleTimeoutRef.current); + } + seekBubbleTimeoutRef.current = setTimeout(() => { + setShowSeekBubble(false); + }, 2000); + + controlsInteractionRef.current(); + }, [ + progress, + max, + seek, + calculateTrickplayUrl, + updateSeekBubbleTime, + isLiveTV, + ]); + + const handleProgressSeekLeft = useCallback(() => { + const newPosition = Math.max(min.value, progress.value - 10 * 1000); + progress.value = newPosition; + seek(newPosition); + + calculateTrickplayUrl(msToTicks(newPosition)); + updateSeekBubbleTime(newPosition); + setShowSeekBubble(true); + + if (seekBubbleTimeoutRef.current) { + clearTimeout(seekBubbleTimeoutRef.current); + } + seekBubbleTimeoutRef.current = setTimeout(() => { + setShowSeekBubble(false); + }, 2000); + + controlsInteractionRef.current(); + }, [progress, min, seek, calculateTrickplayUrl, updateSeekBubbleTime]); + + // Minimal seek mode handlers (only show progress bar, not full controls) + const handleMinimalSeekRight = useCallback(() => { + // For live TV, check if we're already at the live edge + if (isLiveTV && max.value - progress.value < LIVE_EDGE_THRESHOLD) { + // Already at live edge, don't seek further + return; + } + + const newPosition = Math.min(max.value, progress.value + 10 * 1000); + progress.value = newPosition; + seek(newPosition); + + calculateTrickplayUrl(msToTicks(newPosition)); + updateSeekBubbleTime(newPosition); + setShowSeekBubble(true); + + // Show minimal seek bar and reset its timeout + showMinimalSeek(); + + if (seekBubbleTimeoutRef.current) { + clearTimeout(seekBubbleTimeoutRef.current); + } + seekBubbleTimeoutRef.current = setTimeout(() => { + setShowSeekBubble(false); + }, 2000); + }, [ + progress, + max, + seek, + calculateTrickplayUrl, + updateSeekBubbleTime, + showMinimalSeek, + isLiveTV, + ]); + + const handleMinimalSeekLeft = useCallback(() => { + const newPosition = Math.max(min.value, progress.value - 10 * 1000); + progress.value = newPosition; + seek(newPosition); + + calculateTrickplayUrl(msToTicks(newPosition)); + updateSeekBubbleTime(newPosition); + setShowSeekBubble(true); + + // Show minimal seek bar and reset its timeout + showMinimalSeek(); + + if (seekBubbleTimeoutRef.current) { + clearTimeout(seekBubbleTimeoutRef.current); + } + seekBubbleTimeoutRef.current = setTimeout(() => { + setShowSeekBubble(false); + }, 2000); + }, [ + progress, + min, + seek, + calculateTrickplayUrl, + updateSeekBubbleTime, + showMinimalSeek, + ]); + + // Continuous seeking functions (for button long-press and D-pad long-press) + const stopContinuousSeeking = useCallback(() => { + if (continuousSeekRef.current) { + clearInterval(continuousSeekRef.current); + continuousSeekRef.current = null; + } + seekAccelerationRef.current = 1; + + if (seekBubbleTimeoutRef.current) { + clearTimeout(seekBubbleTimeoutRef.current); + } + seekBubbleTimeoutRef.current = setTimeout(() => { + setShowSeekBubble(false); + }, 2000); + + // Start minimal seekbar hide timeout (if it's showing) + startMinimalSeekHideTimeout(); + }, [startMinimalSeekHideTimeout]); + + const startContinuousSeekForward = useCallback(() => { + // For live TV, check if we're already at the live edge + if (isLiveTV && max.value - progress.value < LIVE_EDGE_THRESHOLD) { + // Already at live edge, don't start continuous seeking + return; + } + + seekAccelerationRef.current = 1; + + handleSeekForwardButton(); + + continuousSeekRef.current = setInterval(() => { + // For live TV, stop continuous seeking when we hit the live edge + if (isLiveTV && max.value - progress.value < LIVE_EDGE_THRESHOLD) { + stopContinuousSeeking(); + return; + } + + const seekAmount = + CONTROLS_CONSTANTS.LONG_PRESS_INITIAL_SEEK * + seekAccelerationRef.current * + 1000; + const newPosition = Math.min(max.value, progress.value + seekAmount); + progress.value = newPosition; + seek(newPosition); + + calculateTrickplayUrl(msToTicks(newPosition)); + updateSeekBubbleTime(newPosition); + + seekAccelerationRef.current = Math.min( + seekAccelerationRef.current * + CONTROLS_CONSTANTS.LONG_PRESS_ACCELERATION, + CONTROLS_CONSTANTS.LONG_PRESS_MAX_ACCELERATION, + ); + + controlsInteractionRef.current(); + }, CONTROLS_CONSTANTS.LONG_PRESS_INTERVAL); + }, [ + handleSeekForwardButton, + max, + progress, + seek, + calculateTrickplayUrl, + updateSeekBubbleTime, + isLiveTV, + stopContinuousSeeking, + ]); + + const startContinuousSeekBackward = useCallback(() => { + seekAccelerationRef.current = 1; + + handleSeekBackwardButton(); + + continuousSeekRef.current = setInterval(() => { + const seekAmount = + CONTROLS_CONSTANTS.LONG_PRESS_INITIAL_SEEK * + seekAccelerationRef.current * + 1000; + const newPosition = Math.max(min.value, progress.value - seekAmount); + progress.value = newPosition; + seek(newPosition); + + calculateTrickplayUrl(msToTicks(newPosition)); + updateSeekBubbleTime(newPosition); + + seekAccelerationRef.current = Math.min( + seekAccelerationRef.current * + CONTROLS_CONSTANTS.LONG_PRESS_ACCELERATION, + CONTROLS_CONSTANTS.LONG_PRESS_MAX_ACCELERATION, + ); + + controlsInteractionRef.current(); + }, CONTROLS_CONSTANTS.LONG_PRESS_INTERVAL); + }, [ + handleSeekBackwardButton, + min, + progress, + seek, + calculateTrickplayUrl, + updateSeekBubbleTime, + ]); + + // D-pad long press handlers - show minimal seekbar when controls are hidden + const handleDpadLongSeekForward = useCallback(() => { + if (!showControls) { + showMinimalSeekPersistent(); + } + startContinuousSeekForward(); + }, [showControls, showMinimalSeekPersistent, startContinuousSeekForward]); + + const handleDpadLongSeekBackward = useCallback(() => { + if (!showControls) { + showMinimalSeekPersistent(); + } + startContinuousSeekBackward(); + }, [showControls, showMinimalSeekPersistent, startContinuousSeekBackward]); + + // Callback for remote interactions to reset timeout + const handleRemoteInteraction = useCallback(() => { + controlsInteractionRef.current(); + }, []); + + // Callback for up/down D-pad - show controls with play button focused + const handleVerticalDpad = useCallback(() => { + if (isSkipOrCountdownVisible) return; // Skip/countdown has focus, don't show controls + setFocusPlayButton(true); + setShowControls(true); + }, [setShowControls, isSkipOrCountdownVisible]); + + const hideControls = useCallback(() => { + setShowControls(false); + setFocusPlayButton(false); + }, [setShowControls]); + + // When controls hide (and no skip/countdown overlay is visible), move focus + // to the invisible overlay so hidden buttons can't receive select events. + useEffect(() => { + if (!showControls && !isSkipOrCountdownVisible) { + // Small delay to let the controls fade-out animation start and + // the focus engine settle before stealing focus + const t = setTimeout(() => { + focusOverlayRef.current?.focus(); + }, 100); + return () => clearTimeout(t); + } + }, [showControls, isSkipOrCountdownVisible]); + + const handleBack = useCallback(() => { + router.back(); + }, [router]); + + const handleWillExit = useCallback(() => { + exitingRef.current = true; + setIsExiting(true); + }, []); + + const handleCancelExit = useCallback(() => { + exitingRef.current = false; + setIsExiting(false); + }, []); + + const { isSliding: isRemoteSliding } = useRemoteControl({ + showControls: showControls, + toggleControls, + togglePlay, + isProgressBarFocused, + onSeekLeft: handleProgressSeekLeft, + onSeekRight: handleProgressSeekRight, + onMinimalSeekLeft: handleMinimalSeekLeft, + onMinimalSeekRight: handleMinimalSeekRight, + onInteraction: handleRemoteInteraction, + onLongSeekLeftStart: handleDpadLongSeekBackward, + onLongSeekRightStart: handleDpadLongSeekForward, + onLongSeekStop: stopContinuousSeeking, + onVerticalDpad: handleVerticalDpad, + onHideControls: hideControls, + onBack: handleBack, + onWillExit: handleWillExit, + onCancelExit: handleCancelExit, + videoTitle: item?.Name ?? undefined, + }); + + const { handleControlsInteraction } = useControlsTimeout({ + showControls: showControls, + isSliding: isRemoteSliding, + episodeView: false, + onHideControls: hideControls, + timeout: TV_AUTO_HIDE_TIMEOUT, + disabled: false, + }); + + controlsInteractionRef.current = handleControlsInteraction; + + const handlePlayPauseButton = useCallback(() => { + togglePlay(); + controlsInteractionRef.current(); + }, [togglePlay]); + + const handlePreviousItem = useCallback(() => { + if (goToPreviousItem) { + goToPreviousItem(); + } + controlsInteractionRef.current(); + }, [goToPreviousItem]); + + const handleNextItemButton = useCallback(() => { + if (goToNextItemProp) { + goToNextItemProp(); + } else { + goToNextItemRef.current({ isAutoPlay: false }); + } + controlsInteractionRef.current(); + }, [goToNextItemProp]); + + const goToNextItem = useCallback( + ({ isAutoPlay: _isAutoPlay }: { isAutoPlay?: boolean } = {}) => { + if (!nextItem || !settings) { + return; + } + + // Use the live selection passed down from the player (currentSubtitleIndex + // / currentAudioIndex), not the stale URL params the episode started with. + // This path runs on autoplay; the manual "Next" button uses goToNextItemProp. + const previousIndexes = { + subtitleIndex, + audioIndex, + }; + + const { + mediaSource: newMediaSource, + audioIndex: defaultAudioIndex, + subtitleIndex: defaultSubtitleIndex, + } = getDefaultPlaySettings(nextItem, settings, { + indexes: previousIndexes, + source: mediaSource ?? undefined, + }); + + const queryParams = new URLSearchParams({ + itemId: nextItem.Id ?? "", + audioIndex: defaultAudioIndex?.toString() ?? "", + subtitleIndex: defaultSubtitleIndex?.toString() ?? "", + mediaSourceId: newMediaSource?.Id ?? "", + bitrateValue: bitrateValue?.toString() ?? "", + playbackPosition: + nextItem.UserData?.PlaybackPositionTicks?.toString() ?? "", + }).toString(); + + router.replace(`player/direct-player?${queryParams}` as any); + }, + [ + nextItem, + settings, + subtitleIndex, + audioIndex, + mediaSource, + bitrateValue, + router, + ], + ); + + goToNextItemRef.current = goToNextItem; + + const handleAutoPlayFinish = useCallback(() => { + if (exitingRef.current) return; + goToNextItem({ isAutoPlay: true }); + }, [goToNextItem]); + + const topOverlayFocusTarget = skipSegmentRef ?? nextEpisodeRef; + + return ( + + + + {/* Invisible overlay that steals focus when controls are hidden. + Prevents hidden control buttons from receiving select/enter events + from the TV remote. Pressing center button here toggles play/pause. */} + { + togglePlay(); + setShowControls(true); + setFocusPlayButton(true); + }} + /> + + {getTechnicalInfo && ( + + )} + + {/* Skip intro card */} + + + {/* Skip credits card - show when there's content after credits, OR no next episode */} + + + {nextItem && ( + + )} + + {/* Minimal seek bar - shows only progress bar when seeking while controls hidden */} + {/* Uses exact same layout as normal controls for alignment */} + + + + + + + {/* Same padding as TVFocusableProgressBar for alignment */} + + + + + ({ + width: `${max.value > 0 ? (cacheProgress.value / max.value) * 100 : 0}%`, + })), + ]} + /> + ({ + width: `${max.value > 0 ? (effectiveProgress.value / max.value) * 100 : 0}%`, + })), + ]} + /> + + {/* Chapter markers */} + {chapterPositions.length > 0 && ( + + {chapterPositions.map((position, index) => ( + + ))} + + )} + + + + + + + {formatTimeString(currentTime, "ms")} + + {!isLiveTV && ( + + + -{formatTimeString(remainingTime, "ms")} + + + {t("player.ends_at", { time: getFinishTime() })} + + + )} + + + + + + + + {item?.Type === "Episode" && ( + {`${item.SeriesName} - ${item.SeasonName} Episode ${item.IndexNumber}`} + )} + + + {item?.Name} + + {isLiveTV && ( + + + {t("player.live")} + + + )} + + {item?.Type === "Movie" && ( + + {item?.ProductionYear} + + )} + + + {/* Upward: control buttons → visible skip segment or next episode card */} + {topOverlayFocusTarget && ( + + )} + + + + {hasChapters && ( + + )} + + {hasChapters && ( + + )} + + + + + {audioOptions.length > 0 && ( + + )} + + + + {getTechnicalInfo && ( + + )} + + + + + + + {/* Bidirectional focus guides - stacked together per docs */} + {/* Downward: play button → progress bar */} + {progressBarRef && ( + + )} + {/* Upward: progress bar → play button */} + {playButtonRef && ( + + )} + + {/* Progress bar with focus trapping for left/right */} + + setIsProgressBarFocused(true)} + onBlur={() => setIsProgressBarFocused(false)} + refSetter={setProgressBarRef} + hasTVPreferredFocus={false} + /> + + + + + {formatTimeString(currentTime, "ms")} + + {!isLiveTV && ( + + + -{formatTimeString(remainingTime, "ms")} + + + {t("player.ends_at", { time: getFinishTime() })} + + + )} + + + + + ); +}; + +const styles = StyleSheet.create({ + controlsContainer: { + ...StyleSheet.absoluteFill, + }, + darkOverlay: { + ...StyleSheet.absoluteFill, + backgroundColor: "rgba(0, 0, 0, 0.4)", + }, + focusStealingOverlay: { + ...StyleSheet.absoluteFill, + zIndex: 1, + }, + bottomContainer: { + position: "absolute", + bottom: 0, + left: 0, + right: 0, + zIndex: 10, + }, + bottomInner: { + flexDirection: "column", + }, + metadataContainer: { + marginBottom: 16, + }, + titleRow: { + flexDirection: "row", + alignItems: "center", + gap: 12, + }, + subtitleText: { + color: "rgba(255,255,255,0.6)", + }, + titleText: { + color: "#fff", + fontWeight: "bold", + }, + liveBadge: { + backgroundColor: "#EF4444", + paddingHorizontal: 12, + paddingVertical: 4, + borderRadius: 6, + }, + liveBadgeText: { + color: "#FFF", + fontWeight: "bold", + }, + controlButtonsRow: { + flexDirection: "row", + alignItems: "center", + gap: 16, + marginBottom: 20, + paddingVertical: 8, + }, + controlButtonsSpacer: { + flex: 1, + }, + trickplayBubbleContainer: { + position: "absolute", + bottom: 190, + left: 0, + right: 0, + zIndex: 20, + }, + trickplayBubblePositioned: { + position: "absolute", + bottom: 0, + }, + focusGuide: { + height: 1, + width: "100%", + }, + progressBarContainer: { + height: TV_SEEKBAR_HEIGHT, + justifyContent: "center", + marginBottom: 8, + }, + progressTrack: { + height: TV_SEEKBAR_HEIGHT, + backgroundColor: "rgba(255,255,255,0.2)", + borderRadius: 8, + overflow: "hidden", + }, + cacheProgress: { + position: "absolute", + top: 0, + left: 0, + height: "100%", + backgroundColor: "rgba(255,255,255,0.3)", + borderRadius: 8, + }, + progressFill: { + position: "absolute", + top: 0, + left: 0, + height: "100%", + backgroundColor: "#fff", + borderRadius: 8, + }, + timeContainer: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + marginTop: 12, + }, + timeText: { + color: "rgba(255,255,255,0.7)", + }, + timeRight: { + flexDirection: "column", + alignItems: "flex-end", + }, + endsAtText: { + color: "rgba(255,255,255,0.5)", + marginTop: 2, + }, + // Minimal seek bar styles + minimalSeekBarContainer: { + position: "absolute", + bottom: 0, + left: 0, + right: 0, + zIndex: 5, + }, + minimalProgressWrapper: { + // Match TVFocusableProgressBar padding for alignment + paddingVertical: 8, + paddingHorizontal: 4, + }, + minimalProgressGlow: { + // Same glow effect and scale as focused TVFocusableProgressBar + shadowColor: "#fff", + shadowOffset: { width: 0, height: 0 }, + shadowOpacity: 0.5, + shadowRadius: 12, + transform: [{ scale: 1.02 }], + }, + minimalProgressTrack: { + // Brighter track like focused state + backgroundColor: "rgba(255,255,255,0.35)", + }, + minimalProgressTrackWrapper: { + position: "relative", + height: TV_SEEKBAR_HEIGHT, + }, + minimalChapterMarkersContainer: { + position: "absolute", + top: 0, + left: 0, + right: 0, + bottom: 0, + }, + minimalChapterMarker: { + position: "absolute", + width: 2, + height: TV_SEEKBAR_HEIGHT + 5, + bottom: 0, + backgroundColor: "rgba(255, 255, 255, 0.6)", + borderRadius: 1, + transform: [{ translateX: -1 }], + }, +}); diff --git a/components/video-player/controls/EpisodeList.tsx b/components/video-player/controls/EpisodeList.tsx index a7d6c5bc5..13c02f008 100644 --- a/components/video-player/controls/EpisodeList.tsx +++ b/components/video-player/controls/EpisodeList.tsx @@ -2,11 +2,9 @@ import { Ionicons } from "@expo/vector-icons"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { useGlobalSearchParams } from "expo-router"; import { atom, useAtom } from "jotai"; import { useEffect, useMemo, useRef } from "react"; import { TouchableOpacity, View } from "react-native"; -import { SafeAreaView } from "react-native-safe-area-context"; import ContinueWatchingPoster from "@/components/ContinueWatchingPoster"; import { HorizontalScroll, @@ -18,11 +16,17 @@ import { SeasonDropdown, type SeasonIndexState, } from "@/components/series/SeasonDropdown"; +import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets"; import { useDownload } from "@/providers/DownloadProvider"; -import type { DownloadedItem } from "@/providers/Downloads/types"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { useOfflineMode } from "@/providers/OfflineModeProvider"; +import { + getDownloadedEpisodesForSeason, + getDownloadedSeasonNumbers, +} from "@/utils/downloads/offline-series"; import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData"; import { runtimeTicksToSeconds } from "@/utils/time"; +import { HEADER_LAYOUT, ICON_SIZES } from "./constants"; type Props = { item: BaseItemDto; @@ -40,10 +44,8 @@ export const EpisodeList: React.FC = ({ item, close, goToItem }) => { const scrollToIndex = (index: number) => { scrollViewRef.current?.scrollToIndex(index, 100); }; - const { offline } = useGlobalSearchParams<{ - offline: string; - }>(); - const isOffline = offline === "true"; + const isOffline = useOfflineMode(); + const insets = useControlsSafeAreaInsets(); // Set the initial season index useEffect(() => { @@ -55,11 +57,12 @@ export const EpisodeList: React.FC = ({ item, close, goToItem }) => { } }, []); + // Read the live (cached) downloads DB inside the query rather than the + // provider's downloadedItems snapshot. The snapshot only refreshes on the + // provider refreshKey, so after updateDownloadedItem() invalidates + // ["episodes"]/["seasons"] (e.g. progress/played writes) the refetch would + // return stale data. getAllDownloadedItems() is cached, so this stays cheap. const { getDownloadedItems } = useDownload(); - const downloadedFiles = useMemo( - () => getDownloadedItems(), - [getDownloadedItems], - ); const seasonIndex = seasonIndexState[item.ParentId ?? ""]; @@ -68,15 +71,9 @@ export const EpisodeList: React.FC = ({ item, close, goToItem }) => { queryFn: async () => { if (isOffline) { if (!item.SeriesId) return []; - const seriesEpisodes = downloadedFiles?.filter( - (f: DownloadedItem) => f.item.SeriesId === item.SeriesId, - ); - const seasonNumbers = Array.from( - new Set( - seriesEpisodes - ?.map((f: DownloadedItem) => f.item.ParentIndexNumber) - .filter(Boolean), - ), + const seasonNumbers = getDownloadedSeasonNumbers( + getDownloadedItems(), + item.SeriesId, ); // Create fake season objects return seasonNumbers.map((seasonNumber) => ({ @@ -117,14 +114,12 @@ export const EpisodeList: React.FC = ({ item, close, goToItem }) => { queryKey: ["episodes", item.SeriesId, selectedSeasonId], queryFn: async () => { if (isOffline) { - if (!item.SeriesId) return []; - return downloadedFiles - ?.filter( - (f: DownloadedItem) => - f.item.SeriesId === item.SeriesId && - f.item.ParentIndexNumber === seasonIndex, - ) - .map((f: DownloadedItem) => f.item); + if (!item.SeriesId || typeof seasonIndex !== "number") return []; + return getDownloadedEpisodesForSeason( + getDownloadedItems(), + item.SeriesId, + seasonIndex, + ); } if (!api || !user?.Id || !item.Id || !selectedSeasonId) return []; const res = await getTvShowsApi(api).getEpisodes({ @@ -153,6 +148,9 @@ export const EpisodeList: React.FC = ({ item, close, goToItem }) => { const queryClient = useQueryClient(); useEffect(() => { + // Don't prefetch when offline - data is already local + if (isOffline) return; + for (const e of episodes || []) { queryClient.prefetchQuery({ queryKey: ["item", e.Id], @@ -168,7 +166,7 @@ export const EpisodeList: React.FC = ({ item, close, goToItem }) => { staleTime: 60 * 5 * 1000, }); } - }, [episodes]); + }, [episodes, isOffline]); // Scroll to the current item when episodes are fetched useEffect(() => { @@ -181,15 +179,21 @@ export const EpisodeList: React.FC = ({ item, close, goToItem }) => { }, [episodes, item.Id]); return ( - - + {seasons && seasons.length > 0 && !episodesLoading && episodes && ( = ({ item, close, goToItem }) => { onPress={async () => { close(); }} - className='aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2 ml-auto' + className='aspect-square flex flex-col rounded-xl items-center justify-center p-2 ml-auto' > - + @@ -274,6 +278,6 @@ export const EpisodeList: React.FC = ({ item, close, goToItem }) => { showsHorizontalScrollIndicator={false} /> )} - + ); }; diff --git a/components/video-player/controls/GestureOverlay.tsx b/components/video-player/controls/GestureOverlay.tsx index e4ca20e6e..6f395af77 100644 --- a/components/video-player/controls/GestureOverlay.tsx +++ b/components/video-player/controls/GestureOverlay.tsx @@ -41,6 +41,7 @@ export const GestureOverlay = ({ }); const [fadeAnim] = useState(new Animated.Value(0)); const isDraggingRef = useRef(false); + const hideScheduledRef = useRef(false); const hideTimeoutRef = useRef(null); const lastUpdateTime = useRef(0); @@ -51,18 +52,11 @@ export const GestureOverlay = ({ side?: "left" | "right", isDuringDrag = false, ) => { - // Clear any existing timeout - if (hideTimeoutRef.current) { - clearTimeout(hideTimeoutRef.current); - hideTimeoutRef.current = null; - } - - // Defer ALL state updates to avoid useInsertionEffect warning requestAnimationFrame(() => { setFeedback({ visible: true, icon, text, side }); if (!isDuringDrag) { - // For discrete actions (like skip), show normal animation + hideScheduledRef.current = false; Animated.sequence([ Animated.timing(fadeAnim, { toValue: 1, @@ -80,16 +74,17 @@ export const GestureOverlay = ({ setFeedback((prev) => ({ ...prev, visible: false })); }); }); - } else if (!isDraggingRef.current) { - // For drag start, just fade in and stay visible + } else if (!isDraggingRef.current && !hideScheduledRef.current) { + // Cancel any pending hide from a previous drag + if (hideTimeoutRef.current) { + clearTimeout(hideTimeoutRef.current); + hideTimeoutRef.current = null; + } + hideScheduledRef.current = false; isDraggingRef.current = true; - Animated.timing(fadeAnim, { - toValue: 1, - duration: 200, - useNativeDriver: true, - }).start(); + fadeAnim.stopAnimation(); + fadeAnim.setValue(1); } - // For drag updates, just update the state, don't restart animation }); }, [fadeAnim], @@ -97,9 +92,9 @@ export const GestureOverlay = ({ const hideDragFeedback = useCallback(() => { isDraggingRef.current = false; - - // Delay hiding slightly to avoid flicker + hideScheduledRef.current = true; hideTimeoutRef.current = setTimeout(() => { + fadeAnim.stopAnimation(); Animated.timing(fadeAnim, { toValue: 0, duration: 300, @@ -107,6 +102,7 @@ export const GestureOverlay = ({ }).start(() => { requestAnimationFrame(() => { setFeedback((prev) => ({ ...prev, visible: false })); + hideScheduledRef.current = false; }); }); }, 100) as unknown as number; diff --git a/components/video-player/controls/HeaderControls.tsx b/components/video-player/controls/HeaderControls.tsx index d8e4dcfe8..d3cd7a285 100644 --- a/components/video-player/controls/HeaderControls.tsx +++ b/components/video-player/controls/HeaderControls.tsx @@ -3,25 +3,18 @@ import type { BaseItemDto, MediaSourceInfo, } from "@jellyfin/sdk/lib/generated-client"; -import { useRouter } from "expo-router"; -import { type Dispatch, type FC, type SetStateAction } from "react"; -import { - Platform, - TouchableOpacity, - useWindowDimensions, - View, -} from "react-native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { type FC, useCallback, useState } from "react"; +import { Platform, TouchableOpacity, View } from "react-native"; +import useRouter from "@/hooks/useAppRouter"; +import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets"; import { useHaptic } from "@/hooks/useHaptic"; -import { useSettings, VideoPlayer } from "@/utils/atoms/settings"; -import { ICON_SIZES } from "./constants"; -import { VideoProvider } from "./contexts/VideoContext"; +import { useOrientation } from "@/hooks/useOrientation"; +import { OrientationLock } from "@/packages/expo-screen-orientation"; +import { HEADER_LAYOUT, ICON_SIZES } from "./constants"; import DropdownView from "./dropdown/DropdownView"; -import { type ScaleFactor, ScaleFactorSelector } from "./ScaleFactorSelector"; -import { - type AspectRatio, - AspectRatioSelector, -} from "./VideoScalingModeSelector"; +import { PlaybackSpeedScope } from "./utils/playback-speed-settings"; +import { type AspectRatio } from "./VideoScalingModeSelector"; +import { ZoomToggle } from "./ZoomToggle"; interface HeaderControlsProps { item: BaseItemDto; @@ -34,17 +27,15 @@ interface HeaderControlsProps { goToNextItem: (options: { isAutoPlay?: boolean }) => void; previousItem?: BaseItemDto | null; nextItem?: BaseItemDto | null; - getAudioTracks?: (() => Promise) | (() => any[]); - getSubtitleTracks?: (() => Promise) | (() => any[]); - setAudioTrack?: (index: number) => void; - setSubtitleTrack?: (index: number) => void; - setSubtitleURL?: (url: string, customName: string) => void; aspectRatio?: AspectRatio; - scaleFactor?: ScaleFactor; - setAspectRatio?: Dispatch>; - setScaleFactor?: Dispatch>; - setVideoAspectRatio?: (aspectRatio: string | null) => Promise; - setVideoScaleFactor?: (scaleFactor: number) => Promise; + isZoomedToFill?: boolean; + onZoomToggle?: () => void; + // Playback speed props + playbackSpeed?: number; + setPlaybackSpeed?: (speed: number, scope: PlaybackSpeedScope) => void; + // Technical info props + showTechnicalInfo?: boolean; + onToggleTechnicalInfo?: () => void; } export const HeaderControls: FC = ({ @@ -58,90 +49,107 @@ export const HeaderControls: FC = ({ goToNextItem, previousItem, nextItem, - getAudioTracks, - getSubtitleTracks, - setAudioTrack, - setSubtitleTrack, - setSubtitleURL, - aspectRatio = "default", - scaleFactor = 1.0, - setAspectRatio, - setScaleFactor, - setVideoAspectRatio, - setVideoScaleFactor, + aspectRatio: _aspectRatio = "default", + isZoomedToFill = false, + onZoomToggle, + playbackSpeed = 1.0, + setPlaybackSpeed, + showTechnicalInfo = false, + onToggleTechnicalInfo, }) => { - const { settings } = useSettings(); const router = useRouter(); - const insets = useSafeAreaInsets(); - const { width: screenWidth } = useWindowDimensions(); + const insets = useControlsSafeAreaInsets(); const lightHapticFeedback = useHaptic("light"); - - const handleAspectRatioChange = async (newRatio: AspectRatio) => { - if (!setAspectRatio || !setVideoAspectRatio) return; - - setAspectRatio(newRatio); - const aspectRatioString = newRatio === "default" ? null : newRatio; - await setVideoAspectRatio(aspectRatioString); - }; - - const handleScaleFactorChange = async (newScale: ScaleFactor) => { - if (!setScaleFactor || !setVideoScaleFactor) return; - - setScaleFactor(newScale); - await setVideoScaleFactor(newScale); - }; + const { orientation, lockOrientation } = useOrientation(); + const [isTogglingOrientation, setIsTogglingOrientation] = useState(false); const onClose = async () => { lightHapticFeedback(); router.back(); }; + const toggleOrientation = useCallback(async () => { + if (isTogglingOrientation) return; + + setIsTogglingOrientation(true); + lightHapticFeedback(); + + try { + const isPortrait = + orientation === OrientationLock.PORTRAIT_UP || + orientation === OrientationLock.PORTRAIT_DOWN; + + await lockOrientation( + isPortrait ? OrientationLock.LANDSCAPE : OrientationLock.PORTRAIT_UP, + ); + } finally { + setIsTogglingOrientation(false); + } + }, [ + orientation, + lockOrientation, + isTogglingOrientation, + lightHapticFeedback, + ]); + return ( {!Platform.isTV && (!offline || !mediaSource?.TranscodingUrl) && ( - - - - - + + + )} - {!Platform.isTV && - (settings.defaultPlayer === VideoPlayer.VLC_4 || - Platform.OS === "android") && ( - - - - )} + {/* Rotate toggle is Android-only: iOS does not reliably rotate the + player back to portrait programmatically. */} + {Platform.OS === "android" && ( + + + + )} + {!Platform.isTV && startPictureInPicture && ( + + + + )} {item?.Type === "Episode" && ( = ({ /> )} - - {})} + disabled={!onZoomToggle} /> = ({ } }, ); + + // Cancel animation on unmount to prevent onFinish from firing after exit + return () => { + cancelAnimation(progress); + }; } }, [show, onFinish]); diff --git a/components/video-player/controls/ScaleFactorSelector.tsx b/components/video-player/controls/ScaleFactorSelector.tsx deleted file mode 100644 index 0e5f2b100..000000000 --- a/components/video-player/controls/ScaleFactorSelector.tsx +++ /dev/null @@ -1,146 +0,0 @@ -import { Ionicons } from "@expo/vector-icons"; -import React, { useMemo } from "react"; -import { Platform, View } from "react-native"; -import { - type OptionGroup, - PlatformDropdown, -} from "@/components/PlatformDropdown"; -import { useHaptic } from "@/hooks/useHaptic"; - -export type ScaleFactor = - | 1.0 - | 1.1 - | 1.2 - | 1.3 - | 1.4 - | 1.5 - | 1.6 - | 1.7 - | 1.8 - | 1.9 - | 2.0; - -interface ScaleFactorSelectorProps { - currentScale: ScaleFactor; - onScaleChange: (scale: ScaleFactor) => void; - disabled?: boolean; -} - -interface ScaleFactorOption { - id: ScaleFactor; - label: string; - description: string; -} - -const SCALE_FACTOR_OPTIONS: ScaleFactorOption[] = [ - { - id: 1.0, - label: "1.0x", - description: "Original size", - }, - { - id: 1.1, - label: "1.1x", - description: "10% larger", - }, - { - id: 1.2, - label: "1.2x", - description: "20% larger", - }, - { - id: 1.3, - label: "1.3x", - description: "30% larger", - }, - { - id: 1.4, - label: "1.4x", - description: "40% larger", - }, - { - id: 1.5, - label: "1.5x", - description: "50% larger", - }, - { - id: 1.6, - label: "1.6x", - description: "60% larger", - }, - { - id: 1.7, - label: "1.7x", - description: "70% larger", - }, - { - id: 1.8, - label: "1.8x", - description: "80% larger", - }, - { - id: 1.9, - label: "1.9x", - description: "90% larger", - }, - { - id: 2.0, - label: "2.0x", - description: "Double size", - }, -]; - -export const ScaleFactorSelector: React.FC = ({ - currentScale, - onScaleChange, - disabled = false, -}) => { - const lightHapticFeedback = useHaptic("light"); - - const handleScaleSelect = (scale: ScaleFactor) => { - onScaleChange(scale); - lightHapticFeedback(); - }; - - const optionGroups = useMemo(() => { - return [ - { - options: SCALE_FACTOR_OPTIONS.map((option) => ({ - type: "radio" as const, - label: option.label, - value: option.id, - selected: option.id === currentScale, - onPress: () => handleScaleSelect(option.id), - disabled, - })), - }, - ]; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [currentScale, disabled]); - - const trigger = useMemo( - () => ( - - - - ), - [disabled], - ); - - // Hide on TV platforms - if (Platform.isTV) return null; - - return ( - - ); -}; diff --git a/components/video-player/controls/TechnicalInfoOverlay.tsx b/components/video-player/controls/TechnicalInfoOverlay.tsx new file mode 100644 index 000000000..f5a2e7618 --- /dev/null +++ b/components/video-player/controls/TechnicalInfoOverlay.tsx @@ -0,0 +1,418 @@ +import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client"; +import { + type FC, + memo, + useCallback, + useEffect, + useMemo, + useState, +} from "react"; +import { Platform, StyleSheet, Text, View } from "react-native"; +import Animated, { + Easing, + useAnimatedStyle, + useSharedValue, + withTiming, +} from "react-native-reanimated"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { useScaledTVTypography } from "@/constants/TVTypography"; +import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets"; +import type { TechnicalInfo } from "@/modules/mpv-player"; +import { HEADER_LAYOUT } from "./constants"; + +type PlayMethod = "DirectPlay" | "DirectStream" | "Transcode"; + +interface TechnicalInfoOverlayProps { + showControls: boolean; + visible: boolean; + getTechnicalInfo: () => Promise; + playMethod?: PlayMethod; + transcodeReasons?: string[]; + mediaSource?: MediaSourceInfo | null; + currentSubtitleIndex?: number; + currentAudioIndex?: number; +} + +const formatBitrate = (bitsPerSecond: number): string => { + const mbps = bitsPerSecond / 1_000_000; + if (mbps >= 1) { + return `${mbps.toFixed(1)} Mbps`; + } + const kbps = bitsPerSecond / 1_000; + return `${kbps.toFixed(0)} Kbps`; +}; + +const formatCodec = (codec: string): string => { + // Normalize common codec names + const codecMap: Record = { + h264: "H.264", + hevc: "HEVC", + h265: "HEVC", + vp9: "VP9", + vp8: "VP8", + av1: "AV1", + aac: "AAC", + ac3: "AC3", + eac3: "E-AC3", + dts: "DTS", + truehd: "TrueHD", + flac: "FLAC", + opus: "Opus", + mp3: "MP3", + // Subtitle codecs + srt: "SRT", + subrip: "SRT", + ass: "ASS", + ssa: "SSA", + webvtt: "WebVTT", + vtt: "WebVTT", + pgs: "PGS", + hdmv_pgs_subtitle: "PGS", + dvd_subtitle: "VobSub", + dvdsub: "VobSub", + mov_text: "MOV Text", + cc_dec: "CC", + eia_608: "CC", + }; + return codecMap[codec.toLowerCase()] || codec.toUpperCase(); +}; + +const formatAudioChannels = (channels: number): string => { + switch (channels) { + case 1: + return "Mono"; + case 2: + return "Stereo"; + case 6: + return "5.1"; + case 8: + return "7.1"; + default: + return `${channels}ch`; + } +}; + +const formatVideoRange = (range?: string | null): string | null => { + if (!range || range === "SDR") return null; + const rangeMap: Record = { + HDR10: "HDR10", + HDR10Plus: "HDR10+", + HLG: "HLG", + "Dolby Vision": "Dolby Vision", + DolbyVision: "Dolby Vision", + }; + return rangeMap[range] || range; +}; + +const formatFps = (fps: number): string => { + // Common frame rates + if (Math.abs(fps - 23.976) < 0.01) return "23.976"; + if (Math.abs(fps - 29.97) < 0.01) return "29.97"; + if (Math.abs(fps - 59.94) < 0.01) return "59.94"; + if (Number.isInteger(fps)) return fps.toString(); + return fps.toFixed(2); +}; + +const getPlayMethodLabel = (method: PlayMethod): string => { + switch (method) { + case "DirectPlay": + return "Direct Play"; + case "DirectStream": + return "Direct Stream"; + case "Transcode": + return "Transcoding"; + default: + return method; + } +}; + +const getPlayMethodColor = (method: PlayMethod): string => { + switch (method) { + case "DirectPlay": + return "#4ade80"; // green + case "DirectStream": + return "#60a5fa"; // blue + case "Transcode": + return "#fbbf24"; // yellow/amber + default: + return "white"; + } +}; + +const formatTranscodeReason = (reason: string): string => { + // Convert camelCase/PascalCase to readable format + const reasonMap: Record = { + ContainerNotSupported: "Container not supported", + VideoCodecNotSupported: "Video codec not supported", + AudioCodecNotSupported: "Audio codec not supported", + SubtitleCodecNotSupported: "Subtitle codec not supported", + AudioIsExternal: "Audio is external", + SecondaryAudioNotSupported: "Secondary audio not supported", + VideoProfileNotSupported: "Video profile not supported", + VideoLevelNotSupported: "Video level not supported", + VideoResolutionNotSupported: "Resolution not supported", + VideoBitDepthNotSupported: "Bit depth not supported", + VideoFramerateNotSupported: "Framerate not supported", + RefFramesNotSupported: "Ref frames not supported", + AnamorphicVideoNotSupported: "Anamorphic video not supported", + InterlacedVideoNotSupported: "Interlaced video not supported", + AudioChannelsNotSupported: "Audio channels not supported", + AudioProfileNotSupported: "Audio profile not supported", + AudioSampleRateNotSupported: "Sample rate not supported", + AudioBitDepthNotSupported: "Audio bit depth not supported", + ContainerBitrateExceedsLimit: "Bitrate exceeds limit", + VideoBitrateNotSupported: "Video bitrate not supported", + AudioBitrateNotSupported: "Audio bitrate not supported", + UnknownVideoStreamInfo: "Unknown video stream", + UnknownAudioStreamInfo: "Unknown audio stream", + DirectPlayError: "Direct play error", + VideoRangeTypeNotSupported: "HDR not supported", + VideoCodecTagNotSupported: "Video codec tag not supported", + }; + return reasonMap[reason] || reason; +}; + +export const TechnicalInfoOverlay: FC = memo( + ({ + showControls: _showControls, + visible, + getTechnicalInfo, + playMethod, + transcodeReasons, + mediaSource, + currentSubtitleIndex, + currentAudioIndex, + }) => { + const typography = useScaledTVTypography(); + const insets = useSafeAreaInsets(); + const safeInsets = useControlsSafeAreaInsets(); + const [info, setInfo] = useState(null); + + const opacity = useSharedValue(0); + + // Extract stream info from media source + const streamInfo = useMemo(() => { + if (!mediaSource?.MediaStreams) return null; + + const videoStream = mediaSource.MediaStreams.find( + (s) => s.Type === "Video", + ); + const audioStream = mediaSource.MediaStreams.find( + (s) => + s.Type === "Audio" && + (currentAudioIndex !== undefined + ? s.Index === currentAudioIndex + : s.IsDefault), + ); + const subtitleStream = mediaSource.MediaStreams.find( + (s) => + s.Type === "Subtitle" && + currentSubtitleIndex !== undefined && + currentSubtitleIndex >= 0 && + s.Index === currentSubtitleIndex, + ); + + return { + container: mediaSource.Container, + videoRange: videoStream?.VideoRangeType, + bitDepth: videoStream?.BitDepth, + audioChannels: audioStream?.Channels, + audioCodecFromSource: audioStream?.Codec, + subtitleCodec: subtitleStream?.Codec, + subtitleTitle: subtitleStream?.DisplayTitle, + }; + }, [mediaSource, currentAudioIndex, currentSubtitleIndex]); + + // Animate visibility based on visible prop only (stays visible regardless of controls) + useEffect(() => { + opacity.value = withTiming(visible ? 1 : 0, { + duration: 300, + easing: Easing.out(Easing.quad), + }); + }, [visible, opacity]); + + // Fetch technical info periodically when visible + const fetchInfo = useCallback(async () => { + try { + const data = await getTechnicalInfo(); + setInfo(data); + } catch (_error) { + // Silently fail - the info is optional + } + }, [getTechnicalInfo]); + + useEffect(() => { + if (!visible) { + return; + } + + // Fetch immediately + fetchInfo(); + + // Then fetch every 2 seconds + const interval = setInterval(fetchInfo, 2000); + return () => clearInterval(interval); + }, [visible, fetchInfo]); + + const animatedStyle = useAnimatedStyle(() => ({ + opacity: opacity.value, + })); + + // Don't render if not visible + if (!visible) return null; + + // TV-specific styles + const containerStyle = Platform.isTV + ? { + top: Math.max(insets.top, 48) + 20, + left: Math.max(insets.left, 48) + 20, + } + : { + top: safeInsets.top + HEADER_LAYOUT.CONTAINER_PADDING + 4, + left: safeInsets.left + HEADER_LAYOUT.CONTAINER_PADDING + 20, + }; + + const textStyle = Platform.isTV + ? [ + styles.infoTextTV, + { fontSize: typography.body, lineHeight: typography.body * 1.5 }, + ] + : styles.infoText; + const reasonStyle = Platform.isTV + ? [styles.reasonTextTV, { fontSize: typography.callout }] + : styles.reasonText; + const boxStyle = Platform.isTV ? styles.infoBoxTV : styles.infoBox; + + return ( + + + {playMethod && ( + + {getPlayMethodLabel(playMethod)} + + )} + {transcodeReasons && transcodeReasons.length > 0 && ( + + {transcodeReasons.map(formatTranscodeReason).join(", ")} + + )} + {info?.videoWidth && info?.videoHeight && ( + + {info.videoWidth}x{info.videoHeight} + {streamInfo?.bitDepth ? ` ${streamInfo.bitDepth}bit` : ""} + {formatVideoRange(streamInfo?.videoRange) + ? ` ${formatVideoRange(streamInfo?.videoRange)}` + : ""} + + )} + {info?.videoCodec && ( + + Video: {formatCodec(info.videoCodec)} + {info.fps ? ` @ ${formatFps(info.fps)} fps` : ""} + + )} + {info?.audioCodec && ( + + Audio: {formatCodec(info.audioCodec)} + {streamInfo?.audioChannels + ? ` ${formatAudioChannels(streamInfo.audioChannels)}` + : ""} + + )} + {streamInfo?.subtitleCodec && ( + + Subtitle: {formatCodec(streamInfo.subtitleCodec)} + + )} + {(info?.videoBitrate || info?.audioBitrate) && ( + + Bitrate:{" "} + {info.videoBitrate + ? formatBitrate(info.videoBitrate) + : info.audioBitrate + ? formatBitrate(info.audioBitrate) + : "N/A"} + + )} + {info?.cacheSeconds !== undefined && ( + + Buffer: {info.cacheSeconds.toFixed(1)}s + {info?.demuxerMaxBytes !== undefined + ? ` (cap ${info.demuxerMaxBytes}MB` + + `${info.demuxerMaxBackBytes !== undefined ? ` / ${info.demuxerMaxBackBytes}MB back` : ""}` + + `${info?.cacheSecsLimit !== undefined && info.cacheSecsLimit < 3600 ? ` · ${info.cacheSecsLimit.toFixed(0)}s` : ""}` + + ")" + : ""} + + )} + {info?.voDriver && ( + + VO: {info.voDriver} + {info.hwdec ? ` / ${info.hwdec}` : ""} + + )} + {info?.estimatedVfFps !== undefined && ( + + Output FPS: {info.estimatedVfFps.toFixed(2)} + {info?.fps ? ` (container ${formatFps(info.fps)})` : ""} + + )} + {info?.droppedFrames !== undefined && info.droppedFrames > 0 && ( + + Dropped: {info.droppedFrames} frames + + )} + {!info && !playMethod && Loading...} + + + ); + }, +); + +TechnicalInfoOverlay.displayName = "TechnicalInfoOverlay"; + +const styles = StyleSheet.create({ + container: { + position: "absolute", + zIndex: 15, + }, + infoBox: { + backgroundColor: "rgba(0, 0, 0, 0.5)", + borderRadius: 8, + paddingHorizontal: 12, + paddingVertical: 8, + minWidth: 150, + }, + infoBoxTV: { + backgroundColor: "rgba(0, 0, 0, 0.6)", + borderRadius: 12, + paddingHorizontal: 20, + paddingVertical: 16, + minWidth: 250, + }, + infoText: { + color: "white", + fontSize: 12, + fontFamily: Platform.OS === "ios" ? "Menlo" : "monospace", + lineHeight: 18, + }, + infoTextTV: { + color: "white", + fontFamily: Platform.OS === "ios" ? "Menlo" : "monospace", + }, + warningText: { + color: "#ff9800", + }, + reasonText: { + color: "#fbbf24", + fontSize: 10, + }, + reasonTextTV: { + color: "#fbbf24", + }, +}); diff --git a/components/video-player/controls/TimeDisplay.tsx b/components/video-player/controls/TimeDisplay.tsx index 37ba755b6..f85af6343 100644 --- a/components/video-player/controls/TimeDisplay.tsx +++ b/components/video-player/controls/TimeDisplay.tsx @@ -1,4 +1,5 @@ import type { FC } from "react"; +import { useTranslation } from "react-i18next"; import { View } from "react-native"; import { Text } from "@/components/common/Text"; import { formatTimeString } from "@/utils/time"; @@ -6,18 +7,22 @@ import { formatTimeString } from "@/utils/time"; interface TimeDisplayProps { currentTime: number; remainingTime: number; - isVlc: boolean; } +/** + * Displays current time and remaining time. + * MPV player uses milliseconds for time values. + */ export const TimeDisplay: FC = ({ currentTime, remainingTime, - isVlc, }) => { + const { t } = useTranslation(); + const getFinishTime = () => { const now = new Date(); - const remainingMs = isVlc ? remainingTime : remainingTime * 1000; - const finishTime = new Date(now.getTime() + remainingMs); + // remainingTime is in ms + const finishTime = new Date(now.getTime() + remainingTime); return finishTime.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", @@ -28,14 +33,14 @@ export const TimeDisplay: FC = ({ return ( - {formatTimeString(currentTime, isVlc ? "ms" : "s")} + {formatTimeString(currentTime, "ms")} - -{formatTimeString(remainingTime, isVlc ? "ms" : "s")} + -{formatTimeString(remainingTime, "ms")} - ends at {getFinishTime()} + {t("player.ends_at", { time: getFinishTime() })} diff --git a/components/video-player/controls/TrickplayBubble.tsx b/components/video-player/controls/TrickplayBubble.tsx index 49645ed2e..e00a19e6a 100644 --- a/components/video-player/controls/TrickplayBubble.tsx +++ b/components/video-player/controls/TrickplayBubble.tsx @@ -4,6 +4,12 @@ import { View } from "react-native"; import { Text } from "@/components/common/Text"; import { CONTROLS_CONSTANTS } from "./constants"; +// Slightly larger preview (scale 1.6 vs old 1.4) to give the overlay text +// more room and feel closer to the Jellyfin web style. +const BASE_IMAGE_SCALE = 1.6; +const BUBBLE_LEFT_OFFSET = 62; +const BUBBLE_WIDTH_MULTIPLIER = 1.5; + interface TrickplayBubbleProps { trickPlayUrl: { x: number; @@ -22,12 +28,18 @@ interface TrickplayBubbleProps { minutes: number; seconds: number; }; + /** Scale factor for the image (default 1). Does not affect timestamp text. */ + imageScale?: number; + /** Chapter name at the scrubbed position, if any. */ + chapterName?: string | null; } export const TrickplayBubble: FC = ({ trickPlayUrl, trickplayInfo, time, + imageScale = 1, + chapterName, }) => { if (!trickPlayUrl || !trickplayInfo) { return null; @@ -36,18 +48,28 @@ export const TrickplayBubble: FC = ({ const { x, y, url } = trickPlayUrl; const tileWidth = CONTROLS_CONSTANTS.TILE_WIDTH; const tileHeight = tileWidth / trickplayInfo.aspectRatio!; + const timeStr = `${time.hours > 0 ? `${time.hours}:` : ""}${ + time.minutes < 10 ? `0${time.minutes}` : time.minutes + }:${time.seconds < 10 ? `0${time.seconds}` : time.seconds}`; + + const finalScale = BASE_IMAGE_SCALE * imageScale; return ( = ({ width: tileWidth, height: tileHeight, alignSelf: "center", - transform: [{ scale: 1.4 }], + transform: [{ scale: finalScale }], borderRadius: 5, }} className='bg-neutral-800 overflow-hidden' > = ({ source={{ uri: url }} contentFit='cover' /> + {/* + * Bottom-right overlay (Jellyfin web style) — chapter name (small, + * faded) above the timestamp (small, bold). Sits on top of the + * trickplay frame inside the same overflow:hidden container so it + * always stays within the bubble bounds. + */} + + {chapterName ? ( + + {chapterName} + + ) : null} + + {timeStr} + + - - {`${time.hours > 0 ? `${time.hours}:` : ""}${ - time.minutes < 10 ? `0${time.minutes}` : time.minutes - }:${time.seconds < 10 ? `0${time.seconds}` : time.seconds}`} - ); }; diff --git a/components/video-player/controls/ZoomToggle.tsx b/components/video-player/controls/ZoomToggle.tsx new file mode 100644 index 000000000..ede704018 --- /dev/null +++ b/components/video-player/controls/ZoomToggle.tsx @@ -0,0 +1,44 @@ +import { Ionicons } from "@expo/vector-icons"; +import React from "react"; +import { Platform, TouchableOpacity, View } from "react-native"; +import { useHaptic } from "@/hooks/useHaptic"; +import { ICON_SIZES } from "./constants"; + +interface ZoomToggleProps { + isZoomedToFill: boolean; + onToggle: () => void; + disabled?: boolean; +} + +export const ZoomToggle: React.FC = ({ + isZoomedToFill, + onToggle, + disabled = false, +}) => { + const lightHapticFeedback = useHaptic("light"); + + const handlePress = () => { + if (disabled) return; + lightHapticFeedback(); + onToggle(); + }; + + // Hide on TV platforms + if (Platform.isTV) return null; + + return ( + + + + + + ); +}; diff --git a/components/video-player/controls/constants.ts b/components/video-player/controls/constants.ts index afe57070e..cec24162a 100644 --- a/components/video-player/controls/constants.ts +++ b/components/video-player/controls/constants.ts @@ -1,12 +1,13 @@ export const CONTROLS_CONSTANTS = { TIMEOUT: 4000, - SCRUB_INTERVAL_MS: 10 * 1000, // 10 seconds in ms + SCRUB_INTERVAL_MS: 30 * 1000, // 30 seconds in ms SCRUB_INTERVAL_TICKS: 10 * 10000000, // 10 seconds in ticks TILE_WIDTH: 150, PROGRESS_UNIT_MS: 1000, // 1 second in ms PROGRESS_UNIT_TICKS: 10000000, // 1 second in ticks - LONG_PRESS_INITIAL_SEEK: 10, - LONG_PRESS_ACCELERATION: 1.1, + LONG_PRESS_INITIAL_SEEK: 30, + LONG_PRESS_ACCELERATION: 1.2, + LONG_PRESS_MAX_ACCELERATION: 4, LONG_PRESS_INTERVAL: 300, SLIDER_DEBOUNCE_MS: 3, } as const; @@ -15,3 +16,7 @@ export const ICON_SIZES = { HEADER: 24, CENTER: 50, } as const; + +export const HEADER_LAYOUT = { + CONTAINER_PADDING: 8, // p-2 = 8px (matches HeaderControls) +} as const; diff --git a/components/video-player/controls/contexts/ControlContext.tsx b/components/video-player/controls/contexts/ControlContext.tsx deleted file mode 100644 index c13211c97..000000000 --- a/components/video-player/controls/contexts/ControlContext.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import type { - BaseItemDto, - MediaSourceInfo, -} from "@jellyfin/sdk/lib/generated-client"; -import type React from "react"; -import { createContext, type ReactNode, useContext } from "react"; - -interface ControlContextProps { - item: BaseItemDto; - mediaSource: MediaSourceInfo | null | undefined; - isVideoLoaded: boolean | undefined; -} - -const ControlContext = createContext( - undefined, -); - -interface ControlProviderProps { - children: ReactNode; - item: BaseItemDto; - mediaSource: MediaSourceInfo | null | undefined; - isVideoLoaded: boolean | undefined; -} - -export const ControlProvider: React.FC = ({ - children, - item, - mediaSource, - isVideoLoaded, -}) => { - return ( - - {children} - - ); -}; - -export const useControlContext = () => { - const context = useContext(ControlContext); - if (context === undefined) { - throw new Error("useControlContext must be used within a ControlProvider"); - } - return context; -}; diff --git a/components/video-player/controls/contexts/PlayerContext.tsx b/components/video-player/controls/contexts/PlayerContext.tsx new file mode 100644 index 000000000..e3c87b789 --- /dev/null +++ b/components/video-player/controls/contexts/PlayerContext.tsx @@ -0,0 +1,118 @@ +import type { + BaseItemDto, + MediaSourceInfo, +} from "@jellyfin/sdk/lib/generated-client"; +import React, { + createContext, + type MutableRefObject, + type ReactNode, + useContext, + useMemo, +} from "react"; +import type { MpvPlayerViewRef } from "@/modules"; +import type { DownloadedItem } from "@/providers/Downloads/types"; + +interface PlayerContextProps { + playerRef: MutableRefObject; + item: BaseItemDto; + mediaSource: MediaSourceInfo | null | undefined; + isVideoLoaded: boolean; + tracksReady: boolean; + downloadedItem: DownloadedItem | null; +} + +const PlayerContext = createContext(undefined); + +interface PlayerProviderProps { + children: ReactNode; + playerRef: MutableRefObject; + item: BaseItemDto; + mediaSource: MediaSourceInfo | null | undefined; + isVideoLoaded: boolean; + tracksReady: boolean; + downloadedItem?: DownloadedItem | null; +} + +export const PlayerProvider: React.FC = ({ + children, + playerRef, + item, + mediaSource, + isVideoLoaded, + tracksReady, + downloadedItem = null, +}) => { + const value = useMemo( + () => ({ + playerRef, + item, + mediaSource, + isVideoLoaded, + tracksReady, + downloadedItem, + }), + [playerRef, item, mediaSource, isVideoLoaded, tracksReady, downloadedItem], + ); + + return ( + {children} + ); +}; + +// Core context hook +export const usePlayerContext = () => { + const context = useContext(PlayerContext); + if (!context) + throw new Error("usePlayerContext must be used within PlayerProvider"); + return context; +}; + +// Player controls hook - MPV player only +export const usePlayerControls = () => { + const { playerRef } = usePlayerContext(); + + return { + // Subtitle controls + getSubtitleTracks: async () => { + return playerRef.current?.getSubtitleTracks?.() ?? null; + }, + setSubtitleTrack: (trackId: number) => { + playerRef.current?.setSubtitleTrack?.(trackId); + }, + disableSubtitles: () => { + playerRef.current?.disableSubtitles?.(); + }, + addSubtitleFile: (url: string, select = true) => { + playerRef.current?.addSubtitleFile?.(url, select); + }, + + // Audio controls + getAudioTracks: async () => { + return playerRef.current?.getAudioTracks?.() ?? null; + }, + setAudioTrack: (trackId: number) => { + playerRef.current?.setAudioTrack?.(trackId); + }, + + // Playback controls + play: () => playerRef.current?.play?.(), + pause: () => playerRef.current?.pause?.(), + seekTo: (position: number) => playerRef.current?.seekTo?.(position), + seekBy: (offset: number) => playerRef.current?.seekBy?.(offset), + setSpeed: (speed: number) => playerRef.current?.setSpeed?.(speed), + + // Subtitle positioning + setSubtitleScale: (scale: number) => + playerRef.current?.setSubtitleScale?.(scale), + setSubtitlePosition: (position: number) => + playerRef.current?.setSubtitlePosition?.(position), + setSubtitleMarginY: (margin: number) => + playerRef.current?.setSubtitleMarginY?.(margin), + setSubtitleFontSize: (size: number) => + playerRef.current?.setSubtitleFontSize?.(size), + + // PiP + startPictureInPicture: () => playerRef.current?.startPictureInPicture?.(), + stopPictureInPicture: () => playerRef.current?.stopPictureInPicture?.(), + }; +}; diff --git a/components/video-player/controls/contexts/VideoContext.tsx b/components/video-player/controls/contexts/VideoContext.tsx index ed7fa1e04..7c5750849 100644 --- a/components/video-player/controls/contexts/VideoContext.tsx +++ b/components/video-player/controls/contexts/VideoContext.tsx @@ -1,5 +1,54 @@ +/** + * VideoContext.tsx + * + * Manages subtitle and audio track state for the video player UI. + * + * ============================================================================ + * ARCHITECTURE + * ============================================================================ + * + * - Jellyfin is source of truth for subtitle list (embedded + external) + * - MPV only knows about: + * - Embedded subs it finds in the video stream + * - External subs we explicitly add via addSubtitleFile() + * - UI shows Jellyfin's complete list + * - On selection: either select embedded track or load external URL + * + * ============================================================================ + * INDEX TYPES + * ============================================================================ + * + * 1. SERVER INDEX (sub.Index / track.index) + * - Jellyfin's server-side stream index + * - Used to report playback state to Jellyfin server + * - Value of -1 means disabled/none + * + * 2. MPV INDEX (track.mpvIndex) + * - MPV's internal track ID + * - MPV orders tracks as: [all embedded, then all external] + * - IDs: 1..embeddedCount for embedded, embeddedCount+1.. for external + * - Value of -1 means track needs replacePlayer() (e.g., burned-in sub) + * + * ============================================================================ + * SUBTITLE HANDLING + * ============================================================================ + * + * Embedded (DeliveryMethod.Embed): + * - Already in MPV's track list + * - Select via setSubtitleTrack(mpvId) + * + * External (DeliveryMethod.External): + * - Loaded into MPV on video start + * - Select via setSubtitleTrack(embeddedCount + externalPosition + 1) + * + * Image-based during transcoding: + * - Burned into video by Jellyfin, not in MPV + * - Requires replacePlayer() to change + */ + import { SubtitleDeliveryMethod } from "@jellyfin/sdk/lib/generated-client"; -import { router, useLocalSearchParams } from "expo-router"; +import { File } from "expo-file-system"; +import { useLocalSearchParams } from "expo-router"; import type React from "react"; import { createContext, @@ -9,52 +58,36 @@ import { useMemo, useState, } from "react"; -import type { TrackInfo } from "@/modules/VlcPlayer.types"; +import { Platform } from "react-native"; +import useRouter from "@/hooks/useAppRouter"; +import type { MpvAudioTrack } from "@/modules"; +import { useOfflineMode } from "@/providers/OfflineModeProvider"; +import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles"; +import { isImageBasedSubtitle } from "@/utils/jellyfin/subtitleUtils"; import type { Track } from "../types"; -import { useControlContext } from "./ControlContext"; +import { usePlayerContext, usePlayerControls } from "./PlayerContext"; + +// Starting index for local (client-downloaded) subtitles +// Uses negative indices to avoid collision with Jellyfin indices +const LOCAL_SUBTITLE_INDEX_START = -100; interface VideoContextProps { - audioTracks: Track[] | null; subtitleTracks: Track[] | null; - setAudioTrack: ((index: number) => void) | undefined; - setSubtitleTrack: ((index: number) => void) | undefined; - setSubtitleURL: ((url: string, customName: string) => void) | undefined; + audioTracks: Track[] | null; } const VideoContext = createContext(undefined); -interface VideoProviderProps { - children: ReactNode; - getAudioTracks: - | (() => Promise) - | (() => TrackInfo[]) - | undefined; - getSubtitleTracks: - | (() => Promise) - | (() => TrackInfo[]) - | undefined; - setAudioTrack: ((index: number) => void) | undefined; - setSubtitleTrack: ((index: number) => void) | undefined; - setSubtitleURL: ((url: string, customName: string) => void) | undefined; -} - -export const VideoProvider: React.FC = ({ +export const VideoProvider: React.FC<{ children: ReactNode }> = ({ children, - getSubtitleTracks, - getAudioTracks, - setSubtitleTrack, - setSubtitleURL, - setAudioTrack, }) => { - const [audioTracks, setAudioTracks] = useState(null); const [subtitleTracks, setSubtitleTracks] = useState(null); + const [audioTracks, setAudioTracks] = useState(null); - const ControlContext = useControlContext(); - const isVideoLoaded = ControlContext?.isVideoLoaded; - const mediaSource = ControlContext?.mediaSource; - - const allSubs = - mediaSource?.MediaStreams?.filter((s) => s.Type === "Subtitle") || []; + const { tracksReady, mediaSource, downloadedItem } = usePlayerContext(); + const playerControls = usePlayerControls(); + const offline = useOfflineMode(); + const router = useRouter(); const { itemId, audioIndex, bitrateValue, subtitleIndex, playbackPosition } = useLocalSearchParams<{ @@ -66,185 +99,298 @@ export const VideoProvider: React.FC = ({ playbackPosition: string; }>(); - const onTextBasedSubtitle = useMemo(() => { - return ( - allSubs.find( - (s) => - s.Index?.toString() === subtitleIndex && - (s.DeliveryMethod === SubtitleDeliveryMethod.Embed || - s.DeliveryMethod === SubtitleDeliveryMethod.Hls || - s.DeliveryMethod === SubtitleDeliveryMethod.External), - ) || subtitleIndex === "-1" + const allSubs = + mediaSource?.MediaStreams?.filter((s) => s.Type === "Subtitle") || []; + const allAudio = + mediaSource?.MediaStreams?.filter((s) => s.Type === "Audio") || []; + + const isTranscoding = Boolean(mediaSource?.TranscodingUrl); + + /** + * Check if the currently selected subtitle is image-based. + * Used to determine if we need to refresh the player when changing subs. + */ + const isCurrentSubImageBased = useMemo(() => { + if (subtitleIndex === "-1") return false; + const currentSub = allSubs.find( + (s) => s.Index?.toString() === subtitleIndex, ); + return currentSub ? isImageBasedSubtitle(currentSub) : false; }, [allSubs, subtitleIndex]); - const setPlayerParams = ({ - chosenAudioIndex = audioIndex, - chosenSubtitleIndex = subtitleIndex, - }: { - chosenAudioIndex?: string; - chosenSubtitleIndex?: string; + /** + * Refresh the player with new parameters. + * This triggers Jellyfin to re-process the stream (e.g., burn in image subs). + */ + const replacePlayer = (params: { + audioIndex?: string; + subtitleIndex?: string; }) => { - console.log("chosenSubtitleIndex", chosenSubtitleIndex); const queryParams = new URLSearchParams({ itemId: itemId ?? "", - audioIndex: chosenAudioIndex, - subtitleIndex: chosenSubtitleIndex, + audioIndex: params.audioIndex ?? audioIndex, + subtitleIndex: params.subtitleIndex ?? subtitleIndex, mediaSourceId: mediaSource?.Id ?? "", bitrateValue: bitrateValue, playbackPosition: playbackPosition, }).toString(); - router.replace(`player/direct-player?${queryParams}` as any); }; - const setTrackParams = ( - type: "audio" | "subtitle", - index: number, - serverIndex: number, - ) => { - const setTrack = type === "audio" ? setAudioTrack : setSubtitleTrack; - const paramKey = type === "audio" ? "audioIndex" : "subtitleIndex"; - - // If we're transcoding and we're going from a image based subtitle - // to a text based subtitle, we need to change the player params. - - const shouldChangePlayerParams = - type === "subtitle" && - mediaSource?.TranscodingUrl && - !onTextBasedSubtitle; - - console.log("Set player params", index, serverIndex); - if (shouldChangePlayerParams) { - setPlayerParams({ - chosenSubtitleIndex: serverIndex.toString(), - }); - return; - } - setTrack?.(serverIndex); - router.setParams({ - [paramKey]: serverIndex.toString(), - }); - }; - + // Fetch tracks when ready useEffect(() => { + if (!tracksReady) return; + const fetchTracks = async () => { - if (getSubtitleTracks) { - let subtitleData: TrackInfo[] | null = null; - try { - subtitleData = await getSubtitleTracks(); - } catch (error) { - console.log("[VideoContext] Failed to get subtitle tracks:", error); - return; - } - // Only FOR VLC 3, If we're transcoding, we need to reverse the subtitle data, because VLC reverses the HLS subtitles. - if ( - mediaSource?.TranscodingUrl && - subtitleData && - subtitleData.length > 1 - ) { - subtitleData = [subtitleData[0], ...subtitleData.slice(1).reverse()]; - } + // Check if this is offline transcoded content + // For transcoded offline content, only ONE audio track exists in the file + const isOfflineTranscoded = + offline && downloadedItem?.userData?.isTranscoded === true; - let embedSubIndex = 1; - const processedSubs: Track[] = allSubs?.map((sub) => { - /** A boolean value determining if we should increment the embedSubIndex, currently only Embed and Hls subtitles are automatically added into VLC Player */ - const shouldIncrement = - sub.DeliveryMethod === SubtitleDeliveryMethod.Embed || - sub.DeliveryMethod === SubtitleDeliveryMethod.Hls || - sub.DeliveryMethod === SubtitleDeliveryMethod.External; - /** The index of subtitle inside VLC Player Itself */ - const vlcIndex = subtitleData?.at(embedSubIndex)?.index ?? -1; - if (shouldIncrement) embedSubIndex++; - return { - name: sub.DisplayTitle || "Undefined Subtitle", - index: sub.Index ?? -1, - setTrack: () => - shouldIncrement - ? setTrackParams("subtitle", vlcIndex, sub.Index ?? -1) - : setPlayerParams({ - chosenSubtitleIndex: sub.Index?.toString(), - }), - }; - }); - - // Step 3: Restore the original order - const subtitles: Track[] = processedSubs.sort( - (a, b) => a.index - b.index, + if (isOfflineTranscoded) { + // Build single audio track entry - only the downloaded track exists + const downloadedAudioIndex = downloadedItem.userData.audioStreamIndex; + const downloadedTrack = allAudio.find( + (a) => a.Index === downloadedAudioIndex, ); - // Add a "Disable Subtitles" option - subtitles.unshift({ + if (downloadedTrack) { + const audio: Track[] = [ + { + name: downloadedTrack.DisplayTitle || "Audio", + index: downloadedTrack.Index ?? 0, + mpvIndex: 1, // Only track in file (MPV uses 1-based indexing) + setTrack: () => { + // Track is already selected (only one available) + router.setParams({ audioIndex: String(downloadedTrack.Index) }); + }, + }, + ]; + setAudioTracks(audio); + } else { + // Fallback: show no audio tracks if the stored track wasn't found + setAudioTracks([]); + } + + // For subtitles in transcoded offline content: + // - Text-based subs may still be embedded + // - Image-based subs were burned in during transcoding + const downloadedSubtitleIndex = + downloadedItem.userData.subtitleStreamIndex; + const subs: Track[] = []; + + // Add "Disable" option + subs.push({ name: "Disable", index: -1, - setTrack: () => - !mediaSource?.TranscodingUrl || onTextBasedSubtitle - ? setTrackParams("subtitle", -1, -1) - : setPlayerParams({ chosenSubtitleIndex: "-1" }), - }); - setSubtitleTracks(subtitles); - } - if (getAudioTracks) { - let audioData: TrackInfo[] | null = null; - try { - audioData = await getAudioTracks(); - } catch (error) { - console.log("[VideoContext] Failed to get audio tracks:", error); - return; - } - const allAudio = - mediaSource?.MediaStreams?.filter((s) => s.Type === "Audio") || []; - const audioTracks: Track[] = allAudio?.map((audio, idx) => { - if (!mediaSource?.TranscodingUrl) { - const vlcIndex = audioData?.at(idx + 1)?.index ?? -1; - return { - name: audio.DisplayTitle ?? "Undefined Audio", - index: audio.Index ?? -1, - setTrack: () => - setTrackParams("audio", vlcIndex, audio.Index ?? -1), - }; - } - return { - name: audio.DisplayTitle ?? "Undefined Audio", - index: audio.Index ?? -1, - setTrack: () => - setPlayerParams({ chosenAudioIndex: audio.Index?.toString() }), - }; + mpvIndex: -1, + setTrack: () => { + playerControls.setSubtitleTrack(-1); + router.setParams({ subtitleIndex: "-1" }); + }, }); - // Add a "Disable Audio" option if its not transcoding. - if (!mediaSource?.TranscodingUrl) { - audioTracks.unshift({ - name: "Disable", - index: -1, - setTrack: () => setTrackParams("audio", -1, -1), - }); + // For text-based subs, they should still be available in the file + let subIdx = 1; + for (const sub of allSubs) { + if (sub.IsTextSubtitleStream) { + subs.push({ + name: sub.DisplayTitle || "Unknown", + index: sub.Index ?? -1, + mpvIndex: subIdx, + setTrack: () => { + playerControls.setSubtitleTrack(subIdx); + router.setParams({ subtitleIndex: String(sub.Index) }); + }, + }); + subIdx++; + } else if (sub.Index === downloadedSubtitleIndex) { + // This image-based sub was burned in - show it but indicate it's active + subs.push({ + name: `${sub.DisplayTitle || "Unknown"} (burned in)`, + index: sub.Index ?? -1, + mpvIndex: -1, // Can't be changed + setTrack: () => { + // Already burned in, just update params + router.setParams({ subtitleIndex: String(sub.Index) }); + }, + }); + } } - setAudioTracks(audioTracks); + + setSubtitleTracks(subs.sort((a, b) => a.index - b.index)); + return; } + + // MPV track handling + const audioData = await playerControls.getAudioTracks().catch(() => null); + const playerAudio = (audioData as MpvAudioTrack[]) ?? []; + + // Separate embedded vs external subtitles from Jellyfin's list + // MPV orders tracks as: [all embedded, then all external] + const embeddedSubs = allSubs.filter( + (s) => s.DeliveryMethod === SubtitleDeliveryMethod.Embed, + ); + const externalSubs = allSubs.filter( + (s) => s.DeliveryMethod === SubtitleDeliveryMethod.External, + ); + + // Count embedded subs that will be in MPV + // (excludes image-based subs during transcoding as they're burned in) + const embeddedInPlayer = embeddedSubs.filter( + (s) => !isTranscoding || !isImageBasedSubtitle(s), + ); + + const subs: Track[] = []; + + // Process all Jellyfin subtitles + for (const sub of allSubs) { + const isEmbedded = sub.DeliveryMethod === SubtitleDeliveryMethod.Embed; + const isExternal = + sub.DeliveryMethod === SubtitleDeliveryMethod.External; + + // For image-based subs during transcoding, need to refresh player + if (isTranscoding && isImageBasedSubtitle(sub)) { + subs.push({ + name: sub.DisplayTitle || "Unknown", + index: sub.Index ?? -1, + mpvIndex: -1, + setTrack: () => { + replacePlayer({ subtitleIndex: String(sub.Index) }); + }, + }); + continue; + } + + // Calculate MPV track ID based on type + // MPV IDs: [1..embeddedCount] for embedded, [embeddedCount+1..] for external + let mpvId = -1; + + if (isEmbedded) { + // Find position among embedded subs that are in player + const embeddedPosition = embeddedInPlayer.findIndex( + (s) => s.Index === sub.Index, + ); + if (embeddedPosition !== -1) { + mpvId = embeddedPosition + 1; // 1-based ID + } + } else if (isExternal) { + // Find position among external subs, offset by embedded count + const externalPosition = externalSubs.findIndex( + (s) => s.Index === sub.Index, + ); + if (externalPosition !== -1) { + mpvId = embeddedInPlayer.length + externalPosition + 1; + } + } + + subs.push({ + name: sub.DisplayTitle || "Unknown", + index: sub.Index ?? -1, + mpvIndex: mpvId, + setTrack: () => { + // Transcoding + switching to/from image-based sub + if ( + isTranscoding && + (isImageBasedSubtitle(sub) || isCurrentSubImageBased) + ) { + replacePlayer({ subtitleIndex: String(sub.Index) }); + return; + } + + // Direct switch in player + if (mpvId !== -1) { + playerControls.setSubtitleTrack(mpvId); + router.setParams({ subtitleIndex: String(sub.Index) }); + return; + } + + // Fallback - refresh player + replacePlayer({ subtitleIndex: String(sub.Index) }); + }, + }); + } + + // Add "Disable" option at the beginning + subs.unshift({ + name: "Disable", + index: -1, + mpvIndex: -1, + setTrack: () => { + if (isTranscoding && isCurrentSubImageBased) { + replacePlayer({ subtitleIndex: "-1" }); + } else { + playerControls.setSubtitleTrack(-1); + router.setParams({ subtitleIndex: "-1" }); + } + }, + }); + + // Process audio tracks + const audio: Track[] = allAudio.map((a, idx) => { + const playerTrack = playerAudio[idx]; + const mpvId = playerTrack?.id ?? idx + 1; + + return { + name: a.DisplayTitle || "Unknown", + index: a.Index ?? -1, + mpvIndex: mpvId, + setTrack: () => { + if (isTranscoding) { + replacePlayer({ audioIndex: String(a.Index) }); + return; + } + playerControls.setAudioTrack(mpvId); + router.setParams({ audioIndex: String(a.Index) }); + }, + }; + }); + + // TV only: Merge locally downloaded subtitles (from OpenSubtitles) + if (Platform.isTV && itemId) { + const localSubs = getSubtitlesForItem(itemId); + let localIdx = 0; + for (const localSub of localSubs) { + // Verify file still exists (cache may have been cleared) + const subtitleFile = new File(localSub.filePath); + if (!subtitleFile.exists) { + continue; + } + + const localIndex = LOCAL_SUBTITLE_INDEX_START - localIdx; + subs.push({ + name: localSub.name, + index: localIndex, + mpvIndex: -1, // Will be loaded dynamically via addSubtitleFile + isLocal: true, + localPath: localSub.filePath, + setTrack: () => { + // Add the subtitle file to MPV and select it + playerControls.addSubtitleFile(localSub.filePath, true); + router.setParams({ subtitleIndex: String(localIndex) }); + }, + }); + localIdx++; + } + } + + setSubtitleTracks(subs.sort((a, b) => a.index - b.index)); + setAudioTracks(audio); }; + fetchTracks(); - }, [isVideoLoaded, getAudioTracks, getSubtitleTracks]); + }, [tracksReady, mediaSource, offline, downloadedItem, itemId]); return ( - + {children} ); }; export const useVideoContext = () => { - const context = useContext(VideoContext); - if (context === undefined) { - throw new Error("useVideoContext must be used within a VideoProvider"); - } - return context; + const ctx = useContext(VideoContext); + if (!ctx) + throw new Error("useVideoContext must be used within VideoProvider"); + return ctx; }; diff --git a/components/video-player/controls/dropdown/DropdownView.tsx b/components/video-player/controls/dropdown/DropdownView.tsx index e1332e43c..7b6713b39 100644 --- a/components/video-player/controls/dropdown/DropdownView.tsx +++ b/components/video-player/controls/dropdown/DropdownView.tsx @@ -1,5 +1,5 @@ import { Ionicons } from "@expo/vector-icons"; -import { useLocalSearchParams, useRouter } from "expo-router"; +import { useLocalSearchParams } from "expo-router"; import { useCallback, useMemo, useRef } from "react"; import { Platform, View } from "react-native"; import { BITRATES } from "@/components/BitrateSelector"; @@ -7,20 +7,48 @@ import { type OptionGroup, PlatformDropdown, } from "@/components/PlatformDropdown"; -import { useControlContext } from "../contexts/ControlContext"; +import { PLAYBACK_SPEEDS } from "@/components/PlaybackSpeedSelector"; +import useRouter from "@/hooks/useAppRouter"; +import { useOfflineMode } from "@/providers/OfflineModeProvider"; +import { useSettings } from "@/utils/atoms/settings"; +import { usePlayerContext } from "../contexts/PlayerContext"; import { useVideoContext } from "../contexts/VideoContext"; +import { PlaybackSpeedScope } from "../utils/playback-speed-settings"; -const DropdownView = () => { - const videoContext = useVideoContext(); - const { subtitleTracks, audioTracks } = videoContext; - const ControlContext = useControlContext(); - const [item, mediaSource] = [ - ControlContext?.item, - ControlContext?.mediaSource, - ]; +// Subtitle scale presets (direct multiplier values) +const SUBTITLE_SCALE_PRESETS = [ + { label: "0.1x", value: 0.1 }, + { label: "0.25x", value: 0.25 }, + { label: "0.5x", value: 0.5 }, + { label: "0.75x", value: 0.75 }, + { label: "1.0x", value: 1.0 }, + { label: "1.25x", value: 1.25 }, + { label: "1.5x", value: 1.5 }, + { label: "2.0x", value: 2.0 }, + { label: "2.5x", value: 2.5 }, + { label: "3.0x", value: 3.0 }, +] as const; + +interface DropdownViewProps { + playbackSpeed?: number; + setPlaybackSpeed?: (speed: number, scope: PlaybackSpeedScope) => void; + showTechnicalInfo?: boolean; + onToggleTechnicalInfo?: () => void; +} + +const DropdownView = ({ + playbackSpeed = 1.0, + setPlaybackSpeed, + showTechnicalInfo = false, + onToggleTechnicalInfo, +}: DropdownViewProps) => { + const { subtitleTracks, audioTracks } = useVideoContext(); + const { item, mediaSource } = usePlayerContext(); + const { settings, updateSettings } = useSettings(); const router = useRouter(); + const isOffline = useOfflineMode(); - const { subtitleIndex, audioIndex, bitrateValue, playbackPosition, offline } = + const { subtitleIndex, audioIndex, bitrateValue, playbackPosition } = useLocalSearchParams<{ itemId: string; audioIndex: string; @@ -28,15 +56,12 @@ const DropdownView = () => { mediaSourceId: string; bitrateValue: string; playbackPosition: string; - offline: string; }>(); // Use ref to track playbackPosition without causing re-renders const playbackPositionRef = useRef(playbackPosition); playbackPositionRef.current = playbackPosition; - const isOffline = offline === "true"; - // Stabilize IDs to prevent unnecessary recalculations const itemIdRef = useRef(item.Id); const mediaSourceIdRef = useRef(mediaSource?.Id); @@ -100,6 +125,18 @@ const DropdownView = () => { onPress: () => sub.setTrack(), })), }); + + // Subtitle Scale Section + groups.push({ + title: "Subtitle Scale", + options: SUBTITLE_SCALE_PRESETS.map((preset) => ({ + type: "radio" as const, + label: preset.label, + value: preset.value.toString(), + selected: (settings.mpvSubtitleScale ?? 1.0) === preset.value, + onPress: () => updateSettings({ mpvSubtitleScale: preset.value }), + })), + }); } // Audio Section @@ -116,6 +153,35 @@ const DropdownView = () => { }); } + // Speed Section + if (setPlaybackSpeed) { + groups.push({ + title: "Speed", + options: PLAYBACK_SPEEDS.map((speed) => ({ + type: "radio" as const, + label: speed.label, + value: speed.value.toString(), + selected: playbackSpeed === speed.value, + onPress: () => setPlaybackSpeed(speed.value, PlaybackSpeedScope.All), + })), + }); + } + + // Technical Info (at bottom) + if (onToggleTechnicalInfo) { + groups.push({ + options: [ + { + type: "action" as const, + label: showTechnicalInfo + ? "Hide Technical Info" + : "Show Technical Info", + onPress: onToggleTechnicalInfo, + }, + ], + }); + } + return groups; // eslint-disable-next-line react-hooks/exhaustive-deps }, [ @@ -126,6 +192,12 @@ const DropdownView = () => { audioTracksKey, subtitleIndex, audioIndex, + settings.mpvSubtitleScale, + updateSettings, + playbackSpeed, + setPlaybackSpeed, + showTechnicalInfo, + onToggleTechnicalInfo, // Note: subtitleTracks and audioTracks are intentionally excluded // because we use subtitleTracksKey and audioTracksKey for stability ]); @@ -148,6 +220,7 @@ const DropdownView = () => { title='Playback Options' groups={optionGroups} trigger={trigger} + expoUIConfig={{}} bottomSheetConfig={{ enablePanDownToClose: true, }} diff --git a/components/video-player/controls/hooks/index.ts b/components/video-player/controls/hooks/index.ts index 08b234ac5..cfb317599 100644 --- a/components/video-player/controls/hooks/index.ts +++ b/components/video-player/controls/hooks/index.ts @@ -1,3 +1,4 @@ +export { useChapterNavigation } from "./useChapterNavigation"; export { useRemoteControl } from "./useRemoteControl"; export { useVideoNavigation } from "./useVideoNavigation"; export { useVideoSlider } from "./useVideoSlider"; diff --git a/components/video-player/controls/hooks/useChapterNavigation.ts b/components/video-player/controls/hooks/useChapterNavigation.ts new file mode 100644 index 000000000..00d3330c0 --- /dev/null +++ b/components/video-player/controls/hooks/useChapterNavigation.ts @@ -0,0 +1,150 @@ +import type { ChapterInfo } from "@jellyfin/sdk/lib/generated-client"; +import { useCallback, useMemo } from "react"; +import type { SharedValue } from "react-native-reanimated"; +import { ticksToMs } from "@/utils/time"; + +export interface UseChapterNavigationProps { + /** Chapters array from the item */ + chapters: ChapterInfo[] | null | undefined; + /** Current progress in milliseconds (SharedValue) */ + progress: SharedValue; + /** Total duration in milliseconds */ + maxMs: number; + /** Seek function that accepts milliseconds */ + seek: (ms: number) => void; +} + +export interface UseChapterNavigationReturn { + /** Array of chapters */ + chapters: ChapterInfo[]; + /** Index of the current chapter (-1 if no chapters) */ + currentChapterIndex: number; + /** Current chapter info or null */ + currentChapter: ChapterInfo | null; + /** Whether there's a next chapter available */ + hasNextChapter: boolean; + /** Whether there's a previous chapter available */ + hasPreviousChapter: boolean; + /** Navigate to the next chapter */ + goToNextChapter: () => void; + /** Navigate to the previous chapter (or restart current if >3s in) */ + goToPreviousChapter: () => void; + /** Array of chapter positions as percentages (0-100) for tick marks */ + chapterPositions: number[]; + /** Whether chapters are available */ + hasChapters: boolean; +} + +// Threshold in ms - if more than 3 seconds into chapter, restart instead of going to previous +const RESTART_THRESHOLD_MS = 3000; + +/** + * Hook for chapter navigation in video player + * Provides current chapter info and navigation functions + */ +export function useChapterNavigation({ + chapters: rawChapters, + progress, + maxMs, + seek, +}: UseChapterNavigationProps): UseChapterNavigationReturn { + // Ensure chapters is always an array + const chapters = useMemo(() => rawChapters ?? [], [rawChapters]); + + // Calculate chapter positions as percentages for tick marks + const chapterPositions = useMemo(() => { + if (!chapters.length || maxMs <= 0) return []; + + return chapters + .map((chapter) => { + const positionMs = ticksToMs(chapter.StartPositionTicks); + return (positionMs / maxMs) * 100; + }) + .filter((pos) => pos > 0 && pos < 100); // Skip first (0%) and any at the end + }, [chapters, maxMs]); + + // Find current chapter index based on progress + // The current chapter is the one with the largest StartPositionTicks that is <= current progress + const getCurrentChapterIndex = useCallback((): number => { + if (!chapters.length) return -1; + + const currentMs = progress.value; + let currentIndex = -1; + + for (let i = 0; i < chapters.length; i++) { + const chapterMs = ticksToMs(chapters[i].StartPositionTicks); + if (chapterMs <= currentMs) { + currentIndex = i; + } else { + break; + } + } + + return currentIndex; + }, [chapters, progress]); + + // Current chapter index (computed once for rendering) + const currentChapterIndex = getCurrentChapterIndex(); + + // Current chapter info + const currentChapter = useMemo(() => { + if (currentChapterIndex < 0 || currentChapterIndex >= chapters.length) { + return null; + } + return chapters[currentChapterIndex]; + }, [chapters, currentChapterIndex]); + + // Navigation availability + const hasNextChapter = + chapters.length > 0 && currentChapterIndex < chapters.length - 1; + const hasPreviousChapter = chapters.length > 0 && currentChapterIndex >= 0; + + // Navigate to next chapter + const goToNextChapter = useCallback(() => { + const idx = getCurrentChapterIndex(); + if (idx < chapters.length - 1) { + const nextChapter = chapters[idx + 1]; + const nextMs = ticksToMs(nextChapter.StartPositionTicks); + progress.value = nextMs; + seek(nextMs); + } + }, [chapters, getCurrentChapterIndex, progress, seek]); + + // Navigate to previous chapter (or restart current if >3s in) + const goToPreviousChapter = useCallback(() => { + const idx = getCurrentChapterIndex(); + if (idx < 0) return; + + const currentChapterMs = ticksToMs(chapters[idx].StartPositionTicks); + const currentMs = progress.value; + const timeIntoChapter = currentMs - currentChapterMs; + + // If more than 3 seconds into the current chapter, restart it + // Otherwise, go to the previous chapter + if (timeIntoChapter > RESTART_THRESHOLD_MS && idx >= 0) { + progress.value = currentChapterMs; + seek(currentChapterMs); + } else if (idx > 0) { + const prevChapter = chapters[idx - 1]; + const prevMs = ticksToMs(prevChapter.StartPositionTicks); + progress.value = prevMs; + seek(prevMs); + } else { + // At the first chapter, just restart it + progress.value = currentChapterMs; + seek(currentChapterMs); + } + }, [chapters, getCurrentChapterIndex, progress, seek]); + + return { + chapters, + currentChapterIndex, + currentChapter, + hasNextChapter, + hasPreviousChapter, + goToNextChapter, + goToPreviousChapter, + chapterPositions, + hasChapters: chapters.length > 0, + }; +} diff --git a/components/video-player/controls/hooks/useRemoteControl.ts b/components/video-player/controls/hooks/useRemoteControl.ts index 8eba2c451..8a4fd902e 100644 --- a/components/video-player/controls/hooks/useRemoteControl.ts +++ b/components/video-player/controls/hooks/useRemoteControl.ts @@ -1,184 +1,237 @@ -import { useCallback, useEffect, useRef, useState } from "react"; -import { Platform } from "react-native"; +import { useEffect, useRef, useState } from "react"; +import { Alert } from "react-native"; import { type SharedValue, useSharedValue } from "react-native-reanimated"; -import { msToTicks, ticksToSeconds } from "@/utils/time"; -import { CONTROLS_CONSTANTS } from "../constants"; - -// TV event handler with fallback for non-TV platforms -let useTVEventHandler: (callback: (evt: any) => void) => void; -if (Platform.isTV) { - try { - useTVEventHandler = require("react-native").useTVEventHandler; - } catch { - // Fallback for non-TV platforms - useTVEventHandler = () => {}; - } -} else { - // No-op hook for non-TV platforms - useTVEventHandler = () => {}; -} +import { useTVBackPress } from "@/hooks/useTVBackPress"; +import { useTVEventHandler } from "@/hooks/useTVEventHandler"; interface UseRemoteControlProps { - progress: SharedValue; - min: SharedValue; - max: SharedValue; - isVlc: boolean; showControls: boolean; - isPlaying: boolean; - seek: (value: number) => void; - play: () => void; - togglePlay: () => void; toggleControls: () => void; - calculateTrickplayUrl: (progressInTicks: number) => void; - handleSeekForward: (seconds: number) => void; - handleSeekBackward: (seconds: number) => void; + /** When true, disables handling D-pad events (e.g., when settings modal is open) */ + disableSeeking?: boolean; + /** Callback for back/menu button press (tvOS: menu, Android TV: back) */ + onBack?: () => void; + /** Callback to hide controls (called on back press when controls are visible) */ + onHideControls?: () => void; + /** Title of the video being played (shown in exit confirmation) */ + videoTitle?: string; + /** Whether the progress bar currently has focus */ + isProgressBarFocused?: boolean; + /** Callback for seeking left when progress bar is focused */ + onSeekLeft?: () => void; + /** Callback for seeking right when progress bar is focused */ + onSeekRight?: () => void; + /** Callback for seeking left when controls are hidden (minimal seek mode) */ + onMinimalSeekLeft?: () => void; + /** Callback for seeking right when controls are hidden (minimal seek mode) */ + onMinimalSeekRight?: () => void; + /** Callback for any interaction that should reset the controls timeout */ + onInteraction?: () => void; + /** Callback when long press seek left starts (eventKeyAction: 0) */ + onLongSeekLeftStart?: () => void; + /** Callback when long press seek right starts (eventKeyAction: 0) */ + onLongSeekRightStart?: () => void; + /** Callback when long press seek ends (eventKeyAction: 1) */ + onLongSeekStop?: () => void; + /** Callback when up/down D-pad pressed (to show controls with play button focused) */ + onVerticalDpad?: () => void; + /** Called before the exit confirmation Alert is shown (e.g., to pause countdown) */ + onWillExit?: () => void; + /** Called when the user cancels the exit confirmation Alert */ + onCancelExit?: () => void; + // Legacy props - kept for backwards compatibility with mobile Controls.tsx + // These are ignored in the simplified implementation + progress?: SharedValue; + min?: SharedValue; + max?: SharedValue; + isPlaying?: boolean; + seek?: (value: number) => void; + play?: () => void; + togglePlay?: () => void; + calculateTrickplayUrl?: (progressInTicks: number) => void; + handleSeekForward?: (seconds: number) => void; + handleSeekBackward?: (seconds: number) => void; } +/** + * Hook to manage TV remote control interactions. + * Simplified version - D-pad navigation is handled by native focus system. + * This hook handles: + * - Showing controls on any button press + * - Play/pause button on TV remote + */ export function useRemoteControl({ - progress, - min, - max, - isVlc, showControls, - isPlaying, - seek, - play, togglePlay, - toggleControls, - calculateTrickplayUrl, - handleSeekForward, - handleSeekBackward, + onBack, + onHideControls, + videoTitle, + isProgressBarFocused, + onSeekLeft, + onSeekRight, + onMinimalSeekLeft, + onMinimalSeekRight, + onInteraction, + onLongSeekLeftStart, + onLongSeekRightStart, + onLongSeekStop, + onVerticalDpad, + onWillExit, + onCancelExit, }: UseRemoteControlProps) { + // Keep these for backward compatibility with the component const remoteScrubProgress = useSharedValue(null); const isRemoteScrubbing = useSharedValue(false); - const [showRemoteBubble, setShowRemoteBubble] = useState(false); - const [longPressScrubMode, setLongPressScrubMode] = useState< - "FF" | "RW" | null - >(null); - const [isSliding, setIsSliding] = useState(false); - const [time, setTime] = useState({ hours: 0, minutes: 0, seconds: 0 }); + const [showRemoteBubble] = useState(false); + const [isSliding] = useState(false); + const [time] = useState({ hours: 0, minutes: 0, seconds: 0 }); - const longPressTimeoutRef = useRef | null>( - null, - ); - const SCRUB_INTERVAL = isVlc - ? CONTROLS_CONSTANTS.SCRUB_INTERVAL_MS - : CONTROLS_CONSTANTS.SCRUB_INTERVAL_TICKS; + // Use refs to avoid stale closures in BackHandler + const showControlsRef = useRef(showControls); + const onHideControlsRef = useRef(onHideControls); + const onBackRef = useRef(onBack); + const videoTitleRef = useRef(videoTitle); + const onWillExitRef = useRef(onWillExit); + const onCancelExitRef = useRef(onCancelExit); - const updateTime = useCallback( - (progressValue: number) => { - const progressInTicks = isVlc ? msToTicks(progressValue) : progressValue; - const progressInSeconds = Math.floor(ticksToSeconds(progressInTicks)); - const hours = Math.floor(progressInSeconds / 3600); - const minutes = Math.floor((progressInSeconds % 3600) / 60); - const seconds = progressInSeconds % 60; - setTime({ hours, minutes, seconds }); - }, - [isVlc], - ); + useEffect(() => { + showControlsRef.current = showControls; + onHideControlsRef.current = onHideControls; + onBackRef.current = onBack; + videoTitleRef.current = videoTitle; + onWillExitRef.current = onWillExit; + onCancelExitRef.current = onCancelExit; + }, [ + showControls, + onHideControls, + onBack, + videoTitle, + onWillExit, + onCancelExit, + ]); + + // BackHandler owns player exit: Android TV sends hardware back here, and + // react-native-tvos maps the Apple TV menu button to the same API. + useTVBackPress(() => { + if (showControlsRef.current && onHideControlsRef.current) { + // Controls are visible, so the first back press only hides them. + onHideControlsRef.current(); + return true; + } + if (onBackRef.current) { + // Signal Controls that exit is imminent (pauses countdown, sets guard) + onWillExitRef.current?.(); + + // Controls are hidden, so confirm before leaving playback. + Alert.alert( + "Stop Playback", + videoTitleRef.current + ? `Stop playing "${videoTitleRef.current}"?` + : "Are you sure you want to stop playback?", + [ + { + text: "Cancel", + style: "cancel", + onPress: () => onCancelExitRef.current?.(), + }, + { text: "Stop", style: "destructive", onPress: onBackRef.current }, + ], + ); + return true; + } + return false; + }, []); // TV remote control handling (no-op on non-TV platforms) useTVEventHandler((evt) => { if (!evt) return; - switch (evt.eventType) { - case "longLeft": { - setLongPressScrubMode((prev) => (!prev ? "RW" : null)); - break; - } - case "longRight": { - setLongPressScrubMode((prev) => (!prev ? "FF" : null)); - break; - } - case "left": - case "right": { - isRemoteScrubbing.value = true; - setShowRemoteBubble(true); - - const direction = evt.eventType === "left" ? -1 : 1; - const base = remoteScrubProgress.value ?? progress.value; - const updated = Math.max( - min.value, - Math.min(max.value, base + direction * SCRUB_INTERVAL), - ); - remoteScrubProgress.value = updated; - const progressInTicks = isVlc ? msToTicks(updated) : updated; - calculateTrickplayUrl(progressInTicks); - updateTime(updated); - break; - } - case "select": { - if (isRemoteScrubbing.value && remoteScrubProgress.value != null) { - progress.value = remoteScrubProgress.value; - - const seekTarget = isVlc - ? Math.max(0, remoteScrubProgress.value) - : Math.max(0, ticksToSeconds(remoteScrubProgress.value)); - - seek(seekTarget); - if (isPlaying) play(); - - isRemoteScrubbing.value = false; - remoteScrubProgress.value = null; - setShowRemoteBubble(false); - } else { - togglePlay(); - } - break; - } - case "down": - case "up": - // cancel scrubbing on other directions - isRemoteScrubbing.value = false; - remoteScrubProgress.value = null; - setShowRemoteBubble(false); - break; - default: - break; + // Back/menu is handled by useTVBackPress above. Keep this handler focused + // on remote-control events like play/pause, D-pad, and long seek. + if (evt.eventType === "menu") { + return; } - if (!showControls) toggleControls(); + // Handle play/pause button press on TV remote + if (evt.eventType === "playPause") { + togglePlay?.(); + onInteraction?.(); + return; + } + + // Handle long press D-pad for continuous seeking (works in both modes) + // Must be checked BEFORE the showControls check to work when controls are hidden + if (evt.eventType === "longLeft") { + if (evt.eventKeyAction === 0 && onLongSeekLeftStart) { + // Key pressed - start continuous seeking backward + onLongSeekLeftStart(); + } else if (evt.eventKeyAction === 1 && onLongSeekStop) { + // Key released - stop seeking + onLongSeekStop(); + } + return; + } + + if (evt.eventType === "longRight") { + if (evt.eventKeyAction === 0 && onLongSeekRightStart) { + // Key pressed - start continuous seeking forward + onLongSeekRightStart(); + } else if (evt.eventKeyAction === 1 && onLongSeekStop) { + // Key released - stop seeking + onLongSeekStop(); + } + return; + } + + // Handle D-pad when controls are hidden + if (!showControls) { + // Ignore select/enter events - let the native Pressable handle them + // This prevents controls from showing when pressing buttons like skip intro + if (evt.eventType === "select" || evt.eventType === "enter") { + return; + } + // Minimal seek mode for left/right + if (evt.eventType === "left" && onMinimalSeekLeft) { + onMinimalSeekLeft(); + return; + } + if (evt.eventType === "right" && onMinimalSeekRight) { + onMinimalSeekRight(); + return; + } + // Up/down shows controls with play button focused + if ( + (evt.eventType === "up" || evt.eventType === "down") && + onVerticalDpad + ) { + onVerticalDpad(); + return; + } + // Ignore all other events (focus/blur, swipes, etc.) + // User can press up/down to show controls + return; + } + + // Controls are showing - handle seeking when progress bar is focused + if (isProgressBarFocused) { + if (evt.eventType === "left" && onSeekLeft) { + onSeekLeft(); + return; + } + if (evt.eventType === "right" && onSeekRight) { + onSeekRight(); + return; + } + } + + // Reset the timeout on any D-pad navigation when controls are showing + onInteraction?.(); }); - useEffect(() => { - let isActive = true; - let seekTime = CONTROLS_CONSTANTS.LONG_PRESS_INITIAL_SEEK; - - const scrubWithLongPress = () => { - if (!isActive || !longPressScrubMode) return; - - setIsSliding(true); - const scrubFn = - longPressScrubMode === "FF" ? handleSeekForward : handleSeekBackward; - scrubFn(seekTime); - seekTime *= CONTROLS_CONSTANTS.LONG_PRESS_ACCELERATION; - - longPressTimeoutRef.current = setTimeout( - scrubWithLongPress, - CONTROLS_CONSTANTS.LONG_PRESS_INTERVAL, - ); - }; - - if (longPressScrubMode) { - isActive = true; - scrubWithLongPress(); - } - - return () => { - isActive = false; - setIsSliding(false); - if (longPressTimeoutRef.current) { - clearTimeout(longPressTimeoutRef.current); - longPressTimeoutRef.current = null; - } - }; - }, [longPressScrubMode, handleSeekForward, handleSeekBackward]); - return { remoteScrubProgress, isRemoteScrubbing, showRemoteBubble, - longPressScrubMode, isSliding, time, }; diff --git a/components/video-player/controls/hooks/useVideoNavigation.ts b/components/video-player/controls/hooks/useVideoNavigation.ts index 0573d6e47..5468c790e 100644 --- a/components/video-player/controls/hooks/useVideoNavigation.ts +++ b/components/video-player/controls/hooks/useVideoNavigation.ts @@ -3,20 +3,22 @@ import type { SharedValue } from "react-native-reanimated"; import { useHaptic } from "@/hooks/useHaptic"; import { useSettings } from "@/utils/atoms/settings"; import { writeToLog } from "@/utils/log"; -import { secondsToMs, ticksToSeconds } from "@/utils/time"; +import { secondsToMs } from "@/utils/time"; interface UseVideoNavigationProps { progress: SharedValue; isPlaying: boolean; - isVlc: boolean; seek: (value: number) => void; play: () => void; } +/** + * Hook to manage video navigation (seeking forward/backward). + * MPV player uses milliseconds for time values. + */ export function useVideoNavigation({ progress, isPlaying, - isVlc, seek, play, }: UseVideoNavigationProps) { @@ -30,16 +32,15 @@ export function useVideoNavigation({ try { const curr = progress.value; if (curr !== undefined) { - const newTime = isVlc - ? Math.max(0, curr - secondsToMs(seconds)) - : Math.max(0, ticksToSeconds(curr) - seconds); + // MPV uses ms + const newTime = Math.max(0, curr - secondsToMs(seconds)); seek(newTime); } } catch (error) { writeToLog("ERROR", "Error seeking video backwards", error); } }, - [isPlaying, isVlc, seek, progress], + [isPlaying, seek, progress], ); const handleSeekForward = useCallback( @@ -48,16 +49,15 @@ export function useVideoNavigation({ try { const curr = progress.value; if (curr !== undefined) { - const newTime = isVlc - ? curr + secondsToMs(seconds) - : ticksToSeconds(curr) + seconds; + // MPV uses ms + const newTime = curr + secondsToMs(seconds); seek(Math.max(0, newTime)); } } catch (error) { writeToLog("ERROR", "Error seeking video forwards", error); } }, - [isPlaying, isVlc, seek, progress], + [isPlaying, seek, progress], ); const handleSkipBackward = useCallback(async () => { @@ -69,9 +69,11 @@ export function useVideoNavigation({ try { const curr = progress.value; if (curr !== undefined) { - const newTime = isVlc - ? Math.max(0, curr - secondsToMs(settings.rewindSkipTime)) - : Math.max(0, ticksToSeconds(curr) - settings.rewindSkipTime); + // MPV uses ms + const newTime = Math.max( + 0, + curr - secondsToMs(settings.rewindSkipTime), + ); seek(newTime); if (wasPlayingRef.current) { play(); @@ -80,7 +82,7 @@ export function useVideoNavigation({ } catch (error) { writeToLog("ERROR", "Error seeking video backwards", error); } - }, [settings, isPlaying, isVlc, play, seek, progress, lightHapticFeedback]); + }, [settings, isPlaying, play, seek, progress, lightHapticFeedback]); const handleSkipForward = useCallback(async () => { if (!settings?.forwardSkipTime) { @@ -91,9 +93,8 @@ export function useVideoNavigation({ try { const curr = progress.value; if (curr !== undefined) { - const newTime = isVlc - ? curr + secondsToMs(settings.forwardSkipTime) - : ticksToSeconds(curr) + settings.forwardSkipTime; + // MPV uses ms + const newTime = curr + secondsToMs(settings.forwardSkipTime); seek(Math.max(0, newTime)); if (wasPlayingRef.current) { play(); @@ -102,7 +103,7 @@ export function useVideoNavigation({ } catch (error) { writeToLog("ERROR", "Error seeking video forwards", error); } - }, [settings, isPlaying, isVlc, play, seek, progress, lightHapticFeedback]); + }, [settings, isPlaying, play, seek, progress, lightHapticFeedback]); return { handleSeekBackward, diff --git a/components/video-player/controls/hooks/useVideoSlider.ts b/components/video-player/controls/hooks/useVideoSlider.ts index 85072954d..3c19ce7ad 100644 --- a/components/video-player/controls/hooks/useVideoSlider.ts +++ b/components/video-player/controls/hooks/useVideoSlider.ts @@ -8,7 +8,6 @@ interface UseVideoSliderProps { progress: SharedValue; isSeeking: SharedValue; isPlaying: boolean; - isVlc: boolean; seek: (value: number) => void; play: () => void; pause: () => void; @@ -16,11 +15,14 @@ interface UseVideoSliderProps { showControls: boolean; } +/** + * Hook to manage video slider interactions. + * MPV player uses milliseconds for time values. + */ export function useVideoSlider({ progress, isSeeking, isPlaying, - isVlc, seek, play, pause, @@ -62,21 +64,35 @@ export function useVideoSlider({ setIsSliding(false); isSeeking.value = false; progress.value = value; - const seekValue = Math.max( - 0, - Math.floor(isVlc ? value : ticksToSeconds(value)), - ); + // MPV uses ms, seek expects ms + const seekValue = Math.max(0, Math.floor(value)); seek(seekValue); if (wasPlayingRef.current) { play(); } }, - [isVlc, seek, play, progress, isSeeking], + [seek, play, progress, isSeeking], + ); + + // Programmatic seek (chapter list, hotkeys) that bypasses the slide gesture. + // Reads `isPlaying` directly instead of `wasPlayingRef`, which is only set + // during a real slide and would carry stale state on a tap-to-seek. + const seekTo = useCallback( + (value: number) => { + const seekValue = Math.max(0, Math.floor(value)); + progress.value = seekValue; + seek(seekValue); + if (isPlaying) { + play(); + } + }, + [seek, play, progress, isPlaying], ); const handleSliderChange = useCallback( debounce((value: number) => { - const progressInTicks = isVlc ? msToTicks(value) : value; + // Convert ms to ticks for trickplay + const progressInTicks = msToTicks(value); calculateTrickplayUrl(progressInTicks); const progressInSeconds = Math.floor(ticksToSeconds(progressInTicks)); const hours = Math.floor(progressInSeconds / 3600); @@ -84,7 +100,7 @@ export function useVideoSlider({ const seconds = progressInSeconds % 60; setTime({ hours, minutes, seconds }); }, CONTROLS_CONSTANTS.SLIDER_DEBOUNCE_MS), - [isVlc, calculateTrickplayUrl], + [calculateTrickplayUrl], ); return { @@ -95,5 +111,6 @@ export function useVideoSlider({ handleTouchEnd, handleSliderComplete, handleSliderChange, + seekTo, }; } diff --git a/components/video-player/controls/hooks/useVideoTime.ts b/components/video-player/controls/hooks/useVideoTime.ts index bb0fa77d8..ad6800813 100644 --- a/components/video-player/controls/hooks/useVideoTime.ts +++ b/components/video-player/controls/hooks/useVideoTime.ts @@ -4,21 +4,18 @@ import { type SharedValue, useAnimatedReaction, } from "react-native-reanimated"; -import { ticksToSeconds } from "@/utils/time"; interface UseVideoTimeProps { progress: SharedValue; max: SharedValue; isSeeking: SharedValue; - isVlc: boolean; } -export function useVideoTime({ - progress, - max, - isSeeking, - isVlc, -}: UseVideoTimeProps) { +/** + * Hook to manage video time display. + * MPV player uses milliseconds for time values. + */ +export function useVideoTime({ progress, max, isSeeking }: UseVideoTimeProps) { const [currentTime, setCurrentTime] = useState(0); const [remainingTime, setRemainingTime] = useState(Number.POSITIVE_INFINITY); @@ -27,19 +24,16 @@ export function useVideoTime({ const updateTimes = useCallback( (currentProgress: number, maxValue: number) => { - const current = isVlc ? currentProgress : ticksToSeconds(currentProgress); - const remaining = isVlc - ? maxValue - currentProgress - : ticksToSeconds(maxValue - currentProgress); + // MPV uses milliseconds + const current = currentProgress; + const remaining = maxValue - currentProgress; // Only update state if the displayed time actually changed (avoid sub-second updates) - const currentSeconds = Math.floor(current / (isVlc ? 1000 : 1)); - const remainingSeconds = Math.floor(remaining / (isVlc ? 1000 : 1)); - const lastCurrentSeconds = Math.floor( - lastCurrentTimeRef.current / (isVlc ? 1000 : 1), - ); + const currentSeconds = Math.floor(current / 1000); + const remainingSeconds = Math.floor(remaining / 1000); + const lastCurrentSeconds = Math.floor(lastCurrentTimeRef.current / 1000); const lastRemainingSeconds = Math.floor( - lastRemainingTimeRef.current / (isVlc ? 1000 : 1), + lastRemainingTimeRef.current / 1000, ); if ( @@ -52,7 +46,7 @@ export function useVideoTime({ lastRemainingTimeRef.current = remaining; } }, - [isVlc], + [], ); useAnimatedReaction( diff --git a/components/video-player/controls/hooks/useVolumeAndBrightness.ts b/components/video-player/controls/hooks/useVolumeAndBrightness.ts index 2863949b2..c3ad0fa7c 100644 --- a/components/video-player/controls/hooks/useVolumeAndBrightness.ts +++ b/components/video-player/controls/hooks/useVolumeAndBrightness.ts @@ -34,6 +34,7 @@ export const useVolumeAndBrightness = ({ const initialVolume = useRef(null); const initialBrightness = useRef(null); const dragStartY = useRef(null); + const brightnessSupported = useRef(true); const startVolumeDrag = useCallback(async (startY: number) => { if (Platform.isTV || !VolumeManager) return; @@ -88,20 +89,26 @@ export const useVolumeAndBrightness = ({ }, []); const startBrightnessDrag = useCallback(async (startY: number) => { - if (Platform.isTV || !Brightness) return; + if (Platform.isTV || !Brightness || !brightnessSupported.current) return; try { const brightness = await Brightness.getBrightnessAsync(); initialBrightness.current = brightness; dragStartY.current = startY; } catch (error) { - console.error("Error starting brightness drag:", error); + console.warn("Brightness not supported on this device:", error); + brightnessSupported.current = false; } }, []); const updateBrightnessDrag = useCallback( async (deltaY: number) => { - if (Platform.isTV || !Brightness || initialBrightness.current === null) + if ( + Platform.isTV || + !Brightness || + initialBrightness.current === null || + !brightnessSupported.current + ) return; try { @@ -118,7 +125,8 @@ export const useVolumeAndBrightness = ({ const brightnessPercent = Math.round(newBrightness * 100); onBrightnessChange?.(brightnessPercent); } catch (error) { - console.error("Error updating brightness:", error); + console.warn("Brightness not supported on this device:", error); + brightnessSupported.current = false; } }, [onBrightnessChange], diff --git a/components/video-player/controls/types.ts b/components/video-player/controls/types.ts index f6c0e00a3..ca2ea1413 100644 --- a/components/video-player/controls/types.ts +++ b/components/video-player/controls/types.ts @@ -20,7 +20,12 @@ type TranscodedSubtitle = { type Track = { name: string; index: number; + mpvIndex?: number; setTrack: () => void; + /** True for client-side downloaded subtitles (e.g., from OpenSubtitles) */ + isLocal?: boolean; + /** File path for local subtitles */ + localPath?: string; }; -export type { EmbeddedSubtitle, ExternalSubtitle, TranscodedSubtitle, Track }; +export type { EmbeddedSubtitle, ExternalSubtitle, Track, TranscodedSubtitle }; diff --git a/components/video-player/controls/useControlsTimeout.ts b/components/video-player/controls/useControlsTimeout.ts index 80d41af20..9bbd8138c 100644 --- a/components/video-player/controls/useControlsTimeout.ts +++ b/components/video-player/controls/useControlsTimeout.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef } from "react"; +import { useCallback, useEffect, useRef } from "react"; interface UseControlsTimeoutProps { showControls: boolean; @@ -6,6 +6,7 @@ interface UseControlsTimeoutProps { episodeView: boolean; onHideControls: () => void; timeout?: number; + disabled?: boolean; } export const useControlsTimeout = ({ @@ -14,6 +15,7 @@ export const useControlsTimeout = ({ episodeView, onHideControls, timeout = 10000, + disabled = false, }: UseControlsTimeoutProps) => { const controlsTimeoutRef = useRef | null>(null); @@ -23,7 +25,7 @@ export const useControlsTimeout = ({ clearTimeout(controlsTimeoutRef.current); } - if (showControls && !isSliding && !episodeView) { + if (!disabled && showControls && !isSliding && !episodeView) { controlsTimeoutRef.current = setTimeout(() => { onHideControls(); }, timeout); @@ -37,18 +39,18 @@ export const useControlsTimeout = ({ clearTimeout(controlsTimeoutRef.current); } }; - }, [showControls, isSliding, episodeView, timeout, onHideControls]); + }, [showControls, isSliding, episodeView, timeout, onHideControls, disabled]); - const handleControlsInteraction = () => { - if (showControls) { - if (controlsTimeoutRef.current) { - clearTimeout(controlsTimeoutRef.current); - } - controlsTimeoutRef.current = setTimeout(() => { - onHideControls(); - }, timeout); + const handleControlsInteraction = useCallback(() => { + if (disabled || !showControls) return; + + if (controlsTimeoutRef.current) { + clearTimeout(controlsTimeoutRef.current); } - }; + controlsTimeoutRef.current = setTimeout(() => { + onHideControls(); + }, timeout); + }, [disabled, showControls, onHideControls, timeout]); return { handleControlsInteraction, diff --git a/components/video-player/controls/utils/playback-speed-settings.ts b/components/video-player/controls/utils/playback-speed-settings.ts new file mode 100644 index 000000000..772737f9c --- /dev/null +++ b/components/video-player/controls/utils/playback-speed-settings.ts @@ -0,0 +1,98 @@ +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; +import type { Settings } from "@/utils/atoms/settings"; + +export enum PlaybackSpeedScope { + Media = "media", + Show = "show", + All = "all", +} + +interface ClearConflictingSettingsResult { + readonly updatedPerMedia: Settings["playbackSpeedPerMedia"]; + readonly updatedPerShow: Settings["playbackSpeedPerShow"]; +} + +/** + * Clears conflicting playback speed settings based on the selected scope. + * + * When setting a playback speed at a certain scope, this function removes + * any more specific settings that would override the new setting: + * - "all" scope: clears both media-specific and show-specific settings + * - "media" scope: clears show-specific settings + * - "show" scope: clears media-specific settings + */ +export const clearConflictingSettings = ( + scope: PlaybackSpeedScope, + item: BaseItemDto | undefined, + perMedia: Settings["playbackSpeedPerMedia"], + perShow: Settings["playbackSpeedPerShow"], +): ClearConflictingSettingsResult => { + const updatedPerMedia = { ...perMedia }; + const updatedPerShow = { ...perShow }; + + if (scope === "all") { + // Clear both media-specific and show-specific settings + if (item?.Id && updatedPerMedia[item.Id] !== undefined) { + delete updatedPerMedia[item.Id]; + } + if (item?.SeriesId && updatedPerShow[item.SeriesId] !== undefined) { + delete updatedPerShow[item.SeriesId]; + } + } else if (scope === "media") { + // Clear show-specific setting only + if (item?.SeriesId && updatedPerShow[item.SeriesId] !== undefined) { + delete updatedPerShow[item.SeriesId]; + } + } else if (scope === "show") { + // Clear media-specific setting only + if (item?.Id && updatedPerMedia[item.Id] !== undefined) { + delete updatedPerMedia[item.Id]; + } + } + + return { updatedPerMedia, updatedPerShow }; +}; + +/** + * Updates playback speed settings based on the selected scope and speed. + * + * This function handles both clearing conflicting settings and updating + * the appropriate setting based on the scope: + * - "all": updates the default playback speed + * - "media": sets a speed for the specific media item + * - "show": sets a speed for the entire show + */ +export const updatePlaybackSpeedSettings = ( + speed: number, + scope: PlaybackSpeedScope, + item: BaseItemDto | undefined, + settings: Settings, + updateSettings: (updates: Partial) => void, +): void => { + const { updatedPerMedia, updatedPerShow } = clearConflictingSettings( + scope, + item, + settings.playbackSpeedPerMedia, + settings.playbackSpeedPerShow, + ); + + if (scope === "all") { + updateSettings({ + defaultPlaybackSpeed: speed, + playbackSpeedPerMedia: updatedPerMedia, + playbackSpeedPerShow: updatedPerShow, + }); + } else if (scope === "media" && item?.Id) { + updatedPerMedia[item.Id] = speed; + updateSettings({ + playbackSpeedPerMedia: updatedPerMedia, + playbackSpeedPerShow: updatedPerShow, + }); + } else if (scope === "show" && item?.SeriesId) { + updatedPerShow[item.SeriesId] = speed; + updateSettings({ + playbackSpeedPerShow: updatedPerShow, + playbackSpeedPerMedia: updatedPerMedia, + }); + } +}; diff --git a/components/vlc/VideoDebugInfo.tsx b/components/vlc/VideoDebugInfo.tsx deleted file mode 100644 index 40b74b6d1..000000000 --- a/components/vlc/VideoDebugInfo.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import type React from "react"; -import { useEffect, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { TouchableOpacity, View, type ViewProps } from "react-native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; -import type { TrackInfo, VlcPlayerViewRef } from "@/modules/VlcPlayer.types"; -import { Text } from "../common/Text"; - -interface Props extends ViewProps { - playerRef: React.RefObject; -} - -export const VideoDebugInfo: React.FC = ({ playerRef, ...props }) => { - const [audioTracks, setAudioTracks] = useState(null); - const [subtitleTracks, setSubtitleTracks] = useState( - null, - ); - - useEffect(() => { - const fetchTracks = async () => { - if (playerRef.current) { - try { - const audio = await playerRef.current.getAudioTracks(); - const subtitles = await playerRef.current.getSubtitleTracks(); - setAudioTracks(audio); - setSubtitleTracks(subtitles); - } catch (error) { - console.log("[VideoDebugInfo] Failed to fetch tracks:", error); - } - } - }; - - fetchTracks(); - }, [playerRef]); - - const insets = useSafeAreaInsets(); - - const { t } = useTranslation(); - - return ( - - {t("player.playback_state")} - {t("player.audio_tracks")} - {audioTracks?.map((track, index) => ( - - {track.name} ({t("player.index")} {track.index}) - - ))} - {t("player.subtitles_tracks")} - {subtitleTracks?.map((track, index) => ( - - {track.name} ({t("player.index")} {track.index}) - - ))} - { - if (playerRef.current) { - playerRef.current - .getAudioTracks() - .then(setAudioTracks) - .catch((err) => { - console.log( - "[VideoDebugInfo] Failed to get audio tracks:", - err, - ); - }); - playerRef.current - .getSubtitleTracks() - .then(setSubtitleTracks) - .catch((err) => { - console.log( - "[VideoDebugInfo] Failed to get subtitle tracks:", - err, - ); - }); - } - }} - > - - {t("player.refresh_tracks")} - - - - ); -}; diff --git a/components/watchlists/WatchlistSheet.tsx b/components/watchlists/WatchlistSheet.tsx new file mode 100644 index 000000000..9d585909b --- /dev/null +++ b/components/watchlists/WatchlistSheet.tsx @@ -0,0 +1,318 @@ +import { Ionicons } from "@expo/vector-icons"; +import { + BottomSheetBackdrop, + type BottomSheetBackdropProps, + BottomSheetModal, + BottomSheetView, +} from "@gorhom/bottom-sheet"; +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import React, { + forwardRef, + useCallback, + useImperativeHandle, + useMemo, + useRef, +} from "react"; +import { useTranslation } from "react-i18next"; +import { + ActivityIndicator, + StyleSheet, + TouchableOpacity, + View, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Text } from "@/components/common/Text"; +import useRouter from "@/hooks/useAppRouter"; +import { + useAddToWatchlist, + useRemoveFromWatchlist, +} from "@/hooks/useWatchlistMutations"; +import { + useItemInWatchlists, + useMyWatchlistsQuery, +} from "@/hooks/useWatchlists"; +import type { StreamystatsWatchlist } from "@/utils/streamystats/types"; + +export interface WatchlistSheetRef { + open: (item: BaseItemDto) => void; + close: () => void; +} + +interface WatchlistRowProps { + watchlist: StreamystatsWatchlist; + isInWatchlist: boolean; + isCompatible: boolean; + onToggle: () => void; + isLoading: boolean; +} + +const WatchlistRow: React.FC = ({ + watchlist, + isInWatchlist, + isCompatible, + onToggle, + isLoading, +}) => { + const disabled = !isCompatible && !isInWatchlist; + + return ( + + + + + {watchlist.name} + + {watchlist.allowedItemType && ( + + + {watchlist.allowedItemType} + + + )} + + {watchlist.description && ( + + {watchlist.description} + + )} + + {watchlist.itemCount ?? 0} items + + + + {isLoading ? ( + + ) : isInWatchlist ? ( + + ) : isCompatible ? ( + + ) : ( + + )} + + + ); +}; + +interface WatchlistSheetContentProps { + item: BaseItemDto; + onClose: () => void; +} + +const WatchlistSheetContent: React.FC = ({ + item, + onClose, +}) => { + const { t } = useTranslation(); + const router = useRouter(); + const insets = useSafeAreaInsets(); + + const { data: myWatchlists, isLoading: watchlistsLoading } = + useMyWatchlistsQuery(); + const { data: watchlistsContainingItem, isLoading: checkingLoading } = + useItemInWatchlists(item.Id); + + const addToWatchlist = useAddToWatchlist(); + const removeFromWatchlist = useRemoveFromWatchlist(); + + const isLoading = watchlistsLoading || checkingLoading; + + // Sort watchlists: ones containing item first, then compatible ones, then incompatible + const sortedWatchlists = useMemo(() => { + if (!myWatchlists) return []; + + return [...myWatchlists].sort((a, b) => { + const aInWatchlist = watchlistsContainingItem?.includes(a.id) ?? false; + const bInWatchlist = watchlistsContainingItem?.includes(b.id) ?? false; + + const aCompatible = !a.allowedItemType || a.allowedItemType === item.Type; + const bCompatible = !b.allowedItemType || b.allowedItemType === item.Type; + + // Items in watchlist first + if (aInWatchlist && !bInWatchlist) return -1; + if (!aInWatchlist && bInWatchlist) return 1; + + // Then compatible items + if (aCompatible && !bCompatible) return -1; + if (!aCompatible && bCompatible) return 1; + + // Then alphabetically + return a.name.localeCompare(b.name); + }); + }, [myWatchlists, watchlistsContainingItem, item.Type]); + + const handleToggle = useCallback( + async (watchlist: StreamystatsWatchlist) => { + if (!item.Id) return; + + const isInWatchlist = watchlistsContainingItem?.includes(watchlist.id); + + if (isInWatchlist) { + await removeFromWatchlist.mutateAsync({ + watchlistId: watchlist.id, + itemId: item.Id, + watchlistName: watchlist.name, + }); + } else { + await addToWatchlist.mutateAsync({ + watchlistId: watchlist.id, + itemId: item.Id, + watchlistName: watchlist.name, + }); + } + }, + [item.Id, watchlistsContainingItem, addToWatchlist, removeFromWatchlist], + ); + + const handleCreateNew = useCallback(() => { + onClose(); + router.push("/(auth)/(tabs)/(watchlists)/create"); + }, [onClose, router]); + + const isItemCompatible = useCallback( + (watchlist: StreamystatsWatchlist) => { + if (!watchlist.allowedItemType) return true; + return watchlist.allowedItemType === item.Type; + }, + [item.Type], + ); + + if (isLoading) { + return ( + + + {t("watchlists.loading")} + + ); + } + + return ( + + {/* Header */} + + + {t("watchlists.select_watchlist")} + + + {item.Name} + + + + {/* Watchlist List */} + {sortedWatchlists.length === 0 ? ( + + + + {t("watchlists.empty_title")} + + + {t("watchlists.empty_description")} + + + ) : ( + + {sortedWatchlists.map((watchlist, index) => ( + + handleToggle(watchlist)} + isLoading={ + addToWatchlist.isPending || removeFromWatchlist.isPending + } + /> + {index < sortedWatchlists.length - 1 && ( + + )} + + ))} + + )} + + {/* Create New Button */} + + + + {t("watchlists.create_new")} + + + + ); +}; + +export const WatchlistSheet = forwardRef( + (_props, ref) => { + const bottomSheetModalRef = useRef(null); + const [currentItem, setCurrentItem] = React.useState( + null, + ); + const insets = useSafeAreaInsets(); + + useImperativeHandle(ref, () => ({ + open: (item: BaseItemDto) => { + setCurrentItem(item); + bottomSheetModalRef.current?.present(); + }, + close: () => { + bottomSheetModalRef.current?.dismiss(); + }, + })); + + const handleClose = useCallback(() => { + bottomSheetModalRef.current?.dismiss(); + }, []); + + const renderBackdrop = useCallback( + (props: BottomSheetBackdropProps) => ( + + ), + [], + ); + + return ( + + + {currentItem && ( + + )} + + + ); + }, +); diff --git a/constants/Languages.ts b/constants/Languages.ts deleted file mode 100644 index 8014e3800..000000000 --- a/constants/Languages.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { DefaultLanguageOption } from "@/utils/atoms/settings"; - -export const LANGUAGES: DefaultLanguageOption[] = [ - { label: "English", value: "eng" }, - { label: "Spanish", value: "spa" }, - { label: "Chinese (Mandarin)", value: "cmn" }, - { label: "Hindi", value: "hin" }, - { label: "Arabic", value: "ara" }, - { label: "French", value: "fra" }, - { label: "Russian", value: "rus" }, - { label: "Portuguese", value: "por" }, - { label: "Japanese", value: "jpn" }, - { label: "German", value: "deu" }, - { label: "Italian", value: "ita" }, - { label: "Korean", value: "kor" }, - { label: "Turkish", value: "tur" }, - { label: "Dutch", value: "nld" }, - { label: "Polish", value: "pol" }, - { label: "Vietnamese", value: "vie" }, - { label: "Thai", value: "tha" }, - { label: "Indonesian", value: "ind" }, - { label: "Greek", value: "ell" }, - { label: "Swedish", value: "swe" }, - { label: "Danish", value: "dan" }, - { label: "Norwegian", value: "nor" }, - { label: "Finnish", value: "fin" }, - { label: "Czech", value: "ces" }, - { label: "Hungarian", value: "hun" }, - { label: "Romanian", value: "ron" }, - { label: "Ukrainian", value: "ukr" }, - { label: "Hebrew", value: "heb" }, - { label: "Bengali", value: "ben" }, - { label: "Punjabi", value: "pan" }, - { label: "Tagalog", value: "tgl" }, - { label: "Swahili", value: "swa" }, - { label: "Malay", value: "msa" }, - { label: "Persian", value: "fas" }, - { label: "Urdu", value: "urd" }, -]; diff --git a/constants/MediaTypes.js b/constants/MediaTypes.ts similarity index 67% rename from constants/MediaTypes.js rename to constants/MediaTypes.ts index 2cf275cc2..77d08834a 100644 --- a/constants/MediaTypes.js +++ b/constants/MediaTypes.ts @@ -3,9 +3,13 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -export default { +const MediaTypes = { Audio: "Audio", Video: "Video", Photo: "Photo", Book: "Book", -}; +} as const; + +export type MediaType = (typeof MediaTypes)[keyof typeof MediaTypes]; + +export default MediaTypes; diff --git a/constants/SubtitleConstants.ts b/constants/SubtitleConstants.ts deleted file mode 100644 index 7fc7a8e65..000000000 --- a/constants/SubtitleConstants.ts +++ /dev/null @@ -1,45 +0,0 @@ -export type VLCColor = - | "Black" - | "Gray" - | "Silver" - | "White" - | "Maroon" - | "Red" - | "Fuchsia" - | "Yellow" - | "Olive" - | "Green" - | "Teal" - | "Lime" - | "Purple" - | "Navy" - | "Blue" - | "Aqua"; - -export type OutlineThickness = "None" | "Thin" | "Normal" | "Thick"; - -export const VLC_COLORS: Record = { - Black: 0, - Gray: 8421504, - Silver: 12632256, - White: 16777215, - Maroon: 8388608, - Red: 16711680, - Fuchsia: 16711935, - Yellow: 16776960, - Olive: 8421376, - Green: 32768, - Teal: 32896, - Lime: 65280, - Purple: 8388736, - Navy: 128, - Blue: 255, - Aqua: 65535, -}; - -export const OUTLINE_THICKNESS: Record = { - None: 0, - Thin: 2, - Normal: 4, - Thick: 6, -}; diff --git a/constants/TVPosterSizes.ts b/constants/TVPosterSizes.ts new file mode 100644 index 000000000..132b75a66 --- /dev/null +++ b/constants/TVPosterSizes.ts @@ -0,0 +1,12 @@ +/** + * @deprecated Import from "@/constants/TVSizes" instead. + * This file is kept for backwards compatibility. + */ + +export { + type ScaledTVPosterSizes, + TVPosterSizes, + useScaledTVPosterSizes, +} from "./TVSizes"; + +export type TVPosterSizeKey = keyof typeof import("./TVSizes").TVPosterSizes; diff --git a/constants/TVSizes.ts b/constants/TVSizes.ts new file mode 100644 index 000000000..117609021 --- /dev/null +++ b/constants/TVSizes.ts @@ -0,0 +1,179 @@ +import { TVTypographyScale, useSettings } from "@/utils/atoms/settings"; +import { scaleSize } from "@/utils/scaleSize"; + +/** + * TV Layout Sizes + * + * Unified constants for TV interface layout including posters, gaps, and padding. + * Base values are designed for 1920x1080 and scaled to the actual viewport via + * scaleSize(), then further adjusted by the user's tvTypographyScale setting. + */ + +// ============================================================================= +// BASE VALUES (at Default scale) +// ============================================================================= + +/** + * Base poster widths in pixels. + * Heights are calculated from aspect ratios. + */ +export const TVPosterSizes = { + /** Portrait posters (movies, series) - 10:15 aspect ratio */ + poster: 300, + + /** Landscape posters (continue watching, thumbs, hero) - 16:9 aspect ratio */ + landscape: 470, + + /** Episode cards - 16:9 aspect ratio */ + episode: 440, +} as const; + +/** + * Base gap/spacing values in pixels. + */ +export const TVGaps = { + /** Gap between items in horizontal lists */ + item: 24, + + /** Gap between sections vertically */ + section: 32, + + /** Small gap for tight layouts */ + small: 12, + + /** Large gap for spacious layouts */ + large: 48, +} as const; + +/** + * Base padding values in pixels. + */ +export const TVPadding = { + /** Horizontal padding from screen edges (static — matches native search inset) */ + horizontal: 80, + + /** Padding to accommodate scale animations (1.05x) */ + scale: 20, + + /** Vertical padding for content areas */ + vertical: 24, + + /** Hero section height as percentage of screen height (0.0 - 1.0) */ + heroHeight: 0.6, +} as const; + +/** + * Animation and interaction values. + */ +export const TVAnimation = { + /** Scale factor for focused items */ + focusScale: 1.05, +} as const; + +// ============================================================================= +// SCALING +// ============================================================================= + +/** + * Scale multipliers for each typography scale level. + * Applied to poster sizes and gaps. + */ +const sizeScaleMultipliers: Record = { + [TVTypographyScale.Small]: 0.53, + [TVTypographyScale.Default]: 0.63, + [TVTypographyScale.Large]: 0.77, + [TVTypographyScale.ExtraLarge]: 0.84, +}; + +// ============================================================================= +// HOOKS +// ============================================================================= + +export type ScaledTVPosterSizes = { + poster: number; + landscape: number; + episode: number; +}; + +export type ScaledTVGaps = { + item: number; + section: number; + small: number; + large: number; +}; + +export type ScaledTVPadding = { + horizontal: number; + scale: number; + vertical: number; + heroHeight: number; +}; + +export type ScaledTVSizes = { + posters: ScaledTVPosterSizes; + gaps: ScaledTVGaps; + padding: ScaledTVPadding; + animation: typeof TVAnimation; +}; + +/** + * Hook that returns all scaled TV sizes based on user settings. + * + * @example + * const sizes = useScaledTVSizes(); + * + */ +export const useScaledTVSizes = (): ScaledTVSizes => { + const { settings } = useSettings(); + const scale = + sizeScaleMultipliers[settings.tvTypographyScale] ?? + sizeScaleMultipliers[TVTypographyScale.Default]; + + return { + posters: { + poster: Math.round(scaleSize(TVPosterSizes.poster) * scale), + landscape: Math.round(scaleSize(TVPosterSizes.landscape) * scale), + episode: Math.round(scaleSize(TVPosterSizes.episode) * scale), + }, + gaps: { + item: Math.round(scaleSize(TVGaps.item) * scale), + section: Math.round(scaleSize(TVGaps.section) * scale), + small: Math.round(scaleSize(TVGaps.small) * scale), + large: Math.round(scaleSize(TVGaps.large) * scale), + }, + padding: { + // Static: matches the native tvOS search bar inset, which is a fixed + // point value and does not change with the typography scale setting. + horizontal: TVPadding.horizontal, + scale: Math.round(scaleSize(TVPadding.scale) * scale), + vertical: Math.round(scaleSize(TVPadding.vertical) * scale), + heroHeight: TVPadding.heroHeight * scale, + }, + animation: TVAnimation, + }; +}; + +/** + * Hook that returns only scaled poster sizes. + * Use this for backwards compatibility or when you only need poster sizes. + */ +export const useScaledTVPosterSizes = (): ScaledTVPosterSizes => { + const sizes = useScaledTVSizes(); + return sizes.posters; +}; + +/** + * Hook that returns only scaled gap sizes. + */ +export const useScaledTVGaps = (): ScaledTVGaps => { + const sizes = useScaledTVSizes(); + return sizes.gaps; +}; + +/** + * Hook that returns only scaled padding sizes. + */ +export const useScaledTVPadding = (): ScaledTVPadding => { + const sizes = useScaledTVSizes(); + return sizes.padding; +}; diff --git a/constants/TVTypography.ts b/constants/TVTypography.ts new file mode 100644 index 000000000..cbac9b693 --- /dev/null +++ b/constants/TVTypography.ts @@ -0,0 +1,75 @@ +import { TVTypographyScale, useSettings } from "@/utils/atoms/settings"; +import { scaleSize } from "@/utils/scaleSize"; + +/** + * TV Typography Scale + * + * Consistent text sizes for TV interface components. + * Base values are designed for 1920x1080 and scaled to the actual viewport via + * scaleSize(), then further adjusted by the user's tvTypographyScale setting. + */ + +// ============================================================================= +// BASE VALUES (at Default scale) +// ============================================================================= + +export const TVTypography = { + /** Hero titles, movie/show names */ + display: 70, + + /** Episode series name, major headings */ + title: 42, + + /** Section headers (Cast, Technical Details, From this Series) */ + heading: 32, + + /** Overview, actor names, card titles, metadata */ + body: 40, + + /** Secondary text, labels, subtitles */ + callout: 26, +}; + +export type TVTypographyKey = keyof typeof TVTypography; + +// ============================================================================= +// SCALING +// ============================================================================= + +const scaleMultipliers: Record = { + [TVTypographyScale.Small]: 0.6, + [TVTypographyScale.Default]: 0.7, + [TVTypographyScale.Large]: 0.84, + [TVTypographyScale.ExtraLarge]: 0.98, +}; + +// ============================================================================= +// HOOKS +// ============================================================================= + +export type ScaledTVTypography = { + display: number; + title: number; + heading: number; + body: number; + callout: number; +}; + +/** + * Hook that returns scaled TV typography values based on user settings. + * Use this instead of the static TVTypography constant for dynamic scaling. + */ +export const useScaledTVTypography = (): ScaledTVTypography => { + const { settings } = useSettings(); + const scale = + scaleMultipliers[settings.tvTypographyScale] ?? + scaleMultipliers[TVTypographyScale.Default]; + + return { + display: Math.round(scaleSize(TVTypography.display) * scale), + title: Math.round(scaleSize(TVTypography.title) * scale), + heading: Math.round(scaleSize(TVTypography.heading) * scale), + body: Math.round(scaleSize(TVTypography.body) * scale), + callout: Math.round(scaleSize(TVTypography.callout) * scale), + }; +}; diff --git a/constants/Values.ts b/constants/Values.ts index 4c3e4d81c..5029d8520 100644 --- a/constants/Values.ts +++ b/constants/Values.ts @@ -1,3 +1,6 @@ import { Platform } from "react-native"; export const TAB_HEIGHT = Platform.OS === "android" ? 58 : 74; + +// Matches `w-28` poster cards (approx 112px wide, 10/15 aspect ratio) + 2 lines of text. +export const POSTER_CAROUSEL_HEIGHT = 220; diff --git a/crowdin.yml b/crowdin.yml index 38b86bcd3..b5d279682 100644 --- a/crowdin.yml +++ b/crowdin.yml @@ -1,12 +1,10 @@ -"project_id_env": "CROWDIN_PROJECT_ID" -"api_token_env": "CROWDIN_PERSONAL_TOKEN" -"base_path": "." - -"preserve_hierarchy": true - -"files": [ - { - "source": "translations/en.json", - "translation": "translations/%two_letters_code%.json" - } -] \ No newline at end of file +project_id_env: CROWDIN_PROJECT_ID +api_token_env: CROWDIN_PERSONAL_TOKEN +base_path: . +preserve_hierarchy: 1 +files: + - source: translations/en.json + translation: translations/%two_letters_code%.json + skip_untranslated_strings: false + skip_untranslated_files: false + export_only_approved: false diff --git a/docs/jellyfin-openapi-stable.json b/docs/jellyfin-openapi-stable.json new file mode 100644 index 000000000..728a442b3 --- /dev/null +++ b/docs/jellyfin-openapi-stable.json @@ -0,0 +1,69435 @@ +{ + "openapi": "3.0.1", + "info": { + "title": "Jellyfin API", + "version": "10.11.5", + "x-jellyfin-version": "10.11.5" + }, + "servers": [ + { + "url": "http://localhost" + } + ], + "paths": { + "/System/ActivityLog/Entries": { + "get": { + "tags": [ + "ActivityLog" + ], + "summary": "Gets activity log entries.", + "operationId": "GetLogEntries", + "parameters": [ + { + "name": "startIndex", + "in": "query", + "description": "Optional. The record index to start at. All items with a lower index will be dropped from the results.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "limit", + "in": "query", + "description": "Optional. The maximum number of records to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "minDate", + "in": "query", + "description": "Optional. The minimum date. Format = ISO.", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "hasUserId", + "in": "query", + "description": "Optional. Filter log entries if it has user id, or not.", + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "description": "Activity log returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActivityLogEntryQueryResult" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ActivityLogEntryQueryResult" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ActivityLogEntryQueryResult" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "RequiresElevation" + ] + } + ] + } + }, + "/Auth/Keys": { + "get": { + "tags": [ + "ApiKey" + ], + "summary": "Get all keys.", + "operationId": "GetKeys", + "responses": { + "200": { + "description": "Api keys retrieved.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthenticationInfoQueryResult" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/AuthenticationInfoQueryResult" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/AuthenticationInfoQueryResult" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "RequiresElevation" + ] + } + ] + }, + "post": { + "tags": [ + "ApiKey" + ], + "summary": "Create a new api key.", + "operationId": "CreateKey", + "parameters": [ + { + "name": "app", + "in": "query", + "description": "Name of the app using the authentication key.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Api key created." + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "RequiresElevation" + ] + } + ] + } + }, + "/Auth/Keys/{key}": { + "delete": { + "tags": [ + "ApiKey" + ], + "summary": "Remove an api key.", + "operationId": "RevokeKey", + "parameters": [ + { + "name": "key", + "in": "path", + "description": "The access token to delete.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Api key deleted." + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "RequiresElevation" + ] + } + ] + } + }, + "/Artists": { + "get": { + "tags": [ + "Artists" + ], + "summary": "Gets all artists from a given item, folder, or the entire library.", + "operationId": "GetArtists", + "parameters": [ + { + "name": "minCommunityRating", + "in": "query", + "description": "Optional filter by minimum community rating.", + "schema": { + "type": "number", + "format": "double" + } + }, + { + "name": "startIndex", + "in": "query", + "description": "Optional. The record index to start at. All items with a lower index will be dropped from the results.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "limit", + "in": "query", + "description": "Optional. The maximum number of records to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "searchTerm", + "in": "query", + "description": "Optional. Search term.", + "schema": { + "type": "string" + } + }, + { + "name": "parentId", + "in": "query", + "description": "Specify this to localize the search to a specific item or folder. Omit to use the root.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "fields", + "in": "query", + "description": "Optional. Specify additional fields of information to return in the output.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ItemFields" + } + } + }, + { + "name": "excludeItemTypes", + "in": "query", + "description": "Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BaseItemKind" + } + } + }, + { + "name": "includeItemTypes", + "in": "query", + "description": "Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BaseItemKind" + } + } + }, + { + "name": "filters", + "in": "query", + "description": "Optional. Specify additional filters to apply.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ItemFilter" + } + } + }, + { + "name": "isFavorite", + "in": "query", + "description": "Optional filter by items that are marked as favorite, or not.", + "schema": { + "type": "boolean" + } + }, + { + "name": "mediaTypes", + "in": "query", + "description": "Optional filter by MediaType. Allows multiple, comma delimited.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MediaType" + } + } + }, + { + "name": "genres", + "in": "query", + "description": "Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited.", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "name": "genreIds", + "in": "query", + "description": "Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited.", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + }, + { + "name": "officialRatings", + "in": "query", + "description": "Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited.", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "name": "tags", + "in": "query", + "description": "Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited.", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "name": "years", + "in": "query", + "description": "Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited.", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + } + }, + { + "name": "enableUserData", + "in": "query", + "description": "Optional, include user data.", + "schema": { + "type": "boolean" + } + }, + { + "name": "imageTypeLimit", + "in": "query", + "description": "Optional, the max number of images to return, per image type.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "enableImageTypes", + "in": "query", + "description": "Optional. The image types to include in the output.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ImageType" + } + } + }, + { + "name": "person", + "in": "query", + "description": "Optional. If specified, results will be filtered to include only those containing the specified person.", + "schema": { + "type": "string" + } + }, + { + "name": "personIds", + "in": "query", + "description": "Optional. If specified, results will be filtered to include only those containing the specified person ids.", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + }, + { + "name": "personTypes", + "in": "query", + "description": "Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "name": "studios", + "in": "query", + "description": "Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited.", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "name": "studioIds", + "in": "query", + "description": "Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited.", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + }, + { + "name": "userId", + "in": "query", + "description": "User id.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "nameStartsWithOrGreater", + "in": "query", + "description": "Optional filter by items whose name is sorted equally or greater than a given input string.", + "schema": { + "type": "string" + } + }, + { + "name": "nameStartsWith", + "in": "query", + "description": "Optional filter by items whose name is sorted equally than a given input string.", + "schema": { + "type": "string" + } + }, + { + "name": "nameLessThan", + "in": "query", + "description": "Optional filter by items whose name is equally or lesser than a given input string.", + "schema": { + "type": "string" + } + }, + { + "name": "sortBy", + "in": "query", + "description": "Optional. Specify one or more sort orders, comma delimited.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ItemSortBy" + } + } + }, + { + "name": "sortOrder", + "in": "query", + "description": "Sort Order - Ascending,Descending.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SortOrder" + } + } + }, + { + "name": "enableImages", + "in": "query", + "description": "Optional, include image information in output.", + "schema": { + "type": "boolean", + "default": true + } + }, + { + "name": "enableTotalRecordCount", + "in": "query", + "description": "Total record count.", + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Artists returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Artists/{name}": { + "get": { + "tags": [ + "Artists" + ], + "summary": "Gets an artist by name.", + "operationId": "GetArtistByName", + "parameters": [ + { + "name": "name", + "in": "path", + "description": "Studio name.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "userId", + "in": "query", + "description": "Optional. Filter by user id, and attach user data.", + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Artist returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BaseItemDto" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDto" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDto" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Artists/AlbumArtists": { + "get": { + "tags": [ + "Artists" + ], + "summary": "Gets all album artists from a given item, folder, or the entire library.", + "operationId": "GetAlbumArtists", + "parameters": [ + { + "name": "minCommunityRating", + "in": "query", + "description": "Optional filter by minimum community rating.", + "schema": { + "type": "number", + "format": "double" + } + }, + { + "name": "startIndex", + "in": "query", + "description": "Optional. The record index to start at. All items with a lower index will be dropped from the results.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "limit", + "in": "query", + "description": "Optional. The maximum number of records to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "searchTerm", + "in": "query", + "description": "Optional. Search term.", + "schema": { + "type": "string" + } + }, + { + "name": "parentId", + "in": "query", + "description": "Specify this to localize the search to a specific item or folder. Omit to use the root.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "fields", + "in": "query", + "description": "Optional. Specify additional fields of information to return in the output.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ItemFields" + } + } + }, + { + "name": "excludeItemTypes", + "in": "query", + "description": "Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BaseItemKind" + } + } + }, + { + "name": "includeItemTypes", + "in": "query", + "description": "Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BaseItemKind" + } + } + }, + { + "name": "filters", + "in": "query", + "description": "Optional. Specify additional filters to apply.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ItemFilter" + } + } + }, + { + "name": "isFavorite", + "in": "query", + "description": "Optional filter by items that are marked as favorite, or not.", + "schema": { + "type": "boolean" + } + }, + { + "name": "mediaTypes", + "in": "query", + "description": "Optional filter by MediaType. Allows multiple, comma delimited.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MediaType" + } + } + }, + { + "name": "genres", + "in": "query", + "description": "Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited.", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "name": "genreIds", + "in": "query", + "description": "Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited.", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + }, + { + "name": "officialRatings", + "in": "query", + "description": "Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited.", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "name": "tags", + "in": "query", + "description": "Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited.", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "name": "years", + "in": "query", + "description": "Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited.", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + } + }, + { + "name": "enableUserData", + "in": "query", + "description": "Optional, include user data.", + "schema": { + "type": "boolean" + } + }, + { + "name": "imageTypeLimit", + "in": "query", + "description": "Optional, the max number of images to return, per image type.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "enableImageTypes", + "in": "query", + "description": "Optional. The image types to include in the output.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ImageType" + } + } + }, + { + "name": "person", + "in": "query", + "description": "Optional. If specified, results will be filtered to include only those containing the specified person.", + "schema": { + "type": "string" + } + }, + { + "name": "personIds", + "in": "query", + "description": "Optional. If specified, results will be filtered to include only those containing the specified person ids.", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + }, + { + "name": "personTypes", + "in": "query", + "description": "Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "name": "studios", + "in": "query", + "description": "Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited.", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "name": "studioIds", + "in": "query", + "description": "Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited.", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + }, + { + "name": "userId", + "in": "query", + "description": "User id.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "nameStartsWithOrGreater", + "in": "query", + "description": "Optional filter by items whose name is sorted equally or greater than a given input string.", + "schema": { + "type": "string" + } + }, + { + "name": "nameStartsWith", + "in": "query", + "description": "Optional filter by items whose name is sorted equally than a given input string.", + "schema": { + "type": "string" + } + }, + { + "name": "nameLessThan", + "in": "query", + "description": "Optional filter by items whose name is equally or lesser than a given input string.", + "schema": { + "type": "string" + } + }, + { + "name": "sortBy", + "in": "query", + "description": "Optional. Specify one or more sort orders, comma delimited.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ItemSortBy" + } + } + }, + { + "name": "sortOrder", + "in": "query", + "description": "Sort Order - Ascending,Descending.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SortOrder" + } + } + }, + { + "name": "enableImages", + "in": "query", + "description": "Optional, include image information in output.", + "schema": { + "type": "boolean", + "default": true + } + }, + { + "name": "enableTotalRecordCount", + "in": "query", + "description": "Total record count.", + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Album artists returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Audio/{itemId}/stream": { + "get": { + "tags": [ + "Audio" + ], + "summary": "Gets an audio stream.", + "operationId": "GetAudioStream", + "parameters": [ + { + "name": "itemId", + "in": "path", + "description": "The item id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "container", + "in": "query", + "description": "The audio container.", + "schema": { + "pattern": "^[a-zA-Z0-9\\-\\._,|]{0,40}$", + "type": "string" + } + }, + { + "name": "static", + "in": "query", + "description": "Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.", + "schema": { + "type": "boolean" + } + }, + { + "name": "params", + "in": "query", + "description": "The streaming parameters.", + "schema": { + "type": "string" + } + }, + { + "name": "tag", + "in": "query", + "description": "The tag.", + "schema": { + "type": "string" + } + }, + { + "name": "deviceProfileId", + "in": "query", + "description": "Optional. The dlna device profile id to utilize.", + "deprecated": true, + "schema": { + "type": "string" + } + }, + { + "name": "playSessionId", + "in": "query", + "description": "The play session id.", + "schema": { + "type": "string" + } + }, + { + "name": "segmentContainer", + "in": "query", + "description": "The segment container.", + "schema": { + "pattern": "^[a-zA-Z0-9\\-\\._,|]{0,40}$", + "type": "string" + } + }, + { + "name": "segmentLength", + "in": "query", + "description": "The segment length.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "minSegments", + "in": "query", + "description": "The minimum number of segments.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "mediaSourceId", + "in": "query", + "description": "The media version id, if playing an alternate version.", + "schema": { + "type": "string" + } + }, + { + "name": "deviceId", + "in": "query", + "description": "The device id of the client requesting. Used to stop encoding processes when needed.", + "schema": { + "type": "string" + } + }, + { + "name": "audioCodec", + "in": "query", + "description": "Optional. Specify an audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension.", + "schema": { + "pattern": "^[a-zA-Z0-9\\-\\._,|]{0,40}$", + "type": "string" + } + }, + { + "name": "enableAutoStreamCopy", + "in": "query", + "description": "Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.", + "schema": { + "type": "boolean" + } + }, + { + "name": "allowVideoStreamCopy", + "in": "query", + "description": "Whether or not to allow copying of the video stream url.", + "schema": { + "type": "boolean" + } + }, + { + "name": "allowAudioStreamCopy", + "in": "query", + "description": "Whether or not to allow copying of the audio stream url.", + "schema": { + "type": "boolean" + } + }, + { + "name": "breakOnNonKeyFrames", + "in": "query", + "description": "Optional. Whether to break on non key frames.", + "schema": { + "type": "boolean" + } + }, + { + "name": "audioSampleRate", + "in": "query", + "description": "Optional. Specify a specific audio sample rate, e.g. 44100.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxAudioBitDepth", + "in": "query", + "description": "Optional. The maximum audio bit depth.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "audioBitRate", + "in": "query", + "description": "Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "audioChannels", + "in": "query", + "description": "Optional. Specify a specific number of audio channels to encode to, e.g. 2.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxAudioChannels", + "in": "query", + "description": "Optional. Specify a maximum number of audio channels to encode to, e.g. 2.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "profile", + "in": "query", + "description": "Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.", + "schema": { + "type": "string" + } + }, + { + "name": "level", + "in": "query", + "description": "Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.", + "schema": { + "pattern": "-?[0-9]+(?:\\.[0-9]+)?", + "type": "string" + } + }, + { + "name": "framerate", + "in": "query", + "description": "Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.", + "schema": { + "type": "number", + "format": "float" + } + }, + { + "name": "maxFramerate", + "in": "query", + "description": "Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.", + "schema": { + "type": "number", + "format": "float" + } + }, + { + "name": "copyTimestamps", + "in": "query", + "description": "Whether or not to copy timestamps when transcoding with an offset. Defaults to false.", + "schema": { + "type": "boolean" + } + }, + { + "name": "startTimeTicks", + "in": "query", + "description": "Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.", + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "width", + "in": "query", + "description": "Optional. The fixed horizontal resolution of the encoded video.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "height", + "in": "query", + "description": "Optional. The fixed vertical resolution of the encoded video.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "videoBitRate", + "in": "query", + "description": "Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "subtitleStreamIndex", + "in": "query", + "description": "Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "subtitleMethod", + "in": "query", + "description": "Optional. Specify the subtitle delivery method.", + "schema": { + "enum": [ + "Encode", + "Embed", + "External", + "Hls", + "Drop" + ], + "allOf": [ + { + "$ref": "#/components/schemas/SubtitleDeliveryMethod" + } + ] + } + }, + { + "name": "maxRefFrames", + "in": "query", + "description": "Optional.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxVideoBitDepth", + "in": "query", + "description": "Optional. The maximum video bit depth.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "requireAvc", + "in": "query", + "description": "Optional. Whether to require avc.", + "schema": { + "type": "boolean" + } + }, + { + "name": "deInterlace", + "in": "query", + "description": "Optional. Whether to deinterlace the video.", + "schema": { + "type": "boolean" + } + }, + { + "name": "requireNonAnamorphic", + "in": "query", + "description": "Optional. Whether to require a non anamorphic stream.", + "schema": { + "type": "boolean" + } + }, + { + "name": "transcodingMaxAudioChannels", + "in": "query", + "description": "Optional. The maximum number of audio channels to transcode.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "cpuCoreLimit", + "in": "query", + "description": "Optional. The limit of how many cpu cores to use.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "liveStreamId", + "in": "query", + "description": "The live stream id.", + "schema": { + "type": "string" + } + }, + { + "name": "enableMpegtsM2TsMode", + "in": "query", + "description": "Optional. Whether to enable the MpegtsM2Ts mode.", + "schema": { + "type": "boolean" + } + }, + { + "name": "videoCodec", + "in": "query", + "description": "Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension.", + "schema": { + "pattern": "^[a-zA-Z0-9\\-\\._,|]{0,40}$", + "type": "string" + } + }, + { + "name": "subtitleCodec", + "in": "query", + "description": "Optional. Specify a subtitle codec to encode to.", + "schema": { + "pattern": "^[a-zA-Z0-9\\-\\._,|]{0,40}$", + "type": "string" + } + }, + { + "name": "transcodeReasons", + "in": "query", + "description": "Optional. The transcoding reason.", + "schema": { + "type": "string" + } + }, + { + "name": "audioStreamIndex", + "in": "query", + "description": "Optional. The index of the audio stream to use. If omitted the first audio stream will be used.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "videoStreamIndex", + "in": "query", + "description": "Optional. The index of the video stream to use. If omitted the first video stream will be used.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "context", + "in": "query", + "description": "Optional. The MediaBrowser.Model.Dlna.EncodingContext.", + "schema": { + "enum": [ + "Streaming", + "Static" + ], + "allOf": [ + { + "$ref": "#/components/schemas/EncodingContext" + } + ] + } + }, + { + "name": "streamOptions", + "in": "query", + "description": "Optional. The streaming options.", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string", + "nullable": true + } + } + }, + { + "name": "enableAudioVbrEncoding", + "in": "query", + "description": "Optional. Whether to enable Audio Encoding.", + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Audio stream returned.", + "content": { + "audio/*": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + } + } + }, + "head": { + "tags": [ + "Audio" + ], + "summary": "Gets an audio stream.", + "operationId": "HeadAudioStream", + "parameters": [ + { + "name": "itemId", + "in": "path", + "description": "The item id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "container", + "in": "query", + "description": "The audio container.", + "schema": { + "pattern": "^[a-zA-Z0-9\\-\\._,|]{0,40}$", + "type": "string" + } + }, + { + "name": "static", + "in": "query", + "description": "Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.", + "schema": { + "type": "boolean" + } + }, + { + "name": "params", + "in": "query", + "description": "The streaming parameters.", + "schema": { + "type": "string" + } + }, + { + "name": "tag", + "in": "query", + "description": "The tag.", + "schema": { + "type": "string" + } + }, + { + "name": "deviceProfileId", + "in": "query", + "description": "Optional. The dlna device profile id to utilize.", + "deprecated": true, + "schema": { + "type": "string" + } + }, + { + "name": "playSessionId", + "in": "query", + "description": "The play session id.", + "schema": { + "type": "string" + } + }, + { + "name": "segmentContainer", + "in": "query", + "description": "The segment container.", + "schema": { + "pattern": "^[a-zA-Z0-9\\-\\._,|]{0,40}$", + "type": "string" + } + }, + { + "name": "segmentLength", + "in": "query", + "description": "The segment length.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "minSegments", + "in": "query", + "description": "The minimum number of segments.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "mediaSourceId", + "in": "query", + "description": "The media version id, if playing an alternate version.", + "schema": { + "type": "string" + } + }, + { + "name": "deviceId", + "in": "query", + "description": "The device id of the client requesting. Used to stop encoding processes when needed.", + "schema": { + "type": "string" + } + }, + { + "name": "audioCodec", + "in": "query", + "description": "Optional. Specify an audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension.", + "schema": { + "pattern": "^[a-zA-Z0-9\\-\\._,|]{0,40}$", + "type": "string" + } + }, + { + "name": "enableAutoStreamCopy", + "in": "query", + "description": "Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.", + "schema": { + "type": "boolean" + } + }, + { + "name": "allowVideoStreamCopy", + "in": "query", + "description": "Whether or not to allow copying of the video stream url.", + "schema": { + "type": "boolean" + } + }, + { + "name": "allowAudioStreamCopy", + "in": "query", + "description": "Whether or not to allow copying of the audio stream url.", + "schema": { + "type": "boolean" + } + }, + { + "name": "breakOnNonKeyFrames", + "in": "query", + "description": "Optional. Whether to break on non key frames.", + "schema": { + "type": "boolean" + } + }, + { + "name": "audioSampleRate", + "in": "query", + "description": "Optional. Specify a specific audio sample rate, e.g. 44100.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxAudioBitDepth", + "in": "query", + "description": "Optional. The maximum audio bit depth.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "audioBitRate", + "in": "query", + "description": "Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "audioChannels", + "in": "query", + "description": "Optional. Specify a specific number of audio channels to encode to, e.g. 2.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxAudioChannels", + "in": "query", + "description": "Optional. Specify a maximum number of audio channels to encode to, e.g. 2.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "profile", + "in": "query", + "description": "Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.", + "schema": { + "type": "string" + } + }, + { + "name": "level", + "in": "query", + "description": "Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.", + "schema": { + "pattern": "-?[0-9]+(?:\\.[0-9]+)?", + "type": "string" + } + }, + { + "name": "framerate", + "in": "query", + "description": "Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.", + "schema": { + "type": "number", + "format": "float" + } + }, + { + "name": "maxFramerate", + "in": "query", + "description": "Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.", + "schema": { + "type": "number", + "format": "float" + } + }, + { + "name": "copyTimestamps", + "in": "query", + "description": "Whether or not to copy timestamps when transcoding with an offset. Defaults to false.", + "schema": { + "type": "boolean" + } + }, + { + "name": "startTimeTicks", + "in": "query", + "description": "Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.", + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "width", + "in": "query", + "description": "Optional. The fixed horizontal resolution of the encoded video.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "height", + "in": "query", + "description": "Optional. The fixed vertical resolution of the encoded video.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "videoBitRate", + "in": "query", + "description": "Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "subtitleStreamIndex", + "in": "query", + "description": "Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "subtitleMethod", + "in": "query", + "description": "Optional. Specify the subtitle delivery method.", + "schema": { + "enum": [ + "Encode", + "Embed", + "External", + "Hls", + "Drop" + ], + "allOf": [ + { + "$ref": "#/components/schemas/SubtitleDeliveryMethod" + } + ] + } + }, + { + "name": "maxRefFrames", + "in": "query", + "description": "Optional.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxVideoBitDepth", + "in": "query", + "description": "Optional. The maximum video bit depth.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "requireAvc", + "in": "query", + "description": "Optional. Whether to require avc.", + "schema": { + "type": "boolean" + } + }, + { + "name": "deInterlace", + "in": "query", + "description": "Optional. Whether to deinterlace the video.", + "schema": { + "type": "boolean" + } + }, + { + "name": "requireNonAnamorphic", + "in": "query", + "description": "Optional. Whether to require a non anamorphic stream.", + "schema": { + "type": "boolean" + } + }, + { + "name": "transcodingMaxAudioChannels", + "in": "query", + "description": "Optional. The maximum number of audio channels to transcode.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "cpuCoreLimit", + "in": "query", + "description": "Optional. The limit of how many cpu cores to use.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "liveStreamId", + "in": "query", + "description": "The live stream id.", + "schema": { + "type": "string" + } + }, + { + "name": "enableMpegtsM2TsMode", + "in": "query", + "description": "Optional. Whether to enable the MpegtsM2Ts mode.", + "schema": { + "type": "boolean" + } + }, + { + "name": "videoCodec", + "in": "query", + "description": "Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension.", + "schema": { + "pattern": "^[a-zA-Z0-9\\-\\._,|]{0,40}$", + "type": "string" + } + }, + { + "name": "subtitleCodec", + "in": "query", + "description": "Optional. Specify a subtitle codec to encode to.", + "schema": { + "pattern": "^[a-zA-Z0-9\\-\\._,|]{0,40}$", + "type": "string" + } + }, + { + "name": "transcodeReasons", + "in": "query", + "description": "Optional. The transcoding reason.", + "schema": { + "type": "string" + } + }, + { + "name": "audioStreamIndex", + "in": "query", + "description": "Optional. The index of the audio stream to use. If omitted the first audio stream will be used.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "videoStreamIndex", + "in": "query", + "description": "Optional. The index of the video stream to use. If omitted the first video stream will be used.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "context", + "in": "query", + "description": "Optional. The MediaBrowser.Model.Dlna.EncodingContext.", + "schema": { + "enum": [ + "Streaming", + "Static" + ], + "allOf": [ + { + "$ref": "#/components/schemas/EncodingContext" + } + ] + } + }, + { + "name": "streamOptions", + "in": "query", + "description": "Optional. The streaming options.", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string", + "nullable": true + } + } + }, + { + "name": "enableAudioVbrEncoding", + "in": "query", + "description": "Optional. Whether to enable Audio Encoding.", + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Audio stream returned.", + "content": { + "audio/*": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + } + } + } + }, + "/Audio/{itemId}/stream.{container}": { + "get": { + "tags": [ + "Audio" + ], + "summary": "Gets an audio stream.", + "operationId": "GetAudioStreamByContainer", + "parameters": [ + { + "name": "itemId", + "in": "path", + "description": "The item id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "container", + "in": "path", + "description": "The audio container.", + "required": true, + "schema": { + "pattern": "^[a-zA-Z0-9\\-\\._,|]{0,40}$", + "type": "string" + } + }, + { + "name": "static", + "in": "query", + "description": "Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.", + "schema": { + "type": "boolean" + } + }, + { + "name": "params", + "in": "query", + "description": "The streaming parameters.", + "schema": { + "type": "string" + } + }, + { + "name": "tag", + "in": "query", + "description": "The tag.", + "schema": { + "type": "string" + } + }, + { + "name": "deviceProfileId", + "in": "query", + "description": "Optional. The dlna device profile id to utilize.", + "deprecated": true, + "schema": { + "type": "string" + } + }, + { + "name": "playSessionId", + "in": "query", + "description": "The play session id.", + "schema": { + "type": "string" + } + }, + { + "name": "segmentContainer", + "in": "query", + "description": "The segment container.", + "schema": { + "pattern": "^[a-zA-Z0-9\\-\\._,|]{0,40}$", + "type": "string" + } + }, + { + "name": "segmentLength", + "in": "query", + "description": "The segment length.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "minSegments", + "in": "query", + "description": "The minimum number of segments.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "mediaSourceId", + "in": "query", + "description": "The media version id, if playing an alternate version.", + "schema": { + "type": "string" + } + }, + { + "name": "deviceId", + "in": "query", + "description": "The device id of the client requesting. Used to stop encoding processes when needed.", + "schema": { + "type": "string" + } + }, + { + "name": "audioCodec", + "in": "query", + "description": "Optional. Specify an audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension.", + "schema": { + "pattern": "^[a-zA-Z0-9\\-\\._,|]{0,40}$", + "type": "string" + } + }, + { + "name": "enableAutoStreamCopy", + "in": "query", + "description": "Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.", + "schema": { + "type": "boolean" + } + }, + { + "name": "allowVideoStreamCopy", + "in": "query", + "description": "Whether or not to allow copying of the video stream url.", + "schema": { + "type": "boolean" + } + }, + { + "name": "allowAudioStreamCopy", + "in": "query", + "description": "Whether or not to allow copying of the audio stream url.", + "schema": { + "type": "boolean" + } + }, + { + "name": "breakOnNonKeyFrames", + "in": "query", + "description": "Optional. Whether to break on non key frames.", + "schema": { + "type": "boolean" + } + }, + { + "name": "audioSampleRate", + "in": "query", + "description": "Optional. Specify a specific audio sample rate, e.g. 44100.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxAudioBitDepth", + "in": "query", + "description": "Optional. The maximum audio bit depth.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "audioBitRate", + "in": "query", + "description": "Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "audioChannels", + "in": "query", + "description": "Optional. Specify a specific number of audio channels to encode to, e.g. 2.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxAudioChannels", + "in": "query", + "description": "Optional. Specify a maximum number of audio channels to encode to, e.g. 2.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "profile", + "in": "query", + "description": "Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.", + "schema": { + "type": "string" + } + }, + { + "name": "level", + "in": "query", + "description": "Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.", + "schema": { + "pattern": "-?[0-9]+(?:\\.[0-9]+)?", + "type": "string" + } + }, + { + "name": "framerate", + "in": "query", + "description": "Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.", + "schema": { + "type": "number", + "format": "float" + } + }, + { + "name": "maxFramerate", + "in": "query", + "description": "Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.", + "schema": { + "type": "number", + "format": "float" + } + }, + { + "name": "copyTimestamps", + "in": "query", + "description": "Whether or not to copy timestamps when transcoding with an offset. Defaults to false.", + "schema": { + "type": "boolean" + } + }, + { + "name": "startTimeTicks", + "in": "query", + "description": "Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.", + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "width", + "in": "query", + "description": "Optional. The fixed horizontal resolution of the encoded video.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "height", + "in": "query", + "description": "Optional. The fixed vertical resolution of the encoded video.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "videoBitRate", + "in": "query", + "description": "Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "subtitleStreamIndex", + "in": "query", + "description": "Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "subtitleMethod", + "in": "query", + "description": "Optional. Specify the subtitle delivery method.", + "schema": { + "enum": [ + "Encode", + "Embed", + "External", + "Hls", + "Drop" + ], + "allOf": [ + { + "$ref": "#/components/schemas/SubtitleDeliveryMethod" + } + ] + } + }, + { + "name": "maxRefFrames", + "in": "query", + "description": "Optional.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxVideoBitDepth", + "in": "query", + "description": "Optional. The maximum video bit depth.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "requireAvc", + "in": "query", + "description": "Optional. Whether to require avc.", + "schema": { + "type": "boolean" + } + }, + { + "name": "deInterlace", + "in": "query", + "description": "Optional. Whether to deinterlace the video.", + "schema": { + "type": "boolean" + } + }, + { + "name": "requireNonAnamorphic", + "in": "query", + "description": "Optional. Whether to require a non anamorphic stream.", + "schema": { + "type": "boolean" + } + }, + { + "name": "transcodingMaxAudioChannels", + "in": "query", + "description": "Optional. The maximum number of audio channels to transcode.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "cpuCoreLimit", + "in": "query", + "description": "Optional. The limit of how many cpu cores to use.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "liveStreamId", + "in": "query", + "description": "The live stream id.", + "schema": { + "type": "string" + } + }, + { + "name": "enableMpegtsM2TsMode", + "in": "query", + "description": "Optional. Whether to enable the MpegtsM2Ts mode.", + "schema": { + "type": "boolean" + } + }, + { + "name": "videoCodec", + "in": "query", + "description": "Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension.", + "schema": { + "pattern": "^[a-zA-Z0-9\\-\\._,|]{0,40}$", + "type": "string" + } + }, + { + "name": "subtitleCodec", + "in": "query", + "description": "Optional. Specify a subtitle codec to encode to.", + "schema": { + "pattern": "^[a-zA-Z0-9\\-\\._,|]{0,40}$", + "type": "string" + } + }, + { + "name": "transcodeReasons", + "in": "query", + "description": "Optional. The transcoding reason.", + "schema": { + "type": "string" + } + }, + { + "name": "audioStreamIndex", + "in": "query", + "description": "Optional. The index of the audio stream to use. If omitted the first audio stream will be used.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "videoStreamIndex", + "in": "query", + "description": "Optional. The index of the video stream to use. If omitted the first video stream will be used.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "context", + "in": "query", + "description": "Optional. The MediaBrowser.Model.Dlna.EncodingContext.", + "schema": { + "enum": [ + "Streaming", + "Static" + ], + "allOf": [ + { + "$ref": "#/components/schemas/EncodingContext" + } + ] + } + }, + { + "name": "streamOptions", + "in": "query", + "description": "Optional. The streaming options.", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string", + "nullable": true + } + } + }, + { + "name": "enableAudioVbrEncoding", + "in": "query", + "description": "Optional. Whether to enable Audio Encoding.", + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Audio stream returned.", + "content": { + "audio/*": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + } + } + }, + "head": { + "tags": [ + "Audio" + ], + "summary": "Gets an audio stream.", + "operationId": "HeadAudioStreamByContainer", + "parameters": [ + { + "name": "itemId", + "in": "path", + "description": "The item id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "container", + "in": "path", + "description": "The audio container.", + "required": true, + "schema": { + "pattern": "^[a-zA-Z0-9\\-\\._,|]{0,40}$", + "type": "string" + } + }, + { + "name": "static", + "in": "query", + "description": "Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.", + "schema": { + "type": "boolean" + } + }, + { + "name": "params", + "in": "query", + "description": "The streaming parameters.", + "schema": { + "type": "string" + } + }, + { + "name": "tag", + "in": "query", + "description": "The tag.", + "schema": { + "type": "string" + } + }, + { + "name": "deviceProfileId", + "in": "query", + "description": "Optional. The dlna device profile id to utilize.", + "deprecated": true, + "schema": { + "type": "string" + } + }, + { + "name": "playSessionId", + "in": "query", + "description": "The play session id.", + "schema": { + "type": "string" + } + }, + { + "name": "segmentContainer", + "in": "query", + "description": "The segment container.", + "schema": { + "pattern": "^[a-zA-Z0-9\\-\\._,|]{0,40}$", + "type": "string" + } + }, + { + "name": "segmentLength", + "in": "query", + "description": "The segment length.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "minSegments", + "in": "query", + "description": "The minimum number of segments.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "mediaSourceId", + "in": "query", + "description": "The media version id, if playing an alternate version.", + "schema": { + "type": "string" + } + }, + { + "name": "deviceId", + "in": "query", + "description": "The device id of the client requesting. Used to stop encoding processes when needed.", + "schema": { + "type": "string" + } + }, + { + "name": "audioCodec", + "in": "query", + "description": "Optional. Specify an audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension.", + "schema": { + "pattern": "^[a-zA-Z0-9\\-\\._,|]{0,40}$", + "type": "string" + } + }, + { + "name": "enableAutoStreamCopy", + "in": "query", + "description": "Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.", + "schema": { + "type": "boolean" + } + }, + { + "name": "allowVideoStreamCopy", + "in": "query", + "description": "Whether or not to allow copying of the video stream url.", + "schema": { + "type": "boolean" + } + }, + { + "name": "allowAudioStreamCopy", + "in": "query", + "description": "Whether or not to allow copying of the audio stream url.", + "schema": { + "type": "boolean" + } + }, + { + "name": "breakOnNonKeyFrames", + "in": "query", + "description": "Optional. Whether to break on non key frames.", + "schema": { + "type": "boolean" + } + }, + { + "name": "audioSampleRate", + "in": "query", + "description": "Optional. Specify a specific audio sample rate, e.g. 44100.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxAudioBitDepth", + "in": "query", + "description": "Optional. The maximum audio bit depth.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "audioBitRate", + "in": "query", + "description": "Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "audioChannels", + "in": "query", + "description": "Optional. Specify a specific number of audio channels to encode to, e.g. 2.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxAudioChannels", + "in": "query", + "description": "Optional. Specify a maximum number of audio channels to encode to, e.g. 2.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "profile", + "in": "query", + "description": "Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.", + "schema": { + "type": "string" + } + }, + { + "name": "level", + "in": "query", + "description": "Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.", + "schema": { + "pattern": "-?[0-9]+(?:\\.[0-9]+)?", + "type": "string" + } + }, + { + "name": "framerate", + "in": "query", + "description": "Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.", + "schema": { + "type": "number", + "format": "float" + } + }, + { + "name": "maxFramerate", + "in": "query", + "description": "Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.", + "schema": { + "type": "number", + "format": "float" + } + }, + { + "name": "copyTimestamps", + "in": "query", + "description": "Whether or not to copy timestamps when transcoding with an offset. Defaults to false.", + "schema": { + "type": "boolean" + } + }, + { + "name": "startTimeTicks", + "in": "query", + "description": "Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.", + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "width", + "in": "query", + "description": "Optional. The fixed horizontal resolution of the encoded video.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "height", + "in": "query", + "description": "Optional. The fixed vertical resolution of the encoded video.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "videoBitRate", + "in": "query", + "description": "Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "subtitleStreamIndex", + "in": "query", + "description": "Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "subtitleMethod", + "in": "query", + "description": "Optional. Specify the subtitle delivery method.", + "schema": { + "enum": [ + "Encode", + "Embed", + "External", + "Hls", + "Drop" + ], + "allOf": [ + { + "$ref": "#/components/schemas/SubtitleDeliveryMethod" + } + ] + } + }, + { + "name": "maxRefFrames", + "in": "query", + "description": "Optional.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxVideoBitDepth", + "in": "query", + "description": "Optional. The maximum video bit depth.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "requireAvc", + "in": "query", + "description": "Optional. Whether to require avc.", + "schema": { + "type": "boolean" + } + }, + { + "name": "deInterlace", + "in": "query", + "description": "Optional. Whether to deinterlace the video.", + "schema": { + "type": "boolean" + } + }, + { + "name": "requireNonAnamorphic", + "in": "query", + "description": "Optional. Whether to require a non anamorphic stream.", + "schema": { + "type": "boolean" + } + }, + { + "name": "transcodingMaxAudioChannels", + "in": "query", + "description": "Optional. The maximum number of audio channels to transcode.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "cpuCoreLimit", + "in": "query", + "description": "Optional. The limit of how many cpu cores to use.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "liveStreamId", + "in": "query", + "description": "The live stream id.", + "schema": { + "type": "string" + } + }, + { + "name": "enableMpegtsM2TsMode", + "in": "query", + "description": "Optional. Whether to enable the MpegtsM2Ts mode.", + "schema": { + "type": "boolean" + } + }, + { + "name": "videoCodec", + "in": "query", + "description": "Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension.", + "schema": { + "pattern": "^[a-zA-Z0-9\\-\\._,|]{0,40}$", + "type": "string" + } + }, + { + "name": "subtitleCodec", + "in": "query", + "description": "Optional. Specify a subtitle codec to encode to.", + "schema": { + "pattern": "^[a-zA-Z0-9\\-\\._,|]{0,40}$", + "type": "string" + } + }, + { + "name": "transcodeReasons", + "in": "query", + "description": "Optional. The transcoding reason.", + "schema": { + "type": "string" + } + }, + { + "name": "audioStreamIndex", + "in": "query", + "description": "Optional. The index of the audio stream to use. If omitted the first audio stream will be used.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "videoStreamIndex", + "in": "query", + "description": "Optional. The index of the video stream to use. If omitted the first video stream will be used.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "context", + "in": "query", + "description": "Optional. The MediaBrowser.Model.Dlna.EncodingContext.", + "schema": { + "enum": [ + "Streaming", + "Static" + ], + "allOf": [ + { + "$ref": "#/components/schemas/EncodingContext" + } + ] + } + }, + { + "name": "streamOptions", + "in": "query", + "description": "Optional. The streaming options.", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string", + "nullable": true + } + } + }, + { + "name": "enableAudioVbrEncoding", + "in": "query", + "description": "Optional. Whether to enable Audio Encoding.", + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Audio stream returned.", + "content": { + "audio/*": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + } + } + } + }, + "/Backup": { + "get": { + "tags": [ + "Backup" + ], + "summary": "Gets a list of all currently present backups in the backup directory.", + "operationId": "ListBackups", + "responses": { + "200": { + "description": "Backups available.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BackupManifestDto" + } + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BackupManifestDto" + } + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BackupManifestDto" + } + } + } + } + }, + "403": { + "description": "User does not have permission to retrieve information.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "CustomAuthentication": [ + "RequiresElevation" + ] + } + ] + } + }, + "/Backup/Create": { + "post": { + "tags": [ + "Backup" + ], + "summary": "Creates a new Backup.", + "operationId": "CreateBackup", + "requestBody": { + "description": "The backup options.", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/BackupOptionsDto" + } + ], + "description": "Defines the optional contents of the backup archive." + } + }, + "text/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/BackupOptionsDto" + } + ], + "description": "Defines the optional contents of the backup archive." + } + }, + "application/*+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/BackupOptionsDto" + } + ], + "description": "Defines the optional contents of the backup archive." + } + } + } + }, + "responses": { + "200": { + "description": "Backup created.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackupManifestDto" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/BackupManifestDto" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/BackupManifestDto" + } + } + } + }, + "403": { + "description": "User does not have permission to retrieve information.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "CustomAuthentication": [ + "RequiresElevation" + ] + } + ] + } + }, + "/Backup/Manifest": { + "get": { + "tags": [ + "Backup" + ], + "summary": "Gets the descriptor from an existing archive is present.", + "operationId": "GetBackup", + "parameters": [ + { + "name": "path", + "in": "query", + "description": "The data to start a restore process.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Backup archive manifest.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackupManifestDto" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/BackupManifestDto" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/BackupManifestDto" + } + } + } + }, + "204": { + "description": "Not a valid jellyfin Archive." + }, + "404": { + "description": "Not a valid path.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "User does not have permission to retrieve information.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "CustomAuthentication": [ + "RequiresElevation" + ] + } + ] + } + }, + "/Backup/Restore": { + "post": { + "tags": [ + "Backup" + ], + "summary": "Restores to a backup by restarting the server and applying the backup.", + "operationId": "StartRestoreBackup", + "requestBody": { + "description": "The data to start a restore process.", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/BackupRestoreRequestDto" + } + ], + "description": "Defines properties used to start a restore process." + } + }, + "text/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/BackupRestoreRequestDto" + } + ], + "description": "Defines properties used to start a restore process." + } + }, + "application/*+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/BackupRestoreRequestDto" + } + ], + "description": "Defines properties used to start a restore process." + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Backup restore started." + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "User does not have permission to retrieve information.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "CustomAuthentication": [ + "RequiresElevation" + ] + } + ] + } + }, + "/Branding/Configuration": { + "get": { + "tags": [ + "Branding" + ], + "summary": "Gets branding configuration.", + "operationId": "GetBrandingOptions", + "responses": { + "200": { + "description": "Branding configuration returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BrandingOptionsDto" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/BrandingOptionsDto" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/BrandingOptionsDto" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + } + } + } + }, + "/Branding/Css": { + "get": { + "tags": [ + "Branding" + ], + "summary": "Gets branding css.", + "operationId": "GetBrandingCss", + "responses": { + "200": { + "description": "Branding css returned.", + "content": { + "text/css": { + "schema": { + "type": "string" + } + }, + "application/json": { + "schema": { + "type": "string" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "type": "string" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "type": "string" + } + } + } + }, + "204": { + "description": "No branding css configured." + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + } + } + } + }, + "/Branding/Css.css": { + "get": { + "tags": [ + "Branding" + ], + "summary": "Gets branding css.", + "operationId": "GetBrandingCss_2", + "responses": { + "200": { + "description": "Branding css returned.", + "content": { + "text/css": { + "schema": { + "type": "string" + } + }, + "application/json": { + "schema": { + "type": "string" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "type": "string" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "type": "string" + } + } + } + }, + "204": { + "description": "No branding css configured." + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + } + } + } + }, + "/Channels": { + "get": { + "tags": [ + "Channels" + ], + "summary": "Gets available channels.", + "operationId": "GetChannels", + "parameters": [ + { + "name": "userId", + "in": "query", + "description": "User Id to filter by. Use System.Guid.Empty to not filter by user.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "startIndex", + "in": "query", + "description": "Optional. The record index to start at. All items with a lower index will be dropped from the results.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "limit", + "in": "query", + "description": "Optional. The maximum number of records to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "supportsLatestItems", + "in": "query", + "description": "Optional. Filter by channels that support getting latest items.", + "schema": { + "type": "boolean" + } + }, + { + "name": "supportsMediaDeletion", + "in": "query", + "description": "Optional. Filter by channels that support media deletion.", + "schema": { + "type": "boolean" + } + }, + { + "name": "isFavorite", + "in": "query", + "description": "Optional. Filter by channels that are favorite.", + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "description": "Channels returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Channels/{channelId}/Features": { + "get": { + "tags": [ + "Channels" + ], + "summary": "Get channel features.", + "operationId": "GetChannelFeatures", + "parameters": [ + { + "name": "channelId", + "in": "path", + "description": "Channel id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Channel features returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChannelFeatures" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ChannelFeatures" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ChannelFeatures" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Channels/{channelId}/Items": { + "get": { + "tags": [ + "Channels" + ], + "summary": "Get channel items.", + "operationId": "GetChannelItems", + "parameters": [ + { + "name": "channelId", + "in": "path", + "description": "Channel Id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "folderId", + "in": "query", + "description": "Optional. Folder Id.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "userId", + "in": "query", + "description": "Optional. User Id.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "startIndex", + "in": "query", + "description": "Optional. The record index to start at. All items with a lower index will be dropped from the results.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "limit", + "in": "query", + "description": "Optional. The maximum number of records to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "sortOrder", + "in": "query", + "description": "Optional. Sort Order - Ascending,Descending.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SortOrder" + } + } + }, + { + "name": "filters", + "in": "query", + "description": "Optional. Specify additional filters to apply.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ItemFilter" + } + } + }, + { + "name": "sortBy", + "in": "query", + "description": "Optional. Specify one or more sort orders, comma delimited. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ItemSortBy" + } + } + }, + { + "name": "fields", + "in": "query", + "description": "Optional. Specify additional fields of information to return in the output.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ItemFields" + } + } + } + ], + "responses": { + "200": { + "description": "Channel items returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Channels/Features": { + "get": { + "tags": [ + "Channels" + ], + "summary": "Get all channel features.", + "operationId": "GetAllChannelFeatures", + "responses": { + "200": { + "description": "All channel features returned.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ChannelFeatures" + } + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ChannelFeatures" + } + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ChannelFeatures" + } + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Channels/Items/Latest": { + "get": { + "tags": [ + "Channels" + ], + "summary": "Gets latest channel items.", + "operationId": "GetLatestChannelItems", + "parameters": [ + { + "name": "userId", + "in": "query", + "description": "Optional. User Id.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "startIndex", + "in": "query", + "description": "Optional. The record index to start at. All items with a lower index will be dropped from the results.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "limit", + "in": "query", + "description": "Optional. The maximum number of records to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "filters", + "in": "query", + "description": "Optional. Specify additional filters to apply.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ItemFilter" + } + } + }, + { + "name": "fields", + "in": "query", + "description": "Optional. Specify additional fields of information to return in the output.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ItemFields" + } + } + }, + { + "name": "channelIds", + "in": "query", + "description": "Optional. Specify one or more channel id's, comma delimited.", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + } + ], + "responses": { + "200": { + "description": "Latest channel items returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/ClientLog/Document": { + "post": { + "tags": [ + "ClientLog" + ], + "summary": "Upload a document.", + "operationId": "LogFile", + "requestBody": { + "content": { + "text/plain": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "responses": { + "200": { + "description": "Document saved.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClientLogDocumentResponseDto" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ClientLogDocumentResponseDto" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ClientLogDocumentResponseDto" + } + } + } + }, + "403": { + "description": "Event logging disabled.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "413": { + "description": "Upload size too large.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Collections": { + "post": { + "tags": [ + "Collection" + ], + "summary": "Creates a new collection.", + "operationId": "CreateCollection", + "parameters": [ + { + "name": "name", + "in": "query", + "description": "The name of the collection.", + "schema": { + "type": "string" + } + }, + { + "name": "ids", + "in": "query", + "description": "Item Ids to add to the collection.", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "name": "parentId", + "in": "query", + "description": "Optional. Create the collection within a specific folder.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "isLocked", + "in": "query", + "description": "Whether or not to lock the new collection.", + "schema": { + "type": "boolean", + "default": false + } + } + ], + "responses": { + "200": { + "description": "Collection created.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CollectionCreationResult" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/CollectionCreationResult" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/CollectionCreationResult" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "CollectionManagement", + "DefaultAuthorization" + ] + } + ] + } + }, + "/Collections/{collectionId}/Items": { + "post": { + "tags": [ + "Collection" + ], + "summary": "Adds items to a collection.", + "operationId": "AddToCollection", + "parameters": [ + { + "name": "collectionId", + "in": "path", + "description": "The collection id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "ids", + "in": "query", + "description": "Item ids, comma delimited.", + "required": true, + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + } + ], + "responses": { + "204": { + "description": "Items added to collection." + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "CollectionManagement", + "DefaultAuthorization" + ] + } + ] + }, + "delete": { + "tags": [ + "Collection" + ], + "summary": "Removes items from a collection.", + "operationId": "RemoveFromCollection", + "parameters": [ + { + "name": "collectionId", + "in": "path", + "description": "The collection id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "ids", + "in": "query", + "description": "Item ids, comma delimited.", + "required": true, + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + } + ], + "responses": { + "204": { + "description": "Items removed from collection." + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "CollectionManagement", + "DefaultAuthorization" + ] + } + ] + } + }, + "/System/Configuration": { + "get": { + "tags": [ + "Configuration" + ], + "summary": "Gets application configuration.", + "operationId": "GetConfiguration", + "responses": { + "200": { + "description": "Application configuration returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ServerConfiguration" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ServerConfiguration" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ServerConfiguration" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + }, + "post": { + "tags": [ + "Configuration" + ], + "summary": "Updates application configuration.", + "operationId": "UpdateConfiguration", + "requestBody": { + "description": "Configuration.", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/ServerConfiguration" + } + ], + "description": "Represents the server configuration." + } + }, + "text/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/ServerConfiguration" + } + ], + "description": "Represents the server configuration." + } + }, + "application/*+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/ServerConfiguration" + } + ], + "description": "Represents the server configuration." + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Configuration updated." + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "RequiresElevation", + "DefaultAuthorization" + ] + } + ] + } + }, + "/System/Configuration/{key}": { + "get": { + "tags": [ + "Configuration" + ], + "summary": "Gets a named configuration.", + "operationId": "GetNamedConfiguration", + "parameters": [ + { + "name": "key", + "in": "path", + "description": "Configuration key.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Configuration returned.", + "content": { + "application/json": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + }, + "post": { + "tags": [ + "Configuration" + ], + "summary": "Updates named configuration.", + "operationId": "UpdateNamedConfiguration", + "parameters": [ + { + "name": "key", + "in": "path", + "description": "Configuration key.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Configuration.", + "content": { + "application/json": { + "schema": { } + }, + "text/json": { + "schema": { } + }, + "application/*+json": { + "schema": { } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Named configuration updated." + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "RequiresElevation", + "DefaultAuthorization" + ] + } + ] + } + }, + "/System/Configuration/Branding": { + "post": { + "tags": [ + "Configuration" + ], + "summary": "Updates branding configuration.", + "operationId": "UpdateBrandingConfiguration", + "requestBody": { + "description": "Branding configuration.", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/BrandingOptionsDto" + } + ], + "description": "The branding options DTO for API use.\r\nThis DTO excludes SplashscreenLocation to prevent it from being updated via API." + } + }, + "text/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/BrandingOptionsDto" + } + ], + "description": "The branding options DTO for API use.\r\nThis DTO excludes SplashscreenLocation to prevent it from being updated via API." + } + }, + "application/*+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/BrandingOptionsDto" + } + ], + "description": "The branding options DTO for API use.\r\nThis DTO excludes SplashscreenLocation to prevent it from being updated via API." + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Branding configuration updated." + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "RequiresElevation", + "DefaultAuthorization" + ] + } + ] + } + }, + "/System/Configuration/MetadataOptions/Default": { + "get": { + "tags": [ + "Configuration" + ], + "summary": "Gets a default MetadataOptions object.", + "operationId": "GetDefaultMetadataOptions", + "responses": { + "200": { + "description": "Metadata options returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MetadataOptions" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/MetadataOptions" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/MetadataOptions" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "RequiresElevation", + "DefaultAuthorization" + ] + } + ] + } + }, + "/web/ConfigurationPage": { + "get": { + "tags": [ + "Dashboard" + ], + "summary": "Gets a dashboard configuration page.", + "operationId": "GetDashboardConfigurationPage", + "parameters": [ + { + "name": "name", + "in": "query", + "description": "The name of the page.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "ConfigurationPage returned.", + "content": { + "text/html": { + "schema": { + "type": "string", + "format": "binary" + } + }, + "application/x-javascript": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "404": { + "description": "Plugin configuration page not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + } + } + } + }, + "/web/ConfigurationPages": { + "get": { + "tags": [ + "Dashboard" + ], + "summary": "Gets the configuration pages.", + "operationId": "GetConfigurationPages", + "parameters": [ + { + "name": "enableInMainMenu", + "in": "query", + "description": "Whether to enable in the main menu.", + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "description": "ConfigurationPages returned.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ConfigurationPageInfo" + } + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ConfigurationPageInfo" + } + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ConfigurationPageInfo" + } + } + } + } + }, + "404": { + "description": "Server still loading.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "RequiresElevation" + ] + } + ] + } + }, + "/Devices": { + "get": { + "tags": [ + "Devices" + ], + "summary": "Get Devices.", + "operationId": "GetDevices", + "parameters": [ + { + "name": "userId", + "in": "query", + "description": "Gets or sets the user identifier.", + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Devices retrieved.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceInfoDtoQueryResult" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/DeviceInfoDtoQueryResult" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/DeviceInfoDtoQueryResult" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "RequiresElevation" + ] + } + ] + }, + "delete": { + "tags": [ + "Devices" + ], + "summary": "Deletes a device.", + "operationId": "DeleteDevice", + "parameters": [ + { + "name": "id", + "in": "query", + "description": "Device Id.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Device deleted." + }, + "404": { + "description": "Device not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "RequiresElevation" + ] + } + ] + } + }, + "/Devices/Info": { + "get": { + "tags": [ + "Devices" + ], + "summary": "Get info for a device.", + "operationId": "GetDeviceInfo", + "parameters": [ + { + "name": "id", + "in": "query", + "description": "Device Id.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Device info retrieved.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceInfoDto" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/DeviceInfoDto" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/DeviceInfoDto" + } + } + } + }, + "404": { + "description": "Device not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "RequiresElevation" + ] + } + ] + } + }, + "/Devices/Options": { + "get": { + "tags": [ + "Devices" + ], + "summary": "Get options for a device.", + "operationId": "GetDeviceOptions", + "parameters": [ + { + "name": "id", + "in": "query", + "description": "Device Id.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Device options retrieved.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceOptionsDto" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/DeviceOptionsDto" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/DeviceOptionsDto" + } + } + } + }, + "404": { + "description": "Device not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "RequiresElevation" + ] + } + ] + }, + "post": { + "tags": [ + "Devices" + ], + "summary": "Update device options.", + "operationId": "UpdateDeviceOptions", + "parameters": [ + { + "name": "id", + "in": "query", + "description": "Device Id.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Device Options.", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/DeviceOptionsDto" + } + ], + "description": "A dto representing custom options for a device." + } + }, + "text/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/DeviceOptionsDto" + } + ], + "description": "A dto representing custom options for a device." + } + }, + "application/*+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/DeviceOptionsDto" + } + ], + "description": "A dto representing custom options for a device." + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Device options updated." + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "RequiresElevation" + ] + } + ] + } + }, + "/DisplayPreferences/{displayPreferencesId}": { + "get": { + "tags": [ + "DisplayPreferences" + ], + "summary": "Get Display Preferences.", + "operationId": "GetDisplayPreferences", + "parameters": [ + { + "name": "displayPreferencesId", + "in": "path", + "description": "Display preferences id.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "userId", + "in": "query", + "description": "User id.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "client", + "in": "query", + "description": "Client.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Display preferences retrieved.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DisplayPreferencesDto" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/DisplayPreferencesDto" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/DisplayPreferencesDto" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + }, + "post": { + "tags": [ + "DisplayPreferences" + ], + "summary": "Update Display Preferences.", + "operationId": "UpdateDisplayPreferences", + "parameters": [ + { + "name": "displayPreferencesId", + "in": "path", + "description": "Display preferences id.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "userId", + "in": "query", + "description": "User Id.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "client", + "in": "query", + "description": "Client.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "New Display Preferences object.", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/DisplayPreferencesDto" + } + ], + "description": "Defines the display preferences for any item that supports them (usually Folders)." + } + }, + "text/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/DisplayPreferencesDto" + } + ], + "description": "Defines the display preferences for any item that supports them (usually Folders)." + } + }, + "application/*+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/DisplayPreferencesDto" + } + ], + "description": "Defines the display preferences for any item that supports them (usually Folders)." + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Display preferences updated." + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Audio/{itemId}/hls1/{playlistId}/{segmentId}.{container}": { + "get": { + "tags": [ + "DynamicHls" + ], + "summary": "Gets a video stream using HTTP live streaming.", + "operationId": "GetHlsAudioSegment", + "parameters": [ + { + "name": "itemId", + "in": "path", + "description": "The item id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "playlistId", + "in": "path", + "description": "The playlist id.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "segmentId", + "in": "path", + "description": "The segment id.", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "container", + "in": "path", + "description": "The video container. Possible values are: ts, webm, asf, wmv, ogv, mp4, m4v, mkv, mpeg, mpg, avi, 3gp, wmv, wtv, m2ts, mov, iso, flv.", + "required": true, + "schema": { + "pattern": "^[a-zA-Z0-9\\-\\._,|]{0,40}$", + "type": "string" + } + }, + { + "name": "runtimeTicks", + "in": "query", + "description": "The position of the requested segment in ticks.", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "actualSegmentLengthTicks", + "in": "query", + "description": "The length of the requested segment in ticks.", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "static", + "in": "query", + "description": "Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.", + "schema": { + "type": "boolean" + } + }, + { + "name": "params", + "in": "query", + "description": "The streaming parameters.", + "schema": { + "type": "string" + } + }, + { + "name": "tag", + "in": "query", + "description": "The tag.", + "schema": { + "type": "string" + } + }, + { + "name": "deviceProfileId", + "in": "query", + "description": "Optional. The dlna device profile id to utilize.", + "deprecated": true, + "schema": { + "type": "string" + } + }, + { + "name": "playSessionId", + "in": "query", + "description": "The play session id.", + "schema": { + "type": "string" + } + }, + { + "name": "segmentContainer", + "in": "query", + "description": "The segment container.", + "schema": { + "pattern": "^[a-zA-Z0-9\\-\\._,|]{0,40}$", + "type": "string" + } + }, + { + "name": "segmentLength", + "in": "query", + "description": "The segment length.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "minSegments", + "in": "query", + "description": "The minimum number of segments.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "mediaSourceId", + "in": "query", + "description": "The media version id, if playing an alternate version.", + "schema": { + "type": "string" + } + }, + { + "name": "deviceId", + "in": "query", + "description": "The device id of the client requesting. Used to stop encoding processes when needed.", + "schema": { + "type": "string" + } + }, + { + "name": "audioCodec", + "in": "query", + "description": "Optional. Specify an audio codec to encode to, e.g. mp3.", + "schema": { + "pattern": "^[a-zA-Z0-9\\-\\._,|]{0,40}$", + "type": "string" + } + }, + { + "name": "enableAutoStreamCopy", + "in": "query", + "description": "Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.", + "schema": { + "type": "boolean" + } + }, + { + "name": "allowVideoStreamCopy", + "in": "query", + "description": "Whether or not to allow copying of the video stream url.", + "schema": { + "type": "boolean" + } + }, + { + "name": "allowAudioStreamCopy", + "in": "query", + "description": "Whether or not to allow copying of the audio stream url.", + "schema": { + "type": "boolean" + } + }, + { + "name": "breakOnNonKeyFrames", + "in": "query", + "description": "Optional. Whether to break on non key frames.", + "schema": { + "type": "boolean" + } + }, + { + "name": "audioSampleRate", + "in": "query", + "description": "Optional. Specify a specific audio sample rate, e.g. 44100.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxAudioBitDepth", + "in": "query", + "description": "Optional. The maximum audio bit depth.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxStreamingBitrate", + "in": "query", + "description": "Optional. The maximum streaming bitrate.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "audioBitRate", + "in": "query", + "description": "Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "audioChannels", + "in": "query", + "description": "Optional. Specify a specific number of audio channels to encode to, e.g. 2.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxAudioChannels", + "in": "query", + "description": "Optional. Specify a maximum number of audio channels to encode to, e.g. 2.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "profile", + "in": "query", + "description": "Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.", + "schema": { + "type": "string" + } + }, + { + "name": "level", + "in": "query", + "description": "Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.", + "schema": { + "pattern": "-?[0-9]+(?:\\.[0-9]+)?", + "type": "string" + } + }, + { + "name": "framerate", + "in": "query", + "description": "Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.", + "schema": { + "type": "number", + "format": "float" + } + }, + { + "name": "maxFramerate", + "in": "query", + "description": "Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.", + "schema": { + "type": "number", + "format": "float" + } + }, + { + "name": "copyTimestamps", + "in": "query", + "description": "Whether or not to copy timestamps when transcoding with an offset. Defaults to false.", + "schema": { + "type": "boolean" + } + }, + { + "name": "startTimeTicks", + "in": "query", + "description": "Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.", + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "width", + "in": "query", + "description": "Optional. The fixed horizontal resolution of the encoded video.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "height", + "in": "query", + "description": "Optional. The fixed vertical resolution of the encoded video.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "videoBitRate", + "in": "query", + "description": "Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "subtitleStreamIndex", + "in": "query", + "description": "Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "subtitleMethod", + "in": "query", + "description": "Optional. Specify the subtitle delivery method.", + "schema": { + "enum": [ + "Encode", + "Embed", + "External", + "Hls", + "Drop" + ], + "allOf": [ + { + "$ref": "#/components/schemas/SubtitleDeliveryMethod" + } + ] + } + }, + { + "name": "maxRefFrames", + "in": "query", + "description": "Optional.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxVideoBitDepth", + "in": "query", + "description": "Optional. The maximum video bit depth.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "requireAvc", + "in": "query", + "description": "Optional. Whether to require avc.", + "schema": { + "type": "boolean" + } + }, + { + "name": "deInterlace", + "in": "query", + "description": "Optional. Whether to deinterlace the video.", + "schema": { + "type": "boolean" + } + }, + { + "name": "requireNonAnamorphic", + "in": "query", + "description": "Optional. Whether to require a non anamorphic stream.", + "schema": { + "type": "boolean" + } + }, + { + "name": "transcodingMaxAudioChannels", + "in": "query", + "description": "Optional. The maximum number of audio channels to transcode.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "cpuCoreLimit", + "in": "query", + "description": "Optional. The limit of how many cpu cores to use.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "liveStreamId", + "in": "query", + "description": "The live stream id.", + "schema": { + "type": "string" + } + }, + { + "name": "enableMpegtsM2TsMode", + "in": "query", + "description": "Optional. Whether to enable the MpegtsM2Ts mode.", + "schema": { + "type": "boolean" + } + }, + { + "name": "videoCodec", + "in": "query", + "description": "Optional. Specify a video codec to encode to, e.g. h264.", + "schema": { + "pattern": "^[a-zA-Z0-9\\-\\._,|]{0,40}$", + "type": "string" + } + }, + { + "name": "subtitleCodec", + "in": "query", + "description": "Optional. Specify a subtitle codec to encode to.", + "schema": { + "pattern": "^[a-zA-Z0-9\\-\\._,|]{0,40}$", + "type": "string" + } + }, + { + "name": "transcodeReasons", + "in": "query", + "description": "Optional. The transcoding reason.", + "schema": { + "type": "string" + } + }, + { + "name": "audioStreamIndex", + "in": "query", + "description": "Optional. The index of the audio stream to use. If omitted the first audio stream will be used.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "videoStreamIndex", + "in": "query", + "description": "Optional. The index of the video stream to use. If omitted the first video stream will be used.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "context", + "in": "query", + "description": "Optional. The MediaBrowser.Model.Dlna.EncodingContext.", + "schema": { + "enum": [ + "Streaming", + "Static" + ], + "allOf": [ + { + "$ref": "#/components/schemas/EncodingContext" + } + ] + } + }, + { + "name": "streamOptions", + "in": "query", + "description": "Optional. The streaming options.", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string", + "nullable": true + } + } + }, + { + "name": "enableAudioVbrEncoding", + "in": "query", + "description": "Optional. Whether to enable Audio Encoding.", + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Video stream returned.", + "content": { + "audio/*": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Audio/{itemId}/main.m3u8": { + "get": { + "tags": [ + "DynamicHls" + ], + "summary": "Gets an audio stream using HTTP live streaming.", + "operationId": "GetVariantHlsAudioPlaylist", + "parameters": [ + { + "name": "itemId", + "in": "path", + "description": "The item id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "static", + "in": "query", + "description": "Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.", + "schema": { + "type": "boolean" + } + }, + { + "name": "params", + "in": "query", + "description": "The streaming parameters.", + "schema": { + "type": "string" + } + }, + { + "name": "tag", + "in": "query", + "description": "The tag.", + "schema": { + "type": "string" + } + }, + { + "name": "deviceProfileId", + "in": "query", + "description": "Optional. The dlna device profile id to utilize.", + "deprecated": true, + "schema": { + "type": "string" + } + }, + { + "name": "playSessionId", + "in": "query", + "description": "The play session id.", + "schema": { + "type": "string" + } + }, + { + "name": "segmentContainer", + "in": "query", + "description": "The segment container.", + "schema": { + "pattern": "^[a-zA-Z0-9\\-\\._,|]{0,40}$", + "type": "string" + } + }, + { + "name": "segmentLength", + "in": "query", + "description": "The segment length.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "minSegments", + "in": "query", + "description": "The minimum number of segments.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "mediaSourceId", + "in": "query", + "description": "The media version id, if playing an alternate version.", + "schema": { + "type": "string" + } + }, + { + "name": "deviceId", + "in": "query", + "description": "The device id of the client requesting. Used to stop encoding processes when needed.", + "schema": { + "type": "string" + } + }, + { + "name": "audioCodec", + "in": "query", + "description": "Optional. Specify an audio codec to encode to, e.g. mp3.", + "schema": { + "pattern": "^[a-zA-Z0-9\\-\\._,|]{0,40}$", + "type": "string" + } + }, + { + "name": "enableAutoStreamCopy", + "in": "query", + "description": "Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.", + "schema": { + "type": "boolean" + } + }, + { + "name": "allowVideoStreamCopy", + "in": "query", + "description": "Whether or not to allow copying of the video stream url.", + "schema": { + "type": "boolean" + } + }, + { + "name": "allowAudioStreamCopy", + "in": "query", + "description": "Whether or not to allow copying of the audio stream url.", + "schema": { + "type": "boolean" + } + }, + { + "name": "breakOnNonKeyFrames", + "in": "query", + "description": "Optional. Whether to break on non key frames.", + "schema": { + "type": "boolean" + } + }, + { + "name": "audioSampleRate", + "in": "query", + "description": "Optional. Specify a specific audio sample rate, e.g. 44100.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxAudioBitDepth", + "in": "query", + "description": "Optional. The maximum audio bit depth.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxStreamingBitrate", + "in": "query", + "description": "Optional. The maximum streaming bitrate.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "audioBitRate", + "in": "query", + "description": "Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "audioChannels", + "in": "query", + "description": "Optional. Specify a specific number of audio channels to encode to, e.g. 2.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxAudioChannels", + "in": "query", + "description": "Optional. Specify a maximum number of audio channels to encode to, e.g. 2.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "profile", + "in": "query", + "description": "Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.", + "schema": { + "type": "string" + } + }, + { + "name": "level", + "in": "query", + "description": "Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.", + "schema": { + "pattern": "-?[0-9]+(?:\\.[0-9]+)?", + "type": "string" + } + }, + { + "name": "framerate", + "in": "query", + "description": "Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.", + "schema": { + "type": "number", + "format": "float" + } + }, + { + "name": "maxFramerate", + "in": "query", + "description": "Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.", + "schema": { + "type": "number", + "format": "float" + } + }, + { + "name": "copyTimestamps", + "in": "query", + "description": "Whether or not to copy timestamps when transcoding with an offset. Defaults to false.", + "schema": { + "type": "boolean" + } + }, + { + "name": "startTimeTicks", + "in": "query", + "description": "Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.", + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "width", + "in": "query", + "description": "Optional. The fixed horizontal resolution of the encoded video.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "height", + "in": "query", + "description": "Optional. The fixed vertical resolution of the encoded video.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "videoBitRate", + "in": "query", + "description": "Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "subtitleStreamIndex", + "in": "query", + "description": "Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "subtitleMethod", + "in": "query", + "description": "Optional. Specify the subtitle delivery method.", + "schema": { + "enum": [ + "Encode", + "Embed", + "External", + "Hls", + "Drop" + ], + "allOf": [ + { + "$ref": "#/components/schemas/SubtitleDeliveryMethod" + } + ] + } + }, + { + "name": "maxRefFrames", + "in": "query", + "description": "Optional.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxVideoBitDepth", + "in": "query", + "description": "Optional. The maximum video bit depth.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "requireAvc", + "in": "query", + "description": "Optional. Whether to require avc.", + "schema": { + "type": "boolean" + } + }, + { + "name": "deInterlace", + "in": "query", + "description": "Optional. Whether to deinterlace the video.", + "schema": { + "type": "boolean" + } + }, + { + "name": "requireNonAnamorphic", + "in": "query", + "description": "Optional. Whether to require a non anamorphic stream.", + "schema": { + "type": "boolean" + } + }, + { + "name": "transcodingMaxAudioChannels", + "in": "query", + "description": "Optional. The maximum number of audio channels to transcode.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "cpuCoreLimit", + "in": "query", + "description": "Optional. The limit of how many cpu cores to use.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "liveStreamId", + "in": "query", + "description": "The live stream id.", + "schema": { + "type": "string" + } + }, + { + "name": "enableMpegtsM2TsMode", + "in": "query", + "description": "Optional. Whether to enable the MpegtsM2Ts mode.", + "schema": { + "type": "boolean" + } + }, + { + "name": "videoCodec", + "in": "query", + "description": "Optional. Specify a video codec to encode to, e.g. h264.", + "schema": { + "pattern": "^[a-zA-Z0-9\\-\\._,|]{0,40}$", + "type": "string" + } + }, + { + "name": "subtitleCodec", + "in": "query", + "description": "Optional. Specify a subtitle codec to encode to.", + "schema": { + "pattern": "^[a-zA-Z0-9\\-\\._,|]{0,40}$", + "type": "string" + } + }, + { + "name": "transcodeReasons", + "in": "query", + "description": "Optional. The transcoding reason.", + "schema": { + "type": "string" + } + }, + { + "name": "audioStreamIndex", + "in": "query", + "description": "Optional. The index of the audio stream to use. If omitted the first audio stream will be used.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "videoStreamIndex", + "in": "query", + "description": "Optional. The index of the video stream to use. If omitted the first video stream will be used.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "context", + "in": "query", + "description": "Optional. The MediaBrowser.Model.Dlna.EncodingContext.", + "schema": { + "enum": [ + "Streaming", + "Static" + ], + "allOf": [ + { + "$ref": "#/components/schemas/EncodingContext" + } + ] + } + }, + { + "name": "streamOptions", + "in": "query", + "description": "Optional. The streaming options.", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string", + "nullable": true + } + } + }, + { + "name": "enableAudioVbrEncoding", + "in": "query", + "description": "Optional. Whether to enable Audio Encoding.", + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Audio stream returned.", + "content": { + "application/x-mpegURL": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Audio/{itemId}/master.m3u8": { + "get": { + "tags": [ + "DynamicHls" + ], + "summary": "Gets an audio hls playlist stream.", + "operationId": "GetMasterHlsAudioPlaylist", + "parameters": [ + { + "name": "itemId", + "in": "path", + "description": "The item id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "static", + "in": "query", + "description": "Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.", + "schema": { + "type": "boolean" + } + }, + { + "name": "params", + "in": "query", + "description": "The streaming parameters.", + "schema": { + "type": "string" + } + }, + { + "name": "tag", + "in": "query", + "description": "The tag.", + "schema": { + "type": "string" + } + }, + { + "name": "deviceProfileId", + "in": "query", + "description": "Optional. The dlna device profile id to utilize.", + "deprecated": true, + "schema": { + "type": "string" + } + }, + { + "name": "playSessionId", + "in": "query", + "description": "The play session id.", + "schema": { + "type": "string" + } + }, + { + "name": "segmentContainer", + "in": "query", + "description": "The segment container.", + "schema": { + "pattern": "^[a-zA-Z0-9\\-\\._,|]{0,40}$", + "type": "string" + } + }, + { + "name": "segmentLength", + "in": "query", + "description": "The segment length.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "minSegments", + "in": "query", + "description": "The minimum number of segments.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "mediaSourceId", + "in": "query", + "description": "The media version id, if playing an alternate version.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "deviceId", + "in": "query", + "description": "The device id of the client requesting. Used to stop encoding processes when needed.", + "schema": { + "type": "string" + } + }, + { + "name": "audioCodec", + "in": "query", + "description": "Optional. Specify an audio codec to encode to, e.g. mp3.", + "schema": { + "pattern": "^[a-zA-Z0-9\\-\\._,|]{0,40}$", + "type": "string" + } + }, + { + "name": "enableAutoStreamCopy", + "in": "query", + "description": "Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.", + "schema": { + "type": "boolean" + } + }, + { + "name": "allowVideoStreamCopy", + "in": "query", + "description": "Whether or not to allow copying of the video stream url.", + "schema": { + "type": "boolean" + } + }, + { + "name": "allowAudioStreamCopy", + "in": "query", + "description": "Whether or not to allow copying of the audio stream url.", + "schema": { + "type": "boolean" + } + }, + { + "name": "breakOnNonKeyFrames", + "in": "query", + "description": "Optional. Whether to break on non key frames.", + "schema": { + "type": "boolean" + } + }, + { + "name": "audioSampleRate", + "in": "query", + "description": "Optional. Specify a specific audio sample rate, e.g. 44100.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxAudioBitDepth", + "in": "query", + "description": "Optional. The maximum audio bit depth.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxStreamingBitrate", + "in": "query", + "description": "Optional. The maximum streaming bitrate.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "audioBitRate", + "in": "query", + "description": "Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "audioChannels", + "in": "query", + "description": "Optional. Specify a specific number of audio channels to encode to, e.g. 2.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxAudioChannels", + "in": "query", + "description": "Optional. Specify a maximum number of audio channels to encode to, e.g. 2.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "profile", + "in": "query", + "description": "Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.", + "schema": { + "type": "string" + } + }, + { + "name": "level", + "in": "query", + "description": "Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.", + "schema": { + "pattern": "-?[0-9]+(?:\\.[0-9]+)?", + "type": "string" + } + }, + { + "name": "framerate", + "in": "query", + "description": "Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.", + "schema": { + "type": "number", + "format": "float" + } + }, + { + "name": "maxFramerate", + "in": "query", + "description": "Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.", + "schema": { + "type": "number", + "format": "float" + } + }, + { + "name": "copyTimestamps", + "in": "query", + "description": "Whether or not to copy timestamps when transcoding with an offset. Defaults to false.", + "schema": { + "type": "boolean" + } + }, + { + "name": "startTimeTicks", + "in": "query", + "description": "Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.", + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "width", + "in": "query", + "description": "Optional. The fixed horizontal resolution of the encoded video.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "height", + "in": "query", + "description": "Optional. The fixed vertical resolution of the encoded video.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "videoBitRate", + "in": "query", + "description": "Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "subtitleStreamIndex", + "in": "query", + "description": "Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "subtitleMethod", + "in": "query", + "description": "Optional. Specify the subtitle delivery method.", + "schema": { + "enum": [ + "Encode", + "Embed", + "External", + "Hls", + "Drop" + ], + "allOf": [ + { + "$ref": "#/components/schemas/SubtitleDeliveryMethod" + } + ] + } + }, + { + "name": "maxRefFrames", + "in": "query", + "description": "Optional.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxVideoBitDepth", + "in": "query", + "description": "Optional. The maximum video bit depth.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "requireAvc", + "in": "query", + "description": "Optional. Whether to require avc.", + "schema": { + "type": "boolean" + } + }, + { + "name": "deInterlace", + "in": "query", + "description": "Optional. Whether to deinterlace the video.", + "schema": { + "type": "boolean" + } + }, + { + "name": "requireNonAnamorphic", + "in": "query", + "description": "Optional. Whether to require a non anamorphic stream.", + "schema": { + "type": "boolean" + } + }, + { + "name": "transcodingMaxAudioChannels", + "in": "query", + "description": "Optional. The maximum number of audio channels to transcode.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "cpuCoreLimit", + "in": "query", + "description": "Optional. The limit of how many cpu cores to use.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "liveStreamId", + "in": "query", + "description": "The live stream id.", + "schema": { + "type": "string" + } + }, + { + "name": "enableMpegtsM2TsMode", + "in": "query", + "description": "Optional. Whether to enable the MpegtsM2Ts mode.", + "schema": { + "type": "boolean" + } + }, + { + "name": "videoCodec", + "in": "query", + "description": "Optional. Specify a video codec to encode to, e.g. h264.", + "schema": { + "pattern": "^[a-zA-Z0-9\\-\\._,|]{0,40}$", + "type": "string" + } + }, + { + "name": "subtitleCodec", + "in": "query", + "description": "Optional. Specify a subtitle codec to encode to.", + "schema": { + "pattern": "^[a-zA-Z0-9\\-\\._,|]{0,40}$", + "type": "string" + } + }, + { + "name": "transcodeReasons", + "in": "query", + "description": "Optional. The transcoding reason.", + "schema": { + "type": "string" + } + }, + { + "name": "audioStreamIndex", + "in": "query", + "description": "Optional. The index of the audio stream to use. If omitted the first audio stream will be used.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "videoStreamIndex", + "in": "query", + "description": "Optional. The index of the video stream to use. If omitted the first video stream will be used.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "context", + "in": "query", + "description": "Optional. The MediaBrowser.Model.Dlna.EncodingContext.", + "schema": { + "enum": [ + "Streaming", + "Static" + ], + "allOf": [ + { + "$ref": "#/components/schemas/EncodingContext" + } + ] + } + }, + { + "name": "streamOptions", + "in": "query", + "description": "Optional. The streaming options.", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string", + "nullable": true + } + } + }, + { + "name": "enableAdaptiveBitrateStreaming", + "in": "query", + "description": "Enable adaptive bitrate streaming.", + "schema": { + "type": "boolean", + "default": false + } + }, + { + "name": "enableAudioVbrEncoding", + "in": "query", + "description": "Optional. Whether to enable Audio Encoding.", + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Audio stream returned.", + "content": { + "application/x-mpegURL": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + }, + "head": { + "tags": [ + "DynamicHls" + ], + "summary": "Gets an audio hls playlist stream.", + "operationId": "HeadMasterHlsAudioPlaylist", + "parameters": [ + { + "name": "itemId", + "in": "path", + "description": "The item id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "static", + "in": "query", + "description": "Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.", + "schema": { + "type": "boolean" + } + }, + { + "name": "params", + "in": "query", + "description": "The streaming parameters.", + "schema": { + "type": "string" + } + }, + { + "name": "tag", + "in": "query", + "description": "The tag.", + "schema": { + "type": "string" + } + }, + { + "name": "deviceProfileId", + "in": "query", + "description": "Optional. The dlna device profile id to utilize.", + "deprecated": true, + "schema": { + "type": "string" + } + }, + { + "name": "playSessionId", + "in": "query", + "description": "The play session id.", + "schema": { + "type": "string" + } + }, + { + "name": "segmentContainer", + "in": "query", + "description": "The segment container.", + "schema": { + "pattern": "^[a-zA-Z0-9\\-\\._,|]{0,40}$", + "type": "string" + } + }, + { + "name": "segmentLength", + "in": "query", + "description": "The segment length.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "minSegments", + "in": "query", + "description": "The minimum number of segments.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "mediaSourceId", + "in": "query", + "description": "The media version id, if playing an alternate version.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "deviceId", + "in": "query", + "description": "The device id of the client requesting. Used to stop encoding processes when needed.", + "schema": { + "type": "string" + } + }, + { + "name": "audioCodec", + "in": "query", + "description": "Optional. Specify an audio codec to encode to, e.g. mp3.", + "schema": { + "pattern": "^[a-zA-Z0-9\\-\\._,|]{0,40}$", + "type": "string" + } + }, + { + "name": "enableAutoStreamCopy", + "in": "query", + "description": "Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.", + "schema": { + "type": "boolean" + } + }, + { + "name": "allowVideoStreamCopy", + "in": "query", + "description": "Whether or not to allow copying of the video stream url.", + "schema": { + "type": "boolean" + } + }, + { + "name": "allowAudioStreamCopy", + "in": "query", + "description": "Whether or not to allow copying of the audio stream url.", + "schema": { + "type": "boolean" + } + }, + { + "name": "breakOnNonKeyFrames", + "in": "query", + "description": "Optional. Whether to break on non key frames.", + "schema": { + "type": "boolean" + } + }, + { + "name": "audioSampleRate", + "in": "query", + "description": "Optional. Specify a specific audio sample rate, e.g. 44100.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxAudioBitDepth", + "in": "query", + "description": "Optional. The maximum audio bit depth.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxStreamingBitrate", + "in": "query", + "description": "Optional. The maximum streaming bitrate.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "audioBitRate", + "in": "query", + "description": "Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "audioChannels", + "in": "query", + "description": "Optional. Specify a specific number of audio channels to encode to, e.g. 2.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxAudioChannels", + "in": "query", + "description": "Optional. Specify a maximum number of audio channels to encode to, e.g. 2.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "profile", + "in": "query", + "description": "Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.", + "schema": { + "type": "string" + } + }, + { + "name": "level", + "in": "query", + "description": "Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.", + "schema": { + "pattern": "-?[0-9]+(?:\\.[0-9]+)?", + "type": "string" + } + }, + { + "name": "framerate", + "in": "query", + "description": "Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.", + "schema": { + "type": "number", + "format": "float" + } + }, + { + "name": "maxFramerate", + "in": "query", + "description": "Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.", + "schema": { + "type": "number", + "format": "float" + } + }, + { + "name": "copyTimestamps", + "in": "query", + "description": "Whether or not to copy timestamps when transcoding with an offset. Defaults to false.", + "schema": { + "type": "boolean" + } + }, + { + "name": "startTimeTicks", + "in": "query", + "description": "Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.", + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "width", + "in": "query", + "description": "Optional. The fixed horizontal resolution of the encoded video.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "height", + "in": "query", + "description": "Optional. The fixed vertical resolution of the encoded video.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "videoBitRate", + "in": "query", + "description": "Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "subtitleStreamIndex", + "in": "query", + "description": "Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "subtitleMethod", + "in": "query", + "description": "Optional. Specify the subtitle delivery method.", + "schema": { + "enum": [ + "Encode", + "Embed", + "External", + "Hls", + "Drop" + ], + "allOf": [ + { + "$ref": "#/components/schemas/SubtitleDeliveryMethod" + } + ] + } + }, + { + "name": "maxRefFrames", + "in": "query", + "description": "Optional.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxVideoBitDepth", + "in": "query", + "description": "Optional. The maximum video bit depth.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "requireAvc", + "in": "query", + "description": "Optional. Whether to require avc.", + "schema": { + "type": "boolean" + } + }, + { + "name": "deInterlace", + "in": "query", + "description": "Optional. Whether to deinterlace the video.", + "schema": { + "type": "boolean" + } + }, + { + "name": "requireNonAnamorphic", + "in": "query", + "description": "Optional. Whether to require a non anamorphic stream.", + "schema": { + "type": "boolean" + } + }, + { + "name": "transcodingMaxAudioChannels", + "in": "query", + "description": "Optional. The maximum number of audio channels to transcode.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "cpuCoreLimit", + "in": "query", + "description": "Optional. The limit of how many cpu cores to use.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "liveStreamId", + "in": "query", + "description": "The live stream id.", + "schema": { + "type": "string" + } + }, + { + "name": "enableMpegtsM2TsMode", + "in": "query", + "description": "Optional. Whether to enable the MpegtsM2Ts mode.", + "schema": { + "type": "boolean" + } + }, + { + "name": "videoCodec", + "in": "query", + "description": "Optional. Specify a video codec to encode to, e.g. h264.", + "schema": { + "pattern": "^[a-zA-Z0-9\\-\\._,|]{0,40}$", + "type": "string" + } + }, + { + "name": "subtitleCodec", + "in": "query", + "description": "Optional. Specify a subtitle codec to encode to.", + "schema": { + "pattern": "^[a-zA-Z0-9\\-\\._,|]{0,40}$", + "type": "string" + } + }, + { + "name": "transcodeReasons", + "in": "query", + "description": "Optional. The transcoding reason.", + "schema": { + "type": "string" + } + }, + { + "name": "audioStreamIndex", + "in": "query", + "description": "Optional. The index of the audio stream to use. If omitted the first audio stream will be used.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "videoStreamIndex", + "in": "query", + "description": "Optional. The index of the video stream to use. If omitted the first video stream will be used.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "context", + "in": "query", + "description": "Optional. The MediaBrowser.Model.Dlna.EncodingContext.", + "schema": { + "enum": [ + "Streaming", + "Static" + ], + "allOf": [ + { + "$ref": "#/components/schemas/EncodingContext" + } + ] + } + }, + { + "name": "streamOptions", + "in": "query", + "description": "Optional. The streaming options.", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string", + "nullable": true + } + } + }, + { + "name": "enableAdaptiveBitrateStreaming", + "in": "query", + "description": "Enable adaptive bitrate streaming.", + "schema": { + "type": "boolean", + "default": false + } + }, + { + "name": "enableAudioVbrEncoding", + "in": "query", + "description": "Optional. Whether to enable Audio Encoding.", + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Audio stream returned.", + "content": { + "application/x-mpegURL": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Videos/{itemId}/hls1/{playlistId}/{segmentId}.{container}": { + "get": { + "tags": [ + "DynamicHls" + ], + "summary": "Gets a video stream using HTTP live streaming.", + "operationId": "GetHlsVideoSegment", + "parameters": [ + { + "name": "itemId", + "in": "path", + "description": "The item id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "playlistId", + "in": "path", + "description": "The playlist id.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "segmentId", + "in": "path", + "description": "The segment id.", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "container", + "in": "path", + "description": "The video container. Possible values are: ts, webm, asf, wmv, ogv, mp4, m4v, mkv, mpeg, mpg, avi, 3gp, wmv, wtv, m2ts, mov, iso, flv.", + "required": true, + "schema": { + "pattern": "^[a-zA-Z0-9\\-\\._,|]{0,40}$", + "type": "string" + } + }, + { + "name": "runtimeTicks", + "in": "query", + "description": "The position of the requested segment in ticks.", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "actualSegmentLengthTicks", + "in": "query", + "description": "The length of the requested segment in ticks.", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "static", + "in": "query", + "description": "Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.", + "schema": { + "type": "boolean" + } + }, + { + "name": "params", + "in": "query", + "description": "The streaming parameters.", + "schema": { + "type": "string" + } + }, + { + "name": "tag", + "in": "query", + "description": "The tag.", + "schema": { + "type": "string" + } + }, + { + "name": "deviceProfileId", + "in": "query", + "description": "Optional. The dlna device profile id to utilize.", + "deprecated": true, + "schema": { + "type": "string" + } + }, + { + "name": "playSessionId", + "in": "query", + "description": "The play session id.", + "schema": { + "type": "string" + } + }, + { + "name": "segmentContainer", + "in": "query", + "description": "The segment container.", + "schema": { + "pattern": "^[a-zA-Z0-9\\-\\._,|]{0,40}$", + "type": "string" + } + }, + { + "name": "segmentLength", + "in": "query", + "description": "The desired segment length.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "minSegments", + "in": "query", + "description": "The minimum number of segments.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "mediaSourceId", + "in": "query", + "description": "The media version id, if playing an alternate version.", + "schema": { + "type": "string" + } + }, + { + "name": "deviceId", + "in": "query", + "description": "The device id of the client requesting. Used to stop encoding processes when needed.", + "schema": { + "type": "string" + } + }, + { + "name": "audioCodec", + "in": "query", + "description": "Optional. Specify an audio codec to encode to, e.g. mp3.", + "schema": { + "pattern": "^[a-zA-Z0-9\\-\\._,|]{0,40}$", + "type": "string" + } + }, + { + "name": "enableAutoStreamCopy", + "in": "query", + "description": "Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.", + "schema": { + "type": "boolean" + } + }, + { + "name": "allowVideoStreamCopy", + "in": "query", + "description": "Whether or not to allow copying of the video stream url.", + "schema": { + "type": "boolean" + } + }, + { + "name": "allowAudioStreamCopy", + "in": "query", + "description": "Whether or not to allow copying of the audio stream url.", + "schema": { + "type": "boolean" + } + }, + { + "name": "breakOnNonKeyFrames", + "in": "query", + "description": "Optional. Whether to break on non key frames.", + "schema": { + "type": "boolean" + } + }, + { + "name": "audioSampleRate", + "in": "query", + "description": "Optional. Specify a specific audio sample rate, e.g. 44100.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxAudioBitDepth", + "in": "query", + "description": "Optional. The maximum audio bit depth.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "audioBitRate", + "in": "query", + "description": "Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "audioChannels", + "in": "query", + "description": "Optional. Specify a specific number of audio channels to encode to, e.g. 2.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxAudioChannels", + "in": "query", + "description": "Optional. Specify a maximum number of audio channels to encode to, e.g. 2.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "profile", + "in": "query", + "description": "Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.", + "schema": { + "type": "string" + } + }, + { + "name": "level", + "in": "query", + "description": "Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.", + "schema": { + "pattern": "-?[0-9]+(?:\\.[0-9]+)?", + "type": "string" + } + }, + { + "name": "framerate", + "in": "query", + "description": "Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.", + "schema": { + "type": "number", + "format": "float" + } + }, + { + "name": "maxFramerate", + "in": "query", + "description": "Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.", + "schema": { + "type": "number", + "format": "float" + } + }, + { + "name": "copyTimestamps", + "in": "query", + "description": "Whether or not to copy timestamps when transcoding with an offset. Defaults to false.", + "schema": { + "type": "boolean" + } + }, + { + "name": "startTimeTicks", + "in": "query", + "description": "Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.", + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "width", + "in": "query", + "description": "Optional. The fixed horizontal resolution of the encoded video.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "height", + "in": "query", + "description": "Optional. The fixed vertical resolution of the encoded video.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxWidth", + "in": "query", + "description": "Optional. The maximum horizontal resolution of the encoded video.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxHeight", + "in": "query", + "description": "Optional. The maximum vertical resolution of the encoded video.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "videoBitRate", + "in": "query", + "description": "Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "subtitleStreamIndex", + "in": "query", + "description": "Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "subtitleMethod", + "in": "query", + "description": "Optional. Specify the subtitle delivery method.", + "schema": { + "enum": [ + "Encode", + "Embed", + "External", + "Hls", + "Drop" + ], + "allOf": [ + { + "$ref": "#/components/schemas/SubtitleDeliveryMethod" + } + ] + } + }, + { + "name": "maxRefFrames", + "in": "query", + "description": "Optional.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxVideoBitDepth", + "in": "query", + "description": "Optional. The maximum video bit depth.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "requireAvc", + "in": "query", + "description": "Optional. Whether to require avc.", + "schema": { + "type": "boolean" + } + }, + { + "name": "deInterlace", + "in": "query", + "description": "Optional. Whether to deinterlace the video.", + "schema": { + "type": "boolean" + } + }, + { + "name": "requireNonAnamorphic", + "in": "query", + "description": "Optional. Whether to require a non anamorphic stream.", + "schema": { + "type": "boolean" + } + }, + { + "name": "transcodingMaxAudioChannels", + "in": "query", + "description": "Optional. The maximum number of audio channels to transcode.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "cpuCoreLimit", + "in": "query", + "description": "Optional. The limit of how many cpu cores to use.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "liveStreamId", + "in": "query", + "description": "The live stream id.", + "schema": { + "type": "string" + } + }, + { + "name": "enableMpegtsM2TsMode", + "in": "query", + "description": "Optional. Whether to enable the MpegtsM2Ts mode.", + "schema": { + "type": "boolean" + } + }, + { + "name": "videoCodec", + "in": "query", + "description": "Optional. Specify a video codec to encode to, e.g. h264.", + "schema": { + "pattern": "^[a-zA-Z0-9\\-\\._,|]{0,40}$", + "type": "string" + } + }, + { + "name": "subtitleCodec", + "in": "query", + "description": "Optional. Specify a subtitle codec to encode to.", + "schema": { + "pattern": "^[a-zA-Z0-9\\-\\._,|]{0,40}$", + "type": "string" + } + }, + { + "name": "transcodeReasons", + "in": "query", + "description": "Optional. The transcoding reason.", + "schema": { + "type": "string" + } + }, + { + "name": "audioStreamIndex", + "in": "query", + "description": "Optional. The index of the audio stream to use. If omitted the first audio stream will be used.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "videoStreamIndex", + "in": "query", + "description": "Optional. The index of the video stream to use. If omitted the first video stream will be used.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "context", + "in": "query", + "description": "Optional. The MediaBrowser.Model.Dlna.EncodingContext.", + "schema": { + "enum": [ + "Streaming", + "Static" + ], + "allOf": [ + { + "$ref": "#/components/schemas/EncodingContext" + } + ] + } + }, + { + "name": "streamOptions", + "in": "query", + "description": "Optional. The streaming options.", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string", + "nullable": true + } + } + }, + { + "name": "enableAudioVbrEncoding", + "in": "query", + "description": "Optional. Whether to enable Audio Encoding.", + "schema": { + "type": "boolean", + "default": true + } + }, + { + "name": "alwaysBurnInSubtitleWhenTranscoding", + "in": "query", + "description": "Whether to always burn in subtitles when transcoding.", + "schema": { + "type": "boolean", + "default": false + } + } + ], + "responses": { + "200": { + "description": "Video stream returned.", + "content": { + "video/*": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Videos/{itemId}/live.m3u8": { + "get": { + "tags": [ + "DynamicHls" + ], + "summary": "Gets a hls live stream.", + "operationId": "GetLiveHlsStream", + "parameters": [ + { + "name": "itemId", + "in": "path", + "description": "The item id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "container", + "in": "query", + "description": "The audio container.", + "schema": { + "pattern": "^[a-zA-Z0-9\\-\\._,|]{0,40}$", + "type": "string" + } + }, + { + "name": "static", + "in": "query", + "description": "Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.", + "schema": { + "type": "boolean" + } + }, + { + "name": "params", + "in": "query", + "description": "The streaming parameters.", + "schema": { + "type": "string" + } + }, + { + "name": "tag", + "in": "query", + "description": "The tag.", + "schema": { + "type": "string" + } + }, + { + "name": "deviceProfileId", + "in": "query", + "description": "Optional. The dlna device profile id to utilize.", + "deprecated": true, + "schema": { + "type": "string" + } + }, + { + "name": "playSessionId", + "in": "query", + "description": "The play session id.", + "schema": { + "type": "string" + } + }, + { + "name": "segmentContainer", + "in": "query", + "description": "The segment container.", + "schema": { + "pattern": "^[a-zA-Z0-9\\-\\._,|]{0,40}$", + "type": "string" + } + }, + { + "name": "segmentLength", + "in": "query", + "description": "The segment length.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "minSegments", + "in": "query", + "description": "The minimum number of segments.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "mediaSourceId", + "in": "query", + "description": "The media version id, if playing an alternate version.", + "schema": { + "type": "string" + } + }, + { + "name": "deviceId", + "in": "query", + "description": "The device id of the client requesting. Used to stop encoding processes when needed.", + "schema": { + "type": "string" + } + }, + { + "name": "audioCodec", + "in": "query", + "description": "Optional. Specify an audio codec to encode to, e.g. mp3.", + "schema": { + "pattern": "^[a-zA-Z0-9\\-\\._,|]{0,40}$", + "type": "string" + } + }, + { + "name": "enableAutoStreamCopy", + "in": "query", + "description": "Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.", + "schema": { + "type": "boolean" + } + }, + { + "name": "allowVideoStreamCopy", + "in": "query", + "description": "Whether or not to allow copying of the video stream url.", + "schema": { + "type": "boolean" + } + }, + { + "name": "allowAudioStreamCopy", + "in": "query", + "description": "Whether or not to allow copying of the audio stream url.", + "schema": { + "type": "boolean" + } + }, + { + "name": "breakOnNonKeyFrames", + "in": "query", + "description": "Optional. Whether to break on non key frames.", + "schema": { + "type": "boolean" + } + }, + { + "name": "audioSampleRate", + "in": "query", + "description": "Optional. Specify a specific audio sample rate, e.g. 44100.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxAudioBitDepth", + "in": "query", + "description": "Optional. The maximum audio bit depth.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "audioBitRate", + "in": "query", + "description": "Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "audioChannels", + "in": "query", + "description": "Optional. Specify a specific number of audio channels to encode to, e.g. 2.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxAudioChannels", + "in": "query", + "description": "Optional. Specify a maximum number of audio channels to encode to, e.g. 2.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "profile", + "in": "query", + "description": "Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.", + "schema": { + "type": "string" + } + }, + { + "name": "level", + "in": "query", + "description": "Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.", + "schema": { + "pattern": "-?[0-9]+(?:\\.[0-9]+)?", + "type": "string" + } + }, + { + "name": "framerate", + "in": "query", + "description": "Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.", + "schema": { + "type": "number", + "format": "float" + } + }, + { + "name": "maxFramerate", + "in": "query", + "description": "Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.", + "schema": { + "type": "number", + "format": "float" + } + }, + { + "name": "copyTimestamps", + "in": "query", + "description": "Whether or not to copy timestamps when transcoding with an offset. Defaults to false.", + "schema": { + "type": "boolean" + } + }, + { + "name": "startTimeTicks", + "in": "query", + "description": "Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.", + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "width", + "in": "query", + "description": "Optional. The fixed horizontal resolution of the encoded video.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "height", + "in": "query", + "description": "Optional. The fixed vertical resolution of the encoded video.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "videoBitRate", + "in": "query", + "description": "Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "subtitleStreamIndex", + "in": "query", + "description": "Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "subtitleMethod", + "in": "query", + "description": "Optional. Specify the subtitle delivery method.", + "schema": { + "enum": [ + "Encode", + "Embed", + "External", + "Hls", + "Drop" + ], + "allOf": [ + { + "$ref": "#/components/schemas/SubtitleDeliveryMethod" + } + ] + } + }, + { + "name": "maxRefFrames", + "in": "query", + "description": "Optional.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxVideoBitDepth", + "in": "query", + "description": "Optional. The maximum video bit depth.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "requireAvc", + "in": "query", + "description": "Optional. Whether to require avc.", + "schema": { + "type": "boolean" + } + }, + { + "name": "deInterlace", + "in": "query", + "description": "Optional. Whether to deinterlace the video.", + "schema": { + "type": "boolean" + } + }, + { + "name": "requireNonAnamorphic", + "in": "query", + "description": "Optional. Whether to require a non anamorphic stream.", + "schema": { + "type": "boolean" + } + }, + { + "name": "transcodingMaxAudioChannels", + "in": "query", + "description": "Optional. The maximum number of audio channels to transcode.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "cpuCoreLimit", + "in": "query", + "description": "Optional. The limit of how many cpu cores to use.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "liveStreamId", + "in": "query", + "description": "The live stream id.", + "schema": { + "type": "string" + } + }, + { + "name": "enableMpegtsM2TsMode", + "in": "query", + "description": "Optional. Whether to enable the MpegtsM2Ts mode.", + "schema": { + "type": "boolean" + } + }, + { + "name": "videoCodec", + "in": "query", + "description": "Optional. Specify a video codec to encode to, e.g. h264.", + "schema": { + "pattern": "^[a-zA-Z0-9\\-\\._,|]{0,40}$", + "type": "string" + } + }, + { + "name": "subtitleCodec", + "in": "query", + "description": "Optional. Specify a subtitle codec to encode to.", + "schema": { + "pattern": "^[a-zA-Z0-9\\-\\._,|]{0,40}$", + "type": "string" + } + }, + { + "name": "transcodeReasons", + "in": "query", + "description": "Optional. The transcoding reason.", + "schema": { + "type": "string" + } + }, + { + "name": "audioStreamIndex", + "in": "query", + "description": "Optional. The index of the audio stream to use. If omitted the first audio stream will be used.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "videoStreamIndex", + "in": "query", + "description": "Optional. The index of the video stream to use. If omitted the first video stream will be used.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "context", + "in": "query", + "description": "Optional. The MediaBrowser.Model.Dlna.EncodingContext.", + "schema": { + "enum": [ + "Streaming", + "Static" + ], + "allOf": [ + { + "$ref": "#/components/schemas/EncodingContext" + } + ] + } + }, + { + "name": "streamOptions", + "in": "query", + "description": "Optional. The streaming options.", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string", + "nullable": true + } + } + }, + { + "name": "maxWidth", + "in": "query", + "description": "Optional. The max width.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxHeight", + "in": "query", + "description": "Optional. The max height.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "enableSubtitlesInManifest", + "in": "query", + "description": "Optional. Whether to enable subtitles in the manifest.", + "schema": { + "type": "boolean" + } + }, + { + "name": "enableAudioVbrEncoding", + "in": "query", + "description": "Optional. Whether to enable Audio Encoding.", + "schema": { + "type": "boolean", + "default": true + } + }, + { + "name": "alwaysBurnInSubtitleWhenTranscoding", + "in": "query", + "description": "Whether to always burn in subtitles when transcoding.", + "schema": { + "type": "boolean", + "default": false + } + } + ], + "responses": { + "200": { + "description": "Hls live stream retrieved.", + "content": { + "application/x-mpegURL": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Videos/{itemId}/main.m3u8": { + "get": { + "tags": [ + "DynamicHls" + ], + "summary": "Gets a video stream using HTTP live streaming.", + "operationId": "GetVariantHlsVideoPlaylist", + "parameters": [ + { + "name": "itemId", + "in": "path", + "description": "The item id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "static", + "in": "query", + "description": "Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.", + "schema": { + "type": "boolean" + } + }, + { + "name": "params", + "in": "query", + "description": "The streaming parameters.", + "schema": { + "type": "string" + } + }, + { + "name": "tag", + "in": "query", + "description": "The tag.", + "schema": { + "type": "string" + } + }, + { + "name": "deviceProfileId", + "in": "query", + "description": "Optional. The dlna device profile id to utilize.", + "deprecated": true, + "schema": { + "type": "string" + } + }, + { + "name": "playSessionId", + "in": "query", + "description": "The play session id.", + "schema": { + "type": "string" + } + }, + { + "name": "segmentContainer", + "in": "query", + "description": "The segment container.", + "schema": { + "pattern": "^[a-zA-Z0-9\\-\\._,|]{0,40}$", + "type": "string" + } + }, + { + "name": "segmentLength", + "in": "query", + "description": "The segment length.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "minSegments", + "in": "query", + "description": "The minimum number of segments.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "mediaSourceId", + "in": "query", + "description": "The media version id, if playing an alternate version.", + "schema": { + "type": "string" + } + }, + { + "name": "deviceId", + "in": "query", + "description": "The device id of the client requesting. Used to stop encoding processes when needed.", + "schema": { + "type": "string" + } + }, + { + "name": "audioCodec", + "in": "query", + "description": "Optional. Specify an audio codec to encode to, e.g. mp3.", + "schema": { + "pattern": "^[a-zA-Z0-9\\-\\._,|]{0,40}$", + "type": "string" + } + }, + { + "name": "enableAutoStreamCopy", + "in": "query", + "description": "Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.", + "schema": { + "type": "boolean" + } + }, + { + "name": "allowVideoStreamCopy", + "in": "query", + "description": "Whether or not to allow copying of the video stream url.", + "schema": { + "type": "boolean" + } + }, + { + "name": "allowAudioStreamCopy", + "in": "query", + "description": "Whether or not to allow copying of the audio stream url.", + "schema": { + "type": "boolean" + } + }, + { + "name": "breakOnNonKeyFrames", + "in": "query", + "description": "Optional. Whether to break on non key frames.", + "schema": { + "type": "boolean" + } + }, + { + "name": "audioSampleRate", + "in": "query", + "description": "Optional. Specify a specific audio sample rate, e.g. 44100.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxAudioBitDepth", + "in": "query", + "description": "Optional. The maximum audio bit depth.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "audioBitRate", + "in": "query", + "description": "Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "audioChannels", + "in": "query", + "description": "Optional. Specify a specific number of audio channels to encode to, e.g. 2.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxAudioChannels", + "in": "query", + "description": "Optional. Specify a maximum number of audio channels to encode to, e.g. 2.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "profile", + "in": "query", + "description": "Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.", + "schema": { + "type": "string" + } + }, + { + "name": "level", + "in": "query", + "description": "Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.", + "schema": { + "pattern": "-?[0-9]+(?:\\.[0-9]+)?", + "type": "string" + } + }, + { + "name": "framerate", + "in": "query", + "description": "Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.", + "schema": { + "type": "number", + "format": "float" + } + }, + { + "name": "maxFramerate", + "in": "query", + "description": "Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.", + "schema": { + "type": "number", + "format": "float" + } + }, + { + "name": "copyTimestamps", + "in": "query", + "description": "Whether or not to copy timestamps when transcoding with an offset. Defaults to false.", + "schema": { + "type": "boolean" + } + }, + { + "name": "startTimeTicks", + "in": "query", + "description": "Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.", + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "width", + "in": "query", + "description": "Optional. The fixed horizontal resolution of the encoded video.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "height", + "in": "query", + "description": "Optional. The fixed vertical resolution of the encoded video.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxWidth", + "in": "query", + "description": "Optional. The maximum horizontal resolution of the encoded video.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxHeight", + "in": "query", + "description": "Optional. The maximum vertical resolution of the encoded video.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "videoBitRate", + "in": "query", + "description": "Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "subtitleStreamIndex", + "in": "query", + "description": "Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "subtitleMethod", + "in": "query", + "description": "Optional. Specify the subtitle delivery method.", + "schema": { + "enum": [ + "Encode", + "Embed", + "External", + "Hls", + "Drop" + ], + "allOf": [ + { + "$ref": "#/components/schemas/SubtitleDeliveryMethod" + } + ] + } + }, + { + "name": "maxRefFrames", + "in": "query", + "description": "Optional.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxVideoBitDepth", + "in": "query", + "description": "Optional. The maximum video bit depth.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "requireAvc", + "in": "query", + "description": "Optional. Whether to require avc.", + "schema": { + "type": "boolean" + } + }, + { + "name": "deInterlace", + "in": "query", + "description": "Optional. Whether to deinterlace the video.", + "schema": { + "type": "boolean" + } + }, + { + "name": "requireNonAnamorphic", + "in": "query", + "description": "Optional. Whether to require a non anamorphic stream.", + "schema": { + "type": "boolean" + } + }, + { + "name": "transcodingMaxAudioChannels", + "in": "query", + "description": "Optional. The maximum number of audio channels to transcode.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "cpuCoreLimit", + "in": "query", + "description": "Optional. The limit of how many cpu cores to use.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "liveStreamId", + "in": "query", + "description": "The live stream id.", + "schema": { + "type": "string" + } + }, + { + "name": "enableMpegtsM2TsMode", + "in": "query", + "description": "Optional. Whether to enable the MpegtsM2Ts mode.", + "schema": { + "type": "boolean" + } + }, + { + "name": "videoCodec", + "in": "query", + "description": "Optional. Specify a video codec to encode to, e.g. h264.", + "schema": { + "pattern": "^[a-zA-Z0-9\\-\\._,|]{0,40}$", + "type": "string" + } + }, + { + "name": "subtitleCodec", + "in": "query", + "description": "Optional. Specify a subtitle codec to encode to.", + "schema": { + "pattern": "^[a-zA-Z0-9\\-\\._,|]{0,40}$", + "type": "string" + } + }, + { + "name": "transcodeReasons", + "in": "query", + "description": "Optional. The transcoding reason.", + "schema": { + "type": "string" + } + }, + { + "name": "audioStreamIndex", + "in": "query", + "description": "Optional. The index of the audio stream to use. If omitted the first audio stream will be used.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "videoStreamIndex", + "in": "query", + "description": "Optional. The index of the video stream to use. If omitted the first video stream will be used.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "context", + "in": "query", + "description": "Optional. The MediaBrowser.Model.Dlna.EncodingContext.", + "schema": { + "enum": [ + "Streaming", + "Static" + ], + "allOf": [ + { + "$ref": "#/components/schemas/EncodingContext" + } + ] + } + }, + { + "name": "streamOptions", + "in": "query", + "description": "Optional. The streaming options.", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string", + "nullable": true + } + } + }, + { + "name": "enableAudioVbrEncoding", + "in": "query", + "description": "Optional. Whether to enable Audio Encoding.", + "schema": { + "type": "boolean", + "default": true + } + }, + { + "name": "alwaysBurnInSubtitleWhenTranscoding", + "in": "query", + "description": "Whether to always burn in subtitles when transcoding.", + "schema": { + "type": "boolean", + "default": false + } + } + ], + "responses": { + "200": { + "description": "Video stream returned.", + "content": { + "application/x-mpegURL": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Videos/{itemId}/master.m3u8": { + "get": { + "tags": [ + "DynamicHls" + ], + "summary": "Gets a video hls playlist stream.", + "operationId": "GetMasterHlsVideoPlaylist", + "parameters": [ + { + "name": "itemId", + "in": "path", + "description": "The item id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "static", + "in": "query", + "description": "Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.", + "schema": { + "type": "boolean" + } + }, + { + "name": "params", + "in": "query", + "description": "The streaming parameters.", + "schema": { + "type": "string" + } + }, + { + "name": "tag", + "in": "query", + "description": "The tag.", + "schema": { + "type": "string" + } + }, + { + "name": "deviceProfileId", + "in": "query", + "description": "Optional. The dlna device profile id to utilize.", + "deprecated": true, + "schema": { + "type": "string" + } + }, + { + "name": "playSessionId", + "in": "query", + "description": "The play session id.", + "schema": { + "type": "string" + } + }, + { + "name": "segmentContainer", + "in": "query", + "description": "The segment container.", + "schema": { + "pattern": "^[a-zA-Z0-9\\-\\._,|]{0,40}$", + "type": "string" + } + }, + { + "name": "segmentLength", + "in": "query", + "description": "The segment length.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "minSegments", + "in": "query", + "description": "The minimum number of segments.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "mediaSourceId", + "in": "query", + "description": "The media version id, if playing an alternate version.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "deviceId", + "in": "query", + "description": "The device id of the client requesting. Used to stop encoding processes when needed.", + "schema": { + "type": "string" + } + }, + { + "name": "audioCodec", + "in": "query", + "description": "Optional. Specify an audio codec to encode to, e.g. mp3.", + "schema": { + "pattern": "^[a-zA-Z0-9\\-\\._,|]{0,40}$", + "type": "string" + } + }, + { + "name": "enableAutoStreamCopy", + "in": "query", + "description": "Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.", + "schema": { + "type": "boolean" + } + }, + { + "name": "allowVideoStreamCopy", + "in": "query", + "description": "Whether or not to allow copying of the video stream url.", + "schema": { + "type": "boolean" + } + }, + { + "name": "allowAudioStreamCopy", + "in": "query", + "description": "Whether or not to allow copying of the audio stream url.", + "schema": { + "type": "boolean" + } + }, + { + "name": "breakOnNonKeyFrames", + "in": "query", + "description": "Optional. Whether to break on non key frames.", + "schema": { + "type": "boolean" + } + }, + { + "name": "audioSampleRate", + "in": "query", + "description": "Optional. Specify a specific audio sample rate, e.g. 44100.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxAudioBitDepth", + "in": "query", + "description": "Optional. The maximum audio bit depth.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "audioBitRate", + "in": "query", + "description": "Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "audioChannels", + "in": "query", + "description": "Optional. Specify a specific number of audio channels to encode to, e.g. 2.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxAudioChannels", + "in": "query", + "description": "Optional. Specify a maximum number of audio channels to encode to, e.g. 2.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "profile", + "in": "query", + "description": "Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.", + "schema": { + "type": "string" + } + }, + { + "name": "level", + "in": "query", + "description": "Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.", + "schema": { + "pattern": "-?[0-9]+(?:\\.[0-9]+)?", + "type": "string" + } + }, + { + "name": "framerate", + "in": "query", + "description": "Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.", + "schema": { + "type": "number", + "format": "float" + } + }, + { + "name": "maxFramerate", + "in": "query", + "description": "Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.", + "schema": { + "type": "number", + "format": "float" + } + }, + { + "name": "copyTimestamps", + "in": "query", + "description": "Whether or not to copy timestamps when transcoding with an offset. Defaults to false.", + "schema": { + "type": "boolean" + } + }, + { + "name": "startTimeTicks", + "in": "query", + "description": "Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.", + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "width", + "in": "query", + "description": "Optional. The fixed horizontal resolution of the encoded video.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "height", + "in": "query", + "description": "Optional. The fixed vertical resolution of the encoded video.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxWidth", + "in": "query", + "description": "Optional. The maximum horizontal resolution of the encoded video.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxHeight", + "in": "query", + "description": "Optional. The maximum vertical resolution of the encoded video.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "videoBitRate", + "in": "query", + "description": "Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "subtitleStreamIndex", + "in": "query", + "description": "Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "subtitleMethod", + "in": "query", + "description": "Optional. Specify the subtitle delivery method.", + "schema": { + "enum": [ + "Encode", + "Embed", + "External", + "Hls", + "Drop" + ], + "allOf": [ + { + "$ref": "#/components/schemas/SubtitleDeliveryMethod" + } + ] + } + }, + { + "name": "maxRefFrames", + "in": "query", + "description": "Optional.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxVideoBitDepth", + "in": "query", + "description": "Optional. The maximum video bit depth.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "requireAvc", + "in": "query", + "description": "Optional. Whether to require avc.", + "schema": { + "type": "boolean" + } + }, + { + "name": "deInterlace", + "in": "query", + "description": "Optional. Whether to deinterlace the video.", + "schema": { + "type": "boolean" + } + }, + { + "name": "requireNonAnamorphic", + "in": "query", + "description": "Optional. Whether to require a non anamorphic stream.", + "schema": { + "type": "boolean" + } + }, + { + "name": "transcodingMaxAudioChannels", + "in": "query", + "description": "Optional. The maximum number of audio channels to transcode.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "cpuCoreLimit", + "in": "query", + "description": "Optional. The limit of how many cpu cores to use.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "liveStreamId", + "in": "query", + "description": "The live stream id.", + "schema": { + "type": "string" + } + }, + { + "name": "enableMpegtsM2TsMode", + "in": "query", + "description": "Optional. Whether to enable the MpegtsM2Ts mode.", + "schema": { + "type": "boolean" + } + }, + { + "name": "videoCodec", + "in": "query", + "description": "Optional. Specify a video codec to encode to, e.g. h264.", + "schema": { + "pattern": "^[a-zA-Z0-9\\-\\._,|]{0,40}$", + "type": "string" + } + }, + { + "name": "subtitleCodec", + "in": "query", + "description": "Optional. Specify a subtitle codec to encode to.", + "schema": { + "pattern": "^[a-zA-Z0-9\\-\\._,|]{0,40}$", + "type": "string" + } + }, + { + "name": "transcodeReasons", + "in": "query", + "description": "Optional. The transcoding reason.", + "schema": { + "type": "string" + } + }, + { + "name": "audioStreamIndex", + "in": "query", + "description": "Optional. The index of the audio stream to use. If omitted the first audio stream will be used.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "videoStreamIndex", + "in": "query", + "description": "Optional. The index of the video stream to use. If omitted the first video stream will be used.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "context", + "in": "query", + "description": "Optional. The MediaBrowser.Model.Dlna.EncodingContext.", + "schema": { + "enum": [ + "Streaming", + "Static" + ], + "allOf": [ + { + "$ref": "#/components/schemas/EncodingContext" + } + ] + } + }, + { + "name": "streamOptions", + "in": "query", + "description": "Optional. The streaming options.", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string", + "nullable": true + } + } + }, + { + "name": "enableAdaptiveBitrateStreaming", + "in": "query", + "description": "Enable adaptive bitrate streaming.", + "schema": { + "type": "boolean", + "default": false + } + }, + { + "name": "enableTrickplay", + "in": "query", + "description": "Enable trickplay image playlists being added to master playlist.", + "schema": { + "type": "boolean", + "default": true + } + }, + { + "name": "enableAudioVbrEncoding", + "in": "query", + "description": "Whether to enable Audio Encoding.", + "schema": { + "type": "boolean", + "default": true + } + }, + { + "name": "alwaysBurnInSubtitleWhenTranscoding", + "in": "query", + "description": "Whether to always burn in subtitles when transcoding.", + "schema": { + "type": "boolean", + "default": false + } + } + ], + "responses": { + "200": { + "description": "Video stream returned.", + "content": { + "application/x-mpegURL": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + }, + "head": { + "tags": [ + "DynamicHls" + ], + "summary": "Gets a video hls playlist stream.", + "operationId": "HeadMasterHlsVideoPlaylist", + "parameters": [ + { + "name": "itemId", + "in": "path", + "description": "The item id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "static", + "in": "query", + "description": "Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.", + "schema": { + "type": "boolean" + } + }, + { + "name": "params", + "in": "query", + "description": "The streaming parameters.", + "schema": { + "type": "string" + } + }, + { + "name": "tag", + "in": "query", + "description": "The tag.", + "schema": { + "type": "string" + } + }, + { + "name": "deviceProfileId", + "in": "query", + "description": "Optional. The dlna device profile id to utilize.", + "deprecated": true, + "schema": { + "type": "string" + } + }, + { + "name": "playSessionId", + "in": "query", + "description": "The play session id.", + "schema": { + "type": "string" + } + }, + { + "name": "segmentContainer", + "in": "query", + "description": "The segment container.", + "schema": { + "pattern": "^[a-zA-Z0-9\\-\\._,|]{0,40}$", + "type": "string" + } + }, + { + "name": "segmentLength", + "in": "query", + "description": "The segment length.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "minSegments", + "in": "query", + "description": "The minimum number of segments.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "mediaSourceId", + "in": "query", + "description": "The media version id, if playing an alternate version.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "deviceId", + "in": "query", + "description": "The device id of the client requesting. Used to stop encoding processes when needed.", + "schema": { + "type": "string" + } + }, + { + "name": "audioCodec", + "in": "query", + "description": "Optional. Specify an audio codec to encode to, e.g. mp3.", + "schema": { + "pattern": "^[a-zA-Z0-9\\-\\._,|]{0,40}$", + "type": "string" + } + }, + { + "name": "enableAutoStreamCopy", + "in": "query", + "description": "Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.", + "schema": { + "type": "boolean" + } + }, + { + "name": "allowVideoStreamCopy", + "in": "query", + "description": "Whether or not to allow copying of the video stream url.", + "schema": { + "type": "boolean" + } + }, + { + "name": "allowAudioStreamCopy", + "in": "query", + "description": "Whether or not to allow copying of the audio stream url.", + "schema": { + "type": "boolean" + } + }, + { + "name": "breakOnNonKeyFrames", + "in": "query", + "description": "Optional. Whether to break on non key frames.", + "schema": { + "type": "boolean" + } + }, + { + "name": "audioSampleRate", + "in": "query", + "description": "Optional. Specify a specific audio sample rate, e.g. 44100.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxAudioBitDepth", + "in": "query", + "description": "Optional. The maximum audio bit depth.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "audioBitRate", + "in": "query", + "description": "Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "audioChannels", + "in": "query", + "description": "Optional. Specify a specific number of audio channels to encode to, e.g. 2.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxAudioChannels", + "in": "query", + "description": "Optional. Specify a maximum number of audio channels to encode to, e.g. 2.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "profile", + "in": "query", + "description": "Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.", + "schema": { + "type": "string" + } + }, + { + "name": "level", + "in": "query", + "description": "Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.", + "schema": { + "pattern": "-?[0-9]+(?:\\.[0-9]+)?", + "type": "string" + } + }, + { + "name": "framerate", + "in": "query", + "description": "Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.", + "schema": { + "type": "number", + "format": "float" + } + }, + { + "name": "maxFramerate", + "in": "query", + "description": "Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.", + "schema": { + "type": "number", + "format": "float" + } + }, + { + "name": "copyTimestamps", + "in": "query", + "description": "Whether or not to copy timestamps when transcoding with an offset. Defaults to false.", + "schema": { + "type": "boolean" + } + }, + { + "name": "startTimeTicks", + "in": "query", + "description": "Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.", + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "width", + "in": "query", + "description": "Optional. The fixed horizontal resolution of the encoded video.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "height", + "in": "query", + "description": "Optional. The fixed vertical resolution of the encoded video.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxWidth", + "in": "query", + "description": "Optional. The maximum horizontal resolution of the encoded video.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxHeight", + "in": "query", + "description": "Optional. The maximum vertical resolution of the encoded video.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "videoBitRate", + "in": "query", + "description": "Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "subtitleStreamIndex", + "in": "query", + "description": "Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "subtitleMethod", + "in": "query", + "description": "Optional. Specify the subtitle delivery method.", + "schema": { + "enum": [ + "Encode", + "Embed", + "External", + "Hls", + "Drop" + ], + "allOf": [ + { + "$ref": "#/components/schemas/SubtitleDeliveryMethod" + } + ] + } + }, + { + "name": "maxRefFrames", + "in": "query", + "description": "Optional.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxVideoBitDepth", + "in": "query", + "description": "Optional. The maximum video bit depth.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "requireAvc", + "in": "query", + "description": "Optional. Whether to require avc.", + "schema": { + "type": "boolean" + } + }, + { + "name": "deInterlace", + "in": "query", + "description": "Optional. Whether to deinterlace the video.", + "schema": { + "type": "boolean" + } + }, + { + "name": "requireNonAnamorphic", + "in": "query", + "description": "Optional. Whether to require a non anamorphic stream.", + "schema": { + "type": "boolean" + } + }, + { + "name": "transcodingMaxAudioChannels", + "in": "query", + "description": "Optional. The maximum number of audio channels to transcode.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "cpuCoreLimit", + "in": "query", + "description": "Optional. The limit of how many cpu cores to use.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "liveStreamId", + "in": "query", + "description": "The live stream id.", + "schema": { + "type": "string" + } + }, + { + "name": "enableMpegtsM2TsMode", + "in": "query", + "description": "Optional. Whether to enable the MpegtsM2Ts mode.", + "schema": { + "type": "boolean" + } + }, + { + "name": "videoCodec", + "in": "query", + "description": "Optional. Specify a video codec to encode to, e.g. h264.", + "schema": { + "pattern": "^[a-zA-Z0-9\\-\\._,|]{0,40}$", + "type": "string" + } + }, + { + "name": "subtitleCodec", + "in": "query", + "description": "Optional. Specify a subtitle codec to encode to.", + "schema": { + "pattern": "^[a-zA-Z0-9\\-\\._,|]{0,40}$", + "type": "string" + } + }, + { + "name": "transcodeReasons", + "in": "query", + "description": "Optional. The transcoding reason.", + "schema": { + "type": "string" + } + }, + { + "name": "audioStreamIndex", + "in": "query", + "description": "Optional. The index of the audio stream to use. If omitted the first audio stream will be used.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "videoStreamIndex", + "in": "query", + "description": "Optional. The index of the video stream to use. If omitted the first video stream will be used.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "context", + "in": "query", + "description": "Optional. The MediaBrowser.Model.Dlna.EncodingContext.", + "schema": { + "enum": [ + "Streaming", + "Static" + ], + "allOf": [ + { + "$ref": "#/components/schemas/EncodingContext" + } + ] + } + }, + { + "name": "streamOptions", + "in": "query", + "description": "Optional. The streaming options.", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string", + "nullable": true + } + } + }, + { + "name": "enableAdaptiveBitrateStreaming", + "in": "query", + "description": "Enable adaptive bitrate streaming.", + "schema": { + "type": "boolean", + "default": false + } + }, + { + "name": "enableTrickplay", + "in": "query", + "description": "Enable trickplay image playlists being added to master playlist.", + "schema": { + "type": "boolean", + "default": true + } + }, + { + "name": "enableAudioVbrEncoding", + "in": "query", + "description": "Whether to enable Audio Encoding.", + "schema": { + "type": "boolean", + "default": true + } + }, + { + "name": "alwaysBurnInSubtitleWhenTranscoding", + "in": "query", + "description": "Whether to always burn in subtitles when transcoding.", + "schema": { + "type": "boolean", + "default": false + } + } + ], + "responses": { + "200": { + "description": "Video stream returned.", + "content": { + "application/x-mpegURL": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Environment/DefaultDirectoryBrowser": { + "get": { + "tags": [ + "Environment" + ], + "summary": "Get Default directory browser.", + "operationId": "GetDefaultDirectoryBrowser", + "responses": { + "200": { + "description": "Default directory browser returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultDirectoryBrowserInfoDto" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/DefaultDirectoryBrowserInfoDto" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/DefaultDirectoryBrowserInfoDto" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "FirstTimeSetupOrElevated", + "DefaultAuthorization" + ] + } + ] + } + }, + "/Environment/DirectoryContents": { + "get": { + "tags": [ + "Environment" + ], + "summary": "Gets the contents of a given directory in the file system.", + "operationId": "GetDirectoryContents", + "parameters": [ + { + "name": "path", + "in": "query", + "description": "The path.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "includeFiles", + "in": "query", + "description": "An optional filter to include or exclude files from the results. true/false.", + "schema": { + "type": "boolean", + "default": false + } + }, + { + "name": "includeDirectories", + "in": "query", + "description": "An optional filter to include or exclude folders from the results. true/false.", + "schema": { + "type": "boolean", + "default": false + } + } + ], + "responses": { + "200": { + "description": "Directory contents returned.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FileSystemEntryInfo" + } + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FileSystemEntryInfo" + } + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FileSystemEntryInfo" + } + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "FirstTimeSetupOrElevated", + "DefaultAuthorization" + ] + } + ] + } + }, + "/Environment/Drives": { + "get": { + "tags": [ + "Environment" + ], + "summary": "Gets available drives from the server's file system.", + "operationId": "GetDrives", + "responses": { + "200": { + "description": "List of entries returned.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FileSystemEntryInfo" + } + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FileSystemEntryInfo" + } + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FileSystemEntryInfo" + } + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "FirstTimeSetupOrElevated", + "DefaultAuthorization" + ] + } + ] + } + }, + "/Environment/NetworkShares": { + "get": { + "tags": [ + "Environment" + ], + "summary": "Gets network paths.", + "operationId": "GetNetworkShares", + "responses": { + "200": { + "description": "Empty array returned.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FileSystemEntryInfo" + } + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FileSystemEntryInfo" + } + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FileSystemEntryInfo" + } + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "deprecated": true, + "security": [ + { + "CustomAuthentication": [ + "FirstTimeSetupOrElevated", + "DefaultAuthorization" + ] + } + ] + } + }, + "/Environment/ParentPath": { + "get": { + "tags": [ + "Environment" + ], + "summary": "Gets the parent path of a given path.", + "operationId": "GetParentPath", + "parameters": [ + { + "name": "path", + "in": "query", + "description": "The path.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "string" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "type": "string" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "type": "string" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "FirstTimeSetupOrElevated", + "DefaultAuthorization" + ] + } + ] + } + }, + "/Environment/ValidatePath": { + "post": { + "tags": [ + "Environment" + ], + "summary": "Validates path.", + "operationId": "ValidatePath", + "requestBody": { + "description": "Validate request object.", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/ValidatePathDto" + } + ], + "description": "Validate path object." + } + }, + "text/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/ValidatePathDto" + } + ], + "description": "Validate path object." + } + }, + "application/*+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/ValidatePathDto" + } + ], + "description": "Validate path object." + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Path validated." + }, + "404": { + "description": "Path not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "FirstTimeSetupOrElevated", + "DefaultAuthorization" + ] + } + ] + } + }, + "/Items/Filters": { + "get": { + "tags": [ + "Filter" + ], + "summary": "Gets legacy query filters.", + "operationId": "GetQueryFiltersLegacy", + "parameters": [ + { + "name": "userId", + "in": "query", + "description": "Optional. User id.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "parentId", + "in": "query", + "description": "Optional. Parent id.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "includeItemTypes", + "in": "query", + "description": "Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BaseItemKind" + } + } + }, + { + "name": "mediaTypes", + "in": "query", + "description": "Optional. Filter by MediaType. Allows multiple, comma delimited.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MediaType" + } + } + } + ], + "responses": { + "200": { + "description": "Legacy filters retrieved.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/QueryFiltersLegacy" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/QueryFiltersLegacy" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/QueryFiltersLegacy" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Items/Filters2": { + "get": { + "tags": [ + "Filter" + ], + "summary": "Gets query filters.", + "operationId": "GetQueryFilters", + "parameters": [ + { + "name": "userId", + "in": "query", + "description": "Optional. User id.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "parentId", + "in": "query", + "description": "Optional. Specify this to localize the search to a specific item or folder. Omit to use the root.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "includeItemTypes", + "in": "query", + "description": "Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BaseItemKind" + } + } + }, + { + "name": "isAiring", + "in": "query", + "description": "Optional. Is item airing.", + "schema": { + "type": "boolean" + } + }, + { + "name": "isMovie", + "in": "query", + "description": "Optional. Is item movie.", + "schema": { + "type": "boolean" + } + }, + { + "name": "isSports", + "in": "query", + "description": "Optional. Is item sports.", + "schema": { + "type": "boolean" + } + }, + { + "name": "isKids", + "in": "query", + "description": "Optional. Is item kids.", + "schema": { + "type": "boolean" + } + }, + { + "name": "isNews", + "in": "query", + "description": "Optional. Is item news.", + "schema": { + "type": "boolean" + } + }, + { + "name": "isSeries", + "in": "query", + "description": "Optional. Is item series.", + "schema": { + "type": "boolean" + } + }, + { + "name": "recursive", + "in": "query", + "description": "Optional. Search recursive.", + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "description": "Filters retrieved.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/QueryFilters" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/QueryFilters" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/QueryFilters" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Genres": { + "get": { + "tags": [ + "Genres" + ], + "summary": "Gets all genres from a given item, folder, or the entire library.", + "operationId": "GetGenres", + "parameters": [ + { + "name": "startIndex", + "in": "query", + "description": "Optional. The record index to start at. All items with a lower index will be dropped from the results.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "limit", + "in": "query", + "description": "Optional. The maximum number of records to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "searchTerm", + "in": "query", + "description": "The search term.", + "schema": { + "type": "string" + } + }, + { + "name": "parentId", + "in": "query", + "description": "Specify this to localize the search to a specific item or folder. Omit to use the root.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "fields", + "in": "query", + "description": "Optional. Specify additional fields of information to return in the output.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ItemFields" + } + } + }, + { + "name": "excludeItemTypes", + "in": "query", + "description": "Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BaseItemKind" + } + } + }, + { + "name": "includeItemTypes", + "in": "query", + "description": "Optional. If specified, results will be filtered in based on item type. This allows multiple, comma delimited.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BaseItemKind" + } + } + }, + { + "name": "isFavorite", + "in": "query", + "description": "Optional filter by items that are marked as favorite, or not.", + "schema": { + "type": "boolean" + } + }, + { + "name": "imageTypeLimit", + "in": "query", + "description": "Optional, the max number of images to return, per image type.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "enableImageTypes", + "in": "query", + "description": "Optional. The image types to include in the output.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ImageType" + } + } + }, + { + "name": "userId", + "in": "query", + "description": "User id.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "nameStartsWithOrGreater", + "in": "query", + "description": "Optional filter by items whose name is sorted equally or greater than a given input string.", + "schema": { + "type": "string" + } + }, + { + "name": "nameStartsWith", + "in": "query", + "description": "Optional filter by items whose name is sorted equally than a given input string.", + "schema": { + "type": "string" + } + }, + { + "name": "nameLessThan", + "in": "query", + "description": "Optional filter by items whose name is equally or lesser than a given input string.", + "schema": { + "type": "string" + } + }, + { + "name": "sortBy", + "in": "query", + "description": "Optional. Specify one or more sort orders, comma delimited.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ItemSortBy" + } + } + }, + { + "name": "sortOrder", + "in": "query", + "description": "Sort Order - Ascending,Descending.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SortOrder" + } + } + }, + { + "name": "enableImages", + "in": "query", + "description": "Optional, include image information in output.", + "schema": { + "type": "boolean", + "default": true + } + }, + { + "name": "enableTotalRecordCount", + "in": "query", + "description": "Optional. Include total record count.", + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Genres returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Genres/{genreName}": { + "get": { + "tags": [ + "Genres" + ], + "summary": "Gets a genre, by name.", + "operationId": "GetGenre", + "parameters": [ + { + "name": "genreName", + "in": "path", + "description": "The genre name.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "userId", + "in": "query", + "description": "The user id.", + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Genres returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BaseItemDto" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDto" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDto" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Audio/{itemId}/hls/{segmentId}/stream.aac": { + "get": { + "tags": [ + "HlsSegment" + ], + "summary": "Gets the specified audio segment for an audio item.", + "operationId": "GetHlsAudioSegmentLegacyAac", + "parameters": [ + { + "name": "itemId", + "in": "path", + "description": "The item id.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "segmentId", + "in": "path", + "description": "The segment id.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Hls audio segment returned.", + "content": { + "audio/*": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + } + } + } + }, + "/Audio/{itemId}/hls/{segmentId}/stream.mp3": { + "get": { + "tags": [ + "HlsSegment" + ], + "summary": "Gets the specified audio segment for an audio item.", + "operationId": "GetHlsAudioSegmentLegacyMp3", + "parameters": [ + { + "name": "itemId", + "in": "path", + "description": "The item id.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "segmentId", + "in": "path", + "description": "The segment id.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Hls audio segment returned.", + "content": { + "audio/*": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + } + } + } + }, + "/Videos/{itemId}/hls/{playlistId}/{segmentId}.{segmentContainer}": { + "get": { + "tags": [ + "HlsSegment" + ], + "summary": "Gets a hls video segment.", + "operationId": "GetHlsVideoSegmentLegacy", + "parameters": [ + { + "name": "itemId", + "in": "path", + "description": "The item id.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "playlistId", + "in": "path", + "description": "The playlist id.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "segmentId", + "in": "path", + "description": "The segment id.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "segmentContainer", + "in": "path", + "description": "The segment container.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Hls video segment returned.", + "content": { + "video/*": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "404": { + "description": "Hls segment not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + } + } + } + }, + "/Videos/{itemId}/hls/{playlistId}/stream.m3u8": { + "get": { + "tags": [ + "HlsSegment" + ], + "summary": "Gets a hls video playlist.", + "operationId": "GetHlsPlaylistLegacy", + "parameters": [ + { + "name": "itemId", + "in": "path", + "description": "The video id.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "playlistId", + "in": "path", + "description": "The playlist id.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Hls video playlist returned.", + "content": { + "application/x-mpegURL": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Videos/ActiveEncodings": { + "delete": { + "tags": [ + "HlsSegment" + ], + "summary": "Stops an active encoding.", + "operationId": "StopEncodingProcess", + "parameters": [ + { + "name": "deviceId", + "in": "query", + "description": "The device id of the client requesting. Used to stop encoding processes when needed.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "playSessionId", + "in": "query", + "description": "The play session id.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Encoding stopped successfully." + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Artists/{name}/Images/{imageType}/{imageIndex}": { + "get": { + "tags": [ + "Image" + ], + "summary": "Get artist image by name.", + "operationId": "GetArtistImage", + "parameters": [ + { + "name": "name", + "in": "path", + "description": "Artist name.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "imageType", + "in": "path", + "description": "Image type.", + "required": true, + "schema": { + "enum": [ + "Primary", + "Art", + "Backdrop", + "Banner", + "Logo", + "Thumb", + "Disc", + "Box", + "Screenshot", + "Menu", + "Chapter", + "BoxRear", + "Profile" + ], + "allOf": [ + { + "$ref": "#/components/schemas/ImageType" + } + ], + "description": "Enum ImageType." + } + }, + { + "name": "tag", + "in": "query", + "description": "Optional. Supply the cache tag from the item object to receive strong caching headers.", + "schema": { + "type": "string" + } + }, + { + "name": "format", + "in": "query", + "description": "Determines the output format of the image - original,gif,jpg,png.", + "schema": { + "enum": [ + "Bmp", + "Gif", + "Jpg", + "Png", + "Webp", + "Svg" + ], + "allOf": [ + { + "$ref": "#/components/schemas/ImageFormat" + } + ] + } + }, + { + "name": "maxWidth", + "in": "query", + "description": "The maximum image width to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxHeight", + "in": "query", + "description": "The maximum image height to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "percentPlayed", + "in": "query", + "description": "Optional. Percent to render for the percent played overlay.", + "schema": { + "type": "number", + "format": "double" + } + }, + { + "name": "unplayedCount", + "in": "query", + "description": "Optional. Unplayed count overlay to render.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "width", + "in": "query", + "description": "The fixed image width to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "height", + "in": "query", + "description": "The fixed image height to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "quality", + "in": "query", + "description": "Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "fillWidth", + "in": "query", + "description": "Width of box to fill.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "fillHeight", + "in": "query", + "description": "Height of box to fill.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "blur", + "in": "query", + "description": "Optional. Blur image.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "backgroundColor", + "in": "query", + "description": "Optional. Apply a background color for transparent images.", + "schema": { + "type": "string" + } + }, + { + "name": "foregroundLayer", + "in": "query", + "description": "Optional. Apply a foreground layer on top of the image.", + "schema": { + "type": "string" + } + }, + { + "name": "imageIndex", + "in": "path", + "description": "Image index.", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Image stream returned.", + "content": { + "image/*": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "404": { + "description": "Item not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + } + } + }, + "head": { + "tags": [ + "Image" + ], + "summary": "Get artist image by name.", + "operationId": "HeadArtistImage", + "parameters": [ + { + "name": "name", + "in": "path", + "description": "Artist name.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "imageType", + "in": "path", + "description": "Image type.", + "required": true, + "schema": { + "enum": [ + "Primary", + "Art", + "Backdrop", + "Banner", + "Logo", + "Thumb", + "Disc", + "Box", + "Screenshot", + "Menu", + "Chapter", + "BoxRear", + "Profile" + ], + "allOf": [ + { + "$ref": "#/components/schemas/ImageType" + } + ], + "description": "Enum ImageType." + } + }, + { + "name": "tag", + "in": "query", + "description": "Optional. Supply the cache tag from the item object to receive strong caching headers.", + "schema": { + "type": "string" + } + }, + { + "name": "format", + "in": "query", + "description": "Determines the output format of the image - original,gif,jpg,png.", + "schema": { + "enum": [ + "Bmp", + "Gif", + "Jpg", + "Png", + "Webp", + "Svg" + ], + "allOf": [ + { + "$ref": "#/components/schemas/ImageFormat" + } + ] + } + }, + { + "name": "maxWidth", + "in": "query", + "description": "The maximum image width to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxHeight", + "in": "query", + "description": "The maximum image height to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "percentPlayed", + "in": "query", + "description": "Optional. Percent to render for the percent played overlay.", + "schema": { + "type": "number", + "format": "double" + } + }, + { + "name": "unplayedCount", + "in": "query", + "description": "Optional. Unplayed count overlay to render.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "width", + "in": "query", + "description": "The fixed image width to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "height", + "in": "query", + "description": "The fixed image height to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "quality", + "in": "query", + "description": "Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "fillWidth", + "in": "query", + "description": "Width of box to fill.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "fillHeight", + "in": "query", + "description": "Height of box to fill.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "blur", + "in": "query", + "description": "Optional. Blur image.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "backgroundColor", + "in": "query", + "description": "Optional. Apply a background color for transparent images.", + "schema": { + "type": "string" + } + }, + { + "name": "foregroundLayer", + "in": "query", + "description": "Optional. Apply a foreground layer on top of the image.", + "schema": { + "type": "string" + } + }, + { + "name": "imageIndex", + "in": "path", + "description": "Image index.", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Image stream returned.", + "content": { + "image/*": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "404": { + "description": "Item not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + } + } + } + }, + "/Branding/Splashscreen": { + "get": { + "tags": [ + "Image" + ], + "summary": "Generates or gets the splashscreen.", + "operationId": "GetSplashscreen", + "parameters": [ + { + "name": "tag", + "in": "query", + "description": "Supply the cache tag from the item object to receive strong caching headers.", + "schema": { + "type": "string" + } + }, + { + "name": "format", + "in": "query", + "description": "Determines the output format of the image - original,gif,jpg,png.", + "schema": { + "enum": [ + "Bmp", + "Gif", + "Jpg", + "Png", + "Webp", + "Svg" + ], + "allOf": [ + { + "$ref": "#/components/schemas/ImageFormat" + } + ] + } + } + ], + "responses": { + "200": { + "description": "Splashscreen returned successfully.", + "content": { + "image/*": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + } + } + }, + "post": { + "tags": [ + "Image" + ], + "summary": "Uploads a custom splashscreen.\r\nThe body is expected to the image contents base64 encoded.", + "operationId": "UploadCustomSplashscreen", + "requestBody": { + "content": { + "image/*": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "responses": { + "204": { + "description": "Successfully uploaded new splashscreen." + }, + "400": { + "description": "Error reading MimeType from uploaded image.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "User does not have permission to upload splashscreen..", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "CustomAuthentication": [ + "RequiresElevation" + ] + } + ] + }, + "delete": { + "tags": [ + "Image" + ], + "summary": "Delete a custom splashscreen.", + "operationId": "DeleteCustomSplashscreen", + "responses": { + "204": { + "description": "Successfully deleted the custom splashscreen." + }, + "403": { + "description": "User does not have permission to delete splashscreen.." + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "CustomAuthentication": [ + "RequiresElevation" + ] + } + ] + } + }, + "/Genres/{name}/Images/{imageType}": { + "get": { + "tags": [ + "Image" + ], + "summary": "Get genre image by name.", + "operationId": "GetGenreImage", + "parameters": [ + { + "name": "name", + "in": "path", + "description": "Genre name.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "imageType", + "in": "path", + "description": "Image type.", + "required": true, + "schema": { + "enum": [ + "Primary", + "Art", + "Backdrop", + "Banner", + "Logo", + "Thumb", + "Disc", + "Box", + "Screenshot", + "Menu", + "Chapter", + "BoxRear", + "Profile" + ], + "allOf": [ + { + "$ref": "#/components/schemas/ImageType" + } + ], + "description": "Enum ImageType." + } + }, + { + "name": "tag", + "in": "query", + "description": "Optional. Supply the cache tag from the item object to receive strong caching headers.", + "schema": { + "type": "string" + } + }, + { + "name": "format", + "in": "query", + "description": "Determines the output format of the image - original,gif,jpg,png.", + "schema": { + "enum": [ + "Bmp", + "Gif", + "Jpg", + "Png", + "Webp", + "Svg" + ], + "allOf": [ + { + "$ref": "#/components/schemas/ImageFormat" + } + ] + } + }, + { + "name": "maxWidth", + "in": "query", + "description": "The maximum image width to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxHeight", + "in": "query", + "description": "The maximum image height to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "percentPlayed", + "in": "query", + "description": "Optional. Percent to render for the percent played overlay.", + "schema": { + "type": "number", + "format": "double" + } + }, + { + "name": "unplayedCount", + "in": "query", + "description": "Optional. Unplayed count overlay to render.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "width", + "in": "query", + "description": "The fixed image width to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "height", + "in": "query", + "description": "The fixed image height to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "quality", + "in": "query", + "description": "Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "fillWidth", + "in": "query", + "description": "Width of box to fill.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "fillHeight", + "in": "query", + "description": "Height of box to fill.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "blur", + "in": "query", + "description": "Optional. Blur image.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "backgroundColor", + "in": "query", + "description": "Optional. Apply a background color for transparent images.", + "schema": { + "type": "string" + } + }, + { + "name": "foregroundLayer", + "in": "query", + "description": "Optional. Apply a foreground layer on top of the image.", + "schema": { + "type": "string" + } + }, + { + "name": "imageIndex", + "in": "query", + "description": "Image index.", + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Image stream returned.", + "content": { + "image/*": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "404": { + "description": "Item not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + } + } + }, + "head": { + "tags": [ + "Image" + ], + "summary": "Get genre image by name.", + "operationId": "HeadGenreImage", + "parameters": [ + { + "name": "name", + "in": "path", + "description": "Genre name.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "imageType", + "in": "path", + "description": "Image type.", + "required": true, + "schema": { + "enum": [ + "Primary", + "Art", + "Backdrop", + "Banner", + "Logo", + "Thumb", + "Disc", + "Box", + "Screenshot", + "Menu", + "Chapter", + "BoxRear", + "Profile" + ], + "allOf": [ + { + "$ref": "#/components/schemas/ImageType" + } + ], + "description": "Enum ImageType." + } + }, + { + "name": "tag", + "in": "query", + "description": "Optional. Supply the cache tag from the item object to receive strong caching headers.", + "schema": { + "type": "string" + } + }, + { + "name": "format", + "in": "query", + "description": "Determines the output format of the image - original,gif,jpg,png.", + "schema": { + "enum": [ + "Bmp", + "Gif", + "Jpg", + "Png", + "Webp", + "Svg" + ], + "allOf": [ + { + "$ref": "#/components/schemas/ImageFormat" + } + ] + } + }, + { + "name": "maxWidth", + "in": "query", + "description": "The maximum image width to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxHeight", + "in": "query", + "description": "The maximum image height to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "percentPlayed", + "in": "query", + "description": "Optional. Percent to render for the percent played overlay.", + "schema": { + "type": "number", + "format": "double" + } + }, + { + "name": "unplayedCount", + "in": "query", + "description": "Optional. Unplayed count overlay to render.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "width", + "in": "query", + "description": "The fixed image width to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "height", + "in": "query", + "description": "The fixed image height to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "quality", + "in": "query", + "description": "Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "fillWidth", + "in": "query", + "description": "Width of box to fill.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "fillHeight", + "in": "query", + "description": "Height of box to fill.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "blur", + "in": "query", + "description": "Optional. Blur image.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "backgroundColor", + "in": "query", + "description": "Optional. Apply a background color for transparent images.", + "schema": { + "type": "string" + } + }, + { + "name": "foregroundLayer", + "in": "query", + "description": "Optional. Apply a foreground layer on top of the image.", + "schema": { + "type": "string" + } + }, + { + "name": "imageIndex", + "in": "query", + "description": "Image index.", + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Image stream returned.", + "content": { + "image/*": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "404": { + "description": "Item not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + } + } + } + }, + "/Genres/{name}/Images/{imageType}/{imageIndex}": { + "get": { + "tags": [ + "Image" + ], + "summary": "Get genre image by name.", + "operationId": "GetGenreImageByIndex", + "parameters": [ + { + "name": "name", + "in": "path", + "description": "Genre name.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "imageType", + "in": "path", + "description": "Image type.", + "required": true, + "schema": { + "enum": [ + "Primary", + "Art", + "Backdrop", + "Banner", + "Logo", + "Thumb", + "Disc", + "Box", + "Screenshot", + "Menu", + "Chapter", + "BoxRear", + "Profile" + ], + "allOf": [ + { + "$ref": "#/components/schemas/ImageType" + } + ], + "description": "Enum ImageType." + } + }, + { + "name": "imageIndex", + "in": "path", + "description": "Image index.", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "tag", + "in": "query", + "description": "Optional. Supply the cache tag from the item object to receive strong caching headers.", + "schema": { + "type": "string" + } + }, + { + "name": "format", + "in": "query", + "description": "Determines the output format of the image - original,gif,jpg,png.", + "schema": { + "enum": [ + "Bmp", + "Gif", + "Jpg", + "Png", + "Webp", + "Svg" + ], + "allOf": [ + { + "$ref": "#/components/schemas/ImageFormat" + } + ] + } + }, + { + "name": "maxWidth", + "in": "query", + "description": "The maximum image width to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxHeight", + "in": "query", + "description": "The maximum image height to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "percentPlayed", + "in": "query", + "description": "Optional. Percent to render for the percent played overlay.", + "schema": { + "type": "number", + "format": "double" + } + }, + { + "name": "unplayedCount", + "in": "query", + "description": "Optional. Unplayed count overlay to render.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "width", + "in": "query", + "description": "The fixed image width to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "height", + "in": "query", + "description": "The fixed image height to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "quality", + "in": "query", + "description": "Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "fillWidth", + "in": "query", + "description": "Width of box to fill.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "fillHeight", + "in": "query", + "description": "Height of box to fill.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "blur", + "in": "query", + "description": "Optional. Blur image.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "backgroundColor", + "in": "query", + "description": "Optional. Apply a background color for transparent images.", + "schema": { + "type": "string" + } + }, + { + "name": "foregroundLayer", + "in": "query", + "description": "Optional. Apply a foreground layer on top of the image.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Image stream returned.", + "content": { + "image/*": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "404": { + "description": "Item not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + } + } + }, + "head": { + "tags": [ + "Image" + ], + "summary": "Get genre image by name.", + "operationId": "HeadGenreImageByIndex", + "parameters": [ + { + "name": "name", + "in": "path", + "description": "Genre name.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "imageType", + "in": "path", + "description": "Image type.", + "required": true, + "schema": { + "enum": [ + "Primary", + "Art", + "Backdrop", + "Banner", + "Logo", + "Thumb", + "Disc", + "Box", + "Screenshot", + "Menu", + "Chapter", + "BoxRear", + "Profile" + ], + "allOf": [ + { + "$ref": "#/components/schemas/ImageType" + } + ], + "description": "Enum ImageType." + } + }, + { + "name": "imageIndex", + "in": "path", + "description": "Image index.", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "tag", + "in": "query", + "description": "Optional. Supply the cache tag from the item object to receive strong caching headers.", + "schema": { + "type": "string" + } + }, + { + "name": "format", + "in": "query", + "description": "Determines the output format of the image - original,gif,jpg,png.", + "schema": { + "enum": [ + "Bmp", + "Gif", + "Jpg", + "Png", + "Webp", + "Svg" + ], + "allOf": [ + { + "$ref": "#/components/schemas/ImageFormat" + } + ] + } + }, + { + "name": "maxWidth", + "in": "query", + "description": "The maximum image width to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxHeight", + "in": "query", + "description": "The maximum image height to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "percentPlayed", + "in": "query", + "description": "Optional. Percent to render for the percent played overlay.", + "schema": { + "type": "number", + "format": "double" + } + }, + { + "name": "unplayedCount", + "in": "query", + "description": "Optional. Unplayed count overlay to render.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "width", + "in": "query", + "description": "The fixed image width to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "height", + "in": "query", + "description": "The fixed image height to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "quality", + "in": "query", + "description": "Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "fillWidth", + "in": "query", + "description": "Width of box to fill.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "fillHeight", + "in": "query", + "description": "Height of box to fill.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "blur", + "in": "query", + "description": "Optional. Blur image.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "backgroundColor", + "in": "query", + "description": "Optional. Apply a background color for transparent images.", + "schema": { + "type": "string" + } + }, + { + "name": "foregroundLayer", + "in": "query", + "description": "Optional. Apply a foreground layer on top of the image.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Image stream returned.", + "content": { + "image/*": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "404": { + "description": "Item not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + } + } + } + }, + "/Items/{itemId}/Images": { + "get": { + "tags": [ + "Image" + ], + "summary": "Get item image infos.", + "operationId": "GetItemImageInfos", + "parameters": [ + { + "name": "itemId", + "in": "path", + "description": "Item id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Item images returned.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ImageInfo" + } + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ImageInfo" + } + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ImageInfo" + } + } + } + } + }, + "404": { + "description": "Item not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Items/{itemId}/Images/{imageType}": { + "delete": { + "tags": [ + "Image" + ], + "summary": "Delete an item's image.", + "operationId": "DeleteItemImage", + "parameters": [ + { + "name": "itemId", + "in": "path", + "description": "Item id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "imageType", + "in": "path", + "description": "Image type.", + "required": true, + "schema": { + "enum": [ + "Primary", + "Art", + "Backdrop", + "Banner", + "Logo", + "Thumb", + "Disc", + "Box", + "Screenshot", + "Menu", + "Chapter", + "BoxRear", + "Profile" + ], + "allOf": [ + { + "$ref": "#/components/schemas/ImageType" + } + ], + "description": "Enum ImageType." + } + }, + { + "name": "imageIndex", + "in": "query", + "description": "The image index.", + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "204": { + "description": "Image deleted." + }, + "404": { + "description": "Item not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "RequiresElevation" + ] + } + ] + }, + "post": { + "tags": [ + "Image" + ], + "summary": "Set item image.", + "operationId": "SetItemImage", + "parameters": [ + { + "name": "itemId", + "in": "path", + "description": "Item id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "imageType", + "in": "path", + "description": "Image type.", + "required": true, + "schema": { + "enum": [ + "Primary", + "Art", + "Backdrop", + "Banner", + "Logo", + "Thumb", + "Disc", + "Box", + "Screenshot", + "Menu", + "Chapter", + "BoxRear", + "Profile" + ], + "allOf": [ + { + "$ref": "#/components/schemas/ImageType" + } + ], + "description": "Enum ImageType." + } + } + ], + "requestBody": { + "content": { + "image/*": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "responses": { + "204": { + "description": "Image saved." + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Item not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "RequiresElevation" + ] + } + ] + }, + "get": { + "tags": [ + "Image" + ], + "summary": "Gets the item's image.", + "operationId": "GetItemImage", + "parameters": [ + { + "name": "itemId", + "in": "path", + "description": "Item id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "imageType", + "in": "path", + "description": "Image type.", + "required": true, + "schema": { + "enum": [ + "Primary", + "Art", + "Backdrop", + "Banner", + "Logo", + "Thumb", + "Disc", + "Box", + "Screenshot", + "Menu", + "Chapter", + "BoxRear", + "Profile" + ], + "allOf": [ + { + "$ref": "#/components/schemas/ImageType" + } + ], + "description": "Enum ImageType." + } + }, + { + "name": "maxWidth", + "in": "query", + "description": "The maximum image width to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxHeight", + "in": "query", + "description": "The maximum image height to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "width", + "in": "query", + "description": "The fixed image width to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "height", + "in": "query", + "description": "The fixed image height to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "quality", + "in": "query", + "description": "Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "fillWidth", + "in": "query", + "description": "Width of box to fill.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "fillHeight", + "in": "query", + "description": "Height of box to fill.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "tag", + "in": "query", + "description": "Optional. Supply the cache tag from the item object to receive strong caching headers.", + "schema": { + "type": "string" + } + }, + { + "name": "format", + "in": "query", + "description": "Optional. The MediaBrowser.Model.Drawing.ImageFormat of the returned image.", + "schema": { + "enum": [ + "Bmp", + "Gif", + "Jpg", + "Png", + "Webp", + "Svg" + ], + "allOf": [ + { + "$ref": "#/components/schemas/ImageFormat" + } + ] + } + }, + { + "name": "percentPlayed", + "in": "query", + "description": "Optional. Percent to render for the percent played overlay.", + "schema": { + "type": "number", + "format": "double" + } + }, + { + "name": "unplayedCount", + "in": "query", + "description": "Optional. Unplayed count overlay to render.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "blur", + "in": "query", + "description": "Optional. Blur image.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "backgroundColor", + "in": "query", + "description": "Optional. Apply a background color for transparent images.", + "schema": { + "type": "string" + } + }, + { + "name": "foregroundLayer", + "in": "query", + "description": "Optional. Apply a foreground layer on top of the image.", + "schema": { + "type": "string" + } + }, + { + "name": "imageIndex", + "in": "query", + "description": "Image index.", + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Image stream returned.", + "content": { + "image/*": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "404": { + "description": "Item not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + } + } + }, + "head": { + "tags": [ + "Image" + ], + "summary": "Gets the item's image.", + "operationId": "HeadItemImage", + "parameters": [ + { + "name": "itemId", + "in": "path", + "description": "Item id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "imageType", + "in": "path", + "description": "Image type.", + "required": true, + "schema": { + "enum": [ + "Primary", + "Art", + "Backdrop", + "Banner", + "Logo", + "Thumb", + "Disc", + "Box", + "Screenshot", + "Menu", + "Chapter", + "BoxRear", + "Profile" + ], + "allOf": [ + { + "$ref": "#/components/schemas/ImageType" + } + ], + "description": "Enum ImageType." + } + }, + { + "name": "maxWidth", + "in": "query", + "description": "The maximum image width to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxHeight", + "in": "query", + "description": "The maximum image height to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "width", + "in": "query", + "description": "The fixed image width to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "height", + "in": "query", + "description": "The fixed image height to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "quality", + "in": "query", + "description": "Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "fillWidth", + "in": "query", + "description": "Width of box to fill.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "fillHeight", + "in": "query", + "description": "Height of box to fill.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "tag", + "in": "query", + "description": "Optional. Supply the cache tag from the item object to receive strong caching headers.", + "schema": { + "type": "string" + } + }, + { + "name": "format", + "in": "query", + "description": "Optional. The MediaBrowser.Model.Drawing.ImageFormat of the returned image.", + "schema": { + "enum": [ + "Bmp", + "Gif", + "Jpg", + "Png", + "Webp", + "Svg" + ], + "allOf": [ + { + "$ref": "#/components/schemas/ImageFormat" + } + ] + } + }, + { + "name": "percentPlayed", + "in": "query", + "description": "Optional. Percent to render for the percent played overlay.", + "schema": { + "type": "number", + "format": "double" + } + }, + { + "name": "unplayedCount", + "in": "query", + "description": "Optional. Unplayed count overlay to render.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "blur", + "in": "query", + "description": "Optional. Blur image.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "backgroundColor", + "in": "query", + "description": "Optional. Apply a background color for transparent images.", + "schema": { + "type": "string" + } + }, + { + "name": "foregroundLayer", + "in": "query", + "description": "Optional. Apply a foreground layer on top of the image.", + "schema": { + "type": "string" + } + }, + { + "name": "imageIndex", + "in": "query", + "description": "Image index.", + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Image stream returned.", + "content": { + "image/*": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "404": { + "description": "Item not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + } + } + } + }, + "/Items/{itemId}/Images/{imageType}/{imageIndex}": { + "delete": { + "tags": [ + "Image" + ], + "summary": "Delete an item's image.", + "operationId": "DeleteItemImageByIndex", + "parameters": [ + { + "name": "itemId", + "in": "path", + "description": "Item id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "imageType", + "in": "path", + "description": "Image type.", + "required": true, + "schema": { + "enum": [ + "Primary", + "Art", + "Backdrop", + "Banner", + "Logo", + "Thumb", + "Disc", + "Box", + "Screenshot", + "Menu", + "Chapter", + "BoxRear", + "Profile" + ], + "allOf": [ + { + "$ref": "#/components/schemas/ImageType" + } + ], + "description": "Enum ImageType." + } + }, + { + "name": "imageIndex", + "in": "path", + "description": "The image index.", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "204": { + "description": "Image deleted." + }, + "404": { + "description": "Item not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "RequiresElevation" + ] + } + ] + }, + "post": { + "tags": [ + "Image" + ], + "summary": "Set item image.", + "operationId": "SetItemImageByIndex", + "parameters": [ + { + "name": "itemId", + "in": "path", + "description": "Item id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "imageType", + "in": "path", + "description": "Image type.", + "required": true, + "schema": { + "enum": [ + "Primary", + "Art", + "Backdrop", + "Banner", + "Logo", + "Thumb", + "Disc", + "Box", + "Screenshot", + "Menu", + "Chapter", + "BoxRear", + "Profile" + ], + "allOf": [ + { + "$ref": "#/components/schemas/ImageType" + } + ], + "description": "Enum ImageType." + } + }, + { + "name": "imageIndex", + "in": "path", + "description": "(Unused) Image index.", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "requestBody": { + "content": { + "image/*": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "responses": { + "204": { + "description": "Image saved." + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Item not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "RequiresElevation" + ] + } + ] + }, + "get": { + "tags": [ + "Image" + ], + "summary": "Gets the item's image.", + "operationId": "GetItemImageByIndex", + "parameters": [ + { + "name": "itemId", + "in": "path", + "description": "Item id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "imageType", + "in": "path", + "description": "Image type.", + "required": true, + "schema": { + "enum": [ + "Primary", + "Art", + "Backdrop", + "Banner", + "Logo", + "Thumb", + "Disc", + "Box", + "Screenshot", + "Menu", + "Chapter", + "BoxRear", + "Profile" + ], + "allOf": [ + { + "$ref": "#/components/schemas/ImageType" + } + ], + "description": "Enum ImageType." + } + }, + { + "name": "imageIndex", + "in": "path", + "description": "Image index.", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxWidth", + "in": "query", + "description": "The maximum image width to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxHeight", + "in": "query", + "description": "The maximum image height to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "width", + "in": "query", + "description": "The fixed image width to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "height", + "in": "query", + "description": "The fixed image height to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "quality", + "in": "query", + "description": "Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "fillWidth", + "in": "query", + "description": "Width of box to fill.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "fillHeight", + "in": "query", + "description": "Height of box to fill.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "tag", + "in": "query", + "description": "Optional. Supply the cache tag from the item object to receive strong caching headers.", + "schema": { + "type": "string" + } + }, + { + "name": "format", + "in": "query", + "description": "Optional. The MediaBrowser.Model.Drawing.ImageFormat of the returned image.", + "schema": { + "enum": [ + "Bmp", + "Gif", + "Jpg", + "Png", + "Webp", + "Svg" + ], + "allOf": [ + { + "$ref": "#/components/schemas/ImageFormat" + } + ] + } + }, + { + "name": "percentPlayed", + "in": "query", + "description": "Optional. Percent to render for the percent played overlay.", + "schema": { + "type": "number", + "format": "double" + } + }, + { + "name": "unplayedCount", + "in": "query", + "description": "Optional. Unplayed count overlay to render.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "blur", + "in": "query", + "description": "Optional. Blur image.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "backgroundColor", + "in": "query", + "description": "Optional. Apply a background color for transparent images.", + "schema": { + "type": "string" + } + }, + { + "name": "foregroundLayer", + "in": "query", + "description": "Optional. Apply a foreground layer on top of the image.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Image stream returned.", + "content": { + "image/*": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "404": { + "description": "Item not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + } + } + }, + "head": { + "tags": [ + "Image" + ], + "summary": "Gets the item's image.", + "operationId": "HeadItemImageByIndex", + "parameters": [ + { + "name": "itemId", + "in": "path", + "description": "Item id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "imageType", + "in": "path", + "description": "Image type.", + "required": true, + "schema": { + "enum": [ + "Primary", + "Art", + "Backdrop", + "Banner", + "Logo", + "Thumb", + "Disc", + "Box", + "Screenshot", + "Menu", + "Chapter", + "BoxRear", + "Profile" + ], + "allOf": [ + { + "$ref": "#/components/schemas/ImageType" + } + ], + "description": "Enum ImageType." + } + }, + { + "name": "imageIndex", + "in": "path", + "description": "Image index.", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxWidth", + "in": "query", + "description": "The maximum image width to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxHeight", + "in": "query", + "description": "The maximum image height to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "width", + "in": "query", + "description": "The fixed image width to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "height", + "in": "query", + "description": "The fixed image height to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "quality", + "in": "query", + "description": "Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "fillWidth", + "in": "query", + "description": "Width of box to fill.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "fillHeight", + "in": "query", + "description": "Height of box to fill.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "tag", + "in": "query", + "description": "Optional. Supply the cache tag from the item object to receive strong caching headers.", + "schema": { + "type": "string" + } + }, + { + "name": "format", + "in": "query", + "description": "Optional. The MediaBrowser.Model.Drawing.ImageFormat of the returned image.", + "schema": { + "enum": [ + "Bmp", + "Gif", + "Jpg", + "Png", + "Webp", + "Svg" + ], + "allOf": [ + { + "$ref": "#/components/schemas/ImageFormat" + } + ] + } + }, + { + "name": "percentPlayed", + "in": "query", + "description": "Optional. Percent to render for the percent played overlay.", + "schema": { + "type": "number", + "format": "double" + } + }, + { + "name": "unplayedCount", + "in": "query", + "description": "Optional. Unplayed count overlay to render.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "blur", + "in": "query", + "description": "Optional. Blur image.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "backgroundColor", + "in": "query", + "description": "Optional. Apply a background color for transparent images.", + "schema": { + "type": "string" + } + }, + { + "name": "foregroundLayer", + "in": "query", + "description": "Optional. Apply a foreground layer on top of the image.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Image stream returned.", + "content": { + "image/*": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "404": { + "description": "Item not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + } + } + } + }, + "/Items/{itemId}/Images/{imageType}/{imageIndex}/{tag}/{format}/{maxWidth}/{maxHeight}/{percentPlayed}/{unplayedCount}": { + "get": { + "tags": [ + "Image" + ], + "summary": "Gets the item's image.", + "operationId": "GetItemImage2", + "parameters": [ + { + "name": "itemId", + "in": "path", + "description": "Item id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "imageType", + "in": "path", + "description": "Image type.", + "required": true, + "schema": { + "enum": [ + "Primary", + "Art", + "Backdrop", + "Banner", + "Logo", + "Thumb", + "Disc", + "Box", + "Screenshot", + "Menu", + "Chapter", + "BoxRear", + "Profile" + ], + "allOf": [ + { + "$ref": "#/components/schemas/ImageType" + } + ], + "description": "Enum ImageType." + } + }, + { + "name": "maxWidth", + "in": "path", + "description": "The maximum image width to return.", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxHeight", + "in": "path", + "description": "The maximum image height to return.", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "width", + "in": "query", + "description": "The fixed image width to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "height", + "in": "query", + "description": "The fixed image height to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "quality", + "in": "query", + "description": "Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "fillWidth", + "in": "query", + "description": "Width of box to fill.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "fillHeight", + "in": "query", + "description": "Height of box to fill.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "tag", + "in": "path", + "description": "Optional. Supply the cache tag from the item object to receive strong caching headers.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "format", + "in": "path", + "description": "Determines the output format of the image - original,gif,jpg,png.", + "required": true, + "schema": { + "enum": [ + "Bmp", + "Gif", + "Jpg", + "Png", + "Webp", + "Svg" + ], + "allOf": [ + { + "$ref": "#/components/schemas/ImageFormat" + } + ], + "description": "Enum ImageOutputFormat." + } + }, + { + "name": "percentPlayed", + "in": "path", + "description": "Optional. Percent to render for the percent played overlay.", + "required": true, + "schema": { + "type": "number", + "format": "double" + } + }, + { + "name": "unplayedCount", + "in": "path", + "description": "Optional. Unplayed count overlay to render.", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "blur", + "in": "query", + "description": "Optional. Blur image.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "backgroundColor", + "in": "query", + "description": "Optional. Apply a background color for transparent images.", + "schema": { + "type": "string" + } + }, + { + "name": "foregroundLayer", + "in": "query", + "description": "Optional. Apply a foreground layer on top of the image.", + "schema": { + "type": "string" + } + }, + { + "name": "imageIndex", + "in": "path", + "description": "Image index.", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Image stream returned.", + "content": { + "image/*": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "404": { + "description": "Item not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + } + } + }, + "head": { + "tags": [ + "Image" + ], + "summary": "Gets the item's image.", + "operationId": "HeadItemImage2", + "parameters": [ + { + "name": "itemId", + "in": "path", + "description": "Item id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "imageType", + "in": "path", + "description": "Image type.", + "required": true, + "schema": { + "enum": [ + "Primary", + "Art", + "Backdrop", + "Banner", + "Logo", + "Thumb", + "Disc", + "Box", + "Screenshot", + "Menu", + "Chapter", + "BoxRear", + "Profile" + ], + "allOf": [ + { + "$ref": "#/components/schemas/ImageType" + } + ], + "description": "Enum ImageType." + } + }, + { + "name": "maxWidth", + "in": "path", + "description": "The maximum image width to return.", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxHeight", + "in": "path", + "description": "The maximum image height to return.", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "width", + "in": "query", + "description": "The fixed image width to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "height", + "in": "query", + "description": "The fixed image height to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "quality", + "in": "query", + "description": "Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "fillWidth", + "in": "query", + "description": "Width of box to fill.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "fillHeight", + "in": "query", + "description": "Height of box to fill.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "tag", + "in": "path", + "description": "Optional. Supply the cache tag from the item object to receive strong caching headers.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "format", + "in": "path", + "description": "Determines the output format of the image - original,gif,jpg,png.", + "required": true, + "schema": { + "enum": [ + "Bmp", + "Gif", + "Jpg", + "Png", + "Webp", + "Svg" + ], + "allOf": [ + { + "$ref": "#/components/schemas/ImageFormat" + } + ], + "description": "Enum ImageOutputFormat." + } + }, + { + "name": "percentPlayed", + "in": "path", + "description": "Optional. Percent to render for the percent played overlay.", + "required": true, + "schema": { + "type": "number", + "format": "double" + } + }, + { + "name": "unplayedCount", + "in": "path", + "description": "Optional. Unplayed count overlay to render.", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "blur", + "in": "query", + "description": "Optional. Blur image.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "backgroundColor", + "in": "query", + "description": "Optional. Apply a background color for transparent images.", + "schema": { + "type": "string" + } + }, + { + "name": "foregroundLayer", + "in": "query", + "description": "Optional. Apply a foreground layer on top of the image.", + "schema": { + "type": "string" + } + }, + { + "name": "imageIndex", + "in": "path", + "description": "Image index.", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Image stream returned.", + "content": { + "image/*": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "404": { + "description": "Item not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + } + } + } + }, + "/Items/{itemId}/Images/{imageType}/{imageIndex}/Index": { + "post": { + "tags": [ + "Image" + ], + "summary": "Updates the index for an item image.", + "operationId": "UpdateItemImageIndex", + "parameters": [ + { + "name": "itemId", + "in": "path", + "description": "Item id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "imageType", + "in": "path", + "description": "Image type.", + "required": true, + "schema": { + "enum": [ + "Primary", + "Art", + "Backdrop", + "Banner", + "Logo", + "Thumb", + "Disc", + "Box", + "Screenshot", + "Menu", + "Chapter", + "BoxRear", + "Profile" + ], + "allOf": [ + { + "$ref": "#/components/schemas/ImageType" + } + ], + "description": "Enum ImageType." + } + }, + { + "name": "imageIndex", + "in": "path", + "description": "Old image index.", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "newIndex", + "in": "query", + "description": "New image index.", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "204": { + "description": "Image index updated." + }, + "404": { + "description": "Item not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "RequiresElevation" + ] + } + ] + } + }, + "/MusicGenres/{name}/Images/{imageType}": { + "get": { + "tags": [ + "Image" + ], + "summary": "Get music genre image by name.", + "operationId": "GetMusicGenreImage", + "parameters": [ + { + "name": "name", + "in": "path", + "description": "Music genre name.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "imageType", + "in": "path", + "description": "Image type.", + "required": true, + "schema": { + "enum": [ + "Primary", + "Art", + "Backdrop", + "Banner", + "Logo", + "Thumb", + "Disc", + "Box", + "Screenshot", + "Menu", + "Chapter", + "BoxRear", + "Profile" + ], + "allOf": [ + { + "$ref": "#/components/schemas/ImageType" + } + ], + "description": "Enum ImageType." + } + }, + { + "name": "tag", + "in": "query", + "description": "Optional. Supply the cache tag from the item object to receive strong caching headers.", + "schema": { + "type": "string" + } + }, + { + "name": "format", + "in": "query", + "description": "Determines the output format of the image - original,gif,jpg,png.", + "schema": { + "enum": [ + "Bmp", + "Gif", + "Jpg", + "Png", + "Webp", + "Svg" + ], + "allOf": [ + { + "$ref": "#/components/schemas/ImageFormat" + } + ] + } + }, + { + "name": "maxWidth", + "in": "query", + "description": "The maximum image width to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxHeight", + "in": "query", + "description": "The maximum image height to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "percentPlayed", + "in": "query", + "description": "Optional. Percent to render for the percent played overlay.", + "schema": { + "type": "number", + "format": "double" + } + }, + { + "name": "unplayedCount", + "in": "query", + "description": "Optional. Unplayed count overlay to render.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "width", + "in": "query", + "description": "The fixed image width to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "height", + "in": "query", + "description": "The fixed image height to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "quality", + "in": "query", + "description": "Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "fillWidth", + "in": "query", + "description": "Width of box to fill.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "fillHeight", + "in": "query", + "description": "Height of box to fill.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "blur", + "in": "query", + "description": "Optional. Blur image.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "backgroundColor", + "in": "query", + "description": "Optional. Apply a background color for transparent images.", + "schema": { + "type": "string" + } + }, + { + "name": "foregroundLayer", + "in": "query", + "description": "Optional. Apply a foreground layer on top of the image.", + "schema": { + "type": "string" + } + }, + { + "name": "imageIndex", + "in": "query", + "description": "Image index.", + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Image stream returned.", + "content": { + "image/*": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "404": { + "description": "Item not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + } + } + }, + "head": { + "tags": [ + "Image" + ], + "summary": "Get music genre image by name.", + "operationId": "HeadMusicGenreImage", + "parameters": [ + { + "name": "name", + "in": "path", + "description": "Music genre name.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "imageType", + "in": "path", + "description": "Image type.", + "required": true, + "schema": { + "enum": [ + "Primary", + "Art", + "Backdrop", + "Banner", + "Logo", + "Thumb", + "Disc", + "Box", + "Screenshot", + "Menu", + "Chapter", + "BoxRear", + "Profile" + ], + "allOf": [ + { + "$ref": "#/components/schemas/ImageType" + } + ], + "description": "Enum ImageType." + } + }, + { + "name": "tag", + "in": "query", + "description": "Optional. Supply the cache tag from the item object to receive strong caching headers.", + "schema": { + "type": "string" + } + }, + { + "name": "format", + "in": "query", + "description": "Determines the output format of the image - original,gif,jpg,png.", + "schema": { + "enum": [ + "Bmp", + "Gif", + "Jpg", + "Png", + "Webp", + "Svg" + ], + "allOf": [ + { + "$ref": "#/components/schemas/ImageFormat" + } + ] + } + }, + { + "name": "maxWidth", + "in": "query", + "description": "The maximum image width to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxHeight", + "in": "query", + "description": "The maximum image height to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "percentPlayed", + "in": "query", + "description": "Optional. Percent to render for the percent played overlay.", + "schema": { + "type": "number", + "format": "double" + } + }, + { + "name": "unplayedCount", + "in": "query", + "description": "Optional. Unplayed count overlay to render.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "width", + "in": "query", + "description": "The fixed image width to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "height", + "in": "query", + "description": "The fixed image height to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "quality", + "in": "query", + "description": "Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "fillWidth", + "in": "query", + "description": "Width of box to fill.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "fillHeight", + "in": "query", + "description": "Height of box to fill.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "blur", + "in": "query", + "description": "Optional. Blur image.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "backgroundColor", + "in": "query", + "description": "Optional. Apply a background color for transparent images.", + "schema": { + "type": "string" + } + }, + { + "name": "foregroundLayer", + "in": "query", + "description": "Optional. Apply a foreground layer on top of the image.", + "schema": { + "type": "string" + } + }, + { + "name": "imageIndex", + "in": "query", + "description": "Image index.", + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Image stream returned.", + "content": { + "image/*": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "404": { + "description": "Item not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + } + } + } + }, + "/MusicGenres/{name}/Images/{imageType}/{imageIndex}": { + "get": { + "tags": [ + "Image" + ], + "summary": "Get music genre image by name.", + "operationId": "GetMusicGenreImageByIndex", + "parameters": [ + { + "name": "name", + "in": "path", + "description": "Music genre name.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "imageType", + "in": "path", + "description": "Image type.", + "required": true, + "schema": { + "enum": [ + "Primary", + "Art", + "Backdrop", + "Banner", + "Logo", + "Thumb", + "Disc", + "Box", + "Screenshot", + "Menu", + "Chapter", + "BoxRear", + "Profile" + ], + "allOf": [ + { + "$ref": "#/components/schemas/ImageType" + } + ], + "description": "Enum ImageType." + } + }, + { + "name": "imageIndex", + "in": "path", + "description": "Image index.", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "tag", + "in": "query", + "description": "Optional. Supply the cache tag from the item object to receive strong caching headers.", + "schema": { + "type": "string" + } + }, + { + "name": "format", + "in": "query", + "description": "Determines the output format of the image - original,gif,jpg,png.", + "schema": { + "enum": [ + "Bmp", + "Gif", + "Jpg", + "Png", + "Webp", + "Svg" + ], + "allOf": [ + { + "$ref": "#/components/schemas/ImageFormat" + } + ] + } + }, + { + "name": "maxWidth", + "in": "query", + "description": "The maximum image width to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxHeight", + "in": "query", + "description": "The maximum image height to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "percentPlayed", + "in": "query", + "description": "Optional. Percent to render for the percent played overlay.", + "schema": { + "type": "number", + "format": "double" + } + }, + { + "name": "unplayedCount", + "in": "query", + "description": "Optional. Unplayed count overlay to render.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "width", + "in": "query", + "description": "The fixed image width to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "height", + "in": "query", + "description": "The fixed image height to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "quality", + "in": "query", + "description": "Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "fillWidth", + "in": "query", + "description": "Width of box to fill.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "fillHeight", + "in": "query", + "description": "Height of box to fill.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "blur", + "in": "query", + "description": "Optional. Blur image.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "backgroundColor", + "in": "query", + "description": "Optional. Apply a background color for transparent images.", + "schema": { + "type": "string" + } + }, + { + "name": "foregroundLayer", + "in": "query", + "description": "Optional. Apply a foreground layer on top of the image.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Image stream returned.", + "content": { + "image/*": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "404": { + "description": "Item not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + } + } + }, + "head": { + "tags": [ + "Image" + ], + "summary": "Get music genre image by name.", + "operationId": "HeadMusicGenreImageByIndex", + "parameters": [ + { + "name": "name", + "in": "path", + "description": "Music genre name.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "imageType", + "in": "path", + "description": "Image type.", + "required": true, + "schema": { + "enum": [ + "Primary", + "Art", + "Backdrop", + "Banner", + "Logo", + "Thumb", + "Disc", + "Box", + "Screenshot", + "Menu", + "Chapter", + "BoxRear", + "Profile" + ], + "allOf": [ + { + "$ref": "#/components/schemas/ImageType" + } + ], + "description": "Enum ImageType." + } + }, + { + "name": "imageIndex", + "in": "path", + "description": "Image index.", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "tag", + "in": "query", + "description": "Optional. Supply the cache tag from the item object to receive strong caching headers.", + "schema": { + "type": "string" + } + }, + { + "name": "format", + "in": "query", + "description": "Determines the output format of the image - original,gif,jpg,png.", + "schema": { + "enum": [ + "Bmp", + "Gif", + "Jpg", + "Png", + "Webp", + "Svg" + ], + "allOf": [ + { + "$ref": "#/components/schemas/ImageFormat" + } + ] + } + }, + { + "name": "maxWidth", + "in": "query", + "description": "The maximum image width to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxHeight", + "in": "query", + "description": "The maximum image height to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "percentPlayed", + "in": "query", + "description": "Optional. Percent to render for the percent played overlay.", + "schema": { + "type": "number", + "format": "double" + } + }, + { + "name": "unplayedCount", + "in": "query", + "description": "Optional. Unplayed count overlay to render.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "width", + "in": "query", + "description": "The fixed image width to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "height", + "in": "query", + "description": "The fixed image height to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "quality", + "in": "query", + "description": "Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "fillWidth", + "in": "query", + "description": "Width of box to fill.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "fillHeight", + "in": "query", + "description": "Height of box to fill.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "blur", + "in": "query", + "description": "Optional. Blur image.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "backgroundColor", + "in": "query", + "description": "Optional. Apply a background color for transparent images.", + "schema": { + "type": "string" + } + }, + { + "name": "foregroundLayer", + "in": "query", + "description": "Optional. Apply a foreground layer on top of the image.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Image stream returned.", + "content": { + "image/*": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "404": { + "description": "Item not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + } + } + } + }, + "/Persons/{name}/Images/{imageType}": { + "get": { + "tags": [ + "Image" + ], + "summary": "Get person image by name.", + "operationId": "GetPersonImage", + "parameters": [ + { + "name": "name", + "in": "path", + "description": "Person name.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "imageType", + "in": "path", + "description": "Image type.", + "required": true, + "schema": { + "enum": [ + "Primary", + "Art", + "Backdrop", + "Banner", + "Logo", + "Thumb", + "Disc", + "Box", + "Screenshot", + "Menu", + "Chapter", + "BoxRear", + "Profile" + ], + "allOf": [ + { + "$ref": "#/components/schemas/ImageType" + } + ], + "description": "Enum ImageType." + } + }, + { + "name": "tag", + "in": "query", + "description": "Optional. Supply the cache tag from the item object to receive strong caching headers.", + "schema": { + "type": "string" + } + }, + { + "name": "format", + "in": "query", + "description": "Determines the output format of the image - original,gif,jpg,png.", + "schema": { + "enum": [ + "Bmp", + "Gif", + "Jpg", + "Png", + "Webp", + "Svg" + ], + "allOf": [ + { + "$ref": "#/components/schemas/ImageFormat" + } + ] + } + }, + { + "name": "maxWidth", + "in": "query", + "description": "The maximum image width to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxHeight", + "in": "query", + "description": "The maximum image height to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "percentPlayed", + "in": "query", + "description": "Optional. Percent to render for the percent played overlay.", + "schema": { + "type": "number", + "format": "double" + } + }, + { + "name": "unplayedCount", + "in": "query", + "description": "Optional. Unplayed count overlay to render.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "width", + "in": "query", + "description": "The fixed image width to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "height", + "in": "query", + "description": "The fixed image height to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "quality", + "in": "query", + "description": "Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "fillWidth", + "in": "query", + "description": "Width of box to fill.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "fillHeight", + "in": "query", + "description": "Height of box to fill.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "blur", + "in": "query", + "description": "Optional. Blur image.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "backgroundColor", + "in": "query", + "description": "Optional. Apply a background color for transparent images.", + "schema": { + "type": "string" + } + }, + { + "name": "foregroundLayer", + "in": "query", + "description": "Optional. Apply a foreground layer on top of the image.", + "schema": { + "type": "string" + } + }, + { + "name": "imageIndex", + "in": "query", + "description": "Image index.", + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Image stream returned.", + "content": { + "image/*": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "404": { + "description": "Item not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + } + } + }, + "head": { + "tags": [ + "Image" + ], + "summary": "Get person image by name.", + "operationId": "HeadPersonImage", + "parameters": [ + { + "name": "name", + "in": "path", + "description": "Person name.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "imageType", + "in": "path", + "description": "Image type.", + "required": true, + "schema": { + "enum": [ + "Primary", + "Art", + "Backdrop", + "Banner", + "Logo", + "Thumb", + "Disc", + "Box", + "Screenshot", + "Menu", + "Chapter", + "BoxRear", + "Profile" + ], + "allOf": [ + { + "$ref": "#/components/schemas/ImageType" + } + ], + "description": "Enum ImageType." + } + }, + { + "name": "tag", + "in": "query", + "description": "Optional. Supply the cache tag from the item object to receive strong caching headers.", + "schema": { + "type": "string" + } + }, + { + "name": "format", + "in": "query", + "description": "Determines the output format of the image - original,gif,jpg,png.", + "schema": { + "enum": [ + "Bmp", + "Gif", + "Jpg", + "Png", + "Webp", + "Svg" + ], + "allOf": [ + { + "$ref": "#/components/schemas/ImageFormat" + } + ] + } + }, + { + "name": "maxWidth", + "in": "query", + "description": "The maximum image width to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxHeight", + "in": "query", + "description": "The maximum image height to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "percentPlayed", + "in": "query", + "description": "Optional. Percent to render for the percent played overlay.", + "schema": { + "type": "number", + "format": "double" + } + }, + { + "name": "unplayedCount", + "in": "query", + "description": "Optional. Unplayed count overlay to render.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "width", + "in": "query", + "description": "The fixed image width to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "height", + "in": "query", + "description": "The fixed image height to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "quality", + "in": "query", + "description": "Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "fillWidth", + "in": "query", + "description": "Width of box to fill.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "fillHeight", + "in": "query", + "description": "Height of box to fill.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "blur", + "in": "query", + "description": "Optional. Blur image.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "backgroundColor", + "in": "query", + "description": "Optional. Apply a background color for transparent images.", + "schema": { + "type": "string" + } + }, + { + "name": "foregroundLayer", + "in": "query", + "description": "Optional. Apply a foreground layer on top of the image.", + "schema": { + "type": "string" + } + }, + { + "name": "imageIndex", + "in": "query", + "description": "Image index.", + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Image stream returned.", + "content": { + "image/*": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "404": { + "description": "Item not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + } + } + } + }, + "/Persons/{name}/Images/{imageType}/{imageIndex}": { + "get": { + "tags": [ + "Image" + ], + "summary": "Get person image by name.", + "operationId": "GetPersonImageByIndex", + "parameters": [ + { + "name": "name", + "in": "path", + "description": "Person name.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "imageType", + "in": "path", + "description": "Image type.", + "required": true, + "schema": { + "enum": [ + "Primary", + "Art", + "Backdrop", + "Banner", + "Logo", + "Thumb", + "Disc", + "Box", + "Screenshot", + "Menu", + "Chapter", + "BoxRear", + "Profile" + ], + "allOf": [ + { + "$ref": "#/components/schemas/ImageType" + } + ], + "description": "Enum ImageType." + } + }, + { + "name": "imageIndex", + "in": "path", + "description": "Image index.", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "tag", + "in": "query", + "description": "Optional. Supply the cache tag from the item object to receive strong caching headers.", + "schema": { + "type": "string" + } + }, + { + "name": "format", + "in": "query", + "description": "Determines the output format of the image - original,gif,jpg,png.", + "schema": { + "enum": [ + "Bmp", + "Gif", + "Jpg", + "Png", + "Webp", + "Svg" + ], + "allOf": [ + { + "$ref": "#/components/schemas/ImageFormat" + } + ] + } + }, + { + "name": "maxWidth", + "in": "query", + "description": "The maximum image width to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxHeight", + "in": "query", + "description": "The maximum image height to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "percentPlayed", + "in": "query", + "description": "Optional. Percent to render for the percent played overlay.", + "schema": { + "type": "number", + "format": "double" + } + }, + { + "name": "unplayedCount", + "in": "query", + "description": "Optional. Unplayed count overlay to render.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "width", + "in": "query", + "description": "The fixed image width to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "height", + "in": "query", + "description": "The fixed image height to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "quality", + "in": "query", + "description": "Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "fillWidth", + "in": "query", + "description": "Width of box to fill.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "fillHeight", + "in": "query", + "description": "Height of box to fill.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "blur", + "in": "query", + "description": "Optional. Blur image.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "backgroundColor", + "in": "query", + "description": "Optional. Apply a background color for transparent images.", + "schema": { + "type": "string" + } + }, + { + "name": "foregroundLayer", + "in": "query", + "description": "Optional. Apply a foreground layer on top of the image.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Image stream returned.", + "content": { + "image/*": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "404": { + "description": "Item not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + } + } + }, + "head": { + "tags": [ + "Image" + ], + "summary": "Get person image by name.", + "operationId": "HeadPersonImageByIndex", + "parameters": [ + { + "name": "name", + "in": "path", + "description": "Person name.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "imageType", + "in": "path", + "description": "Image type.", + "required": true, + "schema": { + "enum": [ + "Primary", + "Art", + "Backdrop", + "Banner", + "Logo", + "Thumb", + "Disc", + "Box", + "Screenshot", + "Menu", + "Chapter", + "BoxRear", + "Profile" + ], + "allOf": [ + { + "$ref": "#/components/schemas/ImageType" + } + ], + "description": "Enum ImageType." + } + }, + { + "name": "imageIndex", + "in": "path", + "description": "Image index.", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "tag", + "in": "query", + "description": "Optional. Supply the cache tag from the item object to receive strong caching headers.", + "schema": { + "type": "string" + } + }, + { + "name": "format", + "in": "query", + "description": "Determines the output format of the image - original,gif,jpg,png.", + "schema": { + "enum": [ + "Bmp", + "Gif", + "Jpg", + "Png", + "Webp", + "Svg" + ], + "allOf": [ + { + "$ref": "#/components/schemas/ImageFormat" + } + ] + } + }, + { + "name": "maxWidth", + "in": "query", + "description": "The maximum image width to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxHeight", + "in": "query", + "description": "The maximum image height to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "percentPlayed", + "in": "query", + "description": "Optional. Percent to render for the percent played overlay.", + "schema": { + "type": "number", + "format": "double" + } + }, + { + "name": "unplayedCount", + "in": "query", + "description": "Optional. Unplayed count overlay to render.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "width", + "in": "query", + "description": "The fixed image width to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "height", + "in": "query", + "description": "The fixed image height to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "quality", + "in": "query", + "description": "Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "fillWidth", + "in": "query", + "description": "Width of box to fill.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "fillHeight", + "in": "query", + "description": "Height of box to fill.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "blur", + "in": "query", + "description": "Optional. Blur image.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "backgroundColor", + "in": "query", + "description": "Optional. Apply a background color for transparent images.", + "schema": { + "type": "string" + } + }, + { + "name": "foregroundLayer", + "in": "query", + "description": "Optional. Apply a foreground layer on top of the image.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Image stream returned.", + "content": { + "image/*": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "404": { + "description": "Item not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + } + } + } + }, + "/Studios/{name}/Images/{imageType}": { + "get": { + "tags": [ + "Image" + ], + "summary": "Get studio image by name.", + "operationId": "GetStudioImage", + "parameters": [ + { + "name": "name", + "in": "path", + "description": "Studio name.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "imageType", + "in": "path", + "description": "Image type.", + "required": true, + "schema": { + "enum": [ + "Primary", + "Art", + "Backdrop", + "Banner", + "Logo", + "Thumb", + "Disc", + "Box", + "Screenshot", + "Menu", + "Chapter", + "BoxRear", + "Profile" + ], + "allOf": [ + { + "$ref": "#/components/schemas/ImageType" + } + ], + "description": "Enum ImageType." + } + }, + { + "name": "tag", + "in": "query", + "description": "Optional. Supply the cache tag from the item object to receive strong caching headers.", + "schema": { + "type": "string" + } + }, + { + "name": "format", + "in": "query", + "description": "Determines the output format of the image - original,gif,jpg,png.", + "schema": { + "enum": [ + "Bmp", + "Gif", + "Jpg", + "Png", + "Webp", + "Svg" + ], + "allOf": [ + { + "$ref": "#/components/schemas/ImageFormat" + } + ] + } + }, + { + "name": "maxWidth", + "in": "query", + "description": "The maximum image width to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxHeight", + "in": "query", + "description": "The maximum image height to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "percentPlayed", + "in": "query", + "description": "Optional. Percent to render for the percent played overlay.", + "schema": { + "type": "number", + "format": "double" + } + }, + { + "name": "unplayedCount", + "in": "query", + "description": "Optional. Unplayed count overlay to render.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "width", + "in": "query", + "description": "The fixed image width to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "height", + "in": "query", + "description": "The fixed image height to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "quality", + "in": "query", + "description": "Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "fillWidth", + "in": "query", + "description": "Width of box to fill.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "fillHeight", + "in": "query", + "description": "Height of box to fill.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "blur", + "in": "query", + "description": "Optional. Blur image.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "backgroundColor", + "in": "query", + "description": "Optional. Apply a background color for transparent images.", + "schema": { + "type": "string" + } + }, + { + "name": "foregroundLayer", + "in": "query", + "description": "Optional. Apply a foreground layer on top of the image.", + "schema": { + "type": "string" + } + }, + { + "name": "imageIndex", + "in": "query", + "description": "Image index.", + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Image stream returned.", + "content": { + "image/*": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "404": { + "description": "Item not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + } + } + }, + "head": { + "tags": [ + "Image" + ], + "summary": "Get studio image by name.", + "operationId": "HeadStudioImage", + "parameters": [ + { + "name": "name", + "in": "path", + "description": "Studio name.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "imageType", + "in": "path", + "description": "Image type.", + "required": true, + "schema": { + "enum": [ + "Primary", + "Art", + "Backdrop", + "Banner", + "Logo", + "Thumb", + "Disc", + "Box", + "Screenshot", + "Menu", + "Chapter", + "BoxRear", + "Profile" + ], + "allOf": [ + { + "$ref": "#/components/schemas/ImageType" + } + ], + "description": "Enum ImageType." + } + }, + { + "name": "tag", + "in": "query", + "description": "Optional. Supply the cache tag from the item object to receive strong caching headers.", + "schema": { + "type": "string" + } + }, + { + "name": "format", + "in": "query", + "description": "Determines the output format of the image - original,gif,jpg,png.", + "schema": { + "enum": [ + "Bmp", + "Gif", + "Jpg", + "Png", + "Webp", + "Svg" + ], + "allOf": [ + { + "$ref": "#/components/schemas/ImageFormat" + } + ] + } + }, + { + "name": "maxWidth", + "in": "query", + "description": "The maximum image width to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxHeight", + "in": "query", + "description": "The maximum image height to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "percentPlayed", + "in": "query", + "description": "Optional. Percent to render for the percent played overlay.", + "schema": { + "type": "number", + "format": "double" + } + }, + { + "name": "unplayedCount", + "in": "query", + "description": "Optional. Unplayed count overlay to render.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "width", + "in": "query", + "description": "The fixed image width to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "height", + "in": "query", + "description": "The fixed image height to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "quality", + "in": "query", + "description": "Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "fillWidth", + "in": "query", + "description": "Width of box to fill.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "fillHeight", + "in": "query", + "description": "Height of box to fill.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "blur", + "in": "query", + "description": "Optional. Blur image.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "backgroundColor", + "in": "query", + "description": "Optional. Apply a background color for transparent images.", + "schema": { + "type": "string" + } + }, + { + "name": "foregroundLayer", + "in": "query", + "description": "Optional. Apply a foreground layer on top of the image.", + "schema": { + "type": "string" + } + }, + { + "name": "imageIndex", + "in": "query", + "description": "Image index.", + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Image stream returned.", + "content": { + "image/*": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "404": { + "description": "Item not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + } + } + } + }, + "/Studios/{name}/Images/{imageType}/{imageIndex}": { + "get": { + "tags": [ + "Image" + ], + "summary": "Get studio image by name.", + "operationId": "GetStudioImageByIndex", + "parameters": [ + { + "name": "name", + "in": "path", + "description": "Studio name.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "imageType", + "in": "path", + "description": "Image type.", + "required": true, + "schema": { + "enum": [ + "Primary", + "Art", + "Backdrop", + "Banner", + "Logo", + "Thumb", + "Disc", + "Box", + "Screenshot", + "Menu", + "Chapter", + "BoxRear", + "Profile" + ], + "allOf": [ + { + "$ref": "#/components/schemas/ImageType" + } + ], + "description": "Enum ImageType." + } + }, + { + "name": "imageIndex", + "in": "path", + "description": "Image index.", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "tag", + "in": "query", + "description": "Optional. Supply the cache tag from the item object to receive strong caching headers.", + "schema": { + "type": "string" + } + }, + { + "name": "format", + "in": "query", + "description": "Determines the output format of the image - original,gif,jpg,png.", + "schema": { + "enum": [ + "Bmp", + "Gif", + "Jpg", + "Png", + "Webp", + "Svg" + ], + "allOf": [ + { + "$ref": "#/components/schemas/ImageFormat" + } + ] + } + }, + { + "name": "maxWidth", + "in": "query", + "description": "The maximum image width to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxHeight", + "in": "query", + "description": "The maximum image height to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "percentPlayed", + "in": "query", + "description": "Optional. Percent to render for the percent played overlay.", + "schema": { + "type": "number", + "format": "double" + } + }, + { + "name": "unplayedCount", + "in": "query", + "description": "Optional. Unplayed count overlay to render.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "width", + "in": "query", + "description": "The fixed image width to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "height", + "in": "query", + "description": "The fixed image height to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "quality", + "in": "query", + "description": "Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "fillWidth", + "in": "query", + "description": "Width of box to fill.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "fillHeight", + "in": "query", + "description": "Height of box to fill.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "blur", + "in": "query", + "description": "Optional. Blur image.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "backgroundColor", + "in": "query", + "description": "Optional. Apply a background color for transparent images.", + "schema": { + "type": "string" + } + }, + { + "name": "foregroundLayer", + "in": "query", + "description": "Optional. Apply a foreground layer on top of the image.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Image stream returned.", + "content": { + "image/*": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "404": { + "description": "Item not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + } + } + }, + "head": { + "tags": [ + "Image" + ], + "summary": "Get studio image by name.", + "operationId": "HeadStudioImageByIndex", + "parameters": [ + { + "name": "name", + "in": "path", + "description": "Studio name.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "imageType", + "in": "path", + "description": "Image type.", + "required": true, + "schema": { + "enum": [ + "Primary", + "Art", + "Backdrop", + "Banner", + "Logo", + "Thumb", + "Disc", + "Box", + "Screenshot", + "Menu", + "Chapter", + "BoxRear", + "Profile" + ], + "allOf": [ + { + "$ref": "#/components/schemas/ImageType" + } + ], + "description": "Enum ImageType." + } + }, + { + "name": "imageIndex", + "in": "path", + "description": "Image index.", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "tag", + "in": "query", + "description": "Optional. Supply the cache tag from the item object to receive strong caching headers.", + "schema": { + "type": "string" + } + }, + { + "name": "format", + "in": "query", + "description": "Determines the output format of the image - original,gif,jpg,png.", + "schema": { + "enum": [ + "Bmp", + "Gif", + "Jpg", + "Png", + "Webp", + "Svg" + ], + "allOf": [ + { + "$ref": "#/components/schemas/ImageFormat" + } + ] + } + }, + { + "name": "maxWidth", + "in": "query", + "description": "The maximum image width to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxHeight", + "in": "query", + "description": "The maximum image height to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "percentPlayed", + "in": "query", + "description": "Optional. Percent to render for the percent played overlay.", + "schema": { + "type": "number", + "format": "double" + } + }, + { + "name": "unplayedCount", + "in": "query", + "description": "Optional. Unplayed count overlay to render.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "width", + "in": "query", + "description": "The fixed image width to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "height", + "in": "query", + "description": "The fixed image height to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "quality", + "in": "query", + "description": "Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "fillWidth", + "in": "query", + "description": "Width of box to fill.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "fillHeight", + "in": "query", + "description": "Height of box to fill.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "blur", + "in": "query", + "description": "Optional. Blur image.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "backgroundColor", + "in": "query", + "description": "Optional. Apply a background color for transparent images.", + "schema": { + "type": "string" + } + }, + { + "name": "foregroundLayer", + "in": "query", + "description": "Optional. Apply a foreground layer on top of the image.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Image stream returned.", + "content": { + "image/*": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "404": { + "description": "Item not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + } + } + } + }, + "/UserImage": { + "post": { + "tags": [ + "Image" + ], + "summary": "Sets the user image.", + "operationId": "PostUserImage", + "parameters": [ + { + "name": "userId", + "in": "query", + "description": "User Id.", + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "image/*": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "responses": { + "204": { + "description": "Image updated." + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "User does not have permission to delete the image.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Item not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + }, + "delete": { + "tags": [ + "Image" + ], + "summary": "Delete the user's image.", + "operationId": "DeleteUserImage", + "parameters": [ + { + "name": "userId", + "in": "query", + "description": "User Id.", + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "204": { + "description": "Image deleted." + }, + "403": { + "description": "User does not have permission to delete the image.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + }, + "get": { + "tags": [ + "Image" + ], + "summary": "Get user profile image.", + "operationId": "GetUserImage", + "parameters": [ + { + "name": "userId", + "in": "query", + "description": "User id.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "tag", + "in": "query", + "description": "Optional. Supply the cache tag from the item object to receive strong caching headers.", + "schema": { + "type": "string" + } + }, + { + "name": "format", + "in": "query", + "description": "Determines the output format of the image - original,gif,jpg,png.", + "schema": { + "enum": [ + "Bmp", + "Gif", + "Jpg", + "Png", + "Webp", + "Svg" + ], + "allOf": [ + { + "$ref": "#/components/schemas/ImageFormat" + } + ] + } + } + ], + "responses": { + "200": { + "description": "Image stream returned.", + "content": { + "image/*": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "400": { + "description": "User id not provided.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Item not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + } + } + }, + "head": { + "tags": [ + "Image" + ], + "summary": "Get user profile image.", + "operationId": "HeadUserImage", + "parameters": [ + { + "name": "userId", + "in": "query", + "description": "User id.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "tag", + "in": "query", + "description": "Optional. Supply the cache tag from the item object to receive strong caching headers.", + "schema": { + "type": "string" + } + }, + { + "name": "format", + "in": "query", + "description": "Determines the output format of the image - original,gif,jpg,png.", + "schema": { + "enum": [ + "Bmp", + "Gif", + "Jpg", + "Png", + "Webp", + "Svg" + ], + "allOf": [ + { + "$ref": "#/components/schemas/ImageFormat" + } + ] + } + } + ], + "responses": { + "200": { + "description": "Image stream returned.", + "content": { + "image/*": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "400": { + "description": "User id not provided.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Item not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + } + } + } + }, + "/Albums/{itemId}/InstantMix": { + "get": { + "tags": [ + "InstantMix" + ], + "summary": "Creates an instant playlist based on a given album.", + "operationId": "GetInstantMixFromAlbum", + "parameters": [ + { + "name": "itemId", + "in": "path", + "description": "The item id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "userId", + "in": "query", + "description": "Optional. Filter by user id, and attach user data.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "limit", + "in": "query", + "description": "Optional. The maximum number of records to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "fields", + "in": "query", + "description": "Optional. Specify additional fields of information to return in the output.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ItemFields" + } + } + }, + { + "name": "enableImages", + "in": "query", + "description": "Optional. Include image information in output.", + "schema": { + "type": "boolean" + } + }, + { + "name": "enableUserData", + "in": "query", + "description": "Optional. Include user data.", + "schema": { + "type": "boolean" + } + }, + { + "name": "imageTypeLimit", + "in": "query", + "description": "Optional. The max number of images to return, per image type.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "enableImageTypes", + "in": "query", + "description": "Optional. The image types to include in the output.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ImageType" + } + } + } + ], + "responses": { + "200": { + "description": "Instant playlist returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + } + } + }, + "404": { + "description": "Item not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Artists/{itemId}/InstantMix": { + "get": { + "tags": [ + "InstantMix" + ], + "summary": "Creates an instant playlist based on a given artist.", + "operationId": "GetInstantMixFromArtists", + "parameters": [ + { + "name": "itemId", + "in": "path", + "description": "The item id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "userId", + "in": "query", + "description": "Optional. Filter by user id, and attach user data.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "limit", + "in": "query", + "description": "Optional. The maximum number of records to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "fields", + "in": "query", + "description": "Optional. Specify additional fields of information to return in the output.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ItemFields" + } + } + }, + { + "name": "enableImages", + "in": "query", + "description": "Optional. Include image information in output.", + "schema": { + "type": "boolean" + } + }, + { + "name": "enableUserData", + "in": "query", + "description": "Optional. Include user data.", + "schema": { + "type": "boolean" + } + }, + { + "name": "imageTypeLimit", + "in": "query", + "description": "Optional. The max number of images to return, per image type.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "enableImageTypes", + "in": "query", + "description": "Optional. The image types to include in the output.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ImageType" + } + } + } + ], + "responses": { + "200": { + "description": "Instant playlist returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + } + } + }, + "404": { + "description": "Item not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Artists/InstantMix": { + "get": { + "tags": [ + "InstantMix" + ], + "summary": "Creates an instant playlist based on a given artist.", + "operationId": "GetInstantMixFromArtists2", + "parameters": [ + { + "name": "id", + "in": "query", + "description": "The item id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "userId", + "in": "query", + "description": "Optional. Filter by user id, and attach user data.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "limit", + "in": "query", + "description": "Optional. The maximum number of records to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "fields", + "in": "query", + "description": "Optional. Specify additional fields of information to return in the output.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ItemFields" + } + } + }, + { + "name": "enableImages", + "in": "query", + "description": "Optional. Include image information in output.", + "schema": { + "type": "boolean" + } + }, + { + "name": "enableUserData", + "in": "query", + "description": "Optional. Include user data.", + "schema": { + "type": "boolean" + } + }, + { + "name": "imageTypeLimit", + "in": "query", + "description": "Optional. The max number of images to return, per image type.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "enableImageTypes", + "in": "query", + "description": "Optional. The image types to include in the output.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ImageType" + } + } + } + ], + "responses": { + "200": { + "description": "Instant playlist returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + } + } + }, + "404": { + "description": "Item not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "deprecated": true, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Items/{itemId}/InstantMix": { + "get": { + "tags": [ + "InstantMix" + ], + "summary": "Creates an instant playlist based on a given item.", + "operationId": "GetInstantMixFromItem", + "parameters": [ + { + "name": "itemId", + "in": "path", + "description": "The item id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "userId", + "in": "query", + "description": "Optional. Filter by user id, and attach user data.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "limit", + "in": "query", + "description": "Optional. The maximum number of records to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "fields", + "in": "query", + "description": "Optional. Specify additional fields of information to return in the output.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ItemFields" + } + } + }, + { + "name": "enableImages", + "in": "query", + "description": "Optional. Include image information in output.", + "schema": { + "type": "boolean" + } + }, + { + "name": "enableUserData", + "in": "query", + "description": "Optional. Include user data.", + "schema": { + "type": "boolean" + } + }, + { + "name": "imageTypeLimit", + "in": "query", + "description": "Optional. The max number of images to return, per image type.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "enableImageTypes", + "in": "query", + "description": "Optional. The image types to include in the output.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ImageType" + } + } + } + ], + "responses": { + "200": { + "description": "Instant playlist returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + } + } + }, + "404": { + "description": "Item not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/MusicGenres/{name}/InstantMix": { + "get": { + "tags": [ + "InstantMix" + ], + "summary": "Creates an instant playlist based on a given genre.", + "operationId": "GetInstantMixFromMusicGenreByName", + "parameters": [ + { + "name": "name", + "in": "path", + "description": "The genre name.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "userId", + "in": "query", + "description": "Optional. Filter by user id, and attach user data.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "limit", + "in": "query", + "description": "Optional. The maximum number of records to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "fields", + "in": "query", + "description": "Optional. Specify additional fields of information to return in the output.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ItemFields" + } + } + }, + { + "name": "enableImages", + "in": "query", + "description": "Optional. Include image information in output.", + "schema": { + "type": "boolean" + } + }, + { + "name": "enableUserData", + "in": "query", + "description": "Optional. Include user data.", + "schema": { + "type": "boolean" + } + }, + { + "name": "imageTypeLimit", + "in": "query", + "description": "Optional. The max number of images to return, per image type.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "enableImageTypes", + "in": "query", + "description": "Optional. The image types to include in the output.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ImageType" + } + } + } + ], + "responses": { + "200": { + "description": "Instant playlist returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/MusicGenres/InstantMix": { + "get": { + "tags": [ + "InstantMix" + ], + "summary": "Creates an instant playlist based on a given genre.", + "operationId": "GetInstantMixFromMusicGenreById", + "parameters": [ + { + "name": "id", + "in": "query", + "description": "The item id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "userId", + "in": "query", + "description": "Optional. Filter by user id, and attach user data.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "limit", + "in": "query", + "description": "Optional. The maximum number of records to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "fields", + "in": "query", + "description": "Optional. Specify additional fields of information to return in the output.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ItemFields" + } + } + }, + { + "name": "enableImages", + "in": "query", + "description": "Optional. Include image information in output.", + "schema": { + "type": "boolean" + } + }, + { + "name": "enableUserData", + "in": "query", + "description": "Optional. Include user data.", + "schema": { + "type": "boolean" + } + }, + { + "name": "imageTypeLimit", + "in": "query", + "description": "Optional. The max number of images to return, per image type.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "enableImageTypes", + "in": "query", + "description": "Optional. The image types to include in the output.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ImageType" + } + } + } + ], + "responses": { + "200": { + "description": "Instant playlist returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + } + } + }, + "404": { + "description": "Item not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Playlists/{itemId}/InstantMix": { + "get": { + "tags": [ + "InstantMix" + ], + "summary": "Creates an instant playlist based on a given playlist.", + "operationId": "GetInstantMixFromPlaylist", + "parameters": [ + { + "name": "itemId", + "in": "path", + "description": "The item id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "userId", + "in": "query", + "description": "Optional. Filter by user id, and attach user data.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "limit", + "in": "query", + "description": "Optional. The maximum number of records to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "fields", + "in": "query", + "description": "Optional. Specify additional fields of information to return in the output.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ItemFields" + } + } + }, + { + "name": "enableImages", + "in": "query", + "description": "Optional. Include image information in output.", + "schema": { + "type": "boolean" + } + }, + { + "name": "enableUserData", + "in": "query", + "description": "Optional. Include user data.", + "schema": { + "type": "boolean" + } + }, + { + "name": "imageTypeLimit", + "in": "query", + "description": "Optional. The max number of images to return, per image type.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "enableImageTypes", + "in": "query", + "description": "Optional. The image types to include in the output.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ImageType" + } + } + } + ], + "responses": { + "200": { + "description": "Instant playlist returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + } + } + }, + "404": { + "description": "Item not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Songs/{itemId}/InstantMix": { + "get": { + "tags": [ + "InstantMix" + ], + "summary": "Creates an instant playlist based on a given song.", + "operationId": "GetInstantMixFromSong", + "parameters": [ + { + "name": "itemId", + "in": "path", + "description": "The item id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "userId", + "in": "query", + "description": "Optional. Filter by user id, and attach user data.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "limit", + "in": "query", + "description": "Optional. The maximum number of records to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "fields", + "in": "query", + "description": "Optional. Specify additional fields of information to return in the output.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ItemFields" + } + } + }, + { + "name": "enableImages", + "in": "query", + "description": "Optional. Include image information in output.", + "schema": { + "type": "boolean" + } + }, + { + "name": "enableUserData", + "in": "query", + "description": "Optional. Include user data.", + "schema": { + "type": "boolean" + } + }, + { + "name": "imageTypeLimit", + "in": "query", + "description": "Optional. The max number of images to return, per image type.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "enableImageTypes", + "in": "query", + "description": "Optional. The image types to include in the output.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ImageType" + } + } + } + ], + "responses": { + "200": { + "description": "Instant playlist returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + } + } + }, + "404": { + "description": "Item not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Items/{itemId}/ExternalIdInfos": { + "get": { + "tags": [ + "ItemLookup" + ], + "summary": "Get the item's external id info.", + "operationId": "GetExternalIdInfos", + "parameters": [ + { + "name": "itemId", + "in": "path", + "description": "Item id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "External id info retrieved.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ExternalIdInfo" + } + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ExternalIdInfo" + } + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ExternalIdInfo" + } + } + } + } + }, + "404": { + "description": "Item not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "RequiresElevation", + "DefaultAuthorization" + ] + } + ] + } + }, + "/Items/RemoteSearch/Apply/{itemId}": { + "post": { + "tags": [ + "ItemLookup" + ], + "summary": "Applies search criteria to an item and refreshes metadata.", + "operationId": "ApplySearchCriteria", + "parameters": [ + { + "name": "itemId", + "in": "path", + "description": "Item id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "replaceAllImages", + "in": "query", + "description": "Optional. Whether or not to replace all images. Default: True.", + "schema": { + "type": "boolean", + "default": true + } + } + ], + "requestBody": { + "description": "The remote search result.", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/RemoteSearchResult" + } + ] + } + }, + "text/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/RemoteSearchResult" + } + ] + } + }, + "application/*+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/RemoteSearchResult" + } + ] + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Item metadata refreshed." + }, + "404": { + "description": "Item not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "RequiresElevation", + "DefaultAuthorization" + ] + } + ] + } + }, + "/Items/RemoteSearch/Book": { + "post": { + "tags": [ + "ItemLookup" + ], + "summary": "Get book remote search.", + "operationId": "GetBookRemoteSearchResults", + "requestBody": { + "description": "Remote search query.", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/BookInfoRemoteSearchQuery" + } + ] + } + }, + "text/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/BookInfoRemoteSearchQuery" + } + ] + } + }, + "application/*+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/BookInfoRemoteSearchQuery" + } + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Book remote search executed.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RemoteSearchResult" + } + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RemoteSearchResult" + } + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RemoteSearchResult" + } + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Items/RemoteSearch/BoxSet": { + "post": { + "tags": [ + "ItemLookup" + ], + "summary": "Get box set remote search.", + "operationId": "GetBoxSetRemoteSearchResults", + "requestBody": { + "description": "Remote search query.", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/BoxSetInfoRemoteSearchQuery" + } + ] + } + }, + "text/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/BoxSetInfoRemoteSearchQuery" + } + ] + } + }, + "application/*+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/BoxSetInfoRemoteSearchQuery" + } + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Box set remote search executed.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RemoteSearchResult" + } + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RemoteSearchResult" + } + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RemoteSearchResult" + } + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Items/RemoteSearch/Movie": { + "post": { + "tags": [ + "ItemLookup" + ], + "summary": "Get movie remote search.", + "operationId": "GetMovieRemoteSearchResults", + "requestBody": { + "description": "Remote search query.", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/MovieInfoRemoteSearchQuery" + } + ] + } + }, + "text/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/MovieInfoRemoteSearchQuery" + } + ] + } + }, + "application/*+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/MovieInfoRemoteSearchQuery" + } + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Movie remote search executed.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RemoteSearchResult" + } + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RemoteSearchResult" + } + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RemoteSearchResult" + } + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Items/RemoteSearch/MusicAlbum": { + "post": { + "tags": [ + "ItemLookup" + ], + "summary": "Get music album remote search.", + "operationId": "GetMusicAlbumRemoteSearchResults", + "requestBody": { + "description": "Remote search query.", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/AlbumInfoRemoteSearchQuery" + } + ] + } + }, + "text/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/AlbumInfoRemoteSearchQuery" + } + ] + } + }, + "application/*+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/AlbumInfoRemoteSearchQuery" + } + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Music album remote search executed.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RemoteSearchResult" + } + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RemoteSearchResult" + } + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RemoteSearchResult" + } + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Items/RemoteSearch/MusicArtist": { + "post": { + "tags": [ + "ItemLookup" + ], + "summary": "Get music artist remote search.", + "operationId": "GetMusicArtistRemoteSearchResults", + "requestBody": { + "description": "Remote search query.", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/ArtistInfoRemoteSearchQuery" + } + ] + } + }, + "text/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/ArtistInfoRemoteSearchQuery" + } + ] + } + }, + "application/*+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/ArtistInfoRemoteSearchQuery" + } + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Music artist remote search executed.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RemoteSearchResult" + } + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RemoteSearchResult" + } + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RemoteSearchResult" + } + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Items/RemoteSearch/MusicVideo": { + "post": { + "tags": [ + "ItemLookup" + ], + "summary": "Get music video remote search.", + "operationId": "GetMusicVideoRemoteSearchResults", + "requestBody": { + "description": "Remote search query.", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/MusicVideoInfoRemoteSearchQuery" + } + ] + } + }, + "text/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/MusicVideoInfoRemoteSearchQuery" + } + ] + } + }, + "application/*+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/MusicVideoInfoRemoteSearchQuery" + } + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Music video remote search executed.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RemoteSearchResult" + } + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RemoteSearchResult" + } + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RemoteSearchResult" + } + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Items/RemoteSearch/Person": { + "post": { + "tags": [ + "ItemLookup" + ], + "summary": "Get person remote search.", + "operationId": "GetPersonRemoteSearchResults", + "requestBody": { + "description": "Remote search query.", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/PersonLookupInfoRemoteSearchQuery" + } + ] + } + }, + "text/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/PersonLookupInfoRemoteSearchQuery" + } + ] + } + }, + "application/*+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/PersonLookupInfoRemoteSearchQuery" + } + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Person remote search executed.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RemoteSearchResult" + } + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RemoteSearchResult" + } + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RemoteSearchResult" + } + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "RequiresElevation", + "DefaultAuthorization" + ] + } + ] + } + }, + "/Items/RemoteSearch/Series": { + "post": { + "tags": [ + "ItemLookup" + ], + "summary": "Get series remote search.", + "operationId": "GetSeriesRemoteSearchResults", + "requestBody": { + "description": "Remote search query.", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/SeriesInfoRemoteSearchQuery" + } + ] + } + }, + "text/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/SeriesInfoRemoteSearchQuery" + } + ] + } + }, + "application/*+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/SeriesInfoRemoteSearchQuery" + } + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Series remote search executed.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RemoteSearchResult" + } + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RemoteSearchResult" + } + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RemoteSearchResult" + } + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Items/RemoteSearch/Trailer": { + "post": { + "tags": [ + "ItemLookup" + ], + "summary": "Get trailer remote search.", + "operationId": "GetTrailerRemoteSearchResults", + "requestBody": { + "description": "Remote search query.", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/TrailerInfoRemoteSearchQuery" + } + ] + } + }, + "text/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/TrailerInfoRemoteSearchQuery" + } + ] + } + }, + "application/*+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/TrailerInfoRemoteSearchQuery" + } + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Trailer remote search executed.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RemoteSearchResult" + } + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RemoteSearchResult" + } + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RemoteSearchResult" + } + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Items/{itemId}/Refresh": { + "post": { + "tags": [ + "ItemRefresh" + ], + "summary": "Refreshes metadata for an item.", + "operationId": "RefreshItem", + "parameters": [ + { + "name": "itemId", + "in": "path", + "description": "Item id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "metadataRefreshMode", + "in": "query", + "description": "(Optional) Specifies the metadata refresh mode.", + "schema": { + "enum": [ + "None", + "ValidationOnly", + "Default", + "FullRefresh" + ], + "allOf": [ + { + "$ref": "#/components/schemas/MetadataRefreshMode" + } + ], + "default": "None" + } + }, + { + "name": "imageRefreshMode", + "in": "query", + "description": "(Optional) Specifies the image refresh mode.", + "schema": { + "enum": [ + "None", + "ValidationOnly", + "Default", + "FullRefresh" + ], + "allOf": [ + { + "$ref": "#/components/schemas/MetadataRefreshMode" + } + ], + "default": "None" + } + }, + { + "name": "replaceAllMetadata", + "in": "query", + "description": "(Optional) Determines if metadata should be replaced. Only applicable if mode is FullRefresh.", + "schema": { + "type": "boolean", + "default": false + } + }, + { + "name": "replaceAllImages", + "in": "query", + "description": "(Optional) Determines if images should be replaced. Only applicable if mode is FullRefresh.", + "schema": { + "type": "boolean", + "default": false + } + }, + { + "name": "regenerateTrickplay", + "in": "query", + "description": "(Optional) Determines if trickplay images should be replaced. Only applicable if mode is FullRefresh.", + "schema": { + "type": "boolean", + "default": false + } + } + ], + "responses": { + "204": { + "description": "Item metadata refresh queued." + }, + "404": { + "description": "Item to refresh not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "RequiresElevation" + ] + } + ] + } + }, + "/Items": { + "get": { + "tags": [ + "Items" + ], + "summary": "Gets items based on a query.", + "operationId": "GetItems", + "parameters": [ + { + "name": "userId", + "in": "query", + "description": "The user id supplied as query parameter; this is required when not using an API key.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "maxOfficialRating", + "in": "query", + "description": "Optional filter by maximum official rating (PG, PG-13, TV-MA, etc).", + "schema": { + "type": "string" + } + }, + { + "name": "hasThemeSong", + "in": "query", + "description": "Optional filter by items with theme songs.", + "schema": { + "type": "boolean" + } + }, + { + "name": "hasThemeVideo", + "in": "query", + "description": "Optional filter by items with theme videos.", + "schema": { + "type": "boolean" + } + }, + { + "name": "hasSubtitles", + "in": "query", + "description": "Optional filter by items with subtitles.", + "schema": { + "type": "boolean" + } + }, + { + "name": "hasSpecialFeature", + "in": "query", + "description": "Optional filter by items with special features.", + "schema": { + "type": "boolean" + } + }, + { + "name": "hasTrailer", + "in": "query", + "description": "Optional filter by items with trailers.", + "schema": { + "type": "boolean" + } + }, + { + "name": "adjacentTo", + "in": "query", + "description": "Optional. Return items that are siblings of a supplied item.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "indexNumber", + "in": "query", + "description": "Optional filter by index number.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "parentIndexNumber", + "in": "query", + "description": "Optional filter by parent index number.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "hasParentalRating", + "in": "query", + "description": "Optional filter by items that have or do not have a parental rating.", + "schema": { + "type": "boolean" + } + }, + { + "name": "isHd", + "in": "query", + "description": "Optional filter by items that are HD or not.", + "schema": { + "type": "boolean" + } + }, + { + "name": "is4K", + "in": "query", + "description": "Optional filter by items that are 4K or not.", + "schema": { + "type": "boolean" + } + }, + { + "name": "locationTypes", + "in": "query", + "description": "Optional. If specified, results will be filtered based on LocationType. This allows multiple, comma delimited.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LocationType" + } + } + }, + { + "name": "excludeLocationTypes", + "in": "query", + "description": "Optional. If specified, results will be filtered based on the LocationType. This allows multiple, comma delimited.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LocationType" + } + } + }, + { + "name": "isMissing", + "in": "query", + "description": "Optional filter by items that are missing episodes or not.", + "schema": { + "type": "boolean" + } + }, + { + "name": "isUnaired", + "in": "query", + "description": "Optional filter by items that are unaired episodes or not.", + "schema": { + "type": "boolean" + } + }, + { + "name": "minCommunityRating", + "in": "query", + "description": "Optional filter by minimum community rating.", + "schema": { + "type": "number", + "format": "double" + } + }, + { + "name": "minCriticRating", + "in": "query", + "description": "Optional filter by minimum critic rating.", + "schema": { + "type": "number", + "format": "double" + } + }, + { + "name": "minPremiereDate", + "in": "query", + "description": "Optional. The minimum premiere date. Format = ISO.", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "minDateLastSaved", + "in": "query", + "description": "Optional. The minimum last saved date. Format = ISO.", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "minDateLastSavedForUser", + "in": "query", + "description": "Optional. The minimum last saved date for the current user. Format = ISO.", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "maxPremiereDate", + "in": "query", + "description": "Optional. The maximum premiere date. Format = ISO.", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "hasOverview", + "in": "query", + "description": "Optional filter by items that have an overview or not.", + "schema": { + "type": "boolean" + } + }, + { + "name": "hasImdbId", + "in": "query", + "description": "Optional filter by items that have an IMDb id or not.", + "schema": { + "type": "boolean" + } + }, + { + "name": "hasTmdbId", + "in": "query", + "description": "Optional filter by items that have a TMDb id or not.", + "schema": { + "type": "boolean" + } + }, + { + "name": "hasTvdbId", + "in": "query", + "description": "Optional filter by items that have a TVDb id or not.", + "schema": { + "type": "boolean" + } + }, + { + "name": "isMovie", + "in": "query", + "description": "Optional filter for live tv movies.", + "schema": { + "type": "boolean" + } + }, + { + "name": "isSeries", + "in": "query", + "description": "Optional filter for live tv series.", + "schema": { + "type": "boolean" + } + }, + { + "name": "isNews", + "in": "query", + "description": "Optional filter for live tv news.", + "schema": { + "type": "boolean" + } + }, + { + "name": "isKids", + "in": "query", + "description": "Optional filter for live tv kids.", + "schema": { + "type": "boolean" + } + }, + { + "name": "isSports", + "in": "query", + "description": "Optional filter for live tv sports.", + "schema": { + "type": "boolean" + } + }, + { + "name": "excludeItemIds", + "in": "query", + "description": "Optional. If specified, results will be filtered by excluding item ids. This allows multiple, comma delimited.", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + }, + { + "name": "startIndex", + "in": "query", + "description": "Optional. The record index to start at. All items with a lower index will be dropped from the results.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "limit", + "in": "query", + "description": "Optional. The maximum number of records to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "recursive", + "in": "query", + "description": "When searching within folders, this determines whether or not the search will be recursive. true/false.", + "schema": { + "type": "boolean" + } + }, + { + "name": "searchTerm", + "in": "query", + "description": "Optional. Filter based on a search term.", + "schema": { + "type": "string" + } + }, + { + "name": "sortOrder", + "in": "query", + "description": "Sort Order - Ascending, Descending.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SortOrder" + } + } + }, + { + "name": "parentId", + "in": "query", + "description": "Specify this to localize the search to a specific item or folder. Omit to use the root.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "fields", + "in": "query", + "description": "Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ItemFields" + } + } + }, + { + "name": "excludeItemTypes", + "in": "query", + "description": "Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BaseItemKind" + } + } + }, + { + "name": "includeItemTypes", + "in": "query", + "description": "Optional. If specified, results will be filtered based on the item type. This allows multiple, comma delimited.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BaseItemKind" + } + } + }, + { + "name": "filters", + "in": "query", + "description": "Optional. Specify additional filters to apply. This allows multiple, comma delimited. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ItemFilter" + } + } + }, + { + "name": "isFavorite", + "in": "query", + "description": "Optional filter by items that are marked as favorite, or not.", + "schema": { + "type": "boolean" + } + }, + { + "name": "mediaTypes", + "in": "query", + "description": "Optional filter by MediaType. Allows multiple, comma delimited.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MediaType" + } + } + }, + { + "name": "imageTypes", + "in": "query", + "description": "Optional. If specified, results will be filtered based on those containing image types. This allows multiple, comma delimited.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ImageType" + } + } + }, + { + "name": "sortBy", + "in": "query", + "description": "Optional. Specify one or more sort orders, comma delimited. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ItemSortBy" + } + } + }, + { + "name": "isPlayed", + "in": "query", + "description": "Optional filter by items that are played, or not.", + "schema": { + "type": "boolean" + } + }, + { + "name": "genres", + "in": "query", + "description": "Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited.", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "name": "officialRatings", + "in": "query", + "description": "Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited.", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "name": "tags", + "in": "query", + "description": "Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited.", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "name": "years", + "in": "query", + "description": "Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited.", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + } + }, + { + "name": "enableUserData", + "in": "query", + "description": "Optional, include user data.", + "schema": { + "type": "boolean" + } + }, + { + "name": "imageTypeLimit", + "in": "query", + "description": "Optional, the max number of images to return, per image type.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "enableImageTypes", + "in": "query", + "description": "Optional. The image types to include in the output.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ImageType" + } + } + }, + { + "name": "person", + "in": "query", + "description": "Optional. If specified, results will be filtered to include only those containing the specified person.", + "schema": { + "type": "string" + } + }, + { + "name": "personIds", + "in": "query", + "description": "Optional. If specified, results will be filtered to include only those containing the specified person id.", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + }, + { + "name": "personTypes", + "in": "query", + "description": "Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "name": "studios", + "in": "query", + "description": "Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited.", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "name": "artists", + "in": "query", + "description": "Optional. If specified, results will be filtered based on artists. This allows multiple, pipe delimited.", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "name": "excludeArtistIds", + "in": "query", + "description": "Optional. If specified, results will be filtered based on artist id. This allows multiple, pipe delimited.", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + }, + { + "name": "artistIds", + "in": "query", + "description": "Optional. If specified, results will be filtered to include only those containing the specified artist id.", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + }, + { + "name": "albumArtistIds", + "in": "query", + "description": "Optional. If specified, results will be filtered to include only those containing the specified album artist id.", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + }, + { + "name": "contributingArtistIds", + "in": "query", + "description": "Optional. If specified, results will be filtered to include only those containing the specified contributing artist id.", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + }, + { + "name": "albums", + "in": "query", + "description": "Optional. If specified, results will be filtered based on album. This allows multiple, pipe delimited.", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "name": "albumIds", + "in": "query", + "description": "Optional. If specified, results will be filtered based on album id. This allows multiple, pipe delimited.", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + }, + { + "name": "ids", + "in": "query", + "description": "Optional. If specific items are needed, specify a list of item id's to retrieve. This allows multiple, comma delimited.", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + }, + { + "name": "videoTypes", + "in": "query", + "description": "Optional filter by VideoType (videofile, dvd, bluray, iso). Allows multiple, comma delimited.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/VideoType" + } + } + }, + { + "name": "minOfficialRating", + "in": "query", + "description": "Optional filter by minimum official rating (PG, PG-13, TV-MA, etc).", + "schema": { + "type": "string" + } + }, + { + "name": "isLocked", + "in": "query", + "description": "Optional filter by items that are locked.", + "schema": { + "type": "boolean" + } + }, + { + "name": "isPlaceHolder", + "in": "query", + "description": "Optional filter by items that are placeholders.", + "schema": { + "type": "boolean" + } + }, + { + "name": "hasOfficialRating", + "in": "query", + "description": "Optional filter by items that have official ratings.", + "schema": { + "type": "boolean" + } + }, + { + "name": "collapseBoxSetItems", + "in": "query", + "description": "Whether or not to hide items behind their boxsets.", + "schema": { + "type": "boolean" + } + }, + { + "name": "minWidth", + "in": "query", + "description": "Optional. Filter by the minimum width of the item.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "minHeight", + "in": "query", + "description": "Optional. Filter by the minimum height of the item.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxWidth", + "in": "query", + "description": "Optional. Filter by the maximum width of the item.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxHeight", + "in": "query", + "description": "Optional. Filter by the maximum height of the item.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "is3D", + "in": "query", + "description": "Optional filter by items that are 3D, or not.", + "schema": { + "type": "boolean" + } + }, + { + "name": "seriesStatus", + "in": "query", + "description": "Optional filter by Series Status. Allows multiple, comma delimited.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SeriesStatus" + } + } + }, + { + "name": "nameStartsWithOrGreater", + "in": "query", + "description": "Optional filter by items whose name is sorted equally or greater than a given input string.", + "schema": { + "type": "string" + } + }, + { + "name": "nameStartsWith", + "in": "query", + "description": "Optional filter by items whose name is sorted equally than a given input string.", + "schema": { + "type": "string" + } + }, + { + "name": "nameLessThan", + "in": "query", + "description": "Optional filter by items whose name is equally or lesser than a given input string.", + "schema": { + "type": "string" + } + }, + { + "name": "studioIds", + "in": "query", + "description": "Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited.", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + }, + { + "name": "genreIds", + "in": "query", + "description": "Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited.", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + }, + { + "name": "enableTotalRecordCount", + "in": "query", + "description": "Optional. Enable the total record count.", + "schema": { + "type": "boolean", + "default": true + } + }, + { + "name": "enableImages", + "in": "query", + "description": "Optional, include image information in output.", + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + }, + "delete": { + "tags": [ + "Library" + ], + "summary": "Deletes items from the library and filesystem.", + "operationId": "DeleteItems", + "parameters": [ + { + "name": "ids", + "in": "query", + "description": "The item ids.", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + } + ], + "responses": { + "204": { + "description": "Items deleted." + }, + "401": { + "description": "Unauthorized access.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/UserItems/{itemId}/UserData": { + "get": { + "tags": [ + "Items" + ], + "summary": "Get Item User Data.", + "operationId": "GetItemUserData", + "parameters": [ + { + "name": "userId", + "in": "query", + "description": "The user id.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "itemId", + "in": "path", + "description": "The item id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "return item user data.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserItemDataDto" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/UserItemDataDto" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/UserItemDataDto" + } + } + } + }, + "404": { + "description": "Item is not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + }, + "post": { + "tags": [ + "Items" + ], + "summary": "Update Item User Data.", + "operationId": "UpdateItemUserData", + "parameters": [ + { + "name": "userId", + "in": "query", + "description": "The user id.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "itemId", + "in": "path", + "description": "The item id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "description": "New user data object.", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/UpdateUserItemDataDto" + } + ], + "description": "This is used by the api to get information about a item user data." + } + }, + "text/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/UpdateUserItemDataDto" + } + ], + "description": "This is used by the api to get information about a item user data." + } + }, + "application/*+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/UpdateUserItemDataDto" + } + ], + "description": "This is used by the api to get information about a item user data." + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "return updated user item data.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserItemDataDto" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/UserItemDataDto" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/UserItemDataDto" + } + } + } + }, + "404": { + "description": "Item is not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/UserItems/Resume": { + "get": { + "tags": [ + "Items" + ], + "summary": "Gets items based on a query.", + "operationId": "GetResumeItems", + "parameters": [ + { + "name": "userId", + "in": "query", + "description": "The user id.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "startIndex", + "in": "query", + "description": "The start index.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "limit", + "in": "query", + "description": "The item limit.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "searchTerm", + "in": "query", + "description": "The search term.", + "schema": { + "type": "string" + } + }, + { + "name": "parentId", + "in": "query", + "description": "Specify this to localize the search to a specific item or folder. Omit to use the root.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "fields", + "in": "query", + "description": "Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ItemFields" + } + } + }, + { + "name": "mediaTypes", + "in": "query", + "description": "Optional. Filter by MediaType. Allows multiple, comma delimited.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MediaType" + } + } + }, + { + "name": "enableUserData", + "in": "query", + "description": "Optional. Include user data.", + "schema": { + "type": "boolean" + } + }, + { + "name": "imageTypeLimit", + "in": "query", + "description": "Optional. The max number of images to return, per image type.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "enableImageTypes", + "in": "query", + "description": "Optional. The image types to include in the output.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ImageType" + } + } + }, + { + "name": "excludeItemTypes", + "in": "query", + "description": "Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BaseItemKind" + } + } + }, + { + "name": "includeItemTypes", + "in": "query", + "description": "Optional. If specified, results will be filtered based on the item type. This allows multiple, comma delimited.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BaseItemKind" + } + } + }, + { + "name": "enableTotalRecordCount", + "in": "query", + "description": "Optional. Enable the total record count.", + "schema": { + "type": "boolean", + "default": true + } + }, + { + "name": "enableImages", + "in": "query", + "description": "Optional. Include image information in output.", + "schema": { + "type": "boolean", + "default": true + } + }, + { + "name": "excludeActiveSessions", + "in": "query", + "description": "Optional. Whether to exclude the currently active sessions.", + "schema": { + "type": "boolean", + "default": false + } + } + ], + "responses": { + "200": { + "description": "Items returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Items/{itemId}": { + "post": { + "tags": [ + "ItemUpdate" + ], + "summary": "Updates an item.", + "operationId": "UpdateItem", + "parameters": [ + { + "name": "itemId", + "in": "path", + "description": "The item id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "description": "The new item properties.", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/BaseItemDto" + } + ], + "description": "This is strictly used as a data transfer object from the api layer.\r\nThis holds information about a BaseItem in a format that is convenient for the client." + } + }, + "text/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/BaseItemDto" + } + ], + "description": "This is strictly used as a data transfer object from the api layer.\r\nThis holds information about a BaseItem in a format that is convenient for the client." + } + }, + "application/*+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/BaseItemDto" + } + ], + "description": "This is strictly used as a data transfer object from the api layer.\r\nThis holds information about a BaseItem in a format that is convenient for the client." + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Item updated." + }, + "404": { + "description": "Item not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "RequiresElevation" + ] + } + ] + }, + "delete": { + "tags": [ + "Library" + ], + "summary": "Deletes an item from the library and filesystem.", + "operationId": "DeleteItem", + "parameters": [ + { + "name": "itemId", + "in": "path", + "description": "The item id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "204": { + "description": "Item deleted." + }, + "401": { + "description": "Unauthorized access.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Item not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + }, + "get": { + "tags": [ + "UserLibrary" + ], + "summary": "Gets an item from a user's library.", + "operationId": "GetItem", + "parameters": [ + { + "name": "userId", + "in": "query", + "description": "User id.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "itemId", + "in": "path", + "description": "Item id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Item returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BaseItemDto" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDto" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDto" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Items/{itemId}/ContentType": { + "post": { + "tags": [ + "ItemUpdate" + ], + "summary": "Updates an item's content type.", + "operationId": "UpdateItemContentType", + "parameters": [ + { + "name": "itemId", + "in": "path", + "description": "The item id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "contentType", + "in": "query", + "description": "The content type of the item.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Item content type updated." + }, + "404": { + "description": "Item not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "RequiresElevation" + ] + } + ] + } + }, + "/Items/{itemId}/MetadataEditor": { + "get": { + "tags": [ + "ItemUpdate" + ], + "summary": "Gets metadata editor info for an item.", + "operationId": "GetMetadataEditorInfo", + "parameters": [ + { + "name": "itemId", + "in": "path", + "description": "The item id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Item metadata editor returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MetadataEditorInfo" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/MetadataEditorInfo" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/MetadataEditorInfo" + } + } + } + }, + "404": { + "description": "Item not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "RequiresElevation" + ] + } + ] + } + }, + "/Albums/{itemId}/Similar": { + "get": { + "tags": [ + "Library" + ], + "summary": "Gets similar items.", + "operationId": "GetSimilarAlbums", + "parameters": [ + { + "name": "itemId", + "in": "path", + "description": "The item id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "excludeArtistIds", + "in": "query", + "description": "Exclude artist ids.", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + }, + { + "name": "userId", + "in": "query", + "description": "Optional. Filter by user id, and attach user data.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "limit", + "in": "query", + "description": "Optional. The maximum number of records to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "fields", + "in": "query", + "description": "Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ItemFields" + } + } + } + ], + "responses": { + "200": { + "description": "Similar items returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Artists/{itemId}/Similar": { + "get": { + "tags": [ + "Library" + ], + "summary": "Gets similar items.", + "operationId": "GetSimilarArtists", + "parameters": [ + { + "name": "itemId", + "in": "path", + "description": "The item id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "excludeArtistIds", + "in": "query", + "description": "Exclude artist ids.", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + }, + { + "name": "userId", + "in": "query", + "description": "Optional. Filter by user id, and attach user data.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "limit", + "in": "query", + "description": "Optional. The maximum number of records to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "fields", + "in": "query", + "description": "Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ItemFields" + } + } + } + ], + "responses": { + "200": { + "description": "Similar items returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Items/{itemId}/Ancestors": { + "get": { + "tags": [ + "Library" + ], + "summary": "Gets all parents of an item.", + "operationId": "GetAncestors", + "parameters": [ + { + "name": "itemId", + "in": "path", + "description": "The item id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "userId", + "in": "query", + "description": "Optional. Filter by user id, and attach user data.", + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Item parents returned.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BaseItemDto" + } + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BaseItemDto" + } + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BaseItemDto" + } + } + } + } + }, + "404": { + "description": "Item not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Items/{itemId}/CriticReviews": { + "get": { + "tags": [ + "Library" + ], + "summary": "Gets critic review for an item.", + "operationId": "GetCriticReviews", + "parameters": [ + { + "name": "itemId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Critic reviews returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "deprecated": true, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Items/{itemId}/Download": { + "get": { + "tags": [ + "Library" + ], + "summary": "Downloads item media.", + "operationId": "GetDownload", + "parameters": [ + { + "name": "itemId", + "in": "path", + "description": "The item id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Media downloaded.", + "content": { + "video/*": { + "schema": { + "type": "string", + "format": "binary" + } + }, + "audio/*": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "404": { + "description": "Item not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "Download", + "DefaultAuthorization" + ] + } + ] + } + }, + "/Items/{itemId}/File": { + "get": { + "tags": [ + "Library" + ], + "summary": "Get the original file of an item.", + "operationId": "GetFile", + "parameters": [ + { + "name": "itemId", + "in": "path", + "description": "The item id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "File stream returned.", + "content": { + "video/*": { + "schema": { + "type": "string", + "format": "binary" + } + }, + "audio/*": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "404": { + "description": "Item not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Items/{itemId}/Similar": { + "get": { + "tags": [ + "Library" + ], + "summary": "Gets similar items.", + "operationId": "GetSimilarItems", + "parameters": [ + { + "name": "itemId", + "in": "path", + "description": "The item id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "excludeArtistIds", + "in": "query", + "description": "Exclude artist ids.", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + }, + { + "name": "userId", + "in": "query", + "description": "Optional. Filter by user id, and attach user data.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "limit", + "in": "query", + "description": "Optional. The maximum number of records to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "fields", + "in": "query", + "description": "Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ItemFields" + } + } + } + ], + "responses": { + "200": { + "description": "Similar items returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Items/{itemId}/ThemeMedia": { + "get": { + "tags": [ + "Library" + ], + "summary": "Get theme songs and videos for an item.", + "operationId": "GetThemeMedia", + "parameters": [ + { + "name": "itemId", + "in": "path", + "description": "The item id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "userId", + "in": "query", + "description": "Optional. Filter by user id, and attach user data.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "inheritFromParent", + "in": "query", + "description": "Optional. Determines whether or not parent items should be searched for theme media.", + "schema": { + "type": "boolean", + "default": false + } + }, + { + "name": "sortBy", + "in": "query", + "description": "Optional. Specify one or more sort orders, comma delimited. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ItemSortBy" + } + } + }, + { + "name": "sortOrder", + "in": "query", + "description": "Optional. Sort Order - Ascending, Descending.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SortOrder" + } + } + } + ], + "responses": { + "200": { + "description": "Theme songs and videos returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AllThemeMediaResult" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/AllThemeMediaResult" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/AllThemeMediaResult" + } + } + } + }, + "404": { + "description": "Item not found." + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Items/{itemId}/ThemeSongs": { + "get": { + "tags": [ + "Library" + ], + "summary": "Get theme songs for an item.", + "operationId": "GetThemeSongs", + "parameters": [ + { + "name": "itemId", + "in": "path", + "description": "The item id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "userId", + "in": "query", + "description": "Optional. Filter by user id, and attach user data.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "inheritFromParent", + "in": "query", + "description": "Optional. Determines whether or not parent items should be searched for theme media.", + "schema": { + "type": "boolean", + "default": false + } + }, + { + "name": "sortBy", + "in": "query", + "description": "Optional. Specify one or more sort orders, comma delimited. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ItemSortBy" + } + } + }, + { + "name": "sortOrder", + "in": "query", + "description": "Optional. Sort Order - Ascending, Descending.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SortOrder" + } + } + } + ], + "responses": { + "200": { + "description": "Theme songs returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ThemeMediaResult" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ThemeMediaResult" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ThemeMediaResult" + } + } + } + }, + "404": { + "description": "Item not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Items/{itemId}/ThemeVideos": { + "get": { + "tags": [ + "Library" + ], + "summary": "Get theme videos for an item.", + "operationId": "GetThemeVideos", + "parameters": [ + { + "name": "itemId", + "in": "path", + "description": "The item id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "userId", + "in": "query", + "description": "Optional. Filter by user id, and attach user data.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "inheritFromParent", + "in": "query", + "description": "Optional. Determines whether or not parent items should be searched for theme media.", + "schema": { + "type": "boolean", + "default": false + } + }, + { + "name": "sortBy", + "in": "query", + "description": "Optional. Specify one or more sort orders, comma delimited. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ItemSortBy" + } + } + }, + { + "name": "sortOrder", + "in": "query", + "description": "Optional. Sort Order - Ascending, Descending.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SortOrder" + } + } + } + ], + "responses": { + "200": { + "description": "Theme videos returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ThemeMediaResult" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ThemeMediaResult" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ThemeMediaResult" + } + } + } + }, + "404": { + "description": "Item not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Items/Counts": { + "get": { + "tags": [ + "Library" + ], + "summary": "Get item counts.", + "operationId": "GetItemCounts", + "parameters": [ + { + "name": "userId", + "in": "query", + "description": "Optional. Get counts from a specific user's library.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "isFavorite", + "in": "query", + "description": "Optional. Get counts of favorite items.", + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "description": "Item counts returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ItemCounts" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ItemCounts" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ItemCounts" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Libraries/AvailableOptions": { + "get": { + "tags": [ + "Library" + ], + "summary": "Gets the library options info.", + "operationId": "GetLibraryOptionsInfo", + "parameters": [ + { + "name": "libraryContentType", + "in": "query", + "description": "Library content type.", + "schema": { + "enum": [ + "unknown", + "movies", + "tvshows", + "music", + "musicvideos", + "trailers", + "homevideos", + "boxsets", + "books", + "photos", + "livetv", + "playlists", + "folders" + ], + "allOf": [ + { + "$ref": "#/components/schemas/CollectionType" + } + ] + } + }, + { + "name": "isNewLibrary", + "in": "query", + "description": "Whether this is a new library.", + "schema": { + "type": "boolean", + "default": false + } + } + ], + "responses": { + "200": { + "description": "Library options info returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LibraryOptionsResultDto" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/LibraryOptionsResultDto" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/LibraryOptionsResultDto" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "FirstTimeSetupOrDefault", + "DefaultAuthorization" + ] + } + ] + } + }, + "/Library/Media/Updated": { + "post": { + "tags": [ + "Library" + ], + "summary": "Reports that new movies have been added by an external source.", + "operationId": "PostUpdatedMedia", + "requestBody": { + "description": "The update paths.", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/MediaUpdateInfoDto" + } + ], + "description": "Media Update Info Dto." + } + }, + "text/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/MediaUpdateInfoDto" + } + ], + "description": "Media Update Info Dto." + } + }, + "application/*+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/MediaUpdateInfoDto" + } + ], + "description": "Media Update Info Dto." + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Report success." + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Library/MediaFolders": { + "get": { + "tags": [ + "Library" + ], + "summary": "Gets all user media folders.", + "operationId": "GetMediaFolders", + "parameters": [ + { + "name": "isHidden", + "in": "query", + "description": "Optional. Filter by folders that are marked hidden, or not.", + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "description": "Media folders returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "RequiresElevation" + ] + } + ] + } + }, + "/Library/Movies/Added": { + "post": { + "tags": [ + "Library" + ], + "summary": "Reports that new movies have been added by an external source.", + "operationId": "PostAddedMovies", + "parameters": [ + { + "name": "tmdbId", + "in": "query", + "description": "The tmdbId.", + "schema": { + "type": "string" + } + }, + { + "name": "imdbId", + "in": "query", + "description": "The imdbId.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Report success." + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Library/Movies/Updated": { + "post": { + "tags": [ + "Library" + ], + "summary": "Reports that new movies have been added by an external source.", + "operationId": "PostUpdatedMovies", + "parameters": [ + { + "name": "tmdbId", + "in": "query", + "description": "The tmdbId.", + "schema": { + "type": "string" + } + }, + { + "name": "imdbId", + "in": "query", + "description": "The imdbId.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Report success." + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Library/PhysicalPaths": { + "get": { + "tags": [ + "Library" + ], + "summary": "Gets a list of physical paths from virtual folders.", + "operationId": "GetPhysicalPaths", + "responses": { + "200": { + "description": "Physical paths returned.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "RequiresElevation" + ] + } + ] + } + }, + "/Library/Refresh": { + "post": { + "tags": [ + "Library" + ], + "summary": "Starts a library scan.", + "operationId": "RefreshLibrary", + "responses": { + "204": { + "description": "Library scan started." + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "RequiresElevation" + ] + } + ] + } + }, + "/Library/Series/Added": { + "post": { + "tags": [ + "Library" + ], + "summary": "Reports that new episodes of a series have been added by an external source.", + "operationId": "PostAddedSeries", + "parameters": [ + { + "name": "tvdbId", + "in": "query", + "description": "The tvdbId.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Report success." + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Library/Series/Updated": { + "post": { + "tags": [ + "Library" + ], + "summary": "Reports that new episodes of a series have been added by an external source.", + "operationId": "PostUpdatedSeries", + "parameters": [ + { + "name": "tvdbId", + "in": "query", + "description": "The tvdbId.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Report success." + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Movies/{itemId}/Similar": { + "get": { + "tags": [ + "Library" + ], + "summary": "Gets similar items.", + "operationId": "GetSimilarMovies", + "parameters": [ + { + "name": "itemId", + "in": "path", + "description": "The item id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "excludeArtistIds", + "in": "query", + "description": "Exclude artist ids.", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + }, + { + "name": "userId", + "in": "query", + "description": "Optional. Filter by user id, and attach user data.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "limit", + "in": "query", + "description": "Optional. The maximum number of records to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "fields", + "in": "query", + "description": "Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ItemFields" + } + } + } + ], + "responses": { + "200": { + "description": "Similar items returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Shows/{itemId}/Similar": { + "get": { + "tags": [ + "Library" + ], + "summary": "Gets similar items.", + "operationId": "GetSimilarShows", + "parameters": [ + { + "name": "itemId", + "in": "path", + "description": "The item id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "excludeArtistIds", + "in": "query", + "description": "Exclude artist ids.", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + }, + { + "name": "userId", + "in": "query", + "description": "Optional. Filter by user id, and attach user data.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "limit", + "in": "query", + "description": "Optional. The maximum number of records to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "fields", + "in": "query", + "description": "Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ItemFields" + } + } + } + ], + "responses": { + "200": { + "description": "Similar items returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Trailers/{itemId}/Similar": { + "get": { + "tags": [ + "Library" + ], + "summary": "Gets similar items.", + "operationId": "GetSimilarTrailers", + "parameters": [ + { + "name": "itemId", + "in": "path", + "description": "The item id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "excludeArtistIds", + "in": "query", + "description": "Exclude artist ids.", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + }, + { + "name": "userId", + "in": "query", + "description": "Optional. Filter by user id, and attach user data.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "limit", + "in": "query", + "description": "Optional. The maximum number of records to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "fields", + "in": "query", + "description": "Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ItemFields" + } + } + } + ], + "responses": { + "200": { + "description": "Similar items returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Library/VirtualFolders": { + "get": { + "tags": [ + "LibraryStructure" + ], + "summary": "Gets all virtual folders.", + "operationId": "GetVirtualFolders", + "responses": { + "200": { + "description": "Virtual folders retrieved.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/VirtualFolderInfo" + } + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/VirtualFolderInfo" + } + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/VirtualFolderInfo" + } + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "FirstTimeSetupOrElevated", + "DefaultAuthorization" + ] + } + ] + }, + "post": { + "tags": [ + "LibraryStructure" + ], + "summary": "Adds a virtual folder.", + "operationId": "AddVirtualFolder", + "parameters": [ + { + "name": "name", + "in": "query", + "description": "The name of the virtual folder.", + "schema": { + "type": "string" + } + }, + { + "name": "collectionType", + "in": "query", + "description": "The type of the collection.", + "schema": { + "enum": [ + "movies", + "tvshows", + "music", + "musicvideos", + "homevideos", + "boxsets", + "books", + "mixed" + ], + "allOf": [ + { + "$ref": "#/components/schemas/CollectionTypeOptions" + } + ] + } + }, + { + "name": "paths", + "in": "query", + "description": "The paths of the virtual folder.", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "name": "refreshLibrary", + "in": "query", + "description": "Whether to refresh the library.", + "schema": { + "type": "boolean", + "default": false + } + } + ], + "requestBody": { + "description": "The library options.", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/AddVirtualFolderDto" + } + ], + "description": "Add virtual folder dto." + } + }, + "text/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/AddVirtualFolderDto" + } + ], + "description": "Add virtual folder dto." + } + }, + "application/*+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/AddVirtualFolderDto" + } + ], + "description": "Add virtual folder dto." + } + } + } + }, + "responses": { + "204": { + "description": "Folder added." + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "FirstTimeSetupOrElevated", + "DefaultAuthorization" + ] + } + ] + }, + "delete": { + "tags": [ + "LibraryStructure" + ], + "summary": "Removes a virtual folder.", + "operationId": "RemoveVirtualFolder", + "parameters": [ + { + "name": "name", + "in": "query", + "description": "The name of the folder.", + "schema": { + "type": "string" + } + }, + { + "name": "refreshLibrary", + "in": "query", + "description": "Whether to refresh the library.", + "schema": { + "type": "boolean", + "default": false + } + } + ], + "responses": { + "204": { + "description": "Folder removed." + }, + "404": { + "description": "Folder not found." + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "FirstTimeSetupOrElevated", + "DefaultAuthorization" + ] + } + ] + } + }, + "/Library/VirtualFolders/LibraryOptions": { + "post": { + "tags": [ + "LibraryStructure" + ], + "summary": "Update library options.", + "operationId": "UpdateLibraryOptions", + "requestBody": { + "description": "The library name and options.", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/UpdateLibraryOptionsDto" + } + ], + "description": "Update library options dto." + } + }, + "text/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/UpdateLibraryOptionsDto" + } + ], + "description": "Update library options dto." + } + }, + "application/*+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/UpdateLibraryOptionsDto" + } + ], + "description": "Update library options dto." + } + } + } + }, + "responses": { + "204": { + "description": "Library updated." + }, + "404": { + "description": "Item not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "FirstTimeSetupOrElevated", + "DefaultAuthorization" + ] + } + ] + } + }, + "/Library/VirtualFolders/Name": { + "post": { + "tags": [ + "LibraryStructure" + ], + "summary": "Renames a virtual folder.", + "operationId": "RenameVirtualFolder", + "parameters": [ + { + "name": "name", + "in": "query", + "description": "The name of the virtual folder.", + "schema": { + "type": "string" + } + }, + { + "name": "newName", + "in": "query", + "description": "The new name.", + "schema": { + "type": "string" + } + }, + { + "name": "refreshLibrary", + "in": "query", + "description": "Whether to refresh the library.", + "schema": { + "type": "boolean", + "default": false + } + } + ], + "responses": { + "204": { + "description": "Folder renamed." + }, + "404": { + "description": "Library doesn't exist.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "409": { + "description": "Library already exists.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "FirstTimeSetupOrElevated", + "DefaultAuthorization" + ] + } + ] + } + }, + "/Library/VirtualFolders/Paths": { + "post": { + "tags": [ + "LibraryStructure" + ], + "summary": "Add a media path to a library.", + "operationId": "AddMediaPath", + "parameters": [ + { + "name": "refreshLibrary", + "in": "query", + "description": "Whether to refresh the library.", + "schema": { + "type": "boolean", + "default": false + } + } + ], + "requestBody": { + "description": "The media path dto.", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/MediaPathDto" + } + ], + "description": "Media Path dto." + } + }, + "text/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/MediaPathDto" + } + ], + "description": "Media Path dto." + } + }, + "application/*+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/MediaPathDto" + } + ], + "description": "Media Path dto." + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Media path added." + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "FirstTimeSetupOrElevated", + "DefaultAuthorization" + ] + } + ] + }, + "delete": { + "tags": [ + "LibraryStructure" + ], + "summary": "Remove a media path.", + "operationId": "RemoveMediaPath", + "parameters": [ + { + "name": "name", + "in": "query", + "description": "The name of the library.", + "schema": { + "type": "string" + } + }, + { + "name": "path", + "in": "query", + "description": "The path to remove.", + "schema": { + "type": "string" + } + }, + { + "name": "refreshLibrary", + "in": "query", + "description": "Whether to refresh the library.", + "schema": { + "type": "boolean", + "default": false + } + } + ], + "responses": { + "204": { + "description": "Media path removed." + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "FirstTimeSetupOrElevated", + "DefaultAuthorization" + ] + } + ] + } + }, + "/Library/VirtualFolders/Paths/Update": { + "post": { + "tags": [ + "LibraryStructure" + ], + "summary": "Updates a media path.", + "operationId": "UpdateMediaPath", + "requestBody": { + "description": "The name of the library and path infos.", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/UpdateMediaPathRequestDto" + } + ], + "description": "Update library options dto." + } + }, + "text/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/UpdateMediaPathRequestDto" + } + ], + "description": "Update library options dto." + } + }, + "application/*+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/UpdateMediaPathRequestDto" + } + ], + "description": "Update library options dto." + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Media path updated." + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "FirstTimeSetupOrElevated", + "DefaultAuthorization" + ] + } + ] + } + }, + "/LiveTv/ChannelMappingOptions": { + "get": { + "tags": [ + "LiveTv" + ], + "summary": "Get channel mapping options.", + "operationId": "GetChannelMappingOptions", + "parameters": [ + { + "name": "providerId", + "in": "query", + "description": "Provider id.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Channel mapping options returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChannelMappingOptionsDto" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ChannelMappingOptionsDto" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ChannelMappingOptionsDto" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "LiveTvAccess", + "DefaultAuthorization" + ] + } + ] + } + }, + "/LiveTv/ChannelMappings": { + "post": { + "tags": [ + "LiveTv" + ], + "summary": "Set channel mappings.", + "operationId": "SetChannelMapping", + "requestBody": { + "description": "The set channel mapping dto.", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/SetChannelMappingDto" + } + ], + "description": "Set channel mapping dto." + } + }, + "text/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/SetChannelMappingDto" + } + ], + "description": "Set channel mapping dto." + } + }, + "application/*+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/SetChannelMappingDto" + } + ], + "description": "Set channel mapping dto." + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Created channel mapping returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TunerChannelMapping" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/TunerChannelMapping" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/TunerChannelMapping" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "LiveTvManagement", + "DefaultAuthorization" + ] + } + ] + } + }, + "/LiveTv/Channels": { + "get": { + "tags": [ + "LiveTv" + ], + "summary": "Gets available live tv channels.", + "operationId": "GetLiveTvChannels", + "parameters": [ + { + "name": "type", + "in": "query", + "description": "Optional. Filter by channel type.", + "schema": { + "enum": [ + "TV", + "Radio" + ], + "allOf": [ + { + "$ref": "#/components/schemas/ChannelType" + } + ] + } + }, + { + "name": "userId", + "in": "query", + "description": "Optional. Filter by user and attach user data.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "startIndex", + "in": "query", + "description": "Optional. The record index to start at. All items with a lower index will be dropped from the results.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "isMovie", + "in": "query", + "description": "Optional. Filter for movies.", + "schema": { + "type": "boolean" + } + }, + { + "name": "isSeries", + "in": "query", + "description": "Optional. Filter for series.", + "schema": { + "type": "boolean" + } + }, + { + "name": "isNews", + "in": "query", + "description": "Optional. Filter for news.", + "schema": { + "type": "boolean" + } + }, + { + "name": "isKids", + "in": "query", + "description": "Optional. Filter for kids.", + "schema": { + "type": "boolean" + } + }, + { + "name": "isSports", + "in": "query", + "description": "Optional. Filter for sports.", + "schema": { + "type": "boolean" + } + }, + { + "name": "limit", + "in": "query", + "description": "Optional. The maximum number of records to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "isFavorite", + "in": "query", + "description": "Optional. Filter by channels that are favorites, or not.", + "schema": { + "type": "boolean" + } + }, + { + "name": "isLiked", + "in": "query", + "description": "Optional. Filter by channels that are liked, or not.", + "schema": { + "type": "boolean" + } + }, + { + "name": "isDisliked", + "in": "query", + "description": "Optional. Filter by channels that are disliked, or not.", + "schema": { + "type": "boolean" + } + }, + { + "name": "enableImages", + "in": "query", + "description": "Optional. Include image information in output.", + "schema": { + "type": "boolean" + } + }, + { + "name": "imageTypeLimit", + "in": "query", + "description": "Optional. The max number of images to return, per image type.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "enableImageTypes", + "in": "query", + "description": "\"Optional. The image types to include in the output.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ImageType" + } + } + }, + { + "name": "fields", + "in": "query", + "description": "Optional. Specify additional fields of information to return in the output.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ItemFields" + } + } + }, + { + "name": "enableUserData", + "in": "query", + "description": "Optional. Include user data.", + "schema": { + "type": "boolean" + } + }, + { + "name": "sortBy", + "in": "query", + "description": "Optional. Key to sort by.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ItemSortBy" + } + } + }, + { + "name": "sortOrder", + "in": "query", + "description": "Optional. Sort order.", + "schema": { + "enum": [ + "Ascending", + "Descending" + ], + "allOf": [ + { + "$ref": "#/components/schemas/SortOrder" + } + ] + } + }, + { + "name": "enableFavoriteSorting", + "in": "query", + "description": "Optional. Incorporate favorite and like status into channel sorting.", + "schema": { + "type": "boolean", + "default": false + } + }, + { + "name": "addCurrentProgram", + "in": "query", + "description": "Optional. Adds current program info to each channel.", + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Available live tv channels returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "LiveTvAccess", + "DefaultAuthorization" + ] + } + ] + } + }, + "/LiveTv/Channels/{channelId}": { + "get": { + "tags": [ + "LiveTv" + ], + "summary": "Gets a live tv channel.", + "operationId": "GetChannel", + "parameters": [ + { + "name": "channelId", + "in": "path", + "description": "Channel id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "userId", + "in": "query", + "description": "Optional. Attach user data.", + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Live tv channel returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BaseItemDto" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDto" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDto" + } + } + } + }, + "404": { + "description": "Item not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "LiveTvAccess", + "DefaultAuthorization" + ] + } + ] + } + }, + "/LiveTv/GuideInfo": { + "get": { + "tags": [ + "LiveTv" + ], + "summary": "Get guide info.", + "operationId": "GetGuideInfo", + "responses": { + "200": { + "description": "Guide info returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GuideInfo" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/GuideInfo" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/GuideInfo" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "LiveTvAccess", + "DefaultAuthorization" + ] + } + ] + } + }, + "/LiveTv/Info": { + "get": { + "tags": [ + "LiveTv" + ], + "summary": "Gets available live tv services.", + "operationId": "GetLiveTvInfo", + "responses": { + "200": { + "description": "Available live tv services returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LiveTvInfo" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/LiveTvInfo" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/LiveTvInfo" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "LiveTvAccess", + "DefaultAuthorization" + ] + } + ] + } + }, + "/LiveTv/ListingProviders": { + "post": { + "tags": [ + "LiveTv" + ], + "summary": "Adds a listings provider.", + "operationId": "AddListingProvider", + "parameters": [ + { + "name": "pw", + "in": "query", + "description": "Password.", + "schema": { + "type": "string" + } + }, + { + "name": "validateListings", + "in": "query", + "description": "Validate listings.", + "schema": { + "type": "boolean", + "default": false + } + }, + { + "name": "validateLogin", + "in": "query", + "description": "Validate login.", + "schema": { + "type": "boolean", + "default": false + } + } + ], + "requestBody": { + "description": "New listings info.", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/ListingsProviderInfo" + } + ] + } + }, + "text/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/ListingsProviderInfo" + } + ] + } + }, + "application/*+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/ListingsProviderInfo" + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "Created listings provider returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListingsProviderInfo" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ListingsProviderInfo" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ListingsProviderInfo" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "LiveTvManagement", + "DefaultAuthorization" + ] + } + ] + }, + "delete": { + "tags": [ + "LiveTv" + ], + "summary": "Delete listing provider.", + "operationId": "DeleteListingProvider", + "parameters": [ + { + "name": "id", + "in": "query", + "description": "Listing provider id.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Listing provider deleted." + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "LiveTvManagement", + "DefaultAuthorization" + ] + } + ] + } + }, + "/LiveTv/ListingProviders/Default": { + "get": { + "tags": [ + "LiveTv" + ], + "summary": "Gets default listings provider info.", + "operationId": "GetDefaultListingProvider", + "responses": { + "200": { + "description": "Default listings provider info returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListingsProviderInfo" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ListingsProviderInfo" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ListingsProviderInfo" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "LiveTvAccess", + "DefaultAuthorization" + ] + } + ] + } + }, + "/LiveTv/ListingProviders/Lineups": { + "get": { + "tags": [ + "LiveTv" + ], + "summary": "Gets available lineups.", + "operationId": "GetLineups", + "parameters": [ + { + "name": "id", + "in": "query", + "description": "Provider id.", + "schema": { + "type": "string" + } + }, + { + "name": "type", + "in": "query", + "description": "Provider type.", + "schema": { + "type": "string" + } + }, + { + "name": "location", + "in": "query", + "description": "Location.", + "schema": { + "type": "string" + } + }, + { + "name": "country", + "in": "query", + "description": "Country.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Available lineups returned.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NameIdPair" + } + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NameIdPair" + } + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NameIdPair" + } + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "LiveTvAccess", + "DefaultAuthorization" + ] + } + ] + } + }, + "/LiveTv/ListingProviders/SchedulesDirect/Countries": { + "get": { + "tags": [ + "LiveTv" + ], + "summary": "Gets available countries.", + "operationId": "GetSchedulesDirectCountries", + "responses": { + "200": { + "description": "Available countries returned.", + "content": { + "application/json": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "LiveTvAccess", + "DefaultAuthorization" + ] + } + ] + } + }, + "/LiveTv/LiveRecordings/{recordingId}/stream": { + "get": { + "tags": [ + "LiveTv" + ], + "summary": "Gets a live tv recording stream.", + "operationId": "GetLiveRecordingFile", + "parameters": [ + { + "name": "recordingId", + "in": "path", + "description": "Recording id.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Recording stream returned.", + "content": { + "video/*": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "404": { + "description": "Recording not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + } + } + } + }, + "/LiveTv/LiveStreamFiles/{streamId}/stream.{container}": { + "get": { + "tags": [ + "LiveTv" + ], + "summary": "Gets a live tv channel stream.", + "operationId": "GetLiveStreamFile", + "parameters": [ + { + "name": "streamId", + "in": "path", + "description": "Stream id.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "container", + "in": "path", + "description": "Container type.", + "required": true, + "schema": { + "pattern": "^[a-zA-Z0-9\\-\\._,|]{0,40}$", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Stream returned.", + "content": { + "video/*": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "404": { + "description": "Stream not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + } + } + } + }, + "/LiveTv/Programs": { + "get": { + "tags": [ + "LiveTv" + ], + "summary": "Gets available live tv epgs.", + "operationId": "GetLiveTvPrograms", + "parameters": [ + { + "name": "channelIds", + "in": "query", + "description": "The channels to return guide information for.", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + }, + { + "name": "userId", + "in": "query", + "description": "Optional. Filter by user id.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "minStartDate", + "in": "query", + "description": "Optional. The minimum premiere start date.", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "hasAired", + "in": "query", + "description": "Optional. Filter by programs that have completed airing, or not.", + "schema": { + "type": "boolean" + } + }, + { + "name": "isAiring", + "in": "query", + "description": "Optional. Filter by programs that are currently airing, or not.", + "schema": { + "type": "boolean" + } + }, + { + "name": "maxStartDate", + "in": "query", + "description": "Optional. The maximum premiere start date.", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "minEndDate", + "in": "query", + "description": "Optional. The minimum premiere end date.", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "maxEndDate", + "in": "query", + "description": "Optional. The maximum premiere end date.", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "isMovie", + "in": "query", + "description": "Optional. Filter for movies.", + "schema": { + "type": "boolean" + } + }, + { + "name": "isSeries", + "in": "query", + "description": "Optional. Filter for series.", + "schema": { + "type": "boolean" + } + }, + { + "name": "isNews", + "in": "query", + "description": "Optional. Filter for news.", + "schema": { + "type": "boolean" + } + }, + { + "name": "isKids", + "in": "query", + "description": "Optional. Filter for kids.", + "schema": { + "type": "boolean" + } + }, + { + "name": "isSports", + "in": "query", + "description": "Optional. Filter for sports.", + "schema": { + "type": "boolean" + } + }, + { + "name": "startIndex", + "in": "query", + "description": "Optional. The record index to start at. All items with a lower index will be dropped from the results.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "limit", + "in": "query", + "description": "Optional. The maximum number of records to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "sortBy", + "in": "query", + "description": "Optional. Specify one or more sort orders, comma delimited. Options: Name, StartDate.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ItemSortBy" + } + } + }, + { + "name": "sortOrder", + "in": "query", + "description": "Sort Order - Ascending,Descending.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SortOrder" + } + } + }, + { + "name": "genres", + "in": "query", + "description": "The genres to return guide information for.", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "name": "genreIds", + "in": "query", + "description": "The genre ids to return guide information for.", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + }, + { + "name": "enableImages", + "in": "query", + "description": "Optional. Include image information in output.", + "schema": { + "type": "boolean" + } + }, + { + "name": "imageTypeLimit", + "in": "query", + "description": "Optional. The max number of images to return, per image type.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "enableImageTypes", + "in": "query", + "description": "Optional. The image types to include in the output.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ImageType" + } + } + }, + { + "name": "enableUserData", + "in": "query", + "description": "Optional. Include user data.", + "schema": { + "type": "boolean" + } + }, + { + "name": "seriesTimerId", + "in": "query", + "description": "Optional. Filter by series timer id.", + "schema": { + "type": "string" + } + }, + { + "name": "librarySeriesId", + "in": "query", + "description": "Optional. Filter by library series id.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "fields", + "in": "query", + "description": "Optional. Specify additional fields of information to return in the output.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ItemFields" + } + } + }, + { + "name": "enableTotalRecordCount", + "in": "query", + "description": "Retrieve total record count.", + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Live tv epgs returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "LiveTvAccess", + "DefaultAuthorization" + ] + } + ] + }, + "post": { + "tags": [ + "LiveTv" + ], + "summary": "Gets available live tv epgs.", + "operationId": "GetPrograms", + "requestBody": { + "description": "Request body.", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/GetProgramsDto" + } + ], + "description": "Get programs dto." + } + }, + "text/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/GetProgramsDto" + } + ], + "description": "Get programs dto." + } + }, + "application/*+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/GetProgramsDto" + } + ], + "description": "Get programs dto." + } + } + } + }, + "responses": { + "200": { + "description": "Live tv epgs returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "LiveTvAccess", + "DefaultAuthorization" + ] + } + ] + } + }, + "/LiveTv/Programs/{programId}": { + "get": { + "tags": [ + "LiveTv" + ], + "summary": "Gets a live tv program.", + "operationId": "GetProgram", + "parameters": [ + { + "name": "programId", + "in": "path", + "description": "Program id.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "userId", + "in": "query", + "description": "Optional. Attach user data.", + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Program returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BaseItemDto" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDto" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDto" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "LiveTvAccess", + "DefaultAuthorization" + ] + } + ] + } + }, + "/LiveTv/Programs/Recommended": { + "get": { + "tags": [ + "LiveTv" + ], + "summary": "Gets recommended live tv epgs.", + "operationId": "GetRecommendedPrograms", + "parameters": [ + { + "name": "userId", + "in": "query", + "description": "Optional. filter by user id.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "startIndex", + "in": "query", + "description": "Optional. The record index to start at. All items with a lower index will be dropped from the results.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "limit", + "in": "query", + "description": "Optional. The maximum number of records to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "isAiring", + "in": "query", + "description": "Optional. Filter by programs that are currently airing, or not.", + "schema": { + "type": "boolean" + } + }, + { + "name": "hasAired", + "in": "query", + "description": "Optional. Filter by programs that have completed airing, or not.", + "schema": { + "type": "boolean" + } + }, + { + "name": "isSeries", + "in": "query", + "description": "Optional. Filter for series.", + "schema": { + "type": "boolean" + } + }, + { + "name": "isMovie", + "in": "query", + "description": "Optional. Filter for movies.", + "schema": { + "type": "boolean" + } + }, + { + "name": "isNews", + "in": "query", + "description": "Optional. Filter for news.", + "schema": { + "type": "boolean" + } + }, + { + "name": "isKids", + "in": "query", + "description": "Optional. Filter for kids.", + "schema": { + "type": "boolean" + } + }, + { + "name": "isSports", + "in": "query", + "description": "Optional. Filter for sports.", + "schema": { + "type": "boolean" + } + }, + { + "name": "enableImages", + "in": "query", + "description": "Optional. Include image information in output.", + "schema": { + "type": "boolean" + } + }, + { + "name": "imageTypeLimit", + "in": "query", + "description": "Optional. The max number of images to return, per image type.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "enableImageTypes", + "in": "query", + "description": "Optional. The image types to include in the output.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ImageType" + } + } + }, + { + "name": "genreIds", + "in": "query", + "description": "The genres to return guide information for.", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + }, + { + "name": "fields", + "in": "query", + "description": "Optional. Specify additional fields of information to return in the output.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ItemFields" + } + } + }, + { + "name": "enableUserData", + "in": "query", + "description": "Optional. include user data.", + "schema": { + "type": "boolean" + } + }, + { + "name": "enableTotalRecordCount", + "in": "query", + "description": "Retrieve total record count.", + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Recommended epgs returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "LiveTvAccess", + "DefaultAuthorization" + ] + } + ] + } + }, + "/LiveTv/Recordings": { + "get": { + "tags": [ + "LiveTv" + ], + "summary": "Gets live tv recordings.", + "operationId": "GetRecordings", + "parameters": [ + { + "name": "channelId", + "in": "query", + "description": "Optional. Filter by channel id.", + "schema": { + "type": "string" + } + }, + { + "name": "userId", + "in": "query", + "description": "Optional. Filter by user and attach user data.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "startIndex", + "in": "query", + "description": "Optional. The record index to start at. All items with a lower index will be dropped from the results.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "limit", + "in": "query", + "description": "Optional. The maximum number of records to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "status", + "in": "query", + "description": "Optional. Filter by recording status.", + "schema": { + "enum": [ + "New", + "InProgress", + "Completed", + "Cancelled", + "ConflictedOk", + "ConflictedNotOk", + "Error" + ], + "allOf": [ + { + "$ref": "#/components/schemas/RecordingStatus" + } + ] + } + }, + { + "name": "isInProgress", + "in": "query", + "description": "Optional. Filter by recordings that are in progress, or not.", + "schema": { + "type": "boolean" + } + }, + { + "name": "seriesTimerId", + "in": "query", + "description": "Optional. Filter by recordings belonging to a series timer.", + "schema": { + "type": "string" + } + }, + { + "name": "enableImages", + "in": "query", + "description": "Optional. Include image information in output.", + "schema": { + "type": "boolean" + } + }, + { + "name": "imageTypeLimit", + "in": "query", + "description": "Optional. The max number of images to return, per image type.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "enableImageTypes", + "in": "query", + "description": "Optional. The image types to include in the output.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ImageType" + } + } + }, + { + "name": "fields", + "in": "query", + "description": "Optional. Specify additional fields of information to return in the output.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ItemFields" + } + } + }, + { + "name": "enableUserData", + "in": "query", + "description": "Optional. Include user data.", + "schema": { + "type": "boolean" + } + }, + { + "name": "isMovie", + "in": "query", + "description": "Optional. Filter for movies.", + "schema": { + "type": "boolean" + } + }, + { + "name": "isSeries", + "in": "query", + "description": "Optional. Filter for series.", + "schema": { + "type": "boolean" + } + }, + { + "name": "isKids", + "in": "query", + "description": "Optional. Filter for kids.", + "schema": { + "type": "boolean" + } + }, + { + "name": "isSports", + "in": "query", + "description": "Optional. Filter for sports.", + "schema": { + "type": "boolean" + } + }, + { + "name": "isNews", + "in": "query", + "description": "Optional. Filter for news.", + "schema": { + "type": "boolean" + } + }, + { + "name": "isLibraryItem", + "in": "query", + "description": "Optional. Filter for is library item.", + "schema": { + "type": "boolean" + } + }, + { + "name": "enableTotalRecordCount", + "in": "query", + "description": "Optional. Return total record count.", + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Live tv recordings returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "LiveTvAccess", + "DefaultAuthorization" + ] + } + ] + } + }, + "/LiveTv/Recordings/{recordingId}": { + "get": { + "tags": [ + "LiveTv" + ], + "summary": "Gets a live tv recording.", + "operationId": "GetRecording", + "parameters": [ + { + "name": "recordingId", + "in": "path", + "description": "Recording id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "userId", + "in": "query", + "description": "Optional. Attach user data.", + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Recording returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BaseItemDto" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDto" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDto" + } + } + } + }, + "404": { + "description": "Item not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "LiveTvAccess", + "DefaultAuthorization" + ] + } + ] + }, + "delete": { + "tags": [ + "LiveTv" + ], + "summary": "Deletes a live tv recording.", + "operationId": "DeleteRecording", + "parameters": [ + { + "name": "recordingId", + "in": "path", + "description": "Recording id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "204": { + "description": "Recording deleted." + }, + "404": { + "description": "Item not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "LiveTvManagement", + "DefaultAuthorization" + ] + } + ] + } + }, + "/LiveTv/Recordings/Folders": { + "get": { + "tags": [ + "LiveTv" + ], + "summary": "Gets recording folders.", + "operationId": "GetRecordingFolders", + "parameters": [ + { + "name": "userId", + "in": "query", + "description": "Optional. Filter by user and attach user data.", + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Recording folders returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "LiveTvAccess", + "DefaultAuthorization" + ] + } + ] + } + }, + "/LiveTv/Recordings/Groups": { + "get": { + "tags": [ + "LiveTv" + ], + "summary": "Gets live tv recording groups.", + "operationId": "GetRecordingGroups", + "parameters": [ + { + "name": "userId", + "in": "query", + "description": "Optional. Filter by user and attach user data.", + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Recording groups returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "deprecated": true, + "security": [ + { + "CustomAuthentication": [ + "LiveTvAccess", + "DefaultAuthorization" + ] + } + ] + } + }, + "/LiveTv/Recordings/Groups/{groupId}": { + "get": { + "tags": [ + "LiveTv" + ], + "summary": "Get recording group.", + "operationId": "GetRecordingGroup", + "parameters": [ + { + "name": "groupId", + "in": "path", + "description": "Group id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "deprecated": true, + "security": [ + { + "CustomAuthentication": [ + "LiveTvAccess", + "DefaultAuthorization" + ] + } + ] + } + }, + "/LiveTv/Recordings/Series": { + "get": { + "tags": [ + "LiveTv" + ], + "summary": "Gets live tv recording series.", + "operationId": "GetRecordingsSeries", + "parameters": [ + { + "name": "channelId", + "in": "query", + "description": "Optional. Filter by channel id.", + "schema": { + "type": "string" + } + }, + { + "name": "userId", + "in": "query", + "description": "Optional. Filter by user and attach user data.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "groupId", + "in": "query", + "description": "Optional. Filter by recording group.", + "schema": { + "type": "string" + } + }, + { + "name": "startIndex", + "in": "query", + "description": "Optional. The record index to start at. All items with a lower index will be dropped from the results.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "limit", + "in": "query", + "description": "Optional. The maximum number of records to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "status", + "in": "query", + "description": "Optional. Filter by recording status.", + "schema": { + "enum": [ + "New", + "InProgress", + "Completed", + "Cancelled", + "ConflictedOk", + "ConflictedNotOk", + "Error" + ], + "allOf": [ + { + "$ref": "#/components/schemas/RecordingStatus" + } + ] + } + }, + { + "name": "isInProgress", + "in": "query", + "description": "Optional. Filter by recordings that are in progress, or not.", + "schema": { + "type": "boolean" + } + }, + { + "name": "seriesTimerId", + "in": "query", + "description": "Optional. Filter by recordings belonging to a series timer.", + "schema": { + "type": "string" + } + }, + { + "name": "enableImages", + "in": "query", + "description": "Optional. Include image information in output.", + "schema": { + "type": "boolean" + } + }, + { + "name": "imageTypeLimit", + "in": "query", + "description": "Optional. The max number of images to return, per image type.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "enableImageTypes", + "in": "query", + "description": "Optional. The image types to include in the output.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ImageType" + } + } + }, + { + "name": "fields", + "in": "query", + "description": "Optional. Specify additional fields of information to return in the output.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ItemFields" + } + } + }, + { + "name": "enableUserData", + "in": "query", + "description": "Optional. Include user data.", + "schema": { + "type": "boolean" + } + }, + { + "name": "enableTotalRecordCount", + "in": "query", + "description": "Optional. Return total record count.", + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Live tv recordings returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "deprecated": true, + "security": [ + { + "CustomAuthentication": [ + "LiveTvAccess", + "DefaultAuthorization" + ] + } + ] + } + }, + "/LiveTv/SeriesTimers": { + "get": { + "tags": [ + "LiveTv" + ], + "summary": "Gets live tv series timers.", + "operationId": "GetSeriesTimers", + "parameters": [ + { + "name": "sortBy", + "in": "query", + "description": "Optional. Sort by SortName or Priority.", + "schema": { + "type": "string" + } + }, + { + "name": "sortOrder", + "in": "query", + "description": "Optional. Sort in Ascending or Descending order.", + "schema": { + "enum": [ + "Ascending", + "Descending" + ], + "allOf": [ + { + "$ref": "#/components/schemas/SortOrder" + } + ] + } + } + ], + "responses": { + "200": { + "description": "Timers returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SeriesTimerInfoDtoQueryResult" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/SeriesTimerInfoDtoQueryResult" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/SeriesTimerInfoDtoQueryResult" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "LiveTvAccess", + "DefaultAuthorization" + ] + } + ] + }, + "post": { + "tags": [ + "LiveTv" + ], + "summary": "Creates a live tv series timer.", + "operationId": "CreateSeriesTimer", + "requestBody": { + "description": "New series timer info.", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/SeriesTimerInfoDto" + } + ], + "description": "Class SeriesTimerInfoDto." + } + }, + "text/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/SeriesTimerInfoDto" + } + ], + "description": "Class SeriesTimerInfoDto." + } + }, + "application/*+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/SeriesTimerInfoDto" + } + ], + "description": "Class SeriesTimerInfoDto." + } + } + } + }, + "responses": { + "204": { + "description": "Series timer info created." + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "LiveTvManagement", + "DefaultAuthorization" + ] + } + ] + } + }, + "/LiveTv/SeriesTimers/{timerId}": { + "get": { + "tags": [ + "LiveTv" + ], + "summary": "Gets a live tv series timer.", + "operationId": "GetSeriesTimer", + "parameters": [ + { + "name": "timerId", + "in": "path", + "description": "Timer id.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Series timer returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SeriesTimerInfoDto" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/SeriesTimerInfoDto" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/SeriesTimerInfoDto" + } + } + } + }, + "404": { + "description": "Series timer not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "LiveTvAccess", + "DefaultAuthorization" + ] + } + ] + }, + "delete": { + "tags": [ + "LiveTv" + ], + "summary": "Cancels a live tv series timer.", + "operationId": "CancelSeriesTimer", + "parameters": [ + { + "name": "timerId", + "in": "path", + "description": "Timer id.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Timer cancelled." + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "LiveTvManagement", + "DefaultAuthorization" + ] + } + ] + }, + "post": { + "tags": [ + "LiveTv" + ], + "summary": "Updates a live tv series timer.", + "operationId": "UpdateSeriesTimer", + "parameters": [ + { + "name": "timerId", + "in": "path", + "description": "Timer id.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "New series timer info.", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/SeriesTimerInfoDto" + } + ], + "description": "Class SeriesTimerInfoDto." + } + }, + "text/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/SeriesTimerInfoDto" + } + ], + "description": "Class SeriesTimerInfoDto." + } + }, + "application/*+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/SeriesTimerInfoDto" + } + ], + "description": "Class SeriesTimerInfoDto." + } + } + } + }, + "responses": { + "204": { + "description": "Series timer updated." + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "LiveTvManagement", + "DefaultAuthorization" + ] + } + ] + } + }, + "/LiveTv/Timers": { + "get": { + "tags": [ + "LiveTv" + ], + "summary": "Gets the live tv timers.", + "operationId": "GetTimers", + "parameters": [ + { + "name": "channelId", + "in": "query", + "description": "Optional. Filter by channel id.", + "schema": { + "type": "string" + } + }, + { + "name": "seriesTimerId", + "in": "query", + "description": "Optional. Filter by timers belonging to a series timer.", + "schema": { + "type": "string" + } + }, + { + "name": "isActive", + "in": "query", + "description": "Optional. Filter by timers that are active.", + "schema": { + "type": "boolean" + } + }, + { + "name": "isScheduled", + "in": "query", + "description": "Optional. Filter by timers that are scheduled.", + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TimerInfoDtoQueryResult" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/TimerInfoDtoQueryResult" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/TimerInfoDtoQueryResult" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "LiveTvAccess", + "DefaultAuthorization" + ] + } + ] + }, + "post": { + "tags": [ + "LiveTv" + ], + "summary": "Creates a live tv timer.", + "operationId": "CreateTimer", + "requestBody": { + "description": "New timer info.", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/TimerInfoDto" + } + ] + } + }, + "text/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/TimerInfoDto" + } + ] + } + }, + "application/*+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/TimerInfoDto" + } + ] + } + } + } + }, + "responses": { + "204": { + "description": "Timer created." + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "LiveTvManagement", + "DefaultAuthorization" + ] + } + ] + } + }, + "/LiveTv/Timers/{timerId}": { + "get": { + "tags": [ + "LiveTv" + ], + "summary": "Gets a timer.", + "operationId": "GetTimer", + "parameters": [ + { + "name": "timerId", + "in": "path", + "description": "Timer id.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Timer returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TimerInfoDto" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/TimerInfoDto" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/TimerInfoDto" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "LiveTvAccess", + "DefaultAuthorization" + ] + } + ] + }, + "delete": { + "tags": [ + "LiveTv" + ], + "summary": "Cancels a live tv timer.", + "operationId": "CancelTimer", + "parameters": [ + { + "name": "timerId", + "in": "path", + "description": "Timer id.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Timer deleted." + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "LiveTvManagement", + "DefaultAuthorization" + ] + } + ] + }, + "post": { + "tags": [ + "LiveTv" + ], + "summary": "Updates a live tv timer.", + "operationId": "UpdateTimer", + "parameters": [ + { + "name": "timerId", + "in": "path", + "description": "Timer id.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "New timer info.", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/TimerInfoDto" + } + ] + } + }, + "text/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/TimerInfoDto" + } + ] + } + }, + "application/*+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/TimerInfoDto" + } + ] + } + } + } + }, + "responses": { + "204": { + "description": "Timer updated." + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "LiveTvManagement", + "DefaultAuthorization" + ] + } + ] + } + }, + "/LiveTv/Timers/Defaults": { + "get": { + "tags": [ + "LiveTv" + ], + "summary": "Gets the default values for a new timer.", + "operationId": "GetDefaultTimer", + "parameters": [ + { + "name": "programId", + "in": "query", + "description": "Optional. To attach default values based on a program.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Default values returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SeriesTimerInfoDto" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/SeriesTimerInfoDto" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/SeriesTimerInfoDto" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "LiveTvAccess", + "DefaultAuthorization" + ] + } + ] + } + }, + "/LiveTv/TunerHosts": { + "post": { + "tags": [ + "LiveTv" + ], + "summary": "Adds a tuner host.", + "operationId": "AddTunerHost", + "requestBody": { + "description": "New tuner host.", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/TunerHostInfo" + } + ] + } + }, + "text/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/TunerHostInfo" + } + ] + } + }, + "application/*+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/TunerHostInfo" + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "Created tuner host returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TunerHostInfo" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/TunerHostInfo" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/TunerHostInfo" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "LiveTvManagement", + "DefaultAuthorization" + ] + } + ] + }, + "delete": { + "tags": [ + "LiveTv" + ], + "summary": "Deletes a tuner host.", + "operationId": "DeleteTunerHost", + "parameters": [ + { + "name": "id", + "in": "query", + "description": "Tuner host id.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Tuner host deleted." + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "LiveTvManagement", + "DefaultAuthorization" + ] + } + ] + } + }, + "/LiveTv/TunerHosts/Types": { + "get": { + "tags": [ + "LiveTv" + ], + "summary": "Get tuner host types.", + "operationId": "GetTunerHostTypes", + "responses": { + "200": { + "description": "Tuner host types returned.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NameIdPair" + } + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NameIdPair" + } + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NameIdPair" + } + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "LiveTvAccess", + "DefaultAuthorization" + ] + } + ] + } + }, + "/LiveTv/Tuners/{tunerId}/Reset": { + "post": { + "tags": [ + "LiveTv" + ], + "summary": "Resets a tv tuner.", + "operationId": "ResetTuner", + "parameters": [ + { + "name": "tunerId", + "in": "path", + "description": "Tuner id.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Tuner reset." + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "LiveTvManagement", + "DefaultAuthorization" + ] + } + ] + } + }, + "/LiveTv/Tuners/Discover": { + "get": { + "tags": [ + "LiveTv" + ], + "summary": "Discover tuners.", + "operationId": "DiscoverTuners", + "parameters": [ + { + "name": "newDevicesOnly", + "in": "query", + "description": "Only discover new tuners.", + "schema": { + "type": "boolean", + "default": false + } + } + ], + "responses": { + "200": { + "description": "Tuners returned.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TunerHostInfo" + } + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TunerHostInfo" + } + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TunerHostInfo" + } + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "LiveTvManagement", + "DefaultAuthorization" + ] + } + ] + } + }, + "/LiveTv/Tuners/Discvover": { + "get": { + "tags": [ + "LiveTv" + ], + "summary": "Discover tuners.", + "operationId": "DiscvoverTuners", + "parameters": [ + { + "name": "newDevicesOnly", + "in": "query", + "description": "Only discover new tuners.", + "schema": { + "type": "boolean", + "default": false + } + } + ], + "responses": { + "200": { + "description": "Tuners returned.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TunerHostInfo" + } + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TunerHostInfo" + } + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TunerHostInfo" + } + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "LiveTvManagement", + "DefaultAuthorization" + ] + } + ] + } + }, + "/Localization/Countries": { + "get": { + "tags": [ + "Localization" + ], + "summary": "Gets known countries.", + "operationId": "GetCountries", + "responses": { + "200": { + "description": "Known countries returned.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CountryInfo" + } + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CountryInfo" + } + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CountryInfo" + } + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "FirstTimeSetupOrDefault", + "DefaultAuthorization" + ] + } + ] + } + }, + "/Localization/Cultures": { + "get": { + "tags": [ + "Localization" + ], + "summary": "Gets known cultures.", + "operationId": "GetCultures", + "responses": { + "200": { + "description": "Known cultures returned.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CultureDto" + } + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CultureDto" + } + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CultureDto" + } + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "FirstTimeSetupOrDefault", + "DefaultAuthorization" + ] + } + ] + } + }, + "/Localization/Options": { + "get": { + "tags": [ + "Localization" + ], + "summary": "Gets localization options.", + "operationId": "GetLocalizationOptions", + "responses": { + "200": { + "description": "Localization options returned.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LocalizationOption" + } + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LocalizationOption" + } + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LocalizationOption" + } + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "FirstTimeSetupOrDefault", + "DefaultAuthorization" + ] + } + ] + } + }, + "/Localization/ParentalRatings": { + "get": { + "tags": [ + "Localization" + ], + "summary": "Gets known parental ratings.", + "operationId": "GetParentalRatings", + "responses": { + "200": { + "description": "Known parental ratings returned.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ParentalRating" + } + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ParentalRating" + } + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ParentalRating" + } + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "FirstTimeSetupOrDefault", + "DefaultAuthorization" + ] + } + ] + } + }, + "/Audio/{itemId}/Lyrics": { + "get": { + "tags": [ + "Lyrics" + ], + "summary": "Gets an item's lyrics.", + "operationId": "GetLyrics", + "parameters": [ + { + "name": "itemId", + "in": "path", + "description": "Item id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Lyrics returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LyricDto" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/LyricDto" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/LyricDto" + } + } + } + }, + "404": { + "description": "Something went wrong. No Lyrics will be returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + }, + "post": { + "tags": [ + "Lyrics" + ], + "summary": "Upload an external lyric file.", + "operationId": "UploadLyrics", + "parameters": [ + { + "name": "itemId", + "in": "path", + "description": "The item the lyric belongs to.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "fileName", + "in": "query", + "description": "Name of the file being uploaded.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "text/plain": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "responses": { + "200": { + "description": "Lyrics uploaded.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LyricDto" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/LyricDto" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/LyricDto" + } + } + } + }, + "400": { + "description": "Error processing upload.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Item not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "LyricManagement", + "DefaultAuthorization" + ] + } + ] + }, + "delete": { + "tags": [ + "Lyrics" + ], + "summary": "Deletes an external lyric file.", + "operationId": "DeleteLyrics", + "parameters": [ + { + "name": "itemId", + "in": "path", + "description": "The item id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "204": { + "description": "Lyric deleted." + }, + "404": { + "description": "Item not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "LyricManagement", + "DefaultAuthorization" + ] + } + ] + } + }, + "/Audio/{itemId}/RemoteSearch/Lyrics": { + "get": { + "tags": [ + "Lyrics" + ], + "summary": "Search remote lyrics.", + "operationId": "SearchRemoteLyrics", + "parameters": [ + { + "name": "itemId", + "in": "path", + "description": "The item id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Lyrics retrieved.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RemoteLyricInfoDto" + } + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RemoteLyricInfoDto" + } + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RemoteLyricInfoDto" + } + } + } + } + }, + "404": { + "description": "Item not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "LyricManagement", + "DefaultAuthorization" + ] + } + ] + } + }, + "/Audio/{itemId}/RemoteSearch/Lyrics/{lyricId}": { + "post": { + "tags": [ + "Lyrics" + ], + "summary": "Downloads a remote lyric.", + "operationId": "DownloadRemoteLyrics", + "parameters": [ + { + "name": "itemId", + "in": "path", + "description": "The item id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "lyricId", + "in": "path", + "description": "The lyric id.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Lyric downloaded.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LyricDto" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/LyricDto" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/LyricDto" + } + } + } + }, + "404": { + "description": "Item not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "LyricManagement", + "DefaultAuthorization" + ] + } + ] + } + }, + "/Providers/Lyrics/{lyricId}": { + "get": { + "tags": [ + "Lyrics" + ], + "summary": "Gets the remote lyrics.", + "operationId": "GetRemoteLyrics", + "parameters": [ + { + "name": "lyricId", + "in": "path", + "description": "The remote provider item id.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "File returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LyricDto" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/LyricDto" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/LyricDto" + } + } + } + }, + "404": { + "description": "Lyric not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "LyricManagement", + "DefaultAuthorization" + ] + } + ] + } + }, + "/Items/{itemId}/PlaybackInfo": { + "get": { + "tags": [ + "MediaInfo" + ], + "summary": "Gets live playback media info for an item.", + "operationId": "GetPlaybackInfo", + "parameters": [ + { + "name": "itemId", + "in": "path", + "description": "The item id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "userId", + "in": "query", + "description": "The user id.", + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Playback info returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlaybackInfoResponse" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/PlaybackInfoResponse" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/PlaybackInfoResponse" + } + } + } + }, + "404": { + "description": "Item not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + }, + "post": { + "tags": [ + "MediaInfo" + ], + "summary": "Gets live playback media info for an item.", + "description": "For backwards compatibility parameters can be sent via Query or Body, with Query having higher precedence.\r\nQuery parameters are obsolete.", + "operationId": "GetPostedPlaybackInfo", + "parameters": [ + { + "name": "itemId", + "in": "path", + "description": "The item id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "userId", + "in": "query", + "description": "The user id.", + "deprecated": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "maxStreamingBitrate", + "in": "query", + "description": "The maximum streaming bitrate.", + "deprecated": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "startTimeTicks", + "in": "query", + "description": "The start time in ticks.", + "deprecated": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "audioStreamIndex", + "in": "query", + "description": "The audio stream index.", + "deprecated": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "subtitleStreamIndex", + "in": "query", + "description": "The subtitle stream index.", + "deprecated": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxAudioChannels", + "in": "query", + "description": "The maximum number of audio channels.", + "deprecated": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "mediaSourceId", + "in": "query", + "description": "The media source id.", + "deprecated": true, + "schema": { + "type": "string" + } + }, + { + "name": "liveStreamId", + "in": "query", + "description": "The livestream id.", + "deprecated": true, + "schema": { + "type": "string" + } + }, + { + "name": "autoOpenLiveStream", + "in": "query", + "description": "Whether to auto open the livestream.", + "deprecated": true, + "schema": { + "type": "boolean" + } + }, + { + "name": "enableDirectPlay", + "in": "query", + "description": "Whether to enable direct play. Default: true.", + "deprecated": true, + "schema": { + "type": "boolean" + } + }, + { + "name": "enableDirectStream", + "in": "query", + "description": "Whether to enable direct stream. Default: true.", + "deprecated": true, + "schema": { + "type": "boolean" + } + }, + { + "name": "enableTranscoding", + "in": "query", + "description": "Whether to enable transcoding. Default: true.", + "deprecated": true, + "schema": { + "type": "boolean" + } + }, + { + "name": "allowVideoStreamCopy", + "in": "query", + "description": "Whether to allow to copy the video stream. Default: true.", + "deprecated": true, + "schema": { + "type": "boolean" + } + }, + { + "name": "allowAudioStreamCopy", + "in": "query", + "description": "Whether to allow to copy the audio stream. Default: true.", + "deprecated": true, + "schema": { + "type": "boolean" + } + } + ], + "requestBody": { + "description": "The playback info.", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/PlaybackInfoDto" + } + ], + "description": "Playback info dto." + } + }, + "text/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/PlaybackInfoDto" + } + ], + "description": "Playback info dto." + } + }, + "application/*+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/PlaybackInfoDto" + } + ], + "description": "Playback info dto." + } + } + } + }, + "responses": { + "200": { + "description": "Playback info returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlaybackInfoResponse" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/PlaybackInfoResponse" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/PlaybackInfoResponse" + } + } + } + }, + "404": { + "description": "Item not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/LiveStreams/Close": { + "post": { + "tags": [ + "MediaInfo" + ], + "summary": "Closes a media source.", + "operationId": "CloseLiveStream", + "parameters": [ + { + "name": "liveStreamId", + "in": "query", + "description": "The livestream id.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Livestream closed." + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/LiveStreams/Open": { + "post": { + "tags": [ + "MediaInfo" + ], + "summary": "Opens a media source.", + "operationId": "OpenLiveStream", + "parameters": [ + { + "name": "openToken", + "in": "query", + "description": "The open token.", + "schema": { + "type": "string" + } + }, + { + "name": "userId", + "in": "query", + "description": "The user id.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "playSessionId", + "in": "query", + "description": "The play session id.", + "schema": { + "type": "string" + } + }, + { + "name": "maxStreamingBitrate", + "in": "query", + "description": "The maximum streaming bitrate.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "startTimeTicks", + "in": "query", + "description": "The start time in ticks.", + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "audioStreamIndex", + "in": "query", + "description": "The audio stream index.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "subtitleStreamIndex", + "in": "query", + "description": "The subtitle stream index.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxAudioChannels", + "in": "query", + "description": "The maximum number of audio channels.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "itemId", + "in": "query", + "description": "The item id.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "enableDirectPlay", + "in": "query", + "description": "Whether to enable direct play. Default: true.", + "schema": { + "type": "boolean" + } + }, + { + "name": "enableDirectStream", + "in": "query", + "description": "Whether to enable direct stream. Default: true.", + "schema": { + "type": "boolean" + } + }, + { + "name": "alwaysBurnInSubtitleWhenTranscoding", + "in": "query", + "description": "Always burn-in subtitle when transcoding.", + "schema": { + "type": "boolean" + } + } + ], + "requestBody": { + "description": "The open live stream dto.", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/OpenLiveStreamDto" + } + ], + "description": "Open live stream dto." + } + }, + "text/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/OpenLiveStreamDto" + } + ], + "description": "Open live stream dto." + } + }, + "application/*+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/OpenLiveStreamDto" + } + ], + "description": "Open live stream dto." + } + } + } + }, + "responses": { + "200": { + "description": "Media source opened.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LiveStreamResponse" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/LiveStreamResponse" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/LiveStreamResponse" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Playback/BitrateTest": { + "get": { + "tags": [ + "MediaInfo" + ], + "summary": "Tests the network with a request with the size of the bitrate.", + "operationId": "GetBitrateTestBytes", + "parameters": [ + { + "name": "size", + "in": "query", + "description": "The bitrate. Defaults to 102400.", + "schema": { + "maximum": 100000000, + "minimum": 1, + "type": "integer", + "format": "int32", + "default": 102400 + } + } + ], + "responses": { + "200": { + "description": "Test buffer returned.", + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/MediaSegments/{itemId}": { + "get": { + "tags": [ + "MediaSegments" + ], + "summary": "Gets all media segments based on an itemId.", + "operationId": "GetItemSegments", + "parameters": [ + { + "name": "itemId", + "in": "path", + "description": "The ItemId.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "includeSegmentTypes", + "in": "query", + "description": "Optional filter of requested segment types.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MediaSegmentType" + } + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MediaSegmentDtoQueryResult" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/MediaSegmentDtoQueryResult" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/MediaSegmentDtoQueryResult" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Movies/Recommendations": { + "get": { + "tags": [ + "Movies" + ], + "summary": "Gets movie recommendations.", + "operationId": "GetMovieRecommendations", + "parameters": [ + { + "name": "userId", + "in": "query", + "description": "Optional. Filter by user id, and attach user data.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "parentId", + "in": "query", + "description": "Specify this to localize the search to a specific item or folder. Omit to use the root.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "fields", + "in": "query", + "description": "Optional. The fields to return.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ItemFields" + } + } + }, + { + "name": "categoryLimit", + "in": "query", + "description": "The max number of categories to return.", + "schema": { + "type": "integer", + "format": "int32", + "default": 5 + } + }, + { + "name": "itemLimit", + "in": "query", + "description": "The max number of items to return per category.", + "schema": { + "type": "integer", + "format": "int32", + "default": 8 + } + } + ], + "responses": { + "200": { + "description": "Movie recommendations returned.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RecommendationDto" + } + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RecommendationDto" + } + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RecommendationDto" + } + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/MusicGenres": { + "get": { + "tags": [ + "MusicGenres" + ], + "summary": "Gets all music genres from a given item, folder, or the entire library.", + "operationId": "GetMusicGenres", + "parameters": [ + { + "name": "startIndex", + "in": "query", + "description": "Optional. The record index to start at. All items with a lower index will be dropped from the results.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "limit", + "in": "query", + "description": "Optional. The maximum number of records to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "searchTerm", + "in": "query", + "description": "The search term.", + "schema": { + "type": "string" + } + }, + { + "name": "parentId", + "in": "query", + "description": "Specify this to localize the search to a specific item or folder. Omit to use the root.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "fields", + "in": "query", + "description": "Optional. Specify additional fields of information to return in the output.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ItemFields" + } + } + }, + { + "name": "excludeItemTypes", + "in": "query", + "description": "Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BaseItemKind" + } + } + }, + { + "name": "includeItemTypes", + "in": "query", + "description": "Optional. If specified, results will be filtered in based on item type. This allows multiple, comma delimited.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BaseItemKind" + } + } + }, + { + "name": "isFavorite", + "in": "query", + "description": "Optional filter by items that are marked as favorite, or not.", + "schema": { + "type": "boolean" + } + }, + { + "name": "imageTypeLimit", + "in": "query", + "description": "Optional, the max number of images to return, per image type.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "enableImageTypes", + "in": "query", + "description": "Optional. The image types to include in the output.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ImageType" + } + } + }, + { + "name": "userId", + "in": "query", + "description": "User id.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "nameStartsWithOrGreater", + "in": "query", + "description": "Optional filter by items whose name is sorted equally or greater than a given input string.", + "schema": { + "type": "string" + } + }, + { + "name": "nameStartsWith", + "in": "query", + "description": "Optional filter by items whose name is sorted equally than a given input string.", + "schema": { + "type": "string" + } + }, + { + "name": "nameLessThan", + "in": "query", + "description": "Optional filter by items whose name is equally or lesser than a given input string.", + "schema": { + "type": "string" + } + }, + { + "name": "sortBy", + "in": "query", + "description": "Optional. Specify one or more sort orders, comma delimited.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ItemSortBy" + } + } + }, + { + "name": "sortOrder", + "in": "query", + "description": "Sort Order - Ascending,Descending.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SortOrder" + } + } + }, + { + "name": "enableImages", + "in": "query", + "description": "Optional, include image information in output.", + "schema": { + "type": "boolean", + "default": true + } + }, + { + "name": "enableTotalRecordCount", + "in": "query", + "description": "Optional. Include total record count.", + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Music genres returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "deprecated": true, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/MusicGenres/{genreName}": { + "get": { + "tags": [ + "MusicGenres" + ], + "summary": "Gets a music genre, by name.", + "operationId": "GetMusicGenre", + "parameters": [ + { + "name": "genreName", + "in": "path", + "description": "The genre name.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "userId", + "in": "query", + "description": "Optional. Filter by user id, and attach user data.", + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BaseItemDto" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDto" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDto" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Packages": { + "get": { + "tags": [ + "Package" + ], + "summary": "Gets available packages.", + "operationId": "GetPackages", + "responses": { + "200": { + "description": "Available packages returned.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PackageInfo" + } + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PackageInfo" + } + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PackageInfo" + } + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "RequiresElevation" + ] + } + ] + } + }, + "/Packages/{name}": { + "get": { + "tags": [ + "Package" + ], + "summary": "Gets a package by name or assembly GUID.", + "operationId": "GetPackageInfo", + "parameters": [ + { + "name": "name", + "in": "path", + "description": "The name of the package.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "assemblyGuid", + "in": "query", + "description": "The GUID of the associated assembly.", + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Package retrieved.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PackageInfo" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/PackageInfo" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/PackageInfo" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "RequiresElevation" + ] + } + ] + } + }, + "/Packages/Installed/{name}": { + "post": { + "tags": [ + "Package" + ], + "summary": "Installs a package.", + "operationId": "InstallPackage", + "parameters": [ + { + "name": "name", + "in": "path", + "description": "Package name.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "assemblyGuid", + "in": "query", + "description": "GUID of the associated assembly.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "version", + "in": "query", + "description": "Optional version. Defaults to latest version.", + "schema": { + "type": "string" + } + }, + { + "name": "repositoryUrl", + "in": "query", + "description": "Optional. Specify the repository to install from.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Package found." + }, + "404": { + "description": "Package not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "RequiresElevation" + ] + } + ] + } + }, + "/Packages/Installing/{packageId}": { + "delete": { + "tags": [ + "Package" + ], + "summary": "Cancels a package installation.", + "operationId": "CancelPackageInstallation", + "parameters": [ + { + "name": "packageId", + "in": "path", + "description": "Installation Id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "204": { + "description": "Installation cancelled." + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "RequiresElevation" + ] + } + ] + } + }, + "/Repositories": { + "get": { + "tags": [ + "Package" + ], + "summary": "Gets all package repositories.", + "operationId": "GetRepositories", + "responses": { + "200": { + "description": "Package repositories returned.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RepositoryInfo" + } + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RepositoryInfo" + } + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RepositoryInfo" + } + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "RequiresElevation" + ] + } + ] + }, + "post": { + "tags": [ + "Package" + ], + "summary": "Sets the enabled and existing package repositories.", + "operationId": "SetRepositories", + "requestBody": { + "description": "The list of package repositories.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RepositoryInfo" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RepositoryInfo" + } + } + }, + "application/*+json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RepositoryInfo" + } + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Package repositories saved." + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "RequiresElevation" + ] + } + ] + } + }, + "/Persons": { + "get": { + "tags": [ + "Persons" + ], + "summary": "Gets all persons.", + "operationId": "GetPersons", + "parameters": [ + { + "name": "limit", + "in": "query", + "description": "Optional. The maximum number of records to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "searchTerm", + "in": "query", + "description": "The search term.", + "schema": { + "type": "string" + } + }, + { + "name": "fields", + "in": "query", + "description": "Optional. Specify additional fields of information to return in the output.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ItemFields" + } + } + }, + { + "name": "filters", + "in": "query", + "description": "Optional. Specify additional filters to apply.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ItemFilter" + } + } + }, + { + "name": "isFavorite", + "in": "query", + "description": "Optional filter by items that are marked as favorite, or not. userId is required.", + "schema": { + "type": "boolean" + } + }, + { + "name": "enableUserData", + "in": "query", + "description": "Optional, include user data.", + "schema": { + "type": "boolean" + } + }, + { + "name": "imageTypeLimit", + "in": "query", + "description": "Optional, the max number of images to return, per image type.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "enableImageTypes", + "in": "query", + "description": "Optional. The image types to include in the output.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ImageType" + } + } + }, + { + "name": "excludePersonTypes", + "in": "query", + "description": "Optional. If specified results will be filtered to exclude those containing the specified PersonType. Allows multiple, comma-delimited.", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "name": "personTypes", + "in": "query", + "description": "Optional. If specified results will be filtered to include only those containing the specified PersonType. Allows multiple, comma-delimited.", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "name": "appearsInItemId", + "in": "query", + "description": "Optional. If specified, person results will be filtered on items related to said persons.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "userId", + "in": "query", + "description": "User id.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "enableImages", + "in": "query", + "description": "Optional, include image information in output.", + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Persons returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Persons/{name}": { + "get": { + "tags": [ + "Persons" + ], + "summary": "Get person by name.", + "operationId": "GetPerson", + "parameters": [ + { + "name": "name", + "in": "path", + "description": "Person name.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "userId", + "in": "query", + "description": "Optional. Filter by user id, and attach user data.", + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Person returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BaseItemDto" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDto" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDto" + } + } + } + }, + "404": { + "description": "Person not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Playlists": { + "post": { + "tags": [ + "Playlists" + ], + "summary": "Creates a new playlist.", + "description": "For backwards compatibility parameters can be sent via Query or Body, with Query having higher precedence.\r\nQuery parameters are obsolete.", + "operationId": "CreatePlaylist", + "parameters": [ + { + "name": "name", + "in": "query", + "description": "The playlist name.", + "deprecated": true, + "schema": { + "type": "string" + } + }, + { + "name": "ids", + "in": "query", + "description": "The item ids.", + "deprecated": true, + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + }, + { + "name": "userId", + "in": "query", + "description": "The user id.", + "deprecated": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "mediaType", + "in": "query", + "description": "The media type.", + "deprecated": true, + "schema": { + "enum": [ + "Unknown", + "Video", + "Audio", + "Photo", + "Book" + ], + "allOf": [ + { + "$ref": "#/components/schemas/MediaType" + } + ] + } + } + ], + "requestBody": { + "description": "The create playlist payload.", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/CreatePlaylistDto" + } + ], + "description": "Create new playlist dto." + } + }, + "text/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/CreatePlaylistDto" + } + ], + "description": "Create new playlist dto." + } + }, + "application/*+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/CreatePlaylistDto" + } + ], + "description": "Create new playlist dto." + } + } + } + }, + "responses": { + "200": { + "description": "Playlist created.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlaylistCreationResult" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/PlaylistCreationResult" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/PlaylistCreationResult" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Playlists/{playlistId}": { + "post": { + "tags": [ + "Playlists" + ], + "summary": "Updates a playlist.", + "operationId": "UpdatePlaylist", + "parameters": [ + { + "name": "playlistId", + "in": "path", + "description": "The playlist id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "description": "The Jellyfin.Api.Models.PlaylistDtos.UpdatePlaylistDto id.", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/UpdatePlaylistDto" + } + ], + "description": "Update existing playlist dto. Fields set to `null` will not be updated and keep their current values." + } + }, + "text/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/UpdatePlaylistDto" + } + ], + "description": "Update existing playlist dto. Fields set to `null` will not be updated and keep their current values." + } + }, + "application/*+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/UpdatePlaylistDto" + } + ], + "description": "Update existing playlist dto. Fields set to `null` will not be updated and keep their current values." + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Playlist updated." + }, + "403": { + "description": "Access forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Playlist not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + }, + "get": { + "tags": [ + "Playlists" + ], + "summary": "Get a playlist.", + "operationId": "GetPlaylist", + "parameters": [ + { + "name": "playlistId", + "in": "path", + "description": "The playlist id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "The playlist.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlaylistDto" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/PlaylistDto" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/PlaylistDto" + } + } + } + }, + "404": { + "description": "Playlist not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Playlists/{playlistId}/Items": { + "post": { + "tags": [ + "Playlists" + ], + "summary": "Adds items to a playlist.", + "operationId": "AddItemToPlaylist", + "parameters": [ + { + "name": "playlistId", + "in": "path", + "description": "The playlist id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "ids", + "in": "query", + "description": "Item id, comma delimited.", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + }, + { + "name": "userId", + "in": "query", + "description": "The userId.", + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "204": { + "description": "Items added to playlist." + }, + "403": { + "description": "Access forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Playlist not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + }, + "delete": { + "tags": [ + "Playlists" + ], + "summary": "Removes items from a playlist.", + "operationId": "RemoveItemFromPlaylist", + "parameters": [ + { + "name": "playlistId", + "in": "path", + "description": "The playlist id.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "entryIds", + "in": "query", + "description": "The item ids, comma delimited.", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + ], + "responses": { + "204": { + "description": "Items removed." + }, + "403": { + "description": "Access forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Playlist not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + }, + "get": { + "tags": [ + "Playlists" + ], + "summary": "Gets the original items of a playlist.", + "operationId": "GetPlaylistItems", + "parameters": [ + { + "name": "playlistId", + "in": "path", + "description": "The playlist id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "userId", + "in": "query", + "description": "User id.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "startIndex", + "in": "query", + "description": "Optional. The record index to start at. All items with a lower index will be dropped from the results.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "limit", + "in": "query", + "description": "Optional. The maximum number of records to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "fields", + "in": "query", + "description": "Optional. Specify additional fields of information to return in the output.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ItemFields" + } + } + }, + { + "name": "enableImages", + "in": "query", + "description": "Optional. Include image information in output.", + "schema": { + "type": "boolean" + } + }, + { + "name": "enableUserData", + "in": "query", + "description": "Optional. Include user data.", + "schema": { + "type": "boolean" + } + }, + { + "name": "imageTypeLimit", + "in": "query", + "description": "Optional. The max number of images to return, per image type.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "enableImageTypes", + "in": "query", + "description": "Optional. The image types to include in the output.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ImageType" + } + } + } + ], + "responses": { + "200": { + "description": "Original playlist returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Playlist not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Playlists/{playlistId}/Items/{itemId}/Move/{newIndex}": { + "post": { + "tags": [ + "Playlists" + ], + "summary": "Moves a playlist item.", + "operationId": "MoveItem", + "parameters": [ + { + "name": "playlistId", + "in": "path", + "description": "The playlist id.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "itemId", + "in": "path", + "description": "The item id.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "newIndex", + "in": "path", + "description": "The new index.", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "204": { + "description": "Item moved to new index." + }, + "403": { + "description": "Access forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Playlist not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Playlists/{playlistId}/Users": { + "get": { + "tags": [ + "Playlists" + ], + "summary": "Get a playlist's users.", + "operationId": "GetPlaylistUsers", + "parameters": [ + { + "name": "playlistId", + "in": "path", + "description": "The playlist id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Found shares.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PlaylistUserPermissions" + } + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PlaylistUserPermissions" + } + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PlaylistUserPermissions" + } + } + } + } + }, + "403": { + "description": "Access forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Playlist not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Playlists/{playlistId}/Users/{userId}": { + "get": { + "tags": [ + "Playlists" + ], + "summary": "Get a playlist user.", + "operationId": "GetPlaylistUser", + "parameters": [ + { + "name": "playlistId", + "in": "path", + "description": "The playlist id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "userId", + "in": "path", + "description": "The user id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "User permission found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlaylistUserPermissions" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/PlaylistUserPermissions" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/PlaylistUserPermissions" + } + } + } + }, + "403": { + "description": "Access forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Playlist not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + }, + "post": { + "tags": [ + "Playlists" + ], + "summary": "Modify a user of a playlist's users.", + "operationId": "UpdatePlaylistUser", + "parameters": [ + { + "name": "playlistId", + "in": "path", + "description": "The playlist id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "userId", + "in": "path", + "description": "The user id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "description": "The Jellyfin.Api.Models.PlaylistDtos.UpdatePlaylistUserDto.", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/UpdatePlaylistUserDto" + } + ], + "description": "Update existing playlist user dto. Fields set to `null` will not be updated and keep their current values." + } + }, + "text/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/UpdatePlaylistUserDto" + } + ], + "description": "Update existing playlist user dto. Fields set to `null` will not be updated and keep their current values." + } + }, + "application/*+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/UpdatePlaylistUserDto" + } + ], + "description": "Update existing playlist user dto. Fields set to `null` will not be updated and keep their current values." + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "User's permissions modified." + }, + "403": { + "description": "Access forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Playlist not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + }, + "delete": { + "tags": [ + "Playlists" + ], + "summary": "Remove a user from a playlist's users.", + "operationId": "RemoveUserFromPlaylist", + "parameters": [ + { + "name": "playlistId", + "in": "path", + "description": "The playlist id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "userId", + "in": "path", + "description": "The user id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "204": { + "description": "User permissions removed from playlist." + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "No playlist or user permissions found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "Unauthorized access." + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/PlayingItems/{itemId}": { + "post": { + "tags": [ + "Playstate" + ], + "summary": "Reports that a session has begun playing an item.", + "operationId": "OnPlaybackStart", + "parameters": [ + { + "name": "itemId", + "in": "path", + "description": "Item id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "mediaSourceId", + "in": "query", + "description": "The id of the MediaSource.", + "schema": { + "type": "string" + } + }, + { + "name": "audioStreamIndex", + "in": "query", + "description": "The audio stream index.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "subtitleStreamIndex", + "in": "query", + "description": "The subtitle stream index.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "playMethod", + "in": "query", + "description": "The play method.", + "schema": { + "enum": [ + "Transcode", + "DirectStream", + "DirectPlay" + ], + "allOf": [ + { + "$ref": "#/components/schemas/PlayMethod" + } + ] + } + }, + { + "name": "liveStreamId", + "in": "query", + "description": "The live stream id.", + "schema": { + "type": "string" + } + }, + { + "name": "playSessionId", + "in": "query", + "description": "The play session id.", + "schema": { + "type": "string" + } + }, + { + "name": "canSeek", + "in": "query", + "description": "Indicates if the client can seek.", + "schema": { + "type": "boolean", + "default": false + } + } + ], + "responses": { + "204": { + "description": "Play start recorded." + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "deprecated": true, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + }, + "delete": { + "tags": [ + "Playstate" + ], + "summary": "Reports that a session has stopped playing an item.", + "operationId": "OnPlaybackStopped", + "parameters": [ + { + "name": "itemId", + "in": "path", + "description": "Item id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "mediaSourceId", + "in": "query", + "description": "The id of the MediaSource.", + "schema": { + "type": "string" + } + }, + { + "name": "nextMediaType", + "in": "query", + "description": "The next media type that will play.", + "schema": { + "type": "string" + } + }, + { + "name": "positionTicks", + "in": "query", + "description": "Optional. The position, in ticks, where playback stopped. 1 tick = 10000 ms.", + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "liveStreamId", + "in": "query", + "description": "The live stream id.", + "schema": { + "type": "string" + } + }, + { + "name": "playSessionId", + "in": "query", + "description": "The play session id.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Playback stop recorded." + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "deprecated": true, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/PlayingItems/{itemId}/Progress": { + "post": { + "tags": [ + "Playstate" + ], + "summary": "Reports a session's playback progress.", + "operationId": "OnPlaybackProgress", + "parameters": [ + { + "name": "itemId", + "in": "path", + "description": "Item id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "mediaSourceId", + "in": "query", + "description": "The id of the MediaSource.", + "schema": { + "type": "string" + } + }, + { + "name": "positionTicks", + "in": "query", + "description": "Optional. The current position, in ticks. 1 tick = 10000 ms.", + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "audioStreamIndex", + "in": "query", + "description": "The audio stream index.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "subtitleStreamIndex", + "in": "query", + "description": "The subtitle stream index.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "volumeLevel", + "in": "query", + "description": "Scale of 0-100.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "playMethod", + "in": "query", + "description": "The play method.", + "schema": { + "enum": [ + "Transcode", + "DirectStream", + "DirectPlay" + ], + "allOf": [ + { + "$ref": "#/components/schemas/PlayMethod" + } + ] + } + }, + { + "name": "liveStreamId", + "in": "query", + "description": "The live stream id.", + "schema": { + "type": "string" + } + }, + { + "name": "playSessionId", + "in": "query", + "description": "The play session id.", + "schema": { + "type": "string" + } + }, + { + "name": "repeatMode", + "in": "query", + "description": "The repeat mode.", + "schema": { + "enum": [ + "RepeatNone", + "RepeatAll", + "RepeatOne" + ], + "allOf": [ + { + "$ref": "#/components/schemas/RepeatMode" + } + ] + } + }, + { + "name": "isPaused", + "in": "query", + "description": "Indicates if the player is paused.", + "schema": { + "type": "boolean", + "default": false + } + }, + { + "name": "isMuted", + "in": "query", + "description": "Indicates if the player is muted.", + "schema": { + "type": "boolean", + "default": false + } + } + ], + "responses": { + "204": { + "description": "Play progress recorded." + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "deprecated": true, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Sessions/Playing": { + "post": { + "tags": [ + "Playstate" + ], + "summary": "Reports playback has started within a session.", + "operationId": "ReportPlaybackStart", + "requestBody": { + "description": "The playback start info.", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/PlaybackStartInfo" + } + ], + "description": "Class PlaybackStartInfo." + } + }, + "text/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/PlaybackStartInfo" + } + ], + "description": "Class PlaybackStartInfo." + } + }, + "application/*+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/PlaybackStartInfo" + } + ], + "description": "Class PlaybackStartInfo." + } + } + } + }, + "responses": { + "204": { + "description": "Playback start recorded." + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Sessions/Playing/Ping": { + "post": { + "tags": [ + "Playstate" + ], + "summary": "Pings a playback session.", + "operationId": "PingPlaybackSession", + "parameters": [ + { + "name": "playSessionId", + "in": "query", + "description": "Playback session id.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Playback session pinged." + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Sessions/Playing/Progress": { + "post": { + "tags": [ + "Playstate" + ], + "summary": "Reports playback progress within a session.", + "operationId": "ReportPlaybackProgress", + "requestBody": { + "description": "The playback progress info.", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/PlaybackProgressInfo" + } + ], + "description": "Class PlaybackProgressInfo." + } + }, + "text/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/PlaybackProgressInfo" + } + ], + "description": "Class PlaybackProgressInfo." + } + }, + "application/*+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/PlaybackProgressInfo" + } + ], + "description": "Class PlaybackProgressInfo." + } + } + } + }, + "responses": { + "204": { + "description": "Playback progress recorded." + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Sessions/Playing/Stopped": { + "post": { + "tags": [ + "Playstate" + ], + "summary": "Reports playback has stopped within a session.", + "operationId": "ReportPlaybackStopped", + "requestBody": { + "description": "The playback stop info.", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/PlaybackStopInfo" + } + ], + "description": "Class PlaybackStopInfo." + } + }, + "text/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/PlaybackStopInfo" + } + ], + "description": "Class PlaybackStopInfo." + } + }, + "application/*+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/PlaybackStopInfo" + } + ], + "description": "Class PlaybackStopInfo." + } + } + } + }, + "responses": { + "204": { + "description": "Playback stop recorded." + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/UserPlayedItems/{itemId}": { + "post": { + "tags": [ + "Playstate" + ], + "summary": "Marks an item as played for user.", + "operationId": "MarkPlayedItem", + "parameters": [ + { + "name": "userId", + "in": "query", + "description": "User id.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "itemId", + "in": "path", + "description": "Item id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "datePlayed", + "in": "query", + "description": "Optional. The date the item was played.", + "schema": { + "type": "string", + "format": "date-time" + } + } + ], + "responses": { + "200": { + "description": "Item marked as played.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserItemDataDto" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/UserItemDataDto" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/UserItemDataDto" + } + } + } + }, + "404": { + "description": "Item not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + }, + "delete": { + "tags": [ + "Playstate" + ], + "summary": "Marks an item as unplayed for user.", + "operationId": "MarkUnplayedItem", + "parameters": [ + { + "name": "userId", + "in": "query", + "description": "User id.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "itemId", + "in": "path", + "description": "Item id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Item marked as unplayed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserItemDataDto" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/UserItemDataDto" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/UserItemDataDto" + } + } + } + }, + "404": { + "description": "Item not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Plugins": { + "get": { + "tags": [ + "Plugins" + ], + "summary": "Gets a list of currently installed plugins.", + "operationId": "GetPlugins", + "responses": { + "200": { + "description": "Installed plugins returned.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PluginInfo" + } + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PluginInfo" + } + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PluginInfo" + } + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "RequiresElevation" + ] + } + ] + } + }, + "/Plugins/{pluginId}": { + "delete": { + "tags": [ + "Plugins" + ], + "summary": "Uninstalls a plugin.", + "operationId": "UninstallPlugin", + "parameters": [ + { + "name": "pluginId", + "in": "path", + "description": "Plugin id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "204": { + "description": "Plugin uninstalled." + }, + "404": { + "description": "Plugin not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "deprecated": true, + "security": [ + { + "CustomAuthentication": [ + "RequiresElevation" + ] + } + ] + } + }, + "/Plugins/{pluginId}/{version}": { + "delete": { + "tags": [ + "Plugins" + ], + "summary": "Uninstalls a plugin by version.", + "operationId": "UninstallPluginByVersion", + "parameters": [ + { + "name": "pluginId", + "in": "path", + "description": "Plugin id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "version", + "in": "path", + "description": "Plugin version.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Plugin uninstalled." + }, + "404": { + "description": "Plugin not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "RequiresElevation" + ] + } + ] + } + }, + "/Plugins/{pluginId}/{version}/Disable": { + "post": { + "tags": [ + "Plugins" + ], + "summary": "Disable a plugin.", + "operationId": "DisablePlugin", + "parameters": [ + { + "name": "pluginId", + "in": "path", + "description": "Plugin id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "version", + "in": "path", + "description": "Plugin version.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Plugin disabled." + }, + "404": { + "description": "Plugin not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "RequiresElevation" + ] + } + ] + } + }, + "/Plugins/{pluginId}/{version}/Enable": { + "post": { + "tags": [ + "Plugins" + ], + "summary": "Enables a disabled plugin.", + "operationId": "EnablePlugin", + "parameters": [ + { + "name": "pluginId", + "in": "path", + "description": "Plugin id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "version", + "in": "path", + "description": "Plugin version.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Plugin enabled." + }, + "404": { + "description": "Plugin not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "RequiresElevation" + ] + } + ] + } + }, + "/Plugins/{pluginId}/{version}/Image": { + "get": { + "tags": [ + "Plugins" + ], + "summary": "Gets a plugin's image.", + "operationId": "GetPluginImage", + "parameters": [ + { + "name": "pluginId", + "in": "path", + "description": "Plugin id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "version", + "in": "path", + "description": "Plugin version.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Plugin image returned.", + "content": { + "image/*": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "RequiresElevation" + ] + } + ] + } + }, + "/Plugins/{pluginId}/Configuration": { + "get": { + "tags": [ + "Plugins" + ], + "summary": "Gets plugin configuration.", + "operationId": "GetPluginConfiguration", + "parameters": [ + { + "name": "pluginId", + "in": "path", + "description": "Plugin id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Plugin configuration returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BasePluginConfiguration" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/BasePluginConfiguration" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/BasePluginConfiguration" + } + } + } + }, + "404": { + "description": "Plugin not found or plugin configuration not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "RequiresElevation" + ] + } + ] + }, + "post": { + "tags": [ + "Plugins" + ], + "summary": "Updates plugin configuration.", + "description": "Accepts plugin configuration as JSON body.", + "operationId": "UpdatePluginConfiguration", + "parameters": [ + { + "name": "pluginId", + "in": "path", + "description": "Plugin id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "204": { + "description": "Plugin configuration updated." + }, + "404": { + "description": "Plugin not found or plugin does not have configuration.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "RequiresElevation" + ] + } + ] + } + }, + "/Plugins/{pluginId}/Manifest": { + "post": { + "tags": [ + "Plugins" + ], + "summary": "Gets a plugin's manifest.", + "operationId": "GetPluginManifest", + "parameters": [ + { + "name": "pluginId", + "in": "path", + "description": "Plugin id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "204": { + "description": "Plugin manifest returned." + }, + "404": { + "description": "Plugin not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "RequiresElevation" + ] + } + ] + } + }, + "/QuickConnect/Authorize": { + "post": { + "tags": [ + "QuickConnect" + ], + "summary": "Authorizes a pending quick connect request.", + "operationId": "AuthorizeQuickConnect", + "parameters": [ + { + "name": "code", + "in": "query", + "description": "Quick connect code to authorize.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "userId", + "in": "query", + "description": "The user the authorize. Access to the requested user is required.", + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Quick connect result authorized successfully.", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "type": "boolean" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "type": "boolean" + } + } + } + }, + "403": { + "description": "Unknown user id.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/QuickConnect/Connect": { + "get": { + "tags": [ + "QuickConnect" + ], + "summary": "Attempts to retrieve authentication information.", + "operationId": "GetQuickConnectState", + "parameters": [ + { + "name": "secret", + "in": "query", + "description": "Secret previously returned from the Initiate endpoint.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Quick connect result returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/QuickConnectResult" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/QuickConnectResult" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/QuickConnectResult" + } + } + } + }, + "404": { + "description": "Unknown quick connect secret.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + } + } + } + }, + "/QuickConnect/Enabled": { + "get": { + "tags": [ + "QuickConnect" + ], + "summary": "Gets the current quick connect state.", + "operationId": "GetQuickConnectEnabled", + "responses": { + "200": { + "description": "Quick connect state returned.", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "type": "boolean" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "type": "boolean" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + } + } + } + }, + "/QuickConnect/Initiate": { + "post": { + "tags": [ + "QuickConnect" + ], + "summary": "Initiate a new quick connect request.", + "operationId": "InitiateQuickConnect", + "responses": { + "200": { + "description": "Quick connect request successfully created.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/QuickConnectResult" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/QuickConnectResult" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/QuickConnectResult" + } + } + } + }, + "401": { + "description": "Quick connect is not active on this server." + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + } + } + } + }, + "/Items/{itemId}/RemoteImages": { + "get": { + "tags": [ + "RemoteImage" + ], + "summary": "Gets available remote images for an item.", + "operationId": "GetRemoteImages", + "parameters": [ + { + "name": "itemId", + "in": "path", + "description": "Item Id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "type", + "in": "query", + "description": "The image type.", + "schema": { + "enum": [ + "Primary", + "Art", + "Backdrop", + "Banner", + "Logo", + "Thumb", + "Disc", + "Box", + "Screenshot", + "Menu", + "Chapter", + "BoxRear", + "Profile" + ], + "allOf": [ + { + "$ref": "#/components/schemas/ImageType" + } + ] + } + }, + { + "name": "startIndex", + "in": "query", + "description": "Optional. The record index to start at. All items with a lower index will be dropped from the results.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "limit", + "in": "query", + "description": "Optional. The maximum number of records to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "providerName", + "in": "query", + "description": "Optional. The image provider to use.", + "schema": { + "type": "string" + } + }, + { + "name": "includeAllLanguages", + "in": "query", + "description": "Optional. Include all languages.", + "schema": { + "type": "boolean", + "default": false + } + } + ], + "responses": { + "200": { + "description": "Remote Images returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RemoteImageResult" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/RemoteImageResult" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/RemoteImageResult" + } + } + } + }, + "404": { + "description": "Item not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Items/{itemId}/RemoteImages/Download": { + "post": { + "tags": [ + "RemoteImage" + ], + "summary": "Downloads a remote image for an item.", + "operationId": "DownloadRemoteImage", + "parameters": [ + { + "name": "itemId", + "in": "path", + "description": "Item Id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "type", + "in": "query", + "description": "The image type.", + "required": true, + "schema": { + "enum": [ + "Primary", + "Art", + "Backdrop", + "Banner", + "Logo", + "Thumb", + "Disc", + "Box", + "Screenshot", + "Menu", + "Chapter", + "BoxRear", + "Profile" + ], + "allOf": [ + { + "$ref": "#/components/schemas/ImageType" + } + ], + "description": "Enum ImageType." + } + }, + { + "name": "imageUrl", + "in": "query", + "description": "The image url.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Remote image downloaded." + }, + "404": { + "description": "Remote image not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "RequiresElevation" + ] + } + ] + } + }, + "/Items/{itemId}/RemoteImages/Providers": { + "get": { + "tags": [ + "RemoteImage" + ], + "summary": "Gets available remote image providers for an item.", + "operationId": "GetRemoteImageProviders", + "parameters": [ + { + "name": "itemId", + "in": "path", + "description": "Item Id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Returned remote image providers.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ImageProviderInfo" + } + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ImageProviderInfo" + } + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ImageProviderInfo" + } + } + } + } + }, + "404": { + "description": "Item not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/ScheduledTasks": { + "get": { + "tags": [ + "ScheduledTasks" + ], + "summary": "Get tasks.", + "operationId": "GetTasks", + "parameters": [ + { + "name": "isHidden", + "in": "query", + "description": "Optional filter tasks that are hidden, or not.", + "schema": { + "type": "boolean" + } + }, + { + "name": "isEnabled", + "in": "query", + "description": "Optional filter tasks that are enabled, or not.", + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "description": "Scheduled tasks retrieved.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TaskInfo" + } + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TaskInfo" + } + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TaskInfo" + } + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "RequiresElevation" + ] + } + ] + } + }, + "/ScheduledTasks/{taskId}": { + "get": { + "tags": [ + "ScheduledTasks" + ], + "summary": "Get task by id.", + "operationId": "GetTask", + "parameters": [ + { + "name": "taskId", + "in": "path", + "description": "Task Id.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Task retrieved.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TaskInfo" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/TaskInfo" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/TaskInfo" + } + } + } + }, + "404": { + "description": "Task not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "RequiresElevation" + ] + } + ] + } + }, + "/ScheduledTasks/{taskId}/Triggers": { + "post": { + "tags": [ + "ScheduledTasks" + ], + "summary": "Update specified task triggers.", + "operationId": "UpdateTask", + "parameters": [ + { + "name": "taskId", + "in": "path", + "description": "Task Id.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Triggers.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TaskTriggerInfo" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TaskTriggerInfo" + } + } + }, + "application/*+json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TaskTriggerInfo" + } + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Task triggers updated." + }, + "404": { + "description": "Task not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "RequiresElevation" + ] + } + ] + } + }, + "/ScheduledTasks/Running/{taskId}": { + "post": { + "tags": [ + "ScheduledTasks" + ], + "summary": "Start specified task.", + "operationId": "StartTask", + "parameters": [ + { + "name": "taskId", + "in": "path", + "description": "Task Id.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Task started." + }, + "404": { + "description": "Task not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "RequiresElevation" + ] + } + ] + }, + "delete": { + "tags": [ + "ScheduledTasks" + ], + "summary": "Stop specified task.", + "operationId": "StopTask", + "parameters": [ + { + "name": "taskId", + "in": "path", + "description": "Task Id.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Task stopped." + }, + "404": { + "description": "Task not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "RequiresElevation" + ] + } + ] + } + }, + "/Search/Hints": { + "get": { + "tags": [ + "Search" + ], + "summary": "Gets the search hint result.", + "operationId": "GetSearchHints", + "parameters": [ + { + "name": "startIndex", + "in": "query", + "description": "Optional. The record index to start at. All items with a lower index will be dropped from the results.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "limit", + "in": "query", + "description": "Optional. The maximum number of records to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "userId", + "in": "query", + "description": "Optional. Supply a user id to search within a user's library or omit to search all.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "searchTerm", + "in": "query", + "description": "The search term to filter on.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "includeItemTypes", + "in": "query", + "description": "If specified, only results with the specified item types are returned. This allows multiple, comma delimited.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BaseItemKind" + } + } + }, + { + "name": "excludeItemTypes", + "in": "query", + "description": "If specified, results with these item types are filtered out. This allows multiple, comma delimited.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BaseItemKind" + } + } + }, + { + "name": "mediaTypes", + "in": "query", + "description": "If specified, only results with the specified media types are returned. This allows multiple, comma delimited.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MediaType" + } + } + }, + { + "name": "parentId", + "in": "query", + "description": "If specified, only children of the parent are returned.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "isMovie", + "in": "query", + "description": "Optional filter for movies.", + "schema": { + "type": "boolean" + } + }, + { + "name": "isSeries", + "in": "query", + "description": "Optional filter for series.", + "schema": { + "type": "boolean" + } + }, + { + "name": "isNews", + "in": "query", + "description": "Optional filter for news.", + "schema": { + "type": "boolean" + } + }, + { + "name": "isKids", + "in": "query", + "description": "Optional filter for kids.", + "schema": { + "type": "boolean" + } + }, + { + "name": "isSports", + "in": "query", + "description": "Optional filter for sports.", + "schema": { + "type": "boolean" + } + }, + { + "name": "includePeople", + "in": "query", + "description": "Optional filter whether to include people.", + "schema": { + "type": "boolean", + "default": true + } + }, + { + "name": "includeMedia", + "in": "query", + "description": "Optional filter whether to include media.", + "schema": { + "type": "boolean", + "default": true + } + }, + { + "name": "includeGenres", + "in": "query", + "description": "Optional filter whether to include genres.", + "schema": { + "type": "boolean", + "default": true + } + }, + { + "name": "includeStudios", + "in": "query", + "description": "Optional filter whether to include studios.", + "schema": { + "type": "boolean", + "default": true + } + }, + { + "name": "includeArtists", + "in": "query", + "description": "Optional filter whether to include artists.", + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Search hint returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SearchHintResult" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/SearchHintResult" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/SearchHintResult" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Auth/PasswordResetProviders": { + "get": { + "tags": [ + "Session" + ], + "summary": "Get all password reset providers.", + "operationId": "GetPasswordResetProviders", + "responses": { + "200": { + "description": "Password reset providers retrieved.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NameIdPair" + } + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NameIdPair" + } + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NameIdPair" + } + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "RequiresElevation" + ] + } + ] + } + }, + "/Auth/Providers": { + "get": { + "tags": [ + "Session" + ], + "summary": "Get all auth providers.", + "operationId": "GetAuthProviders", + "responses": { + "200": { + "description": "Auth providers retrieved.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NameIdPair" + } + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NameIdPair" + } + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NameIdPair" + } + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "RequiresElevation" + ] + } + ] + } + }, + "/Sessions": { + "get": { + "tags": [ + "Session" + ], + "summary": "Gets a list of sessions.", + "operationId": "GetSessions", + "parameters": [ + { + "name": "controllableByUserId", + "in": "query", + "description": "Filter by sessions that a given user is allowed to remote control.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "deviceId", + "in": "query", + "description": "Filter by device Id.", + "schema": { + "type": "string" + } + }, + { + "name": "activeWithinSeconds", + "in": "query", + "description": "Optional. Filter by sessions that were active in the last n seconds.", + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "List of sessions returned.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SessionInfoDto" + } + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SessionInfoDto" + } + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SessionInfoDto" + } + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Sessions/{sessionId}/Command": { + "post": { + "tags": [ + "Session" + ], + "summary": "Issues a full general command to a client.", + "operationId": "SendFullGeneralCommand", + "parameters": [ + { + "name": "sessionId", + "in": "path", + "description": "The session id.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "The MediaBrowser.Model.Session.GeneralCommand.", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/GeneralCommand" + } + ] + } + }, + "text/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/GeneralCommand" + } + ] + } + }, + "application/*+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/GeneralCommand" + } + ] + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Full general command sent to session." + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Sessions/{sessionId}/Command/{command}": { + "post": { + "tags": [ + "Session" + ], + "summary": "Issues a general command to a client.", + "operationId": "SendGeneralCommand", + "parameters": [ + { + "name": "sessionId", + "in": "path", + "description": "The session id.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "command", + "in": "path", + "description": "The command to send.", + "required": true, + "schema": { + "enum": [ + "MoveUp", + "MoveDown", + "MoveLeft", + "MoveRight", + "PageUp", + "PageDown", + "PreviousLetter", + "NextLetter", + "ToggleOsd", + "ToggleContextMenu", + "Select", + "Back", + "TakeScreenshot", + "SendKey", + "SendString", + "GoHome", + "GoToSettings", + "VolumeUp", + "VolumeDown", + "Mute", + "Unmute", + "ToggleMute", + "SetVolume", + "SetAudioStreamIndex", + "SetSubtitleStreamIndex", + "ToggleFullscreen", + "DisplayContent", + "GoToSearch", + "DisplayMessage", + "SetRepeatMode", + "ChannelUp", + "ChannelDown", + "Guide", + "ToggleStats", + "PlayMediaSource", + "PlayTrailers", + "SetShuffleQueue", + "PlayState", + "PlayNext", + "ToggleOsdMenu", + "Play", + "SetMaxStreamingBitrate", + "SetPlaybackOrder" + ], + "allOf": [ + { + "$ref": "#/components/schemas/GeneralCommandType" + } + ], + "description": "This exists simply to identify a set of known commands." + } + } + ], + "responses": { + "204": { + "description": "General command sent to session." + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Sessions/{sessionId}/Message": { + "post": { + "tags": [ + "Session" + ], + "summary": "Issues a command to a client to display a message to the user.", + "operationId": "SendMessageCommand", + "parameters": [ + { + "name": "sessionId", + "in": "path", + "description": "The session id.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "The MediaBrowser.Model.Session.MessageCommand object containing Header, Message Text, and TimeoutMs.", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/MessageCommand" + } + ] + } + }, + "text/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/MessageCommand" + } + ] + } + }, + "application/*+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/MessageCommand" + } + ] + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Message sent." + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Sessions/{sessionId}/Playing": { + "post": { + "tags": [ + "Session" + ], + "summary": "Instructs a session to play an item.", + "operationId": "Play", + "parameters": [ + { + "name": "sessionId", + "in": "path", + "description": "The session id.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "playCommand", + "in": "query", + "description": "The type of play command to issue (PlayNow, PlayNext, PlayLast). Clients who have not yet implemented play next and play last may play now.", + "required": true, + "schema": { + "enum": [ + "PlayNow", + "PlayNext", + "PlayLast", + "PlayInstantMix", + "PlayShuffle" + ], + "allOf": [ + { + "$ref": "#/components/schemas/PlayCommand" + } + ], + "description": "Enum PlayCommand." + } + }, + { + "name": "itemIds", + "in": "query", + "description": "The ids of the items to play, comma delimited.", + "required": true, + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + }, + { + "name": "startPositionTicks", + "in": "query", + "description": "The starting position of the first item.", + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "mediaSourceId", + "in": "query", + "description": "Optional. The media source id.", + "schema": { + "type": "string" + } + }, + { + "name": "audioStreamIndex", + "in": "query", + "description": "Optional. The index of the audio stream to play.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "subtitleStreamIndex", + "in": "query", + "description": "Optional. The index of the subtitle stream to play.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "startIndex", + "in": "query", + "description": "Optional. The start index.", + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "204": { + "description": "Instruction sent to session." + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Sessions/{sessionId}/Playing/{command}": { + "post": { + "tags": [ + "Session" + ], + "summary": "Issues a playstate command to a client.", + "operationId": "SendPlaystateCommand", + "parameters": [ + { + "name": "sessionId", + "in": "path", + "description": "The session id.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "command", + "in": "path", + "description": "The MediaBrowser.Model.Session.PlaystateCommand.", + "required": true, + "schema": { + "enum": [ + "Stop", + "Pause", + "Unpause", + "NextTrack", + "PreviousTrack", + "Seek", + "Rewind", + "FastForward", + "PlayPause" + ], + "allOf": [ + { + "$ref": "#/components/schemas/PlaystateCommand" + } + ], + "description": "Enum PlaystateCommand." + } + }, + { + "name": "seekPositionTicks", + "in": "query", + "description": "The optional position ticks.", + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "controllingUserId", + "in": "query", + "description": "The optional controlling user id.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Playstate command sent to session." + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Sessions/{sessionId}/System/{command}": { + "post": { + "tags": [ + "Session" + ], + "summary": "Issues a system command to a client.", + "operationId": "SendSystemCommand", + "parameters": [ + { + "name": "sessionId", + "in": "path", + "description": "The session id.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "command", + "in": "path", + "description": "The command to send.", + "required": true, + "schema": { + "enum": [ + "MoveUp", + "MoveDown", + "MoveLeft", + "MoveRight", + "PageUp", + "PageDown", + "PreviousLetter", + "NextLetter", + "ToggleOsd", + "ToggleContextMenu", + "Select", + "Back", + "TakeScreenshot", + "SendKey", + "SendString", + "GoHome", + "GoToSettings", + "VolumeUp", + "VolumeDown", + "Mute", + "Unmute", + "ToggleMute", + "SetVolume", + "SetAudioStreamIndex", + "SetSubtitleStreamIndex", + "ToggleFullscreen", + "DisplayContent", + "GoToSearch", + "DisplayMessage", + "SetRepeatMode", + "ChannelUp", + "ChannelDown", + "Guide", + "ToggleStats", + "PlayMediaSource", + "PlayTrailers", + "SetShuffleQueue", + "PlayState", + "PlayNext", + "ToggleOsdMenu", + "Play", + "SetMaxStreamingBitrate", + "SetPlaybackOrder" + ], + "allOf": [ + { + "$ref": "#/components/schemas/GeneralCommandType" + } + ], + "description": "This exists simply to identify a set of known commands." + } + } + ], + "responses": { + "204": { + "description": "System command sent to session." + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Sessions/{sessionId}/User/{userId}": { + "post": { + "tags": [ + "Session" + ], + "summary": "Adds an additional user to a session.", + "operationId": "AddUserToSession", + "parameters": [ + { + "name": "sessionId", + "in": "path", + "description": "The session id.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "userId", + "in": "path", + "description": "The user id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "204": { + "description": "User added to session." + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + }, + "delete": { + "tags": [ + "Session" + ], + "summary": "Removes an additional user from a session.", + "operationId": "RemoveUserFromSession", + "parameters": [ + { + "name": "sessionId", + "in": "path", + "description": "The session id.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "userId", + "in": "path", + "description": "The user id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "204": { + "description": "User removed from session." + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Sessions/{sessionId}/Viewing": { + "post": { + "tags": [ + "Session" + ], + "summary": "Instructs a session to browse to an item or view.", + "operationId": "DisplayContent", + "parameters": [ + { + "name": "sessionId", + "in": "path", + "description": "The session Id.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "itemType", + "in": "query", + "description": "The type of item to browse to.", + "required": true, + "schema": { + "enum": [ + "AggregateFolder", + "Audio", + "AudioBook", + "BasePluginFolder", + "Book", + "BoxSet", + "Channel", + "ChannelFolderItem", + "CollectionFolder", + "Episode", + "Folder", + "Genre", + "ManualPlaylistsFolder", + "Movie", + "LiveTvChannel", + "LiveTvProgram", + "MusicAlbum", + "MusicArtist", + "MusicGenre", + "MusicVideo", + "Person", + "Photo", + "PhotoAlbum", + "Playlist", + "PlaylistsFolder", + "Program", + "Recording", + "Season", + "Series", + "Studio", + "Trailer", + "TvChannel", + "TvProgram", + "UserRootFolder", + "UserView", + "Video", + "Year" + ], + "allOf": [ + { + "$ref": "#/components/schemas/BaseItemKind" + } + ], + "description": "The base item kind." + } + }, + { + "name": "itemId", + "in": "query", + "description": "The Id of the item.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "itemName", + "in": "query", + "description": "The name of the item.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Instruction sent to session." + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Sessions/Capabilities": { + "post": { + "tags": [ + "Session" + ], + "summary": "Updates capabilities for a device.", + "operationId": "PostCapabilities", + "parameters": [ + { + "name": "id", + "in": "query", + "description": "The session id.", + "schema": { + "type": "string" + } + }, + { + "name": "playableMediaTypes", + "in": "query", + "description": "A list of playable media types, comma delimited. Audio, Video, Book, Photo.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MediaType" + } + } + }, + { + "name": "supportedCommands", + "in": "query", + "description": "A list of supported remote control commands, comma delimited.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GeneralCommandType" + } + } + }, + { + "name": "supportsMediaControl", + "in": "query", + "description": "Determines whether media can be played remotely..", + "schema": { + "type": "boolean", + "default": false + } + }, + { + "name": "supportsPersistentIdentifier", + "in": "query", + "description": "Determines whether the device supports a unique identifier.", + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "204": { + "description": "Capabilities posted." + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Sessions/Capabilities/Full": { + "post": { + "tags": [ + "Session" + ], + "summary": "Updates capabilities for a device.", + "operationId": "PostFullCapabilities", + "parameters": [ + { + "name": "id", + "in": "query", + "description": "The session id.", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "The MediaBrowser.Model.Session.ClientCapabilities.", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/ClientCapabilitiesDto" + } + ], + "description": "Client capabilities dto." + } + }, + "text/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/ClientCapabilitiesDto" + } + ], + "description": "Client capabilities dto." + } + }, + "application/*+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/ClientCapabilitiesDto" + } + ], + "description": "Client capabilities dto." + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Capabilities updated." + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Sessions/Logout": { + "post": { + "tags": [ + "Session" + ], + "summary": "Reports that a session has ended.", + "operationId": "ReportSessionEnded", + "responses": { + "204": { + "description": "Session end reported to server." + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Sessions/Viewing": { + "post": { + "tags": [ + "Session" + ], + "summary": "Reports that a session is viewing an item.", + "operationId": "ReportViewing", + "parameters": [ + { + "name": "sessionId", + "in": "query", + "description": "The session id.", + "schema": { + "type": "string" + } + }, + { + "name": "itemId", + "in": "query", + "description": "The item id.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Session reported to server." + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Startup/Complete": { + "post": { + "tags": [ + "Startup" + ], + "summary": "Completes the startup wizard.", + "operationId": "CompleteWizard", + "responses": { + "204": { + "description": "Startup wizard completed." + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "FirstTimeSetupOrElevated", + "DefaultAuthorization" + ] + } + ] + } + }, + "/Startup/Configuration": { + "get": { + "tags": [ + "Startup" + ], + "summary": "Gets the initial startup wizard configuration.", + "operationId": "GetStartupConfiguration", + "responses": { + "200": { + "description": "Initial startup wizard configuration retrieved.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StartupConfigurationDto" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/StartupConfigurationDto" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/StartupConfigurationDto" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "FirstTimeSetupOrElevated", + "DefaultAuthorization" + ] + } + ] + }, + "post": { + "tags": [ + "Startup" + ], + "summary": "Sets the initial startup wizard configuration.", + "operationId": "UpdateInitialConfiguration", + "requestBody": { + "description": "The updated startup configuration.", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/StartupConfigurationDto" + } + ], + "description": "The startup configuration DTO." + } + }, + "text/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/StartupConfigurationDto" + } + ], + "description": "The startup configuration DTO." + } + }, + "application/*+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/StartupConfigurationDto" + } + ], + "description": "The startup configuration DTO." + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Configuration saved." + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "FirstTimeSetupOrElevated", + "DefaultAuthorization" + ] + } + ] + } + }, + "/Startup/FirstUser": { + "get": { + "tags": [ + "Startup" + ], + "summary": "Gets the first user.", + "operationId": "GetFirstUser_2", + "responses": { + "200": { + "description": "Initial user retrieved.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StartupUserDto" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/StartupUserDto" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/StartupUserDto" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "FirstTimeSetupOrElevated", + "DefaultAuthorization" + ] + } + ] + } + }, + "/Startup/RemoteAccess": { + "post": { + "tags": [ + "Startup" + ], + "summary": "Sets remote access and UPnP.", + "operationId": "SetRemoteAccess", + "requestBody": { + "description": "The startup remote access dto.", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/StartupRemoteAccessDto" + } + ], + "description": "Startup remote access dto." + } + }, + "text/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/StartupRemoteAccessDto" + } + ], + "description": "Startup remote access dto." + } + }, + "application/*+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/StartupRemoteAccessDto" + } + ], + "description": "Startup remote access dto." + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Configuration saved." + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "FirstTimeSetupOrElevated", + "DefaultAuthorization" + ] + } + ] + } + }, + "/Startup/User": { + "get": { + "tags": [ + "Startup" + ], + "summary": "Gets the first user.", + "operationId": "GetFirstUser", + "responses": { + "200": { + "description": "Initial user retrieved.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StartupUserDto" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/StartupUserDto" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/StartupUserDto" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "FirstTimeSetupOrElevated", + "DefaultAuthorization" + ] + } + ] + }, + "post": { + "tags": [ + "Startup" + ], + "summary": "Sets the user name and password.", + "operationId": "UpdateStartupUser", + "requestBody": { + "description": "The DTO containing username and password.", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/StartupUserDto" + } + ], + "description": "The startup user DTO." + } + }, + "text/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/StartupUserDto" + } + ], + "description": "The startup user DTO." + } + }, + "application/*+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/StartupUserDto" + } + ], + "description": "The startup user DTO." + } + } + } + }, + "responses": { + "204": { + "description": "Updated user name and password." + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "FirstTimeSetupOrElevated", + "DefaultAuthorization" + ] + } + ] + } + }, + "/Studios": { + "get": { + "tags": [ + "Studios" + ], + "summary": "Gets all studios from a given item, folder, or the entire library.", + "operationId": "GetStudios", + "parameters": [ + { + "name": "startIndex", + "in": "query", + "description": "Optional. The record index to start at. All items with a lower index will be dropped from the results.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "limit", + "in": "query", + "description": "Optional. The maximum number of records to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "searchTerm", + "in": "query", + "description": "Optional. Search term.", + "schema": { + "type": "string" + } + }, + { + "name": "parentId", + "in": "query", + "description": "Specify this to localize the search to a specific item or folder. Omit to use the root.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "fields", + "in": "query", + "description": "Optional. Specify additional fields of information to return in the output.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ItemFields" + } + } + }, + { + "name": "excludeItemTypes", + "in": "query", + "description": "Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BaseItemKind" + } + } + }, + { + "name": "includeItemTypes", + "in": "query", + "description": "Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BaseItemKind" + } + } + }, + { + "name": "isFavorite", + "in": "query", + "description": "Optional filter by items that are marked as favorite, or not.", + "schema": { + "type": "boolean" + } + }, + { + "name": "enableUserData", + "in": "query", + "description": "Optional, include user data.", + "schema": { + "type": "boolean" + } + }, + { + "name": "imageTypeLimit", + "in": "query", + "description": "Optional, the max number of images to return, per image type.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "enableImageTypes", + "in": "query", + "description": "Optional. The image types to include in the output.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ImageType" + } + } + }, + { + "name": "userId", + "in": "query", + "description": "User id.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "nameStartsWithOrGreater", + "in": "query", + "description": "Optional filter by items whose name is sorted equally or greater than a given input string.", + "schema": { + "type": "string" + } + }, + { + "name": "nameStartsWith", + "in": "query", + "description": "Optional filter by items whose name is sorted equally than a given input string.", + "schema": { + "type": "string" + } + }, + { + "name": "nameLessThan", + "in": "query", + "description": "Optional filter by items whose name is equally or lesser than a given input string.", + "schema": { + "type": "string" + } + }, + { + "name": "enableImages", + "in": "query", + "description": "Optional, include image information in output.", + "schema": { + "type": "boolean", + "default": true + } + }, + { + "name": "enableTotalRecordCount", + "in": "query", + "description": "Total record count.", + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Studios returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Studios/{name}": { + "get": { + "tags": [ + "Studios" + ], + "summary": "Gets a studio by name.", + "operationId": "GetStudio", + "parameters": [ + { + "name": "name", + "in": "path", + "description": "Studio name.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "userId", + "in": "query", + "description": "Optional. Filter by user id, and attach user data.", + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Studio returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BaseItemDto" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDto" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDto" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/FallbackFont/Fonts": { + "get": { + "tags": [ + "Subtitle" + ], + "summary": "Gets a list of available fallback font files.", + "operationId": "GetFallbackFontList", + "responses": { + "200": { + "description": "Information retrieved.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FontFile" + } + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FontFile" + } + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FontFile" + } + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/FallbackFont/Fonts/{name}": { + "get": { + "tags": [ + "Subtitle" + ], + "summary": "Gets a fallback font file.", + "operationId": "GetFallbackFont", + "parameters": [ + { + "name": "name", + "in": "path", + "description": "The name of the fallback font file to get.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Fallback font file retrieved.", + "content": { + "font/*": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Items/{itemId}/RemoteSearch/Subtitles/{language}": { + "get": { + "tags": [ + "Subtitle" + ], + "summary": "Search remote subtitles.", + "operationId": "SearchRemoteSubtitles", + "parameters": [ + { + "name": "itemId", + "in": "path", + "description": "The item id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "language", + "in": "path", + "description": "The language of the subtitles.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "isPerfectMatch", + "in": "query", + "description": "Optional. Only show subtitles which are a perfect match.", + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "description": "Subtitles retrieved.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RemoteSubtitleInfo" + } + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RemoteSubtitleInfo" + } + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RemoteSubtitleInfo" + } + } + } + } + }, + "404": { + "description": "Item not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "SubtitleManagement", + "DefaultAuthorization" + ] + } + ] + } + }, + "/Items/{itemId}/RemoteSearch/Subtitles/{subtitleId}": { + "post": { + "tags": [ + "Subtitle" + ], + "summary": "Downloads a remote subtitle.", + "operationId": "DownloadRemoteSubtitles", + "parameters": [ + { + "name": "itemId", + "in": "path", + "description": "The item id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "subtitleId", + "in": "path", + "description": "The subtitle id.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Subtitle downloaded." + }, + "404": { + "description": "Item not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "SubtitleManagement", + "DefaultAuthorization" + ] + } + ] + } + }, + "/Providers/Subtitles/Subtitles/{subtitleId}": { + "get": { + "tags": [ + "Subtitle" + ], + "summary": "Gets the remote subtitles.", + "operationId": "GetRemoteSubtitles", + "parameters": [ + { + "name": "subtitleId", + "in": "path", + "description": "The item id.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "File returned.", + "content": { + "text/*": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "SubtitleManagement", + "DefaultAuthorization" + ] + } + ] + } + }, + "/Videos/{itemId}/{mediaSourceId}/Subtitles/{index}/subtitles.m3u8": { + "get": { + "tags": [ + "Subtitle" + ], + "summary": "Gets an HLS subtitle playlist.", + "operationId": "GetSubtitlePlaylist", + "parameters": [ + { + "name": "itemId", + "in": "path", + "description": "The item id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "index", + "in": "path", + "description": "The subtitle stream index.", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "mediaSourceId", + "in": "path", + "description": "The media source id.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "segmentLength", + "in": "query", + "description": "The subtitle segment length.", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Subtitle playlist retrieved.", + "content": { + "application/x-mpegURL": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "404": { + "description": "Item not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Videos/{itemId}/Subtitles": { + "post": { + "tags": [ + "Subtitle" + ], + "summary": "Upload an external subtitle file.", + "operationId": "UploadSubtitle", + "parameters": [ + { + "name": "itemId", + "in": "path", + "description": "The item the subtitle belongs to.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "description": "The request body.", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/UploadSubtitleDto" + } + ], + "description": "Upload subtitles dto." + } + }, + "text/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/UploadSubtitleDto" + } + ], + "description": "Upload subtitles dto." + } + }, + "application/*+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/UploadSubtitleDto" + } + ], + "description": "Upload subtitles dto." + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Subtitle uploaded." + }, + "404": { + "description": "Item not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "SubtitleManagement", + "DefaultAuthorization" + ] + } + ] + } + }, + "/Videos/{itemId}/Subtitles/{index}": { + "delete": { + "tags": [ + "Subtitle" + ], + "summary": "Deletes an external subtitle file.", + "operationId": "DeleteSubtitle", + "parameters": [ + { + "name": "itemId", + "in": "path", + "description": "The item id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "index", + "in": "path", + "description": "The index of the subtitle file.", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "204": { + "description": "Subtitle deleted." + }, + "404": { + "description": "Item not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "RequiresElevation" + ] + } + ] + } + }, + "/Videos/{routeItemId}/{routeMediaSourceId}/Subtitles/{routeIndex}/{routeStartPositionTicks}/Stream.{routeFormat}": { + "get": { + "tags": [ + "Subtitle" + ], + "summary": "Gets subtitles in a specified format.", + "operationId": "GetSubtitleWithTicks", + "parameters": [ + { + "name": "routeItemId", + "in": "path", + "description": "The (route) item id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "routeMediaSourceId", + "in": "path", + "description": "The (route) media source id.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "routeIndex", + "in": "path", + "description": "The (route) subtitle stream index.", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "routeStartPositionTicks", + "in": "path", + "description": "The (route) start position of the subtitle in ticks.", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "routeFormat", + "in": "path", + "description": "The (route) format of the returned subtitle.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "itemId", + "in": "query", + "description": "The item id.", + "deprecated": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "mediaSourceId", + "in": "query", + "description": "The media source id.", + "deprecated": true, + "schema": { + "type": "string" + } + }, + { + "name": "index", + "in": "query", + "description": "The subtitle stream index.", + "deprecated": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "startPositionTicks", + "in": "query", + "description": "The start position of the subtitle in ticks.", + "deprecated": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "format", + "in": "query", + "description": "The format of the returned subtitle.", + "deprecated": true, + "schema": { + "type": "string" + } + }, + { + "name": "endPositionTicks", + "in": "query", + "description": "Optional. The end position of the subtitle in ticks.", + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "copyTimestamps", + "in": "query", + "description": "Optional. Whether to copy the timestamps.", + "schema": { + "type": "boolean", + "default": false + } + }, + { + "name": "addVttTimeMap", + "in": "query", + "description": "Optional. Whether to add a VTT time map.", + "schema": { + "type": "boolean", + "default": false + } + } + ], + "responses": { + "200": { + "description": "File returned.", + "content": { + "text/*": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + } + } + } + }, + "/Videos/{routeItemId}/{routeMediaSourceId}/Subtitles/{routeIndex}/Stream.{routeFormat}": { + "get": { + "tags": [ + "Subtitle" + ], + "summary": "Gets subtitles in a specified format.", + "operationId": "GetSubtitle", + "parameters": [ + { + "name": "routeItemId", + "in": "path", + "description": "The (route) item id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "routeMediaSourceId", + "in": "path", + "description": "The (route) media source id.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "routeIndex", + "in": "path", + "description": "The (route) subtitle stream index.", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "routeFormat", + "in": "path", + "description": "The (route) format of the returned subtitle.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "itemId", + "in": "query", + "description": "The item id.", + "deprecated": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "mediaSourceId", + "in": "query", + "description": "The media source id.", + "deprecated": true, + "schema": { + "type": "string" + } + }, + { + "name": "index", + "in": "query", + "description": "The subtitle stream index.", + "deprecated": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "format", + "in": "query", + "description": "The format of the returned subtitle.", + "deprecated": true, + "schema": { + "type": "string" + } + }, + { + "name": "endPositionTicks", + "in": "query", + "description": "Optional. The end position of the subtitle in ticks.", + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "copyTimestamps", + "in": "query", + "description": "Optional. Whether to copy the timestamps.", + "schema": { + "type": "boolean", + "default": false + } + }, + { + "name": "addVttTimeMap", + "in": "query", + "description": "Optional. Whether to add a VTT time map.", + "schema": { + "type": "boolean", + "default": false + } + }, + { + "name": "startPositionTicks", + "in": "query", + "description": "The start position of the subtitle in ticks.", + "schema": { + "type": "integer", + "format": "int64", + "default": 0 + } + } + ], + "responses": { + "200": { + "description": "File returned.", + "content": { + "text/*": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + } + } + } + }, + "/Items/Suggestions": { + "get": { + "tags": [ + "Suggestions" + ], + "summary": "Gets suggestions.", + "operationId": "GetSuggestions", + "parameters": [ + { + "name": "userId", + "in": "query", + "description": "The user id.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "mediaType", + "in": "query", + "description": "The media types.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MediaType" + } + } + }, + { + "name": "type", + "in": "query", + "description": "The type.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BaseItemKind" + } + } + }, + { + "name": "startIndex", + "in": "query", + "description": "Optional. The start index.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "limit", + "in": "query", + "description": "Optional. The limit.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "enableTotalRecordCount", + "in": "query", + "description": "Whether to enable the total record count.", + "schema": { + "type": "boolean", + "default": false + } + } + ], + "responses": { + "200": { + "description": "Suggestions returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/SyncPlay/{id}": { + "get": { + "tags": [ + "SyncPlay" + ], + "summary": "Gets a SyncPlay group by id.", + "operationId": "SyncPlayGetGroup", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The id of the group.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Group returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GroupInfoDto" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/GroupInfoDto" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/GroupInfoDto" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "SyncPlayJoinGroup", + "SyncPlayHasAccess", + "DefaultAuthorization" + ] + } + ] + } + }, + "/SyncPlay/Buffering": { + "post": { + "tags": [ + "SyncPlay" + ], + "summary": "Notify SyncPlay group that member is buffering.", + "operationId": "SyncPlayBuffering", + "requestBody": { + "description": "The player status.", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/BufferRequestDto" + } + ], + "description": "Class BufferRequestDto." + } + }, + "text/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/BufferRequestDto" + } + ], + "description": "Class BufferRequestDto." + } + }, + "application/*+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/BufferRequestDto" + } + ], + "description": "Class BufferRequestDto." + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Group state update sent to all group members." + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "SyncPlayIsInGroup", + "SyncPlayHasAccess", + "DefaultAuthorization" + ] + } + ] + } + }, + "/SyncPlay/Join": { + "post": { + "tags": [ + "SyncPlay" + ], + "summary": "Join an existing SyncPlay group.", + "operationId": "SyncPlayJoinGroup", + "requestBody": { + "description": "The group to join.", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/JoinGroupRequestDto" + } + ], + "description": "Class JoinGroupRequestDto." + } + }, + "text/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/JoinGroupRequestDto" + } + ], + "description": "Class JoinGroupRequestDto." + } + }, + "application/*+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/JoinGroupRequestDto" + } + ], + "description": "Class JoinGroupRequestDto." + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Group join successful." + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "SyncPlayJoinGroup", + "SyncPlayHasAccess", + "DefaultAuthorization" + ] + } + ] + } + }, + "/SyncPlay/Leave": { + "post": { + "tags": [ + "SyncPlay" + ], + "summary": "Leave the joined SyncPlay group.", + "operationId": "SyncPlayLeaveGroup", + "responses": { + "204": { + "description": "Group leave successful." + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "SyncPlayIsInGroup", + "SyncPlayHasAccess", + "DefaultAuthorization" + ] + } + ] + } + }, + "/SyncPlay/List": { + "get": { + "tags": [ + "SyncPlay" + ], + "summary": "Gets all SyncPlay groups.", + "operationId": "SyncPlayGetGroups", + "responses": { + "200": { + "description": "Groups returned.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GroupInfoDto" + } + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GroupInfoDto" + } + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GroupInfoDto" + } + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "SyncPlayJoinGroup", + "SyncPlayHasAccess", + "DefaultAuthorization" + ] + } + ] + } + }, + "/SyncPlay/MovePlaylistItem": { + "post": { + "tags": [ + "SyncPlay" + ], + "summary": "Request to move an item in the playlist in SyncPlay group.", + "operationId": "SyncPlayMovePlaylistItem", + "requestBody": { + "description": "The new position for the item.", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/MovePlaylistItemRequestDto" + } + ], + "description": "Class MovePlaylistItemRequestDto." + } + }, + "text/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/MovePlaylistItemRequestDto" + } + ], + "description": "Class MovePlaylistItemRequestDto." + } + }, + "application/*+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/MovePlaylistItemRequestDto" + } + ], + "description": "Class MovePlaylistItemRequestDto." + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Queue update sent to all group members." + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "SyncPlayIsInGroup", + "SyncPlayHasAccess", + "DefaultAuthorization" + ] + } + ] + } + }, + "/SyncPlay/New": { + "post": { + "tags": [ + "SyncPlay" + ], + "summary": "Create a new SyncPlay group.", + "operationId": "SyncPlayCreateGroup", + "requestBody": { + "description": "The settings of the new group.", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/NewGroupRequestDto" + } + ], + "description": "Class NewGroupRequestDto." + } + }, + "text/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/NewGroupRequestDto" + } + ], + "description": "Class NewGroupRequestDto." + } + }, + "application/*+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/NewGroupRequestDto" + } + ], + "description": "Class NewGroupRequestDto." + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GroupInfoDto" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/GroupInfoDto" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/GroupInfoDto" + } + } + } + }, + "204": { + "description": "New group created." + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "SyncPlayCreateGroup", + "SyncPlayHasAccess", + "DefaultAuthorization" + ] + } + ] + } + }, + "/SyncPlay/NextItem": { + "post": { + "tags": [ + "SyncPlay" + ], + "summary": "Request next item in SyncPlay group.", + "operationId": "SyncPlayNextItem", + "requestBody": { + "description": "The current item information.", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/NextItemRequestDto" + } + ], + "description": "Class NextItemRequestDto." + } + }, + "text/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/NextItemRequestDto" + } + ], + "description": "Class NextItemRequestDto." + } + }, + "application/*+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/NextItemRequestDto" + } + ], + "description": "Class NextItemRequestDto." + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Next item update sent to all group members." + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "SyncPlayIsInGroup", + "SyncPlayHasAccess", + "DefaultAuthorization" + ] + } + ] + } + }, + "/SyncPlay/Pause": { + "post": { + "tags": [ + "SyncPlay" + ], + "summary": "Request pause in SyncPlay group.", + "operationId": "SyncPlayPause", + "responses": { + "204": { + "description": "Pause update sent to all group members." + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "SyncPlayIsInGroup", + "SyncPlayHasAccess", + "DefaultAuthorization" + ] + } + ] + } + }, + "/SyncPlay/Ping": { + "post": { + "tags": [ + "SyncPlay" + ], + "summary": "Update session ping.", + "operationId": "SyncPlayPing", + "requestBody": { + "description": "The new ping.", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/PingRequestDto" + } + ], + "description": "Class PingRequestDto." + } + }, + "text/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/PingRequestDto" + } + ], + "description": "Class PingRequestDto." + } + }, + "application/*+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/PingRequestDto" + } + ], + "description": "Class PingRequestDto." + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Ping updated." + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "SyncPlayHasAccess", + "DefaultAuthorization" + ] + } + ] + } + }, + "/SyncPlay/PreviousItem": { + "post": { + "tags": [ + "SyncPlay" + ], + "summary": "Request previous item in SyncPlay group.", + "operationId": "SyncPlayPreviousItem", + "requestBody": { + "description": "The current item information.", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/PreviousItemRequestDto" + } + ], + "description": "Class PreviousItemRequestDto." + } + }, + "text/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/PreviousItemRequestDto" + } + ], + "description": "Class PreviousItemRequestDto." + } + }, + "application/*+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/PreviousItemRequestDto" + } + ], + "description": "Class PreviousItemRequestDto." + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Previous item update sent to all group members." + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "SyncPlayIsInGroup", + "SyncPlayHasAccess", + "DefaultAuthorization" + ] + } + ] + } + }, + "/SyncPlay/Queue": { + "post": { + "tags": [ + "SyncPlay" + ], + "summary": "Request to queue items to the playlist of a SyncPlay group.", + "operationId": "SyncPlayQueue", + "requestBody": { + "description": "The items to add.", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/QueueRequestDto" + } + ], + "description": "Class QueueRequestDto." + } + }, + "text/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/QueueRequestDto" + } + ], + "description": "Class QueueRequestDto." + } + }, + "application/*+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/QueueRequestDto" + } + ], + "description": "Class QueueRequestDto." + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Queue update sent to all group members." + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "SyncPlayIsInGroup", + "SyncPlayHasAccess", + "DefaultAuthorization" + ] + } + ] + } + }, + "/SyncPlay/Ready": { + "post": { + "tags": [ + "SyncPlay" + ], + "summary": "Notify SyncPlay group that member is ready for playback.", + "operationId": "SyncPlayReady", + "requestBody": { + "description": "The player status.", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/ReadyRequestDto" + } + ], + "description": "Class ReadyRequest." + } + }, + "text/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/ReadyRequestDto" + } + ], + "description": "Class ReadyRequest." + } + }, + "application/*+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/ReadyRequestDto" + } + ], + "description": "Class ReadyRequest." + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Group state update sent to all group members." + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "SyncPlayIsInGroup", + "SyncPlayHasAccess", + "DefaultAuthorization" + ] + } + ] + } + }, + "/SyncPlay/RemoveFromPlaylist": { + "post": { + "tags": [ + "SyncPlay" + ], + "summary": "Request to remove items from the playlist in SyncPlay group.", + "operationId": "SyncPlayRemoveFromPlaylist", + "requestBody": { + "description": "The items to remove.", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/RemoveFromPlaylistRequestDto" + } + ], + "description": "Class RemoveFromPlaylistRequestDto." + } + }, + "text/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/RemoveFromPlaylistRequestDto" + } + ], + "description": "Class RemoveFromPlaylistRequestDto." + } + }, + "application/*+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/RemoveFromPlaylistRequestDto" + } + ], + "description": "Class RemoveFromPlaylistRequestDto." + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Queue update sent to all group members." + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "SyncPlayIsInGroup", + "SyncPlayHasAccess", + "DefaultAuthorization" + ] + } + ] + } + }, + "/SyncPlay/Seek": { + "post": { + "tags": [ + "SyncPlay" + ], + "summary": "Request seek in SyncPlay group.", + "operationId": "SyncPlaySeek", + "requestBody": { + "description": "The new playback position.", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/SeekRequestDto" + } + ], + "description": "Class SeekRequestDto." + } + }, + "text/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/SeekRequestDto" + } + ], + "description": "Class SeekRequestDto." + } + }, + "application/*+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/SeekRequestDto" + } + ], + "description": "Class SeekRequestDto." + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Seek update sent to all group members." + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "SyncPlayIsInGroup", + "SyncPlayHasAccess", + "DefaultAuthorization" + ] + } + ] + } + }, + "/SyncPlay/SetIgnoreWait": { + "post": { + "tags": [ + "SyncPlay" + ], + "summary": "Request SyncPlay group to ignore member during group-wait.", + "operationId": "SyncPlaySetIgnoreWait", + "requestBody": { + "description": "The settings to set.", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/IgnoreWaitRequestDto" + } + ], + "description": "Class IgnoreWaitRequestDto." + } + }, + "text/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/IgnoreWaitRequestDto" + } + ], + "description": "Class IgnoreWaitRequestDto." + } + }, + "application/*+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/IgnoreWaitRequestDto" + } + ], + "description": "Class IgnoreWaitRequestDto." + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Member state updated." + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "SyncPlayIsInGroup", + "SyncPlayHasAccess", + "DefaultAuthorization" + ] + } + ] + } + }, + "/SyncPlay/SetNewQueue": { + "post": { + "tags": [ + "SyncPlay" + ], + "summary": "Request to set new playlist in SyncPlay group.", + "operationId": "SyncPlaySetNewQueue", + "requestBody": { + "description": "The new playlist to play in the group.", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/PlayRequestDto" + } + ], + "description": "Class PlayRequestDto." + } + }, + "text/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/PlayRequestDto" + } + ], + "description": "Class PlayRequestDto." + } + }, + "application/*+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/PlayRequestDto" + } + ], + "description": "Class PlayRequestDto." + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Queue update sent to all group members." + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "SyncPlayIsInGroup", + "SyncPlayHasAccess", + "DefaultAuthorization" + ] + } + ] + } + }, + "/SyncPlay/SetPlaylistItem": { + "post": { + "tags": [ + "SyncPlay" + ], + "summary": "Request to change playlist item in SyncPlay group.", + "operationId": "SyncPlaySetPlaylistItem", + "requestBody": { + "description": "The new item to play.", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/SetPlaylistItemRequestDto" + } + ], + "description": "Class SetPlaylistItemRequestDto." + } + }, + "text/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/SetPlaylistItemRequestDto" + } + ], + "description": "Class SetPlaylistItemRequestDto." + } + }, + "application/*+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/SetPlaylistItemRequestDto" + } + ], + "description": "Class SetPlaylistItemRequestDto." + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Queue update sent to all group members." + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "SyncPlayIsInGroup", + "SyncPlayHasAccess", + "DefaultAuthorization" + ] + } + ] + } + }, + "/SyncPlay/SetRepeatMode": { + "post": { + "tags": [ + "SyncPlay" + ], + "summary": "Request to set repeat mode in SyncPlay group.", + "operationId": "SyncPlaySetRepeatMode", + "requestBody": { + "description": "The new repeat mode.", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/SetRepeatModeRequestDto" + } + ], + "description": "Class SetRepeatModeRequestDto." + } + }, + "text/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/SetRepeatModeRequestDto" + } + ], + "description": "Class SetRepeatModeRequestDto." + } + }, + "application/*+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/SetRepeatModeRequestDto" + } + ], + "description": "Class SetRepeatModeRequestDto." + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Play queue update sent to all group members." + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "SyncPlayIsInGroup", + "SyncPlayHasAccess", + "DefaultAuthorization" + ] + } + ] + } + }, + "/SyncPlay/SetShuffleMode": { + "post": { + "tags": [ + "SyncPlay" + ], + "summary": "Request to set shuffle mode in SyncPlay group.", + "operationId": "SyncPlaySetShuffleMode", + "requestBody": { + "description": "The new shuffle mode.", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/SetShuffleModeRequestDto" + } + ], + "description": "Class SetShuffleModeRequestDto." + } + }, + "text/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/SetShuffleModeRequestDto" + } + ], + "description": "Class SetShuffleModeRequestDto." + } + }, + "application/*+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/SetShuffleModeRequestDto" + } + ], + "description": "Class SetShuffleModeRequestDto." + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Play queue update sent to all group members." + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "SyncPlayIsInGroup", + "SyncPlayHasAccess", + "DefaultAuthorization" + ] + } + ] + } + }, + "/SyncPlay/Stop": { + "post": { + "tags": [ + "SyncPlay" + ], + "summary": "Request stop in SyncPlay group.", + "operationId": "SyncPlayStop", + "responses": { + "204": { + "description": "Stop update sent to all group members." + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "SyncPlayIsInGroup", + "SyncPlayHasAccess", + "DefaultAuthorization" + ] + } + ] + } + }, + "/SyncPlay/Unpause": { + "post": { + "tags": [ + "SyncPlay" + ], + "summary": "Request unpause in SyncPlay group.", + "operationId": "SyncPlayUnpause", + "responses": { + "204": { + "description": "Unpause update sent to all group members." + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "SyncPlayIsInGroup", + "SyncPlayHasAccess", + "DefaultAuthorization" + ] + } + ] + } + }, + "/System/Endpoint": { + "get": { + "tags": [ + "System" + ], + "summary": "Gets information about the request endpoint.", + "operationId": "GetEndpointInfo", + "responses": { + "200": { + "description": "Information retrieved.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EndPointInfo" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/EndPointInfo" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/EndPointInfo" + } + } + } + }, + "403": { + "description": "User does not have permission to get endpoint information.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/System/Info": { + "get": { + "tags": [ + "System" + ], + "summary": "Gets information about the server.", + "operationId": "GetSystemInfo", + "responses": { + "200": { + "description": "Information retrieved.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SystemInfo" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/SystemInfo" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/SystemInfo" + } + } + } + }, + "403": { + "description": "User does not have permission to retrieve information.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "CustomAuthentication": [ + "FirstTimeSetupOrIgnoreParentalControl", + "DefaultAuthorization" + ] + } + ] + } + }, + "/System/Info/Public": { + "get": { + "tags": [ + "System" + ], + "summary": "Gets public information about the server.", + "operationId": "GetPublicSystemInfo", + "responses": { + "200": { + "description": "Information retrieved.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicSystemInfo" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/PublicSystemInfo" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/PublicSystemInfo" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + } + } + } + }, + "/System/Info/Storage": { + "get": { + "tags": [ + "System" + ], + "summary": "Gets information about the server.", + "operationId": "GetSystemStorage", + "responses": { + "200": { + "description": "Information retrieved.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SystemStorageDto" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/SystemStorageDto" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/SystemStorageDto" + } + } + } + }, + "403": { + "description": "User does not have permission to retrieve information.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "CustomAuthentication": [ + "RequiresElevation" + ] + } + ] + } + }, + "/System/Logs": { + "get": { + "tags": [ + "System" + ], + "summary": "Gets a list of available server log files.", + "operationId": "GetServerLogs", + "responses": { + "200": { + "description": "Information retrieved.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LogFile" + } + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LogFile" + } + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LogFile" + } + } + } + } + }, + "403": { + "description": "User does not have permission to get server logs.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "CustomAuthentication": [ + "RequiresElevation" + ] + } + ] + } + }, + "/System/Logs/Log": { + "get": { + "tags": [ + "System" + ], + "summary": "Gets a log file.", + "operationId": "GetLogFile", + "parameters": [ + { + "name": "name", + "in": "query", + "description": "The name of the log file to get.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Log file retrieved.", + "content": { + "text/plain": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "403": { + "description": "User does not have permission to get log files.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Could not find a log file with the name.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "CustomAuthentication": [ + "RequiresElevation" + ] + } + ] + } + }, + "/System/Ping": { + "get": { + "tags": [ + "System" + ], + "summary": "Pings the system.", + "operationId": "GetPingSystem", + "responses": { + "200": { + "description": "Information retrieved.", + "content": { + "application/json": { + "schema": { + "type": "string" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "type": "string" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "type": "string" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + } + } + }, + "post": { + "tags": [ + "System" + ], + "summary": "Pings the system.", + "operationId": "PostPingSystem", + "responses": { + "200": { + "description": "Information retrieved.", + "content": { + "application/json": { + "schema": { + "type": "string" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "type": "string" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "type": "string" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + } + } + } + }, + "/System/Restart": { + "post": { + "tags": [ + "System" + ], + "summary": "Restarts the application.", + "operationId": "RestartApplication", + "responses": { + "204": { + "description": "Server restarted." + }, + "403": { + "description": "User does not have permission to restart server.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "CustomAuthentication": [ + "LocalAccessOrRequiresElevation" + ] + } + ] + } + }, + "/System/Shutdown": { + "post": { + "tags": [ + "System" + ], + "summary": "Shuts down the application.", + "operationId": "ShutdownApplication", + "responses": { + "204": { + "description": "Server shut down." + }, + "403": { + "description": "User does not have permission to shutdown server.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "CustomAuthentication": [ + "RequiresElevation" + ] + } + ] + } + }, + "/GetUtcTime": { + "get": { + "tags": [ + "TimeSync" + ], + "summary": "Gets the current UTC time.", + "operationId": "GetUtcTime", + "responses": { + "200": { + "description": "Time returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UtcTimeResponse" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/UtcTimeResponse" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/UtcTimeResponse" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + } + } + } + }, + "/Tmdb/ClientConfiguration": { + "get": { + "tags": [ + "Tmdb" + ], + "summary": "Gets the TMDb image configuration options.", + "operationId": "TmdbClientConfiguration", + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConfigImageTypes" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Trailers": { + "get": { + "tags": [ + "Trailers" + ], + "summary": "Finds movies and trailers similar to a given trailer.", + "operationId": "GetTrailers", + "parameters": [ + { + "name": "userId", + "in": "query", + "description": "The user id supplied as query parameter; this is required when not using an API key.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "maxOfficialRating", + "in": "query", + "description": "Optional filter by maximum official rating (PG, PG-13, TV-MA, etc).", + "schema": { + "type": "string" + } + }, + { + "name": "hasThemeSong", + "in": "query", + "description": "Optional filter by items with theme songs.", + "schema": { + "type": "boolean" + } + }, + { + "name": "hasThemeVideo", + "in": "query", + "description": "Optional filter by items with theme videos.", + "schema": { + "type": "boolean" + } + }, + { + "name": "hasSubtitles", + "in": "query", + "description": "Optional filter by items with subtitles.", + "schema": { + "type": "boolean" + } + }, + { + "name": "hasSpecialFeature", + "in": "query", + "description": "Optional filter by items with special features.", + "schema": { + "type": "boolean" + } + }, + { + "name": "hasTrailer", + "in": "query", + "description": "Optional filter by items with trailers.", + "schema": { + "type": "boolean" + } + }, + { + "name": "adjacentTo", + "in": "query", + "description": "Optional. Return items that are siblings of a supplied item.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "parentIndexNumber", + "in": "query", + "description": "Optional filter by parent index number.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "hasParentalRating", + "in": "query", + "description": "Optional filter by items that have or do not have a parental rating.", + "schema": { + "type": "boolean" + } + }, + { + "name": "isHd", + "in": "query", + "description": "Optional filter by items that are HD or not.", + "schema": { + "type": "boolean" + } + }, + { + "name": "is4K", + "in": "query", + "description": "Optional filter by items that are 4K or not.", + "schema": { + "type": "boolean" + } + }, + { + "name": "locationTypes", + "in": "query", + "description": "Optional. If specified, results will be filtered based on LocationType. This allows multiple, comma delimited.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LocationType" + } + } + }, + { + "name": "excludeLocationTypes", + "in": "query", + "description": "Optional. If specified, results will be filtered based on the LocationType. This allows multiple, comma delimited.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LocationType" + } + } + }, + { + "name": "isMissing", + "in": "query", + "description": "Optional filter by items that are missing episodes or not.", + "schema": { + "type": "boolean" + } + }, + { + "name": "isUnaired", + "in": "query", + "description": "Optional filter by items that are unaired episodes or not.", + "schema": { + "type": "boolean" + } + }, + { + "name": "minCommunityRating", + "in": "query", + "description": "Optional filter by minimum community rating.", + "schema": { + "type": "number", + "format": "double" + } + }, + { + "name": "minCriticRating", + "in": "query", + "description": "Optional filter by minimum critic rating.", + "schema": { + "type": "number", + "format": "double" + } + }, + { + "name": "minPremiereDate", + "in": "query", + "description": "Optional. The minimum premiere date. Format = ISO.", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "minDateLastSaved", + "in": "query", + "description": "Optional. The minimum last saved date. Format = ISO.", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "minDateLastSavedForUser", + "in": "query", + "description": "Optional. The minimum last saved date for the current user. Format = ISO.", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "maxPremiereDate", + "in": "query", + "description": "Optional. The maximum premiere date. Format = ISO.", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "hasOverview", + "in": "query", + "description": "Optional filter by items that have an overview or not.", + "schema": { + "type": "boolean" + } + }, + { + "name": "hasImdbId", + "in": "query", + "description": "Optional filter by items that have an IMDb id or not.", + "schema": { + "type": "boolean" + } + }, + { + "name": "hasTmdbId", + "in": "query", + "description": "Optional filter by items that have a TMDb id or not.", + "schema": { + "type": "boolean" + } + }, + { + "name": "hasTvdbId", + "in": "query", + "description": "Optional filter by items that have a TVDb id or not.", + "schema": { + "type": "boolean" + } + }, + { + "name": "isMovie", + "in": "query", + "description": "Optional filter for live tv movies.", + "schema": { + "type": "boolean" + } + }, + { + "name": "isSeries", + "in": "query", + "description": "Optional filter for live tv series.", + "schema": { + "type": "boolean" + } + }, + { + "name": "isNews", + "in": "query", + "description": "Optional filter for live tv news.", + "schema": { + "type": "boolean" + } + }, + { + "name": "isKids", + "in": "query", + "description": "Optional filter for live tv kids.", + "schema": { + "type": "boolean" + } + }, + { + "name": "isSports", + "in": "query", + "description": "Optional filter for live tv sports.", + "schema": { + "type": "boolean" + } + }, + { + "name": "excludeItemIds", + "in": "query", + "description": "Optional. If specified, results will be filtered by excluding item ids. This allows multiple, comma delimited.", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + }, + { + "name": "startIndex", + "in": "query", + "description": "Optional. The record index to start at. All items with a lower index will be dropped from the results.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "limit", + "in": "query", + "description": "Optional. The maximum number of records to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "recursive", + "in": "query", + "description": "When searching within folders, this determines whether or not the search will be recursive. true/false.", + "schema": { + "type": "boolean" + } + }, + { + "name": "searchTerm", + "in": "query", + "description": "Optional. Filter based on a search term.", + "schema": { + "type": "string" + } + }, + { + "name": "sortOrder", + "in": "query", + "description": "Sort Order - Ascending, Descending.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SortOrder" + } + } + }, + { + "name": "parentId", + "in": "query", + "description": "Specify this to localize the search to a specific item or folder. Omit to use the root.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "fields", + "in": "query", + "description": "Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ItemFields" + } + } + }, + { + "name": "excludeItemTypes", + "in": "query", + "description": "Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BaseItemKind" + } + } + }, + { + "name": "filters", + "in": "query", + "description": "Optional. Specify additional filters to apply. This allows multiple, comma delimited. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ItemFilter" + } + } + }, + { + "name": "isFavorite", + "in": "query", + "description": "Optional filter by items that are marked as favorite, or not.", + "schema": { + "type": "boolean" + } + }, + { + "name": "mediaTypes", + "in": "query", + "description": "Optional filter by MediaType. Allows multiple, comma delimited.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MediaType" + } + } + }, + { + "name": "imageTypes", + "in": "query", + "description": "Optional. If specified, results will be filtered based on those containing image types. This allows multiple, comma delimited.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ImageType" + } + } + }, + { + "name": "sortBy", + "in": "query", + "description": "Optional. Specify one or more sort orders, comma delimited. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ItemSortBy" + } + } + }, + { + "name": "isPlayed", + "in": "query", + "description": "Optional filter by items that are played, or not.", + "schema": { + "type": "boolean" + } + }, + { + "name": "genres", + "in": "query", + "description": "Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited.", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "name": "officialRatings", + "in": "query", + "description": "Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited.", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "name": "tags", + "in": "query", + "description": "Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited.", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "name": "years", + "in": "query", + "description": "Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited.", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + } + }, + { + "name": "enableUserData", + "in": "query", + "description": "Optional, include user data.", + "schema": { + "type": "boolean" + } + }, + { + "name": "imageTypeLimit", + "in": "query", + "description": "Optional, the max number of images to return, per image type.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "enableImageTypes", + "in": "query", + "description": "Optional. The image types to include in the output.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ImageType" + } + } + }, + { + "name": "person", + "in": "query", + "description": "Optional. If specified, results will be filtered to include only those containing the specified person.", + "schema": { + "type": "string" + } + }, + { + "name": "personIds", + "in": "query", + "description": "Optional. If specified, results will be filtered to include only those containing the specified person id.", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + }, + { + "name": "personTypes", + "in": "query", + "description": "Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "name": "studios", + "in": "query", + "description": "Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited.", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "name": "artists", + "in": "query", + "description": "Optional. If specified, results will be filtered based on artists. This allows multiple, pipe delimited.", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "name": "excludeArtistIds", + "in": "query", + "description": "Optional. If specified, results will be filtered based on artist id. This allows multiple, pipe delimited.", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + }, + { + "name": "artistIds", + "in": "query", + "description": "Optional. If specified, results will be filtered to include only those containing the specified artist id.", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + }, + { + "name": "albumArtistIds", + "in": "query", + "description": "Optional. If specified, results will be filtered to include only those containing the specified album artist id.", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + }, + { + "name": "contributingArtistIds", + "in": "query", + "description": "Optional. If specified, results will be filtered to include only those containing the specified contributing artist id.", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + }, + { + "name": "albums", + "in": "query", + "description": "Optional. If specified, results will be filtered based on album. This allows multiple, pipe delimited.", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "name": "albumIds", + "in": "query", + "description": "Optional. If specified, results will be filtered based on album id. This allows multiple, pipe delimited.", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + }, + { + "name": "ids", + "in": "query", + "description": "Optional. If specific items are needed, specify a list of item id's to retrieve. This allows multiple, comma delimited.", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + }, + { + "name": "videoTypes", + "in": "query", + "description": "Optional filter by VideoType (videofile, dvd, bluray, iso). Allows multiple, comma delimited.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/VideoType" + } + } + }, + { + "name": "minOfficialRating", + "in": "query", + "description": "Optional filter by minimum official rating (PG, PG-13, TV-MA, etc).", + "schema": { + "type": "string" + } + }, + { + "name": "isLocked", + "in": "query", + "description": "Optional filter by items that are locked.", + "schema": { + "type": "boolean" + } + }, + { + "name": "isPlaceHolder", + "in": "query", + "description": "Optional filter by items that are placeholders.", + "schema": { + "type": "boolean" + } + }, + { + "name": "hasOfficialRating", + "in": "query", + "description": "Optional filter by items that have official ratings.", + "schema": { + "type": "boolean" + } + }, + { + "name": "collapseBoxSetItems", + "in": "query", + "description": "Whether or not to hide items behind their boxsets.", + "schema": { + "type": "boolean" + } + }, + { + "name": "minWidth", + "in": "query", + "description": "Optional. Filter by the minimum width of the item.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "minHeight", + "in": "query", + "description": "Optional. Filter by the minimum height of the item.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxWidth", + "in": "query", + "description": "Optional. Filter by the maximum width of the item.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxHeight", + "in": "query", + "description": "Optional. Filter by the maximum height of the item.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "is3D", + "in": "query", + "description": "Optional filter by items that are 3D, or not.", + "schema": { + "type": "boolean" + } + }, + { + "name": "seriesStatus", + "in": "query", + "description": "Optional filter by Series Status. Allows multiple, comma delimited.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SeriesStatus" + } + } + }, + { + "name": "nameStartsWithOrGreater", + "in": "query", + "description": "Optional filter by items whose name is sorted equally or greater than a given input string.", + "schema": { + "type": "string" + } + }, + { + "name": "nameStartsWith", + "in": "query", + "description": "Optional filter by items whose name is sorted equally than a given input string.", + "schema": { + "type": "string" + } + }, + { + "name": "nameLessThan", + "in": "query", + "description": "Optional filter by items whose name is equally or lesser than a given input string.", + "schema": { + "type": "string" + } + }, + { + "name": "studioIds", + "in": "query", + "description": "Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited.", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + }, + { + "name": "genreIds", + "in": "query", + "description": "Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited.", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + }, + { + "name": "enableTotalRecordCount", + "in": "query", + "description": "Optional. Enable the total record count.", + "schema": { + "type": "boolean", + "default": true + } + }, + { + "name": "enableImages", + "in": "query", + "description": "Optional, include image information in output.", + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Videos/{itemId}/Trickplay/{width}/{index}.jpg": { + "get": { + "tags": [ + "Trickplay" + ], + "summary": "Gets a trickplay tile image.", + "operationId": "GetTrickplayTileImage", + "parameters": [ + { + "name": "itemId", + "in": "path", + "description": "The item id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "width", + "in": "path", + "description": "The width of a single tile.", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "index", + "in": "path", + "description": "The index of the desired tile.", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "mediaSourceId", + "in": "query", + "description": "The media version id, if using an alternate version.", + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Tile image not found at specified index.", + "content": { + "image/*": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Videos/{itemId}/Trickplay/{width}/tiles.m3u8": { + "get": { + "tags": [ + "Trickplay" + ], + "summary": "Gets an image tiles playlist for trickplay.", + "operationId": "GetTrickplayHlsPlaylist", + "parameters": [ + { + "name": "itemId", + "in": "path", + "description": "The item id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "width", + "in": "path", + "description": "The width of a single tile.", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "mediaSourceId", + "in": "query", + "description": "The media version id, if using an alternate version.", + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Tiles playlist returned.", + "content": { + "application/x-mpegURL": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Shows/{seriesId}/Episodes": { + "get": { + "tags": [ + "TvShows" + ], + "summary": "Gets episodes for a tv season.", + "operationId": "GetEpisodes", + "parameters": [ + { + "name": "seriesId", + "in": "path", + "description": "The series id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "userId", + "in": "query", + "description": "The user id.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "fields", + "in": "query", + "description": "Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ItemFields" + } + } + }, + { + "name": "season", + "in": "query", + "description": "Optional filter by season number.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "seasonId", + "in": "query", + "description": "Optional. Filter by season id.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "isMissing", + "in": "query", + "description": "Optional. Filter by items that are missing episodes or not.", + "schema": { + "type": "boolean" + } + }, + { + "name": "adjacentTo", + "in": "query", + "description": "Optional. Return items that are siblings of a supplied item.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "startItemId", + "in": "query", + "description": "Optional. Skip through the list until a given item is found.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "startIndex", + "in": "query", + "description": "Optional. The record index to start at. All items with a lower index will be dropped from the results.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "limit", + "in": "query", + "description": "Optional. The maximum number of records to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "enableImages", + "in": "query", + "description": "Optional, include image information in output.", + "schema": { + "type": "boolean" + } + }, + { + "name": "imageTypeLimit", + "in": "query", + "description": "Optional, the max number of images to return, per image type.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "enableImageTypes", + "in": "query", + "description": "Optional. The image types to include in the output.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ImageType" + } + } + }, + { + "name": "enableUserData", + "in": "query", + "description": "Optional. Include user data.", + "schema": { + "type": "boolean" + } + }, + { + "name": "sortBy", + "in": "query", + "description": "Optional. Specify one or more sort orders, comma delimited. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.", + "schema": { + "enum": [ + "Default", + "AiredEpisodeOrder", + "Album", + "AlbumArtist", + "Artist", + "DateCreated", + "OfficialRating", + "DatePlayed", + "PremiereDate", + "StartDate", + "SortName", + "Name", + "Random", + "Runtime", + "CommunityRating", + "ProductionYear", + "PlayCount", + "CriticRating", + "IsFolder", + "IsUnplayed", + "IsPlayed", + "SeriesSortName", + "VideoBitRate", + "AirTime", + "Studio", + "IsFavoriteOrLiked", + "DateLastContentAdded", + "SeriesDatePlayed", + "ParentIndexNumber", + "IndexNumber" + ], + "allOf": [ + { + "$ref": "#/components/schemas/ItemSortBy" + } + ] + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Shows/{seriesId}/Seasons": { + "get": { + "tags": [ + "TvShows" + ], + "summary": "Gets seasons for a tv series.", + "operationId": "GetSeasons", + "parameters": [ + { + "name": "seriesId", + "in": "path", + "description": "The series id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "userId", + "in": "query", + "description": "The user id.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "fields", + "in": "query", + "description": "Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ItemFields" + } + } + }, + { + "name": "isSpecialSeason", + "in": "query", + "description": "Optional. Filter by special season.", + "schema": { + "type": "boolean" + } + }, + { + "name": "isMissing", + "in": "query", + "description": "Optional. Filter by items that are missing episodes or not.", + "schema": { + "type": "boolean" + } + }, + { + "name": "adjacentTo", + "in": "query", + "description": "Optional. Return items that are siblings of a supplied item.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "enableImages", + "in": "query", + "description": "Optional. Include image information in output.", + "schema": { + "type": "boolean" + } + }, + { + "name": "imageTypeLimit", + "in": "query", + "description": "Optional. The max number of images to return, per image type.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "enableImageTypes", + "in": "query", + "description": "Optional. The image types to include in the output.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ImageType" + } + } + }, + { + "name": "enableUserData", + "in": "query", + "description": "Optional. Include user data.", + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Shows/NextUp": { + "get": { + "tags": [ + "TvShows" + ], + "summary": "Gets a list of next up episodes.", + "operationId": "GetNextUp", + "parameters": [ + { + "name": "userId", + "in": "query", + "description": "The user id of the user to get the next up episodes for.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "startIndex", + "in": "query", + "description": "Optional. The record index to start at. All items with a lower index will be dropped from the results.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "limit", + "in": "query", + "description": "Optional. The maximum number of records to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "fields", + "in": "query", + "description": "Optional. Specify additional fields of information to return in the output.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ItemFields" + } + } + }, + { + "name": "seriesId", + "in": "query", + "description": "Optional. Filter by series id.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "parentId", + "in": "query", + "description": "Optional. Specify this to localize the search to a specific item or folder. Omit to use the root.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "enableImages", + "in": "query", + "description": "Optional. Include image information in output.", + "schema": { + "type": "boolean" + } + }, + { + "name": "imageTypeLimit", + "in": "query", + "description": "Optional. The max number of images to return, per image type.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "enableImageTypes", + "in": "query", + "description": "Optional. The image types to include in the output.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ImageType" + } + } + }, + { + "name": "enableUserData", + "in": "query", + "description": "Optional. Include user data.", + "schema": { + "type": "boolean" + } + }, + { + "name": "nextUpDateCutoff", + "in": "query", + "description": "Optional. Starting date of shows to show in Next Up section.", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "enableTotalRecordCount", + "in": "query", + "description": "Whether to enable the total records count. Defaults to true.", + "schema": { + "type": "boolean", + "default": true + } + }, + { + "name": "disableFirstEpisode", + "in": "query", + "description": "Whether to disable sending the first episode in a series as next up.", + "deprecated": true, + "schema": { + "type": "boolean", + "default": false + } + }, + { + "name": "enableResumable", + "in": "query", + "description": "Whether to include resumable episodes in next up results.", + "schema": { + "type": "boolean", + "default": true + } + }, + { + "name": "enableRewatching", + "in": "query", + "description": "Whether to include watched episodes in next up results.", + "schema": { + "type": "boolean", + "default": false + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Shows/Upcoming": { + "get": { + "tags": [ + "TvShows" + ], + "summary": "Gets a list of upcoming episodes.", + "operationId": "GetUpcomingEpisodes", + "parameters": [ + { + "name": "userId", + "in": "query", + "description": "The user id of the user to get the upcoming episodes for.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "startIndex", + "in": "query", + "description": "Optional. The record index to start at. All items with a lower index will be dropped from the results.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "limit", + "in": "query", + "description": "Optional. The maximum number of records to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "fields", + "in": "query", + "description": "Optional. Specify additional fields of information to return in the output.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ItemFields" + } + } + }, + { + "name": "parentId", + "in": "query", + "description": "Optional. Specify this to localize the search to a specific item or folder. Omit to use the root.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "enableImages", + "in": "query", + "description": "Optional. Include image information in output.", + "schema": { + "type": "boolean" + } + }, + { + "name": "imageTypeLimit", + "in": "query", + "description": "Optional. The max number of images to return, per image type.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "enableImageTypes", + "in": "query", + "description": "Optional. The image types to include in the output.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ImageType" + } + } + }, + { + "name": "enableUserData", + "in": "query", + "description": "Optional. Include user data.", + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Audio/{itemId}/universal": { + "get": { + "tags": [ + "UniversalAudio" + ], + "summary": "Gets an audio stream.", + "operationId": "GetUniversalAudioStream", + "parameters": [ + { + "name": "itemId", + "in": "path", + "description": "The item id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "container", + "in": "query", + "description": "Optional. The audio container.", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "name": "mediaSourceId", + "in": "query", + "description": "The media version id, if playing an alternate version.", + "schema": { + "type": "string" + } + }, + { + "name": "deviceId", + "in": "query", + "description": "The device id of the client requesting. Used to stop encoding processes when needed.", + "schema": { + "type": "string" + } + }, + { + "name": "userId", + "in": "query", + "description": "Optional. The user id.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "audioCodec", + "in": "query", + "description": "Optional. The audio codec to transcode to.", + "schema": { + "pattern": "^[a-zA-Z0-9\\-\\._,|]{0,40}$", + "type": "string" + } + }, + { + "name": "maxAudioChannels", + "in": "query", + "description": "Optional. The maximum number of audio channels.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "transcodingAudioChannels", + "in": "query", + "description": "Optional. The number of how many audio channels to transcode to.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxStreamingBitrate", + "in": "query", + "description": "Optional. The maximum streaming bitrate.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "audioBitRate", + "in": "query", + "description": "Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "startTimeTicks", + "in": "query", + "description": "Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.", + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "transcodingContainer", + "in": "query", + "description": "Optional. The container to transcode to.", + "schema": { + "pattern": "^[a-zA-Z0-9\\-\\._,|]{0,40}$", + "type": "string" + } + }, + { + "name": "transcodingProtocol", + "in": "query", + "description": "Optional. The transcoding protocol.", + "schema": { + "enum": [ + "http", + "hls" + ], + "allOf": [ + { + "$ref": "#/components/schemas/MediaStreamProtocol" + } + ] + } + }, + { + "name": "maxAudioSampleRate", + "in": "query", + "description": "Optional. The maximum audio sample rate.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxAudioBitDepth", + "in": "query", + "description": "Optional. The maximum audio bit depth.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "enableRemoteMedia", + "in": "query", + "description": "Optional. Whether to enable remote media.", + "schema": { + "type": "boolean" + } + }, + { + "name": "enableAudioVbrEncoding", + "in": "query", + "description": "Optional. Whether to enable Audio Encoding.", + "schema": { + "type": "boolean", + "default": true + } + }, + { + "name": "breakOnNonKeyFrames", + "in": "query", + "description": "Optional. Whether to break on non key frames.", + "schema": { + "type": "boolean", + "default": false + } + }, + { + "name": "enableRedirection", + "in": "query", + "description": "Whether to enable redirection. Defaults to true.", + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Audio stream returned.", + "content": { + "audio/*": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "302": { + "description": "Redirected to remote audio stream." + }, + "404": { + "description": "Item not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + }, + "head": { + "tags": [ + "UniversalAudio" + ], + "summary": "Gets an audio stream.", + "operationId": "HeadUniversalAudioStream", + "parameters": [ + { + "name": "itemId", + "in": "path", + "description": "The item id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "container", + "in": "query", + "description": "Optional. The audio container.", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "name": "mediaSourceId", + "in": "query", + "description": "The media version id, if playing an alternate version.", + "schema": { + "type": "string" + } + }, + { + "name": "deviceId", + "in": "query", + "description": "The device id of the client requesting. Used to stop encoding processes when needed.", + "schema": { + "type": "string" + } + }, + { + "name": "userId", + "in": "query", + "description": "Optional. The user id.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "audioCodec", + "in": "query", + "description": "Optional. The audio codec to transcode to.", + "schema": { + "pattern": "^[a-zA-Z0-9\\-\\._,|]{0,40}$", + "type": "string" + } + }, + { + "name": "maxAudioChannels", + "in": "query", + "description": "Optional. The maximum number of audio channels.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "transcodingAudioChannels", + "in": "query", + "description": "Optional. The number of how many audio channels to transcode to.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxStreamingBitrate", + "in": "query", + "description": "Optional. The maximum streaming bitrate.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "audioBitRate", + "in": "query", + "description": "Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "startTimeTicks", + "in": "query", + "description": "Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.", + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "transcodingContainer", + "in": "query", + "description": "Optional. The container to transcode to.", + "schema": { + "pattern": "^[a-zA-Z0-9\\-\\._,|]{0,40}$", + "type": "string" + } + }, + { + "name": "transcodingProtocol", + "in": "query", + "description": "Optional. The transcoding protocol.", + "schema": { + "enum": [ + "http", + "hls" + ], + "allOf": [ + { + "$ref": "#/components/schemas/MediaStreamProtocol" + } + ] + } + }, + { + "name": "maxAudioSampleRate", + "in": "query", + "description": "Optional. The maximum audio sample rate.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxAudioBitDepth", + "in": "query", + "description": "Optional. The maximum audio bit depth.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "enableRemoteMedia", + "in": "query", + "description": "Optional. Whether to enable remote media.", + "schema": { + "type": "boolean" + } + }, + { + "name": "enableAudioVbrEncoding", + "in": "query", + "description": "Optional. Whether to enable Audio Encoding.", + "schema": { + "type": "boolean", + "default": true + } + }, + { + "name": "breakOnNonKeyFrames", + "in": "query", + "description": "Optional. Whether to break on non key frames.", + "schema": { + "type": "boolean", + "default": false + } + }, + { + "name": "enableRedirection", + "in": "query", + "description": "Whether to enable redirection. Defaults to true.", + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Audio stream returned.", + "content": { + "audio/*": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "302": { + "description": "Redirected to remote audio stream." + }, + "404": { + "description": "Item not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Users": { + "get": { + "tags": [ + "User" + ], + "summary": "Gets a list of users.", + "operationId": "GetUsers", + "parameters": [ + { + "name": "isHidden", + "in": "query", + "description": "Optional filter by IsHidden=true or false.", + "schema": { + "type": "boolean" + } + }, + { + "name": "isDisabled", + "in": "query", + "description": "Optional filter by IsDisabled=true or false.", + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "description": "Users returned.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserDto" + } + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserDto" + } + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserDto" + } + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + }, + "post": { + "tags": [ + "User" + ], + "summary": "Updates a user.", + "operationId": "UpdateUser", + "parameters": [ + { + "name": "userId", + "in": "query", + "description": "The user id.", + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "description": "The updated user model.", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/UserDto" + } + ], + "description": "Class UserDto." + } + }, + "text/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/UserDto" + } + ], + "description": "Class UserDto." + } + }, + "application/*+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/UserDto" + } + ], + "description": "Class UserDto." + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "User updated." + }, + "400": { + "description": "User information was not supplied.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "User update forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Users/{userId}": { + "get": { + "tags": [ + "User" + ], + "summary": "Gets a user by Id.", + "operationId": "GetUserById", + "parameters": [ + { + "name": "userId", + "in": "path", + "description": "The user id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "User returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserDto" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/UserDto" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/UserDto" + } + } + } + }, + "404": { + "description": "User not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "IgnoreParentalControl", + "DefaultAuthorization" + ] + } + ] + }, + "delete": { + "tags": [ + "User" + ], + "summary": "Deletes a user.", + "operationId": "DeleteUser", + "parameters": [ + { + "name": "userId", + "in": "path", + "description": "The user id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "204": { + "description": "User deleted." + }, + "404": { + "description": "User not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "RequiresElevation" + ] + } + ] + } + }, + "/Users/{userId}/Policy": { + "post": { + "tags": [ + "User" + ], + "summary": "Updates a user policy.", + "operationId": "UpdateUserPolicy", + "parameters": [ + { + "name": "userId", + "in": "path", + "description": "The user id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "description": "The new user policy.", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/UserPolicy" + } + ] + } + }, + "text/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/UserPolicy" + } + ] + } + }, + "application/*+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/UserPolicy" + } + ] + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "User policy updated." + }, + "400": { + "description": "User policy was not supplied.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "User policy update forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "CustomAuthentication": [ + "RequiresElevation" + ] + } + ] + } + }, + "/Users/AuthenticateByName": { + "post": { + "tags": [ + "User" + ], + "summary": "Authenticates a user by name.", + "operationId": "AuthenticateUserByName", + "requestBody": { + "description": "The M:Jellyfin.Api.Controllers.UserController.AuthenticateUserByName(Jellyfin.Api.Models.UserDtos.AuthenticateUserByName) request.", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/AuthenticateUserByName" + } + ], + "description": "The authenticate user by name request body." + } + }, + "text/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/AuthenticateUserByName" + } + ], + "description": "The authenticate user by name request body." + } + }, + "application/*+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/AuthenticateUserByName" + } + ], + "description": "The authenticate user by name request body." + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "User authenticated.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthenticationResult" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/AuthenticationResult" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/AuthenticationResult" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + } + } + } + }, + "/Users/AuthenticateWithQuickConnect": { + "post": { + "tags": [ + "User" + ], + "summary": "Authenticates a user with quick connect.", + "operationId": "AuthenticateWithQuickConnect", + "requestBody": { + "description": "The Jellyfin.Api.Models.UserDtos.QuickConnectDto request.", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/QuickConnectDto" + } + ], + "description": "The quick connect request body." + } + }, + "text/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/QuickConnectDto" + } + ], + "description": "The quick connect request body." + } + }, + "application/*+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/QuickConnectDto" + } + ], + "description": "The quick connect request body." + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "User authenticated.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthenticationResult" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/AuthenticationResult" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/AuthenticationResult" + } + } + } + }, + "400": { + "description": "Missing token." + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + } + } + } + }, + "/Users/Configuration": { + "post": { + "tags": [ + "User" + ], + "summary": "Updates a user configuration.", + "operationId": "UpdateUserConfiguration", + "parameters": [ + { + "name": "userId", + "in": "query", + "description": "The user id.", + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "description": "The new user configuration.", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/UserConfiguration" + } + ], + "description": "Class UserConfiguration." + } + }, + "text/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/UserConfiguration" + } + ], + "description": "Class UserConfiguration." + } + }, + "application/*+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/UserConfiguration" + } + ], + "description": "Class UserConfiguration." + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "User configuration updated." + }, + "403": { + "description": "User configuration update forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Users/ForgotPassword": { + "post": { + "tags": [ + "User" + ], + "summary": "Initiates the forgot password process for a local user.", + "operationId": "ForgotPassword", + "requestBody": { + "description": "The forgot password request containing the entered username.", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/ForgotPasswordDto" + } + ], + "description": "Forgot Password request body DTO." + } + }, + "text/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/ForgotPasswordDto" + } + ], + "description": "Forgot Password request body DTO." + } + }, + "application/*+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/ForgotPasswordDto" + } + ], + "description": "Forgot Password request body DTO." + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Password reset process started.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ForgotPasswordResult" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ForgotPasswordResult" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ForgotPasswordResult" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + } + } + } + }, + "/Users/ForgotPassword/Pin": { + "post": { + "tags": [ + "User" + ], + "summary": "Redeems a forgot password pin.", + "operationId": "ForgotPasswordPin", + "requestBody": { + "description": "The forgot password pin request containing the entered pin.", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/ForgotPasswordPinDto" + } + ], + "description": "Forgot Password Pin enter request body DTO." + } + }, + "text/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/ForgotPasswordPinDto" + } + ], + "description": "Forgot Password Pin enter request body DTO." + } + }, + "application/*+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/ForgotPasswordPinDto" + } + ], + "description": "Forgot Password Pin enter request body DTO." + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Pin reset process started.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PinRedeemResult" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/PinRedeemResult" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/PinRedeemResult" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + } + } + } + }, + "/Users/Me": { + "get": { + "tags": [ + "User" + ], + "summary": "Gets the user based on auth token.", + "operationId": "GetCurrentUser", + "responses": { + "200": { + "description": "User returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserDto" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/UserDto" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/UserDto" + } + } + } + }, + "400": { + "description": "Token is not owned by a user.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Users/New": { + "post": { + "tags": [ + "User" + ], + "summary": "Creates a user.", + "operationId": "CreateUserByName", + "requestBody": { + "description": "The create user by name request body.", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/CreateUserByName" + } + ], + "description": "The create user by name request body." + } + }, + "text/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/CreateUserByName" + } + ], + "description": "The create user by name request body." + } + }, + "application/*+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/CreateUserByName" + } + ], + "description": "The create user by name request body." + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "User created.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserDto" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/UserDto" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/UserDto" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "RequiresElevation" + ] + } + ] + } + }, + "/Users/Password": { + "post": { + "tags": [ + "User" + ], + "summary": "Updates a user's password.", + "operationId": "UpdateUserPassword", + "parameters": [ + { + "name": "userId", + "in": "query", + "description": "The user id.", + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "description": "The M:Jellyfin.Api.Controllers.UserController.UpdateUserPassword(System.Nullable{System.Guid},Jellyfin.Api.Models.UserDtos.UpdateUserPassword) request.", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/UpdateUserPassword" + } + ], + "description": "The update user password request body." + } + }, + "text/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/UpdateUserPassword" + } + ], + "description": "The update user password request body." + } + }, + "application/*+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/UpdateUserPassword" + } + ], + "description": "The update user password request body." + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Password successfully reset." + }, + "403": { + "description": "User is not allowed to update the password.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "User not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Users/Public": { + "get": { + "tags": [ + "User" + ], + "summary": "Gets a list of publicly visible users for display on a login screen.", + "operationId": "GetPublicUsers", + "responses": { + "200": { + "description": "Public users returned.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserDto" + } + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserDto" + } + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserDto" + } + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + } + } + } + }, + "/Items/{itemId}/Intros": { + "get": { + "tags": [ + "UserLibrary" + ], + "summary": "Gets intros to play before the main media item plays.", + "operationId": "GetIntros", + "parameters": [ + { + "name": "userId", + "in": "query", + "description": "User id.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "itemId", + "in": "path", + "description": "Item id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Intros returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Items/{itemId}/LocalTrailers": { + "get": { + "tags": [ + "UserLibrary" + ], + "summary": "Gets local trailers for an item.", + "operationId": "GetLocalTrailers", + "parameters": [ + { + "name": "userId", + "in": "query", + "description": "User id.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "itemId", + "in": "path", + "description": "Item id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "An Microsoft.AspNetCore.Mvc.OkResult containing the item's local trailers.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BaseItemDto" + } + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BaseItemDto" + } + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BaseItemDto" + } + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Items/{itemId}/SpecialFeatures": { + "get": { + "tags": [ + "UserLibrary" + ], + "summary": "Gets special features for an item.", + "operationId": "GetSpecialFeatures", + "parameters": [ + { + "name": "userId", + "in": "query", + "description": "User id.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "itemId", + "in": "path", + "description": "Item id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Special features returned.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BaseItemDto" + } + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BaseItemDto" + } + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BaseItemDto" + } + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Items/Latest": { + "get": { + "tags": [ + "UserLibrary" + ], + "summary": "Gets latest media.", + "operationId": "GetLatestMedia", + "parameters": [ + { + "name": "userId", + "in": "query", + "description": "User id.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "parentId", + "in": "query", + "description": "Specify this to localize the search to a specific item or folder. Omit to use the root.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "fields", + "in": "query", + "description": "Optional. Specify additional fields of information to return in the output.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ItemFields" + } + } + }, + { + "name": "includeItemTypes", + "in": "query", + "description": "Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BaseItemKind" + } + } + }, + { + "name": "isPlayed", + "in": "query", + "description": "Filter by items that are played, or not.", + "schema": { + "type": "boolean" + } + }, + { + "name": "enableImages", + "in": "query", + "description": "Optional. include image information in output.", + "schema": { + "type": "boolean" + } + }, + { + "name": "imageTypeLimit", + "in": "query", + "description": "Optional. the max number of images to return, per image type.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "enableImageTypes", + "in": "query", + "description": "Optional. The image types to include in the output.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ImageType" + } + } + }, + { + "name": "enableUserData", + "in": "query", + "description": "Optional. include user data.", + "schema": { + "type": "boolean" + } + }, + { + "name": "limit", + "in": "query", + "description": "Return item limit.", + "schema": { + "type": "integer", + "format": "int32", + "default": 20 + } + }, + { + "name": "groupItems", + "in": "query", + "description": "Whether or not to group items into a parent container.", + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Latest media returned.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BaseItemDto" + } + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BaseItemDto" + } + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BaseItemDto" + } + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Items/Root": { + "get": { + "tags": [ + "UserLibrary" + ], + "summary": "Gets the root folder from a user's library.", + "operationId": "GetRootFolder", + "parameters": [ + { + "name": "userId", + "in": "query", + "description": "User id.", + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Root folder returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BaseItemDto" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDto" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDto" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/UserFavoriteItems/{itemId}": { + "post": { + "tags": [ + "UserLibrary" + ], + "summary": "Marks an item as a favorite.", + "operationId": "MarkFavoriteItem", + "parameters": [ + { + "name": "userId", + "in": "query", + "description": "User id.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "itemId", + "in": "path", + "description": "Item id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Item marked as favorite.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserItemDataDto" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/UserItemDataDto" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/UserItemDataDto" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + }, + "delete": { + "tags": [ + "UserLibrary" + ], + "summary": "Unmarks item as a favorite.", + "operationId": "UnmarkFavoriteItem", + "parameters": [ + { + "name": "userId", + "in": "query", + "description": "User id.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "itemId", + "in": "path", + "description": "Item id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Item unmarked as favorite.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserItemDataDto" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/UserItemDataDto" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/UserItemDataDto" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/UserItems/{itemId}/Rating": { + "delete": { + "tags": [ + "UserLibrary" + ], + "summary": "Deletes a user's saved personal rating for an item.", + "operationId": "DeleteUserItemRating", + "parameters": [ + { + "name": "userId", + "in": "query", + "description": "User id.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "itemId", + "in": "path", + "description": "Item id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Personal rating removed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserItemDataDto" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/UserItemDataDto" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/UserItemDataDto" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + }, + "post": { + "tags": [ + "UserLibrary" + ], + "summary": "Updates a user's rating for an item.", + "operationId": "UpdateUserItemRating", + "parameters": [ + { + "name": "userId", + "in": "query", + "description": "User id.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "itemId", + "in": "path", + "description": "Item id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "likes", + "in": "query", + "description": "Whether this M:Jellyfin.Api.Controllers.UserLibraryController.UpdateUserItemRating(System.Nullable{System.Guid},System.Guid,System.Nullable{System.Boolean}) is likes.", + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "description": "Item rating updated.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserItemDataDto" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/UserItemDataDto" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/UserItemDataDto" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/UserViews": { + "get": { + "tags": [ + "UserViews" + ], + "summary": "Get user views.", + "operationId": "GetUserViews", + "parameters": [ + { + "name": "userId", + "in": "query", + "description": "User id.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "includeExternalContent", + "in": "query", + "description": "Whether or not to include external views such as channels or live tv.", + "schema": { + "type": "boolean" + } + }, + { + "name": "presetViews", + "in": "query", + "description": "Preset views.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CollectionType" + } + } + }, + { + "name": "includeHidden", + "in": "query", + "description": "Whether or not to include hidden content.", + "schema": { + "type": "boolean", + "default": false + } + } + ], + "responses": { + "200": { + "description": "User views returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/UserViews/GroupingOptions": { + "get": { + "tags": [ + "UserViews" + ], + "summary": "Get user view grouping options.", + "operationId": "GetGroupingOptions", + "parameters": [ + { + "name": "userId", + "in": "query", + "description": "User id.", + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "User view grouping options returned.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SpecialViewOptionDto" + } + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SpecialViewOptionDto" + } + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SpecialViewOptionDto" + } + } + } + } + }, + "404": { + "description": "User not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Videos/{videoId}/{mediaSourceId}/Attachments/{index}": { + "get": { + "tags": [ + "VideoAttachments" + ], + "summary": "Get video attachment.", + "operationId": "GetAttachment", + "parameters": [ + { + "name": "videoId", + "in": "path", + "description": "Video ID.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "mediaSourceId", + "in": "path", + "description": "Media Source ID.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "index", + "in": "path", + "description": "Attachment Index.", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Attachment retrieved.", + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "404": { + "description": "Video or attachment not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + } + } + } + }, + "/Videos/{itemId}/AdditionalParts": { + "get": { + "tags": [ + "Videos" + ], + "summary": "Gets additional parts for a video.", + "operationId": "GetAdditionalPart", + "parameters": [ + { + "name": "itemId", + "in": "path", + "description": "The item id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "userId", + "in": "query", + "description": "Optional. Filter by user id, and attach user data.", + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Additional parts returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Videos/{itemId}/AlternateSources": { + "delete": { + "tags": [ + "Videos" + ], + "summary": "Removes alternate video sources.", + "operationId": "DeleteAlternateSources", + "parameters": [ + { + "name": "itemId", + "in": "path", + "description": "The item id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "204": { + "description": "Alternate sources deleted." + }, + "404": { + "description": "Video not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "RequiresElevation" + ] + } + ] + } + }, + "/Videos/{itemId}/stream": { + "get": { + "tags": [ + "Videos" + ], + "summary": "Gets a video stream.", + "operationId": "GetVideoStream", + "parameters": [ + { + "name": "itemId", + "in": "path", + "description": "The item id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "container", + "in": "query", + "description": "The video container. Possible values are: ts, webm, asf, wmv, ogv, mp4, m4v, mkv, mpeg, mpg, avi, 3gp, wmv, wtv, m2ts, mov, iso, flv.", + "schema": { + "pattern": "^[a-zA-Z0-9\\-\\._,|]{0,40}$", + "type": "string" + } + }, + { + "name": "static", + "in": "query", + "description": "Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.", + "schema": { + "type": "boolean" + } + }, + { + "name": "params", + "in": "query", + "description": "The streaming parameters.", + "schema": { + "type": "string" + } + }, + { + "name": "tag", + "in": "query", + "description": "The tag.", + "schema": { + "type": "string" + } + }, + { + "name": "deviceProfileId", + "in": "query", + "description": "Optional. The dlna device profile id to utilize.", + "deprecated": true, + "schema": { + "type": "string" + } + }, + { + "name": "playSessionId", + "in": "query", + "description": "The play session id.", + "schema": { + "type": "string" + } + }, + { + "name": "segmentContainer", + "in": "query", + "description": "The segment container.", + "schema": { + "pattern": "^[a-zA-Z0-9\\-\\._,|]{0,40}$", + "type": "string" + } + }, + { + "name": "segmentLength", + "in": "query", + "description": "The segment length.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "minSegments", + "in": "query", + "description": "The minimum number of segments.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "mediaSourceId", + "in": "query", + "description": "The media version id, if playing an alternate version.", + "schema": { + "type": "string" + } + }, + { + "name": "deviceId", + "in": "query", + "description": "The device id of the client requesting. Used to stop encoding processes when needed.", + "schema": { + "type": "string" + } + }, + { + "name": "audioCodec", + "in": "query", + "description": "Optional. Specify an audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension.", + "schema": { + "pattern": "^[a-zA-Z0-9\\-\\._,|]{0,40}$", + "type": "string" + } + }, + { + "name": "enableAutoStreamCopy", + "in": "query", + "description": "Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.", + "schema": { + "type": "boolean" + } + }, + { + "name": "allowVideoStreamCopy", + "in": "query", + "description": "Whether or not to allow copying of the video stream url.", + "schema": { + "type": "boolean" + } + }, + { + "name": "allowAudioStreamCopy", + "in": "query", + "description": "Whether or not to allow copying of the audio stream url.", + "schema": { + "type": "boolean" + } + }, + { + "name": "breakOnNonKeyFrames", + "in": "query", + "description": "Optional. Whether to break on non key frames.", + "schema": { + "type": "boolean" + } + }, + { + "name": "audioSampleRate", + "in": "query", + "description": "Optional. Specify a specific audio sample rate, e.g. 44100.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxAudioBitDepth", + "in": "query", + "description": "Optional. The maximum audio bit depth.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "audioBitRate", + "in": "query", + "description": "Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "audioChannels", + "in": "query", + "description": "Optional. Specify a specific number of audio channels to encode to, e.g. 2.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxAudioChannels", + "in": "query", + "description": "Optional. Specify a maximum number of audio channels to encode to, e.g. 2.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "profile", + "in": "query", + "description": "Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.", + "schema": { + "type": "string" + } + }, + { + "name": "level", + "in": "query", + "description": "Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.", + "schema": { + "pattern": "-?[0-9]+(?:\\.[0-9]+)?", + "type": "string" + } + }, + { + "name": "framerate", + "in": "query", + "description": "Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.", + "schema": { + "type": "number", + "format": "float" + } + }, + { + "name": "maxFramerate", + "in": "query", + "description": "Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.", + "schema": { + "type": "number", + "format": "float" + } + }, + { + "name": "copyTimestamps", + "in": "query", + "description": "Whether or not to copy timestamps when transcoding with an offset. Defaults to false.", + "schema": { + "type": "boolean" + } + }, + { + "name": "startTimeTicks", + "in": "query", + "description": "Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.", + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "width", + "in": "query", + "description": "Optional. The fixed horizontal resolution of the encoded video.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "height", + "in": "query", + "description": "Optional. The fixed vertical resolution of the encoded video.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxWidth", + "in": "query", + "description": "Optional. The maximum horizontal resolution of the encoded video.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxHeight", + "in": "query", + "description": "Optional. The maximum vertical resolution of the encoded video.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "videoBitRate", + "in": "query", + "description": "Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "subtitleStreamIndex", + "in": "query", + "description": "Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "subtitleMethod", + "in": "query", + "description": "Optional. Specify the subtitle delivery method.", + "schema": { + "enum": [ + "Encode", + "Embed", + "External", + "Hls", + "Drop" + ], + "allOf": [ + { + "$ref": "#/components/schemas/SubtitleDeliveryMethod" + } + ] + } + }, + { + "name": "maxRefFrames", + "in": "query", + "description": "Optional.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxVideoBitDepth", + "in": "query", + "description": "Optional. The maximum video bit depth.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "requireAvc", + "in": "query", + "description": "Optional. Whether to require avc.", + "schema": { + "type": "boolean" + } + }, + { + "name": "deInterlace", + "in": "query", + "description": "Optional. Whether to deinterlace the video.", + "schema": { + "type": "boolean" + } + }, + { + "name": "requireNonAnamorphic", + "in": "query", + "description": "Optional. Whether to require a non anamorphic stream.", + "schema": { + "type": "boolean" + } + }, + { + "name": "transcodingMaxAudioChannels", + "in": "query", + "description": "Optional. The maximum number of audio channels to transcode.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "cpuCoreLimit", + "in": "query", + "description": "Optional. The limit of how many cpu cores to use.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "liveStreamId", + "in": "query", + "description": "The live stream id.", + "schema": { + "type": "string" + } + }, + { + "name": "enableMpegtsM2TsMode", + "in": "query", + "description": "Optional. Whether to enable the MpegtsM2Ts mode.", + "schema": { + "type": "boolean" + } + }, + { + "name": "videoCodec", + "in": "query", + "description": "Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension.", + "schema": { + "pattern": "^[a-zA-Z0-9\\-\\._,|]{0,40}$", + "type": "string" + } + }, + { + "name": "subtitleCodec", + "in": "query", + "description": "Optional. Specify a subtitle codec to encode to.", + "schema": { + "pattern": "^[a-zA-Z0-9\\-\\._,|]{0,40}$", + "type": "string" + } + }, + { + "name": "transcodeReasons", + "in": "query", + "description": "Optional. The transcoding reason.", + "schema": { + "type": "string" + } + }, + { + "name": "audioStreamIndex", + "in": "query", + "description": "Optional. The index of the audio stream to use. If omitted the first audio stream will be used.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "videoStreamIndex", + "in": "query", + "description": "Optional. The index of the video stream to use. If omitted the first video stream will be used.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "context", + "in": "query", + "description": "Optional. The MediaBrowser.Model.Dlna.EncodingContext.", + "schema": { + "enum": [ + "Streaming", + "Static" + ], + "allOf": [ + { + "$ref": "#/components/schemas/EncodingContext" + } + ] + } + }, + { + "name": "streamOptions", + "in": "query", + "description": "Optional. The streaming options.", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string", + "nullable": true + } + } + }, + { + "name": "enableAudioVbrEncoding", + "in": "query", + "description": "Optional. Whether to enable Audio Encoding.", + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Video stream returned.", + "content": { + "video/*": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + } + } + }, + "head": { + "tags": [ + "Videos" + ], + "summary": "Gets a video stream.", + "operationId": "HeadVideoStream", + "parameters": [ + { + "name": "itemId", + "in": "path", + "description": "The item id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "container", + "in": "query", + "description": "The video container. Possible values are: ts, webm, asf, wmv, ogv, mp4, m4v, mkv, mpeg, mpg, avi, 3gp, wmv, wtv, m2ts, mov, iso, flv.", + "schema": { + "pattern": "^[a-zA-Z0-9\\-\\._,|]{0,40}$", + "type": "string" + } + }, + { + "name": "static", + "in": "query", + "description": "Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.", + "schema": { + "type": "boolean" + } + }, + { + "name": "params", + "in": "query", + "description": "The streaming parameters.", + "schema": { + "type": "string" + } + }, + { + "name": "tag", + "in": "query", + "description": "The tag.", + "schema": { + "type": "string" + } + }, + { + "name": "deviceProfileId", + "in": "query", + "description": "Optional. The dlna device profile id to utilize.", + "deprecated": true, + "schema": { + "type": "string" + } + }, + { + "name": "playSessionId", + "in": "query", + "description": "The play session id.", + "schema": { + "type": "string" + } + }, + { + "name": "segmentContainer", + "in": "query", + "description": "The segment container.", + "schema": { + "pattern": "^[a-zA-Z0-9\\-\\._,|]{0,40}$", + "type": "string" + } + }, + { + "name": "segmentLength", + "in": "query", + "description": "The segment length.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "minSegments", + "in": "query", + "description": "The minimum number of segments.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "mediaSourceId", + "in": "query", + "description": "The media version id, if playing an alternate version.", + "schema": { + "type": "string" + } + }, + { + "name": "deviceId", + "in": "query", + "description": "The device id of the client requesting. Used to stop encoding processes when needed.", + "schema": { + "type": "string" + } + }, + { + "name": "audioCodec", + "in": "query", + "description": "Optional. Specify an audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension.", + "schema": { + "pattern": "^[a-zA-Z0-9\\-\\._,|]{0,40}$", + "type": "string" + } + }, + { + "name": "enableAutoStreamCopy", + "in": "query", + "description": "Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.", + "schema": { + "type": "boolean" + } + }, + { + "name": "allowVideoStreamCopy", + "in": "query", + "description": "Whether or not to allow copying of the video stream url.", + "schema": { + "type": "boolean" + } + }, + { + "name": "allowAudioStreamCopy", + "in": "query", + "description": "Whether or not to allow copying of the audio stream url.", + "schema": { + "type": "boolean" + } + }, + { + "name": "breakOnNonKeyFrames", + "in": "query", + "description": "Optional. Whether to break on non key frames.", + "schema": { + "type": "boolean" + } + }, + { + "name": "audioSampleRate", + "in": "query", + "description": "Optional. Specify a specific audio sample rate, e.g. 44100.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxAudioBitDepth", + "in": "query", + "description": "Optional. The maximum audio bit depth.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "audioBitRate", + "in": "query", + "description": "Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "audioChannels", + "in": "query", + "description": "Optional. Specify a specific number of audio channels to encode to, e.g. 2.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxAudioChannels", + "in": "query", + "description": "Optional. Specify a maximum number of audio channels to encode to, e.g. 2.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "profile", + "in": "query", + "description": "Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.", + "schema": { + "type": "string" + } + }, + { + "name": "level", + "in": "query", + "description": "Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.", + "schema": { + "pattern": "-?[0-9]+(?:\\.[0-9]+)?", + "type": "string" + } + }, + { + "name": "framerate", + "in": "query", + "description": "Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.", + "schema": { + "type": "number", + "format": "float" + } + }, + { + "name": "maxFramerate", + "in": "query", + "description": "Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.", + "schema": { + "type": "number", + "format": "float" + } + }, + { + "name": "copyTimestamps", + "in": "query", + "description": "Whether or not to copy timestamps when transcoding with an offset. Defaults to false.", + "schema": { + "type": "boolean" + } + }, + { + "name": "startTimeTicks", + "in": "query", + "description": "Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.", + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "width", + "in": "query", + "description": "Optional. The fixed horizontal resolution of the encoded video.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "height", + "in": "query", + "description": "Optional. The fixed vertical resolution of the encoded video.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxWidth", + "in": "query", + "description": "Optional. The maximum horizontal resolution of the encoded video.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxHeight", + "in": "query", + "description": "Optional. The maximum vertical resolution of the encoded video.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "videoBitRate", + "in": "query", + "description": "Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "subtitleStreamIndex", + "in": "query", + "description": "Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "subtitleMethod", + "in": "query", + "description": "Optional. Specify the subtitle delivery method.", + "schema": { + "enum": [ + "Encode", + "Embed", + "External", + "Hls", + "Drop" + ], + "allOf": [ + { + "$ref": "#/components/schemas/SubtitleDeliveryMethod" + } + ] + } + }, + { + "name": "maxRefFrames", + "in": "query", + "description": "Optional.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxVideoBitDepth", + "in": "query", + "description": "Optional. The maximum video bit depth.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "requireAvc", + "in": "query", + "description": "Optional. Whether to require avc.", + "schema": { + "type": "boolean" + } + }, + { + "name": "deInterlace", + "in": "query", + "description": "Optional. Whether to deinterlace the video.", + "schema": { + "type": "boolean" + } + }, + { + "name": "requireNonAnamorphic", + "in": "query", + "description": "Optional. Whether to require a non anamorphic stream.", + "schema": { + "type": "boolean" + } + }, + { + "name": "transcodingMaxAudioChannels", + "in": "query", + "description": "Optional. The maximum number of audio channels to transcode.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "cpuCoreLimit", + "in": "query", + "description": "Optional. The limit of how many cpu cores to use.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "liveStreamId", + "in": "query", + "description": "The live stream id.", + "schema": { + "type": "string" + } + }, + { + "name": "enableMpegtsM2TsMode", + "in": "query", + "description": "Optional. Whether to enable the MpegtsM2Ts mode.", + "schema": { + "type": "boolean" + } + }, + { + "name": "videoCodec", + "in": "query", + "description": "Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension.", + "schema": { + "pattern": "^[a-zA-Z0-9\\-\\._,|]{0,40}$", + "type": "string" + } + }, + { + "name": "subtitleCodec", + "in": "query", + "description": "Optional. Specify a subtitle codec to encode to.", + "schema": { + "pattern": "^[a-zA-Z0-9\\-\\._,|]{0,40}$", + "type": "string" + } + }, + { + "name": "transcodeReasons", + "in": "query", + "description": "Optional. The transcoding reason.", + "schema": { + "type": "string" + } + }, + { + "name": "audioStreamIndex", + "in": "query", + "description": "Optional. The index of the audio stream to use. If omitted the first audio stream will be used.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "videoStreamIndex", + "in": "query", + "description": "Optional. The index of the video stream to use. If omitted the first video stream will be used.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "context", + "in": "query", + "description": "Optional. The MediaBrowser.Model.Dlna.EncodingContext.", + "schema": { + "enum": [ + "Streaming", + "Static" + ], + "allOf": [ + { + "$ref": "#/components/schemas/EncodingContext" + } + ] + } + }, + { + "name": "streamOptions", + "in": "query", + "description": "Optional. The streaming options.", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string", + "nullable": true + } + } + }, + { + "name": "enableAudioVbrEncoding", + "in": "query", + "description": "Optional. Whether to enable Audio Encoding.", + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Video stream returned.", + "content": { + "video/*": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + } + } + } + }, + "/Videos/{itemId}/stream.{container}": { + "get": { + "tags": [ + "Videos" + ], + "summary": "Gets a video stream.", + "operationId": "GetVideoStreamByContainer", + "parameters": [ + { + "name": "itemId", + "in": "path", + "description": "The item id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "container", + "in": "path", + "description": "The video container. Possible values are: ts, webm, asf, wmv, ogv, mp4, m4v, mkv, mpeg, mpg, avi, 3gp, wmv, wtv, m2ts, mov, iso, flv.", + "required": true, + "schema": { + "pattern": "^[a-zA-Z0-9\\-\\._,|]{0,40}$", + "type": "string" + } + }, + { + "name": "static", + "in": "query", + "description": "Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.", + "schema": { + "type": "boolean" + } + }, + { + "name": "params", + "in": "query", + "description": "The streaming parameters.", + "schema": { + "type": "string" + } + }, + { + "name": "tag", + "in": "query", + "description": "The tag.", + "schema": { + "type": "string" + } + }, + { + "name": "deviceProfileId", + "in": "query", + "description": "Optional. The dlna device profile id to utilize.", + "schema": { + "type": "string" + } + }, + { + "name": "playSessionId", + "in": "query", + "description": "The play session id.", + "schema": { + "type": "string" + } + }, + { + "name": "segmentContainer", + "in": "query", + "description": "The segment container.", + "schema": { + "pattern": "^[a-zA-Z0-9\\-\\._,|]{0,40}$", + "type": "string" + } + }, + { + "name": "segmentLength", + "in": "query", + "description": "The segment length.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "minSegments", + "in": "query", + "description": "The minimum number of segments.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "mediaSourceId", + "in": "query", + "description": "The media version id, if playing an alternate version.", + "schema": { + "type": "string" + } + }, + { + "name": "deviceId", + "in": "query", + "description": "The device id of the client requesting. Used to stop encoding processes when needed.", + "schema": { + "type": "string" + } + }, + { + "name": "audioCodec", + "in": "query", + "description": "Optional. Specify an audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension.", + "schema": { + "pattern": "^[a-zA-Z0-9\\-\\._,|]{0,40}$", + "type": "string" + } + }, + { + "name": "enableAutoStreamCopy", + "in": "query", + "description": "Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.", + "schema": { + "type": "boolean" + } + }, + { + "name": "allowVideoStreamCopy", + "in": "query", + "description": "Whether or not to allow copying of the video stream url.", + "schema": { + "type": "boolean" + } + }, + { + "name": "allowAudioStreamCopy", + "in": "query", + "description": "Whether or not to allow copying of the audio stream url.", + "schema": { + "type": "boolean" + } + }, + { + "name": "breakOnNonKeyFrames", + "in": "query", + "description": "Optional. Whether to break on non key frames.", + "schema": { + "type": "boolean" + } + }, + { + "name": "audioSampleRate", + "in": "query", + "description": "Optional. Specify a specific audio sample rate, e.g. 44100.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxAudioBitDepth", + "in": "query", + "description": "Optional. The maximum audio bit depth.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "audioBitRate", + "in": "query", + "description": "Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "audioChannels", + "in": "query", + "description": "Optional. Specify a specific number of audio channels to encode to, e.g. 2.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxAudioChannels", + "in": "query", + "description": "Optional. Specify a maximum number of audio channels to encode to, e.g. 2.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "profile", + "in": "query", + "description": "Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.", + "schema": { + "type": "string" + } + }, + { + "name": "level", + "in": "query", + "description": "Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.", + "schema": { + "pattern": "-?[0-9]+(?:\\.[0-9]+)?", + "type": "string" + } + }, + { + "name": "framerate", + "in": "query", + "description": "Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.", + "schema": { + "type": "number", + "format": "float" + } + }, + { + "name": "maxFramerate", + "in": "query", + "description": "Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.", + "schema": { + "type": "number", + "format": "float" + } + }, + { + "name": "copyTimestamps", + "in": "query", + "description": "Whether or not to copy timestamps when transcoding with an offset. Defaults to false.", + "schema": { + "type": "boolean" + } + }, + { + "name": "startTimeTicks", + "in": "query", + "description": "Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.", + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "width", + "in": "query", + "description": "Optional. The fixed horizontal resolution of the encoded video.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "height", + "in": "query", + "description": "Optional. The fixed vertical resolution of the encoded video.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxWidth", + "in": "query", + "description": "Optional. The maximum horizontal resolution of the encoded video.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxHeight", + "in": "query", + "description": "Optional. The maximum vertical resolution of the encoded video.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "videoBitRate", + "in": "query", + "description": "Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "subtitleStreamIndex", + "in": "query", + "description": "Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "subtitleMethod", + "in": "query", + "description": "Optional. Specify the subtitle delivery method.", + "schema": { + "enum": [ + "Encode", + "Embed", + "External", + "Hls", + "Drop" + ], + "allOf": [ + { + "$ref": "#/components/schemas/SubtitleDeliveryMethod" + } + ] + } + }, + { + "name": "maxRefFrames", + "in": "query", + "description": "Optional.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxVideoBitDepth", + "in": "query", + "description": "Optional. The maximum video bit depth.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "requireAvc", + "in": "query", + "description": "Optional. Whether to require avc.", + "schema": { + "type": "boolean" + } + }, + { + "name": "deInterlace", + "in": "query", + "description": "Optional. Whether to deinterlace the video.", + "schema": { + "type": "boolean" + } + }, + { + "name": "requireNonAnamorphic", + "in": "query", + "description": "Optional. Whether to require a non anamorphic stream.", + "schema": { + "type": "boolean" + } + }, + { + "name": "transcodingMaxAudioChannels", + "in": "query", + "description": "Optional. The maximum number of audio channels to transcode.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "cpuCoreLimit", + "in": "query", + "description": "Optional. The limit of how many cpu cores to use.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "liveStreamId", + "in": "query", + "description": "The live stream id.", + "schema": { + "type": "string" + } + }, + { + "name": "enableMpegtsM2TsMode", + "in": "query", + "description": "Optional. Whether to enable the MpegtsM2Ts mode.", + "schema": { + "type": "boolean" + } + }, + { + "name": "videoCodec", + "in": "query", + "description": "Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension.", + "schema": { + "pattern": "^[a-zA-Z0-9\\-\\._,|]{0,40}$", + "type": "string" + } + }, + { + "name": "subtitleCodec", + "in": "query", + "description": "Optional. Specify a subtitle codec to encode to.", + "schema": { + "pattern": "^[a-zA-Z0-9\\-\\._,|]{0,40}$", + "type": "string" + } + }, + { + "name": "transcodeReasons", + "in": "query", + "description": "Optional. The transcoding reason.", + "schema": { + "type": "string" + } + }, + { + "name": "audioStreamIndex", + "in": "query", + "description": "Optional. The index of the audio stream to use. If omitted the first audio stream will be used.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "videoStreamIndex", + "in": "query", + "description": "Optional. The index of the video stream to use. If omitted the first video stream will be used.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "context", + "in": "query", + "description": "Optional. The MediaBrowser.Model.Dlna.EncodingContext.", + "schema": { + "enum": [ + "Streaming", + "Static" + ], + "allOf": [ + { + "$ref": "#/components/schemas/EncodingContext" + } + ] + } + }, + { + "name": "streamOptions", + "in": "query", + "description": "Optional. The streaming options.", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string", + "nullable": true + } + } + }, + { + "name": "enableAudioVbrEncoding", + "in": "query", + "description": "Optional. Whether to enable Audio Encoding.", + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Video stream returned.", + "content": { + "video/*": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + } + } + }, + "head": { + "tags": [ + "Videos" + ], + "summary": "Gets a video stream.", + "operationId": "HeadVideoStreamByContainer", + "parameters": [ + { + "name": "itemId", + "in": "path", + "description": "The item id.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "container", + "in": "path", + "description": "The video container. Possible values are: ts, webm, asf, wmv, ogv, mp4, m4v, mkv, mpeg, mpg, avi, 3gp, wmv, wtv, m2ts, mov, iso, flv.", + "required": true, + "schema": { + "pattern": "^[a-zA-Z0-9\\-\\._,|]{0,40}$", + "type": "string" + } + }, + { + "name": "static", + "in": "query", + "description": "Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.", + "schema": { + "type": "boolean" + } + }, + { + "name": "params", + "in": "query", + "description": "The streaming parameters.", + "schema": { + "type": "string" + } + }, + { + "name": "tag", + "in": "query", + "description": "The tag.", + "schema": { + "type": "string" + } + }, + { + "name": "deviceProfileId", + "in": "query", + "description": "Optional. The dlna device profile id to utilize.", + "schema": { + "type": "string" + } + }, + { + "name": "playSessionId", + "in": "query", + "description": "The play session id.", + "schema": { + "type": "string" + } + }, + { + "name": "segmentContainer", + "in": "query", + "description": "The segment container.", + "schema": { + "pattern": "^[a-zA-Z0-9\\-\\._,|]{0,40}$", + "type": "string" + } + }, + { + "name": "segmentLength", + "in": "query", + "description": "The segment length.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "minSegments", + "in": "query", + "description": "The minimum number of segments.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "mediaSourceId", + "in": "query", + "description": "The media version id, if playing an alternate version.", + "schema": { + "type": "string" + } + }, + { + "name": "deviceId", + "in": "query", + "description": "The device id of the client requesting. Used to stop encoding processes when needed.", + "schema": { + "type": "string" + } + }, + { + "name": "audioCodec", + "in": "query", + "description": "Optional. Specify an audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension.", + "schema": { + "pattern": "^[a-zA-Z0-9\\-\\._,|]{0,40}$", + "type": "string" + } + }, + { + "name": "enableAutoStreamCopy", + "in": "query", + "description": "Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.", + "schema": { + "type": "boolean" + } + }, + { + "name": "allowVideoStreamCopy", + "in": "query", + "description": "Whether or not to allow copying of the video stream url.", + "schema": { + "type": "boolean" + } + }, + { + "name": "allowAudioStreamCopy", + "in": "query", + "description": "Whether or not to allow copying of the audio stream url.", + "schema": { + "type": "boolean" + } + }, + { + "name": "breakOnNonKeyFrames", + "in": "query", + "description": "Optional. Whether to break on non key frames.", + "schema": { + "type": "boolean" + } + }, + { + "name": "audioSampleRate", + "in": "query", + "description": "Optional. Specify a specific audio sample rate, e.g. 44100.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxAudioBitDepth", + "in": "query", + "description": "Optional. The maximum audio bit depth.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "audioBitRate", + "in": "query", + "description": "Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "audioChannels", + "in": "query", + "description": "Optional. Specify a specific number of audio channels to encode to, e.g. 2.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxAudioChannels", + "in": "query", + "description": "Optional. Specify a maximum number of audio channels to encode to, e.g. 2.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "profile", + "in": "query", + "description": "Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.", + "schema": { + "type": "string" + } + }, + { + "name": "level", + "in": "query", + "description": "Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.", + "schema": { + "pattern": "-?[0-9]+(?:\\.[0-9]+)?", + "type": "string" + } + }, + { + "name": "framerate", + "in": "query", + "description": "Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.", + "schema": { + "type": "number", + "format": "float" + } + }, + { + "name": "maxFramerate", + "in": "query", + "description": "Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.", + "schema": { + "type": "number", + "format": "float" + } + }, + { + "name": "copyTimestamps", + "in": "query", + "description": "Whether or not to copy timestamps when transcoding with an offset. Defaults to false.", + "schema": { + "type": "boolean" + } + }, + { + "name": "startTimeTicks", + "in": "query", + "description": "Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.", + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "width", + "in": "query", + "description": "Optional. The fixed horizontal resolution of the encoded video.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "height", + "in": "query", + "description": "Optional. The fixed vertical resolution of the encoded video.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxWidth", + "in": "query", + "description": "Optional. The maximum horizontal resolution of the encoded video.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxHeight", + "in": "query", + "description": "Optional. The maximum vertical resolution of the encoded video.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "videoBitRate", + "in": "query", + "description": "Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "subtitleStreamIndex", + "in": "query", + "description": "Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "subtitleMethod", + "in": "query", + "description": "Optional. Specify the subtitle delivery method.", + "schema": { + "enum": [ + "Encode", + "Embed", + "External", + "Hls", + "Drop" + ], + "allOf": [ + { + "$ref": "#/components/schemas/SubtitleDeliveryMethod" + } + ] + } + }, + { + "name": "maxRefFrames", + "in": "query", + "description": "Optional.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxVideoBitDepth", + "in": "query", + "description": "Optional. The maximum video bit depth.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "requireAvc", + "in": "query", + "description": "Optional. Whether to require avc.", + "schema": { + "type": "boolean" + } + }, + { + "name": "deInterlace", + "in": "query", + "description": "Optional. Whether to deinterlace the video.", + "schema": { + "type": "boolean" + } + }, + { + "name": "requireNonAnamorphic", + "in": "query", + "description": "Optional. Whether to require a non anamorphic stream.", + "schema": { + "type": "boolean" + } + }, + { + "name": "transcodingMaxAudioChannels", + "in": "query", + "description": "Optional. The maximum number of audio channels to transcode.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "cpuCoreLimit", + "in": "query", + "description": "Optional. The limit of how many cpu cores to use.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "liveStreamId", + "in": "query", + "description": "The live stream id.", + "schema": { + "type": "string" + } + }, + { + "name": "enableMpegtsM2TsMode", + "in": "query", + "description": "Optional. Whether to enable the MpegtsM2Ts mode.", + "schema": { + "type": "boolean" + } + }, + { + "name": "videoCodec", + "in": "query", + "description": "Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension.", + "schema": { + "pattern": "^[a-zA-Z0-9\\-\\._,|]{0,40}$", + "type": "string" + } + }, + { + "name": "subtitleCodec", + "in": "query", + "description": "Optional. Specify a subtitle codec to encode to.", + "schema": { + "pattern": "^[a-zA-Z0-9\\-\\._,|]{0,40}$", + "type": "string" + } + }, + { + "name": "transcodeReasons", + "in": "query", + "description": "Optional. The transcoding reason.", + "schema": { + "type": "string" + } + }, + { + "name": "audioStreamIndex", + "in": "query", + "description": "Optional. The index of the audio stream to use. If omitted the first audio stream will be used.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "videoStreamIndex", + "in": "query", + "description": "Optional. The index of the video stream to use. If omitted the first video stream will be used.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "context", + "in": "query", + "description": "Optional. The MediaBrowser.Model.Dlna.EncodingContext.", + "schema": { + "enum": [ + "Streaming", + "Static" + ], + "allOf": [ + { + "$ref": "#/components/schemas/EncodingContext" + } + ] + } + }, + { + "name": "streamOptions", + "in": "query", + "description": "Optional. The streaming options.", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string", + "nullable": true + } + } + }, + { + "name": "enableAudioVbrEncoding", + "in": "query", + "description": "Optional. Whether to enable Audio Encoding.", + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Video stream returned.", + "content": { + "video/*": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + } + } + } + }, + "/Videos/MergeVersions": { + "post": { + "tags": [ + "Videos" + ], + "summary": "Merges videos into a single record.", + "operationId": "MergeVersions", + "parameters": [ + { + "name": "ids", + "in": "query", + "description": "Item id list. This allows multiple, comma delimited.", + "required": true, + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + } + ], + "responses": { + "204": { + "description": "Videos merged." + }, + "400": { + "description": "Supply at least 2 video ids.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "RequiresElevation" + ] + } + ] + } + }, + "/Years": { + "get": { + "tags": [ + "Years" + ], + "summary": "Get years.", + "operationId": "GetYears", + "parameters": [ + { + "name": "startIndex", + "in": "query", + "description": "Skips over a given number of items within the results. Use for paging.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "limit", + "in": "query", + "description": "Optional. The maximum number of records to return.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "sortOrder", + "in": "query", + "description": "Sort Order - Ascending,Descending.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SortOrder" + } + } + }, + { + "name": "parentId", + "in": "query", + "description": "Specify this to localize the search to a specific item or folder. Omit to use the root.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "fields", + "in": "query", + "description": "Optional. Specify additional fields of information to return in the output.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ItemFields" + } + } + }, + { + "name": "excludeItemTypes", + "in": "query", + "description": "Optional. If specified, results will be excluded based on item type. This allows multiple, comma delimited.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BaseItemKind" + } + } + }, + { + "name": "includeItemTypes", + "in": "query", + "description": "Optional. If specified, results will be included based on item type. This allows multiple, comma delimited.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BaseItemKind" + } + } + }, + { + "name": "mediaTypes", + "in": "query", + "description": "Optional. Filter by MediaType. Allows multiple, comma delimited.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MediaType" + } + } + }, + { + "name": "sortBy", + "in": "query", + "description": "Optional. Specify one or more sort orders, comma delimited. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ItemSortBy" + } + } + }, + { + "name": "enableUserData", + "in": "query", + "description": "Optional. Include user data.", + "schema": { + "type": "boolean" + } + }, + { + "name": "imageTypeLimit", + "in": "query", + "description": "Optional. The max number of images to return, per image type.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "enableImageTypes", + "in": "query", + "description": "Optional. The image types to include in the output.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ImageType" + } + } + }, + { + "name": "userId", + "in": "query", + "description": "User Id.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "recursive", + "in": "query", + "description": "Search recursively.", + "schema": { + "type": "boolean", + "default": true + } + }, + { + "name": "enableImages", + "in": "query", + "description": "Optional. Include image information in output.", + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Year query returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDtoQueryResult" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + }, + "/Years/{year}": { + "get": { + "tags": [ + "Years" + ], + "summary": "Gets a year.", + "operationId": "GetYear", + "parameters": [ + { + "name": "year", + "in": "path", + "description": "The year.", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "userId", + "in": "query", + "description": "Optional. Filter by user id, and attach user data.", + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Year returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BaseItemDto" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDto" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/BaseItemDto" + } + } + } + }, + "404": { + "description": "Year not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"CamelCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json; profile=\"PascalCase\"": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "The server is currently starting or is temporarily not available.", + "headers": { + "Retry-After": { + "description": "A hint for when to retry the operation in full seconds.", + "allowEmptyValue": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + "Message": { + "description": "A short plain-text reason why the server is not available.", + "allowEmptyValue": true, + "schema": { + "type": "string", + "format": "text" + } + } + }, + "content": { + "text/html": { } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "CustomAuthentication": [ + "DefaultAuthorization" + ] + } + ] + } + } + }, + "components": { + "schemas": { + "AccessSchedule": { + "type": "object", + "properties": { + "Id": { + "type": "integer", + "description": "Gets the id of this instance.", + "format": "int32", + "readOnly": true + }, + "UserId": { + "type": "string", + "description": "Gets the id of the associated user.", + "format": "uuid" + }, + "DayOfWeek": { + "enum": [ + "Sunday", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + "Everyday", + "Weekday", + "Weekend" + ], + "allOf": [ + { + "$ref": "#/components/schemas/DynamicDayOfWeek" + } + ], + "description": "Gets or sets the day of week." + }, + "StartHour": { + "type": "number", + "description": "Gets or sets the start hour.", + "format": "double" + }, + "EndHour": { + "type": "number", + "description": "Gets or sets the end hour.", + "format": "double" + } + }, + "additionalProperties": false, + "description": "An entity representing a user's access schedule." + }, + "ActivityLogEntry": { + "type": "object", + "properties": { + "Id": { + "type": "integer", + "description": "Gets or sets the identifier.", + "format": "int64" + }, + "Name": { + "type": "string", + "description": "Gets or sets the name." + }, + "Overview": { + "type": "string", + "description": "Gets or sets the overview.", + "nullable": true + }, + "ShortOverview": { + "type": "string", + "description": "Gets or sets the short overview.", + "nullable": true + }, + "Type": { + "type": "string", + "description": "Gets or sets the type." + }, + "ItemId": { + "type": "string", + "description": "Gets or sets the item identifier.", + "nullable": true + }, + "Date": { + "type": "string", + "description": "Gets or sets the date.", + "format": "date-time" + }, + "UserId": { + "type": "string", + "description": "Gets or sets the user identifier.", + "format": "uuid" + }, + "UserPrimaryImageTag": { + "type": "string", + "description": "Gets or sets the user primary image tag.", + "nullable": true, + "deprecated": true + }, + "Severity": { + "enum": [ + "Trace", + "Debug", + "Information", + "Warning", + "Error", + "Critical", + "None" + ], + "allOf": [ + { + "$ref": "#/components/schemas/LogLevel" + } + ], + "description": "Gets or sets the log severity." + } + }, + "additionalProperties": false, + "description": "An activity log entry." + }, + "ActivityLogEntryMessage": { + "type": "object", + "properties": { + "Data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ActivityLogEntry" + }, + "description": "Gets or sets the data.", + "nullable": true + }, + "MessageId": { + "type": "string", + "description": "Gets or sets the message id.", + "format": "uuid" + }, + "MessageType": { + "enum": [ + "ForceKeepAlive", + "GeneralCommand", + "UserDataChanged", + "Sessions", + "Play", + "SyncPlayCommand", + "SyncPlayGroupUpdate", + "Playstate", + "RestartRequired", + "ServerShuttingDown", + "ServerRestarting", + "LibraryChanged", + "UserDeleted", + "UserUpdated", + "SeriesTimerCreated", + "TimerCreated", + "SeriesTimerCancelled", + "TimerCancelled", + "RefreshProgress", + "ScheduledTaskEnded", + "PackageInstallationCancelled", + "PackageInstallationFailed", + "PackageInstallationCompleted", + "PackageInstalling", + "PackageUninstalled", + "ActivityLogEntry", + "ScheduledTasksInfo", + "ActivityLogEntryStart", + "ActivityLogEntryStop", + "SessionsStart", + "SessionsStop", + "ScheduledTasksInfoStart", + "ScheduledTasksInfoStop", + "KeepAlive" + ], + "allOf": [ + { + "$ref": "#/components/schemas/SessionMessageType" + } + ], + "description": "The different kinds of messages that are used in the WebSocket api.", + "default": "ActivityLogEntry", + "readOnly": true + } + }, + "additionalProperties": false, + "description": "Activity log created message." + }, + "ActivityLogEntryQueryResult": { + "type": "object", + "properties": { + "Items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ActivityLogEntry" + }, + "description": "Gets or sets the items." + }, + "TotalRecordCount": { + "type": "integer", + "description": "Gets or sets the total number of records available.", + "format": "int32" + }, + "StartIndex": { + "type": "integer", + "description": "Gets or sets the index of the first record in Items.", + "format": "int32" + } + }, + "additionalProperties": false, + "description": "Query result container." + }, + "ActivityLogEntryStartMessage": { + "type": "object", + "properties": { + "Data": { + "type": "string", + "description": "Gets or sets the data.", + "nullable": true + }, + "MessageType": { + "enum": [ + "ForceKeepAlive", + "GeneralCommand", + "UserDataChanged", + "Sessions", + "Play", + "SyncPlayCommand", + "SyncPlayGroupUpdate", + "Playstate", + "RestartRequired", + "ServerShuttingDown", + "ServerRestarting", + "LibraryChanged", + "UserDeleted", + "UserUpdated", + "SeriesTimerCreated", + "TimerCreated", + "SeriesTimerCancelled", + "TimerCancelled", + "RefreshProgress", + "ScheduledTaskEnded", + "PackageInstallationCancelled", + "PackageInstallationFailed", + "PackageInstallationCompleted", + "PackageInstalling", + "PackageUninstalled", + "ActivityLogEntry", + "ScheduledTasksInfo", + "ActivityLogEntryStart", + "ActivityLogEntryStop", + "SessionsStart", + "SessionsStop", + "ScheduledTasksInfoStart", + "ScheduledTasksInfoStop", + "KeepAlive" + ], + "allOf": [ + { + "$ref": "#/components/schemas/SessionMessageType" + } + ], + "description": "The different kinds of messages that are used in the WebSocket api.", + "default": "ActivityLogEntryStart", + "readOnly": true + } + }, + "additionalProperties": false, + "description": "Activity log entry start message.\r\nData is the timing data encoded as \"$initialDelay,$interval\" in ms." + }, + "ActivityLogEntryStopMessage": { + "type": "object", + "properties": { + "MessageType": { + "enum": [ + "ForceKeepAlive", + "GeneralCommand", + "UserDataChanged", + "Sessions", + "Play", + "SyncPlayCommand", + "SyncPlayGroupUpdate", + "Playstate", + "RestartRequired", + "ServerShuttingDown", + "ServerRestarting", + "LibraryChanged", + "UserDeleted", + "UserUpdated", + "SeriesTimerCreated", + "TimerCreated", + "SeriesTimerCancelled", + "TimerCancelled", + "RefreshProgress", + "ScheduledTaskEnded", + "PackageInstallationCancelled", + "PackageInstallationFailed", + "PackageInstallationCompleted", + "PackageInstalling", + "PackageUninstalled", + "ActivityLogEntry", + "ScheduledTasksInfo", + "ActivityLogEntryStart", + "ActivityLogEntryStop", + "SessionsStart", + "SessionsStop", + "ScheduledTasksInfoStart", + "ScheduledTasksInfoStop", + "KeepAlive" + ], + "allOf": [ + { + "$ref": "#/components/schemas/SessionMessageType" + } + ], + "description": "The different kinds of messages that are used in the WebSocket api.", + "default": "ActivityLogEntryStop", + "readOnly": true + } + }, + "additionalProperties": false, + "description": "Activity log entry stop message." + }, + "AddVirtualFolderDto": { + "type": "object", + "properties": { + "LibraryOptions": { + "allOf": [ + { + "$ref": "#/components/schemas/LibraryOptions" + } + ], + "description": "Gets or sets library options.", + "nullable": true + } + }, + "additionalProperties": false, + "description": "Add virtual folder dto." + }, + "AlbumInfo": { + "type": "object", + "properties": { + "Name": { + "type": "string", + "description": "Gets or sets the name.", + "nullable": true + }, + "OriginalTitle": { + "type": "string", + "description": "Gets or sets the original title.", + "nullable": true + }, + "Path": { + "type": "string", + "description": "Gets or sets the path.", + "nullable": true + }, + "MetadataLanguage": { + "type": "string", + "description": "Gets or sets the metadata language.", + "nullable": true + }, + "MetadataCountryCode": { + "type": "string", + "description": "Gets or sets the metadata country code.", + "nullable": true + }, + "ProviderIds": { + "type": "object", + "additionalProperties": { + "type": "string", + "nullable": true + }, + "description": "Gets or sets the provider ids.", + "nullable": true + }, + "Year": { + "type": "integer", + "description": "Gets or sets the year.", + "format": "int32", + "nullable": true + }, + "IndexNumber": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "ParentIndexNumber": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "PremiereDate": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "IsAutomated": { + "type": "boolean" + }, + "AlbumArtists": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Gets or sets the album artist." + }, + "ArtistProviderIds": { + "type": "object", + "additionalProperties": { + "type": "string", + "nullable": true + }, + "description": "Gets or sets the artist provider ids." + }, + "SongInfos": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SongInfo" + } + } + }, + "additionalProperties": false + }, + "AlbumInfoRemoteSearchQuery": { + "type": "object", + "properties": { + "SearchInfo": { + "allOf": [ + { + "$ref": "#/components/schemas/AlbumInfo" + } + ], + "nullable": true + }, + "ItemId": { + "type": "string", + "format": "uuid" + }, + "SearchProviderName": { + "type": "string", + "description": "Gets or sets the provider name to search within if set.", + "nullable": true + }, + "IncludeDisabledProviders": { + "type": "boolean", + "description": "Gets or sets a value indicating whether disabled providers should be included." + } + }, + "additionalProperties": false + }, + "AllThemeMediaResult": { + "type": "object", + "properties": { + "ThemeVideosResult": { + "allOf": [ + { + "$ref": "#/components/schemas/ThemeMediaResult" + } + ], + "description": "Class ThemeMediaResult.", + "nullable": true + }, + "ThemeSongsResult": { + "allOf": [ + { + "$ref": "#/components/schemas/ThemeMediaResult" + } + ], + "description": "Class ThemeMediaResult.", + "nullable": true + }, + "SoundtrackSongsResult": { + "allOf": [ + { + "$ref": "#/components/schemas/ThemeMediaResult" + } + ], + "description": "Class ThemeMediaResult.", + "nullable": true + } + }, + "additionalProperties": false + }, + "ArtistInfo": { + "type": "object", + "properties": { + "Name": { + "type": "string", + "description": "Gets or sets the name.", + "nullable": true + }, + "OriginalTitle": { + "type": "string", + "description": "Gets or sets the original title.", + "nullable": true + }, + "Path": { + "type": "string", + "description": "Gets or sets the path.", + "nullable": true + }, + "MetadataLanguage": { + "type": "string", + "description": "Gets or sets the metadata language.", + "nullable": true + }, + "MetadataCountryCode": { + "type": "string", + "description": "Gets or sets the metadata country code.", + "nullable": true + }, + "ProviderIds": { + "type": "object", + "additionalProperties": { + "type": "string", + "nullable": true + }, + "description": "Gets or sets the provider ids.", + "nullable": true + }, + "Year": { + "type": "integer", + "description": "Gets or sets the year.", + "format": "int32", + "nullable": true + }, + "IndexNumber": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "ParentIndexNumber": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "PremiereDate": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "IsAutomated": { + "type": "boolean" + }, + "SongInfos": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SongInfo" + } + } + }, + "additionalProperties": false + }, + "ArtistInfoRemoteSearchQuery": { + "type": "object", + "properties": { + "SearchInfo": { + "allOf": [ + { + "$ref": "#/components/schemas/ArtistInfo" + } + ], + "nullable": true + }, + "ItemId": { + "type": "string", + "format": "uuid" + }, + "SearchProviderName": { + "type": "string", + "description": "Gets or sets the provider name to search within if set.", + "nullable": true + }, + "IncludeDisabledProviders": { + "type": "boolean", + "description": "Gets or sets a value indicating whether disabled providers should be included." + } + }, + "additionalProperties": false + }, + "AudioSpatialFormat": { + "enum": [ + "None", + "DolbyAtmos", + "DTSX" + ], + "type": "string", + "description": "An enum representing formats of spatial audio." + }, + "AuthenticateUserByName": { + "type": "object", + "properties": { + "Username": { + "type": "string", + "description": "Gets or sets the username.", + "nullable": true + }, + "Pw": { + "type": "string", + "description": "Gets or sets the plain text password.", + "nullable": true + } + }, + "additionalProperties": false, + "description": "The authenticate user by name request body." + }, + "AuthenticationInfo": { + "type": "object", + "properties": { + "Id": { + "type": "integer", + "description": "Gets or sets the identifier.", + "format": "int64" + }, + "AccessToken": { + "type": "string", + "description": "Gets or sets the access token.", + "nullable": true + }, + "DeviceId": { + "type": "string", + "description": "Gets or sets the device identifier.", + "nullable": true + }, + "AppName": { + "type": "string", + "description": "Gets or sets the name of the application.", + "nullable": true + }, + "AppVersion": { + "type": "string", + "description": "Gets or sets the application version.", + "nullable": true + }, + "DeviceName": { + "type": "string", + "description": "Gets or sets the name of the device.", + "nullable": true + }, + "UserId": { + "type": "string", + "description": "Gets or sets the user identifier.", + "format": "uuid" + }, + "IsActive": { + "type": "boolean", + "description": "Gets or sets a value indicating whether this instance is active." + }, + "DateCreated": { + "type": "string", + "description": "Gets or sets the date created.", + "format": "date-time" + }, + "DateRevoked": { + "type": "string", + "description": "Gets or sets the date revoked.", + "format": "date-time", + "nullable": true + }, + "DateLastActivity": { + "type": "string", + "format": "date-time" + }, + "UserName": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, + "AuthenticationInfoQueryResult": { + "type": "object", + "properties": { + "Items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AuthenticationInfo" + }, + "description": "Gets or sets the items." + }, + "TotalRecordCount": { + "type": "integer", + "description": "Gets or sets the total number of records available.", + "format": "int32" + }, + "StartIndex": { + "type": "integer", + "description": "Gets or sets the index of the first record in Items.", + "format": "int32" + } + }, + "additionalProperties": false, + "description": "Query result container." + }, + "AuthenticationResult": { + "type": "object", + "properties": { + "User": { + "allOf": [ + { + "$ref": "#/components/schemas/UserDto" + } + ], + "description": "Class UserDto.", + "nullable": true + }, + "SessionInfo": { + "allOf": [ + { + "$ref": "#/components/schemas/SessionInfoDto" + } + ], + "description": "Session info DTO.", + "nullable": true + }, + "AccessToken": { + "type": "string", + "description": "Gets or sets the access token.", + "nullable": true + }, + "ServerId": { + "type": "string", + "description": "Gets or sets the server id.", + "nullable": true + } + }, + "additionalProperties": false, + "description": "A class representing an authentication result." + }, + "BackupManifestDto": { + "type": "object", + "properties": { + "ServerVersion": { + "type": "string", + "description": "Gets or sets the jellyfin version this backup was created with." + }, + "BackupEngineVersion": { + "type": "string", + "description": "Gets or sets the backup engine version this backup was created with." + }, + "DateCreated": { + "type": "string", + "description": "Gets or sets the date this backup was created with.", + "format": "date-time" + }, + "Path": { + "type": "string", + "description": "Gets or sets the path to the backup on the system." + }, + "Options": { + "allOf": [ + { + "$ref": "#/components/schemas/BackupOptionsDto" + } + ], + "description": "Gets or sets the contents of the backup archive." + } + }, + "additionalProperties": false, + "description": "Manifest type for backups internal structure." + }, + "BackupOptionsDto": { + "type": "object", + "properties": { + "Metadata": { + "type": "boolean", + "description": "Gets or sets a value indicating whether the archive contains the Metadata contents." + }, + "Trickplay": { + "type": "boolean", + "description": "Gets or sets a value indicating whether the archive contains the Trickplay contents." + }, + "Subtitles": { + "type": "boolean", + "description": "Gets or sets a value indicating whether the archive contains the Subtitle contents." + }, + "Database": { + "type": "boolean", + "description": "Gets or sets a value indicating whether the archive contains the Database contents." + } + }, + "additionalProperties": false, + "description": "Defines the optional contents of the backup archive." + }, + "BackupRestoreRequestDto": { + "type": "object", + "properties": { + "ArchiveFileName": { + "type": "string", + "description": "Gets or Sets the name of the backup archive to restore from. Must be present in MediaBrowser.Common.Configuration.IApplicationPaths.BackupPath." + } + }, + "additionalProperties": false, + "description": "Defines properties used to start a restore process." + }, + "BaseItemDto": { + "type": "object", + "properties": { + "Name": { + "type": "string", + "description": "Gets or sets the name.", + "nullable": true + }, + "OriginalTitle": { + "type": "string", + "nullable": true + }, + "ServerId": { + "type": "string", + "description": "Gets or sets the server identifier.", + "nullable": true + }, + "Id": { + "type": "string", + "description": "Gets or sets the id.", + "format": "uuid" + }, + "Etag": { + "type": "string", + "description": "Gets or sets the etag.", + "nullable": true + }, + "SourceType": { + "type": "string", + "description": "Gets or sets the type of the source.", + "nullable": true + }, + "PlaylistItemId": { + "type": "string", + "description": "Gets or sets the playlist item identifier.", + "nullable": true + }, + "DateCreated": { + "type": "string", + "description": "Gets or sets the date created.", + "format": "date-time", + "nullable": true + }, + "DateLastMediaAdded": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "ExtraType": { + "enum": [ + "Unknown", + "Clip", + "Trailer", + "BehindTheScenes", + "DeletedScene", + "Interview", + "Scene", + "Sample", + "ThemeSong", + "ThemeVideo", + "Featurette", + "Short" + ], + "allOf": [ + { + "$ref": "#/components/schemas/ExtraType" + } + ], + "nullable": true + }, + "AirsBeforeSeasonNumber": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "AirsAfterSeasonNumber": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "AirsBeforeEpisodeNumber": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "CanDelete": { + "type": "boolean", + "nullable": true + }, + "CanDownload": { + "type": "boolean", + "nullable": true + }, + "HasLyrics": { + "type": "boolean", + "nullable": true + }, + "HasSubtitles": { + "type": "boolean", + "nullable": true + }, + "PreferredMetadataLanguage": { + "type": "string", + "nullable": true + }, + "PreferredMetadataCountryCode": { + "type": "string", + "nullable": true + }, + "Container": { + "type": "string", + "nullable": true + }, + "SortName": { + "type": "string", + "description": "Gets or sets the name of the sort.", + "nullable": true + }, + "ForcedSortName": { + "type": "string", + "nullable": true + }, + "Video3DFormat": { + "enum": [ + "HalfSideBySide", + "FullSideBySide", + "FullTopAndBottom", + "HalfTopAndBottom", + "MVC" + ], + "allOf": [ + { + "$ref": "#/components/schemas/Video3DFormat" + } + ], + "description": "Gets or sets the video3 D format.", + "nullable": true + }, + "PremiereDate": { + "type": "string", + "description": "Gets or sets the premiere date.", + "format": "date-time", + "nullable": true + }, + "ExternalUrls": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ExternalUrl" + }, + "description": "Gets or sets the external urls.", + "nullable": true + }, + "MediaSources": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MediaSourceInfo" + }, + "description": "Gets or sets the media versions.", + "nullable": true + }, + "CriticRating": { + "type": "number", + "description": "Gets or sets the critic rating.", + "format": "float", + "nullable": true + }, + "ProductionLocations": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "Path": { + "type": "string", + "description": "Gets or sets the path.", + "nullable": true + }, + "EnableMediaSourceDisplay": { + "type": "boolean", + "nullable": true + }, + "OfficialRating": { + "type": "string", + "description": "Gets or sets the official rating.", + "nullable": true + }, + "CustomRating": { + "type": "string", + "description": "Gets or sets the custom rating.", + "nullable": true + }, + "ChannelId": { + "type": "string", + "description": "Gets or sets the channel identifier.", + "format": "uuid", + "nullable": true + }, + "ChannelName": { + "type": "string", + "nullable": true + }, + "Overview": { + "type": "string", + "description": "Gets or sets the overview.", + "nullable": true + }, + "Taglines": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Gets or sets the taglines.", + "nullable": true + }, + "Genres": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Gets or sets the genres.", + "nullable": true + }, + "CommunityRating": { + "type": "number", + "description": "Gets or sets the community rating.", + "format": "float", + "nullable": true + }, + "CumulativeRunTimeTicks": { + "type": "integer", + "description": "Gets or sets the cumulative run time ticks.", + "format": "int64", + "nullable": true + }, + "RunTimeTicks": { + "type": "integer", + "description": "Gets or sets the run time ticks.", + "format": "int64", + "nullable": true + }, + "PlayAccess": { + "enum": [ + "Full", + "None" + ], + "allOf": [ + { + "$ref": "#/components/schemas/PlayAccess" + } + ], + "description": "Gets or sets the play access.", + "nullable": true + }, + "AspectRatio": { + "type": "string", + "description": "Gets or sets the aspect ratio.", + "nullable": true + }, + "ProductionYear": { + "type": "integer", + "description": "Gets or sets the production year.", + "format": "int32", + "nullable": true + }, + "IsPlaceHolder": { + "type": "boolean", + "description": "Gets or sets a value indicating whether this instance is place holder.", + "nullable": true + }, + "Number": { + "type": "string", + "description": "Gets or sets the number.", + "nullable": true + }, + "ChannelNumber": { + "type": "string", + "nullable": true + }, + "IndexNumber": { + "type": "integer", + "description": "Gets or sets the index number.", + "format": "int32", + "nullable": true + }, + "IndexNumberEnd": { + "type": "integer", + "description": "Gets or sets the index number end.", + "format": "int32", + "nullable": true + }, + "ParentIndexNumber": { + "type": "integer", + "description": "Gets or sets the parent index number.", + "format": "int32", + "nullable": true + }, + "RemoteTrailers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MediaUrl" + }, + "description": "Gets or sets the trailer urls.", + "nullable": true + }, + "ProviderIds": { + "type": "object", + "additionalProperties": { + "type": "string", + "nullable": true + }, + "description": "Gets or sets the provider ids.", + "nullable": true + }, + "IsHD": { + "type": "boolean", + "description": "Gets or sets a value indicating whether this instance is HD.", + "nullable": true + }, + "IsFolder": { + "type": "boolean", + "description": "Gets or sets a value indicating whether this instance is folder.", + "nullable": true + }, + "ParentId": { + "type": "string", + "description": "Gets or sets the parent id.", + "format": "uuid", + "nullable": true + }, + "Type": { + "enum": [ + "AggregateFolder", + "Audio", + "AudioBook", + "BasePluginFolder", + "Book", + "BoxSet", + "Channel", + "ChannelFolderItem", + "CollectionFolder", + "Episode", + "Folder", + "Genre", + "ManualPlaylistsFolder", + "Movie", + "LiveTvChannel", + "LiveTvProgram", + "MusicAlbum", + "MusicArtist", + "MusicGenre", + "MusicVideo", + "Person", + "Photo", + "PhotoAlbum", + "Playlist", + "PlaylistsFolder", + "Program", + "Recording", + "Season", + "Series", + "Studio", + "Trailer", + "TvChannel", + "TvProgram", + "UserRootFolder", + "UserView", + "Video", + "Year" + ], + "allOf": [ + { + "$ref": "#/components/schemas/BaseItemKind" + } + ], + "description": "Gets or sets the type." + }, + "People": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BaseItemPerson" + }, + "description": "Gets or sets the people.", + "nullable": true + }, + "Studios": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NameGuidPair" + }, + "description": "Gets or sets the studios.", + "nullable": true + }, + "GenreItems": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NameGuidPair" + }, + "nullable": true + }, + "ParentLogoItemId": { + "type": "string", + "description": "Gets or sets whether the item has a logo, this will hold the Id of the Parent that has one.", + "format": "uuid", + "nullable": true + }, + "ParentBackdropItemId": { + "type": "string", + "description": "Gets or sets whether the item has any backdrops, this will hold the Id of the Parent that has one.", + "format": "uuid", + "nullable": true + }, + "ParentBackdropImageTags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Gets or sets the parent backdrop image tags.", + "nullable": true + }, + "LocalTrailerCount": { + "type": "integer", + "description": "Gets or sets the local trailer count.", + "format": "int32", + "nullable": true + }, + "UserData": { + "allOf": [ + { + "$ref": "#/components/schemas/UserItemDataDto" + } + ], + "description": "Gets or sets the user data for this item based on the user it's being requested for.", + "nullable": true + }, + "RecursiveItemCount": { + "type": "integer", + "description": "Gets or sets the recursive item count.", + "format": "int32", + "nullable": true + }, + "ChildCount": { + "type": "integer", + "description": "Gets or sets the child count.", + "format": "int32", + "nullable": true + }, + "SeriesName": { + "type": "string", + "description": "Gets or sets the name of the series.", + "nullable": true + }, + "SeriesId": { + "type": "string", + "description": "Gets or sets the series id.", + "format": "uuid", + "nullable": true + }, + "SeasonId": { + "type": "string", + "description": "Gets or sets the season identifier.", + "format": "uuid", + "nullable": true + }, + "SpecialFeatureCount": { + "type": "integer", + "description": "Gets or sets the special feature count.", + "format": "int32", + "nullable": true + }, + "DisplayPreferencesId": { + "type": "string", + "description": "Gets or sets the display preferences id.", + "nullable": true + }, + "Status": { + "type": "string", + "description": "Gets or sets the status.", + "nullable": true + }, + "AirTime": { + "type": "string", + "description": "Gets or sets the air time.", + "nullable": true + }, + "AirDays": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DayOfWeek" + }, + "description": "Gets or sets the air days.", + "nullable": true + }, + "Tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Gets or sets the tags.", + "nullable": true + }, + "PrimaryImageAspectRatio": { + "type": "number", + "description": "Gets or sets the primary image aspect ratio, after image enhancements.", + "format": "double", + "nullable": true + }, + "Artists": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Gets or sets the artists.", + "nullable": true + }, + "ArtistItems": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NameGuidPair" + }, + "description": "Gets or sets the artist items.", + "nullable": true + }, + "Album": { + "type": "string", + "description": "Gets or sets the album.", + "nullable": true + }, + "CollectionType": { + "enum": [ + "unknown", + "movies", + "tvshows", + "music", + "musicvideos", + "trailers", + "homevideos", + "boxsets", + "books", + "photos", + "livetv", + "playlists", + "folders" + ], + "allOf": [ + { + "$ref": "#/components/schemas/CollectionType" + } + ], + "description": "Gets or sets the type of the collection.", + "nullable": true + }, + "DisplayOrder": { + "type": "string", + "description": "Gets or sets the display order.", + "nullable": true + }, + "AlbumId": { + "type": "string", + "description": "Gets or sets the album id.", + "format": "uuid", + "nullable": true + }, + "AlbumPrimaryImageTag": { + "type": "string", + "description": "Gets or sets the album image tag.", + "nullable": true + }, + "SeriesPrimaryImageTag": { + "type": "string", + "description": "Gets or sets the series primary image tag.", + "nullable": true + }, + "AlbumArtist": { + "type": "string", + "description": "Gets or sets the album artist.", + "nullable": true + }, + "AlbumArtists": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NameGuidPair" + }, + "description": "Gets or sets the album artists.", + "nullable": true + }, + "SeasonName": { + "type": "string", + "description": "Gets or sets the name of the season.", + "nullable": true + }, + "MediaStreams": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MediaStream" + }, + "description": "Gets or sets the media streams.", + "nullable": true + }, + "VideoType": { + "enum": [ + "VideoFile", + "Iso", + "Dvd", + "BluRay" + ], + "allOf": [ + { + "$ref": "#/components/schemas/VideoType" + } + ], + "description": "Gets or sets the type of the video.", + "nullable": true + }, + "PartCount": { + "type": "integer", + "description": "Gets or sets the part count.", + "format": "int32", + "nullable": true + }, + "MediaSourceCount": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "ImageTags": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "Gets or sets the image tags.", + "nullable": true + }, + "BackdropImageTags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Gets or sets the backdrop image tags.", + "nullable": true + }, + "ScreenshotImageTags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Gets or sets the screenshot image tags.", + "nullable": true + }, + "ParentLogoImageTag": { + "type": "string", + "description": "Gets or sets the parent logo image tag.", + "nullable": true + }, + "ParentArtItemId": { + "type": "string", + "description": "Gets or sets whether the item has fan art, this will hold the Id of the Parent that has one.", + "format": "uuid", + "nullable": true + }, + "ParentArtImageTag": { + "type": "string", + "description": "Gets or sets the parent art image tag.", + "nullable": true + }, + "SeriesThumbImageTag": { + "type": "string", + "description": "Gets or sets the series thumb image tag.", + "nullable": true + }, + "ImageBlurHashes": { + "type": "object", + "properties": { + "Primary": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "Art": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "Backdrop": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "Banner": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "Logo": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "Thumb": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "Disc": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "Box": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "Screenshot": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "Menu": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "Chapter": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "BoxRear": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "Profile": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "description": "Gets or sets the blurhashes for the image tags.\r\nMaps image type to dictionary mapping image tag to blurhash value.", + "nullable": true + }, + "SeriesStudio": { + "type": "string", + "description": "Gets or sets the series studio.", + "nullable": true + }, + "ParentThumbItemId": { + "type": "string", + "description": "Gets or sets the parent thumb item id.", + "format": "uuid", + "nullable": true + }, + "ParentThumbImageTag": { + "type": "string", + "description": "Gets or sets the parent thumb image tag.", + "nullable": true + }, + "ParentPrimaryImageItemId": { + "type": "string", + "description": "Gets or sets the parent primary image item identifier.", + "format": "uuid", + "nullable": true + }, + "ParentPrimaryImageTag": { + "type": "string", + "description": "Gets or sets the parent primary image tag.", + "nullable": true + }, + "Chapters": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ChapterInfo" + }, + "description": "Gets or sets the chapters.", + "nullable": true + }, + "Trickplay": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/TrickplayInfoDto" + } + }, + "description": "Gets or sets the trickplay manifest.", + "nullable": true + }, + "LocationType": { + "enum": [ + "FileSystem", + "Remote", + "Virtual", + "Offline" + ], + "allOf": [ + { + "$ref": "#/components/schemas/LocationType" + } + ], + "description": "Gets or sets the type of the location.", + "nullable": true + }, + "IsoType": { + "enum": [ + "Dvd", + "BluRay" + ], + "allOf": [ + { + "$ref": "#/components/schemas/IsoType" + } + ], + "description": "Gets or sets the type of the iso.", + "nullable": true + }, + "MediaType": { + "enum": [ + "Unknown", + "Video", + "Audio", + "Photo", + "Book" + ], + "allOf": [ + { + "$ref": "#/components/schemas/MediaType" + } + ], + "description": "Gets or sets the type of the media.", + "default": "Unknown" + }, + "EndDate": { + "type": "string", + "description": "Gets or sets the end date.", + "format": "date-time", + "nullable": true + }, + "LockedFields": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MetadataField" + }, + "description": "Gets or sets the locked fields.", + "nullable": true + }, + "TrailerCount": { + "type": "integer", + "description": "Gets or sets the trailer count.", + "format": "int32", + "nullable": true + }, + "MovieCount": { + "type": "integer", + "description": "Gets or sets the movie count.", + "format": "int32", + "nullable": true + }, + "SeriesCount": { + "type": "integer", + "description": "Gets or sets the series count.", + "format": "int32", + "nullable": true + }, + "ProgramCount": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "EpisodeCount": { + "type": "integer", + "description": "Gets or sets the episode count.", + "format": "int32", + "nullable": true + }, + "SongCount": { + "type": "integer", + "description": "Gets or sets the song count.", + "format": "int32", + "nullable": true + }, + "AlbumCount": { + "type": "integer", + "description": "Gets or sets the album count.", + "format": "int32", + "nullable": true + }, + "ArtistCount": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "MusicVideoCount": { + "type": "integer", + "description": "Gets or sets the music video count.", + "format": "int32", + "nullable": true + }, + "LockData": { + "type": "boolean", + "description": "Gets or sets a value indicating whether [enable internet providers].", + "nullable": true + }, + "Width": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "Height": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "CameraMake": { + "type": "string", + "nullable": true + }, + "CameraModel": { + "type": "string", + "nullable": true + }, + "Software": { + "type": "string", + "nullable": true + }, + "ExposureTime": { + "type": "number", + "format": "double", + "nullable": true + }, + "FocalLength": { + "type": "number", + "format": "double", + "nullable": true + }, + "ImageOrientation": { + "enum": [ + "TopLeft", + "TopRight", + "BottomRight", + "BottomLeft", + "LeftTop", + "RightTop", + "RightBottom", + "LeftBottom" + ], + "allOf": [ + { + "$ref": "#/components/schemas/ImageOrientation" + } + ], + "nullable": true + }, + "Aperture": { + "type": "number", + "format": "double", + "nullable": true + }, + "ShutterSpeed": { + "type": "number", + "format": "double", + "nullable": true + }, + "Latitude": { + "type": "number", + "format": "double", + "nullable": true + }, + "Longitude": { + "type": "number", + "format": "double", + "nullable": true + }, + "Altitude": { + "type": "number", + "format": "double", + "nullable": true + }, + "IsoSpeedRating": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "SeriesTimerId": { + "type": "string", + "description": "Gets or sets the series timer identifier.", + "nullable": true + }, + "ProgramId": { + "type": "string", + "description": "Gets or sets the program identifier.", + "nullable": true + }, + "ChannelPrimaryImageTag": { + "type": "string", + "description": "Gets or sets the channel primary image tag.", + "nullable": true + }, + "StartDate": { + "type": "string", + "description": "Gets or sets the start date of the recording, in UTC.", + "format": "date-time", + "nullable": true + }, + "CompletionPercentage": { + "type": "number", + "description": "Gets or sets the completion percentage.", + "format": "double", + "nullable": true + }, + "IsRepeat": { + "type": "boolean", + "description": "Gets or sets a value indicating whether this instance is repeat.", + "nullable": true + }, + "EpisodeTitle": { + "type": "string", + "description": "Gets or sets the episode title.", + "nullable": true + }, + "ChannelType": { + "enum": [ + "TV", + "Radio" + ], + "allOf": [ + { + "$ref": "#/components/schemas/ChannelType" + } + ], + "description": "Gets or sets the type of the channel.", + "nullable": true + }, + "Audio": { + "enum": [ + "Mono", + "Stereo", + "Dolby", + "DolbyDigital", + "Thx", + "Atmos" + ], + "allOf": [ + { + "$ref": "#/components/schemas/ProgramAudio" + } + ], + "description": "Gets or sets the audio.", + "nullable": true + }, + "IsMovie": { + "type": "boolean", + "description": "Gets or sets a value indicating whether this instance is movie.", + "nullable": true + }, + "IsSports": { + "type": "boolean", + "description": "Gets or sets a value indicating whether this instance is sports.", + "nullable": true + }, + "IsSeries": { + "type": "boolean", + "description": "Gets or sets a value indicating whether this instance is series.", + "nullable": true + }, + "IsLive": { + "type": "boolean", + "description": "Gets or sets a value indicating whether this instance is live.", + "nullable": true + }, + "IsNews": { + "type": "boolean", + "description": "Gets or sets a value indicating whether this instance is news.", + "nullable": true + }, + "IsKids": { + "type": "boolean", + "description": "Gets or sets a value indicating whether this instance is kids.", + "nullable": true + }, + "IsPremiere": { + "type": "boolean", + "description": "Gets or sets a value indicating whether this instance is premiere.", + "nullable": true + }, + "TimerId": { + "type": "string", + "description": "Gets or sets the timer identifier.", + "nullable": true + }, + "NormalizationGain": { + "type": "number", + "description": "Gets or sets the gain required for audio normalization.", + "format": "float", + "nullable": true + }, + "CurrentProgram": { + "allOf": [ + { + "$ref": "#/components/schemas/BaseItemDto" + } + ], + "description": "Gets or sets the current program.", + "nullable": true + } + }, + "additionalProperties": false, + "description": "This is strictly used as a data transfer object from the api layer.\r\nThis holds information about a BaseItem in a format that is convenient for the client." + }, + "BaseItemDtoQueryResult": { + "type": "object", + "properties": { + "Items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BaseItemDto" + }, + "description": "Gets or sets the items." + }, + "TotalRecordCount": { + "type": "integer", + "description": "Gets or sets the total number of records available.", + "format": "int32" + }, + "StartIndex": { + "type": "integer", + "description": "Gets or sets the index of the first record in Items.", + "format": "int32" + } + }, + "additionalProperties": false, + "description": "Query result container." + }, + "BaseItemKind": { + "enum": [ + "AggregateFolder", + "Audio", + "AudioBook", + "BasePluginFolder", + "Book", + "BoxSet", + "Channel", + "ChannelFolderItem", + "CollectionFolder", + "Episode", + "Folder", + "Genre", + "ManualPlaylistsFolder", + "Movie", + "LiveTvChannel", + "LiveTvProgram", + "MusicAlbum", + "MusicArtist", + "MusicGenre", + "MusicVideo", + "Person", + "Photo", + "PhotoAlbum", + "Playlist", + "PlaylistsFolder", + "Program", + "Recording", + "Season", + "Series", + "Studio", + "Trailer", + "TvChannel", + "TvProgram", + "UserRootFolder", + "UserView", + "Video", + "Year" + ], + "type": "string", + "description": "The base item kind." + }, + "BaseItemPerson": { + "type": "object", + "properties": { + "Name": { + "type": "string", + "description": "Gets or sets the name.", + "nullable": true + }, + "Id": { + "type": "string", + "description": "Gets or sets the identifier.", + "format": "uuid" + }, + "Role": { + "type": "string", + "description": "Gets or sets the role.", + "nullable": true + }, + "Type": { + "enum": [ + "Unknown", + "Actor", + "Director", + "Composer", + "Writer", + "GuestStar", + "Producer", + "Conductor", + "Lyricist", + "Arranger", + "Engineer", + "Mixer", + "Remixer", + "Creator", + "Artist", + "AlbumArtist", + "Author", + "Illustrator", + "Penciller", + "Inker", + "Colorist", + "Letterer", + "CoverArtist", + "Editor", + "Translator" + ], + "allOf": [ + { + "$ref": "#/components/schemas/PersonKind" + } + ], + "description": "Gets or sets the type.", + "default": "Unknown" + }, + "PrimaryImageTag": { + "type": "string", + "description": "Gets or sets the primary image tag.", + "nullable": true + }, + "ImageBlurHashes": { + "type": "object", + "properties": { + "Primary": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "Art": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "Backdrop": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "Banner": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "Logo": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "Thumb": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "Disc": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "Box": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "Screenshot": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "Menu": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "Chapter": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "BoxRear": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "Profile": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "description": "Gets or sets the primary image blurhash.", + "nullable": true + } + }, + "additionalProperties": false, + "description": "This is used by the api to get information about a Person within a BaseItem." + }, + "BasePluginConfiguration": { + "type": "object", + "additionalProperties": false, + "description": "Class BasePluginConfiguration." + }, + "BookInfo": { + "type": "object", + "properties": { + "Name": { + "type": "string", + "description": "Gets or sets the name.", + "nullable": true + }, + "OriginalTitle": { + "type": "string", + "description": "Gets or sets the original title.", + "nullable": true + }, + "Path": { + "type": "string", + "description": "Gets or sets the path.", + "nullable": true + }, + "MetadataLanguage": { + "type": "string", + "description": "Gets or sets the metadata language.", + "nullable": true + }, + "MetadataCountryCode": { + "type": "string", + "description": "Gets or sets the metadata country code.", + "nullable": true + }, + "ProviderIds": { + "type": "object", + "additionalProperties": { + "type": "string", + "nullable": true + }, + "description": "Gets or sets the provider ids.", + "nullable": true + }, + "Year": { + "type": "integer", + "description": "Gets or sets the year.", + "format": "int32", + "nullable": true + }, + "IndexNumber": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "ParentIndexNumber": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "PremiereDate": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "IsAutomated": { + "type": "boolean" + }, + "SeriesName": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, + "BookInfoRemoteSearchQuery": { + "type": "object", + "properties": { + "SearchInfo": { + "allOf": [ + { + "$ref": "#/components/schemas/BookInfo" + } + ], + "nullable": true + }, + "ItemId": { + "type": "string", + "format": "uuid" + }, + "SearchProviderName": { + "type": "string", + "description": "Gets or sets the provider name to search within if set.", + "nullable": true + }, + "IncludeDisabledProviders": { + "type": "boolean", + "description": "Gets or sets a value indicating whether disabled providers should be included." + } + }, + "additionalProperties": false + }, + "BoxSetInfo": { + "type": "object", + "properties": { + "Name": { + "type": "string", + "description": "Gets or sets the name.", + "nullable": true + }, + "OriginalTitle": { + "type": "string", + "description": "Gets or sets the original title.", + "nullable": true + }, + "Path": { + "type": "string", + "description": "Gets or sets the path.", + "nullable": true + }, + "MetadataLanguage": { + "type": "string", + "description": "Gets or sets the metadata language.", + "nullable": true + }, + "MetadataCountryCode": { + "type": "string", + "description": "Gets or sets the metadata country code.", + "nullable": true + }, + "ProviderIds": { + "type": "object", + "additionalProperties": { + "type": "string", + "nullable": true + }, + "description": "Gets or sets the provider ids.", + "nullable": true + }, + "Year": { + "type": "integer", + "description": "Gets or sets the year.", + "format": "int32", + "nullable": true + }, + "IndexNumber": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "ParentIndexNumber": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "PremiereDate": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "IsAutomated": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "BoxSetInfoRemoteSearchQuery": { + "type": "object", + "properties": { + "SearchInfo": { + "allOf": [ + { + "$ref": "#/components/schemas/BoxSetInfo" + } + ], + "nullable": true + }, + "ItemId": { + "type": "string", + "format": "uuid" + }, + "SearchProviderName": { + "type": "string", + "description": "Gets or sets the provider name to search within if set.", + "nullable": true + }, + "IncludeDisabledProviders": { + "type": "boolean", + "description": "Gets or sets a value indicating whether disabled providers should be included." + } + }, + "additionalProperties": false + }, + "BrandingOptionsDto": { + "type": "object", + "properties": { + "LoginDisclaimer": { + "type": "string", + "description": "Gets or sets the login disclaimer.", + "nullable": true + }, + "CustomCss": { + "type": "string", + "description": "Gets or sets the custom CSS.", + "nullable": true + }, + "SplashscreenEnabled": { + "type": "boolean", + "description": "Gets or sets a value indicating whether to enable the splashscreen." + } + }, + "additionalProperties": false, + "description": "The branding options DTO for API use.\r\nThis DTO excludes SplashscreenLocation to prevent it from being updated via API." + }, + "BufferRequestDto": { + "type": "object", + "properties": { + "When": { + "type": "string", + "description": "Gets or sets when the request has been made by the client.", + "format": "date-time" + }, + "PositionTicks": { + "type": "integer", + "description": "Gets or sets the position ticks.", + "format": "int64" + }, + "IsPlaying": { + "type": "boolean", + "description": "Gets or sets a value indicating whether the client playback is unpaused." + }, + "PlaylistItemId": { + "type": "string", + "description": "Gets or sets the playlist item identifier of the playing item.", + "format": "uuid" + } + }, + "additionalProperties": false, + "description": "Class BufferRequestDto." + }, + "CastReceiverApplication": { + "type": "object", + "properties": { + "Id": { + "type": "string", + "description": "Gets or sets the cast receiver application id." + }, + "Name": { + "type": "string", + "description": "Gets or sets the cast receiver application name." + } + }, + "additionalProperties": false, + "description": "The cast receiver application model." + }, + "ChannelFeatures": { + "type": "object", + "properties": { + "Name": { + "type": "string", + "description": "Gets or sets the name." + }, + "Id": { + "type": "string", + "description": "Gets or sets the identifier.", + "format": "uuid" + }, + "CanSearch": { + "type": "boolean", + "description": "Gets or sets a value indicating whether this instance can search." + }, + "MediaTypes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ChannelMediaType" + }, + "description": "Gets or sets the media types." + }, + "ContentTypes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ChannelMediaContentType" + }, + "description": "Gets or sets the content types." + }, + "MaxPageSize": { + "type": "integer", + "description": "Gets or sets the maximum number of records the channel allows retrieving at a time.", + "format": "int32", + "nullable": true + }, + "AutoRefreshLevels": { + "type": "integer", + "description": "Gets or sets the automatic refresh levels.", + "format": "int32", + "nullable": true + }, + "DefaultSortFields": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ChannelItemSortField" + }, + "description": "Gets or sets the default sort orders." + }, + "SupportsSortOrderToggle": { + "type": "boolean", + "description": "Gets or sets a value indicating whether a sort ascending/descending toggle is supported." + }, + "SupportsLatestMedia": { + "type": "boolean", + "description": "Gets or sets a value indicating whether [supports latest media]." + }, + "CanFilter": { + "type": "boolean", + "description": "Gets or sets a value indicating whether this instance can filter." + }, + "SupportsContentDownloading": { + "type": "boolean", + "description": "Gets or sets a value indicating whether [supports content downloading]." + } + }, + "additionalProperties": false + }, + "ChannelItemSortField": { + "enum": [ + "Name", + "CommunityRating", + "PremiereDate", + "DateCreated", + "Runtime", + "PlayCount", + "CommunityPlayCount" + ], + "type": "string" + }, + "ChannelMappingOptionsDto": { + "type": "object", + "properties": { + "TunerChannels": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TunerChannelMapping" + }, + "description": "Gets or sets list of tuner channels." + }, + "ProviderChannels": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NameIdPair" + }, + "description": "Gets or sets list of provider channels." + }, + "Mappings": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NameValuePair" + }, + "description": "Gets or sets list of mappings." + }, + "ProviderName": { + "type": "string", + "description": "Gets or sets provider name.", + "nullable": true + } + }, + "additionalProperties": false, + "description": "Channel mapping options dto." + }, + "ChannelMediaContentType": { + "enum": [ + "Clip", + "Podcast", + "Trailer", + "Movie", + "Episode", + "Song", + "MovieExtra", + "TvExtra" + ], + "type": "string" + }, + "ChannelMediaType": { + "enum": [ + "Audio", + "Video", + "Photo" + ], + "type": "string" + }, + "ChannelType": { + "enum": [ + "TV", + "Radio" + ], + "type": "string", + "description": "Enum ChannelType." + }, + "ChapterInfo": { + "type": "object", + "properties": { + "StartPositionTicks": { + "type": "integer", + "description": "Gets or sets the start position ticks.", + "format": "int64" + }, + "Name": { + "type": "string", + "description": "Gets or sets the name.", + "nullable": true + }, + "ImagePath": { + "type": "string", + "description": "Gets or sets the image path.", + "nullable": true + }, + "ImageDateModified": { + "type": "string", + "format": "date-time" + }, + "ImageTag": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false, + "description": "Class ChapterInfo." + }, + "ClientCapabilitiesDto": { + "type": "object", + "properties": { + "PlayableMediaTypes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MediaType" + }, + "description": "Gets or sets the list of playable media types." + }, + "SupportedCommands": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GeneralCommandType" + }, + "description": "Gets or sets the list of supported commands." + }, + "SupportsMediaControl": { + "type": "boolean", + "description": "Gets or sets a value indicating whether session supports media control." + }, + "SupportsPersistentIdentifier": { + "type": "boolean", + "description": "Gets or sets a value indicating whether session supports a persistent identifier." + }, + "DeviceProfile": { + "allOf": [ + { + "$ref": "#/components/schemas/DeviceProfile" + } + ], + "description": "Gets or sets the device profile.", + "nullable": true + }, + "AppStoreUrl": { + "type": "string", + "description": "Gets or sets the app store url.", + "nullable": true + }, + "IconUrl": { + "type": "string", + "description": "Gets or sets the icon url.", + "nullable": true + } + }, + "additionalProperties": false, + "description": "Client capabilities dto." + }, + "ClientLogDocumentResponseDto": { + "type": "object", + "properties": { + "FileName": { + "type": "string", + "description": "Gets the resulting filename." + } + }, + "additionalProperties": false, + "description": "Client log document response dto." + }, + "CodecProfile": { + "type": "object", + "properties": { + "Type": { + "enum": [ + "Video", + "VideoAudio", + "Audio" + ], + "allOf": [ + { + "$ref": "#/components/schemas/CodecType" + } + ], + "description": "Gets or sets the MediaBrowser.Model.Dlna.CodecType which this container must meet." + }, + "Conditions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProfileCondition" + }, + "description": "Gets or sets the list of MediaBrowser.Model.Dlna.ProfileCondition which this profile must meet." + }, + "ApplyConditions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProfileCondition" + }, + "description": "Gets or sets the list of MediaBrowser.Model.Dlna.ProfileCondition to apply if this profile is met." + }, + "Codec": { + "type": "string", + "description": "Gets or sets the codec(s) that this profile applies to.", + "nullable": true + }, + "Container": { + "type": "string", + "description": "Gets or sets the container(s) which this profile will be applied to.", + "nullable": true + }, + "SubContainer": { + "type": "string", + "description": "Gets or sets the sub-container(s) which this profile will be applied to.", + "nullable": true + } + }, + "additionalProperties": false, + "description": "Defines the MediaBrowser.Model.Dlna.CodecProfile." + }, + "CodecType": { + "enum": [ + "Video", + "VideoAudio", + "Audio" + ], + "type": "string" + }, + "CollectionCreationResult": { + "type": "object", + "properties": { + "Id": { + "type": "string", + "format": "uuid" + } + }, + "additionalProperties": false + }, + "CollectionType": { + "enum": [ + "unknown", + "movies", + "tvshows", + "music", + "musicvideos", + "trailers", + "homevideos", + "boxsets", + "books", + "photos", + "livetv", + "playlists", + "folders" + ], + "type": "string", + "description": "Collection type." + }, + "CollectionTypeOptions": { + "enum": [ + "movies", + "tvshows", + "music", + "musicvideos", + "homevideos", + "boxsets", + "books", + "mixed" + ], + "type": "string", + "description": "The collection type options." + }, + "ConfigImageTypes": { + "type": "object", + "properties": { + "BackdropSizes": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "BaseUrl": { + "type": "string", + "nullable": true + }, + "LogoSizes": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "PosterSizes": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "ProfileSizes": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "SecureBaseUrl": { + "type": "string", + "nullable": true + }, + "StillSizes": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + } + }, + "additionalProperties": false + }, + "ConfigurationPageInfo": { + "type": "object", + "properties": { + "Name": { + "type": "string", + "description": "Gets or sets the name." + }, + "EnableInMainMenu": { + "type": "boolean", + "description": "Gets or sets a value indicating whether the configurations page is enabled in the main menu." + }, + "MenuSection": { + "type": "string", + "description": "Gets or sets the menu section.", + "nullable": true + }, + "MenuIcon": { + "type": "string", + "description": "Gets or sets the menu icon.", + "nullable": true + }, + "DisplayName": { + "type": "string", + "description": "Gets or sets the display name.", + "nullable": true + }, + "PluginId": { + "type": "string", + "description": "Gets or sets the plugin id.", + "format": "uuid", + "nullable": true + } + }, + "additionalProperties": false, + "description": "The configuration page info." + }, + "ContainerProfile": { + "type": "object", + "properties": { + "Type": { + "enum": [ + "Audio", + "Video", + "Photo", + "Subtitle", + "Lyric" + ], + "allOf": [ + { + "$ref": "#/components/schemas/DlnaProfileType" + } + ], + "description": "Gets or sets the MediaBrowser.Model.Dlna.DlnaProfileType which this container must meet." + }, + "Conditions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProfileCondition" + }, + "description": "Gets or sets the list of MediaBrowser.Model.Dlna.ProfileCondition which this container will be applied to." + }, + "Container": { + "type": "string", + "description": "Gets or sets the container(s) which this container must meet.", + "nullable": true + }, + "SubContainer": { + "type": "string", + "description": "Gets or sets the sub container(s) which this container must meet.", + "nullable": true + } + }, + "additionalProperties": false, + "description": "Defines the MediaBrowser.Model.Dlna.ContainerProfile." + }, + "CountryInfo": { + "type": "object", + "properties": { + "Name": { + "type": "string", + "description": "Gets or sets the name.", + "nullable": true + }, + "DisplayName": { + "type": "string", + "description": "Gets or sets the display name.", + "nullable": true + }, + "TwoLetterISORegionName": { + "type": "string", + "description": "Gets or sets the name of the two letter ISO region.", + "nullable": true + }, + "ThreeLetterISORegionName": { + "type": "string", + "description": "Gets or sets the name of the three letter ISO region.", + "nullable": true + } + }, + "additionalProperties": false, + "description": "Class CountryInfo." + }, + "CreatePlaylistDto": { + "type": "object", + "properties": { + "Name": { + "type": "string", + "description": "Gets or sets the name of the new playlist." + }, + "Ids": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "description": "Gets or sets item ids to add to the playlist." + }, + "UserId": { + "type": "string", + "description": "Gets or sets the user id.", + "format": "uuid", + "nullable": true + }, + "MediaType": { + "enum": [ + "Unknown", + "Video", + "Audio", + "Photo", + "Book" + ], + "allOf": [ + { + "$ref": "#/components/schemas/MediaType" + } + ], + "description": "Gets or sets the media type.", + "nullable": true + }, + "Users": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PlaylistUserPermissions" + }, + "description": "Gets or sets the playlist users." + }, + "IsPublic": { + "type": "boolean", + "description": "Gets or sets a value indicating whether the playlist is public." + } + }, + "additionalProperties": false, + "description": "Create new playlist dto." + }, + "CreateUserByName": { + "required": [ + "Name" + ], + "type": "object", + "properties": { + "Name": { + "type": "string", + "description": "Gets or sets the username." + }, + "Password": { + "type": "string", + "description": "Gets or sets the password.", + "nullable": true + } + }, + "additionalProperties": false, + "description": "The create user by name request body." + }, + "CultureDto": { + "type": "object", + "properties": { + "Name": { + "type": "string", + "description": "Gets the name." + }, + "DisplayName": { + "type": "string", + "description": "Gets the display name." + }, + "TwoLetterISOLanguageName": { + "type": "string", + "description": "Gets the name of the two letter ISO language." + }, + "ThreeLetterISOLanguageName": { + "type": "string", + "description": "Gets the name of the three letter ISO language.", + "nullable": true, + "readOnly": true + }, + "ThreeLetterISOLanguageNames": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false, + "description": "Class CultureDto." + }, + "CustomDatabaseOption": { + "type": "object", + "properties": { + "Key": { + "type": "string", + "description": "Gets or sets the key of the value." + }, + "Value": { + "type": "string", + "description": "Gets or sets the value." + } + }, + "additionalProperties": false, + "description": "The custom value option for custom database providers." + }, + "CustomDatabaseOptions": { + "type": "object", + "properties": { + "PluginName": { + "type": "string", + "description": "Gets or sets the Plugin name to search for database providers." + }, + "PluginAssembly": { + "type": "string", + "description": "Gets or sets the plugin assembly to search for providers." + }, + "ConnectionString": { + "type": "string", + "description": "Gets or sets the connection string for the custom database provider." + }, + "Options": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CustomDatabaseOption" + }, + "description": "Gets or sets the list of extra options for the custom provider." + } + }, + "additionalProperties": false, + "description": "Defines the options for a custom database connector." + }, + "DatabaseConfigurationOptions": { + "type": "object", + "properties": { + "DatabaseType": { + "type": "string", + "description": "Gets or Sets the type of database jellyfin should use." + }, + "CustomProviderOptions": { + "allOf": [ + { + "$ref": "#/components/schemas/CustomDatabaseOptions" + } + ], + "description": "Gets or sets the options required to use a custom database provider.", + "nullable": true + }, + "LockingBehavior": { + "enum": [ + "NoLock", + "Pessimistic", + "Optimistic" + ], + "allOf": [ + { + "$ref": "#/components/schemas/DatabaseLockingBehaviorTypes" + } + ], + "description": "Gets or Sets the kind of locking behavior jellyfin should perform. Possible options are \"NoLock\", \"Pessimistic\", \"Optimistic\".\r\nDefaults to \"NoLock\"." + } + }, + "additionalProperties": false, + "description": "Options to configure jellyfins managed database." + }, + "DatabaseLockingBehaviorTypes": { + "enum": [ + "NoLock", + "Pessimistic", + "Optimistic" + ], + "type": "string", + "description": "Defines all possible methods for locking database access for concurrent queries." + }, + "DayOfWeek": { + "enum": [ + "Sunday", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday" + ], + "type": "string" + }, + "DayPattern": { + "enum": [ + "Daily", + "Weekdays", + "Weekends" + ], + "type": "string" + }, + "DefaultDirectoryBrowserInfoDto": { + "type": "object", + "properties": { + "Path": { + "type": "string", + "description": "Gets or sets the path.", + "nullable": true + } + }, + "additionalProperties": false, + "description": "Default directory browser info." + }, + "DeinterlaceMethod": { + "enum": [ + "yadif", + "bwdif" + ], + "type": "string", + "description": "Enum containing deinterlace methods." + }, + "DeviceInfoDto": { + "type": "object", + "properties": { + "Name": { + "type": "string", + "description": "Gets or sets the name.", + "nullable": true + }, + "CustomName": { + "type": "string", + "description": "Gets or sets the custom name.", + "nullable": true + }, + "AccessToken": { + "type": "string", + "description": "Gets or sets the access token.", + "nullable": true + }, + "Id": { + "type": "string", + "description": "Gets or sets the identifier.", + "nullable": true + }, + "LastUserName": { + "type": "string", + "description": "Gets or sets the last name of the user.", + "nullable": true + }, + "AppName": { + "type": "string", + "description": "Gets or sets the name of the application.", + "nullable": true + }, + "AppVersion": { + "type": "string", + "description": "Gets or sets the application version.", + "nullable": true + }, + "LastUserId": { + "type": "string", + "description": "Gets or sets the last user identifier.", + "format": "uuid", + "nullable": true + }, + "DateLastActivity": { + "type": "string", + "description": "Gets or sets the date last modified.", + "format": "date-time", + "nullable": true + }, + "Capabilities": { + "allOf": [ + { + "$ref": "#/components/schemas/ClientCapabilitiesDto" + } + ], + "description": "Gets or sets the capabilities." + }, + "IconUrl": { + "type": "string", + "description": "Gets or sets the icon URL.", + "nullable": true + } + }, + "additionalProperties": false, + "description": "A DTO representing device information." + }, + "DeviceInfoDtoQueryResult": { + "type": "object", + "properties": { + "Items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DeviceInfoDto" + }, + "description": "Gets or sets the items." + }, + "TotalRecordCount": { + "type": "integer", + "description": "Gets or sets the total number of records available.", + "format": "int32" + }, + "StartIndex": { + "type": "integer", + "description": "Gets or sets the index of the first record in Items.", + "format": "int32" + } + }, + "additionalProperties": false, + "description": "Query result container." + }, + "DeviceOptionsDto": { + "type": "object", + "properties": { + "Id": { + "type": "integer", + "description": "Gets or sets the id.", + "format": "int32" + }, + "DeviceId": { + "type": "string", + "description": "Gets or sets the device id.", + "nullable": true + }, + "CustomName": { + "type": "string", + "description": "Gets or sets the custom name.", + "nullable": true + } + }, + "additionalProperties": false, + "description": "A dto representing custom options for a device." + }, + "DeviceProfile": { + "type": "object", + "properties": { + "Name": { + "type": "string", + "description": "Gets or sets the name of this device profile. User profiles must have a unique name.", + "nullable": true + }, + "Id": { + "type": "string", + "description": "Gets or sets the unique internal identifier.", + "format": "uuid", + "nullable": true + }, + "MaxStreamingBitrate": { + "type": "integer", + "description": "Gets or sets the maximum allowed bitrate for all streamed content.", + "format": "int32", + "nullable": true + }, + "MaxStaticBitrate": { + "type": "integer", + "description": "Gets or sets the maximum allowed bitrate for statically streamed content (= direct played files).", + "format": "int32", + "nullable": true + }, + "MusicStreamingTranscodingBitrate": { + "type": "integer", + "description": "Gets or sets the maximum allowed bitrate for transcoded music streams.", + "format": "int32", + "nullable": true + }, + "MaxStaticMusicBitrate": { + "type": "integer", + "description": "Gets or sets the maximum allowed bitrate for statically streamed (= direct played) music files.", + "format": "int32", + "nullable": true + }, + "DirectPlayProfiles": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DirectPlayProfile" + }, + "description": "Gets or sets the direct play profiles." + }, + "TranscodingProfiles": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TranscodingProfile" + }, + "description": "Gets or sets the transcoding profiles." + }, + "ContainerProfiles": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ContainerProfile" + }, + "description": "Gets or sets the container profiles. Failing to meet these optional conditions causes transcoding to occur." + }, + "CodecProfiles": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CodecProfile" + }, + "description": "Gets or sets the codec profiles." + }, + "SubtitleProfiles": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SubtitleProfile" + }, + "description": "Gets or sets the subtitle profiles." + } + }, + "additionalProperties": false, + "description": "A MediaBrowser.Model.Dlna.DeviceProfile represents a set of metadata which determines which content a certain device is able to play.\r\n
\r\nSpecifically, it defines the supported containers and\r\ncodecs (video and/or audio, including codec profiles and levels)\r\nthe device is able to direct play (without transcoding or remuxing),\r\nas well as which containers/codecs to transcode to in case it isn't." + }, + "DirectPlayProfile": { + "type": "object", + "properties": { + "Container": { + "type": "string", + "description": "Gets or sets the container." + }, + "AudioCodec": { + "type": "string", + "description": "Gets or sets the audio codec.", + "nullable": true + }, + "VideoCodec": { + "type": "string", + "description": "Gets or sets the video codec.", + "nullable": true + }, + "Type": { + "enum": [ + "Audio", + "Video", + "Photo", + "Subtitle", + "Lyric" + ], + "allOf": [ + { + "$ref": "#/components/schemas/DlnaProfileType" + } + ], + "description": "Gets or sets the Dlna profile type." + } + }, + "additionalProperties": false, + "description": "Defines the MediaBrowser.Model.Dlna.DirectPlayProfile." + }, + "DisplayPreferencesDto": { + "type": "object", + "properties": { + "Id": { + "type": "string", + "description": "Gets or sets the user id.", + "nullable": true + }, + "ViewType": { + "type": "string", + "description": "Gets or sets the type of the view.", + "nullable": true + }, + "SortBy": { + "type": "string", + "description": "Gets or sets the sort by.", + "nullable": true + }, + "IndexBy": { + "type": "string", + "description": "Gets or sets the index by.", + "nullable": true + }, + "RememberIndexing": { + "type": "boolean", + "description": "Gets or sets a value indicating whether [remember indexing]." + }, + "PrimaryImageHeight": { + "type": "integer", + "description": "Gets or sets the height of the primary image.", + "format": "int32" + }, + "PrimaryImageWidth": { + "type": "integer", + "description": "Gets or sets the width of the primary image.", + "format": "int32" + }, + "CustomPrefs": { + "type": "object", + "additionalProperties": { + "type": "string", + "nullable": true + }, + "description": "Gets or sets the custom prefs." + }, + "ScrollDirection": { + "enum": [ + "Horizontal", + "Vertical" + ], + "allOf": [ + { + "$ref": "#/components/schemas/ScrollDirection" + } + ], + "description": "Gets or sets the scroll direction." + }, + "ShowBackdrop": { + "type": "boolean", + "description": "Gets or sets a value indicating whether to show backdrops on this item." + }, + "RememberSorting": { + "type": "boolean", + "description": "Gets or sets a value indicating whether [remember sorting]." + }, + "SortOrder": { + "enum": [ + "Ascending", + "Descending" + ], + "allOf": [ + { + "$ref": "#/components/schemas/SortOrder" + } + ], + "description": "Gets or sets the sort order." + }, + "ShowSidebar": { + "type": "boolean", + "description": "Gets or sets a value indicating whether [show sidebar]." + }, + "Client": { + "type": "string", + "description": "Gets or sets the client.", + "nullable": true + } + }, + "additionalProperties": false, + "description": "Defines the display preferences for any item that supports them (usually Folders)." + }, + "DlnaProfileType": { + "enum": [ + "Audio", + "Video", + "Photo", + "Subtitle", + "Lyric" + ], + "type": "string" + }, + "DownMixStereoAlgorithms": { + "enum": [ + "None", + "Dave750", + "NightmodeDialogue", + "Rfc7845", + "Ac4" + ], + "type": "string", + "description": "An enum representing an algorithm to downmix surround sound to stereo." + }, + "DynamicDayOfWeek": { + "enum": [ + "Sunday", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + "Everyday", + "Weekday", + "Weekend" + ], + "type": "string", + "description": "An enum that represents a day of the week, weekdays, weekends, or all days." + }, + "EmbeddedSubtitleOptions": { + "enum": [ + "AllowAll", + "AllowText", + "AllowImage", + "AllowNone" + ], + "type": "string", + "description": "An enum representing the options to disable embedded subs." + }, + "EncoderPreset": { + "enum": [ + "auto", + "placebo", + "veryslow", + "slower", + "slow", + "medium", + "fast", + "faster", + "veryfast", + "superfast", + "ultrafast" + ], + "type": "string", + "description": "Enum containing encoder presets." + }, + "EncodingContext": { + "enum": [ + "Streaming", + "Static" + ], + "type": "string" + }, + "EncodingOptions": { + "type": "object", + "properties": { + "EncodingThreadCount": { + "type": "integer", + "description": "Gets or sets the thread count used for encoding.", + "format": "int32" + }, + "TranscodingTempPath": { + "type": "string", + "description": "Gets or sets the temporary transcoding path.", + "nullable": true + }, + "FallbackFontPath": { + "type": "string", + "description": "Gets or sets the path to the fallback font.", + "nullable": true + }, + "EnableFallbackFont": { + "type": "boolean", + "description": "Gets or sets a value indicating whether to use the fallback font." + }, + "EnableAudioVbr": { + "type": "boolean", + "description": "Gets or sets a value indicating whether audio VBR is enabled." + }, + "DownMixAudioBoost": { + "type": "number", + "description": "Gets or sets the audio boost applied when downmixing audio.", + "format": "double" + }, + "DownMixStereoAlgorithm": { + "enum": [ + "None", + "Dave750", + "NightmodeDialogue", + "Rfc7845", + "Ac4" + ], + "allOf": [ + { + "$ref": "#/components/schemas/DownMixStereoAlgorithms" + } + ], + "description": "Gets or sets the algorithm used for downmixing audio to stereo." + }, + "MaxMuxingQueueSize": { + "type": "integer", + "description": "Gets or sets the maximum size of the muxing queue.", + "format": "int32" + }, + "EnableThrottling": { + "type": "boolean", + "description": "Gets or sets a value indicating whether throttling is enabled." + }, + "ThrottleDelaySeconds": { + "type": "integer", + "description": "Gets or sets the delay after which throttling happens.", + "format": "int32" + }, + "EnableSegmentDeletion": { + "type": "boolean", + "description": "Gets or sets a value indicating whether segment deletion is enabled." + }, + "SegmentKeepSeconds": { + "type": "integer", + "description": "Gets or sets seconds for which segments should be kept before being deleted.", + "format": "int32" + }, + "HardwareAccelerationType": { + "enum": [ + "none", + "amf", + "qsv", + "nvenc", + "v4l2m2m", + "vaapi", + "videotoolbox", + "rkmpp" + ], + "allOf": [ + { + "$ref": "#/components/schemas/HardwareAccelerationType" + } + ], + "description": "Gets or sets the hardware acceleration type." + }, + "EncoderAppPath": { + "type": "string", + "description": "Gets or sets the FFmpeg path as set by the user via the UI.", + "nullable": true + }, + "EncoderAppPathDisplay": { + "type": "string", + "description": "Gets or sets the current FFmpeg path being used by the system and displayed on the transcode page.", + "nullable": true + }, + "VaapiDevice": { + "type": "string", + "description": "Gets or sets the VA-API device.", + "nullable": true + }, + "QsvDevice": { + "type": "string", + "description": "Gets or sets the QSV device.", + "nullable": true + }, + "EnableTonemapping": { + "type": "boolean", + "description": "Gets or sets a value indicating whether tonemapping is enabled." + }, + "EnableVppTonemapping": { + "type": "boolean", + "description": "Gets or sets a value indicating whether VPP tonemapping is enabled." + }, + "EnableVideoToolboxTonemapping": { + "type": "boolean", + "description": "Gets or sets a value indicating whether videotoolbox tonemapping is enabled." + }, + "TonemappingAlgorithm": { + "enum": [ + "none", + "clip", + "linear", + "gamma", + "reinhard", + "hable", + "mobius", + "bt2390" + ], + "allOf": [ + { + "$ref": "#/components/schemas/TonemappingAlgorithm" + } + ], + "description": "Gets or sets the tone-mapping algorithm." + }, + "TonemappingMode": { + "enum": [ + "auto", + "max", + "rgb", + "lum", + "itp" + ], + "allOf": [ + { + "$ref": "#/components/schemas/TonemappingMode" + } + ], + "description": "Gets or sets the tone-mapping mode." + }, + "TonemappingRange": { + "enum": [ + "auto", + "tv", + "pc" + ], + "allOf": [ + { + "$ref": "#/components/schemas/TonemappingRange" + } + ], + "description": "Gets or sets the tone-mapping range." + }, + "TonemappingDesat": { + "type": "number", + "description": "Gets or sets the tone-mapping desaturation.", + "format": "double" + }, + "TonemappingPeak": { + "type": "number", + "description": "Gets or sets the tone-mapping peak.", + "format": "double" + }, + "TonemappingParam": { + "type": "number", + "description": "Gets or sets the tone-mapping parameters.", + "format": "double" + }, + "VppTonemappingBrightness": { + "type": "number", + "description": "Gets or sets the VPP tone-mapping brightness.", + "format": "double" + }, + "VppTonemappingContrast": { + "type": "number", + "description": "Gets or sets the VPP tone-mapping contrast.", + "format": "double" + }, + "H264Crf": { + "type": "integer", + "description": "Gets or sets the H264 CRF.", + "format": "int32" + }, + "H265Crf": { + "type": "integer", + "description": "Gets or sets the H265 CRF.", + "format": "int32" + }, + "EncoderPreset": { + "enum": [ + "auto", + "placebo", + "veryslow", + "slower", + "slow", + "medium", + "fast", + "faster", + "veryfast", + "superfast", + "ultrafast" + ], + "allOf": [ + { + "$ref": "#/components/schemas/EncoderPreset" + } + ], + "description": "Gets or sets the encoder preset.", + "nullable": true + }, + "DeinterlaceDoubleRate": { + "type": "boolean", + "description": "Gets or sets a value indicating whether the framerate is doubled when deinterlacing." + }, + "DeinterlaceMethod": { + "enum": [ + "yadif", + "bwdif" + ], + "allOf": [ + { + "$ref": "#/components/schemas/DeinterlaceMethod" + } + ], + "description": "Gets or sets the deinterlace method." + }, + "EnableDecodingColorDepth10Hevc": { + "type": "boolean", + "description": "Gets or sets a value indicating whether 10bit HEVC decoding is enabled." + }, + "EnableDecodingColorDepth10Vp9": { + "type": "boolean", + "description": "Gets or sets a value indicating whether 10bit VP9 decoding is enabled." + }, + "EnableDecodingColorDepth10HevcRext": { + "type": "boolean", + "description": "Gets or sets a value indicating whether 8/10bit HEVC RExt decoding is enabled." + }, + "EnableDecodingColorDepth12HevcRext": { + "type": "boolean", + "description": "Gets or sets a value indicating whether 12bit HEVC RExt decoding is enabled." + }, + "EnableEnhancedNvdecDecoder": { + "type": "boolean", + "description": "Gets or sets a value indicating whether the enhanced NVDEC is enabled." + }, + "PreferSystemNativeHwDecoder": { + "type": "boolean", + "description": "Gets or sets a value indicating whether the system native hardware decoder should be used." + }, + "EnableIntelLowPowerH264HwEncoder": { + "type": "boolean", + "description": "Gets or sets a value indicating whether the Intel H264 low-power hardware encoder should be used." + }, + "EnableIntelLowPowerHevcHwEncoder": { + "type": "boolean", + "description": "Gets or sets a value indicating whether the Intel HEVC low-power hardware encoder should be used." + }, + "EnableHardwareEncoding": { + "type": "boolean", + "description": "Gets or sets a value indicating whether hardware encoding is enabled." + }, + "AllowHevcEncoding": { + "type": "boolean", + "description": "Gets or sets a value indicating whether HEVC encoding is enabled." + }, + "AllowAv1Encoding": { + "type": "boolean", + "description": "Gets or sets a value indicating whether AV1 encoding is enabled." + }, + "EnableSubtitleExtraction": { + "type": "boolean", + "description": "Gets or sets a value indicating whether subtitle extraction is enabled." + }, + "HardwareDecodingCodecs": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Gets or sets the codecs hardware encoding is used for.", + "nullable": true + }, + "AllowOnDemandMetadataBasedKeyframeExtractionForExtensions": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Gets or sets the file extensions on-demand metadata based keyframe extraction is enabled for.", + "nullable": true + } + }, + "additionalProperties": false, + "description": "Class EncodingOptions." + }, + "EndPointInfo": { + "type": "object", + "properties": { + "IsLocal": { + "type": "boolean" + }, + "IsInNetwork": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "ExternalIdInfo": { + "type": "object", + "properties": { + "Name": { + "type": "string", + "description": "Gets or sets the display name of the external id provider (IE: IMDB, MusicBrainz, etc)." + }, + "Key": { + "type": "string", + "description": "Gets or sets the unique key for this id. This key should be unique across all providers." + }, + "Type": { + "enum": [ + "Album", + "AlbumArtist", + "Artist", + "BoxSet", + "Episode", + "Movie", + "OtherArtist", + "Person", + "ReleaseGroup", + "Season", + "Series", + "Track", + "Book", + "Recording" + ], + "allOf": [ + { + "$ref": "#/components/schemas/ExternalIdMediaType" + } + ], + "description": "Gets or sets the specific media type for this id. This is used to distinguish between the different\r\nexternal id types for providers with multiple ids.\r\nA null value indicates there is no specific media type associated with the external id, or this is the\r\ndefault id for the external provider so there is no need to specify a type.", + "nullable": true + } + }, + "additionalProperties": false, + "description": "Represents the external id information for serialization to the client." + }, + "ExternalIdMediaType": { + "enum": [ + "Album", + "AlbumArtist", + "Artist", + "BoxSet", + "Episode", + "Movie", + "OtherArtist", + "Person", + "ReleaseGroup", + "Season", + "Series", + "Track", + "Book", + "Recording" + ], + "type": "string", + "description": "The specific media type of an MediaBrowser.Model.Providers.ExternalIdInfo." + }, + "ExternalUrl": { + "type": "object", + "properties": { + "Name": { + "type": "string", + "description": "Gets or sets the name.", + "nullable": true + }, + "Url": { + "type": "string", + "description": "Gets or sets the type of the item.", + "nullable": true + } + }, + "additionalProperties": false + }, + "ExtraType": { + "enum": [ + "Unknown", + "Clip", + "Trailer", + "BehindTheScenes", + "DeletedScene", + "Interview", + "Scene", + "Sample", + "ThemeSong", + "ThemeVideo", + "Featurette", + "Short" + ], + "type": "string" + }, + "FileSystemEntryInfo": { + "type": "object", + "properties": { + "Name": { + "type": "string", + "description": "Gets the name." + }, + "Path": { + "type": "string", + "description": "Gets the path." + }, + "Type": { + "enum": [ + "File", + "Directory", + "NetworkComputer", + "NetworkShare" + ], + "allOf": [ + { + "$ref": "#/components/schemas/FileSystemEntryType" + } + ], + "description": "Gets the type." + } + }, + "additionalProperties": false, + "description": "Class FileSystemEntryInfo." + }, + "FileSystemEntryType": { + "enum": [ + "File", + "Directory", + "NetworkComputer", + "NetworkShare" + ], + "type": "string", + "description": "Enum FileSystemEntryType." + }, + "FolderStorageDto": { + "type": "object", + "properties": { + "Path": { + "type": "string", + "description": "Gets the path of the folder in question." + }, + "FreeSpace": { + "type": "integer", + "description": "Gets the free space of the underlying storage device of the Jellyfin.Api.Models.SystemInfoDtos.FolderStorageDto.Path.", + "format": "int64" + }, + "UsedSpace": { + "type": "integer", + "description": "Gets the used space of the underlying storage device of the Jellyfin.Api.Models.SystemInfoDtos.FolderStorageDto.Path.", + "format": "int64" + }, + "StorageType": { + "type": "string", + "description": "Gets the kind of storage device of the Jellyfin.Api.Models.SystemInfoDtos.FolderStorageDto.Path.", + "nullable": true + }, + "DeviceId": { + "type": "string", + "description": "Gets the Device Identifier.", + "nullable": true + } + }, + "additionalProperties": false, + "description": "Contains information about a specific folder." + }, + "FontFile": { + "type": "object", + "properties": { + "Name": { + "type": "string", + "description": "Gets or sets the name.", + "nullable": true + }, + "Size": { + "type": "integer", + "description": "Gets or sets the size.", + "format": "int64" + }, + "DateCreated": { + "type": "string", + "description": "Gets or sets the date created.", + "format": "date-time" + }, + "DateModified": { + "type": "string", + "description": "Gets or sets the date modified.", + "format": "date-time" + } + }, + "additionalProperties": false, + "description": "Class FontFile." + }, + "ForceKeepAliveMessage": { + "type": "object", + "properties": { + "Data": { + "type": "integer", + "description": "Gets or sets the data.", + "format": "int32" + }, + "MessageId": { + "type": "string", + "description": "Gets or sets the message id.", + "format": "uuid" + }, + "MessageType": { + "enum": [ + "ForceKeepAlive", + "GeneralCommand", + "UserDataChanged", + "Sessions", + "Play", + "SyncPlayCommand", + "SyncPlayGroupUpdate", + "Playstate", + "RestartRequired", + "ServerShuttingDown", + "ServerRestarting", + "LibraryChanged", + "UserDeleted", + "UserUpdated", + "SeriesTimerCreated", + "TimerCreated", + "SeriesTimerCancelled", + "TimerCancelled", + "RefreshProgress", + "ScheduledTaskEnded", + "PackageInstallationCancelled", + "PackageInstallationFailed", + "PackageInstallationCompleted", + "PackageInstalling", + "PackageUninstalled", + "ActivityLogEntry", + "ScheduledTasksInfo", + "ActivityLogEntryStart", + "ActivityLogEntryStop", + "SessionsStart", + "SessionsStop", + "ScheduledTasksInfoStart", + "ScheduledTasksInfoStop", + "KeepAlive" + ], + "allOf": [ + { + "$ref": "#/components/schemas/SessionMessageType" + } + ], + "description": "The different kinds of messages that are used in the WebSocket api.", + "default": "ForceKeepAlive", + "readOnly": true + } + }, + "additionalProperties": false, + "description": "Force keep alive websocket messages." + }, + "ForgotPasswordAction": { + "enum": [ + "ContactAdmin", + "PinCode", + "InNetworkRequired" + ], + "type": "string" + }, + "ForgotPasswordDto": { + "required": [ + "EnteredUsername" + ], + "type": "object", + "properties": { + "EnteredUsername": { + "type": "string", + "description": "Gets or sets the entered username to have its password reset." + } + }, + "additionalProperties": false, + "description": "Forgot Password request body DTO." + }, + "ForgotPasswordPinDto": { + "required": [ + "Pin" + ], + "type": "object", + "properties": { + "Pin": { + "type": "string", + "description": "Gets or sets the entered pin to have the password reset." + } + }, + "additionalProperties": false, + "description": "Forgot Password Pin enter request body DTO." + }, + "ForgotPasswordResult": { + "type": "object", + "properties": { + "Action": { + "enum": [ + "ContactAdmin", + "PinCode", + "InNetworkRequired" + ], + "allOf": [ + { + "$ref": "#/components/schemas/ForgotPasswordAction" + } + ], + "description": "Gets or sets the action." + }, + "PinFile": { + "type": "string", + "description": "Gets or sets the pin file.", + "nullable": true + }, + "PinExpirationDate": { + "type": "string", + "description": "Gets or sets the pin expiration date.", + "format": "date-time", + "nullable": true + } + }, + "additionalProperties": false + }, + "GeneralCommand": { + "type": "object", + "properties": { + "Name": { + "enum": [ + "MoveUp", + "MoveDown", + "MoveLeft", + "MoveRight", + "PageUp", + "PageDown", + "PreviousLetter", + "NextLetter", + "ToggleOsd", + "ToggleContextMenu", + "Select", + "Back", + "TakeScreenshot", + "SendKey", + "SendString", + "GoHome", + "GoToSettings", + "VolumeUp", + "VolumeDown", + "Mute", + "Unmute", + "ToggleMute", + "SetVolume", + "SetAudioStreamIndex", + "SetSubtitleStreamIndex", + "ToggleFullscreen", + "DisplayContent", + "GoToSearch", + "DisplayMessage", + "SetRepeatMode", + "ChannelUp", + "ChannelDown", + "Guide", + "ToggleStats", + "PlayMediaSource", + "PlayTrailers", + "SetShuffleQueue", + "PlayState", + "PlayNext", + "ToggleOsdMenu", + "Play", + "SetMaxStreamingBitrate", + "SetPlaybackOrder" + ], + "allOf": [ + { + "$ref": "#/components/schemas/GeneralCommandType" + } + ], + "description": "This exists simply to identify a set of known commands." + }, + "ControllingUserId": { + "type": "string", + "format": "uuid" + }, + "Arguments": { + "type": "object", + "additionalProperties": { + "type": "string", + "nullable": true + } + } + }, + "additionalProperties": false + }, + "GeneralCommandMessage": { + "type": "object", + "properties": { + "Data": { + "allOf": [ + { + "$ref": "#/components/schemas/GeneralCommand" + } + ], + "description": "Gets or sets the data.", + "nullable": true + }, + "MessageId": { + "type": "string", + "description": "Gets or sets the message id.", + "format": "uuid" + }, + "MessageType": { + "enum": [ + "ForceKeepAlive", + "GeneralCommand", + "UserDataChanged", + "Sessions", + "Play", + "SyncPlayCommand", + "SyncPlayGroupUpdate", + "Playstate", + "RestartRequired", + "ServerShuttingDown", + "ServerRestarting", + "LibraryChanged", + "UserDeleted", + "UserUpdated", + "SeriesTimerCreated", + "TimerCreated", + "SeriesTimerCancelled", + "TimerCancelled", + "RefreshProgress", + "ScheduledTaskEnded", + "PackageInstallationCancelled", + "PackageInstallationFailed", + "PackageInstallationCompleted", + "PackageInstalling", + "PackageUninstalled", + "ActivityLogEntry", + "ScheduledTasksInfo", + "ActivityLogEntryStart", + "ActivityLogEntryStop", + "SessionsStart", + "SessionsStop", + "ScheduledTasksInfoStart", + "ScheduledTasksInfoStop", + "KeepAlive" + ], + "allOf": [ + { + "$ref": "#/components/schemas/SessionMessageType" + } + ], + "description": "The different kinds of messages that are used in the WebSocket api.", + "default": "GeneralCommand", + "readOnly": true + } + }, + "additionalProperties": false, + "description": "General command websocket message." + }, + "GeneralCommandType": { + "enum": [ + "MoveUp", + "MoveDown", + "MoveLeft", + "MoveRight", + "PageUp", + "PageDown", + "PreviousLetter", + "NextLetter", + "ToggleOsd", + "ToggleContextMenu", + "Select", + "Back", + "TakeScreenshot", + "SendKey", + "SendString", + "GoHome", + "GoToSettings", + "VolumeUp", + "VolumeDown", + "Mute", + "Unmute", + "ToggleMute", + "SetVolume", + "SetAudioStreamIndex", + "SetSubtitleStreamIndex", + "ToggleFullscreen", + "DisplayContent", + "GoToSearch", + "DisplayMessage", + "SetRepeatMode", + "ChannelUp", + "ChannelDown", + "Guide", + "ToggleStats", + "PlayMediaSource", + "PlayTrailers", + "SetShuffleQueue", + "PlayState", + "PlayNext", + "ToggleOsdMenu", + "Play", + "SetMaxStreamingBitrate", + "SetPlaybackOrder" + ], + "type": "string", + "description": "This exists simply to identify a set of known commands." + }, + "GetProgramsDto": { + "type": "object", + "properties": { + "ChannelIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "description": "Gets or sets the channels to return guide information for.", + "nullable": true + }, + "UserId": { + "type": "string", + "description": "Gets or sets optional. Filter by user id.", + "format": "uuid", + "nullable": true + }, + "MinStartDate": { + "type": "string", + "description": "Gets or sets the minimum premiere start date.", + "format": "date-time", + "nullable": true + }, + "HasAired": { + "type": "boolean", + "description": "Gets or sets filter by programs that have completed airing, or not.", + "nullable": true + }, + "IsAiring": { + "type": "boolean", + "description": "Gets or sets filter by programs that are currently airing, or not.", + "nullable": true + }, + "MaxStartDate": { + "type": "string", + "description": "Gets or sets the maximum premiere start date.", + "format": "date-time", + "nullable": true + }, + "MinEndDate": { + "type": "string", + "description": "Gets or sets the minimum premiere end date.", + "format": "date-time", + "nullable": true + }, + "MaxEndDate": { + "type": "string", + "description": "Gets or sets the maximum premiere end date.", + "format": "date-time", + "nullable": true + }, + "IsMovie": { + "type": "boolean", + "description": "Gets or sets filter for movies.", + "nullable": true + }, + "IsSeries": { + "type": "boolean", + "description": "Gets or sets filter for series.", + "nullable": true + }, + "IsNews": { + "type": "boolean", + "description": "Gets or sets filter for news.", + "nullable": true + }, + "IsKids": { + "type": "boolean", + "description": "Gets or sets filter for kids.", + "nullable": true + }, + "IsSports": { + "type": "boolean", + "description": "Gets or sets filter for sports.", + "nullable": true + }, + "StartIndex": { + "type": "integer", + "description": "Gets or sets the record index to start at. All items with a lower index will be dropped from the results.", + "format": "int32", + "nullable": true + }, + "Limit": { + "type": "integer", + "description": "Gets or sets the maximum number of records to return.", + "format": "int32", + "nullable": true + }, + "SortBy": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ItemSortBy" + }, + "description": "Gets or sets specify one or more sort orders, comma delimited. Options: Name, StartDate.", + "nullable": true + }, + "SortOrder": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SortOrder" + }, + "description": "Gets or sets sort order.", + "nullable": true + }, + "Genres": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Gets or sets the genres to return guide information for.", + "nullable": true + }, + "GenreIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "description": "Gets or sets the genre ids to return guide information for.", + "nullable": true + }, + "EnableImages": { + "type": "boolean", + "description": "Gets or sets include image information in output.", + "nullable": true + }, + "EnableTotalRecordCount": { + "type": "boolean", + "description": "Gets or sets a value indicating whether retrieve total record count.", + "default": true + }, + "ImageTypeLimit": { + "type": "integer", + "description": "Gets or sets the max number of images to return, per image type.", + "format": "int32", + "nullable": true + }, + "EnableImageTypes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ImageType" + }, + "description": "Gets or sets the image types to include in the output.", + "nullable": true + }, + "EnableUserData": { + "type": "boolean", + "description": "Gets or sets include user data.", + "nullable": true + }, + "SeriesTimerId": { + "type": "string", + "description": "Gets or sets filter by series timer id.", + "nullable": true + }, + "LibrarySeriesId": { + "type": "string", + "description": "Gets or sets filter by library series id.", + "format": "uuid", + "nullable": true + }, + "Fields": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ItemFields" + }, + "description": "Gets or sets specify additional fields of information to return in the output.", + "nullable": true + } + }, + "additionalProperties": false, + "description": "Get programs dto." + }, + "GroupInfoDto": { + "type": "object", + "properties": { + "GroupId": { + "type": "string", + "description": "Gets the group identifier.", + "format": "uuid" + }, + "GroupName": { + "type": "string", + "description": "Gets the group name." + }, + "State": { + "enum": [ + "Idle", + "Waiting", + "Paused", + "Playing" + ], + "allOf": [ + { + "$ref": "#/components/schemas/GroupStateType" + } + ], + "description": "Gets the group state." + }, + "Participants": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Gets the participants." + }, + "LastUpdatedAt": { + "type": "string", + "description": "Gets the date when this DTO has been created.", + "format": "date-time" + } + }, + "additionalProperties": false, + "description": "Class GroupInfoDto." + }, + "GroupQueueMode": { + "enum": [ + "Queue", + "QueueNext" + ], + "type": "string", + "description": "Enum GroupQueueMode." + }, + "GroupRepeatMode": { + "enum": [ + "RepeatOne", + "RepeatAll", + "RepeatNone" + ], + "type": "string", + "description": "Enum GroupRepeatMode." + }, + "GroupShuffleMode": { + "enum": [ + "Sorted", + "Shuffle" + ], + "type": "string", + "description": "Enum GroupShuffleMode." + }, + "GroupStateType": { + "enum": [ + "Idle", + "Waiting", + "Paused", + "Playing" + ], + "type": "string", + "description": "Enum GroupState." + }, + "GroupStateUpdate": { + "type": "object", + "properties": { + "State": { + "enum": [ + "Idle", + "Waiting", + "Paused", + "Playing" + ], + "allOf": [ + { + "$ref": "#/components/schemas/GroupStateType" + } + ], + "description": "Gets the state of the group." + }, + "Reason": { + "enum": [ + "Play", + "SetPlaylistItem", + "RemoveFromPlaylist", + "MovePlaylistItem", + "Queue", + "Unpause", + "Pause", + "Stop", + "Seek", + "Buffer", + "Ready", + "NextItem", + "PreviousItem", + "SetRepeatMode", + "SetShuffleMode", + "Ping", + "IgnoreWait" + ], + "allOf": [ + { + "$ref": "#/components/schemas/PlaybackRequestType" + } + ], + "description": "Gets the reason of the state change." + } + }, + "additionalProperties": false, + "description": "Class GroupStateUpdate." + }, + "GroupUpdate": { + "type": "object", + "oneOf": [ + { + "$ref": "#/components/schemas/SyncPlayGroupDoesNotExistUpdate" + }, + { + "$ref": "#/components/schemas/SyncPlayGroupJoinedUpdate" + }, + { + "$ref": "#/components/schemas/SyncPlayGroupLeftUpdate" + }, + { + "$ref": "#/components/schemas/SyncPlayLibraryAccessDeniedUpdate" + }, + { + "$ref": "#/components/schemas/SyncPlayNotInGroupUpdate" + }, + { + "$ref": "#/components/schemas/SyncPlayPlayQueueUpdate" + }, + { + "$ref": "#/components/schemas/SyncPlayStateUpdate" + }, + { + "$ref": "#/components/schemas/SyncPlayUserJoinedUpdate" + }, + { + "$ref": "#/components/schemas/SyncPlayUserLeftUpdate" + } + ], + "description": "Represents the list of possible group update types", + "discriminator": { + "propertyName": "Type", + "mapping": { + "GroupDoesNotExist": "#/components/schemas/SyncPlayGroupDoesNotExistUpdate", + "GroupJoined": "#/components/schemas/SyncPlayGroupJoinedUpdate", + "GroupLeft": "#/components/schemas/SyncPlayGroupLeftUpdate", + "LibraryAccessDenied": "#/components/schemas/SyncPlayLibraryAccessDeniedUpdate", + "NotInGroup": "#/components/schemas/SyncPlayNotInGroupUpdate", + "PlayQueue": "#/components/schemas/SyncPlayPlayQueueUpdate", + "StateUpdate": "#/components/schemas/SyncPlayStateUpdate", + "UserJoined": "#/components/schemas/SyncPlayUserJoinedUpdate", + "UserLeft": "#/components/schemas/SyncPlayUserLeftUpdate" + } + } + }, + "GroupUpdateType": { + "enum": [ + "UserJoined", + "UserLeft", + "GroupJoined", + "GroupLeft", + "StateUpdate", + "PlayQueue", + "NotInGroup", + "GroupDoesNotExist", + "LibraryAccessDenied" + ], + "type": "string", + "description": "Enum GroupUpdateType." + }, + "GuideInfo": { + "type": "object", + "properties": { + "StartDate": { + "type": "string", + "description": "Gets or sets the start date.", + "format": "date-time" + }, + "EndDate": { + "type": "string", + "description": "Gets or sets the end date.", + "format": "date-time" + } + }, + "additionalProperties": false + }, + "HardwareAccelerationType": { + "enum": [ + "none", + "amf", + "qsv", + "nvenc", + "v4l2m2m", + "vaapi", + "videotoolbox", + "rkmpp" + ], + "type": "string", + "description": "Enum containing hardware acceleration types." + }, + "IgnoreWaitRequestDto": { + "type": "object", + "properties": { + "IgnoreWait": { + "type": "boolean", + "description": "Gets or sets a value indicating whether the client should be ignored." + } + }, + "additionalProperties": false, + "description": "Class IgnoreWaitRequestDto." + }, + "ImageFormat": { + "enum": [ + "Bmp", + "Gif", + "Jpg", + "Png", + "Webp", + "Svg" + ], + "type": "string", + "description": "Enum ImageOutputFormat." + }, + "ImageInfo": { + "type": "object", + "properties": { + "ImageType": { + "enum": [ + "Primary", + "Art", + "Backdrop", + "Banner", + "Logo", + "Thumb", + "Disc", + "Box", + "Screenshot", + "Menu", + "Chapter", + "BoxRear", + "Profile" + ], + "allOf": [ + { + "$ref": "#/components/schemas/ImageType" + } + ], + "description": "Gets or sets the type of the image." + }, + "ImageIndex": { + "type": "integer", + "description": "Gets or sets the index of the image.", + "format": "int32", + "nullable": true + }, + "ImageTag": { + "type": "string", + "description": "Gets or sets the image tag.", + "nullable": true + }, + "Path": { + "type": "string", + "description": "Gets or sets the path.", + "nullable": true + }, + "BlurHash": { + "type": "string", + "description": "Gets or sets the blurhash.", + "nullable": true + }, + "Height": { + "type": "integer", + "description": "Gets or sets the height.", + "format": "int32", + "nullable": true + }, + "Width": { + "type": "integer", + "description": "Gets or sets the width.", + "format": "int32", + "nullable": true + }, + "Size": { + "type": "integer", + "description": "Gets or sets the size.", + "format": "int64" + } + }, + "additionalProperties": false, + "description": "Class ImageInfo." + }, + "ImageOption": { + "type": "object", + "properties": { + "Type": { + "enum": [ + "Primary", + "Art", + "Backdrop", + "Banner", + "Logo", + "Thumb", + "Disc", + "Box", + "Screenshot", + "Menu", + "Chapter", + "BoxRear", + "Profile" + ], + "allOf": [ + { + "$ref": "#/components/schemas/ImageType" + } + ], + "description": "Gets or sets the type." + }, + "Limit": { + "type": "integer", + "description": "Gets or sets the limit.", + "format": "int32" + }, + "MinWidth": { + "type": "integer", + "description": "Gets or sets the minimum width.", + "format": "int32" + } + }, + "additionalProperties": false + }, + "ImageOrientation": { + "enum": [ + "TopLeft", + "TopRight", + "BottomRight", + "BottomLeft", + "LeftTop", + "RightTop", + "RightBottom", + "LeftBottom" + ], + "type": "string" + }, + "ImageProviderInfo": { + "type": "object", + "properties": { + "Name": { + "type": "string", + "description": "Gets the name." + }, + "SupportedImages": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ImageType" + }, + "description": "Gets the supported image types." + } + }, + "additionalProperties": false, + "description": "Class ImageProviderInfo." + }, + "ImageResolution": { + "enum": [ + "MatchSource", + "P144", + "P240", + "P360", + "P480", + "P720", + "P1080", + "P1440", + "P2160" + ], + "type": "string", + "description": "Enum ImageResolution." + }, + "ImageSavingConvention": { + "enum": [ + "Legacy", + "Compatible" + ], + "type": "string" + }, + "ImageType": { + "enum": [ + "Primary", + "Art", + "Backdrop", + "Banner", + "Logo", + "Thumb", + "Disc", + "Box", + "Screenshot", + "Menu", + "Chapter", + "BoxRear", + "Profile" + ], + "type": "string", + "description": "Enum ImageType." + }, + "InboundKeepAliveMessage": { + "type": "object", + "properties": { + "MessageType": { + "enum": [ + "ForceKeepAlive", + "GeneralCommand", + "UserDataChanged", + "Sessions", + "Play", + "SyncPlayCommand", + "SyncPlayGroupUpdate", + "Playstate", + "RestartRequired", + "ServerShuttingDown", + "ServerRestarting", + "LibraryChanged", + "UserDeleted", + "UserUpdated", + "SeriesTimerCreated", + "TimerCreated", + "SeriesTimerCancelled", + "TimerCancelled", + "RefreshProgress", + "ScheduledTaskEnded", + "PackageInstallationCancelled", + "PackageInstallationFailed", + "PackageInstallationCompleted", + "PackageInstalling", + "PackageUninstalled", + "ActivityLogEntry", + "ScheduledTasksInfo", + "ActivityLogEntryStart", + "ActivityLogEntryStop", + "SessionsStart", + "SessionsStop", + "ScheduledTasksInfoStart", + "ScheduledTasksInfoStop", + "KeepAlive" + ], + "allOf": [ + { + "$ref": "#/components/schemas/SessionMessageType" + } + ], + "description": "The different kinds of messages that are used in the WebSocket api.", + "default": "KeepAlive", + "readOnly": true + } + }, + "additionalProperties": false, + "description": "Keep alive websocket messages." + }, + "InboundWebSocketMessage": { + "type": "object", + "oneOf": [ + { + "$ref": "#/components/schemas/ActivityLogEntryStartMessage" + }, + { + "$ref": "#/components/schemas/ActivityLogEntryStopMessage" + }, + { + "$ref": "#/components/schemas/InboundKeepAliveMessage" + }, + { + "$ref": "#/components/schemas/ScheduledTasksInfoStartMessage" + }, + { + "$ref": "#/components/schemas/ScheduledTasksInfoStopMessage" + }, + { + "$ref": "#/components/schemas/SessionsStartMessage" + }, + { + "$ref": "#/components/schemas/SessionsStopMessage" + } + ], + "description": "Represents the list of possible inbound websocket types", + "discriminator": { + "propertyName": "MessageType", + "mapping": { + "ActivityLogEntryStart": "#/components/schemas/ActivityLogEntryStartMessage", + "ActivityLogEntryStop": "#/components/schemas/ActivityLogEntryStopMessage", + "KeepAlive": "#/components/schemas/InboundKeepAliveMessage", + "ScheduledTasksInfoStart": "#/components/schemas/ScheduledTasksInfoStartMessage", + "ScheduledTasksInfoStop": "#/components/schemas/ScheduledTasksInfoStopMessage", + "SessionsStart": "#/components/schemas/SessionsStartMessage", + "SessionsStop": "#/components/schemas/SessionsStopMessage" + } + } + }, + "InstallationInfo": { + "type": "object", + "properties": { + "Guid": { + "type": "string", + "description": "Gets or sets the Id.", + "format": "uuid" + }, + "Name": { + "type": "string", + "description": "Gets or sets the name.", + "nullable": true + }, + "Version": { + "type": "string", + "description": "Gets or sets the version.", + "nullable": true + }, + "Changelog": { + "type": "string", + "description": "Gets or sets the changelog for this version.", + "nullable": true + }, + "SourceUrl": { + "type": "string", + "description": "Gets or sets the source URL.", + "nullable": true + }, + "Checksum": { + "type": "string", + "description": "Gets or sets a checksum for the binary.", + "nullable": true + }, + "PackageInfo": { + "allOf": [ + { + "$ref": "#/components/schemas/PackageInfo" + } + ], + "description": "Gets or sets package information for the installation.", + "nullable": true + } + }, + "additionalProperties": false, + "description": "Class InstallationInfo." + }, + "IPlugin": { + "type": "object", + "properties": { + "Name": { + "type": "string", + "description": "Gets the name of the plugin.", + "nullable": true, + "readOnly": true + }, + "Description": { + "type": "string", + "description": "Gets the Description.", + "nullable": true, + "readOnly": true + }, + "Id": { + "type": "string", + "description": "Gets the unique id.", + "format": "uuid", + "readOnly": true + }, + "Version": { + "type": "string", + "description": "Gets the plugin version.", + "nullable": true, + "readOnly": true + }, + "AssemblyFilePath": { + "type": "string", + "description": "Gets the path to the assembly file.", + "nullable": true, + "readOnly": true + }, + "CanUninstall": { + "type": "boolean", + "description": "Gets a value indicating whether the plugin can be uninstalled.", + "readOnly": true + }, + "DataFolderPath": { + "type": "string", + "description": "Gets the full path to the data folder, where the plugin can store any miscellaneous files needed.", + "nullable": true, + "readOnly": true + } + }, + "additionalProperties": false, + "description": "Defines the MediaBrowser.Common.Plugins.IPlugin." + }, + "IsoType": { + "enum": [ + "Dvd", + "BluRay" + ], + "type": "string", + "description": "Enum IsoType." + }, + "ItemCounts": { + "type": "object", + "properties": { + "MovieCount": { + "type": "integer", + "description": "Gets or sets the movie count.", + "format": "int32" + }, + "SeriesCount": { + "type": "integer", + "description": "Gets or sets the series count.", + "format": "int32" + }, + "EpisodeCount": { + "type": "integer", + "description": "Gets or sets the episode count.", + "format": "int32" + }, + "ArtistCount": { + "type": "integer", + "description": "Gets or sets the artist count.", + "format": "int32" + }, + "ProgramCount": { + "type": "integer", + "description": "Gets or sets the program count.", + "format": "int32" + }, + "TrailerCount": { + "type": "integer", + "description": "Gets or sets the trailer count.", + "format": "int32" + }, + "SongCount": { + "type": "integer", + "description": "Gets or sets the song count.", + "format": "int32" + }, + "AlbumCount": { + "type": "integer", + "description": "Gets or sets the album count.", + "format": "int32" + }, + "MusicVideoCount": { + "type": "integer", + "description": "Gets or sets the music video count.", + "format": "int32" + }, + "BoxSetCount": { + "type": "integer", + "description": "Gets or sets the box set count.", + "format": "int32" + }, + "BookCount": { + "type": "integer", + "description": "Gets or sets the book count.", + "format": "int32" + }, + "ItemCount": { + "type": "integer", + "description": "Gets or sets the item count.", + "format": "int32" + } + }, + "additionalProperties": false, + "description": "Class LibrarySummary." + }, + "ItemFields": { + "enum": [ + "AirTime", + "CanDelete", + "CanDownload", + "ChannelInfo", + "Chapters", + "Trickplay", + "ChildCount", + "CumulativeRunTimeTicks", + "CustomRating", + "DateCreated", + "DateLastMediaAdded", + "DisplayPreferencesId", + "Etag", + "ExternalUrls", + "Genres", + "ItemCounts", + "MediaSourceCount", + "MediaSources", + "OriginalTitle", + "Overview", + "ParentId", + "Path", + "People", + "PlayAccess", + "ProductionLocations", + "ProviderIds", + "PrimaryImageAspectRatio", + "RecursiveItemCount", + "Settings", + "SeriesStudio", + "SortName", + "SpecialEpisodeNumbers", + "Studios", + "Taglines", + "Tags", + "RemoteTrailers", + "MediaStreams", + "SeasonUserData", + "DateLastRefreshed", + "DateLastSaved", + "RefreshState", + "ChannelImage", + "EnableMediaSourceDisplay", + "Width", + "Height", + "ExtraIds", + "LocalTrailerCount", + "IsHD", + "SpecialFeatureCount" + ], + "type": "string", + "description": "Used to control the data that gets attached to DtoBaseItems." + }, + "ItemFilter": { + "enum": [ + "IsFolder", + "IsNotFolder", + "IsUnplayed", + "IsPlayed", + "IsFavorite", + "IsResumable", + "Likes", + "Dislikes", + "IsFavoriteOrLikes" + ], + "type": "string", + "description": "Enum ItemFilter." + }, + "ItemSortBy": { + "enum": [ + "Default", + "AiredEpisodeOrder", + "Album", + "AlbumArtist", + "Artist", + "DateCreated", + "OfficialRating", + "DatePlayed", + "PremiereDate", + "StartDate", + "SortName", + "Name", + "Random", + "Runtime", + "CommunityRating", + "ProductionYear", + "PlayCount", + "CriticRating", + "IsFolder", + "IsUnplayed", + "IsPlayed", + "SeriesSortName", + "VideoBitRate", + "AirTime", + "Studio", + "IsFavoriteOrLiked", + "DateLastContentAdded", + "SeriesDatePlayed", + "ParentIndexNumber", + "IndexNumber" + ], + "type": "string", + "description": "These represent sort orders." + }, + "JoinGroupRequestDto": { + "type": "object", + "properties": { + "GroupId": { + "type": "string", + "description": "Gets or sets the group identifier.", + "format": "uuid" + } + }, + "additionalProperties": false, + "description": "Class JoinGroupRequestDto." + }, + "KeepUntil": { + "enum": [ + "UntilDeleted", + "UntilSpaceNeeded", + "UntilWatched", + "UntilDate" + ], + "type": "string" + }, + "LibraryChangedMessage": { + "type": "object", + "properties": { + "Data": { + "allOf": [ + { + "$ref": "#/components/schemas/LibraryUpdateInfo" + } + ], + "description": "Class LibraryUpdateInfo.", + "nullable": true + }, + "MessageId": { + "type": "string", + "description": "Gets or sets the message id.", + "format": "uuid" + }, + "MessageType": { + "enum": [ + "ForceKeepAlive", + "GeneralCommand", + "UserDataChanged", + "Sessions", + "Play", + "SyncPlayCommand", + "SyncPlayGroupUpdate", + "Playstate", + "RestartRequired", + "ServerShuttingDown", + "ServerRestarting", + "LibraryChanged", + "UserDeleted", + "UserUpdated", + "SeriesTimerCreated", + "TimerCreated", + "SeriesTimerCancelled", + "TimerCancelled", + "RefreshProgress", + "ScheduledTaskEnded", + "PackageInstallationCancelled", + "PackageInstallationFailed", + "PackageInstallationCompleted", + "PackageInstalling", + "PackageUninstalled", + "ActivityLogEntry", + "ScheduledTasksInfo", + "ActivityLogEntryStart", + "ActivityLogEntryStop", + "SessionsStart", + "SessionsStop", + "ScheduledTasksInfoStart", + "ScheduledTasksInfoStop", + "KeepAlive" + ], + "allOf": [ + { + "$ref": "#/components/schemas/SessionMessageType" + } + ], + "description": "The different kinds of messages that are used in the WebSocket api.", + "default": "LibraryChanged", + "readOnly": true + } + }, + "additionalProperties": false, + "description": "Library changed message." + }, + "LibraryOptionInfoDto": { + "type": "object", + "properties": { + "Name": { + "type": "string", + "description": "Gets or sets name.", + "nullable": true + }, + "DefaultEnabled": { + "type": "boolean", + "description": "Gets or sets a value indicating whether default enabled." + } + }, + "additionalProperties": false, + "description": "Library option info dto." + }, + "LibraryOptions": { + "type": "object", + "properties": { + "Enabled": { + "type": "boolean" + }, + "EnablePhotos": { + "type": "boolean" + }, + "EnableRealtimeMonitor": { + "type": "boolean" + }, + "EnableLUFSScan": { + "type": "boolean" + }, + "EnableChapterImageExtraction": { + "type": "boolean" + }, + "ExtractChapterImagesDuringLibraryScan": { + "type": "boolean" + }, + "EnableTrickplayImageExtraction": { + "type": "boolean" + }, + "ExtractTrickplayImagesDuringLibraryScan": { + "type": "boolean" + }, + "PathInfos": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MediaPathInfo" + } + }, + "SaveLocalMetadata": { + "type": "boolean" + }, + "EnableInternetProviders": { + "type": "boolean", + "deprecated": true + }, + "EnableAutomaticSeriesGrouping": { + "type": "boolean" + }, + "EnableEmbeddedTitles": { + "type": "boolean" + }, + "EnableEmbeddedExtrasTitles": { + "type": "boolean" + }, + "EnableEmbeddedEpisodeInfos": { + "type": "boolean" + }, + "AutomaticRefreshIntervalDays": { + "type": "integer", + "format": "int32" + }, + "PreferredMetadataLanguage": { + "type": "string", + "description": "Gets or sets the preferred metadata language.", + "nullable": true + }, + "MetadataCountryCode": { + "type": "string", + "description": "Gets or sets the metadata country code.", + "nullable": true + }, + "SeasonZeroDisplayName": { + "type": "string" + }, + "MetadataSavers": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "DisabledLocalMetadataReaders": { + "type": "array", + "items": { + "type": "string" + } + }, + "LocalMetadataReaderOrder": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "DisabledSubtitleFetchers": { + "type": "array", + "items": { + "type": "string" + } + }, + "SubtitleFetcherOrder": { + "type": "array", + "items": { + "type": "string" + } + }, + "DisabledMediaSegmentProviders": { + "type": "array", + "items": { + "type": "string" + } + }, + "MediaSegmentProviderOrder": { + "type": "array", + "items": { + "type": "string" + } + }, + "SkipSubtitlesIfEmbeddedSubtitlesPresent": { + "type": "boolean" + }, + "SkipSubtitlesIfAudioTrackMatches": { + "type": "boolean" + }, + "SubtitleDownloadLanguages": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "RequirePerfectSubtitleMatch": { + "type": "boolean" + }, + "SaveSubtitlesWithMedia": { + "type": "boolean" + }, + "SaveLyricsWithMedia": { + "type": "boolean", + "default": false + }, + "SaveTrickplayWithMedia": { + "type": "boolean", + "default": false + }, + "DisabledLyricFetchers": { + "type": "array", + "items": { + "type": "string" + } + }, + "LyricFetcherOrder": { + "type": "array", + "items": { + "type": "string" + } + }, + "PreferNonstandardArtistsTag": { + "type": "boolean", + "default": false + }, + "UseCustomTagDelimiters": { + "type": "boolean", + "default": false + }, + "CustomTagDelimiters": { + "type": "array", + "items": { + "type": "string" + } + }, + "DelimiterWhitelist": { + "type": "array", + "items": { + "type": "string" + } + }, + "AutomaticallyAddToCollection": { + "type": "boolean" + }, + "AllowEmbeddedSubtitles": { + "enum": [ + "AllowAll", + "AllowText", + "AllowImage", + "AllowNone" + ], + "allOf": [ + { + "$ref": "#/components/schemas/EmbeddedSubtitleOptions" + } + ], + "description": "An enum representing the options to disable embedded subs." + }, + "TypeOptions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TypeOptions" + } + } + }, + "additionalProperties": false + }, + "LibraryOptionsResultDto": { + "type": "object", + "properties": { + "MetadataSavers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LibraryOptionInfoDto" + }, + "description": "Gets or sets the metadata savers." + }, + "MetadataReaders": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LibraryOptionInfoDto" + }, + "description": "Gets or sets the metadata readers." + }, + "SubtitleFetchers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LibraryOptionInfoDto" + }, + "description": "Gets or sets the subtitle fetchers." + }, + "LyricFetchers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LibraryOptionInfoDto" + }, + "description": "Gets or sets the list of lyric fetchers." + }, + "MediaSegmentProviders": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LibraryOptionInfoDto" + }, + "description": "Gets or sets the list of MediaSegment Providers." + }, + "TypeOptions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LibraryTypeOptionsDto" + }, + "description": "Gets or sets the type options." + } + }, + "additionalProperties": false, + "description": "Library options result dto." + }, + "LibraryStorageDto": { + "type": "object", + "properties": { + "Id": { + "type": "string", + "description": "Gets or sets the Library Id.", + "format": "uuid" + }, + "Name": { + "type": "string", + "description": "Gets or sets the name of the library." + }, + "Folders": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FolderStorageDto" + }, + "description": "Gets or sets the storage informations about the folders used in a library." + } + }, + "additionalProperties": false, + "description": "Contains informations about a libraries storage informations." + }, + "LibraryTypeOptionsDto": { + "type": "object", + "properties": { + "Type": { + "type": "string", + "description": "Gets or sets the type.", + "nullable": true + }, + "MetadataFetchers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LibraryOptionInfoDto" + }, + "description": "Gets or sets the metadata fetchers." + }, + "ImageFetchers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LibraryOptionInfoDto" + }, + "description": "Gets or sets the image fetchers." + }, + "SupportedImageTypes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ImageType" + }, + "description": "Gets or sets the supported image types." + }, + "DefaultImageOptions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ImageOption" + }, + "description": "Gets or sets the default image options." + } + }, + "additionalProperties": false, + "description": "Library type options dto." + }, + "LibraryUpdateInfo": { + "type": "object", + "properties": { + "FoldersAddedTo": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Gets or sets the folders added to." + }, + "FoldersRemovedFrom": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Gets or sets the folders removed from." + }, + "ItemsAdded": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Gets or sets the items added." + }, + "ItemsRemoved": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Gets or sets the items removed." + }, + "ItemsUpdated": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Gets or sets the items updated." + }, + "CollectionFolders": { + "type": "array", + "items": { + "type": "string" + } + }, + "IsEmpty": { + "type": "boolean", + "readOnly": true + } + }, + "additionalProperties": false, + "description": "Class LibraryUpdateInfo." + }, + "ListingsProviderInfo": { + "type": "object", + "properties": { + "Id": { + "type": "string", + "nullable": true + }, + "Type": { + "type": "string", + "nullable": true + }, + "Username": { + "type": "string", + "nullable": true + }, + "Password": { + "type": "string", + "nullable": true + }, + "ListingsId": { + "type": "string", + "nullable": true + }, + "ZipCode": { + "type": "string", + "nullable": true + }, + "Country": { + "type": "string", + "nullable": true + }, + "Path": { + "type": "string", + "nullable": true + }, + "EnabledTuners": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "EnableAllTuners": { + "type": "boolean" + }, + "NewsCategories": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "SportsCategories": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "KidsCategories": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "MovieCategories": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "ChannelMappings": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NameValuePair" + }, + "nullable": true + }, + "MoviePrefix": { + "type": "string", + "nullable": true + }, + "PreferredLanguage": { + "type": "string", + "nullable": true + }, + "UserAgent": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, + "LiveStreamResponse": { + "type": "object", + "properties": { + "MediaSource": { + "allOf": [ + { + "$ref": "#/components/schemas/MediaSourceInfo" + } + ] + } + }, + "additionalProperties": false + }, + "LiveTvInfo": { + "type": "object", + "properties": { + "Services": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LiveTvServiceInfo" + }, + "description": "Gets or sets the services." + }, + "IsEnabled": { + "type": "boolean", + "description": "Gets or sets a value indicating whether this instance is enabled." + }, + "EnabledUsers": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Gets or sets the enabled users." + } + }, + "additionalProperties": false + }, + "LiveTvOptions": { + "type": "object", + "properties": { + "GuideDays": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "RecordingPath": { + "type": "string", + "nullable": true + }, + "MovieRecordingPath": { + "type": "string", + "nullable": true + }, + "SeriesRecordingPath": { + "type": "string", + "nullable": true + }, + "EnableRecordingSubfolders": { + "type": "boolean" + }, + "EnableOriginalAudioWithEncodedRecordings": { + "type": "boolean" + }, + "TunerHosts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TunerHostInfo" + }, + "nullable": true + }, + "ListingProviders": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ListingsProviderInfo" + }, + "nullable": true + }, + "PrePaddingSeconds": { + "type": "integer", + "format": "int32" + }, + "PostPaddingSeconds": { + "type": "integer", + "format": "int32" + }, + "MediaLocationsCreated": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "RecordingPostProcessor": { + "type": "string", + "nullable": true + }, + "RecordingPostProcessorArguments": { + "type": "string", + "nullable": true + }, + "SaveRecordingNFO": { + "type": "boolean" + }, + "SaveRecordingImages": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "LiveTvServiceInfo": { + "type": "object", + "properties": { + "Name": { + "type": "string", + "description": "Gets or sets the name.", + "nullable": true + }, + "HomePageUrl": { + "type": "string", + "description": "Gets or sets the home page URL.", + "nullable": true + }, + "Status": { + "enum": [ + "Ok", + "Unavailable" + ], + "allOf": [ + { + "$ref": "#/components/schemas/LiveTvServiceStatus" + } + ], + "description": "Gets or sets the status." + }, + "StatusMessage": { + "type": "string", + "description": "Gets or sets the status message.", + "nullable": true + }, + "Version": { + "type": "string", + "description": "Gets or sets the version.", + "nullable": true + }, + "HasUpdateAvailable": { + "type": "boolean", + "description": "Gets or sets a value indicating whether this instance has update available." + }, + "IsVisible": { + "type": "boolean", + "description": "Gets or sets a value indicating whether this instance is visible." + }, + "Tuners": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + } + }, + "additionalProperties": false, + "description": "Class ServiceInfo." + }, + "LiveTvServiceStatus": { + "enum": [ + "Ok", + "Unavailable" + ], + "type": "string" + }, + "LocalizationOption": { + "type": "object", + "properties": { + "Name": { + "type": "string", + "nullable": true + }, + "Value": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, + "LocationType": { + "enum": [ + "FileSystem", + "Remote", + "Virtual", + "Offline" + ], + "type": "string", + "description": "Enum LocationType." + }, + "LogFile": { + "type": "object", + "properties": { + "DateCreated": { + "type": "string", + "description": "Gets or sets the date created.", + "format": "date-time" + }, + "DateModified": { + "type": "string", + "description": "Gets or sets the date modified.", + "format": "date-time" + }, + "Size": { + "type": "integer", + "description": "Gets or sets the size.", + "format": "int64" + }, + "Name": { + "type": "string", + "description": "Gets or sets the name." + } + }, + "additionalProperties": false + }, + "LogLevel": { + "enum": [ + "Trace", + "Debug", + "Information", + "Warning", + "Error", + "Critical", + "None" + ], + "type": "string" + }, + "LyricDto": { + "type": "object", + "properties": { + "Metadata": { + "allOf": [ + { + "$ref": "#/components/schemas/LyricMetadata" + } + ], + "description": "Gets or sets Metadata for the lyrics." + }, + "Lyrics": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LyricLine" + }, + "description": "Gets or sets a collection of individual lyric lines." + } + }, + "additionalProperties": false, + "description": "LyricResponse model." + }, + "LyricLine": { + "type": "object", + "properties": { + "Text": { + "type": "string", + "description": "Gets the text of this lyric line." + }, + "Start": { + "type": "integer", + "description": "Gets the start time in ticks.", + "format": "int64", + "nullable": true + }, + "Cues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LyricLineCue" + }, + "description": "Gets the time-aligned cues for the song's lyrics.", + "nullable": true + } + }, + "additionalProperties": false, + "description": "Lyric model." + }, + "LyricLineCue": { + "type": "object", + "properties": { + "Position": { + "type": "integer", + "description": "Gets the start character index of the cue.", + "format": "int32" + }, + "EndPosition": { + "type": "integer", + "description": "Gets the end character index of the cue.", + "format": "int32" + }, + "Start": { + "type": "integer", + "description": "Gets the timestamp the lyric is synced to in ticks.", + "format": "int64" + }, + "End": { + "type": "integer", + "description": "Gets the end timestamp the lyric is synced to in ticks.", + "format": "int64", + "nullable": true + } + }, + "additionalProperties": false, + "description": "LyricLineCue model, holds information about the timing of words within a LyricLine." + }, + "LyricMetadata": { + "type": "object", + "properties": { + "Artist": { + "type": "string", + "description": "Gets or sets the song artist.", + "nullable": true + }, + "Album": { + "type": "string", + "description": "Gets or sets the album this song is on.", + "nullable": true + }, + "Title": { + "type": "string", + "description": "Gets or sets the title of the song.", + "nullable": true + }, + "Author": { + "type": "string", + "description": "Gets or sets the author of the lyric data.", + "nullable": true + }, + "Length": { + "type": "integer", + "description": "Gets or sets the length of the song in ticks.", + "format": "int64", + "nullable": true + }, + "By": { + "type": "string", + "description": "Gets or sets who the LRC file was created by.", + "nullable": true + }, + "Offset": { + "type": "integer", + "description": "Gets or sets the lyric offset compared to audio in ticks.", + "format": "int64", + "nullable": true + }, + "Creator": { + "type": "string", + "description": "Gets or sets the software used to create the LRC file.", + "nullable": true + }, + "Version": { + "type": "string", + "description": "Gets or sets the version of the creator used.", + "nullable": true + }, + "IsSynced": { + "type": "boolean", + "description": "Gets or sets a value indicating whether this lyric is synced.", + "nullable": true + } + }, + "additionalProperties": false, + "description": "LyricMetadata model." + }, + "MediaAttachment": { + "type": "object", + "properties": { + "Codec": { + "type": "string", + "description": "Gets or sets the codec.", + "nullable": true + }, + "CodecTag": { + "type": "string", + "description": "Gets or sets the codec tag.", + "nullable": true + }, + "Comment": { + "type": "string", + "description": "Gets or sets the comment.", + "nullable": true + }, + "Index": { + "type": "integer", + "description": "Gets or sets the index.", + "format": "int32" + }, + "FileName": { + "type": "string", + "description": "Gets or sets the filename.", + "nullable": true + }, + "MimeType": { + "type": "string", + "description": "Gets or sets the MIME type.", + "nullable": true + }, + "DeliveryUrl": { + "type": "string", + "description": "Gets or sets the delivery URL.", + "nullable": true + } + }, + "additionalProperties": false, + "description": "Class MediaAttachment." + }, + "MediaPathDto": { + "required": [ + "Name" + ], + "type": "object", + "properties": { + "Name": { + "type": "string", + "description": "Gets or sets the name of the library." + }, + "Path": { + "type": "string", + "description": "Gets or sets the path to add.", + "nullable": true + }, + "PathInfo": { + "allOf": [ + { + "$ref": "#/components/schemas/MediaPathInfo" + } + ], + "description": "Gets or sets the path info.", + "nullable": true + } + }, + "additionalProperties": false, + "description": "Media Path dto." + }, + "MediaPathInfo": { + "type": "object", + "properties": { + "Path": { + "type": "string" + } + }, + "additionalProperties": false + }, + "MediaProtocol": { + "enum": [ + "File", + "Http", + "Rtmp", + "Rtsp", + "Udp", + "Rtp", + "Ftp" + ], + "type": "string" + }, + "MediaSegmentDto": { + "type": "object", + "properties": { + "Id": { + "type": "string", + "description": "Gets or sets the id of the media segment.", + "format": "uuid" + }, + "ItemId": { + "type": "string", + "description": "Gets or sets the id of the associated item.", + "format": "uuid" + }, + "Type": { + "enum": [ + "Unknown", + "Commercial", + "Preview", + "Recap", + "Outro", + "Intro" + ], + "allOf": [ + { + "$ref": "#/components/schemas/MediaSegmentType" + } + ], + "description": "Gets or sets the type of content this segment defines.", + "default": "Unknown" + }, + "StartTicks": { + "type": "integer", + "description": "Gets or sets the start of the segment.", + "format": "int64" + }, + "EndTicks": { + "type": "integer", + "description": "Gets or sets the end of the segment.", + "format": "int64" + } + }, + "additionalProperties": false, + "description": "Api model for MediaSegment's." + }, + "MediaSegmentDtoQueryResult": { + "type": "object", + "properties": { + "Items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MediaSegmentDto" + }, + "description": "Gets or sets the items." + }, + "TotalRecordCount": { + "type": "integer", + "description": "Gets or sets the total number of records available.", + "format": "int32" + }, + "StartIndex": { + "type": "integer", + "description": "Gets or sets the index of the first record in Items.", + "format": "int32" + } + }, + "additionalProperties": false, + "description": "Query result container." + }, + "MediaSegmentType": { + "enum": [ + "Unknown", + "Commercial", + "Preview", + "Recap", + "Outro", + "Intro" + ], + "type": "string", + "description": "Defines the types of content an individual Jellyfin.Database.Implementations.Entities.MediaSegment represents." + }, + "MediaSourceInfo": { + "type": "object", + "properties": { + "Protocol": { + "enum": [ + "File", + "Http", + "Rtmp", + "Rtsp", + "Udp", + "Rtp", + "Ftp" + ], + "allOf": [ + { + "$ref": "#/components/schemas/MediaProtocol" + } + ] + }, + "Id": { + "type": "string", + "nullable": true + }, + "Path": { + "type": "string", + "nullable": true + }, + "EncoderPath": { + "type": "string", + "nullable": true + }, + "EncoderProtocol": { + "enum": [ + "File", + "Http", + "Rtmp", + "Rtsp", + "Udp", + "Rtp", + "Ftp" + ], + "allOf": [ + { + "$ref": "#/components/schemas/MediaProtocol" + } + ], + "nullable": true + }, + "Type": { + "enum": [ + "Default", + "Grouping", + "Placeholder" + ], + "allOf": [ + { + "$ref": "#/components/schemas/MediaSourceType" + } + ] + }, + "Container": { + "type": "string", + "nullable": true + }, + "Size": { + "type": "integer", + "format": "int64", + "nullable": true + }, + "Name": { + "type": "string", + "nullable": true + }, + "IsRemote": { + "type": "boolean", + "description": "Gets or sets a value indicating whether the media is remote.\r\nDifferentiate internet url vs local network." + }, + "ETag": { + "type": "string", + "nullable": true + }, + "RunTimeTicks": { + "type": "integer", + "format": "int64", + "nullable": true + }, + "ReadAtNativeFramerate": { + "type": "boolean" + }, + "IgnoreDts": { + "type": "boolean" + }, + "IgnoreIndex": { + "type": "boolean" + }, + "GenPtsInput": { + "type": "boolean" + }, + "SupportsTranscoding": { + "type": "boolean" + }, + "SupportsDirectStream": { + "type": "boolean" + }, + "SupportsDirectPlay": { + "type": "boolean" + }, + "IsInfiniteStream": { + "type": "boolean" + }, + "UseMostCompatibleTranscodingProfile": { + "type": "boolean", + "default": false + }, + "RequiresOpening": { + "type": "boolean" + }, + "OpenToken": { + "type": "string", + "nullable": true + }, + "RequiresClosing": { + "type": "boolean" + }, + "LiveStreamId": { + "type": "string", + "nullable": true + }, + "BufferMs": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "RequiresLooping": { + "type": "boolean" + }, + "SupportsProbing": { + "type": "boolean" + }, + "VideoType": { + "enum": [ + "VideoFile", + "Iso", + "Dvd", + "BluRay" + ], + "allOf": [ + { + "$ref": "#/components/schemas/VideoType" + } + ], + "nullable": true + }, + "IsoType": { + "enum": [ + "Dvd", + "BluRay" + ], + "allOf": [ + { + "$ref": "#/components/schemas/IsoType" + } + ], + "nullable": true + }, + "Video3DFormat": { + "enum": [ + "HalfSideBySide", + "FullSideBySide", + "FullTopAndBottom", + "HalfTopAndBottom", + "MVC" + ], + "allOf": [ + { + "$ref": "#/components/schemas/Video3DFormat" + } + ], + "nullable": true + }, + "MediaStreams": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MediaStream" + }, + "nullable": true + }, + "MediaAttachments": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MediaAttachment" + }, + "nullable": true + }, + "Formats": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "Bitrate": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "FallbackMaxStreamingBitrate": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "Timestamp": { + "enum": [ + "None", + "Zero", + "Valid" + ], + "allOf": [ + { + "$ref": "#/components/schemas/TransportStreamTimestamp" + } + ], + "nullable": true + }, + "RequiredHttpHeaders": { + "type": "object", + "additionalProperties": { + "type": "string", + "nullable": true + }, + "nullable": true + }, + "TranscodingUrl": { + "type": "string", + "nullable": true + }, + "TranscodingSubProtocol": { + "enum": [ + "http", + "hls" + ], + "allOf": [ + { + "$ref": "#/components/schemas/MediaStreamProtocol" + } + ], + "description": "Media streaming protocol.\r\nLowercase for backwards compatibility." + }, + "TranscodingContainer": { + "type": "string", + "nullable": true + }, + "AnalyzeDurationMs": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "DefaultAudioStreamIndex": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "DefaultSubtitleStreamIndex": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "HasSegments": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "MediaSourceType": { + "enum": [ + "Default", + "Grouping", + "Placeholder" + ], + "type": "string" + }, + "MediaStream": { + "type": "object", + "properties": { + "Codec": { + "type": "string", + "description": "Gets or sets the codec.", + "nullable": true + }, + "CodecTag": { + "type": "string", + "description": "Gets or sets the codec tag.", + "nullable": true + }, + "Language": { + "type": "string", + "description": "Gets or sets the language.", + "nullable": true + }, + "ColorRange": { + "type": "string", + "description": "Gets or sets the color range.", + "nullable": true + }, + "ColorSpace": { + "type": "string", + "description": "Gets or sets the color space.", + "nullable": true + }, + "ColorTransfer": { + "type": "string", + "description": "Gets or sets the color transfer.", + "nullable": true + }, + "ColorPrimaries": { + "type": "string", + "description": "Gets or sets the color primaries.", + "nullable": true + }, + "DvVersionMajor": { + "type": "integer", + "description": "Gets or sets the Dolby Vision version major.", + "format": "int32", + "nullable": true + }, + "DvVersionMinor": { + "type": "integer", + "description": "Gets or sets the Dolby Vision version minor.", + "format": "int32", + "nullable": true + }, + "DvProfile": { + "type": "integer", + "description": "Gets or sets the Dolby Vision profile.", + "format": "int32", + "nullable": true + }, + "DvLevel": { + "type": "integer", + "description": "Gets or sets the Dolby Vision level.", + "format": "int32", + "nullable": true + }, + "RpuPresentFlag": { + "type": "integer", + "description": "Gets or sets the Dolby Vision rpu present flag.", + "format": "int32", + "nullable": true + }, + "ElPresentFlag": { + "type": "integer", + "description": "Gets or sets the Dolby Vision el present flag.", + "format": "int32", + "nullable": true + }, + "BlPresentFlag": { + "type": "integer", + "description": "Gets or sets the Dolby Vision bl present flag.", + "format": "int32", + "nullable": true + }, + "DvBlSignalCompatibilityId": { + "type": "integer", + "description": "Gets or sets the Dolby Vision bl signal compatibility id.", + "format": "int32", + "nullable": true + }, + "Rotation": { + "type": "integer", + "description": "Gets or sets the Rotation in degrees.", + "format": "int32", + "nullable": true + }, + "Comment": { + "type": "string", + "description": "Gets or sets the comment.", + "nullable": true + }, + "TimeBase": { + "type": "string", + "description": "Gets or sets the time base.", + "nullable": true + }, + "CodecTimeBase": { + "type": "string", + "description": "Gets or sets the codec time base.", + "nullable": true + }, + "Title": { + "type": "string", + "description": "Gets or sets the title.", + "nullable": true + }, + "Hdr10PlusPresentFlag": { + "type": "boolean", + "nullable": true + }, + "VideoRange": { + "enum": [ + "Unknown", + "SDR", + "HDR" + ], + "allOf": [ + { + "$ref": "#/components/schemas/VideoRange" + } + ], + "description": "Gets the video range.", + "default": "Unknown", + "readOnly": true + }, + "VideoRangeType": { + "enum": [ + "Unknown", + "SDR", + "HDR10", + "HLG", + "DOVI", + "DOVIWithHDR10", + "DOVIWithHLG", + "DOVIWithSDR", + "DOVIWithEL", + "DOVIWithHDR10Plus", + "DOVIWithELHDR10Plus", + "DOVIInvalid", + "HDR10Plus" + ], + "allOf": [ + { + "$ref": "#/components/schemas/VideoRangeType" + } + ], + "description": "Gets the video range type.", + "default": "Unknown", + "readOnly": true + }, + "VideoDoViTitle": { + "type": "string", + "description": "Gets the video dovi title.", + "nullable": true, + "readOnly": true + }, + "AudioSpatialFormat": { + "enum": [ + "None", + "DolbyAtmos", + "DTSX" + ], + "allOf": [ + { + "$ref": "#/components/schemas/AudioSpatialFormat" + } + ], + "description": "Gets the audio spatial format.", + "default": "None", + "readOnly": true + }, + "LocalizedUndefined": { + "type": "string", + "nullable": true + }, + "LocalizedDefault": { + "type": "string", + "nullable": true + }, + "LocalizedForced": { + "type": "string", + "nullable": true + }, + "LocalizedExternal": { + "type": "string", + "nullable": true + }, + "LocalizedHearingImpaired": { + "type": "string", + "nullable": true + }, + "DisplayTitle": { + "type": "string", + "nullable": true, + "readOnly": true + }, + "NalLengthSize": { + "type": "string", + "nullable": true + }, + "IsInterlaced": { + "type": "boolean", + "description": "Gets or sets a value indicating whether this instance is interlaced." + }, + "IsAVC": { + "type": "boolean", + "nullable": true + }, + "ChannelLayout": { + "type": "string", + "description": "Gets or sets the channel layout.", + "nullable": true + }, + "BitRate": { + "type": "integer", + "description": "Gets or sets the bit rate.", + "format": "int32", + "nullable": true + }, + "BitDepth": { + "type": "integer", + "description": "Gets or sets the bit depth.", + "format": "int32", + "nullable": true + }, + "RefFrames": { + "type": "integer", + "description": "Gets or sets the reference frames.", + "format": "int32", + "nullable": true + }, + "PacketLength": { + "type": "integer", + "description": "Gets or sets the length of the packet.", + "format": "int32", + "nullable": true + }, + "Channels": { + "type": "integer", + "description": "Gets or sets the channels.", + "format": "int32", + "nullable": true + }, + "SampleRate": { + "type": "integer", + "description": "Gets or sets the sample rate.", + "format": "int32", + "nullable": true + }, + "IsDefault": { + "type": "boolean", + "description": "Gets or sets a value indicating whether this instance is default." + }, + "IsForced": { + "type": "boolean", + "description": "Gets or sets a value indicating whether this instance is forced." + }, + "IsHearingImpaired": { + "type": "boolean", + "description": "Gets or sets a value indicating whether this instance is for the hearing impaired." + }, + "Height": { + "type": "integer", + "description": "Gets or sets the height.", + "format": "int32", + "nullable": true + }, + "Width": { + "type": "integer", + "description": "Gets or sets the width.", + "format": "int32", + "nullable": true + }, + "AverageFrameRate": { + "type": "number", + "description": "Gets or sets the average frame rate.", + "format": "float", + "nullable": true + }, + "RealFrameRate": { + "type": "number", + "description": "Gets or sets the real frame rate.", + "format": "float", + "nullable": true + }, + "ReferenceFrameRate": { + "type": "number", + "description": "Gets the framerate used as reference.\r\nPrefer AverageFrameRate, if that is null or an unrealistic value\r\nthen fallback to RealFrameRate.", + "format": "float", + "nullable": true, + "readOnly": true + }, + "Profile": { + "type": "string", + "description": "Gets or sets the profile.", + "nullable": true + }, + "Type": { + "enum": [ + "Audio", + "Video", + "Subtitle", + "EmbeddedImage", + "Data", + "Lyric" + ], + "allOf": [ + { + "$ref": "#/components/schemas/MediaStreamType" + } + ], + "description": "Gets or sets the type." + }, + "AspectRatio": { + "type": "string", + "description": "Gets or sets the aspect ratio.", + "nullable": true + }, + "Index": { + "type": "integer", + "description": "Gets or sets the index.", + "format": "int32" + }, + "Score": { + "type": "integer", + "description": "Gets or sets the score.", + "format": "int32", + "nullable": true + }, + "IsExternal": { + "type": "boolean", + "description": "Gets or sets a value indicating whether this instance is external." + }, + "DeliveryMethod": { + "enum": [ + "Encode", + "Embed", + "External", + "Hls", + "Drop" + ], + "allOf": [ + { + "$ref": "#/components/schemas/SubtitleDeliveryMethod" + } + ], + "description": "Gets or sets the method.", + "nullable": true + }, + "DeliveryUrl": { + "type": "string", + "description": "Gets or sets the delivery URL.", + "nullable": true + }, + "IsExternalUrl": { + "type": "boolean", + "description": "Gets or sets a value indicating whether this instance is external URL.", + "nullable": true + }, + "IsTextSubtitleStream": { + "type": "boolean", + "readOnly": true + }, + "SupportsExternalStream": { + "type": "boolean", + "description": "Gets or sets a value indicating whether [supports external stream]." + }, + "Path": { + "type": "string", + "description": "Gets or sets the filename.", + "nullable": true + }, + "PixelFormat": { + "type": "string", + "description": "Gets or sets the pixel format.", + "nullable": true + }, + "Level": { + "type": "number", + "description": "Gets or sets the level.", + "format": "double", + "nullable": true + }, + "IsAnamorphic": { + "type": "boolean", + "description": "Gets or sets whether this instance is anamorphic.", + "nullable": true + } + }, + "additionalProperties": false, + "description": "Class MediaStream." + }, + "MediaStreamProtocol": { + "enum": [ + "http", + "hls" + ], + "type": "string", + "description": "Media streaming protocol.\r\nLowercase for backwards compatibility." + }, + "MediaStreamType": { + "enum": [ + "Audio", + "Video", + "Subtitle", + "EmbeddedImage", + "Data", + "Lyric" + ], + "type": "string", + "description": "Enum MediaStreamType." + }, + "MediaType": { + "enum": [ + "Unknown", + "Video", + "Audio", + "Photo", + "Book" + ], + "type": "string", + "description": "Media types." + }, + "MediaUpdateInfoDto": { + "type": "object", + "properties": { + "Updates": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MediaUpdateInfoPathDto" + }, + "description": "Gets or sets the list of updates." + } + }, + "additionalProperties": false, + "description": "Media Update Info Dto." + }, + "MediaUpdateInfoPathDto": { + "type": "object", + "properties": { + "Path": { + "type": "string", + "description": "Gets or sets media path.", + "nullable": true + }, + "UpdateType": { + "type": "string", + "description": "Gets or sets media update type.\r\nCreated, Modified, Deleted.", + "nullable": true + } + }, + "additionalProperties": false, + "description": "The media update info path." + }, + "MediaUrl": { + "type": "object", + "properties": { + "Url": { + "type": "string", + "nullable": true + }, + "Name": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, + "MessageCommand": { + "required": [ + "Text" + ], + "type": "object", + "properties": { + "Header": { + "type": "string", + "nullable": true + }, + "Text": { + "type": "string" + }, + "TimeoutMs": { + "type": "integer", + "format": "int64", + "nullable": true + } + }, + "additionalProperties": false + }, + "MetadataConfiguration": { + "type": "object", + "properties": { + "UseFileCreationTimeForDateAdded": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "MetadataEditorInfo": { + "type": "object", + "properties": { + "ParentalRatingOptions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ParentalRating" + }, + "description": "Gets or sets the parental rating options." + }, + "Countries": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CountryInfo" + }, + "description": "Gets or sets the countries." + }, + "Cultures": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CultureDto" + }, + "description": "Gets or sets the cultures." + }, + "ExternalIdInfos": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ExternalIdInfo" + }, + "description": "Gets or sets the external id infos." + }, + "ContentType": { + "enum": [ + "unknown", + "movies", + "tvshows", + "music", + "musicvideos", + "trailers", + "homevideos", + "boxsets", + "books", + "photos", + "livetv", + "playlists", + "folders" + ], + "allOf": [ + { + "$ref": "#/components/schemas/CollectionType" + } + ], + "description": "Gets or sets the content type.", + "nullable": true + }, + "ContentTypeOptions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NameValuePair" + }, + "description": "Gets or sets the content type options." + } + }, + "additionalProperties": false, + "description": "A class representing metadata editor information." + }, + "MetadataField": { + "enum": [ + "Cast", + "Genres", + "ProductionLocations", + "Studios", + "Tags", + "Name", + "Overview", + "Runtime", + "OfficialRating" + ], + "type": "string", + "description": "Enum MetadataFields." + }, + "MetadataOptions": { + "type": "object", + "properties": { + "ItemType": { + "type": "string", + "nullable": true + }, + "DisabledMetadataSavers": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "LocalMetadataReaderOrder": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "DisabledMetadataFetchers": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "MetadataFetcherOrder": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "DisabledImageFetchers": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "ImageFetcherOrder": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + } + }, + "additionalProperties": false, + "description": "Class MetadataOptions." + }, + "MetadataRefreshMode": { + "enum": [ + "None", + "ValidationOnly", + "Default", + "FullRefresh" + ], + "type": "string" + }, + "MovePlaylistItemRequestDto": { + "type": "object", + "properties": { + "PlaylistItemId": { + "type": "string", + "description": "Gets or sets the playlist identifier of the item.", + "format": "uuid" + }, + "NewIndex": { + "type": "integer", + "description": "Gets or sets the new position.", + "format": "int32" + } + }, + "additionalProperties": false, + "description": "Class MovePlaylistItemRequestDto." + }, + "MovieInfo": { + "type": "object", + "properties": { + "Name": { + "type": "string", + "description": "Gets or sets the name.", + "nullable": true + }, + "OriginalTitle": { + "type": "string", + "description": "Gets or sets the original title.", + "nullable": true + }, + "Path": { + "type": "string", + "description": "Gets or sets the path.", + "nullable": true + }, + "MetadataLanguage": { + "type": "string", + "description": "Gets or sets the metadata language.", + "nullable": true + }, + "MetadataCountryCode": { + "type": "string", + "description": "Gets or sets the metadata country code.", + "nullable": true + }, + "ProviderIds": { + "type": "object", + "additionalProperties": { + "type": "string", + "nullable": true + }, + "description": "Gets or sets the provider ids.", + "nullable": true + }, + "Year": { + "type": "integer", + "description": "Gets or sets the year.", + "format": "int32", + "nullable": true + }, + "IndexNumber": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "ParentIndexNumber": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "PremiereDate": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "IsAutomated": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "MovieInfoRemoteSearchQuery": { + "type": "object", + "properties": { + "SearchInfo": { + "allOf": [ + { + "$ref": "#/components/schemas/MovieInfo" + } + ], + "nullable": true + }, + "ItemId": { + "type": "string", + "format": "uuid" + }, + "SearchProviderName": { + "type": "string", + "description": "Gets or sets the provider name to search within if set.", + "nullable": true + }, + "IncludeDisabledProviders": { + "type": "boolean", + "description": "Gets or sets a value indicating whether disabled providers should be included." + } + }, + "additionalProperties": false + }, + "MusicVideoInfo": { + "type": "object", + "properties": { + "Name": { + "type": "string", + "description": "Gets or sets the name.", + "nullable": true + }, + "OriginalTitle": { + "type": "string", + "description": "Gets or sets the original title.", + "nullable": true + }, + "Path": { + "type": "string", + "description": "Gets or sets the path.", + "nullable": true + }, + "MetadataLanguage": { + "type": "string", + "description": "Gets or sets the metadata language.", + "nullable": true + }, + "MetadataCountryCode": { + "type": "string", + "description": "Gets or sets the metadata country code.", + "nullable": true + }, + "ProviderIds": { + "type": "object", + "additionalProperties": { + "type": "string", + "nullable": true + }, + "description": "Gets or sets the provider ids.", + "nullable": true + }, + "Year": { + "type": "integer", + "description": "Gets or sets the year.", + "format": "int32", + "nullable": true + }, + "IndexNumber": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "ParentIndexNumber": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "PremiereDate": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "IsAutomated": { + "type": "boolean" + }, + "Artists": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + } + }, + "additionalProperties": false + }, + "MusicVideoInfoRemoteSearchQuery": { + "type": "object", + "properties": { + "SearchInfo": { + "allOf": [ + { + "$ref": "#/components/schemas/MusicVideoInfo" + } + ], + "nullable": true + }, + "ItemId": { + "type": "string", + "format": "uuid" + }, + "SearchProviderName": { + "type": "string", + "description": "Gets or sets the provider name to search within if set.", + "nullable": true + }, + "IncludeDisabledProviders": { + "type": "boolean", + "description": "Gets or sets a value indicating whether disabled providers should be included." + } + }, + "additionalProperties": false + }, + "NameGuidPair": { + "type": "object", + "properties": { + "Name": { + "type": "string", + "nullable": true + }, + "Id": { + "type": "string", + "format": "uuid" + } + }, + "additionalProperties": false + }, + "NameIdPair": { + "type": "object", + "properties": { + "Name": { + "type": "string", + "description": "Gets or sets the name.", + "nullable": true + }, + "Id": { + "type": "string", + "description": "Gets or sets the identifier.", + "nullable": true + } + }, + "additionalProperties": false + }, + "NameValuePair": { + "type": "object", + "properties": { + "Name": { + "type": "string", + "description": "Gets or sets the name.", + "nullable": true + }, + "Value": { + "type": "string", + "description": "Gets or sets the value.", + "nullable": true + } + }, + "additionalProperties": false + }, + "NetworkConfiguration": { + "type": "object", + "properties": { + "BaseUrl": { + "type": "string", + "description": "Gets or sets a value used to specify the URL prefix that your Jellyfin instance can be accessed at." + }, + "EnableHttps": { + "type": "boolean", + "description": "Gets or sets a value indicating whether to use HTTPS." + }, + "RequireHttps": { + "type": "boolean", + "description": "Gets or sets a value indicating whether the server should force connections over HTTPS." + }, + "CertificatePath": { + "type": "string", + "description": "Gets or sets the filesystem path of an X.509 certificate to use for SSL." + }, + "CertificatePassword": { + "type": "string", + "description": "Gets or sets the password required to access the X.509 certificate data in the file specified by MediaBrowser.Common.Net.NetworkConfiguration.CertificatePath." + }, + "InternalHttpPort": { + "type": "integer", + "description": "Gets or sets the internal HTTP server port.", + "format": "int32" + }, + "InternalHttpsPort": { + "type": "integer", + "description": "Gets or sets the internal HTTPS server port.", + "format": "int32" + }, + "PublicHttpPort": { + "type": "integer", + "description": "Gets or sets the public HTTP port.", + "format": "int32" + }, + "PublicHttpsPort": { + "type": "integer", + "description": "Gets or sets the public HTTPS port.", + "format": "int32" + }, + "AutoDiscovery": { + "type": "boolean", + "description": "Gets or sets a value indicating whether Autodiscovery is enabled." + }, + "EnableUPnP": { + "type": "boolean", + "description": "Gets or sets a value indicating whether to enable automatic port forwarding.", + "deprecated": true + }, + "EnableIPv4": { + "type": "boolean", + "description": "Gets or sets a value indicating whether IPv6 is enabled." + }, + "EnableIPv6": { + "type": "boolean", + "description": "Gets or sets a value indicating whether IPv6 is enabled." + }, + "EnableRemoteAccess": { + "type": "boolean", + "description": "Gets or sets a value indicating whether access from outside of the LAN is permitted." + }, + "LocalNetworkSubnets": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Gets or sets the subnets that are deemed to make up the LAN." + }, + "LocalNetworkAddresses": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Gets or sets the interface addresses which Jellyfin will bind to. If empty, all interfaces will be used." + }, + "KnownProxies": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Gets or sets the known proxies." + }, + "IgnoreVirtualInterfaces": { + "type": "boolean", + "description": "Gets or sets a value indicating whether address names that match MediaBrowser.Common.Net.NetworkConfiguration.VirtualInterfaceNames should be ignored for the purposes of binding." + }, + "VirtualInterfaceNames": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Gets or sets a value indicating the interface name prefixes that should be ignored. The list can be comma separated and values are case-insensitive. ." + }, + "EnablePublishedServerUriByRequest": { + "type": "boolean", + "description": "Gets or sets a value indicating whether the published server uri is based on information in HTTP requests." + }, + "PublishedServerUriBySubnet": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Gets or sets the PublishedServerUriBySubnet\r\nGets or sets PublishedServerUri to advertise for specific subnets." + }, + "RemoteIPFilter": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Gets or sets the filter for remote IP connectivity. Used in conjunction with ." + }, + "IsRemoteIPFilterBlacklist": { + "type": "boolean", + "description": "Gets or sets a value indicating whether contains a blacklist or a whitelist. Default is a whitelist." + } + }, + "additionalProperties": false, + "description": "Defines the MediaBrowser.Common.Net.NetworkConfiguration." + }, + "NewGroupRequestDto": { + "type": "object", + "properties": { + "GroupName": { + "type": "string", + "description": "Gets or sets the group name." + } + }, + "additionalProperties": false, + "description": "Class NewGroupRequestDto." + }, + "NextItemRequestDto": { + "type": "object", + "properties": { + "PlaylistItemId": { + "type": "string", + "description": "Gets or sets the playing item identifier.", + "format": "uuid" + } + }, + "additionalProperties": false, + "description": "Class NextItemRequestDto." + }, + "OpenLiveStreamDto": { + "type": "object", + "properties": { + "OpenToken": { + "type": "string", + "description": "Gets or sets the open token.", + "nullable": true + }, + "UserId": { + "type": "string", + "description": "Gets or sets the user id.", + "format": "uuid", + "nullable": true + }, + "PlaySessionId": { + "type": "string", + "description": "Gets or sets the play session id.", + "nullable": true + }, + "MaxStreamingBitrate": { + "type": "integer", + "description": "Gets or sets the max streaming bitrate.", + "format": "int32", + "nullable": true + }, + "StartTimeTicks": { + "type": "integer", + "description": "Gets or sets the start time in ticks.", + "format": "int64", + "nullable": true + }, + "AudioStreamIndex": { + "type": "integer", + "description": "Gets or sets the audio stream index.", + "format": "int32", + "nullable": true + }, + "SubtitleStreamIndex": { + "type": "integer", + "description": "Gets or sets the subtitle stream index.", + "format": "int32", + "nullable": true + }, + "MaxAudioChannels": { + "type": "integer", + "description": "Gets or sets the max audio channels.", + "format": "int32", + "nullable": true + }, + "ItemId": { + "type": "string", + "description": "Gets or sets the item id.", + "format": "uuid", + "nullable": true + }, + "EnableDirectPlay": { + "type": "boolean", + "description": "Gets or sets a value indicating whether to enable direct play.", + "nullable": true + }, + "EnableDirectStream": { + "type": "boolean", + "description": "Gets or sets a value indicating whether to enable direct stream.", + "nullable": true + }, + "AlwaysBurnInSubtitleWhenTranscoding": { + "type": "boolean", + "description": "Gets or sets a value indicating whether always burn in subtitles when transcoding.", + "nullable": true + }, + "DeviceProfile": { + "allOf": [ + { + "$ref": "#/components/schemas/DeviceProfile" + } + ], + "description": "A MediaBrowser.Model.Dlna.DeviceProfile represents a set of metadata which determines which content a certain device is able to play.\r\n
\r\nSpecifically, it defines the supported containers and\r\ncodecs (video and/or audio, including codec profiles and levels)\r\nthe device is able to direct play (without transcoding or remuxing),\r\nas well as which containers/codecs to transcode to in case it isn't.", + "nullable": true + }, + "DirectPlayProtocols": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MediaProtocol" + }, + "description": "Gets or sets the device play protocols." + } + }, + "additionalProperties": false, + "description": "Open live stream dto." + }, + "OutboundKeepAliveMessage": { + "type": "object", + "properties": { + "MessageId": { + "type": "string", + "description": "Gets or sets the message id.", + "format": "uuid" + }, + "MessageType": { + "enum": [ + "ForceKeepAlive", + "GeneralCommand", + "UserDataChanged", + "Sessions", + "Play", + "SyncPlayCommand", + "SyncPlayGroupUpdate", + "Playstate", + "RestartRequired", + "ServerShuttingDown", + "ServerRestarting", + "LibraryChanged", + "UserDeleted", + "UserUpdated", + "SeriesTimerCreated", + "TimerCreated", + "SeriesTimerCancelled", + "TimerCancelled", + "RefreshProgress", + "ScheduledTaskEnded", + "PackageInstallationCancelled", + "PackageInstallationFailed", + "PackageInstallationCompleted", + "PackageInstalling", + "PackageUninstalled", + "ActivityLogEntry", + "ScheduledTasksInfo", + "ActivityLogEntryStart", + "ActivityLogEntryStop", + "SessionsStart", + "SessionsStop", + "ScheduledTasksInfoStart", + "ScheduledTasksInfoStop", + "KeepAlive" + ], + "allOf": [ + { + "$ref": "#/components/schemas/SessionMessageType" + } + ], + "description": "The different kinds of messages that are used in the WebSocket api.", + "default": "KeepAlive", + "readOnly": true + } + }, + "additionalProperties": false, + "description": "Keep alive websocket messages." + }, + "OutboundWebSocketMessage": { + "type": "object", + "oneOf": [ + { + "$ref": "#/components/schemas/ActivityLogEntryMessage" + }, + { + "$ref": "#/components/schemas/ForceKeepAliveMessage" + }, + { + "$ref": "#/components/schemas/GeneralCommandMessage" + }, + { + "$ref": "#/components/schemas/LibraryChangedMessage" + }, + { + "$ref": "#/components/schemas/OutboundKeepAliveMessage" + }, + { + "$ref": "#/components/schemas/PlayMessage" + }, + { + "$ref": "#/components/schemas/PlaystateMessage" + }, + { + "$ref": "#/components/schemas/PluginInstallationCancelledMessage" + }, + { + "$ref": "#/components/schemas/PluginInstallationCompletedMessage" + }, + { + "$ref": "#/components/schemas/PluginInstallationFailedMessage" + }, + { + "$ref": "#/components/schemas/PluginInstallingMessage" + }, + { + "$ref": "#/components/schemas/PluginUninstalledMessage" + }, + { + "$ref": "#/components/schemas/RefreshProgressMessage" + }, + { + "$ref": "#/components/schemas/RestartRequiredMessage" + }, + { + "$ref": "#/components/schemas/ScheduledTaskEndedMessage" + }, + { + "$ref": "#/components/schemas/ScheduledTasksInfoMessage" + }, + { + "$ref": "#/components/schemas/SeriesTimerCancelledMessage" + }, + { + "$ref": "#/components/schemas/SeriesTimerCreatedMessage" + }, + { + "$ref": "#/components/schemas/ServerRestartingMessage" + }, + { + "$ref": "#/components/schemas/ServerShuttingDownMessage" + }, + { + "$ref": "#/components/schemas/SessionsMessage" + }, + { + "$ref": "#/components/schemas/SyncPlayCommandMessage" + }, + { + "$ref": "#/components/schemas/TimerCancelledMessage" + }, + { + "$ref": "#/components/schemas/TimerCreatedMessage" + }, + { + "$ref": "#/components/schemas/UserDataChangedMessage" + }, + { + "$ref": "#/components/schemas/UserDeletedMessage" + }, + { + "$ref": "#/components/schemas/UserUpdatedMessage" + }, + { + "$ref": "#/components/schemas/SyncPlayGroupUpdateMessage" + } + ], + "description": "Represents the list of possible outbound websocket types", + "discriminator": { + "propertyName": "MessageType", + "mapping": { + "ActivityLogEntry": "#/components/schemas/ActivityLogEntryMessage", + "ForceKeepAlive": "#/components/schemas/ForceKeepAliveMessage", + "GeneralCommand": "#/components/schemas/GeneralCommandMessage", + "LibraryChanged": "#/components/schemas/LibraryChangedMessage", + "KeepAlive": "#/components/schemas/OutboundKeepAliveMessage", + "Play": "#/components/schemas/PlayMessage", + "Playstate": "#/components/schemas/PlaystateMessage", + "PackageInstallationCancelled": "#/components/schemas/PluginInstallationCancelledMessage", + "PackageInstallationCompleted": "#/components/schemas/PluginInstallationCompletedMessage", + "PackageInstallationFailed": "#/components/schemas/PluginInstallationFailedMessage", + "PackageInstalling": "#/components/schemas/PluginInstallingMessage", + "PackageUninstalled": "#/components/schemas/PluginUninstalledMessage", + "RefreshProgress": "#/components/schemas/RefreshProgressMessage", + "RestartRequired": "#/components/schemas/RestartRequiredMessage", + "ScheduledTaskEnded": "#/components/schemas/ScheduledTaskEndedMessage", + "ScheduledTasksInfo": "#/components/schemas/ScheduledTasksInfoMessage", + "SeriesTimerCancelled": "#/components/schemas/SeriesTimerCancelledMessage", + "SeriesTimerCreated": "#/components/schemas/SeriesTimerCreatedMessage", + "ServerRestarting": "#/components/schemas/ServerRestartingMessage", + "ServerShuttingDown": "#/components/schemas/ServerShuttingDownMessage", + "Sessions": "#/components/schemas/SessionsMessage", + "SyncPlayCommand": "#/components/schemas/SyncPlayCommandMessage", + "TimerCancelled": "#/components/schemas/TimerCancelledMessage", + "TimerCreated": "#/components/schemas/TimerCreatedMessage", + "UserDataChanged": "#/components/schemas/UserDataChangedMessage", + "UserDeleted": "#/components/schemas/UserDeletedMessage", + "UserUpdated": "#/components/schemas/UserUpdatedMessage", + "SyncPlayGroupUpdate": "#/components/schemas/SyncPlayGroupUpdateMessage" + } + } + }, + "PackageInfo": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Gets or sets the name." + }, + "description": { + "type": "string", + "description": "Gets or sets a long description of the plugin containing features or helpful explanations." + }, + "overview": { + "type": "string", + "description": "Gets or sets a short overview of what the plugin does." + }, + "owner": { + "type": "string", + "description": "Gets or sets the owner." + }, + "category": { + "type": "string", + "description": "Gets or sets the category." + }, + "guid": { + "type": "string", + "description": "Gets or sets the guid of the assembly associated with this plugin.\r\nThis is used to identify the proper item for automatic updates.", + "format": "uuid" + }, + "versions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/VersionInfo" + }, + "description": "Gets or sets the versions." + }, + "imageUrl": { + "type": "string", + "description": "Gets or sets the image url for the package.", + "nullable": true + } + }, + "additionalProperties": false, + "description": "Class PackageInfo." + }, + "ParentalRating": { + "type": "object", + "properties": { + "Name": { + "type": "string", + "description": "Gets or sets the name." + }, + "Value": { + "type": "integer", + "description": "Gets or sets the value.", + "format": "int32", + "nullable": true + }, + "RatingScore": { + "allOf": [ + { + "$ref": "#/components/schemas/ParentalRatingScore" + } + ], + "description": "Gets or sets the rating score.", + "nullable": true + } + }, + "additionalProperties": false, + "description": "Class ParentalRating." + }, + "ParentalRatingScore": { + "type": "object", + "properties": { + "score": { + "type": "integer", + "description": "Gets or sets the score.", + "format": "int32" + }, + "subScore": { + "type": "integer", + "description": "Gets or sets the sub score.", + "format": "int32", + "nullable": true + } + }, + "additionalProperties": false, + "description": "A class representing an parental rating score." + }, + "PathSubstitution": { + "type": "object", + "properties": { + "From": { + "type": "string", + "description": "Gets or sets the value to substitute." + }, + "To": { + "type": "string", + "description": "Gets or sets the value to substitution with." + } + }, + "additionalProperties": false, + "description": "Defines the MediaBrowser.Model.Configuration.PathSubstitution." + }, + "PersonKind": { + "enum": [ + "Unknown", + "Actor", + "Director", + "Composer", + "Writer", + "GuestStar", + "Producer", + "Conductor", + "Lyricist", + "Arranger", + "Engineer", + "Mixer", + "Remixer", + "Creator", + "Artist", + "AlbumArtist", + "Author", + "Illustrator", + "Penciller", + "Inker", + "Colorist", + "Letterer", + "CoverArtist", + "Editor", + "Translator" + ], + "type": "string", + "description": "The person kind." + }, + "PersonLookupInfo": { + "type": "object", + "properties": { + "Name": { + "type": "string", + "description": "Gets or sets the name.", + "nullable": true + }, + "OriginalTitle": { + "type": "string", + "description": "Gets or sets the original title.", + "nullable": true + }, + "Path": { + "type": "string", + "description": "Gets or sets the path.", + "nullable": true + }, + "MetadataLanguage": { + "type": "string", + "description": "Gets or sets the metadata language.", + "nullable": true + }, + "MetadataCountryCode": { + "type": "string", + "description": "Gets or sets the metadata country code.", + "nullable": true + }, + "ProviderIds": { + "type": "object", + "additionalProperties": { + "type": "string", + "nullable": true + }, + "description": "Gets or sets the provider ids.", + "nullable": true + }, + "Year": { + "type": "integer", + "description": "Gets or sets the year.", + "format": "int32", + "nullable": true + }, + "IndexNumber": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "ParentIndexNumber": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "PremiereDate": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "IsAutomated": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "PersonLookupInfoRemoteSearchQuery": { + "type": "object", + "properties": { + "SearchInfo": { + "allOf": [ + { + "$ref": "#/components/schemas/PersonLookupInfo" + } + ], + "nullable": true + }, + "ItemId": { + "type": "string", + "format": "uuid" + }, + "SearchProviderName": { + "type": "string", + "description": "Gets or sets the provider name to search within if set.", + "nullable": true + }, + "IncludeDisabledProviders": { + "type": "boolean", + "description": "Gets or sets a value indicating whether disabled providers should be included." + } + }, + "additionalProperties": false + }, + "PingRequestDto": { + "type": "object", + "properties": { + "Ping": { + "type": "integer", + "description": "Gets or sets the ping time.", + "format": "int64" + } + }, + "additionalProperties": false, + "description": "Class PingRequestDto." + }, + "PinRedeemResult": { + "type": "object", + "properties": { + "Success": { + "type": "boolean", + "description": "Gets or sets a value indicating whether this MediaBrowser.Model.Users.PinRedeemResult is success." + }, + "UsersReset": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Gets or sets the users reset." + } + }, + "additionalProperties": false + }, + "PlayAccess": { + "enum": [ + "Full", + "None" + ], + "type": "string" + }, + "PlaybackErrorCode": { + "enum": [ + "NotAllowed", + "NoCompatibleStream", + "RateLimitExceeded" + ], + "type": "string" + }, + "PlaybackInfoDto": { + "type": "object", + "properties": { + "UserId": { + "type": "string", + "description": "Gets or sets the playback userId.", + "format": "uuid", + "nullable": true + }, + "MaxStreamingBitrate": { + "type": "integer", + "description": "Gets or sets the max streaming bitrate.", + "format": "int32", + "nullable": true + }, + "StartTimeTicks": { + "type": "integer", + "description": "Gets or sets the start time in ticks.", + "format": "int64", + "nullable": true + }, + "AudioStreamIndex": { + "type": "integer", + "description": "Gets or sets the audio stream index.", + "format": "int32", + "nullable": true + }, + "SubtitleStreamIndex": { + "type": "integer", + "description": "Gets or sets the subtitle stream index.", + "format": "int32", + "nullable": true + }, + "MaxAudioChannels": { + "type": "integer", + "description": "Gets or sets the max audio channels.", + "format": "int32", + "nullable": true + }, + "MediaSourceId": { + "type": "string", + "description": "Gets or sets the media source id.", + "nullable": true + }, + "LiveStreamId": { + "type": "string", + "description": "Gets or sets the live stream id.", + "nullable": true + }, + "DeviceProfile": { + "allOf": [ + { + "$ref": "#/components/schemas/DeviceProfile" + } + ], + "description": "A MediaBrowser.Model.Dlna.DeviceProfile represents a set of metadata which determines which content a certain device is able to play.\r\n
\r\nSpecifically, it defines the supported containers and\r\ncodecs (video and/or audio, including codec profiles and levels)\r\nthe device is able to direct play (without transcoding or remuxing),\r\nas well as which containers/codecs to transcode to in case it isn't.", + "nullable": true + }, + "EnableDirectPlay": { + "type": "boolean", + "description": "Gets or sets a value indicating whether to enable direct play.", + "nullable": true + }, + "EnableDirectStream": { + "type": "boolean", + "description": "Gets or sets a value indicating whether to enable direct stream.", + "nullable": true + }, + "EnableTranscoding": { + "type": "boolean", + "description": "Gets or sets a value indicating whether to enable transcoding.", + "nullable": true + }, + "AllowVideoStreamCopy": { + "type": "boolean", + "description": "Gets or sets a value indicating whether to enable video stream copy.", + "nullable": true + }, + "AllowAudioStreamCopy": { + "type": "boolean", + "description": "Gets or sets a value indicating whether to allow audio stream copy.", + "nullable": true + }, + "AutoOpenLiveStream": { + "type": "boolean", + "description": "Gets or sets a value indicating whether to auto open the live stream.", + "nullable": true + }, + "AlwaysBurnInSubtitleWhenTranscoding": { + "type": "boolean", + "description": "Gets or sets a value indicating whether always burn in subtitles when transcoding.", + "nullable": true + } + }, + "additionalProperties": false, + "description": "Playback info dto." + }, + "PlaybackInfoResponse": { + "type": "object", + "properties": { + "MediaSources": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MediaSourceInfo" + }, + "description": "Gets or sets the media sources." + }, + "PlaySessionId": { + "type": "string", + "description": "Gets or sets the play session identifier.", + "nullable": true + }, + "ErrorCode": { + "enum": [ + "NotAllowed", + "NoCompatibleStream", + "RateLimitExceeded" + ], + "allOf": [ + { + "$ref": "#/components/schemas/PlaybackErrorCode" + } + ], + "description": "Gets or sets the error code.", + "nullable": true + } + }, + "additionalProperties": false, + "description": "Class PlaybackInfoResponse." + }, + "PlaybackOrder": { + "enum": [ + "Default", + "Shuffle" + ], + "type": "string", + "description": "Enum PlaybackOrder." + }, + "PlaybackProgressInfo": { + "type": "object", + "properties": { + "CanSeek": { + "type": "boolean", + "description": "Gets or sets a value indicating whether this instance can seek." + }, + "Item": { + "allOf": [ + { + "$ref": "#/components/schemas/BaseItemDto" + } + ], + "description": "Gets or sets the item.", + "nullable": true + }, + "ItemId": { + "type": "string", + "description": "Gets or sets the item identifier.", + "format": "uuid" + }, + "SessionId": { + "type": "string", + "description": "Gets or sets the session id.", + "nullable": true + }, + "MediaSourceId": { + "type": "string", + "description": "Gets or sets the media version identifier.", + "nullable": true + }, + "AudioStreamIndex": { + "type": "integer", + "description": "Gets or sets the index of the audio stream.", + "format": "int32", + "nullable": true + }, + "SubtitleStreamIndex": { + "type": "integer", + "description": "Gets or sets the index of the subtitle stream.", + "format": "int32", + "nullable": true + }, + "IsPaused": { + "type": "boolean", + "description": "Gets or sets a value indicating whether this instance is paused." + }, + "IsMuted": { + "type": "boolean", + "description": "Gets or sets a value indicating whether this instance is muted." + }, + "PositionTicks": { + "type": "integer", + "description": "Gets or sets the position ticks.", + "format": "int64", + "nullable": true + }, + "PlaybackStartTimeTicks": { + "type": "integer", + "format": "int64", + "nullable": true + }, + "VolumeLevel": { + "type": "integer", + "description": "Gets or sets the volume level.", + "format": "int32", + "nullable": true + }, + "Brightness": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "AspectRatio": { + "type": "string", + "nullable": true + }, + "PlayMethod": { + "enum": [ + "Transcode", + "DirectStream", + "DirectPlay" + ], + "allOf": [ + { + "$ref": "#/components/schemas/PlayMethod" + } + ], + "description": "Gets or sets the play method." + }, + "LiveStreamId": { + "type": "string", + "description": "Gets or sets the live stream identifier.", + "nullable": true + }, + "PlaySessionId": { + "type": "string", + "description": "Gets or sets the play session identifier.", + "nullable": true + }, + "RepeatMode": { + "enum": [ + "RepeatNone", + "RepeatAll", + "RepeatOne" + ], + "allOf": [ + { + "$ref": "#/components/schemas/RepeatMode" + } + ], + "description": "Gets or sets the repeat mode." + }, + "PlaybackOrder": { + "enum": [ + "Default", + "Shuffle" + ], + "allOf": [ + { + "$ref": "#/components/schemas/PlaybackOrder" + } + ], + "description": "Gets or sets the playback order." + }, + "NowPlayingQueue": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QueueItem" + }, + "nullable": true + }, + "PlaylistItemId": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false, + "description": "Class PlaybackProgressInfo." + }, + "PlaybackRequestType": { + "enum": [ + "Play", + "SetPlaylistItem", + "RemoveFromPlaylist", + "MovePlaylistItem", + "Queue", + "Unpause", + "Pause", + "Stop", + "Seek", + "Buffer", + "Ready", + "NextItem", + "PreviousItem", + "SetRepeatMode", + "SetShuffleMode", + "Ping", + "IgnoreWait" + ], + "type": "string", + "description": "Enum PlaybackRequestType." + }, + "PlaybackStartInfo": { + "type": "object", + "properties": { + "CanSeek": { + "type": "boolean", + "description": "Gets or sets a value indicating whether this instance can seek." + }, + "Item": { + "allOf": [ + { + "$ref": "#/components/schemas/BaseItemDto" + } + ], + "description": "Gets or sets the item.", + "nullable": true + }, + "ItemId": { + "type": "string", + "description": "Gets or sets the item identifier.", + "format": "uuid" + }, + "SessionId": { + "type": "string", + "description": "Gets or sets the session id.", + "nullable": true + }, + "MediaSourceId": { + "type": "string", + "description": "Gets or sets the media version identifier.", + "nullable": true + }, + "AudioStreamIndex": { + "type": "integer", + "description": "Gets or sets the index of the audio stream.", + "format": "int32", + "nullable": true + }, + "SubtitleStreamIndex": { + "type": "integer", + "description": "Gets or sets the index of the subtitle stream.", + "format": "int32", + "nullable": true + }, + "IsPaused": { + "type": "boolean", + "description": "Gets or sets a value indicating whether this instance is paused." + }, + "IsMuted": { + "type": "boolean", + "description": "Gets or sets a value indicating whether this instance is muted." + }, + "PositionTicks": { + "type": "integer", + "description": "Gets or sets the position ticks.", + "format": "int64", + "nullable": true + }, + "PlaybackStartTimeTicks": { + "type": "integer", + "format": "int64", + "nullable": true + }, + "VolumeLevel": { + "type": "integer", + "description": "Gets or sets the volume level.", + "format": "int32", + "nullable": true + }, + "Brightness": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "AspectRatio": { + "type": "string", + "nullable": true + }, + "PlayMethod": { + "enum": [ + "Transcode", + "DirectStream", + "DirectPlay" + ], + "allOf": [ + { + "$ref": "#/components/schemas/PlayMethod" + } + ], + "description": "Gets or sets the play method." + }, + "LiveStreamId": { + "type": "string", + "description": "Gets or sets the live stream identifier.", + "nullable": true + }, + "PlaySessionId": { + "type": "string", + "description": "Gets or sets the play session identifier.", + "nullable": true + }, + "RepeatMode": { + "enum": [ + "RepeatNone", + "RepeatAll", + "RepeatOne" + ], + "allOf": [ + { + "$ref": "#/components/schemas/RepeatMode" + } + ], + "description": "Gets or sets the repeat mode." + }, + "PlaybackOrder": { + "enum": [ + "Default", + "Shuffle" + ], + "allOf": [ + { + "$ref": "#/components/schemas/PlaybackOrder" + } + ], + "description": "Gets or sets the playback order." + }, + "NowPlayingQueue": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QueueItem" + }, + "nullable": true + }, + "PlaylistItemId": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false, + "description": "Class PlaybackStartInfo." + }, + "PlaybackStopInfo": { + "type": "object", + "properties": { + "Item": { + "allOf": [ + { + "$ref": "#/components/schemas/BaseItemDto" + } + ], + "description": "Gets or sets the item.", + "nullable": true + }, + "ItemId": { + "type": "string", + "description": "Gets or sets the item identifier.", + "format": "uuid" + }, + "SessionId": { + "type": "string", + "description": "Gets or sets the session id.", + "nullable": true + }, + "MediaSourceId": { + "type": "string", + "description": "Gets or sets the media version identifier.", + "nullable": true + }, + "PositionTicks": { + "type": "integer", + "description": "Gets or sets the position ticks.", + "format": "int64", + "nullable": true + }, + "LiveStreamId": { + "type": "string", + "description": "Gets or sets the live stream identifier.", + "nullable": true + }, + "PlaySessionId": { + "type": "string", + "description": "Gets or sets the play session identifier.", + "nullable": true + }, + "Failed": { + "type": "boolean", + "description": "Gets or sets a value indicating whether this MediaBrowser.Model.Session.PlaybackStopInfo is failed." + }, + "NextMediaType": { + "type": "string", + "nullable": true + }, + "PlaylistItemId": { + "type": "string", + "nullable": true + }, + "NowPlayingQueue": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QueueItem" + }, + "nullable": true + } + }, + "additionalProperties": false, + "description": "Class PlaybackStopInfo." + }, + "PlayCommand": { + "enum": [ + "PlayNow", + "PlayNext", + "PlayLast", + "PlayInstantMix", + "PlayShuffle" + ], + "type": "string", + "description": "Enum PlayCommand." + }, + "PlayerStateInfo": { + "type": "object", + "properties": { + "PositionTicks": { + "type": "integer", + "description": "Gets or sets the now playing position ticks.", + "format": "int64", + "nullable": true + }, + "CanSeek": { + "type": "boolean", + "description": "Gets or sets a value indicating whether this instance can seek." + }, + "IsPaused": { + "type": "boolean", + "description": "Gets or sets a value indicating whether this instance is paused." + }, + "IsMuted": { + "type": "boolean", + "description": "Gets or sets a value indicating whether this instance is muted." + }, + "VolumeLevel": { + "type": "integer", + "description": "Gets or sets the volume level.", + "format": "int32", + "nullable": true + }, + "AudioStreamIndex": { + "type": "integer", + "description": "Gets or sets the index of the now playing audio stream.", + "format": "int32", + "nullable": true + }, + "SubtitleStreamIndex": { + "type": "integer", + "description": "Gets or sets the index of the now playing subtitle stream.", + "format": "int32", + "nullable": true + }, + "MediaSourceId": { + "type": "string", + "description": "Gets or sets the now playing media version identifier.", + "nullable": true + }, + "PlayMethod": { + "enum": [ + "Transcode", + "DirectStream", + "DirectPlay" + ], + "allOf": [ + { + "$ref": "#/components/schemas/PlayMethod" + } + ], + "description": "Gets or sets the play method.", + "nullable": true + }, + "RepeatMode": { + "enum": [ + "RepeatNone", + "RepeatAll", + "RepeatOne" + ], + "allOf": [ + { + "$ref": "#/components/schemas/RepeatMode" + } + ], + "description": "Gets or sets the repeat mode." + }, + "PlaybackOrder": { + "enum": [ + "Default", + "Shuffle" + ], + "allOf": [ + { + "$ref": "#/components/schemas/PlaybackOrder" + } + ], + "description": "Gets or sets the playback order." + }, + "LiveStreamId": { + "type": "string", + "description": "Gets or sets the now playing live stream identifier.", + "nullable": true + } + }, + "additionalProperties": false + }, + "PlaylistCreationResult": { + "type": "object", + "properties": { + "Id": { + "type": "string" + } + }, + "additionalProperties": false + }, + "PlaylistDto": { + "type": "object", + "properties": { + "OpenAccess": { + "type": "boolean", + "description": "Gets or sets a value indicating whether the playlist is publicly readable." + }, + "Shares": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PlaylistUserPermissions" + }, + "description": "Gets or sets the share permissions." + }, + "ItemIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "description": "Gets or sets the item ids." + } + }, + "additionalProperties": false, + "description": "DTO for playlists." + }, + "PlaylistUserPermissions": { + "type": "object", + "properties": { + "UserId": { + "type": "string", + "description": "Gets or sets the user id.", + "format": "uuid" + }, + "CanEdit": { + "type": "boolean", + "description": "Gets or sets a value indicating whether the user has edit permissions." + } + }, + "additionalProperties": false, + "description": "Class to hold data on user permissions for playlists." + }, + "PlayMessage": { + "type": "object", + "properties": { + "Data": { + "allOf": [ + { + "$ref": "#/components/schemas/PlayRequest" + } + ], + "description": "Class PlayRequest.", + "nullable": true + }, + "MessageId": { + "type": "string", + "description": "Gets or sets the message id.", + "format": "uuid" + }, + "MessageType": { + "enum": [ + "ForceKeepAlive", + "GeneralCommand", + "UserDataChanged", + "Sessions", + "Play", + "SyncPlayCommand", + "SyncPlayGroupUpdate", + "Playstate", + "RestartRequired", + "ServerShuttingDown", + "ServerRestarting", + "LibraryChanged", + "UserDeleted", + "UserUpdated", + "SeriesTimerCreated", + "TimerCreated", + "SeriesTimerCancelled", + "TimerCancelled", + "RefreshProgress", + "ScheduledTaskEnded", + "PackageInstallationCancelled", + "PackageInstallationFailed", + "PackageInstallationCompleted", + "PackageInstalling", + "PackageUninstalled", + "ActivityLogEntry", + "ScheduledTasksInfo", + "ActivityLogEntryStart", + "ActivityLogEntryStop", + "SessionsStart", + "SessionsStop", + "ScheduledTasksInfoStart", + "ScheduledTasksInfoStop", + "KeepAlive" + ], + "allOf": [ + { + "$ref": "#/components/schemas/SessionMessageType" + } + ], + "description": "The different kinds of messages that are used in the WebSocket api.", + "default": "Play", + "readOnly": true + } + }, + "additionalProperties": false, + "description": "Play command websocket message." + }, + "PlayMethod": { + "enum": [ + "Transcode", + "DirectStream", + "DirectPlay" + ], + "type": "string" + }, + "PlayQueueUpdate": { + "type": "object", + "properties": { + "Reason": { + "enum": [ + "NewPlaylist", + "SetCurrentItem", + "RemoveItems", + "MoveItem", + "Queue", + "QueueNext", + "NextItem", + "PreviousItem", + "RepeatMode", + "ShuffleMode" + ], + "allOf": [ + { + "$ref": "#/components/schemas/PlayQueueUpdateReason" + } + ], + "description": "Gets the request type that originated this update." + }, + "LastUpdate": { + "type": "string", + "description": "Gets the UTC time of the last change to the playing queue.", + "format": "date-time" + }, + "Playlist": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SyncPlayQueueItem" + }, + "description": "Gets the playlist." + }, + "PlayingItemIndex": { + "type": "integer", + "description": "Gets the playing item index in the playlist.", + "format": "int32" + }, + "StartPositionTicks": { + "type": "integer", + "description": "Gets the start position ticks.", + "format": "int64" + }, + "IsPlaying": { + "type": "boolean", + "description": "Gets a value indicating whether the current item is playing." + }, + "ShuffleMode": { + "enum": [ + "Sorted", + "Shuffle" + ], + "allOf": [ + { + "$ref": "#/components/schemas/GroupShuffleMode" + } + ], + "description": "Gets the shuffle mode." + }, + "RepeatMode": { + "enum": [ + "RepeatOne", + "RepeatAll", + "RepeatNone" + ], + "allOf": [ + { + "$ref": "#/components/schemas/GroupRepeatMode" + } + ], + "description": "Gets the repeat mode." + } + }, + "additionalProperties": false, + "description": "Class PlayQueueUpdate." + }, + "PlayQueueUpdateReason": { + "enum": [ + "NewPlaylist", + "SetCurrentItem", + "RemoveItems", + "MoveItem", + "Queue", + "QueueNext", + "NextItem", + "PreviousItem", + "RepeatMode", + "ShuffleMode" + ], + "type": "string", + "description": "Enum PlayQueueUpdateReason." + }, + "PlayRequest": { + "type": "object", + "properties": { + "ItemIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "description": "Gets or sets the item ids.", + "nullable": true + }, + "StartPositionTicks": { + "type": "integer", + "description": "Gets or sets the start position ticks that the first item should be played at.", + "format": "int64", + "nullable": true + }, + "PlayCommand": { + "enum": [ + "PlayNow", + "PlayNext", + "PlayLast", + "PlayInstantMix", + "PlayShuffle" + ], + "allOf": [ + { + "$ref": "#/components/schemas/PlayCommand" + } + ], + "description": "Gets or sets the play command." + }, + "ControllingUserId": { + "type": "string", + "description": "Gets or sets the controlling user identifier.", + "format": "uuid" + }, + "SubtitleStreamIndex": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "AudioStreamIndex": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "MediaSourceId": { + "type": "string", + "nullable": true + }, + "StartIndex": { + "type": "integer", + "format": "int32", + "nullable": true + } + }, + "additionalProperties": false, + "description": "Class PlayRequest." + }, + "PlayRequestDto": { + "type": "object", + "properties": { + "PlayingQueue": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "description": "Gets or sets the playing queue." + }, + "PlayingItemPosition": { + "type": "integer", + "description": "Gets or sets the position of the playing item in the queue.", + "format": "int32" + }, + "StartPositionTicks": { + "type": "integer", + "description": "Gets or sets the start position ticks.", + "format": "int64" + } + }, + "additionalProperties": false, + "description": "Class PlayRequestDto." + }, + "PlaystateCommand": { + "enum": [ + "Stop", + "Pause", + "Unpause", + "NextTrack", + "PreviousTrack", + "Seek", + "Rewind", + "FastForward", + "PlayPause" + ], + "type": "string", + "description": "Enum PlaystateCommand." + }, + "PlaystateMessage": { + "type": "object", + "properties": { + "Data": { + "allOf": [ + { + "$ref": "#/components/schemas/PlaystateRequest" + } + ], + "description": "Gets or sets the data.", + "nullable": true + }, + "MessageId": { + "type": "string", + "description": "Gets or sets the message id.", + "format": "uuid" + }, + "MessageType": { + "enum": [ + "ForceKeepAlive", + "GeneralCommand", + "UserDataChanged", + "Sessions", + "Play", + "SyncPlayCommand", + "SyncPlayGroupUpdate", + "Playstate", + "RestartRequired", + "ServerShuttingDown", + "ServerRestarting", + "LibraryChanged", + "UserDeleted", + "UserUpdated", + "SeriesTimerCreated", + "TimerCreated", + "SeriesTimerCancelled", + "TimerCancelled", + "RefreshProgress", + "ScheduledTaskEnded", + "PackageInstallationCancelled", + "PackageInstallationFailed", + "PackageInstallationCompleted", + "PackageInstalling", + "PackageUninstalled", + "ActivityLogEntry", + "ScheduledTasksInfo", + "ActivityLogEntryStart", + "ActivityLogEntryStop", + "SessionsStart", + "SessionsStop", + "ScheduledTasksInfoStart", + "ScheduledTasksInfoStop", + "KeepAlive" + ], + "allOf": [ + { + "$ref": "#/components/schemas/SessionMessageType" + } + ], + "description": "The different kinds of messages that are used in the WebSocket api.", + "default": "Playstate", + "readOnly": true + } + }, + "additionalProperties": false, + "description": "Playstate message." + }, + "PlaystateRequest": { + "type": "object", + "properties": { + "Command": { + "enum": [ + "Stop", + "Pause", + "Unpause", + "NextTrack", + "PreviousTrack", + "Seek", + "Rewind", + "FastForward", + "PlayPause" + ], + "allOf": [ + { + "$ref": "#/components/schemas/PlaystateCommand" + } + ], + "description": "Enum PlaystateCommand." + }, + "SeekPositionTicks": { + "type": "integer", + "format": "int64", + "nullable": true + }, + "ControllingUserId": { + "type": "string", + "description": "Gets or sets the controlling user identifier.", + "nullable": true + } + }, + "additionalProperties": false + }, + "PluginInfo": { + "type": "object", + "properties": { + "Name": { + "type": "string", + "description": "Gets or sets the name." + }, + "Version": { + "type": "string", + "description": "Gets or sets the version." + }, + "ConfigurationFileName": { + "type": "string", + "description": "Gets or sets the name of the configuration file.", + "nullable": true + }, + "Description": { + "type": "string", + "description": "Gets or sets the description." + }, + "Id": { + "type": "string", + "description": "Gets or sets the unique id.", + "format": "uuid" + }, + "CanUninstall": { + "type": "boolean", + "description": "Gets or sets a value indicating whether the plugin can be uninstalled." + }, + "HasImage": { + "type": "boolean", + "description": "Gets or sets a value indicating whether this plugin has a valid image." + }, + "Status": { + "enum": [ + "Active", + "Restart", + "Deleted", + "Superseded", + "Superceded", + "Malfunctioned", + "NotSupported", + "Disabled" + ], + "allOf": [ + { + "$ref": "#/components/schemas/PluginStatus" + } + ], + "description": "Gets or sets a value indicating the status of the plugin." + } + }, + "additionalProperties": false, + "description": "This is a serializable stub class that is used by the api to provide information about installed plugins." + }, + "PluginInstallationCancelledMessage": { + "type": "object", + "properties": { + "Data": { + "allOf": [ + { + "$ref": "#/components/schemas/InstallationInfo" + } + ], + "description": "Class InstallationInfo.", + "nullable": true + }, + "MessageId": { + "type": "string", + "description": "Gets or sets the message id.", + "format": "uuid" + }, + "MessageType": { + "enum": [ + "ForceKeepAlive", + "GeneralCommand", + "UserDataChanged", + "Sessions", + "Play", + "SyncPlayCommand", + "SyncPlayGroupUpdate", + "Playstate", + "RestartRequired", + "ServerShuttingDown", + "ServerRestarting", + "LibraryChanged", + "UserDeleted", + "UserUpdated", + "SeriesTimerCreated", + "TimerCreated", + "SeriesTimerCancelled", + "TimerCancelled", + "RefreshProgress", + "ScheduledTaskEnded", + "PackageInstallationCancelled", + "PackageInstallationFailed", + "PackageInstallationCompleted", + "PackageInstalling", + "PackageUninstalled", + "ActivityLogEntry", + "ScheduledTasksInfo", + "ActivityLogEntryStart", + "ActivityLogEntryStop", + "SessionsStart", + "SessionsStop", + "ScheduledTasksInfoStart", + "ScheduledTasksInfoStop", + "KeepAlive" + ], + "allOf": [ + { + "$ref": "#/components/schemas/SessionMessageType" + } + ], + "description": "The different kinds of messages that are used in the WebSocket api.", + "default": "PackageInstallationCancelled", + "readOnly": true + } + }, + "additionalProperties": false, + "description": "Plugin installation cancelled message." + }, + "PluginInstallationCompletedMessage": { + "type": "object", + "properties": { + "Data": { + "allOf": [ + { + "$ref": "#/components/schemas/InstallationInfo" + } + ], + "description": "Class InstallationInfo.", + "nullable": true + }, + "MessageId": { + "type": "string", + "description": "Gets or sets the message id.", + "format": "uuid" + }, + "MessageType": { + "enum": [ + "ForceKeepAlive", + "GeneralCommand", + "UserDataChanged", + "Sessions", + "Play", + "SyncPlayCommand", + "SyncPlayGroupUpdate", + "Playstate", + "RestartRequired", + "ServerShuttingDown", + "ServerRestarting", + "LibraryChanged", + "UserDeleted", + "UserUpdated", + "SeriesTimerCreated", + "TimerCreated", + "SeriesTimerCancelled", + "TimerCancelled", + "RefreshProgress", + "ScheduledTaskEnded", + "PackageInstallationCancelled", + "PackageInstallationFailed", + "PackageInstallationCompleted", + "PackageInstalling", + "PackageUninstalled", + "ActivityLogEntry", + "ScheduledTasksInfo", + "ActivityLogEntryStart", + "ActivityLogEntryStop", + "SessionsStart", + "SessionsStop", + "ScheduledTasksInfoStart", + "ScheduledTasksInfoStop", + "KeepAlive" + ], + "allOf": [ + { + "$ref": "#/components/schemas/SessionMessageType" + } + ], + "description": "The different kinds of messages that are used in the WebSocket api.", + "default": "PackageInstallationCompleted", + "readOnly": true + } + }, + "additionalProperties": false, + "description": "Plugin installation completed message." + }, + "PluginInstallationFailedMessage": { + "type": "object", + "properties": { + "Data": { + "allOf": [ + { + "$ref": "#/components/schemas/InstallationInfo" + } + ], + "description": "Class InstallationInfo.", + "nullable": true + }, + "MessageId": { + "type": "string", + "description": "Gets or sets the message id.", + "format": "uuid" + }, + "MessageType": { + "enum": [ + "ForceKeepAlive", + "GeneralCommand", + "UserDataChanged", + "Sessions", + "Play", + "SyncPlayCommand", + "SyncPlayGroupUpdate", + "Playstate", + "RestartRequired", + "ServerShuttingDown", + "ServerRestarting", + "LibraryChanged", + "UserDeleted", + "UserUpdated", + "SeriesTimerCreated", + "TimerCreated", + "SeriesTimerCancelled", + "TimerCancelled", + "RefreshProgress", + "ScheduledTaskEnded", + "PackageInstallationCancelled", + "PackageInstallationFailed", + "PackageInstallationCompleted", + "PackageInstalling", + "PackageUninstalled", + "ActivityLogEntry", + "ScheduledTasksInfo", + "ActivityLogEntryStart", + "ActivityLogEntryStop", + "SessionsStart", + "SessionsStop", + "ScheduledTasksInfoStart", + "ScheduledTasksInfoStop", + "KeepAlive" + ], + "allOf": [ + { + "$ref": "#/components/schemas/SessionMessageType" + } + ], + "description": "The different kinds of messages that are used in the WebSocket api.", + "default": "PackageInstallationFailed", + "readOnly": true + } + }, + "additionalProperties": false, + "description": "Plugin installation failed message." + }, + "PluginInstallingMessage": { + "type": "object", + "properties": { + "Data": { + "allOf": [ + { + "$ref": "#/components/schemas/InstallationInfo" + } + ], + "description": "Class InstallationInfo.", + "nullable": true + }, + "MessageId": { + "type": "string", + "description": "Gets or sets the message id.", + "format": "uuid" + }, + "MessageType": { + "enum": [ + "ForceKeepAlive", + "GeneralCommand", + "UserDataChanged", + "Sessions", + "Play", + "SyncPlayCommand", + "SyncPlayGroupUpdate", + "Playstate", + "RestartRequired", + "ServerShuttingDown", + "ServerRestarting", + "LibraryChanged", + "UserDeleted", + "UserUpdated", + "SeriesTimerCreated", + "TimerCreated", + "SeriesTimerCancelled", + "TimerCancelled", + "RefreshProgress", + "ScheduledTaskEnded", + "PackageInstallationCancelled", + "PackageInstallationFailed", + "PackageInstallationCompleted", + "PackageInstalling", + "PackageUninstalled", + "ActivityLogEntry", + "ScheduledTasksInfo", + "ActivityLogEntryStart", + "ActivityLogEntryStop", + "SessionsStart", + "SessionsStop", + "ScheduledTasksInfoStart", + "ScheduledTasksInfoStop", + "KeepAlive" + ], + "allOf": [ + { + "$ref": "#/components/schemas/SessionMessageType" + } + ], + "description": "The different kinds of messages that are used in the WebSocket api.", + "default": "PackageInstalling", + "readOnly": true + } + }, + "additionalProperties": false, + "description": "Package installing message." + }, + "PluginStatus": { + "enum": [ + "Active", + "Restart", + "Deleted", + "Superseded", + "Superceded", + "Malfunctioned", + "NotSupported", + "Disabled" + ], + "type": "string", + "description": "Plugin load status." + }, + "PluginUninstalledMessage": { + "type": "object", + "properties": { + "Data": { + "allOf": [ + { + "$ref": "#/components/schemas/PluginInfo" + } + ], + "description": "This is a serializable stub class that is used by the api to provide information about installed plugins.", + "nullable": true + }, + "MessageId": { + "type": "string", + "description": "Gets or sets the message id.", + "format": "uuid" + }, + "MessageType": { + "enum": [ + "ForceKeepAlive", + "GeneralCommand", + "UserDataChanged", + "Sessions", + "Play", + "SyncPlayCommand", + "SyncPlayGroupUpdate", + "Playstate", + "RestartRequired", + "ServerShuttingDown", + "ServerRestarting", + "LibraryChanged", + "UserDeleted", + "UserUpdated", + "SeriesTimerCreated", + "TimerCreated", + "SeriesTimerCancelled", + "TimerCancelled", + "RefreshProgress", + "ScheduledTaskEnded", + "PackageInstallationCancelled", + "PackageInstallationFailed", + "PackageInstallationCompleted", + "PackageInstalling", + "PackageUninstalled", + "ActivityLogEntry", + "ScheduledTasksInfo", + "ActivityLogEntryStart", + "ActivityLogEntryStop", + "SessionsStart", + "SessionsStop", + "ScheduledTasksInfoStart", + "ScheduledTasksInfoStop", + "KeepAlive" + ], + "allOf": [ + { + "$ref": "#/components/schemas/SessionMessageType" + } + ], + "description": "The different kinds of messages that are used in the WebSocket api.", + "default": "PackageUninstalled", + "readOnly": true + } + }, + "additionalProperties": false, + "description": "Plugin uninstalled message." + }, + "PreviousItemRequestDto": { + "type": "object", + "properties": { + "PlaylistItemId": { + "type": "string", + "description": "Gets or sets the playing item identifier.", + "format": "uuid" + } + }, + "additionalProperties": false, + "description": "Class PreviousItemRequestDto." + }, + "ProblemDetails": { + "type": "object", + "properties": { + "type": { + "type": "string", + "nullable": true + }, + "title": { + "type": "string", + "nullable": true + }, + "status": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "detail": { + "type": "string", + "nullable": true + }, + "instance": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": { } + }, + "ProcessPriorityClass": { + "enum": [ + "Normal", + "Idle", + "High", + "RealTime", + "BelowNormal", + "AboveNormal" + ], + "type": "string" + }, + "ProfileCondition": { + "type": "object", + "properties": { + "Condition": { + "enum": [ + "Equals", + "NotEquals", + "LessThanEqual", + "GreaterThanEqual", + "EqualsAny" + ], + "allOf": [ + { + "$ref": "#/components/schemas/ProfileConditionType" + } + ] + }, + "Property": { + "enum": [ + "AudioChannels", + "AudioBitrate", + "AudioProfile", + "Width", + "Height", + "Has64BitOffsets", + "PacketLength", + "VideoBitDepth", + "VideoBitrate", + "VideoFramerate", + "VideoLevel", + "VideoProfile", + "VideoTimestamp", + "IsAnamorphic", + "RefFrames", + "NumAudioStreams", + "NumVideoStreams", + "IsSecondaryAudio", + "VideoCodecTag", + "IsAvc", + "IsInterlaced", + "AudioSampleRate", + "AudioBitDepth", + "VideoRangeType", + "NumStreams" + ], + "allOf": [ + { + "$ref": "#/components/schemas/ProfileConditionValue" + } + ] + }, + "Value": { + "type": "string", + "nullable": true + }, + "IsRequired": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "ProfileConditionType": { + "enum": [ + "Equals", + "NotEquals", + "LessThanEqual", + "GreaterThanEqual", + "EqualsAny" + ], + "type": "string" + }, + "ProfileConditionValue": { + "enum": [ + "AudioChannels", + "AudioBitrate", + "AudioProfile", + "Width", + "Height", + "Has64BitOffsets", + "PacketLength", + "VideoBitDepth", + "VideoBitrate", + "VideoFramerate", + "VideoLevel", + "VideoProfile", + "VideoTimestamp", + "IsAnamorphic", + "RefFrames", + "NumAudioStreams", + "NumVideoStreams", + "IsSecondaryAudio", + "VideoCodecTag", + "IsAvc", + "IsInterlaced", + "AudioSampleRate", + "AudioBitDepth", + "VideoRangeType", + "NumStreams" + ], + "type": "string" + }, + "ProgramAudio": { + "enum": [ + "Mono", + "Stereo", + "Dolby", + "DolbyDigital", + "Thx", + "Atmos" + ], + "type": "string" + }, + "PublicSystemInfo": { + "type": "object", + "properties": { + "LocalAddress": { + "type": "string", + "description": "Gets or sets the local address.", + "nullable": true + }, + "ServerName": { + "type": "string", + "description": "Gets or sets the name of the server.", + "nullable": true + }, + "Version": { + "type": "string", + "description": "Gets or sets the server version.", + "nullable": true + }, + "ProductName": { + "type": "string", + "description": "Gets or sets the product name. This is the AssemblyProduct name.", + "nullable": true + }, + "OperatingSystem": { + "type": "string", + "description": "Gets or sets the operating system.", + "nullable": true, + "deprecated": true + }, + "Id": { + "type": "string", + "description": "Gets or sets the id.", + "nullable": true + }, + "StartupWizardCompleted": { + "type": "boolean", + "description": "Gets or sets a value indicating whether the startup wizard is completed.", + "nullable": true + } + }, + "additionalProperties": false + }, + "QueryFilters": { + "type": "object", + "properties": { + "Genres": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NameGuidPair" + }, + "nullable": true + }, + "Tags": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + } + }, + "additionalProperties": false + }, + "QueryFiltersLegacy": { + "type": "object", + "properties": { + "Genres": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "Tags": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "OfficialRatings": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "Years": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + }, + "nullable": true + } + }, + "additionalProperties": false + }, + "QueueItem": { + "type": "object", + "properties": { + "Id": { + "type": "string", + "format": "uuid" + }, + "PlaylistItemId": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, + "QueueRequestDto": { + "type": "object", + "properties": { + "ItemIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "description": "Gets or sets the items to enqueue." + }, + "Mode": { + "enum": [ + "Queue", + "QueueNext" + ], + "allOf": [ + { + "$ref": "#/components/schemas/GroupQueueMode" + } + ], + "description": "Enum GroupQueueMode." + } + }, + "additionalProperties": false, + "description": "Class QueueRequestDto." + }, + "QuickConnectDto": { + "required": [ + "Secret" + ], + "type": "object", + "properties": { + "Secret": { + "type": "string", + "description": "Gets or sets the quick connect secret." + } + }, + "additionalProperties": false, + "description": "The quick connect request body." + }, + "QuickConnectResult": { + "type": "object", + "properties": { + "Authenticated": { + "type": "boolean", + "description": "Gets or sets a value indicating whether this request is authorized." + }, + "Secret": { + "type": "string", + "description": "Gets the secret value used to uniquely identify this request. Can be used to retrieve authentication information." + }, + "Code": { + "type": "string", + "description": "Gets the user facing code used so the user can quickly differentiate this request from others." + }, + "DeviceId": { + "type": "string", + "description": "Gets the requesting device id." + }, + "DeviceName": { + "type": "string", + "description": "Gets the requesting device name." + }, + "AppName": { + "type": "string", + "description": "Gets the requesting app name." + }, + "AppVersion": { + "type": "string", + "description": "Gets the requesting app version." + }, + "DateAdded": { + "type": "string", + "description": "Gets or sets the DateTime that this request was created.", + "format": "date-time" + } + }, + "additionalProperties": false, + "description": "Stores the state of an quick connect request." + }, + "RatingType": { + "enum": [ + "Score", + "Likes" + ], + "type": "string" + }, + "ReadyRequestDto": { + "type": "object", + "properties": { + "When": { + "type": "string", + "description": "Gets or sets when the request has been made by the client.", + "format": "date-time" + }, + "PositionTicks": { + "type": "integer", + "description": "Gets or sets the position ticks.", + "format": "int64" + }, + "IsPlaying": { + "type": "boolean", + "description": "Gets or sets a value indicating whether the client playback is unpaused." + }, + "PlaylistItemId": { + "type": "string", + "description": "Gets or sets the playlist item identifier of the playing item.", + "format": "uuid" + } + }, + "additionalProperties": false, + "description": "Class ReadyRequest." + }, + "RecommendationDto": { + "type": "object", + "properties": { + "Items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BaseItemDto" + }, + "nullable": true + }, + "RecommendationType": { + "enum": [ + "SimilarToRecentlyPlayed", + "SimilarToLikedItem", + "HasDirectorFromRecentlyPlayed", + "HasActorFromRecentlyPlayed", + "HasLikedDirector", + "HasLikedActor" + ], + "allOf": [ + { + "$ref": "#/components/schemas/RecommendationType" + } + ] + }, + "BaselineItemName": { + "type": "string", + "nullable": true + }, + "CategoryId": { + "type": "string", + "format": "uuid" + } + }, + "additionalProperties": false + }, + "RecommendationType": { + "enum": [ + "SimilarToRecentlyPlayed", + "SimilarToLikedItem", + "HasDirectorFromRecentlyPlayed", + "HasActorFromRecentlyPlayed", + "HasLikedDirector", + "HasLikedActor" + ], + "type": "string" + }, + "RecordingStatus": { + "enum": [ + "New", + "InProgress", + "Completed", + "Cancelled", + "ConflictedOk", + "ConflictedNotOk", + "Error" + ], + "type": "string" + }, + "RefreshProgressMessage": { + "type": "object", + "properties": { + "Data": { + "type": "object", + "additionalProperties": { + "type": "string", + "nullable": true + }, + "description": "Gets or sets the data.", + "nullable": true + }, + "MessageId": { + "type": "string", + "description": "Gets or sets the message id.", + "format": "uuid" + }, + "MessageType": { + "enum": [ + "ForceKeepAlive", + "GeneralCommand", + "UserDataChanged", + "Sessions", + "Play", + "SyncPlayCommand", + "SyncPlayGroupUpdate", + "Playstate", + "RestartRequired", + "ServerShuttingDown", + "ServerRestarting", + "LibraryChanged", + "UserDeleted", + "UserUpdated", + "SeriesTimerCreated", + "TimerCreated", + "SeriesTimerCancelled", + "TimerCancelled", + "RefreshProgress", + "ScheduledTaskEnded", + "PackageInstallationCancelled", + "PackageInstallationFailed", + "PackageInstallationCompleted", + "PackageInstalling", + "PackageUninstalled", + "ActivityLogEntry", + "ScheduledTasksInfo", + "ActivityLogEntryStart", + "ActivityLogEntryStop", + "SessionsStart", + "SessionsStop", + "ScheduledTasksInfoStart", + "ScheduledTasksInfoStop", + "KeepAlive" + ], + "allOf": [ + { + "$ref": "#/components/schemas/SessionMessageType" + } + ], + "description": "The different kinds of messages that are used in the WebSocket api.", + "default": "RefreshProgress", + "readOnly": true + } + }, + "additionalProperties": false, + "description": "Refresh progress message." + }, + "RemoteImageInfo": { + "type": "object", + "properties": { + "ProviderName": { + "type": "string", + "description": "Gets or sets the name of the provider.", + "nullable": true + }, + "Url": { + "type": "string", + "description": "Gets or sets the URL.", + "nullable": true + }, + "ThumbnailUrl": { + "type": "string", + "description": "Gets or sets a url used for previewing a smaller version.", + "nullable": true + }, + "Height": { + "type": "integer", + "description": "Gets or sets the height.", + "format": "int32", + "nullable": true + }, + "Width": { + "type": "integer", + "description": "Gets or sets the width.", + "format": "int32", + "nullable": true + }, + "CommunityRating": { + "type": "number", + "description": "Gets or sets the community rating.", + "format": "double", + "nullable": true + }, + "VoteCount": { + "type": "integer", + "description": "Gets or sets the vote count.", + "format": "int32", + "nullable": true + }, + "Language": { + "type": "string", + "description": "Gets or sets the language.", + "nullable": true + }, + "Type": { + "enum": [ + "Primary", + "Art", + "Backdrop", + "Banner", + "Logo", + "Thumb", + "Disc", + "Box", + "Screenshot", + "Menu", + "Chapter", + "BoxRear", + "Profile" + ], + "allOf": [ + { + "$ref": "#/components/schemas/ImageType" + } + ], + "description": "Gets or sets the type." + }, + "RatingType": { + "enum": [ + "Score", + "Likes" + ], + "allOf": [ + { + "$ref": "#/components/schemas/RatingType" + } + ], + "description": "Gets or sets the type of the rating." + } + }, + "additionalProperties": false, + "description": "Class RemoteImageInfo." + }, + "RemoteImageResult": { + "type": "object", + "properties": { + "Images": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RemoteImageInfo" + }, + "description": "Gets or sets the images.", + "nullable": true + }, + "TotalRecordCount": { + "type": "integer", + "description": "Gets or sets the total record count.", + "format": "int32" + }, + "Providers": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Gets or sets the providers.", + "nullable": true + } + }, + "additionalProperties": false, + "description": "Class RemoteImageResult." + }, + "RemoteLyricInfoDto": { + "type": "object", + "properties": { + "Id": { + "type": "string", + "description": "Gets or sets the id for the lyric." + }, + "ProviderName": { + "type": "string", + "description": "Gets the provider name." + }, + "Lyrics": { + "allOf": [ + { + "$ref": "#/components/schemas/LyricDto" + } + ], + "description": "Gets the lyrics." + } + }, + "additionalProperties": false, + "description": "The remote lyric info dto." + }, + "RemoteSearchResult": { + "type": "object", + "properties": { + "Name": { + "type": "string", + "description": "Gets or sets the name.", + "nullable": true + }, + "ProviderIds": { + "type": "object", + "additionalProperties": { + "type": "string", + "nullable": true + }, + "description": "Gets or sets the provider ids.", + "nullable": true + }, + "ProductionYear": { + "type": "integer", + "description": "Gets or sets the year.", + "format": "int32", + "nullable": true + }, + "IndexNumber": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "IndexNumberEnd": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "ParentIndexNumber": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "PremiereDate": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "ImageUrl": { + "type": "string", + "nullable": true + }, + "SearchProviderName": { + "type": "string", + "nullable": true + }, + "Overview": { + "type": "string", + "nullable": true + }, + "AlbumArtist": { + "allOf": [ + { + "$ref": "#/components/schemas/RemoteSearchResult" + } + ], + "nullable": true + }, + "Artists": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RemoteSearchResult" + }, + "nullable": true + } + }, + "additionalProperties": false + }, + "RemoteSubtitleInfo": { + "type": "object", + "properties": { + "ThreeLetterISOLanguageName": { + "type": "string", + "nullable": true + }, + "Id": { + "type": "string", + "nullable": true + }, + "ProviderName": { + "type": "string", + "nullable": true + }, + "Name": { + "type": "string", + "nullable": true + }, + "Format": { + "type": "string", + "nullable": true + }, + "Author": { + "type": "string", + "nullable": true + }, + "Comment": { + "type": "string", + "nullable": true + }, + "DateCreated": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "CommunityRating": { + "type": "number", + "format": "float", + "nullable": true + }, + "FrameRate": { + "type": "number", + "format": "float", + "nullable": true + }, + "DownloadCount": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "IsHashMatch": { + "type": "boolean", + "nullable": true + }, + "AiTranslated": { + "type": "boolean", + "nullable": true + }, + "MachineTranslated": { + "type": "boolean", + "nullable": true + }, + "Forced": { + "type": "boolean", + "nullable": true + }, + "HearingImpaired": { + "type": "boolean", + "nullable": true + } + }, + "additionalProperties": false + }, + "RemoveFromPlaylistRequestDto": { + "type": "object", + "properties": { + "PlaylistItemIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "description": "Gets or sets the playlist identifiers of the items. Ignored when clearing the playlist." + }, + "ClearPlaylist": { + "type": "boolean", + "description": "Gets or sets a value indicating whether the entire playlist should be cleared." + }, + "ClearPlayingItem": { + "type": "boolean", + "description": "Gets or sets a value indicating whether the playing item should be removed as well. Used only when clearing the playlist." + } + }, + "additionalProperties": false, + "description": "Class RemoveFromPlaylistRequestDto." + }, + "RepeatMode": { + "enum": [ + "RepeatNone", + "RepeatAll", + "RepeatOne" + ], + "type": "string" + }, + "RepositoryInfo": { + "type": "object", + "properties": { + "Name": { + "type": "string", + "description": "Gets or sets the name.", + "nullable": true + }, + "Url": { + "type": "string", + "description": "Gets or sets the URL.", + "nullable": true + }, + "Enabled": { + "type": "boolean", + "description": "Gets or sets a value indicating whether the repository is enabled." + } + }, + "additionalProperties": false, + "description": "Class RepositoryInfo." + }, + "RestartRequiredMessage": { + "type": "object", + "properties": { + "MessageId": { + "type": "string", + "description": "Gets or sets the message id.", + "format": "uuid" + }, + "MessageType": { + "enum": [ + "ForceKeepAlive", + "GeneralCommand", + "UserDataChanged", + "Sessions", + "Play", + "SyncPlayCommand", + "SyncPlayGroupUpdate", + "Playstate", + "RestartRequired", + "ServerShuttingDown", + "ServerRestarting", + "LibraryChanged", + "UserDeleted", + "UserUpdated", + "SeriesTimerCreated", + "TimerCreated", + "SeriesTimerCancelled", + "TimerCancelled", + "RefreshProgress", + "ScheduledTaskEnded", + "PackageInstallationCancelled", + "PackageInstallationFailed", + "PackageInstallationCompleted", + "PackageInstalling", + "PackageUninstalled", + "ActivityLogEntry", + "ScheduledTasksInfo", + "ActivityLogEntryStart", + "ActivityLogEntryStop", + "SessionsStart", + "SessionsStop", + "ScheduledTasksInfoStart", + "ScheduledTasksInfoStop", + "KeepAlive" + ], + "allOf": [ + { + "$ref": "#/components/schemas/SessionMessageType" + } + ], + "description": "The different kinds of messages that are used in the WebSocket api.", + "default": "RestartRequired", + "readOnly": true + } + }, + "additionalProperties": false, + "description": "Restart required." + }, + "ScheduledTaskEndedMessage": { + "type": "object", + "properties": { + "Data": { + "allOf": [ + { + "$ref": "#/components/schemas/TaskResult" + } + ], + "description": "Class TaskExecutionInfo.", + "nullable": true + }, + "MessageId": { + "type": "string", + "description": "Gets or sets the message id.", + "format": "uuid" + }, + "MessageType": { + "enum": [ + "ForceKeepAlive", + "GeneralCommand", + "UserDataChanged", + "Sessions", + "Play", + "SyncPlayCommand", + "SyncPlayGroupUpdate", + "Playstate", + "RestartRequired", + "ServerShuttingDown", + "ServerRestarting", + "LibraryChanged", + "UserDeleted", + "UserUpdated", + "SeriesTimerCreated", + "TimerCreated", + "SeriesTimerCancelled", + "TimerCancelled", + "RefreshProgress", + "ScheduledTaskEnded", + "PackageInstallationCancelled", + "PackageInstallationFailed", + "PackageInstallationCompleted", + "PackageInstalling", + "PackageUninstalled", + "ActivityLogEntry", + "ScheduledTasksInfo", + "ActivityLogEntryStart", + "ActivityLogEntryStop", + "SessionsStart", + "SessionsStop", + "ScheduledTasksInfoStart", + "ScheduledTasksInfoStop", + "KeepAlive" + ], + "allOf": [ + { + "$ref": "#/components/schemas/SessionMessageType" + } + ], + "description": "The different kinds of messages that are used in the WebSocket api.", + "default": "ScheduledTaskEnded", + "readOnly": true + } + }, + "additionalProperties": false, + "description": "Scheduled task ended message." + }, + "ScheduledTasksInfoMessage": { + "type": "object", + "properties": { + "Data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TaskInfo" + }, + "description": "Gets or sets the data.", + "nullable": true + }, + "MessageId": { + "type": "string", + "description": "Gets or sets the message id.", + "format": "uuid" + }, + "MessageType": { + "enum": [ + "ForceKeepAlive", + "GeneralCommand", + "UserDataChanged", + "Sessions", + "Play", + "SyncPlayCommand", + "SyncPlayGroupUpdate", + "Playstate", + "RestartRequired", + "ServerShuttingDown", + "ServerRestarting", + "LibraryChanged", + "UserDeleted", + "UserUpdated", + "SeriesTimerCreated", + "TimerCreated", + "SeriesTimerCancelled", + "TimerCancelled", + "RefreshProgress", + "ScheduledTaskEnded", + "PackageInstallationCancelled", + "PackageInstallationFailed", + "PackageInstallationCompleted", + "PackageInstalling", + "PackageUninstalled", + "ActivityLogEntry", + "ScheduledTasksInfo", + "ActivityLogEntryStart", + "ActivityLogEntryStop", + "SessionsStart", + "SessionsStop", + "ScheduledTasksInfoStart", + "ScheduledTasksInfoStop", + "KeepAlive" + ], + "allOf": [ + { + "$ref": "#/components/schemas/SessionMessageType" + } + ], + "description": "The different kinds of messages that are used in the WebSocket api.", + "default": "ScheduledTasksInfo", + "readOnly": true + } + }, + "additionalProperties": false, + "description": "Scheduled tasks info message." + }, + "ScheduledTasksInfoStartMessage": { + "type": "object", + "properties": { + "Data": { + "type": "string", + "description": "Gets or sets the data.", + "nullable": true + }, + "MessageType": { + "enum": [ + "ForceKeepAlive", + "GeneralCommand", + "UserDataChanged", + "Sessions", + "Play", + "SyncPlayCommand", + "SyncPlayGroupUpdate", + "Playstate", + "RestartRequired", + "ServerShuttingDown", + "ServerRestarting", + "LibraryChanged", + "UserDeleted", + "UserUpdated", + "SeriesTimerCreated", + "TimerCreated", + "SeriesTimerCancelled", + "TimerCancelled", + "RefreshProgress", + "ScheduledTaskEnded", + "PackageInstallationCancelled", + "PackageInstallationFailed", + "PackageInstallationCompleted", + "PackageInstalling", + "PackageUninstalled", + "ActivityLogEntry", + "ScheduledTasksInfo", + "ActivityLogEntryStart", + "ActivityLogEntryStop", + "SessionsStart", + "SessionsStop", + "ScheduledTasksInfoStart", + "ScheduledTasksInfoStop", + "KeepAlive" + ], + "allOf": [ + { + "$ref": "#/components/schemas/SessionMessageType" + } + ], + "description": "The different kinds of messages that are used in the WebSocket api.", + "default": "ScheduledTasksInfoStart", + "readOnly": true + } + }, + "additionalProperties": false, + "description": "Scheduled tasks info start message.\r\nData is the timing data encoded as \"$initialDelay,$interval\" in ms." + }, + "ScheduledTasksInfoStopMessage": { + "type": "object", + "properties": { + "MessageType": { + "enum": [ + "ForceKeepAlive", + "GeneralCommand", + "UserDataChanged", + "Sessions", + "Play", + "SyncPlayCommand", + "SyncPlayGroupUpdate", + "Playstate", + "RestartRequired", + "ServerShuttingDown", + "ServerRestarting", + "LibraryChanged", + "UserDeleted", + "UserUpdated", + "SeriesTimerCreated", + "TimerCreated", + "SeriesTimerCancelled", + "TimerCancelled", + "RefreshProgress", + "ScheduledTaskEnded", + "PackageInstallationCancelled", + "PackageInstallationFailed", + "PackageInstallationCompleted", + "PackageInstalling", + "PackageUninstalled", + "ActivityLogEntry", + "ScheduledTasksInfo", + "ActivityLogEntryStart", + "ActivityLogEntryStop", + "SessionsStart", + "SessionsStop", + "ScheduledTasksInfoStart", + "ScheduledTasksInfoStop", + "KeepAlive" + ], + "allOf": [ + { + "$ref": "#/components/schemas/SessionMessageType" + } + ], + "description": "The different kinds of messages that are used in the WebSocket api.", + "default": "ScheduledTasksInfoStop", + "readOnly": true + } + }, + "additionalProperties": false, + "description": "Scheduled tasks info stop message." + }, + "ScrollDirection": { + "enum": [ + "Horizontal", + "Vertical" + ], + "type": "string", + "description": "An enum representing the axis that should be scrolled." + }, + "SearchHint": { + "type": "object", + "properties": { + "ItemId": { + "type": "string", + "description": "Gets or sets the item id.", + "format": "uuid", + "deprecated": true + }, + "Id": { + "type": "string", + "description": "Gets or sets the item id.", + "format": "uuid" + }, + "Name": { + "type": "string", + "description": "Gets or sets the name." + }, + "MatchedTerm": { + "type": "string", + "description": "Gets or sets the matched term.", + "nullable": true + }, + "IndexNumber": { + "type": "integer", + "description": "Gets or sets the index number.", + "format": "int32", + "nullable": true + }, + "ProductionYear": { + "type": "integer", + "description": "Gets or sets the production year.", + "format": "int32", + "nullable": true + }, + "ParentIndexNumber": { + "type": "integer", + "description": "Gets or sets the parent index number.", + "format": "int32", + "nullable": true + }, + "PrimaryImageTag": { + "type": "string", + "description": "Gets or sets the image tag.", + "nullable": true + }, + "ThumbImageTag": { + "type": "string", + "description": "Gets or sets the thumb image tag.", + "nullable": true + }, + "ThumbImageItemId": { + "type": "string", + "description": "Gets or sets the thumb image item identifier.", + "nullable": true + }, + "BackdropImageTag": { + "type": "string", + "description": "Gets or sets the backdrop image tag.", + "nullable": true + }, + "BackdropImageItemId": { + "type": "string", + "description": "Gets or sets the backdrop image item identifier.", + "nullable": true + }, + "Type": { + "enum": [ + "AggregateFolder", + "Audio", + "AudioBook", + "BasePluginFolder", + "Book", + "BoxSet", + "Channel", + "ChannelFolderItem", + "CollectionFolder", + "Episode", + "Folder", + "Genre", + "ManualPlaylistsFolder", + "Movie", + "LiveTvChannel", + "LiveTvProgram", + "MusicAlbum", + "MusicArtist", + "MusicGenre", + "MusicVideo", + "Person", + "Photo", + "PhotoAlbum", + "Playlist", + "PlaylistsFolder", + "Program", + "Recording", + "Season", + "Series", + "Studio", + "Trailer", + "TvChannel", + "TvProgram", + "UserRootFolder", + "UserView", + "Video", + "Year" + ], + "allOf": [ + { + "$ref": "#/components/schemas/BaseItemKind" + } + ], + "description": "Gets or sets the type." + }, + "IsFolder": { + "type": "boolean", + "description": "Gets or sets a value indicating whether this instance is folder.", + "nullable": true + }, + "RunTimeTicks": { + "type": "integer", + "description": "Gets or sets the run time ticks.", + "format": "int64", + "nullable": true + }, + "MediaType": { + "enum": [ + "Unknown", + "Video", + "Audio", + "Photo", + "Book" + ], + "allOf": [ + { + "$ref": "#/components/schemas/MediaType" + } + ], + "description": "Gets or sets the type of the media.", + "default": "Unknown" + }, + "StartDate": { + "type": "string", + "description": "Gets or sets the start date.", + "format": "date-time", + "nullable": true + }, + "EndDate": { + "type": "string", + "description": "Gets or sets the end date.", + "format": "date-time", + "nullable": true + }, + "Series": { + "type": "string", + "description": "Gets or sets the series.", + "nullable": true + }, + "Status": { + "type": "string", + "description": "Gets or sets the status.", + "nullable": true + }, + "Album": { + "type": "string", + "description": "Gets or sets the album.", + "nullable": true + }, + "AlbumId": { + "type": "string", + "description": "Gets or sets the album id.", + "format": "uuid", + "nullable": true + }, + "AlbumArtist": { + "type": "string", + "description": "Gets or sets the album artist.", + "nullable": true + }, + "Artists": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Gets or sets the artists." + }, + "SongCount": { + "type": "integer", + "description": "Gets or sets the song count.", + "format": "int32", + "nullable": true + }, + "EpisodeCount": { + "type": "integer", + "description": "Gets or sets the episode count.", + "format": "int32", + "nullable": true + }, + "ChannelId": { + "type": "string", + "description": "Gets or sets the channel identifier.", + "format": "uuid", + "nullable": true + }, + "ChannelName": { + "type": "string", + "description": "Gets or sets the name of the channel.", + "nullable": true + }, + "PrimaryImageAspectRatio": { + "type": "number", + "description": "Gets or sets the primary image aspect ratio.", + "format": "double", + "nullable": true + } + }, + "additionalProperties": false, + "description": "Class SearchHintResult." + }, + "SearchHintResult": { + "type": "object", + "properties": { + "SearchHints": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SearchHint" + }, + "description": "Gets the search hints." + }, + "TotalRecordCount": { + "type": "integer", + "description": "Gets the total record count.", + "format": "int32" + } + }, + "additionalProperties": false, + "description": "Class SearchHintResult." + }, + "SeekRequestDto": { + "type": "object", + "properties": { + "PositionTicks": { + "type": "integer", + "description": "Gets or sets the position ticks.", + "format": "int64" + } + }, + "additionalProperties": false, + "description": "Class SeekRequestDto." + }, + "SendCommand": { + "type": "object", + "properties": { + "GroupId": { + "type": "string", + "description": "Gets the group identifier.", + "format": "uuid" + }, + "PlaylistItemId": { + "type": "string", + "description": "Gets the playlist identifier of the playing item.", + "format": "uuid" + }, + "When": { + "type": "string", + "description": "Gets or sets the UTC time when to execute the command.", + "format": "date-time" + }, + "PositionTicks": { + "type": "integer", + "description": "Gets the position ticks.", + "format": "int64", + "nullable": true + }, + "Command": { + "enum": [ + "Unpause", + "Pause", + "Stop", + "Seek" + ], + "allOf": [ + { + "$ref": "#/components/schemas/SendCommandType" + } + ], + "description": "Gets the command." + }, + "EmittedAt": { + "type": "string", + "description": "Gets the UTC time when this command has been emitted.", + "format": "date-time" + } + }, + "additionalProperties": false, + "description": "Class SendCommand." + }, + "SendCommandType": { + "enum": [ + "Unpause", + "Pause", + "Stop", + "Seek" + ], + "type": "string", + "description": "Enum SendCommandType." + }, + "SeriesInfo": { + "type": "object", + "properties": { + "Name": { + "type": "string", + "description": "Gets or sets the name.", + "nullable": true + }, + "OriginalTitle": { + "type": "string", + "description": "Gets or sets the original title.", + "nullable": true + }, + "Path": { + "type": "string", + "description": "Gets or sets the path.", + "nullable": true + }, + "MetadataLanguage": { + "type": "string", + "description": "Gets or sets the metadata language.", + "nullable": true + }, + "MetadataCountryCode": { + "type": "string", + "description": "Gets or sets the metadata country code.", + "nullable": true + }, + "ProviderIds": { + "type": "object", + "additionalProperties": { + "type": "string", + "nullable": true + }, + "description": "Gets or sets the provider ids.", + "nullable": true + }, + "Year": { + "type": "integer", + "description": "Gets or sets the year.", + "format": "int32", + "nullable": true + }, + "IndexNumber": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "ParentIndexNumber": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "PremiereDate": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "IsAutomated": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "SeriesInfoRemoteSearchQuery": { + "type": "object", + "properties": { + "SearchInfo": { + "allOf": [ + { + "$ref": "#/components/schemas/SeriesInfo" + } + ], + "nullable": true + }, + "ItemId": { + "type": "string", + "format": "uuid" + }, + "SearchProviderName": { + "type": "string", + "description": "Gets or sets the provider name to search within if set.", + "nullable": true + }, + "IncludeDisabledProviders": { + "type": "boolean", + "description": "Gets or sets a value indicating whether disabled providers should be included." + } + }, + "additionalProperties": false + }, + "SeriesStatus": { + "enum": [ + "Continuing", + "Ended", + "Unreleased" + ], + "type": "string", + "description": "The status of a series." + }, + "SeriesTimerCancelledMessage": { + "type": "object", + "properties": { + "Data": { + "allOf": [ + { + "$ref": "#/components/schemas/TimerEventInfo" + } + ], + "description": "Gets or sets the data.", + "nullable": true + }, + "MessageId": { + "type": "string", + "description": "Gets or sets the message id.", + "format": "uuid" + }, + "MessageType": { + "enum": [ + "ForceKeepAlive", + "GeneralCommand", + "UserDataChanged", + "Sessions", + "Play", + "SyncPlayCommand", + "SyncPlayGroupUpdate", + "Playstate", + "RestartRequired", + "ServerShuttingDown", + "ServerRestarting", + "LibraryChanged", + "UserDeleted", + "UserUpdated", + "SeriesTimerCreated", + "TimerCreated", + "SeriesTimerCancelled", + "TimerCancelled", + "RefreshProgress", + "ScheduledTaskEnded", + "PackageInstallationCancelled", + "PackageInstallationFailed", + "PackageInstallationCompleted", + "PackageInstalling", + "PackageUninstalled", + "ActivityLogEntry", + "ScheduledTasksInfo", + "ActivityLogEntryStart", + "ActivityLogEntryStop", + "SessionsStart", + "SessionsStop", + "ScheduledTasksInfoStart", + "ScheduledTasksInfoStop", + "KeepAlive" + ], + "allOf": [ + { + "$ref": "#/components/schemas/SessionMessageType" + } + ], + "description": "The different kinds of messages that are used in the WebSocket api.", + "default": "SeriesTimerCancelled", + "readOnly": true + } + }, + "additionalProperties": false, + "description": "Series timer cancelled message." + }, + "SeriesTimerCreatedMessage": { + "type": "object", + "properties": { + "Data": { + "allOf": [ + { + "$ref": "#/components/schemas/TimerEventInfo" + } + ], + "description": "Gets or sets the data.", + "nullable": true + }, + "MessageId": { + "type": "string", + "description": "Gets or sets the message id.", + "format": "uuid" + }, + "MessageType": { + "enum": [ + "ForceKeepAlive", + "GeneralCommand", + "UserDataChanged", + "Sessions", + "Play", + "SyncPlayCommand", + "SyncPlayGroupUpdate", + "Playstate", + "RestartRequired", + "ServerShuttingDown", + "ServerRestarting", + "LibraryChanged", + "UserDeleted", + "UserUpdated", + "SeriesTimerCreated", + "TimerCreated", + "SeriesTimerCancelled", + "TimerCancelled", + "RefreshProgress", + "ScheduledTaskEnded", + "PackageInstallationCancelled", + "PackageInstallationFailed", + "PackageInstallationCompleted", + "PackageInstalling", + "PackageUninstalled", + "ActivityLogEntry", + "ScheduledTasksInfo", + "ActivityLogEntryStart", + "ActivityLogEntryStop", + "SessionsStart", + "SessionsStop", + "ScheduledTasksInfoStart", + "ScheduledTasksInfoStop", + "KeepAlive" + ], + "allOf": [ + { + "$ref": "#/components/schemas/SessionMessageType" + } + ], + "description": "The different kinds of messages that are used in the WebSocket api.", + "default": "SeriesTimerCreated", + "readOnly": true + } + }, + "additionalProperties": false, + "description": "Series timer created message." + }, + "SeriesTimerInfoDto": { + "type": "object", + "properties": { + "Id": { + "type": "string", + "description": "Gets or sets the Id of the recording.", + "nullable": true + }, + "Type": { + "type": "string", + "nullable": true + }, + "ServerId": { + "type": "string", + "description": "Gets or sets the server identifier.", + "nullable": true + }, + "ExternalId": { + "type": "string", + "description": "Gets or sets the external identifier.", + "nullable": true + }, + "ChannelId": { + "type": "string", + "description": "Gets or sets the channel id of the recording.", + "format": "uuid" + }, + "ExternalChannelId": { + "type": "string", + "description": "Gets or sets the external channel identifier.", + "nullable": true + }, + "ChannelName": { + "type": "string", + "description": "Gets or sets the channel name of the recording.", + "nullable": true + }, + "ChannelPrimaryImageTag": { + "type": "string", + "nullable": true + }, + "ProgramId": { + "type": "string", + "description": "Gets or sets the program identifier.", + "nullable": true + }, + "ExternalProgramId": { + "type": "string", + "description": "Gets or sets the external program identifier.", + "nullable": true + }, + "Name": { + "type": "string", + "description": "Gets or sets the name of the recording.", + "nullable": true + }, + "Overview": { + "type": "string", + "description": "Gets or sets the description of the recording.", + "nullable": true + }, + "StartDate": { + "type": "string", + "description": "Gets or sets the start date of the recording, in UTC.", + "format": "date-time" + }, + "EndDate": { + "type": "string", + "description": "Gets or sets the end date of the recording, in UTC.", + "format": "date-time" + }, + "ServiceName": { + "type": "string", + "description": "Gets or sets the name of the service.", + "nullable": true + }, + "Priority": { + "type": "integer", + "description": "Gets or sets the priority.", + "format": "int32" + }, + "PrePaddingSeconds": { + "type": "integer", + "description": "Gets or sets the pre padding seconds.", + "format": "int32" + }, + "PostPaddingSeconds": { + "type": "integer", + "description": "Gets or sets the post padding seconds.", + "format": "int32" + }, + "IsPrePaddingRequired": { + "type": "boolean", + "description": "Gets or sets a value indicating whether this instance is pre padding required." + }, + "ParentBackdropItemId": { + "type": "string", + "description": "Gets or sets the Id of the Parent that has a backdrop if the item does not have one.", + "nullable": true + }, + "ParentBackdropImageTags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Gets or sets the parent backdrop image tags.", + "nullable": true + }, + "IsPostPaddingRequired": { + "type": "boolean", + "description": "Gets or sets a value indicating whether this instance is post padding required." + }, + "KeepUntil": { + "enum": [ + "UntilDeleted", + "UntilSpaceNeeded", + "UntilWatched", + "UntilDate" + ], + "allOf": [ + { + "$ref": "#/components/schemas/KeepUntil" + } + ] + }, + "RecordAnyTime": { + "type": "boolean", + "description": "Gets or sets a value indicating whether [record any time]." + }, + "SkipEpisodesInLibrary": { + "type": "boolean" + }, + "RecordAnyChannel": { + "type": "boolean", + "description": "Gets or sets a value indicating whether [record any channel]." + }, + "KeepUpTo": { + "type": "integer", + "format": "int32" + }, + "RecordNewOnly": { + "type": "boolean", + "description": "Gets or sets a value indicating whether [record new only]." + }, + "Days": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DayOfWeek" + }, + "description": "Gets or sets the days.", + "nullable": true + }, + "DayPattern": { + "enum": [ + "Daily", + "Weekdays", + "Weekends" + ], + "allOf": [ + { + "$ref": "#/components/schemas/DayPattern" + } + ], + "description": "Gets or sets the day pattern.", + "nullable": true + }, + "ImageTags": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "Gets or sets the image tags.", + "nullable": true + }, + "ParentThumbItemId": { + "type": "string", + "description": "Gets or sets the parent thumb item id.", + "nullable": true + }, + "ParentThumbImageTag": { + "type": "string", + "description": "Gets or sets the parent thumb image tag.", + "nullable": true + }, + "ParentPrimaryImageItemId": { + "type": "string", + "description": "Gets or sets the parent primary image item identifier.", + "format": "uuid", + "nullable": true + }, + "ParentPrimaryImageTag": { + "type": "string", + "description": "Gets or sets the parent primary image tag.", + "nullable": true + } + }, + "additionalProperties": false, + "description": "Class SeriesTimerInfoDto." + }, + "SeriesTimerInfoDtoQueryResult": { + "type": "object", + "properties": { + "Items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SeriesTimerInfoDto" + }, + "description": "Gets or sets the items." + }, + "TotalRecordCount": { + "type": "integer", + "description": "Gets or sets the total number of records available.", + "format": "int32" + }, + "StartIndex": { + "type": "integer", + "description": "Gets or sets the index of the first record in Items.", + "format": "int32" + } + }, + "additionalProperties": false, + "description": "Query result container." + }, + "ServerConfiguration": { + "type": "object", + "properties": { + "LogFileRetentionDays": { + "type": "integer", + "description": "Gets or sets the number of days we should retain log files.", + "format": "int32" + }, + "IsStartupWizardCompleted": { + "type": "boolean", + "description": "Gets or sets a value indicating whether this instance is first run." + }, + "CachePath": { + "type": "string", + "description": "Gets or sets the cache path.", + "nullable": true + }, + "PreviousVersion": { + "type": "string", + "description": "Gets or sets the last known version that was ran using the configuration.", + "nullable": true + }, + "PreviousVersionStr": { + "type": "string", + "description": "Gets or sets the stringified PreviousVersion to be stored/loaded,\r\nbecause System.Version itself isn't xml-serializable.", + "nullable": true + }, + "EnableMetrics": { + "type": "boolean", + "description": "Gets or sets a value indicating whether to enable prometheus metrics exporting." + }, + "EnableNormalizedItemByNameIds": { + "type": "boolean" + }, + "IsPortAuthorized": { + "type": "boolean", + "description": "Gets or sets a value indicating whether this instance is port authorized." + }, + "QuickConnectAvailable": { + "type": "boolean", + "description": "Gets or sets a value indicating whether quick connect is available for use on this server." + }, + "EnableCaseSensitiveItemIds": { + "type": "boolean", + "description": "Gets or sets a value indicating whether [enable case-sensitive item ids]." + }, + "DisableLiveTvChannelUserDataName": { + "type": "boolean" + }, + "MetadataPath": { + "type": "string", + "description": "Gets or sets the metadata path." + }, + "PreferredMetadataLanguage": { + "type": "string", + "description": "Gets or sets the preferred metadata language." + }, + "MetadataCountryCode": { + "type": "string", + "description": "Gets or sets the metadata country code." + }, + "SortReplaceCharacters": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Gets or sets characters to be replaced with a ' ' in strings to create a sort name." + }, + "SortRemoveCharacters": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Gets or sets characters to be removed from strings to create a sort name." + }, + "SortRemoveWords": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Gets or sets words to be removed from strings to create a sort name." + }, + "MinResumePct": { + "type": "integer", + "description": "Gets or sets the minimum percentage of an item that must be played in order for playstate to be updated.", + "format": "int32" + }, + "MaxResumePct": { + "type": "integer", + "description": "Gets or sets the maximum percentage of an item that can be played while still saving playstate. If this percentage is crossed playstate will be reset to the beginning and the item will be marked watched.", + "format": "int32" + }, + "MinResumeDurationSeconds": { + "type": "integer", + "description": "Gets or sets the minimum duration that an item must have in order to be eligible for playstate updates..", + "format": "int32" + }, + "MinAudiobookResume": { + "type": "integer", + "description": "Gets or sets the minimum minutes of a book that must be played in order for playstate to be updated.", + "format": "int32" + }, + "MaxAudiobookResume": { + "type": "integer", + "description": "Gets or sets the remaining minutes of a book that can be played while still saving playstate. If this percentage is crossed playstate will be reset to the beginning and the item will be marked watched.", + "format": "int32" + }, + "InactiveSessionThreshold": { + "type": "integer", + "description": "Gets or sets the threshold in minutes after a inactive session gets closed automatically.\r\nIf set to 0 the check for inactive sessions gets disabled.", + "format": "int32" + }, + "LibraryMonitorDelay": { + "type": "integer", + "description": "Gets or sets the delay in seconds that we will wait after a file system change to try and discover what has been added/removed\r\nSome delay is necessary with some items because their creation is not atomic. It involves the creation of several\r\ndifferent directories and files.", + "format": "int32" + }, + "LibraryUpdateDuration": { + "type": "integer", + "description": "Gets or sets the duration in seconds that we will wait after a library updated event before executing the library changed notification.", + "format": "int32" + }, + "CacheSize": { + "type": "integer", + "description": "Gets or sets the maximum amount of items to cache.", + "format": "int32" + }, + "ImageSavingConvention": { + "enum": [ + "Legacy", + "Compatible" + ], + "allOf": [ + { + "$ref": "#/components/schemas/ImageSavingConvention" + } + ], + "description": "Gets or sets the image saving convention." + }, + "MetadataOptions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MetadataOptions" + } + }, + "SkipDeserializationForBasicTypes": { + "type": "boolean" + }, + "ServerName": { + "type": "string" + }, + "UICulture": { + "type": "string" + }, + "SaveMetadataHidden": { + "type": "boolean" + }, + "ContentTypes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NameValuePair" + } + }, + "RemoteClientBitrateLimit": { + "type": "integer", + "format": "int32" + }, + "EnableFolderView": { + "type": "boolean" + }, + "EnableGroupingMoviesIntoCollections": { + "type": "boolean" + }, + "EnableGroupingShowsIntoCollections": { + "type": "boolean" + }, + "DisplaySpecialsWithinSeasons": { + "type": "boolean" + }, + "CodecsUsed": { + "type": "array", + "items": { + "type": "string" + } + }, + "PluginRepositories": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RepositoryInfo" + } + }, + "EnableExternalContentInSuggestions": { + "type": "boolean" + }, + "ImageExtractionTimeoutMs": { + "type": "integer", + "format": "int32" + }, + "PathSubstitutions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PathSubstitution" + } + }, + "EnableSlowResponseWarning": { + "type": "boolean", + "description": "Gets or sets a value indicating whether slow server responses should be logged as a warning." + }, + "SlowResponseThresholdMs": { + "type": "integer", + "description": "Gets or sets the threshold for the slow response time warning in ms.", + "format": "int64" + }, + "CorsHosts": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Gets or sets the cors hosts." + }, + "ActivityLogRetentionDays": { + "type": "integer", + "description": "Gets or sets the number of days we should retain activity logs.", + "format": "int32", + "nullable": true + }, + "LibraryScanFanoutConcurrency": { + "type": "integer", + "description": "Gets or sets the how the library scan fans out.", + "format": "int32" + }, + "LibraryMetadataRefreshConcurrency": { + "type": "integer", + "description": "Gets or sets the how many metadata refreshes can run concurrently.", + "format": "int32" + }, + "AllowClientLogUpload": { + "type": "boolean", + "description": "Gets or sets a value indicating whether clients should be allowed to upload logs." + }, + "DummyChapterDuration": { + "type": "integer", + "description": "Gets or sets the dummy chapter duration in seconds, use 0 (zero) or less to disable generation altogether.", + "format": "int32" + }, + "ChapterImageResolution": { + "enum": [ + "MatchSource", + "P144", + "P240", + "P360", + "P480", + "P720", + "P1080", + "P1440", + "P2160" + ], + "allOf": [ + { + "$ref": "#/components/schemas/ImageResolution" + } + ], + "description": "Gets or sets the chapter image resolution." + }, + "ParallelImageEncodingLimit": { + "type": "integer", + "description": "Gets or sets the limit for parallel image encoding.", + "format": "int32" + }, + "CastReceiverApplications": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CastReceiverApplication" + }, + "description": "Gets or sets the list of cast receiver applications." + }, + "TrickplayOptions": { + "allOf": [ + { + "$ref": "#/components/schemas/TrickplayOptions" + } + ], + "description": "Gets or sets the trickplay options." + }, + "EnableLegacyAuthorization": { + "type": "boolean", + "description": "Gets or sets a value indicating whether old authorization methods are allowed." + } + }, + "additionalProperties": false, + "description": "Represents the server configuration." + }, + "ServerDiscoveryInfo": { + "type": "object", + "properties": { + "Address": { + "type": "string", + "description": "Gets the address." + }, + "Id": { + "type": "string", + "description": "Gets the server identifier." + }, + "Name": { + "type": "string", + "description": "Gets the name." + }, + "EndpointAddress": { + "type": "string", + "description": "Gets the endpoint address.", + "nullable": true + } + }, + "additionalProperties": false, + "description": "The server discovery info model." + }, + "ServerRestartingMessage": { + "type": "object", + "properties": { + "MessageId": { + "type": "string", + "description": "Gets or sets the message id.", + "format": "uuid" + }, + "MessageType": { + "enum": [ + "ForceKeepAlive", + "GeneralCommand", + "UserDataChanged", + "Sessions", + "Play", + "SyncPlayCommand", + "SyncPlayGroupUpdate", + "Playstate", + "RestartRequired", + "ServerShuttingDown", + "ServerRestarting", + "LibraryChanged", + "UserDeleted", + "UserUpdated", + "SeriesTimerCreated", + "TimerCreated", + "SeriesTimerCancelled", + "TimerCancelled", + "RefreshProgress", + "ScheduledTaskEnded", + "PackageInstallationCancelled", + "PackageInstallationFailed", + "PackageInstallationCompleted", + "PackageInstalling", + "PackageUninstalled", + "ActivityLogEntry", + "ScheduledTasksInfo", + "ActivityLogEntryStart", + "ActivityLogEntryStop", + "SessionsStart", + "SessionsStop", + "ScheduledTasksInfoStart", + "ScheduledTasksInfoStop", + "KeepAlive" + ], + "allOf": [ + { + "$ref": "#/components/schemas/SessionMessageType" + } + ], + "description": "The different kinds of messages that are used in the WebSocket api.", + "default": "ServerRestarting", + "readOnly": true + } + }, + "additionalProperties": false, + "description": "Server restarting down message." + }, + "ServerShuttingDownMessage": { + "type": "object", + "properties": { + "MessageId": { + "type": "string", + "description": "Gets or sets the message id.", + "format": "uuid" + }, + "MessageType": { + "enum": [ + "ForceKeepAlive", + "GeneralCommand", + "UserDataChanged", + "Sessions", + "Play", + "SyncPlayCommand", + "SyncPlayGroupUpdate", + "Playstate", + "RestartRequired", + "ServerShuttingDown", + "ServerRestarting", + "LibraryChanged", + "UserDeleted", + "UserUpdated", + "SeriesTimerCreated", + "TimerCreated", + "SeriesTimerCancelled", + "TimerCancelled", + "RefreshProgress", + "ScheduledTaskEnded", + "PackageInstallationCancelled", + "PackageInstallationFailed", + "PackageInstallationCompleted", + "PackageInstalling", + "PackageUninstalled", + "ActivityLogEntry", + "ScheduledTasksInfo", + "ActivityLogEntryStart", + "ActivityLogEntryStop", + "SessionsStart", + "SessionsStop", + "ScheduledTasksInfoStart", + "ScheduledTasksInfoStop", + "KeepAlive" + ], + "allOf": [ + { + "$ref": "#/components/schemas/SessionMessageType" + } + ], + "description": "The different kinds of messages that are used in the WebSocket api.", + "default": "ServerShuttingDown", + "readOnly": true + } + }, + "additionalProperties": false, + "description": "Server shutting down message." + }, + "SessionInfoDto": { + "type": "object", + "properties": { + "PlayState": { + "allOf": [ + { + "$ref": "#/components/schemas/PlayerStateInfo" + } + ], + "description": "Gets or sets the play state.", + "nullable": true + }, + "AdditionalUsers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SessionUserInfo" + }, + "description": "Gets or sets the additional users.", + "nullable": true + }, + "Capabilities": { + "allOf": [ + { + "$ref": "#/components/schemas/ClientCapabilitiesDto" + } + ], + "description": "Gets or sets the client capabilities.", + "nullable": true + }, + "RemoteEndPoint": { + "type": "string", + "description": "Gets or sets the remote end point.", + "nullable": true + }, + "PlayableMediaTypes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MediaType" + }, + "description": "Gets or sets the playable media types." + }, + "Id": { + "type": "string", + "description": "Gets or sets the id.", + "nullable": true + }, + "UserId": { + "type": "string", + "description": "Gets or sets the user id.", + "format": "uuid" + }, + "UserName": { + "type": "string", + "description": "Gets or sets the username.", + "nullable": true + }, + "Client": { + "type": "string", + "description": "Gets or sets the type of the client.", + "nullable": true + }, + "LastActivityDate": { + "type": "string", + "description": "Gets or sets the last activity date.", + "format": "date-time" + }, + "LastPlaybackCheckIn": { + "type": "string", + "description": "Gets or sets the last playback check in.", + "format": "date-time" + }, + "LastPausedDate": { + "type": "string", + "description": "Gets or sets the last paused date.", + "format": "date-time", + "nullable": true + }, + "DeviceName": { + "type": "string", + "description": "Gets or sets the name of the device.", + "nullable": true + }, + "DeviceType": { + "type": "string", + "description": "Gets or sets the type of the device.", + "nullable": true + }, + "NowPlayingItem": { + "allOf": [ + { + "$ref": "#/components/schemas/BaseItemDto" + } + ], + "description": "Gets or sets the now playing item.", + "nullable": true + }, + "NowViewingItem": { + "allOf": [ + { + "$ref": "#/components/schemas/BaseItemDto" + } + ], + "description": "Gets or sets the now viewing item.", + "nullable": true + }, + "DeviceId": { + "type": "string", + "description": "Gets or sets the device id.", + "nullable": true + }, + "ApplicationVersion": { + "type": "string", + "description": "Gets or sets the application version.", + "nullable": true + }, + "TranscodingInfo": { + "allOf": [ + { + "$ref": "#/components/schemas/TranscodingInfo" + } + ], + "description": "Gets or sets the transcoding info.", + "nullable": true + }, + "IsActive": { + "type": "boolean", + "description": "Gets or sets a value indicating whether this session is active." + }, + "SupportsMediaControl": { + "type": "boolean", + "description": "Gets or sets a value indicating whether the session supports media control." + }, + "SupportsRemoteControl": { + "type": "boolean", + "description": "Gets or sets a value indicating whether the session supports remote control." + }, + "NowPlayingQueue": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QueueItem" + }, + "description": "Gets or sets the now playing queue.", + "nullable": true + }, + "NowPlayingQueueFullItems": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BaseItemDto" + }, + "description": "Gets or sets the now playing queue full items.", + "nullable": true + }, + "HasCustomDeviceName": { + "type": "boolean", + "description": "Gets or sets a value indicating whether the session has a custom device name." + }, + "PlaylistItemId": { + "type": "string", + "description": "Gets or sets the playlist item id.", + "nullable": true + }, + "ServerId": { + "type": "string", + "description": "Gets or sets the server id.", + "nullable": true + }, + "UserPrimaryImageTag": { + "type": "string", + "description": "Gets or sets the user primary image tag.", + "nullable": true + }, + "SupportedCommands": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GeneralCommandType" + }, + "description": "Gets or sets the supported commands." + } + }, + "additionalProperties": false, + "description": "Session info DTO." + }, + "SessionMessageType": { + "enum": [ + "ForceKeepAlive", + "GeneralCommand", + "UserDataChanged", + "Sessions", + "Play", + "SyncPlayCommand", + "SyncPlayGroupUpdate", + "Playstate", + "RestartRequired", + "ServerShuttingDown", + "ServerRestarting", + "LibraryChanged", + "UserDeleted", + "UserUpdated", + "SeriesTimerCreated", + "TimerCreated", + "SeriesTimerCancelled", + "TimerCancelled", + "RefreshProgress", + "ScheduledTaskEnded", + "PackageInstallationCancelled", + "PackageInstallationFailed", + "PackageInstallationCompleted", + "PackageInstalling", + "PackageUninstalled", + "ActivityLogEntry", + "ScheduledTasksInfo", + "ActivityLogEntryStart", + "ActivityLogEntryStop", + "SessionsStart", + "SessionsStop", + "ScheduledTasksInfoStart", + "ScheduledTasksInfoStop", + "KeepAlive" + ], + "type": "string", + "description": "The different kinds of messages that are used in the WebSocket api." + }, + "SessionsMessage": { + "type": "object", + "properties": { + "Data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SessionInfoDto" + }, + "description": "Gets or sets the data.", + "nullable": true + }, + "MessageId": { + "type": "string", + "description": "Gets or sets the message id.", + "format": "uuid" + }, + "MessageType": { + "enum": [ + "ForceKeepAlive", + "GeneralCommand", + "UserDataChanged", + "Sessions", + "Play", + "SyncPlayCommand", + "SyncPlayGroupUpdate", + "Playstate", + "RestartRequired", + "ServerShuttingDown", + "ServerRestarting", + "LibraryChanged", + "UserDeleted", + "UserUpdated", + "SeriesTimerCreated", + "TimerCreated", + "SeriesTimerCancelled", + "TimerCancelled", + "RefreshProgress", + "ScheduledTaskEnded", + "PackageInstallationCancelled", + "PackageInstallationFailed", + "PackageInstallationCompleted", + "PackageInstalling", + "PackageUninstalled", + "ActivityLogEntry", + "ScheduledTasksInfo", + "ActivityLogEntryStart", + "ActivityLogEntryStop", + "SessionsStart", + "SessionsStop", + "ScheduledTasksInfoStart", + "ScheduledTasksInfoStop", + "KeepAlive" + ], + "allOf": [ + { + "$ref": "#/components/schemas/SessionMessageType" + } + ], + "description": "The different kinds of messages that are used in the WebSocket api.", + "default": "Sessions", + "readOnly": true + } + }, + "additionalProperties": false, + "description": "Sessions message." + }, + "SessionsStartMessage": { + "type": "object", + "properties": { + "Data": { + "type": "string", + "description": "Gets or sets the data.", + "nullable": true + }, + "MessageType": { + "enum": [ + "ForceKeepAlive", + "GeneralCommand", + "UserDataChanged", + "Sessions", + "Play", + "SyncPlayCommand", + "SyncPlayGroupUpdate", + "Playstate", + "RestartRequired", + "ServerShuttingDown", + "ServerRestarting", + "LibraryChanged", + "UserDeleted", + "UserUpdated", + "SeriesTimerCreated", + "TimerCreated", + "SeriesTimerCancelled", + "TimerCancelled", + "RefreshProgress", + "ScheduledTaskEnded", + "PackageInstallationCancelled", + "PackageInstallationFailed", + "PackageInstallationCompleted", + "PackageInstalling", + "PackageUninstalled", + "ActivityLogEntry", + "ScheduledTasksInfo", + "ActivityLogEntryStart", + "ActivityLogEntryStop", + "SessionsStart", + "SessionsStop", + "ScheduledTasksInfoStart", + "ScheduledTasksInfoStop", + "KeepAlive" + ], + "allOf": [ + { + "$ref": "#/components/schemas/SessionMessageType" + } + ], + "description": "The different kinds of messages that are used in the WebSocket api.", + "default": "SessionsStart", + "readOnly": true + } + }, + "additionalProperties": false, + "description": "Sessions start message.\r\nData is the timing data encoded as \"$initialDelay,$interval\" in ms." + }, + "SessionsStopMessage": { + "type": "object", + "properties": { + "MessageType": { + "enum": [ + "ForceKeepAlive", + "GeneralCommand", + "UserDataChanged", + "Sessions", + "Play", + "SyncPlayCommand", + "SyncPlayGroupUpdate", + "Playstate", + "RestartRequired", + "ServerShuttingDown", + "ServerRestarting", + "LibraryChanged", + "UserDeleted", + "UserUpdated", + "SeriesTimerCreated", + "TimerCreated", + "SeriesTimerCancelled", + "TimerCancelled", + "RefreshProgress", + "ScheduledTaskEnded", + "PackageInstallationCancelled", + "PackageInstallationFailed", + "PackageInstallationCompleted", + "PackageInstalling", + "PackageUninstalled", + "ActivityLogEntry", + "ScheduledTasksInfo", + "ActivityLogEntryStart", + "ActivityLogEntryStop", + "SessionsStart", + "SessionsStop", + "ScheduledTasksInfoStart", + "ScheduledTasksInfoStop", + "KeepAlive" + ], + "allOf": [ + { + "$ref": "#/components/schemas/SessionMessageType" + } + ], + "description": "The different kinds of messages that are used in the WebSocket api.", + "default": "SessionsStop", + "readOnly": true + } + }, + "additionalProperties": false, + "description": "Sessions stop message." + }, + "SessionUserInfo": { + "type": "object", + "properties": { + "UserId": { + "type": "string", + "description": "Gets or sets the user identifier.", + "format": "uuid" + }, + "UserName": { + "type": "string", + "description": "Gets or sets the name of the user.", + "nullable": true + } + }, + "additionalProperties": false, + "description": "Class SessionUserInfo." + }, + "SetChannelMappingDto": { + "required": [ + "ProviderChannelId", + "ProviderId", + "TunerChannelId" + ], + "type": "object", + "properties": { + "ProviderId": { + "type": "string", + "description": "Gets or sets the provider id." + }, + "TunerChannelId": { + "type": "string", + "description": "Gets or sets the tuner channel id." + }, + "ProviderChannelId": { + "type": "string", + "description": "Gets or sets the provider channel id." + } + }, + "additionalProperties": false, + "description": "Set channel mapping dto." + }, + "SetPlaylistItemRequestDto": { + "type": "object", + "properties": { + "PlaylistItemId": { + "type": "string", + "description": "Gets or sets the playlist identifier of the playing item.", + "format": "uuid" + } + }, + "additionalProperties": false, + "description": "Class SetPlaylistItemRequestDto." + }, + "SetRepeatModeRequestDto": { + "type": "object", + "properties": { + "Mode": { + "enum": [ + "RepeatOne", + "RepeatAll", + "RepeatNone" + ], + "allOf": [ + { + "$ref": "#/components/schemas/GroupRepeatMode" + } + ], + "description": "Enum GroupRepeatMode." + } + }, + "additionalProperties": false, + "description": "Class SetRepeatModeRequestDto." + }, + "SetShuffleModeRequestDto": { + "type": "object", + "properties": { + "Mode": { + "enum": [ + "Sorted", + "Shuffle" + ], + "allOf": [ + { + "$ref": "#/components/schemas/GroupShuffleMode" + } + ], + "description": "Enum GroupShuffleMode." + } + }, + "additionalProperties": false, + "description": "Class SetShuffleModeRequestDto." + }, + "SongInfo": { + "type": "object", + "properties": { + "Name": { + "type": "string", + "description": "Gets or sets the name.", + "nullable": true + }, + "OriginalTitle": { + "type": "string", + "description": "Gets or sets the original title.", + "nullable": true + }, + "Path": { + "type": "string", + "description": "Gets or sets the path.", + "nullable": true + }, + "MetadataLanguage": { + "type": "string", + "description": "Gets or sets the metadata language.", + "nullable": true + }, + "MetadataCountryCode": { + "type": "string", + "description": "Gets or sets the metadata country code.", + "nullable": true + }, + "ProviderIds": { + "type": "object", + "additionalProperties": { + "type": "string", + "nullable": true + }, + "description": "Gets or sets the provider ids.", + "nullable": true + }, + "Year": { + "type": "integer", + "description": "Gets or sets the year.", + "format": "int32", + "nullable": true + }, + "IndexNumber": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "ParentIndexNumber": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "PremiereDate": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "IsAutomated": { + "type": "boolean" + }, + "AlbumArtists": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "Album": { + "type": "string", + "nullable": true + }, + "Artists": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + } + }, + "additionalProperties": false + }, + "SortOrder": { + "enum": [ + "Ascending", + "Descending" + ], + "type": "string", + "description": "An enum representing the sorting order." + }, + "SpecialViewOptionDto": { + "type": "object", + "properties": { + "Name": { + "type": "string", + "description": "Gets or sets view option name.", + "nullable": true + }, + "Id": { + "type": "string", + "description": "Gets or sets view option id.", + "nullable": true + } + }, + "additionalProperties": false, + "description": "Special view option dto." + }, + "StartupConfigurationDto": { + "type": "object", + "properties": { + "ServerName": { + "type": "string", + "description": "Gets or sets the server name.", + "nullable": true + }, + "UICulture": { + "type": "string", + "description": "Gets or sets UI language culture.", + "nullable": true + }, + "MetadataCountryCode": { + "type": "string", + "description": "Gets or sets the metadata country code.", + "nullable": true + }, + "PreferredMetadataLanguage": { + "type": "string", + "description": "Gets or sets the preferred language for the metadata.", + "nullable": true + } + }, + "additionalProperties": false, + "description": "The startup configuration DTO." + }, + "StartupRemoteAccessDto": { + "required": [ + "EnableAutomaticPortMapping", + "EnableRemoteAccess" + ], + "type": "object", + "properties": { + "EnableRemoteAccess": { + "type": "boolean", + "description": "Gets or sets a value indicating whether enable remote access." + }, + "EnableAutomaticPortMapping": { + "type": "boolean", + "description": "Gets or sets a value indicating whether enable automatic port mapping.", + "deprecated": true + } + }, + "additionalProperties": false, + "description": "Startup remote access dto." + }, + "StartupUserDto": { + "type": "object", + "properties": { + "Name": { + "type": "string", + "description": "Gets or sets the username.", + "nullable": true + }, + "Password": { + "type": "string", + "description": "Gets or sets the user's password.", + "nullable": true + } + }, + "additionalProperties": false, + "description": "The startup user DTO." + }, + "SubtitleDeliveryMethod": { + "enum": [ + "Encode", + "Embed", + "External", + "Hls", + "Drop" + ], + "type": "string", + "description": "Delivery method to use during playback of a specific subtitle format." + }, + "SubtitleOptions": { + "type": "object", + "properties": { + "SkipIfEmbeddedSubtitlesPresent": { + "type": "boolean" + }, + "SkipIfAudioTrackMatches": { + "type": "boolean" + }, + "DownloadLanguages": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "DownloadMovieSubtitles": { + "type": "boolean" + }, + "DownloadEpisodeSubtitles": { + "type": "boolean" + }, + "OpenSubtitlesUsername": { + "type": "string", + "nullable": true + }, + "OpenSubtitlesPasswordHash": { + "type": "string", + "nullable": true + }, + "IsOpenSubtitleVipAccount": { + "type": "boolean" + }, + "RequirePerfectMatch": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "SubtitlePlaybackMode": { + "enum": [ + "Default", + "Always", + "OnlyForced", + "None", + "Smart" + ], + "type": "string", + "description": "An enum representing a subtitle playback mode." + }, + "SubtitleProfile": { + "type": "object", + "properties": { + "Format": { + "type": "string", + "description": "Gets or sets the format.", + "nullable": true + }, + "Method": { + "enum": [ + "Encode", + "Embed", + "External", + "Hls", + "Drop" + ], + "allOf": [ + { + "$ref": "#/components/schemas/SubtitleDeliveryMethod" + } + ], + "description": "Gets or sets the delivery method." + }, + "DidlMode": { + "type": "string", + "description": "Gets or sets the DIDL mode.", + "nullable": true + }, + "Language": { + "type": "string", + "description": "Gets or sets the language.", + "nullable": true + }, + "Container": { + "type": "string", + "description": "Gets or sets the container.", + "nullable": true + } + }, + "additionalProperties": false, + "description": "A class for subtitle profile information." + }, + "SyncPlayCommandMessage": { + "type": "object", + "properties": { + "Data": { + "allOf": [ + { + "$ref": "#/components/schemas/SendCommand" + } + ], + "description": "Class SendCommand.", + "nullable": true + }, + "MessageId": { + "type": "string", + "description": "Gets or sets the message id.", + "format": "uuid" + }, + "MessageType": { + "enum": [ + "ForceKeepAlive", + "GeneralCommand", + "UserDataChanged", + "Sessions", + "Play", + "SyncPlayCommand", + "SyncPlayGroupUpdate", + "Playstate", + "RestartRequired", + "ServerShuttingDown", + "ServerRestarting", + "LibraryChanged", + "UserDeleted", + "UserUpdated", + "SeriesTimerCreated", + "TimerCreated", + "SeriesTimerCancelled", + "TimerCancelled", + "RefreshProgress", + "ScheduledTaskEnded", + "PackageInstallationCancelled", + "PackageInstallationFailed", + "PackageInstallationCompleted", + "PackageInstalling", + "PackageUninstalled", + "ActivityLogEntry", + "ScheduledTasksInfo", + "ActivityLogEntryStart", + "ActivityLogEntryStop", + "SessionsStart", + "SessionsStop", + "ScheduledTasksInfoStart", + "ScheduledTasksInfoStop", + "KeepAlive" + ], + "allOf": [ + { + "$ref": "#/components/schemas/SessionMessageType" + } + ], + "description": "The different kinds of messages that are used in the WebSocket api.", + "default": "SyncPlayCommand", + "readOnly": true + } + }, + "additionalProperties": false, + "description": "Sync play command." + }, + "SyncPlayGroupDoesNotExistUpdate": { + "type": "object", + "properties": { + "GroupId": { + "type": "string", + "description": "Gets the group identifier.", + "format": "uuid", + "readOnly": true + }, + "Data": { + "type": "string", + "description": "Gets the update data.", + "readOnly": true + }, + "Type": { + "enum": [ + "UserJoined", + "UserLeft", + "GroupJoined", + "GroupLeft", + "StateUpdate", + "PlayQueue", + "NotInGroup", + "GroupDoesNotExist", + "LibraryAccessDenied" + ], + "allOf": [ + { + "$ref": "#/components/schemas/GroupUpdateType" + } + ], + "description": "Enum GroupUpdateType.", + "default": "GroupDoesNotExist", + "readOnly": true + } + }, + "additionalProperties": false + }, + "SyncPlayGroupJoinedUpdate": { + "type": "object", + "properties": { + "GroupId": { + "type": "string", + "description": "Gets the group identifier.", + "format": "uuid", + "readOnly": true + }, + "Data": { + "allOf": [ + { + "$ref": "#/components/schemas/GroupInfoDto" + } + ], + "description": "Gets the update data.", + "readOnly": true + }, + "Type": { + "enum": [ + "UserJoined", + "UserLeft", + "GroupJoined", + "GroupLeft", + "StateUpdate", + "PlayQueue", + "NotInGroup", + "GroupDoesNotExist", + "LibraryAccessDenied" + ], + "allOf": [ + { + "$ref": "#/components/schemas/GroupUpdateType" + } + ], + "description": "Enum GroupUpdateType.", + "default": "GroupJoined", + "readOnly": true + } + }, + "additionalProperties": false + }, + "SyncPlayGroupLeftUpdate": { + "type": "object", + "properties": { + "GroupId": { + "type": "string", + "description": "Gets the group identifier.", + "format": "uuid", + "readOnly": true + }, + "Data": { + "type": "string", + "description": "Gets the update data.", + "readOnly": true + }, + "Type": { + "enum": [ + "UserJoined", + "UserLeft", + "GroupJoined", + "GroupLeft", + "StateUpdate", + "PlayQueue", + "NotInGroup", + "GroupDoesNotExist", + "LibraryAccessDenied" + ], + "allOf": [ + { + "$ref": "#/components/schemas/GroupUpdateType" + } + ], + "description": "Enum GroupUpdateType.", + "default": "GroupLeft", + "readOnly": true + } + }, + "additionalProperties": false + }, + "SyncPlayGroupUpdateMessage": { + "type": "object", + "properties": { + "Data": { + "allOf": [ + { + "$ref": "#/components/schemas/GroupUpdate" + } + ], + "description": "Group update data" + }, + "MessageId": { + "type": "string", + "description": "Gets or sets the message id.", + "format": "uuid" + }, + "MessageType": { + "enum": [ + "ForceKeepAlive", + "GeneralCommand", + "UserDataChanged", + "Sessions", + "Play", + "SyncPlayCommand", + "SyncPlayGroupUpdate", + "Playstate", + "RestartRequired", + "ServerShuttingDown", + "ServerRestarting", + "LibraryChanged", + "UserDeleted", + "UserUpdated", + "SeriesTimerCreated", + "TimerCreated", + "SeriesTimerCancelled", + "TimerCancelled", + "RefreshProgress", + "ScheduledTaskEnded", + "PackageInstallationCancelled", + "PackageInstallationFailed", + "PackageInstallationCompleted", + "PackageInstalling", + "PackageUninstalled", + "ActivityLogEntry", + "ScheduledTasksInfo", + "ActivityLogEntryStart", + "ActivityLogEntryStop", + "SessionsStart", + "SessionsStop", + "ScheduledTasksInfoStart", + "ScheduledTasksInfoStop", + "KeepAlive" + ], + "allOf": [ + { + "$ref": "#/components/schemas/SessionMessageType" + } + ], + "description": "The different kinds of messages that are used in the WebSocket api.", + "default": "SyncPlayGroupUpdate", + "readOnly": true + } + }, + "additionalProperties": false, + "description": "Untyped sync play command." + }, + "SyncPlayLibraryAccessDeniedUpdate": { + "type": "object", + "properties": { + "GroupId": { + "type": "string", + "description": "Gets the group identifier.", + "format": "uuid", + "readOnly": true + }, + "Data": { + "type": "string", + "description": "Gets the update data.", + "readOnly": true + }, + "Type": { + "enum": [ + "UserJoined", + "UserLeft", + "GroupJoined", + "GroupLeft", + "StateUpdate", + "PlayQueue", + "NotInGroup", + "GroupDoesNotExist", + "LibraryAccessDenied" + ], + "allOf": [ + { + "$ref": "#/components/schemas/GroupUpdateType" + } + ], + "description": "Enum GroupUpdateType.", + "default": "LibraryAccessDenied", + "readOnly": true + } + }, + "additionalProperties": false + }, + "SyncPlayNotInGroupUpdate": { + "type": "object", + "properties": { + "GroupId": { + "type": "string", + "description": "Gets the group identifier.", + "format": "uuid", + "readOnly": true + }, + "Data": { + "type": "string", + "description": "Gets the update data.", + "readOnly": true + }, + "Type": { + "enum": [ + "UserJoined", + "UserLeft", + "GroupJoined", + "GroupLeft", + "StateUpdate", + "PlayQueue", + "NotInGroup", + "GroupDoesNotExist", + "LibraryAccessDenied" + ], + "allOf": [ + { + "$ref": "#/components/schemas/GroupUpdateType" + } + ], + "description": "Enum GroupUpdateType.", + "default": "NotInGroup", + "readOnly": true + } + }, + "additionalProperties": false + }, + "SyncPlayPlayQueueUpdate": { + "type": "object", + "properties": { + "GroupId": { + "type": "string", + "description": "Gets the group identifier.", + "format": "uuid", + "readOnly": true + }, + "Data": { + "allOf": [ + { + "$ref": "#/components/schemas/PlayQueueUpdate" + } + ], + "description": "Gets the update data.", + "readOnly": true + }, + "Type": { + "enum": [ + "UserJoined", + "UserLeft", + "GroupJoined", + "GroupLeft", + "StateUpdate", + "PlayQueue", + "NotInGroup", + "GroupDoesNotExist", + "LibraryAccessDenied" + ], + "allOf": [ + { + "$ref": "#/components/schemas/GroupUpdateType" + } + ], + "description": "Enum GroupUpdateType.", + "default": "PlayQueue", + "readOnly": true + } + }, + "additionalProperties": false + }, + "SyncPlayQueueItem": { + "type": "object", + "properties": { + "ItemId": { + "type": "string", + "description": "Gets the item identifier.", + "format": "uuid" + }, + "PlaylistItemId": { + "type": "string", + "description": "Gets the playlist identifier of the item.", + "format": "uuid", + "readOnly": true + } + }, + "additionalProperties": false, + "description": "Class QueueItem." + }, + "SyncPlayStateUpdate": { + "type": "object", + "properties": { + "GroupId": { + "type": "string", + "description": "Gets the group identifier.", + "format": "uuid", + "readOnly": true + }, + "Data": { + "allOf": [ + { + "$ref": "#/components/schemas/GroupStateUpdate" + } + ], + "description": "Gets the update data.", + "readOnly": true + }, + "Type": { + "enum": [ + "UserJoined", + "UserLeft", + "GroupJoined", + "GroupLeft", + "StateUpdate", + "PlayQueue", + "NotInGroup", + "GroupDoesNotExist", + "LibraryAccessDenied" + ], + "allOf": [ + { + "$ref": "#/components/schemas/GroupUpdateType" + } + ], + "description": "Enum GroupUpdateType.", + "default": "StateUpdate", + "readOnly": true + } + }, + "additionalProperties": false + }, + "SyncPlayUserAccessType": { + "enum": [ + "CreateAndJoinGroups", + "JoinGroups", + "None" + ], + "type": "string", + "description": "Enum SyncPlayUserAccessType." + }, + "SyncPlayUserJoinedUpdate": { + "type": "object", + "properties": { + "GroupId": { + "type": "string", + "description": "Gets the group identifier.", + "format": "uuid", + "readOnly": true + }, + "Data": { + "type": "string", + "description": "Gets the update data.", + "readOnly": true + }, + "Type": { + "enum": [ + "UserJoined", + "UserLeft", + "GroupJoined", + "GroupLeft", + "StateUpdate", + "PlayQueue", + "NotInGroup", + "GroupDoesNotExist", + "LibraryAccessDenied" + ], + "allOf": [ + { + "$ref": "#/components/schemas/GroupUpdateType" + } + ], + "description": "Enum GroupUpdateType.", + "default": "UserJoined", + "readOnly": true + } + }, + "additionalProperties": false + }, + "SyncPlayUserLeftUpdate": { + "type": "object", + "properties": { + "GroupId": { + "type": "string", + "description": "Gets the group identifier.", + "format": "uuid", + "readOnly": true + }, + "Data": { + "type": "string", + "description": "Gets the update data.", + "readOnly": true + }, + "Type": { + "enum": [ + "UserJoined", + "UserLeft", + "GroupJoined", + "GroupLeft", + "StateUpdate", + "PlayQueue", + "NotInGroup", + "GroupDoesNotExist", + "LibraryAccessDenied" + ], + "allOf": [ + { + "$ref": "#/components/schemas/GroupUpdateType" + } + ], + "description": "Enum GroupUpdateType.", + "default": "UserLeft", + "readOnly": true + } + }, + "additionalProperties": false + }, + "SystemInfo": { + "type": "object", + "properties": { + "LocalAddress": { + "type": "string", + "description": "Gets or sets the local address.", + "nullable": true + }, + "ServerName": { + "type": "string", + "description": "Gets or sets the name of the server.", + "nullable": true + }, + "Version": { + "type": "string", + "description": "Gets or sets the server version.", + "nullable": true + }, + "ProductName": { + "type": "string", + "description": "Gets or sets the product name. This is the AssemblyProduct name.", + "nullable": true + }, + "OperatingSystem": { + "type": "string", + "description": "Gets or sets the operating system.", + "nullable": true, + "deprecated": true + }, + "Id": { + "type": "string", + "description": "Gets or sets the id.", + "nullable": true + }, + "StartupWizardCompleted": { + "type": "boolean", + "description": "Gets or sets a value indicating whether the startup wizard is completed.", + "nullable": true + }, + "OperatingSystemDisplayName": { + "type": "string", + "description": "Gets or sets the display name of the operating system.", + "nullable": true, + "deprecated": true + }, + "PackageName": { + "type": "string", + "description": "Gets or sets the package name.", + "nullable": true + }, + "HasPendingRestart": { + "type": "boolean", + "description": "Gets or sets a value indicating whether this instance has pending restart." + }, + "IsShuttingDown": { + "type": "boolean" + }, + "SupportsLibraryMonitor": { + "type": "boolean", + "description": "Gets or sets a value indicating whether [supports library monitor]." + }, + "WebSocketPortNumber": { + "type": "integer", + "description": "Gets or sets the web socket port number.", + "format": "int32" + }, + "CompletedInstallations": { + "type": "array", + "items": { + "$ref": "#/components/schemas/InstallationInfo" + }, + "description": "Gets or sets the completed installations.", + "nullable": true + }, + "CanSelfRestart": { + "type": "boolean", + "description": "Gets or sets a value indicating whether this instance can self restart.", + "default": true, + "deprecated": true + }, + "CanLaunchWebBrowser": { + "type": "boolean", + "default": false, + "deprecated": true + }, + "ProgramDataPath": { + "type": "string", + "description": "Gets or sets the program data path.", + "nullable": true, + "deprecated": true + }, + "WebPath": { + "type": "string", + "description": "Gets or sets the web UI resources path.", + "nullable": true, + "deprecated": true + }, + "ItemsByNamePath": { + "type": "string", + "description": "Gets or sets the items by name path.", + "nullable": true, + "deprecated": true + }, + "CachePath": { + "type": "string", + "description": "Gets or sets the cache path.", + "nullable": true, + "deprecated": true + }, + "LogPath": { + "type": "string", + "description": "Gets or sets the log path.", + "nullable": true, + "deprecated": true + }, + "InternalMetadataPath": { + "type": "string", + "description": "Gets or sets the internal metadata path.", + "nullable": true, + "deprecated": true + }, + "TranscodingTempPath": { + "type": "string", + "description": "Gets or sets the transcode path.", + "nullable": true, + "deprecated": true + }, + "CastReceiverApplications": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CastReceiverApplication" + }, + "description": "Gets or sets the list of cast receiver applications.", + "nullable": true + }, + "HasUpdateAvailable": { + "type": "boolean", + "description": "Gets or sets a value indicating whether this instance has update available.", + "default": false, + "deprecated": true + }, + "EncoderLocation": { + "type": "string", + "default": "System", + "nullable": true, + "deprecated": true + }, + "SystemArchitecture": { + "type": "string", + "default": "X64", + "nullable": true, + "deprecated": true + } + }, + "additionalProperties": false, + "description": "Class SystemInfo." + }, + "SystemStorageDto": { + "type": "object", + "properties": { + "ProgramDataFolder": { + "allOf": [ + { + "$ref": "#/components/schemas/FolderStorageDto" + } + ], + "description": "Gets or sets the Storage information of the program data folder." + }, + "WebFolder": { + "allOf": [ + { + "$ref": "#/components/schemas/FolderStorageDto" + } + ], + "description": "Gets or sets the Storage information of the web UI resources folder." + }, + "ImageCacheFolder": { + "allOf": [ + { + "$ref": "#/components/schemas/FolderStorageDto" + } + ], + "description": "Gets or sets the Storage information of the folder where images are cached." + }, + "CacheFolder": { + "allOf": [ + { + "$ref": "#/components/schemas/FolderStorageDto" + } + ], + "description": "Gets or sets the Storage information of the cache folder." + }, + "LogFolder": { + "allOf": [ + { + "$ref": "#/components/schemas/FolderStorageDto" + } + ], + "description": "Gets or sets the Storage information of the folder where logfiles are saved to." + }, + "InternalMetadataFolder": { + "allOf": [ + { + "$ref": "#/components/schemas/FolderStorageDto" + } + ], + "description": "Gets or sets the Storage information of the folder where metadata is stored." + }, + "TranscodingTempFolder": { + "allOf": [ + { + "$ref": "#/components/schemas/FolderStorageDto" + } + ], + "description": "Gets or sets the Storage information of the transcoding cache." + }, + "Libraries": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LibraryStorageDto" + }, + "description": "Gets or sets the storage informations of all libraries." + } + }, + "additionalProperties": false, + "description": "Contains informations about the systems storage." + }, + "TaskCompletionStatus": { + "enum": [ + "Completed", + "Failed", + "Cancelled", + "Aborted" + ], + "type": "string", + "description": "Enum TaskCompletionStatus." + }, + "TaskInfo": { + "type": "object", + "properties": { + "Name": { + "type": "string", + "description": "Gets or sets the name.", + "nullable": true + }, + "State": { + "enum": [ + "Idle", + "Cancelling", + "Running" + ], + "allOf": [ + { + "$ref": "#/components/schemas/TaskState" + } + ], + "description": "Gets or sets the state of the task." + }, + "CurrentProgressPercentage": { + "type": "number", + "description": "Gets or sets the progress.", + "format": "double", + "nullable": true + }, + "Id": { + "type": "string", + "description": "Gets or sets the id.", + "nullable": true + }, + "LastExecutionResult": { + "allOf": [ + { + "$ref": "#/components/schemas/TaskResult" + } + ], + "description": "Gets or sets the last execution result.", + "nullable": true + }, + "Triggers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TaskTriggerInfo" + }, + "description": "Gets or sets the triggers.", + "nullable": true + }, + "Description": { + "type": "string", + "description": "Gets or sets the description.", + "nullable": true + }, + "Category": { + "type": "string", + "description": "Gets or sets the category.", + "nullable": true + }, + "IsHidden": { + "type": "boolean", + "description": "Gets or sets a value indicating whether this instance is hidden." + }, + "Key": { + "type": "string", + "description": "Gets or sets the key.", + "nullable": true + } + }, + "additionalProperties": false, + "description": "Class TaskInfo." + }, + "TaskResult": { + "type": "object", + "properties": { + "StartTimeUtc": { + "type": "string", + "description": "Gets or sets the start time UTC.", + "format": "date-time" + }, + "EndTimeUtc": { + "type": "string", + "description": "Gets or sets the end time UTC.", + "format": "date-time" + }, + "Status": { + "enum": [ + "Completed", + "Failed", + "Cancelled", + "Aborted" + ], + "allOf": [ + { + "$ref": "#/components/schemas/TaskCompletionStatus" + } + ], + "description": "Gets or sets the status." + }, + "Name": { + "type": "string", + "description": "Gets or sets the name.", + "nullable": true + }, + "Key": { + "type": "string", + "description": "Gets or sets the key.", + "nullable": true + }, + "Id": { + "type": "string", + "description": "Gets or sets the id.", + "nullable": true + }, + "ErrorMessage": { + "type": "string", + "description": "Gets or sets the error message.", + "nullable": true + }, + "LongErrorMessage": { + "type": "string", + "description": "Gets or sets the long error message.", + "nullable": true + } + }, + "additionalProperties": false, + "description": "Class TaskExecutionInfo." + }, + "TaskState": { + "enum": [ + "Idle", + "Cancelling", + "Running" + ], + "type": "string", + "description": "Enum TaskState." + }, + "TaskTriggerInfo": { + "type": "object", + "properties": { + "Type": { + "enum": [ + "DailyTrigger", + "WeeklyTrigger", + "IntervalTrigger", + "StartupTrigger" + ], + "allOf": [ + { + "$ref": "#/components/schemas/TaskTriggerInfoType" + } + ], + "description": "Gets or sets the type." + }, + "TimeOfDayTicks": { + "type": "integer", + "description": "Gets or sets the time of day.", + "format": "int64", + "nullable": true + }, + "IntervalTicks": { + "type": "integer", + "description": "Gets or sets the interval.", + "format": "int64", + "nullable": true + }, + "DayOfWeek": { + "enum": [ + "Sunday", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday" + ], + "allOf": [ + { + "$ref": "#/components/schemas/DayOfWeek" + } + ], + "description": "Gets or sets the day of week.", + "nullable": true + }, + "MaxRuntimeTicks": { + "type": "integer", + "description": "Gets or sets the maximum runtime ticks.", + "format": "int64", + "nullable": true + } + }, + "additionalProperties": false, + "description": "Class TaskTriggerInfo." + }, + "TaskTriggerInfoType": { + "enum": [ + "DailyTrigger", + "WeeklyTrigger", + "IntervalTrigger", + "StartupTrigger" + ], + "type": "string", + "description": "Enum TaskTriggerInfoType." + }, + "ThemeMediaResult": { + "type": "object", + "properties": { + "Items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BaseItemDto" + }, + "description": "Gets or sets the items." + }, + "TotalRecordCount": { + "type": "integer", + "description": "Gets or sets the total number of records available.", + "format": "int32" + }, + "StartIndex": { + "type": "integer", + "description": "Gets or sets the index of the first record in Items.", + "format": "int32" + }, + "OwnerId": { + "type": "string", + "description": "Gets or sets the owner id.", + "format": "uuid" + } + }, + "additionalProperties": false, + "description": "Class ThemeMediaResult." + }, + "TimerCancelledMessage": { + "type": "object", + "properties": { + "Data": { + "allOf": [ + { + "$ref": "#/components/schemas/TimerEventInfo" + } + ], + "description": "Gets or sets the data.", + "nullable": true + }, + "MessageId": { + "type": "string", + "description": "Gets or sets the message id.", + "format": "uuid" + }, + "MessageType": { + "enum": [ + "ForceKeepAlive", + "GeneralCommand", + "UserDataChanged", + "Sessions", + "Play", + "SyncPlayCommand", + "SyncPlayGroupUpdate", + "Playstate", + "RestartRequired", + "ServerShuttingDown", + "ServerRestarting", + "LibraryChanged", + "UserDeleted", + "UserUpdated", + "SeriesTimerCreated", + "TimerCreated", + "SeriesTimerCancelled", + "TimerCancelled", + "RefreshProgress", + "ScheduledTaskEnded", + "PackageInstallationCancelled", + "PackageInstallationFailed", + "PackageInstallationCompleted", + "PackageInstalling", + "PackageUninstalled", + "ActivityLogEntry", + "ScheduledTasksInfo", + "ActivityLogEntryStart", + "ActivityLogEntryStop", + "SessionsStart", + "SessionsStop", + "ScheduledTasksInfoStart", + "ScheduledTasksInfoStop", + "KeepAlive" + ], + "allOf": [ + { + "$ref": "#/components/schemas/SessionMessageType" + } + ], + "description": "The different kinds of messages that are used in the WebSocket api.", + "default": "TimerCancelled", + "readOnly": true + } + }, + "additionalProperties": false, + "description": "Timer cancelled message." + }, + "TimerCreatedMessage": { + "type": "object", + "properties": { + "Data": { + "allOf": [ + { + "$ref": "#/components/schemas/TimerEventInfo" + } + ], + "description": "Gets or sets the data.", + "nullable": true + }, + "MessageId": { + "type": "string", + "description": "Gets or sets the message id.", + "format": "uuid" + }, + "MessageType": { + "enum": [ + "ForceKeepAlive", + "GeneralCommand", + "UserDataChanged", + "Sessions", + "Play", + "SyncPlayCommand", + "SyncPlayGroupUpdate", + "Playstate", + "RestartRequired", + "ServerShuttingDown", + "ServerRestarting", + "LibraryChanged", + "UserDeleted", + "UserUpdated", + "SeriesTimerCreated", + "TimerCreated", + "SeriesTimerCancelled", + "TimerCancelled", + "RefreshProgress", + "ScheduledTaskEnded", + "PackageInstallationCancelled", + "PackageInstallationFailed", + "PackageInstallationCompleted", + "PackageInstalling", + "PackageUninstalled", + "ActivityLogEntry", + "ScheduledTasksInfo", + "ActivityLogEntryStart", + "ActivityLogEntryStop", + "SessionsStart", + "SessionsStop", + "ScheduledTasksInfoStart", + "ScheduledTasksInfoStop", + "KeepAlive" + ], + "allOf": [ + { + "$ref": "#/components/schemas/SessionMessageType" + } + ], + "description": "The different kinds of messages that are used in the WebSocket api.", + "default": "TimerCreated", + "readOnly": true + } + }, + "additionalProperties": false, + "description": "Timer created message." + }, + "TimerEventInfo": { + "type": "object", + "properties": { + "Id": { + "type": "string" + }, + "ProgramId": { + "type": "string", + "format": "uuid", + "nullable": true + } + }, + "additionalProperties": false + }, + "TimerInfoDto": { + "type": "object", + "properties": { + "Id": { + "type": "string", + "description": "Gets or sets the Id of the recording.", + "nullable": true + }, + "Type": { + "type": "string", + "nullable": true + }, + "ServerId": { + "type": "string", + "description": "Gets or sets the server identifier.", + "nullable": true + }, + "ExternalId": { + "type": "string", + "description": "Gets or sets the external identifier.", + "nullable": true + }, + "ChannelId": { + "type": "string", + "description": "Gets or sets the channel id of the recording.", + "format": "uuid" + }, + "ExternalChannelId": { + "type": "string", + "description": "Gets or sets the external channel identifier.", + "nullable": true + }, + "ChannelName": { + "type": "string", + "description": "Gets or sets the channel name of the recording.", + "nullable": true + }, + "ChannelPrimaryImageTag": { + "type": "string", + "nullable": true + }, + "ProgramId": { + "type": "string", + "description": "Gets or sets the program identifier.", + "nullable": true + }, + "ExternalProgramId": { + "type": "string", + "description": "Gets or sets the external program identifier.", + "nullable": true + }, + "Name": { + "type": "string", + "description": "Gets or sets the name of the recording.", + "nullable": true + }, + "Overview": { + "type": "string", + "description": "Gets or sets the description of the recording.", + "nullable": true + }, + "StartDate": { + "type": "string", + "description": "Gets or sets the start date of the recording, in UTC.", + "format": "date-time" + }, + "EndDate": { + "type": "string", + "description": "Gets or sets the end date of the recording, in UTC.", + "format": "date-time" + }, + "ServiceName": { + "type": "string", + "description": "Gets or sets the name of the service.", + "nullable": true + }, + "Priority": { + "type": "integer", + "description": "Gets or sets the priority.", + "format": "int32" + }, + "PrePaddingSeconds": { + "type": "integer", + "description": "Gets or sets the pre padding seconds.", + "format": "int32" + }, + "PostPaddingSeconds": { + "type": "integer", + "description": "Gets or sets the post padding seconds.", + "format": "int32" + }, + "IsPrePaddingRequired": { + "type": "boolean", + "description": "Gets or sets a value indicating whether this instance is pre padding required." + }, + "ParentBackdropItemId": { + "type": "string", + "description": "Gets or sets the Id of the Parent that has a backdrop if the item does not have one.", + "nullable": true + }, + "ParentBackdropImageTags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Gets or sets the parent backdrop image tags.", + "nullable": true + }, + "IsPostPaddingRequired": { + "type": "boolean", + "description": "Gets or sets a value indicating whether this instance is post padding required." + }, + "KeepUntil": { + "enum": [ + "UntilDeleted", + "UntilSpaceNeeded", + "UntilWatched", + "UntilDate" + ], + "allOf": [ + { + "$ref": "#/components/schemas/KeepUntil" + } + ] + }, + "Status": { + "enum": [ + "New", + "InProgress", + "Completed", + "Cancelled", + "ConflictedOk", + "ConflictedNotOk", + "Error" + ], + "allOf": [ + { + "$ref": "#/components/schemas/RecordingStatus" + } + ], + "description": "Gets or sets the status." + }, + "SeriesTimerId": { + "type": "string", + "description": "Gets or sets the series timer identifier.", + "nullable": true + }, + "ExternalSeriesTimerId": { + "type": "string", + "description": "Gets or sets the external series timer identifier.", + "nullable": true + }, + "RunTimeTicks": { + "type": "integer", + "description": "Gets or sets the run time ticks.", + "format": "int64", + "nullable": true + }, + "ProgramInfo": { + "allOf": [ + { + "$ref": "#/components/schemas/BaseItemDto" + } + ], + "description": "Gets or sets the program information.", + "nullable": true + } + }, + "additionalProperties": false + }, + "TimerInfoDtoQueryResult": { + "type": "object", + "properties": { + "Items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TimerInfoDto" + }, + "description": "Gets or sets the items." + }, + "TotalRecordCount": { + "type": "integer", + "description": "Gets or sets the total number of records available.", + "format": "int32" + }, + "StartIndex": { + "type": "integer", + "description": "Gets or sets the index of the first record in Items.", + "format": "int32" + } + }, + "additionalProperties": false, + "description": "Query result container." + }, + "TonemappingAlgorithm": { + "enum": [ + "none", + "clip", + "linear", + "gamma", + "reinhard", + "hable", + "mobius", + "bt2390" + ], + "type": "string", + "description": "Enum containing tonemapping algorithms." + }, + "TonemappingMode": { + "enum": [ + "auto", + "max", + "rgb", + "lum", + "itp" + ], + "type": "string", + "description": "Enum containing tonemapping modes." + }, + "TonemappingRange": { + "enum": [ + "auto", + "tv", + "pc" + ], + "type": "string", + "description": "Enum containing tonemapping ranges." + }, + "TrailerInfo": { + "type": "object", + "properties": { + "Name": { + "type": "string", + "description": "Gets or sets the name.", + "nullable": true + }, + "OriginalTitle": { + "type": "string", + "description": "Gets or sets the original title.", + "nullable": true + }, + "Path": { + "type": "string", + "description": "Gets or sets the path.", + "nullable": true + }, + "MetadataLanguage": { + "type": "string", + "description": "Gets or sets the metadata language.", + "nullable": true + }, + "MetadataCountryCode": { + "type": "string", + "description": "Gets or sets the metadata country code.", + "nullable": true + }, + "ProviderIds": { + "type": "object", + "additionalProperties": { + "type": "string", + "nullable": true + }, + "description": "Gets or sets the provider ids.", + "nullable": true + }, + "Year": { + "type": "integer", + "description": "Gets or sets the year.", + "format": "int32", + "nullable": true + }, + "IndexNumber": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "ParentIndexNumber": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "PremiereDate": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "IsAutomated": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "TrailerInfoRemoteSearchQuery": { + "type": "object", + "properties": { + "SearchInfo": { + "allOf": [ + { + "$ref": "#/components/schemas/TrailerInfo" + } + ], + "nullable": true + }, + "ItemId": { + "type": "string", + "format": "uuid" + }, + "SearchProviderName": { + "type": "string", + "description": "Gets or sets the provider name to search within if set.", + "nullable": true + }, + "IncludeDisabledProviders": { + "type": "boolean", + "description": "Gets or sets a value indicating whether disabled providers should be included." + } + }, + "additionalProperties": false + }, + "TranscodeReason": { + "enum": [ + "ContainerNotSupported", + "VideoCodecNotSupported", + "AudioCodecNotSupported", + "SubtitleCodecNotSupported", + "AudioIsExternal", + "SecondaryAudioNotSupported", + "VideoProfileNotSupported", + "VideoLevelNotSupported", + "VideoResolutionNotSupported", + "VideoBitDepthNotSupported", + "VideoFramerateNotSupported", + "RefFramesNotSupported", + "AnamorphicVideoNotSupported", + "InterlacedVideoNotSupported", + "AudioChannelsNotSupported", + "AudioProfileNotSupported", + "AudioSampleRateNotSupported", + "AudioBitDepthNotSupported", + "ContainerBitrateExceedsLimit", + "VideoBitrateNotSupported", + "AudioBitrateNotSupported", + "UnknownVideoStreamInfo", + "UnknownAudioStreamInfo", + "DirectPlayError", + "VideoRangeTypeNotSupported", + "VideoCodecTagNotSupported", + "StreamCountExceedsLimit" + ], + "type": "string" + }, + "TranscodeSeekInfo": { + "enum": [ + "Auto", + "Bytes" + ], + "type": "string" + }, + "TranscodingInfo": { + "type": "object", + "properties": { + "AudioCodec": { + "type": "string", + "description": "Gets or sets the thread count used for encoding.", + "nullable": true + }, + "VideoCodec": { + "type": "string", + "description": "Gets or sets the thread count used for encoding.", + "nullable": true + }, + "Container": { + "type": "string", + "description": "Gets or sets the thread count used for encoding.", + "nullable": true + }, + "IsVideoDirect": { + "type": "boolean", + "description": "Gets or sets a value indicating whether the video is passed through." + }, + "IsAudioDirect": { + "type": "boolean", + "description": "Gets or sets a value indicating whether the audio is passed through." + }, + "Bitrate": { + "type": "integer", + "description": "Gets or sets the bitrate.", + "format": "int32", + "nullable": true + }, + "Framerate": { + "type": "number", + "description": "Gets or sets the framerate.", + "format": "float", + "nullable": true + }, + "CompletionPercentage": { + "type": "number", + "description": "Gets or sets the completion percentage.", + "format": "double", + "nullable": true + }, + "Width": { + "type": "integer", + "description": "Gets or sets the video width.", + "format": "int32", + "nullable": true + }, + "Height": { + "type": "integer", + "description": "Gets or sets the video height.", + "format": "int32", + "nullable": true + }, + "AudioChannels": { + "type": "integer", + "description": "Gets or sets the audio channels.", + "format": "int32", + "nullable": true + }, + "HardwareAccelerationType": { + "enum": [ + "none", + "amf", + "qsv", + "nvenc", + "v4l2m2m", + "vaapi", + "videotoolbox", + "rkmpp" + ], + "allOf": [ + { + "$ref": "#/components/schemas/HardwareAccelerationType" + } + ], + "description": "Gets or sets the hardware acceleration type.", + "nullable": true + }, + "TranscodeReasons": { + "enum": [ + "ContainerNotSupported", + "VideoCodecNotSupported", + "AudioCodecNotSupported", + "SubtitleCodecNotSupported", + "AudioIsExternal", + "SecondaryAudioNotSupported", + "VideoProfileNotSupported", + "VideoLevelNotSupported", + "VideoResolutionNotSupported", + "VideoBitDepthNotSupported", + "VideoFramerateNotSupported", + "RefFramesNotSupported", + "AnamorphicVideoNotSupported", + "InterlacedVideoNotSupported", + "AudioChannelsNotSupported", + "AudioProfileNotSupported", + "AudioSampleRateNotSupported", + "AudioBitDepthNotSupported", + "ContainerBitrateExceedsLimit", + "VideoBitrateNotSupported", + "AudioBitrateNotSupported", + "UnknownVideoStreamInfo", + "UnknownAudioStreamInfo", + "DirectPlayError", + "VideoRangeTypeNotSupported", + "VideoCodecTagNotSupported", + "StreamCountExceedsLimit" + ], + "type": "array", + "items": { + "$ref": "#/components/schemas/TranscodeReason" + }, + "description": "Gets or sets the transcode reasons." + } + }, + "additionalProperties": false, + "description": "Class holding information on a running transcode." + }, + "TranscodingProfile": { + "type": "object", + "properties": { + "Container": { + "type": "string", + "description": "Gets or sets the container." + }, + "Type": { + "enum": [ + "Audio", + "Video", + "Photo", + "Subtitle", + "Lyric" + ], + "allOf": [ + { + "$ref": "#/components/schemas/DlnaProfileType" + } + ], + "description": "Gets or sets the DLNA profile type." + }, + "VideoCodec": { + "type": "string", + "description": "Gets or sets the video codec." + }, + "AudioCodec": { + "type": "string", + "description": "Gets or sets the audio codec." + }, + "Protocol": { + "enum": [ + "http", + "hls" + ], + "allOf": [ + { + "$ref": "#/components/schemas/MediaStreamProtocol" + } + ], + "description": "Gets or sets the protocol." + }, + "EstimateContentLength": { + "type": "boolean", + "description": "Gets or sets a value indicating whether the content length should be estimated.", + "default": false + }, + "EnableMpegtsM2TsMode": { + "type": "boolean", + "description": "Gets or sets a value indicating whether M2TS mode is enabled.", + "default": false + }, + "TranscodeSeekInfo": { + "enum": [ + "Auto", + "Bytes" + ], + "allOf": [ + { + "$ref": "#/components/schemas/TranscodeSeekInfo" + } + ], + "description": "Gets or sets the transcoding seek info mode.", + "default": "Auto" + }, + "CopyTimestamps": { + "type": "boolean", + "description": "Gets or sets a value indicating whether timestamps should be copied.", + "default": false + }, + "Context": { + "enum": [ + "Streaming", + "Static" + ], + "allOf": [ + { + "$ref": "#/components/schemas/EncodingContext" + } + ], + "description": "Gets or sets the encoding context.", + "default": "Streaming" + }, + "EnableSubtitlesInManifest": { + "type": "boolean", + "description": "Gets or sets a value indicating whether subtitles are allowed in the manifest.", + "default": false + }, + "MaxAudioChannels": { + "type": "string", + "description": "Gets or sets the maximum audio channels.", + "nullable": true + }, + "MinSegments": { + "type": "integer", + "description": "Gets or sets the minimum amount of segments.", + "format": "int32", + "default": 0 + }, + "SegmentLength": { + "type": "integer", + "description": "Gets or sets the segment length.", + "format": "int32", + "default": 0 + }, + "BreakOnNonKeyFrames": { + "type": "boolean", + "description": "Gets or sets a value indicating whether breaking the video stream on non-keyframes is supported.", + "default": false + }, + "Conditions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProfileCondition" + }, + "description": "Gets or sets the profile conditions." + }, + "EnableAudioVbrEncoding": { + "type": "boolean", + "description": "Gets or sets a value indicating whether variable bitrate encoding is supported.", + "default": true + } + }, + "additionalProperties": false, + "description": "A class for transcoding profile information.\r\nNote for client developers: Conditions defined in MediaBrowser.Model.Dlna.CodecProfile has higher priority and can override values defined here." + }, + "TransportStreamTimestamp": { + "enum": [ + "None", + "Zero", + "Valid" + ], + "type": "string" + }, + "TrickplayInfoDto": { + "type": "object", + "properties": { + "Width": { + "type": "integer", + "description": "Gets the width of an individual thumbnail.", + "format": "int32" + }, + "Height": { + "type": "integer", + "description": "Gets the height of an individual thumbnail.", + "format": "int32" + }, + "TileWidth": { + "type": "integer", + "description": "Gets the amount of thumbnails per row.", + "format": "int32" + }, + "TileHeight": { + "type": "integer", + "description": "Gets the amount of thumbnails per column.", + "format": "int32" + }, + "ThumbnailCount": { + "type": "integer", + "description": "Gets the total amount of non-black thumbnails.", + "format": "int32" + }, + "Interval": { + "type": "integer", + "description": "Gets the interval in milliseconds between each trickplay thumbnail.", + "format": "int32" + }, + "Bandwidth": { + "type": "integer", + "description": "Gets the peak bandwidth usage in bits per second.", + "format": "int32" + } + }, + "additionalProperties": false, + "description": "The trickplay api model." + }, + "TrickplayOptions": { + "type": "object", + "properties": { + "EnableHwAcceleration": { + "type": "boolean", + "description": "Gets or sets a value indicating whether or not to use HW acceleration." + }, + "EnableHwEncoding": { + "type": "boolean", + "description": "Gets or sets a value indicating whether or not to use HW accelerated MJPEG encoding." + }, + "EnableKeyFrameOnlyExtraction": { + "type": "boolean", + "description": "Gets or sets a value indicating whether to only extract key frames.\r\nSignificantly faster, but is not compatible with all decoders and/or video files." + }, + "ScanBehavior": { + "enum": [ + "Blocking", + "NonBlocking" + ], + "allOf": [ + { + "$ref": "#/components/schemas/TrickplayScanBehavior" + } + ], + "description": "Gets or sets the behavior used by trickplay provider on library scan/update." + }, + "ProcessPriority": { + "enum": [ + "Normal", + "Idle", + "High", + "RealTime", + "BelowNormal", + "AboveNormal" + ], + "allOf": [ + { + "$ref": "#/components/schemas/ProcessPriorityClass" + } + ], + "description": "Gets or sets the process priority for the ffmpeg process." + }, + "Interval": { + "type": "integer", + "description": "Gets or sets the interval, in ms, between each new trickplay image.", + "format": "int32" + }, + "WidthResolutions": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + }, + "description": "Gets or sets the target width resolutions, in px, to generates preview images for." + }, + "TileWidth": { + "type": "integer", + "description": "Gets or sets number of tile images to allow in X dimension.", + "format": "int32" + }, + "TileHeight": { + "type": "integer", + "description": "Gets or sets number of tile images to allow in Y dimension.", + "format": "int32" + }, + "Qscale": { + "type": "integer", + "description": "Gets or sets the ffmpeg output quality level.", + "format": "int32" + }, + "JpegQuality": { + "type": "integer", + "description": "Gets or sets the jpeg quality to use for image tiles.", + "format": "int32" + }, + "ProcessThreads": { + "type": "integer", + "description": "Gets or sets the number of threads to be used by ffmpeg.", + "format": "int32" + } + }, + "additionalProperties": false, + "description": "Class TrickplayOptions." + }, + "TrickplayScanBehavior": { + "enum": [ + "Blocking", + "NonBlocking" + ], + "type": "string", + "description": "Enum TrickplayScanBehavior." + }, + "TunerChannelMapping": { + "type": "object", + "properties": { + "Name": { + "type": "string", + "nullable": true + }, + "ProviderChannelName": { + "type": "string", + "nullable": true + }, + "ProviderChannelId": { + "type": "string", + "nullable": true + }, + "Id": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, + "TunerHostInfo": { + "type": "object", + "properties": { + "Id": { + "type": "string", + "nullable": true + }, + "Url": { + "type": "string", + "nullable": true + }, + "Type": { + "type": "string", + "nullable": true + }, + "DeviceId": { + "type": "string", + "nullable": true + }, + "FriendlyName": { + "type": "string", + "nullable": true + }, + "ImportFavoritesOnly": { + "type": "boolean" + }, + "AllowHWTranscoding": { + "type": "boolean" + }, + "AllowFmp4TranscodingContainer": { + "type": "boolean" + }, + "AllowStreamSharing": { + "type": "boolean" + }, + "FallbackMaxStreamingBitrate": { + "type": "integer", + "format": "int32" + }, + "EnableStreamLooping": { + "type": "boolean" + }, + "Source": { + "type": "string", + "nullable": true + }, + "TunerCount": { + "type": "integer", + "format": "int32" + }, + "UserAgent": { + "type": "string", + "nullable": true + }, + "IgnoreDts": { + "type": "boolean" + }, + "ReadAtNativeFramerate": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "TypeOptions": { + "type": "object", + "properties": { + "Type": { + "type": "string", + "nullable": true + }, + "MetadataFetchers": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "MetadataFetcherOrder": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "ImageFetchers": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "ImageFetcherOrder": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "ImageOptions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ImageOption" + }, + "nullable": true + } + }, + "additionalProperties": false + }, + "UnratedItem": { + "enum": [ + "Movie", + "Trailer", + "Series", + "Music", + "Book", + "LiveTvChannel", + "LiveTvProgram", + "ChannelContent", + "Other" + ], + "type": "string", + "description": "An enum representing an unrated item." + }, + "UpdateLibraryOptionsDto": { + "type": "object", + "properties": { + "Id": { + "type": "string", + "description": "Gets or sets the library item id.", + "format": "uuid" + }, + "LibraryOptions": { + "allOf": [ + { + "$ref": "#/components/schemas/LibraryOptions" + } + ], + "description": "Gets or sets library options.", + "nullable": true + } + }, + "additionalProperties": false, + "description": "Update library options dto." + }, + "UpdateMediaPathRequestDto": { + "required": [ + "Name", + "PathInfo" + ], + "type": "object", + "properties": { + "Name": { + "type": "string", + "description": "Gets or sets the library name." + }, + "PathInfo": { + "allOf": [ + { + "$ref": "#/components/schemas/MediaPathInfo" + } + ], + "description": "Gets or sets library folder path information." + } + }, + "additionalProperties": false, + "description": "Update library options dto." + }, + "UpdatePlaylistDto": { + "type": "object", + "properties": { + "Name": { + "type": "string", + "description": "Gets or sets the name of the new playlist.", + "nullable": true + }, + "Ids": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "description": "Gets or sets item ids of the playlist.", + "nullable": true + }, + "Users": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PlaylistUserPermissions" + }, + "description": "Gets or sets the playlist users.", + "nullable": true + }, + "IsPublic": { + "type": "boolean", + "description": "Gets or sets a value indicating whether the playlist is public.", + "nullable": true + } + }, + "additionalProperties": false, + "description": "Update existing playlist dto. Fields set to `null` will not be updated and keep their current values." + }, + "UpdatePlaylistUserDto": { + "type": "object", + "properties": { + "CanEdit": { + "type": "boolean", + "description": "Gets or sets a value indicating whether the user can edit the playlist.", + "nullable": true + } + }, + "additionalProperties": false, + "description": "Update existing playlist user dto. Fields set to `null` will not be updated and keep their current values." + }, + "UpdateUserItemDataDto": { + "type": "object", + "properties": { + "Rating": { + "type": "number", + "description": "Gets or sets the rating.", + "format": "double", + "nullable": true + }, + "PlayedPercentage": { + "type": "number", + "description": "Gets or sets the played percentage.", + "format": "double", + "nullable": true + }, + "UnplayedItemCount": { + "type": "integer", + "description": "Gets or sets the unplayed item count.", + "format": "int32", + "nullable": true + }, + "PlaybackPositionTicks": { + "type": "integer", + "description": "Gets or sets the playback position ticks.", + "format": "int64", + "nullable": true + }, + "PlayCount": { + "type": "integer", + "description": "Gets or sets the play count.", + "format": "int32", + "nullable": true + }, + "IsFavorite": { + "type": "boolean", + "description": "Gets or sets a value indicating whether this instance is favorite.", + "nullable": true + }, + "Likes": { + "type": "boolean", + "description": "Gets or sets a value indicating whether this MediaBrowser.Model.Dto.UpdateUserItemDataDto is likes.", + "nullable": true + }, + "LastPlayedDate": { + "type": "string", + "description": "Gets or sets the last played date.", + "format": "date-time", + "nullable": true + }, + "Played": { + "type": "boolean", + "description": "Gets or sets a value indicating whether this MediaBrowser.Model.Dto.UserItemDataDto is played.", + "nullable": true + }, + "Key": { + "type": "string", + "description": "Gets or sets the key.", + "nullable": true + }, + "ItemId": { + "type": "string", + "description": "Gets or sets the item identifier.", + "nullable": true + } + }, + "additionalProperties": false, + "description": "This is used by the api to get information about a item user data." + }, + "UpdateUserPassword": { + "type": "object", + "properties": { + "CurrentPassword": { + "type": "string", + "description": "Gets or sets the current sha1-hashed password.", + "nullable": true + }, + "CurrentPw": { + "type": "string", + "description": "Gets or sets the current plain text password.", + "nullable": true + }, + "NewPw": { + "type": "string", + "description": "Gets or sets the new plain text password.", + "nullable": true + }, + "ResetPassword": { + "type": "boolean", + "description": "Gets or sets a value indicating whether to reset the password." + } + }, + "additionalProperties": false, + "description": "The update user password request body." + }, + "UploadSubtitleDto": { + "required": [ + "Data", + "Format", + "IsForced", + "IsHearingImpaired", + "Language" + ], + "type": "object", + "properties": { + "Language": { + "type": "string", + "description": "Gets or sets the subtitle language." + }, + "Format": { + "type": "string", + "description": "Gets or sets the subtitle format." + }, + "IsForced": { + "type": "boolean", + "description": "Gets or sets a value indicating whether the subtitle is forced." + }, + "IsHearingImpaired": { + "type": "boolean", + "description": "Gets or sets a value indicating whether the subtitle is for hearing impaired." + }, + "Data": { + "type": "string", + "description": "Gets or sets the subtitle data." + } + }, + "additionalProperties": false, + "description": "Upload subtitles dto." + }, + "UserConfiguration": { + "type": "object", + "properties": { + "AudioLanguagePreference": { + "type": "string", + "description": "Gets or sets the audio language preference.", + "nullable": true + }, + "PlayDefaultAudioTrack": { + "type": "boolean", + "description": "Gets or sets a value indicating whether [play default audio track]." + }, + "SubtitleLanguagePreference": { + "type": "string", + "description": "Gets or sets the subtitle language preference.", + "nullable": true + }, + "DisplayMissingEpisodes": { + "type": "boolean" + }, + "GroupedFolders": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + }, + "SubtitleMode": { + "enum": [ + "Default", + "Always", + "OnlyForced", + "None", + "Smart" + ], + "allOf": [ + { + "$ref": "#/components/schemas/SubtitlePlaybackMode" + } + ], + "description": "An enum representing a subtitle playback mode." + }, + "DisplayCollectionsView": { + "type": "boolean" + }, + "EnableLocalPassword": { + "type": "boolean" + }, + "OrderedViews": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + }, + "LatestItemsExcludes": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + }, + "MyMediaExcludes": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + }, + "HidePlayedInLatest": { + "type": "boolean" + }, + "RememberAudioSelections": { + "type": "boolean" + }, + "RememberSubtitleSelections": { + "type": "boolean" + }, + "EnableNextEpisodeAutoPlay": { + "type": "boolean" + }, + "CastReceiverId": { + "type": "string", + "description": "Gets or sets the id of the selected cast receiver.", + "nullable": true + } + }, + "additionalProperties": false, + "description": "Class UserConfiguration." + }, + "UserDataChangedMessage": { + "type": "object", + "properties": { + "Data": { + "allOf": [ + { + "$ref": "#/components/schemas/UserDataChangeInfo" + } + ], + "description": "Class UserDataChangeInfo.", + "nullable": true + }, + "MessageId": { + "type": "string", + "description": "Gets or sets the message id.", + "format": "uuid" + }, + "MessageType": { + "enum": [ + "ForceKeepAlive", + "GeneralCommand", + "UserDataChanged", + "Sessions", + "Play", + "SyncPlayCommand", + "SyncPlayGroupUpdate", + "Playstate", + "RestartRequired", + "ServerShuttingDown", + "ServerRestarting", + "LibraryChanged", + "UserDeleted", + "UserUpdated", + "SeriesTimerCreated", + "TimerCreated", + "SeriesTimerCancelled", + "TimerCancelled", + "RefreshProgress", + "ScheduledTaskEnded", + "PackageInstallationCancelled", + "PackageInstallationFailed", + "PackageInstallationCompleted", + "PackageInstalling", + "PackageUninstalled", + "ActivityLogEntry", + "ScheduledTasksInfo", + "ActivityLogEntryStart", + "ActivityLogEntryStop", + "SessionsStart", + "SessionsStop", + "ScheduledTasksInfoStart", + "ScheduledTasksInfoStop", + "KeepAlive" + ], + "allOf": [ + { + "$ref": "#/components/schemas/SessionMessageType" + } + ], + "description": "The different kinds of messages that are used in the WebSocket api.", + "default": "UserDataChanged", + "readOnly": true + } + }, + "additionalProperties": false, + "description": "User data changed message." + }, + "UserDataChangeInfo": { + "type": "object", + "properties": { + "UserId": { + "type": "string", + "description": "Gets or sets the user id.", + "format": "uuid" + }, + "UserDataList": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserItemDataDto" + }, + "description": "Gets or sets the user data list." + } + }, + "additionalProperties": false, + "description": "Class UserDataChangeInfo." + }, + "UserDeletedMessage": { + "type": "object", + "properties": { + "Data": { + "type": "string", + "description": "Gets or sets the data.", + "format": "uuid" + }, + "MessageId": { + "type": "string", + "description": "Gets or sets the message id.", + "format": "uuid" + }, + "MessageType": { + "enum": [ + "ForceKeepAlive", + "GeneralCommand", + "UserDataChanged", + "Sessions", + "Play", + "SyncPlayCommand", + "SyncPlayGroupUpdate", + "Playstate", + "RestartRequired", + "ServerShuttingDown", + "ServerRestarting", + "LibraryChanged", + "UserDeleted", + "UserUpdated", + "SeriesTimerCreated", + "TimerCreated", + "SeriesTimerCancelled", + "TimerCancelled", + "RefreshProgress", + "ScheduledTaskEnded", + "PackageInstallationCancelled", + "PackageInstallationFailed", + "PackageInstallationCompleted", + "PackageInstalling", + "PackageUninstalled", + "ActivityLogEntry", + "ScheduledTasksInfo", + "ActivityLogEntryStart", + "ActivityLogEntryStop", + "SessionsStart", + "SessionsStop", + "ScheduledTasksInfoStart", + "ScheduledTasksInfoStop", + "KeepAlive" + ], + "allOf": [ + { + "$ref": "#/components/schemas/SessionMessageType" + } + ], + "description": "The different kinds of messages that are used in the WebSocket api.", + "default": "UserDeleted", + "readOnly": true + } + }, + "additionalProperties": false, + "description": "User deleted message." + }, + "UserDto": { + "type": "object", + "properties": { + "Name": { + "type": "string", + "description": "Gets or sets the name.", + "nullable": true + }, + "ServerId": { + "type": "string", + "description": "Gets or sets the server identifier.", + "nullable": true + }, + "ServerName": { + "type": "string", + "description": "Gets or sets the name of the server.\r\nThis is not used by the server and is for client-side usage only.", + "nullable": true + }, + "Id": { + "type": "string", + "description": "Gets or sets the id.", + "format": "uuid" + }, + "PrimaryImageTag": { + "type": "string", + "description": "Gets or sets the primary image tag.", + "nullable": true + }, + "HasPassword": { + "type": "boolean", + "description": "Gets or sets a value indicating whether this instance has password." + }, + "HasConfiguredPassword": { + "type": "boolean", + "description": "Gets or sets a value indicating whether this instance has configured password." + }, + "HasConfiguredEasyPassword": { + "type": "boolean", + "description": "Gets or sets a value indicating whether this instance has configured easy password.", + "deprecated": true + }, + "EnableAutoLogin": { + "type": "boolean", + "description": "Gets or sets whether async login is enabled or not.", + "nullable": true + }, + "LastLoginDate": { + "type": "string", + "description": "Gets or sets the last login date.", + "format": "date-time", + "nullable": true + }, + "LastActivityDate": { + "type": "string", + "description": "Gets or sets the last activity date.", + "format": "date-time", + "nullable": true + }, + "Configuration": { + "allOf": [ + { + "$ref": "#/components/schemas/UserConfiguration" + } + ], + "description": "Gets or sets the configuration.", + "nullable": true + }, + "Policy": { + "allOf": [ + { + "$ref": "#/components/schemas/UserPolicy" + } + ], + "description": "Gets or sets the policy.", + "nullable": true + }, + "PrimaryImageAspectRatio": { + "type": "number", + "description": "Gets or sets the primary image aspect ratio.", + "format": "double", + "nullable": true + } + }, + "additionalProperties": false, + "description": "Class UserDto." + }, + "UserItemDataDto": { + "type": "object", + "properties": { + "Rating": { + "type": "number", + "description": "Gets or sets the rating.", + "format": "double", + "nullable": true + }, + "PlayedPercentage": { + "type": "number", + "description": "Gets or sets the played percentage.", + "format": "double", + "nullable": true + }, + "UnplayedItemCount": { + "type": "integer", + "description": "Gets or sets the unplayed item count.", + "format": "int32", + "nullable": true + }, + "PlaybackPositionTicks": { + "type": "integer", + "description": "Gets or sets the playback position ticks.", + "format": "int64" + }, + "PlayCount": { + "type": "integer", + "description": "Gets or sets the play count.", + "format": "int32" + }, + "IsFavorite": { + "type": "boolean", + "description": "Gets or sets a value indicating whether this instance is favorite." + }, + "Likes": { + "type": "boolean", + "description": "Gets or sets a value indicating whether this MediaBrowser.Model.Dto.UserItemDataDto is likes.", + "nullable": true + }, + "LastPlayedDate": { + "type": "string", + "description": "Gets or sets the last played date.", + "format": "date-time", + "nullable": true + }, + "Played": { + "type": "boolean", + "description": "Gets or sets a value indicating whether this MediaBrowser.Model.Dto.UserItemDataDto is played." + }, + "Key": { + "type": "string", + "description": "Gets or sets the key." + }, + "ItemId": { + "type": "string", + "description": "Gets or sets the item identifier.", + "format": "uuid" + } + }, + "additionalProperties": false, + "description": "Class UserItemDataDto." + }, + "UserPolicy": { + "required": [ + "AuthenticationProviderId", + "PasswordResetProviderId" + ], + "type": "object", + "properties": { + "IsAdministrator": { + "type": "boolean", + "description": "Gets or sets a value indicating whether this instance is administrator." + }, + "IsHidden": { + "type": "boolean", + "description": "Gets or sets a value indicating whether this instance is hidden." + }, + "EnableCollectionManagement": { + "type": "boolean", + "description": "Gets or sets a value indicating whether this instance can manage collections.", + "default": false + }, + "EnableSubtitleManagement": { + "type": "boolean", + "description": "Gets or sets a value indicating whether this instance can manage subtitles.", + "default": false + }, + "EnableLyricManagement": { + "type": "boolean", + "description": "Gets or sets a value indicating whether this user can manage lyrics.", + "default": false + }, + "IsDisabled": { + "type": "boolean", + "description": "Gets or sets a value indicating whether this instance is disabled." + }, + "MaxParentalRating": { + "type": "integer", + "description": "Gets or sets the max parental rating.", + "format": "int32", + "nullable": true + }, + "MaxParentalSubRating": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "BlockedTags": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "AllowedTags": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "EnableUserPreferenceAccess": { + "type": "boolean" + }, + "AccessSchedules": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AccessSchedule" + }, + "nullable": true + }, + "BlockUnratedItems": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UnratedItem" + }, + "nullable": true + }, + "EnableRemoteControlOfOtherUsers": { + "type": "boolean" + }, + "EnableSharedDeviceControl": { + "type": "boolean" + }, + "EnableRemoteAccess": { + "type": "boolean" + }, + "EnableLiveTvManagement": { + "type": "boolean" + }, + "EnableLiveTvAccess": { + "type": "boolean" + }, + "EnableMediaPlayback": { + "type": "boolean" + }, + "EnableAudioPlaybackTranscoding": { + "type": "boolean" + }, + "EnableVideoPlaybackTranscoding": { + "type": "boolean" + }, + "EnablePlaybackRemuxing": { + "type": "boolean" + }, + "ForceRemoteSourceTranscoding": { + "type": "boolean" + }, + "EnableContentDeletion": { + "type": "boolean" + }, + "EnableContentDeletionFromFolders": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "EnableContentDownloading": { + "type": "boolean" + }, + "EnableSyncTranscoding": { + "type": "boolean", + "description": "Gets or sets a value indicating whether [enable synchronize]." + }, + "EnableMediaConversion": { + "type": "boolean" + }, + "EnabledDevices": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "EnableAllDevices": { + "type": "boolean" + }, + "EnabledChannels": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "nullable": true + }, + "EnableAllChannels": { + "type": "boolean" + }, + "EnabledFolders": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "nullable": true + }, + "EnableAllFolders": { + "type": "boolean" + }, + "InvalidLoginAttemptCount": { + "type": "integer", + "format": "int32" + }, + "LoginAttemptsBeforeLockout": { + "type": "integer", + "format": "int32" + }, + "MaxActiveSessions": { + "type": "integer", + "format": "int32" + }, + "EnablePublicSharing": { + "type": "boolean" + }, + "BlockedMediaFolders": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "nullable": true + }, + "BlockedChannels": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "nullable": true + }, + "RemoteClientBitrateLimit": { + "type": "integer", + "format": "int32" + }, + "AuthenticationProviderId": { + "type": "string" + }, + "PasswordResetProviderId": { + "type": "string" + }, + "SyncPlayAccess": { + "enum": [ + "CreateAndJoinGroups", + "JoinGroups", + "None" + ], + "allOf": [ + { + "$ref": "#/components/schemas/SyncPlayUserAccessType" + } + ], + "description": "Gets or sets a value indicating what SyncPlay features the user can access." + } + }, + "additionalProperties": false + }, + "UserUpdatedMessage": { + "type": "object", + "properties": { + "Data": { + "allOf": [ + { + "$ref": "#/components/schemas/UserDto" + } + ], + "description": "Class UserDto.", + "nullable": true + }, + "MessageId": { + "type": "string", + "description": "Gets or sets the message id.", + "format": "uuid" + }, + "MessageType": { + "enum": [ + "ForceKeepAlive", + "GeneralCommand", + "UserDataChanged", + "Sessions", + "Play", + "SyncPlayCommand", + "SyncPlayGroupUpdate", + "Playstate", + "RestartRequired", + "ServerShuttingDown", + "ServerRestarting", + "LibraryChanged", + "UserDeleted", + "UserUpdated", + "SeriesTimerCreated", + "TimerCreated", + "SeriesTimerCancelled", + "TimerCancelled", + "RefreshProgress", + "ScheduledTaskEnded", + "PackageInstallationCancelled", + "PackageInstallationFailed", + "PackageInstallationCompleted", + "PackageInstalling", + "PackageUninstalled", + "ActivityLogEntry", + "ScheduledTasksInfo", + "ActivityLogEntryStart", + "ActivityLogEntryStop", + "SessionsStart", + "SessionsStop", + "ScheduledTasksInfoStart", + "ScheduledTasksInfoStop", + "KeepAlive" + ], + "allOf": [ + { + "$ref": "#/components/schemas/SessionMessageType" + } + ], + "description": "The different kinds of messages that are used in the WebSocket api.", + "default": "UserUpdated", + "readOnly": true + } + }, + "additionalProperties": false, + "description": "User updated message." + }, + "UtcTimeResponse": { + "type": "object", + "properties": { + "RequestReceptionTime": { + "type": "string", + "description": "Gets the UTC time when request has been received.", + "format": "date-time" + }, + "ResponseTransmissionTime": { + "type": "string", + "description": "Gets the UTC time when response has been sent.", + "format": "date-time" + } + }, + "additionalProperties": false, + "description": "Class UtcTimeResponse." + }, + "ValidatePathDto": { + "type": "object", + "properties": { + "ValidateWritable": { + "type": "boolean", + "description": "Gets or sets a value indicating whether validate if path is writable." + }, + "Path": { + "type": "string", + "description": "Gets or sets the path.", + "nullable": true + }, + "IsFile": { + "type": "boolean", + "description": "Gets or sets is path file.", + "nullable": true + } + }, + "additionalProperties": false, + "description": "Validate path object." + }, + "VersionInfo": { + "type": "object", + "properties": { + "version": { + "type": "string", + "description": "Gets or sets the version." + }, + "VersionNumber": { + "type": "string", + "description": "Gets the version as a System.Version.", + "readOnly": true + }, + "changelog": { + "type": "string", + "description": "Gets or sets the changelog for this version.", + "nullable": true + }, + "targetAbi": { + "type": "string", + "description": "Gets or sets the ABI that this version was built against.", + "nullable": true + }, + "sourceUrl": { + "type": "string", + "description": "Gets or sets the source URL.", + "nullable": true + }, + "checksum": { + "type": "string", + "description": "Gets or sets a checksum for the binary.", + "nullable": true + }, + "timestamp": { + "type": "string", + "description": "Gets or sets a timestamp of when the binary was built.", + "nullable": true + }, + "repositoryName": { + "type": "string", + "description": "Gets or sets the repository name." + }, + "repositoryUrl": { + "type": "string", + "description": "Gets or sets the repository url." + } + }, + "additionalProperties": false, + "description": "Defines the MediaBrowser.Model.Updates.VersionInfo class." + }, + "Video3DFormat": { + "enum": [ + "HalfSideBySide", + "FullSideBySide", + "FullTopAndBottom", + "HalfTopAndBottom", + "MVC" + ], + "type": "string" + }, + "VideoRange": { + "enum": [ + "Unknown", + "SDR", + "HDR" + ], + "type": "string", + "description": "An enum representing video ranges." + }, + "VideoRangeType": { + "enum": [ + "Unknown", + "SDR", + "HDR10", + "HLG", + "DOVI", + "DOVIWithHDR10", + "DOVIWithHLG", + "DOVIWithSDR", + "DOVIWithEL", + "DOVIWithHDR10Plus", + "DOVIWithELHDR10Plus", + "DOVIInvalid", + "HDR10Plus" + ], + "type": "string", + "description": "An enum representing types of video ranges." + }, + "VideoType": { + "enum": [ + "VideoFile", + "Iso", + "Dvd", + "BluRay" + ], + "type": "string", + "description": "Enum VideoType." + }, + "VirtualFolderInfo": { + "type": "object", + "properties": { + "Name": { + "type": "string", + "description": "Gets or sets the name.", + "nullable": true + }, + "Locations": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Gets or sets the locations.", + "nullable": true + }, + "CollectionType": { + "enum": [ + "movies", + "tvshows", + "music", + "musicvideos", + "homevideos", + "boxsets", + "books", + "mixed" + ], + "allOf": [ + { + "$ref": "#/components/schemas/CollectionTypeOptions" + } + ], + "description": "Gets or sets the type of the collection.", + "nullable": true + }, + "LibraryOptions": { + "allOf": [ + { + "$ref": "#/components/schemas/LibraryOptions" + } + ], + "nullable": true + }, + "ItemId": { + "type": "string", + "description": "Gets or sets the item identifier.", + "nullable": true + }, + "PrimaryImageItemId": { + "type": "string", + "description": "Gets or sets the primary image item identifier.", + "nullable": true + }, + "RefreshProgress": { + "type": "number", + "format": "double", + "nullable": true + }, + "RefreshStatus": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false, + "description": "Used to hold information about a user's list of configured virtual folders." + }, + "WebSocketMessage": { + "type": "object", + "oneOf": [ + { + "$ref": "#/components/schemas/InboundWebSocketMessage" + }, + { + "$ref": "#/components/schemas/OutboundWebSocketMessage" + } + ], + "description": "Represents the possible websocket types" + }, + "XbmcMetadataOptions": { + "type": "object", + "properties": { + "UserId": { + "type": "string", + "nullable": true + }, + "ReleaseDateFormat": { + "type": "string" + }, + "SaveImagePathsInNfo": { + "type": "boolean" + }, + "EnablePathSubstitution": { + "type": "boolean" + }, + "EnableExtraThumbsDuplication": { + "type": "boolean" + } + }, + "additionalProperties": false + } + }, + "securitySchemes": { + "CustomAuthentication": { + "type": "apiKey", + "description": "API key header parameter", + "name": "Authorization", + "in": "header" + } + } + } +} \ No newline at end of file diff --git a/docs/ks-player/GettingStarted.md b/docs/ks-player/GettingStarted.md new file mode 100644 index 000000000..d5ec51cf0 --- /dev/null +++ b/docs/ks-player/GettingStarted.md @@ -0,0 +1,157 @@ +# Getting Started with KSPlayer + +KSPlayer is a powerful media playback framework for iOS, tvOS, macOS, xrOS, and visionOS. It supports both AVPlayer and FFmpeg-based playback with AppKit/UIKit/SwiftUI support. + +## Requirements + +- iOS 13+ +- macOS 10.15+ +- tvOS 13+ +- xrOS 1+ + +## Troubleshooting + +### Missing Metal Toolchain (CocoaPods builds) + +If your build fails compiling `Shaders.metal` with: + +`cannot execute tool 'metal' due to missing Metal Toolchain` + +Install the component: + +```bash +xcodebuild -downloadComponent MetalToolchain +``` + +Then verify: + +```bash +xcrun --find metal +xcrun metal -v +``` + +## Installation + +### Swift Package Manager + +Add KSPlayer to your `Package.swift`: + +```swift +dependencies: [ + .package(url: "https://github.com/kingslay/KSPlayer.git", .branch("main")) +] +``` + +Or in Xcode: File → Add Packages → Enter the repository URL. + +### CocoaPods + +Add to your `Podfile`: + +```ruby +target 'YourApp' do + use_frameworks! + pod 'KSPlayer', :git => 'https://github.com/kingslay/KSPlayer.git', :branch => 'main' + pod 'DisplayCriteria', :git => 'https://github.com/kingslay/KSPlayer.git', :branch => 'main' + pod 'FFmpegKit', :git => 'https://github.com/kingslay/FFmpegKit.git', :branch => 'main' + pod 'Libass', :git => 'https://github.com/kingslay/FFmpegKit.git', :branch => 'main' +end +``` + +Then run: + +```bash +pod install +``` + +## Initial Setup + +### Configure Player Type + +KSPlayer supports two player backends: +- `KSAVPlayer` - Uses AVPlayer (default first player) +- `KSMEPlayer` - Uses FFmpeg for decoding + +Configure the player type before creating any player views: + +```swift +import KSPlayer + +// Use KSMEPlayer as the secondary/fallback player +KSOptions.secondPlayerType = KSMEPlayer.self + +// Or set KSMEPlayer as the primary player +KSOptions.firstPlayerType = KSMEPlayer.self +``` + +### Player Type Selection Strategy + +The player uses `firstPlayerType` initially. If playback fails, it automatically switches to `secondPlayerType`. + +```swift +// Default configuration +KSOptions.firstPlayerType = KSAVPlayer.self // Uses AVPlayer first +KSOptions.secondPlayerType = KSMEPlayer.self // Falls back to FFmpeg +``` + +## Quick Start + +### UIKit + +```swift +import KSPlayer + +class VideoViewController: UIViewController { + private var playerView: IOSVideoPlayerView! + + override func viewDidLoad() { + super.viewDidLoad() + + KSOptions.secondPlayerType = KSMEPlayer.self + + playerView = IOSVideoPlayerView() + view.addSubview(playerView) + + playerView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + playerView.topAnchor.constraint(equalTo: view.topAnchor), + playerView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + playerView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + playerView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + + let url = URL(string: "https://example.com/video.mp4")! + playerView.set(url: url, options: KSOptions()) + } +} +``` + +### SwiftUI (iOS 16+) + +```swift +import KSPlayer +import SwiftUI + +struct VideoPlayerScreen: View { + let url: URL + + var body: some View { + KSVideoPlayerView(url: url, options: KSOptions()) + } +} +``` + +## Key Imports + +```swift +import KSPlayer +import AVFoundation // For AVMediaType, etc. +``` + +## Next Steps + +- [UIKit Usage](UIKitUsage.md) - Detailed UIKit integration +- [SwiftUI Usage](SwiftUIUsage.md) - SwiftUI views and modifiers +- [KSOptions](KSOptions.md) - Configuration options +- [Types and Protocols](TypesAndProtocols.md) - Core types reference + diff --git a/docs/ks-player/KSOptions.md b/docs/ks-player/KSOptions.md new file mode 100644 index 000000000..a7370de85 --- /dev/null +++ b/docs/ks-player/KSOptions.md @@ -0,0 +1,349 @@ +# KSOptions + +`KSOptions` is the configuration class for KSPlayer. It contains both instance properties (per-player settings) and static properties (global defaults). + +## Creating Options + +```swift +let options = KSOptions() + +// Configure instance properties +options.isLoopPlay = true +options.startPlayTime = 30.0 // Start at 30 seconds + +// Use with player +playerView.set(url: url, options: options) +``` + +## Instance Properties + +### Buffering + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `preferredForwardBufferDuration` | `TimeInterval` | `3.0` | Minimum buffer duration before playback starts | +| `maxBufferDuration` | `TimeInterval` | `30.0` | Maximum buffer duration | +| `isSecondOpen` | `Bool` | `false` | Enable fast open (instant playback) | + +### Seeking + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `isAccurateSeek` | `Bool` | `false` | Enable frame-accurate seeking | +| `seekFlags` | `Int32` | `1` | FFmpeg seek flags (AVSEEK_FLAG_BACKWARD) | +| `isSeekedAutoPlay` | `Bool` | `true` | Auto-play after seeking | + +### Playback + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `isLoopPlay` | `Bool` | `false` | Loop playback (for short videos) | +| `startPlayTime` | `TimeInterval` | `0` | Initial playback position (seconds) | +| `startPlayRate` | `Float` | `1.0` | Initial playback rate | +| `registerRemoteControll` | `Bool` | `true` | Enable system remote control | + +### Video + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `display` | `DisplayEnum` | `.plane` | Display mode (`.plane`, `.vr`, `.vrBox`) | +| `videoDelay` | `Double` | `0.0` | Video delay in seconds | +| `autoDeInterlace` | `Bool` | `false` | Auto-detect interlacing | +| `autoRotate` | `Bool` | `true` | Auto-rotate based on metadata | +| `destinationDynamicRange` | `DynamicRange?` | `nil` | Target HDR mode | +| `videoAdaptable` | `Bool` | `true` | Enable adaptive bitrate | +| `videoFilters` | `[String]` | `[]` | FFmpeg video filters | +| `syncDecodeVideo` | `Bool` | `false` | Synchronous video decoding | +| `hardwareDecode` | `Bool` | `true` | Use hardware decoding | +| `asynchronousDecompression` | `Bool` | `false` | Async hardware decompression | +| `videoDisable` | `Bool` | `false` | Disable video track | + +### Audio + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `audioFilters` | `[String]` | `[]` | FFmpeg audio filters | +| `syncDecodeAudio` | `Bool` | `false` | Synchronous audio decoding | + +### Subtitles + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `autoSelectEmbedSubtitle` | `Bool` | `true` | Auto-select embedded subtitles | +| `isSeekImageSubtitle` | `Bool` | `false` | Seek for image subtitles | + +### Picture-in-Picture + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `canStartPictureInPictureAutomaticallyFromInline` | `Bool` | `true` | Auto-start PiP when app backgrounds | + +### Window (macOS) + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `automaticWindowResize` | `Bool` | `true` | Auto-resize window to video aspect ratio | + +### Network/HTTP + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `referer` | `String?` | `nil` | HTTP referer header | +| `userAgent` | `String?` | `"KSPlayer"` | HTTP user agent | +| `cache` | `Bool` | `false` | Enable FFmpeg HTTP caching | +| `outputURL` | `URL?` | `nil` | URL to record/save stream | + +### FFmpeg Options + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `avOptions` | `[String: Any]` | `[:]` | AVURLAsset options | +| `formatContextOptions` | `[String: Any]` | See below | FFmpeg format context options | +| `decoderOptions` | `[String: Any]` | See below | FFmpeg decoder options | +| `probesize` | `Int64?` | `nil` | FFmpeg probe size | +| `maxAnalyzeDuration` | `Int64?` | `nil` | Max analyze duration | +| `lowres` | `UInt8` | `0` | Low resolution decoding | +| `nobuffer` | `Bool` | `false` | Disable buffering | +| `codecLowDelay` | `Bool` | `false` | Low delay codec mode | + +#### Default formatContextOptions + +```swift +[ + "user_agent": "KSPlayer", + "scan_all_pmts": 1, + "reconnect": 1, + "reconnect_streamed": 1 +] +``` + +#### Default decoderOptions + +```swift +[ + "threads": "auto", + "refcounted_frames": "1" +] +``` + +### Read-Only Timing Properties + +| Property | Type | Description | +|----------|------|-------------| +| `formatName` | `String` | Detected format name | +| `prepareTime` | `Double` | Time when prepare started | +| `dnsStartTime` | `Double` | DNS lookup start time | +| `tcpStartTime` | `Double` | TCP connection start time | +| `tcpConnectedTime` | `Double` | TCP connected time | +| `openTime` | `Double` | File open time | +| `findTime` | `Double` | Stream find time | +| `readyTime` | `Double` | Ready to play time | +| `readAudioTime` | `Double` | First audio read time | +| `readVideoTime` | `Double` | First video read time | +| `decodeAudioTime` | `Double` | First audio decode time | +| `decodeVideoTime` | `Double` | First video decode time | + +## Static Properties (Global Defaults) + +### Player Types + +```swift +// Primary player type (default: AVPlayer) +KSOptions.firstPlayerType: MediaPlayerProtocol.Type = KSAVPlayer.self + +// Fallback player type (default: FFmpeg) +KSOptions.secondPlayerType: MediaPlayerProtocol.Type? = KSMEPlayer.self +``` + +### Buffering Defaults + +```swift +KSOptions.preferredForwardBufferDuration: TimeInterval = 3.0 +KSOptions.maxBufferDuration: TimeInterval = 30.0 +KSOptions.isSecondOpen: Bool = false +``` + +### Playback Defaults + +```swift +KSOptions.isAccurateSeek: Bool = false +KSOptions.isLoopPlay: Bool = false +KSOptions.isAutoPlay: Bool = true +KSOptions.isSeekedAutoPlay: Bool = true +``` + +### Decoding + +```swift +KSOptions.hardwareDecode: Bool = true +KSOptions.asynchronousDecompression: Bool = false +KSOptions.canStartPictureInPictureAutomaticallyFromInline: Bool = true +``` + +### UI Options + +```swift +// Top bar visibility: .always, .horizantalOnly, .none +KSOptions.topBarShowInCase: KSPlayerTopBarShowCase = .always + +// Auto-hide controls delay +KSOptions.animateDelayTimeInterval: TimeInterval = 5.0 + +// Gesture controls +KSOptions.enableBrightnessGestures: Bool = true +KSOptions.enableVolumeGestures: Bool = true +KSOptions.enablePlaytimeGestures: Bool = true + +// Background playback +KSOptions.canBackgroundPlay: Bool = false +``` + +### PiP + +```swift +KSOptions.isPipPopViewController: Bool = false +``` + +### Logging + +```swift +// Log levels: .panic, .fatal, .error, .warning, .info, .verbose, .debug, .trace +KSOptions.logLevel: LogLevel = .warning +KSOptions.logger: LogHandler = OSLog(lable: "KSPlayer") +``` + +### System + +```swift +KSOptions.useSystemHTTPProxy: Bool = true +KSOptions.preferredFrame: Bool = true +``` + +### Subtitle Data Sources + +```swift +KSOptions.subtitleDataSouces: [SubtitleDataSouce] = [DirectorySubtitleDataSouce()] +``` + +## Methods + +### HTTP Headers + +```swift +let options = KSOptions() +options.appendHeader(["Referer": "https://example.com"]) +options.appendHeader(["Authorization": "Bearer token123"]) +``` + +### Cookies + +```swift +let cookies = [HTTPCookie(properties: [ + .name: "session", + .value: "abc123", + .domain: "example.com", + .path: "/" +])!] +options.setCookie(cookies) +``` + +## Overridable Methods + +Subclass `KSOptions` to customize behavior: + +### Buffering Algorithm + +```swift +class CustomOptions: KSOptions { + override func playable(capacitys: [CapacityProtocol], isFirst: Bool, isSeek: Bool) -> LoadingState { + // Custom buffering logic + super.playable(capacitys: capacitys, isFirst: isFirst, isSeek: isSeek) + } +} +``` + +### Adaptive Bitrate + +```swift +override func adaptable(state: VideoAdaptationState?) -> (Int64, Int64)? { + // Return (currentBitrate, targetBitrate) or nil + super.adaptable(state: state) +} +``` + +### Track Selection + +```swift +// Select preferred video track +override func wantedVideo(tracks: [MediaPlayerTrack]) -> Int? { + // Return index of preferred track or nil for auto + return tracks.firstIndex { $0.bitRate > 5_000_000 } +} + +// Select preferred audio track +override func wantedAudio(tracks: [MediaPlayerTrack]) -> Int? { + // Return index of preferred track or nil for auto + return tracks.firstIndex { $0.languageCode == "en" } +} +``` + +### Display Layer + +```swift +override func isUseDisplayLayer() -> Bool { + // Return true to use AVSampleBufferDisplayLayer (supports HDR10+) + // Return false for other display modes + display == .plane +} +``` + +### Track Processing + +```swift +override func process(assetTrack: some MediaPlayerTrack) { + super.process(assetTrack: assetTrack) + // Custom processing before decoder creation +} +``` + +### Live Playback Rate + +```swift +override func liveAdaptivePlaybackRate(loadingState: LoadingState) -> Float? { + // Return adjusted playback rate for live streams + // Return nil to keep current rate + if loadingState.loadedTime > preferredForwardBufferDuration + 5 { + return 1.2 // Speed up if too far behind + } + return nil +} +``` + +## Example: Custom Options + +```swift +class StreamingOptions: KSOptions { + override init() { + super.init() + + // Low latency settings + preferredForwardBufferDuration = 1.0 + isSecondOpen = true + nobuffer = true + codecLowDelay = true + + // Custom headers + appendHeader(["X-Custom-Header": "value"]) + } + + override func wantedAudio(tracks: [MediaPlayerTrack]) -> Int? { + // Prefer English audio + return tracks.firstIndex { $0.languageCode == "en" } + } +} + +// Usage +let options = StreamingOptions() +playerView.set(url: streamURL, options: options) +``` + diff --git a/docs/ks-player/KSPlayerLayer.md b/docs/ks-player/KSPlayerLayer.md new file mode 100644 index 000000000..e8d071fff --- /dev/null +++ b/docs/ks-player/KSPlayerLayer.md @@ -0,0 +1,442 @@ +# KSPlayerLayer + +`KSPlayerLayer` is the core playback controller that manages the media player instance and provides a high-level API for playback control. + +## Overview + +`KSPlayerLayer` wraps `MediaPlayerProtocol` implementations (`KSAVPlayer` or `KSMEPlayer`) and handles: +- Player lifecycle management +- Playback state transitions +- Remote control integration +- Picture-in-Picture support +- Background/foreground handling + +## Creating a KSPlayerLayer + +### Basic Initialization + +```swift +let url = URL(string: "https://example.com/video.mp4")! +let options = KSOptions() + +let playerLayer = KSPlayerLayer( + url: url, + isAutoPlay: true, // Default: KSOptions.isAutoPlay + options: options, + delegate: self +) +``` + +### Constructor Parameters + +```swift +public init( + url: URL, + isAutoPlay: Bool = KSOptions.isAutoPlay, + options: KSOptions, + delegate: KSPlayerLayerDelegate? = nil +) +``` + +## Properties + +### Core Properties + +| Property | Type | Description | +|----------|------|-------------| +| `url` | `URL` | Current media URL (read-only after init) | +| `options` | `KSOptions` | Player configuration (read-only) | +| `player` | `MediaPlayerProtocol` | Underlying player instance | +| `state` | `KSPlayerState` | Current playback state (read-only) | +| `delegate` | `KSPlayerLayerDelegate?` | Event delegate | + +### Published Properties (for Combine/SwiftUI) + +```swift +@Published public var bufferingProgress: Int = 0 // 0-100 +@Published public var loopCount: Int = 0 // Loop iteration count +@Published public var isPipActive: Bool = false // Picture-in-Picture state +``` + +## Playback Control Methods + +### play() + +Start or resume playback: + +```swift +playerLayer.play() +``` + +### pause() + +Pause playback: + +```swift +playerLayer.pause() +``` + +### stop() + +Stop playback and reset player state: + +```swift +playerLayer.stop() +``` + +### seek(time:autoPlay:completion:) + +Seek to a specific time: + +```swift +playerLayer.seek(time: 30.0, autoPlay: true) { finished in + if finished { + print("Seek completed") + } +} +``` + +Parameters: +- `time: TimeInterval` - Target time in seconds +- `autoPlay: Bool` - Whether to auto-play after seeking +- `completion: @escaping ((Bool) -> Void)` - Called when seek completes + +### prepareToPlay() + +Prepare the player (called automatically when `isAutoPlay` is true): + +```swift +playerLayer.prepareToPlay() +``` + +## URL Management + +### set(url:options:) + +Change the video URL: + +```swift +let newURL = URL(string: "https://example.com/another-video.mp4")! +playerLayer.set(url: newURL, options: KSOptions()) +``` + +### set(urls:options:) + +Set a playlist of URLs: + +```swift +let urls = [ + URL(string: "https://example.com/video1.mp4")!, + URL(string: "https://example.com/video2.mp4")!, + URL(string: "https://example.com/video3.mp4")! +] +playerLayer.set(urls: urls, options: KSOptions()) +``` + +The player automatically advances to the next URL when playback finishes. + +## Accessing the Player + +### Player Properties + +Access underlying player properties through `playerLayer.player`: + +```swift +// Duration +let duration = playerLayer.player.duration + +// Current time +let currentTime = playerLayer.player.currentPlaybackTime + +// Playing state +let isPlaying = playerLayer.player.isPlaying + +// Seekable +let canSeek = playerLayer.player.seekable + +// Natural size +let videoSize = playerLayer.player.naturalSize + +// File size (estimated) +let fileSize = playerLayer.player.fileSize +``` + +### Player Control + +```swift +// Volume (0.0 to 1.0) +playerLayer.player.playbackVolume = 0.5 + +// Mute +playerLayer.player.isMuted = true + +// Playback rate +playerLayer.player.playbackRate = 1.5 + +// Content mode +playerLayer.player.contentMode = .scaleAspectFit +``` + +### Tracks + +```swift +// Get audio tracks +let audioTracks = playerLayer.player.tracks(mediaType: .audio) + +// Get video tracks +let videoTracks = playerLayer.player.tracks(mediaType: .video) + +// Select a track +if let englishTrack = audioTracks.first(where: { $0.languageCode == "en" }) { + playerLayer.player.select(track: englishTrack) +} +``` + +### External Playback (AirPlay) + +```swift +// Enable AirPlay +playerLayer.player.allowsExternalPlayback = true + +// Check if actively using AirPlay +let isAirPlaying = playerLayer.player.isExternalPlaybackActive + +// Auto-switch to external when screen connected +playerLayer.player.usesExternalPlaybackWhileExternalScreenIsActive = true +``` + +### Picture-in-Picture + +```swift +// Available on tvOS 14.0+, iOS 14.0+ +if #available(tvOS 14.0, iOS 14.0, *) { + // Toggle PiP + playerLayer.isPipActive.toggle() + + // Or access controller directly + playerLayer.player.pipController?.start(view: playerLayer) + playerLayer.player.pipController?.stop(restoreUserInterface: true) +} +``` + +### Dynamic Info + +```swift +if let dynamicInfo = playerLayer.player.dynamicInfo { + print("FPS: \(dynamicInfo.displayFPS)") + print("A/V Sync: \(dynamicInfo.audioVideoSyncDiff)") + print("Dropped frames: \(dynamicInfo.droppedVideoFrameCount)") + print("Audio bitrate: \(dynamicInfo.audioBitrate)") + print("Video bitrate: \(dynamicInfo.videoBitrate)") + + // Metadata + if let title = dynamicInfo.metadata["title"] { + print("Title: \(title)") + } +} +``` + +### Chapters + +```swift +let chapters = playerLayer.player.chapters +for chapter in chapters { + print("\(chapter.title): \(chapter.start) - \(chapter.end)") +} +``` + +### Thumbnails + +```swift +Task { + if let thumbnail = await playerLayer.player.thumbnailImageAtCurrentTime() { + let image = UIImage(cgImage: thumbnail) + // Use thumbnail + } +} +``` + +## KSPlayerLayerDelegate + +Implement the delegate to receive events: + +```swift +extension MyViewController: KSPlayerLayerDelegate { + func player(layer: KSPlayerLayer, state: KSPlayerState) { + switch state { + case .initialized: + print("Player initialized") + case .preparing: + print("Preparing...") + case .readyToPlay: + print("Ready - Duration: \(layer.player.duration)") + case .buffering: + print("Buffering...") + case .bufferFinished: + print("Playing") + case .paused: + print("Paused") + case .playedToTheEnd: + print("Finished") + case .error: + print("Error occurred") + } + } + + func player(layer: KSPlayerLayer, currentTime: TimeInterval, totalTime: TimeInterval) { + let progress = totalTime > 0 ? currentTime / totalTime : 0 + print("Progress: \(Int(progress * 100))%") + } + + func player(layer: KSPlayerLayer, finish error: Error?) { + if let error = error { + print("Playback error: \(error.localizedDescription)") + } else { + print("Playback completed successfully") + } + } + + func player(layer: KSPlayerLayer, bufferedCount: Int, consumeTime: TimeInterval) { + // bufferedCount: 0 = initial load + // consumeTime: time spent buffering + print("Buffer #\(bufferedCount), took \(consumeTime)s") + } +} +``` + +## Player View Integration + +The player's view can be added to your view hierarchy: + +```swift +if let playerView = playerLayer.player.view { + containerView.addSubview(playerView) + playerView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + playerView.topAnchor.constraint(equalTo: containerView.topAnchor), + playerView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), + playerView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), + playerView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor), + ]) +} +``` + +## Remote Control + +Remote control is automatically registered when `options.registerRemoteControll` is `true` (default). + +### Supported Commands + +- Play/Pause +- Stop +- Next/Previous track (for playlists) +- Skip forward/backward (15 seconds) +- Change playback position +- Change playback rate +- Change repeat mode +- Language/audio track selection + +### Customizing Remote Control + +```swift +// Disable auto-registration +options.registerRemoteControll = false + +// Manually register later +playerLayer.registerRemoteControllEvent() +``` + +### Now Playing Info + +```swift +import MediaPlayer + +// Set custom Now Playing info +MPNowPlayingInfoCenter.default().nowPlayingInfo = [ + MPMediaItemPropertyTitle: "Video Title", + MPMediaItemPropertyArtist: "Artist Name", + MPMediaItemPropertyPlaybackDuration: playerLayer.player.duration +] +``` + +## Background/Foreground Handling + +KSPlayerLayer automatically handles app lifecycle: + +- **Background**: Pauses video (unless `KSOptions.canBackgroundPlay` is `true`) +- **Foreground**: Resumes display + +```swift +// Enable background playback +KSOptions.canBackgroundPlay = true +``` + +## Player Type Switching + +The player automatically switches from `firstPlayerType` to `secondPlayerType` on failure: + +```swift +// Configure player types before creating KSPlayerLayer +KSOptions.firstPlayerType = KSAVPlayer.self +KSOptions.secondPlayerType = KSMEPlayer.self +``` + +## Complete Example + +```swift +class VideoPlayerController: UIViewController, KSPlayerLayerDelegate { + private var playerLayer: KSPlayerLayer! + private var containerView: UIView! + + override func viewDidLoad() { + super.viewDidLoad() + + containerView = UIView() + view.addSubview(containerView) + containerView.frame = view.bounds + + let url = URL(string: "https://example.com/video.mp4")! + let options = KSOptions() + options.isLoopPlay = true + + playerLayer = KSPlayerLayer( + url: url, + options: options, + delegate: self + ) + + if let playerView = playerLayer.player.view { + containerView.addSubview(playerView) + playerView.frame = containerView.bounds + playerView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + } + } + + // MARK: - KSPlayerLayerDelegate + + func player(layer: KSPlayerLayer, state: KSPlayerState) { + print("State: \(state)") + } + + func player(layer: KSPlayerLayer, currentTime: TimeInterval, totalTime: TimeInterval) { + // Update progress UI + } + + func player(layer: KSPlayerLayer, finish error: Error?) { + if let error = error { + showError(error) + } + } + + func player(layer: KSPlayerLayer, bufferedCount: Int, consumeTime: TimeInterval) { + if bufferedCount == 0 { + print("Initial load took \(consumeTime)s") + } + } + + deinit { + playerLayer.stop() + } +} +``` + diff --git a/docs/ks-player/SubtitleSupport.md b/docs/ks-player/SubtitleSupport.md new file mode 100644 index 000000000..5480e00f9 --- /dev/null +++ b/docs/ks-player/SubtitleSupport.md @@ -0,0 +1,490 @@ +# Subtitle Support + +KSPlayer provides comprehensive subtitle support including embedded subtitles, external subtitle files, and online subtitle search. + +## SubtitleModel + +`SubtitleModel` manages subtitle sources, selection, and rendering. + +### Properties + +```swift +open class SubtitleModel: ObservableObject { + // Available subtitle sources + @Published public private(set) var subtitleInfos: [any SubtitleInfo] + + // Current subtitle parts being displayed + @Published public private(set) var parts: [SubtitlePart] + + // Global subtitle delay (seconds) + public var subtitleDelay: Double = 0.0 + + // Current media URL + public var url: URL? + + // Selected subtitle + @Published public var selectedSubtitleInfo: (any SubtitleInfo)? +} +``` + +### Static Styling Properties + +```swift +SubtitleModel.textColor: Color = .white +SubtitleModel.textBackgroundColor: Color = .clear +SubtitleModel.textFontSize: CGFloat = SubtitleModel.Size.standard.rawValue +SubtitleModel.textBold: Bool = false +SubtitleModel.textItalic: Bool = false +SubtitleModel.textPosition: TextPosition = TextPosition() +``` + +### Font Sizes + +```swift +public enum Size { + case smaller // 12pt (iPhone), 20pt (iPad/Mac), 48pt (TV) + case standard // 16pt (iPhone), 26pt (iPad/Mac), 58pt (TV) + case large // 20pt (iPhone), 32pt (iPad/Mac), 68pt (TV) +} +``` + +### Methods + +```swift +// Add subtitle source +public func addSubtitle(info: any SubtitleInfo) + +// Add subtitle data source +public func addSubtitle(dataSouce: SubtitleDataSouce) + +// Search for subtitles online +public func searchSubtitle(query: String?, languages: [String]) + +// Get subtitle for current time (called internally) +public func subtitle(currentTime: TimeInterval) -> Bool +``` + +## SubtitleInfo Protocol + +Protocol for subtitle track information: + +```swift +public protocol SubtitleInfo: KSSubtitleProtocol, AnyObject, Hashable, Identifiable { + var subtitleID: String { get } + var name: String { get } + var delay: TimeInterval { get set } + var isEnabled: Bool { get set } +} +``` + +### KSSubtitleProtocol + +```swift +public protocol KSSubtitleProtocol { + func search(for time: TimeInterval) -> [SubtitlePart] +} +``` + +## URLSubtitleInfo + +Subtitle from a URL: + +```swift +public class URLSubtitleInfo: KSSubtitle, SubtitleInfo { + public private(set) var downloadURL: URL + public var delay: TimeInterval = 0 + public private(set) var name: String + public let subtitleID: String + public var comment: String? + public var isEnabled: Bool + + // Simple initializer + public convenience init(url: URL) + + // Full initializer + public init( + subtitleID: String, + name: String, + url: URL, + userAgent: String? = nil + ) +} +``` + +### Example: Loading External Subtitle + +```swift +let subtitleURL = URL(string: "https://example.com/subtitle.srt")! +let subtitleInfo = URLSubtitleInfo(url: subtitleURL) + +// Add to subtitle model +subtitleModel.addSubtitle(info: subtitleInfo) + +// Or select directly +subtitleModel.selectedSubtitleInfo = subtitleInfo +``` + +## SubtitlePart + +A single subtitle cue: + +```swift +public class SubtitlePart: CustomStringConvertible, Identifiable { + public var start: TimeInterval + public var end: TimeInterval + public var origin: CGPoint = .zero + public let text: NSAttributedString? + public var image: UIImage? // For image-based subtitles (e.g., SUP) + public var textPosition: TextPosition? + + public convenience init(_ start: TimeInterval, _ end: TimeInterval, _ string: String) + public init(_ start: TimeInterval, _ end: TimeInterval, attributedString: NSAttributedString?) +} +``` + +## SubtitleDataSouce Protocol + +Protocol for subtitle sources: + +```swift +public protocol SubtitleDataSouce: AnyObject { + var infos: [any SubtitleInfo] { get } +} +``` + +### FileURLSubtitleDataSouce + +For file-based subtitle sources: + +```swift +public protocol FileURLSubtitleDataSouce: SubtitleDataSouce { + func searchSubtitle(fileURL: URL?) async throws +} +``` + +### SearchSubtitleDataSouce + +For online subtitle search: + +```swift +public protocol SearchSubtitleDataSouce: SubtitleDataSouce { + func searchSubtitle(query: String?, languages: [String]) async throws +} +``` + +### CacheSubtitleDataSouce + +For cached subtitles: + +```swift +public protocol CacheSubtitleDataSouce: FileURLSubtitleDataSouce { + func addCache(fileURL: URL, downloadURL: URL) +} +``` + +## Built-in Data Sources + +### URLSubtitleDataSouce + +Simple URL-based subtitle source: + +```swift +public class URLSubtitleDataSouce: SubtitleDataSouce { + public var infos: [any SubtitleInfo] + + public init(urls: [URL]) +} + +// Example +let subtitleSource = URLSubtitleDataSouce(urls: [ + URL(string: "https://example.com/english.srt")!, + URL(string: "https://example.com/spanish.srt")! +]) +``` + +### DirectorySubtitleDataSouce + +Searches for subtitles in the same directory as the video: + +```swift +public class DirectorySubtitleDataSouce: FileURLSubtitleDataSouce { + public var infos: [any SubtitleInfo] + + public init() + public func searchSubtitle(fileURL: URL?) async throws +} +``` + +### PlistCacheSubtitleDataSouce + +Caches downloaded subtitle locations: + +```swift +public class PlistCacheSubtitleDataSouce: CacheSubtitleDataSouce { + public static let singleton: PlistCacheSubtitleDataSouce + public var infos: [any SubtitleInfo] + + public func searchSubtitle(fileURL: URL?) async throws + public func addCache(fileURL: URL, downloadURL: URL) +} +``` + +## Online Subtitle Providers + +### ShooterSubtitleDataSouce + +Shooter.cn subtitle search (for local files): + +```swift +public class ShooterSubtitleDataSouce: FileURLSubtitleDataSouce { + public var infos: [any SubtitleInfo] + + public init() + public func searchSubtitle(fileURL: URL?) async throws +} +``` + +### AssrtSubtitleDataSouce + +Assrt.net subtitle search: + +```swift +public class AssrtSubtitleDataSouce: SearchSubtitleDataSouce { + public var infos: [any SubtitleInfo] + + public init(token: String) + public func searchSubtitle(query: String?, languages: [String]) async throws +} + +// Example +let assrtSource = AssrtSubtitleDataSouce(token: "your-api-token") +``` + +### OpenSubtitleDataSouce + +OpenSubtitles.com API: + +```swift +public class OpenSubtitleDataSouce: SearchSubtitleDataSouce { + public var infos: [any SubtitleInfo] + + public init(apiKey: String, username: String? = nil, password: String? = nil) + + // Search by query + public func searchSubtitle(query: String?, languages: [String]) async throws + + // Search by IDs + public func searchSubtitle( + query: String?, + imdbID: Int, + tmdbID: Int, + languages: [String] + ) async throws + + // Search with custom parameters + public func searchSubtitle(queryItems: [String: String]) async throws +} + +// Example +let openSubSource = OpenSubtitleDataSouce(apiKey: "your-api-key") +``` + +## Configuring Default Data Sources + +```swift +// Set default subtitle data sources +KSOptions.subtitleDataSouces = [ + DirectorySubtitleDataSouce(), + PlistCacheSubtitleDataSouce.singleton +] + +// Add online search +KSOptions.subtitleDataSouces.append( + OpenSubtitleDataSouce(apiKey: "your-key") +) +``` + +## UIKit Integration + +### With VideoPlayerView + +```swift +class VideoViewController: UIViewController { + let playerView = IOSVideoPlayerView() + + func loadSubtitle(url: URL) { + let subtitleInfo = URLSubtitleInfo(url: url) + playerView.srtControl.addSubtitle(info: subtitleInfo) + playerView.srtControl.selectedSubtitleInfo = subtitleInfo + } + + func selectSubtitle(at index: Int) { + let subtitles = playerView.srtControl.subtitleInfos + if index < subtitles.count { + playerView.srtControl.selectedSubtitleInfo = subtitles[index] + } + } + + func disableSubtitles() { + playerView.srtControl.selectedSubtitleInfo = nil + } +} +``` + +### Subtitle Styling + +```swift +// Configure before creating player +SubtitleModel.textFontSize = 20 +SubtitleModel.textColor = .yellow +SubtitleModel.textBackgroundColor = Color.black.opacity(0.5) +SubtitleModel.textBold = true + +// Update during playback (VideoPlayerView only) +playerView.updateSrt() +``` + +## SwiftUI Integration + +### With KSVideoPlayer.Coordinator + +```swift +struct PlayerView: View { + @StateObject var coordinator = KSVideoPlayer.Coordinator() + + var body: some View { + VStack { + KSVideoPlayer(coordinator: coordinator, url: url, options: KSOptions()) + + // Subtitle picker + Picker("Subtitle", selection: $coordinator.subtitleModel.selectedSubtitleInfo) { + Text("Off").tag(nil as (any SubtitleInfo)?) + ForEach(coordinator.subtitleModel.subtitleInfos, id: \.subtitleID) { info in + Text(info.name).tag(info as (any SubtitleInfo)?) + } + } + } + } +} +``` + +### Adding External Subtitles + +```swift +func addSubtitle(url: URL) { + let info = URLSubtitleInfo(url: url) + coordinator.subtitleModel.addSubtitle(info: info) +} +``` + +### Searching Online Subtitles + +```swift +func searchSubtitles(title: String) { + coordinator.subtitleModel.searchSubtitle( + query: title, + languages: ["en", "es"] + ) +} +``` + +## TextPosition + +Subtitle text positioning: + +```swift +public struct TextPosition { + public var verticalAlign: VerticalAlignment = .bottom + public var horizontalAlign: HorizontalAlignment = .center + public var leftMargin: CGFloat = 0 + public var rightMargin: CGFloat = 0 + public var verticalMargin: CGFloat = 10 +} + +// Configure position +SubtitleModel.textPosition = TextPosition( + verticalAlign: .bottom, + horizontalAlign: .center, + verticalMargin: 50 +) +``` + +## Supported Subtitle Formats + +KSPlayer supports various subtitle formats through FFmpeg and built-in parsers: + +- **Text Formats**: SRT, ASS/SSA, VTT, TTML +- **Image Formats**: SUP/PGS, VobSub (IDX/SUB) +- **Embedded Subtitles**: From MKV, MP4, etc. + +## Parsing Subtitles Manually + +```swift +let subtitle = KSSubtitle() + +// Parse from URL +Task { + try await subtitle.parse(url: subtitleURL) + print("Loaded \(subtitle.parts.count) subtitle cues") +} + +// Parse from data +try subtitle.parse(data: subtitleData, encoding: .utf8) + +// Search for subtitle at time +let parts = subtitle.search(for: currentTime) +``` + +## Complete Example + +```swift +class SubtitlePlayerController: UIViewController, KSPlayerLayerDelegate { + private var playerLayer: KSPlayerLayer! + private var subtitleModel = SubtitleModel() + private var subtitleLabel = UILabel() + + override func viewDidLoad() { + super.viewDidLoad() + setupSubtitleLabel() + + // Configure subtitle sources + let subtitleSource = URLSubtitleDataSouce(urls: [ + URL(string: "https://example.com/english.srt")! + ]) + subtitleModel.addSubtitle(dataSouce: subtitleSource) + + // Create player + let url = URL(string: "https://example.com/video.mp4")! + playerLayer = KSPlayerLayer(url: url, options: KSOptions(), delegate: self) + subtitleModel.url = url + } + + func player(layer: KSPlayerLayer, state: KSPlayerState) { + if state == .readyToPlay { + // Add embedded subtitles + if let subtitleDataSource = layer.player.subtitleDataSouce { + subtitleModel.addSubtitle(dataSouce: subtitleDataSource) + } + + // Auto-select first subtitle + subtitleModel.selectedSubtitleInfo = subtitleModel.subtitleInfos.first + } + } + + func player(layer: KSPlayerLayer, currentTime: TimeInterval, totalTime: TimeInterval) { + if subtitleModel.subtitle(currentTime: currentTime) { + updateSubtitleDisplay() + } + } + + private func updateSubtitleDisplay() { + if let part = subtitleModel.parts.first { + subtitleLabel.attributedText = part.text + subtitleLabel.isHidden = false + } else { + subtitleLabel.isHidden = true + } + } +} +``` + diff --git a/docs/ks-player/SwiftUIUsage.md b/docs/ks-player/SwiftUIUsage.md new file mode 100644 index 000000000..c1fd8470a --- /dev/null +++ b/docs/ks-player/SwiftUIUsage.md @@ -0,0 +1,426 @@ +# SwiftUI Usage + +KSPlayer provides full SwiftUI support with `KSVideoPlayer` (a UIViewRepresentable) and `KSVideoPlayerView` (a complete player view with controls). + +**Minimum Requirements:** iOS 16.0, macOS 13.0, tvOS 16.0 + +## KSVideoPlayerView + +`KSVideoPlayerView` is a complete video player with built-in controls, subtitle display, and settings. + +### Basic Usage + +```swift +import KSPlayer +import SwiftUI + +struct VideoScreen: View { + let url = URL(string: "https://example.com/video.mp4")! + + var body: some View { + KSVideoPlayerView(url: url, options: KSOptions()) + } +} +``` + +### With Custom Title + +```swift +KSVideoPlayerView( + url: url, + options: KSOptions(), + title: "My Video Title" +) +``` + +### With Coordinator and Subtitle Data Source + +```swift +struct VideoScreen: View { + @StateObject private var coordinator = KSVideoPlayer.Coordinator() + let url: URL + let subtitleDataSource: SubtitleDataSouce? + + var body: some View { + KSVideoPlayerView( + coordinator: coordinator, + url: url, + options: KSOptions(), + title: "Video Title", + subtitleDataSouce: subtitleDataSource + ) + } +} +``` + +## KSVideoPlayer + +`KSVideoPlayer` is the lower-level UIViewRepresentable that provides the video rendering surface. Use this when you want full control over the UI. + +### Basic Usage + +```swift +import KSPlayer +import SwiftUI + +struct CustomPlayerView: View { + @StateObject private var coordinator = KSVideoPlayer.Coordinator() + let url: URL + let options: KSOptions + + var body: some View { + KSVideoPlayer(coordinator: coordinator, url: url, options: options) + .onStateChanged { layer, state in + print("State changed: \(state)") + } + .onPlay { currentTime, totalTime in + print("Playing: \(currentTime)/\(totalTime)") + } + .onFinish { layer, error in + if let error = error { + print("Error: \(error)") + } + } + } +} +``` + +### Initializer + +```swift +public struct KSVideoPlayer { + public init( + coordinator: Coordinator, + url: URL, + options: KSOptions + ) +} +``` + +## KSVideoPlayer.Coordinator + +The Coordinator manages player state and provides bindings for SwiftUI views. + +### Creating a Coordinator + +```swift +@StateObject private var coordinator = KSVideoPlayer.Coordinator() +``` + +### Published Properties + +```swift +@MainActor +public final class Coordinator: ObservableObject { + // Playback state (read-only computed property) + public var state: KSPlayerState { get } + + // Mute control + @Published public var isMuted: Bool = false + + // Volume (0.0 to 1.0) + @Published public var playbackVolume: Float = 1.0 + + // Content mode toggle + @Published public var isScaleAspectFill: Bool = false + + // Playback rate (1.0 = normal) + @Published public var playbackRate: Float = 1.0 + + // Controls visibility + @Published public var isMaskShow: Bool = true + + // Subtitle model + public var subtitleModel: SubtitleModel + + // Time model for progress display + public var timemodel: ControllerTimeModel + + // The underlying player layer + public var playerLayer: KSPlayerLayer? +} +``` + +### Coordinator Methods + +```swift +// Skip forward/backward by seconds +public func skip(interval: Int) + +// Seek to specific time +public func seek(time: TimeInterval) + +// Show/hide controls with optional auto-hide +public func mask(show: Bool, autoHide: Bool = true) + +// Reset player state (called automatically on view dismissal) +public func resetPlayer() +``` + +### Using Coordinator for Playback Control + +```swift +struct PlayerView: View { + @StateObject private var coordinator = KSVideoPlayer.Coordinator() + let url: URL + + var body: some View { + VStack { + KSVideoPlayer(coordinator: coordinator, url: url, options: KSOptions()) + + HStack { + Button("Play") { + coordinator.playerLayer?.play() + } + + Button("Pause") { + coordinator.playerLayer?.pause() + } + + Button("-15s") { + coordinator.skip(interval: -15) + } + + Button("+15s") { + coordinator.skip(interval: 15) + } + } + + Slider(value: $coordinator.playbackVolume, in: 0...1) + + Toggle("Mute", isOn: $coordinator.isMuted) + } + } +} +``` + +## View Modifiers + +### onStateChanged + +Called when playback state changes: + +```swift +KSVideoPlayer(coordinator: coordinator, url: url, options: options) + .onStateChanged { layer, state in + switch state { + case .initialized: break + case .preparing: break + case .readyToPlay: + // Access metadata + if let title = layer.player.dynamicInfo?.metadata["title"] { + print("Title: \(title)") + } + case .buffering: break + case .bufferFinished: break + case .paused: break + case .playedToTheEnd: break + case .error: break + } + } +``` + +### onPlay + +Called periodically during playback with current and total time: + +```swift +.onPlay { currentTime, totalTime in + let progress = currentTime / totalTime + print("Progress: \(Int(progress * 100))%") +} +``` + +### onFinish + +Called when playback ends (naturally or with error): + +```swift +.onFinish { layer, error in + if let error = error { + print("Playback failed: \(error.localizedDescription)") + } else { + print("Playback completed") + } +} +``` + +### onBufferChanged + +Called when buffering status changes: + +```swift +.onBufferChanged { bufferedCount, consumeTime in + // bufferedCount: 0 = initial loading + print("Buffer count: \(bufferedCount), time: \(consumeTime)") +} +``` + +### onSwipe (iOS only) + +Called on swipe gestures: + +```swift +#if canImport(UIKit) +.onSwipe { direction in + switch direction { + case .up: print("Swipe up") + case .down: print("Swipe down") + case .left: print("Swipe left") + case .right: print("Swipe right") + default: break + } +} +#endif +``` + +## ControllerTimeModel + +Used for displaying playback time: + +```swift +public class ControllerTimeModel: ObservableObject { + @Published public var currentTime: Int = 0 + @Published public var totalTime: Int = 1 +} +``` + +Usage: + +```swift +struct TimeDisplay: View { + @ObservedObject var timeModel: ControllerTimeModel + + var body: some View { + Text("\(timeModel.currentTime) / \(timeModel.totalTime)") + } +} + +// In your player view: +TimeDisplay(timeModel: coordinator.timemodel) +``` + +## Subtitle Integration + +Access subtitles through the coordinator: + +```swift +struct SubtitlePicker: View { + @ObservedObject var subtitleModel: SubtitleModel + + var body: some View { + Picker("Subtitle", selection: $subtitleModel.selectedSubtitleInfo) { + Text("Off").tag(nil as (any SubtitleInfo)?) + ForEach(subtitleModel.subtitleInfos, id: \.subtitleID) { info in + Text(info.name).tag(info as (any SubtitleInfo)?) + } + } + } +} + +// Usage: +SubtitlePicker(subtitleModel: coordinator.subtitleModel) +``` + +## Complete Example + +```swift +import KSPlayer +import SwiftUI + +@available(iOS 16.0, *) +struct FullPlayerView: View { + @StateObject private var coordinator = KSVideoPlayer.Coordinator() + @State private var url: URL + @State private var title: String + @Environment(\.dismiss) private var dismiss + + init(url: URL, title: String) { + _url = State(initialValue: url) + _title = State(initialValue: title) + } + + var body: some View { + ZStack { + KSVideoPlayer(coordinator: coordinator, url: url, options: KSOptions()) + .onStateChanged { layer, state in + if state == .readyToPlay { + if let movieTitle = layer.player.dynamicInfo?.metadata["title"] { + title = movieTitle + } + } + } + .onFinish { _, error in + if error != nil { + dismiss() + } + } + .ignoresSafeArea() + .onTapGesture { + coordinator.isMaskShow.toggle() + } + + // Custom controls overlay + if coordinator.isMaskShow { + VStack { + HStack { + Button("Back") { dismiss() } + Spacer() + Text(title) + } + .padding() + + Spacer() + + HStack(spacing: 40) { + Button(action: { coordinator.skip(interval: -15) }) { + Image(systemName: "gobackward.15") + } + + Button(action: { + if coordinator.state.isPlaying { + coordinator.playerLayer?.pause() + } else { + coordinator.playerLayer?.play() + } + }) { + Image(systemName: coordinator.state.isPlaying ? "pause.fill" : "play.fill") + } + + Button(action: { coordinator.skip(interval: 15) }) { + Image(systemName: "goforward.15") + } + } + .font(.largeTitle) + + Spacer() + } + .foregroundColor(.white) + } + } + .preferredColorScheme(.dark) + } +} +``` + +## URL Change Handling + +The player automatically detects URL changes: + +```swift +struct DynamicPlayerView: View { + @StateObject private var coordinator = KSVideoPlayer.Coordinator() + @State private var currentURL: URL + + var body: some View { + VStack { + KSVideoPlayer(coordinator: coordinator, url: currentURL, options: KSOptions()) + + Button("Load Next Video") { + currentURL = URL(string: "https://example.com/next-video.mp4")! + } + } + } +} +``` + diff --git a/docs/ks-player/TrackManagement.md b/docs/ks-player/TrackManagement.md new file mode 100644 index 000000000..f8eefd82f --- /dev/null +++ b/docs/ks-player/TrackManagement.md @@ -0,0 +1,473 @@ +# Track Management + +KSPlayer provides APIs for managing audio, video, and subtitle tracks within media files. + +## Overview + +Tracks represent individual streams within a media container (video tracks, audio tracks, subtitle tracks). You can: +- Query available tracks +- Get track metadata +- Select/enable specific tracks + +## Getting Tracks + +### From MediaPlayerProtocol + +```swift +// Get audio tracks +let audioTracks = player.tracks(mediaType: .audio) + +// Get video tracks +let videoTracks = player.tracks(mediaType: .video) + +// Get subtitle tracks +let subtitleTracks = player.tracks(mediaType: .subtitle) +``` + +### From KSPlayerLayer + +```swift +if let player = playerLayer.player { + let audioTracks = player.tracks(mediaType: .audio) + // ... +} +``` + +### From VideoPlayerView + +```swift +if let player = playerView.playerLayer?.player { + let tracks = player.tracks(mediaType: .audio) + // ... +} +``` + +### From SwiftUI Coordinator + +```swift +let audioTracks = coordinator.playerLayer?.player.tracks(mediaType: .audio) ?? [] +``` + +## MediaPlayerTrack Protocol + +All tracks conform to `MediaPlayerTrack`: + +```swift +public protocol MediaPlayerTrack: AnyObject, CustomStringConvertible { + var trackID: Int32 { get } + var name: String { get } + var languageCode: String? { get } + var mediaType: AVFoundation.AVMediaType { get } + var nominalFrameRate: Float { get set } + var bitRate: Int64 { get } + var bitDepth: Int32 { get } + var isEnabled: Bool { get set } + var isImageSubtitle: Bool { get } + var rotation: Int16 { get } + var dovi: DOVIDecoderConfigurationRecord? { get } + var fieldOrder: FFmpegFieldOrder { get } + var formatDescription: CMFormatDescription? { get } +} +``` + +## Track Properties + +### Basic Properties + +| Property | Type | Description | +|----------|------|-------------| +| `trackID` | `Int32` | Unique track identifier | +| `name` | `String` | Track name (often empty) | +| `languageCode` | `String?` | ISO 639-1/639-2 language code | +| `mediaType` | `AVMediaType` | `.audio`, `.video`, or `.subtitle` | +| `isEnabled` | `Bool` | Whether track is currently active | + +### Audio Properties + +| Property | Type | Description | +|----------|------|-------------| +| `bitRate` | `Int64` | Audio bitrate in bps | +| `audioStreamBasicDescription` | `AudioStreamBasicDescription?` | Core Audio format info | + +### Video Properties + +| Property | Type | Description | +|----------|------|-------------| +| `naturalSize` | `CGSize` | Video dimensions | +| `nominalFrameRate` | `Float` | Frame rate | +| `bitRate` | `Int64` | Video bitrate in bps | +| `bitDepth` | `Int32` | Color depth (8, 10, 12) | +| `rotation` | `Int16` | Rotation in degrees | +| `fieldOrder` | `FFmpegFieldOrder` | Interlacing type | +| `dynamicRange` | `DynamicRange?` | SDR/HDR/Dolby Vision | +| `dovi` | `DOVIDecoderConfigurationRecord?` | Dolby Vision config | + +### Color Properties + +| Property | Type | Description | +|----------|------|-------------| +| `colorPrimaries` | `String?` | Color primaries (e.g., "ITU_R_709_2") | +| `transferFunction` | `String?` | Transfer function | +| `yCbCrMatrix` | `String?` | YCbCr matrix | +| `colorSpace` | `CGColorSpace?` | Computed color space | + +### Subtitle Properties + +| Property | Type | Description | +|----------|------|-------------| +| `isImageSubtitle` | `Bool` | True for bitmap subtitles (SUP, VobSub) | + +### Computed Properties + +```swift +extension MediaPlayerTrack { + // Localized language name + var language: String? { + languageCode.flatMap { Locale.current.localizedString(forLanguageCode: $0) } + } + + // FourCC codec type + var codecType: FourCharCode + + // Video format subtype + var mediaSubType: CMFormatDescription.MediaSubType +} +``` + +## Selecting Tracks + +### Select a Track + +```swift +// Find English audio track +if let englishTrack = audioTracks.first(where: { $0.languageCode == "en" }) { + player.select(track: englishTrack) +} +``` + +### Select Track by Index + +```swift +let audioTracks = player.tracks(mediaType: .audio) +if audioTracks.count > 1 { + player.select(track: audioTracks[1]) +} +``` + +### Check Currently Selected Track + +```swift +let currentAudio = audioTracks.first(where: { $0.isEnabled }) +print("Current audio: \(currentAudio?.name ?? "none")") +``` + +## Track Selection Examples + +### Audio Track Selection + +```swift +func selectAudioTrack(languageCode: String) { + let audioTracks = player.tracks(mediaType: .audio) + + if let track = audioTracks.first(where: { $0.languageCode == languageCode }) { + player.select(track: track) + print("Selected: \(track.language ?? track.name)") + } +} + +// Usage +selectAudioTrack(languageCode: "en") // English +selectAudioTrack(languageCode: "es") // Spanish +selectAudioTrack(languageCode: "ja") // Japanese +``` + +### Video Track Selection (Multi-angle/quality) + +```swift +func selectVideoTrack(preferredBitrate: Int64) { + let videoTracks = player.tracks(mediaType: .video) + + // Find closest bitrate + let sorted = videoTracks.sorted { + abs($0.bitRate - preferredBitrate) < abs($1.bitRate - preferredBitrate) + } + + if let track = sorted.first { + player.select(track: track) + print("Selected video: \(track.naturalSize.width)x\(track.naturalSize.height)") + } +} +``` + +### HDR Track Selection + +```swift +func selectHDRTrack() { + let videoTracks = player.tracks(mediaType: .video) + + // Prefer Dolby Vision, then HDR10, then SDR + let preferredOrder: [DynamicRange] = [.dolbyVision, .hdr10, .hlg, .sdr] + + for range in preferredOrder { + if let track = videoTracks.first(where: { $0.dynamicRange == range }) { + player.select(track: track) + print("Selected: \(range)") + return + } + } +} +``` + +## UIKit Track Selection UI + +### Using UIAlertController + +```swift +func showAudioTrackPicker() { + guard let player = playerLayer?.player else { return } + + let audioTracks = player.tracks(mediaType: .audio) + guard !audioTracks.isEmpty else { return } + + let alert = UIAlertController( + title: "Select Audio Track", + message: nil, + preferredStyle: .actionSheet + ) + + for track in audioTracks { + let title = track.language ?? track.name + let action = UIAlertAction(title: title, style: .default) { _ in + player.select(track: track) + } + + if track.isEnabled { + action.setValue(true, forKey: "checked") + } + + alert.addAction(action) + } + + alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) + present(alert, animated: true) +} +``` + +### Track Info Display + +```swift +func displayTrackInfo() { + guard let player = playerLayer?.player else { return } + + // Video info + if let videoTrack = player.tracks(mediaType: .video).first(where: { $0.isEnabled }) { + print("Video: \(videoTrack.naturalSize.width)x\(videoTrack.naturalSize.height)") + print("FPS: \(videoTrack.nominalFrameRate)") + print("Bitrate: \(videoTrack.bitRate / 1000) kbps") + print("HDR: \(videoTrack.dynamicRange?.description ?? "SDR")") + } + + // Audio info + if let audioTrack = player.tracks(mediaType: .audio).first(where: { $0.isEnabled }) { + print("Audio: \(audioTrack.language ?? "Unknown")") + print("Bitrate: \(audioTrack.bitRate / 1000) kbps") + } +} +``` + +## SwiftUI Track Selection + +### Audio Track Picker + +```swift +struct AudioTrackPicker: View { + let player: MediaPlayerProtocol? + + var audioTracks: [MediaPlayerTrack] { + player?.tracks(mediaType: .audio) ?? [] + } + + var body: some View { + Menu { + ForEach(audioTracks, id: \.trackID) { track in + Button { + player?.select(track: track) + } label: { + HStack { + Text(track.language ?? track.name) + if track.isEnabled { + Image(systemName: "checkmark") + } + } + } + } + } label: { + Image(systemName: "waveform.circle.fill") + } + } +} +``` + +### Video Track Picker + +```swift +struct VideoTrackPicker: View { + let player: MediaPlayerProtocol? + + var videoTracks: [MediaPlayerTrack] { + player?.tracks(mediaType: .video) ?? [] + } + + var body: some View { + Picker("Video", selection: Binding( + get: { videoTracks.first(where: { $0.isEnabled })?.trackID }, + set: { newValue in + if let track = videoTracks.first(where: { $0.trackID == newValue }) { + player?.select(track: track) + } + } + )) { + ForEach(videoTracks, id: \.trackID) { track in + Text("\(Int(track.naturalSize.width))x\(Int(track.naturalSize.height))") + .tag(track.trackID as Int32?) + } + } + } +} +``` + +## Automatic Track Selection + +Configure `KSOptions` for automatic track selection: + +```swift +class CustomOptions: KSOptions { + // Prefer English audio + override func wantedAudio(tracks: [MediaPlayerTrack]) -> Int? { + if let index = tracks.firstIndex(where: { $0.languageCode == "en" }) { + return index + } + return nil // Use default selection + } + + // Prefer highest quality video + override func wantedVideo(tracks: [MediaPlayerTrack]) -> Int? { + if let index = tracks.enumerated().max(by: { $0.element.bitRate < $1.element.bitRate })?.offset { + return index + } + return nil + } +} +``` + +## Track Events + +### Detecting Track Changes + +```swift +func player(layer: KSPlayerLayer, state: KSPlayerState) { + if state == .readyToPlay { + let player = layer.player + + // Log available tracks + print("Audio tracks: \(player.tracks(mediaType: .audio).count)") + print("Video tracks: \(player.tracks(mediaType: .video).count)") + print("Subtitle tracks: \(player.tracks(mediaType: .subtitle).count)") + + // Get current selections + let currentAudio = player.tracks(mediaType: .audio).first(where: { $0.isEnabled }) + let currentVideo = player.tracks(mediaType: .video).first(where: { $0.isEnabled }) + + print("Current audio: \(currentAudio?.language ?? "unknown")") + print("Current video: \(currentVideo?.naturalSize ?? .zero)") + } +} +``` + +## Complete Example + +```swift +class TrackSelectionController: UIViewController, KSPlayerLayerDelegate { + private var playerLayer: KSPlayerLayer! + private var audioButton: UIButton! + private var videoInfoLabel: UILabel! + + override func viewDidLoad() { + super.viewDidLoad() + setupUI() + + let url = URL(string: "https://example.com/multi-track-video.mkv")! + playerLayer = KSPlayerLayer(url: url, options: KSOptions(), delegate: self) + } + + func player(layer: KSPlayerLayer, state: KSPlayerState) { + if state == .readyToPlay { + updateTrackUI() + } + } + + private func updateTrackUI() { + guard let player = playerLayer?.player else { return } + + // Update video info + if let video = player.tracks(mediaType: .video).first(where: { $0.isEnabled }) { + videoInfoLabel.text = """ + \(Int(video.naturalSize.width))x\(Int(video.naturalSize.height)) @ \(Int(video.nominalFrameRate))fps + \(video.dynamicRange?.description ?? "SDR") + """ + } + + // Update audio button + let audioCount = player.tracks(mediaType: .audio).count + audioButton.isHidden = audioCount < 2 + + if let audio = player.tracks(mediaType: .audio).first(where: { $0.isEnabled }) { + audioButton.setTitle(audio.language ?? "Audio", for: .normal) + } + } + + @objc private func audioButtonTapped() { + guard let player = playerLayer?.player else { return } + + let tracks = player.tracks(mediaType: .audio) + let alert = UIAlertController(title: "Audio Track", message: nil, preferredStyle: .actionSheet) + + for track in tracks { + let title = [track.language, track.name] + .compactMap { $0 } + .joined(separator: " - ") + + let action = UIAlertAction(title: title.isEmpty ? "Track \(track.trackID)" : title, style: .default) { [weak self] _ in + player.select(track: track) + self?.updateTrackUI() + } + + if track.isEnabled { + action.setValue(true, forKey: "checked") + alert.preferredAction = action + } + + alert.addAction(action) + } + + alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) + present(alert, animated: true) + } + + private func setupUI() { + audioButton = UIButton(type: .system) + audioButton.addTarget(self, action: #selector(audioButtonTapped), for: .touchUpInside) + view.addSubview(audioButton) + + videoInfoLabel = UILabel() + videoInfoLabel.numberOfLines = 0 + view.addSubview(videoInfoLabel) + } + + // Delegate methods... + func player(layer: KSPlayerLayer, currentTime: TimeInterval, totalTime: TimeInterval) {} + func player(layer: KSPlayerLayer, finish error: Error?) {} + func player(layer: KSPlayerLayer, bufferedCount: Int, consumeTime: TimeInterval) {} +} +``` + diff --git a/docs/ks-player/TypesAndProtocols.md b/docs/ks-player/TypesAndProtocols.md new file mode 100644 index 000000000..c8b1eb0e7 --- /dev/null +++ b/docs/ks-player/TypesAndProtocols.md @@ -0,0 +1,543 @@ +# Types and Protocols + +This reference documents all core types, protocols, and enums in KSPlayer. + +## Protocols + +### MediaPlayerProtocol + +The main protocol that player implementations (`KSAVPlayer`, `KSMEPlayer`) must conform to. + +```swift +public protocol MediaPlayerProtocol: MediaPlayback { + var delegate: MediaPlayerDelegate? { get set } + var view: UIView? { get } + var playableTime: TimeInterval { get } + var isReadyToPlay: Bool { get } + var playbackState: MediaPlaybackState { get } + var loadState: MediaLoadState { get } + var isPlaying: Bool { get } + var seekable: Bool { get } + var isMuted: Bool { get set } + var allowsExternalPlayback: Bool { get set } + var usesExternalPlaybackWhileExternalScreenIsActive: Bool { get set } + var isExternalPlaybackActive: Bool { get } + var playbackRate: Float { get set } + var playbackVolume: Float { get set } + var contentMode: UIViewContentMode { get set } + var subtitleDataSouce: SubtitleDataSouce? { get } + var dynamicInfo: DynamicInfo? { get } + + @available(macOS 12.0, iOS 15.0, tvOS 15.0, *) + var playbackCoordinator: AVPlaybackCoordinator { get } + + @available(tvOS 14.0, *) + var pipController: KSPictureInPictureController? { get } + + init(url: URL, options: KSOptions) + func replace(url: URL, options: KSOptions) + func play() + func pause() + func enterBackground() + func enterForeground() + func thumbnailImageAtCurrentTime() async -> CGImage? + func tracks(mediaType: AVFoundation.AVMediaType) -> [MediaPlayerTrack] + func select(track: some MediaPlayerTrack) +} +``` + +### MediaPlayback + +Base protocol for playback functionality: + +```swift +public protocol MediaPlayback: AnyObject { + var duration: TimeInterval { get } + var fileSize: Double { get } + var naturalSize: CGSize { get } + var chapters: [Chapter] { get } + var currentPlaybackTime: TimeInterval { get } + + func prepareToPlay() + func shutdown() + func seek(time: TimeInterval, completion: @escaping ((Bool) -> Void)) +} +``` + +### MediaPlayerDelegate + +Delegate for receiving player events: + +```swift +@MainActor +public protocol MediaPlayerDelegate: AnyObject { + func readyToPlay(player: some MediaPlayerProtocol) + func changeLoadState(player: some MediaPlayerProtocol) + func changeBuffering(player: some MediaPlayerProtocol, progress: Int) + func playBack(player: some MediaPlayerProtocol, loopCount: Int) + func finish(player: some MediaPlayerProtocol, error: Error?) +} +``` + +### MediaPlayerTrack + +Protocol for audio/video/subtitle track information: + +```swift +public protocol MediaPlayerTrack: AnyObject, CustomStringConvertible { + var trackID: Int32 { get } + var name: String { get } + var languageCode: String? { get } + var mediaType: AVFoundation.AVMediaType { get } + var nominalFrameRate: Float { get set } + var bitRate: Int64 { get } + var bitDepth: Int32 { get } + var isEnabled: Bool { get set } + var isImageSubtitle: Bool { get } + var rotation: Int16 { get } + var dovi: DOVIDecoderConfigurationRecord? { get } + var fieldOrder: FFmpegFieldOrder { get } + var formatDescription: CMFormatDescription? { get } +} +``` + +#### Extension Properties + +```swift +extension MediaPlayerTrack { + var language: String? // Localized language name + var codecType: FourCharCode + var dynamicRange: DynamicRange? + var colorSpace: CGColorSpace? + var mediaSubType: CMFormatDescription.MediaSubType + var audioStreamBasicDescription: AudioStreamBasicDescription? + var naturalSize: CGSize + var colorPrimaries: String? + var transferFunction: String? + var yCbCrMatrix: String? +} +``` + +### KSPlayerLayerDelegate + +Delegate for `KSPlayerLayer` events: + +```swift +@MainActor +public protocol KSPlayerLayerDelegate: AnyObject { + func player(layer: KSPlayerLayer, state: KSPlayerState) + func player(layer: KSPlayerLayer, currentTime: TimeInterval, totalTime: TimeInterval) + func player(layer: KSPlayerLayer, finish error: Error?) + func player(layer: KSPlayerLayer, bufferedCount: Int, consumeTime: TimeInterval) +} +``` + +### PlayerControllerDelegate + +Delegate for `PlayerView` events: + +```swift +public protocol PlayerControllerDelegate: AnyObject { + func playerController(state: KSPlayerState) + func playerController(currentTime: TimeInterval, totalTime: TimeInterval) + func playerController(finish error: Error?) + func playerController(maskShow: Bool) + func playerController(action: PlayerButtonType) + func playerController(bufferedCount: Int, consumeTime: TimeInterval) + func playerController(seek: TimeInterval) +} +``` + +### CapacityProtocol + +Buffer capacity information: + +```swift +public protocol CapacityProtocol { + var fps: Float { get } + var packetCount: Int { get } + var frameCount: Int { get } + var frameMaxCount: Int { get } + var isEndOfFile: Bool { get } + var mediaType: AVFoundation.AVMediaType { get } +} +``` + +## Enums + +### KSPlayerState + +Player state enumeration: + +```swift +public enum KSPlayerState: CustomStringConvertible { + case initialized // Player created + case preparing // Loading media + case readyToPlay // Ready to start playback + case buffering // Buffering data + case bufferFinished // Buffer sufficient, playing + case paused // Playback paused + case playedToTheEnd // Reached end of media + case error // Error occurred + + public var isPlaying: Bool // true for .buffering or .bufferFinished +} +``` + +### MediaPlaybackState + +Low-level playback state: + +```swift +public enum MediaPlaybackState: Int { + case idle + case playing + case paused + case seeking + case finished + case stopped +} +``` + +### MediaLoadState + +Loading state: + +```swift +public enum MediaLoadState: Int { + case idle + case loading + case playable +} +``` + +### DynamicRange + +HDR/SDR content range: + +```swift +public enum DynamicRange: Int32 { + case sdr = 0 + case hdr10 = 2 + case hlg = 3 + case dolbyVision = 5 + + static var availableHDRModes: [DynamicRange] // Device-supported modes +} +``` + +### DisplayEnum + +Video display mode: + +```swift +@MainActor +public enum DisplayEnum { + case plane // Normal 2D display + case vr // VR mode (spherical) + case vrBox // VR Box mode (side-by-side) +} +``` + +### FFmpegFieldOrder + +Video interlacing: + +```swift +public enum FFmpegFieldOrder: UInt8 { + case unknown = 0 + case progressive + case tt // Top coded first, top displayed first + case bb // Bottom coded first, bottom displayed first + case tb // Top coded first, bottom displayed first + case bt // Bottom coded first, top displayed first +} +``` + +### VideoInterlacingType + +Detected interlacing type: + +```swift +public enum VideoInterlacingType: String { + case tff // Top field first + case bff // Bottom field first + case progressive // Progressive scan + case undetermined +} +``` + +### ClockProcessType + +Internal clock synchronization: + +```swift +public enum ClockProcessType { + case remain + case next + case dropNextFrame + case dropNextPacket + case dropGOPPacket + case flush + case seek +} +``` + +### PlayerButtonType + +UI button types: + +```swift +public enum PlayerButtonType: Int { + case play = 101 + case pause + case back + case srt // Subtitles + case landscape // Fullscreen + case replay + case lock + case rate // Playback speed + case definition // Quality + case pictureInPicture + case audioSwitch + case videoSwitch +} +``` + +### KSPlayerTopBarShowCase + +Top bar visibility: + +```swift +public enum KSPlayerTopBarShowCase { + case always // Always show + case horizantalOnly // Only in landscape + case none // Never show +} +``` + +### KSPanDirection + +Gesture direction: + +```swift +public enum KSPanDirection { + case horizontal + case vertical +} +``` + +### TimeType + +Time formatting: + +```swift +public enum TimeType { + case min // MM:SS + case hour // H:MM:SS + case minOrHour // MM:SS or H:MM:SS based on duration + case millisecond // HH:MM:SS.ms +} +``` + +### LogLevel + +Logging levels: + +```swift +public enum LogLevel: Int32 { + case panic = 0 + case fatal = 8 + case error = 16 + case warning = 24 + case info = 32 + case verbose = 40 + case debug = 48 + case trace = 56 +} +``` + +## Structs + +### Chapter + +Video chapter information: + +```swift +public struct Chapter { + public let start: TimeInterval + public let end: TimeInterval + public let title: String +} +``` + +### LoadingState + +Buffer loading state: + +```swift +public struct LoadingState { + public let loadedTime: TimeInterval + public let progress: TimeInterval + public let packetCount: Int + public let frameCount: Int + public let isEndOfFile: Bool + public let isPlayable: Bool + public let isFirst: Bool + public let isSeek: Bool +} +``` + +### VideoAdaptationState + +Adaptive bitrate state: + +```swift +public struct VideoAdaptationState { + public struct BitRateState { + let bitRate: Int64 + let time: TimeInterval + } + + public let bitRates: [Int64] + public let duration: TimeInterval + public internal(set) var fps: Float + public internal(set) var bitRateStates: [BitRateState] + public internal(set) var currentPlaybackTime: TimeInterval + public internal(set) var isPlayable: Bool + public internal(set) var loadedCount: Int +} +``` + +### DOVIDecoderConfigurationRecord + +Dolby Vision configuration: + +```swift +public struct DOVIDecoderConfigurationRecord { + public let dv_version_major: UInt8 + public let dv_version_minor: UInt8 + public let dv_profile: UInt8 + public let dv_level: UInt8 + public let rpu_present_flag: UInt8 + public let el_present_flag: UInt8 + public let bl_present_flag: UInt8 + public let dv_bl_signal_compatibility_id: UInt8 +} +``` + +### KSClock + +Internal clock for A/V sync: + +```swift +public struct KSClock { + public private(set) var lastMediaTime: CFTimeInterval + public internal(set) var position: Int64 + public internal(set) var time: CMTime + + func getTime() -> TimeInterval +} +``` + +### TextPosition + +Subtitle text positioning: + +```swift +public struct TextPosition { + public var verticalAlign: VerticalAlignment = .bottom + public var horizontalAlign: HorizontalAlignment = .center + public var leftMargin: CGFloat = 0 + public var rightMargin: CGFloat = 0 + public var verticalMargin: CGFloat = 10 + public var edgeInsets: EdgeInsets { get } +} +``` + +## Classes + +### DynamicInfo + +Runtime playback information: + +```swift +public class DynamicInfo: ObservableObject { + public var metadata: [String: String] // Media metadata + public var bytesRead: Int64 // Bytes transferred + public var audioBitrate: Int // Current audio bitrate + public var videoBitrate: Int // Current video bitrate + + @Published + public var displayFPS: Double = 0.0 // Current display FPS + public var audioVideoSyncDiff: Double = 0.0 // A/V sync difference + public var droppedVideoFrameCount: UInt32 // Dropped frames + public var droppedVideoPacketCount: UInt32 // Dropped packets +} +``` + +## Error Handling + +### KSPlayerErrorCode + +```swift +public enum KSPlayerErrorCode: Int { + case unknown + case formatCreate + case formatOpenInput + case formatOutputCreate + case formatWriteHeader + case formatFindStreamInfo + case readFrame + case codecContextCreate + case codecContextSetParam + case codecContextFindDecoder + case codesContextOpen + case codecVideoSendPacket + case codecAudioSendPacket + case codecVideoReceiveFrame + case codecAudioReceiveFrame + case auidoSwrInit + case codecSubtitleSendPacket + case videoTracksUnplayable + case subtitleUnEncoding + case subtitleUnParse + case subtitleFormatUnSupport + case subtitleParamsEmpty +} +``` + +### Error Domain + +```swift +public let KSPlayerErrorDomain = "KSPlayerErrorDomain" +``` + +## Extensions + +### TimeInterval Formatting + +```swift +extension TimeInterval { + func toString(for type: TimeType) -> String +} + +// Example: +let time: TimeInterval = 3661 // 1 hour, 1 minute, 1 second +time.toString(for: .min) // "61:01" +time.toString(for: .hour) // "1:01:01" +time.toString(for: .minOrHour) // "1:01:01" +``` + +### Int Formatting + +```swift +extension Int { + func toString(for type: TimeType) -> String +} + +extension FixedWidthInteger { + var kmFormatted: String // "1.5K", "2.3M", etc. +} +``` + diff --git a/docs/ks-player/UIKitUsage.md b/docs/ks-player/UIKitUsage.md new file mode 100644 index 000000000..7b67f1b26 --- /dev/null +++ b/docs/ks-player/UIKitUsage.md @@ -0,0 +1,337 @@ +# UIKit Usage + +This guide covers using KSPlayer with UIKit in iOS applications. + +## IOSVideoPlayerView + +`IOSVideoPlayerView` is the main UIKit video player view for iOS. It extends `VideoPlayerView` with iOS-specific features like fullscreen, gestures, and AirPlay. + +### Basic Setup + +```swift +import KSPlayer + +class VideoViewController: UIViewController { + private var playerView: IOSVideoPlayerView! + + override func viewDidLoad() { + super.viewDidLoad() + + playerView = IOSVideoPlayerView() + view.addSubview(playerView) + + playerView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + playerView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + playerView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + playerView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + playerView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + } +} +``` + +### Setting Video URL + +#### Simple URL + +```swift +let url = URL(string: "https://example.com/video.mp4")! +playerView.set(url: url, options: KSOptions()) +``` + +#### With KSPlayerResource + +```swift +let resource = KSPlayerResource( + url: URL(string: "https://example.com/video.mp4")!, + options: KSOptions(), + name: "Video Title", + cover: URL(string: "https://example.com/cover.jpg"), + subtitleURLs: [URL(string: "https://example.com/subtitle.srt")!] +) +playerView.set(resource: resource) +``` + +#### Multiple Definitions (Quality Options) + +```swift +let hdDefinition = KSPlayerResourceDefinition( + url: URL(string: "https://example.com/video_hd.mp4")!, + definition: "1080p", + options: KSOptions() +) + +let sdDefinition = KSPlayerResourceDefinition( + url: URL(string: "https://example.com/video_sd.mp4")!, + definition: "480p", + options: KSOptions() +) + +let resource = KSPlayerResource( + name: "Video Title", + definitions: [hdDefinition, sdDefinition], + cover: URL(string: "https://example.com/cover.jpg") +) + +playerView.set(resource: resource, definitionIndex: 0) +``` + +### KSPlayerResource + +```swift +public class KSPlayerResource { + public let name: String + public let definitions: [KSPlayerResourceDefinition] + public let cover: URL? + public let subtitleDataSouce: SubtitleDataSouce? + public var nowPlayingInfo: KSNowPlayableMetadata? + + // Convenience initializer for single URL + public convenience init( + url: URL, + options: KSOptions = KSOptions(), + name: String = "", + cover: URL? = nil, + subtitleURLs: [URL]? = nil + ) + + // Full initializer + public init( + name: String, + definitions: [KSPlayerResourceDefinition], + cover: URL? = nil, + subtitleDataSouce: SubtitleDataSouce? = nil + ) +} +``` + +### KSPlayerResourceDefinition + +```swift +public struct KSPlayerResourceDefinition { + public let url: URL + public let definition: String + public let options: KSOptions + + public init(url: URL, definition: String, options: KSOptions = KSOptions()) +} +``` + +### PlayerControllerDelegate + +Implement `PlayerControllerDelegate` to receive playback events: + +```swift +class VideoViewController: UIViewController, PlayerControllerDelegate { + override func viewDidLoad() { + super.viewDidLoad() + playerView.delegate = self + } + + func playerController(state: KSPlayerState) { + switch state { + case .initialized: + print("Player initialized") + case .preparing: + print("Preparing to play") + case .readyToPlay: + print("Ready to play") + case .buffering: + print("Buffering...") + case .bufferFinished: + print("Buffer finished, playing") + case .paused: + print("Paused") + case .playedToTheEnd: + print("Playback completed") + case .error: + print("Error occurred") + } + } + + func playerController(currentTime: TimeInterval, totalTime: TimeInterval) { + // Called periodically during playback + print("Progress: \(currentTime)/\(totalTime)") + } + + func playerController(finish error: Error?) { + if let error = error { + print("Playback error: \(error)") + } else { + print("Playback finished") + } + } + + func playerController(maskShow: Bool) { + // Controls visibility changed + } + + func playerController(action: PlayerButtonType) { + // Button pressed + } + + func playerController(bufferedCount: Int, consumeTime: TimeInterval) { + // Buffer status update (bufferedCount: 0 = first load) + } + + func playerController(seek: TimeInterval) { + // Seek completed + } +} +``` + +### Playback Control + +```swift +// Play +playerView.play() + +// Pause +playerView.pause() + +// Seek to time +playerView.seek(time: 30.0) { finished in + print("Seek completed: \(finished)") +} + +// Reset player +playerView.resetPlayer() +``` + +### Time Callbacks + +```swift +// Listen to time changes +playerView.playTimeDidChange = { currentTime, totalTime in + print("Current: \(currentTime), Total: \(totalTime)") +} + +// Back button handler +playerView.backBlock = { [weak self] in + self?.navigationController?.popViewController(animated: true) +} +``` + +### Fullscreen Control + +```swift +// Check if in fullscreen +let isFullscreen = playerView.landscapeButton.isSelected + +// Toggle fullscreen +playerView.updateUI(isFullScreen: true) // Enter fullscreen +playerView.updateUI(isFullScreen: false) // Exit fullscreen +``` + +### Customizing IOSVideoPlayerView + +Subclass to customize behavior: + +```swift +class CustomVideoPlayerView: IOSVideoPlayerView { + override func customizeUIComponents() { + super.customizeUIComponents() + + // Hide playback rate button + toolBar.playbackRateButton.isHidden = true + } + + override func onButtonPressed(type: PlayerButtonType, button: UIButton) { + if type == .landscape { + // Custom landscape button behavior + } else { + super.onButtonPressed(type: type, button: button) + } + } + + override func updateUI(isLandscape: Bool) { + super.updateUI(isLandscape: isLandscape) + // Additional UI updates for orientation + } +} +``` + +## VideoPlayerView (Base Class) + +`VideoPlayerView` is the base class with playback controls. `IOSVideoPlayerView` extends it for iOS. + +### Key Properties + +```swift +public var playerLayer: KSPlayerLayer? +public weak var delegate: PlayerControllerDelegate? +public let toolBar: PlayerToolBar +public let srtControl: SubtitleModel +public var playTimeDidChange: ((TimeInterval, TimeInterval) -> Void)? +public var backBlock: (() -> Void)? +``` + +### Accessing Player Layer + +```swift +// Get the underlying player +if let player = playerView.playerLayer?.player { + // Access player properties + let duration = player.duration + let currentTime = player.currentPlaybackTime + let isPlaying = player.isPlaying +} +``` + +## IOSVideoPlayerView Properties + +```swift +// UI Components +public var backButton: UIButton +public var maskImageView: UIImageView // Cover image +public var airplayStatusView: UIView // AirPlay status indicator +public var routeButton: AVRoutePickerView // AirPlay route picker +public var landscapeButton: UIControl // Fullscreen toggle +public var volumeViewSlider: UXSlider // Volume control + +// State +public var isMaskShow: Bool // Controls visibility +``` + +## PlayerButtonType + +Button types for `onButtonPressed`: + +```swift +public enum PlayerButtonType: Int { + case play = 101 + case pause + case back + case srt // Subtitle selection + case landscape // Fullscreen toggle + case replay + case lock // Lock controls + case rate // Playback rate + case definition // Quality selection + case pictureInPicture + case audioSwitch // Audio track + case videoSwitch // Video track +} +``` + +## Document Picker Integration + +`IOSVideoPlayerView` supports opening files via document picker: + +```swift +extension IOSVideoPlayerView: UIDocumentPickerDelegate { + public func documentPicker(_ controller: UIDocumentPickerViewController, + didPickDocumentsAt urls: [URL]) { + if let url = urls.first { + if url.isMovie || url.isAudio { + set(url: url, options: KSOptions()) + } else { + // Assume subtitle file + srtControl.selectedSubtitleInfo = URLSubtitleInfo(url: url) + } + } + } +} +``` + diff --git a/docs/research/hdr-mpv.md b/docs/research/hdr-mpv.md new file mode 100644 index 000000000..6061e026d --- /dev/null +++ b/docs/research/hdr-mpv.md @@ -0,0 +1,436 @@ +# HDR Support on tvOS with mpv - Research Document + +## Problem Statement + +HDR content appears washed out on Apple TV when using the mpv-based player. The TV doesn't show an HDR indicator and colors look flat compared to other apps like Infuse. + +**Key Discovery:** HDR works correctly on iPhone but not on tvOS, despite using the same mpv player. + +--- + +## Why HDR Works on iPhone + +In `MpvPlayerView.swift`: +```swift +#if !os(tvOS) +if #available(iOS 17.0, *) { + displayLayer.wantsExtendedDynamicRangeContent = true +} +#endif +``` + +On iOS 17+, setting `wantsExtendedDynamicRangeContent = true` on `AVSampleBufferDisplayLayer` enables Extended Dynamic Range (EDR). This tells the display layer to preserve HDR metadata and render in high dynamic range. + +**This API does not exist on tvOS.** Attempting to use it results in: +> 'wantsExtendedDynamicRangeContent' is unavailable in tvOS + +tvOS uses a different HDR architecture designed for external displays via HDMI. + +--- + +## tvOS HDR Architecture + +Unlike iPhone (integrated display), Apple TV connects to external TVs. Apple expects apps to: + +1. **Use `AVDisplayCriteria`** to request display mode changes +2. **Attach proper colorspace metadata** to pixel buffers +3. **Let the TV handle HDR rendering** via HDMI passthrough + +This is how Netflix, Infuse, and the TV app work - they signal "I'm playing HDR10 at 24fps" and tvOS switches the TV to that mode. + +--- + +## MPVKit vo_avfoundation Analysis + +**Location:** `/MPVKit/Sources/BuildScripts/patch/libmpv/0004-avfoundation-video-output.patch` + +### Existing HDR Infrastructure + +The driver has comprehensive HDR support already built in: + +#### 1. HDR Metadata Copy Function (lines 253-270) +```c +static void copy_hdr_metadata(CVPixelBufferRef src, CVPixelBufferRef dst) +{ + const CFStringRef keys[] = { + kCVImageBufferTransferFunctionKey, // PQ for HDR10, HLG for HLG + kCVImageBufferColorPrimariesKey, // BT.2020 for HDR + kCVImageBufferYCbCrMatrixKey, + kCVImageBufferMasteringDisplayColorVolumeKey, // HDR10 static metadata + kCVImageBufferContentLightLevelInfoKey, // MaxCLL, MaxFALL + }; + + for (size_t i = 0; i < MP_ARRAY_SIZE(keys); i++) { + CFTypeRef value = CVBufferGetAttachment(src, keys[i], NULL); + if (value) { + CVBufferSetAttachment(dst, keys[i], value, kCVAttachmentMode_ShouldPropagate); + } + } +} +``` + +#### 2. 10-bit HDR Format Support (lines 232-247) +```c +// For 10-bit HDR content (P010), use RGBA half-float to preserve HDR precision +if (format == kCVPixelFormatType_420YpCbCr10BiPlanarVideoRange || + format == kCVPixelFormatType_420YpCbCr10BiPlanarFullRange) { + outputFormat = kCVPixelFormatType_64RGBAHalf; +} +``` + +#### 3. HDR-Safe GPU Compositing (lines 694-695) +```c +CGColorSpaceRef workingColorSpace = CGColorSpaceCreateWithName( + kCGColorSpaceExtendedLinearDisplayP3); +``` + +### The Problem: Metadata Not Attached in Main Code Path + +**Critical Finding:** `copy_hdr_metadata()` is only called during OSD compositing (line 609-610): + +```c +// In composite mode, render OSD and composite onto frame +if (p->composite_osd) { + render_osd(vo, pts); + CVPixelBufferRef composited = composite_frame(vo, pixbuf); + // copy_hdr_metadata() called inside composite_frame() +} +``` + +If `composite_osd` is false (default), **HDR metadata is never attached**. + +### Frame Flow Analysis + +``` +draw_frame() called + │ + ├─► Hardware decoded (IMGFMT_VIDEOTOOLBOX) + │ └─► pixbuf = mpi->planes[3] // Direct from VideoToolbox + │ └─► Metadata SHOULD be attached by decoder, but not verified + │ + └─► Software decoded (NV12, 420P, P010) + └─► upload_software_frame() + └─► Creates new CVPixelBuffer + └─► Copies pixel data only + └─► ❌ NO colorspace metadata attached! + + ▼ + CMVideoFormatDescriptionCreateForImageBuffer(finalBuffer) + └─► Format description created FROM pixel buffer + └─► If buffer lacks HDR metadata, format won't have it + + ▼ + [displayLayer enqueueSampleBuffer:buf] + └─► Sent to display layer without HDR signal +``` + +--- + +## Root Cause Summary + +| Issue | Impact | +|-------|--------| +| `wantsExtendedDynamicRangeContent` unavailable on tvOS | Can't use iOS EDR approach | +| `copy_hdr_metadata()` only runs during OSD compositing | Main playback path skips HDR metadata | +| Software decoded frames get no colorspace attachments | mpv knows colorspace but doesn't pass it to pixel buffer | +| VideoToolbox metadata not verified | May or may not have HDR attachments | + +--- + +## mp_image Colorspace Structures + +mpv uses libplacebo's colorspace structures. Here's how colorspace info flows: + +### Structure Hierarchy + +``` +mp_image (video/mp_image.h) + └─► params: mp_image_params + └─► color: pl_color_space + │ ├─► primaries: pl_color_primaries (BT.2020, etc.) + │ ├─► transfer: pl_color_transfer (PQ, HLG, etc.) + │ └─► hdr: pl_hdr_metadata (MaxCLL, MaxFALL, etc.) + └─► repr: pl_color_repr + ├─► sys: pl_color_system (BT.2100_PQ, etc.) + └─► levels: pl_color_levels (TV/Full range) +``` + +### Key Enums + +#### Color Primaries (`enum pl_color_primaries`) +```c +PL_COLOR_PRIM_UNKNOWN = 0, +PL_COLOR_PRIM_BT_709, // HD/SDR standard +PL_COLOR_PRIM_BT_2020, // UHD/HDR wide gamut ← HDR +PL_COLOR_PRIM_DCI_P3, // DCI P3 (cinema) +PL_COLOR_PRIM_DISPLAY_P3, // Display P3 (Apple) +// ... more +``` + +#### Transfer Functions (`enum pl_color_transfer`) +```c +PL_COLOR_TRC_UNKNOWN = 0, +PL_COLOR_TRC_BT_1886, // SDR gamma +PL_COLOR_TRC_SRGB, // sRGB +PL_COLOR_TRC_PQ, // SMPTE 2084 PQ (HDR10/DolbyVision) ← HDR +PL_COLOR_TRC_HLG, // ITU-R BT.2100 HLG ← HDR +// ... more +``` + +#### Color Systems (`enum pl_color_system`) +```c +PL_COLOR_SYSTEM_BT_709, // HD/SDR +PL_COLOR_SYSTEM_BT_2020_NC, // UHD (non-constant luminance) +PL_COLOR_SYSTEM_BT_2100_PQ, // HDR10 ← HDR +PL_COLOR_SYSTEM_BT_2100_HLG, // HLG ← HDR +PL_COLOR_SYSTEM_DOLBYVISION, // Dolby Vision ← HDR +// ... more +``` + +### HDR Metadata Structure (`struct pl_hdr_metadata`) +```c +struct pl_hdr_metadata { + struct pl_raw_primaries prim; // CIE xy primaries + float min_luma, max_luma; // Luminance range (cd/m²) + float max_cll; // Maximum Content Light Level + float max_fall; // Maximum Frame-Average Light Level + // ... more +}; +``` + +### Accessing Colorspace in vo_avfoundation + +```c +// In draw_frame(): +struct mp_image *mpi = frame->current; + +// Color primaries +enum pl_color_primaries prim = mpi->params.color.primaries; + +// Transfer function +enum pl_color_transfer trc = mpi->params.color.transfer; + +// HDR metadata +struct pl_hdr_metadata hdr = mpi->params.color.hdr; + +// HDR detection +bool is_hdr = (trc == PL_COLOR_TRC_PQ || trc == PL_COLOR_TRC_HLG); +bool is_wide_gamut = (prim == PL_COLOR_PRIM_BT_2020); +``` + +--- + +## The Fix + +### Required Changes in vo_avfoundation + +**File:** `MPVKit/Sources/BuildScripts/patch/libmpv/0004-avfoundation-video-output.patch` + +**Location:** After line 821 in `draw_frame()`, before the sample buffer is created. + +#### Add Required Include +```c +#include "video/csputils.h" // For pl_color_* enums (if not already included) +``` + +#### Add HDR Metadata Attachment Function +```c +// Add after copy_hdr_metadata() function (around line 270) +static void attach_hdr_metadata(struct vo *vo, CVPixelBufferRef pixbuf, + struct mp_image *mpi) +{ + enum pl_color_primaries prim = mpi->params.color.primaries; + enum pl_color_transfer trc = mpi->params.color.transfer; + + // Attach BT.2020 color primaries (HDR wide color gamut) + if (prim == PL_COLOR_PRIM_BT_2020) { + CVBufferSetAttachment(pixbuf, kCVImageBufferColorPrimariesKey, + kCVImageBufferColorPrimaries_ITU_R_2020, + kCVAttachmentMode_ShouldPropagate); + CVBufferSetAttachment(pixbuf, kCVImageBufferYCbCrMatrixKey, + kCVImageBufferYCbCrMatrix_ITU_R_2020, + kCVAttachmentMode_ShouldPropagate); + + MP_VERBOSE(vo, "HDR: Attached BT.2020 color primaries\n"); + } + + // Attach PQ transfer function (HDR10/Dolby Vision) + if (trc == PL_COLOR_TRC_PQ) { + CVBufferSetAttachment(pixbuf, kCVImageBufferTransferFunctionKey, + kCVImageBufferTransferFunction_SMPTE_ST_2084_PQ, + kCVAttachmentMode_ShouldPropagate); + + MP_VERBOSE(vo, "HDR: Attached PQ transfer function (HDR10)\n"); + } + // Attach HLG transfer function + else if (trc == PL_COLOR_TRC_HLG) { + CVBufferSetAttachment(pixbuf, kCVImageBufferTransferFunctionKey, + kCVImageBufferTransferFunction_ITU_R_2100_HLG, + kCVAttachmentMode_ShouldPropagate); + + MP_VERBOSE(vo, "HDR: Attached HLG transfer function\n"); + } + + // Attach HDR static metadata if available + struct pl_hdr_metadata hdr = mpi->params.color.hdr; + if (hdr.max_cll > 0 || hdr.max_fall > 0) { + // ContentLightLevelInfo is a 4-byte structure: + // - 2 bytes: MaxCLL (max content light level) + // - 2 bytes: MaxFALL (max frame-average light level) + uint16_t cll_data[2] = { + (uint16_t)fminf(hdr.max_cll, 65535.0f), + (uint16_t)fminf(hdr.max_fall, 65535.0f) + }; + + CFDataRef cllInfo = CFDataCreate(NULL, (const UInt8 *)cll_data, sizeof(cll_data)); + if (cllInfo) { + CVBufferSetAttachment(pixbuf, kCVImageBufferContentLightLevelInfoKey, + cllInfo, kCVAttachmentMode_ShouldPropagate); + CFRelease(cllInfo); + + MP_VERBOSE(vo, "HDR: Attached CLL metadata (MaxCLL=%d, MaxFALL=%d)\n", + cll_data[0], cll_data[1]); + } + } +} +``` + +#### Call the Function in draw_frame() + +```c +// In draw_frame(), after line 821 (after getting pixbuf), add: + +// Attach HDR colorspace metadata to pixel buffer +// This ensures the display layer receives proper HDR signaling +attach_hdr_metadata(vo, pixbuf, mpi); +``` + +### Complete draw_frame() Modification + +The modified section should look like: + +```c +CVPixelBufferRef pixbuf = NULL; +bool pixbufNeedsRelease = false; + +// Handle different input formats +if (mpi->imgfmt == IMGFMT_VIDEOTOOLBOX) { + // Hardware decoded: zero-copy passthrough + pixbuf = (CVPixelBufferRef)mpi->planes[3]; +} else { + // Software decoded: upload to CVPixelBuffer + pixbuf = upload_software_frame(vo, mpi); + if (!pixbuf) { + MP_ERR(vo, "Failed to upload software frame\n"); + mp_image_unrefp(&mpi); + return false; + } + pixbufNeedsRelease = true; +} + +// >>> NEW: Attach HDR colorspace metadata <<< +attach_hdr_metadata(vo, pixbuf, mpi); + +CVPixelBufferRef finalBuffer = pixbuf; +bool needsRelease = false; +// ... rest of the function +``` + +--- + +## Alternative Solutions + +### Option A: Enable composite_osd Mode (Quick Test) + +Since `copy_hdr_metadata()` works in composite mode, try enabling it: +``` +--avfoundation-composite-osd=yes +``` + +This would trigger the existing HDR metadata path. Downside: OSD compositing has performance overhead. + +### Option B: Full vo_avfoundation Fix (Recommended) + +Modify the driver to always attach colorspace metadata based on `mp_image` params. This is the implementation described above. + +### Option C: Dual Player Approach + +Use AVPlayer for HDR content, mpv for everything else. This is what Swiftfin does. + +--- + +## Implementation Checklist + +- [ ] Clone MPVKit fork +- [ ] Modify `0004-avfoundation-video-output.patch`: + - [ ] Add `attach_hdr_metadata()` function + - [ ] Call it in `draw_frame()` after getting pixbuf + - [ ] Add necessary includes if needed +- [ ] Rebuild MPVKit +- [ ] Test with HDR10 content on tvOS +- [ ] Verify TV shows HDR indicator +- [ ] Test with HLG content +- [ ] Test with Dolby Vision content (may need additional work) + +--- + +## Current Implementation Status + +**What's implemented in Streamyfin:** + +1. **HDR Detection** (`MPVLayerRenderer.swift`) + - Reads `video-params/primaries` and `video-params/gamma` from mpv + - Detects HDR10 (bt.2020 + pq), HLG, Dolby Vision + +2. **AVDisplayCriteria** (`MpvPlayerView.swift`) + - Sets `preferredDisplayCriteria` on tvOS 17.0+ when HDR detected + - Creates CMFormatDescription with HDR color extensions + +3. **target-colorspace-hint** (`MPVLayerRenderer.swift`) + - Added `target-colorspace-hint=yes` for tvOS + +**What's NOT working:** +- TV doesn't show HDR indicator +- Colors appear washed out +- The pixel buffers lack HDR metadata attachments ← **This is what the fix addresses** + +--- + +## Industry Context + +| Project | Player | HDR Status | +|---------|--------|------------| +| [Swiftfin](https://github.com/jellyfin/Swiftfin/issues/811) | VLCKit | Washed out, uses AVPlayer for HDR | +| [Plex](https://freetime.mikeconnelly.com/archives/8360) | mpv | No HDR support | +| Infuse | Custom Metal engine | Works correctly | + +**Key insight:** No mpv-based player has solved HDR on tvOS. This fix could be a first. + +--- + +## Technical References + +### Apple Documentation +- [AVDisplayManager](https://developer.apple.com/documentation/avkit/avdisplaymanager) +- [AVDisplayCriteria](https://developer.apple.com/documentation/avkit/avdisplaycriteria) +- [WWDC22: Display HDR video in EDR](https://developer.apple.com/videos/play/wwdc2022/110565/) + +### CVImageBuffer Keys +- `kCVImageBufferColorPrimariesKey` - Color gamut (BT.709, BT.2020, P3) +- `kCVImageBufferTransferFunctionKey` - Transfer function (sRGB, PQ, HLG) +- `kCVImageBufferYCbCrMatrixKey` - YCbCr conversion matrix +- `kCVImageBufferMasteringDisplayColorVolumeKey` - Mastering display metadata +- `kCVImageBufferContentLightLevelInfoKey` - MaxCLL/MaxFALL + +### mpv/libplacebo Source +- mp_image struct: `video/mp_image.h` +- Colorspace enums: libplacebo `pl_color.h` +- vo_avfoundation: `MPVKit/Sources/BuildScripts/patch/libmpv/0004-avfoundation-video-output.patch` + +### Key Functions in vo_avfoundation +| Function | Line | Purpose | +|----------|------|---------| +| `draw_frame()` | 781 | Main frame rendering | +| `copy_hdr_metadata()` | 253 | Copy HDR metadata between buffers | +| `upload_software_frame()` | 295 | Upload SW frames to CVPixelBuffer | +| `composite_frame()` | 582 | OSD compositing with HDR support | diff --git a/docs/tv-discovery.md b/docs/tv-discovery.md new file mode 100644 index 000000000..c2bc67eb5 --- /dev/null +++ b/docs/tv-discovery.md @@ -0,0 +1,136 @@ +# TV Discovery + +This document explains Streamyfin's platform-specific home screen discovery integrations for Apple TV and Android TV. + +## Overview + +Streamyfin currently publishes the same "Continue and Next Up" content to two different platform surfaces: + +- `tvOS`: Apple TV Top Shelf +- `Android TV`: preview channel recommendations + +Both integrations are fed by the same shared payload builder in [utils/tvDiscovery/payload.ts](../utils/tvDiscovery/payload.ts). + +## Shared Data Flow + +The TV home screen data starts in [components/home/Home.tv.tsx](../components/home/Home.tv.tsx), where the app fetches resume and next-up items and passes them into [utils/tvDiscovery/sync.ts](../utils/tvDiscovery/sync.ts). + +The sync layer: + +- builds a normalized TV discovery payload +- sends it to the tvOS Top Shelf cache writer on Apple TV +- sends it to the Android TV recommendations module on Android TV +- clears published content when server or user state changes + +## Apple TV Top Shelf + +Apple TV uses a Top Shelf extension target, not the main app process. + +Relevant files: + +- [plugins/withTVOSTopShelf.ts](../plugins/withTVOSTopShelf.ts) +- [targets/StreamyfinTopShelf/TopShelfProvider.swift](../targets/StreamyfinTopShelf/TopShelfProvider.swift) +- [modules/top-shelf-cache/ios/TopShelfCacheModule.swift](../modules/top-shelf-cache/ios/TopShelfCacheModule.swift) +- [utils/topshelf/cache.ts](../utils/topshelf/cache.ts) + +How it works: + +- the app builds a lightweight JSON payload +- the app stores that payload in the shared app group container +- the tvOS Top Shelf extension reads the cached payload +- the extension renders sections and items for Top Shelf + +Why the API key is stored on tvOS: + +- the Top Shelf extension runs outside the app process +- it may need authenticated image access when loading poster artwork +- the app stores the API key so the extension can build authenticated requests + +## Android TV Recommendations + +Android TV uses the TV provider APIs to publish a preview channel and preview programs. + +Relevant files: + +- [modules/tv-recommendations/android/src/main/java/expo/modules/tvrecommendations/TvRecommendationsPublisher.kt](../modules/tv-recommendations/android/src/main/java/expo/modules/tvrecommendations/TvRecommendationsPublisher.kt) +- [modules/tv-recommendations/android/src/main/java/expo/modules/tvrecommendations/TvRecommendationsModule.kt](../modules/tv-recommendations/android/src/main/java/expo/modules/tvrecommendations/TvRecommendationsModule.kt) +- [modules/tv-recommendations/android/src/main/java/expo/modules/tvrecommendations/TvRecommendationsReceiver.kt](../modules/tv-recommendations/android/src/main/java/expo/modules/tvrecommendations/TvRecommendationsReceiver.kt) +- [modules/tv-recommendations/android/src/main/AndroidManifest.xml](../modules/tv-recommendations/android/src/main/AndroidManifest.xml) +- [utils/tvDiscovery/sync.ts](../utils/tvDiscovery/sync.ts) + +How it works: + +- the app builds the shared TV discovery payload +- the Android native module creates or updates a single preview channel +- the module inserts or updates preview programs for each item +- the module stores the last payload in shared preferences +- the `INITIALIZE_PROGRAMS` receiver can replay the cached payload when requested by the system + +Important differences from tvOS: + +- Android TV does not use a separate extension target +- Android TV content is persisted through `TvContractCompat` +- artwork is currently published as poster URLs, not app-proxied local content + +## Logging + +### JavaScript logs + +Look for `TVDiscovery` in Metro or app logs. + +Examples: + +- payload prepared +- Android sync result +- clear operations + +### Native Android logs + +Use `adb logcat | grep TvRecommendations` + +Examples: + +- channel created or updated +- preview programs inserted or updated +- stale programs deleted +- cached payload replayed + +## Verifying Android TV Output + +1. Launch the TV build and let the home screen load. +2. Watch `adb logcat | grep TvRecommendations`. +3. Return to the Android TV / Google TV home screen. +4. Look for the `Continue and Next Up` row. +5. If needed, enable the Streamyfin channel in `Customize home` or `Manage channels`. + +Note: + +- some launchers delay or hide new preview channels +- some devices expose TV provider data per user/profile + +## Build Notes + +This feature does not currently require a fresh `prebuild` to work in the checked-in Android project. + +Why: + +- the Android integration is a local Expo module +- its receiver is declared in the module manifest +- Gradle merges it during normal Android TV builds + +Typical commands: + +- `bun run android:tv` +- `bun run ios:tv` + +## Current Limitations + +- Android TV artwork may fail on authenticated Jellyfin servers because the launcher fetches poster URLs outside the app +- Android TV currently publishes a preview channel only, not Watch Next +- tvOS and Android TV both use the same payload source, so section selection is shared unless explicitly split later + +## Future Improvements + +- add a local image proxy or cache for Android TV artwork +- add Watch Next support for resumable content +- add a native debug dump method for querying TV provider state from inside the app process diff --git a/docs/tv-focus-guide.md b/docs/tv-focus-guide.md new file mode 100644 index 000000000..fc49a93e8 --- /dev/null +++ b/docs/tv-focus-guide.md @@ -0,0 +1,305 @@ +# TV Focus Guide Navigation + +This document explains how to use `TVFocusGuideView` to create reliable focus navigation between non-adjacent sections on Apple TV and Android TV. + +## Platform Differences (CRITICAL) + +### tvOS vs Android TV + +**`nextFocusUp`, `nextFocusDown`, `nextFocusLeft`, `nextFocusRight` props only work on Android TV, NOT tvOS.** + +This is a [known limitation](https://github.com/react-native-tvos/react-native-tvos/issues/490). These props are documented as "only for Android" in React Native. + +```typescript +// ❌ Does NOT work on tvOS (Apple TV) + + ... + + +// ✅ Works on both tvOS and Android TV + + ... + +``` + +**For tvOS, always use `TVFocusGuideView` with the `destinations` prop.** + +## ScrollView vs FlatList for TV + +**Use ScrollView instead of FlatList for horizontal lists on TV when focus navigation is critical.** + +FlatList only renders visible items and manages its own recycling, which can interfere with focus navigation. ScrollView renders all items at once, providing more predictable focus behavior. + +```typescript +// ❌ FlatList can cause focus issues on TV + } +/> + +// ✅ ScrollView provides reliable focus navigation + + {cast.map((person, index) => ( + + ))} + +``` + +**When to use which:** +- **ScrollView**: Small to medium lists (< 20 items) where focus navigation must be reliable +- **FlatList**: Large lists where performance is more important than perfect focus navigation + +## The Problem + +tvOS uses a **geometric focus engine** that draws a ray in the navigation direction and finds the nearest focusable element. This works well for adjacent elements but fails when: + +- Sections are not geometrically aligned (e.g., left-aligned buttons above a horizontally-scrolling list) +- Lists are long and the "nearest" element is in the middle rather than the first item +- There's empty space between focusable sections + +**Symptoms:** +- Focus lands in the middle of a list instead of the first item +- Can't navigate down to a section at all +- Focus jumps to unexpected elements + +## The Solution: TVFocusGuideView with destinations + +`TVFocusGuideView` is a React Native component that creates an invisible focus region. When combined with the `destinations` prop, it redirects focus to specific elements. + +### Basic Pattern + +```typescript +import { TVFocusGuideView, View } from "react-native"; + +// 1. Track the destination element with state (NOT useRef!) +const [targetRef, setTargetRef] = useState(null); + +// 2. Place an invisible focus guide between sections +{targetRef && ( + +)} + +// 3. Pass the state setter as a callback ref to the target + +``` + +### Why useState Instead of useRef? + +The focus guide only updates when it receives a prop change. Using `useRef` won't trigger re-renders when the ref is set, so the focus guide won't know about the destination. **Always use `useState`** to track refs for focus guides. + +```typescript +// ❌ Won't work - useRef doesn't trigger re-renders +const targetRef = useRef(null); + + +// ✅ Works - useState triggers re-render when ref is set +const [targetRef, setTargetRef] = useState(null); + +``` + +## Bidirectional Navigation (CRITICAL PATTERN) + +When you need focus to navigate both UP and DOWN between sections, you must stack both focus guides together AND avoid `hasTVPreferredFocus` on the destination element. + +### The Focus Flickering Problem + +If you use `hasTVPreferredFocus={true}` on an element that is ALSO the destination of a focus guide, you will get **focus flickering** where focus rapidly jumps back and forth between elements. + +```typescript +// ❌ CAUSES FOCUS FLICKERING - destination has hasTVPreferredFocus + + + {items.map((item, index) => ( + + ))} + + +// ✅ CORRECT - destination does NOT have hasTVPreferredFocus + + + {items.map((item, index) => ( + + ))} + +``` + +### Complete Bidirectional Example + +```typescript +const MyScreen: React.FC = () => { + // Track refs for focus navigation + const [playButtonRef, setPlayButtonRef] = useState(null); + const [firstCastCardRef, setFirstCastCardRef] = useState(null); + + return ( + + {/* Action buttons section */} + + + Play + + + + {/* Cast section */} + + Cast + + {/* BOTH focus guides stacked together, above the list */} + {/* Downward: Play button → first cast card */} + {firstCastCardRef && ( + + )} + {/* Upward: cast → Play button */} + {playButtonRef && ( + + )} + + {/* Use ScrollView, not FlatList, for reliable focus */} + + {cast.map((person, index) => ( + + ))} + + + + ); +}; +``` + +### Key Rules for Bidirectional Navigation + +1. **Stack both focus guides together** - Place them adjacent to each other, above the destination list +2. **Do NOT use `hasTVPreferredFocus` on focus guide destinations** - This causes focus flickering +3. **Use ScrollView instead of FlatList** - More reliable focus behavior +4. **Use `useState` for refs, not `useRef`** - Triggers re-renders when refs are set + +## Focus Guide Placement + +The focus guides should be placed **together** above the destination section: + +``` +┌─────────────────────────┐ +│ Action Buttons │ ← Source (going down) +│ [Play] [Request] │ Has hasTVPreferredFocus ✓ +└─────────────────────────┘ + ↓ +┌─────────────────────────┐ +│ TVFocusGuideView │ ← Downward guide +│ destinations=[card1] │ +├─────────────────────────┤ +│ TVFocusGuideView │ ← Upward guide +│ destinations=[playBtn] │ (stacked together) +└─────────────────────────┘ + ↓ +┌─────────────────────────┐ +│ Cast Cards (ScrollView)│ ← First card is destination +│ [👤] [👤] [👤] [👤] │ NO hasTVPreferredFocus ✗ +└─────────────────────────┘ +``` + +## Component Pattern with refSetter + +For components that need to be focus guide destinations, use a `refSetter` callback prop: + +```typescript +interface TVCastCardProps { + person: { id: number; name: string }; + onPress: () => void; + refSetter?: (ref: View | null) => void; +} + +const TVCastCard: React.FC = ({ + person, + onPress, + refSetter, +}) => { + return ( + + {person.name} + + ); +}; + +// Usage + +``` + +## Tips and Gotchas + +1. **Guard against null refs**: Only render the focus guide when the ref is set: + ```typescript + {targetRef && } + ``` + +2. **Style the guide invisibly**: Use `height: 1` or `width: 1` to make it invisible but still functional: + ```typescript + style={{ height: 1, width: "100%" }} + ``` + +3. **Multiple destinations**: You can provide multiple destinations and the focus engine will pick the geometrically closest one: + ```typescript + + ``` + +4. **Focus trapping**: Use `trapFocusUp`, `trapFocusDown`, etc. to prevent focus from leaving a region (useful for modals): + ```typescript + + {/* Modal content */} + + ``` + +5. **Auto focus**: Use `autoFocus` to automatically focus the first focusable child when entering a region: + ```typescript + + {/* First focusable child will receive focus */} + + ``` + + **Warning**: Don't use `autoFocus` on a wrapper when you also have bidirectional focus guides - it can interfere with upward navigation. + +## Common Mistakes + +| Mistake | Result | Fix | +|---------|--------|-----| +| Using `nextFocusUp`/`nextFocusDown` props | Doesn't work on tvOS | Use `TVFocusGuideView` | +| Using FlatList for horizontal lists | Focus navigation unreliable | Use ScrollView | +| `hasTVPreferredFocus` on focus guide destination | Focus flickering loop | Remove `hasTVPreferredFocus` from destination | +| Focus guides placed separately | Focus flickering | Stack both guides together | +| Using `useRef` for focus guide refs | Focus guide doesn't update | Use `useState` | + +## Reference Implementation + +See `components/jellyseerr/tv/TVJellyseerrPage.tsx` for a complete implementation of bidirectional focus navigation between action buttons and a cast list. diff --git a/docs/tv-modal-guide.md b/docs/tv-modal-guide.md new file mode 100644 index 000000000..a1b57e9bd --- /dev/null +++ b/docs/tv-modal-guide.md @@ -0,0 +1,416 @@ +# TV Modal Guide + +This document explains how to implement modals, bottom sheets, and overlays on Apple TV and Android TV in React Native. + +## The Problem + +On TV platforms, modals have unique challenges: +- The hardware back button must work correctly to dismiss modals +- Focus management must be handled explicitly +- React Native's `Modal` component breaks the TV focus chain +- Overlay/absolute-positioned modals don't handle back button correctly + +## Navigation-Based Modal Pattern (Recommended) + +For modals that need proper back button support, use the **navigation-based modal pattern**. This leverages Expo Router's stack navigation with transparent modal presentation. + +### Architecture + +``` +┌─────────────────────────────────────┐ +│ 1. Jotai Atom (state) │ +│ Stores modal data/params │ +├─────────────────────────────────────┤ +│ 2. Hook (trigger) │ +│ Sets atom + calls router.push() │ +├─────────────────────────────────────┤ +│ 3. Page File (UI) │ +│ Reads atom, renders modal │ +│ Clears atom on unmount │ +├─────────────────────────────────────┤ +│ 4. Stack.Screen (config) │ +│ presentation: transparentModal │ +│ animation: fade │ +└─────────────────────────────────────┘ +``` + +### Step 1: Create the Atom + +Create a Jotai atom to store the modal state/data: + +```typescript +// utils/atoms/tvExampleModal.ts +import { atom } from "jotai"; + +export interface TVExampleModalData { + itemId: string; + title: string; + // ... other data the modal needs +} + +export const tvExampleModalAtom = atom(null); +``` + +### Step 2: Create the Hook + +Create a hook that sets the atom and navigates to the modal: + +```typescript +// hooks/useTVExampleModal.ts +import { useSetAtom } from "jotai"; +import { router } from "expo-router"; +import { tvExampleModalAtom, TVExampleModalData } from "@/utils/atoms/tvExampleModal"; + +export const useTVExampleModal = () => { + const setModalData = useSetAtom(tvExampleModalAtom); + + const openModal = (data: TVExampleModalData) => { + setModalData(data); + router.push("/tv-example-modal"); + }; + + return { openModal }; +}; +``` + +### Step 3: Create the Modal Page + +Create a page file that reads the atom and renders the modal UI: + +```typescript +// app/(auth)/tv-example-modal.tsx +import { useEffect } from "react"; +import { View, Pressable, Text } from "react-native"; +import { useAtom } from "jotai"; +import { router } from "expo-router"; +import { BlurView } from "expo-blur"; +import { tvExampleModalAtom } from "@/utils/atoms/tvExampleModal"; + +export default function TVExampleModal() { + const [modalData, setModalData] = useAtom(tvExampleModalAtom); + + // Clear atom on unmount + useEffect(() => { + return () => { + setModalData(null); + }; + }, [setModalData]); + + // Handle case where modal is opened without data + if (!modalData) { + router.back(); + return null; + } + + return ( + + {/* Background overlay */} + router.back()} + /> + + {/* Modal content */} + + + {modalData.title} + + {/* Modal content here */} + + router.back()} + hasTVPreferredFocus + style={({ focused }) => ({ + marginTop: 24, + padding: 16, + borderRadius: 8, + backgroundColor: focused ? "#fff" : "rgba(255,255,255,0.1)", + })} + > + {({ focused }) => ( + + Close + + )} + + + + ); +} +``` + +### Step 4: Add Stack.Screen Configuration + +Add the modal route to `app/_layout.tsx`: + +```typescript +// In app/_layout.tsx, inside your Stack navigator + +``` + +### Usage + +```typescript +// In any component +import { useTVExampleModal } from "@/hooks/useTVExampleModal"; + +const MyComponent = () => { + const { openModal } = useTVExampleModal(); + + return ( + openModal({ itemId: "123", title: "Example" })} + > + Open Modal + + ); +}; +``` + +### Reference Implementation + +See `useTVRequestModal` + `app/(auth)/tv-request-modal.tsx` for a complete working example. + +--- + +## Bottom Sheet Pattern (Inline Overlays) + +For simpler overlays that don't need back button navigation (like option selectors), use an **inline absolute-positioned overlay**. This pattern is ideal for: +- Dropdown selectors +- Quick action menus +- Option pickers + +### Key Principles + +1. **Use absolute positioning instead of Modal** - React Native's `Modal` breaks the TV focus chain +2. **Horizontal ScrollView for options** - Natural for TV remotes (left/right D-pad) +3. **Disable background focus** - Prevent focus flickering between overlay and background + +### Implementation + +```typescript +import { useState } from "react"; +import { View, ScrollView, Pressable, Text } from "react-native"; +import { BlurView } from "expo-blur"; + +const TVOptionSelector: React.FC<{ + options: { label: string; value: string }[]; + selectedValue: string; + onSelect: (value: string) => void; + isOpen: boolean; + onClose: () => void; +}> = ({ options, selectedValue, onSelect, isOpen, onClose }) => { + if (!isOpen) return null; + + const selectedIndex = options.findIndex(o => o.value === selectedValue); + + return ( + + + + {options.map((option, index) => ( + { + onSelect(option.value); + onClose(); + }} + /> + ))} + + + + ); +}; +``` + +### Option Card Component + +```typescript +import { useState, useRef, useEffect } from "react"; +import { Pressable, Text, Animated } from "react-native"; + +const TVOptionCard: React.FC<{ + label: string; + isSelected: boolean; + hasTVPreferredFocus?: boolean; + onPress: () => void; +}> = ({ label, isSelected, hasTVPreferredFocus, onPress }) => { + const [focused, setFocused] = useState(false); + const scale = useRef(new Animated.Value(1)).current; + + const animateTo = (toValue: number) => { + Animated.spring(scale, { + toValue, + useNativeDriver: true, + tension: 50, + friction: 7, + }).start(); + }; + + return ( + { + setFocused(true); + animateTo(1.05); + }} + onBlur={() => { + setFocused(false); + animateTo(1); + }} + hasTVPreferredFocus={hasTVPreferredFocus} + > + + + {label} + + + + ); +}; +``` + +### Reference Implementation + +See `TVOptionSelector` and `TVOptionCard` in `components/ItemContent.tv.tsx`. + +--- + +## Focus Management for Overlays + +**CRITICAL**: When displaying overlays on TV, you must explicitly disable focus on all background elements. Without this, the TV focus engine will rapidly switch between overlay and background elements, causing a focus loop. + +### Solution + +Add a `disabled` prop to every focusable component and pass `disabled={isModalOpen}` when an overlay is visible: + +```typescript +// 1. Track modal state +const [openModal, setOpenModal] = useState(null); +const isModalOpen = openModal !== null; + +// 2. Each focusable component accepts disabled prop +const TVFocusableButton: React.FC<{ + onPress: () => void; + disabled?: boolean; +}> = ({ onPress, disabled }) => ( + + {/* content */} + +); + +// 3. Pass disabled to all background components when modal is open + +``` + +### Reference Implementation + +See `settings.tv.tsx` for a complete example with `TVSettingsOptionButton`, `TVSettingsToggle`, `TVSettingsStepper`, etc. + +--- + +## Focus Trapping + +For modals that should trap focus (prevent navigation outside the modal), use `TVFocusGuideView` with trap props: + +```typescript +import { TVFocusGuideView } from "react-native"; + + + {/* Modal content - focus cannot escape */} + +``` + +**Warning**: Don't use `autoFocus` on focus guide wrappers when you also have bidirectional focus guides - it can interfere with navigation. + +--- + +## Common Mistakes + +| Mistake | Result | Fix | +|---------|--------|-----| +| Using React Native `Modal` | Focus chain breaks | Use navigation-based or absolute positioning | +| Overlay without disabling background focus | Focus flickering loop | Add `disabled` prop to all background focusables | +| No `hasTVPreferredFocus` in modal | Focus stuck on background | Set preferred focus on first modal element | +| Missing `presentation: "transparentModal"` | Modal not transparent | Add to Stack.Screen options | +| Not clearing atom on unmount | Stale data on reopen | Clear in useEffect cleanup | + +--- + +## When to Use Which Pattern + +| Scenario | Pattern | +|----------|---------| +| Full-screen modal with back button | Navigation-based modal | +| Confirmation dialogs | Navigation-based modal | +| Option selectors / dropdowns | Bottom sheet (inline) | +| Quick action menus | Bottom sheet (inline) | +| Complex forms | Navigation-based modal | diff --git a/eas.json b/eas.json index 08f54fd47..23e28e82e 100644 --- a/eas.json +++ b/eas.json @@ -1,6 +1,7 @@ { "cli": { - "version": ">= 9.1.0" + "version": ">= 16.0.0", + "appVersionSource": "remote" }, "build": { "development": { @@ -43,34 +44,86 @@ "EXPO_PUBLIC_WRITE_DEBUG": "1" } }, + "preview_tv": { + "distribution": "internal", + "env": { + "EXPO_TV": "1", + "EXPO_PUBLIC_WRITE_DEBUG": "1" + } + }, "production": { + "bun": "1.3.14", "environment": "production", - "channel": "0.48.0", + "autoIncrement": true, "android": { - "image": "latest" + "image": "latest", + "config": "android-production.yml" + }, + "ios": { + "config": "ios-production.yml" } }, "production-apk": { + "bun": "1.3.14", "environment": "production", - "channel": "0.48.0", + "autoIncrement": true, "android": { "buildType": "apk", - "image": "latest" + "image": "latest", + "config": "android-production-apk.yml" } }, "production-apk-tv": { + "bun": "1.3.14", "environment": "production", - "channel": "0.48.0", + "autoIncrement": true, "android": { "buildType": "apk", - "image": "latest" + "image": "latest", + "config": "android-production-tv.yml" }, "env": { "EXPO_TV": "1" } + }, + "production_tv": { + "bun": "1.3.14", + "environment": "production", + "autoIncrement": true, + "env": { + "EXPO_TV": "1" + }, + "ios": { + "credentialsSource": "local", + "config": "ios-production.yml" + } + }, + "ci": { + "extends": "production", + "autoIncrement": false + }, + "ci_tv": { + "extends": "production_tv", + "autoIncrement": false } }, "submit": { - "production": {} + "production": { + "ios": { + "appleTeamId": "MWD5K362T8", + "ascAppId": "6593660679" + }, + "android": { + "serviceAccountKeyPath": "./google-service-account.json", + "track": "internal", + "releaseStatus": "completed" + } + }, + "production_tv": { + "ios": { + "appleTeamId": "MWD5K362T8", + "ascAppId": "6593660679" + } + } } } diff --git a/hooks/useAppRouter.ts b/hooks/useAppRouter.ts new file mode 100644 index 000000000..956ea9282 --- /dev/null +++ b/hooks/useAppRouter.ts @@ -0,0 +1,86 @@ +import { useRouter } from "expo-router"; +import { useCallback, useMemo } from "react"; +import { useOfflineMode } from "@/providers/OfflineModeProvider"; + +/** + * Drop-in replacement for expo-router's useRouter that automatically + * preserves offline state across navigation. + * + * - For object-form navigation, automatically adds offline=true when in offline context + * - For string URLs, passes through unchanged (caller handles offline param) + * + * @example + * import useRouter from "@/hooks/useAppRouter"; + * + * const router = useRouter(); + * router.push({ pathname: "/items/page", params: { id: item.Id } }); // offline added automatically + */ +export function useAppRouter() { + const router = useRouter(); + const isOffline = useOfflineMode(); + + const push = useCallback( + (href: Parameters[0]) => { + if (typeof href === "string") { + router.push(href as any); + } else { + const callerParams = (href.params ?? {}) as Record; + const hasExplicitOffline = "offline" in callerParams; + router.push({ + ...href, + params: { + // Only add offline if caller hasn't explicitly set it + ...(isOffline && !hasExplicitOffline && { offline: "true" }), + ...callerParams, + }, + } as any); + } + }, + [router, isOffline], + ); + + const replace = useCallback( + (href: Parameters[0]) => { + if (typeof href === "string") { + router.replace(href as any); + } else { + const callerParams = (href.params ?? {}) as Record; + const hasExplicitOffline = "offline" in callerParams; + router.replace({ + ...href, + params: { + // Only add offline if caller hasn't explicitly set it + ...(isOffline && !hasExplicitOffline && { offline: "true" }), + ...callerParams, + }, + } as any); + } + }, + [router, isOffline], + ); + + const setParams = useCallback( + (params: Parameters[0]) => { + const callerParams = (params ?? {}) as Record; + const hasExplicitOffline = "offline" in callerParams; + router.setParams({ + // Only add offline if caller hasn't explicitly set it + ...(isOffline && !hasExplicitOffline && { offline: "true" }), + ...callerParams, + }); + }, + [router, isOffline], + ); + + return useMemo( + () => ({ + ...router, + push, + replace, + setParams, + }), + [router, push, replace, setParams], + ); +} + +export default useAppRouter; diff --git a/hooks/useControlsSafeAreaInsets.ts b/hooks/useControlsSafeAreaInsets.ts new file mode 100644 index 000000000..4fa4968e6 --- /dev/null +++ b/hooks/useControlsSafeAreaInsets.ts @@ -0,0 +1,18 @@ +import { + type EdgeInsets, + useSafeAreaInsets, +} from "react-native-safe-area-context"; +import { useSettings } from "@/utils/atoms/settings"; + +const ZERO_INSETS: EdgeInsets = { top: 0, right: 0, bottom: 0, left: 0 }; + +/** + * Returns safe-area insets to apply to in-player controls, honoring the + * `safeAreaInControlsEnabled` user setting. When the setting is disabled, + * returns zero insets so controls can sit flush against the screen edges. + */ +export const useControlsSafeAreaInsets = (): EdgeInsets => { + const { settings } = useSettings(); + const insets = useSafeAreaInsets(); + return settings.safeAreaInControlsEnabled ? insets : ZERO_INSETS; +}; diff --git a/hooks/useControlsVisibility.ts b/hooks/useControlsVisibility.ts deleted file mode 100644 index caca0d842..000000000 --- a/hooks/useControlsVisibility.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { useCallback, useEffect, useRef } from "react"; -import { useSharedValue } from "react-native-reanimated"; - -export const useControlsVisibility = (timeout = 3000) => { - const opacity = useSharedValue(1); - - const hideControlsTimerRef = useRef | null>( - null, - ); - - const showControls = useCallback(() => { - opacity.value = 1; - if (hideControlsTimerRef.current) { - clearTimeout(hideControlsTimerRef.current); - } - hideControlsTimerRef.current = setTimeout(() => { - opacity.value = 0; - }, timeout); - }, [timeout]); - - const hideControls = useCallback(() => { - opacity.value = 0; - if (hideControlsTimerRef.current) { - clearTimeout(hideControlsTimerRef.current); - } - }, []); - - useEffect(() => { - return () => { - if (hideControlsTimerRef.current) { - clearTimeout(hideControlsTimerRef.current); - } - }; - }, []); - - return { opacity, showControls, hideControls }; -}; diff --git a/hooks/useCreditSkipper.ts b/hooks/useCreditSkipper.ts index d023e7be5..40c1d6955 100644 --- a/hooks/useCreditSkipper.ts +++ b/hooks/useCreditSkipper.ts @@ -5,29 +5,32 @@ import { useSegments } from "@/utils/segments"; import { msToSeconds, secondsToMs } from "@/utils/time"; import { useHaptic } from "./useHaptic"; +/** + * Custom hook to handle skipping credits in a media player. + * The player reports time values in milliseconds. + */ export const useCreditSkipper = ( itemId: string, currentTime: number, - seek: (time: number) => void, + seek: (ms: number) => void, play: () => void, - isVlc = false, isOffline = false, api: Api | null = null, downloadedFiles: DownloadedItem[] | undefined = undefined, + totalDuration?: number, ) => { const [showSkipCreditButton, setShowSkipCreditButton] = useState(false); const lightHapticFeedback = useHaptic("light"); - if (isVlc) { - currentTime = msToSeconds(currentTime); - } + // Convert ms to seconds for comparison with timestamps + const currentTimeSeconds = msToSeconds(currentTime); + const totalDurationInSeconds = + totalDuration != null ? msToSeconds(totalDuration) : undefined; + + // Regular function (not useCallback) to match useIntroSkipper pattern const wrappedSeek = (seconds: number) => { - if (isVlc) { - seek(secondsToMs(seconds)); - return; - } - seek(seconds); + seek(secondsToMs(seconds)); }; const { data: segments } = useSegments( @@ -38,27 +41,69 @@ export const useCreditSkipper = ( ); const creditTimestamps = segments?.creditSegments?.[0]; + // Determine if there's content after credits (credits don't extend to video end) + // Use a 5-second buffer to account for timing discrepancies + const hasContentAfterCredits = (() => { + if ( + !creditTimestamps || + totalDurationInSeconds == null || + !Number.isFinite(totalDurationInSeconds) + ) { + return false; + } + const creditsEndToVideoEnd = + totalDurationInSeconds - creditTimestamps.endTime; + // If credits end more than 5 seconds before video ends, there's content after + return creditsEndToVideoEnd > 5; + })(); + useEffect(() => { if (creditTimestamps) { - setShowSkipCreditButton( - currentTime > creditTimestamps.startTime && - currentTime < creditTimestamps.endTime, - ); + const shouldShow = + currentTimeSeconds > creditTimestamps.startTime && + currentTimeSeconds < creditTimestamps.endTime; + + setShowSkipCreditButton(shouldShow); + } else { + // Reset button state when no credit timestamps exist + if (showSkipCreditButton) { + setShowSkipCreditButton(false); + } } - }, [creditTimestamps, currentTime]); + }, [creditTimestamps, currentTimeSeconds, showSkipCreditButton]); const skipCredit = useCallback(() => { if (!creditTimestamps) return; + try { lightHapticFeedback(); - wrappedSeek(creditTimestamps.endTime); + + // Calculate the target seek position + let seekTarget = creditTimestamps.endTime; + + // If we have total duration, ensure we don't seek past the end of the video. + // Some media sources report credit end times that exceed the actual video duration, + // which causes the player to pause/stop when seeking past the end. + // Leave a small buffer (2 seconds) to trigger the natural end-of-video flow + // (next episode countdown, etc.) instead of an abrupt pause. + if (totalDurationInSeconds && seekTarget >= totalDurationInSeconds) { + seekTarget = Math.max(0, totalDurationInSeconds - 2); + } + + wrappedSeek(seekTarget); setTimeout(() => { play(); }, 200); } catch (error) { - console.error("Error skipping credit", error); + console.error("[CREDIT_SKIPPER] Error skipping credit", error); } - }, [creditTimestamps, lightHapticFeedback, wrappedSeek, play]); + }, [ + creditTimestamps, + lightHapticFeedback, + wrappedSeek, + play, + totalDurationInSeconds, + ]); - return { showSkipCreditButton, skipCredit }; + return { showSkipCreditButton, skipCredit, hasContentAfterCredits }; }; diff --git a/hooks/useDefaultPlaySettings.ts b/hooks/useDefaultPlaySettings.ts index b9ab1d440..ad292639a 100644 --- a/hooks/useDefaultPlaySettings.ts +++ b/hooks/useDefaultPlaySettings.ts @@ -1,51 +1,34 @@ import { type BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import { useMemo } from "react"; -import { BITRATES } from "@/components/BitrateSelector"; import type { Settings } from "@/utils/atoms/settings"; +import { + getDefaultPlaySettings, + type PlaySettingsOptions, +} from "@/utils/jellyfin/getDefaultPlaySettings"; -// Used only for initial play settings. +/** + * React hook wrapper for getDefaultPlaySettings. + * Used in UI components for initial playback (no previous track state). + * + * @param item - The media item to play + * @param settings - User settings (language preferences, bitrate, etc.) + * @param options - Optional flags to control behavior (e.g., applyLanguagePreferences for TV) + */ const useDefaultPlaySettings = ( - item: BaseItemDto, + item: BaseItemDto | null | undefined, settings: Settings | null, -) => { - const playSettings = useMemo(() => { - // 1. Get first media source - const mediaSource = item.MediaSources?.[0]; - - // 2. Get default or preferred audio - const defaultAudioIndex = mediaSource?.DefaultAudioStreamIndex; - const preferedAudioIndex = mediaSource?.MediaStreams?.find( - (x) => - x.Type === "Audio" && - x.Language === - settings?.defaultAudioLanguage?.ThreeLetterISOLanguageName, - )?.Index; - - const firstAudioIndex = mediaSource?.MediaStreams?.find( - (x) => x.Type === "Audio", - )?.Index; - - // 4. Get default bitrate from settings or fallback to max - let bitrate = settings?.defaultBitrate ?? BITRATES[0]; - // value undefined seems to get lost in settings. This is just a failsafe - if (bitrate.key === BITRATES[0].key) { - bitrate = BITRATES[0]; - } + options?: PlaySettingsOptions, +) => + useMemo(() => { + const { mediaSource, audioIndex, subtitleIndex, bitrate } = + getDefaultPlaySettings(item, settings, undefined, options); return { - defaultAudioIndex: - preferedAudioIndex ?? defaultAudioIndex ?? firstAudioIndex ?? undefined, - defaultSubtitleIndex: mediaSource?.DefaultSubtitleStreamIndex ?? -1, - defaultMediaSource: mediaSource ?? undefined, - defaultBitrate: bitrate ?? undefined, + defaultMediaSource: mediaSource, + defaultAudioIndex: audioIndex, + defaultSubtitleIndex: subtitleIndex, + defaultBitrate: bitrate, }; - }, [ - item.MediaSources, - settings?.defaultAudioLanguage, - settings?.defaultSubtitleLanguage, - ]); - - return playSettings; -}; + }, [item, settings, options]); export default useDefaultPlaySettings; diff --git a/hooks/useDownloadedFileOpener.ts b/hooks/useDownloadedFileOpener.ts deleted file mode 100644 index 732ae36cb..000000000 --- a/hooks/useDownloadedFileOpener.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; -import { useRouter } from "expo-router"; -import { useCallback } from "react"; -import { usePlaySettings } from "@/providers/PlaySettingsProvider"; -import { writeToLog } from "@/utils/log"; - -export const useDownloadedFileOpener = () => { - const router = useRouter(); - const { setPlayUrl, setOfflineSettings } = usePlaySettings(); - - const openFile = useCallback( - async (item: BaseItemDto) => { - if (!item.Id) { - writeToLog("ERROR", "Attempted to open a file without an ID."); - console.error("Attempted to open a file without an ID."); - return; - } - const queryParams = new URLSearchParams({ - itemId: item.Id, - offline: "true", - playbackPosition: - item.UserData?.PlaybackPositionTicks?.toString() ?? "0", - }); - try { - router.push(`/player/direct-player?${queryParams.toString()}`); - } catch (error) { - writeToLog("ERROR", "Error opening file", error); - console.error("Error opening file:", error); - } - }, - [setOfflineSettings, setPlayUrl, router], - ); - - return { openFile }; -}; diff --git a/hooks/useFavorite.ts b/hooks/useFavorite.ts index b9e47e08b..77af77eeb 100644 --- a/hooks/useFavorite.ts +++ b/hooks/useFavorite.ts @@ -1,109 +1,140 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; -import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { useAtom } from "jotai"; -import { useEffect, useState } from "react"; +import { atom, useAtom } from "jotai"; +import { useCallback, useEffect, useMemo, useRef } from "react"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +// Shared atom to store favorite status across all components +// Maps itemId -> isFavorite +const favoritesAtom = atom>({}); + export const useFavorite = (item: BaseItemDto) => { const queryClient = useQueryClient(); const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); - const type = "item"; - const [isFavorite, setIsFavorite] = useState(item.UserData?.IsFavorite); + const [favorites, setFavorites] = useAtom(favoritesAtom); + + const itemId = item.Id ?? ""; + + // Get current favorite status from shared state, falling back to item data + const isFavorite = itemId + ? (favorites[itemId] ?? item.UserData?.IsFavorite) + : item.UserData?.IsFavorite; + + // Update shared state when item data changes + useEffect(() => { + if (itemId && item.UserData?.IsFavorite !== undefined) { + setFavorites((prev) => ({ + ...prev, + [itemId]: item.UserData!.IsFavorite!, + })); + } + }, [itemId, item.UserData?.IsFavorite, setFavorites]); + + // Helper to update favorite status in shared state + const setIsFavorite = useCallback( + (value: boolean | undefined) => { + if (itemId && value !== undefined) { + setFavorites((prev) => ({ ...prev, [itemId]: value })); + } + }, + [itemId, setFavorites], + ); + + // Use refs to avoid stale closure issues in mutationFn + const itemRef = useRef(item); + const apiRef = useRef(api); + const userRef = useRef(user); + + // Keep refs updated + useEffect(() => { + itemRef.current = item; + }, [item]); useEffect(() => { - setIsFavorite(item.UserData?.IsFavorite); - }, [item.UserData?.IsFavorite]); + apiRef.current = api; + }, [api]); - const updateItemInQueries = (newData: Partial) => { - queryClient.setQueryData( - [type, item.Id], - (old) => { - if (!old) return old; - return { - ...old, - ...newData, - UserData: { ...old.UserData, ...newData.UserData }, - }; - }, - ); - }; + useEffect(() => { + userRef.current = user; + }, [user]); - const markFavoriteMutation = useMutation({ - mutationFn: async () => { - if (api && user) { - await getUserLibraryApi(api).markFavoriteItem({ - userId: user.Id, - itemId: item.Id!, - }); - } + const itemQueryKeyPrefix = useMemo( + () => ["item", item.Id] as const, + [item.Id], + ); + + const updateItemInQueries = useCallback( + (newData: Partial) => { + queryClient.setQueriesData( + { queryKey: itemQueryKeyPrefix }, + (old) => { + if (!old) return old; + return { + ...old, + ...newData, + UserData: { ...old.UserData, ...newData.UserData }, + }; + }, + ); }, - onMutate: async () => { - await queryClient.cancelQueries({ queryKey: [type, item.Id] }); - const previousItem = queryClient.getQueryData([ - type, - item.Id, - ]); - updateItemInQueries({ UserData: { IsFavorite: true } }); + [itemQueryKeyPrefix, queryClient], + ); - return { previousItem }; - }, - onError: (_err, _variables, context) => { - if (context?.previousItem) { - queryClient.setQueryData([type, item.Id], context.previousItem); + const favoriteMutation = useMutation({ + mutationFn: async (nextIsFavorite: boolean) => { + const currentApi = apiRef.current; + const currentUser = userRef.current; + const currentItem = itemRef.current; + + if (!currentApi || !currentUser?.Id || !currentItem?.Id) { + return; } + + // Use the same endpoint format as the web client: + // POST /Users/{userId}/FavoriteItems/{itemId} - add favorite + // DELETE /Users/{userId}/FavoriteItems/{itemId} - remove favorite + const path = `/Users/${currentUser.Id}/FavoriteItems/${currentItem.Id}`; + + const response = nextIsFavorite + ? await currentApi.post(path, {}, {}) + : await currentApi.delete(path, {}); + return response.data; + }, + onMutate: async (nextIsFavorite: boolean) => { + await queryClient.cancelQueries({ queryKey: itemQueryKeyPrefix }); + + const previousIsFavorite = isFavorite; + const previousQueries = queryClient.getQueriesData({ + queryKey: itemQueryKeyPrefix, + }); + + setIsFavorite(nextIsFavorite); + updateItemInQueries({ UserData: { IsFavorite: nextIsFavorite } }); + + return { previousIsFavorite, previousQueries }; + }, + onError: (_err, _nextIsFavorite, context) => { + if (context?.previousQueries) { + for (const [queryKey, data] of context.previousQueries) { + queryClient.setQueryData(queryKey, data); + } + } + setIsFavorite(context?.previousIsFavorite); }, onSettled: () => { - queryClient.invalidateQueries({ queryKey: [type, item.Id] }); + queryClient.invalidateQueries({ queryKey: itemQueryKeyPrefix }); queryClient.invalidateQueries({ queryKey: ["home", "favorites"] }); - setIsFavorite(true); }, }); - const unmarkFavoriteMutation = useMutation({ - mutationFn: async () => { - if (api && user) { - await getUserLibraryApi(api).unmarkFavoriteItem({ - userId: user.Id, - itemId: item.Id!, - }); - } - }, - onMutate: async () => { - await queryClient.cancelQueries({ queryKey: [type, item.Id] }); - const previousItem = queryClient.getQueryData([ - type, - item.Id, - ]); - updateItemInQueries({ UserData: { IsFavorite: false } }); - - return { previousItem }; - }, - onError: (_err, _variables, context) => { - if (context?.previousItem) { - queryClient.setQueryData([type, item.Id], context.previousItem); - } - }, - onSettled: () => { - queryClient.invalidateQueries({ queryKey: [type, item.Id] }); - queryClient.invalidateQueries({ queryKey: ["home", "favorites"] }); - setIsFavorite(false); - }, - }); - - const toggleFavorite = () => { - if (isFavorite) { - unmarkFavoriteMutation.mutate(); - } else { - markFavoriteMutation.mutate(); - } - }; + const toggleFavorite = useCallback(() => { + favoriteMutation.mutate(!isFavorite); + }, [favoriteMutation, isFavorite]); return { isFavorite, toggleFavorite, - markFavoriteMutation, - unmarkFavoriteMutation, + favoriteMutation, }; }; diff --git a/hooks/useImageColors.ts b/hooks/useImageColors.ts deleted file mode 100644 index 4d8a01369..000000000 --- a/hooks/useImageColors.ts +++ /dev/null @@ -1,120 +0,0 @@ -import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; -import { useAtom, useAtomValue } from "jotai"; -import { useEffect, useMemo } from "react"; -import { Platform } from "react-native"; -import type * as ImageColorsType from "react-native-image-colors"; -import { apiAtom } from "@/providers/JellyfinProvider"; - -// Conditionally import react-native-image-colors only on non-TV platforms -const ImageColors = Platform.isTV - ? null - : (require("react-native-image-colors") as typeof ImageColorsType); - -import { - adjustToNearBlack, - calculateTextColor, - isCloseToBlack, - itemThemeColorAtom, -} from "@/utils/atoms/primaryColor"; -import { getItemImage } from "@/utils/getItemImage"; -import { storage } from "@/utils/mmkv"; - -/** - * Custom hook to extract and manage image colors for a given item. - * - * @param item - The BaseItemDto object representing the item. - * @param disabled - A boolean flag to disable color extraction. - * - */ -export const useImageColors = ({ - item, - url, - disabled, -}: { - item?: BaseItemDto | null; - url?: string | null; - disabled?: boolean; -}) => { - const api = useAtomValue(apiAtom); - const [, setPrimaryColor] = useAtom(itemThemeColorAtom); - - const isTv = Platform.isTV; - - const source = useMemo(() => { - if (!api) return; - if (url) return { uri: url }; - if (item) - return getItemImage({ - item, - api, - variant: "Primary", - quality: 80, - width: 300, - }); - return null; - }, [api, item, url]); - - useEffect(() => { - if (isTv) return; - if (disabled) return; - if (source?.uri) { - const _primary = storage.getString(`${source.uri}-primary`); - const _text = storage.getString(`${source.uri}-text`); - - if (_primary && _text) { - setPrimaryColor({ - primary: _primary, - text: _text, - }); - return; - } - - // Extract colors from the image - if (!ImageColors?.getColors) return; - - ImageColors.getColors(source.uri, { - fallback: "#fff", - cache: false, - }) - .then((colors: ImageColorsType.ImageColorsResult) => { - let primary = "#fff"; - let text = "#000"; - let backup = "#fff"; - - // Select the appropriate color based on the platform - if (colors.platform === "android") { - primary = colors.dominant; - backup = colors.vibrant; - } else if (colors.platform === "ios") { - primary = colors.detail; - backup = colors.primary; - } - - // Adjust the primary color if it's too close to black - if (primary && isCloseToBlack(primary)) { - if (backup && !isCloseToBlack(backup)) primary = backup; - primary = adjustToNearBlack(primary); - } - - // Calculate the text color based on the primary color - if (primary) text = calculateTextColor(primary); - - setPrimaryColor({ - primary, - text, - }); - - // Cache the colors in storage - if (source.uri && primary) { - storage.set(`${source.uri}-primary`, primary); - storage.set(`${source.uri}-text`, text); - } - }) - .catch((error: any) => { - console.error("Error getting colors", error); - }); - } - }, [isTv, source?.uri, setPrimaryColor, disabled]); - - if (isTv) return; -}; diff --git a/hooks/useImageStorage.ts b/hooks/useImageStorage.ts index ec66c5053..b5b6896ca 100644 --- a/hooks/useImageStorage.ts +++ b/hooks/useImageStorage.ts @@ -1,3 +1,4 @@ +import { File, Paths } from "expo-file-system"; import { useCallback } from "react"; import { storage } from "@/utils/mmkv"; @@ -12,36 +13,28 @@ const useImageStorage = () => { } }, []); + /** + * expo-file-system instead of fetch+Blob+FileReader: the latter silently + * resolves to an empty payload under RN's New Architecture. + */ const image2Base64 = useCallback(async (url?: string | null) => { if (!url) return null; - let blob: Blob; + const tmpFile = new File( + Paths.cache, + `img-${Date.now()}-${Math.random().toString(36).slice(2)}.jpg`, + ); try { - // Fetch the data from the URL - const response = await fetch(url); - blob = await response.blob(); + const downloaded = await File.downloadFileAsync(url, tmpFile, { + idempotent: true, + }); + return await downloaded.base64(); } catch (error) { console.warn("Error fetching image:", error); return null; + } finally { + if (tmpFile.exists) tmpFile.delete(); } - - // Create a FileReader instance - const reader = new FileReader(); - - // Convert blob to base64 - return new Promise((resolve, reject) => { - reader.onloadend = () => { - if (typeof reader.result === "string") { - // Extract the base64 string (remove the data URL prefix) - const base64 = reader.result.split(",")[1]; - resolve(base64); - } else { - reject(new Error("Failed to convert image to base64")); - } - }; - reader.onerror = reject; - reader.readAsDataURL(blob); - }); }, []); const saveImage = useCallback( diff --git a/hooks/useIntroSkipper.ts b/hooks/useIntroSkipper.ts index d11ed511f..eeed98331 100644 --- a/hooks/useIntroSkipper.ts +++ b/hooks/useIntroSkipper.ts @@ -7,31 +7,26 @@ import { useHaptic } from "./useHaptic"; /** * Custom hook to handle skipping intros in a media player. + * MPV player uses milliseconds for time. * - * @param {number} currentTime - The current playback time in seconds. + * @param {number} currentTime - The current playback time in milliseconds. */ export const useIntroSkipper = ( itemId: string, currentTime: number, - seek: (ticks: number) => void, + seek: (ms: number) => void, play: () => void, - isVlc = false, isOffline = false, api: Api | null = null, downloadedFiles: DownloadedItem[] | undefined = undefined, ) => { const [showSkipButton, setShowSkipButton] = useState(false); - if (isVlc) { - currentTime = msToSeconds(currentTime); - } + // Convert ms to seconds for comparison with timestamps + const currentTimeSeconds = msToSeconds(currentTime); const lightHapticFeedback = useHaptic("light"); const wrappedSeek = (seconds: number) => { - if (isVlc) { - seek(secondsToMs(seconds)); - return; - } - seek(seconds); + seek(secondsToMs(seconds)); }; const { data: segments } = useSegments( @@ -45,8 +40,8 @@ export const useIntroSkipper = ( useEffect(() => { if (introTimestamps) { const shouldShow = - currentTime > introTimestamps.startTime && - currentTime < introTimestamps.endTime; + currentTimeSeconds > introTimestamps.startTime && + currentTimeSeconds < introTimestamps.endTime; setShowSkipButton(shouldShow); } else { @@ -54,7 +49,7 @@ export const useIntroSkipper = ( setShowSkipButton(false); } } - }, [introTimestamps, currentTime, showSkipButton]); + }, [introTimestamps, currentTimeSeconds, showSkipButton]); const skipIntro = useCallback(() => { if (!introTimestamps) return; diff --git a/hooks/useItemPeopleQuery.ts b/hooks/useItemPeopleQuery.ts new file mode 100644 index 000000000..2dbad062c --- /dev/null +++ b/hooks/useItemPeopleQuery.ts @@ -0,0 +1,37 @@ +import type { + BaseItemPerson, + ItemFields, +} from "@jellyfin/sdk/lib/generated-client/models"; +import { getItemsApi } from "@jellyfin/sdk/lib/utils/api"; +import { useQuery } from "@tanstack/react-query"; +import { useAtom } from "jotai"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; + +export const useItemPeopleQuery = ( + itemId: string | undefined, + enabled: boolean, +) => { + const [api] = useAtom(apiAtom); + const [user] = useAtom(userAtom); + + return useQuery({ + queryKey: ["item", itemId, "people"], + queryFn: async () => { + if (!api || !user?.Id || !itemId) return []; + + const response = await getItemsApi(api).getItems({ + ids: [itemId], + userId: user.Id, + fields: ["People" satisfies ItemFields], + }); + + const people = response.data.Items?.[0]?.People; + return Array.isArray(people) ? people : []; + }, + enabled: !!api && !!user?.Id && !!itemId && enabled, + staleTime: 10 * 60 * 1000, + refetchOnMount: false, + refetchOnReconnect: false, + refetchOnWindowFocus: false, + }); +}; diff --git a/hooks/useItemQuery.ts b/hooks/useItemQuery.ts index 370b5f35a..d45fe51c6 100644 --- a/hooks/useItemQuery.ts +++ b/hooks/useItemQuery.ts @@ -2,6 +2,7 @@ import { ItemFields } from "@jellyfin/sdk/lib/generated-client/models"; import { getItemsApi } from "@jellyfin/sdk/lib/utils/api"; import { useQuery } from "@tanstack/react-query"; import { useAtom } from "jotai"; +import { Platform } from "react-native"; import { useDownload } from "@/providers/DownloadProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; @@ -12,11 +13,17 @@ export const excludeFields = (fieldsToExclude: ItemFields[]) => { ); }; +type ExtraQueryOptions = { + gcTime?: number; + staleTime?: number; +}; + export const useItemQuery = ( itemId: string | undefined, isOffline?: boolean, fields?: ItemFields[], excludeFields?: ItemFields[], + queryOptions?: ExtraQueryOptions, ) => { const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); @@ -49,9 +56,12 @@ export const useItemQuery = ( return response.data.Items?.[0]; }, enabled: !!itemId, + staleTime: isOffline ? Infinity : 60 * 1000, + refetchInterval: !isOffline && Platform.isTV ? 60 * 1000 : undefined, refetchOnMount: true, refetchOnWindowFocus: true, refetchOnReconnect: true, networkMode: "always", + ...queryOptions, }); }; diff --git a/hooks/useJellyseerr.ts b/hooks/useJellyseerr.ts index 9bdd43945..4ae918d85 100644 --- a/hooks/useJellyseerr.ts +++ b/hooks/useJellyseerr.ts @@ -10,10 +10,10 @@ import type { } from "@/utils/jellyseerr/server/models/Search"; import { storage } from "@/utils/mmkv"; import "@/augmentations"; -import { useQueryClient } from "@tanstack/react-query"; import { t } from "i18next"; import { useCallback, useMemo } from "react"; import { toast } from "sonner-native"; +import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient"; import { useSettings } from "@/utils/atoms/settings"; import type { RTRating } from "@/utils/jellyseerr/server/api/rating/rottentomatoes"; import { @@ -244,6 +244,22 @@ export class JellyseerrApi { .then(({ data }) => data); } + async approveRequest(requestId: number): Promise { + return this.axios + ?.post( + `${Endpoints.API_V1 + Endpoints.REQUEST}/${requestId}/approve`, + ) + .then(({ data }) => data); + } + + async declineRequest(requestId: number): Promise { + return this.axios + ?.post( + `${Endpoints.API_V1 + Endpoints.REQUEST}/${requestId}/decline`, + ) + .then(({ data }) => data); + } + async requests( params = { filter: "all", @@ -420,7 +436,7 @@ const jellyseerrUserAtom = atom(storage.get(JELLYSEERR_USER)); export const useJellyseerr = () => { const { settings, updateSettings } = useSettings(); const [jellyseerrUser, setJellyseerrUser] = useAtom(jellyseerrUserAtom); - const queryClient = useQueryClient(); + const queryClient = useNetworkAwareQueryClient(); const jellyseerrApi = useMemo(() => { const cookies = storage.get(JELLYSEERR_COOKIES); @@ -512,7 +528,8 @@ export const useJellyseerr = () => { }; const jellyseerrRegion = useMemo( - () => jellyseerrUser?.settings?.region || "US", + // streamingRegion and discoverRegion exists. region doesn't + () => jellyseerrUser?.settings?.discoverRegion || "US", [jellyseerrUser], ); diff --git a/hooks/useMarkAsPlayed.ts b/hooks/useMarkAsPlayed.ts index c789e1bdf..e21687fa6 100644 --- a/hooks/useMarkAsPlayed.ts +++ b/hooks/useMarkAsPlayed.ts @@ -1,25 +1,77 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import { useQueryClient } from "@tanstack/react-query"; +import { useCallback } from "react"; import { useHaptic } from "./useHaptic"; import { usePlaybackManager } from "./usePlaybackManager"; import { useInvalidatePlaybackProgressCache } from "./useRevalidatePlaybackProgressCache"; export const useMarkAsPlayed = (items: BaseItemDto[]) => { + const queryClient = useQueryClient(); const lightHapticFeedback = useHaptic("light"); const { markItemPlayed, markItemUnplayed } = usePlaybackManager(); const invalidatePlaybackProgressCache = useInvalidatePlaybackProgressCache(); - const toggle = async (played: boolean) => { - lightHapticFeedback(); - // Process all items - await Promise.all( - items.map((item) => { - if (!item.Id) return Promise.resolve(); - return played ? markItemPlayed(item.Id) : markItemUnplayed(item.Id); - }), - ); + const toggle = useCallback( + async (played: boolean) => { + lightHapticFeedback(); - await invalidatePlaybackProgressCache(); - }; + const itemIds = items.map((item) => item.Id).filter(Boolean) as string[]; + + const previousQueriesByItemId = itemIds.map((itemId) => ({ + itemId, + queries: queryClient.getQueriesData({ + queryKey: ["item", itemId], + }), + })); + + for (const itemId of itemIds) { + queryClient.setQueriesData( + { queryKey: ["item", itemId] }, + (old) => { + if (!old) return old; + return { + ...old, + UserData: { + ...old.UserData, + Played: played, + PlaybackPositionTicks: 0, + PlayedPercentage: 0, + }, + }; + }, + ); + } + + // Process all items + try { + await Promise.all( + items.map((item) => { + if (!item.Id) return Promise.resolve(); + return played ? markItemPlayed(item.Id) : markItemUnplayed(item.Id); + }), + ); + } catch (_error) { + for (const { queries } of previousQueriesByItemId) { + for (const [queryKey, data] of queries) { + queryClient.setQueryData(queryKey, data); + } + } + } finally { + await invalidatePlaybackProgressCache(); + for (const itemId of itemIds) { + queryClient.invalidateQueries({ queryKey: ["item", itemId] }); + } + } + }, + [ + invalidatePlaybackProgressCache, + items, + lightHapticFeedback, + markItemPlayed, + markItemUnplayed, + queryClient, + ], + ); return toggle; }; diff --git a/hooks/useMusicCast.ts b/hooks/useMusicCast.ts new file mode 100644 index 000000000..2352a6104 --- /dev/null +++ b/hooks/useMusicCast.ts @@ -0,0 +1,161 @@ +import type { Api } from "@jellyfin/sdk"; +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import { useCallback } from "react"; +import CastContext, { + CastState, + MediaStreamType, + PlayServicesState, + useCastState, + useRemoteMediaClient, +} from "react-native-google-cast"; +import { getAudioContentType } from "@/utils/jellyfin/audio/getAudioContentType"; +import { getAudioStreamUrl } from "@/utils/jellyfin/audio/getAudioStreamUrl"; + +interface UseMusicCastOptions { + api: Api | null; + userId: string | undefined; +} + +interface CastQueueOptions { + queue: BaseItemDto[]; + startIndex: number; +} + +/** + * Hook for casting music to Chromecast with full queue support + */ +export const useMusicCast = ({ api, userId }: UseMusicCastOptions) => { + const client = useRemoteMediaClient(); + const castState = useCastState(); + + const isConnected = castState === CastState.CONNECTED; + + /** + * Get album art URL for a track + */ + const getAlbumArtUrl = useCallback( + (track: BaseItemDto): string | undefined => { + if (!api) return undefined; + const albumId = track.AlbumId || track.ParentId; + if (albumId) { + return `${api.basePath}/Items/${albumId}/Images/Primary?maxHeight=600&maxWidth=600`; + } + return `${api.basePath}/Items/${track.Id}/Images/Primary?maxHeight=600&maxWidth=600`; + }, + [api], + ); + + /** + * Cast a queue of tracks to Chromecast + * Uses native queue support for seamless track transitions + */ + const castQueue = useCallback( + async ({ queue, startIndex }: CastQueueOptions): Promise => { + if (!client || !api || !userId) { + console.warn("Cannot cast: missing client, api, or userId"); + return false; + } + + try { + // Check Play Services state (Android) + const state = await CastContext.getPlayServicesState(); + if (state && state !== PlayServicesState.SUCCESS) { + CastContext.showPlayServicesErrorDialog(state); + return false; + } + + // Build queue items - limit to 100 tracks due to Cast SDK message size limit + const queueToSend = queue.slice(0, 100); + const queueItems = await Promise.all( + queueToSend.map(async (track) => { + const streamResult = await getAudioStreamUrl( + api, + userId, + track.Id!, + ); + if (!streamResult) { + throw new Error( + `Failed to get stream URL for track: ${track.Name}`, + ); + } + + const contentType = getAudioContentType( + streamResult.mediaSource?.Container, + ); + + // Calculate stream duration in seconds from runtime ticks + const streamDurationSeconds = track.RunTimeTicks + ? track.RunTimeTicks / 10000000 + : undefined; + + return { + mediaInfo: { + contentId: track.Id, + contentUrl: streamResult.url, + contentType, + streamType: MediaStreamType.BUFFERED, + streamDuration: streamDurationSeconds, + metadata: { + type: "musicTrack" as const, + title: track.Name || "Unknown Track", + artist: track.AlbumArtist || track.Artists?.join(", ") || "", + albumName: track.Album || "", + images: getAlbumArtUrl(track) + ? [{ url: getAlbumArtUrl(track)! }] + : [], + }, + }, + autoplay: true, + preloadTime: 10, // Preload 10 seconds before track ends + }; + }), + ); + + // Load media with queue + await client.loadMedia({ + queueData: { + items: queueItems, + startIndex: Math.min(startIndex, queueItems.length - 1), + }, + }); + + // Show expanded controls + CastContext.showExpandedControls(); + + return true; + } catch (error) { + console.error("Failed to cast music queue:", error); + return false; + } + }, + [client, api, userId, getAlbumArtUrl], + ); + + /** + * Cast a single track to Chromecast + */ + const castTrack = useCallback( + async (track: BaseItemDto): Promise => { + return castQueue({ queue: [track], startIndex: 0 }); + }, + [castQueue], + ); + + /** + * Stop casting and disconnect + */ + const stopCasting = useCallback(async () => { + if (client) { + await client.stop(); + } + }, [client]); + + return { + client, + isConnected, + castState, + castQueue, + castTrack, + stopCasting, + }; +}; diff --git a/hooks/useNetworkAwareQueryClient.ts b/hooks/useNetworkAwareQueryClient.ts new file mode 100644 index 000000000..66c928742 --- /dev/null +++ b/hooks/useNetworkAwareQueryClient.ts @@ -0,0 +1,61 @@ +import type { + InvalidateOptions, + InvalidateQueryFilters, + QueryClient, + QueryKey, +} from "@tanstack/react-query"; +import { useQueryClient } from "@tanstack/react-query"; +import { useCallback, useMemo } from "react"; +import { invalidateQueriesWhenOnline } from "@/utils/query/networkAwareInvalidate"; + +type NetworkAwareQueryClient = QueryClient & { + forceInvalidateQueries: QueryClient["invalidateQueries"]; +}; + +/** + * Returns a queryClient wrapper with network-aware invalidation. + * Use this instead of useQueryClient when you need to invalidate queries. + * + * - invalidateQueries: Only invalidates when online (preserves offline cache) + * - forceInvalidateQueries: Always invalidates (use sparingly) + */ +export function useNetworkAwareQueryClient(): NetworkAwareQueryClient { + const queryClient = useQueryClient(); + + const networkAwareInvalidate = useCallback( + ( + filters?: InvalidateQueryFilters, + options?: InvalidateOptions, + ): Promise => { + if (!filters) { + return Promise.resolve(); + } + return invalidateQueriesWhenOnline(queryClient, filters, options); + }, + [queryClient], + ); + + return useMemo(() => { + // Use a Proxy to wrap the queryClient and override invalidateQueries. + // Object.create doesn't work because QueryClient uses private fields (#) + // which can only be accessed on the exact instance they were defined on. + const forceInvalidate = queryClient.invalidateQueries.bind(queryClient); + + return new Proxy(queryClient, { + get(target, prop) { + if (prop === "invalidateQueries") { + return networkAwareInvalidate; + } + if (prop === "forceInvalidateQueries") { + return forceInvalidate; + } + const value = Reflect.get(target, prop, target); + // Bind methods to the original target to preserve private field access + if (typeof value === "function") { + return value.bind(target); + } + return value; + }, + }) as NetworkAwareQueryClient; + }, [queryClient, networkAwareInvalidate]); +} diff --git a/hooks/useOrientation.ts b/hooks/useOrientation.ts index 3483aefb7..924e6253f 100644 --- a/hooks/useOrientation.ts +++ b/hooks/useOrientation.ts @@ -1,5 +1,5 @@ import type { OrientationChangeEvent } from "expo-screen-orientation"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { Platform } from "react-native"; import { addOrientationChangeListener, @@ -21,6 +21,8 @@ const orientationToOrientationLock = ( return OrientationLock.LANDSCAPE_RIGHT; case Orientation.PORTRAIT_UP: return OrientationLock.PORTRAIT_UP; + case Orientation.UNKNOWN: + return OrientationLock.LANDSCAPE; default: return OrientationLock.PORTRAIT_UP; } @@ -53,27 +55,28 @@ export const useOrientation = () => { }; }, []); - const lockOrientation = async ( - lock: (typeof OrientationLock)[keyof typeof OrientationLock], - ) => { - if (Platform.isTV) return; + const lockOrientation = useCallback( + async (lock: (typeof OrientationLock)[keyof typeof OrientationLock]) => { + if (Platform.isTV) return; - if (lock === OrientationLock.DEFAULT) { - await unlockAsync(); - } else { - await lockAsync(lock); - } - }; + if (lock === OrientationLock.DEFAULT) { + await unlockAsync(); + } else { + await lockAsync(lock); + } + }, + [], + ); - const unlockOrientationFn = async () => { + const unlockOrientation = useCallback(async () => { if (Platform.isTV) return; await unlockAsync(); - }; + }, []); return { orientation, setOrientation, lockOrientation, - unlockOrientation: unlockOrientationFn, + unlockOrientation, }; }; diff --git a/hooks/usePlaybackManager.ts b/hooks/usePlaybackManager.ts index 5ea237cf0..94abb98b1 100644 --- a/hooks/usePlaybackManager.ts +++ b/hooks/usePlaybackManager.ts @@ -3,7 +3,7 @@ import type { PlaybackProgressInfo, } from "@jellyfin/sdk/lib/generated-client"; import { getPlaystateApi, getTvShowsApi } from "@jellyfin/sdk/lib/utils/api"; -import { useQuery } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useAtomValue } from "jotai"; import { useMemo } from "react"; import { useDownload } from "@/providers/DownloadProvider"; @@ -69,6 +69,7 @@ export const usePlaybackManager = ({ const api = useAtomValue(apiAtom); const user = useAtomValue(userAtom); const { isConnected } = useNetworkStatus(); + const queryClient = useQueryClient(); const { getDownloadedItemById, updateDownloadedItem, getDownloadedItems } = useDownload(); @@ -79,7 +80,7 @@ export const usePlaybackManager = ({ const { data: adjacentItems } = useQuery({ queryKey: ["adjacentItems", item?.Id, item?.SeriesId, isOffline], queryFn: async (): Promise => { - if (!item || !item.SeriesId) { + if (!item?.SeriesId) { return null; } @@ -108,30 +109,35 @@ export const usePlaybackManager = ({ staleTime: 0, }); + /** + * Derive prev/next from the current item's real position in the adjacent + * list rather than from the array length. `getEpisodes({ adjacentTo })` does + * not guarantee a fixed [prev, current, next] shape — at the first/last + * episode it can still return the current item as the first/last entry — so + * length-based indexing wrongly surfaces the current episode as "previous". + */ + const currentIndex = useMemo( + () => adjacentItems?.findIndex((e) => e.Id === item?.Id) ?? -1, + [adjacentItems, item], + ); + + /** A neighbour is only navigable if it has an actual media file (not a + * "Virtual"/missing episode placeholder, e.g. an absent Special). */ + const isNavigable = (episode?: BaseItemDto | null): episode is BaseItemDto => + !!episode && episode.Id !== item?.Id && episode.LocationType !== "Virtual"; + const previousItem = useMemo(() => { - if (!adjacentItems || adjacentItems.length <= 1) { - return null; - } - - if (adjacentItems.length === 2) { - return adjacentItems[0].Id === item?.Id ? null : adjacentItems[0]; - } - - return adjacentItems[0]; - }, [adjacentItems, item]); + if (!adjacentItems || currentIndex <= 0) return null; + const candidate = adjacentItems[currentIndex - 1]; + return isNavigable(candidate) ? candidate : null; + }, [adjacentItems, currentIndex, item]); /** The next item in the series */ const nextItem = useMemo(() => { - if (!adjacentItems || adjacentItems.length <= 1) { - return null; - } - - if (adjacentItems.length === 2) { - return adjacentItems[1].Id === item?.Id ? null : adjacentItems[1]; - } - - return adjacentItems[2]; - }, [adjacentItems, item]); + if (!adjacentItems || currentIndex < 0) return null; + const candidate = adjacentItems[currentIndex + 1]; + return isNavigable(candidate) ? candidate : null; + }, [adjacentItems, currentIndex, item]); /** * Reports playback progress. @@ -186,6 +192,9 @@ export const usePlaybackManager = ({ }, }, }); + // Force invalidate queries so they refetch from updated local database + queryClient.invalidateQueries({ queryKey: ["item", itemId] }); + queryClient.invalidateQueries({ queryKey: ["episodes"] }); } // Handle remote state update if online @@ -226,6 +235,9 @@ export const usePlaybackManager = ({ }, }, }); + // Force invalidate queries so they refetch from updated local database + queryClient.invalidateQueries({ queryKey: ["item", itemId] }); + queryClient.invalidateQueries({ queryKey: ["episodes"] }); } // Handle remote state update if online @@ -237,6 +249,7 @@ export const usePlaybackManager = ({ }); } catch (error) { console.error("Failed to mark item as played on server", error); + throw error; } } }; @@ -267,6 +280,9 @@ export const usePlaybackManager = ({ }, }, }); + // Force invalidate queries so they refetch from updated local database + queryClient.invalidateQueries({ queryKey: ["item", itemId] }); + queryClient.invalidateQueries({ queryKey: ["episodes"] }); } // Handle remote state update if online @@ -278,6 +294,7 @@ export const usePlaybackManager = ({ }); } catch (error) { console.error("Failed to mark item as unplayed on server", error); + throw error; } } }; diff --git a/hooks/usePlaybackSpeed.ts b/hooks/usePlaybackSpeed.ts new file mode 100644 index 000000000..f946c905c --- /dev/null +++ b/hooks/usePlaybackSpeed.ts @@ -0,0 +1,45 @@ +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; +import { useMemo } from "react"; +import type { Settings } from "@/utils/atoms/settings"; + +/** + * Determines the appropriate playback speed for a media item based on a three-tier priority system: + * 1. Media-specific speed (highest priority) + * 2. Series-specific speed (medium priority) + * 3. Default speed (lowest priority) + */ +const usePlaybackSpeed = ( + item: BaseItemDto | null, + settings: Settings | null, +): { readonly playbackSpeed: number } => { + const playbackSpeed = useMemo(() => { + if (!settings || !item) { + return 1.0; + } + + // Start with the lowest priority: default playback speed + let selectedPlaybackSpeed = settings.defaultPlaybackSpeed; + + // Second priority: use what is set for Series if it is a Series + if (item.SeriesId && settings.playbackSpeedPerShow[item.SeriesId]) { + selectedPlaybackSpeed = settings.playbackSpeedPerShow[item.SeriesId]; + } + + // Highest priority: use what is set for Media if it is set + if (item.Id && settings.playbackSpeedPerMedia[item.Id] !== undefined) { + selectedPlaybackSpeed = settings.playbackSpeedPerMedia[item.Id]; + } + + return selectedPlaybackSpeed; + }, [ + item?.Id, + item?.SeriesId, + settings?.defaultPlaybackSpeed, + settings?.playbackSpeedPerMedia, + settings?.playbackSpeedPerShow, + ]); + + return { playbackSpeed }; +}; + +export default usePlaybackSpeed; diff --git a/hooks/usePlaylistMutations.ts b/hooks/usePlaylistMutations.ts new file mode 100644 index 000000000..dd3f13d6e --- /dev/null +++ b/hooks/usePlaylistMutations.ts @@ -0,0 +1,194 @@ +import { getLibraryApi, getPlaylistsApi } from "@jellyfin/sdk/lib/utils/api"; +import { useMutation } from "@tanstack/react-query"; +import { useAtomValue } from "jotai"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner-native"; +import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; + +/** + * Hook to create a new playlist + */ +export const useCreatePlaylist = () => { + const api = useAtomValue(apiAtom); + const user = useAtomValue(userAtom); + const queryClient = useNetworkAwareQueryClient(); + const { t } = useTranslation(); + + const mutation = useMutation({ + mutationFn: async ({ + name, + trackIds, + }: { + name: string; + trackIds?: string[]; + }): Promise => { + if (!api || !user?.Id) { + throw new Error("API not configured"); + } + + const response = await getPlaylistsApi(api).createPlaylist({ + createPlaylistDto: { + Name: name, + Ids: trackIds, + UserId: user.Id, + MediaType: "Audio", + }, + }); + + return response.data.Id; + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["music-playlists"], + refetchType: "all", + }); + toast.success(t("music.playlists.created")); + }, + onError: (error: Error) => { + toast.error(error.message || t("music.playlists.failed_to_create")); + }, + }); + + return mutation; +}; + +/** + * Hook to add a track to a playlist + */ +export const useAddToPlaylist = () => { + const api = useAtomValue(apiAtom); + const user = useAtomValue(userAtom); + const queryClient = useNetworkAwareQueryClient(); + const { t } = useTranslation(); + + const mutation = useMutation({ + mutationFn: async ({ + playlistId, + trackIds, + }: { + playlistId: string; + trackIds: string[]; + playlistName?: string; + }): Promise => { + if (!api || !user?.Id) { + throw new Error("API not configured"); + } + + await getPlaylistsApi(api).addItemToPlaylist({ + playlistId, + ids: trackIds, + userId: user.Id, + }); + }, + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ + queryKey: ["music-playlists"], + }); + queryClient.invalidateQueries({ + queryKey: ["music-playlist", variables.playlistId], + }); + if (variables.playlistName) { + toast.success( + t("music.playlists.added_to", { name: variables.playlistName }), + ); + } else { + toast.success(t("music.playlists.added")); + } + }, + onError: (error: Error) => { + toast.error(error.message || t("music.playlists.failed_to_add")); + }, + }); + + return mutation; +}; + +/** + * Hook to remove a track from a playlist + */ +export const useRemoveFromPlaylist = () => { + const api = useAtomValue(apiAtom); + const queryClient = useNetworkAwareQueryClient(); + const { t } = useTranslation(); + + const mutation = useMutation({ + mutationFn: async ({ + playlistId, + entryIds, + }: { + playlistId: string; + entryIds: string[]; + playlistName?: string; + }): Promise => { + if (!api) { + throw new Error("API not configured"); + } + + await getPlaylistsApi(api).removeItemFromPlaylist({ + playlistId, + entryIds, + }); + }, + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ + queryKey: ["music-playlists"], + }); + queryClient.invalidateQueries({ + queryKey: ["music-playlist", variables.playlistId], + }); + queryClient.invalidateQueries({ + queryKey: ["music-playlist-tracks", variables.playlistId], + }); + if (variables.playlistName) { + toast.success( + t("music.playlists.removed_from", { name: variables.playlistName }), + ); + } else { + toast.success(t("music.playlists.removed")); + } + }, + onError: (error: Error) => { + toast.error(error.message || t("music.playlists.failed_to_remove")); + }, + }); + + return mutation; +}; + +/** + * Hook to delete a playlist + */ +export const useDeletePlaylist = () => { + const api = useAtomValue(apiAtom); + const queryClient = useNetworkAwareQueryClient(); + const { t } = useTranslation(); + + const mutation = useMutation({ + mutationFn: async ({ + playlistId, + }: { + playlistId: string; + }): Promise => { + if (!api) { + throw new Error("API not configured"); + } + + await getLibraryApi(api).deleteItem({ + itemId: playlistId, + }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["music-playlists"], + refetchType: "all", + }); + toast.success(t("music.playlists.deleted")); + }, + onError: (error: Error) => { + toast.error(error.message || t("music.playlists.failed_to_delete")); + }, + }); + + return mutation; +}; diff --git a/hooks/useRefreshLibraryOnFocus.ts b/hooks/useRefreshLibraryOnFocus.ts new file mode 100644 index 000000000..f89ebd58c --- /dev/null +++ b/hooks/useRefreshLibraryOnFocus.ts @@ -0,0 +1,50 @@ +import { useFocusEffect } from "expo-router"; +import { useCallback, useRef } from "react"; +import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient"; + +// Query keys that depend on the set of library items. Kept in sync with the +// LibraryChanged handler in WebSocketProvider. +const LIBRARY_QUERY_KEYS = [ + ["home"], + ["library-items"], + ["nextUp-all"], + ["nextUp"], + ["resumeItems"], +]; + +/** + * Fallback refresh for newly added/removed content. + * + * The primary path is the server's `LibraryChanged` WebSocket event (handled in + * WebSocketProvider). This hook is a safety net for cases where the socket was + * down or the change happened while the screen was unfocused: when the screen + * regains focus, it invalidates the library-dependent queries so React Query + * refetches the latest content. + * + * Skips the refresh on the very first focus (initial mount already fetches) and + * throttles to avoid refetch storms when quickly switching tabs. + */ +export function useRefreshLibraryOnFocus(throttleMs = 30_000) { + const queryClient = useNetworkAwareQueryClient(); + const hasFocusedOnce = useRef(false); + const lastRefreshRef = useRef(0); + + useFocusEffect( + useCallback(() => { + if (!hasFocusedOnce.current) { + hasFocusedOnce.current = true; + return; + } + + const now = Date.now(); + if (now - lastRefreshRef.current < throttleMs) { + return; + } + lastRefreshRef.current = now; + + for (const queryKey of LIBRARY_QUERY_KEYS) { + queryClient.invalidateQueries({ queryKey }); + } + }, [queryClient, throttleMs]), + ); +} diff --git a/hooks/useRemoteSubtitles.ts b/hooks/useRemoteSubtitles.ts new file mode 100644 index 000000000..b101aeeee --- /dev/null +++ b/hooks/useRemoteSubtitles.ts @@ -0,0 +1,332 @@ +import type { + BaseItemDto, + RemoteSubtitleInfo, +} from "@jellyfin/sdk/lib/generated-client"; +import { getSubtitleApi } from "@jellyfin/sdk/lib/utils/api"; +import { useMutation } from "@tanstack/react-query"; +import { Directory, File, Paths } from "expo-file-system"; +import { useAtomValue } from "jotai"; +import { useCallback, useMemo } from "react"; +import { Platform } from "react-native"; +import { apiAtom } from "@/providers/JellyfinProvider"; +import { + addDownloadedSubtitle, + type DownloadedSubtitle, +} from "@/utils/atoms/downloadedSubtitles"; +import { useSettings } from "@/utils/atoms/settings"; +import { + OpenSubtitlesApi, + type OpenSubtitlesResult, +} from "@/utils/opensubtitles/api"; + +export interface SubtitleSearchResult { + id: string; + name: string; + providerName: string; + format: string; + language: string; + communityRating?: number; + downloadCount?: number; + isHashMatch?: boolean; + hearingImpaired?: boolean; + aiTranslated?: boolean; + machineTranslated?: boolean; + /** For OpenSubtitles: file ID to download */ + fileId?: number; + /** Source: 'jellyfin' or 'opensubtitles' */ + source: "jellyfin" | "opensubtitles"; +} + +interface UseRemoteSubtitlesOptions { + itemId: string; + item: BaseItemDto; + mediaSourceId?: string | null; +} + +/** + * Convert Jellyfin RemoteSubtitleInfo to unified SubtitleSearchResult + */ +function jellyfinToResult(sub: RemoteSubtitleInfo): SubtitleSearchResult { + return { + id: sub.Id ?? "", + name: sub.Name ?? "Unknown", + providerName: sub.ProviderName ?? "Unknown", + format: sub.Format ?? "srt", + language: sub.ThreeLetterISOLanguageName ?? "", + communityRating: sub.CommunityRating ?? undefined, + downloadCount: sub.DownloadCount ?? undefined, + isHashMatch: sub.IsHashMatch ?? undefined, + hearingImpaired: sub.HearingImpaired ?? undefined, + aiTranslated: sub.AiTranslated ?? undefined, + machineTranslated: sub.MachineTranslated ?? undefined, + source: "jellyfin", + }; +} + +/** + * Convert OpenSubtitles result to unified SubtitleSearchResult + */ +function openSubtitlesToResult( + sub: OpenSubtitlesResult, +): SubtitleSearchResult | null { + const firstFile = sub.attributes.files[0]; + if (!firstFile) return null; + + return { + id: sub.id, + name: + sub.attributes.release || sub.attributes.files[0]?.file_name || "Unknown", + providerName: "OpenSubtitles", + format: sub.attributes.format || "srt", + language: sub.attributes.language, + communityRating: sub.attributes.ratings, + downloadCount: sub.attributes.download_count, + isHashMatch: false, + hearingImpaired: sub.attributes.hearing_impaired, + aiTranslated: sub.attributes.ai_translated, + machineTranslated: sub.attributes.machine_translated, + fileId: firstFile.file_id, + source: "opensubtitles", + }; +} + +/** + * Hook for searching and downloading remote subtitles + * + * Primary: Uses Jellyfin's subtitle API (server-side OpenSubtitles plugin) + * Fallback: Direct OpenSubtitles API when server has no provider + */ +export function useRemoteSubtitles({ + itemId, + item, + mediaSourceId: _mediaSourceId, +}: UseRemoteSubtitlesOptions) { + const api = useAtomValue(apiAtom); + const { settings } = useSettings(); + const openSubtitlesApiKey = settings.openSubtitlesApiKey; + + // Check if we can use OpenSubtitles fallback + const hasOpenSubtitlesApiKey = Boolean(openSubtitlesApiKey); + + // Create OpenSubtitles API client when API key is available + const openSubtitlesApi = useMemo(() => { + if (!openSubtitlesApiKey) return null; + return new OpenSubtitlesApi(openSubtitlesApiKey); + }, [openSubtitlesApiKey]); + + /** + * Search for subtitles via Jellyfin API + */ + const searchJellyfin = useCallback( + async (language: string): Promise => { + if (!api) throw new Error("API not available"); + + const subtitleApi = getSubtitleApi(api); + const response = await subtitleApi.searchRemoteSubtitles({ + itemId, + language, + }); + + return (response.data || []).map(jellyfinToResult); + }, + [api, itemId], + ); + + /** + * Search for subtitles via OpenSubtitles direct API + */ + const searchOpenSubtitles = useCallback( + async (language: string): Promise => { + if (!openSubtitlesApi) { + throw new Error("OpenSubtitles API key not configured"); + } + + // Get IMDB ID from item if available + const imdbId = item.ProviderIds?.Imdb; + + // Build search params + const params: Parameters[0] = { + languages: language, + }; + + if (imdbId) { + params.imdbId = imdbId; + } else { + // Fall back to title search + params.query = item.Name || ""; + params.year = item.ProductionYear || undefined; + } + + // For TV episodes, add season/episode info + if (item.Type === "Episode") { + params.seasonNumber = item.ParentIndexNumber || undefined; + params.episodeNumber = item.IndexNumber || undefined; + } + + const response = await openSubtitlesApi.search(params); + + return response.data + .map(openSubtitlesToResult) + .filter((r): r is SubtitleSearchResult => r !== null); + }, + [openSubtitlesApi, item], + ); + + /** + * Download subtitle via Jellyfin API (saves to server library) + */ + const downloadJellyfin = useCallback( + async (subtitleId: string): Promise => { + if (!api) throw new Error("API not available"); + + const subtitleApi = getSubtitleApi(api); + await subtitleApi.downloadRemoteSubtitles({ + itemId, + subtitleId, + }); + }, + [api, itemId], + ); + + /** + * Download subtitle via OpenSubtitles API (returns local file path) + * + * On TV: Downloads to cache directory and persists metadata in MMKV + * On mobile: Downloads to cache directory (ephemeral, no persistence) + * + * Uses a flat filename structure with itemId prefix to avoid tvOS permission issues + */ + const downloadOpenSubtitles = useCallback( + async ( + fileId: number, + result: SubtitleSearchResult, + ): Promise<{ path: string; subtitle?: DownloadedSubtitle }> => { + if (!openSubtitlesApi) { + throw new Error("OpenSubtitles API key not configured"); + } + + // Get download link + const response = await openSubtitlesApi.download(fileId); + const originalFileName = response.file_name || `subtitle_${fileId}.srt`; + + // Use cache directory for both platforms (tvOS has permission issues with documents) + // TV: Uses itemId prefix for organization and persists metadata + // Mobile: Simple filename, no persistence + const subtitlesDir = new Directory(Paths.cache, "streamyfin-subtitles"); + + // Ensure directory exists + if (!subtitlesDir.exists) { + subtitlesDir.create(); + } + + // TV: Prefix filename with itemId for organization + // Mobile: Use original filename + const fileName = Platform.isTV + ? `${itemId}_${originalFileName}` + : originalFileName; + + // Create file and download + const destination = new File(subtitlesDir, fileName); + + // Delete existing file if it exists (re-download) + if (destination.exists) { + destination.delete(); + } + + await File.downloadFileAsync(response.link, destination); + + // TV: Persist metadata for future sessions + if (Platform.isTV) { + const subtitleMetadata: DownloadedSubtitle = { + id: result.id, + itemId, + filePath: destination.uri, + name: result.name, + language: result.language, + format: result.format, + source: "opensubtitles", + downloadedAt: Date.now(), + }; + addDownloadedSubtitle(subtitleMetadata); + return { path: destination.uri, subtitle: subtitleMetadata }; + } + + return { path: destination.uri }; + }, + [openSubtitlesApi, itemId], + ); + + /** + * Search mutation - tries Jellyfin first, falls back to OpenSubtitles + */ + const searchMutation = useMutation({ + mutationFn: async ({ + language, + preferOpenSubtitles = false, + }: { + language: string; + preferOpenSubtitles?: boolean; + }) => { + // If user prefers OpenSubtitles and has API key, use it + if (preferOpenSubtitles && hasOpenSubtitlesApiKey) { + return searchOpenSubtitles(language); + } + + // Try Jellyfin first + try { + const results = await searchJellyfin(language); + // If no results and we have OpenSubtitles fallback, try it + if (results.length === 0 && hasOpenSubtitlesApiKey) { + return searchOpenSubtitles(language); + } + return results; + } catch (error) { + // If Jellyfin fails (no provider configured) and we have fallback, use it + if (hasOpenSubtitlesApiKey) { + return searchOpenSubtitles(language); + } + throw error; + } + }, + }); + + /** + * Download mutation + */ + const downloadMutation = useMutation({ + mutationFn: async (result: SubtitleSearchResult) => { + if (result.source === "jellyfin") { + await downloadJellyfin(result.id); + return { type: "server" as const }; + } + if (result.fileId) { + const { path, subtitle } = await downloadOpenSubtitles( + result.fileId, + result, + ); + return { type: "local" as const, path, subtitle }; + } + throw new Error("Invalid subtitle result"); + }, + }); + + return { + // State + hasOpenSubtitlesApiKey, + isSearching: searchMutation.isPending, + isDownloading: downloadMutation.isPending, + searchError: searchMutation.error, + downloadError: downloadMutation.error, + searchResults: searchMutation.data, + + // Actions + search: searchMutation.mutate, + searchAsync: searchMutation.mutateAsync, + download: downloadMutation.mutate, + downloadAsync: downloadMutation.mutateAsync, + reset: () => { + searchMutation.reset(); + downloadMutation.reset(); + }, + }; +} diff --git a/hooks/useRevalidatePlaybackProgressCache.ts b/hooks/useRevalidatePlaybackProgressCache.ts index e10202f34..c8c310fc8 100644 --- a/hooks/useRevalidatePlaybackProgressCache.ts +++ b/hooks/useRevalidatePlaybackProgressCache.ts @@ -1,4 +1,4 @@ -import { useQueryClient } from "@tanstack/react-query"; +import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient"; import { useDownload } from "@/providers/DownloadProvider"; import { useTwoWaySync } from "./useTwoWaySync"; @@ -6,7 +6,7 @@ import { useTwoWaySync } from "./useTwoWaySync"; * useRevalidatePlaybackProgressCache invalidates queries related to playback progress. */ export function useInvalidatePlaybackProgressCache() { - const queryClient = useQueryClient(); + const queryClient = useNetworkAwareQueryClient(); const { getDownloadedItems } = useDownload(); const { syncPlaybackState } = useTwoWaySync(); diff --git a/hooks/useSessions.ts b/hooks/useSessions.ts index 5aba65159..108441c0e 100644 --- a/hooks/useSessions.ts +++ b/hooks/useSessions.ts @@ -21,7 +21,7 @@ export const useSessions = ({ const { data, isLoading } = useQuery({ queryKey: ["sessions"], queryFn: async () => { - if (!api || !user || !user.Policy?.IsAdministrator) { + if (!api || !user?.Policy?.IsAdministrator) { return []; } const response = await getSessionApi(api).getSessions({ @@ -55,7 +55,7 @@ export const useAllSessions = ({ const { data, isLoading } = useQuery({ queryKey: ["allSessions"], queryFn: async () => { - if (!api || !user || !user.Policy?.IsAdministrator) { + if (!api || !user?.Policy?.IsAdministrator) { return []; } const response = await getSessionApi(api).getSessions({ diff --git a/hooks/useTVAccountActionModal.ts b/hooks/useTVAccountActionModal.ts new file mode 100644 index 000000000..97db7ac50 --- /dev/null +++ b/hooks/useTVAccountActionModal.ts @@ -0,0 +1,34 @@ +import { useCallback } from "react"; +import useRouter from "@/hooks/useAppRouter"; +import { tvAccountActionModalAtom } from "@/utils/atoms/tvAccountActionModal"; +import type { + SavedServer, + SavedServerAccount, +} from "@/utils/secureCredentials"; +import { store } from "@/utils/store"; + +interface ShowAccountActionModalParams { + server: SavedServer; + account: SavedServerAccount; + onLogin: () => void; + onDelete: () => void; +} + +export const useTVAccountActionModal = () => { + const router = useRouter(); + + const showAccountActionModal = useCallback( + (params: ShowAccountActionModalParams) => { + store.set(tvAccountActionModalAtom, { + server: params.server, + account: params.account, + onLogin: params.onLogin, + onDelete: params.onDelete, + }); + router.push("/tv-account-action-modal"); + }, + [router], + ); + + return { showAccountActionModal }; +}; diff --git a/hooks/useTVAccountSelectModal.ts b/hooks/useTVAccountSelectModal.ts new file mode 100644 index 000000000..3bc61ed77 --- /dev/null +++ b/hooks/useTVAccountSelectModal.ts @@ -0,0 +1,34 @@ +import { useCallback } from "react"; +import useRouter from "@/hooks/useAppRouter"; +import { tvAccountSelectModalAtom } from "@/utils/atoms/tvAccountSelectModal"; +import type { + SavedServer, + SavedServerAccount, +} from "@/utils/secureCredentials"; +import { store } from "@/utils/store"; + +interface ShowAccountSelectModalParams { + server: SavedServer; + onAccountAction: (account: SavedServerAccount) => void; + onAddAccount: () => void; + onDeleteServer: () => void; +} + +export const useTVAccountSelectModal = () => { + const router = useRouter(); + + const showAccountSelectModal = useCallback( + (params: ShowAccountSelectModalParams) => { + store.set(tvAccountSelectModalAtom, { + server: params.server, + onAccountAction: params.onAccountAction, + onAddAccount: params.onAddAccount, + onDeleteServer: params.onDeleteServer, + }); + router.push("/tv-account-select-modal"); + }, + [router], + ); + + return { showAccountSelectModal }; +}; diff --git a/hooks/useTVBackHandler.ts b/hooks/useTVBackHandler.ts new file mode 100644 index 000000000..5de841dae --- /dev/null +++ b/hooks/useTVBackHandler.ts @@ -0,0 +1,88 @@ +import { useSegments } from "expo-router"; +import { useEffect } from "react"; +import { Platform } from "react-native"; +import { + disableTVMenuKeyInterception, + enableTVMenuKeyInterception, + useTVBackPress, +} from "./useTVBackPress"; + +export { enableTVMenuKeyInterception } from "./useTVBackPress"; + +/** All tab route names used in the bottom tab navigator. */ +export const TAB_ROUTES = [ + "(home)", + "(search)", + "(favorites)", + "(libraries)", + "(watchlists)", + "(custom-links)", + "(settings)", +] as const; + +export type TabRoute = (typeof TAB_ROUTES)[number]; + +/** Check if a segment string is a tab route. */ +export function isTabRoute(s: string): s is TabRoute { + return (TAB_ROUTES as readonly string[]).includes(s); +} + +/** + * Check if we're at the root of a tab + */ +function isAtTabRoot(segments: string[]): boolean { + const lastSegment = segments[segments.length - 1]; + return isTabRoute(lastSegment) || lastSegment === "index"; +} + +/** + * Get the current tab name from segments + */ +function getCurrentTab(segments: string[]): TabRoute | undefined { + return segments.find(isTabRoute); +} + +/** + * Keeps tvOS menu key interception disabled on the home tab root so the system + * can apply its native app-exit behavior. Other routes can opt into + * interception when they need JS-owned back handling. + */ +export function useTVHomeBackHandler() { + const segments = useSegments(); + + const currentTab = getCurrentTab(segments); + const atTabRoot = isAtTabRoot(segments); + const isOnHomeRoot = atTabRoot && currentTab === "(home)"; + + useEffect(() => { + if (!Platform.isTV) return; + + if (isOnHomeRoot) { + disableTVMenuKeyInterception(); + return; + } + + enableTVMenuKeyInterception(); + }, [isOnHomeRoot]); +} + +/** + * Handles back press at a non-Home tab root on Android TV by navigating to Home. + * + * Without NativeTabs, the Stack navigator used for the Android TV nav bar has no + * built-in tab-level back handling — pressing back at a tab root would pop the + * Stack entirely and exit the tab navigator. This hook intercepts that and routes + * to Home instead. + */ +export function useTVTabRootBackHandler( + onNavigateHome: () => void, + isAtTabRoot: boolean, + currentTab: string | undefined, +) { + useTVBackPress(() => { + if (!Platform.isTV || Platform.OS !== "android") return false; + if (!isAtTabRoot || currentTab === "(home)") return false; + onNavigateHome(); + return true; + }, [isAtTabRoot, currentTab, onNavigateHome]); +} diff --git a/hooks/useTVBackPress.ts b/hooks/useTVBackPress.ts new file mode 100644 index 000000000..2631cdab5 --- /dev/null +++ b/hooks/useTVBackPress.ts @@ -0,0 +1,72 @@ +import { type DependencyList, useEffect } from "react"; +import { BackHandler, Platform } from "react-native"; + +type TVBackPressHandler = () => boolean | null | undefined; + +let TVEventControl: { + enableTVMenuKey: () => void; + disableTVMenuKey: () => void; +} | null = null; + +if (Platform.isTV) { + try { + TVEventControl = require("react-native").TVEventControl; + } catch { + TVEventControl = null; + } +} + +export function enableTVMenuKeyInterception() { + if (Platform.isTV && TVEventControl) { + TVEventControl.enableTVMenuKey(); + } +} + +export function disableTVMenuKeyInterception() { + if (Platform.isTV && TVEventControl) { + TVEventControl.disableTVMenuKey(); + } +} + +export function useTVMenuKeyInterception(enabled = true) { + useEffect(() => { + if (!Platform.isTV) return; + + if (enabled) { + enableTVMenuKeyInterception(); + return; + } + + disableTVMenuKeyInterception(); + }, [enabled]); +} + +/** + * Subscribe to TV back presses through React Native's BackHandler. + * + * On Android TV this handles the hardware back button. On tvOS, + * react-native-tvos maps the Apple TV menu button to the same API when menu key + * interception is enabled. + * + * @see https://reactnative.dev/docs/backhandler + */ +export function useTVBackPress( + handler: TVBackPressHandler, + deps: DependencyList, +) { + useEffect(() => { + if (!Platform.isTV) return; + + // BackHandler is the shared back/menu surface for TV platforms: + // Android TV sends hardware back here, and react-native-tvos sends menu + // here when menu key interception is enabled. + const subscription = BackHandler.addEventListener( + "hardwareBackPress", + handler, + ); + + return () => { + subscription.remove(); + }; + }, deps); +} diff --git a/hooks/useTVEventHandler.ts b/hooks/useTVEventHandler.ts new file mode 100644 index 000000000..d92011b75 --- /dev/null +++ b/hooks/useTVEventHandler.ts @@ -0,0 +1,17 @@ +import type { HWEvent } from "react-native"; +import { Platform } from "react-native"; + +type UseTVEventHandler = (callback: (evt: HWEvent) => void) => void; + +let tvEventHandler: UseTVEventHandler = () => {}; + +if (Platform.isTV) { + try { + tvEventHandler = require("react-native") + .useTVEventHandler as UseTVEventHandler; + } catch { + tvEventHandler = () => {}; + } +} + +export const useTVEventHandler = tvEventHandler; diff --git a/hooks/useTVItemActionModal.ts b/hooks/useTVItemActionModal.ts new file mode 100644 index 000000000..3c547c0d6 --- /dev/null +++ b/hooks/useTVItemActionModal.ts @@ -0,0 +1,82 @@ +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import { useQueryClient } from "@tanstack/react-query"; +import { useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import { Alert } from "react-native"; +import { usePlaybackManager } from "@/hooks/usePlaybackManager"; +import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache"; + +export const useTVItemActionModal = () => { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + const { markItemPlayed, markItemUnplayed } = usePlaybackManager(); + const invalidatePlaybackProgressCache = useInvalidatePlaybackProgressCache(); + + const showItemActions = useCallback( + (item: BaseItemDto) => { + const isPlayed = item.UserData?.Played ?? false; + const itemTitle = + item.Type === "Episode" + ? `${item.SeriesName} - ${item.Name}` + : (item.Name ?? ""); + + const actionLabel = isPlayed + ? t("item_card.mark_unplayed") + : t("item_card.mark_played"); + + Alert.alert(itemTitle, undefined, [ + { text: t("common.cancel"), style: "cancel" }, + { + text: actionLabel, + onPress: async () => { + if (!item.Id) return; + + // Optimistic update + queryClient.setQueriesData( + { queryKey: ["item", item.Id] }, + (old) => { + if (!old) return old; + return { + ...old, + UserData: { + ...old.UserData, + Played: !isPlayed, + PlaybackPositionTicks: 0, + PlayedPercentage: 0, + }, + }; + }, + ); + + try { + if (!isPlayed) { + await markItemPlayed(item.Id); + } else { + await markItemUnplayed(item.Id); + } + } catch { + // Revert on failure + queryClient.invalidateQueries({ + queryKey: ["item", item.Id], + }); + } finally { + await invalidatePlaybackProgressCache(); + queryClient.invalidateQueries({ + queryKey: ["item", item.Id], + }); + } + }, + }, + ]); + }, + [ + t, + queryClient, + markItemPlayed, + markItemUnplayed, + invalidatePlaybackProgressCache, + ], + ); + + return { showItemActions }; +}; diff --git a/hooks/useTVOptionModal.ts b/hooks/useTVOptionModal.ts new file mode 100644 index 000000000..68db4de8d --- /dev/null +++ b/hooks/useTVOptionModal.ts @@ -0,0 +1,38 @@ +import { useCallback } from "react"; +import useRouter from "@/hooks/useAppRouter"; +import { + type TVOptionItem, + tvOptionModalAtom, +} from "@/utils/atoms/tvOptionModal"; +import { store } from "@/utils/store"; + +interface ShowOptionsParams { + title: string; + options: TVOptionItem[]; + onSelect: (value: T) => void; + cardWidth?: number; + cardHeight?: number; + deferApplyUntilDismissed?: boolean; +} + +export const useTVOptionModal = () => { + const router = useRouter(); + + const showOptions = useCallback( + (params: ShowOptionsParams) => { + // Use store.set for synchronous update before navigation + store.set(tvOptionModalAtom, { + title: params.title, + options: params.options, + onSelect: params.onSelect, + cardWidth: params.cardWidth, + cardHeight: params.cardHeight, + deferApplyUntilDismissed: params.deferApplyUntilDismissed, + }); + router.push("/(auth)/tv-option-modal"); + }, + [router], + ); + + return { showOptions }; +}; diff --git a/hooks/useTVRequestModal.ts b/hooks/useTVRequestModal.ts new file mode 100644 index 000000000..0c096bb46 --- /dev/null +++ b/hooks/useTVRequestModal.ts @@ -0,0 +1,34 @@ +import { useCallback } from "react"; +import useRouter from "@/hooks/useAppRouter"; +import { tvRequestModalAtom } from "@/utils/atoms/tvRequestModal"; +import type { MediaType } from "@/utils/jellyseerr/server/constants/media"; +import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces"; +import { store } from "@/utils/store"; + +interface ShowRequestModalParams { + requestBody: MediaRequestBody; + title: string; + id: number; + mediaType: MediaType; + onRequested: () => void; +} + +export const useTVRequestModal = () => { + const router = useRouter(); + + const showRequestModal = useCallback( + (params: ShowRequestModalParams) => { + store.set(tvRequestModalAtom, { + requestBody: params.requestBody, + title: params.title, + id: params.id, + mediaType: params.mediaType, + onRequested: params.onRequested, + }); + router.push("/(auth)/tv-request-modal"); + }, + [router], + ); + + return { showRequestModal }; +}; diff --git a/hooks/useTVSeasonSelectModal.ts b/hooks/useTVSeasonSelectModal.ts new file mode 100644 index 000000000..7b2f4f201 --- /dev/null +++ b/hooks/useTVSeasonSelectModal.ts @@ -0,0 +1,23 @@ +import { useCallback } from "react"; +import useRouter from "@/hooks/useAppRouter"; +import { + type TVSeasonSelectModalState, + tvSeasonSelectModalAtom, +} from "@/utils/atoms/tvSeasonSelectModal"; +import { store } from "@/utils/store"; + +type ShowSeasonSelectModalParams = NonNullable; + +export const useTVSeasonSelectModal = () => { + const router = useRouter(); + + const showSeasonSelectModal = useCallback( + (params: ShowSeasonSelectModalParams) => { + store.set(tvSeasonSelectModalAtom, params); + router.push("/(auth)/tv-season-select-modal"); + }, + [router], + ); + + return { showSeasonSelectModal }; +}; diff --git a/hooks/useTVSeriesSeasonModal.ts b/hooks/useTVSeriesSeasonModal.ts new file mode 100644 index 000000000..dcd5d4784 --- /dev/null +++ b/hooks/useTVSeriesSeasonModal.ts @@ -0,0 +1,34 @@ +import { useCallback } from "react"; +import useRouter from "@/hooks/useAppRouter"; +import { tvSeriesSeasonModalAtom } from "@/utils/atoms/tvSeriesSeasonModal"; +import { store } from "@/utils/store"; + +interface ShowSeasonModalParams { + seasons: Array<{ + label: string; + value: number; + selected: boolean; + }>; + selectedSeasonIndex: number | string; + itemId: string; + onSeasonSelect: (seasonIndex: number) => void; +} + +export const useTVSeriesSeasonModal = () => { + const router = useRouter(); + + const showSeasonModal = useCallback( + (params: ShowSeasonModalParams) => { + store.set(tvSeriesSeasonModalAtom, { + seasons: params.seasons, + selectedSeasonIndex: params.selectedSeasonIndex, + itemId: params.itemId, + onSeasonSelect: params.onSeasonSelect, + }); + router.push("/(auth)/tv-series-season-modal"); + }, + [router], + ); + + return { showSeasonModal }; +}; diff --git a/hooks/useTVSubtitleModal.ts b/hooks/useTVSubtitleModal.ts new file mode 100644 index 000000000..38d442239 --- /dev/null +++ b/hooks/useTVSubtitleModal.ts @@ -0,0 +1,40 @@ +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; +import { useCallback } from "react"; +import type { Track } from "@/components/video-player/controls/types"; +import useRouter from "@/hooks/useAppRouter"; +import { tvSubtitleModalAtom } from "@/utils/atoms/tvSubtitleModal"; +import { store } from "@/utils/store"; + +interface ShowSubtitleModalParams { + item: BaseItemDto; + mediaSourceId?: string | null; + subtitleTracks: Track[]; + currentSubtitleIndex: number; + onDisableSubtitles?: () => void; + onServerSubtitleDownloaded?: () => void; + onLocalSubtitleDownloaded?: (path: string) => void; + refreshSubtitleTracks?: () => Promise; +} + +export const useTVSubtitleModal = () => { + const router = useRouter(); + + const showSubtitleModal = useCallback( + (params: ShowSubtitleModalParams) => { + store.set(tvSubtitleModalAtom, { + item: params.item, + mediaSourceId: params.mediaSourceId, + subtitleTracks: params.subtitleTracks, + currentSubtitleIndex: params.currentSubtitleIndex, + onDisableSubtitles: params.onDisableSubtitles, + onServerSubtitleDownloaded: params.onServerSubtitleDownloaded, + onLocalSubtitleDownloaded: params.onLocalSubtitleDownloaded, + refreshSubtitleTracks: params.refreshSubtitleTracks, + }); + router.push("/(auth)/tv-subtitle-modal"); + }, + [router], + ); + + return { showSubtitleModal }; +}; diff --git a/hooks/useTVThemeMusic.ts b/hooks/useTVThemeMusic.ts new file mode 100644 index 000000000..13a2d5cf5 --- /dev/null +++ b/hooks/useTVThemeMusic.ts @@ -0,0 +1,225 @@ +import { getLibraryApi } from "@jellyfin/sdk/lib/utils/api"; +import { useQuery } from "@tanstack/react-query"; +import { + type AudioPlayer, + createAudioPlayer, + setAudioModeAsync, +} from "expo-audio"; +import { useAtom } from "jotai"; +import { useEffect } from "react"; +import { Platform } from "react-native"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { useSettings } from "@/utils/atoms/settings"; + +const TARGET_VOLUME = 0.3; +const FADE_IN_DURATION = 2000; +const FADE_OUT_DURATION = 1000; +const FADE_STEP_MS = 50; + +/** + * Smoothly transitions audio volume from `from` to `to` over `duration` ms. + * Returns a cleanup function that cancels the fade. + */ +function fadeVolume( + player: AudioPlayer, + from: number, + to: number, + duration: number, +): { promise: Promise; cancel: () => void } { + let cancelled = false; + const cancel = () => { + cancelled = true; + }; + + const steps = Math.max(1, Math.floor(duration / FADE_STEP_MS)); + const delta = (to - from) / steps; + + const promise = new Promise((resolve) => { + let current = from; + let step = 0; + + const tick = () => { + if (cancelled || step >= steps) { + if (!cancelled) { + player.volume = to; + } + resolve(); + return; + } + step++; + current += delta; + player.volume = Math.max(0, Math.min(1, current)); + if (!cancelled) { + setTimeout(tick, FADE_STEP_MS); + } else { + resolve(); + } + }; + + tick(); + }); + + return { promise, cancel }; +} + +// --- Module-level singleton state --- +let sharedPlayer: AudioPlayer | null = null; +let currentSongId: string | null = null; +let ownerCount = 0; +let activeFade: { cancel: () => void } | null = null; +let cleanupPromise: Promise | null = null; + +/** Fade out, stop, and release the shared player. */ +async function teardownSharedPlayer(): Promise { + const player = sharedPlayer; + if (!player) return; + + activeFade?.cancel(); + activeFade = null; + + try { + if (player.isLoaded) { + const currentVolume = player.volume ?? TARGET_VOLUME; + const fade = fadeVolume(player, currentVolume, 0, FADE_OUT_DURATION); + activeFade = fade; + await fade.promise; + activeFade = null; + player.pause(); + } + } catch { + // ignore + } + + if (sharedPlayer === player) { + sharedPlayer = null; + currentSongId = null; + } +} + +/** Begin cleanup idempotently; returns the shared promise. */ +function beginCleanup(): Promise { + if (!cleanupPromise) { + cleanupPromise = teardownSharedPlayer().finally(() => { + cleanupPromise = null; + }); + } + return cleanupPromise; +} + +export function useTVThemeMusic(itemId: string | undefined) { + const [api] = useAtom(apiAtom); + const [user] = useAtom(userAtom); + const { settings } = useSettings(); + + const enabled = + Platform.isTV && + !!api && + !!user?.Id && + !!itemId && + settings.tvThemeMusicEnabled; + + // Fetch theme songs + const { data: themeSongs } = useQuery({ + queryKey: ["themeSongs", itemId], + queryFn: async () => { + const result = await getLibraryApi(api!).getThemeSongs({ + itemId: itemId!, + userId: user!.Id!, + inheritFromParent: true, + }); + return result.data; + }, + enabled, + staleTime: 5 * 60 * 1000, + }); + + // Load and play audio when theme songs are available and enabled + useEffect(() => { + if (!enabled || !themeSongs?.Items?.length || !api) { + return; + } + + const themeItem = themeSongs.Items[0]; + const songId = themeItem.Id!; + + ownerCount++; + let mounted = true; + + const startPlayback = async () => { + // If the same song is already playing, keep it going + if (currentSongId === songId && sharedPlayer) { + return; + } + + // If a different song is playing (or cleanup is in progress), tear it down first + if (sharedPlayer || cleanupPromise) { + activeFade?.cancel(); + activeFade = null; + await beginCleanup(); + } + + if (!mounted) return; + + const player = createAudioPlayer(null); + sharedPlayer = player; + currentSongId = songId; + + try { + await setAudioModeAsync({ + playsInSilentMode: true, + shouldPlayInBackground: false, + }); + + const params = new URLSearchParams({ + UserId: user!.Id!, + DeviceId: api.deviceInfo.id ?? "", + MaxStreamingBitrate: "140000000", + Container: "mp3,aac,m4a|aac,m4b|aac,flac,wav", + TranscodingContainer: "mp4", + TranscodingProtocol: "http", + AudioCodec: "aac", + ApiKey: api.accessToken ?? "", + EnableRedirection: "true", + EnableRemoteMedia: "false", + }); + const url = `${api.basePath}/Audio/${themeItem.Id}/universal?${params.toString()}`; + player.replace({ uri: url }); + + if (!mounted || sharedPlayer !== player) { + player.pause(); + return; + } + + player.loop = true; + player.volume = 0; + player.play(); + + if (mounted && sharedPlayer === player) { + const fade = fadeVolume(player, 0, TARGET_VOLUME, FADE_IN_DURATION); + activeFade = fade; + await fade.promise; + activeFade = null; + } + } catch (e) { + console.warn("Theme music playback error:", e); + } + }; + + startPlayback(); + + // Cleanup: decrement owner count, defer teardown check + return () => { + mounted = false; + ownerCount--; + + // Defer the check so React can finish processing both unmount + mount + // in the same commit. If another instance mounts (same song), ownerCount + // will be back to >0 and we skip teardown entirely. + setTimeout(() => { + if (ownerCount === 0) { + beginCleanup(); + } + }, 0); + }; + }, [enabled, themeSongs, api]); +} diff --git a/hooks/useTVUserSwitchModal.ts b/hooks/useTVUserSwitchModal.ts new file mode 100644 index 000000000..a0b0a9441 --- /dev/null +++ b/hooks/useTVUserSwitchModal.ts @@ -0,0 +1,42 @@ +import { useCallback } from "react"; +import useRouter from "@/hooks/useAppRouter"; +import { tvUserSwitchModalAtom } from "@/utils/atoms/tvUserSwitchModal"; +import type { + SavedServer, + SavedServerAccount, +} from "@/utils/secureCredentials"; +import { store } from "@/utils/store"; + +interface UseTVUserSwitchModalOptions { + onAccountSelect: (account: SavedServerAccount) => void; +} + +export function useTVUserSwitchModal() { + const router = useRouter(); + + const showUserSwitchModal = useCallback( + ( + server: SavedServer, + currentUserId: string, + options: UseTVUserSwitchModalOptions, + ) => { + // Need at least 2 accounts (current + at least one other) + if (server.accounts.length < 2) { + return; + } + + store.set(tvUserSwitchModalAtom, { + serverUrl: server.address, + serverName: server.name || server.address, + accounts: server.accounts, + currentUserId, + onAccountSelect: options.onAccountSelect, + }); + + router.push("/(auth)/tv-user-switch-modal"); + }, + [router], + ); + + return { showUserSwitchModal }; +} diff --git a/hooks/useWatchlistMutations.ts b/hooks/useWatchlistMutations.ts new file mode 100644 index 000000000..5e65ebf99 --- /dev/null +++ b/hooks/useWatchlistMutations.ts @@ -0,0 +1,302 @@ +import { useMutation } from "@tanstack/react-query"; +import { useAtomValue } from "jotai"; +import { useCallback } from "react"; +import { toast } from "sonner-native"; +import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient"; +import { apiAtom } from "@/providers/JellyfinProvider"; +import { useSettings } from "@/utils/atoms/settings"; +import { createStreamystatsApi } from "@/utils/streamystats/api"; +import type { + CreateWatchlistRequest, + StreamystatsWatchlist, + UpdateWatchlistRequest, +} from "@/utils/streamystats/types"; + +/** + * Hook to create a new watchlist + */ +export const useCreateWatchlist = () => { + const api = useAtomValue(apiAtom); + const { settings } = useSettings(); + const queryClient = useNetworkAwareQueryClient(); + + const mutation = useMutation({ + mutationFn: async ( + data: CreateWatchlistRequest, + ): Promise => { + if (!settings?.streamyStatsServerUrl || !api?.accessToken) { + throw new Error("Streamystats not configured"); + } + + const streamystatsApi = createStreamystatsApi({ + serverUrl: settings.streamyStatsServerUrl, + jellyfinToken: api.accessToken, + }); + + const response = await streamystatsApi.createWatchlist(data); + if (response.error) { + throw new Error(response.error); + } + return response.data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["streamystats", "watchlists"], + }); + toast.success("Watchlist created"); + }, + onError: (error: Error) => { + toast.error(error.message || "Failed to create watchlist"); + }, + }); + + return mutation; +}; + +/** + * Hook to update a watchlist + */ +export const useUpdateWatchlist = () => { + const api = useAtomValue(apiAtom); + const { settings } = useSettings(); + const queryClient = useNetworkAwareQueryClient(); + + const mutation = useMutation({ + mutationFn: async ({ + watchlistId, + data, + }: { + watchlistId: number; + data: UpdateWatchlistRequest; + }): Promise => { + if (!settings?.streamyStatsServerUrl || !api?.accessToken) { + throw new Error("Streamystats not configured"); + } + + const streamystatsApi = createStreamystatsApi({ + serverUrl: settings.streamyStatsServerUrl, + jellyfinToken: api.accessToken, + }); + + const response = await streamystatsApi.updateWatchlist(watchlistId, data); + if (response.error) { + throw new Error(response.error); + } + return response.data; + }, + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ + queryKey: ["streamystats", "watchlists"], + }); + queryClient.invalidateQueries({ + queryKey: ["streamystats", "watchlist", variables.watchlistId], + }); + toast.success("Watchlist updated"); + }, + onError: (error: Error) => { + toast.error(error.message || "Failed to update watchlist"); + }, + }); + + return mutation; +}; + +/** + * Hook to delete a watchlist + */ +export const useDeleteWatchlist = () => { + const api = useAtomValue(apiAtom); + const { settings } = useSettings(); + const queryClient = useNetworkAwareQueryClient(); + + const mutation = useMutation({ + mutationFn: async (watchlistId: number): Promise => { + if (!settings?.streamyStatsServerUrl || !api?.accessToken) { + throw new Error("Streamystats not configured"); + } + + const streamystatsApi = createStreamystatsApi({ + serverUrl: settings.streamyStatsServerUrl, + jellyfinToken: api.accessToken, + }); + + const response = await streamystatsApi.deleteWatchlist(watchlistId); + if (response.error) { + throw new Error(response.error); + } + }, + onSuccess: (_data, watchlistId) => { + queryClient.invalidateQueries({ + queryKey: ["streamystats", "watchlists"], + }); + queryClient.removeQueries({ + queryKey: ["streamystats", "watchlist", watchlistId], + }); + toast.success("Watchlist deleted"); + }, + onError: (error: Error) => { + toast.error(error.message || "Failed to delete watchlist"); + }, + }); + + return mutation; +}; + +/** + * Hook to add an item to a watchlist with optimistic update + */ +export const useAddToWatchlist = () => { + const api = useAtomValue(apiAtom); + const { settings } = useSettings(); + const queryClient = useNetworkAwareQueryClient(); + + const mutation = useMutation({ + mutationFn: async ({ + watchlistId, + itemId, + }: { + watchlistId: number; + itemId: string; + watchlistName?: string; + }): Promise => { + if (!settings?.streamyStatsServerUrl || !api?.accessToken) { + throw new Error("Streamystats not configured"); + } + + const streamystatsApi = createStreamystatsApi({ + serverUrl: settings.streamyStatsServerUrl, + jellyfinToken: api.accessToken, + }); + + const response = await streamystatsApi.addWatchlistItem( + watchlistId, + itemId, + ); + if (response.error) { + throw new Error(response.error); + } + }, + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ + queryKey: ["streamystats", "watchlists"], + }); + queryClient.invalidateQueries({ + queryKey: ["streamystats", "watchlist", variables.watchlistId], + }); + queryClient.invalidateQueries({ + queryKey: ["streamystats", "watchlistItems", variables.watchlistId], + }); + queryClient.invalidateQueries({ + queryKey: ["streamystats", "itemInWatchlists", variables.itemId], + }); + if (variables.watchlistName) { + toast.success(`Added to ${variables.watchlistName}`); + } else { + toast.success("Added to watchlist"); + } + }, + onError: (error: Error) => { + toast.error(error.message || "Failed to add to watchlist"); + }, + }); + + return mutation; +}; + +/** + * Hook to remove an item from a watchlist with optimistic update + */ +export const useRemoveFromWatchlist = () => { + const api = useAtomValue(apiAtom); + const { settings } = useSettings(); + const queryClient = useNetworkAwareQueryClient(); + + const mutation = useMutation({ + mutationFn: async ({ + watchlistId, + itemId, + }: { + watchlistId: number; + itemId: string; + watchlistName?: string; + }): Promise => { + if (!settings?.streamyStatsServerUrl || !api?.accessToken) { + throw new Error("Streamystats not configured"); + } + + const streamystatsApi = createStreamystatsApi({ + serverUrl: settings.streamyStatsServerUrl, + jellyfinToken: api.accessToken, + }); + + const response = await streamystatsApi.removeWatchlistItem( + watchlistId, + itemId, + ); + if (response.error) { + throw new Error(response.error); + } + }, + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ + queryKey: ["streamystats", "watchlists"], + }); + queryClient.invalidateQueries({ + queryKey: ["streamystats", "watchlist", variables.watchlistId], + }); + queryClient.invalidateQueries({ + queryKey: ["streamystats", "watchlistItems", variables.watchlistId], + }); + queryClient.invalidateQueries({ + queryKey: ["streamystats", "itemInWatchlists", variables.itemId], + }); + if (variables.watchlistName) { + toast.success(`Removed from ${variables.watchlistName}`); + } else { + toast.success("Removed from watchlist"); + } + }, + onError: (error: Error) => { + toast.error(error.message || "Failed to remove from watchlist"); + }, + }); + + return mutation; +}; + +/** + * Hook to toggle an item in a watchlist + */ +export const useToggleWatchlistItem = () => { + const addMutation = useAddToWatchlist(); + const removeMutation = useRemoveFromWatchlist(); + + const toggle = useCallback( + async (params: { + watchlistId: number; + itemId: string; + isInWatchlist: boolean; + watchlistName?: string; + }) => { + if (params.isInWatchlist) { + await removeMutation.mutateAsync({ + watchlistId: params.watchlistId, + itemId: params.itemId, + watchlistName: params.watchlistName, + }); + } else { + await addMutation.mutateAsync({ + watchlistId: params.watchlistId, + itemId: params.itemId, + watchlistName: params.watchlistName, + }); + } + }, + [addMutation, removeMutation], + ); + + return { + toggle, + isLoading: addMutation.isPending || removeMutation.isPending, + }; +}; diff --git a/hooks/useWatchlists.ts b/hooks/useWatchlists.ts new file mode 100644 index 000000000..84891aa7a --- /dev/null +++ b/hooks/useWatchlists.ts @@ -0,0 +1,290 @@ +import type { + BaseItemDto, + PublicSystemInfo, +} from "@jellyfin/sdk/lib/generated-client/models"; +import { getItemsApi, getSystemApi } from "@jellyfin/sdk/lib/utils/api"; +import { useQuery } from "@tanstack/react-query"; +import { useAtomValue } from "jotai"; +import { useMemo } from "react"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { useSettings } from "@/utils/atoms/settings"; +import { createStreamystatsApi } from "@/utils/streamystats/api"; +import type { + GetWatchlistItemsParams, + StreamystatsWatchlist, +} from "@/utils/streamystats/types"; + +/** + * Hook to check if Streamystats is configured + */ +export const useStreamystatsEnabled = () => { + const { settings } = useSettings(); + return useMemo( + () => Boolean(settings?.streamyStatsServerUrl), + [settings?.streamyStatsServerUrl], + ); +}; + +/** + * Hook to get the Jellyfin server ID needed for Streamystats API calls + */ +export const useJellyfinServerId = () => { + const api = useAtomValue(apiAtom); + const streamystatsEnabled = useStreamystatsEnabled(); + + const { data: serverInfo, isLoading } = useQuery({ + queryKey: ["jellyfin", "serverInfo"], + queryFn: async (): Promise => { + if (!api) return null; + const response = await getSystemApi(api).getPublicSystemInfo(); + return response.data; + }, + enabled: Boolean(api) && streamystatsEnabled, + staleTime: 60 * 60 * 1000, // 1 hour + }); + + return { + jellyfinServerId: serverInfo?.Id, + isLoading, + }; +}; + +/** + * Hook to get all watchlists (own + public) + */ +export const useWatchlistsQuery = () => { + const api = useAtomValue(apiAtom); + const user = useAtomValue(userAtom); + const { settings } = useSettings(); + const streamystatsEnabled = useStreamystatsEnabled(); + + return useQuery({ + queryKey: [ + "streamystats", + "watchlists", + settings?.streamyStatsServerUrl, + user?.Id, + ], + queryFn: async (): Promise => { + if (!settings?.streamyStatsServerUrl || !api?.accessToken) { + return []; + } + + const streamystatsApi = createStreamystatsApi({ + serverUrl: settings.streamyStatsServerUrl, + jellyfinToken: api.accessToken, + }); + + const response = await streamystatsApi.getWatchlists(); + return response.data || []; + }, + enabled: streamystatsEnabled && Boolean(api?.accessToken), + staleTime: 60 * 1000, // 1 minute + }); +}; + +/** + * Hook to get a single watchlist with its items + */ +export const useWatchlistDetailQuery = ( + watchlistId: number | undefined, + params?: GetWatchlistItemsParams, +) => { + const api = useAtomValue(apiAtom); + const user = useAtomValue(userAtom); + const { settings } = useSettings(); + const streamystatsEnabled = useStreamystatsEnabled(); + + return useQuery({ + queryKey: [ + "streamystats", + "watchlist", + watchlistId, + params?.type, + params?.sort, + settings?.streamyStatsServerUrl, + ], + queryFn: async (): Promise => { + if ( + !settings?.streamyStatsServerUrl || + !api?.accessToken || + !watchlistId + ) { + return null; + } + + const streamystatsApi = createStreamystatsApi({ + serverUrl: settings.streamyStatsServerUrl, + jellyfinToken: api.accessToken, + }); + + const response = await streamystatsApi.getWatchlistDetail( + watchlistId, + params, + ); + return response.data || null; + }, + enabled: + streamystatsEnabled && + Boolean(api?.accessToken) && + Boolean(watchlistId) && + Boolean(user?.Id), + staleTime: 60 * 1000, // 1 minute + }); +}; + +/** + * Hook to get watchlist items enriched with Jellyfin item data + */ +export const useWatchlistItemsQuery = ( + watchlistId: number | undefined, + params?: GetWatchlistItemsParams, +) => { + const api = useAtomValue(apiAtom); + const user = useAtomValue(userAtom); + const { settings } = useSettings(); + const { jellyfinServerId } = useJellyfinServerId(); + const streamystatsEnabled = useStreamystatsEnabled(); + + return useQuery({ + queryKey: [ + "streamystats", + "watchlistItems", + watchlistId, + jellyfinServerId, + params?.type, + params?.sort, + settings?.streamyStatsServerUrl, + ], + queryFn: async (): Promise => { + if ( + !settings?.streamyStatsServerUrl || + !api?.accessToken || + !watchlistId || + !jellyfinServerId || + !user?.Id + ) { + return []; + } + + const streamystatsApi = createStreamystatsApi({ + serverUrl: settings.streamyStatsServerUrl, + jellyfinToken: api.accessToken, + }); + + // Get watchlist item IDs from Streamystats + const watchlistDetail = await streamystatsApi.getWatchlistItemIds({ + watchlistId, + jellyfinServerId, + }); + + const itemIds = watchlistDetail.data?.items; + if (!itemIds?.length) { + return []; + } + + // Fetch full item details from Jellyfin + const response = await getItemsApi(api).getItems({ + userId: user.Id, + ids: itemIds, + fields: [ + "PrimaryImageAspectRatio", + "Genres", + "Overview", + "DateCreated", + ], + enableImageTypes: ["Primary", "Backdrop", "Thumb"], + }); + + return response.data.Items || []; + }, + enabled: + streamystatsEnabled && + Boolean(api?.accessToken) && + Boolean(watchlistId) && + Boolean(jellyfinServerId) && + Boolean(user?.Id), + staleTime: 60 * 1000, // 1 minute + }); +}; + +/** + * Hook to get the user's own watchlists only (for add-to-watchlist picker) + */ +export const useMyWatchlistsQuery = () => { + const user = useAtomValue(userAtom); + const { data: allWatchlists, ...rest } = useWatchlistsQuery(); + + const myWatchlists = useMemo(() => { + if (!allWatchlists || !user?.Id) return []; + return allWatchlists.filter((w) => w.userId === user.Id); + }, [allWatchlists, user?.Id]); + + return { + data: myWatchlists, + ...rest, + }; +}; + +/** + * Hook to check which of the user's watchlists contain a specific item + */ +export const useItemInWatchlists = (itemId: string | undefined) => { + const { data: myWatchlists } = useMyWatchlistsQuery(); + const api = useAtomValue(apiAtom); + const { settings } = useSettings(); + const { jellyfinServerId } = useJellyfinServerId(); + const streamystatsEnabled = useStreamystatsEnabled(); + + return useQuery({ + queryKey: [ + "streamystats", + "itemInWatchlists", + itemId, + jellyfinServerId, + settings?.streamyStatsServerUrl, + ], + queryFn: async (): Promise => { + if ( + !settings?.streamyStatsServerUrl || + !api?.accessToken || + !itemId || + !jellyfinServerId || + !myWatchlists?.length + ) { + return []; + } + + const streamystatsApi = createStreamystatsApi({ + serverUrl: settings.streamyStatsServerUrl, + jellyfinToken: api.accessToken, + }); + + // Check each watchlist to see if it contains the item + const watchlistsContainingItem: number[] = []; + + for (const watchlist of myWatchlists) { + try { + const detail = await streamystatsApi.getWatchlistItemIds({ + watchlistId: watchlist.id, + jellyfinServerId, + }); + if (detail.data?.items?.includes(itemId)) { + watchlistsContainingItem.push(watchlist.id); + } + } catch { + // Ignore errors for individual watchlists + } + } + + return watchlistsContainingItem; + }, + enabled: + streamystatsEnabled && + Boolean(api?.accessToken) && + Boolean(itemId) && + Boolean(jellyfinServerId) && + Boolean(myWatchlists?.length), + staleTime: 30 * 1000, // 30 seconds + }); +}; diff --git a/hooks/useWebsockets.ts b/hooks/useWebsockets.ts index 32b110a4a..6881f3d64 100644 --- a/hooks/useWebsockets.ts +++ b/hooks/useWebsockets.ts @@ -1,7 +1,7 @@ -import { useRouter } from "expo-router"; import { useEffect } from "react"; import { useTranslation } from "react-i18next"; import { Alert } from "react-native"; +import useRouter from "@/hooks/useAppRouter"; import { useWebSocketContext } from "@/providers/WebSocketProvider"; interface UseWebSocketProps { @@ -96,8 +96,6 @@ export const useWebSocket = ({ | Record | undefined; // Arguments are Dictionary - console.log("[WS] ~ ", lastMessage); - if (command === "PlayPause") { console.log("Command ~ PlayPause"); togglePlay(); diff --git a/hooks/useWifiSSID.ts b/hooks/useWifiSSID.ts new file mode 100644 index 000000000..de0e28285 --- /dev/null +++ b/hooks/useWifiSSID.ts @@ -0,0 +1,125 @@ +import { useCallback, useEffect, useState } from "react"; +import { Platform } from "react-native"; +import { getSSID } from "@/modules/wifi-ssid"; + +export type PermissionStatus = + | "granted" + | "denied" + | "undetermined" + | "unavailable"; + +export interface UseWifiSSIDReturn { + ssid: string | null; + permissionStatus: PermissionStatus; + requestPermission: () => Promise; + isLoading: boolean; +} + +// WiFi SSID is not available on tvOS +if (Platform.isTV) { + // Export a stub hook for tvOS + module.exports = { + useWifiSSID: (): UseWifiSSIDReturn => ({ + ssid: null, + permissionStatus: "unavailable" as PermissionStatus, + requestPermission: async () => false, + isLoading: false, + }), + }; +} + +// Only import Location on non-TV platforms +const Location = Platform.isTV ? null : require("expo-location"); + +function mapLocationStatus(status: number | undefined): PermissionStatus { + if (!Location) return "unavailable"; + switch (status) { + case Location.PermissionStatus?.GRANTED: + return "granted"; + case Location.PermissionStatus?.DENIED: + return "denied"; + default: + return "undetermined"; + } +} + +export function useWifiSSID(): UseWifiSSIDReturn { + const [ssid, setSSID] = useState(null); + const [permissionStatus, setPermissionStatus] = useState( + Platform.isTV ? "unavailable" : "undetermined", + ); + const [isLoading, setIsLoading] = useState(!Platform.isTV); + + const fetchSSID = useCallback(async () => { + if (Platform.isTV) return; + const result = await getSSID(); + setSSID(result); + }, []); + + const requestPermission = useCallback(async (): Promise => { + if (Platform.isTV || !Location) { + setPermissionStatus("unavailable"); + return false; + } + + try { + const { status } = await Location.requestForegroundPermissionsAsync(); + const newStatus = mapLocationStatus(status); + setPermissionStatus(newStatus); + + if (newStatus === "granted") { + await fetchSSID(); + } + + return newStatus === "granted"; + } catch { + setPermissionStatus("unavailable"); + return false; + } + }, [fetchSSID]); + + useEffect(() => { + if (Platform.isTV || !Location) { + setIsLoading(false); + return; + } + + async function initialize() { + setIsLoading(true); + try { + const { status } = await Location.getForegroundPermissionsAsync(); + const mappedStatus = mapLocationStatus(status); + setPermissionStatus(mappedStatus); + + if (mappedStatus === "granted") { + await fetchSSID(); + } + } catch { + setPermissionStatus("unavailable"); + } + setIsLoading(false); + } + + initialize(); + }, [fetchSSID]); + + // Refresh SSID when permission status changes to granted + useEffect(() => { + if (Platform.isTV) return; + + if (permissionStatus === "granted") { + fetchSSID(); + + // Also set up an interval to periodically check SSID + const interval = setInterval(fetchSSID, 10000); // Check every 10 seconds + return () => clearInterval(interval); + } + }, [permissionStatus, fetchSSID]); + + return { + ssid, + permissionStatus, + requestPermission, + isLoading, + }; +} diff --git a/i18n.ts b/i18n.ts index d462efdfd..0eb92a068 100644 --- a/i18n.ts +++ b/i18n.ts @@ -29,7 +29,7 @@ import vi from "./translations/vi.json"; import zhCN from "./translations/zh-CN.json"; import zhTW from "./translations/zh-TW.json"; -export const APP_LANGUAGES = [ +const _APP_LANGUAGES = [ { label: "Catalan", value: "ca" }, { label: "العربية", value: "ar" }, { label: "Dansk", value: "da" }, @@ -57,7 +57,9 @@ export const APP_LANGUAGES = [ { label: "简体中文", value: "zh-CN" }, { label: "繁體中文", value: "zh-TW" }, { label: "Tiếng Việt", value: "vi" }, -]; +].sort((a, b) => a.label.localeCompare(b.label)); + +export const APP_LANGUAGES = _APP_LANGUAGES; i18n.use(initReactI18next).init({ compatibilityJSON: "v4", diff --git a/index.js b/index.js deleted file mode 100644 index 8e1414bcd..000000000 --- a/index.js +++ /dev/null @@ -1,2 +0,0 @@ -import "react-native-url-polyfill/auto"; -import "expo-router/entry"; diff --git a/index.ts b/index.ts new file mode 100644 index 000000000..7a0294a3e --- /dev/null +++ b/index.ts @@ -0,0 +1,10 @@ +import "react-native-url-polyfill/auto"; +import { Platform } from "react-native"; +import "expo-router/entry"; + +// TrackPlayer is not supported on tvOS +if (!Platform.isTV) { + const TrackPlayer = require("react-native-track-player").default; + const { PlaybackService } = require("./services/PlaybackService"); + TrackPlayer.registerPlaybackService(() => PlaybackService); +} diff --git a/modules/VlcPlayer.types.ts b/modules/VlcPlayer.types.ts deleted file mode 100644 index 93c0923dc..000000000 --- a/modules/VlcPlayer.types.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { ViewStyle } from "react-native"; - -export type PlaybackStatePayload = { - nativeEvent: { - target: number; - state: "Opening" | "Buffering" | "Playing" | "Paused" | "Error"; - currentTime: number; - duration: number; - isBuffering: boolean; - isPlaying: boolean; - }; -}; - -export type ProgressUpdatePayload = { - nativeEvent: { - currentTime: number; - duration: number; - isPlaying: boolean; - isBuffering: boolean; - }; -}; - -export type VideoLoadStartPayload = { - nativeEvent: { - target: number; - }; -}; - -export type PipStartedPayload = { - nativeEvent: { - pipStarted: boolean; - }; -}; - -export type VideoStateChangePayload = PlaybackStatePayload; - -export type VideoProgressPayload = ProgressUpdatePayload; - -export type VlcPlayerSource = { - uri: string; - type?: string; - isNetwork?: boolean; - autoplay?: boolean; - startPosition?: number; - externalSubtitles?: { name: string; DeliveryUrl: string }[]; - initOptions?: any[]; - mediaOptions?: { [key: string]: any }; -}; - -export type TrackInfo = { - name: string; - index: number; - language?: string; -}; - -export type ChapterInfo = { - name: string; - timeOffset: number; - duration: number; -}; - -export type NowPlayingMetadata = { - title?: string; - artist?: string; - albumTitle?: string; - artworkUri?: string; -}; - -export type VlcPlayerViewProps = { - source: VlcPlayerSource; - style?: ViewStyle | ViewStyle[]; - progressUpdateInterval?: number; - paused?: boolean; - muted?: boolean; - volume?: number; - videoAspectRatio?: string; - nowPlayingMetadata?: NowPlayingMetadata; - onVideoProgress?: (event: ProgressUpdatePayload) => void; - onVideoStateChange?: (event: PlaybackStatePayload) => void; - onVideoLoadStart?: (event: VideoLoadStartPayload) => void; - onVideoLoadEnd?: (event: VideoLoadStartPayload) => void; - onVideoError?: (event: PlaybackStatePayload) => void; - onPipStarted?: (event: PipStartedPayload) => void; -}; - -export interface VlcPlayerViewRef { - startPictureInPicture: () => Promise; - play: () => Promise; - pause: () => Promise; - stop: () => Promise; - seekTo: (time: number) => Promise; - setAudioTrack: (trackIndex: number) => Promise; - getAudioTracks: () => Promise; - setSubtitleTrack: (trackIndex: number) => Promise; - getSubtitleTracks: () => Promise; - setSubtitleDelay: (delay: number) => Promise; - setAudioDelay: (delay: number) => Promise; - takeSnapshot: (path: string, width: number, height: number) => Promise; - setRate: (rate: number) => Promise; - nextChapter: () => Promise; - previousChapter: () => Promise; - getChapters: () => Promise; - setVideoCropGeometry: (cropGeometry: string | null) => Promise; - getVideoCropGeometry: () => Promise; - setSubtitleURL: (url: string) => Promise; - setVideoAspectRatio: (aspectRatio: string | null) => Promise; - setVideoScaleFactor: (scaleFactor: number) => Promise; -} diff --git a/modules/VlcPlayerView.tsx b/modules/VlcPlayerView.tsx deleted file mode 100644 index a5cac3afa..000000000 --- a/modules/VlcPlayerView.tsx +++ /dev/null @@ -1,147 +0,0 @@ -import { requireNativeViewManager } from "expo-modules-core"; -import * as React from "react"; -import { ViewStyle } from "react-native"; -import type { - VlcPlayerSource, - VlcPlayerViewProps, - VlcPlayerViewRef, -} from "./VlcPlayer.types"; - -interface NativeViewRef extends VlcPlayerViewRef { - setNativeProps?: (props: Partial) => void; -} - -const VLCViewManager = requireNativeViewManager("VlcPlayer"); - -// Create a forwarded ref version of the native view -const NativeView = React.forwardRef( - (props, ref) => { - return ; - }, -); - -const VlcPlayerView = React.forwardRef( - (props, ref) => { - const nativeRef = React.useRef(null); - - React.useImperativeHandle(ref, () => ({ - startPictureInPicture: async () => { - await nativeRef.current?.startPictureInPicture(); - }, - play: async () => { - await nativeRef.current?.play(); - }, - pause: async () => { - await nativeRef.current?.pause(); - }, - stop: async () => { - await nativeRef.current?.stop(); - }, - seekTo: async (time: number) => { - await nativeRef.current?.seekTo(time); - }, - setAudioTrack: async (trackIndex: number) => { - await nativeRef.current?.setAudioTrack(trackIndex); - }, - getAudioTracks: async () => { - const tracks = await nativeRef.current?.getAudioTracks(); - return tracks ?? null; - }, - setSubtitleTrack: async (trackIndex: number) => { - await nativeRef.current?.setSubtitleTrack(trackIndex); - }, - getSubtitleTracks: async () => { - const tracks = await nativeRef.current?.getSubtitleTracks(); - return tracks ?? null; - }, - setSubtitleDelay: async (delay: number) => { - await nativeRef.current?.setSubtitleDelay(delay); - }, - setAudioDelay: async (delay: number) => { - await nativeRef.current?.setAudioDelay(delay); - }, - takeSnapshot: async (path: string, width: number, height: number) => { - await nativeRef.current?.takeSnapshot(path, width, height); - }, - setRate: async (rate: number) => { - await nativeRef.current?.setRate(rate); - }, - nextChapter: async () => { - await nativeRef.current?.nextChapter(); - }, - previousChapter: async () => { - await nativeRef.current?.previousChapter(); - }, - getChapters: async () => { - const chapters = await nativeRef.current?.getChapters(); - return chapters ?? null; - }, - setVideoCropGeometry: async (geometry: string | null) => { - await nativeRef.current?.setVideoCropGeometry(geometry); - }, - getVideoCropGeometry: async () => { - const geometry = await nativeRef.current?.getVideoCropGeometry(); - return geometry ?? null; - }, - setSubtitleURL: async (url: string) => { - await nativeRef.current?.setSubtitleURL(url); - }, - setVideoAspectRatio: async (aspectRatio: string | null) => { - await nativeRef.current?.setVideoAspectRatio(aspectRatio); - }, - setVideoScaleFactor: async (scaleFactor: number) => { - await nativeRef.current?.setVideoScaleFactor(scaleFactor); - }, - })); - - const { - source, - style, - progressUpdateInterval = 500, - paused, - muted, - volume, - videoAspectRatio, - nowPlayingMetadata, - onVideoLoadStart, - onVideoStateChange, - onVideoProgress, - onVideoLoadEnd, - onVideoError, - onPipStarted, - ...otherProps - } = props; - - const processedSource: VlcPlayerSource = - typeof source === "string" - ? ({ uri: source } as unknown as VlcPlayerSource) - : source; - - if (processedSource.startPosition !== undefined) { - processedSource.startPosition = Math.floor(processedSource.startPosition); - } - - return ( - - ); - }, -); - -export default VlcPlayerView; diff --git a/modules/background-downloader/android/build.gradle b/modules/background-downloader/android/build.gradle index ed8acf6bb..f2987348e 100644 --- a/modules/background-downloader/android/build.gradle +++ b/modules/background-downloader/android/build.gradle @@ -1,46 +1,20 @@ -plugins { - id 'com.android.library' - id 'kotlin-android' -} +apply plugin: 'expo-module-gradle-plugin' group = 'expo.modules.backgrounddownloader' version = '1.0.0' -def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle") -def kotlinVersion = findProperty('android.kotlinVersion') ?: '1.9.25' - -apply from: expoModulesCorePlugin - -applyKotlinExpoModulesCorePlugin() -useDefaultAndroidSdkVersions() -useCoreDependencies() -useExpoPublishing() +expoModule { + canBePublished false +} android { namespace "expo.modules.backgrounddownloader" - - compileOptions { - sourceCompatibility JavaVersion.VERSION_17 - targetCompatibility JavaVersion.VERSION_17 - } - - kotlinOptions { - jvmTarget = "17" - } - - lintOptions { - abortOnError false + defaultConfig { + versionCode 1 + versionName "1.0.0" } } dependencies { - implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion" - implementation "com.squareup.okhttp3:okhttp:5.3.0" + implementation "com.squareup.okhttp3:okhttp:4.12.0" } - -tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach { - kotlinOptions { - jvmTarget = "17" - } -} - diff --git a/modules/background-downloader/android/src/main/AndroidManifest.xml b/modules/background-downloader/android/src/main/AndroidManifest.xml index 44554032c..95d01ff99 100644 --- a/modules/background-downloader/android/src/main/AndroidManifest.xml +++ b/modules/background-downloader/android/src/main/AndroidManifest.xml @@ -2,7 +2,8 @@ - + + = 35 && isLikelyBootContext()) { + Log.w(TAG, "Skipping foreground start - likely boot context on Android 15+") + stopSelf() + return START_NOT_STICKY + } + + startForegroundSafely() return START_STICKY } + + /** + * Check if we're likely in a boot context by checking system uptime. + * If the system has been up for less than the threshold, we might be in boot context. + */ + private fun isLikelyBootContext(): Boolean { + val uptimeMs = SystemClock.elapsedRealtime() + return uptimeMs < BOOT_THRESHOLD_MS + } + + /** + * Start foreground service safely with proper service type for Android 14+ + */ + private fun startForegroundSafely() { + if (isForegroundStarted) return + + try { + if (Build.VERSION.SDK_INT >= 34) { + ServiceCompat.startForeground( + this, + NOTIFICATION_ID, + createNotification(), + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC + ) + } else { + startForeground(NOTIFICATION_ID, createNotification()) + } + isForegroundStarted = true + } catch (e: Exception) { + Log.e(TAG, "Failed to start foreground service", e) + // If we can't start foreground, stop the service + stopSelf() + } + } override fun onDestroy() { + wakeLock?.let { if (it.isHeld) it.release() } + Log.d(TAG, "Wake lock released") Log.d(TAG, "DownloadService destroyed") super.onDestroy() } @@ -86,7 +146,7 @@ class DownloadService : Service() { activeDownloadCount++ Log.d(TAG, "Download started, active count: $activeDownloadCount") if (activeDownloadCount == 1) { - startForeground(NOTIFICATION_ID, createNotification()) + startForegroundSafely() } } @@ -94,7 +154,10 @@ class DownloadService : Service() { activeDownloadCount = maxOf(0, activeDownloadCount - 1) Log.d(TAG, "Download stopped, active count: $activeDownloadCount") if (activeDownloadCount == 0) { - stopForeground(STOP_FOREGROUND_REMOVE) + if (isForegroundStarted) { + ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) + isForegroundStarted = false + } stopSelf() } } diff --git a/modules/glass-poster/expo-module.config.json b/modules/glass-poster/expo-module.config.json new file mode 100644 index 000000000..9c325f521 --- /dev/null +++ b/modules/glass-poster/expo-module.config.json @@ -0,0 +1,6 @@ +{ + "platforms": ["apple"], + "apple": { + "modules": ["GlassPosterModule"] + } +} diff --git a/modules/glass-poster/index.ts b/modules/glass-poster/index.ts new file mode 100644 index 000000000..f448ad096 --- /dev/null +++ b/modules/glass-poster/index.ts @@ -0,0 +1,8 @@ +// Glass Poster - Native SwiftUI glass effect for tvOS 26+ + +export * from "./src/GlassPoster.types"; +export { + default as GlassPosterModule, + isGlassEffectAvailable, +} from "./src/GlassPosterModule"; +export { default as GlassPosterView } from "./src/GlassPosterView"; diff --git a/modules/glass-poster/ios/GlassPoster.podspec b/modules/glass-poster/ios/GlassPoster.podspec new file mode 100644 index 000000000..60e5af697 --- /dev/null +++ b/modules/glass-poster/ios/GlassPoster.podspec @@ -0,0 +1,23 @@ +Pod::Spec.new do |s| + s.name = 'GlassPoster' + s.version = '1.0.0' + s.summary = 'Native SwiftUI glass effect poster for tvOS' + s.description = 'Provides Liquid Glass effect poster cards for tvOS 26+' + s.author = 'Streamyfin' + s.homepage = 'https://github.com/streamyfin/streamyfin' + s.platforms = { + :ios => '15.1', + :tvos => '15.1' + } + s.source = { git: '' } + s.static_framework = true + + s.dependency 'ExpoModulesCore' + + s.pod_target_xcconfig = { + 'DEFINES_MODULE' => 'YES', + 'SWIFT_VERSION' => '5.9' + } + + s.source_files = "*.{h,m,mm,swift}" +end diff --git a/modules/glass-poster/ios/GlassPosterExpoView.swift b/modules/glass-poster/ios/GlassPosterExpoView.swift new file mode 100644 index 000000000..d2d654c58 --- /dev/null +++ b/modules/glass-poster/ios/GlassPosterExpoView.swift @@ -0,0 +1,94 @@ +import ExpoModulesCore +import SwiftUI +import UIKit +import Combine + +/// Observable state that SwiftUI can watch for changes without rebuilding the entire view +class GlassPosterState: ObservableObject { + @Published var imageUrl: String? = nil + @Published var aspectRatio: Double = 10.0 / 15.0 + @Published var cornerRadius: Double = 24 + @Published var progress: Double = 0 + @Published var showWatchedIndicator: Bool = false + @Published var isFocused: Bool = false + @Published var width: Double = 260 +} + +/// ExpoView wrapper that hosts the SwiftUI GlassPosterView +class GlassPosterExpoView: ExpoView { + private var hostingController: UIHostingController? + private let state = GlassPosterState() + + // Stored dimensions for intrinsic content size + private var posterWidth: CGFloat = 260 + private var posterAspectRatio: CGFloat = 10.0 / 15.0 + + // Event dispatchers + let onLoad = EventDispatcher() + let onError = EventDispatcher() + + required init(appContext: AppContext? = nil) { + super.init(appContext: appContext) + setupHostingController() + } + + private func setupHostingController() { + let wrapper = GlassPosterViewWrapper(state: state) + let hostingController = UIHostingController(rootView: wrapper) + hostingController.view.backgroundColor = .clear + hostingController.view.translatesAutoresizingMaskIntoConstraints = false + + addSubview(hostingController.view) + + NSLayoutConstraint.activate([ + hostingController.view.topAnchor.constraint(equalTo: topAnchor), + hostingController.view.leadingAnchor.constraint(equalTo: leadingAnchor), + hostingController.view.trailingAnchor.constraint(equalTo: trailingAnchor), + hostingController.view.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) + + self.hostingController = hostingController + } + + // Override intrinsic content size for proper React Native layout + override var intrinsicContentSize: CGSize { + let height = posterWidth / posterAspectRatio + return CGSize(width: posterWidth, height: height) + } + + // MARK: - Property Setters + // These now update the observable state object directly. + // SwiftUI observes state changes and only re-renders affected views. + + func setImageUrl(_ url: String?) { + state.imageUrl = url + } + + func setAspectRatio(_ ratio: Double) { + state.aspectRatio = ratio + posterAspectRatio = CGFloat(ratio) + invalidateIntrinsicContentSize() + } + + func setWidth(_ width: Double) { + state.width = width + posterWidth = CGFloat(width) + invalidateIntrinsicContentSize() + } + + func setCornerRadius(_ radius: Double) { + state.cornerRadius = radius + } + + func setProgress(_ progress: Double) { + state.progress = progress + } + + func setShowWatchedIndicator(_ show: Bool) { + state.showWatchedIndicator = show + } + + func setIsFocused(_ focused: Bool) { + state.isFocused = focused + } +} diff --git a/modules/glass-poster/ios/GlassPosterModule.swift b/modules/glass-poster/ios/GlassPosterModule.swift new file mode 100644 index 000000000..3b9b9b194 --- /dev/null +++ b/modules/glass-poster/ios/GlassPosterModule.swift @@ -0,0 +1,50 @@ +import ExpoModulesCore + +public class GlassPosterModule: Module { + public func definition() -> ModuleDefinition { + Name("GlassPoster") + + // Check if glass effect is available (tvOS 26+) + Function("isGlassEffectAvailable") { () -> Bool in + #if os(tvOS) + if #available(tvOS 26.0, *) { + return true + } + #endif + return false + } + + // Native view component + View(GlassPosterExpoView.self) { + Prop("imageUrl") { (view: GlassPosterExpoView, url: String?) in + view.setImageUrl(url) + } + + Prop("aspectRatio") { (view: GlassPosterExpoView, ratio: Double) in + view.setAspectRatio(ratio) + } + + Prop("cornerRadius") { (view: GlassPosterExpoView, radius: Double) in + view.setCornerRadius(radius) + } + + Prop("progress") { (view: GlassPosterExpoView, progress: Double) in + view.setProgress(progress) + } + + Prop("showWatchedIndicator") { (view: GlassPosterExpoView, show: Bool) in + view.setShowWatchedIndicator(show) + } + + Prop("isFocused") { (view: GlassPosterExpoView, focused: Bool) in + view.setIsFocused(focused) + } + + Prop("width") { (view: GlassPosterExpoView, width: Double) in + view.setWidth(width) + } + + Events("onLoad", "onError") + } + } +} diff --git a/modules/glass-poster/ios/GlassPosterView.swift b/modules/glass-poster/ios/GlassPosterView.swift new file mode 100644 index 000000000..8c8e4f5f1 --- /dev/null +++ b/modules/glass-poster/ios/GlassPosterView.swift @@ -0,0 +1,219 @@ +import SwiftUI + +/// Wrapper view that observes state changes from GlassPosterState +/// This allows SwiftUI to efficiently update only the changed properties +/// instead of rebuilding the entire view hierarchy on every prop change. +struct GlassPosterViewWrapper: View { + @ObservedObject var state: GlassPosterState + + var body: some View { + GlassPosterView( + imageUrl: state.imageUrl, + aspectRatio: state.aspectRatio, + cornerRadius: state.cornerRadius, + progress: state.progress, + showWatchedIndicator: state.showWatchedIndicator, + isFocused: state.isFocused, + width: state.width + ) + } +} + +/// SwiftUI view with tvOS 26 Liquid Glass effect +struct GlassPosterView: View { + var imageUrl: String? = nil + var aspectRatio: Double = 10.0 / 15.0 + var cornerRadius: Double = 24 + var progress: Double = 0 + var showWatchedIndicator: Bool = false + var isFocused: Bool = false + var width: Double = 260 + + // Internal focus state for tvOS + @FocusState private var isInternallyFocused: Bool + + // Combined focus state (external prop OR internal focus) + private var isCurrentlyFocused: Bool { + isFocused || isInternallyFocused + } + + // Calculated height based on width and aspect ratio + private var height: Double { + width / aspectRatio + } + + var body: some View { + #if os(tvOS) + if #available(tvOS 26.0, *) { + glassContent + } else { + fallbackContent + } + #else + fallbackContent + #endif + } + + // MARK: - tvOS 26+ Content (glass effect disabled for now) + + #if os(tvOS) + @available(tvOS 26.0, *) + private var glassContent: some View { + return ZStack { + // Image content + imageContent + .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) + + // White border on focus + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .stroke(Color.white, lineWidth: isCurrentlyFocused ? 4 : 0) + + // Progress bar overlay + if progress > 0 { + progressOverlay + } + + // Watched indicator + if showWatchedIndicator { + watchedIndicatorOverlay + } + } + .frame(width: width, height: height) + .focusable() + .focused($isInternallyFocused) + .scaleEffect(isCurrentlyFocused ? 1.05 : 1.0) + .animation(.easeOut(duration: 0.15), value: isCurrentlyFocused) + } + #endif + + // MARK: - Fallback for older tvOS versions + + private var fallbackContent: some View { + ZStack { + // Main image + imageContent + .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) + + // White border on focus + RoundedRectangle(cornerRadius: cornerRadius) + .stroke(Color.white, lineWidth: isFocused ? 4 : 0) + + // Progress bar overlay + if progress > 0 { + progressOverlay + } + + // Watched indicator + if showWatchedIndicator { + watchedIndicatorOverlay + } + } + .frame(width: width, height: height) + .scaleEffect(isFocused ? 1.05 : 1.0) + .animation(.easeOut(duration: 0.15), value: isFocused) + } + + // MARK: - Shared Components + + private var imageContent: some View { + Group { + if let urlString = imageUrl, let url = URL(string: urlString) { + AsyncImage(url: url) { phase in + switch phase { + case .empty: + placeholderView + case .success(let image): + image + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: width, height: height) + .clipped() + case .failure: + placeholderView + @unknown default: + placeholderView + } + } + } else { + placeholderView + } + } + } + + private var placeholderView: some View { + Rectangle() + .fill(Color.gray.opacity(0.3)) + } + + private var progressOverlay: some View { + VStack { + Spacer() + GeometryReader { geometry in + ZStack(alignment: .leading) { + // Background track + Rectangle() + .fill(Color.white.opacity(0.3)) + .frame(height: 4) + + // Progress fill + Rectangle() + .fill(Color.white) + .frame(width: geometry.size.width * CGFloat(progress / 100), height: 4) + } + } + .frame(height: 4) + } + .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) + } + + private var watchedIndicatorOverlay: some View { + VStack { + HStack { + Spacer() + ZStack { + Circle() + .fill(Color.white.opacity(0.9)) + .frame(width: 28, height: 28) + + Image(systemName: "checkmark") + .font(.system(size: 14, weight: .bold)) + .foregroundColor(.black) + } + .padding(8) + } + Spacer() + } + } +} + +// MARK: - Preview + +#if DEBUG +struct GlassPosterView_Previews: PreviewProvider { + static var previews: some View { + VStack(spacing: 20) { + GlassPosterView( + imageUrl: "https://image.tmdb.org/t/p/w500/example.jpg", + aspectRatio: 10.0 / 15.0, + cornerRadius: 24, + progress: 45, + showWatchedIndicator: false, + isFocused: true, + width: 260 + ) + + GlassPosterView( + imageUrl: "https://image.tmdb.org/t/p/w500/example.jpg", + aspectRatio: 16.0 / 9.0, + cornerRadius: 24, + progress: 75, + showWatchedIndicator: true, + isFocused: false, + width: 400 + ) + } + .padding() + .background(Color.black) + } +} +#endif diff --git a/modules/glass-poster/src/GlassPoster.types.ts b/modules/glass-poster/src/GlassPoster.types.ts new file mode 100644 index 000000000..8878779b1 --- /dev/null +++ b/modules/glass-poster/src/GlassPoster.types.ts @@ -0,0 +1,26 @@ +import type { StyleProp, ViewStyle } from "react-native"; + +export interface GlassPosterViewProps { + /** URL of the image to display */ + imageUrl: string | null; + /** Aspect ratio of the poster (width/height). Default: 10/15 for portrait, 16/9 for landscape */ + aspectRatio: number; + /** Corner radius in points. Default: 24 */ + cornerRadius: number; + /** Progress percentage (0-100). Shows progress bar at bottom when > 0 */ + progress: number; + /** Whether to show the watched checkmark indicator */ + showWatchedIndicator: boolean; + /** Whether the poster is currently focused (for scale animation) */ + isFocused: boolean; + /** Width of the poster in points. Required for proper sizing. */ + width: number; + /** Style for the container view */ + style?: StyleProp; + /** Called when the image loads successfully */ + onLoad?: () => void; + /** Called when image loading fails */ + onError?: (error: string) => void; +} + +export type GlassPosterModuleEvents = Record; diff --git a/modules/glass-poster/src/GlassPosterModule.ts b/modules/glass-poster/src/GlassPosterModule.ts new file mode 100644 index 000000000..20c2714f3 --- /dev/null +++ b/modules/glass-poster/src/GlassPosterModule.ts @@ -0,0 +1,43 @@ +import { NativeModule, requireNativeModule } from "expo"; +import { Platform } from "react-native"; + +import type { GlassPosterModuleEvents } from "./GlassPoster.types"; + +declare class GlassPosterModuleType extends NativeModule { + isGlassEffectAvailable(): boolean; +} + +// Only load the native module on tvOS +let GlassPosterNativeModule: GlassPosterModuleType | null = null; + +if (Platform.OS === "ios" && Platform.isTV) { + try { + GlassPosterNativeModule = + requireNativeModule("GlassPoster"); + } catch { + // Module not available, will use fallback + } +} + +/** + * Check if the native glass effect is available (tvOS 26+) + * NOTE: Glass effect is currently disabled for performance reasons. + * The native module rebuilds views on every focus change which causes lag. + * Re-enable by uncommenting the native module check below. + */ +export function isGlassEffectAvailable(): boolean { + // Glass effect disabled - using JS-based focus effects instead + return false; + + // Original implementation (re-enable when glass effect is optimized): + // if (!GlassPosterNativeModule) { + // return false; + // } + // try { + // return GlassPosterNativeModule.isGlassEffectAvailable(); + // } catch { + // return false; + // } +} + +export default GlassPosterNativeModule; diff --git a/modules/glass-poster/src/GlassPosterView.tsx b/modules/glass-poster/src/GlassPosterView.tsx new file mode 100644 index 000000000..0ec104f5c --- /dev/null +++ b/modules/glass-poster/src/GlassPosterView.tsx @@ -0,0 +1,46 @@ +import { requireNativeView } from "expo"; +import * as React from "react"; +import { Platform, View } from "react-native"; + +import type { GlassPosterViewProps } from "./GlassPoster.types"; +import { isGlassEffectAvailable } from "./GlassPosterModule"; + +// Only require the native view on tvOS +let NativeGlassPosterView: React.ComponentType | null = + null; + +if (Platform.OS === "ios" && Platform.isTV) { + try { + NativeGlassPosterView = + requireNativeView("GlassPoster"); + } catch { + // Module not available + } +} + +/** + * GlassPosterView - Native SwiftUI glass effect poster for tvOS 26+ + * + * On tvOS 26+: Renders with native Liquid Glass effect + * On older tvOS: Renders with subtle glass-like material effect + * On other platforms: Returns null (use existing poster components) + */ +const GlassPosterView: React.FC = (props) => { + // Only render on tvOS + if (!Platform.isTV || Platform.OS !== "ios") { + return null; + } + + // Use native view if available + if (NativeGlassPosterView) { + return ; + } + + // Fallback: return empty view (caller should handle this) + return ; +}; + +export default GlassPosterView; + +// Re-export availability check for convenience +export { isGlassEffectAvailable }; diff --git a/modules/glass-poster/src/index.ts b/modules/glass-poster/src/index.ts new file mode 100644 index 000000000..eee2be164 --- /dev/null +++ b/modules/glass-poster/src/index.ts @@ -0,0 +1,6 @@ +export * from "./GlassPoster.types"; +export { + default as GlassPosterModule, + isGlassEffectAvailable, +} from "./GlassPosterModule"; +export { default as GlassPosterView } from "./GlassPosterView"; diff --git a/modules/index.ts b/modules/index.ts index b5d4a28b9..a48b4acac 100644 --- a/modules/index.ts +++ b/modules/index.ts @@ -1,17 +1,4 @@ -import type { - ChapterInfo, - PlaybackStatePayload, - ProgressUpdatePayload, - TrackInfo, - VideoLoadStartPayload, - VideoProgressPayload, - VideoStateChangePayload, - VlcPlayerSource, - VlcPlayerViewProps, - VlcPlayerViewRef, -} from "./VlcPlayer.types"; -import VlcPlayerView from "./VlcPlayerView"; - +// Background Downloader export type { ActiveDownload, DownloadCompleteEvent, @@ -20,23 +7,34 @@ export type { DownloadStartedEvent, StorageLocation, } from "./background-downloader"; -// Background Downloader export { default as BackgroundDownloader } from "./background-downloader"; - -// Component -export { VlcPlayerView }; - -// Component Types -export type { VlcPlayerViewProps, VlcPlayerViewRef }; - -// Media Types -export type { ChapterInfo, TrackInfo, VlcPlayerSource }; - -// Playback Events (alphabetically sorted) +// Glass Poster (tvOS 26+) +export type { GlassPosterViewProps } from "./glass-poster"; +export { GlassPosterView, isGlassEffectAvailable } from "./glass-poster"; +// MPV Player (iOS + Android) export type { - PlaybackStatePayload, - ProgressUpdatePayload, - VideoLoadStartPayload, - VideoProgressPayload, - VideoStateChangePayload, -}; + AudioTrack as MpvAudioTrack, + MpvPlayerViewProps, + MpvPlayerViewRef, + OnErrorEventPayload as MpvOnErrorEventPayload, + OnLoadEventPayload as MpvOnLoadEventPayload, + OnPlaybackStateChangePayload as MpvOnPlaybackStateChangePayload, + OnProgressEventPayload as MpvOnProgressEventPayload, + OnTracksReadyEventPayload as MpvOnTracksReadyEventPayload, + SubtitleTrack as MpvSubtitleTrack, + VideoSource as MpvVideoSource, +} from "./mpv-player"; +export { MpvPlayerView } from "./mpv-player"; +// Top Shelf cache (tvOS) +export type { + TopShelfCacheItem, + TopShelfCachePayload, + TopShelfCacheSection, +} from "./top-shelf-cache"; +export { clearTopShelfCache, writeTopShelfCache } from "./top-shelf-cache"; +// TV recommendations (Android TV) +export { + clearTvRecommendations, + refreshTvRecommendations, + syncTvRecommendations, +} from "./tv-recommendations"; diff --git a/modules/mpv-player/android/build.gradle b/modules/mpv-player/android/build.gradle new file mode 100644 index 000000000..affa53219 --- /dev/null +++ b/modules/mpv-player/android/build.gradle @@ -0,0 +1,57 @@ +apply plugin: 'com.android.library' + +group = 'expo.modules.mpvplayer' +version = '0.7.6' + +def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle") +apply from: expoModulesCorePlugin +applyKotlinExpoModulesCorePlugin() +useCoreDependencies() +useExpoPublishing() + +// If you want to use the managed Android SDK versions from expo-modules-core, set this to true. +// The Android SDK versions will be bumped from time to time in SDK releases and may introduce breaking changes in your module code. +// Most of the time, you may like to manage the Android SDK versions yourself. +def useManagedAndroidSdkVersions = false +if (useManagedAndroidSdkVersions) { + useDefaultAndroidSdkVersions() +} else { + buildscript { + // Simple helper that allows the root project to override versions declared by this library. + ext.safeExtGet = { prop, fallback -> + rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback + } + } + project.android { + compileSdkVersion safeExtGet("compileSdkVersion", 36) + defaultConfig { + minSdkVersion safeExtGet("minSdkVersion", 26) + targetSdkVersion safeExtGet("targetSdkVersion", 36) + } + } +} + +android { + namespace "expo.modules.mpvplayer" + defaultConfig { + versionCode 1 + versionName "0.7.6" + ndk { + // Architectures supported by mpv-android + abiFilters 'arm64-v8a', 'armeabi-v7a', 'x86', 'x86_64' + } + } + lintOptions { + abortOnError false + } + sourceSets { + main { + jniLibs.srcDirs = ['libs'] + } + } +} + +dependencies { + // libmpv from Maven Central + implementation 'dev.jdtech.mpv:libmpv:1.0.0' +} diff --git a/modules/mpv-player/android/src/main/AndroidManifest.xml b/modules/mpv-player/android/src/main/AndroidManifest.xml new file mode 100644 index 000000000..c6f2e479e --- /dev/null +++ b/modules/mpv-player/android/src/main/AndroidManifest.xml @@ -0,0 +1,9 @@ + + + + + + + diff --git a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MPVLayerRenderer.kt b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MPVLayerRenderer.kt new file mode 100644 index 000000000..6b41a621a --- /dev/null +++ b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MPVLayerRenderer.kt @@ -0,0 +1,878 @@ +package expo.modules.mpvplayer + +import android.app.UiModeManager +import android.content.Context +import android.content.res.Configuration +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.system.Os +import android.util.Log +import android.view.Surface +import java.io.File +import java.util.Locale + +/** + * MPV renderer that wraps libmpv for video playback. + * This mirrors the iOS MPVLayerRenderer implementation. + */ +class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver { + + companion object { + private const val TAG = "MPVLayerRenderer" + + // Property observation format types + const val MPV_FORMAT_NONE = 0 + const val MPV_FORMAT_STRING = 1 + const val MPV_FORMAT_OSD_STRING = 2 + const val MPV_FORMAT_FLAG = 3 + const val MPV_FORMAT_INT64 = 4 + const val MPV_FORMAT_DOUBLE = 5 + const val MPV_FORMAT_NODE = 6 + } + + private fun isTvDevice(): Boolean { + val uiModeManager = context.getSystemService(Context.UI_MODE_SERVICE) as UiModeManager + return uiModeManager.currentModeType == Configuration.UI_MODE_TYPE_TELEVISION + } + + /** + * True only on the Android emulator. Its goldfish/ranchu MediaCodec can't bind a + * decode output surface (decode opens with surface 0x0): HEVC then fails cleanly and + * mpv auto-falls-back to software, but H.264 "opens" deceptively and wedges the core + * (no fallback) — black video, then any command (seek/pause) deadlocks the UI thread + * → ANR. We force software decoding here. + * + * Only QEMU/SDK-exclusive signals are checked so a real device can never match — a + * false positive would needlessly drop shipping hardware to software decoding. The + * emulator reports ro.hardware=goldfish|ranchu, an sdk_* product, or a generic/ + * emulator build fingerprint, none of which appear on real devices. + */ + private fun isEmulator(): Boolean { + val hardware = Build.HARDWARE.lowercase() + if (hardware == "goldfish" || hardware == "ranchu") return true + + val product = Build.PRODUCT + if (product == "sdk" || product.startsWith("sdk_")) return true + + val fingerprint = Build.FINGERPRINT + return fingerprint.startsWith("generic") || + fingerprint.contains("emulator", ignoreCase = true) + } + + interface Delegate { + fun onPositionChanged(position: Double, duration: Double, cacheSeconds: Double) + fun onPauseChanged(isPaused: Boolean) + fun onLoadingChanged(isLoading: Boolean) + fun onReadyToSeek() + fun onTracksReady() + fun onError(message: String) + fun onVideoDimensionsChanged(width: Int, height: Int) + } + + var delegate: Delegate? = null + + private val mainHandler = Handler(Looper.getMainLooper()) + + private var surface: Surface? = null + private var isRunning = false + + // This renderer's own mpv handle. Per-instance (not singleton) — each + // player screen gets a fresh mpv handle and drops the reference on stop. + // We intentionally do NOT call a destroy() equivalent: libmpv 1.0's + // nativeDestroy has an internal use-after-free we can't fix from Kotlin, + // so we mirror Findroid and let the JVM GC + native finalization path + // reclaim resources. Only one player is alive at a time in this app. + private var mpv: MPVLib? = null + + // Cached state + private var cachedPosition: Double = 0.0 + private var cachedDuration: Double = 0.0 + private var cachedCacheSeconds: Double = 0.0 + private var _isPaused: Boolean = true + private var _isLoading: Boolean = false + private var _playbackSpeed: Double = 1.0 + private var isReadyToSeek: Boolean = false + + // Progress update throttling - CRITICAL for performance! + // DO NOT REMOVE THIS THROTTLE - it is essential for battery life and CPU efficiency. + // + // Without throttling, time-pos fires every video frame (24+ times/sec at 24fps). + // Each update crosses the React Native JS bridge, which is expensive on mobile. + // Even if the JS side does nothing, 24+ bridge calls/sec wastes CPU and battery. + // + // Throttling to 1 update/sec during normal playback is sufficient for: + // - Progress bar updates (users can't perceive 1-second granularity) + // - Playback position tracking + // - Any JS-side logic that needs current position + // + // During seeking, we bypass the throttle for responsive scrubbing. + // This optimization reduced CPU usage by ~50% for downloaded file playback. + private var lastProgressUpdateTime: Long = 0 + private var _isSeeking: Boolean = false + + // Video dimensions + private var _videoWidth: Int = 0 + private var _videoHeight: Int = 0 + + val videoWidth: Int + get() = _videoWidth + + val videoHeight: Int + get() = _videoHeight + + // Current video config + private var currentUrl: String? = null + private var currentHeaders: Map? = null + private var pendingExternalSubtitles: List = emptyList() + private var initialSubtitleId: Int? = null + private var initialAudioId: Int? = null + + val isPausedState: Boolean + get() = _isPaused + + val currentPosition: Double + get() = cachedPosition + + val duration: Double + get() = cachedDuration + + /** + * The VO driver to use. Stored so attachSurface can re-enable the same driver. + */ + private var voDriver: String = "gpu-next" + + fun start(voDriver: String = "gpu-next") { + if (isRunning) return + + try { + // Per-instance handle — see class-level comment. Each player gets + // its own mpv; we drop the reference in stop(). + val mpv = MPVLib.create(context) + this.mpv = mpv + mpv.addObserver(this) + + // Resolved once — TV gets the memory-pressure customizations + // (SCUDO_OPTIONS, hwdec/profile, demuxer-seekable-cache, larger + // audio-buffer) that would be counterproductive on higher-RAM + // mobile devices. Demuxer cache sizes are NOT included here — + // those come from user settings via load(). + val isTV = isTvDevice() + + // mpv config directory — used by the config-dir option below and + // as XDG_CONFIG_HOME for fontconfig. + val mpvDir = File(context.getExternalFilesDir(null) ?: context.filesDir, "mpv") + if (!mpvDir.exists()) mpvDir.mkdirs() + + // Point fontconfig (new in libmpv 1.0) at writable app dirs so it + // persists its font index across runs instead of re-walking + // /system/fonts on every subtitle/seek event. Each rebuild costs + // ~1-2 s and ~10-30 MB of scudo:primary memory that scudo then + // holds onto. Without this we see "No usable fontconfig + // configuration file found, using fallback" on every re-init. + try { + val cacheDir = context.cacheDir.absolutePath + val configDir = (context.getExternalFilesDir(null) ?: context.filesDir).absolutePath + Os.setenv("XDG_CACHE_HOME", cacheDir, true) + Os.setenv("XDG_CONFIG_HOME", configDir, true) + Os.setenv("HOME", configDir, true) + } catch (e: Exception) { + Log.w(TAG, "Could not set XDG/HOME env for fontconfig: ${e.message}") + } + + mpv?.setOptionString("config", "yes") + mpv?.setOptionString("config-dir", mpvDir.path) + + // Configure mpv options before initialization (based on Findroid) + this.voDriver = voDriver + mpv?.setOptionString("vo", voDriver) + mpv?.setOptionString("gpu-context", "android") + mpv?.setOptionString("opengl-es", "yes") + + // Hardware decoder codecs (shared) + mpv?.setOptionString("hwdec-codecs", "h264,hevc,mpeg4,mpeg2video,vp8,vp9,av1") + + // Pause on initial cache fill (shared default). The actual + // cache mode, cache-secs, and demuxer cache sizes come from + // user preferences and are applied per-load in load(). + mpv?.setOptionString("cache-pause-initial", "yes") + + // Hardware decode path + TV-only memory options. Demuxer cache + // sizes and cache-secs are NOT set here — they come from user + // preferences via load(). + // - Emulator: software decode. Its MediaCodec can't bind an + // output surface (surface 0x0); HEVC then fails cleanly and + // mpv auto-falls-back to software, but H.264 "opens" + // deceptively and wedges the core with no fallback (black + // video, then any command — seek/pause — deadlocks the UI + // thread → ANR). hwdec=no makes every codec render via the + // gpu-next VO. Real devices unaffected. + // - Real TV hardware: zero-copy `mediacodec` (fastest on + // low-power devices) + fast profile. + // - Real phone: `mediacodec-copy` (broadest compatibility). + when { + isEmulator() -> mpv?.setOptionString("hwdec", "no") + isTV -> { + mpv?.setOptionString("hwdec", "mediacodec") + mpv?.setOptionString("profile", "fast") + // Don't retain already-played content for backward + // seeking over a network source — Jellyfin can re-fetch + // on demand. Saves up to ~30 MiB on long seeks and + // reduces swap pressure. + mpv?.setOptionString("demuxer-seekable-cache", "no") + // Larger audio buffer to absorb page-fault stalls + // (default ~0.2s). Cheap insurance against the audio + // underruns that happen when the kernel is swap-thrashing. + mpv?.setOptionString("audio-buffer", "0.5") + } + else -> mpv?.setOptionString("hwdec", "mediacodec-copy") + } + + // Seeking optimization - faster seeking at the cost of less precision + // Use keyframe seeking by default (much faster for network streams) + mpv?.setOptionString("hr-seek", "no") + // Drop frames during seeking for faster response + mpv?.setOptionString("hr-seek-framedrop", "yes") + + // Subtitle settings + mpv?.setOptionString("sub-scale-with-window", "no") + mpv?.setOptionString("sub-use-margins", "no") + mpv?.setOptionString("subs-match-os-language", "yes") + mpv?.setOptionString("subs-fallback", "yes") + + // Important: Start with force-window=no, will be set to yes when surface is attached + mpv?.setOptionString("force-window", "no") + mpv?.setOptionString("keep-open", "always") + + mpv.initialize() + + // Observe properties + observeProperties() + + isRunning = true + Log.i(TAG, "MPV renderer started") + } catch (e: Exception) { + Log.e(TAG, "Failed to start MPV renderer: ${e.message}") + delegate?.onError("Failed to start renderer: ${e.message}") + } + } + + fun stop() { + if (!isRunning) return + isRunning = false + + val m = mpv + mpv = null + + // Clear cached media state on the main thread so the next player + // screen doesn't observe stale position/duration values during the + // (async) teardown below. + currentUrl = null + currentHeaders = null + pendingExternalSubtitles = emptyList() + initialSubtitleId = null + initialAudioId = null + cachedPosition = 0.0 + cachedDuration = 0.0 + cachedCacheSeconds = 0.0 + + if (m == null) return + + // Teardown runs on a background daemon thread. mpv's "stop" command + // flushes the demuxer queue and releases the MediaCodec hardware + // decoder — synchronous JNI work that can block for hundreds of ms + // on TV hardware. Running it on the main thread produced a visible + // delay/stutter between pressing "exit" and the confirm alert + // appearing. The local `m` keeps the MPVLib instance alive for the + // lifetime of this thread even though we've already nulled `mpv`. + Thread { + // Drop force-window BEFORE issuing stop. With keep-open=always + + // force-window=yes, mpv tears down the decoder at stop time but + // tries to keep the VO alive — which fires an internal + // video-reconfig. On libmpv 1.0's gpu-next/android backend that + // reconfig path crashes with "Missing surface pointer" because we + // detach the Surface below before mpv's worker reaches the + // reconfig step (command() is async). Setting force-window=no + // first makes mpv tear VO down cleanly instead of attempting a + // doomed re-init, eliminating the fatal VO error and the + // "playback won't restart" aftermath. + try { + m.setOptionString("force-window", "no") + } catch (e: Exception) { + Log.e(TAG, "Error clearing force-window: ${e.message}") + } + try { + // Stop playback — flushes demuxer queue and signals MediaCodec + // to release its hardware decoders. This is the bulk of what + // we can reclaim without calling destroy(). + m.command(arrayOf("stop")) + } catch (e: Exception) { + Log.e(TAG, "Error stopping mpv playback: ${e.message}") + } + try { + m.removeObserver(this) + } catch (e: Exception) { + Log.e(TAG, "Error removing mpv observer: ${e.message}") + } + try { + m.detachSurface() + } catch (e: Exception) { + Log.e(TAG, "Error detaching mpv surface: ${e.message}") + } + }.also { it.isDaemon = true }.start() + } + + /** + * Attach surface and ensure video output is active. + * + * During PiP transitions, the surface is destroyed and recreated by Android. + * We keep the VO pipeline alive (not killed with vo=null) so that rendering + * resumes immediately when the new surface is attached — avoiding the black + * screen that occurs when the VO is fully re-initialized via setOptionString. + */ + fun attachSurface(surface: Surface) { + this.surface = surface + Log.i(TAG, "[PiP] attachSurface — isRunning=$isRunning, vo=$voDriver, surface=${surface.hashCode()}") + if (isRunning) { + mpv?.attachSurface(surface) + mpv?.setOptionString("force-window", "yes") + // Read back vo to confirm it's still active + val activeVo = try { mpv?.getPropertyString("vo") } catch (e: Exception) { null } + Log.i(TAG, "[PiP] attachSurface — attached, activeVo=$activeVo") + } + } + + /** + * Detach surface without killing the VO pipeline. + * + * The previous approach (vo=null / force-window=no) destroyed the entire video + * output pipeline on every surface transition. During PiP mode, the rapid + * destroy/recreate cycle caused a black screen because setOptionString("vo", ...) + * did not properly re-initialize rendering into the new PiP surface. + * + * By keeping the VO alive, frames are simply dropped while no surface is + * attached, and rendering resumes immediately when the new surface arrives. + */ + fun detachSurface() { + this.surface = null + Log.i(TAG, "[PiP] detachSurface — isRunning=$isRunning, vo=$voDriver") + if (isRunning) { + mpv?.detachSurface() + val activeVo = try { mpv?.getPropertyString("vo") } catch (e: Exception) { null } + Log.i(TAG, "[PiP] detachSurface — detached, activeVo=$activeVo (should still be $voDriver)") + } + } + + /** + * Updates the surface size. Called from surfaceChanged. + * Based on Findroid's implementation. + */ + fun updateSurfaceSize(width: Int, height: Int) { + if (isRunning) { + mpv?.setPropertyString("android-surface-size", "${width}x$height") + Log.i(TAG, "[PiP] updateSurfaceSize — ${width}x${height}") + } else { + Log.w(TAG, "[PiP] updateSurfaceSize — called but renderer not running") + } + } + + /** + * Force mpv to render a frame to the current surface. + * Steps forward one frame then seeks back to the original position. + * Used after PiP entry to work around mpv stopping pixel output. + */ + fun forceRedraw() { + if (!isRunning) return + val pos = cachedPosition + Log.i(TAG, "[PiP] forceRedraw — stepping frame then seeking to $pos") + mpv?.command(arrayOf("frame-step")) + if (pos > 0) { + mpv?.command(arrayOf("seek", pos.toString(), "absolute")) + } + } + + fun load( + url: String, + headers: Map? = null, + startPosition: Double? = null, + externalSubtitles: List? = null, + initialSubtitleId: Int? = null, + initialAudioId: Int? = null, + cacheEnabled: String? = null, + cacheSeconds: Int? = null, + demuxerMaxBytes: Int? = null, + demuxerMaxBackBytes: Int? = null + ) { + currentUrl = url + currentHeaders = headers + pendingExternalSubtitles = externalSubtitles ?: emptyList() + this.initialSubtitleId = initialSubtitleId + this.initialAudioId = initialAudioId + + _isLoading = true + isReadyToSeek = false + mainHandler.post { delegate?.onLoadingChanged(true) } + + // Stop previous playback + mpv?.command(arrayOf("stop")) + + // Set HTTP headers if provided + updateHttpHeaders(headers) + + // Apply cache/buffer settings from user preferences (mirrors iOS). + // These override the conservative defaults applied in start() so the + // TV/mobile settings screen actually takes effect on Android. + cacheEnabled?.let { mpv?.setOptionString("cache", it) } + cacheSeconds?.let { mpv?.setOptionString("cache-secs", it.toString()) } + demuxerMaxBytes?.let { mpv?.setOptionString("demuxer-max-bytes", "${it}MiB") } + demuxerMaxBackBytes?.let { mpv?.setOptionString("demuxer-max-back-bytes", "${it}MiB") } + + // Set start position. mpv's time parser requires '.' as the decimal + // separator; use Locale.US so devices with other default locales + // (e.g. ',' as decimal separator) don't break resume-from-position. + if (startPosition != null && startPosition > 0) { + mpv?.setPropertyString("start", String.format(Locale.US, "%.2f", startPosition)) + } else { + mpv?.setPropertyString("start", "0") + } + + // Set initial audio track if specified + if (initialAudioId != null && initialAudioId > 0) { + setAudioTrack(initialAudioId) + } + + // Set initial subtitle track if no external subs + if (pendingExternalSubtitles.isEmpty()) { + if (initialSubtitleId != null) { + setSubtitleTrack(initialSubtitleId) + } else { + disableSubtitles() + } + } else { + disableSubtitles() + } + + // Load the file + mpv?.command(arrayOf("loadfile", url, "replace")) + } + + fun reloadCurrentItem() { + currentUrl?.let { url -> + load(url, currentHeaders) + } + } + + private fun updateHttpHeaders(headers: Map?) { + if (headers.isNullOrEmpty()) { + // Clear headers + return + } + + val headerString = headers.entries.joinToString("\r\n") { "${it.key}: ${it.value}" } + mpv?.setPropertyString("http-header-fields", headerString) + } + + private fun observeProperties() { + mpv?.observeProperty("duration", MPV_FORMAT_DOUBLE) + mpv?.observeProperty("time-pos", MPV_FORMAT_DOUBLE) + mpv?.observeProperty("pause", MPV_FORMAT_FLAG) + mpv?.observeProperty("track-list/count", MPV_FORMAT_INT64) + mpv?.observeProperty("paused-for-cache", MPV_FORMAT_FLAG) + mpv?.observeProperty("demuxer-cache-duration", MPV_FORMAT_DOUBLE) + // Video dimensions for PiP aspect ratio + mpv?.observeProperty("video-params/w", MPV_FORMAT_INT64) + mpv?.observeProperty("video-params/h", MPV_FORMAT_INT64) + } + + // MARK: - Playback Controls + + fun play() { + mpv?.setPropertyBoolean("pause", false) + } + + fun pause() { + mpv?.setPropertyBoolean("pause", true) + } + + fun togglePause() { + if (_isPaused) play() else pause() + } + + fun seekTo(seconds: Double) { + val clamped = maxOf(0.0, seconds) + cachedPosition = clamped + mpv?.command(arrayOf("seek", clamped.toString(), "absolute")) + } + + fun seekBy(seconds: Double) { + val newPosition = maxOf(0.0, cachedPosition + seconds) + cachedPosition = newPosition + mpv?.command(arrayOf("seek", seconds.toString(), "relative")) + } + + fun setSpeed(speed: Double) { + _playbackSpeed = speed + mpv?.setPropertyDouble("speed", speed) + } + + fun getSpeed(): Double { + return mpv?.getPropertyDouble("speed") ?: _playbackSpeed + } + + // MARK: - Subtitle Controls + + fun getSubtitleTracks(): List> { + val tracks = mutableListOf>() + + val trackCount = mpv?.getPropertyInt("track-list/count") ?: 0 + + for (i in 0 until trackCount) { + val trackType = mpv?.getPropertyString("track-list/$i/type") ?: continue + if (trackType != "sub") continue + + val trackId = mpv?.getPropertyInt("track-list/$i/id") ?: continue + val track = mutableMapOf("id" to trackId) + + mpv?.getPropertyString("track-list/$i/title")?.let { track["title"] = it } + mpv?.getPropertyString("track-list/$i/lang")?.let { track["lang"] = it } + + val selected = mpv?.getPropertyBoolean("track-list/$i/selected") ?: false + track["selected"] = selected + + tracks.add(track) + } + + return tracks + } + + fun setSubtitleTrack(trackId: Int) { + Log.i(TAG, "setSubtitleTrack: setting sid to $trackId") + if (trackId < 0) { + mpv?.setPropertyString("sid", "no") + } else { + mpv?.setPropertyInt("sid", trackId) + } + } + + fun disableSubtitles() { + mpv?.setPropertyString("sid", "no") + } + + fun getCurrentSubtitleTrack(): Int { + return mpv?.getPropertyInt("sid") ?: 0 + } + + fun addSubtitleFile(url: String, select: Boolean = true) { + val flag = if (select) "select" else "cached" + mpv?.command(arrayOf("sub-add", url, flag)) + } + + // MARK: - Subtitle Positioning + + fun setSubtitlePosition(position: Int) { + mpv?.setPropertyInt("sub-pos", position) + } + + fun setSubtitleScale(scale: Double) { + mpv?.setPropertyDouble("sub-scale", scale) + } + + fun setSubtitleMarginY(margin: Int) { + mpv?.setPropertyInt("sub-margin-y", margin) + } + + fun setSubtitleAlignX(alignment: String) { + mpv?.setPropertyString("sub-align-x", alignment) + } + + fun setSubtitleAlignY(alignment: String) { + mpv?.setPropertyString("sub-align-y", alignment) + } + + fun setSubtitleFontSize(size: Int) { + mpv?.setPropertyInt("sub-font-size", size) + } + + fun setSubtitleBorderStyle(style: String) { + mpv?.setPropertyString("sub-border-style", style) + } + + fun setSubtitleBackgroundColor(color: String) { + mpv?.setPropertyString("sub-back-color", color) + } + + fun setSubtitleAssOverride(mode: String) { + mpv?.setPropertyString("sub-ass-override", mode) + } + + // MARK: - Audio Track Controls + + fun getAudioTracks(): List> { + val tracks = mutableListOf>() + + val trackCount = mpv?.getPropertyInt("track-list/count") ?: 0 + + for (i in 0 until trackCount) { + val trackType = mpv?.getPropertyString("track-list/$i/type") ?: continue + if (trackType != "audio") continue + + val trackId = mpv?.getPropertyInt("track-list/$i/id") ?: continue + val track = mutableMapOf("id" to trackId) + + mpv?.getPropertyString("track-list/$i/title")?.let { track["title"] = it } + mpv?.getPropertyString("track-list/$i/lang")?.let { track["lang"] = it } + mpv?.getPropertyString("track-list/$i/codec")?.let { track["codec"] = it } + + val channels = mpv?.getPropertyInt("track-list/$i/audio-channels") + if (channels != null && channels > 0) { + track["channels"] = channels + } + + val selected = mpv?.getPropertyBoolean("track-list/$i/selected") ?: false + track["selected"] = selected + + tracks.add(track) + } + + return tracks + } + + fun setAudioTrack(trackId: Int) { + Log.i(TAG, "setAudioTrack: setting aid to $trackId") + mpv?.setPropertyInt("aid", trackId) + } + + fun getCurrentAudioTrack(): Int { + return mpv?.getPropertyInt("aid") ?: 0 + } + + // MARK: - Video Scaling + + fun setZoomedToFill(zoomed: Boolean) { + // panscan: 0.0 = fit (letterbox), 1.0 = fill (crop) + val panscanValue = if (zoomed) 1.0 else 0.0 + Log.i(TAG, "setZoomedToFill: setting panscan to $panscanValue") + mpv?.setPropertyDouble("panscan", panscanValue) + } + + // MARK: - Technical Info + + fun getTechnicalInfo(): Map { + val info = mutableMapOf() + + // Video dimensions + mpv?.getPropertyInt("video-params/w")?.takeIf { it > 0 }?.let { + info["videoWidth"] = it + } + mpv?.getPropertyInt("video-params/h")?.takeIf { it > 0 }?.let { + info["videoHeight"] = it + } + + // Video codec + mpv?.getPropertyString("video-format")?.let { + info["videoCodec"] = it + } + + // Audio codec + mpv?.getPropertyString("audio-codec-name")?.let { + info["audioCodec"] = it + } + + // FPS (container fps) + mpv?.getPropertyDouble("container-fps")?.takeIf { it > 0 }?.let { + info["fps"] = it + } + + // Video bitrate (bits per second) + mpv?.getPropertyInt("video-bitrate")?.takeIf { it > 0 }?.let { + info["videoBitrate"] = it + } + + // Audio bitrate (bits per second) + mpv?.getPropertyInt("audio-bitrate")?.takeIf { it > 0 }?.let { + info["audioBitrate"] = it + } + + // Demuxer cache duration (seconds of video buffered) + mpv?.getPropertyDouble("demuxer-cache-duration")?.let { + info["cacheSeconds"] = it + } + + // Configured cache limits — read back from mpv to confirm user + // settings actually took effect. mpv stores byte sizes as int64 + // (bytes); convert to MiB for display. + mpv?.getPropertyInt("demuxer-max-bytes")?.let { bytes -> + info["demuxerMaxBytes"] = bytes / (1024 * 1024) + } + mpv?.getPropertyInt("demuxer-max-back-bytes")?.let { bytes -> + info["demuxerMaxBackBytes"] = bytes / (1024 * 1024) + } + mpv?.getPropertyDouble("cache-secs")?.let { secs -> + info["cacheSecsLimit"] = secs + } + + // Dropped frames + mpv?.getPropertyInt("frame-drop-count")?.let { + info["droppedFrames"] = it + } + + // Active video output driver (read from MPV to confirm what's actually applied) + mpv?.getPropertyString("vo")?.let { + info["voDriver"] = it + } + + // Active hardware decoder. + // hwdec-current yields e.g. "mediacodec", + // "mediacodec-copy", "auto-copy" or empty when SW decoding. + mpv?.getPropertyString("hwdec-current")?.let { + info["hwdec"] = it + } + + // Estimated video output fps (renderer-side, after filtering). + // Useful for diagnosing display/pipeline drops vs container fps. + mpv?.getPropertyDouble("estimated-vf-fps")?.takeIf { it > 0 }?.let { + info["estimatedVfFps"] = it + } + + return info + } + + // MARK: - MPVLib.EventObserver + + override fun eventProperty(property: String) { + // Property changed but no value provided + } + + override fun eventProperty(property: String, value: Long) { + when (property) { + "track-list/count" -> { + if (value > 0) { + Log.i(TAG, "Track list updated: $value tracks available") + mainHandler.post { delegate?.onTracksReady() } + } + } + "video-params/w" -> { + val width = value.toInt() + if (width > 0 && width != _videoWidth) { + _videoWidth = width + notifyVideoDimensionsIfReady() + } + } + "video-params/h" -> { + val height = value.toInt() + if (height > 0 && height != _videoHeight) { + _videoHeight = height + notifyVideoDimensionsIfReady() + } + } + } + } + + private fun notifyVideoDimensionsIfReady() { + if (_videoWidth > 0 && _videoHeight > 0) { + Log.i(TAG, "Video dimensions: ${_videoWidth}x${_videoHeight}") + mainHandler.post { delegate?.onVideoDimensionsChanged(_videoWidth, _videoHeight) } + } + } + + override fun eventProperty(property: String, value: Boolean) { + when (property) { + "pause" -> { + if (value != _isPaused) { + _isPaused = value + mainHandler.post { delegate?.onPauseChanged(value) } + } + } + "paused-for-cache" -> { + if (value != _isLoading) { + _isLoading = value + mainHandler.post { delegate?.onLoadingChanged(value) } + } + } + } + } + + override fun eventProperty(property: String, value: String) { + // Handle string properties if needed + } + + override fun eventProperty(property: String, value: Double) { + when (property) { + "duration" -> { + cachedDuration = value + mainHandler.post { delegate?.onPositionChanged(cachedPosition, cachedDuration, cachedCacheSeconds) } + } + "time-pos" -> { + cachedPosition = value + // Always update immediately when seeking, otherwise throttle to once per second + val now = System.currentTimeMillis() + val shouldUpdate = _isSeeking || (now - lastProgressUpdateTime >= 1000) + if (shouldUpdate) { + lastProgressUpdateTime = now + mainHandler.post { delegate?.onPositionChanged(cachedPosition, cachedDuration, cachedCacheSeconds) } + } + } + "demuxer-cache-duration" -> { + cachedCacheSeconds = value + } + } + } + + override fun event(eventId: Int) { + when (eventId) { + MPVLib.MPV_EVENT_FILE_LOADED -> { + // Add external subtitles now that file is loaded + if (pendingExternalSubtitles.isNotEmpty()) { + pendingExternalSubtitles.forEachIndexed { index, subUrl -> + android.util.Log.d("MPVRenderer", "Adding external subtitle [$index]: $subUrl") + // "auto" flag = add without auto-selecting (order preserved, MPVLib.command is sync) + mpv?.command(arrayOf("sub-add", subUrl, "auto")) + } + pendingExternalSubtitles = emptyList() + } + + // Apply the initial audio/subtitle selection now that the file's + // tracks are enumerated. Setting sid/aid before `loadfile` does not + // reliably stick for embedded tracks (the selection is silently + // dropped), so we (re)apply here for embedded and external alike. + // This is what makes a carried-over subtitle show up on the next + // episode without a manual re-selection. + initialAudioId?.let { if (it > 0) setAudioTrack(it) } + initialSubtitleId?.let { setSubtitleTrack(it) } ?: disableSubtitles() + + if (!isReadyToSeek) { + isReadyToSeek = true + mainHandler.post { delegate?.onReadyToSeek() } + } + + if (_isLoading) { + _isLoading = false + mainHandler.post { delegate?.onLoadingChanged(false) } + } + } + MPVLib.MPV_EVENT_SEEK -> { + // Seek started - show loading indicator and enable immediate progress updates + _isSeeking = true + if (!_isLoading) { + _isLoading = true + mainHandler.post { delegate?.onLoadingChanged(true) } + } + } + MPVLib.MPV_EVENT_PLAYBACK_RESTART -> { + // Video playback has started/restarted (including after seek) + _isSeeking = false + if (_isLoading) { + _isLoading = false + mainHandler.post { delegate?.onLoadingChanged(false) } + } + } + MPVLib.MPV_EVENT_END_FILE -> { + Log.i(TAG, "Playback ended") + } + MPVLib.MPV_EVENT_SHUTDOWN -> { + Log.w(TAG, "MPV shutdown") + } + } + } +} + diff --git a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MPVLib.kt b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MPVLib.kt new file mode 100644 index 000000000..5f947c285 --- /dev/null +++ b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MPVLib.kt @@ -0,0 +1,175 @@ +package expo.modules.mpvplayer + +import android.content.Context +import dev.jdtech.mpv.MPVLib as LibMPV + +/** + * Per-instance wrapper around the dev.jdtech.mpv.MPVLib class. + * + * libmpv 1.0 exposes an instance-based API: each `LibMPV.create(ctx)` returns + * a fresh, independent handle. Each player creates its own MPVLib instance + * (Findroid pattern) and on teardown we simply drop the reference. We do NOT + * call `LibMPV.destroy()` — its native implementation has an internal + * use-after-free on libmpv 1.0 that we cannot fix from Kotlin. Letting the + * GC reach the JVM-level finalizer (or never reaching it, since the native + * handle lives in process-global state until exit) is strictly safer than + * crashing. + * + * Trade-off: mpv's native footprint (decoder + demuxer cache) for one player + * stays allocated until the next player's allocation displaces it in scudo's + * arena. On a TV app where the player is the dominant memory consumer and + * only one player is alive at a time, this is acceptable. + */ +class MPVLib private constructor(private val instance: LibMPV) { + + // Event observer interface — mirrors dev.jdtech.mpv.MPVLib.EventObserver + // so MPVLayerRenderer implements a stable, wrapper-owned signature. + interface EventObserver { + fun eventProperty(property: String) + fun eventProperty(property: String, value: Long) + fun eventProperty(property: String, value: Boolean) + fun eventProperty(property: String, value: String) + fun eventProperty(property: String, value: Double) + fun event(eventId: Int) + } + + private val observers = mutableListOf() + + // Library event observer that forwards LibMPV callbacks to our observers. + private val libObserver = object : LibMPV.EventObserver { + override fun eventProperty(property: String) = + dispatch { it.eventProperty(property) } + + override fun eventProperty(property: String, value: Long) = + dispatch { it.eventProperty(property, value) } + + override fun eventProperty(property: String, value: Boolean) = + dispatch { it.eventProperty(property, value) } + + override fun eventProperty(property: String, value: String) = + dispatch { it.eventProperty(property, value) } + + override fun eventProperty(property: String, value: Double) = + dispatch { it.eventProperty(property, value) } + + override fun event(eventId: Int) = + dispatch { it.event(eventId) } + + private inline fun dispatch(block: (EventObserver) -> Unit) { + synchronized(observers) { + observers.forEach(block) + } + } + } + + fun addObserver(observer: EventObserver) { + synchronized(observers) { observers.add(observer) } + } + + fun removeObserver(observer: EventObserver) { + synchronized(observers) { observers.remove(observer) } + } + + fun initialize() { + instance.init() + } + + fun attachSurface(surface: android.view.Surface) { + instance.attachSurface(surface) + } + + fun detachSurface() { + instance.detachSurface() + } + + fun command(cmd: Array) { + instance.command(cmd) + } + + fun setOptionString(name: String, value: String): Int { + return instance.setOptionString(name, value) + } + + fun getPropertyInt(name: String): Int? = try { + instance.getPropertyInt(name) + } catch (e: Exception) { null } + + fun getPropertyDouble(name: String): Double? = try { + instance.getPropertyDouble(name) + } catch (e: Exception) { null } + + fun getPropertyBoolean(name: String): Boolean? = try { + instance.getPropertyBoolean(name) + } catch (e: Exception) { null } + + fun getPropertyString(name: String): String? = try { + instance.getPropertyString(name) + } catch (e: Exception) { null } + + fun setPropertyInt(name: String, value: Int) { + instance.setPropertyInt(name, value) + } + + fun setPropertyDouble(name: String, value: Double) { + instance.setPropertyDouble(name, value) + } + + fun setPropertyBoolean(name: String, value: Boolean) { + instance.setPropertyBoolean(name, value) + } + + fun setPropertyString(name: String, value: String) { + instance.setPropertyString(name, value) + } + + fun observeProperty(name: String, format: Int) { + instance.observeProperty(name, format) + } + + companion object { + /** + * Create a fresh mpv handle. Each call returns an independent instance — + * do not share across players. Attach exactly one [EventObserver] per + * player via [addObserver]. + */ + fun create(context: Context): MPVLib { + val lib = LibMPV.create(context) + ?: throw IllegalStateException("LibMPV.create returned null") + val wrapper = MPVLib(lib) + // The libObserver is attached for the lifetime of this MPVLib + // instance and forwards every LibMPV callback to our observers + // list. Player-specific observers are added/removed via + // addObserver/removeObserver. + lib.addObserver(wrapper.libObserver) + return wrapper + } + + // MPV Event IDs (kept here so observers can reference them without + // holding a reference to an instance). + const val MPV_EVENT_NONE = 0 + const val MPV_EVENT_SHUTDOWN = 1 + const val MPV_EVENT_LOG_MESSAGE = 2 + const val MPV_EVENT_GET_PROPERTY_REPLY = 3 + const val MPV_EVENT_SET_PROPERTY_REPLY = 4 + const val MPV_EVENT_COMMAND_REPLY = 5 + const val MPV_EVENT_START_FILE = 6 + const val MPV_EVENT_END_FILE = 7 + const val MPV_EVENT_FILE_LOADED = 8 + const val MPV_EVENT_IDLE = 11 + const val MPV_EVENT_TICK = 14 + const val MPV_EVENT_CLIENT_MESSAGE = 16 + const val MPV_EVENT_VIDEO_RECONFIG = 17 + const val MPV_EVENT_AUDIO_RECONFIG = 18 + const val MPV_EVENT_SEEK = 20 + const val MPV_EVENT_PLAYBACK_RESTART = 21 + const val MPV_EVENT_PROPERTY_CHANGE = 22 + const val MPV_EVENT_QUEUE_OVERFLOW = 24 + + // End file reason + const val MPV_END_FILE_REASON_EOF = 0 + const val MPV_END_FILE_REASON_STOP = 2 + const val MPV_END_FILE_REASON_QUIT = 3 + const val MPV_END_FILE_REASON_ERROR = 4 + const val MPV_END_FILE_REASON_REDIRECT = 5 + } +} diff --git a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerModule.kt b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerModule.kt new file mode 100644 index 000000000..46e8bbee8 --- /dev/null +++ b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerModule.kt @@ -0,0 +1,221 @@ +package expo.modules.mpvplayer + +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition + +class MpvPlayerModule : Module() { + override fun definition() = ModuleDefinition { + Name("MpvPlayer") + + // Defines event names that the module can send to JavaScript. + Events("onChange") + + // Defines a JavaScript synchronous function that runs the native code on the JavaScript thread. + Function("hello") { + "Hello from MPV Player! 👋" + } + + // Defines a JavaScript function that always returns a Promise and whose native code + // is by default dispatched on the different thread than the JavaScript runtime runs on. + AsyncFunction("setValueAsync") { value: String -> + sendEvent("onChange", mapOf("value" to value)) + } + + // Enables the module to be used as a native view. + View(MpvPlayerView::class) { + // All video load options are passed via a single "source" prop + Prop("source") { view: MpvPlayerView, source: Map? -> + if (source == null) return@Prop + + val urlString = source["url"] as? String ?: return@Prop + + // Parse cache config if provided (mirrors iOS) + @Suppress("UNCHECKED_CAST") + val cacheConfig = source["cacheConfig"] as? Map + + @Suppress("UNCHECKED_CAST") + val config = VideoLoadConfig( + url = urlString, + headers = source["headers"] as? Map, + externalSubtitles = source["externalSubtitles"] as? List, + startPosition = (source["startPosition"] as? Number)?.toDouble(), + autoplay = (source["autoplay"] as? Boolean) ?: true, + initialSubtitleId = (source["initialSubtitleId"] as? Number)?.toInt(), + initialAudioId = (source["initialAudioId"] as? Number)?.toInt(), + voDriver = source["voDriver"] as? String, + cacheEnabled = cacheConfig?.get("enabled") as? String, + cacheSeconds = (cacheConfig?.get("cacheSeconds") as? Number)?.toInt(), + demuxerMaxBytes = (cacheConfig?.get("maxBytes") as? Number)?.toInt(), + demuxerMaxBackBytes = (cacheConfig?.get("maxBackBytes") as? Number)?.toInt() + ) + + view.loadVideo(config) + } + + // Now Playing metadata for media controls (iOS-only, no-op on Android) + // Android handles media session differently via MediaSessionCompat + Prop("nowPlayingMetadata") { _: MpvPlayerView, _: Map? -> + // No-op on Android - media session integration would require MediaSessionCompat + } + + // Async function to play video + AsyncFunction("play") { view: MpvPlayerView -> + view.play() + } + + // Async function to pause video + AsyncFunction("pause") { view: MpvPlayerView -> + view.pause() + } + + // Stop playback and release the MediaCodec decoder + demuxer. + // Does not synchronously tear down the native mpv handle (see + // MPVLib / MpvPlayerView.destroy docs). Call before navigating + // away from the player screen to avoid OOM during screen + // transitions on low-RAM devices. + AsyncFunction("destroy") { view: MpvPlayerView -> + view.destroy() + } + + // Async function to seek to position + AsyncFunction("seekTo") { view: MpvPlayerView, position: Double -> + view.seekTo(position) + } + + // Async function to seek by offset + AsyncFunction("seekBy") { view: MpvPlayerView, offset: Double -> + view.seekBy(offset) + } + + // Async function to set playback speed + AsyncFunction("setSpeed") { view: MpvPlayerView, speed: Double -> + view.setSpeed(speed) + } + + // Function to get current speed + AsyncFunction("getSpeed") { view: MpvPlayerView -> + view.getSpeed() + } + + // Function to check if paused + AsyncFunction("isPaused") { view: MpvPlayerView -> + view.isPaused() + } + + // Function to get current position + AsyncFunction("getCurrentPosition") { view: MpvPlayerView -> + view.getCurrentPosition() + } + + // Function to get duration + AsyncFunction("getDuration") { view: MpvPlayerView -> + view.getDuration() + } + + // Picture in Picture functions + AsyncFunction("startPictureInPicture") { view: MpvPlayerView -> + view.startPictureInPicture() + } + + AsyncFunction("stopPictureInPicture") { view: MpvPlayerView -> + view.stopPictureInPicture() + } + + AsyncFunction("isPictureInPictureSupported") { view: MpvPlayerView -> + view.isPictureInPictureSupported() + } + + AsyncFunction("isPictureInPictureActive") { view: MpvPlayerView -> + view.isPictureInPictureActive() + } + + // Subtitle functions + AsyncFunction("getSubtitleTracks") { view: MpvPlayerView -> + view.getSubtitleTracks() + } + + AsyncFunction("setSubtitleTrack") { view: MpvPlayerView, trackId: Int -> + view.setSubtitleTrack(trackId) + } + + AsyncFunction("disableSubtitles") { view: MpvPlayerView -> + view.disableSubtitles() + } + + AsyncFunction("getCurrentSubtitleTrack") { view: MpvPlayerView -> + view.getCurrentSubtitleTrack() + } + + AsyncFunction("addSubtitleFile") { view: MpvPlayerView, url: String, select: Boolean -> + view.addSubtitleFile(url, select) + } + + // Subtitle positioning functions + AsyncFunction("setSubtitlePosition") { view: MpvPlayerView, position: Int -> + view.setSubtitlePosition(position) + } + + AsyncFunction("setSubtitleScale") { view: MpvPlayerView, scale: Double -> + view.setSubtitleScale(scale) + } + + AsyncFunction("setSubtitleMarginY") { view: MpvPlayerView, margin: Int -> + view.setSubtitleMarginY(margin) + } + + AsyncFunction("setSubtitleAlignX") { view: MpvPlayerView, alignment: String -> + view.setSubtitleAlignX(alignment) + } + + AsyncFunction("setSubtitleAlignY") { view: MpvPlayerView, alignment: String -> + view.setSubtitleAlignY(alignment) + } + + AsyncFunction("setSubtitleFontSize") { view: MpvPlayerView, size: Int -> + view.setSubtitleFontSize(size) + } + + AsyncFunction("setSubtitleBorderStyle") { view: MpvPlayerView, style: String -> + view.setSubtitleBorderStyle(style) + } + + AsyncFunction("setSubtitleBackgroundColor") { view: MpvPlayerView, color: String -> + view.setSubtitleBackgroundColor(color) + } + + AsyncFunction("setSubtitleAssOverride") { view: MpvPlayerView, mode: String -> + view.setSubtitleAssOverride(mode) + } + + // Audio track functions + AsyncFunction("getAudioTracks") { view: MpvPlayerView -> + view.getAudioTracks() + } + + AsyncFunction("setAudioTrack") { view: MpvPlayerView, trackId: Int -> + view.setAudioTrack(trackId) + } + + AsyncFunction("getCurrentAudioTrack") { view: MpvPlayerView -> + view.getCurrentAudioTrack() + } + + // Video scaling functions + AsyncFunction("setZoomedToFill") { view: MpvPlayerView, zoomed: Boolean -> + view.setZoomedToFill(zoomed) + } + + AsyncFunction("isZoomedToFill") { view: MpvPlayerView -> + view.isZoomedToFill() + } + + // Technical info function + AsyncFunction("getTechnicalInfo") { view: MpvPlayerView -> + view.getTechnicalInfo() + } + + // Defines events that the view can send to JavaScript + Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady", "onPictureInPictureChange") + } + } +} diff --git a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerView.kt b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerView.kt new file mode 100644 index 000000000..df74be18e --- /dev/null +++ b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerView.kt @@ -0,0 +1,556 @@ +package expo.modules.mpvplayer + +import android.content.Context +import android.graphics.Color +import android.os.Handler +import android.os.Looper +import android.util.Log +import android.view.Surface +import android.view.SurfaceHolder +import android.view.SurfaceView +import android.view.ViewGroup +import expo.modules.kotlin.AppContext +import expo.modules.kotlin.viewevent.EventDispatcher +import expo.modules.kotlin.views.ExpoView + +/** + * Configuration for loading a video + */ +data class VideoLoadConfig( + val url: String, + val headers: Map? = null, + val externalSubtitles: List? = null, + val startPosition: Double? = null, + val autoplay: Boolean = true, + val initialSubtitleId: Int? = null, + val initialAudioId: Int? = null, + val voDriver: String? = null, + val cacheEnabled: String? = null, + val cacheSeconds: Int? = null, + val demuxerMaxBytes: Int? = null, + val demuxerMaxBackBytes: Int? = null, +) + +/** + * MpvPlayerView - ExpoView that hosts the MPV player. + * + * Uses SurfaceView (not TextureView) so the surface routes directly to + * SurfaceFlinger (the OS compositor) rather than compositing into the + * app's window surface. This matches mpv-android's architecture and + * gives mpv a standalone surface. + * + * PiP black-screen mitigation: SurfaceView's surface is destroyed and + * recreated on PiP entry/exit, and the new surface's initial dimensions + * can be stale until the next layout pass. We push dimension updates to + * mpv via both SurfaceHolder.Callback.surfaceChanged AND an + * OnLayoutChangeListener, so the PiP transition (which fires layout + * passes on the view itself) reaches mpv promptly. + */ +class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext), + MPVLayerRenderer.Delegate, SurfaceHolder.Callback { + + companion object { + private const val TAG = "MpvPlayerView" + } + + // Event dispatchers + val onLoad by EventDispatcher() + val onPlaybackStateChange by EventDispatcher() + val onProgress by EventDispatcher() + val onError by EventDispatcher() + val onTracksReady by EventDispatcher() + val onPictureInPictureChange by EventDispatcher() + + private var surfaceView: SurfaceView + private var renderer: MPVLayerRenderer? = null + private var pipController: PiPController? = null + + private var currentUrl: String? = null + private var cachedPosition: Double = 0.0 + private var cachedDuration: Double = 0.0 + private var intendedPlayState: Boolean = false + private var surfaceReady: Boolean = false + private var pendingConfig: VideoLoadConfig? = null + private var rendererStarted: Boolean = false + private var activeSurface: Surface? = null + + // PiP state tracking + private val pipHandler = Handler(Looper.getMainLooper()) + + init { + setBackgroundColor(Color.BLACK) + + // SurfaceView for video rendering. Routes the surface directly to + // SurfaceFlinger (the OS compositor), giving mpv a standalone + // surface. TextureView composites into the app's window surface + // which is less efficient and breaks PiP transitions. + surfaceView = SurfaceView(context).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + } + surfaceView.holder.addCallback(this@MpvPlayerView) + addView(surfaceView) + + // Push dimension updates to mpv on every view bounds change. This + // is the primary PiP black-screen fix: entering PiP fires a layout + // pass on the SurfaceView itself, and we proactively tell mpv the + // new size so it resizes its EGL swapchain before rendering. + surfaceView.addOnLayoutChangeListener { _, left, top, right, bottom, + oldLeft, oldTop, oldRight, oldBottom -> + val w = right - left + val h = bottom - top + val oldW = oldRight - oldLeft + val oldH = oldBottom - oldTop + if (w > 0 && h > 0 && (w != oldW || h != oldH)) { + renderer?.updateSurfaceSize(w, h) + } + } + + // Initialize PiP controller with Expo's AppContext for proper activity access + pipController = PiPController(context, appContext) + pipController?.setPlayerView(surfaceView) + pipController?.delegate = object : PiPController.Delegate { + override fun onPlay() { + play() + } + + override fun onPause() { + pause() + } + + override fun onSeekBy(seconds: Double) { + seekBy(seconds) + } + + override fun onPictureInPictureModeChanged(isInPiP: Boolean) { + if (isInPiP) { + // Post size syncs after the PiP layout settles. Two passes + // catch both the immediate surface re-attach and the + // post-animation layout pass. Replaces the old TextureView + // measure/layout polling hack (forcePiPBufferSize). + pipHandler.removeCallbacksAndMessages(null) + pipHandler.postDelayed({ syncSurfaceSizeToView() }, 100) + pipHandler.postDelayed({ syncSurfaceSizeToView() }, 500) + } else { + // Restore from PiP: surface resized back to fullscreen. + pipHandler.removeCallbacksAndMessages(null) + pipHandler.postDelayed({ syncSurfaceSizeToView() }, 100) + } + onPictureInPictureChange(mapOf("isActive" to isInPiP)) + } + } + + // Renderer is created lazily in loadVideo once we have the voDriver setting + renderer = MPVLayerRenderer(context) + renderer?.delegate = this + } + + /** + * Start the renderer with the given VO driver. + * Called lazily on first loadVideo so user settings are available. + */ + private fun ensureRendererStarted(voDriver: String?) { + if (rendererStarted) return + + try { + renderer?.start(voDriver ?: "gpu-next") + rendererStarted = true + + // If the surface is already alive (surfaceCreated fired before + // loadVideo), attach it now. With SurfaceView the surface is + // owned by the holder, so we read it from there directly rather + // than stashing it on the side. + surfaceView.holder.surface?.takeIf { it.isValid }?.let { surface -> + activeSurface = surface + renderer?.attachSurface(surface) + syncSurfaceSizeToView() + } + } catch (e: Exception) { + Log.e(TAG, "Failed to start renderer: ${e.message}") + onError(mapOf("error" to "Failed to start renderer: ${e.message}")) + } + } + + // MARK: - SurfaceHolder.Callback + + override fun surfaceCreated(holder: SurfaceHolder) { + val surface = holder.surface + surfaceReady = true + + if (rendererStarted) { + // The previous Surface reference is holder-owned; do NOT release + // it (SurfaceView manages its lifecycle). Just track the new one. + activeSurface = surface + renderer?.attachSurface(surface) + // Push the actual view dimensions immediately so mpv doesn't + // render against stale full-screen geometry during PiP transitions. + syncSurfaceSizeToView() + } + + // If we have a pending load, execute it now + pendingConfig?.let { config -> + ensureRendererStarted(config.voDriver) + loadVideoInternal(config) + pendingConfig = null + } + } + + override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) { + if (width > 0 && height > 0) { + renderer?.updateSurfaceSize(width, height) + } + } + + override fun surfaceDestroyed(holder: SurfaceHolder) { + surfaceReady = false + renderer?.detachSurface() + // Do NOT issue mpv "stop" here. Playback continues against the + // demuxer; when surfaceCreated fires again (PiP entry/exit, app + // background/foreground), we re-attach and frames resume. This + // matches the keep-open=always setting in MPVLayerRenderer. + // + // Do NOT release activeSurface — SurfaceView owns it via the holder. + activeSurface = null + } + + /** + * Read the actual SurfaceView width/height and push them to mpv. + * The PiP transition can fire surfaceCreated before the view's layout + * has settled to PiP dimensions, so we re-sync after layout passes. + */ + private fun syncSurfaceSizeToView() { + if (!surfaceReady) return + val w = surfaceView.width + val h = surfaceView.height + if (w > 0 && h > 0) { + renderer?.updateSurfaceSize(w, h) + } + } + + // MARK: - Video Loading + + fun loadVideo(config: VideoLoadConfig) { + // Skip reload if same URL is already playing + if (currentUrl == config.url) { + return + } + + if (!surfaceReady) { + // Surface not ready, store config and load when ready + pendingConfig = config + return + } + + // Ensure renderer is started with the configured VO driver + ensureRendererStarted(config.voDriver) + + loadVideoInternal(config) + } + + private fun loadVideoInternal(config: VideoLoadConfig) { + currentUrl = config.url + + renderer?.load( + url = config.url, + headers = config.headers, + startPosition = config.startPosition, + externalSubtitles = config.externalSubtitles, + initialSubtitleId = config.initialSubtitleId, + initialAudioId = config.initialAudioId, + cacheEnabled = config.cacheEnabled, + cacheSeconds = config.cacheSeconds, + demuxerMaxBytes = config.demuxerMaxBytes, + demuxerMaxBackBytes = config.demuxerMaxBackBytes + ) + + if (config.autoplay) { + play() + } + + onLoad(mapOf("url" to config.url)) + } + + // Convenience method for simple loads + fun loadVideo(url: String, headers: Map? = null) { + loadVideo(VideoLoadConfig(url = url, headers = headers)) + } + + // MARK: - Playback Controls + + fun play() { + intendedPlayState = true + renderer?.play() + pipController?.setPlaybackRate(1.0) + } + + fun pause() { + intendedPlayState = false + renderer?.pause() + pipController?.setPlaybackRate(0.0) + } + + /** + * Stop playback and release decoder resources. + * + * Delegates to [MPVLayerRenderer.stop], which issues mpv's "stop" command + * on a background thread (flushing the demuxer and releasing the + * MediaCodec hardware decoder) and drops the per-instance mpv handle. + * + * NOTE: this does NOT call `LibMPV.destroy()`. libmpv 1.0's + * nativeDestroy has an internal use-after-free on the JNI global ref + * path, so the native mpv handle is intentionally left for the JVM GC + * / native finalizer rather than torn down synchronously. See + * [MPVLib] class doc for the full rationale. + * + * Call this BEFORE navigating away from the player screen so the + * decoder is reclaimed before the next screen (or the next episode's + * player) mounts. Otherwise Expo Router renders the new screen first + * and you briefly have two mpv instances + two 4K decoders alive — + * instant OOM on a 2 GB device. + */ + fun destroy() { + renderer?.stop() + + // Reset view-level state so a subsequent loadVideo() on the SAME view + // instance re-creates the mpv handle and re-attaches the still-live + // SurfaceView surface. Without this, rendererStarted stays true and + // ensureRendererStarted() early-returns, so renderer.start() is never + // called again — but stop() already nulled the renderer's mpv handle. + // The next loadVideo() then runs loadVideoInternal() -> renderer.load() + // against mpv == null, where every mpv?.command() (including the + // "stop" and load commands) silently no-ops, leaving a black frame. + // + // This path is hit by direct-player.tsx's goToNextItem()/stop(), + // which call destroy() immediately before router.replace() to the + // same route — Expo Router reuses the same MpvPlayerView instance, + // so the next source load happens on this view without a remount. + // + // SurfaceView note: the surface is owned by the holder and survives + // across destroy()/loadVideo() on the same view instance. The next + // ensureRendererStarted() reads it from surfaceView.holder.surface. + rendererStarted = false + currentUrl = null + activeSurface = null + } + + fun seekTo(position: Double) { + renderer?.seekTo(position) + } + + fun seekBy(offset: Double) { + renderer?.seekBy(offset) + } + + fun setSpeed(speed: Double) { + renderer?.setSpeed(speed) + } + + fun getSpeed(): Double { + return renderer?.getSpeed() ?: 1.0 + } + + fun isPaused(): Boolean { + return renderer?.isPausedState ?: true + } + + fun getCurrentPosition(): Double { + return cachedPosition + } + + fun getDuration(): Double { + return cachedDuration + } + + // MARK: - Picture in Picture + + fun startPictureInPicture() { + pipController?.startPictureInPicture() + } + + fun stopPictureInPicture() { + pipHandler.removeCallbacksAndMessages(null) + pipController?.stopPictureInPicture() + } + + fun isPictureInPictureSupported(): Boolean { + return pipController?.isPictureInPictureSupported() ?: false + } + + fun isPictureInPictureActive(): Boolean { + return pipController?.isPictureInPictureActive() ?: false + } + + // MARK: - Subtitle Controls + + fun getSubtitleTracks(): List> { + return renderer?.getSubtitleTracks() ?: emptyList() + } + + fun setSubtitleTrack(trackId: Int) { + renderer?.setSubtitleTrack(trackId) + } + + fun disableSubtitles() { + renderer?.disableSubtitles() + } + + fun getCurrentSubtitleTrack(): Int { + return renderer?.getCurrentSubtitleTrack() ?: 0 + } + + fun addSubtitleFile(url: String, select: Boolean = true) { + renderer?.addSubtitleFile(url, select) + } + + // MARK: - Subtitle Positioning + + fun setSubtitlePosition(position: Int) { + renderer?.setSubtitlePosition(position) + } + + fun setSubtitleScale(scale: Double) { + renderer?.setSubtitleScale(scale) + } + + fun setSubtitleMarginY(margin: Int) { + renderer?.setSubtitleMarginY(margin) + } + + fun setSubtitleAlignX(alignment: String) { + renderer?.setSubtitleAlignX(alignment) + } + + fun setSubtitleAlignY(alignment: String) { + renderer?.setSubtitleAlignY(alignment) + } + + fun setSubtitleFontSize(size: Int) { + renderer?.setSubtitleFontSize(size) + } + + fun setSubtitleBorderStyle(style: String) { + renderer?.setSubtitleBorderStyle(style) + } + + fun setSubtitleBackgroundColor(color: String) { + renderer?.setSubtitleBackgroundColor(color) + } + + fun setSubtitleAssOverride(mode: String) { + renderer?.setSubtitleAssOverride(mode) + } + + // MARK: - Audio Track Controls + + fun getAudioTracks(): List> { + return renderer?.getAudioTracks() ?: emptyList() + } + + fun setAudioTrack(trackId: Int) { + renderer?.setAudioTrack(trackId) + } + + fun getCurrentAudioTrack(): Int { + return renderer?.getCurrentAudioTrack() ?: 0 + } + + // MARK: - Video Scaling + + private var _isZoomedToFill: Boolean = false + + fun setZoomedToFill(zoomed: Boolean) { + _isZoomedToFill = zoomed + renderer?.setZoomedToFill(zoomed) + } + + fun isZoomedToFill(): Boolean { + return _isZoomedToFill + } + + // MARK: - Technical Info + + fun getTechnicalInfo(): Map { + return renderer?.getTechnicalInfo() ?: emptyMap() + } + + // MARK: - MPVLayerRenderer.Delegate + + override fun onPositionChanged(position: Double, duration: Double, cacheSeconds: Double) { + cachedPosition = position + cachedDuration = duration + + // Update PiP progress + if (pipController?.isPictureInPictureActive() == true) { + pipController?.setCurrentTime(position, duration) + } + + onProgress(mapOf( + "position" to position, + "duration" to duration, + "progress" to if (duration > 0) position / duration else 0.0, + "cacheSeconds" to cacheSeconds + )) + } + + override fun onPauseChanged(isPaused: Boolean) { + pipController?.setPlaybackRate(if (isPaused) 0.0 else 1.0) + + onPlaybackStateChange(mapOf( + "isPaused" to isPaused, + "isPlaying" to !isPaused + )) + } + + override fun onLoadingChanged(isLoading: Boolean) { + onPlaybackStateChange(mapOf( + "isLoading" to isLoading + )) + } + + override fun onReadyToSeek() { + onPlaybackStateChange(mapOf( + "isReadyToSeek" to true + )) + } + + override fun onTracksReady() { + onTracksReady(emptyMap()) + } + + override fun onVideoDimensionsChanged(width: Int, height: Int) { + pipController?.setVideoDimensions(width, height) + } + + override fun onError(message: String) { + onError(mapOf("error" to message)) + } + + // MARK: - Cleanup + + /** + * Proactively tear down the player. Called from onDetachedFromWindow so + * the app releases mpv + decoder buffers when the View detaches from the + * window. The JS-facing destroy() is intentionally thinner (just + * renderer.stop()) — see this thread for why the full teardown was kept + * off the JS path. + */ + fun cleanup() { + pipHandler.removeCallbacksAndMessages(null) + pipController?.stopPictureInPicture() + renderer?.stop() + renderer?.delegate = null + + // SurfaceView owns the Surface via its holder — do NOT release it. + activeSurface = null + surfaceReady = false + currentUrl = null + rendererStarted = false + } + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + cleanup() + } +} diff --git a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/PiPController.kt b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/PiPController.kt new file mode 100644 index 000000000..c86d57e3d --- /dev/null +++ b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/PiPController.kt @@ -0,0 +1,448 @@ +package expo.modules.mpvplayer + +import android.app.Activity +import android.app.Application +import android.app.PictureInPictureParams +import android.app.RemoteAction +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.PackageManager +import android.graphics.drawable.Icon +import android.graphics.Rect +import android.os.Build +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.util.Log +import android.util.Rational +import android.view.View +import androidx.annotation.RequiresApi +import expo.modules.kotlin.AppContext + +class PiPController(private val context: Context, private val appContext: AppContext? = null) { + + companion object { + private const val TAG = "PiPController" + private const val DEFAULT_ASPECT_WIDTH = 16 + private const val DEFAULT_ASPECT_HEIGHT = 9 + private const val ACTION_PIP_PLAY_PAUSE = "expo.modules.mpvplayer.PIP_PLAY_PAUSE" + private const val ACTION_PIP_SKIP_FORWARD = "expo.modules.mpvplayer.PIP_SKIP_FORWARD" + private const val ACTION_PIP_SKIP_BACKWARD = "expo.modules.mpvplayer.PIP_SKIP_BACKWARD" + } + + interface Delegate { + fun onPlay() + fun onPause() + fun onSeekBy(seconds: Double) + fun onPictureInPictureModeChanged(isInPiP: Boolean) + } + + var delegate: Delegate? = null + + private var currentPosition: Double = 0.0 + private var currentDuration: Double = 0.0 + private var playbackRate: Double = 1.0 + // Independently tracks whether the system should auto-enter PiP on home + // press. Decoupled from playbackRate so that disabling auto-enter + // (e.g. when the player unmounts) doesn't corrupt the play/pause icon + // state that buildPiPActions() derives from playbackRate. + private var autoEnterEnabled: Boolean = false + + private var videoWidth: Int = 0 + private var videoHeight: Int = 0 + private var playerView: View? = null + + // PiP state tracking + private var isInPiPMode: Boolean = false + private var pipEntryNotified: Boolean = false + private val pipHandler = Handler(Looper.getMainLooper()) + private var lifecycleCallbacks: Application.ActivityLifecycleCallbacks? = null + private var lifecycleRegistered = false + private var pipBroadcastReceiver: BroadcastReceiver? = null + + fun isPictureInPictureSupported(): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) + } else { + false + } + } + + fun isPictureInPictureActive(): Boolean { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val activity = getActivity() + return activity?.isInPictureInPictureMode ?: false + } + return false + } + + fun startPictureInPicture() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + + val activity = getActivity() ?: run { + Log.e(TAG, "Cannot start PiP: no activity") + return + } + + if (!isPictureInPictureSupported()) { + Log.e(TAG, "PiP not supported on this device") + return + } + + try { + val params = buildPiPParams(forEntering = true) + val result = activity.enterPictureInPictureMode(params) + + if (!result) { + Log.e(TAG, "enterPictureInPictureMode rejected by system") + isInPiPMode = false + return + } + + isInPiPMode = true + pipEntryNotified = true + delegate?.onPictureInPictureModeChanged(true) + registerLifecycleCallbacks() + } catch (e: Exception) { + Log.e(TAG, "Failed to enter PiP: ${e.message}") + } + } + + fun stopPictureInPicture() { + // Disable auto-enter eligibility without touching playbackRate. + // playbackRate drives the play/pause icon in buildPiPActions(); + // mutating it here would cause a stale icon if PiP is re-entered + // before the next playback state callback corrects it. + autoEnterEnabled = false + isInPiPMode = false + pipEntryNotified = false + unregisterLifecycleCallbacks() + + val activity = getActivity() ?: return + + // Push minimal params with just auto-enter disabled. Do NOT call + // buildPiPParams() — it calls ensurePiPReceiverRegistered() and + // setActions(), which would re-register the broadcast receiver + // (just unregistered above) and attach play/pause/skip actions to + // params being torn down. That leaves a live receiver + stale + // actions after the player has unmounted. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + try { + activity.setPictureInPictureParams( + PictureInPictureParams.Builder() + .setAutoEnterEnabled(false) + .build() + ) + } catch (e: Exception) { + Log.e(TAG, "Failed to clear PiP auto-enter params: ${e.message}") + } + } + if (activity.isInPictureInPictureMode) { + activity.moveTaskToBack(false) + } + } + + fun isCurrentlyInPiP(): Boolean = isInPiPMode + + fun setCurrentTime(position: Double, duration: Double) { + currentPosition = position + currentDuration = duration + } + + fun setPlaybackRate(rate: Double) { + playbackRate = rate + autoEnterEnabled = rate > 0 + + if (rate > 0) { + registerLifecycleCallbacks() + } + + // Update PiP params so autoEnterEnabled and action icons track play/pause state + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val activity = getActivity() + if (activity != null) { + try { + activity.setPictureInPictureParams(buildPiPParams()) + } catch (e: Exception) { + Log.e(TAG, "Failed to update PiP params: ${e.message}") + } + } + } + } + + fun setVideoDimensions(width: Int, height: Int) { + if (width > 0 && height > 0) { + videoWidth = width + videoHeight = height + updatePiPParamsIfNeeded() + } + } + + fun setPlayerView(view: View?) { + playerView = view + } + + private fun updatePiPParamsIfNeeded() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val activity = getActivity() + if (activity?.isInPictureInPictureMode == true) { + try { + activity.setPictureInPictureParams(buildPiPParams()) + } catch (e: Exception) { + Log.e(TAG, "Failed to update PiP params: ${e.message}") + } + } + } + } + + @RequiresApi(Build.VERSION_CODES.O) + private fun buildPiPParams(forEntering: Boolean = false): PictureInPictureParams { + val view = playerView + val viewWidth = view?.width ?: 0 + val viewHeight = view?.height ?: 0 + + val displayAspectRatio = Rational(viewWidth.coerceAtLeast(1), viewHeight.coerceAtLeast(1)) + + // Video aspect ratio with 2.39:1 clamping + val aspectRatio = if (videoWidth > 0 && videoHeight > 0) { + Rational( + videoWidth.coerceAtMost((videoHeight * 2.39f).toInt()), + videoHeight.coerceAtMost((videoWidth * 2.39f).toInt()) + ) + } else { + Rational(DEFAULT_ASPECT_WIDTH, DEFAULT_ASPECT_HEIGHT) + } + + val sourceRectHint = if (viewWidth > 0 && viewHeight > 0 && videoWidth > 0 && videoHeight > 0) { + if (displayAspectRatio < aspectRatio) { + val space = ((viewHeight - (viewWidth.toFloat() / aspectRatio.toFloat())) / 2).toInt() + Rect(0, space, viewWidth, (viewWidth.toFloat() / aspectRatio.toFloat()).toInt() + space) + } else { + val space = ((viewWidth - (viewHeight.toFloat() * aspectRatio.toFloat())) / 2).toInt() + Rect(space, 0, (viewHeight.toFloat() * aspectRatio.toFloat()).toInt() + space, viewHeight) + } + } else { + null + } + + val builder = PictureInPictureParams.Builder() + .setAspectRatio(aspectRatio) + + sourceRectHint?.let { builder.setSourceRectHint(it) } + + ensurePiPReceiverRegistered() + builder.setActions(buildPiPActions()) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + builder.setAutoEnterEnabled(forEntering || autoEnterEnabled) + } + + return builder.build() + } + + private fun getActivity(): Activity? { + appContext?.currentActivity?.let { return it } + + var ctx = context + while (ctx is android.content.ContextWrapper) { + if (ctx is Activity) return ctx + ctx = ctx.baseContext + } + return null + } + + // MARK: - Lifecycle-based PiP Detection + + private fun registerLifecycleCallbacks() { + if (lifecycleRegistered) return + + val app = context.applicationContext as? Application ?: run { + Log.w(TAG, "Cannot access Application for lifecycle callbacks, falling back to polling") + startFallbackPolling() + return + } + + lifecycleCallbacks = object : Application.ActivityLifecycleCallbacks { + override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {} + override fun onActivityStarted(activity: Activity) {} + + override fun onActivityResumed(activity: Activity) { + if (!isInPiPMode) return + if (!activity.isInPictureInPictureMode) { + isInPiPMode = false + pipEntryNotified = false + delegate?.onPictureInPictureModeChanged(false) + } + } + + override fun onActivityPaused(activity: Activity) { + // Proactively hide controls when user leaves while playing, + // before the PiP window captures the UI. onActivityStopped + // will restore if PiP didn't actually enter. + if (playbackRate > 0 && !isInPiPMode) { + isInPiPMode = true + pipEntryNotified = true + delegate?.onPictureInPictureModeChanged(true) + } + } + + override fun onActivityStopped(activity: Activity) { + pipHandler.postDelayed({ + val inPip = activity.isInPictureInPictureMode + + if (inPip && !isInPiPMode) { + isInPiPMode = true + pipEntryNotified = true + delegate?.onPictureInPictureModeChanged(true) + return@postDelayed + } + + if (!isInPiPMode) return@postDelayed + if (inPip) return@postDelayed + + // Not in PiP after 1s — check again to avoid false positive during transition + pipHandler.postDelayed({ + if (!isInPiPMode) return@postDelayed + if (!activity.isInPictureInPictureMode) { + isInPiPMode = false + pipEntryNotified = false + delegate?.onPictureInPictureModeChanged(false) + } + }, 1500) + }, 1000) + } + + override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {} + + override fun onActivityDestroyed(activity: Activity) { + isInPiPMode = false + } + } + + app.registerActivityLifecycleCallbacks(lifecycleCallbacks) + lifecycleRegistered = true + } + + private fun unregisterLifecycleCallbacks() { + if (!lifecycleRegistered) return + lifecycleCallbacks?.let { + (context.applicationContext as? Application) + ?.unregisterActivityLifecycleCallbacks(it) + } + lifecycleCallbacks = null + lifecycleRegistered = false + pipHandler.removeCallbacksAndMessages(null) + unregisterPiPBroadcastReceiver() + } + + private fun startFallbackPolling() { + var falseReadCount = 0 + pipHandler.removeCallbacksAndMessages(null) + pipHandler.postDelayed(object : Runnable { + override fun run() { + if (!isInPiPMode) return + + var ctx = context + var activity: Activity? = null + while (ctx is android.content.ContextWrapper) { + if (ctx is Activity) { activity = ctx; break } + ctx = ctx.baseContext + } + + val stillInPip = activity?.isInPictureInPictureMode == true + + if (!stillInPip) { + falseReadCount++ + if (falseReadCount >= 3) { + isInPiPMode = false + delegate?.onPictureInPictureModeChanged(false) + return + } + pipHandler.postDelayed(this, 500) + return + } + + falseReadCount = 0 + pipHandler.postDelayed(this, 1000) + } + }, 3000) + } + + // MARK: - PiP Remote Actions + + private fun ensurePiPReceiverRegistered() { + if (pipBroadcastReceiver != null) return + + pipBroadcastReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + when (intent.action) { + ACTION_PIP_PLAY_PAUSE -> { + if (playbackRate > 0) delegate?.onPause() else delegate?.onPlay() + } + ACTION_PIP_SKIP_FORWARD -> delegate?.onSeekBy(10.0) + ACTION_PIP_SKIP_BACKWARD -> delegate?.onSeekBy(-10.0) + } + } + } + + val filter = IntentFilter().apply { + addAction(ACTION_PIP_PLAY_PAUSE) + addAction(ACTION_PIP_SKIP_FORWARD) + addAction(ACTION_PIP_SKIP_BACKWARD) + } + val registerFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + Context.RECEIVER_EXPORTED + } else { + 0 + } + context.applicationContext.registerReceiver(pipBroadcastReceiver, filter, registerFlags) + } + + private fun unregisterPiPBroadcastReceiver() { + pipBroadcastReceiver?.let { + try { + context.applicationContext.unregisterReceiver(it) + } catch (_: Exception) {} + } + pipBroadcastReceiver = null + } + + private fun buildPiPActions(): List { + val isPlaying = playbackRate > 0 + + return listOf( + RemoteAction( + Icon.createWithResource(context, android.R.drawable.ic_media_rew), + "Rewind", "Skip backward 10 seconds", + createPiPPendingIntent(ACTION_PIP_SKIP_BACKWARD) + ), + RemoteAction( + Icon.createWithResource( + context, + if (isPlaying) android.R.drawable.ic_media_pause else android.R.drawable.ic_media_play + ), + if (isPlaying) "Pause" else "Play", + if (isPlaying) "Pause playback" else "Resume playback", + createPiPPendingIntent(ACTION_PIP_PLAY_PAUSE) + ), + RemoteAction( + Icon.createWithResource(context, android.R.drawable.ic_media_ff), + "Fast Forward", "Skip forward 10 seconds", + createPiPPendingIntent(ACTION_PIP_SKIP_FORWARD) + ) + ) + } + + private fun createPiPPendingIntent(action: String): android.app.PendingIntent { + val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + android.app.PendingIntent.FLAG_IMMUTABLE + } else { + 0 + } + return android.app.PendingIntent.getBroadcast( + context.applicationContext, 0, Intent(action), flags + ) + } +} diff --git a/modules/mpv-player/expo-module.config.json b/modules/mpv-player/expo-module.config.json new file mode 100644 index 000000000..f5092bade --- /dev/null +++ b/modules/mpv-player/expo-module.config.json @@ -0,0 +1,9 @@ +{ + "platforms": ["apple", "android", "web"], + "apple": { + "modules": ["MpvPlayerModule"] + }, + "android": { + "modules": ["expo.modules.mpvplayer.MpvPlayerModule"] + } +} diff --git a/modules/mpv-player/index.ts b/modules/mpv-player/index.ts new file mode 100644 index 000000000..cab14e349 --- /dev/null +++ b/modules/mpv-player/index.ts @@ -0,0 +1,6 @@ +// Reexport the native module. On web, it will be resolved to MpvPlayerModule.web.ts +// and on native platforms to MpvPlayerModule.ts + +export * from "./src/MpvPlayer.types"; +export { default } from "./src/MpvPlayerModule"; +export { default as MpvPlayerView } from "./src/MpvPlayerView"; diff --git a/modules/mpv-player/ios/Logger.swift b/modules/mpv-player/ios/Logger.swift new file mode 100644 index 000000000..43d89182b --- /dev/null +++ b/modules/mpv-player/ios/Logger.swift @@ -0,0 +1,154 @@ +import Foundation + +final class Logger: @unchecked Sendable { + static let shared = Logger() + + struct LogEntry { + let message: String + let type: String + let timestamp: Date + } + + private let queue = DispatchQueue(label: "mpvkit.logger", attributes: .concurrent) + private var logs: [LogEntry] = [] + private let logFileURL: URL + + private let maxFileSize = 1024 * 512 + private let maxLogEntries = 1000 + + private init() { + let tmpDir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + logFileURL = tmpDir.appendingPathComponent("logs.txt") + } + + func log(_ message: String, type: String = "General") { + let entry = LogEntry(message: message, type: type, timestamp: Date()) + + queue.async(flags: .barrier) { + self.logs.append(entry) + + if self.logs.count > self.maxLogEntries { + self.logs.removeFirst(self.logs.count - self.maxLogEntries) + } + + self.saveLogToFile(entry) + self.debugLog(entry) + + DispatchQueue.main.async { + NotificationCenter.default.post(name: NSNotification.Name("LoggerNotification"), object: nil, + userInfo: [ + "message": message, + "type": type, + "timestamp": entry.timestamp + ] + ) + } + } + } + + func getLogs() -> String { + var result = "" + queue.sync { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "dd-MM HH:mm:ss" + result = logs.map { "[\(dateFormatter.string(from: $0.timestamp))] [\($0.type)] \($0.message)" } + .joined(separator: "\n----\n") + } + return result + } + + func getLogsAsync() async -> String { + return await withCheckedContinuation { continuation in + queue.async { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "dd-MM HH:mm:ss" + let result = self.logs.map { "[\(dateFormatter.string(from: $0.timestamp))] [\($0.type)] \($0.message)" } + .joined(separator: "\n----\n") + continuation.resume(returning: result) + } + } + } + + func clearLogs() { + queue.async(flags: .barrier) { + self.logs.removeAll() + try? FileManager.default.removeItem(at: self.logFileURL) + } + } + + func clearLogsAsync() async { + await withCheckedContinuation { continuation in + queue.async(flags: .barrier) { + self.logs.removeAll() + try? FileManager.default.removeItem(at: self.logFileURL) + continuation.resume() + } + } + } + + private func saveLogToFile(_ log: LogEntry) { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "dd-MM HH:mm:ss" + + let logString = "[\(dateFormatter.string(from: log.timestamp))] [\(log.type)] \(log.message)\n---\n" + + guard let data = logString.data(using: .utf8) else { + print("Failed to encode log string to UTF-8") + return + } + + do { + if FileManager.default.fileExists(atPath: logFileURL.path) { + let attributes = try FileManager.default.attributesOfItem(atPath: logFileURL.path) + let fileSize = attributes[.size] as? UInt64 ?? 0 + + if fileSize + UInt64(data.count) > maxFileSize { + self.truncateLogFile() + } + + if let handle = try? FileHandle(forWritingTo: logFileURL) { + handle.seekToEndOfFile() + handle.write(data) + handle.closeFile() + } + } else { + try data.write(to: logFileURL) + } + } catch { + print("Error managing log file: \(error)") + try? data.write(to: logFileURL) + } + } + + private func truncateLogFile() { + do { + guard let content = try? String(contentsOf: logFileURL, encoding: .utf8), + !content.isEmpty else { + return + } + + let entries = content.components(separatedBy: "\n---\n") + guard entries.count > 10 else { return } + + let keepCount = entries.count / 2 + let truncatedEntries = Array(entries.suffix(keepCount)) + let truncatedContent = truncatedEntries.joined(separator: "\n---\n") + + if let truncatedData = truncatedContent.data(using: .utf8) { + try truncatedData.write(to: logFileURL) + } + } catch { + print("Error truncating log file: \(error)") + try? FileManager.default.removeItem(at: logFileURL) + } + } + + private func debugLog(_ entry: LogEntry) { +#if DEBUG + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "dd-MM HH:mm:ss" + let formattedMessage = "[\(dateFormatter.string(from: entry.timestamp))] [\(entry.type)] \(entry.message)" + print(formattedMessage) +#endif + } +} diff --git a/modules/mpv-player/ios/MPVLayerRenderer.swift b/modules/mpv-player/ios/MPVLayerRenderer.swift new file mode 100644 index 000000000..75bc3d9af --- /dev/null +++ b/modules/mpv-player/ios/MPVLayerRenderer.swift @@ -0,0 +1,1063 @@ +import UIKit +import MPVKit +import CoreMedia +import CoreVideo +import AVFoundation + +/// HDR mode detected from video properties +enum HDRMode { + case sdr + case hdr10 + case dolbyVision + case hlg +} + +protocol MPVLayerRendererDelegate: AnyObject { + func renderer(_ renderer: MPVLayerRenderer, didUpdatePosition position: Double, duration: Double, cacheSeconds: Double) + func renderer(_ renderer: MPVLayerRenderer, didChangePause isPaused: Bool) + func renderer(_ renderer: MPVLayerRenderer, didChangeLoading isLoading: Bool) + func renderer(_ renderer: MPVLayerRenderer, didBecomeReadyToSeek: Bool) + func renderer(_ renderer: MPVLayerRenderer, didBecomeTracksReady: Bool) + func renderer(_ renderer: MPVLayerRenderer, didDetectHDRMode mode: HDRMode, fps: Double) + func renderer(_ renderer: MPVLayerRenderer, didSelectAudioOutput audioOutput: String) +} + +/// MPV player using vo_avfoundation for video output. +/// This renders video directly to AVSampleBufferDisplayLayer for PiP support. +final class MPVLayerRenderer { + enum RendererError: Error { + case mpvCreationFailed + case mpvInitialization(Int32) + } + + private let displayLayer: AVSampleBufferDisplayLayer + private let queue: DispatchQueue + private let stateQueue = DispatchQueue(label: "mpv.avfoundation.state", attributes: .concurrent) + + // Key to identify if we're on the mpv queue (to avoid deadlock in stop()) + private static let queueKey = DispatchSpecificKey() + + private var mpv: OpaquePointer? + + private var currentPreset: PlayerPreset? + private var currentURL: URL? + private var currentHeaders: [String: String]? + private var pendingExternalSubtitles: [String] = [] + private var initialSubtitleId: Int? + private var initialAudioId: Int? + + private var _isRunning = false + private var _isStopping = false + + private var isRunning: Bool { + get { stateQueue.sync { _isRunning } } + set { stateQueue.async(flags: .barrier) { self._isRunning = newValue } } + } + + private var isStopping: Bool { + get { stateQueue.sync { _isStopping } } + set { stateQueue.sync(flags: .barrier) { _isStopping = newValue } } // Must be sync for stop() to work + } + + // KVO observation for display layer status + private var statusObservation: NSKeyValueObservation? + + weak var delegate: MPVLayerRendererDelegate? + + // Thread-safe state for playback + private var _cachedDuration: Double = 0 + private var _cachedPosition: Double = 0 + private var _cachedCacheSeconds: Double = 0 + private var _isPaused: Bool = true + private var _playbackSpeed: Double = 1.0 + private var _isLoading: Bool = false + private var _isReadyToSeek: Bool = false + private var _isSeeking: Bool = false + + // Progress update throttling - CRITICAL for performance! + // DO NOT REMOVE THIS THROTTLE - it is essential for battery life and CPU efficiency. + // + // Without throttling, time-pos fires every video frame (24+ times/sec at 24fps). + // Each update crosses the React Native JS bridge, which is expensive on mobile. + // Even if the JS side does nothing, 24+ bridge calls/sec wastes CPU and battery. + // + // Throttling to 1 update/sec during normal playback is sufficient for: + // - Progress bar updates (users can't perceive 1-second granularity) + // - Playback position tracking + // - Any JS-side logic that needs current position + // + // During seeking, we bypass the throttle for responsive scrubbing. + // This optimization reduced CPU usage by ~50% for downloaded file playback. + private var lastProgressUpdateTime: CFAbsoluteTime = 0 + + // Thread-safe accessors + private var cachedDuration: Double { + get { stateQueue.sync { _cachedDuration } } + set { stateQueue.async(flags: .barrier) { self._cachedDuration = newValue } } + } + private var cachedPosition: Double { + get { stateQueue.sync { _cachedPosition } } + set { stateQueue.async(flags: .barrier) { self._cachedPosition = newValue } } + } + private var cachedCacheSeconds: Double { + get { stateQueue.sync { _cachedCacheSeconds } } + set { stateQueue.async(flags: .barrier) { self._cachedCacheSeconds = newValue } } + } + private var isPaused: Bool { + get { stateQueue.sync { _isPaused } } + set { stateQueue.async(flags: .barrier) { self._isPaused = newValue } } + } + private var playbackSpeed: Double { + get { stateQueue.sync { _playbackSpeed } } + set { stateQueue.async(flags: .barrier) { self._playbackSpeed = newValue } } + } + private var isLoading: Bool { + get { stateQueue.sync { _isLoading } } + set { stateQueue.async(flags: .barrier) { self._isLoading = newValue } } + } + private var isReadyToSeek: Bool { + get { stateQueue.sync { _isReadyToSeek } } + set { stateQueue.async(flags: .barrier) { self._isReadyToSeek = newValue } } + } + private var isSeeking: Bool { + get { stateQueue.sync { _isSeeking } } + set { stateQueue.async(flags: .barrier) { self._isSeeking = newValue } } + } + + var isPausedState: Bool { + return isPaused + } + + init(displayLayer: AVSampleBufferDisplayLayer) { + self.displayLayer = displayLayer + self.queue = DispatchQueue(label: "mpv.avfoundation", qos: .userInitiated) + queue.setSpecific(key: Self.queueKey, value: true) + observeDisplayLayerStatus() + } + + + /// Watches for display layer failures and auto-recovers. + /// + /// iOS aggressively kills VideoToolbox decoder sessions when the app is + /// backgrounded, the screen is locked, or system resources are low. + /// This causes the video to go black - especially problematic for PiP. + /// + /// This KVO observer detects when the display layer status becomes `.failed` + /// and automatically reinitializes the hardware decoder to restore video. + private func observeDisplayLayerStatus() { + statusObservation = displayLayer.observe(\.status, options: [.new]) { [weak self] layer, _ in + guard let self else { return } + + if layer.status == .failed { + print("🔧 Display layer failed - auto-resetting decoder") + self.queue.async { + self.performDecoderReset() + } + } + } + } + + /// Actually performs the decoder reset (called by observer or manually) + private func performDecoderReset() { + guard let handle = mpv else { return } + print("🔧 Resetting decoder: status=\(displayLayer.status.rawValue), requiresFlush=\(displayLayer.requiresFlushToResumeDecoding)") + commandSync(handle, ["set", "hwdec", "no"]) + commandSync(handle, ["set", "hwdec", "auto"]) + } + + deinit { + stop() + } + + func start() throws { + guard !isRunning else { return } + guard let handle = mpv_create() else { + throw RendererError.mpvCreationFailed + } + mpv = handle + + // Logging - only warnings and errors in release, verbose in debug + #if DEBUG + checkError(mpv_request_log_messages(handle, "warn")) + #else + checkError(mpv_request_log_messages(handle, "no")) + #endif + + // Pass the AVSampleBufferDisplayLayer to mpv via --wid + // The vo_avfoundation driver expects this + let layerPtrInt = Int(bitPattern: Unmanaged.passUnretained(displayLayer).toOpaque()) + var displayLayerPtr = Int64(layerPtrInt) + checkError(mpv_set_option(handle, "wid", MPV_FORMAT_INT64, &displayLayerPtr)) + + // Use AVFoundation video output - required for PiP support + checkError(mpv_set_option_string(handle, "vo", "avfoundation")) + + // Composite OSD mode - renders subtitles directly onto video frames using GPU. + // CRITICAL: Must be set immediately after vo=avfoundation, before hwdec options. + // Moving this elsewhere causes tvOS to freeze when exiting the player. + // tvOS: "no" (breaks subtitle rendering; note: subtitle styling won't work). + // Simulator: "no" (no VideoToolbox support). + // iOS device: "yes" for PiP subtitle support. + #if os(tvOS) || targetEnvironment(simulator) + checkError(mpv_set_option_string(handle, "avfoundation-composite-osd", "no")) + #else + checkError(mpv_set_option_string(handle, "avfoundation-composite-osd", "yes")) + #endif + + // Hardware decoding with VideoToolbox + // On simulator, use software decoding since VideoToolbox is not available + // On device, use VideoToolbox with software fallback enabled + #if targetEnvironment(simulator) + checkError(mpv_set_option_string(handle, "hwdec", "no")) + #else + checkError(mpv_set_option_string(handle, "hwdec", "videotoolbox")) + #endif + checkError(mpv_set_option_string(handle, "hwdec-codecs", "all")) + checkError(mpv_set_option_string(handle, "hwdec-software-fallback", "yes")) + + // HDR passthrough - signal content colorspace to display system + // This prevents tone-mapping and allows HDR content to pass through + #if os(tvOS) + checkError(mpv_set_option_string(handle, "target-colorspace-hint", "yes")) + #endif + + // Subtitle and audio settings + checkError(mpv_set_option_string(mpv, "sub-scale-with-window", "no")) + checkError(mpv_set_option_string(mpv, "sub-use-margins", "no")) + checkError(mpv_set_option_string(mpv, "subs-match-os-language", "yes")) + checkError(mpv_set_option_string(mpv, "subs-fallback", "yes")) + + // Initialize mpv + let initStatus = mpv_initialize(handle) + guard initStatus >= 0 else { + throw RendererError.mpvInitialization(initStatus) + } + + // Observe properties + observeProperties() + + // Setup wakeup callback + mpv_set_wakeup_callback(handle, { ctx in + guard let ctx = ctx else { return } + let instance = Unmanaged.fromOpaque(ctx).takeUnretainedValue() + instance.processEvents() + }, Unmanaged.passUnretained(self).toOpaque()) + isRunning = true + } + + func stop() { + if isStopping { return } + if !isRunning, mpv == nil { return } + isRunning = false + isStopping = true + + // Stop observing display layer status + statusObservation?.invalidate() + statusObservation = nil + + // Clear wakeup callback first to stop event processing + if let handle = mpv { + mpv_set_wakeup_callback(handle, nil, nil) + + // Send quit command and drain events on the mpv queue + queue.sync { [weak self] in + guard let self, let handle = self.mpv else { return } + self.commandSync(handle, ["quit"]) + + // Drain any remaining events after quit + var drainCount = 0 + let maxDrain = 100 + while drainCount < maxDrain, let event = mpv_wait_event(handle, 0.1)?.pointee { + if event.event_id == MPV_EVENT_NONE || event.event_id == MPV_EVENT_SHUTDOWN { + break + } + drainCount += 1 + } + } + + // Call mpv_terminate_destroy on a background thread to avoid blocking main + // mpv_terminate_destroy may need main thread for AVFoundation cleanup, + // so we can't call it while blocking main with queue.sync + let handleToDestroy = handle + mpv = nil // Clear immediately so nothing else uses it + DispatchQueue.global(qos: .userInitiated).async { + mpv_terminate_destroy(handleToDestroy) + } + } + + DispatchQueue.main.async { [weak self] in + guard let self else { return } + if #available(iOS 18.0, tvOS 17.0, *) { + self.displayLayer.sampleBufferRenderer.flush(removingDisplayedImage: true, completionHandler: nil) + } else { + self.displayLayer.flushAndRemoveImage() + } + } + + isStopping = false + } + + func load( + url: URL, + with preset: PlayerPreset, + headers: [String: String]? = nil, + startPosition: Double? = nil, + externalSubtitles: [String]? = nil, + initialSubtitleId: Int? = nil, + initialAudioId: Int? = nil, + cacheEnabled: String? = nil, + cacheSeconds: Int? = nil, + demuxerMaxBytes: Int? = nil, + demuxerMaxBackBytes: Int? = nil + ) { + currentPreset = preset + currentURL = url + currentHeaders = headers + pendingExternalSubtitles = externalSubtitles ?? [] + self.initialSubtitleId = initialSubtitleId + self.initialAudioId = initialAudioId + queue.async { [weak self] in + guard let self else { return } + self.isLoading = true + self.isReadyToSeek = false + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.delegate?.renderer(self, didChangeLoading: true) + } + + guard let handle = self.mpv else { return } + + self.apply(commands: preset.commands, on: handle) + // Stop previous playback before loading new file + self.command(handle, ["stop"]) + self.updateHTTPHeaders(headers) + + // Apply cache/buffer settings + if let cacheMode = cacheEnabled { + self.setProperty(name: "cache", value: cacheMode) + } + if let cacheSecs = cacheSeconds { + self.setProperty(name: "cache-secs", value: String(cacheSecs)) + } + if let maxBytes = demuxerMaxBytes { + self.setProperty(name: "demuxer-max-bytes", value: "\(maxBytes)MiB") + } + if let maxBackBytes = demuxerMaxBackBytes { + self.setProperty(name: "demuxer-max-back-bytes", value: "\(maxBackBytes)MiB") + } + + // Set start position + if let startPos = startPosition, startPos > 0 { + self.setProperty(name: "start", value: String(format: "%.2f", startPos)) + } else { + self.setProperty(name: "start", value: "0") + } + // Set initial audio track if specified + if let audioId = self.initialAudioId, audioId > 0 { + self.setAudioTrack(audioId) + } + // Set initial subtitle track if no external subs + if self.pendingExternalSubtitles.isEmpty { + if let subId = self.initialSubtitleId { + self.setSubtitleTrack(subId) + } else { + self.disableSubtitles() + } + } else { + self.disableSubtitles() + } + let target = url.isFileURL ? url.path : url.absoluteString + self.command(handle, ["loadfile", target, "replace"]) + } + } + + func reloadCurrentItem() { + guard let url = currentURL, let preset = currentPreset else { return } + load(url: url, with: preset, headers: currentHeaders) + } + + func applyPreset(_ preset: PlayerPreset) { + currentPreset = preset + guard let handle = mpv else { return } + queue.async { [weak self] in + guard let self else { return } + self.apply(commands: preset.commands, on: handle) + } + } + + // MARK: - Property Helpers + + private func setOption(name: String, value: String) { + guard let handle = mpv else { return } + checkError(mpv_set_option_string(handle, name, value)) + } + + private func setProperty(name: String, value: String) { + guard let handle = mpv else { return } + let status = mpv_set_property_string(handle, name, value) + if status < 0 { + Logger.shared.log("Failed to set property \(name)=\(value) (\(status))", type: "Warn") + } + } + + private func clearProperty(name: String) { + guard let handle = mpv else { return } + let status = mpv_set_property(handle, name, MPV_FORMAT_NONE, nil) + if status < 0 { + Logger.shared.log("Failed to clear property \(name) (\(status))", type: "Warn") + } + } + + private func updateHTTPHeaders(_ headers: [String: String]?) { + guard let headers, !headers.isEmpty else { + clearProperty(name: "http-header-fields") + return + } + + let headerString = headers + .map { key, value in "\(key): \(value)" } + .joined(separator: "\r\n") + setProperty(name: "http-header-fields", value: headerString) + } + + private func observeProperties() { + guard let handle = mpv else { return } + let properties: [(String, mpv_format)] = [ + ("duration", MPV_FORMAT_DOUBLE), + ("time-pos", MPV_FORMAT_DOUBLE), + ("pause", MPV_FORMAT_FLAG), + ("track-list/count", MPV_FORMAT_INT64), + ("paused-for-cache", MPV_FORMAT_FLAG), + ("demuxer-cache-duration", MPV_FORMAT_DOUBLE), + ("current-ao", MPV_FORMAT_STRING) + ] + for (name, format) in properties { + mpv_observe_property(handle, 0, name, format) + } + } + + private func apply(commands: [[String]], on handle: OpaquePointer) { + for command in commands { + guard !command.isEmpty else { continue } + self.command(handle, command) + } + } + + private func command(_ handle: OpaquePointer, _ args: [String]) { + guard !args.isEmpty else { return } + _ = withCStringArray(args) { pointer in + mpv_command_async(handle, 0, pointer) + } + } + + @discardableResult + private func commandSync(_ handle: OpaquePointer, _ args: [String]) -> Int32 { + guard !args.isEmpty else { return -1 } + return withCStringArray(args) { pointer in + mpv_command(handle, pointer) + } + } + + private func checkError(_ status: CInt) { + if status < 0 { + Logger.shared.log("MPV API error: \(String(cString: mpv_error_string(status)))", type: "Error") + } + } + + // MARK: - Event Handling + + private func processEvents() { + queue.async { [weak self] in + guard let self else { return } + + while self.mpv != nil && !self.isStopping { + guard let handle = self.mpv, + let eventPointer = mpv_wait_event(handle, 0) else { return } + let event = eventPointer.pointee + if event.event_id == MPV_EVENT_NONE { break } + self.handleEvent(event) + if event.event_id == MPV_EVENT_SHUTDOWN { break } + } + } + } + + private func handleEvent(_ event: mpv_event) { + switch event.event_id { + case MPV_EVENT_FILE_LOADED: + // Add external subtitles now that the file is loaded + if !pendingExternalSubtitles.isEmpty, let handle = mpv { + for (index, subUrl) in pendingExternalSubtitles.enumerated() { + print("🔧 Adding external subtitle [\(index)]: \(subUrl)") + // Use commandSync to ensure subs are added in exact order (not async) + // "auto" flag = add without auto-selecting + commandSync(handle, ["sub-add", subUrl, "auto"]) + } + pendingExternalSubtitles = [] + } + // Apply the initial audio/subtitle selection now that the file's + // tracks are enumerated. Setting sid/aid before `loadfile` does not + // reliably stick for embedded tracks (the selection is silently + // dropped), so we (re)apply here for embedded and external alike. + // This is what makes a carried-over subtitle show up on the next + // episode without a manual re-selection. + if let audioId = initialAudioId, audioId > 0 { + setAudioTrack(audioId) + } + if let subId = initialSubtitleId { + setSubtitleTrack(subId) + } else { + disableSubtitles() + } + if !isReadyToSeek { + isReadyToSeek = true + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.delegate?.renderer(self, didBecomeReadyToSeek: true) + } + } + // Notify loading ended + if isLoading { + isLoading = false + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.delegate?.renderer(self, didChangeLoading: false) + } + } + + // Detect HDR mode for tvOS display switching + detectHDRMode() + + case MPV_EVENT_SEEK: + // Seek started - show loading indicator and enable immediate progress updates + isSeeking = true + if !isLoading { + isLoading = true + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.delegate?.renderer(self, didChangeLoading: true) + } + } + + case MPV_EVENT_PLAYBACK_RESTART: + // Video playback has started/restarted (including after seek) + isSeeking = false + if isLoading { + isLoading = false + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.delegate?.renderer(self, didChangeLoading: false) + } + } + case MPV_EVENT_PROPERTY_CHANGE: + if let property = event.data?.assumingMemoryBound(to: mpv_event_property.self).pointee.name { + let name = String(cString: property) + refreshProperty(named: name, event: event) + } + + case MPV_EVENT_SHUTDOWN: + Logger.shared.log("mpv shutdown", type: "Warn") + + case MPV_EVENT_LOG_MESSAGE: + if let logMessagePointer = event.data?.assumingMemoryBound(to: mpv_event_log_message.self) { + let component = String(cString: logMessagePointer.pointee.prefix) + let text = String(cString: logMessagePointer.pointee.text) + let lower = text.lowercased() + if lower.contains("error") { + Logger.shared.log("mpv[\(component)] \(text)", type: "Error") + } else if lower.contains("warn") || lower.contains("warning") { + Logger.shared.log("mpv[\(component)] \(text)", type: "Warn") + } + } + default: + break + } + } + + private func refreshProperty(named name: String, event: mpv_event) { + guard let handle = mpv else { return } + switch name { + case "duration": + var value = Double(0) + let status = getProperty(handle: handle, name: name, format: MPV_FORMAT_DOUBLE, value: &value) + if status >= 0 { + cachedDuration = value + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.delegate?.renderer(self, didUpdatePosition: self.cachedPosition, duration: self.cachedDuration, cacheSeconds: self.cachedCacheSeconds) + } + } + case "time-pos": + var value = Double(0) + let status = getProperty(handle: handle, name: name, format: MPV_FORMAT_DOUBLE, value: &value) + if status >= 0 { + cachedPosition = value + // Always update immediately when seeking, otherwise throttle to once per second + let now = CFAbsoluteTimeGetCurrent() + let shouldUpdate = isSeeking || (now - lastProgressUpdateTime >= 1.0) + if shouldUpdate { + lastProgressUpdateTime = now + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.delegate?.renderer(self, didUpdatePosition: self.cachedPosition, duration: self.cachedDuration, cacheSeconds: self.cachedCacheSeconds) + } + } + } + case "demuxer-cache-duration": + var value = Double(0) + let status = getProperty(handle: handle, name: name, format: MPV_FORMAT_DOUBLE, value: &value) + if status >= 0 { + cachedCacheSeconds = value + } + case "pause": + var flag: Int32 = 0 + let status = getProperty(handle: handle, name: name, format: MPV_FORMAT_FLAG, value: &flag) + if status >= 0 { + let newPaused = flag != 0 + if newPaused != isPaused { + isPaused = newPaused + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.delegate?.renderer(self, didChangePause: self.isPaused) + } + } + } + case "paused-for-cache": + var flag: Int32 = 0 + let status = getProperty(handle: handle, name: name, format: MPV_FORMAT_FLAG, value: &flag) + if status >= 0 { + let buffering = flag != 0 + if buffering != isLoading { + isLoading = buffering + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.delegate?.renderer(self, didChangeLoading: buffering) + } + } + } + case "track-list/count": + var trackCount: Int64 = 0 + let status = getProperty(handle: handle, name: name, format: MPV_FORMAT_INT64, value: &trackCount) + if status >= 0 && trackCount > 0 { + Logger.shared.log("Track list updated: \(trackCount) tracks available", type: "Info") + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.delegate?.renderer(self, didBecomeTracksReady: true) + } + } + case "current-ao": + // Audio output is now active - notify delegate + if let aoName = getStringProperty(handle: handle, name: name) { + print("[MPV] 🔊 Audio output selected: \(aoName)") + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.delegate?.renderer(self, didSelectAudioOutput: aoName) + } + } + default: + break + } + } + + private func getStringProperty(handle: OpaquePointer, name: String) -> String? { + var result: String? + if let cString = mpv_get_property_string(handle, name) { + result = String(cString: cString) + mpv_free(cString) + } + return result + } + + @discardableResult + private func getProperty(handle: OpaquePointer, name: String, format: mpv_format, value: inout T) -> Int32 { + return withUnsafeMutablePointer(to: &value) { mutablePointer in + return mpv_get_property(handle, name, format, mutablePointer) + } + } + + @inline(__always) + private func withCStringArray(_ args: [String], body: (UnsafeMutablePointer?>?) -> R) -> R { + var cStrings = [UnsafeMutablePointer?]() + cStrings.reserveCapacity(args.count + 1) + for s in args { + cStrings.append(strdup(s)) + } + cStrings.append(nil) + defer { + for ptr in cStrings where ptr != nil { + free(ptr) + } + } + + return cStrings.withUnsafeMutableBufferPointer { buffer in + return buffer.baseAddress!.withMemoryRebound(to: UnsafePointer?.self, capacity: buffer.count) { rebound in + return body(UnsafeMutablePointer(mutating: rebound)) + } + } + } + + // MARK: - Playback Controls + + func play() { + setProperty(name: "pause", value: "no") + } + + func pausePlayback() { + setProperty(name: "pause", value: "yes") + } + + func togglePause() { + if isPaused { play() } else { pausePlayback() } + } + + func seek(to seconds: Double) { + guard let handle = mpv else { return } + let clamped = max(0, seconds) + cachedPosition = clamped + commandSync(handle, ["seek", String(clamped), "absolute"]) + } + + + + func seek(by seconds: Double) { + guard let handle = mpv else { return } + let newPosition = max(0, cachedPosition + seconds) + cachedPosition = newPosition + commandSync(handle, ["seek", String(seconds), "relative"]) + } + + /// Sync timebase - no-op for vo_avfoundation (mpv handles timing) + func syncTimebase() { + // vo_avfoundation manages its own timebase + } + + func setSpeed(_ speed: Double) { + playbackSpeed = speed + setProperty(name: "speed", value: String(speed)) + } + + func getSpeed() -> Double { + guard let handle = mpv else { return 1.0 } + var speed: Double = 1.0 + getProperty(handle: handle, name: "speed", format: MPV_FORMAT_DOUBLE, value: &speed) + return speed + } + + // MARK: - Subtitle Controls + + func getSubtitleTracks() -> [[String: Any]] { + guard let handle = mpv else { + Logger.shared.log("getSubtitleTracks: mpv handle is nil", type: "Warn") + return [] + } + var tracks: [[String: Any]] = [] + + var trackCount: Int64 = 0 + getProperty(handle: handle, name: "track-list/count", format: MPV_FORMAT_INT64, value: &trackCount) + + for i in 0.. Int { + guard let handle = mpv else { return 0 } + var sid: Int64 = 0 + getProperty(handle: handle, name: "sid", format: MPV_FORMAT_INT64, value: &sid) + return Int(sid) + } + + func addSubtitleFile(url: String, select: Bool = true) { + guard let handle = mpv else { return } + let flag = select ? "select" : "cached" + commandSync(handle, ["sub-add", url, flag]) + } + + // MARK: - Subtitle Positioning + + func setSubtitlePosition(_ position: Int) { + setProperty(name: "sub-pos", value: String(position)) + } + + func setSubtitleScale(_ scale: Double) { + setProperty(name: "sub-scale", value: String(scale)) + } + + func setSubtitleMarginY(_ margin: Int) { + setProperty(name: "sub-margin-y", value: String(margin)) + } + + func setSubtitleAlignX(_ alignment: String) { + setProperty(name: "sub-align-x", value: alignment) + } + + func setSubtitleAlignY(_ alignment: String) { + setProperty(name: "sub-align-y", value: alignment) + } + + func setSubtitleFontSize(_ size: Int) { + setProperty(name: "sub-font-size", value: String(size)) + } + + func setSubtitleBackgroundColor(_ color: String) { + setProperty(name: "sub-back-color", value: color) + } + + func setSubtitleBorderStyle(_ style: String) { + // "outline-and-shadow" (default) or "background-box" (enables background color) + setProperty(name: "sub-border-style", value: style) + } + + func setSubtitleAssOverride(_ mode: String) { + // Controls whether to override ASS subtitle styles + // "no" = keep ASS styles, "force" = override with user settings + setProperty(name: "sub-ass-override", value: mode) + } + + // MARK: - Audio Track Controls + + func getAudioTracks() -> [[String: Any]] { + guard let handle = mpv else { + Logger.shared.log("getAudioTracks: mpv handle is nil", type: "Warn") + return [] + } + var tracks: [[String: Any]] = [] + + var trackCount: Int64 = 0 + getProperty(handle: handle, name: "track-list/count", format: MPV_FORMAT_INT64, value: &trackCount) + + for i in 0.. 0 { + track["channels"] = Int(channels) + } + + var selected: Int32 = 0 + getProperty(handle: handle, name: "track-list/\(i)/selected", format: MPV_FORMAT_FLAG, value: &selected) + track["selected"] = selected != 0 + + Logger.shared.log("getAudioTracks: found audio track id=\(trackId), title=\(track["title"] ?? "none"), lang=\(track["lang"] ?? "none")", type: "Info") + tracks.append(track) + } + + Logger.shared.log("getAudioTracks: returning \(tracks.count) audio tracks", type: "Info") + return tracks + } + + func setAudioTrack(_ trackId: Int) { + guard mpv != nil else { + Logger.shared.log("setAudioTrack: mpv handle is nil", type: "Warn") + return + } + Logger.shared.log("setAudioTrack: setting aid to \(trackId)", type: "Info") + setProperty(name: "aid", value: String(trackId)) + } + + func getCurrentAudioTrack() -> Int { + guard let handle = mpv else { return 0 } + var aid: Int64 = 0 + getProperty(handle: handle, name: "aid", format: MPV_FORMAT_INT64, value: &aid) + return Int(aid) + } + + // MARK: - HDR Detection + + /// Detects the HDR mode of the currently playing video by reading mpv properties + private func detectHDRMode() { + guard let handle = mpv else { return } + + // Get video color properties + let primaries = getStringProperty(handle: handle, name: "video-params/primaries") + let gamma = getStringProperty(handle: handle, name: "video-params/gamma") + + // Get FPS for display criteria + var fps: Double = 24.0 + getProperty(handle: handle, name: "container-fps", format: MPV_FORMAT_DOUBLE, value: &fps) + if fps <= 0 { fps = 24.0 } + + Logger.shared.log("HDR Detection - primaries: \(primaries ?? "nil"), gamma: \(gamma ?? "nil"), fps: \(fps)", type: "Info") + + // Determine HDR mode based on color properties + // bt.2020 primaries with PQ gamma = HDR10 or Dolby Vision + // bt.2020 primaries with HLG gamma = HLG + // Otherwise SDR + let hdrMode: HDRMode + + if primaries == "bt.2020" || primaries == "bt.2020-ncl" { + if gamma == "pq" { + // PQ gamma indicates HDR10 or Dolby Vision + // We'll use hdr10 as the base, Dolby Vision detection would need codec inspection + // For DV Profile 8.1, HDR10 fallback should work + hdrMode = .hdr10 + } else if gamma == "hlg" { + hdrMode = .hlg + } else { + // bt.2020 without HDR gamma - still request HDR mode for wide color + hdrMode = .hdr10 + } + } else { + hdrMode = .sdr + } + + Logger.shared.log("HDR Detection - detected mode: \(hdrMode)", type: "Info") + + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.delegate?.renderer(self, didDetectHDRMode: hdrMode, fps: fps) + } + } + + // MARK: - Technical Info + + func getTechnicalInfo() -> [String: Any] { + guard let handle = mpv else { return [:] } + + var info: [String: Any] = [:] + + // Video dimensions + var videoWidth: Int64 = 0 + var videoHeight: Int64 = 0 + if getProperty(handle: handle, name: "video-params/w", format: MPV_FORMAT_INT64, value: &videoWidth) >= 0 { + info["videoWidth"] = Int(videoWidth) + } + if getProperty(handle: handle, name: "video-params/h", format: MPV_FORMAT_INT64, value: &videoHeight) >= 0 { + info["videoHeight"] = Int(videoHeight) + } + + // Video codec + if let videoCodec = getStringProperty(handle: handle, name: "video-format") { + info["videoCodec"] = videoCodec + } + + // Audio codec + if let audioCodec = getStringProperty(handle: handle, name: "audio-codec-name") { + info["audioCodec"] = audioCodec + } + + // FPS (container fps) + var fps: Double = 0 + if getProperty(handle: handle, name: "container-fps", format: MPV_FORMAT_DOUBLE, value: &fps) >= 0 && fps > 0 { + info["fps"] = fps + } + + // Video bitrate (bits per second) + var videoBitrate: Int64 = 0 + if getProperty(handle: handle, name: "video-bitrate", format: MPV_FORMAT_INT64, value: &videoBitrate) >= 0 && videoBitrate > 0 { + info["videoBitrate"] = Int(videoBitrate) + } + + // Audio bitrate (bits per second) + var audioBitrate: Int64 = 0 + if getProperty(handle: handle, name: "audio-bitrate", format: MPV_FORMAT_INT64, value: &audioBitrate) >= 0 && audioBitrate > 0 { + info["audioBitrate"] = Int(audioBitrate) + } + + // Demuxer cache duration (seconds of video buffered) + var cacheSeconds: Double = 0 + if getProperty(handle: handle, name: "demuxer-cache-duration", format: MPV_FORMAT_DOUBLE, value: &cacheSeconds) >= 0 { + info["cacheSeconds"] = cacheSeconds + } + + // Configured cache limits — read back from mpv to confirm user + // settings actually took effect. mpv stores byte sizes as int64 + // (bytes); convert to MiB for display. + var demuxerMaxBytes: Int64 = 0 + if getProperty(handle: handle, name: "demuxer-max-bytes", format: MPV_FORMAT_INT64, value: &demuxerMaxBytes) >= 0 { + info["demuxerMaxBytes"] = Int(demuxerMaxBytes / (1024 * 1024)) + } + var demuxerMaxBackBytes: Int64 = 0 + if getProperty(handle: handle, name: "demuxer-max-back-bytes", format: MPV_FORMAT_INT64, value: &demuxerMaxBackBytes) >= 0 { + info["demuxerMaxBackBytes"] = Int(demuxerMaxBackBytes / (1024 * 1024)) + } + var cacheSecsLimit: Double = 0 + if getProperty(handle: handle, name: "cache-secs", format: MPV_FORMAT_DOUBLE, value: &cacheSecsLimit) >= 0 { + info["cacheSecsLimit"] = cacheSecsLimit + } + + // Dropped frames + var droppedFrames: Int64 = 0 + if getProperty(handle: handle, name: "frame-drop-count", format: MPV_FORMAT_INT64, value: &droppedFrames) >= 0 { + info["droppedFrames"] = Int(droppedFrames) + } + + // Active video output driver + if let voDriver = getStringProperty(handle: handle, name: "vo") { + info["voDriver"] = voDriver + } + + // Active hardware decoder + if let hwdec = getStringProperty(handle: handle, name: "hwdec-current") { + info["hwdec"] = hwdec + } + + // Estimated video output fps (post-filter) + var estimatedVfFps: Double = 0 + if getProperty(handle: handle, name: "estimated-vf-fps", format: MPV_FORMAT_DOUBLE, value: &estimatedVfFps) >= 0 && estimatedVfFps > 0 { + info["estimatedVfFps"] = estimatedVfFps + } + + return info + } +} diff --git a/modules/mpv-player/ios/MPVNowPlayingManager.swift b/modules/mpv-player/ios/MPVNowPlayingManager.swift new file mode 100644 index 000000000..73dafdbcb --- /dev/null +++ b/modules/mpv-player/ios/MPVNowPlayingManager.swift @@ -0,0 +1,188 @@ +import Foundation +import MediaPlayer +import UIKit +import AVFoundation + +/// Simple manager for Now Playing info and remote commands. +/// Stores all state internally and updates Now Playing when ready. +class MPVNowPlayingManager { + static let shared = MPVNowPlayingManager() + + // State + private var title: String? + private var artist: String? + private var albumTitle: String? + private var cachedArtwork: MPMediaItemArtwork? + private var duration: TimeInterval = 0 + private var position: TimeInterval = 0 + private var isPlaying: Bool = false + private var isCommandsSetup = false + + private var artworkTask: URLSessionDataTask? + + private init() {} + + // MARK: - Audio Session + + func activateAudioSession() { + do { + let session = AVAudioSession.sharedInstance() + try session.setCategory(.playback, mode: .moviePlayback) + try session.setActive(true) + print("[NowPlaying] Audio session activated") + } catch { + print("[NowPlaying] Audio session error: \(error)") + } + } + + func deactivateAudioSession() { + do { + try AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) + print("[NowPlaying] Audio session deactivated") + } catch { + print("[NowPlaying] Deactivation error: \(error)") + } + } + + // MARK: - Remote Commands + + func setupRemoteCommands( + playHandler: @escaping () -> Void, + pauseHandler: @escaping () -> Void, + toggleHandler: @escaping () -> Void, + seekHandler: @escaping (TimeInterval) -> Void, + skipForward: @escaping (TimeInterval) -> Void, + skipBackward: @escaping (TimeInterval) -> Void + ) { + guard !isCommandsSetup else { return } + isCommandsSetup = true + + DispatchQueue.main.async { + UIApplication.shared.beginReceivingRemoteControlEvents() + } + + let cc = MPRemoteCommandCenter.shared() + + cc.playCommand.isEnabled = true + cc.playCommand.addTarget { _ in playHandler(); return .success } + + cc.pauseCommand.isEnabled = true + cc.pauseCommand.addTarget { _ in pauseHandler(); return .success } + + cc.togglePlayPauseCommand.isEnabled = true + cc.togglePlayPauseCommand.addTarget { _ in toggleHandler(); return .success } + + cc.skipForwardCommand.isEnabled = true + cc.skipForwardCommand.preferredIntervals = [15] + cc.skipForwardCommand.addTarget { e in + if let ev = e as? MPSkipIntervalCommandEvent { skipForward(ev.interval) } + return .success + } + + cc.skipBackwardCommand.isEnabled = true + cc.skipBackwardCommand.preferredIntervals = [15] + cc.skipBackwardCommand.addTarget { e in + if let ev = e as? MPSkipIntervalCommandEvent { skipBackward(ev.interval) } + return .success + } + + cc.changePlaybackPositionCommand.isEnabled = true + cc.changePlaybackPositionCommand.addTarget { e in + if let ev = e as? MPChangePlaybackPositionCommandEvent { seekHandler(ev.positionTime) } + return .success + } + + print("[NowPlaying] Remote commands ready") + } + + func cleanupRemoteCommands() { + guard isCommandsSetup else { return } + + let cc = MPRemoteCommandCenter.shared() + cc.playCommand.removeTarget(nil) + cc.pauseCommand.removeTarget(nil) + cc.togglePlayPauseCommand.removeTarget(nil) + cc.skipForwardCommand.removeTarget(nil) + cc.skipBackwardCommand.removeTarget(nil) + cc.changePlaybackPositionCommand.removeTarget(nil) + + DispatchQueue.main.async { + UIApplication.shared.endReceivingRemoteControlEvents() + } + + isCommandsSetup = false + print("[NowPlaying] Remote commands cleaned up") + } + + // MARK: - State Updates (call these whenever data changes) + + /// Set metadata (title, artist, artwork URL) + func setMetadata(title: String?, artist: String?, albumTitle: String?, artworkUrl: String?) { + self.title = title + self.artist = artist + self.albumTitle = albumTitle + + print("[NowPlaying] Metadata: \(title ?? "nil")") + + // Load artwork async + artworkTask?.cancel() + if let urlString = artworkUrl, let url = URL(string: urlString) { + artworkTask = URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in + if let data = data, let image = UIImage(data: data) { + self?.cachedArtwork = MPMediaItemArtwork(boundsSize: image.size) { _ in image } + print("[NowPlaying] Artwork loaded") + DispatchQueue.main.async { self?.refresh() } + } + } + artworkTask?.resume() + } + + refresh() + } + + /// Update playback state (position, duration, playing) + func updatePlayback(position: TimeInterval, duration: TimeInterval, isPlaying: Bool) { + self.position = position + self.duration = duration + self.isPlaying = isPlaying + refresh() + } + + /// Clear everything + func clear() { + artworkTask?.cancel() + title = nil + artist = nil + albumTitle = nil + cachedArtwork = nil + duration = 0 + position = 0 + isPlaying = false + MPNowPlayingInfoCenter.default().nowPlayingInfo = nil + print("[NowPlaying] Cleared") + } + + // MARK: - Private + + /// Refresh Now Playing info if we have enough data + private func refresh() { + guard duration > 0 else { + print("[NowPlaying] refresh skipped - duration is 0") + return + } + + var info: [String: Any] = [ + MPMediaItemPropertyPlaybackDuration: duration, + MPNowPlayingInfoPropertyElapsedPlaybackTime: position, + MPNowPlayingInfoPropertyPlaybackRate: isPlaying ? 1.0 : 0.0 + ] + + if let title { info[MPMediaItemPropertyTitle] = title } + if let artist { info[MPMediaItemPropertyArtist] = artist } + if let albumTitle { info[MPMediaItemPropertyAlbumTitle] = albumTitle } + if let cachedArtwork { info[MPMediaItemPropertyArtwork] = cachedArtwork } + + MPNowPlayingInfoCenter.default().nowPlayingInfo = info + print("[NowPlaying] ✅ Set info: title=\(title ?? "nil"), dur=\(Int(duration))s, pos=\(Int(position))s, rate=\(isPlaying ? 1.0 : 0.0)") + } +} diff --git a/modules/mpv-player/ios/MpvPlayer.podspec b/modules/mpv-player/ios/MpvPlayer.podspec new file mode 100644 index 000000000..4aad64440 --- /dev/null +++ b/modules/mpv-player/ios/MpvPlayer.podspec @@ -0,0 +1,20 @@ +Pod::Spec.new do |s| + s.name = 'MpvPlayer' + s.version = '1.0.0' + s.summary = 'MPV-based video player for Streamyfin (Expo module)' + s.author = 'Streamyfin' + s.homepage = 'https://github.com/streamyfin/streamyfin' + s.platforms = { :ios => '15.1', :tvos => '15.1' } + s.source = { git: '' } + s.static_framework = true + + s.dependency 'ExpoModulesCore' + s.dependency 'MPVKit' + + s.pod_target_xcconfig = { + 'DEFINES_MODULE' => 'YES', + 'SWIFT_COMPILATION_MODE' => 'wholemodule' + } + + s.source_files = "*.{h,m,mm,swift,hpp,cpp}" +end diff --git a/modules/mpv-player/ios/MpvPlayerModule.swift b/modules/mpv-player/ios/MpvPlayerModule.swift new file mode 100644 index 000000000..7e031f37a --- /dev/null +++ b/modules/mpv-player/ios/MpvPlayerModule.swift @@ -0,0 +1,225 @@ +import ExpoModulesCore + +public class MpvPlayerModule: Module { + public func definition() -> ModuleDefinition { + Name("MpvPlayer") + + // Defines event names that the module can send to JavaScript. + Events("onChange") + + // Defines a JavaScript synchronous function that runs the native code on the JavaScript thread. + Function("hello") { + return "Hello from MPV Player! 👋" + } + + // Defines a JavaScript function that always returns a Promise and whose native code + // is by default dispatched on the different thread than the JavaScript runtime runs on. + AsyncFunction("setValueAsync") { (value: String) in + // Send an event to JavaScript. + self.sendEvent("onChange", [ + "value": value + ]) + } + + // Enables the module to be used as a native view. Definition components that are accepted as part of the + // view definition: Prop, Events. + View(MpvPlayerView.self) { + // All video load options are passed via a single "source" prop + Prop("source") { (view: MpvPlayerView, source: [String: Any]?) in + guard let source = source, + let urlString = source["url"] as? String, + let videoURL = URL(string: urlString) else { return } + + // Parse cache config if provided + let cacheConfig = source["cacheConfig"] as? [String: Any] + + let config = VideoLoadConfig( + url: videoURL, + headers: source["headers"] as? [String: String], + externalSubtitles: source["externalSubtitles"] as? [String], + startPosition: source["startPosition"] as? Double, + autoplay: (source["autoplay"] as? Bool) ?? true, + initialSubtitleId: source["initialSubtitleId"] as? Int, + initialAudioId: source["initialAudioId"] as? Int, + cacheEnabled: cacheConfig?["enabled"] as? String, + cacheSeconds: cacheConfig?["cacheSeconds"] as? Int, + demuxerMaxBytes: cacheConfig?["maxBytes"] as? Int, + demuxerMaxBackBytes: cacheConfig?["maxBackBytes"] as? Int + ) + + view.loadVideo(config: config) + } + + // Now Playing metadata for iOS Control Center and Lock Screen + Prop("nowPlayingMetadata") { (view: MpvPlayerView, metadata: [String: Any]?) in + guard let metadata = metadata else { return } + // Convert Any values to String, filtering out nil/null values + var stringMetadata: [String: String] = [:] + for (key, value) in metadata { + if let stringValue = value as? String { + stringMetadata[key] = stringValue + } + } + if !stringMetadata.isEmpty { + view.setNowPlayingMetadata(stringMetadata) + } + } + + // Async function to play video + AsyncFunction("play") { (view: MpvPlayerView) in + view.play() + } + + // Async function to pause video + AsyncFunction("pause") { (view: MpvPlayerView) in + view.pause() + } + + // Synchronously destroy mpv instance + decoder before navigating + // away from the player screen (cross-platform; matches Android). + AsyncFunction("destroy") { (view: MpvPlayerView) in + view.destroy() + } + + // Async function to seek to position + AsyncFunction("seekTo") { (view: MpvPlayerView, position: Double) in + view.seekTo(position: position) + } + + // Async function to seek by offset + AsyncFunction("seekBy") { (view: MpvPlayerView, offset: Double) in + view.seekBy(offset: offset) + } + + // Async function to set playback speed + AsyncFunction("setSpeed") { (view: MpvPlayerView, speed: Double) in + view.setSpeed(speed: speed) + } + + // Function to get current speed + AsyncFunction("getSpeed") { (view: MpvPlayerView) -> Double in + return view.getSpeed() + } + + // Function to check if paused + AsyncFunction("isPaused") { (view: MpvPlayerView) -> Bool in + return view.isPaused() + } + + // Function to get current position + AsyncFunction("getCurrentPosition") { (view: MpvPlayerView) -> Double in + return view.getCurrentPosition() + } + + // Function to get duration + AsyncFunction("getDuration") { (view: MpvPlayerView) -> Double in + return view.getDuration() + } + + // Picture in Picture functions + AsyncFunction("startPictureInPicture") { (view: MpvPlayerView) in + view.startPictureInPicture() + } + + AsyncFunction("stopPictureInPicture") { (view: MpvPlayerView) in + view.stopPictureInPicture() + } + + AsyncFunction("isPictureInPictureSupported") { (view: MpvPlayerView) -> Bool in + return view.isPictureInPictureSupported() + } + + AsyncFunction("isPictureInPictureActive") { (view: MpvPlayerView) -> Bool in + return view.isPictureInPictureActive() + } + + // Subtitle functions + AsyncFunction("getSubtitleTracks") { (view: MpvPlayerView) -> [[String: Any]] in + return view.getSubtitleTracks() + } + + AsyncFunction("setSubtitleTrack") { (view: MpvPlayerView, trackId: Int) in + view.setSubtitleTrack(trackId) + } + + AsyncFunction("disableSubtitles") { (view: MpvPlayerView) in + view.disableSubtitles() + } + + AsyncFunction("getCurrentSubtitleTrack") { (view: MpvPlayerView) -> Int in + return view.getCurrentSubtitleTrack() + } + + AsyncFunction("addSubtitleFile") { (view: MpvPlayerView, url: String, select: Bool) in + view.addSubtitleFile(url: url, select: select) + } + + // Subtitle positioning functions + AsyncFunction("setSubtitlePosition") { (view: MpvPlayerView, position: Int) in + view.setSubtitlePosition(position) + } + + AsyncFunction("setSubtitleScale") { (view: MpvPlayerView, scale: Double) in + view.setSubtitleScale(scale) + } + + AsyncFunction("setSubtitleMarginY") { (view: MpvPlayerView, margin: Int) in + view.setSubtitleMarginY(margin) + } + + AsyncFunction("setSubtitleAlignX") { (view: MpvPlayerView, alignment: String) in + view.setSubtitleAlignX(alignment) + } + + AsyncFunction("setSubtitleAlignY") { (view: MpvPlayerView, alignment: String) in + view.setSubtitleAlignY(alignment) + } + + AsyncFunction("setSubtitleFontSize") { (view: MpvPlayerView, size: Int) in + view.setSubtitleFontSize(size) + } + + AsyncFunction("setSubtitleBackgroundColor") { (view: MpvPlayerView, color: String) in + view.setSubtitleBackgroundColor(color) + } + + AsyncFunction("setSubtitleBorderStyle") { (view: MpvPlayerView, style: String) in + view.setSubtitleBorderStyle(style) + } + + AsyncFunction("setSubtitleAssOverride") { (view: MpvPlayerView, mode: String) in + view.setSubtitleAssOverride(mode) + } + + // Audio track functions + AsyncFunction("getAudioTracks") { (view: MpvPlayerView) -> [[String: Any]] in + return view.getAudioTracks() + } + + AsyncFunction("setAudioTrack") { (view: MpvPlayerView, trackId: Int) in + view.setAudioTrack(trackId) + } + + AsyncFunction("getCurrentAudioTrack") { (view: MpvPlayerView) -> Int in + return view.getCurrentAudioTrack() + } + + // Video scaling functions + AsyncFunction("setZoomedToFill") { (view: MpvPlayerView, zoomed: Bool) in + view.setZoomedToFill(zoomed) + } + + AsyncFunction("isZoomedToFill") { (view: MpvPlayerView) -> Bool in + return view.isZoomedToFill() + } + + // Technical info function + AsyncFunction("getTechnicalInfo") { (view: MpvPlayerView) -> [String: Any] in + return view.getTechnicalInfo() + } + + // Defines events that the view can send to JavaScript + Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady", "onPictureInPictureChange") + } + } +} diff --git a/modules/mpv-player/ios/MpvPlayerView.swift b/modules/mpv-player/ios/MpvPlayerView.swift new file mode 100644 index 000000000..9a4e9612c --- /dev/null +++ b/modules/mpv-player/ios/MpvPlayerView.swift @@ -0,0 +1,748 @@ +import AVFAudio +import AVFoundation +import CoreMedia +import ExpoModulesCore +import MediaPlayer +import UIKit + +/// Configuration for loading a video +struct VideoLoadConfig { + let url: URL + var headers: [String: String]? + var externalSubtitles: [String]? + var startPosition: Double? + var autoplay: Bool + /// MPV subtitle track ID to select on start (1-based, -1 to disable, nil to use default) + var initialSubtitleId: Int? + /// MPV audio track ID to select on start (1-based, nil to use default) + var initialAudioId: Int? + /// Cache/buffer settings + var cacheEnabled: String? // "auto", "yes", or "no" + var cacheSeconds: Int? // Seconds of video to buffer + var demuxerMaxBytes: Int? // Max cache size in MB + var demuxerMaxBackBytes: Int? // Max backward cache size in MB + + init( + url: URL, + headers: [String: String]? = nil, + externalSubtitles: [String]? = nil, + startPosition: Double? = nil, + autoplay: Bool = true, + initialSubtitleId: Int? = nil, + initialAudioId: Int? = nil, + cacheEnabled: String? = nil, + cacheSeconds: Int? = nil, + demuxerMaxBytes: Int? = nil, + demuxerMaxBackBytes: Int? = nil + ) { + self.url = url + self.headers = headers + self.externalSubtitles = externalSubtitles + self.startPosition = startPosition + self.autoplay = autoplay + self.initialSubtitleId = initialSubtitleId + self.initialAudioId = initialAudioId + self.cacheEnabled = cacheEnabled + self.cacheSeconds = cacheSeconds + self.demuxerMaxBytes = demuxerMaxBytes + self.demuxerMaxBackBytes = demuxerMaxBackBytes + } +} + +// This view will be used as a native component. Make sure to inherit from `ExpoView` +// to apply the proper styling (e.g. border radius and shadows). +class MpvPlayerView: ExpoView { + private let displayLayer = AVSampleBufferDisplayLayer() + private var renderer: MPVLayerRenderer? + private var videoContainer: UIView! + private var pipController: PiPController? + let onLoad = EventDispatcher() + let onPlaybackStateChange = EventDispatcher() + let onProgress = EventDispatcher() + let onError = EventDispatcher() + let onTracksReady = EventDispatcher() + let onPictureInPictureChange = EventDispatcher() + + private var currentURL: URL? + private var cachedPosition: Double = 0 + private var cachedDuration: Double = 0 + private var intendedPlayState: Bool = false + private var _isZoomedToFill: Bool = false + private var appStateObserver: NSObjectProtocol? + + // Reference to now playing manager + private let nowPlayingManager = MPVNowPlayingManager.shared + + required init(appContext: AppContext? = nil) { + super.init(appContext: appContext) + setupNotifications() + setupView() + } + + private func setupView() { + clipsToBounds = true + backgroundColor = .black + + videoContainer = UIView() + videoContainer.translatesAutoresizingMaskIntoConstraints = false + videoContainer.backgroundColor = .black + videoContainer.clipsToBounds = true + addSubview(videoContainer) + + displayLayer.frame = bounds + displayLayer.videoGravity = .resizeAspect + #if !os(tvOS) + if #available(iOS 17.0, *) { + displayLayer.wantsExtendedDynamicRangeContent = true + } + #endif + displayLayer.backgroundColor = UIColor.black.cgColor + videoContainer.layer.addSublayer(displayLayer) + + NSLayoutConstraint.activate([ + videoContainer.topAnchor.constraint(equalTo: topAnchor), + videoContainer.leadingAnchor.constraint(equalTo: leadingAnchor), + videoContainer.trailingAnchor.constraint(equalTo: trailingAnchor), + videoContainer.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) + + renderer = MPVLayerRenderer(displayLayer: displayLayer) + renderer?.delegate = self + + // Setup PiP + pipController = PiPController(sampleBufferDisplayLayer: displayLayer) + pipController?.delegate = self + + do { + try renderer?.start() + } catch { + onError(["error": "Failed to start renderer: \(error.localizedDescription)"]) + } + + // Pause playback when app enters background on tvOS + #if os(tvOS) + appStateObserver = NotificationCenter.default.addObserver( + forName: UIApplication.didEnterBackgroundNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.pause() + } + #endif + } + + override func layoutSubviews() { + super.layoutSubviews() + CATransaction.begin() + CATransaction.setDisableActions(true) + displayLayer.frame = videoContainer.bounds + displayLayer.isHidden = false + displayLayer.opacity = 1.0 + CATransaction.commit() + } + + // MARK: - Audio Session & Notifications + + private func configureAudioSession() { + let session = AVAudioSession.sharedInstance() + do { + try session.setCategory(.playback, mode: .moviePlayback, policy: .longFormAudio, options: []) + try session.setActive(true) + } catch { + print("Failed to configure audio session: \(error)") + } + } + + /// Deactivate the session AND reset the category — `setActive(false)` alone + /// leaves `.playback`/`.longFormAudio` on the shared singleton, so any later + /// reactivation (foreground, route change, other modules) re-steals audio. + private func tearDownAudioSession() { + let session = AVAudioSession.sharedInstance() + try? session.setActive(false, options: .notifyOthersOnDeactivation) + try? session.setCategory(.ambient, mode: .default, options: [.mixWithOthers]) + } + + private func setupNotifications() { + // Handle audio session interruptions (e.g., incoming calls, other apps playing audio) + NotificationCenter.default.addObserver( + self, selector: #selector(handleAudioSessionInterruption), + name: AVAudioSession.interruptionNotification, object: nil) + } + + @objc func handleAudioSessionInterruption(_ notification: Notification) { + guard let userInfo = notification.userInfo, + let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt, + let type = AVAudioSession.InterruptionType(rawValue: typeValue) else { + return + } + + switch type { + case .began: + // Interruption began - pause the video + print("[MPV] Audio session interrupted - pausing video") + self.pause() + + case .ended: + // Interruption ended - check if we should resume + if let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt { + let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue) + if options.contains(.shouldResume) { + print("[MPV] Audio session interruption ended - can resume") + // Don't auto-resume - let user manually resume playback + } else { + print("[MPV] Audio session interruption ended - should not resume") + } + } + + @unknown default: + break + } + } + + private func setupRemoteCommands() { + nowPlayingManager.setupRemoteCommands( + playHandler: { [weak self] in self?.play() }, + pauseHandler: { [weak self] in self?.pause() }, + toggleHandler: { [weak self] in + guard let self else { return } + if self.intendedPlayState { self.pause() } else { self.play() } + }, + seekHandler: { [weak self] time in self?.seekTo(position: time) }, + skipForward: { [weak self] interval in self?.seekBy(offset: interval) }, + skipBackward: { [weak self] interval in self?.seekBy(offset: -interval) } + ) + } + + // MARK: - Now Playing Info + + func setNowPlayingMetadata(_ metadata: [String: String]) { + print("[MPV] setNowPlayingMetadata: \(metadata["title"] ?? "nil")") + nowPlayingManager.setMetadata( + title: metadata["title"], + artist: metadata["artist"], + albumTitle: metadata["albumTitle"], + artworkUrl: metadata["artworkUri"] + ) + } + + private func clearNowPlayingInfo() { + nowPlayingManager.cleanupRemoteCommands() + nowPlayingManager.deactivateAudioSession() + nowPlayingManager.clear() + } + + func loadVideo(config: VideoLoadConfig) { + // Skip reload if same URL is already playing + if currentURL == config.url { + return + } + currentURL = config.url + + let preset = PlayerPreset( + id: .sdrRec709, + title: "Default", + summary: "Default playback preset", + stream: nil, + commands: [] + ) + + // Pass everything to the renderer - it handles start position and external subs + renderer?.load( + url: config.url, + with: preset, + headers: config.headers, + startPosition: config.startPosition, + externalSubtitles: config.externalSubtitles, + initialSubtitleId: config.initialSubtitleId, + initialAudioId: config.initialAudioId, + cacheEnabled: config.cacheEnabled, + cacheSeconds: config.cacheSeconds, + demuxerMaxBytes: config.demuxerMaxBytes, + demuxerMaxBackBytes: config.demuxerMaxBackBytes + ) + + if config.autoplay { + play() + } + + onLoad(["url": config.url.absoluteString]) + } + + // Convenience method for simple loads + func loadVideo(url: URL, headers: [String: String]? = nil) { + loadVideo(config: VideoLoadConfig(url: url, headers: headers)) + } + + func play() { + intendedPlayState = true + configureAudioSession() + setupRemoteCommands() + renderer?.play() + pipController?.setPlaybackRate(1.0) + pipController?.updatePlaybackState() + } + + func pause() { + intendedPlayState = false + renderer?.pausePlayback() + pipController?.setPlaybackRate(0.0) + pipController?.updatePlaybackState() + } + + /** + * Synchronously stop and destroy the mpv instance + decoder so memory is + * freed before the next screen mounts. Safe to call multiple times — the + * underlying renderer.stop() guards against re-entry. + * + * Cross-platform counterpart of MpvPlayerView.destroy() on Android. + */ + func destroy() { + renderer?.stop() + + // Reset view state and re-create the mpv handle so a subsequent + // loadVideo() on the SAME view instance can actually load. + // Without this, stop() leaves renderer.mpv == nil, and the next + // loadVideo(config:) calls renderer.load() which early-returns + // at `guard let handle = self.mpv else { return }` — but only + // after flipping isLoading = true and dispatching the loading + // delegate callback, so the JS layer is stuck in a perpetual + // "loading" state with no actual playback. + // + // This path is hit by direct-player.tsx's goToNextItem()/stop(), + // which call destroy() immediately before router.replace() to + // the same route — Expo Router reuses the same MpvPlayerView + // instance, so the next `source` prop update arrives on this + // view without a remount. setupView() is otherwise the only + // place start() is called, so without re-starting here the + // renderer stays dead until the whole view is unmounted and + // recreated. + // + // start() is idempotent (`guard !isRunning else { return }`) + // and stop() has already nulled mpv synchronously before + // dispatching the async mpv_terminate_destroy, so creating a + // fresh handle here is safe even while the old handle's + // teardown is still in flight on a background queue (libmpv + // handles are independent). + currentURL = nil + intendedPlayState = false + do { + try renderer?.start() + } catch { + onError(["error": "Failed to restart renderer after destroy: \(error.localizedDescription)"]) + } + } + + func seekTo(position: Double) { + // Update cached position and Now Playing immediately for smooth Control Center feedback + cachedPosition = position + syncNowPlaying(isPlaying: !isPaused()) + renderer?.seek(to: position) + } + + func seekBy(offset: Double) { + // Update cached position and Now Playing immediately for smooth Control Center feedback + let newPosition = max(0, min(cachedPosition + offset, cachedDuration)) + cachedPosition = newPosition + syncNowPlaying(isPlaying: !isPaused()) + renderer?.seek(by: offset) + } + + func setSpeed(speed: Double) { + renderer?.setSpeed(speed) + } + + func getSpeed() -> Double { + return renderer?.getSpeed() ?? 1.0 + } + + func isPaused() -> Bool { + return renderer?.isPausedState ?? true + } + + func getCurrentPosition() -> Double { + return cachedPosition + } + + func getDuration() -> Double { + return cachedDuration + } + + // MARK: - Picture in Picture + + func startPictureInPicture() { + print("🎬 MpvPlayerView: startPictureInPicture called") + print("🎬 Duration: \(getDuration()), IsPlaying: \(!isPaused())") + pipController?.startPictureInPicture() + } + + func stopPictureInPicture() { + pipController?.stopPictureInPicture() + } + + func isPictureInPictureSupported() -> Bool { + return pipController?.isPictureInPictureSupported ?? false + } + + func isPictureInPictureActive() -> Bool { + return pipController?.isPictureInPictureActive ?? false + } + + // MARK: - Subtitle Controls + + func getSubtitleTracks() -> [[String: Any]] { + return renderer?.getSubtitleTracks() ?? [] + } + + func setSubtitleTrack(_ trackId: Int) { + renderer?.setSubtitleTrack(trackId) + } + + func disableSubtitles() { + renderer?.disableSubtitles() + } + + func getCurrentSubtitleTrack() -> Int { + return renderer?.getCurrentSubtitleTrack() ?? 0 + } + + func addSubtitleFile(url: String, select: Bool = true) { + renderer?.addSubtitleFile(url: url, select: select) + } + + // MARK: - Audio Track Controls + + func getAudioTracks() -> [[String: Any]] { + return renderer?.getAudioTracks() ?? [] + } + + func setAudioTrack(_ trackId: Int) { + renderer?.setAudioTrack(trackId) + } + + func getCurrentAudioTrack() -> Int { + return renderer?.getCurrentAudioTrack() ?? 0 + } + + // MARK: - Subtitle Positioning + + func setSubtitlePosition(_ position: Int) { + renderer?.setSubtitlePosition(position) + } + + func setSubtitleScale(_ scale: Double) { + renderer?.setSubtitleScale(scale) + } + + func setSubtitleMarginY(_ margin: Int) { + renderer?.setSubtitleMarginY(margin) + } + + func setSubtitleAlignX(_ alignment: String) { + renderer?.setSubtitleAlignX(alignment) + } + + func setSubtitleAlignY(_ alignment: String) { + renderer?.setSubtitleAlignY(alignment) + } + + func setSubtitleFontSize(_ size: Int) { + renderer?.setSubtitleFontSize(size) + } + + func setSubtitleBackgroundColor(_ color: String) { + renderer?.setSubtitleBackgroundColor(color) + } + + func setSubtitleBorderStyle(_ style: String) { + renderer?.setSubtitleBorderStyle(style) + } + + func setSubtitleAssOverride(_ mode: String) { + renderer?.setSubtitleAssOverride(mode) + } + + // MARK: - Video Scaling + + func setZoomedToFill(_ zoomed: Bool) { + _isZoomedToFill = zoomed + displayLayer.videoGravity = zoomed ? .resizeAspectFill : .resizeAspect + } + + func isZoomedToFill() -> Bool { + return _isZoomedToFill + } + + // MARK: - Technical Info + + func getTechnicalInfo() -> [String: Any] { + return renderer?.getTechnicalInfo() ?? [:] + } + + deinit { + if let observer = appStateObserver { + NotificationCenter.default.removeObserver(observer) + } + #if os(tvOS) + resetDisplayCriteria() + #endif + pipController?.stopPictureInPicture() + renderer?.stop() + displayLayer.removeFromSuperlayer() + clearNowPlayingInfo() + tearDownAudioSession() + NotificationCenter.default.removeObserver(self) + } +} + +// MARK: - MPVLayerRendererDelegate + +extension MpvPlayerView: MPVLayerRendererDelegate { + + // MARK: - Single location for Now Playing updates + private func syncNowPlaying(isPlaying: Bool) { + print("[MPV] syncNowPlaying: pos=\(Int(cachedPosition))s, dur=\(Int(cachedDuration))s, playing=\(isPlaying)") + nowPlayingManager.updatePlayback(position: cachedPosition, duration: cachedDuration, isPlaying: isPlaying) + } + + func renderer(_: MPVLayerRenderer, didUpdatePosition position: Double, duration: Double, cacheSeconds: Double) { + cachedPosition = position + cachedDuration = duration + + DispatchQueue.main.async { [weak self] in + guard let self else { return } + + if self.pipController?.isPictureInPictureActive == true { + self.pipController?.setCurrentTimeFromSeconds(position, duration: duration) + } + + self.onProgress([ + "position": position, + "duration": duration, + "progress": duration > 0 ? position / duration : 0, + "cacheSeconds": cacheSeconds, + ]) + } + } + + func renderer(_: MPVLayerRenderer, didChangePause isPaused: Bool) { + DispatchQueue.main.async { [weak self] in + guard let self else { return } + + print("[MPV] didChangePause: isPaused=\(isPaused), cachedDuration=\(self.cachedDuration)") + self.pipController?.setPlaybackRate(isPaused ? 0.0 : 1.0) + self.syncNowPlaying(isPlaying: !isPaused) + self.onPlaybackStateChange([ + "isPaused": isPaused, + "isPlaying": !isPaused, + ]) + } + } + + func renderer(_: MPVLayerRenderer, didChangeLoading isLoading: Bool) { + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.onPlaybackStateChange([ + "isLoading": isLoading, + ]) + } + } + + func renderer(_: MPVLayerRenderer, didBecomeReadyToSeek: Bool) { + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.onPlaybackStateChange([ + "isReadyToSeek": didBecomeReadyToSeek, + ]) + } + } + + func renderer(_: MPVLayerRenderer, didBecomeTracksReady: Bool) { + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.onTracksReady([:]) + } + } + func renderer(_: MPVLayerRenderer, didDetectHDRMode mode: HDRMode, fps: Double) { + #if os(tvOS) + setDisplayCriteria(for: mode, fps: Float(fps)) + #endif + } + + func renderer(_: MPVLayerRenderer, didSelectAudioOutput audioOutput: String) { + print("[MPV] Audio output ready (\(audioOutput)), syncing Now Playing") + syncNowPlaying(isPlaying: !isPaused()) + } +} + +// MARK: - tvOS HDR Display Criteria + +#if os(tvOS) +import AVKit +import CoreMedia + +extension MpvPlayerView { + /// Creates a CMFormatDescription with HDR metadata for display criteria + private func createHDRFormatDescription(hdrMode: HDRMode) -> CMFormatDescription? { + var formatDescription: CMFormatDescription? + + // Build extensions dictionary for HDR color properties + var extensions: [String: Any] = [ + kCMFormatDescriptionExtension_FullRangeVideo as String: true + ] + + switch hdrMode { + case .hdr10, .dolbyVision: + // HDR10 and Dolby Vision use BT.2020 primaries with PQ transfer function + extensions[kCMFormatDescriptionExtension_ColorPrimaries as String] = kCMFormatDescriptionColorPrimaries_ITU_R_2020 + extensions[kCMFormatDescriptionExtension_TransferFunction as String] = kCMFormatDescriptionTransferFunction_SMPTE_ST_2084_PQ + extensions[kCMFormatDescriptionExtension_YCbCrMatrix as String] = kCMFormatDescriptionYCbCrMatrix_ITU_R_2020 + case .hlg: + // HLG uses BT.2020 primaries with HLG transfer function + extensions[kCMFormatDescriptionExtension_ColorPrimaries as String] = kCMFormatDescriptionColorPrimaries_ITU_R_2020 + extensions[kCMFormatDescriptionExtension_TransferFunction as String] = kCMFormatDescriptionTransferFunction_ITU_R_2100_HLG + extensions[kCMFormatDescriptionExtension_YCbCrMatrix as String] = kCMFormatDescriptionYCbCrMatrix_ITU_R_2020 + case .sdr: + return nil + } + + // Create a video format description with HDR extensions + // Using HEVC codec type and 4K resolution as typical HDR parameters + let status = CMVideoFormatDescriptionCreate( + allocator: kCFAllocatorDefault, + codecType: kCMVideoCodecType_HEVC, + width: 3840, + height: 2160, + extensions: extensions as CFDictionary, + formatDescriptionOut: &formatDescription + ) + + return status == noErr ? formatDescription : nil + } + + /// Sets the preferred display criteria for HDR content on tvOS + func setDisplayCriteria(for hdrMode: HDRMode, fps: Float) { + guard #available(tvOS 17.0, *) else { + print("🎬 HDR: AVDisplayCriteria requires tvOS 17.0+") + return + } + + guard let window = self.window else { + print("🎬 HDR: No window available for display criteria") + return + } + + let manager = window.avDisplayManager + + if hdrMode == .sdr { + print("🎬 HDR: Setting display criteria to SDR (nil)") + manager.preferredDisplayCriteria = nil + return + } + + guard let formatDescription = createHDRFormatDescription(hdrMode: hdrMode) else { + print("🎬 HDR: Failed to create format description for \(hdrMode)") + return + } + + print("🎬 HDR: Setting display criteria to \(hdrMode), fps: \(fps)") + manager.preferredDisplayCriteria = AVDisplayCriteria( + refreshRate: fps, + formatDescription: formatDescription + ) + } + + /// Resets display criteria when playback ends + func resetDisplayCriteria() { + guard #available(tvOS 17.0, *) else { return } + guard let window = self.window else { return } + print("🎬 HDR: Resetting display criteria") + window.avDisplayManager.preferredDisplayCriteria = nil + } +} +#endif + +// MARK: - PiPControllerDelegate + +extension MpvPlayerView: PiPControllerDelegate { + func pipController(_ controller: PiPController, willStartPictureInPicture: Bool) { + print("PiP will start") + // Sync timebase before PiP starts for smooth transition + renderer?.syncTimebase() + // Set current time for PiP progress bar + pipController?.setCurrentTimeFromSeconds(cachedPosition, duration: cachedDuration) + + // Reset to fit for PiP (zoomed video doesn't display correctly in PiP) + if _isZoomedToFill { + displayLayer.videoGravity = .resizeAspect + } + } + + func pipController(_ controller: PiPController, didStartPictureInPicture: Bool) { + print("PiP did start: \(didStartPictureInPicture)") + // Ensure current time is synced when PiP starts + pipController?.setCurrentTimeFromSeconds(cachedPosition, duration: cachedDuration) + // Notify JS of the actual PiP active state. `didStartPictureInPicture` + // is `false` when AVKit reports a failure to start, so reflect that. + onPictureInPictureChange(["isActive": didStartPictureInPicture]) + } + + func pipController(_ controller: PiPController, willStopPictureInPicture: Bool) { + print("PiP will stop") + // Sync timebase before returning from PiP + renderer?.syncTimebase() + } + + func pipController(_ controller: PiPController, didStopPictureInPicture: Bool) { + print("PiP did stop") + // Ensure timebase is synced after PiP ends + renderer?.syncTimebase() + pipController?.updatePlaybackState() + + // Restore the user's zoom preference + if _isZoomedToFill { + displayLayer.videoGravity = .resizeAspectFill + } + // Notify JS that PiP has fully stopped so the controls overlay can + // be re-mounted when the user returns to full screen. + onPictureInPictureChange(["isActive": false]) + } + + func pipController(_ controller: PiPController, restoreUserInterfaceForPictureInPictureStop completionHandler: @escaping (Bool) -> Void) { + print("PiP restore user interface") + completionHandler(true) + } + + func pipControllerPlay(_ controller: PiPController) { + print("PiP play requested") + intendedPlayState = true + renderer?.play() + pipController?.setPlaybackRate(1.0) + } + + func pipControllerPause(_ controller: PiPController) { + print("PiP pause requested") + intendedPlayState = false + renderer?.pausePlayback() + pipController?.setPlaybackRate(0.0) + } + + func pipController(_ controller: PiPController, skipByInterval interval: CMTime) { + let seconds = CMTimeGetSeconds(interval) + print("PiP skip by interval: \(seconds)") + let target = max(0, cachedPosition + seconds) + seekTo(position: target) + } + + func pipControllerIsPlaying(_ controller: PiPController) -> Bool { + // Use intended state to ignore transient pauses during seeking + return intendedPlayState + } + + func pipControllerDuration(_ controller: PiPController) -> Double { + return getDuration() + } + + func pipControllerCurrentPosition(_ controller: PiPController) -> Double { + return getCurrentPosition() + } +} diff --git a/modules/mpv-player/ios/PiPController.swift b/modules/mpv-player/ios/PiPController.swift new file mode 100644 index 000000000..6ad0bec51 --- /dev/null +++ b/modules/mpv-player/ios/PiPController.swift @@ -0,0 +1,243 @@ +import AVKit +import AVFoundation + +protocol PiPControllerDelegate: AnyObject { + func pipController(_ controller: PiPController, willStartPictureInPicture: Bool) + func pipController(_ controller: PiPController, didStartPictureInPicture: Bool) + func pipController(_ controller: PiPController, willStopPictureInPicture: Bool) + func pipController(_ controller: PiPController, didStopPictureInPicture: Bool) + func pipController(_ controller: PiPController, restoreUserInterfaceForPictureInPictureStop completionHandler: @escaping (Bool) -> Void) + func pipControllerPlay(_ controller: PiPController) + func pipControllerPause(_ controller: PiPController) + func pipController(_ controller: PiPController, skipByInterval interval: CMTime) + func pipControllerIsPlaying(_ controller: PiPController) -> Bool + func pipControllerDuration(_ controller: PiPController) -> Double + func pipControllerCurrentPosition(_ controller: PiPController) -> Double +} + +final class PiPController: NSObject { + private var pipController: AVPictureInPictureController? + private weak var sampleBufferDisplayLayer: AVSampleBufferDisplayLayer? + + weak var delegate: PiPControllerDelegate? + + // Timebase for PiP progress tracking + private var timebase: CMTimebase? + + // Track current time for PiP progress + private var currentTime: CMTime = .zero + private var currentDuration: Double = 0 + + var isPictureInPictureSupported: Bool { + return AVPictureInPictureController.isPictureInPictureSupported() + } + + var isPictureInPictureActive: Bool { + return pipController?.isPictureInPictureActive ?? false + } + + var isPictureInPicturePossible: Bool { + return pipController?.isPictureInPicturePossible ?? false + } + + init(sampleBufferDisplayLayer: AVSampleBufferDisplayLayer) { + self.sampleBufferDisplayLayer = sampleBufferDisplayLayer + super.init() + setupTimebase() + setupPictureInPicture() + } + + private func setupTimebase() { + // Create a timebase for tracking playback time + var newTimebase: CMTimebase? + let status = CMTimebaseCreateWithSourceClock( + allocator: kCFAllocatorDefault, + sourceClock: CMClockGetHostTimeClock(), + timebaseOut: &newTimebase + ) + + if status == noErr, let tb = newTimebase { + timebase = tb + CMTimebaseSetTime(tb, time: .zero) + CMTimebaseSetRate(tb, rate: 0) // Start paused + + // Set the control timebase on the display layer + sampleBufferDisplayLayer?.controlTimebase = tb + } + } + + private func setupPictureInPicture() { + guard isPictureInPictureSupported, + let displayLayer = sampleBufferDisplayLayer else { + return + } + + let contentSource = AVPictureInPictureController.ContentSource( + sampleBufferDisplayLayer: displayLayer, + playbackDelegate: self + ) + + pipController = AVPictureInPictureController(contentSource: contentSource) + pipController?.delegate = self + pipController?.requiresLinearPlayback = false + #if !os(tvOS) + pipController?.canStartPictureInPictureAutomaticallyFromInline = true + #endif + } + + func startPictureInPicture() { + guard let pipController = pipController, + pipController.isPictureInPicturePossible else { + return + } + + pipController.startPictureInPicture() + } + + func stopPictureInPicture() { + pipController?.stopPictureInPicture() + } + + func invalidate() { + if Thread.isMainThread { + pipController?.invalidatePlaybackState() + } else { + DispatchQueue.main.async { [weak self] in + self?.pipController?.invalidatePlaybackState() + } + } + } + + func updatePlaybackState() { + // Only invalidate when PiP is active to avoid "no context menu visible" warnings + guard isPictureInPictureActive else { return } + + if Thread.isMainThread { + pipController?.invalidatePlaybackState() + } else { + DispatchQueue.main.async { [weak self] in + self?.pipController?.invalidatePlaybackState() + } + } + } + + /// Updates the current playback time for PiP progress display + func setCurrentTime(_ time: CMTime) { + currentTime = time + + // Update the timebase to reflect current position + if let tb = timebase { + CMTimebaseSetTime(tb, time: time) + } + + // Only invalidate when PiP is active to avoid unnecessary updates + if isPictureInPictureActive { + updatePlaybackState() + } + } + + /// Updates the current playback time from seconds + func setCurrentTimeFromSeconds(_ seconds: Double, duration: Double) { + guard seconds >= 0 else { return } + currentDuration = duration + let time = CMTime(seconds: seconds, preferredTimescale: 1000) + setCurrentTime(time) + } + + /// Updates the playback rate on the timebase (1.0 = playing, 0.0 = paused) + func setPlaybackRate(_ rate: Float) { + if let tb = timebase { + CMTimebaseSetRate(tb, rate: Float64(rate)) + } + } + + deinit { + if let tb = timebase { + CMTimebaseSetRate(tb, rate: 0) + } + sampleBufferDisplayLayer?.controlTimebase = nil + timebase = nil + pipController?.delegate = nil + pipController = nil + } +} + +// MARK: - AVPictureInPictureControllerDelegate + +extension PiPController: AVPictureInPictureControllerDelegate { + func pictureInPictureControllerWillStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) { + delegate?.pipController(self, willStartPictureInPicture: true) + } + + func pictureInPictureControllerDidStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) { + delegate?.pipController(self, didStartPictureInPicture: true) + } + + func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, failedToStartPictureInPictureWithError error: Error) { + print("Failed to start PiP: \(error)") + delegate?.pipController(self, didStartPictureInPicture: false) + } + + func pictureInPictureControllerWillStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) { + delegate?.pipController(self, willStopPictureInPicture: true) + } + + func pictureInPictureControllerDidStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) { + delegate?.pipController(self, didStopPictureInPicture: true) + } + + func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void) { + delegate?.pipController(self, restoreUserInterfaceForPictureInPictureStop: completionHandler) + } +} + +// MARK: - AVPictureInPictureSampleBufferPlaybackDelegate + +extension PiPController: AVPictureInPictureSampleBufferPlaybackDelegate { + + func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, setPlaying playing: Bool) { + if playing { + delegate?.pipControllerPlay(self) + } else { + delegate?.pipControllerPause(self) + } + } + + func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, didTransitionToRenderSize newRenderSize: CMVideoDimensions) { + } + + func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, skipByInterval skipInterval: CMTime, completion completionHandler: @escaping () -> Void) { + delegate?.pipController(self, skipByInterval: skipInterval) + completionHandler() + } + + var isPlaying: Bool { + return delegate?.pipControllerIsPlaying(self) ?? false + } + + var timeRangeForPlayback: CMTimeRange { + let duration = delegate?.pipControllerDuration(self) ?? 0 + if duration > 0 { + let cmDuration = CMTime(seconds: duration, preferredTimescale: 1000) + return CMTimeRange(start: .zero, duration: cmDuration) + } + return CMTimeRange(start: .zero, duration: .positiveInfinity) + } + + func pictureInPictureControllerTimeRangeForPlayback(_ pictureInPictureController: AVPictureInPictureController) -> CMTimeRange { + return timeRangeForPlayback + } + + func pictureInPictureControllerIsPlaybackPaused(_ pictureInPictureController: AVPictureInPictureController) -> Bool { + return !isPlaying + } + + func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, setPlaying playing: Bool, completion: @escaping () -> Void) { + if playing { + delegate?.pipControllerPlay(self) + } else { + delegate?.pipControllerPause(self) + } + completion() + } +} \ No newline at end of file diff --git a/modules/mpv-player/ios/PlayerPreset.swift b/modules/mpv-player/ios/PlayerPreset.swift new file mode 100644 index 000000000..38112f051 --- /dev/null +++ b/modules/mpv-player/ios/PlayerPreset.swift @@ -0,0 +1,40 @@ +import Foundation + +struct PlayerPreset: Identifiable, Hashable { + enum Identifier: String, CaseIterable { + case sdrRec709 + case hdr10 + case dolbyVisionP5 + case dolbyVisionP8 + } + + struct Stream: Hashable { + enum Source: Hashable { + case remote(URL) + case bundled(resource: String, withExtension: String) + } + + let source: Source + let note: String + + func resolveURL() -> URL? { + switch source { + case .remote(let url): + return url + case .bundled(let resource, let ext): + return Bundle.main.url(forResource: resource, withExtension: ext) + } + } + } + + let id: Identifier + let title: String + let summary: String + let stream: Stream? + let commands: [[String]] + + static var presets: [PlayerPreset] { + let list: [PlayerPreset] = [] + return list + } +} \ No newline at end of file diff --git a/modules/mpv-player/src/MpvPlayer.types.ts b/modules/mpv-player/src/MpvPlayer.types.ts new file mode 100644 index 000000000..17ee75de3 --- /dev/null +++ b/modules/mpv-player/src/MpvPlayer.types.ts @@ -0,0 +1,178 @@ +import type { StyleProp, ViewStyle } from "react-native"; + +export type OnLoadEventPayload = { + url: string; +}; + +export type OnPlaybackStateChangePayload = { + isPaused?: boolean; + isPlaying?: boolean; + isLoading?: boolean; + isReadyToSeek?: boolean; +}; + +export type OnProgressEventPayload = { + position: number; + duration: number; + progress: number; + /** Seconds of video buffered ahead of current position */ + cacheSeconds: number; +}; + +export type OnErrorEventPayload = { + error: string; +}; + +export type OnTracksReadyEventPayload = Record; + +export type OnPictureInPictureChangePayload = { + isActive: boolean; +}; + +export type NowPlayingMetadata = { + title?: string; + artist?: string; + albumTitle?: string; + artworkUri?: string; +}; + +export type MpvPlayerModuleEvents = { + onChange: (params: ChangeEventPayload) => void; +}; + +export type ChangeEventPayload = { + value: string; +}; + +export type VideoSource = { + url: string; + headers?: Record; + externalSubtitles?: string[]; + startPosition?: number; + autoplay?: boolean; + /** MPV subtitle track ID to select on start (1-based, -1 to disable) */ + initialSubtitleId?: number; + /** MPV audio track ID to select on start (1-based) */ + initialAudioId?: number; + /** MPV cache/buffer configuration */ + cacheConfig?: { + /** Whether caching is enabled: "auto" (default), "yes", or "no" */ + enabled?: "auto" | "yes" | "no"; + /** Seconds of video to buffer (default: 10, range: 5-120) */ + cacheSeconds?: number; + /** Maximum cache size in MB (default: 150, range: 50-500) */ + maxBytes?: number; + /** Maximum backward cache size in MB (default: 50, range: 25-200) */ + maxBackBytes?: number; + }; + /** MPV video output driver (Android only) */ + voDriver?: "gpu-next" | "gpu"; +}; + +export type MpvPlayerViewProps = { + source?: VideoSource; + style?: StyleProp; + /** Metadata for iOS Control Center and Lock Screen now playing info */ + nowPlayingMetadata?: NowPlayingMetadata; + onLoad?: (event: { nativeEvent: OnLoadEventPayload }) => void; + onPlaybackStateChange?: (event: { + nativeEvent: OnPlaybackStateChangePayload; + }) => void; + onProgress?: (event: { nativeEvent: OnProgressEventPayload }) => void; + onError?: (event: { nativeEvent: OnErrorEventPayload }) => void; + onTracksReady?: (event: { nativeEvent: OnTracksReadyEventPayload }) => void; + onPictureInPictureChange?: (event: { + nativeEvent: OnPictureInPictureChangePayload; + }) => void; +}; + +export interface MpvPlayerViewRef { + play: () => Promise; + pause: () => Promise; + /** + * Synchronously destroy the mpv instance + decoder + surface buffers. + * Call before navigating away from the player screen so memory is + * freed before the next screen mounts. Safe to call multiple times. + */ + destroy: () => Promise; + // Pre-libmpv-1.0 alias (kept for source-history reference): + // stop: () => Promise; + seekTo: (position: number) => Promise; + seekBy: (offset: number) => Promise; + setSpeed: (speed: number) => Promise; + getSpeed: () => Promise; + isPaused: () => Promise; + getCurrentPosition: () => Promise; + getDuration: () => Promise; + startPictureInPicture: () => Promise; + stopPictureInPicture: () => Promise; + isPictureInPictureSupported: () => Promise; + isPictureInPictureActive: () => Promise; + // Subtitle controls + getSubtitleTracks: () => Promise; + setSubtitleTrack: (trackId: number) => Promise; + disableSubtitles: () => Promise; + getCurrentSubtitleTrack: () => Promise; + addSubtitleFile: (url: string, select?: boolean) => Promise; + // Subtitle positioning + setSubtitlePosition: (position: number) => Promise; + setSubtitleScale: (scale: number) => Promise; + setSubtitleMarginY: (margin: number) => Promise; + setSubtitleAlignX: (alignment: "left" | "center" | "right") => Promise; + setSubtitleAlignY: (alignment: "top" | "center" | "bottom") => Promise; + setSubtitleFontSize: (size: number) => Promise; + setSubtitleBackgroundColor: (color: string) => Promise; + setSubtitleBorderStyle: ( + style: "outline-and-shadow" | "background-box", + ) => Promise; + setSubtitleAssOverride: (mode: "no" | "force") => Promise; + // Audio controls + getAudioTracks: () => Promise; + setAudioTrack: (trackId: number) => Promise; + getCurrentAudioTrack: () => Promise; + // Video scaling + setZoomedToFill: (zoomed: boolean) => Promise; + isZoomedToFill: () => Promise; + // Technical info + getTechnicalInfo: () => Promise; +} + +export type SubtitleTrack = { + id: number; + title?: string; + lang?: string; + selected?: boolean; +}; + +export type AudioTrack = { + id: number; + title?: string; + lang?: string; + codec?: string; + channels?: number; + selected?: boolean; +}; + +export type TechnicalInfo = { + videoWidth?: number; + videoHeight?: number; + videoCodec?: string; + audioCodec?: string; + fps?: number; + videoBitrate?: number; + audioBitrate?: number; + cacheSeconds?: number; + /** Configured demuxer forward cache cap (MiB), read back from mpv */ + demuxerMaxBytes?: number; + /** Configured demuxer backward cache cap (MiB), read back from mpv */ + demuxerMaxBackBytes?: number; + /** Configured cache-secs floor, read back from mpv */ + cacheSecsLimit?: number; + droppedFrames?: number; + /** Active video output driver (read from MPV at runtime) */ + voDriver?: string; + /** Active hardware decoder (read from MPV at runtime) */ + hwdec?: string; + /** Estimated video output fps (mpv "estimated-vf-fps") */ + estimatedVfFps?: number; +}; diff --git a/modules/mpv-player/src/MpvPlayerModule.ts b/modules/mpv-player/src/MpvPlayerModule.ts new file mode 100644 index 000000000..a1b72af82 --- /dev/null +++ b/modules/mpv-player/src/MpvPlayerModule.ts @@ -0,0 +1,11 @@ +import { NativeModule, requireNativeModule } from "expo"; + +import { MpvPlayerModuleEvents } from "./MpvPlayer.types"; + +declare class MpvPlayerModule extends NativeModule { + hello(): string; + setValueAsync(value: string): Promise; +} + +// This call loads the native module object from the JSI. +export default requireNativeModule("MpvPlayer"); diff --git a/modules/mpv-player/src/MpvPlayerModule.web.ts b/modules/mpv-player/src/MpvPlayerModule.web.ts new file mode 100644 index 000000000..47e29e15c --- /dev/null +++ b/modules/mpv-player/src/MpvPlayerModule.web.ts @@ -0,0 +1,19 @@ +import { NativeModule, registerWebModule } from "expo"; + +import { ChangeEventPayload } from "./MpvPlayer.types"; + +type MpvPlayerModuleEvents = { + onChange: (params: ChangeEventPayload) => void; +}; + +class MpvPlayerModule extends NativeModule { + PI = Math.PI; + async setValueAsync(value: string): Promise { + this.emit("onChange", { value }); + } + hello() { + return "Hello world! 👋"; + } +} + +export default registerWebModule(MpvPlayerModule, "MpvPlayerModule"); diff --git a/modules/mpv-player/src/MpvPlayerView.tsx b/modules/mpv-player/src/MpvPlayerView.tsx new file mode 100644 index 000000000..0119cd8c0 --- /dev/null +++ b/modules/mpv-player/src/MpvPlayerView.tsx @@ -0,0 +1,136 @@ +import { requireNativeView } from "expo"; +import * as React from "react"; +import { useImperativeHandle, useRef } from "react"; + +import { MpvPlayerViewProps, MpvPlayerViewRef } from "./MpvPlayer.types"; + +const NativeView: React.ComponentType = + requireNativeView("MpvPlayer"); + +const PIP_LOG = "[PiP] MpvPlayerView.tsx:"; + +export default React.forwardRef( + function MpvPlayerView(props, ref) { + const nativeRef = useRef(null); + + useImperativeHandle(ref, () => ({ + play: async () => { + await nativeRef.current?.play(); + }, + pause: async () => { + await nativeRef.current?.pause(); + }, + destroy: async () => { + await nativeRef.current?.destroy(); + }, + seekTo: async (position: number) => { + await nativeRef.current?.seekTo(position); + }, + seekBy: async (offset: number) => { + await nativeRef.current?.seekBy(offset); + }, + setSpeed: async (speed: number) => { + await nativeRef.current?.setSpeed(speed); + }, + getSpeed: async () => { + return await nativeRef.current?.getSpeed(); + }, + isPaused: async () => { + return await nativeRef.current?.isPaused(); + }, + getCurrentPosition: async () => { + return await nativeRef.current?.getCurrentPosition(); + }, + getDuration: async () => { + return await nativeRef.current?.getDuration(); + }, + startPictureInPicture: async () => { + console.log(PIP_LOG, "startPictureInPicture → native"); + await nativeRef.current?.startPictureInPicture(); + console.log(PIP_LOG, "startPictureInPicture ← native returned"); + }, + stopPictureInPicture: async () => { + console.log(PIP_LOG, "stopPictureInPicture → native"); + await nativeRef.current?.stopPictureInPicture(); + console.log(PIP_LOG, "stopPictureInPicture ← native returned"); + }, + isPictureInPictureSupported: async () => { + const result = await nativeRef.current?.isPictureInPictureSupported(); + console.log(PIP_LOG, "isPictureInPictureSupported =", result); + return result; + }, + isPictureInPictureActive: async () => { + const result = await nativeRef.current?.isPictureInPictureActive(); + console.log(PIP_LOG, "isPictureInPictureActive =", result); + return result; + }, + getSubtitleTracks: async () => { + return await nativeRef.current?.getSubtitleTracks(); + }, + setSubtitleTrack: async (trackId: number) => { + await nativeRef.current?.setSubtitleTrack(trackId); + }, + disableSubtitles: async () => { + await nativeRef.current?.disableSubtitles(); + }, + getCurrentSubtitleTrack: async () => { + return await nativeRef.current?.getCurrentSubtitleTrack(); + }, + addSubtitleFile: async (url: string, select = true) => { + await nativeRef.current?.addSubtitleFile(url, select); + }, + setSubtitlePosition: async (position: number) => { + await nativeRef.current?.setSubtitlePosition(position); + }, + setSubtitleScale: async (scale: number) => { + await nativeRef.current?.setSubtitleScale(scale); + }, + setSubtitleMarginY: async (margin: number) => { + await nativeRef.current?.setSubtitleMarginY(margin); + }, + setSubtitleAlignX: async (alignment: "left" | "center" | "right") => { + await nativeRef.current?.setSubtitleAlignX(alignment); + }, + setSubtitleAlignY: async (alignment: "top" | "center" | "bottom") => { + await nativeRef.current?.setSubtitleAlignY(alignment); + }, + setSubtitleFontSize: async (size: number) => { + await nativeRef.current?.setSubtitleFontSize(size); + }, + setSubtitleBackgroundColor: async (color: string) => { + await nativeRef.current?.setSubtitleBackgroundColor(color); + }, + setSubtitleBorderStyle: async ( + style: "outline-and-shadow" | "background-box", + ) => { + await nativeRef.current?.setSubtitleBorderStyle(style); + }, + setSubtitleAssOverride: async (mode: "no" | "force") => { + await nativeRef.current?.setSubtitleAssOverride(mode); + }, + // Audio controls + getAudioTracks: async () => { + return await nativeRef.current?.getAudioTracks(); + }, + setAudioTrack: async (trackId: number) => { + await nativeRef.current?.setAudioTrack(trackId); + }, + getCurrentAudioTrack: async () => { + return await nativeRef.current?.getCurrentAudioTrack(); + }, + // Video scaling + setZoomedToFill: async (zoomed: boolean) => { + await nativeRef.current?.setZoomedToFill(zoomed); + }, + isZoomedToFill: async () => { + return await nativeRef.current?.isZoomedToFill(); + }, + // Technical info + getTechnicalInfo: async () => { + return await nativeRef.current?.getTechnicalInfo(); + }, + })); + + return ; + }, +); diff --git a/modules/mpv-player/src/MpvPlayerView.web.tsx b/modules/mpv-player/src/MpvPlayerView.web.tsx new file mode 100644 index 000000000..a5252b4bd --- /dev/null +++ b/modules/mpv-player/src/MpvPlayerView.web.tsx @@ -0,0 +1,17 @@ +import { useTranslation } from "react-i18next"; +import { MpvPlayerViewProps } from "./MpvPlayer.types"; + +export default function MpvPlayerView(props: MpvPlayerViewProps) { + const url = props.source?.url ?? ""; + const { t } = useTranslation(); + return ( +
+