Merge remote-tracking branch 'origin/develop' into fix/maxEpisodes-count

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
This commit is contained in:
Lance Chant
2026-05-31 11:47:54 +02:00
395 changed files with 41605 additions and 5472 deletions

View File

@@ -0,0 +1,162 @@
/**
* Downloaded Subtitles Storage
*
* Persists metadata about client-side downloaded subtitles (from OpenSubtitles).
* Subtitle files are stored in Paths.cache/streamyfin-subtitles/ directory.
* Filenames are prefixed with itemId for organization: {itemId}_{filename}
*
* While files are in cache, metadata is persisted in MMKV so subtitles survive
* app restarts (unless cache is manually cleared by the user).
*
* TV platform only.
*/
import { storage } from "../mmkv";
// MMKV storage key
const DOWNLOADED_SUBTITLES_KEY = "downloadedSubtitles.json";
/**
* Metadata for a downloaded subtitle file
*/
export interface DownloadedSubtitle {
/** Unique identifier (uuid) */
id: string;
/** Jellyfin item ID */
itemId: string;
/** Local file path in documents directory */
filePath: string;
/** Display name */
name: string;
/** 3-letter language code */
language: string;
/** File format (srt, ass, etc.) */
format: string;
/** Source provider */
source: "opensubtitles";
/** Unix timestamp when downloaded */
downloadedAt: number;
}
/**
* Storage structure for downloaded subtitles
*/
interface DownloadedSubtitlesStorage {
/** Map of itemId to array of downloaded subtitles */
byItemId: Record<string, DownloadedSubtitle[]>;
}
/**
* Load the storage from MMKV
*/
function loadStorage(): DownloadedSubtitlesStorage {
try {
const data = storage.getString(DOWNLOADED_SUBTITLES_KEY);
if (data) {
return JSON.parse(data) as DownloadedSubtitlesStorage;
}
} catch {
// Ignore parse errors, return empty storage
}
return { byItemId: {} };
}
/**
* Save the storage to MMKV
*/
function saveStorage(data: DownloadedSubtitlesStorage): void {
try {
storage.set(DOWNLOADED_SUBTITLES_KEY, JSON.stringify(data));
} catch (error) {
console.error("Failed to save downloaded subtitles:", error);
}
}
/**
* Get all downloaded subtitles for a specific Jellyfin item
*/
export function getSubtitlesForItem(itemId: string): DownloadedSubtitle[] {
const data = loadStorage();
return data.byItemId[itemId] ?? [];
}
/**
* Add a downloaded subtitle to storage
*/
export function addDownloadedSubtitle(subtitle: DownloadedSubtitle): void {
const data = loadStorage();
// Initialize array for item if it doesn't exist
if (!data.byItemId[subtitle.itemId]) {
data.byItemId[subtitle.itemId] = [];
}
// Check if subtitle with same id already exists and update it
const existingIndex = data.byItemId[subtitle.itemId].findIndex(
(s) => s.id === subtitle.id,
);
if (existingIndex !== -1) {
// Update existing entry
data.byItemId[subtitle.itemId][existingIndex] = subtitle;
} else {
// Add new entry
data.byItemId[subtitle.itemId].push(subtitle);
}
saveStorage(data);
}
/**
* Remove a downloaded subtitle from storage
*/
export function removeDownloadedSubtitle(
itemId: string,
subtitleId: string,
): void {
const data = loadStorage();
if (data.byItemId[itemId]) {
data.byItemId[itemId] = data.byItemId[itemId].filter(
(s) => s.id !== subtitleId,
);
// Clean up empty arrays
if (data.byItemId[itemId].length === 0) {
delete data.byItemId[itemId];
}
saveStorage(data);
}
}
/**
* Remove all downloaded subtitles for a specific item
*/
export function removeAllSubtitlesForItem(itemId: string): void {
const data = loadStorage();
if (data.byItemId[itemId]) {
delete data.byItemId[itemId];
saveStorage(data);
}
}
/**
* Check if a subtitle file already exists for an item by language
*/
export function hasSubtitleForLanguage(
itemId: string,
language: string,
): boolean {
const subtitles = getSubtitlesForItem(itemId);
return subtitles.some((s) => s.language === language);
}
/**
* Get all downloaded subtitles across all items
*/
export function getAllDownloadedSubtitles(): DownloadedSubtitle[] {
const data = loadStorage();
return Object.values(data.byItemId).flat();
}

