/** * 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, but MPV's track list is NOT in MediaStreams order: * 1. Embedded/HLS subs are enumerated from the container (or HLS playlist) * first, in MediaStreams order. * 2. External subs are appended via `sub-add` AFTER the file loads, in the * order they are passed to MPV (here, also MediaStreams order — see * direct-player.tsx where the externalSubtitles array is built by * filtering MediaStreams). * * Iterating in pure MediaStreams order produces the wrong MPV ID whenever an * External sub is listed before an Embed sub in MediaStreams (common when * Jellyfin prepends a converted SRT/VTT ahead of an original PGS/ASS track), * causing e.g. English to select Spanish. We therefore count in two phases * that mirror MPV's actual ordering. * * Image-based subs (PGS/VOBSUB) during transcoding are burned into the video * and absent from MPV's track list; they are skipped in both phases. * * @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; } const isExternal = (sub: MediaStream) => sub.DeliveryMethod === SubtitleDeliveryMethod.External; let mpvIndex = 0; // Phase 1: embedded / HLS subs — these occupy MPV track IDs first because // they come from the container or HLS playlist. for (const sub of allSubs) { if (isExternal(sub)) continue; if (!isSubtitleInMpv(sub, isTranscoding)) continue; mpvIndex++; if (sub.Index === jellyfinSubtitleIndex) { return mpvIndex; } } // Phase 2: external subs — appended via `sub-add` after the file loads, // so they come last in MPV's track list. for (const sub of allSubs) { if (!isExternal(sub)) continue; if (!isSubtitleInMpv(sub, isTranscoding)) continue; mpvIndex++; if (sub.Index === jellyfinSubtitleIndex) { return mpvIndex; } } return undefined; }; /** * Calculate the MPV track ID for a given Jellyfin audio index. * * For direct play: Audio tracks map to their position in the file (1-based). * For transcoding: Only ONE audio track exists in the HLS stream (the selected one), * so we should return 1 or undefined to use the default track. * * MPV track IDs are 1-based. * * @param mediaSource - The media source containing audio streams * @param jellyfinAudioIndex - The Jellyfin server-side audio index * @param isTranscoding - Whether the stream is being transcoded * @returns MPV track ID (1-based), or undefined if not found */ export const getMpvAudioId = ( mediaSource: MediaSourceInfo | null | undefined, jellyfinAudioIndex: number | undefined, isTranscoding: boolean, ): number | undefined => { if (jellyfinAudioIndex === undefined) { return undefined; } // When transcoding, Jellyfin only includes the selected audio track in the HLS stream. // So there's only 1 audio track - no need to specify an ID. if (isTranscoding) { 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; };