mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-05-31 19:18:26 +01:00
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:
162
utils/atoms/downloadedSubtitles.ts
Normal file
162
utils/atoms/downloadedSubtitles.ts
Normal 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();
|
||||
}
|
||||
60
utils/atoms/selectedTVServer.ts
Normal file
60
utils/atoms/selectedTVServer.ts
Normal 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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
14
utils/atoms/tvAccountActionModal.ts
Normal file
14
utils/atoms/tvAccountActionModal.ts
Normal 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);
|
||||
14
utils/atoms/tvAccountSelectModal.ts
Normal file
14
utils/atoms/tvAccountSelectModal.ts
Normal 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);
|
||||
18
utils/atoms/tvOptionModal.ts
Normal file
18
utils/atoms/tvOptionModal.ts
Normal 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);
|
||||
13
utils/atoms/tvRequestModal.ts
Normal file
13
utils/atoms/tvRequestModal.ts
Normal 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);
|
||||
18
utils/atoms/tvSeasonSelectModal.ts
Normal file
18
utils/atoms/tvSeasonSelectModal.ts
Normal 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);
|
||||
14
utils/atoms/tvSeriesSeasonModal.ts
Normal file
14
utils/atoms/tvSeriesSeasonModal.ts
Normal 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);
|
||||
16
utils/atoms/tvSubtitleModal.ts
Normal file
16
utils/atoms/tvSubtitleModal.ts
Normal 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);
|
||||
12
utils/atoms/tvUserSwitchModal.ts
Normal file
12
utils/atoms/tvUserSwitchModal.ts
Normal 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);
|
||||
138
utils/chapters.test.ts
Normal file
138
utils/chapters.test.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
chapterMarkers,
|
||||
chapterNameAt,
|
||||
chapterStartsMs,
|
||||
currentChapterIndex,
|
||||
formatChapterTime,
|
||||
sortedChapters,
|
||||
} from "./chapters";
|
||||
|
||||
// Helper: a ChapterInfo with a start in milliseconds.
|
||||
const ch = (ms: number, name?: string) => ({
|
||||
StartPositionTicks: ms * 10000,
|
||||
Name: name,
|
||||
});
|
||||
|
||||
describe("chapterMarkers", () => {
|
||||
test("maps chapters to position + percent", () => {
|
||||
expect(chapterMarkers([ch(0), ch(30_000), ch(60_000)], 120_000)).toEqual([
|
||||
{ positionMs: 0, percent: 0 },
|
||||
{ positionMs: 30_000, percent: 25 },
|
||||
{ positionMs: 60_000, percent: 50 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("drops chapters past the duration", () => {
|
||||
expect(chapterMarkers([ch(0), ch(200_000)], 120_000)).toEqual([
|
||||
{ positionMs: 0, percent: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("returns [] when duration is 0 or chapters missing", () => {
|
||||
expect(chapterMarkers([ch(0)], 0)).toEqual([]);
|
||||
expect(chapterMarkers(null, 120_000)).toEqual([]);
|
||||
expect(chapterMarkers(undefined, 120_000)).toEqual([]);
|
||||
});
|
||||
|
||||
test("excludes a chapter exactly at the duration", () => {
|
||||
expect(chapterMarkers([ch(0), ch(120_000)], 120_000)).toEqual([
|
||||
{ positionMs: 0, percent: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("skips chapters with no StartPositionTicks", () => {
|
||||
expect(
|
||||
chapterMarkers([{ StartPositionTicks: undefined }, ch(30_000)], 120_000),
|
||||
).toEqual([{ positionMs: 30_000, percent: 25 }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("currentChapterIndex", () => {
|
||||
const chapters = [ch(0), ch(30_000), ch(60_000)];
|
||||
test("returns the chapter containing the position", () => {
|
||||
expect(currentChapterIndex(0, chapters)).toBe(0);
|
||||
expect(currentChapterIndex(15_000, chapters)).toBe(0);
|
||||
expect(currentChapterIndex(30_000, chapters)).toBe(1);
|
||||
expect(currentChapterIndex(90_000, chapters)).toBe(2);
|
||||
});
|
||||
test("returns -1 before the first chapter and for no chapters", () => {
|
||||
expect(currentChapterIndex(-5, chapters)).toBe(-1);
|
||||
expect(currentChapterIndex(10_000, [])).toBe(-1);
|
||||
expect(currentChapterIndex(10_000, null)).toBe(-1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sortedChapters", () => {
|
||||
test("pairs each chapter with its ms start, sorted ascending", () => {
|
||||
const a = ch(60_000, "C");
|
||||
const b = ch(0, "A");
|
||||
const c = ch(30_000, "B");
|
||||
expect(sortedChapters([a, b, c])).toEqual([
|
||||
{ chapter: b, positionMs: 0 },
|
||||
{ chapter: c, positionMs: 30_000 },
|
||||
{ chapter: a, positionMs: 60_000 },
|
||||
]);
|
||||
});
|
||||
test("returns [] for null/undefined", () => {
|
||||
expect(sortedChapters(null)).toEqual([]);
|
||||
expect(sortedChapters(undefined)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("chapterStartsMs", () => {
|
||||
test("returns sorted ms positions", () => {
|
||||
expect(chapterStartsMs([ch(60_000), ch(0), ch(30_000)])).toEqual([
|
||||
0, 30_000, 60_000,
|
||||
]);
|
||||
});
|
||||
|
||||
test("skips entries without StartPositionTicks", () => {
|
||||
expect(
|
||||
chapterStartsMs([ch(30_000), { StartPositionTicks: undefined }, ch(0)]),
|
||||
).toEqual([0, 30_000]);
|
||||
});
|
||||
|
||||
test("returns [] for null/undefined/empty", () => {
|
||||
expect(chapterStartsMs(null)).toEqual([]);
|
||||
expect(chapterStartsMs(undefined)).toEqual([]);
|
||||
expect(chapterStartsMs([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("chapterNameAt", () => {
|
||||
const named = [
|
||||
{ StartPositionTicks: 0, Name: "Intro" },
|
||||
{ StartPositionTicks: 30_000 * 10000, Name: "Action" },
|
||||
{ StartPositionTicks: 60_000 * 10000, Name: "Outro" },
|
||||
];
|
||||
|
||||
test("returns the chapter name for the active position", () => {
|
||||
expect(chapterNameAt(0, named)).toBe("Intro");
|
||||
expect(chapterNameAt(15_000, named)).toBe("Intro");
|
||||
expect(chapterNameAt(45_000, named)).toBe("Action");
|
||||
expect(chapterNameAt(90_000, named)).toBe("Outro");
|
||||
});
|
||||
|
||||
test("returns null before the first chapter", () => {
|
||||
expect(chapterNameAt(-1, named)).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for null/undefined/empty chapters", () => {
|
||||
expect(chapterNameAt(10_000, null)).toBeNull();
|
||||
expect(chapterNameAt(10_000, undefined)).toBeNull();
|
||||
expect(chapterNameAt(10_000, [])).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null when the active chapter has no Name", () => {
|
||||
expect(chapterNameAt(15_000, [ch(0), ch(30_000)])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatChapterTime", () => {
|
||||
test("formats m:ss and h:mm:ss", () => {
|
||||
expect(formatChapterTime(65_000)).toBe("1:05");
|
||||
expect(formatChapterTime(3_725_000)).toBe("1:02:05");
|
||||
expect(formatChapterTime(-100)).toBe("0:00");
|
||||
});
|
||||
});
|
||||
97
utils/chapters.ts
Normal file
97
utils/chapters.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Pure helpers for Jellyfin chapter markers. Dependency-free so they are
|
||||
* unit-testable under `bun test`.
|
||||
*/
|
||||
|
||||
import type { ChapterInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { ticksToMs } from "@/utils/time";
|
||||
|
||||
export interface ChapterMarker {
|
||||
/** Chapter start, in milliseconds. */
|
||||
positionMs: number;
|
||||
/** Chapter start as a percentage (0-100) of the media duration. */
|
||||
percent: number;
|
||||
}
|
||||
|
||||
export interface ChapterEntry {
|
||||
chapter: ChapterInfo;
|
||||
/** Chapter start, in milliseconds. */
|
||||
positionMs: number;
|
||||
}
|
||||
|
||||
/** Chapters paired with their millisecond start, sorted ascending by start. */
|
||||
export const sortedChapters = (
|
||||
chapters: ChapterInfo[] | null | undefined,
|
||||
): ChapterEntry[] =>
|
||||
(chapters ?? [])
|
||||
.filter((c) => c.StartPositionTicks != null)
|
||||
.map((chapter) => ({
|
||||
chapter,
|
||||
positionMs: ticksToMs(chapter.StartPositionTicks),
|
||||
}))
|
||||
.sort((a, b) => a.positionMs - b.positionMs);
|
||||
|
||||
/** Chapter start positions in milliseconds, ascending. */
|
||||
export const chapterStartsMs = (
|
||||
chapters: ChapterInfo[] | null | undefined,
|
||||
): number[] =>
|
||||
(chapters ?? [])
|
||||
.filter((c) => c.StartPositionTicks != null)
|
||||
.map((c) => ticksToMs(c.StartPositionTicks))
|
||||
.sort((a, b) => a - b);
|
||||
|
||||
/** Chapter markers within [0, durationMs]; empty when duration is unknown. */
|
||||
export const chapterMarkers = (
|
||||
chapters: ChapterInfo[] | null | undefined,
|
||||
durationMs: number,
|
||||
): ChapterMarker[] => {
|
||||
if (durationMs <= 0) return [];
|
||||
return chapterStartsMs(chapters)
|
||||
.filter((ms) => ms >= 0 && ms < durationMs)
|
||||
.map((ms) => ({ positionMs: ms, percent: (ms / durationMs) * 100 }));
|
||||
};
|
||||
|
||||
/** Index of the chapter containing `positionMs`, or -1 if before the first. */
|
||||
export const currentChapterIndex = (
|
||||
positionMs: number,
|
||||
chapters: ChapterInfo[] | null | undefined,
|
||||
): number => {
|
||||
const starts = chapterStartsMs(chapters);
|
||||
let index = -1;
|
||||
for (let i = 0; i < starts.length; i++) {
|
||||
if (positionMs >= starts[i]) index = i;
|
||||
else break;
|
||||
}
|
||||
return index;
|
||||
};
|
||||
|
||||
/** Name of the chapter containing `positionMs`, or null if none / unnamed. */
|
||||
export const chapterNameAt = (
|
||||
positionMs: number,
|
||||
chapters: ChapterInfo[] | null | undefined,
|
||||
): string | null => {
|
||||
// Sort once, derive both the active index and the entry from the same array
|
||||
// — `chapterNameAt` runs on every playback tick, so paying for one `sort()`
|
||||
// instead of two is worth the duplication of the index loop here.
|
||||
const sorted = sortedChapters(chapters);
|
||||
let idx = -1;
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
if (positionMs >= sorted[i].positionMs) idx = i;
|
||||
else break;
|
||||
}
|
||||
if (idx < 0) return null;
|
||||
const name = sorted[idx]?.chapter.Name;
|
||||
return name && name.length > 0 ? name : null;
|
||||
};
|
||||
|
||||
/** `m:ss` (or `h:mm:ss` past an hour) label for a millisecond position. */
|
||||
export const formatChapterTime = (positionMs: number): string => {
|
||||
const total = Math.max(0, Math.floor(positionMs / 1000));
|
||||
const hours = Math.floor(total / 3600);
|
||||
const minutes = Math.floor((total % 3600) / 60);
|
||||
const seconds = total % 60;
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return hours > 0
|
||||
? `${hours}:${pad(minutes)}:${pad(seconds)}`
|
||||
: `${minutes}:${pad(seconds)}`;
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Api } from "@jellyfin/sdk";
|
||||
import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import trackPlayerProfile from "@/utils/profiles/trackplayer";
|
||||
import trackPlayerProfile from "../../profiles/trackplayer";
|
||||
|
||||
export interface AudioStreamResult {
|
||||
url: string;
|
||||
|
||||
@@ -12,7 +12,9 @@
|
||||
import type {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
MediaStream,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { BITRATES } from "@/components/BitrateSelector";
|
||||
import { type Settings } from "../atoms/settings";
|
||||
import {
|
||||
@@ -34,20 +36,152 @@ export interface PreviousIndexes {
|
||||
subtitleIndex?: number;
|
||||
}
|
||||
|
||||
export interface PlaySettingsOptions {
|
||||
/** Apply language preferences from settings (used on TV) */
|
||||
applyLanguagePreferences?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a track by language code.
|
||||
*
|
||||
* @param streams - Available media streams
|
||||
* @param languageCode - ISO 639-2 three-letter language code (e.g., "eng", "swe")
|
||||
* @param streamType - Type of stream to search ("Audio" or "Subtitle")
|
||||
* @param forcedOnly - If true, only match forced subtitles
|
||||
* @returns The stream index if found, undefined otherwise
|
||||
*/
|
||||
function findTrackByLanguage(
|
||||
streams: MediaStream[],
|
||||
languageCode: string | undefined,
|
||||
streamType: "Audio" | "Subtitle",
|
||||
forcedOnly = false,
|
||||
): number | undefined {
|
||||
if (!languageCode) return undefined;
|
||||
|
||||
const candidates = streams.filter((s) => {
|
||||
if (s.Type !== streamType) return false;
|
||||
if (forcedOnly && !s.IsForced) return false;
|
||||
// Match on ThreeLetterISOLanguageName (ISO 639-2)
|
||||
return (
|
||||
s.Language?.toLowerCase() === languageCode.toLowerCase() ||
|
||||
// Fallback: some Jellyfin servers use two-letter codes in Language field
|
||||
s.Language?.toLowerCase() === languageCode.substring(0, 2).toLowerCase()
|
||||
);
|
||||
});
|
||||
|
||||
// Prefer default track if multiple match
|
||||
const defaultTrack = candidates.find((s) => s.IsDefault);
|
||||
return defaultTrack?.Index ?? candidates[0]?.Index;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply subtitle mode logic to determine the final subtitle index.
|
||||
*
|
||||
* @param streams - Available media streams
|
||||
* @param settings - User settings containing subtitleMode
|
||||
* @param defaultIndex - The current default subtitle index
|
||||
* @param audioLanguage - The selected audio track's language (for Smart mode)
|
||||
* @param subtitleLanguageCode - The user's preferred subtitle language
|
||||
* @returns The final subtitle index (-1 for disabled)
|
||||
*/
|
||||
function applySubtitleMode(
|
||||
streams: MediaStream[],
|
||||
settings: Settings,
|
||||
defaultIndex: number,
|
||||
audioLanguage: string | undefined,
|
||||
subtitleLanguageCode: string | undefined,
|
||||
): number {
|
||||
const subtitleStreams = streams.filter((s) => s.Type === "Subtitle");
|
||||
const mode = settings.subtitleMode ?? SubtitlePlaybackMode.Default;
|
||||
|
||||
switch (mode) {
|
||||
case SubtitlePlaybackMode.None:
|
||||
// Always disable subtitles
|
||||
return -1;
|
||||
|
||||
case SubtitlePlaybackMode.OnlyForced: {
|
||||
// Only show forced subtitles, prefer matching language
|
||||
const forcedMatch = findTrackByLanguage(
|
||||
streams,
|
||||
subtitleLanguageCode,
|
||||
"Subtitle",
|
||||
true,
|
||||
);
|
||||
if (forcedMatch !== undefined) return forcedMatch;
|
||||
// Fallback to any forced subtitle
|
||||
const anyForced = subtitleStreams.find((s) => s.IsForced);
|
||||
return anyForced?.Index ?? -1;
|
||||
}
|
||||
|
||||
case SubtitlePlaybackMode.Always: {
|
||||
// Always enable subtitles, prefer language match
|
||||
const alwaysMatch = findTrackByLanguage(
|
||||
streams,
|
||||
subtitleLanguageCode,
|
||||
"Subtitle",
|
||||
);
|
||||
if (alwaysMatch !== undefined) return alwaysMatch;
|
||||
// Fallback to first available or current default
|
||||
return subtitleStreams[0]?.Index ?? defaultIndex;
|
||||
}
|
||||
|
||||
case SubtitlePlaybackMode.Smart: {
|
||||
// Enable subtitles only when audio language differs from subtitle preference
|
||||
if (audioLanguage && subtitleLanguageCode) {
|
||||
const audioLang = audioLanguage.toLowerCase();
|
||||
const subLang = subtitleLanguageCode.toLowerCase();
|
||||
// If audio matches subtitle preference, disable subtitles
|
||||
if (
|
||||
audioLang === subLang ||
|
||||
audioLang.startsWith(subLang.substring(0, 2)) ||
|
||||
subLang.startsWith(audioLang.substring(0, 2))
|
||||
) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
// Audio doesn't match preference, enable subtitles
|
||||
const smartMatch = findTrackByLanguage(
|
||||
streams,
|
||||
subtitleLanguageCode,
|
||||
"Subtitle",
|
||||
);
|
||||
return smartMatch ?? subtitleStreams[0]?.Index ?? -1;
|
||||
}
|
||||
default:
|
||||
// Use language preference if set, else keep Jellyfin default
|
||||
if (subtitleLanguageCode) {
|
||||
const langMatch = findTrackByLanguage(
|
||||
streams,
|
||||
subtitleLanguageCode,
|
||||
"Subtitle",
|
||||
);
|
||||
if (langMatch !== undefined) return langMatch;
|
||||
}
|
||||
return defaultIndex;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default play settings for an item.
|
||||
*
|
||||
* @param item - The media item to play
|
||||
* @param settings - User settings (language preferences, bitrate, etc.)
|
||||
* @param previous - Optional previous track selections to carry over (for sequential play)
|
||||
* @param options - Optional flags to control behavior (e.g., applyLanguagePreferences for TV)
|
||||
*/
|
||||
export function getDefaultPlaySettings(
|
||||
item: BaseItemDto,
|
||||
item: BaseItemDto | null | undefined,
|
||||
settings: Settings | null,
|
||||
previous?: { indexes?: PreviousIndexes; source?: MediaSourceInfo },
|
||||
options?: PlaySettingsOptions,
|
||||
): PlaySettings {
|
||||
const bitrate = settings?.defaultBitrate ?? BITRATES[0];
|
||||
|
||||
// Handle undefined/null item
|
||||
if (!item) {
|
||||
return { item: {} as BaseItemDto, bitrate };
|
||||
}
|
||||
|
||||
// Live TV programs don't have media sources
|
||||
if (item.Type === "Program") {
|
||||
return { item, bitrate };
|
||||
@@ -60,6 +194,10 @@ export function getDefaultPlaySettings(
|
||||
let audioIndex = mediaSource?.DefaultAudioStreamIndex;
|
||||
let subtitleIndex = mediaSource?.DefaultSubtitleStreamIndex ?? -1;
|
||||
|
||||
// Track whether we matched previous selections (for language preference fallback)
|
||||
let matchedPreviousAudio = false;
|
||||
let matchedPreviousSubtitle = false;
|
||||
|
||||
// Try to match previous selections (sequential play)
|
||||
if (previous?.indexes && previous?.source && settings) {
|
||||
if (
|
||||
@@ -67,14 +205,22 @@ export function getDefaultPlaySettings(
|
||||
previous.indexes.subtitleIndex !== undefined
|
||||
) {
|
||||
const ranker = new StreamRanker(new SubtitleStreamRanker());
|
||||
const result = { DefaultSubtitleStreamIndex: subtitleIndex };
|
||||
const result = {
|
||||
DefaultSubtitleStreamIndex: subtitleIndex,
|
||||
matched: false,
|
||||
};
|
||||
ranker.rankStream(
|
||||
previous.indexes.subtitleIndex,
|
||||
previous.source,
|
||||
streams,
|
||||
result,
|
||||
);
|
||||
subtitleIndex = result.DefaultSubtitleStreamIndex;
|
||||
// Use the ranker's explicit match signal — this also covers a deliberate
|
||||
// "subtitles off" (-1) and the case where the match equals the default.
|
||||
if (result.matched) {
|
||||
subtitleIndex = result.DefaultSubtitleStreamIndex;
|
||||
matchedPreviousSubtitle = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -82,14 +228,58 @@ export function getDefaultPlaySettings(
|
||||
previous.indexes.audioIndex !== undefined
|
||||
) {
|
||||
const ranker = new StreamRanker(new AudioStreamRanker());
|
||||
const result = { DefaultAudioStreamIndex: audioIndex };
|
||||
const result = { DefaultAudioStreamIndex: audioIndex, matched: false };
|
||||
ranker.rankStream(
|
||||
previous.indexes.audioIndex,
|
||||
previous.source,
|
||||
streams,
|
||||
result,
|
||||
);
|
||||
audioIndex = result.DefaultAudioStreamIndex;
|
||||
// Use the ranker's explicit match signal
|
||||
if (result.matched) {
|
||||
audioIndex = result.DefaultAudioStreamIndex;
|
||||
matchedPreviousAudio = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply language preferences when enabled (TV) and no previous selection matched
|
||||
if (options?.applyLanguagePreferences && settings) {
|
||||
const audioLanguageCode =
|
||||
settings.defaultAudioLanguage?.ThreeLetterISOLanguageName ?? undefined;
|
||||
const subtitleLanguageCode =
|
||||
settings.defaultSubtitleLanguage?.ThreeLetterISOLanguageName ?? undefined;
|
||||
|
||||
// Apply audio language preference if no previous selection matched
|
||||
if (!matchedPreviousAudio && audioLanguageCode) {
|
||||
const langMatch = findTrackByLanguage(
|
||||
streams,
|
||||
audioLanguageCode,
|
||||
"Audio",
|
||||
);
|
||||
if (langMatch !== undefined) {
|
||||
audioIndex = langMatch;
|
||||
}
|
||||
}
|
||||
|
||||
// Get the selected audio track's language for Smart mode
|
||||
const selectedAudioTrack = streams.find(
|
||||
(s) => s.Type === "Audio" && s.Index === audioIndex,
|
||||
);
|
||||
const selectedAudioLanguage =
|
||||
selectedAudioTrack?.Language ??
|
||||
selectedAudioTrack?.DisplayTitle ??
|
||||
undefined;
|
||||
|
||||
// Apply subtitle mode logic if no previous selection matched
|
||||
if (!matchedPreviousSubtitle) {
|
||||
subtitleIndex = applySubtitleMode(
|
||||
streams,
|
||||
settings,
|
||||
subtitleIndex,
|
||||
selectedAudioLanguage,
|
||||
subtitleLanguageCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
32
utils/jellyfin/image/getUserImageUrl.ts
Normal file
32
utils/jellyfin/image/getUserImageUrl.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Retrieves the profile image URL for a Jellyfin user.
|
||||
*
|
||||
* @param serverAddress - The Jellyfin server base URL.
|
||||
* @param userId - The user's ID.
|
||||
* @param primaryImageTag - The user's primary image tag (required for the image to exist).
|
||||
* @param width - The desired image width (default: 280).
|
||||
* @returns The image URL or null if no image tag is provided.
|
||||
*/
|
||||
export const getUserImageUrl = ({
|
||||
serverAddress,
|
||||
userId,
|
||||
primaryImageTag,
|
||||
width = 280,
|
||||
}: {
|
||||
serverAddress: string;
|
||||
userId: string;
|
||||
primaryImageTag?: string | null;
|
||||
width?: number;
|
||||
}): string | null => {
|
||||
if (!primaryImageTag) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
tag: primaryImageTag,
|
||||
quality: "90",
|
||||
width: String(width),
|
||||
});
|
||||
|
||||
return `${serverAddress}/Users/${userId}/Images/Primary?${params.toString()}`;
|
||||
};
|
||||
@@ -7,7 +7,7 @@ import { Bitrate } from "@/components/BitrateSelector";
|
||||
import {
|
||||
type AudioTranscodeModeType,
|
||||
generateDeviceProfile,
|
||||
} from "@/utils/profiles/native";
|
||||
} from "../../profiles/native";
|
||||
import { getDownloadStreamUrl, getStreamUrl } from "./getStreamUrl";
|
||||
|
||||
export const getDownloadUrl = async ({
|
||||
|
||||
@@ -5,13 +5,14 @@ import type {
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { BaseItemKind } from "@jellyfin/sdk/lib/generated-client/models/base-item-kind";
|
||||
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { generateDownloadProfile } from "@/utils/profiles/download";
|
||||
import type { AudioTranscodeModeType } from "@/utils/profiles/native";
|
||||
import { generateDownloadProfile } from "../../profiles/download";
|
||||
import type { AudioTranscodeModeType } from "../../profiles/native";
|
||||
|
||||
interface StreamResult {
|
||||
url: string;
|
||||
sessionId: string | null;
|
||||
mediaSource: MediaSourceInfo | undefined;
|
||||
requiredHttpHeaders?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -50,10 +51,24 @@ const getPlaybackUrl = (
|
||||
return `${api.basePath}${transcodeUrl}`;
|
||||
}
|
||||
|
||||
// Handle remote/external streams (like live TV with external URLs)
|
||||
// These have Protocol "Http" and IsRemote true, with the actual URL in Path
|
||||
if (
|
||||
mediaSource?.IsRemote &&
|
||||
mediaSource?.Protocol === "Http" &&
|
||||
mediaSource?.Path
|
||||
) {
|
||||
console.log("Video is remote stream, using direct Path:", mediaSource.Path);
|
||||
return mediaSource.Path;
|
||||
}
|
||||
|
||||
// Fall back to direct play
|
||||
// Use the mediaSource's actual container when available (important for live TV
|
||||
// where the container may be ts/hls, not mp4)
|
||||
const container = params.container || mediaSource?.Container || "mp4";
|
||||
const streamParams = new URLSearchParams({
|
||||
static: params.static || "true",
|
||||
container: params.container || "mp4",
|
||||
container,
|
||||
mediaSourceId: mediaSource?.Id || "",
|
||||
subtitleStreamIndex: params.subtitleStreamIndex?.toString() || "",
|
||||
audioStreamIndex: params.audioStreamIndex?.toString() || "",
|
||||
@@ -163,6 +178,7 @@ export const getStreamUrl = async ({
|
||||
url: string | null;
|
||||
sessionId: string | null;
|
||||
mediaSource: MediaSourceInfo | undefined;
|
||||
requiredHttpHeaders?: Record<string, string>;
|
||||
} | null> => {
|
||||
if (!api || !userId || !item?.Id) {
|
||||
console.warn("Missing required parameters for getStreamUrl");
|
||||
@@ -210,6 +226,9 @@ export const getStreamUrl = async ({
|
||||
url,
|
||||
sessionId: sessionId || null,
|
||||
mediaSource,
|
||||
requiredHttpHeaders: mediaSource?.RequiredHttpHeaders as
|
||||
| Record<string, string>
|
||||
| undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -254,6 +273,9 @@ export const getStreamUrl = async ({
|
||||
url,
|
||||
sessionId: sessionId || null,
|
||||
mediaSource,
|
||||
requiredHttpHeaders: mediaSource?.RequiredHttpHeaders as
|
||||
| Record<string, string>
|
||||
| undefined,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
289
utils/opensubtitles/api.ts
Normal file
289
utils/opensubtitles/api.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
/**
|
||||
* OpenSubtitles REST API Client
|
||||
* Docs: https://opensubtitles.stoplight.io/docs/opensubtitles-api
|
||||
*
|
||||
* This is a fallback for when the Jellyfin server doesn't have a subtitle provider configured.
|
||||
*/
|
||||
|
||||
const OPENSUBTITLES_API_URL = "https://api.opensubtitles.com/api/v1";
|
||||
|
||||
export interface OpenSubtitlesSearchParams {
|
||||
/** IMDB ID (without "tt" prefix) */
|
||||
imdbId?: string;
|
||||
/** Title for text search */
|
||||
query?: string;
|
||||
/** Year of release */
|
||||
year?: number;
|
||||
/** ISO 639-2B language code (e.g., "eng", "spa") */
|
||||
languages?: string;
|
||||
/** Season number for TV shows */
|
||||
seasonNumber?: number;
|
||||
/** Episode number for TV shows */
|
||||
episodeNumber?: number;
|
||||
}
|
||||
|
||||
export interface OpenSubtitlesFile {
|
||||
file_id: number;
|
||||
file_name: string;
|
||||
}
|
||||
|
||||
export interface OpenSubtitlesFeatureDetails {
|
||||
imdb_id: number;
|
||||
title: string;
|
||||
year: number;
|
||||
feature_type: string;
|
||||
season_number?: number;
|
||||
episode_number?: number;
|
||||
}
|
||||
|
||||
export interface OpenSubtitlesAttributes {
|
||||
subtitle_id: string;
|
||||
language: string;
|
||||
download_count: number;
|
||||
hearing_impaired: boolean;
|
||||
ai_translated: boolean;
|
||||
machine_translated: boolean;
|
||||
fps: number;
|
||||
format: string;
|
||||
from_trusted: boolean;
|
||||
foreign_parts_only: boolean;
|
||||
release: string;
|
||||
files: OpenSubtitlesFile[];
|
||||
feature_details: OpenSubtitlesFeatureDetails;
|
||||
ratings: number;
|
||||
}
|
||||
|
||||
export interface OpenSubtitlesResult {
|
||||
id: string;
|
||||
type: string;
|
||||
attributes: OpenSubtitlesAttributes;
|
||||
}
|
||||
|
||||
export interface OpenSubtitlesSearchResponse {
|
||||
total_count: number;
|
||||
total_pages: number;
|
||||
page: number;
|
||||
data: OpenSubtitlesResult[];
|
||||
}
|
||||
|
||||
export interface OpenSubtitlesDownloadResponse {
|
||||
link: string;
|
||||
file_name: string;
|
||||
requests: number;
|
||||
remaining: number;
|
||||
message: string;
|
||||
reset_time: string;
|
||||
reset_time_utc: string;
|
||||
}
|
||||
|
||||
export class OpenSubtitlesApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public statusCode?: number,
|
||||
public response?: unknown,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "OpenSubtitlesApiError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mapping between ISO 639-1 (2-letter) and ISO 639-2B (3-letter) language codes
|
||||
*/
|
||||
const ISO_639_MAPPING: Record<string, string> = {
|
||||
en: "eng",
|
||||
es: "spa",
|
||||
fr: "fre",
|
||||
de: "ger",
|
||||
it: "ita",
|
||||
pt: "por",
|
||||
ru: "rus",
|
||||
ja: "jpn",
|
||||
ko: "kor",
|
||||
zh: "chi",
|
||||
ar: "ara",
|
||||
pl: "pol",
|
||||
nl: "dut",
|
||||
sv: "swe",
|
||||
no: "nor",
|
||||
da: "dan",
|
||||
fi: "fin",
|
||||
tr: "tur",
|
||||
cs: "cze",
|
||||
el: "gre",
|
||||
he: "heb",
|
||||
hu: "hun",
|
||||
ro: "rum",
|
||||
th: "tha",
|
||||
vi: "vie",
|
||||
id: "ind",
|
||||
ms: "may",
|
||||
bg: "bul",
|
||||
hr: "hrv",
|
||||
sk: "slo",
|
||||
sl: "slv",
|
||||
uk: "ukr",
|
||||
};
|
||||
|
||||
// Reverse mapping: 3-letter to 2-letter
|
||||
const ISO_639_REVERSE: Record<string, string> = Object.fromEntries(
|
||||
Object.entries(ISO_639_MAPPING).map(([k, v]) => [v, k]),
|
||||
);
|
||||
|
||||
/**
|
||||
* Convert ISO 639-2B (3-letter) to ISO 639-1 (2-letter) language code
|
||||
* OpenSubtitles REST API uses 2-letter codes
|
||||
*/
|
||||
function toIso6391(code: string): string {
|
||||
if (code.length === 2) return code;
|
||||
return ISO_639_REVERSE[code.toLowerCase()] || code;
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenSubtitles API client for direct subtitle fetching
|
||||
*/
|
||||
export class OpenSubtitlesApi {
|
||||
private apiKey: string;
|
||||
private userAgent: string;
|
||||
|
||||
constructor(apiKey: string, userAgent = "streamyfin v1.0") {
|
||||
this.apiKey = apiKey;
|
||||
this.userAgent = userAgent;
|
||||
}
|
||||
|
||||
private async request<T>(
|
||||
endpoint: string,
|
||||
options: RequestInit = {},
|
||||
): Promise<T> {
|
||||
const url = `${OPENSUBTITLES_API_URL}${endpoint}`;
|
||||
const headers: HeadersInit = {
|
||||
"Api-Key": this.apiKey,
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": this.userAgent,
|
||||
...options.headers,
|
||||
};
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorBody = await response.text();
|
||||
throw new OpenSubtitlesApiError(
|
||||
`OpenSubtitles API error: ${response.status} ${response.statusText}`,
|
||||
response.status,
|
||||
errorBody,
|
||||
);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for subtitles
|
||||
* Rate limit: 40 requests / 10 seconds
|
||||
*/
|
||||
async search(
|
||||
params: OpenSubtitlesSearchParams,
|
||||
): Promise<OpenSubtitlesSearchResponse> {
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
if (params.imdbId) {
|
||||
// Ensure IMDB ID has "tt" prefix
|
||||
const imdbId = params.imdbId.startsWith("tt")
|
||||
? params.imdbId
|
||||
: `tt${params.imdbId}`;
|
||||
queryParams.set("imdb_id", imdbId);
|
||||
}
|
||||
if (params.query) {
|
||||
queryParams.set("query", params.query);
|
||||
}
|
||||
if (params.year) {
|
||||
queryParams.set("year", params.year.toString());
|
||||
}
|
||||
if (params.languages) {
|
||||
// Convert 3-letter codes to 2-letter codes (API uses ISO 639-1)
|
||||
const lang =
|
||||
params.languages.length === 3
|
||||
? toIso6391(params.languages)
|
||||
: params.languages;
|
||||
queryParams.set("languages", lang);
|
||||
}
|
||||
if (params.seasonNumber !== undefined) {
|
||||
queryParams.set("season_number", params.seasonNumber.toString());
|
||||
}
|
||||
if (params.episodeNumber !== undefined) {
|
||||
queryParams.set("episode_number", params.episodeNumber.toString());
|
||||
}
|
||||
|
||||
return this.request<OpenSubtitlesSearchResponse>(
|
||||
`/subtitles?${queryParams.toString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get download link for a subtitle file
|
||||
* Rate limits:
|
||||
* - Anonymous: 5 downloads/day
|
||||
* - Authenticated: 10 downloads/day (can be increased)
|
||||
*/
|
||||
async download(fileId: number): Promise<OpenSubtitlesDownloadResponse> {
|
||||
return this.request<OpenSubtitlesDownloadResponse>("/download", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ file_id: fileId }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert ISO 639-2B (3-letter) to ISO 639-1 (2-letter) language code
|
||||
* Exported for external use
|
||||
*/
|
||||
export { toIso6391 };
|
||||
|
||||
/**
|
||||
* Convert ISO 639-1 (2-letter) to ISO 639-2B (3-letter) language code
|
||||
*/
|
||||
export function toIso6392B(code: string): string {
|
||||
if (code.length === 3) return code;
|
||||
return ISO_639_MAPPING[code.toLowerCase()] || code;
|
||||
}
|
||||
|
||||
/**
|
||||
* Common subtitle languages for display
|
||||
*/
|
||||
export const COMMON_SUBTITLE_LANGUAGES = [
|
||||
{ code: "eng", name: "English" },
|
||||
{ code: "spa", name: "Spanish" },
|
||||
{ code: "fre", name: "French" },
|
||||
{ code: "ger", name: "German" },
|
||||
{ code: "ita", name: "Italian" },
|
||||
{ code: "por", name: "Portuguese" },
|
||||
{ code: "rus", name: "Russian" },
|
||||
{ code: "jpn", name: "Japanese" },
|
||||
{ code: "kor", name: "Korean" },
|
||||
{ code: "chi", name: "Chinese" },
|
||||
{ code: "ara", name: "Arabic" },
|
||||
{ code: "pol", name: "Polish" },
|
||||
{ code: "dut", name: "Dutch" },
|
||||
{ code: "swe", name: "Swedish" },
|
||||
{ code: "nor", name: "Norwegian" },
|
||||
{ code: "dan", name: "Danish" },
|
||||
{ code: "fin", name: "Finnish" },
|
||||
{ code: "tur", name: "Turkish" },
|
||||
{ code: "cze", name: "Czech" },
|
||||
{ code: "gre", name: "Greek" },
|
||||
{ code: "heb", name: "Hebrew" },
|
||||
{ code: "hun", name: "Hungarian" },
|
||||
{ code: "rom", name: "Romanian" },
|
||||
{ code: "tha", name: "Thai" },
|
||||
{ code: "vie", name: "Vietnamese" },
|
||||
{ code: "ind", name: "Indonesian" },
|
||||
{ code: "may", name: "Malay" },
|
||||
{ code: "bul", name: "Bulgarian" },
|
||||
{ code: "hrv", name: "Croatian" },
|
||||
{ code: "slo", name: "Slovak" },
|
||||
{ code: "slv", name: "Slovenian" },
|
||||
{ code: "ukr", name: "Ukrainian" },
|
||||
];
|
||||
144
utils/pairingService.ts
Normal file
144
utils/pairingService.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import dgram from "react-native-udp";
|
||||
|
||||
const PAIRING_PORT = 54322;
|
||||
const PAIRING_MESSAGE_TYPE = "streamyfin-pair-response";
|
||||
|
||||
export interface PairingCredentials {
|
||||
serverUrl: string;
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export function generatePairingCode(): string {
|
||||
return String(Math.floor(100000 + Math.random() * 900000));
|
||||
}
|
||||
|
||||
export function startPairingListener(
|
||||
code: string,
|
||||
onCredentialsReceived: (credentials: PairingCredentials) => void,
|
||||
onError?: (error: string) => void,
|
||||
): () => void {
|
||||
let active = true;
|
||||
|
||||
const socket = dgram.createSocket({
|
||||
type: "udp4",
|
||||
reusePort: true,
|
||||
debug: __DEV__,
|
||||
});
|
||||
|
||||
socket.on("error", (err) => {
|
||||
if (__DEV__) console.error("[PairingService] Socket error:", err);
|
||||
onError?.(err.message);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
socket.bind(PAIRING_PORT, () => {
|
||||
if (__DEV__)
|
||||
console.log("[PairingService] Listening on port", PAIRING_PORT);
|
||||
});
|
||||
|
||||
socket.on("message", (msg) => {
|
||||
if (!active) return;
|
||||
|
||||
try {
|
||||
const data = JSON.parse(new TextDecoder().decode(msg));
|
||||
|
||||
if (data.type !== PAIRING_MESSAGE_TYPE) return;
|
||||
if (data.code !== code) return;
|
||||
|
||||
if (!data.server_url || !data.username || !data.password) {
|
||||
if (__DEV__)
|
||||
console.error("[PairingService] Missing fields in pairing response");
|
||||
return;
|
||||
}
|
||||
|
||||
active = false;
|
||||
onCredentialsReceived({
|
||||
serverUrl: data.server_url,
|
||||
username: data.username,
|
||||
password: data.password,
|
||||
});
|
||||
cleanup();
|
||||
} catch (error) {
|
||||
if (__DEV__)
|
||||
console.error("[PairingService] Error parsing message:", error);
|
||||
}
|
||||
});
|
||||
|
||||
function cleanup() {
|
||||
active = false;
|
||||
try {
|
||||
socket.close();
|
||||
} catch {
|
||||
// Socket may already be closed
|
||||
}
|
||||
}
|
||||
|
||||
return cleanup;
|
||||
}
|
||||
|
||||
export function sendCredentialsToTV(
|
||||
code: string,
|
||||
serverUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = dgram.createSocket({
|
||||
type: "udp4",
|
||||
reusePort: true,
|
||||
debug: __DEV__,
|
||||
});
|
||||
|
||||
const message = JSON.stringify({
|
||||
type: PAIRING_MESSAGE_TYPE,
|
||||
code,
|
||||
server_url: serverUrl,
|
||||
username,
|
||||
password,
|
||||
});
|
||||
|
||||
const messageBuffer = new TextEncoder().encode(message);
|
||||
|
||||
socket.on("error", (err) => {
|
||||
reject(err);
|
||||
try {
|
||||
socket.close();
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
});
|
||||
|
||||
socket.bind(0, () => {
|
||||
try {
|
||||
socket.setBroadcast(true);
|
||||
socket.send(
|
||||
messageBuffer,
|
||||
0,
|
||||
messageBuffer.length,
|
||||
PAIRING_PORT,
|
||||
"255.255.255.255",
|
||||
(err) => {
|
||||
try {
|
||||
socket.close();
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
try {
|
||||
socket.close();
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
6
utils/profiles/index.ts
Normal file
6
utils/profiles/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export { chromecast } from "./chromecast";
|
||||
export { chromecasth265 } from "./chromecasth265";
|
||||
export { generateDownloadProfile } from "./download";
|
||||
export * from "./native";
|
||||
export { default } from "./native";
|
||||
export { default as trackPlayerProfile } from "./trackplayer";
|
||||
23
utils/profiles/native.d.ts
vendored
23
utils/profiles/native.d.ts
vendored
@@ -1,23 +0,0 @@
|
||||
/**
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* 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 type PlatformType = "ios" | "android";
|
||||
export type PlayerType = "mpv";
|
||||
export type AudioTranscodeModeType = "auto" | "stereo" | "5.1" | "passthrough";
|
||||
|
||||
export interface ProfileOptions {
|
||||
/** Target platform */
|
||||
platform?: PlatformType;
|
||||
/** Video player being used */
|
||||
player?: PlayerType;
|
||||
/** Audio transcoding mode */
|
||||
audioMode?: AudioTranscodeModeType;
|
||||
}
|
||||
|
||||
export function generateDeviceProfile(options?: ProfileOptions): any;
|
||||
|
||||
declare const _default: any;
|
||||
export default _default;
|
||||
@@ -7,22 +7,24 @@ import { Platform } from "react-native";
|
||||
import MediaTypes from "../../constants/MediaTypes";
|
||||
import { getSubtitleProfiles } from "./subtitles";
|
||||
|
||||
/**
|
||||
* @typedef {"ios" | "android"} PlatformType
|
||||
* @typedef {"mpv"} PlayerType
|
||||
* @typedef {"auto" | "stereo" | "5.1" | "passthrough"} AudioTranscodeModeType
|
||||
*
|
||||
* @typedef {Object} ProfileOptions
|
||||
* @property {PlatformType} [platform] - Target platform
|
||||
* @property {PlayerType} [player] - Video player being used (MPV only)
|
||||
* @property {AudioTranscodeModeType} [audioMode] - Audio transcoding mode
|
||||
*/
|
||||
export type PlatformType = "ios" | "android";
|
||||
export type PlayerType = "mpv";
|
||||
export type AudioTranscodeModeType = "auto" | "stereo" | "5.1" | "passthrough";
|
||||
|
||||
export interface ProfileOptions {
|
||||
/** Target platform */
|
||||
platform?: PlatformType;
|
||||
/** Video player being used */
|
||||
player?: PlayerType;
|
||||
/** Audio transcoding mode */
|
||||
audioMode?: AudioTranscodeModeType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Audio direct play profiles for standalone audio items in MPV player.
|
||||
* These define which audio file formats can be played directly without transcoding.
|
||||
*/
|
||||
const getAudioDirectPlayProfile = (platform) => {
|
||||
const getAudioDirectPlayProfile = (platform: PlatformType) => {
|
||||
if (platform === "ios") {
|
||||
// iOS audio formats supported by MPV
|
||||
return {
|
||||
@@ -44,7 +46,7 @@ const getAudioDirectPlayProfile = (platform) => {
|
||||
* Audio codec profiles for standalone audio items in MPV player.
|
||||
* These define codec constraints for audio file playback.
|
||||
*/
|
||||
const getAudioCodecProfile = (platform) => {
|
||||
const getAudioCodecProfile = (platform: PlatformType) => {
|
||||
if (platform === "ios") {
|
||||
// iOS audio codec constraints for MPV
|
||||
return {
|
||||
@@ -66,12 +68,11 @@ const getAudioCodecProfile = (platform) => {
|
||||
* MPV (via FFmpeg) can decode all audio codecs including TrueHD and DTS-HD MA.
|
||||
* The audioMode setting only controls the maximum channel count - MPV will
|
||||
* decode and downmix as needed.
|
||||
*
|
||||
* @param {PlatformType} platform
|
||||
* @param {AudioTranscodeModeType} audioMode
|
||||
* @returns {{ directPlayCodec: string, maxAudioChannels: string }}
|
||||
*/
|
||||
const getVideoAudioCodecs = (platform, audioMode) => {
|
||||
const getVideoAudioCodecs = (
|
||||
platform: PlatformType,
|
||||
audioMode: AudioTranscodeModeType,
|
||||
): { directPlayCodec: string; maxAudioChannels: string } => {
|
||||
// Base codecs
|
||||
const baseCodecs = "aac,mp3,flac,opus,vorbis";
|
||||
|
||||
@@ -120,12 +121,9 @@ const getVideoAudioCodecs = (platform, audioMode) => {
|
||||
|
||||
/**
|
||||
* Generates a device profile for Jellyfin playback.
|
||||
*
|
||||
* @param {ProfileOptions} [options] - Profile configuration options
|
||||
* @returns {Object} Jellyfin device profile
|
||||
*/
|
||||
export const generateDeviceProfile = (options = {}) => {
|
||||
const platform = options.platform || Platform.OS;
|
||||
export const generateDeviceProfile = (options: ProfileOptions = {}) => {
|
||||
const platform = (options.platform || Platform.OS) as PlatformType;
|
||||
const audioMode = options.audioMode || "auto";
|
||||
|
||||
const { directPlayCodec, maxAudioChannels } = getVideoAudioCodecs(
|
||||
9
utils/scaleSize.ts
Normal file
9
utils/scaleSize.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Dimensions } from "react-native";
|
||||
|
||||
const { width: W, height: H } = Dimensions.get("window");
|
||||
|
||||
export const scaleSize = (size: number): number => {
|
||||
const widthRatio = W / 1920;
|
||||
const heightRatio = H / 1080;
|
||||
return size * Math.min(widthRatio, heightRatio);
|
||||
};
|
||||
@@ -22,6 +22,7 @@ export interface ServerCredential {
|
||||
savedAt: number;
|
||||
securityType: AccountSecurityType;
|
||||
pinHash?: string;
|
||||
primaryImageTag?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -32,6 +33,7 @@ export interface SavedServerAccount {
|
||||
username: string;
|
||||
securityType: AccountSecurityType;
|
||||
savedAt: number;
|
||||
primaryImageTag?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -131,6 +133,7 @@ export async function saveAccountCredential(
|
||||
username: credential.username,
|
||||
securityType: credential.securityType,
|
||||
savedAt: credential.savedAt,
|
||||
primaryImageTag: credential.primaryImageTag,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -224,7 +227,7 @@ export async function clearAllCredentials(): Promise<void> {
|
||||
/**
|
||||
* Add or update an account in a server's accounts list.
|
||||
*/
|
||||
function addAccountToServer(
|
||||
export function addAccountToServer(
|
||||
serverUrl: string,
|
||||
serverName: string,
|
||||
account: SavedServerAccount,
|
||||
@@ -475,19 +478,32 @@ export async function migrateToMultiAccount(): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Update account's token after successful login.
|
||||
* Update account's token and optionally other fields after successful login.
|
||||
*/
|
||||
export async function updateAccountToken(
|
||||
serverUrl: string,
|
||||
userId: string,
|
||||
newToken: string,
|
||||
primaryImageTag?: string,
|
||||
): Promise<void> {
|
||||
const credential = await getAccountCredential(serverUrl, userId);
|
||||
if (credential) {
|
||||
credential.token = newToken;
|
||||
credential.savedAt = Date.now();
|
||||
if (primaryImageTag !== undefined) {
|
||||
credential.primaryImageTag = primaryImageTag;
|
||||
}
|
||||
const key = credentialKey(serverUrl, userId);
|
||||
await SecureStore.setItemAsync(key, JSON.stringify(credential));
|
||||
|
||||
// Also update the account info in the server list
|
||||
addAccountToServer(serverUrl, credential.serverName, {
|
||||
userId: credential.userId,
|
||||
username: credential.username,
|
||||
securityType: credential.securityType,
|
||||
savedAt: credential.savedAt,
|
||||
primaryImageTag: credential.primaryImageTag,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,42 @@ abstract class StreamRankerStrategy {
|
||||
trackOptions: any,
|
||||
): void;
|
||||
|
||||
/**
|
||||
* Score how well a candidate stream matches the previously selected stream.
|
||||
* Overridable so subtitle ranking can add mode (forced / hearing-impaired)
|
||||
* awareness without changing audio behavior.
|
||||
*/
|
||||
protected computeScore(
|
||||
prevStream: MediaStream,
|
||||
stream: MediaStream,
|
||||
prevRelIndex: number,
|
||||
newRelIndex: number,
|
||||
): number {
|
||||
let score = 0;
|
||||
|
||||
if (prevStream.Codec === stream.Codec) {
|
||||
score += 1;
|
||||
}
|
||||
if (prevRelIndex === newRelIndex) {
|
||||
score += 1;
|
||||
}
|
||||
if (
|
||||
prevStream.DisplayTitle &&
|
||||
prevStream.DisplayTitle === stream.DisplayTitle
|
||||
) {
|
||||
score += 2;
|
||||
}
|
||||
if (
|
||||
prevStream.Language &&
|
||||
prevStream.Language !== "und" &&
|
||||
prevStream.Language === stream.Language
|
||||
) {
|
||||
score += 2;
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
protected rank(
|
||||
prevIndex: number,
|
||||
prevSource: MediaSourceInfo,
|
||||
@@ -22,6 +58,9 @@ abstract class StreamRankerStrategy {
|
||||
if (prevIndex === -1) {
|
||||
console.debug("AutoSet Subtitle - No Stream Set");
|
||||
trackOptions[`Default${this.streamType}StreamIndex`] = -1;
|
||||
// A deliberate "off" selection is a valid match to retain — flag it so
|
||||
// callers don't fall back to language preferences / subtitle mode.
|
||||
trackOptions.matched = true;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -63,27 +102,12 @@ abstract class StreamRankerStrategy {
|
||||
continue;
|
||||
}
|
||||
|
||||
let score = 0;
|
||||
|
||||
if (prevStream.Codec === stream.Codec) {
|
||||
score += 1;
|
||||
}
|
||||
if (prevRelIndex === newRelIndex) {
|
||||
score += 1;
|
||||
}
|
||||
if (
|
||||
prevStream.DisplayTitle &&
|
||||
prevStream.DisplayTitle === stream.DisplayTitle
|
||||
) {
|
||||
score += 2;
|
||||
}
|
||||
if (
|
||||
prevStream.Language &&
|
||||
prevStream.Language !== "und" &&
|
||||
prevStream.Language === stream.Language
|
||||
) {
|
||||
score += 2;
|
||||
}
|
||||
const score = this.computeScore(
|
||||
prevStream,
|
||||
stream,
|
||||
prevRelIndex,
|
||||
newRelIndex,
|
||||
);
|
||||
|
||||
console.debug(
|
||||
`AutoSet ${this.streamType} - Score ${score} for ${stream.Index} - ${stream.DisplayTitle}`,
|
||||
@@ -101,6 +125,7 @@ abstract class StreamRankerStrategy {
|
||||
`AutoSet ${this.streamType} - Using ${bestStreamIndex} score ${bestStreamScore}.`,
|
||||
);
|
||||
trackOptions[`Default${this.streamType}StreamIndex`] = bestStreamIndex;
|
||||
trackOptions.matched = true;
|
||||
} else {
|
||||
console.debug(
|
||||
`AutoSet ${this.streamType} - Threshold not met. Using default.`,
|
||||
@@ -112,6 +137,67 @@ abstract class StreamRankerStrategy {
|
||||
class SubtitleStreamRanker extends StreamRankerStrategy {
|
||||
streamType = "Subtitle";
|
||||
|
||||
/**
|
||||
* Subtitle scoring that retains both language and mode across episodes.
|
||||
*
|
||||
* - When the previous track has a language: a language match is weighted high
|
||||
* (+3) so it clears the threshold even when codec / title / position differ,
|
||||
* and mode (forced / hearing-impaired) acts as a tiebreaker among
|
||||
* same-language tracks. Different-language candidates get no language or mode
|
||||
* points, so they can never be selected on mode alone (no cross-language
|
||||
* hijack).
|
||||
* - When the previous track has NO usable language (common for SRT/SUBRIP):
|
||||
* language can't help, so mode (forced / hearing-impaired) + codec + relative
|
||||
* position become the identity signal. Without this, unlabeled subtitles
|
||||
* score only codec+relIndex (≤2) and the selection is silently lost.
|
||||
*/
|
||||
protected computeScore(
|
||||
prevStream: MediaStream,
|
||||
stream: MediaStream,
|
||||
prevRelIndex: number,
|
||||
newRelIndex: number,
|
||||
): number {
|
||||
let score = 0;
|
||||
|
||||
if (prevStream.Codec === stream.Codec) {
|
||||
score += 1;
|
||||
}
|
||||
if (prevRelIndex === newRelIndex) {
|
||||
score += 1;
|
||||
}
|
||||
if (
|
||||
prevStream.DisplayTitle &&
|
||||
prevStream.DisplayTitle === stream.DisplayTitle
|
||||
) {
|
||||
score += 2;
|
||||
}
|
||||
|
||||
const prevHasLanguage =
|
||||
!!prevStream.Language && prevStream.Language !== "und";
|
||||
const languageMatches =
|
||||
prevHasLanguage && prevStream.Language === stream.Language;
|
||||
|
||||
if (languageMatches) {
|
||||
score += 3;
|
||||
} else if (prevHasLanguage) {
|
||||
// Previous track had a language but this candidate's differs — do not award
|
||||
// mode points, so a different language is never matched on mode alone.
|
||||
return score;
|
||||
}
|
||||
|
||||
// Either the language matched, or the previous track had no language (so mode
|
||||
// is the primary identity). Normalize the flags to booleans since
|
||||
// IsForced / IsHearingImpaired may be undefined.
|
||||
if (!!prevStream.IsForced === !!stream.IsForced) {
|
||||
score += 2;
|
||||
}
|
||||
if (!!prevStream.IsHearingImpaired === !!stream.IsHearingImpaired) {
|
||||
score += 1;
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
rankStream(
|
||||
prevIndex: number,
|
||||
prevSource: MediaSourceInfo,
|
||||
@@ -156,4 +242,4 @@ class StreamRanker {
|
||||
}
|
||||
}
|
||||
|
||||
export { StreamRanker, SubtitleStreamRanker, AudioStreamRanker };
|
||||
export { AudioStreamRanker, StreamRanker, SubtitleStreamRanker };
|
||||
|
||||
57
utils/topshelf/cache.ts
Normal file
57
utils/topshelf/cache.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { Api } from "@jellyfin/sdk";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Platform } from "react-native";
|
||||
import { clearTopShelfCache, writeTopShelfCache } from "@/modules";
|
||||
import {
|
||||
buildTVDiscoveryPayload,
|
||||
type TVDiscoveryPayload,
|
||||
} from "@/utils/tvDiscovery/payload";
|
||||
|
||||
export function updateTopShelfCache({
|
||||
api,
|
||||
sections,
|
||||
}: {
|
||||
api: Api | null | undefined;
|
||||
sections: Array<{ title: string; items: BaseItemDto[] | undefined }>;
|
||||
}): void {
|
||||
if (Platform.OS !== "ios" || !Platform.isTV) return;
|
||||
|
||||
const payload = buildTVDiscoveryPayload({ api, sections });
|
||||
if (!payload) {
|
||||
clearTopShelfCacheSafely();
|
||||
return;
|
||||
}
|
||||
|
||||
writeTopShelfPayload(payload, api?.accessToken || undefined);
|
||||
}
|
||||
|
||||
export function writeTopShelfPayload(
|
||||
payload: TVDiscoveryPayload,
|
||||
apiKey?: string,
|
||||
): void {
|
||||
if (Platform.OS !== "ios" || !Platform.isTV) return;
|
||||
|
||||
try {
|
||||
const didWrite = writeTopShelfCache(JSON.stringify(payload), apiKey);
|
||||
|
||||
if (!didWrite) {
|
||||
console.warn("[TopShelf] Native cache writer is unavailable");
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("[TopShelf] Failed to write cache", error);
|
||||
}
|
||||
}
|
||||
|
||||
export function clearTopShelfCacheSafely(): void {
|
||||
if (Platform.OS !== "ios" || !Platform.isTV) return;
|
||||
|
||||
try {
|
||||
const didClear = clearTopShelfCache();
|
||||
|
||||
if (!didClear) {
|
||||
console.warn("[TopShelf] Native cache clearer is unavailable");
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("[TopShelf] Failed to clear cache", error);
|
||||
}
|
||||
}
|
||||
173
utils/tvDiscovery/payload.ts
Normal file
173
utils/tvDiscovery/payload.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import type { Api } from "@jellyfin/sdk";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
|
||||
const TV_DISCOVERY_ITEM_LIMIT = 12;
|
||||
const TV_DISCOVERY_SECTION_LIMIT = 3;
|
||||
|
||||
export interface TVDiscoveryItem {
|
||||
id: string;
|
||||
itemType?: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
imageUrl?: string;
|
||||
route: string;
|
||||
playRoute?: string;
|
||||
}
|
||||
|
||||
export interface TVDiscoverySection {
|
||||
title: string;
|
||||
items: TVDiscoveryItem[];
|
||||
}
|
||||
|
||||
export interface TVDiscoveryPayload {
|
||||
version: 1;
|
||||
updatedAt: string;
|
||||
sections: TVDiscoverySection[];
|
||||
}
|
||||
|
||||
function getTVDiscoveryImage(
|
||||
item: BaseItemDto,
|
||||
api: Api,
|
||||
): { url: string } | undefined {
|
||||
const baseUrl = api.basePath;
|
||||
|
||||
// 1. Episode backdrop
|
||||
const episodeBackdrop = item.BackdropImageTags?.[0];
|
||||
if (item.Id && episodeBackdrop) {
|
||||
return {
|
||||
url:
|
||||
`${baseUrl}/Items/${item.Id}/Images/Backdrop/0` +
|
||||
`?fillWidth=1920` +
|
||||
`&fillHeight=1080` +
|
||||
`&quality=90` +
|
||||
`&tag=${encodeURIComponent(episodeBackdrop)}`,
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Series backdrop
|
||||
if (item.SeriesId) {
|
||||
return {
|
||||
url:
|
||||
`${baseUrl}/Items/${item.SeriesId}/Images/Backdrop` +
|
||||
`?fillWidth=1920` +
|
||||
`&fillHeight=1080` +
|
||||
`&quality=90`,
|
||||
};
|
||||
}
|
||||
|
||||
// 3. Generic item backdrop
|
||||
const backdrop = item.BackdropImageTags?.[0];
|
||||
if (item.Id && backdrop) {
|
||||
return {
|
||||
url:
|
||||
`${baseUrl}/Items/${item.Id}/Images/Backdrop/0` +
|
||||
`?fillWidth=1920` +
|
||||
`&fillHeight=1080` +
|
||||
`&quality=90` +
|
||||
`&tag=${encodeURIComponent(backdrop)}`,
|
||||
};
|
||||
}
|
||||
|
||||
// 4. Last resort: crop poster into landscape
|
||||
const primaryTag = item.ImageTags?.Primary;
|
||||
if (item.Id && primaryTag) {
|
||||
return {
|
||||
url:
|
||||
`${baseUrl}/Items/${item.Id}/Images/Primary` +
|
||||
`?fillWidth=1920` +
|
||||
`&fillHeight=1080` +
|
||||
`&quality=90` +
|
||||
`&tag=${encodeURIComponent(primaryTag)}`,
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function formatEpisodeNumber(item: BaseItemDto): string | undefined {
|
||||
const season = item.ParentIndexNumber;
|
||||
const episode = item.IndexNumber;
|
||||
|
||||
if (season != null && episode != null) {
|
||||
return `S${season} • E${episode}`;
|
||||
}
|
||||
|
||||
if (season != null) return `Season ${season}`;
|
||||
if (episode != null) return `Episode ${episode}`;
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getTVDiscoveryTitle(item: BaseItemDto): string {
|
||||
if (item.Type === "Episode") {
|
||||
const episodeNumber = formatEpisodeNumber(item);
|
||||
|
||||
if (item.SeriesName && episodeNumber) {
|
||||
return `${item.SeriesName} - ${episodeNumber}`;
|
||||
}
|
||||
|
||||
if (item.SeriesName) return item.SeriesName;
|
||||
if (episodeNumber) return episodeNumber;
|
||||
return item.Name || "";
|
||||
}
|
||||
|
||||
return item.Name || "";
|
||||
}
|
||||
|
||||
function getTVDiscoverySubtitle(item: BaseItemDto): string | undefined {
|
||||
if (item.Type === "Episode") return undefined;
|
||||
|
||||
return item.ProductionYear ? String(item.ProductionYear) : item.Type;
|
||||
}
|
||||
|
||||
function sectionFromItems(
|
||||
title: string,
|
||||
items: BaseItemDto[] | undefined,
|
||||
api: Api,
|
||||
): TVDiscoverySection | null {
|
||||
const payloadItems = (items || [])
|
||||
.filter((item) => item.Id && item.Name)
|
||||
.slice(0, TV_DISCOVERY_ITEM_LIMIT)
|
||||
.map((item) => {
|
||||
const image = getTVDiscoveryImage(item, api);
|
||||
return {
|
||||
id: item.Id!,
|
||||
itemType: item.Type || undefined,
|
||||
title: getTVDiscoveryTitle(item),
|
||||
subtitle: getTVDiscoverySubtitle(item),
|
||||
imageUrl: image?.url,
|
||||
route: `streamyfin://topshelf/item?id=${encodeURIComponent(item.Id!)}&type=${encodeURIComponent(item.Type || "")}`,
|
||||
playRoute: `streamyfin://topshelf/play?id=${encodeURIComponent(item.Id!)}`,
|
||||
};
|
||||
});
|
||||
|
||||
if (payloadItems.length === 0) return null;
|
||||
|
||||
return {
|
||||
title,
|
||||
items: payloadItems,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildTVDiscoveryPayload({
|
||||
api,
|
||||
sections,
|
||||
}: {
|
||||
api: Api | null | undefined;
|
||||
sections: Array<{ title: string; items: BaseItemDto[] | undefined }>;
|
||||
}): TVDiscoveryPayload | null {
|
||||
if (!api) return null;
|
||||
|
||||
const payloadSections = sections
|
||||
.map((section) => sectionFromItems(section.title, section.items, api))
|
||||
.filter((section): section is TVDiscoverySection => section !== null)
|
||||
.slice(0, TV_DISCOVERY_SECTION_LIMIT);
|
||||
|
||||
if (payloadSections.length === 0) return null;
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
updatedAt: new Date().toISOString(),
|
||||
sections: payloadSections,
|
||||
};
|
||||
}
|
||||
88
utils/tvDiscovery/sync.ts
Normal file
88
utils/tvDiscovery/sync.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import type { Api } from "@jellyfin/sdk";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Platform } from "react-native";
|
||||
import { clearTvRecommendations, syncTvRecommendations } from "@/modules";
|
||||
import {
|
||||
clearTopShelfCacheSafely,
|
||||
writeTopShelfPayload,
|
||||
} from "@/utils/topshelf/cache";
|
||||
import { buildTVDiscoveryPayload } from "./payload";
|
||||
|
||||
export function updateTVDiscovery({
|
||||
api,
|
||||
sections,
|
||||
}: {
|
||||
api: Api | null | undefined;
|
||||
sections: Array<{ title: string; items: BaseItemDto[] | undefined }>;
|
||||
}): void {
|
||||
if (!Platform.isTV) return;
|
||||
|
||||
const payload = buildTVDiscoveryPayload({ api, sections });
|
||||
|
||||
if (!payload) {
|
||||
console.log("[TVDiscovery] No payload generated; clearing TV discovery");
|
||||
clearTVDiscoverySafely();
|
||||
return;
|
||||
}
|
||||
|
||||
const sectionSummary = payload.sections
|
||||
.map((section) => `${section.title}:${section.items.length}`)
|
||||
.join(", ");
|
||||
console.log(
|
||||
`[TVDiscovery] Sync payload prepared for ${Platform.OS} TV with ${payload.sections.length} section(s): ${sectionSummary}`,
|
||||
);
|
||||
|
||||
if (Platform.OS === "ios") {
|
||||
writeTopShelfPayload(payload, api?.accessToken || undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Platform.OS === "android") {
|
||||
try {
|
||||
const didSync = syncTvRecommendations(JSON.stringify(payload));
|
||||
|
||||
console.log(`[TVDiscovery] Android sync result: ${didSync}`);
|
||||
|
||||
if (!didSync) {
|
||||
console.warn(
|
||||
"[TVDiscovery] Android recommendations sync is unavailable",
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[TVDiscovery] Failed to sync Android recommendations",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function clearTVDiscoverySafely(): void {
|
||||
if (!Platform.isTV) return;
|
||||
|
||||
console.log(`[TVDiscovery] Clearing TV discovery for ${Platform.OS} TV`);
|
||||
|
||||
if (Platform.OS === "ios") {
|
||||
clearTopShelfCacheSafely();
|
||||
return;
|
||||
}
|
||||
|
||||
if (Platform.OS === "android") {
|
||||
try {
|
||||
const didClear = clearTvRecommendations();
|
||||
|
||||
console.log(`[TVDiscovery] Android clear result: ${didClear}`);
|
||||
|
||||
if (!didClear) {
|
||||
console.warn(
|
||||
"[TVDiscovery] Android recommendations clearer is unavailable",
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[TVDiscovery] Failed to clear Android recommendations",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useFocusEffect } from "@react-navigation/core";
|
||||
import {
|
||||
type QueryKey,
|
||||
type UseQueryOptions,
|
||||
type UseQueryResult,
|
||||
useQuery,
|
||||
} from "@tanstack/react-query";
|
||||
import { useFocusEffect } from "expo-router/react-navigation";
|
||||
import { useCallback } from "react";
|
||||
|
||||
export function useReactNavigationQuery<
|
||||
|
||||
Reference in New Issue
Block a user