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:
Lance Chant
2026-07-17 13:50:57 +02:00
706 changed files with 164712 additions and 14657 deletions

View File

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

View File

@@ -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;
};

View File

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

View File

@@ -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;
}, {});

View File

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

View File

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

View File

@@ -0,0 +1,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);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
View 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
View 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)}`;
};

View File

@@ -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;
};

View 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;
}

View File

@@ -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;
}

View 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";
};

View 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;
}
};

View File

@@ -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,
};
}

View 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()}`;
};

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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");
}
};

View 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;
};

View File

@@ -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 [];
}
};

View File

@@ -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}`);
}
};

View File

@@ -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
View 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
View 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);
}
});
});
}

View File

@@ -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" },
],
};

View 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
View 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";

View File

@@ -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;

View File

@@ -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
View 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();

View File

@@ -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;
};

View 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;

View 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();

View 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
View 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);
};

View File

@@ -1,5 +0,0 @@
// seconds to ticks util
export function secondsToTicks(seconds: number): number {
return seconds * 10000000;
}

568
utils/secureCredentials.ts Normal file
View 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",
});
}
}

View File

@@ -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
View 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>;

View File

@@ -0,0 +1,2 @@
export * from "./api";
export * from "./types";

329
utils/streamystats/types.ts Normal file
View 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;
}

View File

@@ -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
View 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);
}
}

View 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
View 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,
);
}
}
}

View File

@@ -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
View 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,
};
}