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( path: string, init: RequestInit, session: JellyfinAuthSession, client: JellyfinClientInfo, ): Promise { 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; } 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 { 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 { const payload = await jellyfinRequestJson( `/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> { 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( `/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 { if (!itemId.trim()) throw new Error('Missing Jellyfin item id.'); let source: JellyfinMediaSource | undefined; try { const playbackInfo = await jellyfinRequestJson( `/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( `/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 { if (!selection.itemId) { throw new Error('Missing Jellyfin item id.'); } const item = await jellyfinRequestJson( `/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)); }