View File

@@ -0,0 +1,60 @@
import { atom } from "jotai";
import { storage } from "../mmkv";
const STORAGE_KEY = "selectedTVServer";
export interface SelectedTVServerState {
address: string;
name?: string;
}
/**
* Load the selected TV server from MMKV storage.
*/
function loadSelectedTVServer(): SelectedTVServerState | null {
const stored = storage.getString(STORAGE_KEY);
if (stored) {
try {
return JSON.parse(stored) as SelectedTVServerState;
} catch {
return null;
}
}
return null;
}
/**
* Save the selected TV server to MMKV storage.
*/
function saveSelectedTVServer(server: SelectedTVServerState | null): void {
if (server) {
storage.set(STORAGE_KEY, JSON.stringify(server));
} else {
storage.remove(STORAGE_KEY);
}
}
/**
* Base atom holding the selected TV server state.
*/
const baseSelectedTVServerAtom = atom<SelectedTVServerState | null>(
loadSelectedTVServer(),
);
/**
* Derived atom that persists changes to MMKV storage.
*/
export const selectedTVServerAtom = atom(
(get) => get(baseSelectedTVServerAtom),
(_get, set, newValue: SelectedTVServerState | null) => {
saveSelectedTVServer(newValue);
set(baseSelectedTVServerAtom, newValue);
},
);
/**
* Clear the selected TV server (used when changing servers).
*/
export function clearSelectedTVServer(): void {
storage.remove(STORAGE_KEY);
}

View File

