mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-01 06:22:44 -08:00
feat(core): add Electron runtime, services, and app composition
This commit is contained in:
523
src/core/services/jellyfin.ts
Normal file
523
src/core/services/jellyfin.ts
Normal file
@@ -0,0 +1,523 @@
|
||||
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));
|
||||
}
|
||||
Reference in New Issue
Block a user