feat: KSPlayer as an option for iOS + other improvements (#1266)

This commit is contained in:
Fredrik Burmester
2026-01-03 13:05:50 +01:00
committed by GitHub
parent d1795c9df8
commit 74d86b5d12
191 changed files with 88479 additions and 2316 deletions

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

@@ -8,7 +8,6 @@ import {
} from "@jellyfin/sdk/lib/generated-client";
import { atom, useAtom, useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo } from "react";
import { Platform } from "react-native";
import { BITRATES, type Bitrate } from "@/components/BitrateSelector";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { apiAtom } from "@/providers/JellyfinProvider";
@@ -130,10 +129,15 @@ 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,
}
// iOS video player selection
export enum VideoPlayerIOS {
KSPlayer = "ksplayer",
VLC = "vlc",
}
export type Settings = {
@@ -141,9 +145,12 @@ export type Settings = {
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;
defaultBitrate?: Bitrate;
libraryOptions: LibraryOptions;
@@ -162,24 +169,31 @@ 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;
// MPV subtitle settings
mpvSubtitleScale?: number;
mpvSubtitleMarginY?: number;
mpvSubtitleAlignX?: "left" | "center" | "right";
mpvSubtitleAlignY?: "top" | "center" | "bottom";
mpvSubtitleFontSize?: number;
// KSPlayer settings
ksHardwareDecode: boolean;
ksSubtitleColor: string;
ksSubtitleBackgroundColor: string;
ksSubtitleFontName: string;
// Gesture controls
enableHorizontalSwipeSkip: boolean;
enableLeftSideBrightnessSwipe: boolean;
enableRightSideVolumeSwipe: boolean;
usePopularPlugin: boolean;
showLargeHomeCarousel: boolean;
mergeNextUpAndContinueWatching: boolean;
// iOS video player selection
videoPlayerIOS: VideoPlayerIOS;
};
export interface Lockable<T> {
@@ -201,7 +215,10 @@ export const defaultValues: Settings = {
preferedLanguage: undefined,
searchEngine: "Jellyfin",
marlinServerUrl: "",
openInVLC: false,
streamyStatsServerUrl: "",
streamyStatsMovieRecommendations: false,
streamyStatsSeriesRecommendations: false,
streamyStatsPromotedWatchlists: false,
downloadQuality: DownloadOptions[0],
defaultBitrate: BITRATES[0],
libraryOptions: {
@@ -223,27 +240,34 @@ 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,
// MPV subtitle defaults
mpvSubtitleScale: undefined,
mpvSubtitleMarginY: undefined,
mpvSubtitleAlignX: undefined,
mpvSubtitleAlignY: undefined,
mpvSubtitleFontSize: undefined,
// KSPlayer defaults
ksHardwareDecode: true,
ksSubtitleColor: "#FFFFFF",
ksSubtitleBackgroundColor: "#00000080",
ksSubtitleFontName: "System",
// Gesture controls
enableHorizontalSwipeSkip: true,
enableLeftSideBrightnessSwipe: true,
enableRightSideVolumeSwipe: true,
usePopularPlugin: true,
showLargeHomeCarousel: false,
mergeNextUpAndContinueWatching: false,
// iOS video player selection - default to VLC
videoPlayerIOS: VideoPlayerIOS.VLC,
};
const loadSettings = (): Partial<Settings> => {
@@ -309,20 +333,60 @@ export const useSettings = () => {
[_setPluginSettings],
);
const refreshStreamyfinPluginSettings = useCallback(async () => {
if (!api) {
return;
}
const settings = await api.getStreamyfinPluginConfig().then(
({ data }) => {
writeInfoLog("Got plugin settings", data?.settings);
return data?.settings;
},
(_err) => undefined,
);
setPluginSettings(settings);
return settings;
}, [api]);
const refreshStreamyfinPluginSettings = useCallback(
async (forceOverride = false) => {
if (!api) {
return;
}
const newPluginSettings = await api.getStreamyfinPluginConfig().then(
({ data }) => {
writeInfoLog("Got plugin settings", data?.settings);
return data?.settings;
},
(_err) => undefined,
);
setPluginSettings(newPluginSettings);
// Apply plugin values to settings
if (newPluginSettings && _settings) {
const updates: Partial<Settings> = {};
for (const [key, setting] of Object.entries(newPluginSettings)) {
if (setting && !setting.locked && setting.value !== undefined) {
const settingsKey = key as keyof Settings;
// Apply if forceOverride is true, or if user hasn't explicitly set this value
if (
forceOverride ||
_settings[settingsKey] === undefined ||
_settings[settingsKey] === ""
) {
(updates as any)[settingsKey] = setting.value;
}
}
}
// Auto-enable Streamystats if server URL is provided
const streamyStatsUrl = newPluginSettings.streamyStatsServerUrl;
if (
streamyStatsUrl?.value &&
_settings.searchEngine !== "Streamystats"
) {
updates.searchEngine = "Streamystats";
}
if (Object.keys(updates).length > 0) {
const newSettings = {
...defaultValues,
..._settings,
...updates,
} as Settings;
setSettings(newSettings);
saveSettings(newSettings);
}
}
return newPluginSettings;
},
[api, _settings],
);
const updateSettings = (update: Partial<Settings>) => {
if (!_settings) {

View File

@@ -1,4 +1,13 @@
// 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,
@@ -12,86 +21,83 @@ 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.
/**
* 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)
*/
export function getDefaultPlaySettings(
item: BaseItemDto,
settings: Settings,
previousIndexes?: previousIndexes,
previousSource?: MediaSourceInfo,
settings: Settings | null,
previous?: { indexes?: PreviousIndexes; source?: MediaSourceInfo },
): PlaySettings {
if (item.Type === "Program") {
return {
item,
bitrate: BITRATES[0],
mediaSource: undefined,
audioIndex: undefined,
subtitleIndex: undefined,
};
}
const bitrate = settings?.defaultBitrate ?? BITRATES[0];
// 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);
// 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 };
ranker.rankStream(
previousIndexes.subtitleIndex,
previousSource,
mediaStreams,
trackOptions,
previous.indexes.subtitleIndex,
previous.source,
streams,
result,
);
subtitleIndex = result.DefaultSubtitleStreamIndex;
}
if (
settings.rememberAudioSelections &&
previous.indexes.audioIndex !== undefined
) {
const ranker = new StreamRanker(new AudioStreamRanker());
const result = { DefaultAudioStreamIndex: audioIndex };
ranker.rankStream(
previous.indexes.audioIndex,
previous.source,
streams,
result,
);
audioIndex = result.DefaultAudioStreamIndex;
}
}
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,
);
}
}
// 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

@@ -1,7 +1,7 @@
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 type { Settings } from "../../atoms/settings";
import { generateDeviceProfile } from "../../profiles/native";
import { getAuthHeaders } from "../jellyfin";
interface PostCapabilitiesParams {

View File

@@ -0,0 +1,115 @@
/**
* 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.
*
* Audio tracks are simpler - they're always in MPV (no burn-in like image subs).
* MPV track IDs are 1-based.
*
* @param mediaSource - The media source containing audio streams
* @param jellyfinAudioIndex - The Jellyfin server-side audio index
* @returns MPV track ID (1-based), or undefined if not found
*/
export const getMpvAudioId = (
mediaSource: MediaSourceInfo | null | undefined,
jellyfinAudioIndex: number | undefined,
): number | undefined => {
if (jellyfinAudioIndex === undefined) {
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

@@ -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) =>

View File

@@ -4,8 +4,4 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
export interface DeviceProfileOptions {
transcode?: boolean;
}
export function generateDeviceProfile(options?: DeviceProfileOptions): any;
export function generateDeviceProfile(): any;

View File

@@ -6,12 +6,12 @@
import MediaTypes from "../../constants/MediaTypes";
import { getSubtitleProfiles } from "./subtitles";
export const generateDeviceProfile = ({ transcode = false } = {}) => {
export const generateDeviceProfile = () => {
/**
* Device profile for Native video player
*/
const profile = {
Name: `1. Vlc Player${transcode ? " (Transcoding)" : ""}`,
Name: `1. MPV Player`,
MaxStaticBitrate: 999_999_999,
MaxStreamingBitrate: 999_999_999,
CodecProfiles: [
@@ -48,7 +48,7 @@ export const generateDeviceProfile = ({ transcode = false } = {}) => {
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",
AudioCodec: "aac,ac3,eac3,mp3,flac,alac,opus,vorbis,wma,dts,truehd",
},
{
Type: MediaTypes.Audio,
@@ -75,7 +75,7 @@ export const generateDeviceProfile = ({ transcode = false } = {}) => {
MaxAudioChannels: "2",
},
],
SubtitleProfiles: getSubtitleProfiles(transcode ? "hls" : "External"),
SubtitleProfiles: getSubtitleProfiles(),
};
return profile;

View File

@@ -4,26 +4,19 @@
* 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" },
// 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",
];
const VARYING_SUBTITLE_FORMATS = [
// Text-based formats - these can be loaded externally by MPV
const TEXT_BASED_FORMATS = [
"webvtt",
"vtt",
"srt",
@@ -46,11 +39,23 @@ const VARYING_SUBTITLE_FORMATS = [
"xsub",
];
export const getSubtitleProfiles = (secondaryMethod) => {
const profiles = [...COMMON_SUBTITLE_PROFILES];
for (const format of VARYING_SUBTITLE_FORMATS) {
export const getSubtitleProfiles = () => {
const profiles = [];
// 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: secondaryMethod });
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 = IMAGE_BASED_FORMATS;

319
utils/streamystats/api.ts Normal file
View File

@@ -0,0 +1,319 @@
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,
): 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 });
return response.data;
};
const searchIds = async (
query: string,
type?: StreamystatsSearchParams["type"],
limit?: number,
): Promise<StreamystatsSearchIdsResponse> => {
return search({
q: query,
format: "ids",
type,
limit,
}) 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")}`;
};