mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-27 18:22:41 -08:00
572 lines
16 KiB
TypeScript
572 lines
16 KiB
TypeScript
import { JellyfinConfig } from "../../types";
|
|
|
|
const JELLYFIN_TICKS_PER_SECOND = 10_000_000;
|
|
|
|
export interface JellyfinAuthSession {
|
|
serverUrl: string;
|
|
accessToken: string;
|
|
userId: string;
|
|
username: string;
|
|
}
|
|
|
|
export interface JellyfinLibrary {
|
|
id: string;
|
|
name: string;
|
|
collectionType: string;
|
|
type: string;
|
|
}
|
|
|
|
export interface JellyfinPlaybackSelection {
|
|
itemId: string;
|
|
audioStreamIndex?: number;
|
|
subtitleStreamIndex?: number;
|
|
}
|
|
|
|
export interface JellyfinPlaybackPlan {
|
|
mode: "direct" | "transcode";
|
|
url: string;
|
|
title: string;
|
|
startTimeTicks: number;
|
|
audioStreamIndex: number | null;
|
|
subtitleStreamIndex: number | null;
|
|
}
|
|
|
|
export interface JellyfinSubtitleTrack {
|
|
index: number;
|
|
language: string;
|
|
title: string;
|
|
codec: string;
|
|
isDefault: boolean;
|
|
isForced: boolean;
|
|
isExternal: boolean;
|
|
deliveryMethod: string;
|
|
deliveryUrl: string | null;
|
|
}
|
|
|
|
interface JellyfinAuthResponse {
|
|
AccessToken?: string;
|
|
User?: { Id?: string; Name?: string };
|
|
}
|
|
|
|
interface JellyfinMediaStream {
|
|
Index?: number;
|
|
Type?: string;
|
|
IsExternal?: boolean;
|
|
IsDefault?: boolean;
|
|
IsForced?: boolean;
|
|
Language?: string;
|
|
DisplayTitle?: string;
|
|
Title?: string;
|
|
Codec?: string;
|
|
DeliveryMethod?: string;
|
|
DeliveryUrl?: string;
|
|
IsExternalUrl?: boolean;
|
|
}
|
|
|
|
interface JellyfinMediaSource {
|
|
Id?: string;
|
|
Container?: string;
|
|
SupportsDirectStream?: boolean;
|
|
SupportsTranscoding?: boolean;
|
|
TranscodingUrl?: string;
|
|
DefaultAudioStreamIndex?: number;
|
|
DefaultSubtitleStreamIndex?: number;
|
|
MediaStreams?: JellyfinMediaStream[];
|
|
LiveStreamId?: string;
|
|
}
|
|
|
|
interface JellyfinItemUserData {
|
|
PlaybackPositionTicks?: number;
|
|
}
|
|
|
|
interface JellyfinItem {
|
|
Id?: string;
|
|
Name?: string;
|
|
Type?: string;
|
|
SeriesName?: string;
|
|
ParentIndexNumber?: number;
|
|
IndexNumber?: number;
|
|
UserData?: JellyfinItemUserData;
|
|
MediaSources?: JellyfinMediaSource[];
|
|
}
|
|
|
|
interface JellyfinItemsResponse {
|
|
Items?: JellyfinItem[];
|
|
}
|
|
|
|
interface JellyfinPlaybackInfoResponse {
|
|
MediaSources?: JellyfinMediaSource[];
|
|
}
|
|
|
|
export interface JellyfinClientInfo {
|
|
deviceId: string;
|
|
clientName: string;
|
|
clientVersion: string;
|
|
}
|
|
|
|
function normalizeBaseUrl(value: string): string {
|
|
return value.trim().replace(/\/+$/, "");
|
|
}
|
|
|
|
function ensureString(value: unknown, fallback = ""): string {
|
|
return typeof value === "string" ? value : fallback;
|
|
}
|
|
|
|
function asIntegerOrNull(value: unknown): number | null {
|
|
return typeof value === "number" && Number.isInteger(value) ? value : null;
|
|
}
|
|
|
|
function resolveDeliveryUrl(
|
|
session: JellyfinAuthSession,
|
|
stream: JellyfinMediaStream,
|
|
itemId: string,
|
|
mediaSourceId: string,
|
|
): string | null {
|
|
const deliveryUrl = ensureString(stream.DeliveryUrl).trim();
|
|
if (deliveryUrl) {
|
|
if (stream.IsExternalUrl === true) return deliveryUrl;
|
|
const resolved = new URL(deliveryUrl, `${session.serverUrl}/`);
|
|
if (!resolved.searchParams.has("api_key")) {
|
|
resolved.searchParams.set("api_key", session.accessToken);
|
|
}
|
|
return resolved.toString();
|
|
}
|
|
|
|
const streamIndex = asIntegerOrNull(stream.Index);
|
|
if (streamIndex === null || !itemId || !mediaSourceId) return null;
|
|
const codec = ensureString(stream.Codec).toLowerCase();
|
|
const ext =
|
|
codec === "subrip"
|
|
? "srt"
|
|
: codec === "webvtt"
|
|
? "vtt"
|
|
: codec === "vtt"
|
|
? "vtt"
|
|
: codec === "ass"
|
|
? "ass"
|
|
: codec === "ssa"
|
|
? "ssa"
|
|
: "srt";
|
|
const fallback = new URL(
|
|
`/Videos/${encodeURIComponent(itemId)}/${encodeURIComponent(mediaSourceId)}/Subtitles/${streamIndex}/Stream.${ext}`,
|
|
`${session.serverUrl}/`,
|
|
);
|
|
if (!fallback.searchParams.has("api_key")) {
|
|
fallback.searchParams.set("api_key", session.accessToken);
|
|
}
|
|
return fallback.toString();
|
|
}
|
|
|
|
function createAuthorizationHeader(
|
|
client: JellyfinClientInfo,
|
|
token?: string,
|
|
): string {
|
|
const parts = [
|
|
`Client="${client.clientName}"`,
|
|
`Device="${client.clientName}"`,
|
|
`DeviceId="${client.deviceId}"`,
|
|
`Version="${client.clientVersion}"`,
|
|
];
|
|
if (token) parts.push(`Token="${token}"`);
|
|
return `MediaBrowser ${parts.join(", ")}`;
|
|
}
|
|
|
|
async function jellyfinRequestJson<T>(
|
|
path: string,
|
|
init: RequestInit,
|
|
session: JellyfinAuthSession,
|
|
client: JellyfinClientInfo,
|
|
): Promise<T> {
|
|
const headers = new Headers(init.headers ?? {});
|
|
headers.set("Content-Type", "application/json");
|
|
headers.set(
|
|
"Authorization",
|
|
createAuthorizationHeader(client, session.accessToken),
|
|
);
|
|
headers.set("X-Emby-Token", session.accessToken);
|
|
|
|
const response = await fetch(`${session.serverUrl}${path}`, {
|
|
...init,
|
|
headers,
|
|
});
|
|
|
|
if (response.status === 401 || response.status === 403) {
|
|
throw new Error(
|
|
"Jellyfin authentication failed (invalid or expired token).",
|
|
);
|
|
}
|
|
if (!response.ok) {
|
|
throw new Error(
|
|
`Jellyfin request failed (${response.status} ${response.statusText}).`,
|
|
);
|
|
}
|
|
return response.json() as Promise<T>;
|
|
}
|
|
|
|
function createDirectPlayUrl(
|
|
session: JellyfinAuthSession,
|
|
itemId: string,
|
|
mediaSource: JellyfinMediaSource,
|
|
plan: JellyfinPlaybackPlan,
|
|
): string {
|
|
const query = new URLSearchParams({
|
|
static: "true",
|
|
api_key: session.accessToken,
|
|
MediaSourceId: ensureString(mediaSource.Id),
|
|
});
|
|
if (mediaSource.LiveStreamId) {
|
|
query.set("LiveStreamId", mediaSource.LiveStreamId);
|
|
}
|
|
if (plan.audioStreamIndex !== null) {
|
|
query.set("AudioStreamIndex", String(plan.audioStreamIndex));
|
|
}
|
|
if (plan.subtitleStreamIndex !== null) {
|
|
query.set("SubtitleStreamIndex", String(plan.subtitleStreamIndex));
|
|
}
|
|
if (plan.startTimeTicks > 0) {
|
|
query.set("StartTimeTicks", String(plan.startTimeTicks));
|
|
}
|
|
return `${session.serverUrl}/Videos/${itemId}/stream?${query.toString()}`;
|
|
}
|
|
|
|
function createTranscodeUrl(
|
|
session: JellyfinAuthSession,
|
|
itemId: string,
|
|
mediaSource: JellyfinMediaSource,
|
|
plan: JellyfinPlaybackPlan,
|
|
config: JellyfinConfig,
|
|
): string {
|
|
if (mediaSource.TranscodingUrl) {
|
|
const url = new URL(`${session.serverUrl}${mediaSource.TranscodingUrl}`);
|
|
if (!url.searchParams.has("api_key")) {
|
|
url.searchParams.set("api_key", session.accessToken);
|
|
}
|
|
if (
|
|
!url.searchParams.has("AudioStreamIndex") &&
|
|
plan.audioStreamIndex !== null
|
|
) {
|
|
url.searchParams.set("AudioStreamIndex", String(plan.audioStreamIndex));
|
|
}
|
|
if (
|
|
!url.searchParams.has("SubtitleStreamIndex") &&
|
|
plan.subtitleStreamIndex !== null
|
|
) {
|
|
url.searchParams.set(
|
|
"SubtitleStreamIndex",
|
|
String(plan.subtitleStreamIndex),
|
|
);
|
|
}
|
|
if (!url.searchParams.has("StartTimeTicks") && plan.startTimeTicks > 0) {
|
|
url.searchParams.set("StartTimeTicks", String(plan.startTimeTicks));
|
|
}
|
|
return url.toString();
|
|
}
|
|
|
|
const query = new URLSearchParams({
|
|
api_key: session.accessToken,
|
|
MediaSourceId: ensureString(mediaSource.Id),
|
|
VideoCodec: ensureString(config.transcodeVideoCodec, "h264"),
|
|
TranscodingContainer: "ts",
|
|
});
|
|
if (plan.audioStreamIndex !== null) {
|
|
query.set("AudioStreamIndex", String(plan.audioStreamIndex));
|
|
}
|
|
if (plan.subtitleStreamIndex !== null) {
|
|
query.set("SubtitleStreamIndex", String(plan.subtitleStreamIndex));
|
|
}
|
|
if (plan.startTimeTicks > 0) {
|
|
query.set("StartTimeTicks", String(plan.startTimeTicks));
|
|
}
|
|
return `${session.serverUrl}/Videos/${itemId}/master.m3u8?${query.toString()}`;
|
|
}
|
|
|
|
function getStreamDefaults(source: JellyfinMediaSource): {
|
|
audioStreamIndex: number | null;
|
|
} {
|
|
const audioDefault = asIntegerOrNull(source.DefaultAudioStreamIndex);
|
|
if (audioDefault !== null) return { audioStreamIndex: audioDefault };
|
|
|
|
const streams = Array.isArray(source.MediaStreams) ? source.MediaStreams : [];
|
|
const defaultAudio = streams.find(
|
|
(stream) => stream.Type === "Audio" && stream.IsDefault === true,
|
|
);
|
|
return {
|
|
audioStreamIndex: asIntegerOrNull(defaultAudio?.Index),
|
|
};
|
|
}
|
|
|
|
function getDisplayTitle(item: JellyfinItem): string {
|
|
if (item.Type === "Episode") {
|
|
const season = asIntegerOrNull(item.ParentIndexNumber) ?? 0;
|
|
const episode = asIntegerOrNull(item.IndexNumber) ?? 0;
|
|
const prefix = item.SeriesName ? `${item.SeriesName} ` : "";
|
|
return `${prefix}S${String(season).padStart(2, "0")}E${String(episode).padStart(2, "0")} ${ensureString(item.Name).trim()}`.trim();
|
|
}
|
|
return ensureString(item.Name).trim() || "Jellyfin Item";
|
|
}
|
|
|
|
function shouldPreferDirectPlay(
|
|
source: JellyfinMediaSource,
|
|
config: JellyfinConfig,
|
|
): boolean {
|
|
if (source.SupportsDirectStream !== true) return false;
|
|
if (config.directPlayPreferred === false) return false;
|
|
|
|
const container = ensureString(source.Container).toLowerCase();
|
|
const allowlist = Array.isArray(config.directPlayContainers)
|
|
? config.directPlayContainers.map((entry) => entry.toLowerCase())
|
|
: [];
|
|
if (!container || allowlist.length === 0) return true;
|
|
return allowlist.includes(container);
|
|
}
|
|
|
|
export async function authenticateWithPassword(
|
|
serverUrl: string,
|
|
username: string,
|
|
password: string,
|
|
client: JellyfinClientInfo,
|
|
): Promise<JellyfinAuthSession> {
|
|
const normalizedUrl = normalizeBaseUrl(serverUrl);
|
|
if (!normalizedUrl) throw new Error("Missing Jellyfin server URL.");
|
|
if (!username.trim()) throw new Error("Missing Jellyfin username.");
|
|
if (!password) throw new Error("Missing Jellyfin password.");
|
|
|
|
const response = await fetch(`${normalizedUrl}/Users/AuthenticateByName`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: createAuthorizationHeader(client),
|
|
},
|
|
body: JSON.stringify({
|
|
Username: username,
|
|
Pw: password,
|
|
}),
|
|
});
|
|
|
|
if (response.status === 401 || response.status === 403) {
|
|
throw new Error("Invalid Jellyfin username or password.");
|
|
}
|
|
if (!response.ok) {
|
|
throw new Error(
|
|
`Jellyfin login failed (${response.status} ${response.statusText}).`,
|
|
);
|
|
}
|
|
|
|
const payload = (await response.json()) as JellyfinAuthResponse;
|
|
const accessToken = ensureString(payload.AccessToken);
|
|
const userId = ensureString(payload.User?.Id);
|
|
if (!accessToken || !userId) {
|
|
throw new Error("Jellyfin login response missing token/user.");
|
|
}
|
|
|
|
return {
|
|
serverUrl: normalizedUrl,
|
|
accessToken,
|
|
userId,
|
|
username: username.trim(),
|
|
};
|
|
}
|
|
|
|
export async function listLibraries(
|
|
session: JellyfinAuthSession,
|
|
client: JellyfinClientInfo,
|
|
): Promise<JellyfinLibrary[]> {
|
|
const payload = await jellyfinRequestJson<JellyfinItemsResponse>(
|
|
`/Users/${session.userId}/Views`,
|
|
{ method: "GET" },
|
|
session,
|
|
client,
|
|
);
|
|
|
|
const items = Array.isArray(payload.Items) ? payload.Items : [];
|
|
return items.map((item) => ({
|
|
id: ensureString(item.Id),
|
|
name: ensureString(item.Name, "Untitled"),
|
|
collectionType: ensureString(
|
|
(item as { CollectionType?: string }).CollectionType,
|
|
),
|
|
type: ensureString(item.Type),
|
|
}));
|
|
}
|
|
|
|
export async function listItems(
|
|
session: JellyfinAuthSession,
|
|
client: JellyfinClientInfo,
|
|
options: {
|
|
libraryId: string;
|
|
searchTerm?: string;
|
|
limit?: number;
|
|
},
|
|
): Promise<Array<{ id: string; name: string; type: string; title: string }>> {
|
|
if (!options.libraryId) throw new Error("Missing Jellyfin library id.");
|
|
|
|
const query = new URLSearchParams({
|
|
ParentId: options.libraryId,
|
|
Recursive: "true",
|
|
IncludeItemTypes: "Movie,Episode,Audio",
|
|
Fields: "MediaSources,UserData",
|
|
SortBy: "SortName",
|
|
SortOrder: "Ascending",
|
|
Limit: String(options.limit ?? 100),
|
|
});
|
|
if (options.searchTerm?.trim()) {
|
|
query.set("SearchTerm", options.searchTerm.trim());
|
|
}
|
|
|
|
const payload = await jellyfinRequestJson<JellyfinItemsResponse>(
|
|
`/Users/${session.userId}/Items?${query.toString()}`,
|
|
{ method: "GET" },
|
|
session,
|
|
client,
|
|
);
|
|
const items = Array.isArray(payload.Items) ? payload.Items : [];
|
|
return items.map((item) => ({
|
|
id: ensureString(item.Id),
|
|
name: ensureString(item.Name),
|
|
type: ensureString(item.Type),
|
|
title: getDisplayTitle(item),
|
|
}));
|
|
}
|
|
|
|
export async function listSubtitleTracks(
|
|
session: JellyfinAuthSession,
|
|
client: JellyfinClientInfo,
|
|
itemId: string,
|
|
): Promise<JellyfinSubtitleTrack[]> {
|
|
if (!itemId.trim()) throw new Error("Missing Jellyfin item id.");
|
|
let source: JellyfinMediaSource | undefined;
|
|
|
|
try {
|
|
const playbackInfo =
|
|
await jellyfinRequestJson<JellyfinPlaybackInfoResponse>(
|
|
`/Items/${itemId}/PlaybackInfo?UserId=${encodeURIComponent(session.userId)}`,
|
|
{
|
|
method: "POST",
|
|
body: JSON.stringify({ UserId: session.userId }),
|
|
},
|
|
session,
|
|
client,
|
|
);
|
|
source = Array.isArray(playbackInfo.MediaSources)
|
|
? playbackInfo.MediaSources[0]
|
|
: undefined;
|
|
} catch {}
|
|
|
|
if (!source) {
|
|
const item = await jellyfinRequestJson<JellyfinItem>(
|
|
`/Users/${session.userId}/Items/${itemId}?Fields=MediaSources`,
|
|
{ method: "GET" },
|
|
session,
|
|
client,
|
|
);
|
|
source = Array.isArray(item.MediaSources)
|
|
? item.MediaSources[0]
|
|
: undefined;
|
|
}
|
|
|
|
if (!source) {
|
|
throw new Error("No playable media source found for Jellyfin item.");
|
|
}
|
|
const mediaSourceId = ensureString(source.Id);
|
|
|
|
const streams = Array.isArray(source.MediaStreams) ? source.MediaStreams : [];
|
|
const tracks: JellyfinSubtitleTrack[] = [];
|
|
for (const stream of streams) {
|
|
if (stream.Type !== "Subtitle") continue;
|
|
const index = asIntegerOrNull(stream.Index);
|
|
if (index === null) continue;
|
|
tracks.push({
|
|
index,
|
|
language: ensureString(stream.Language),
|
|
title: ensureString(stream.DisplayTitle || stream.Title),
|
|
codec: ensureString(stream.Codec),
|
|
isDefault: stream.IsDefault === true,
|
|
isForced: stream.IsForced === true,
|
|
isExternal: stream.IsExternal === true,
|
|
deliveryMethod: ensureString(stream.DeliveryMethod),
|
|
deliveryUrl: resolveDeliveryUrl(session, stream, itemId, mediaSourceId),
|
|
});
|
|
}
|
|
return tracks;
|
|
}
|
|
|
|
export async function resolvePlaybackPlan(
|
|
session: JellyfinAuthSession,
|
|
client: JellyfinClientInfo,
|
|
config: JellyfinConfig,
|
|
selection: JellyfinPlaybackSelection,
|
|
): Promise<JellyfinPlaybackPlan> {
|
|
if (!selection.itemId) {
|
|
throw new Error("Missing Jellyfin item id.");
|
|
}
|
|
|
|
const item = await jellyfinRequestJson<JellyfinItem>(
|
|
`/Users/${session.userId}/Items/${selection.itemId}?Fields=MediaSources,UserData`,
|
|
{ method: "GET" },
|
|
session,
|
|
client,
|
|
);
|
|
const source = Array.isArray(item.MediaSources)
|
|
? item.MediaSources[0]
|
|
: undefined;
|
|
if (!source) {
|
|
throw new Error("No playable media source found for Jellyfin item.");
|
|
}
|
|
|
|
const defaults = getStreamDefaults(source);
|
|
const audioStreamIndex =
|
|
selection.audioStreamIndex ?? defaults.audioStreamIndex ?? null;
|
|
const subtitleStreamIndex = selection.subtitleStreamIndex ?? null;
|
|
const startTimeTicks = Math.max(
|
|
0,
|
|
asIntegerOrNull(item.UserData?.PlaybackPositionTicks) ?? 0,
|
|
);
|
|
const basePlan: JellyfinPlaybackPlan = {
|
|
mode: "transcode",
|
|
url: "",
|
|
title: getDisplayTitle(item),
|
|
startTimeTicks,
|
|
audioStreamIndex,
|
|
subtitleStreamIndex,
|
|
};
|
|
|
|
if (shouldPreferDirectPlay(source, config)) {
|
|
return {
|
|
...basePlan,
|
|
mode: "direct",
|
|
url: createDirectPlayUrl(session, selection.itemId, source, basePlan),
|
|
};
|
|
}
|
|
if (
|
|
source.SupportsTranscoding !== true &&
|
|
source.SupportsDirectStream === true
|
|
) {
|
|
return {
|
|
...basePlan,
|
|
mode: "direct",
|
|
url: createDirectPlayUrl(session, selection.itemId, source, basePlan),
|
|
};
|
|
}
|
|
if (source.SupportsTranscoding !== true) {
|
|
throw new Error(
|
|
"Jellyfin item cannot be streamed by direct play or transcoding.",
|
|
);
|
|
}
|
|
|
|
return {
|
|
...basePlan,
|
|
mode: "transcode",
|
|
url: createTranscodeUrl(
|
|
session,
|
|
selection.itemId,
|
|
source,
|
|
basePlan,
|
|
config,
|
|
),
|
|
};
|
|
}
|
|
|
|
export function ticksToSeconds(ticks: number): number {
|
|
return Math.max(0, Math.floor(ticks / JELLYFIN_TICKS_PER_SECOND));
|
|
}
|