mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-28 06:22:45 -08:00
524 lines
16 KiB
TypeScript
524 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));
|
|
}
|