@@ -175,6 +175,14 @@ export enum VideoPlayer {
MPV = 0,
}
// TV Typography scale presets
export enum TVTypographyScale {
Small = "small",
Default = "default",
Large = "large",
ExtraLarge = "extraLarge",
}
// Audio transcoding mode - controls how surround audio is handled
// This controls server-side transcoding behavior for audio streams.
// MPV decodes via FFmpeg and supports most formats, but mobile devices
@@ -187,6 +195,22 @@ export enum AudioTranscodeMode {
AllowAll = "passthrough", // Direct play all audio formats
}
// Inactivity timeout for TV - auto logout after period of no activity
export enum InactivityTimeout {
Disabled = 0,
OneMinute = 60000,
FiveMinutes = 300000,
FifteenMinutes = 900000,
ThirtyMinutes = 1800000,
OneHour = 3600000,
FourHours = 14400000,
TwentyFourHours = 86400000,
}
// MPV cache mode - controls how caching is enabled
export type MpvCacheMode = "auto" | "yes" | "no";
export type MpvVoDriver = "gpu-next" | "gpu";
export type Settings = {
home?: Home | null;
deviceProfile?: "Expo" | "Native" | "Old";
@@ -232,6 +256,15 @@ export type Settings = {
mpvSubtitleAlignX?: "left" | "center" | "right";
mpvSubtitleAlignY?: "top" | "center" | "bottom";
mpvSubtitleFontSize?: number;
mpvSubtitleBackgroundEnabled?: boolean;
mpvSubtitleBackgroundOpacity?: number; // 0-100
// MPV buffer/cache settings
mpvCacheEnabled?: MpvCacheMode;
mpvCacheSeconds?: number;
mpvDemuxerMaxBytes?: number; // MB
mpvDemuxerMaxBackBytes?: number; // MB
// MPV video output driver (Android only)
mpvVoDriver?: MpvVoDriver;
// Gesture controls
enableHorizontalSwipeSkip: boolean;
enableLeftSideBrightnessSwipe: boolean;
@@ -239,8 +272,13 @@ export type Settings = {
hideVolumeSlider: boolean;
hideBrightnessSlider: boolean;
usePopularPlugin: boolean;
showLargeHomeCarousel: boolean;
mergeNextUpAndContinueWatching: boolean;
// TV-specific settings
showHomeBackdrop: boolean;
showTVHeroCarousel: boolean;
tvTypographyScale: TVTypographyScale;
showSeriesPosterOnEpisode: boolean;
tvThemeMusicEnabled: boolean;
// Appearance
hideRemoteSessionButton: boolean;
hideWatchlistsTab: boolean;
@@ -252,6 +290,10 @@ export type Settings = {
preferLocalAudio: boolean;
// Audio transcoding mode
audioTranscodeMode: AudioTranscodeMode;
// OpenSubtitles API key for client-side subtitle fetching
openSubtitlesApiKey?: string;
// TV-only: Inactivity timeout for auto-logout
inactivityTimeout: InactivityTimeout;
};
export interface Lockable<T> {
@@ -317,6 +359,15 @@ export const defaultValues: Settings = {
mpvSubtitleAlignX: undefined,
mpvSubtitleAlignY: undefined,
mpvSubtitleFontSize: undefined,
mpvSubtitleBackgroundEnabled: false,
mpvSubtitleBackgroundOpacity: 75,
// MPV buffer/cache defaults
mpvCacheEnabled: "auto",
mpvCacheSeconds: 10,
mpvDemuxerMaxBytes: 150, // MB
mpvDemuxerMaxBackBytes: 50, // MB
// MPV video output driver defaults (Android only)
mpvVoDriver: "gpu-next",
// Gesture controls
enableHorizontalSwipeSkip: true,
enableLeftSideBrightnessSwipe: true,
@@ -324,8 +375,13 @@ export const defaultValues: Settings = {
hideVolumeSlider: false,
hideBrightnessSlider: false,
usePopularPlugin: true,
showLargeHomeCarousel: false,
mergeNextUpAndContinueWatching: false,
// TV-specific settings
showHomeBackdrop: true,
showTVHeroCarousel: true,
tvTypographyScale: TVTypographyScale.Default,
showSeriesPosterOnEpisode: false,
tvThemeMusicEnabled: true,
// Appearance
hideRemoteSessionButton: false,
hideWatchlistsTab: false,
@@ -337,6 +393,8 @@ export const defaultValues: Settings = {
preferLocalAudio: true,
// Audio transcoding mode
audioTranscodeMode: AudioTranscodeMode.Auto,
// TV-only: Inactivity timeout (disabled by default)
inactivityTimeout: InactivityTimeout.Disabled,
};
const loadSettings = (): Partial<Settings> => {
@@ -382,6 +440,14 @@ export const pluginSettingsAtom = atom<PluginLockableSettings | undefined>(
loadPluginSettings(),
);
const hasMeaningfulSettingValue = (value: unknown) =>
value !== undefined && value !== null && value !== "";
const getEffectiveSettingValue = <K extends keyof Settings>(
settings: Partial<Settings> | null | undefined,
settingsKey: K,
) => settings?.[settingsKey] ?? defaultValues[settingsKey];
export const useSettings = () => {
const api = useAtomValue(apiAtom);
const [_settings, setSettings] = useAtom(settingsAtom);
@@ -423,6 +489,7 @@ export const useSettings = () => {
for (const [key, setting] of Object.entries(newPluginSettings)) {
if (setting?.locked) {
const settingsKey = key as keyof Settings;
// Normalize and apply locked values unconditionally
(updates as any)[settingsKey] = normalizePluginValue(
settingsKey,
setting.value,
@@ -476,9 +543,9 @@ export const useSettings = () => {
// We do not want to save over users pre-existing settings in case admin ever removes/unlocks a setting.
// If admin sets locked to false but provides a value,
// use user settings first and fallback on admin setting if required.
// use persisted settings first, then app defaults, and only fallback on the
// plugin value when neither provides a meaningful value.
const settings: Settings = useMemo(() => {
const _unlockedPluginDefaults: Partial<Settings> = {};
const overrideSettings = Object.entries(pluginSettings ?? {}).reduce<
Partial<Settings>
>((acc, [key, setting]) => {
@@ -490,18 +557,12 @@ export const useSettings = () => {
// Normalize object-typed settings from plugin (plain primitive → { key, value })
value = normalizePluginValue(settingsKey, value);
// For unlocked settings: use plugin value unless user explicitly
// customized (their saved value differs from the default)
const userVal = _settings?.[settingsKey];
const defaultVal = defaultValues[settingsKey];
const userCustomized =
userVal !== undefined &&
JSON.stringify(userVal) !== JSON.stringify(defaultVal);
const effectiveValue = getEffectiveSettingValue(_settings, settingsKey);
(acc as any)[settingsKey] = locked
? value
: userCustomized
? userVal
: hasMeaningfulSettingValue(effectiveValue)
? effectiveValue
: value;
}
return acc;

View File

@@ -0,0 +1,14 @@
import { atom } from "jotai";
import type {
SavedServer,
SavedServerAccount,
} from "@/utils/secureCredentials";
export type TVAccountActionModalState = {
server: SavedServer;
account: SavedServerAccount;
onLogin: () => void;
onDelete: () => void;
} | null;
export const tvAccountActionModalAtom = atom<TVAccountActionModalState>(null);

View File

@@ -0,0 +1,14 @@
import { atom } from "jotai";
import type {
SavedServer,
SavedServerAccount,
} from "@/utils/secureCredentials";
export type TVAccountSelectModalState = {
server: SavedServer;
onAccountAction: (account: SavedServerAccount) => void;
onAddAccount: () => void;
onDeleteServer: () => void;
} | null;
export const tvAccountSelectModalAtom = atom<TVAccountSelectModalState>(null);

View File

@@ -0,0 +1,18 @@
import { atom } from "jotai";
export type TVOptionItem<T = any> = {
label: string;
sublabel?: string;
value: T;
selected: boolean;
};
export type TVOptionModalState = {
title: string;
options: TVOptionItem[];
onSelect: (value: any) => void;
cardWidth?: number;
cardHeight?: number;
} | null;
export const tvOptionModalAtom = atom<TVOptionModalState>(null);

View File

@@ -0,0 +1,13 @@
import { atom } from "jotai";
import type { MediaType } from "@/utils/jellyseerr/server/constants/media";
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
export type TVRequestModalState = {
requestBody: MediaRequestBody;
title: string;
id: number;
mediaType: MediaType;
onRequested: () => void;
} | null;
export const tvRequestModalAtom = atom<TVRequestModalState>(null);

View File

@@ -0,0 +1,18 @@
import { atom } from "jotai";
import type { MediaStatus } from "@/utils/jellyseerr/server/constants/media";
export type TVSeasonSelectModalState = {
seasons: Array<{
id: number;
seasonNumber: number;
episodeCount: number;
status: MediaStatus;
}>;
title: string;
mediaId: number;
tvdbId?: number;
hasAdvancedRequestPermission: boolean;
onRequested: () => void;
} | null;
export const tvSeasonSelectModalAtom = atom<TVSeasonSelectModalState>(null);

View File

@@ -0,0 +1,14 @@
import { atom } from "jotai";
export type TVSeriesSeasonModalState = {
seasons: Array<{
label: string;
value: number;
selected: boolean;
}>;
selectedSeasonIndex: number | string;
itemId: string;
onSeasonSelect: (seasonIndex: number) => void;
} | null;
export const tvSeriesSeasonModalAtom = atom<TVSeriesSeasonModalState>(null);

View File

@@ -0,0 +1,16 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { atom } from "jotai";
import type { Track } from "@/components/video-player/controls/types";
export type TVSubtitleModalState = {
item: BaseItemDto;
mediaSourceId?: string | null;
subtitleTracks: Track[];
currentSubtitleIndex: number;
onDisableSubtitles?: () => void;
onServerSubtitleDownloaded?: () => void;
onLocalSubtitleDownloaded?: (path: string) => void;
refreshSubtitleTracks?: () => Promise<Track[]>;
} | null;
export const tvSubtitleModalAtom = atom<TVSubtitleModalState>(null);

View File

@@ -0,0 +1,12 @@
import { atom } from "jotai";
import type { SavedServerAccount } from "@/utils/secureCredentials";
export type TVUserSwitchModalState = {
serverUrl: string;
serverName: string;
accounts: SavedServerAccount[];
currentUserId: string;
onAccountSelect: (account: SavedServerAccount) => void;
} | null;
export const tvUserSwitchModalAtom = atom<TVUserSwitchModalState>(null);