mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-15 15:48:05 +00:00
feat: KSPlayer as an option for iOS + other improvements (#1266)
This commit is contained in:
committed by
GitHub
parent
d1795c9df8
commit
74d86b5d12
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
115
utils/jellyfin/subtitleUtils.ts
Normal file
115
utils/jellyfin/subtitleUtils.ts
Normal 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;
|
||||
};
|
||||
Submodule utils/jellyseerr updated: 4401b16414...fc6a9e952c
@@ -55,7 +55,6 @@ export const writeToLog = (level: LogLevel, message: string, data?: any) => {
|
||||
const recentLogs = logs.slice(Math.max(logs.length - maxLogs, 0));
|
||||
|
||||
storage.set("logs", JSON.stringify(recentLogs));
|
||||
console.log(message);
|
||||
};
|
||||
|
||||
export const writeInfoLog = (message: string, data?: any) =>
|
||||
|
||||
6
utils/profiles/native.d.ts
vendored
6
utils/profiles/native.d.ts
vendored
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
319
utils/streamystats/api.ts
Normal 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>;
|
||||
2
utils/streamystats/index.ts
Normal file
2
utils/streamystats/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./api";
|
||||
export * from "./types";
|
||||
329
utils/streamystats/types.ts
Normal file
329
utils/streamystats/types.ts
Normal file
@@ -0,0 +1,329 @@
|
||||
/**
|
||||
* Streamystats Search API Types
|
||||
* Based on the Search API specification
|
||||
*/
|
||||
|
||||
export type StreamystatsSearchType =
|
||||
| "all"
|
||||
| "media"
|
||||
| "movies"
|
||||
| "series"
|
||||
| "episodes"
|
||||
| "audio"
|
||||
| "people"
|
||||
| "actors"
|
||||
| "directors"
|
||||
| "writers"
|
||||
| "users"
|
||||
| "watchlists"
|
||||
| "activities"
|
||||
| "sessions";
|
||||
|
||||
export type StreamystatsSearchFormat = "full" | "ids";
|
||||
|
||||
export interface StreamystatsSearchParams {
|
||||
q: string;
|
||||
limit?: number;
|
||||
format?: StreamystatsSearchFormat;
|
||||
type?: StreamystatsSearchType;
|
||||
}
|
||||
|
||||
export interface StreamystatsSearchResultItem {
|
||||
id: string;
|
||||
type: "item" | "user" | "watchlist" | "activity" | "session" | "actor";
|
||||
subtype?: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
imageId?: string;
|
||||
imageTag?: string;
|
||||
href?: string;
|
||||
rank?: number;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface StreamystatsSearchFullResponse {
|
||||
data: {
|
||||
items: StreamystatsSearchResultItem[];
|
||||
users: StreamystatsSearchResultItem[];
|
||||
watchlists: StreamystatsSearchResultItem[];
|
||||
activities: StreamystatsSearchResultItem[];
|
||||
sessions: StreamystatsSearchResultItem[];
|
||||
actors: StreamystatsSearchResultItem[];
|
||||
total: number;
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface StreamystatsSearchIdsResponse {
|
||||
data: {
|
||||
movies: string[];
|
||||
series: string[];
|
||||
episodes: string[];
|
||||
seasons: string[];
|
||||
audio: string[];
|
||||
actors: string[];
|
||||
directors: string[];
|
||||
writers: string[];
|
||||
total: number;
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export type StreamystatsSearchResponse =
|
||||
| StreamystatsSearchFullResponse
|
||||
| StreamystatsSearchIdsResponse;
|
||||
|
||||
/**
|
||||
* Streamystats Recommendations API Types
|
||||
*/
|
||||
|
||||
export type StreamystatsRecommendationType = "Movie" | "Series" | "all";
|
||||
|
||||
export type StreamystatsRecommendationRange =
|
||||
| "7d"
|
||||
| "30d"
|
||||
| "90d"
|
||||
| "thisMonth"
|
||||
| "all";
|
||||
|
||||
export interface StreamystatsRecommendationsParams {
|
||||
serverId?: number;
|
||||
serverName?: string;
|
||||
jellyfinServerId?: string;
|
||||
limit?: number;
|
||||
type?: StreamystatsRecommendationType;
|
||||
range?: StreamystatsRecommendationRange;
|
||||
format?: StreamystatsSearchFormat;
|
||||
includeBasedOn?: boolean;
|
||||
includeReasons?: boolean;
|
||||
}
|
||||
|
||||
export interface StreamystatsRecommendationItem {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "Movie" | "Series";
|
||||
primaryImageTag?: string;
|
||||
backdropImageTag?: string;
|
||||
overview?: string;
|
||||
year?: number;
|
||||
}
|
||||
|
||||
export interface StreamystatsRecommendation {
|
||||
item: StreamystatsRecommendationItem;
|
||||
similarity: number;
|
||||
basedOn?: StreamystatsRecommendationItem[];
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface StreamystatsRecommendationsFullResponse {
|
||||
server: {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
user: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
params: Record<string, unknown>;
|
||||
data: StreamystatsRecommendation[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface StreamystatsRecommendationsIdsResponse {
|
||||
data: {
|
||||
movies: string[];
|
||||
series: string[];
|
||||
total: number;
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export type StreamystatsRecommendationsResponse =
|
||||
| StreamystatsRecommendationsFullResponse
|
||||
| StreamystatsRecommendationsIdsResponse;
|
||||
|
||||
/**
|
||||
* Streamystats Watchlists API Types
|
||||
*/
|
||||
|
||||
export interface StreamystatsServerInfo {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface StreamystatsWatchlistsParams {
|
||||
serverId?: number;
|
||||
serverName?: string;
|
||||
serverUrl?: string;
|
||||
jellyfinServerId?: string;
|
||||
limit?: number;
|
||||
format?: StreamystatsSearchFormat;
|
||||
includePreview?: boolean;
|
||||
}
|
||||
|
||||
export interface StreamystatsWatchlistPreviewItem {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "Movie" | "Series" | "Episode";
|
||||
primaryImageTag?: string;
|
||||
}
|
||||
|
||||
export interface StreamystatsWatchlistItem {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "Movie" | "Series" | "Episode";
|
||||
productionYear?: number;
|
||||
runtimeTicks?: number;
|
||||
genres?: string[];
|
||||
primaryImageTag?: string;
|
||||
seriesId?: string;
|
||||
seriesName?: string;
|
||||
communityRating?: number;
|
||||
}
|
||||
|
||||
export interface StreamystatsWatchlistItemEntry {
|
||||
id: number;
|
||||
watchlistId: number;
|
||||
itemId: string;
|
||||
position: number;
|
||||
addedAt: string;
|
||||
item: StreamystatsWatchlistItem;
|
||||
}
|
||||
|
||||
export interface StreamystatsWatchlist {
|
||||
id: number;
|
||||
serverId: number;
|
||||
userId: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
isPublic: boolean;
|
||||
isPromoted: boolean;
|
||||
allowedItemType?: string;
|
||||
defaultSortOrder?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
itemCount?: number;
|
||||
previewItems?: StreamystatsWatchlistPreviewItem[];
|
||||
items?: StreamystatsWatchlistItemEntry[];
|
||||
}
|
||||
|
||||
export interface StreamystatsWatchlistsFullResponse {
|
||||
server: StreamystatsServerInfo;
|
||||
data: StreamystatsWatchlist[];
|
||||
total: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface StreamystatsWatchlistsIdsResponse {
|
||||
data: {
|
||||
watchlists: string[];
|
||||
total: number;
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export type StreamystatsWatchlistsResponse =
|
||||
| StreamystatsWatchlistsFullResponse
|
||||
| StreamystatsWatchlistsIdsResponse;
|
||||
|
||||
export interface StreamystatsWatchlistDetailParams {
|
||||
watchlistId: number;
|
||||
serverId?: number;
|
||||
serverName?: string;
|
||||
serverUrl?: string;
|
||||
jellyfinServerId?: string;
|
||||
format?: StreamystatsSearchFormat;
|
||||
}
|
||||
|
||||
export interface StreamystatsWatchlistDetailFullResponse {
|
||||
server: StreamystatsServerInfo;
|
||||
data: StreamystatsWatchlist;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface StreamystatsWatchlistDetailIdsResponse {
|
||||
server: StreamystatsServerInfo;
|
||||
data: {
|
||||
id: number;
|
||||
name: string;
|
||||
items: string[];
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export type StreamystatsWatchlistDetailResponse =
|
||||
| StreamystatsWatchlistDetailFullResponse
|
||||
| StreamystatsWatchlistDetailIdsResponse;
|
||||
|
||||
/**
|
||||
* Streamystats Watchlists CRUD Types
|
||||
*/
|
||||
|
||||
export type StreamystatsWatchlistAllowedItemType =
|
||||
| "Movie"
|
||||
| "Series"
|
||||
| "Episode"
|
||||
| null;
|
||||
|
||||
export type StreamystatsWatchlistSortOrder =
|
||||
| "custom"
|
||||
| "name"
|
||||
| "dateAdded"
|
||||
| "releaseDate";
|
||||
|
||||
export interface CreateWatchlistRequest {
|
||||
name: string;
|
||||
description?: string;
|
||||
isPublic?: boolean;
|
||||
allowedItemType?: StreamystatsWatchlistAllowedItemType;
|
||||
defaultSortOrder?: StreamystatsWatchlistSortOrder;
|
||||
}
|
||||
|
||||
export interface UpdateWatchlistRequest {
|
||||
name?: string;
|
||||
description?: string;
|
||||
isPublic?: boolean;
|
||||
allowedItemType?: StreamystatsWatchlistAllowedItemType;
|
||||
defaultSortOrder?: StreamystatsWatchlistSortOrder;
|
||||
}
|
||||
|
||||
export interface CreateWatchlistResponse {
|
||||
data: StreamystatsWatchlist;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface UpdateWatchlistResponse {
|
||||
data: StreamystatsWatchlist;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface DeleteWatchlistResponse {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface AddWatchlistItemResponse {
|
||||
data: {
|
||||
id: number;
|
||||
watchlistId: number;
|
||||
itemId: string;
|
||||
position: number;
|
||||
addedAt: string;
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface RemoveWatchlistItemResponse {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface GetWatchlistsResponse {
|
||||
data: StreamystatsWatchlist[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface GetWatchlistItemsParams {
|
||||
type?: "Movie" | "Series" | "Episode";
|
||||
sort?: StreamystatsWatchlistSortOrder;
|
||||
}
|
||||
@@ -99,3 +99,21 @@ export const msToSeconds = (ms?: number | undefined) => {
|
||||
if (!ms) return 0;
|
||||
return Math.floor(ms / 1000);
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats ticks to a compact duration string (MM:SS or HH:MM:SS).
|
||||
* Useful for music track durations.
|
||||
*/
|
||||
export const formatDuration = (ticks: number | null | undefined): string => {
|
||||
if (!ticks) return "0:00";
|
||||
|
||||
const totalSeconds = Math.floor(ticks / 10000000);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
|
||||
}
|
||||
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user