mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-07-18 02:04:35 +01:00
Merge remote-tracking branch 'origin/develop' into feat/android/choose-download-location
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();
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { atom } from "jotai";
|
||||
import { atomWithStorage } from "jotai/utils";
|
||||
import { storage } from "../mmkv";
|
||||
import { useSettings } from "./settings";
|
||||
|
||||
export enum SortByOption {
|
||||
Default = "Default",
|
||||
@@ -15,13 +16,18 @@ export enum SortByOption {
|
||||
OfficialRating = "OfficialRating",
|
||||
PremiereDate = "PremiereDate",
|
||||
StartDate = "StartDate",
|
||||
IsUnplayed = "IsUnplayed",
|
||||
IsPlayed = "IsPlayed",
|
||||
AirTime = "AirTime",
|
||||
Studio = "Studio",
|
||||
IsFavoriteOrLiked = "IsFavoriteOrLiked",
|
||||
Random = "Random",
|
||||
}
|
||||
export enum FilterByOption {
|
||||
IsFavoriteOrLiked = "IsFavoriteOrLiked",
|
||||
IsUnplayed = "IsUnplayed",
|
||||
IsPlayed = "IsPlayed",
|
||||
Likes = "Likes",
|
||||
IsFavorite = "IsFavorite",
|
||||
IsResumable = "IsResumable",
|
||||
}
|
||||
|
||||
export enum SortOrderOption {
|
||||
Ascending = "Ascending",
|
||||
@@ -44,14 +50,43 @@ export const sortOptions: {
|
||||
{ key: SortByOption.OfficialRating, value: "Official Rating" },
|
||||
{ key: SortByOption.PremiereDate, value: "Premiere Date" },
|
||||
{ key: SortByOption.StartDate, value: "Start Date" },
|
||||
{ key: SortByOption.IsUnplayed, value: "Is Unplayed" },
|
||||
{ key: SortByOption.IsPlayed, value: "Is Played" },
|
||||
|
||||
{ key: SortByOption.AirTime, value: "Air Time" },
|
||||
{ key: SortByOption.Studio, value: "Studio" },
|
||||
{ key: SortByOption.IsFavoriteOrLiked, value: "Is Favorite Or Liked" },
|
||||
|
||||
{ key: SortByOption.Random, value: "Random" },
|
||||
];
|
||||
|
||||
export const useFilterOptions = () => {
|
||||
const { settings } = useSettings();
|
||||
// We want to only show the watchlist option if someone has ticked that setting.
|
||||
const filterOptions = settings?.useKefinTweaks
|
||||
? [
|
||||
{
|
||||
key: FilterByOption.IsFavoriteOrLiked,
|
||||
value: "Is Favorite Or Liked",
|
||||
},
|
||||
{ key: FilterByOption.IsUnplayed, value: "Is Unplayed" },
|
||||
{ key: FilterByOption.IsPlayed, value: "Is Played" },
|
||||
{ key: FilterByOption.IsFavorite, value: "Is Favorite" },
|
||||
{ key: FilterByOption.IsResumable, value: "Is Resumable" },
|
||||
{ key: FilterByOption.Likes, value: "Watchlist" },
|
||||
]
|
||||
: [
|
||||
{
|
||||
key: FilterByOption.IsFavoriteOrLiked,
|
||||
value: "Is Favorite Or Liked",
|
||||
},
|
||||
{ key: FilterByOption.IsUnplayed, value: "Is Unplayed" },
|
||||
{ key: FilterByOption.IsPlayed, value: "Is Played" },
|
||||
{ key: FilterByOption.IsFavorite, value: "Is Favorite" },
|
||||
{ key: FilterByOption.IsResumable, value: "Is Resumable" },
|
||||
];
|
||||
console.log("filterOptions");
|
||||
console.log(filterOptions);
|
||||
return filterOptions;
|
||||
};
|
||||
|
||||
export const sortOrderOptions: {
|
||||
key: SortOrderOption;
|
||||
value: string;
|
||||
@@ -67,6 +102,7 @@ export const sortByAtom = atom<SortByOption[]>([SortByOption.Default]);
|
||||
export const sortOrderAtom = atom<SortOrderOption[]>([
|
||||
SortOrderOption.Ascending,
|
||||
]);
|
||||
export const filterByAtom = atom<FilterByOption[]>([]);
|
||||
|
||||
export interface SortPreference {
|
||||
[libraryId: string]: SortByOption;
|
||||
@@ -76,8 +112,13 @@ export interface SortOrderPreference {
|
||||
[libraryId: string]: SortOrderOption;
|
||||
}
|
||||
|
||||
export interface FilterPreference {
|
||||
[libraryId: string]: FilterByOption;
|
||||
}
|
||||
|
||||
const defaultSortPreference: SortPreference = {};
|
||||
const defaultSortOrderPreference: SortOrderPreference = {};
|
||||
const defaultFilterPreference: FilterPreference = {};
|
||||
|
||||
export const sortByPreferenceAtom = atomWithStorage<SortPreference>(
|
||||
"sortByPreference",
|
||||
@@ -96,6 +137,23 @@ export const sortByPreferenceAtom = atomWithStorage<SortPreference>(
|
||||
},
|
||||
);
|
||||
|
||||
export const FilterByPreferenceAtom = atomWithStorage<FilterPreference>(
|
||||
"filterByPreference",
|
||||
defaultFilterPreference,
|
||||
{
|
||||
getItem: (key) => {
|
||||
const value = storage.getString(key);
|
||||
return value ? JSON.parse(value) : null;
|
||||
},
|
||||
setItem: (key, value) => {
|
||||
storage.set(key, JSON.stringify(value));
|
||||
},
|
||||
removeItem: (key) => {
|
||||
storage.remove(key);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export const sortOrderPreferenceAtom = atomWithStorage<SortOrderPreference>(
|
||||
"sortOrderPreference",
|
||||
defaultSortOrderPreference,
|
||||
@@ -126,3 +184,10 @@ export const getSortOrderPreference = (
|
||||
) => {
|
||||
return preferences?.[libraryId] || null;
|
||||
};
|
||||
|
||||
export const getFilterByPreference = (
|
||||
libraryId: string,
|
||||
preferences: FilterPreference,
|
||||
) => {
|
||||
return preferences?.[libraryId] || null;
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type SortOrder,
|
||||
SubtitlePlaybackMode,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import { t } from "i18next";
|
||||
import { atom, useAtom, useAtomValue } from "jotai";
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { Platform } from "react-native";
|
||||
@@ -122,6 +123,46 @@ export interface MaxAutoPlayEpisodeCount {
|
||||
value: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The plugin may send object-typed settings as plain primitives.
|
||||
* Resolve to the proper option object from the available choices.
|
||||
*/
|
||||
const normalizePluginValue = (
|
||||
settingsKey: keyof Settings,
|
||||
value: unknown,
|
||||
): unknown => {
|
||||
if (typeof value !== "object" || value === null) {
|
||||
const defaultVal = defaultValues[settingsKey];
|
||||
if (
|
||||
typeof defaultVal === "object" &&
|
||||
defaultVal !== null &&
|
||||
"key" in defaultVal &&
|
||||
"value" in defaultVal
|
||||
) {
|
||||
// defaultBitrate needs a lookup because its keys are human-readable
|
||||
// (e.g. "8 Mb/s") that can't be derived from the raw value (e.g. 8000000).
|
||||
// Other { key, value } settings like maxAutoPlayEpisodeCount work with
|
||||
// the fallback because their keys are just String(value) (e.g. "5").
|
||||
if (settingsKey === "defaultBitrate") {
|
||||
const match = BITRATES.find(
|
||||
(b) => b.key === value || b.value === value,
|
||||
);
|
||||
if (match) return match;
|
||||
}
|
||||
// maxAutoPlayEpisodeCount: 0 is invalid (breaks autoplay), clamp to -1
|
||||
// -1 key must match the translated dropdown label so the UI shows "Disabled"
|
||||
if (
|
||||
settingsKey === "maxAutoPlayEpisodeCount" &&
|
||||
(value === 0 || value === -1)
|
||||
) {
|
||||
return { key: t("home.settings.other.disabled"), value: -1 };
|
||||
}
|
||||
return { key: String(value), value };
|
||||
}
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
export type HomeSectionLatestResolver = {
|
||||
parentId?: string;
|
||||
limit?: number;
|
||||
@@ -130,20 +171,58 @@ export type HomeSectionLatestResolver = {
|
||||
includeItemTypes?: Array<BaseItemKind>;
|
||||
};
|
||||
|
||||
// Video player enum - currently only MPV is supported
|
||||
export enum VideoPlayer {
|
||||
// NATIVE, //todo: changes will make this a lot more easier to implement if we want. delete if not wanted
|
||||
VLC_3 = 0,
|
||||
VLC_4 = 1,
|
||||
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
|
||||
// can't passthrough to external receivers, so this primarily affects
|
||||
// bandwidth usage and server load.
|
||||
export enum AudioTranscodeMode {
|
||||
Auto = "auto", // Platform defaults (recommended)
|
||||
ForceStereo = "stereo", // Always transcode to stereo
|
||||
Allow51 = "5.1", // Allow up to 5.1, transcode 7.1+
|
||||
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";
|
||||
mediaListCollectionIds?: string[];
|
||||
preferedLanguage?: string;
|
||||
searchEngine: "Marlin" | "Jellyfin";
|
||||
searchEngine: "Marlin" | "Jellyfin" | "Streamystats";
|
||||
marlinServerUrl?: string;
|
||||
openInVLC?: boolean;
|
||||
streamyStatsServerUrl?: string;
|
||||
streamyStatsMovieRecommendations?: boolean;
|
||||
streamyStatsSeriesRecommendations?: boolean;
|
||||
streamyStatsPromotedWatchlists?: boolean;
|
||||
downloadQuality?: DownloadOption;
|
||||
downloadStorageLocation?: string;
|
||||
defaultBitrate?: Bitrate;
|
||||
@@ -163,24 +242,60 @@ export type Settings = {
|
||||
subtitleSize: number;
|
||||
safeAreaInControlsEnabled: boolean;
|
||||
jellyseerrServerUrl?: string;
|
||||
useKefinTweaks: boolean;
|
||||
hiddenLibraries?: string[];
|
||||
enableH265ForChromecast: boolean;
|
||||
defaultPlayer: VideoPlayer;
|
||||
maxAutoPlayEpisodeCount: MaxAutoPlayEpisodeCount;
|
||||
autoPlayEpisodeCount: number;
|
||||
vlcTextColor?: string;
|
||||
vlcBackgroundColor?: string;
|
||||
vlcOutlineColor?: string;
|
||||
vlcOutlineThickness?: string;
|
||||
vlcBackgroundOpacity?: number;
|
||||
vlcOutlineOpacity?: number;
|
||||
vlcIsBold?: boolean;
|
||||
autoPlayNextEpisode: boolean;
|
||||
// Playback speed settings
|
||||
defaultPlaybackSpeed: number;
|
||||
playbackSpeedPerMedia: Record<string, number>;
|
||||
playbackSpeedPerShow: Record<string, number>;
|
||||
// MPV subtitle settings
|
||||
mpvSubtitleScale?: number;
|
||||
mpvSubtitleMarginY?: number;
|
||||
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;
|
||||
enableRightSideVolumeSwipe: boolean;
|
||||
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;
|
||||
// Audio look-ahead caching
|
||||
audioLookaheadEnabled: boolean;
|
||||
audioLookaheadCount: number;
|
||||
audioMaxCacheSizeMB: number;
|
||||
// Music playback
|
||||
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> {
|
||||
@@ -202,7 +317,10 @@ export const defaultValues: Settings = {
|
||||
preferedLanguage: undefined,
|
||||
searchEngine: "Jellyfin",
|
||||
marlinServerUrl: "",
|
||||
openInVLC: false,
|
||||
streamyStatsServerUrl: "",
|
||||
streamyStatsMovieRecommendations: false,
|
||||
streamyStatsSeriesRecommendations: false,
|
||||
streamyStatsPromotedWatchlists: false,
|
||||
downloadQuality: DownloadOptions[0],
|
||||
downloadStorageLocation: undefined,
|
||||
defaultBitrate: BITRATES[0],
|
||||
@@ -225,27 +343,66 @@ export const defaultValues: Settings = {
|
||||
rewindSkipTime: 10,
|
||||
showCustomMenuLinks: false,
|
||||
disableHapticFeedback: false,
|
||||
subtitleSize: Platform.OS === "ios" ? 60 : 100,
|
||||
subtitleSize: 100, // Scale value * 100, so 100 = 1.0x
|
||||
safeAreaInControlsEnabled: true,
|
||||
jellyseerrServerUrl: undefined,
|
||||
useKefinTweaks: false,
|
||||
hiddenLibraries: [],
|
||||
enableH265ForChromecast: false,
|
||||
defaultPlayer: VideoPlayer.VLC_3, // ios-only setting. does not matter what this is for android
|
||||
maxAutoPlayEpisodeCount: { key: "3", value: 3 },
|
||||
autoPlayEpisodeCount: 0,
|
||||
vlcTextColor: undefined,
|
||||
vlcBackgroundColor: undefined,
|
||||
vlcOutlineColor: undefined,
|
||||
vlcOutlineThickness: undefined,
|
||||
vlcBackgroundOpacity: undefined,
|
||||
vlcOutlineOpacity: undefined,
|
||||
vlcIsBold: undefined,
|
||||
autoPlayNextEpisode: true,
|
||||
// Playback speed defaults
|
||||
defaultPlaybackSpeed: 1.0,
|
||||
playbackSpeedPerMedia: {},
|
||||
playbackSpeedPerShow: {},
|
||||
// MPV subtitle defaults
|
||||
mpvSubtitleScale: undefined,
|
||||
mpvSubtitleMarginY: undefined,
|
||||
mpvSubtitleAlignX: undefined,
|
||||
mpvSubtitleAlignY: undefined,
|
||||
mpvSubtitleFontSize: undefined,
|
||||
mpvSubtitleBackgroundEnabled: false,
|
||||
mpvSubtitleBackgroundOpacity: 75,
|
||||
// MPV buffer/cache defaults.
|
||||
// Android TV gets tighter caps — combined with libmpv 1.0's larger
|
||||
// baseline (fontconfig + libxml2 + libplacebo HDR path + scudo
|
||||
// retention) the larger mobile budget pushes 2 GB Android TV boxes
|
||||
// into swap death during 4K HDR playback. Apple TV has more RAM and
|
||||
// keeps the full budget. Users can override via the settings screen.
|
||||
mpvCacheEnabled: "auto",
|
||||
mpvCacheSeconds: 10,
|
||||
mpvDemuxerMaxBytes: Platform.isTV && Platform.OS === "android" ? 75 : 150, // MB
|
||||
mpvDemuxerMaxBackBytes: Platform.isTV && Platform.OS === "android" ? 30 : 50, // MB
|
||||
// MPV video output driver defaults (Android only)
|
||||
mpvVoDriver: "gpu-next",
|
||||
// Gesture controls
|
||||
enableHorizontalSwipeSkip: true,
|
||||
enableLeftSideBrightnessSwipe: true,
|
||||
enableRightSideVolumeSwipe: true,
|
||||
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,
|
||||
// Audio look-ahead caching defaults
|
||||
audioLookaheadEnabled: true,
|
||||
audioLookaheadCount: 1,
|
||||
audioMaxCacheSizeMB: 500,
|
||||
// Music playback
|
||||
preferLocalAudio: true,
|
||||
// Audio transcoding mode
|
||||
audioTranscodeMode: AudioTranscodeMode.Auto,
|
||||
// TV-only: Inactivity timeout (disabled by default)
|
||||
inactivityTimeout: InactivityTimeout.Disabled,
|
||||
};
|
||||
|
||||
const loadSettings = (): Partial<Settings> => {
|
||||
@@ -291,6 +448,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);
|
||||
@@ -315,16 +480,33 @@ export const useSettings = () => {
|
||||
if (!api) {
|
||||
return;
|
||||
}
|
||||
const settings = await api.getStreamyfinPluginConfig().then(
|
||||
const newPluginSettings = await api.getStreamyfinPluginConfig().then(
|
||||
({ data }) => {
|
||||
writeInfoLog("Got plugin settings", data?.settings);
|
||||
return data?.settings;
|
||||
},
|
||||
(_err) => undefined,
|
||||
);
|
||||
setPluginSettings(settings);
|
||||
return settings;
|
||||
}, [api]);
|
||||
setPluginSettings(newPluginSettings);
|
||||
|
||||
// Locked/unlocked values are handled by the settings memo, which
|
||||
// applies locked values at runtime without overwriting user storage.
|
||||
// We only handle auto-enabling Streamystats here.
|
||||
if (newPluginSettings && _settings) {
|
||||
const streamyStatsUrl = newPluginSettings.streamyStatsServerUrl;
|
||||
if (streamyStatsUrl?.value && _settings.searchEngine !== "Streamystats") {
|
||||
const newSettings = {
|
||||
...defaultValues,
|
||||
..._settings,
|
||||
searchEngine: "Streamystats",
|
||||
} as Settings;
|
||||
setSettings(newSettings);
|
||||
saveSettings(newSettings);
|
||||
}
|
||||
}
|
||||
|
||||
return newPluginSettings;
|
||||
}, [api, _settings]);
|
||||
|
||||
const updateSettings = (update: Partial<Settings>) => {
|
||||
if (!_settings) {
|
||||
@@ -348,28 +530,27 @@ 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]) => {
|
||||
if (setting) {
|
||||
const { value, locked } = setting;
|
||||
let { value } = setting;
|
||||
const { locked } = setting;
|
||||
const settingsKey = key as keyof Settings;
|
||||
|
||||
// Make sure we override default settings with plugin settings when they are not locked.
|
||||
if (
|
||||
!locked &&
|
||||
value !== undefined &&
|
||||
_settings?.[settingsKey] !== value
|
||||
) {
|
||||
(unlockedPluginDefaults as any)[settingsKey] = value;
|
||||
}
|
||||
// Normalize object-typed settings from plugin (plain primitive → { key, value })
|
||||
value = normalizePluginValue(settingsKey, value);
|
||||
|
||||
const effectiveValue = getEffectiveSettingValue(_settings, settingsKey);
|
||||
|
||||
(acc as any)[settingsKey] = locked
|
||||
? value
|
||||
: (_settings?.[settingsKey] ?? value);
|
||||
: 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);
|
||||
27
utils/atoms/tvOptionModal.ts
Normal file
27
utils/atoms/tvOptionModal.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
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;
|
||||
/**
|
||||
* Run onSelect AFTER the modal route is dismissed. Needed only when onSelect
|
||||
* navigates (the in-player audio switch replacing the player while
|
||||
* transcoding), which the still-active modal route would otherwise swallow.
|
||||
* Default (false) runs onSelect before closing, so state-only callers (detail
|
||||
* page, library filters, settings) don't re-render after focus returns and
|
||||
* lose TV focus.
|
||||
*/
|
||||
deferApplyUntilDismissed?: boolean;
|
||||
} | 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);
|
||||
@@ -1,18 +0,0 @@
|
||||
/**
|
||||
* Convert bits to megabits or gigabits
|
||||
*
|
||||
* Return nice looking string
|
||||
* If under 1000Mb, return XXXMB, else return X.XGB
|
||||
*/
|
||||
|
||||
export function convertBitsToMegabitsOrGigabits(bits?: number | null): string {
|
||||
if (!bits) return "0MB";
|
||||
|
||||
const megabits = bits / 1000000;
|
||||
|
||||
if (megabits < 1000) {
|
||||
return `${Math.round(megabits)}MB`;
|
||||
}
|
||||
const gigabits = megabits / 1000;
|
||||
return `${gigabits.toFixed(1)}GB`;
|
||||
}
|
||||
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,47 +0,0 @@
|
||||
import {
|
||||
BaseItemKind,
|
||||
CollectionType,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
|
||||
/**
|
||||
* Converts a ColletionType to a BaseItemKind (also called ItemType)
|
||||
*
|
||||
* CollectionTypes
|
||||
* readonly Unknown: "unknown";
|
||||
readonly Movies: "movies";
|
||||
readonly Tvshows: "tvshows";
|
||||
readonly Trailers: "trailers";
|
||||
readonly Homevideos: "homevideos";
|
||||
readonly Boxsets: "boxsets";
|
||||
readonly Books: "books";
|
||||
readonly Photos: "photos";
|
||||
readonly Livetv: "livetv";
|
||||
readonly Playlists: "playlists";
|
||||
readonly Folders: "folders";
|
||||
*/
|
||||
export const colletionTypeToItemType = (
|
||||
collectionType?: CollectionType | null,
|
||||
): BaseItemKind | undefined => {
|
||||
if (!collectionType) return undefined;
|
||||
|
||||
switch (collectionType) {
|
||||
case CollectionType.Movies:
|
||||
return BaseItemKind.Movie;
|
||||
case CollectionType.Tvshows:
|
||||
return BaseItemKind.Series;
|
||||
case CollectionType.Homevideos:
|
||||
return BaseItemKind.Video;
|
||||
case CollectionType.Books:
|
||||
return BaseItemKind.Book;
|
||||
case CollectionType.Playlists:
|
||||
return BaseItemKind.Playlist;
|
||||
case CollectionType.Folders:
|
||||
return BaseItemKind.Folder;
|
||||
case CollectionType.Photos:
|
||||
return BaseItemKind.Photo;
|
||||
case CollectionType.Trailers:
|
||||
return BaseItemKind.Trailer;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
114
utils/downloads/offline-series.ts
Normal file
114
utils/downloads/offline-series.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Utility functions for querying downloaded series/episode data.
|
||||
* Centralizes common filtering patterns to reduce duplication.
|
||||
*/
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import type { DownloadedItem } from "@/providers/Downloads/types";
|
||||
|
||||
/** Sort episodes by season then episode number */
|
||||
const sortByEpisodeOrder = (a: BaseItemDto, b: BaseItemDto) =>
|
||||
(a.ParentIndexNumber ?? 0) - (b.ParentIndexNumber ?? 0) ||
|
||||
(a.IndexNumber ?? 0) - (b.IndexNumber ?? 0);
|
||||
|
||||
/**
|
||||
* Get all downloaded episodes for a series, sorted by season and episode number.
|
||||
*/
|
||||
export function getDownloadedEpisodesForSeries(
|
||||
downloadedItems: DownloadedItem[] | undefined,
|
||||
seriesId: string,
|
||||
): BaseItemDto[] {
|
||||
if (!downloadedItems) return [];
|
||||
return downloadedItems
|
||||
.filter((f) => f.item.SeriesId === seriesId)
|
||||
.map((f) => f.item)
|
||||
.sort(sortByEpisodeOrder);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get downloaded episodes for a specific season of a series.
|
||||
*/
|
||||
export function getDownloadedEpisodesForSeason(
|
||||
downloadedItems: DownloadedItem[] | undefined,
|
||||
seriesId: string,
|
||||
seasonNumber: number,
|
||||
): BaseItemDto[] {
|
||||
return getDownloadedEpisodesForSeries(downloadedItems, seriesId).filter(
|
||||
(ep) => ep.ParentIndexNumber === seasonNumber,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get downloaded episodes by seasonId (for carousel views).
|
||||
*/
|
||||
export function getDownloadedEpisodesBySeasonId(
|
||||
downloadedItems: DownloadedItem[] | undefined,
|
||||
seasonId: string,
|
||||
): BaseItemDto[] {
|
||||
if (!downloadedItems) return [];
|
||||
return downloadedItems
|
||||
.filter((f) => f.item.Type === "Episode" && f.item.SeasonId === seasonId)
|
||||
.map((f) => f.item)
|
||||
.sort(sortByEpisodeOrder);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unique season numbers from downloaded episodes for a series.
|
||||
*/
|
||||
export function getDownloadedSeasonNumbers(
|
||||
downloadedItems: DownloadedItem[] | undefined,
|
||||
seriesId: string,
|
||||
): number[] {
|
||||
const episodes = getDownloadedEpisodesForSeries(downloadedItems, seriesId);
|
||||
return [
|
||||
...new Set(
|
||||
episodes
|
||||
.map((ep) => ep.ParentIndexNumber)
|
||||
.filter((n): n is number => n != null),
|
||||
),
|
||||
].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build fake season objects from downloaded episodes.
|
||||
* Useful for offline mode where we don't have actual season data.
|
||||
*/
|
||||
export function buildOfflineSeasons(
|
||||
downloadedItems: DownloadedItem[] | undefined,
|
||||
seriesId: string,
|
||||
): BaseItemDto[] {
|
||||
const episodes = getDownloadedEpisodesForSeries(downloadedItems, seriesId);
|
||||
const seasonNumbers = getDownloadedSeasonNumbers(downloadedItems, seriesId);
|
||||
|
||||
return seasonNumbers.map((seasonNum) => {
|
||||
const firstEpisode = episodes.find(
|
||||
(ep) => ep.ParentIndexNumber === seasonNum,
|
||||
);
|
||||
return {
|
||||
Id: `offline-season-${seasonNum}`,
|
||||
IndexNumber: seasonNum,
|
||||
Name: firstEpisode?.SeasonName || `Season ${seasonNum}`,
|
||||
SeriesId: seriesId,
|
||||
} as BaseItemDto;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a series-like object from downloaded episodes.
|
||||
* Useful for offline mode where we don't have the actual series data.
|
||||
*/
|
||||
export function buildOfflineSeriesFromEpisodes(
|
||||
downloadedItems: DownloadedItem[] | undefined,
|
||||
seriesId: string,
|
||||
): BaseItemDto | null {
|
||||
const episodes = getDownloadedEpisodesForSeries(downloadedItems, seriesId);
|
||||
if (episodes.length === 0) return null;
|
||||
|
||||
const firstEpisode = episodes[0];
|
||||
return {
|
||||
Id: seriesId,
|
||||
Name: firstEpisode.SeriesName,
|
||||
Type: "Series",
|
||||
ProductionYear: firstEpisode.ProductionYear,
|
||||
Overview: firstEpisode.SeriesName,
|
||||
} as BaseItemDto;
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import axios from "axios";
|
||||
|
||||
export interface SubtitleTrack {
|
||||
index: number;
|
||||
name: string;
|
||||
uri: string;
|
||||
language: string;
|
||||
default: boolean;
|
||||
forced: boolean;
|
||||
autoSelect: boolean;
|
||||
}
|
||||
|
||||
export async function parseM3U8ForSubtitles(
|
||||
url: string,
|
||||
): Promise<SubtitleTrack[]> {
|
||||
try {
|
||||
const response = await axios.get(url, { responseType: "text" });
|
||||
const lines = response.data.split(/\r?\n/);
|
||||
const subtitleTracks: SubtitleTrack[] = [];
|
||||
let index = 0;
|
||||
|
||||
lines.forEach((line: string) => {
|
||||
if (line.startsWith("#EXT-X-MEDIA:TYPE=SUBTITLES")) {
|
||||
const attributes = parseAttributes(line);
|
||||
const track: SubtitleTrack = {
|
||||
index: index++,
|
||||
name: attributes.NAME || "",
|
||||
uri: attributes.URI || "",
|
||||
language: attributes.LANGUAGE || "",
|
||||
default: attributes.DEFAULT === "YES",
|
||||
forced: attributes.FORCED === "YES",
|
||||
autoSelect: attributes.AUTOSELECT === "YES",
|
||||
};
|
||||
subtitleTracks.push(track);
|
||||
}
|
||||
});
|
||||
|
||||
return subtitleTracks;
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch or parse the M3U8 file:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function parseAttributes(line: string): { [key: string]: string } {
|
||||
const attributes: { [key: string]: string } = {};
|
||||
const parts = line.split(",");
|
||||
parts.forEach((part) => {
|
||||
const [key, value] = part.split("=");
|
||||
if (key && value) {
|
||||
attributes[key.trim()] = value.replace(/"/g, "").trim();
|
||||
}
|
||||
});
|
||||
return attributes;
|
||||
}
|
||||
20
utils/jellyfin/audio/getAudioContentType.ts
Normal file
20
utils/jellyfin/audio/getAudioContentType.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Maps Jellyfin audio container types to MIME types for Chromecast
|
||||
*/
|
||||
export const getAudioContentType = (container?: string | null): string => {
|
||||
if (!container) return "audio/mpeg";
|
||||
|
||||
const map: Record<string, string> = {
|
||||
mp3: "audio/mpeg",
|
||||
aac: "audio/aac",
|
||||
m4a: "audio/mp4",
|
||||
flac: "audio/flac",
|
||||
wav: "audio/wav",
|
||||
opus: "audio/opus",
|
||||
ogg: "audio/ogg",
|
||||
wma: "audio/x-ms-wma",
|
||||
webm: "audio/webm",
|
||||
};
|
||||
|
||||
return map[container.toLowerCase()] ?? "audio/mpeg";
|
||||
};
|
||||
68
utils/jellyfin/audio/getAudioStreamUrl.ts
Normal file
68
utils/jellyfin/audio/getAudioStreamUrl.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
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 "../../profiles/trackplayer";
|
||||
|
||||
export interface AudioStreamResult {
|
||||
url: string;
|
||||
sessionId: string | null;
|
||||
mediaSource: MediaSourceInfo | null;
|
||||
isTranscoding: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the audio stream URL for a Jellyfin item
|
||||
* Handles both direct streaming and transcoding scenarios
|
||||
*/
|
||||
export const getAudioStreamUrl = async (
|
||||
api: Api,
|
||||
userId: string,
|
||||
itemId: string,
|
||||
): Promise<AudioStreamResult | null> => {
|
||||
try {
|
||||
const res = await getMediaInfoApi(api).getPlaybackInfo(
|
||||
{ itemId },
|
||||
{
|
||||
method: "POST",
|
||||
data: {
|
||||
userId,
|
||||
deviceProfile: trackPlayerProfile,
|
||||
startTimeTicks: 0,
|
||||
isPlayback: true,
|
||||
autoOpenLiveStream: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const sessionId = res.data.PlaySessionId || null;
|
||||
const mediaSource = res.data.MediaSources?.[0] || null;
|
||||
|
||||
if (mediaSource?.TranscodingUrl) {
|
||||
return {
|
||||
url: `${api.basePath}${mediaSource.TranscodingUrl}`,
|
||||
sessionId,
|
||||
mediaSource,
|
||||
isTranscoding: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Direct stream
|
||||
const streamParams = new URLSearchParams({
|
||||
static: "true",
|
||||
container: mediaSource?.Container || "mp3",
|
||||
mediaSourceId: mediaSource?.Id || "",
|
||||
deviceId: api.deviceInfo.id,
|
||||
api_key: api.accessToken,
|
||||
userId,
|
||||
});
|
||||
|
||||
return {
|
||||
url: `${api.basePath}/Audio/${itemId}/stream?${streamParams.toString()}`,
|
||||
sessionId,
|
||||
mediaSource,
|
||||
isTranscoding: false,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -1,9 +1,20 @@
|
||||
// utils/getDefaultPlaySettings.ts
|
||||
/**
|
||||
* getDefaultPlaySettings.ts
|
||||
*
|
||||
* Determines default audio/subtitle tracks and bitrate for playback.
|
||||
*
|
||||
* Two use cases:
|
||||
* 1. INITIAL PLAY: No previous state, uses media defaults + user language preferences
|
||||
* 2. SEQUENTIAL PLAY: Has previous state (e.g., next episode), uses StreamRanker
|
||||
* to find matching tracks in the new media
|
||||
*/
|
||||
|
||||
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 {
|
||||
@@ -12,86 +23,271 @@ import {
|
||||
SubtitleStreamRanker,
|
||||
} from "../streamRanker";
|
||||
|
||||
interface PlaySettings {
|
||||
export interface PlaySettings {
|
||||
item: BaseItemDto;
|
||||
bitrate: (typeof BITRATES)[0];
|
||||
mediaSource?: MediaSourceInfo | null;
|
||||
audioIndex?: number | undefined;
|
||||
subtitleIndex?: number | undefined;
|
||||
}
|
||||
|
||||
export interface previousIndexes {
|
||||
audioIndex?: number;
|
||||
subtitleIndex?: number;
|
||||
}
|
||||
|
||||
interface TrackOptions {
|
||||
DefaultAudioStreamIndex: number | undefined;
|
||||
DefaultSubtitleStreamIndex: number | undefined;
|
||||
export interface PreviousIndexes {
|
||||
audioIndex?: number;
|
||||
subtitleIndex?: number;
|
||||
}
|
||||
|
||||
// Used getting default values for the next player.
|
||||
export function getDefaultPlaySettings(
|
||||
item: BaseItemDto,
|
||||
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,
|
||||
previousIndexes?: previousIndexes,
|
||||
previousSource?: MediaSourceInfo,
|
||||
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 | null | undefined,
|
||||
settings: Settings | null,
|
||||
previous?: { indexes?: PreviousIndexes; source?: MediaSourceInfo },
|
||||
options?: PlaySettingsOptions,
|
||||
): PlaySettings {
|
||||
if (item.Type === "Program") {
|
||||
return {
|
||||
item,
|
||||
bitrate: BITRATES[0],
|
||||
mediaSource: undefined,
|
||||
audioIndex: undefined,
|
||||
subtitleIndex: undefined,
|
||||
};
|
||||
const bitrate = settings?.defaultBitrate ?? BITRATES[0];
|
||||
|
||||
// Handle undefined/null item
|
||||
if (!item) {
|
||||
return { item: {} as BaseItemDto, bitrate };
|
||||
}
|
||||
|
||||
// 1. Get first media source
|
||||
// Live TV programs don't have media sources
|
||||
if (item.Type === "Program") {
|
||||
return { item, bitrate };
|
||||
}
|
||||
|
||||
const mediaSource = item.MediaSources?.[0];
|
||||
const streams = mediaSource?.MediaStreams ?? [];
|
||||
|
||||
// We prefer the previous track over the default track.
|
||||
const trackOptions: TrackOptions = {
|
||||
DefaultAudioStreamIndex: mediaSource?.DefaultAudioStreamIndex ?? -1,
|
||||
DefaultSubtitleStreamIndex: mediaSource?.DefaultSubtitleStreamIndex ?? -1,
|
||||
};
|
||||
// Start with media source defaults
|
||||
let audioIndex = mediaSource?.DefaultAudioStreamIndex;
|
||||
let subtitleIndex = mediaSource?.DefaultSubtitleStreamIndex ?? -1;
|
||||
|
||||
const mediaStreams = mediaSource?.MediaStreams ?? [];
|
||||
if (settings?.rememberSubtitleSelections && previousIndexes) {
|
||||
if (previousIndexes.subtitleIndex !== undefined && previousSource) {
|
||||
const subtitleRanker = new SubtitleStreamRanker();
|
||||
const ranker = new StreamRanker(subtitleRanker);
|
||||
// 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 (
|
||||
settings.rememberSubtitleSelections &&
|
||||
previous.indexes.subtitleIndex !== undefined
|
||||
) {
|
||||
const ranker = new StreamRanker(new SubtitleStreamRanker());
|
||||
const result = {
|
||||
DefaultSubtitleStreamIndex: subtitleIndex,
|
||||
matched: false,
|
||||
};
|
||||
ranker.rankStream(
|
||||
previousIndexes.subtitleIndex,
|
||||
previousSource,
|
||||
mediaStreams,
|
||||
trackOptions,
|
||||
previous.indexes.subtitleIndex,
|
||||
previous.source,
|
||||
streams,
|
||||
result,
|
||||
);
|
||||
// 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 (
|
||||
settings.rememberAudioSelections &&
|
||||
previous.indexes.audioIndex !== undefined
|
||||
) {
|
||||
const ranker = new StreamRanker(new AudioStreamRanker());
|
||||
const result = { DefaultAudioStreamIndex: audioIndex, matched: false };
|
||||
ranker.rankStream(
|
||||
previous.indexes.audioIndex,
|
||||
previous.source,
|
||||
streams,
|
||||
result,
|
||||
);
|
||||
// Use the ranker's explicit match signal
|
||||
if (result.matched) {
|
||||
audioIndex = result.DefaultAudioStreamIndex;
|
||||
matchedPreviousAudio = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (settings?.rememberAudioSelections && previousIndexes) {
|
||||
if (previousIndexes.audioIndex !== undefined && previousSource) {
|
||||
const audioRanker = new AudioStreamRanker();
|
||||
const ranker = new StreamRanker(audioRanker);
|
||||
ranker.rankStream(
|
||||
previousIndexes.audioIndex,
|
||||
previousSource,
|
||||
mediaStreams,
|
||||
trackOptions,
|
||||
// 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Get default bitrate from settings or fallback to max
|
||||
const bitrate = settings.defaultBitrate ?? BITRATES[0];
|
||||
|
||||
return {
|
||||
item,
|
||||
bitrate,
|
||||
mediaSource,
|
||||
audioIndex: trackOptions.DefaultAudioStreamIndex,
|
||||
subtitleIndex: trackOptions.DefaultSubtitleStreamIndex,
|
||||
audioIndex: audioIndex ?? undefined,
|
||||
subtitleIndex: subtitleIndex ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
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()}`;
|
||||
};
|
||||
@@ -4,7 +4,10 @@ import type {
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Bitrate } from "@/components/BitrateSelector";
|
||||
import { generateDeviceProfile } from "@/utils/profiles/native";
|
||||
import {
|
||||
type AudioTranscodeModeType,
|
||||
generateDeviceProfile,
|
||||
} from "../../profiles/native";
|
||||
import { getDownloadStreamUrl, getStreamUrl } from "./getStreamUrl";
|
||||
|
||||
export const getDownloadUrl = async ({
|
||||
@@ -16,6 +19,7 @@ export const getDownloadUrl = async ({
|
||||
audioStreamIndex,
|
||||
subtitleStreamIndex,
|
||||
deviceId,
|
||||
audioMode = "auto",
|
||||
}: {
|
||||
api: Api;
|
||||
item: BaseItemDto;
|
||||
@@ -25,6 +29,7 @@ export const getDownloadUrl = async ({
|
||||
audioStreamIndex: number;
|
||||
subtitleStreamIndex: number;
|
||||
deviceId: string;
|
||||
audioMode?: AudioTranscodeModeType;
|
||||
}): Promise<{
|
||||
url: string | null;
|
||||
mediaSource: MediaSourceInfo | null;
|
||||
@@ -39,13 +44,13 @@ export const getDownloadUrl = async ({
|
||||
audioStreamIndex,
|
||||
subtitleStreamIndex,
|
||||
deviceId,
|
||||
deviceProfile: generateDeviceProfile(),
|
||||
deviceProfile: generateDeviceProfile({ audioMode }),
|
||||
});
|
||||
|
||||
if (maxBitrate.key === "Max" && !streamDetails?.mediaSource?.TranscodingUrl) {
|
||||
console.log("Downloading item directly");
|
||||
return {
|
||||
url: `${api.basePath}/Items/${item.Id}/Download?api_key=${api.accessToken}`,
|
||||
url: `${api.basePath}/Items/${mediaSource.Id}/Download?api_key=${api.accessToken}`,
|
||||
mediaSource: streamDetails?.mediaSource ?? null,
|
||||
};
|
||||
}
|
||||
@@ -59,6 +64,7 @@ export const getDownloadUrl = async ({
|
||||
maxStreamingBitrate: maxBitrate.value,
|
||||
audioStreamIndex,
|
||||
subtitleStreamIndex,
|
||||
audioMode,
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -5,12 +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 download from "@/utils/profiles/download";
|
||||
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>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -49,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() || "",
|
||||
@@ -162,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");
|
||||
@@ -209,6 +226,9 @@ export const getStreamUrl = async ({
|
||||
url,
|
||||
sessionId: sessionId || null,
|
||||
mediaSource,
|
||||
requiredHttpHeaders: mediaSource?.RequiredHttpHeaders as
|
||||
| Record<string, string>
|
||||
| undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -253,6 +273,9 @@ export const getStreamUrl = async ({
|
||||
url,
|
||||
sessionId: sessionId || null,
|
||||
mediaSource,
|
||||
requiredHttpHeaders: mediaSource?.RequiredHttpHeaders as
|
||||
| Record<string, string>
|
||||
| undefined,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -265,6 +288,7 @@ export const getDownloadStreamUrl = async ({
|
||||
subtitleStreamIndex = undefined,
|
||||
mediaSourceId,
|
||||
deviceId,
|
||||
audioMode = "auto",
|
||||
}: {
|
||||
api: Api | null | undefined;
|
||||
item: BaseItemDto | null | undefined;
|
||||
@@ -274,6 +298,7 @@ export const getDownloadStreamUrl = async ({
|
||||
subtitleStreamIndex?: number;
|
||||
mediaSourceId?: string | null;
|
||||
deviceId?: string | null;
|
||||
audioMode?: AudioTranscodeModeType;
|
||||
}): Promise<{
|
||||
url: string | null;
|
||||
sessionId: string | null;
|
||||
@@ -292,7 +317,7 @@ export const getDownloadStreamUrl = async ({
|
||||
method: "POST",
|
||||
data: {
|
||||
userId,
|
||||
deviceProfile: download,
|
||||
deviceProfile: generateDownloadProfile(audioMode),
|
||||
subtitleStreamIndex,
|
||||
startTimeTicks: 0,
|
||||
isPlayback: true,
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import type { Api } from "@jellyfin/sdk";
|
||||
import type { AxiosResponse } from "axios";
|
||||
import type { Settings } from "@/utils/atoms/settings";
|
||||
import { generateDeviceProfile } from "@/utils/profiles/native";
|
||||
import { getAuthHeaders } from "../jellyfin";
|
||||
|
||||
interface PostCapabilitiesParams {
|
||||
api: Api | null | undefined;
|
||||
itemId: string | null | undefined;
|
||||
sessionId: string | null | undefined;
|
||||
deviceProfile: Settings["deviceProfile"];
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a media item as not played for a specific user.
|
||||
*
|
||||
* @param params - The parameters for marking an item as not played
|
||||
* @returns A promise that resolves to true if the operation was successful, false otherwise
|
||||
*/
|
||||
export const postCapabilities = async ({
|
||||
api,
|
||||
itemId,
|
||||
sessionId,
|
||||
}: PostCapabilitiesParams): Promise<AxiosResponse> => {
|
||||
if (!api || !itemId || !sessionId) {
|
||||
throw new Error("Missing parameters for marking item as not played");
|
||||
}
|
||||
|
||||
try {
|
||||
const d = api.axiosInstance.post(
|
||||
`${api.basePath}/Sessions/Capabilities/Full`,
|
||||
{
|
||||
playableMediaTypes: ["Audio", "Video"],
|
||||
supportedCommands: [
|
||||
"PlayState",
|
||||
"Play",
|
||||
"ToggleFullscreen",
|
||||
"DisplayMessage",
|
||||
"Mute",
|
||||
"Unmute",
|
||||
"SetVolume",
|
||||
"ToggleMute",
|
||||
],
|
||||
supportsMediaControl: true,
|
||||
id: sessionId,
|
||||
DeviceProfile: generateDeviceProfile(),
|
||||
},
|
||||
{
|
||||
headers: getAuthHeaders(api),
|
||||
},
|
||||
);
|
||||
return d;
|
||||
} catch (_error) {
|
||||
throw new Error("Failed to mark as not played");
|
||||
}
|
||||
};
|
||||
126
utils/jellyfin/subtitleUtils.ts
Normal file
126
utils/jellyfin/subtitleUtils.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Subtitle utility functions for mapping between Jellyfin and MPV track indices.
|
||||
*
|
||||
* Jellyfin uses server-side indices (e.g., 3, 4, 5 for subtitles in MediaStreams).
|
||||
* MPV uses its own track IDs starting from 1, only counting tracks loaded into MPV.
|
||||
*
|
||||
* Image-based subtitles (PGS, VOBSUB) during transcoding are burned into the video
|
||||
* and NOT available in MPV's track list.
|
||||
*/
|
||||
|
||||
import {
|
||||
type MediaSourceInfo,
|
||||
type MediaStream,
|
||||
SubtitleDeliveryMethod,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
|
||||
/** Check if subtitle is image-based (PGS, VOBSUB, etc.) */
|
||||
export const isImageBasedSubtitle = (sub: MediaStream): boolean =>
|
||||
sub.IsTextSubtitleStream === false;
|
||||
|
||||
/**
|
||||
* Determine if a subtitle will be available in MPV's track list.
|
||||
*
|
||||
* A subtitle is in MPV if:
|
||||
* - Delivery is Embed/Hls/External AND not an image-based sub during transcode
|
||||
*/
|
||||
export const isSubtitleInMpv = (
|
||||
sub: MediaStream,
|
||||
isTranscoding: boolean,
|
||||
): boolean => {
|
||||
// During transcoding, image-based subs are burned in, not in MPV
|
||||
if (isTranscoding && isImageBasedSubtitle(sub)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Embed/Hls/External methods mean the sub is loaded into MPV
|
||||
return (
|
||||
sub.DeliveryMethod === SubtitleDeliveryMethod.Embed ||
|
||||
sub.DeliveryMethod === SubtitleDeliveryMethod.Hls ||
|
||||
sub.DeliveryMethod === SubtitleDeliveryMethod.External
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculate the MPV track ID for a given Jellyfin subtitle index.
|
||||
*
|
||||
* MPV track IDs are 1-based and only count subtitles that are actually in MPV.
|
||||
* We iterate through all subtitles, counting only those in MPV, until we find
|
||||
* the one matching the Jellyfin index.
|
||||
*
|
||||
* @param mediaSource - The media source containing subtitle streams
|
||||
* @param jellyfinSubtitleIndex - The Jellyfin server-side subtitle index (-1 = disabled)
|
||||
* @param isTranscoding - Whether the stream is being transcoded
|
||||
* @returns MPV track ID (1-based), or -1 if disabled, or undefined if not in MPV
|
||||
*/
|
||||
export const getMpvSubtitleId = (
|
||||
mediaSource: MediaSourceInfo | null | undefined,
|
||||
jellyfinSubtitleIndex: number | undefined,
|
||||
isTranscoding: boolean,
|
||||
): number | undefined => {
|
||||
// -1 or undefined means disabled
|
||||
if (jellyfinSubtitleIndex === undefined || jellyfinSubtitleIndex === -1) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
const allSubs =
|
||||
mediaSource?.MediaStreams?.filter((s) => s.Type === "Subtitle") || [];
|
||||
|
||||
// Find the subtitle with the matching Jellyfin index
|
||||
const targetSub = allSubs.find((s) => s.Index === jellyfinSubtitleIndex);
|
||||
|
||||
// If the target subtitle isn't in MPV (e.g., image-based during transcode), return undefined
|
||||
if (!targetSub || !isSubtitleInMpv(targetSub, isTranscoding)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Count MPV track position (1-based)
|
||||
let mpvIndex = 0;
|
||||
for (const sub of allSubs) {
|
||||
if (isSubtitleInMpv(sub, isTranscoding)) {
|
||||
mpvIndex++;
|
||||
if (sub.Index === jellyfinSubtitleIndex) {
|
||||
return mpvIndex;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculate the MPV track ID for a given Jellyfin audio index.
|
||||
*
|
||||
* For direct play: Audio tracks map to their position in the file (1-based).
|
||||
* For transcoding: Only ONE audio track exists in the HLS stream (the selected one),
|
||||
* so we should return 1 or undefined to use the default track.
|
||||
*
|
||||
* MPV track IDs are 1-based.
|
||||
*
|
||||
* @param mediaSource - The media source containing audio streams
|
||||
* @param jellyfinAudioIndex - The Jellyfin server-side audio index
|
||||
* @param isTranscoding - Whether the stream is being transcoded
|
||||
* @returns MPV track ID (1-based), or undefined if not found
|
||||
*/
|
||||
export const getMpvAudioId = (
|
||||
mediaSource: MediaSourceInfo | null | undefined,
|
||||
jellyfinAudioIndex: number | undefined,
|
||||
isTranscoding: boolean,
|
||||
): number | undefined => {
|
||||
if (jellyfinAudioIndex === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// When transcoding, Jellyfin only includes the selected audio track in the HLS stream.
|
||||
// So there's only 1 audio track - no need to specify an ID.
|
||||
if (isTranscoding) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const allAudio =
|
||||
mediaSource?.MediaStreams?.filter((s) => s.Type === "Audio") || [];
|
||||
|
||||
// Find position in audio list (1-based for MPV)
|
||||
const position = allAudio.findIndex((a) => a.Index === jellyfinAudioIndex);
|
||||
return position >= 0 ? position + 1 : undefined;
|
||||
};
|
||||
@@ -1,44 +0,0 @@
|
||||
import type { Api } from "@jellyfin/sdk";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getAuthHeaders } from "../jellyfin";
|
||||
|
||||
interface NextUpParams {
|
||||
itemId?: string | null;
|
||||
userId?: string | null;
|
||||
api?: Api | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the next up episodes for a series or all series for a user.
|
||||
*
|
||||
* @param params - The parameters for fetching next up episodes
|
||||
* @returns A promise that resolves to an array of BaseItemDto representing the next up episodes
|
||||
*/
|
||||
export const nextUp = async ({
|
||||
itemId,
|
||||
userId,
|
||||
api,
|
||||
}: NextUpParams): Promise<BaseItemDto[]> => {
|
||||
if (!userId || !api) {
|
||||
console.error("Invalid parameters for nextUp: missing userId or api");
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await api.axiosInstance.get<{ Items: BaseItemDto[] }>(
|
||||
`${api.basePath}/Shows/NextUp`,
|
||||
{
|
||||
params: {
|
||||
SeriesId: itemId || undefined,
|
||||
UserId: userId,
|
||||
Fields: "MediaSourceCount",
|
||||
},
|
||||
headers: getAuthHeaders(api),
|
||||
},
|
||||
);
|
||||
|
||||
return response.data.Items;
|
||||
} catch (_error) {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
@@ -1,34 +0,0 @@
|
||||
import type { Api } from "@jellyfin/sdk";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
|
||||
/**
|
||||
* Retrieves an item by its ID from the API.
|
||||
*
|
||||
* @param api - The Jellyfin API instance.
|
||||
* @param itemId - The ID of the item to retrieve.
|
||||
* @returns The item object or undefined if no item matches the ID.
|
||||
*/
|
||||
export const getItemById = async (
|
||||
api?: Api | null | undefined,
|
||||
itemId?: string | null | undefined,
|
||||
): Promise<BaseItemDto | undefined> => {
|
||||
if (!api || !itemId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const itemData = await getUserLibraryApi(api).getItem({ itemId });
|
||||
|
||||
const item = itemData.data;
|
||||
if (!item) {
|
||||
console.error("No items found with the specified ID:", itemId);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return item;
|
||||
} catch (error) {
|
||||
console.error("Failed to retrieve the item:", error);
|
||||
throw new Error(`Failed to retrieve the item due to an error: ${error}`);
|
||||
}
|
||||
};
|
||||
Submodule utils/jellyseerr updated: 4401b16414...fc6a9e952c
@@ -55,7 +55,6 @@ export const writeToLog = (level: LogLevel, message: string, data?: any) => {
|
||||
const recentLogs = logs.slice(Math.max(logs.length - maxLogs, 0));
|
||||
|
||||
storage.set("logs", JSON.stringify(recentLogs));
|
||||
console.log(message);
|
||||
};
|
||||
|
||||
export const writeInfoLog = (message: string, data?: any) =>
|
||||
@@ -73,21 +72,6 @@ export const readFromLog = (): LogEntry[] => {
|
||||
return logs ? JSON.parse(logs) : [];
|
||||
};
|
||||
|
||||
export const clearLogs = () => {
|
||||
storage.remove("logs");
|
||||
};
|
||||
|
||||
export const dumpDownloadDiagnostics = (extra: any = {}) => {
|
||||
const diagnostics = {
|
||||
timestamp: new Date().toISOString(),
|
||||
processes: extra?.processes || [],
|
||||
nativeTasks: extra?.nativeTasks || [],
|
||||
focusedProcess: extra?.focusedProcess || null,
|
||||
};
|
||||
writeDebugLog("Download diagnostics", diagnostics);
|
||||
return diagnostics;
|
||||
};
|
||||
|
||||
export function useLog() {
|
||||
const context = useContext(LogContext);
|
||||
if (context === null) {
|
||||
|
||||
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" },
|
||||
];
|
||||
145
utils/pairingService.ts
Normal file
145
utils/pairingService.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
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 (!active) return;
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,113 +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/.
|
||||
*/
|
||||
import MediaTypes from "../../constants/MediaTypes";
|
||||
|
||||
/**
|
||||
* Device profile for Native video player
|
||||
*/
|
||||
export default {
|
||||
Name: "1. Vlc Player",
|
||||
MaxStaticBitrate: 20_000_000,
|
||||
MaxStreamingBitrate: 20_000_000,
|
||||
CodecProfiles: [
|
||||
{
|
||||
Type: MediaTypes.Video,
|
||||
Codec: "h264,h265,hevc,mpeg4,divx,xvid,wmv,vc1,vp8,vp9,av1",
|
||||
},
|
||||
{
|
||||
Type: MediaTypes.Audio,
|
||||
Codec: "aac,ac3,eac3,mp3,flac,alac,opus,vorbis,pcm,wma",
|
||||
},
|
||||
],
|
||||
DirectPlayProfiles: [
|
||||
{
|
||||
Type: MediaTypes.Video,
|
||||
Container: "mp4,mkv,avi,mov,flv,ts,m2ts,webm,ogv,3gp,hls",
|
||||
VideoCodec:
|
||||
"h264,hevc,mpeg4,divx,xvid,wmv,vc1,vp8,vp9,av1,avi,mpeg,mpeg2video",
|
||||
AudioCodec: "aac,ac3,eac3,mp3,flac,alac,opus,vorbis,wma",
|
||||
},
|
||||
{
|
||||
Type: MediaTypes.Audio,
|
||||
Container: "mp3,aac,flac,alac,wav,ogg,wma",
|
||||
AudioCodec:
|
||||
"mp3,aac,flac,alac,opus,vorbis,wma,pcm,mpa,wav,ogg,oga,webma,ape",
|
||||
},
|
||||
],
|
||||
TranscodingProfiles: [
|
||||
{
|
||||
Type: MediaTypes.Video,
|
||||
Context: "Streaming",
|
||||
Protocol: "hls",
|
||||
Container: "ts",
|
||||
VideoCodec: "h264, hevc",
|
||||
AudioCodec: "aac,mp3,ac3",
|
||||
CopyTimestamps: false,
|
||||
EnableSubtitlesInManifest: true,
|
||||
},
|
||||
{
|
||||
Type: MediaTypes.Audio,
|
||||
Context: "Streaming",
|
||||
Protocol: "http",
|
||||
Container: "mp3",
|
||||
AudioCodec: "mp3",
|
||||
MaxAudioChannels: "2",
|
||||
},
|
||||
],
|
||||
SubtitleProfiles: [
|
||||
// Official foramts
|
||||
{ Format: "vtt", Method: "Encode" },
|
||||
|
||||
{ Format: "webvtt", Method: "Encode" },
|
||||
|
||||
{ Format: "srt", Method: "Encode" },
|
||||
|
||||
{ Format: "subrip", Method: "Encode" },
|
||||
|
||||
{ Format: "ttml", Method: "Encode" },
|
||||
|
||||
{ Format: "dvdsub", Method: "Encode" },
|
||||
|
||||
{ Format: "ass", Method: "Encode" },
|
||||
|
||||
{ Format: "idx", Method: "Encode" },
|
||||
|
||||
{ Format: "pgs", Method: "Encode" },
|
||||
|
||||
{ Format: "pgssub", Method: "Encode" },
|
||||
|
||||
{ Format: "ssa", Method: "Encode" },
|
||||
|
||||
// Other formats
|
||||
{ Format: "microdvd", Method: "Encode" },
|
||||
|
||||
{ Format: "mov_text", Method: "Encode" },
|
||||
|
||||
{ Format: "mpl2", Method: "Encode" },
|
||||
|
||||
{ Format: "pjs", Method: "Encode" },
|
||||
|
||||
{ Format: "realtext", Method: "Encode" },
|
||||
|
||||
{ Format: "scc", Method: "Encode" },
|
||||
|
||||
{ Format: "smi", Method: "Encode" },
|
||||
|
||||
{ Format: "stl", Method: "Encode" },
|
||||
|
||||
{ Format: "sub", Method: "Encode" },
|
||||
|
||||
{ Format: "subviewer", Method: "Encode" },
|
||||
|
||||
{ Format: "teletext", Method: "Encode" },
|
||||
|
||||
{ Format: "text", Method: "Encode" },
|
||||
|
||||
{ Format: "vplayer", Method: "Encode" },
|
||||
|
||||
{ Format: "xsub", Method: "Encode" },
|
||||
],
|
||||
};
|
||||
80
utils/profiles/download.ts
Normal file
80
utils/profiles/download.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
import type {
|
||||
DeviceProfile,
|
||||
SubtitleProfile,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { type AudioTranscodeModeType, generateDeviceProfile } from "./native";
|
||||
|
||||
/**
|
||||
* Download-specific subtitle profiles.
|
||||
* These are more permissive than streaming profiles since we can embed subtitles.
|
||||
*/
|
||||
const downloadSubtitleProfiles: SubtitleProfile[] = [
|
||||
// Official formats
|
||||
{ Format: "vtt", Method: "Encode" },
|
||||
{ Format: "webvtt", Method: "Encode" },
|
||||
{ Format: "srt", Method: "Encode" },
|
||||
{ Format: "subrip", Method: "Encode" },
|
||||
{ Format: "ttml", Method: "Encode" },
|
||||
{ Format: "dvdsub", Method: "Encode" },
|
||||
{ Format: "ass", Method: "Encode" },
|
||||
{ Format: "idx", Method: "Encode" },
|
||||
{ Format: "pgs", Method: "Encode" },
|
||||
{ Format: "pgssub", Method: "Encode" },
|
||||
{ Format: "ssa", Method: "Encode" },
|
||||
// Other formats
|
||||
{ Format: "microdvd", Method: "Encode" },
|
||||
{ Format: "mov_text", Method: "Encode" },
|
||||
{ Format: "mpl2", Method: "Encode" },
|
||||
{ Format: "pjs", Method: "Encode" },
|
||||
{ Format: "realtext", Method: "Encode" },
|
||||
{ Format: "scc", Method: "Encode" },
|
||||
{ Format: "smi", Method: "Encode" },
|
||||
{ Format: "stl", Method: "Encode" },
|
||||
{ Format: "sub", Method: "Encode" },
|
||||
{ Format: "subviewer", Method: "Encode" },
|
||||
{ Format: "teletext", Method: "Encode" },
|
||||
{ Format: "text", Method: "Encode" },
|
||||
{ Format: "vplayer", Method: "Encode" },
|
||||
{ Format: "xsub", Method: "Encode" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Generates a device profile optimized for downloads.
|
||||
* Uses the same audio codec logic as streaming but with download-specific bitrate limits.
|
||||
*/
|
||||
export const generateDownloadProfile = (
|
||||
audioMode: AudioTranscodeModeType = "auto",
|
||||
): DeviceProfile => {
|
||||
// Get the base profile with proper audio codec configuration
|
||||
const baseProfile = generateDeviceProfile({ audioMode });
|
||||
|
||||
// Override with download-specific settings
|
||||
return {
|
||||
...baseProfile,
|
||||
Name: "1. MPV Download",
|
||||
// Limit bitrate for downloads (20 Mbps)
|
||||
MaxStaticBitrate: 20_000_000,
|
||||
MaxStreamingBitrate: 20_000_000,
|
||||
// Use download-specific subtitle profiles
|
||||
SubtitleProfiles: downloadSubtitleProfiles,
|
||||
// Update transcoding profiles with download-specific settings
|
||||
TranscodingProfiles: baseProfile.TranscodingProfiles.map((profile) => {
|
||||
if (profile.Type === "Video") {
|
||||
return {
|
||||
...profile,
|
||||
CopyTimestamps: false,
|
||||
EnableSubtitlesInManifest: true,
|
||||
};
|
||||
}
|
||||
return profile;
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
// Default export for backward compatibility
|
||||
export default generateDownloadProfile();
|
||||
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";
|
||||
11
utils/profiles/native.d.ts
vendored
11
utils/profiles/native.d.ts
vendored
@@ -1,11 +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 interface DeviceProfileOptions {
|
||||
transcode?: boolean;
|
||||
}
|
||||
|
||||
export function generateDeviceProfile(options?: DeviceProfileOptions): any;
|
||||
@@ -1,82 +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/.
|
||||
*/
|
||||
import MediaTypes from "../../constants/MediaTypes";
|
||||
import { getSubtitleProfiles } from "./subtitles";
|
||||
|
||||
export const generateDeviceProfile = ({ transcode = false } = {}) => {
|
||||
/**
|
||||
* Device profile for Native video player
|
||||
*/
|
||||
const profile = {
|
||||
Name: `1. Vlc Player${transcode ? " (Transcoding)" : ""}`,
|
||||
MaxStaticBitrate: 999_999_999,
|
||||
MaxStreamingBitrate: 999_999_999,
|
||||
CodecProfiles: [
|
||||
{
|
||||
Type: MediaTypes.Video,
|
||||
Codec: "h264,mpeg4,divx,xvid,wmv,vc1,vp8,vp9,av1",
|
||||
},
|
||||
{
|
||||
Type: MediaTypes.Video,
|
||||
Codec: "hevc,h265",
|
||||
Conditions: [
|
||||
{
|
||||
Condition: "LessThanEqual",
|
||||
Property: "VideoLevel",
|
||||
Value: "153",
|
||||
IsRequired: false,
|
||||
},
|
||||
{
|
||||
Condition: "NotEquals",
|
||||
Property: "VideoRangeType",
|
||||
Value: "DOVI", //no dolby vision at all
|
||||
IsRequired: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
Type: MediaTypes.Audio,
|
||||
Codec: "aac,ac3,eac3,mp3,flac,alac,opus,vorbis,pcm,wma",
|
||||
},
|
||||
],
|
||||
DirectPlayProfiles: [
|
||||
{
|
||||
Type: MediaTypes.Video,
|
||||
Container: "mp4,mkv,avi,mov,flv,ts,m2ts,webm,ogv,3gp,hls",
|
||||
VideoCodec:
|
||||
"h264,hevc,mpeg4,divx,xvid,wmv,vc1,vp8,vp9,av1,avi,mpeg,mpeg2video",
|
||||
AudioCodec: "aac,ac3,eac3,mp3,flac,alac,opus,vorbis,wma,dts",
|
||||
},
|
||||
{
|
||||
Type: MediaTypes.Audio,
|
||||
Container: "mp3,aac,flac,alac,wav,ogg,wma",
|
||||
AudioCodec:
|
||||
"mp3,aac,flac,alac,opus,vorbis,wma,pcm,mpa,wav,ogg,oga,webma,ape",
|
||||
},
|
||||
],
|
||||
TranscodingProfiles: [
|
||||
{
|
||||
Type: MediaTypes.Video,
|
||||
Context: "Streaming",
|
||||
Protocol: "hls",
|
||||
Container: "ts",
|
||||
VideoCodec: "h264, hevc",
|
||||
AudioCodec: "aac,mp3,ac3,dts",
|
||||
},
|
||||
{
|
||||
Type: MediaTypes.Audio,
|
||||
Context: "Streaming",
|
||||
Protocol: "http",
|
||||
Container: "mp3",
|
||||
AudioCodec: "mp3",
|
||||
MaxAudioChannels: "2",
|
||||
},
|
||||
],
|
||||
SubtitleProfiles: getSubtitleProfiles(transcode ? "hls" : "External"),
|
||||
};
|
||||
|
||||
return profile;
|
||||
};
|
||||
203
utils/profiles/native.ts
Normal file
203
utils/profiles/native.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
import type { DeviceProfile } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Platform } from "react-native";
|
||||
import MediaTypes from "../../constants/MediaTypes";
|
||||
import { getSubtitleProfiles } from "./subtitles";
|
||||
|
||||
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: PlatformType) => {
|
||||
if (platform === "ios") {
|
||||
// iOS audio formats supported by MPV
|
||||
return {
|
||||
Type: MediaTypes.Audio,
|
||||
Container: "mp3,m4a,aac,flac,alac,wav,aiff,caf",
|
||||
AudioCodec: "mp3,aac,alac,flac,opus,pcm",
|
||||
};
|
||||
}
|
||||
|
||||
// Android audio formats supported by MPV
|
||||
return {
|
||||
Type: MediaTypes.Audio,
|
||||
Container: "mp3,m4a,aac,ogg,flac,wav,webm,mka",
|
||||
AudioCodec: "mp3,aac,flac,vorbis,opus,pcm",
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Audio codec profiles for standalone audio items in MPV player.
|
||||
* These define codec constraints for audio file playback.
|
||||
*/
|
||||
const getAudioCodecProfile = (platform: PlatformType) => {
|
||||
if (platform === "ios") {
|
||||
// iOS audio codec constraints for MPV
|
||||
return {
|
||||
Type: MediaTypes.Audio,
|
||||
Codec: "aac,ac3,eac3,mp3,flac,alac,opus,pcm",
|
||||
};
|
||||
}
|
||||
|
||||
// Android audio codec constraints for MPV
|
||||
return {
|
||||
Type: MediaTypes.Audio,
|
||||
Codec: "aac,ac3,eac3,mp3,flac,vorbis,opus,pcm",
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the video audio codec configuration based on platform and audio mode.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
const getVideoAudioCodecs = (
|
||||
platform: PlatformType,
|
||||
audioMode: AudioTranscodeModeType,
|
||||
): { directPlayCodec: string; maxAudioChannels: string } => {
|
||||
// Base codecs
|
||||
const baseCodecs = "aac,mp3,flac,opus,vorbis";
|
||||
|
||||
// Surround codecs
|
||||
const surroundCodecs = "ac3,eac3,dts";
|
||||
|
||||
// Lossless HD codecs - MPV decodes these and downmixes as needed
|
||||
const losslessHdCodecs = "truehd";
|
||||
|
||||
// Platform-specific codecs
|
||||
const platformCodecs = platform === "ios" ? "alac,wma" : "wma";
|
||||
|
||||
// MPV can decode all codecs - only channel count varies by mode
|
||||
const allCodecs = `${baseCodecs},${surroundCodecs},${losslessHdCodecs},${platformCodecs}`;
|
||||
|
||||
switch (audioMode) {
|
||||
case "stereo":
|
||||
// Limit to 2 channels - MPV will decode and downmix
|
||||
return {
|
||||
directPlayCodec: allCodecs,
|
||||
maxAudioChannels: "2",
|
||||
};
|
||||
|
||||
case "5.1":
|
||||
// Limit to 6 channels
|
||||
return {
|
||||
directPlayCodec: allCodecs,
|
||||
maxAudioChannels: "6",
|
||||
};
|
||||
|
||||
case "passthrough":
|
||||
// Allow up to 8 channels - for external DAC/receiver setups
|
||||
return {
|
||||
directPlayCodec: allCodecs,
|
||||
maxAudioChannels: "8",
|
||||
};
|
||||
|
||||
default:
|
||||
// Auto mode: default to 5.1 (6 channels)
|
||||
return {
|
||||
directPlayCodec: allCodecs,
|
||||
maxAudioChannels: "6",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates a device profile for Jellyfin playback.
|
||||
*/
|
||||
export const generateDeviceProfile = (options: ProfileOptions = {}) => {
|
||||
const platform = (options.platform || Platform.OS) as PlatformType;
|
||||
const audioMode = options.audioMode || "auto";
|
||||
|
||||
const { directPlayCodec, maxAudioChannels } = getVideoAudioCodecs(
|
||||
platform,
|
||||
audioMode,
|
||||
);
|
||||
|
||||
/**
|
||||
* Device profile for MPV player
|
||||
*/
|
||||
const profile = {
|
||||
Name: "1. MPV",
|
||||
MaxStaticBitrate: 999_999_999,
|
||||
MaxStreamingBitrate: 999_999_999,
|
||||
CodecProfiles: [
|
||||
{
|
||||
Type: MediaTypes.Video,
|
||||
Codec: "h264,mpeg4,divx,xvid,wmv,vc1,vp8,vp9,av1",
|
||||
},
|
||||
{
|
||||
Type: MediaTypes.Video,
|
||||
Codec: "hevc,h265",
|
||||
Conditions: [
|
||||
{
|
||||
Condition: "LessThanEqual",
|
||||
Property: "VideoLevel",
|
||||
Value: "153",
|
||||
IsRequired: false,
|
||||
},
|
||||
{
|
||||
Condition: "NotEquals",
|
||||
Property: "VideoRangeType",
|
||||
Value: "DOVI", //no dolby vision at all
|
||||
IsRequired: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
getAudioCodecProfile(platform),
|
||||
],
|
||||
DirectPlayProfiles: [
|
||||
{
|
||||
Type: MediaTypes.Video,
|
||||
Container: "mp4,mkv,avi,mov,flv,ts,m2ts,webm,ogv,3gp,hls",
|
||||
VideoCodec:
|
||||
"h264,hevc,mpeg4,divx,xvid,wmv,vc1,vp8,vp9,av1,avi,mpeg,mpeg2video",
|
||||
AudioCodec: directPlayCodec,
|
||||
},
|
||||
getAudioDirectPlayProfile(platform),
|
||||
],
|
||||
TranscodingProfiles: [
|
||||
{
|
||||
Type: MediaTypes.Video,
|
||||
Context: "Streaming",
|
||||
Protocol: "hls",
|
||||
Container: "ts",
|
||||
VideoCodec: "h264, hevc",
|
||||
AudioCodec: "aac,mp3,ac3,dts",
|
||||
MaxAudioChannels: maxAudioChannels,
|
||||
},
|
||||
{
|
||||
Type: MediaTypes.Audio,
|
||||
Context: "Streaming",
|
||||
Protocol: "http",
|
||||
Container: "mp3",
|
||||
AudioCodec: "mp3",
|
||||
MaxAudioChannels: "2",
|
||||
},
|
||||
],
|
||||
SubtitleProfiles: getSubtitleProfiles(),
|
||||
} satisfies DeviceProfile;
|
||||
|
||||
return profile;
|
||||
};
|
||||
|
||||
// Default export for backward compatibility
|
||||
export default generateDeviceProfile();
|
||||
@@ -1,56 +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/.
|
||||
*/
|
||||
|
||||
const COMMON_SUBTITLE_PROFILES = [
|
||||
// Official formats
|
||||
|
||||
{ Format: "dvdsub", Method: "Embed" },
|
||||
{ Format: "dvdsub", Method: "Encode" },
|
||||
|
||||
{ Format: "idx", Method: "Embed" },
|
||||
{ Format: "idx", Method: "Encode" },
|
||||
|
||||
{ Format: "pgs", Method: "Embed" },
|
||||
{ Format: "pgs", Method: "Encode" },
|
||||
|
||||
{ Format: "pgssub", Method: "Embed" },
|
||||
{ Format: "pgssub", Method: "Encode" },
|
||||
|
||||
{ Format: "teletext", Method: "Embed" },
|
||||
{ Format: "teletext", Method: "Encode" },
|
||||
];
|
||||
|
||||
const VARYING_SUBTITLE_FORMATS = [
|
||||
"webvtt",
|
||||
"vtt",
|
||||
"srt",
|
||||
"subrip",
|
||||
"ttml",
|
||||
"ass",
|
||||
"ssa",
|
||||
"microdvd",
|
||||
"mov_text",
|
||||
"mpl2",
|
||||
"pjs",
|
||||
"realtext",
|
||||
"scc",
|
||||
"smi",
|
||||
"stl",
|
||||
"sub",
|
||||
"subviewer",
|
||||
"text",
|
||||
"vplayer",
|
||||
"xsub",
|
||||
];
|
||||
|
||||
export const getSubtitleProfiles = (secondaryMethod) => {
|
||||
const profiles = [...COMMON_SUBTITLE_PROFILES];
|
||||
for (const format of VARYING_SUBTITLE_FORMATS) {
|
||||
profiles.push({ Format: format, Method: "Embed" });
|
||||
profiles.push({ Format: format, Method: secondaryMethod });
|
||||
}
|
||||
return profiles;
|
||||
};
|
||||
62
utils/profiles/subtitles.ts
Normal file
62
utils/profiles/subtitles.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
import type { SubtitleProfile } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
|
||||
// Image-based formats - these need to be burned in by Jellyfin (Encode method)
|
||||
// because MPV cannot load them externally over HTTP
|
||||
const IMAGE_BASED_FORMATS = [
|
||||
"dvdsub",
|
||||
"idx",
|
||||
"pgs",
|
||||
"pgssub",
|
||||
"teletext",
|
||||
"vobsub",
|
||||
] as const;
|
||||
|
||||
// Text-based formats - these can be loaded externally by MPV
|
||||
const TEXT_BASED_FORMATS = [
|
||||
"webvtt",
|
||||
"vtt",
|
||||
"srt",
|
||||
"subrip",
|
||||
"ttml",
|
||||
"ass",
|
||||
"ssa",
|
||||
"microdvd",
|
||||
"mov_text",
|
||||
"mpl2",
|
||||
"pjs",
|
||||
"realtext",
|
||||
"scc",
|
||||
"smi",
|
||||
"stl",
|
||||
"sub",
|
||||
"subviewer",
|
||||
"text",
|
||||
"vplayer",
|
||||
"xsub",
|
||||
] as const;
|
||||
|
||||
export const getSubtitleProfiles = (): SubtitleProfile[] => {
|
||||
const profiles: SubtitleProfile[] = [];
|
||||
|
||||
// Image-based formats: Embed or Encode (burn-in), NOT External
|
||||
for (const format of IMAGE_BASED_FORMATS) {
|
||||
profiles.push({ Format: format, Method: "Embed" });
|
||||
profiles.push({ Format: format, Method: "Encode" });
|
||||
}
|
||||
|
||||
// Text-based formats: Embed or External
|
||||
for (const format of TEXT_BASED_FORMATS) {
|
||||
profiles.push({ Format: format, Method: "Embed" });
|
||||
profiles.push({ Format: format, Method: "External" });
|
||||
}
|
||||
|
||||
return profiles;
|
||||
};
|
||||
|
||||
// Export for use in player filtering
|
||||
export const IMAGE_SUBTITLE_CODECS: readonly string[] = IMAGE_BASED_FORMATS;
|
||||
94
utils/profiles/trackplayer.ts
Normal file
94
utils/profiles/trackplayer.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
import type {
|
||||
CodecProfile,
|
||||
DeviceProfile,
|
||||
DirectPlayProfile,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Platform } from "react-native";
|
||||
import MediaTypes from "../../constants/MediaTypes";
|
||||
import type { PlatformType } from "./native";
|
||||
|
||||
export interface TrackPlayerProfileOptions {
|
||||
/** Target platform */
|
||||
platform?: PlatformType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Audio direct play profiles for react-native-track-player.
|
||||
* iOS uses AVPlayer, Android uses ExoPlayer - each has different codec support.
|
||||
*/
|
||||
const getDirectPlayProfile = (platform: PlatformType): DirectPlayProfile => {
|
||||
if (platform === "ios") {
|
||||
// iOS AVPlayer supported formats
|
||||
return {
|
||||
Type: MediaTypes.Audio,
|
||||
Container: "mp3,m4a,aac,flac,alac,wav,aiff,caf",
|
||||
AudioCodec: "mp3,aac,alac,flac,opus,pcm",
|
||||
};
|
||||
}
|
||||
|
||||
// Android ExoPlayer supported formats
|
||||
return {
|
||||
Type: MediaTypes.Audio,
|
||||
Container: "mp3,m4a,aac,ogg,flac,wav,webm,mka",
|
||||
AudioCodec: "mp3,aac,flac,vorbis,opus,pcm",
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Audio codec profiles for react-native-track-player.
|
||||
*/
|
||||
const getCodecProfile = (platform: PlatformType): CodecProfile => {
|
||||
if (platform === "ios") {
|
||||
// iOS AVPlayer codec constraints
|
||||
return {
|
||||
Type: MediaTypes.Audio,
|
||||
Codec: "aac,ac3,eac3,mp3,flac,alac,opus,pcm",
|
||||
};
|
||||
}
|
||||
|
||||
// Android ExoPlayer codec constraints
|
||||
return {
|
||||
Type: MediaTypes.Audio,
|
||||
Codec: "aac,ac3,eac3,mp3,flac,vorbis,opus,pcm",
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates a device profile for music playback via react-native-track-player.
|
||||
*
|
||||
* This profile is specifically for standalone audio playback using:
|
||||
* - AVPlayer on iOS
|
||||
* - ExoPlayer on Android
|
||||
*/
|
||||
export const generateTrackPlayerProfile = (
|
||||
options: TrackPlayerProfileOptions = {},
|
||||
): DeviceProfile => {
|
||||
const platform = (options.platform || Platform.OS) as PlatformType;
|
||||
|
||||
return {
|
||||
Name: "Track Player",
|
||||
MaxStaticBitrate: 320_000_000,
|
||||
MaxStreamingBitrate: 320_000_000,
|
||||
CodecProfiles: [getCodecProfile(platform)],
|
||||
DirectPlayProfiles: [getDirectPlayProfile(platform)],
|
||||
TranscodingProfiles: [
|
||||
{
|
||||
Type: MediaTypes.Audio,
|
||||
Context: "Streaming",
|
||||
Protocol: "http",
|
||||
Container: "mp3",
|
||||
AudioCodec: "mp3",
|
||||
MaxAudioChannels: "2",
|
||||
},
|
||||
],
|
||||
SubtitleProfiles: [],
|
||||
};
|
||||
};
|
||||
|
||||
// Default export for convenience
|
||||
export default generateTrackPlayerProfile();
|
||||
21
utils/query/networkAwareInvalidate.ts
Normal file
21
utils/query/networkAwareInvalidate.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import {
|
||||
type InvalidateOptions,
|
||||
type InvalidateQueryFilters,
|
||||
onlineManager,
|
||||
type QueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
/**
|
||||
* Invalidates queries only when online. When offline, the invalidation
|
||||
* is skipped to preserve cached data for offline use.
|
||||
*/
|
||||
export function invalidateQueriesWhenOnline(
|
||||
queryClient: QueryClient,
|
||||
filters: InvalidateQueryFilters,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<void> {
|
||||
if (!onlineManager.isOnline()) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return queryClient.invalidateQueries(filters, options);
|
||||
}
|
||||
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);
|
||||
};
|
||||
@@ -1,5 +0,0 @@
|
||||
// seconds to ticks util
|
||||
|
||||
export function secondsToTicks(seconds: number): number {
|
||||
return seconds * 10000000;
|
||||
}
|
||||
568
utils/secureCredentials.ts
Normal file
568
utils/secureCredentials.ts
Normal file
@@ -0,0 +1,568 @@
|
||||
import * as Crypto from "expo-crypto";
|
||||
import * as SecureStore from "expo-secure-store";
|
||||
import { storage } from "./mmkv";
|
||||
|
||||
const CREDENTIAL_KEY_PREFIX = "credential_";
|
||||
const MULTI_ACCOUNT_MIGRATED_KEY = "multiAccountMigrated";
|
||||
|
||||
/**
|
||||
* Security type for saved accounts.
|
||||
*/
|
||||
export type AccountSecurityType = "none" | "pin" | "password";
|
||||
|
||||
/**
|
||||
* Credential stored in secure storage for a specific account.
|
||||
*/
|
||||
export interface ServerCredential {
|
||||
serverUrl: string;
|
||||
serverName: string;
|
||||
token: string;
|
||||
userId: string;
|
||||
username: string;
|
||||
savedAt: number;
|
||||
securityType: AccountSecurityType;
|
||||
pinHash?: string;
|
||||
primaryImageTag?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Account summary stored in SavedServer for display in UI.
|
||||
*/
|
||||
export interface SavedServerAccount {
|
||||
userId: string;
|
||||
username: string;
|
||||
securityType: AccountSecurityType;
|
||||
savedAt: number;
|
||||
primaryImageTag?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Local network configuration for automatic URL switching.
|
||||
*/
|
||||
export interface LocalNetworkConfig {
|
||||
localUrl: string;
|
||||
homeWifiSSIDs: string[];
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Server with multiple saved accounts.
|
||||
*/
|
||||
export interface SavedServer {
|
||||
address: string;
|
||||
name?: string;
|
||||
accounts: SavedServerAccount[];
|
||||
localNetworkConfig?: LocalNetworkConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy interface for migration purposes.
|
||||
*/
|
||||
interface LegacySavedServer {
|
||||
address: string;
|
||||
name?: string;
|
||||
hasCredentials?: boolean;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy credential interface for migration purposes.
|
||||
*/
|
||||
interface LegacyServerCredential {
|
||||
serverUrl: string;
|
||||
serverName: string;
|
||||
token: string;
|
||||
userId: string;
|
||||
username: string;
|
||||
savedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode server URL to valid secure store key (legacy, for migration).
|
||||
*/
|
||||
export function serverUrlToKey(serverUrl: string): string {
|
||||
const encoded = btoa(serverUrl).replace(/[^a-zA-Z0-9]/g, "_");
|
||||
return `${CREDENTIAL_KEY_PREFIX}${encoded}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate credential key for a specific account (serverUrl + userId).
|
||||
*/
|
||||
export function credentialKey(serverUrl: string, userId: string): string {
|
||||
const combined = `${serverUrl}:${userId}`;
|
||||
const encoded = btoa(combined).replace(/[^a-zA-Z0-9]/g, "_");
|
||||
return `${CREDENTIAL_KEY_PREFIX}${encoded}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash a PIN using SHA256.
|
||||
*/
|
||||
export async function hashPIN(pin: string): Promise<string> {
|
||||
return await Crypto.digestStringAsync(
|
||||
Crypto.CryptoDigestAlgorithm.SHA256,
|
||||
pin,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a PIN against stored hash.
|
||||
*/
|
||||
export async function verifyAccountPIN(
|
||||
serverUrl: string,
|
||||
userId: string,
|
||||
pin: string,
|
||||
): Promise<boolean> {
|
||||
const credential = await getAccountCredential(serverUrl, userId);
|
||||
if (!credential?.pinHash) return false;
|
||||
const inputHash = await hashPIN(pin);
|
||||
return inputHash === credential.pinHash;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save credential for a specific account.
|
||||
*/
|
||||
export async function saveAccountCredential(
|
||||
credential: ServerCredential,
|
||||
): Promise<void> {
|
||||
const key = credentialKey(credential.serverUrl, credential.userId);
|
||||
await SecureStore.setItemAsync(key, JSON.stringify(credential));
|
||||
|
||||
// Update previousServers to include this account
|
||||
addAccountToServer(credential.serverUrl, credential.serverName, {
|
||||
userId: credential.userId,
|
||||
username: credential.username,
|
||||
securityType: credential.securityType,
|
||||
savedAt: credential.savedAt,
|
||||
primaryImageTag: credential.primaryImageTag,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve credential for a specific account.
|
||||
*/
|
||||
export async function getAccountCredential(
|
||||
serverUrl: string,
|
||||
userId: string,
|
||||
): Promise<ServerCredential | null> {
|
||||
const key = credentialKey(serverUrl, userId);
|
||||
const stored = await SecureStore.getItemAsync(key);
|
||||
|
||||
if (stored) {
|
||||
try {
|
||||
return JSON.parse(stored) as ServerCredential;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete credential for a specific account.
|
||||
*/
|
||||
export async function deleteAccountCredential(
|
||||
serverUrl: string,
|
||||
userId: string,
|
||||
): Promise<void> {
|
||||
const key = credentialKey(serverUrl, userId);
|
||||
await SecureStore.deleteItemAsync(key);
|
||||
|
||||
// Remove account from previousServers
|
||||
removeAccountFromServer(serverUrl, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all credentials for a server (by iterating through accounts).
|
||||
*/
|
||||
export async function getServerAccounts(
|
||||
serverUrl: string,
|
||||
): Promise<ServerCredential[]> {
|
||||
const servers = getPreviousServers();
|
||||
const server = servers.find((s) => s.address === serverUrl);
|
||||
if (!server) return [];
|
||||
|
||||
const credentials: ServerCredential[] = [];
|
||||
for (const account of server.accounts) {
|
||||
const credential = await getAccountCredential(serverUrl, account.userId);
|
||||
if (credential) {
|
||||
credentials.push(credential);
|
||||
}
|
||||
}
|
||||
return credentials;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if credentials exist for a specific account.
|
||||
*/
|
||||
export async function hasAccountCredential(
|
||||
serverUrl: string,
|
||||
userId: string,
|
||||
): Promise<boolean> {
|
||||
const key = credentialKey(serverUrl, userId);
|
||||
const stored = await SecureStore.getItemAsync(key);
|
||||
return stored !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add or update an account in a server's accounts list.
|
||||
*/
|
||||
export function addAccountToServer(
|
||||
serverUrl: string,
|
||||
serverName: string,
|
||||
account: SavedServerAccount,
|
||||
): void {
|
||||
const previousServers = getPreviousServers();
|
||||
let serverFound = false;
|
||||
|
||||
const updatedServers = previousServers.map((server) => {
|
||||
if (server.address === serverUrl) {
|
||||
serverFound = true;
|
||||
// Check if account already exists
|
||||
const existingIndex = server.accounts.findIndex(
|
||||
(a) => a.userId === account.userId,
|
||||
);
|
||||
if (existingIndex >= 0) {
|
||||
// Update existing account
|
||||
const updatedAccounts = [...server.accounts];
|
||||
updatedAccounts[existingIndex] = account;
|
||||
return {
|
||||
...server,
|
||||
name: serverName || server.name,
|
||||
accounts: updatedAccounts,
|
||||
};
|
||||
}
|
||||
// Add new account
|
||||
return {
|
||||
...server,
|
||||
name: serverName || server.name,
|
||||
accounts: [...server.accounts, account],
|
||||
};
|
||||
}
|
||||
return server;
|
||||
});
|
||||
|
||||
// If server not found, add it
|
||||
if (!serverFound) {
|
||||
updatedServers.push({
|
||||
address: serverUrl,
|
||||
name: serverName,
|
||||
accounts: [account],
|
||||
});
|
||||
}
|
||||
|
||||
storage.set("previousServers", JSON.stringify(updatedServers));
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an account from a server's accounts list.
|
||||
*/
|
||||
function removeAccountFromServer(serverUrl: string, userId: string): void {
|
||||
const previousServers = getPreviousServers();
|
||||
|
||||
const updatedServers = previousServers.map((server) => {
|
||||
if (server.address === serverUrl) {
|
||||
return {
|
||||
...server,
|
||||
accounts: server.accounts.filter((a) => a.userId !== userId),
|
||||
};
|
||||
}
|
||||
return server;
|
||||
});
|
||||
|
||||
storage.set("previousServers", JSON.stringify(updatedServers));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get previous servers list from MMKV.
|
||||
*/
|
||||
export function getPreviousServers(): SavedServer[] {
|
||||
const stored = storage.getString("previousServers");
|
||||
if (stored) {
|
||||
try {
|
||||
return JSON.parse(stored) as SavedServer[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a server from the list and delete all its account credentials.
|
||||
*/
|
||||
export async function removeServerFromList(serverUrl: string): Promise<void> {
|
||||
const servers = getPreviousServers();
|
||||
const server = servers.find((s) => s.address === serverUrl);
|
||||
|
||||
// Delete all account credentials for this server
|
||||
if (server) {
|
||||
for (const account of server.accounts) {
|
||||
const key = credentialKey(serverUrl, account.userId);
|
||||
await SecureStore.deleteItemAsync(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove server from list
|
||||
const filtered = servers.filter((s) => s.address !== serverUrl);
|
||||
storage.set("previousServers", JSON.stringify(filtered));
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a server to the list without credentials (for server discovery).
|
||||
*/
|
||||
export function addServerToList(serverUrl: string, serverName?: string): void {
|
||||
const servers = getPreviousServers();
|
||||
const existing = servers.find((s) => s.address === serverUrl);
|
||||
|
||||
if (existing) {
|
||||
// Update name if provided
|
||||
if (serverName) {
|
||||
const updated = servers.map((s) =>
|
||||
s.address === serverUrl ? { ...s, name: serverName } : s,
|
||||
);
|
||||
storage.set("previousServers", JSON.stringify(updated));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Add new server with empty accounts
|
||||
const newServer: SavedServer = {
|
||||
address: serverUrl,
|
||||
name: serverName,
|
||||
accounts: [],
|
||||
};
|
||||
|
||||
// Keep max 10 servers
|
||||
const updatedServers = [newServer, ...servers].slice(0, 10);
|
||||
storage.set("previousServers", JSON.stringify(updatedServers));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update local network configuration for a server.
|
||||
*/
|
||||
export function updateServerLocalConfig(
|
||||
serverUrl: string,
|
||||
config: LocalNetworkConfig | undefined,
|
||||
): void {
|
||||
const servers = getPreviousServers();
|
||||
const updatedServers = servers.map((server) => {
|
||||
if (server.address === serverUrl) {
|
||||
return {
|
||||
...server,
|
||||
localNetworkConfig: config,
|
||||
};
|
||||
}
|
||||
return server;
|
||||
});
|
||||
storage.set("previousServers", JSON.stringify(updatedServers));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get local network configuration for a server.
|
||||
*/
|
||||
export function getServerLocalConfig(
|
||||
serverUrl: string,
|
||||
): LocalNetworkConfig | undefined {
|
||||
const servers = getPreviousServers();
|
||||
const server = servers.find((s) => s.address === serverUrl);
|
||||
return server?.localNetworkConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate from legacy single-account format to multi-account format.
|
||||
* Should be called on app startup.
|
||||
*/
|
||||
export async function migrateToMultiAccount(): Promise<void> {
|
||||
// Check if already migrated
|
||||
if (storage.getBoolean(MULTI_ACCOUNT_MIGRATED_KEY)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stored = storage.getString("previousServers");
|
||||
if (!stored) {
|
||||
storage.set(MULTI_ACCOUNT_MIGRATED_KEY, true);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const servers = JSON.parse(stored);
|
||||
|
||||
// Check if already in new format (has accounts array)
|
||||
if (servers.length > 0 && Array.isArray(servers[0].accounts)) {
|
||||
storage.set(MULTI_ACCOUNT_MIGRATED_KEY, true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Migrate from legacy format
|
||||
const migratedServers: SavedServer[] = [];
|
||||
|
||||
for (const legacyServer of servers as LegacySavedServer[]) {
|
||||
const newServer: SavedServer = {
|
||||
address: legacyServer.address,
|
||||
name: legacyServer.name,
|
||||
accounts: [],
|
||||
};
|
||||
|
||||
// Try to get existing credential using legacy key
|
||||
if (legacyServer.hasCredentials) {
|
||||
const legacyKey = serverUrlToKey(legacyServer.address);
|
||||
const storedCred = await SecureStore.getItemAsync(legacyKey);
|
||||
|
||||
if (storedCred) {
|
||||
try {
|
||||
const legacyCred = JSON.parse(storedCred) as LegacyServerCredential;
|
||||
|
||||
// Create new credential with security type
|
||||
const newCredential: ServerCredential = {
|
||||
...legacyCred,
|
||||
securityType: "none", // Existing accounts get no protection (preserve quick-login)
|
||||
};
|
||||
|
||||
// Save with new key format
|
||||
const newKey = credentialKey(
|
||||
legacyServer.address,
|
||||
legacyCred.userId,
|
||||
);
|
||||
await SecureStore.setItemAsync(
|
||||
newKey,
|
||||
JSON.stringify(newCredential),
|
||||
);
|
||||
|
||||
// Delete old key
|
||||
await SecureStore.deleteItemAsync(legacyKey);
|
||||
|
||||
// Add account to server
|
||||
newServer.accounts.push({
|
||||
userId: legacyCred.userId,
|
||||
username: legacyCred.username,
|
||||
securityType: "none",
|
||||
savedAt: legacyCred.savedAt,
|
||||
});
|
||||
} catch {
|
||||
// Skip invalid credentials
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
migratedServers.push(newServer);
|
||||
}
|
||||
|
||||
storage.set("previousServers", JSON.stringify(migratedServers));
|
||||
storage.set(MULTI_ACCOUNT_MIGRATED_KEY, true);
|
||||
} catch {
|
||||
// If parsing fails, reset to empty array
|
||||
storage.set("previousServers", "[]");
|
||||
storage.set(MULTI_ACCOUNT_MIGRATED_KEY, true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy functions for backward compatibility during transition
|
||||
// These delegate to new functions
|
||||
|
||||
/**
|
||||
* @deprecated Use saveAccountCredential instead
|
||||
*/
|
||||
export async function saveServerCredential(
|
||||
credential: ServerCredential,
|
||||
): Promise<void> {
|
||||
return saveAccountCredential(credential);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use getAccountCredential instead
|
||||
*/
|
||||
export async function getServerCredential(
|
||||
serverUrl: string,
|
||||
): Promise<ServerCredential | null> {
|
||||
// Try to get first account's credential for backward compatibility
|
||||
const servers = getPreviousServers();
|
||||
const server = servers.find((s) => s.address === serverUrl);
|
||||
if (server && server.accounts.length > 0) {
|
||||
return getAccountCredential(serverUrl, server.accounts[0].userId);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use deleteAccountCredential instead
|
||||
*/
|
||||
export async function deleteServerCredential(serverUrl: string): Promise<void> {
|
||||
// Delete first account for backward compatibility
|
||||
const servers = getPreviousServers();
|
||||
const server = servers.find((s) => s.address === serverUrl);
|
||||
if (server && server.accounts.length > 0) {
|
||||
return deleteAccountCredential(serverUrl, server.accounts[0].userId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use hasAccountCredential instead
|
||||
*/
|
||||
export async function hasServerCredential(serverUrl: string): Promise<boolean> {
|
||||
const servers = getPreviousServers();
|
||||
const server = servers.find((s) => s.address === serverUrl);
|
||||
return server ? server.accounts.length > 0 : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use migrateToMultiAccount instead
|
||||
*/
|
||||
export async function migrateServersList(): Promise<void> {
|
||||
return migrateToMultiAccount();
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use saveAccountCredential instead
|
||||
*/
|
||||
export async function migrateCurrentSessionToSecureStorage(
|
||||
serverUrl: string,
|
||||
token: string,
|
||||
userId: string,
|
||||
username: string,
|
||||
serverName?: string,
|
||||
): Promise<void> {
|
||||
const existingCredential = await getAccountCredential(serverUrl, userId);
|
||||
|
||||
// Only save if not already saved
|
||||
if (!existingCredential) {
|
||||
await saveAccountCredential({
|
||||
serverUrl,
|
||||
serverName: serverName || "",
|
||||
token,
|
||||
userId,
|
||||
username,
|
||||
savedAt: Date.now(),
|
||||
securityType: "none",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
324
utils/streamystats/api.ts
Normal file
324
utils/streamystats/api.ts
Normal file
@@ -0,0 +1,324 @@
|
||||
import axios from "axios";
|
||||
import type {
|
||||
AddWatchlistItemResponse,
|
||||
CreateWatchlistRequest,
|
||||
CreateWatchlistResponse,
|
||||
DeleteWatchlistResponse,
|
||||
GetWatchlistItemsParams,
|
||||
GetWatchlistsResponse,
|
||||
RemoveWatchlistItemResponse,
|
||||
StreamystatsRecommendationsFullResponse,
|
||||
StreamystatsRecommendationsIdsResponse,
|
||||
StreamystatsRecommendationsParams,
|
||||
StreamystatsSearchFullResponse,
|
||||
StreamystatsSearchIdsResponse,
|
||||
StreamystatsSearchParams,
|
||||
StreamystatsWatchlistDetailFullResponse,
|
||||
StreamystatsWatchlistDetailIdsResponse,
|
||||
StreamystatsWatchlistDetailParams,
|
||||
StreamystatsWatchlistsFullResponse,
|
||||
StreamystatsWatchlistsParams,
|
||||
UpdateWatchlistRequest,
|
||||
UpdateWatchlistResponse,
|
||||
} from "./types";
|
||||
|
||||
interface StreamystatsApiConfig {
|
||||
serverUrl: string;
|
||||
jellyfinToken: string;
|
||||
}
|
||||
|
||||
export const createStreamystatsApi = (config: StreamystatsApiConfig) => {
|
||||
const { serverUrl, jellyfinToken } = config;
|
||||
|
||||
const baseUrl = serverUrl.endsWith("/") ? serverUrl.slice(0, -1) : serverUrl;
|
||||
|
||||
const headers = {
|
||||
Authorization: `MediaBrowser Token="${jellyfinToken}"`,
|
||||
};
|
||||
|
||||
const search = async (
|
||||
params: StreamystatsSearchParams,
|
||||
signal?: AbortSignal,
|
||||
): Promise<
|
||||
StreamystatsSearchIdsResponse | StreamystatsSearchFullResponse
|
||||
> => {
|
||||
const queryParams = new URLSearchParams();
|
||||
queryParams.set("q", params.q);
|
||||
|
||||
if (params.limit) {
|
||||
queryParams.set("limit", params.limit.toString());
|
||||
}
|
||||
if (params.format) {
|
||||
queryParams.set("format", params.format);
|
||||
}
|
||||
if (params.type) {
|
||||
queryParams.set("type", params.type);
|
||||
}
|
||||
|
||||
const url = `${baseUrl}/api/search?${queryParams.toString()}`;
|
||||
const response = await axios.get(url, { headers, signal });
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const searchIds = async (
|
||||
query: string,
|
||||
type?: StreamystatsSearchParams["type"],
|
||||
limit?: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<StreamystatsSearchIdsResponse> => {
|
||||
return search(
|
||||
{
|
||||
q: query,
|
||||
format: "ids",
|
||||
type,
|
||||
limit,
|
||||
},
|
||||
signal,
|
||||
) as Promise<StreamystatsSearchIdsResponse>;
|
||||
};
|
||||
|
||||
const searchFull = async (
|
||||
query: string,
|
||||
type?: StreamystatsSearchParams["type"],
|
||||
limit?: number,
|
||||
): Promise<StreamystatsSearchFullResponse> => {
|
||||
return search({
|
||||
q: query,
|
||||
format: "full",
|
||||
type,
|
||||
limit,
|
||||
}) as Promise<StreamystatsSearchFullResponse>;
|
||||
};
|
||||
|
||||
const getRecommendations = async (
|
||||
params: StreamystatsRecommendationsParams,
|
||||
): Promise<
|
||||
| StreamystatsRecommendationsIdsResponse
|
||||
| StreamystatsRecommendationsFullResponse
|
||||
> => {
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
if (params.serverId) {
|
||||
queryParams.set("serverId", params.serverId.toString());
|
||||
}
|
||||
if (params.serverName) {
|
||||
queryParams.set("serverName", params.serverName);
|
||||
}
|
||||
if (params.jellyfinServerId) {
|
||||
queryParams.set("jellyfinServerId", params.jellyfinServerId);
|
||||
}
|
||||
if (params.limit) {
|
||||
queryParams.set("limit", params.limit.toString());
|
||||
}
|
||||
if (params.type) {
|
||||
queryParams.set("type", params.type);
|
||||
}
|
||||
if (params.range) {
|
||||
queryParams.set("range", params.range);
|
||||
}
|
||||
if (params.format) {
|
||||
queryParams.set("format", params.format);
|
||||
}
|
||||
if (params.includeBasedOn !== undefined) {
|
||||
queryParams.set("includeBasedOn", params.includeBasedOn.toString());
|
||||
}
|
||||
if (params.includeReasons !== undefined) {
|
||||
queryParams.set("includeReasons", params.includeReasons.toString());
|
||||
}
|
||||
|
||||
const url = `${baseUrl}/api/recommendations?${queryParams.toString()}`;
|
||||
const response = await axios.get(url, { headers });
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const getRecommendationIds = async (
|
||||
jellyfinServerId: string,
|
||||
type?: StreamystatsRecommendationsParams["type"],
|
||||
limit?: number,
|
||||
): Promise<StreamystatsRecommendationsIdsResponse> => {
|
||||
return getRecommendations({
|
||||
jellyfinServerId,
|
||||
format: "ids",
|
||||
type,
|
||||
limit,
|
||||
includeBasedOn: false,
|
||||
includeReasons: false,
|
||||
}) as Promise<StreamystatsRecommendationsIdsResponse>;
|
||||
};
|
||||
|
||||
const getPromotedWatchlists = async (
|
||||
params: StreamystatsWatchlistsParams,
|
||||
): Promise<StreamystatsWatchlistsFullResponse> => {
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
if (params.serverId) {
|
||||
queryParams.set("serverId", params.serverId.toString());
|
||||
}
|
||||
if (params.serverName) {
|
||||
queryParams.set("serverName", params.serverName);
|
||||
}
|
||||
if (params.serverUrl) {
|
||||
queryParams.set("serverUrl", params.serverUrl);
|
||||
}
|
||||
if (params.jellyfinServerId) {
|
||||
queryParams.set("jellyfinServerId", params.jellyfinServerId);
|
||||
}
|
||||
if (params.limit) {
|
||||
queryParams.set("limit", params.limit.toString());
|
||||
}
|
||||
if (params.format) {
|
||||
queryParams.set("format", params.format);
|
||||
}
|
||||
if (params.includePreview !== undefined) {
|
||||
queryParams.set("includePreview", params.includePreview.toString());
|
||||
}
|
||||
|
||||
const url = `${baseUrl}/api/watchlists/promoted?${queryParams.toString()}`;
|
||||
const response = await axios.get(url, { headers });
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const getWatchlistItemIds = async (
|
||||
params: StreamystatsWatchlistDetailParams,
|
||||
): Promise<StreamystatsWatchlistDetailIdsResponse> => {
|
||||
const queryParams = new URLSearchParams();
|
||||
queryParams.set("format", "ids");
|
||||
|
||||
if (params.serverId) {
|
||||
queryParams.set("serverId", params.serverId.toString());
|
||||
}
|
||||
if (params.serverName) {
|
||||
queryParams.set("serverName", params.serverName);
|
||||
}
|
||||
if (params.serverUrl) {
|
||||
queryParams.set("serverUrl", params.serverUrl);
|
||||
}
|
||||
if (params.jellyfinServerId) {
|
||||
queryParams.set("jellyfinServerId", params.jellyfinServerId);
|
||||
}
|
||||
|
||||
const url = `${baseUrl}/api/watchlists/${params.watchlistId}?${queryParams.toString()}`;
|
||||
const response = await axios.get(url, { headers });
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all watchlists (own + public)
|
||||
* GET /api/watchlists
|
||||
*/
|
||||
const getWatchlists = async (): Promise<GetWatchlistsResponse> => {
|
||||
const url = `${baseUrl}/api/watchlists`;
|
||||
const response = await axios.get(url, { headers });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a new watchlist
|
||||
* POST /api/watchlists
|
||||
*/
|
||||
const createWatchlist = async (
|
||||
data: CreateWatchlistRequest,
|
||||
): Promise<CreateWatchlistResponse> => {
|
||||
const url = `${baseUrl}/api/watchlists`;
|
||||
const response = await axios.post(url, data, { headers });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a single watchlist with items
|
||||
* GET /api/watchlists/[id]
|
||||
*/
|
||||
const getWatchlistDetail = async (
|
||||
watchlistId: number,
|
||||
params?: GetWatchlistItemsParams,
|
||||
): Promise<StreamystatsWatchlistDetailFullResponse> => {
|
||||
const queryParams = new URLSearchParams();
|
||||
queryParams.set("format", "full");
|
||||
|
||||
if (params?.type) {
|
||||
queryParams.set("type", params.type);
|
||||
}
|
||||
if (params?.sort) {
|
||||
queryParams.set("sort", params.sort);
|
||||
}
|
||||
|
||||
const url = `${baseUrl}/api/watchlists/${watchlistId}?${queryParams.toString()}`;
|
||||
const response = await axios.get(url, { headers });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Update a watchlist (owner only)
|
||||
* PATCH /api/watchlists/[id]
|
||||
*/
|
||||
const updateWatchlist = async (
|
||||
watchlistId: number,
|
||||
data: UpdateWatchlistRequest,
|
||||
): Promise<UpdateWatchlistResponse> => {
|
||||
const url = `${baseUrl}/api/watchlists/${watchlistId}`;
|
||||
const response = await axios.patch(url, data, { headers });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete a watchlist (owner only)
|
||||
* DELETE /api/watchlists/[id]
|
||||
*/
|
||||
const deleteWatchlist = async (
|
||||
watchlistId: number,
|
||||
): Promise<DeleteWatchlistResponse> => {
|
||||
const url = `${baseUrl}/api/watchlists/${watchlistId}`;
|
||||
const response = await axios.delete(url, { headers });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Add an item to a watchlist (owner only)
|
||||
* POST /api/watchlists/[id]/items
|
||||
*/
|
||||
const addWatchlistItem = async (
|
||||
watchlistId: number,
|
||||
itemId: string,
|
||||
): Promise<AddWatchlistItemResponse> => {
|
||||
const url = `${baseUrl}/api/watchlists/${watchlistId}/items`;
|
||||
const response = await axios.post(url, { itemId }, { headers });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove an item from a watchlist (owner only)
|
||||
* DELETE /api/watchlists/[id]/items/[itemId]
|
||||
*/
|
||||
const removeWatchlistItem = async (
|
||||
watchlistId: number,
|
||||
itemId: string,
|
||||
): Promise<RemoveWatchlistItemResponse> => {
|
||||
const url = `${baseUrl}/api/watchlists/${watchlistId}/items/${itemId}`;
|
||||
const response = await axios.delete(url, { headers });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
return {
|
||||
search,
|
||||
searchIds,
|
||||
searchFull,
|
||||
getRecommendations,
|
||||
getRecommendationIds,
|
||||
getPromotedWatchlists,
|
||||
getWatchlistItemIds,
|
||||
// Watchlist CRUD
|
||||
getWatchlists,
|
||||
createWatchlist,
|
||||
getWatchlistDetail,
|
||||
updateWatchlist,
|
||||
deleteWatchlist,
|
||||
addWatchlistItem,
|
||||
removeWatchlistItem,
|
||||
};
|
||||
};
|
||||
|
||||
export type StreamystatsApi = ReturnType<typeof createStreamystatsApi>;
|
||||
2
utils/streamystats/index.ts
Normal file
2
utils/streamystats/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./api";
|
||||
export * from "./types";
|
||||
329
utils/streamystats/types.ts
Normal file
329
utils/streamystats/types.ts
Normal file
@@ -0,0 +1,329 @@
|
||||
/**
|
||||
* Streamystats Search API Types
|
||||
* Based on the Search API specification
|
||||
*/
|
||||
|
||||
export type StreamystatsSearchType =
|
||||
| "all"
|
||||
| "media"
|
||||
| "movies"
|
||||
| "series"
|
||||
| "episodes"
|
||||
| "audio"
|
||||
| "people"
|
||||
| "actors"
|
||||
| "directors"
|
||||
| "writers"
|
||||
| "users"
|
||||
| "watchlists"
|
||||
| "activities"
|
||||
| "sessions";
|
||||
|
||||
export type StreamystatsSearchFormat = "full" | "ids";
|
||||
|
||||
export interface StreamystatsSearchParams {
|
||||
q: string;
|
||||
limit?: number;
|
||||
format?: StreamystatsSearchFormat;
|
||||
type?: StreamystatsSearchType;
|
||||
}
|
||||
|
||||
export interface StreamystatsSearchResultItem {
|
||||
id: string;
|
||||
type: "item" | "user" | "watchlist" | "activity" | "session" | "actor";
|
||||
subtype?: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
imageId?: string;
|
||||
imageTag?: string;
|
||||
href?: string;
|
||||
rank?: number;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface StreamystatsSearchFullResponse {
|
||||
data: {
|
||||
items: StreamystatsSearchResultItem[];
|
||||
users: StreamystatsSearchResultItem[];
|
||||
watchlists: StreamystatsSearchResultItem[];
|
||||
activities: StreamystatsSearchResultItem[];
|
||||
sessions: StreamystatsSearchResultItem[];
|
||||
actors: StreamystatsSearchResultItem[];
|
||||
total: number;
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface StreamystatsSearchIdsResponse {
|
||||
data: {
|
||||
movies: string[];
|
||||
series: string[];
|
||||
episodes: string[];
|
||||
seasons: string[];
|
||||
audio: string[];
|
||||
actors: string[];
|
||||
directors: string[];
|
||||
writers: string[];
|
||||
total: number;
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export type StreamystatsSearchResponse =
|
||||
| StreamystatsSearchFullResponse
|
||||
| StreamystatsSearchIdsResponse;
|
||||
|
||||
/**
|
||||
* Streamystats Recommendations API Types
|
||||
*/
|
||||
|
||||
export type StreamystatsRecommendationType = "Movie" | "Series" | "all";
|
||||
|
||||
export type StreamystatsRecommendationRange =
|
||||
| "7d"
|
||||
| "30d"
|
||||
| "90d"
|
||||
| "thisMonth"
|
||||
| "all";
|
||||
|
||||
export interface StreamystatsRecommendationsParams {
|
||||
serverId?: number;
|
||||
serverName?: string;
|
||||
jellyfinServerId?: string;
|
||||
limit?: number;
|
||||
type?: StreamystatsRecommendationType;
|
||||
range?: StreamystatsRecommendationRange;
|
||||
format?: StreamystatsSearchFormat;
|
||||
includeBasedOn?: boolean;
|
||||
includeReasons?: boolean;
|
||||
}
|
||||
|
||||
export interface StreamystatsRecommendationItem {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "Movie" | "Series";
|
||||
primaryImageTag?: string;
|
||||
backdropImageTag?: string;
|
||||
overview?: string;
|
||||
year?: number;
|
||||
}
|
||||
|
||||
export interface StreamystatsRecommendation {
|
||||
item: StreamystatsRecommendationItem;
|
||||
similarity: number;
|
||||
basedOn?: StreamystatsRecommendationItem[];
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface StreamystatsRecommendationsFullResponse {
|
||||
server: {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
user: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
params: Record<string, unknown>;
|
||||
data: StreamystatsRecommendation[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface StreamystatsRecommendationsIdsResponse {
|
||||
data: {
|
||||
movies: string[];
|
||||
series: string[];
|
||||
total: number;
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export type StreamystatsRecommendationsResponse =
|
||||
| StreamystatsRecommendationsFullResponse
|
||||
| StreamystatsRecommendationsIdsResponse;
|
||||
|
||||
/**
|
||||
* Streamystats Watchlists API Types
|
||||
*/
|
||||
|
||||
export interface StreamystatsServerInfo {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface StreamystatsWatchlistsParams {
|
||||
serverId?: number;
|
||||
serverName?: string;
|
||||
serverUrl?: string;
|
||||
jellyfinServerId?: string;
|
||||
limit?: number;
|
||||
format?: StreamystatsSearchFormat;
|
||||
includePreview?: boolean;
|
||||
}
|
||||
|
||||
export interface StreamystatsWatchlistPreviewItem {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "Movie" | "Series" | "Episode";
|
||||
primaryImageTag?: string;
|
||||
}
|
||||
|
||||
export interface StreamystatsWatchlistItem {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "Movie" | "Series" | "Episode";
|
||||
productionYear?: number;
|
||||
runtimeTicks?: number;
|
||||
genres?: string[];
|
||||
primaryImageTag?: string;
|
||||
seriesId?: string;
|
||||
seriesName?: string;
|
||||
communityRating?: number;
|
||||
}
|
||||
|
||||
export interface StreamystatsWatchlistItemEntry {
|
||||
id: number;
|
||||
watchlistId: number;
|
||||
itemId: string;
|
||||
position: number;
|
||||
addedAt: string;
|
||||
item: StreamystatsWatchlistItem;
|
||||
}
|
||||
|
||||
export interface StreamystatsWatchlist {
|
||||
id: number;
|
||||
serverId: number;
|
||||
userId: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
isPublic: boolean;
|
||||
isPromoted: boolean;
|
||||
allowedItemType?: string;
|
||||
defaultSortOrder?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
itemCount?: number;
|
||||
previewItems?: StreamystatsWatchlistPreviewItem[];
|
||||
items?: StreamystatsWatchlistItemEntry[];
|
||||
}
|
||||
|
||||
export interface StreamystatsWatchlistsFullResponse {
|
||||
server: StreamystatsServerInfo;
|
||||
data: StreamystatsWatchlist[];
|
||||
total: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface StreamystatsWatchlistsIdsResponse {
|
||||
data: {
|
||||
watchlists: string[];
|
||||
total: number;
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export type StreamystatsWatchlistsResponse =
|
||||
| StreamystatsWatchlistsFullResponse
|
||||
| StreamystatsWatchlistsIdsResponse;
|
||||
|
||||
export interface StreamystatsWatchlistDetailParams {
|
||||
watchlistId: number;
|
||||
serverId?: number;
|
||||
serverName?: string;
|
||||
serverUrl?: string;
|
||||
jellyfinServerId?: string;
|
||||
format?: StreamystatsSearchFormat;
|
||||
}
|
||||
|
||||
export interface StreamystatsWatchlistDetailFullResponse {
|
||||
server: StreamystatsServerInfo;
|
||||
data: StreamystatsWatchlist;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface StreamystatsWatchlistDetailIdsResponse {
|
||||
server: StreamystatsServerInfo;
|
||||
data: {
|
||||
id: number;
|
||||
name: string;
|
||||
items: string[];
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export type StreamystatsWatchlistDetailResponse =
|
||||
| StreamystatsWatchlistDetailFullResponse
|
||||
| StreamystatsWatchlistDetailIdsResponse;
|
||||
|
||||
/**
|
||||
* Streamystats Watchlists CRUD Types
|
||||
*/
|
||||
|
||||
export type StreamystatsWatchlistAllowedItemType =
|
||||
| "Movie"
|
||||
| "Series"
|
||||
| "Episode"
|
||||
| null;
|
||||
|
||||
export type StreamystatsWatchlistSortOrder =
|
||||
| "custom"
|
||||
| "name"
|
||||
| "dateAdded"
|
||||
| "releaseDate";
|
||||
|
||||
export interface CreateWatchlistRequest {
|
||||
name: string;
|
||||
description?: string;
|
||||
isPublic?: boolean;
|
||||
allowedItemType?: StreamystatsWatchlistAllowedItemType;
|
||||
defaultSortOrder?: StreamystatsWatchlistSortOrder;
|
||||
}
|
||||
|
||||
export interface UpdateWatchlistRequest {
|
||||
name?: string;
|
||||
description?: string;
|
||||
isPublic?: boolean;
|
||||
allowedItemType?: StreamystatsWatchlistAllowedItemType;
|
||||
defaultSortOrder?: StreamystatsWatchlistSortOrder;
|
||||
}
|
||||
|
||||
export interface CreateWatchlistResponse {
|
||||
data: StreamystatsWatchlist;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface UpdateWatchlistResponse {
|
||||
data: StreamystatsWatchlist;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface DeleteWatchlistResponse {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface AddWatchlistItemResponse {
|
||||
data: {
|
||||
id: number;
|
||||
watchlistId: number;
|
||||
itemId: string;
|
||||
position: number;
|
||||
addedAt: string;
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface RemoveWatchlistItemResponse {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface GetWatchlistsResponse {
|
||||
data: StreamystatsWatchlist[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface GetWatchlistItemsParams {
|
||||
type?: "Movie" | "Series" | "Episode";
|
||||
sort?: StreamystatsWatchlistSortOrder;
|
||||
}
|
||||
@@ -99,3 +99,21 @@ export const msToSeconds = (ms?: number | undefined) => {
|
||||
if (!ms) return 0;
|
||||
return Math.floor(ms / 1000);
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats ticks to a compact duration string (MM:SS or HH:MM:SS).
|
||||
* Useful for music track durations.
|
||||
*/
|
||||
export const formatDuration = (ticks: number | null | undefined): string => {
|
||||
if (!ticks) return "0:00";
|
||||
|
||||
const totalSeconds = Math.floor(ticks / 10000000);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
|
||||
}
|
||||
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
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<
|
||||
|
||||
94
utils/version.ts
Normal file
94
utils/version.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import * as Application from "expo-application";
|
||||
import Constants from "expo-constants";
|
||||
|
||||
/** Raw marketing version (app.json `version`), e.g. "0.54.1". Exposed so the Jellyfin
|
||||
* clientInfo auto-tracks the app version instead of a hardcoded string. */
|
||||
export const APP_VERSION = Application.nativeApplicationVersion ?? "unknown";
|
||||
|
||||
/** Build metadata injected at build time by `app.config.ts` into `extra.build`. */
|
||||
export interface BuildMeta {
|
||||
commit?: string | null;
|
||||
branch?: string | null;
|
||||
profile?: string | null;
|
||||
runNumber?: string | null;
|
||||
builtAt?: string | null;
|
||||
}
|
||||
|
||||
export interface VersionInfo {
|
||||
/** Marketing version (CFBundleShortVersionString / android versionName), e.g. "0.54.1". */
|
||||
version: string | null;
|
||||
/** Build number (CFBundleVersion / versionCode), e.g. "42". */
|
||||
build: string | null;
|
||||
/** Short git commit the build was made from, e.g. "a1b2c3d". */
|
||||
commit: string | null;
|
||||
/** Git branch the build was made from, e.g. "develop". */
|
||||
branch: string | null;
|
||||
/** EAS build profile, e.g. "production", "ci", "preview", or null for local. */
|
||||
profile: string | null;
|
||||
/** GitHub Actions run number the build came from, e.g. "2098". Null outside CI. */
|
||||
runNumber: string | null;
|
||||
isDev: boolean;
|
||||
isProduction: boolean;
|
||||
/** Graduated label for the Settings "App version" row (see tiering below). */
|
||||
display: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a graduated version string for Settings.
|
||||
*
|
||||
* Tiering (most → least detailed):
|
||||
* - dev / local build → `version · branch · commit` (full context for debugging)
|
||||
* - develop / CI / preview → `version · commit · #run` (pin the exact source; the
|
||||
* Actions run number maps the build to its run — artifacts + logs — without
|
||||
* Expo access)
|
||||
* - production (store / TestFlight) → `version` (build number intentionally
|
||||
* not shown: TestFlight already displays it to testers, and the commit pins the
|
||||
* binary better)
|
||||
*/
|
||||
export function getVersionInfo(): VersionInfo {
|
||||
// Read native/config values defensively — a version string must never crash Settings
|
||||
// (e.g. a dev build whose native expo-constants is out of sync with the JS).
|
||||
const read = <T>(fn: () => T): T | null => {
|
||||
try {
|
||||
return fn() ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const version = read(() => Application.nativeApplicationVersion);
|
||||
const build = read(() => Application.nativeBuildVersion);
|
||||
const meta = (read(() => Constants.expoConfig?.extra?.build) ??
|
||||
{}) as BuildMeta;
|
||||
const commit = meta.commit ?? null;
|
||||
const branch = meta.branch ?? null;
|
||||
const profile = meta.profile ?? null;
|
||||
const runNumber = meta.runNumber ?? null;
|
||||
const isDev = __DEV__ === true;
|
||||
const isProduction =
|
||||
typeof profile === "string" && profile.startsWith("production");
|
||||
|
||||
let display: string;
|
||||
if (isDev) {
|
||||
display = [version ?? "dev", branch, commit].filter(Boolean).join(" · ");
|
||||
} else if (isProduction) {
|
||||
display = version ?? build ?? "N/A";
|
||||
} else {
|
||||
display =
|
||||
[version, commit, runNumber && `#${runNumber}`]
|
||||
.filter(Boolean)
|
||||
.join(" · ") || "N/A";
|
||||
}
|
||||
|
||||
return {
|
||||
version,
|
||||
build,
|
||||
commit,
|
||||
branch,
|
||||
profile,
|
||||
runNumber,
|
||||
isDev,
|
||||
isProduction,
|
||||
display,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user