mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-30 17:42:51 +01:00
resolveSubtitleTrack matches a Jellyfin subtitle against the player's real track list (external by external-filename, embedded by language/title) instead of positional counting, which mis-selects when externals/embedded are reordered or the server hides embedded subs. applyMpvSubtitleSelection is the shared entry point (reusable for the cast backend). compareTracksForMenu mirrors web's itemHelper.sortTracks. Drops dead getMpvSubtitleId/isSubtitleInMpv. 14 unit tests.
219 lines
7.7 KiB
TypeScript
219 lines
7.7 KiB
TypeScript
import { describe, expect, test } from "bun:test";
|
|
import type {
|
|
MediaStream,
|
|
SubtitleDeliveryMethod,
|
|
} from "@jellyfin/sdk/lib/generated-client";
|
|
import {
|
|
compareTracksForMenu,
|
|
isExternalSubtitle,
|
|
type PlayerSubtitleTrack,
|
|
resolveSubtitleTrack,
|
|
} from "./subtitleUtils";
|
|
|
|
// String-enum values as typed literals — avoids a runtime SDK import (see subtitleUtils.ts).
|
|
const External = "External" as SubtitleDeliveryMethod;
|
|
const Embed = "Embed" as SubtitleDeliveryMethod;
|
|
|
|
// --- fixtures --------------------------------------------------------------
|
|
|
|
const sub = (o: Partial<MediaStream> & { Index: number }): MediaStream =>
|
|
({ Type: "Subtitle", ...o }) as MediaStream;
|
|
|
|
const ext = (Index: number, o: Partial<MediaStream> = {}): MediaStream =>
|
|
sub({
|
|
Index,
|
|
DeliveryMethod: External,
|
|
IsExternal: true,
|
|
DeliveryUrl: `/sub/${Index}.srt`,
|
|
...o,
|
|
});
|
|
|
|
const emb = (Index: number, o: Partial<MediaStream> = {}): MediaStream =>
|
|
sub({ Index, DeliveryMethod: Embed, ...o });
|
|
|
|
const track = (o: PlayerSubtitleTrack): PlayerSubtitleTrack => o;
|
|
|
|
// Mirror direct-player.tsx online URL builder.
|
|
const urlBuilder =
|
|
(base: string) =>
|
|
(s: MediaStream): string | undefined =>
|
|
s.DeliveryUrl ? `${base}${s.DeliveryUrl}` : undefined;
|
|
|
|
const resolve = (
|
|
streams: MediaStream[],
|
|
index: number | undefined,
|
|
player: PlayerSubtitleTrack[],
|
|
getExpectedExternalUrl = urlBuilder("http://srv"),
|
|
) =>
|
|
resolveSubtitleTrack({
|
|
subtitleStreams: streams,
|
|
jellyfinSubtitleIndex: index,
|
|
playerTracks: player,
|
|
getExpectedExternalUrl,
|
|
});
|
|
|
|
// --- tests -----------------------------------------------------------------
|
|
|
|
describe("isExternalSubtitle", () => {
|
|
test("true for External delivery, IsExternal flag, or a DeliveryUrl", () => {
|
|
expect(isExternalSubtitle(ext(0))).toBe(true);
|
|
expect(isExternalSubtitle(sub({ Index: 1, DeliveryUrl: "/x.srt" }))).toBe(
|
|
true,
|
|
);
|
|
expect(isExternalSubtitle(emb(2))).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("resolveSubtitleTrack — disable / notFound", () => {
|
|
test("index -1 or undefined disables", () => {
|
|
expect(resolve([], -1, [])).toEqual({ kind: "disable" });
|
|
expect(resolve([], undefined, [])).toEqual({ kind: "disable" });
|
|
});
|
|
|
|
test("index not present returns notFound", () => {
|
|
expect(resolve([emb(0)], 99, [track({ id: 1 })])).toEqual({
|
|
kind: "notFound",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("resolveSubtitleTrack — hidden embedded (#954)", () => {
|
|
// Server hides embedded subs: MediaStreams lists only the 3 externals,
|
|
// but mpv still demuxes the 3 embedded from the file → externals get ids 4,5,6.
|
|
const streams = [
|
|
ext(0, { Language: "por" }),
|
|
ext(1, { Language: "eng" }),
|
|
ext(2, { Language: "eng", Title: "SDH" }),
|
|
];
|
|
const player = [
|
|
track({ id: 1, external: false, language: "eng", title: "CC" }),
|
|
track({ id: 2, external: false, language: "spa" }),
|
|
track({ id: 3, external: false, language: "fre" }),
|
|
track({ id: 4, external: true, externalFilename: "http://srv/sub/0.srt" }),
|
|
track({ id: 5, external: true, externalFilename: "http://srv/sub/1.srt" }),
|
|
track({ id: 6, external: true, externalFilename: "http://srv/sub/2.srt" }),
|
|
];
|
|
|
|
test("each external maps to the right player id by filename (not 1,2,3)", () => {
|
|
expect(resolve(streams, 0, player)).toEqual({ kind: "select", trackId: 4 });
|
|
expect(resolve(streams, 1, player)).toEqual({ kind: "select", trackId: 5 });
|
|
expect(resolve(streams, 2, player)).toEqual({ kind: "select", trackId: 6 });
|
|
});
|
|
|
|
test("falls back to external ordinal when filenames are unavailable", () => {
|
|
const noNames = player.map((t) =>
|
|
t.external ? { ...t, externalFilename: undefined } : t,
|
|
);
|
|
expect(resolve(streams, 1, noNames)).toEqual({
|
|
kind: "select",
|
|
trackId: 5,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("resolveSubtitleTrack — external/embed reversal (non-hidden)", () => {
|
|
// Jellyfin lists externals first; mpv lists embedded first then externals.
|
|
const streams = [
|
|
ext(0, { Language: "eng" }),
|
|
emb(1, { Language: "spa" }),
|
|
emb(2, { Language: "fre" }),
|
|
];
|
|
const player = [
|
|
track({ id: 1, external: false, language: "spa" }),
|
|
track({ id: 2, external: false, language: "fre" }),
|
|
track({ id: 3, external: true, externalFilename: "http://srv/sub/0.srt" }),
|
|
];
|
|
|
|
test("external resolves by filename, embedded by language", () => {
|
|
expect(resolve(streams, 0, player)).toEqual({ kind: "select", trackId: 3 });
|
|
expect(resolve(streams, 1, player)).toEqual({ kind: "select", trackId: 1 });
|
|
expect(resolve(streams, 2, player)).toEqual({ kind: "select", trackId: 2 });
|
|
});
|
|
});
|
|
|
|
describe("resolveSubtitleTrack — external without DeliveryUrl (#1763 CodeRabbit)", () => {
|
|
// Middle external has no DeliveryUrl → never loaded into the player.
|
|
const streams = [
|
|
ext(0, { Language: "eng", DeliveryUrl: "/sub/a.srt" }),
|
|
sub({ Index: 1, DeliveryMethod: External, IsExternal: true }),
|
|
ext(2, { Language: "fre", DeliveryUrl: "/sub/c.srt" }),
|
|
];
|
|
const player = [
|
|
track({ id: 4, external: true, externalFilename: "http://srv/sub/a.srt" }),
|
|
track({ id: 5, external: true, externalFilename: "http://srv/sub/c.srt" }),
|
|
];
|
|
|
|
test("loaded externals still map correctly despite the gap", () => {
|
|
expect(resolve(streams, 0, player)).toEqual({ kind: "select", trackId: 4 });
|
|
expect(resolve(streams, 2, player)).toEqual({ kind: "select", trackId: 5 });
|
|
});
|
|
|
|
test("selecting the unloaded external returns notFound", () => {
|
|
expect(resolve(streams, 1, player)).toEqual({ kind: "notFound" });
|
|
});
|
|
});
|
|
|
|
describe("resolveSubtitleTrack — embedded matching", () => {
|
|
test("unique language match wins regardless of order", () => {
|
|
const streams = [emb(0, { Language: "eng" }), emb(1, { Language: "jpn" })];
|
|
const player = [
|
|
track({ id: 1, external: false, language: "eng" }),
|
|
track({ id: 2, external: false, language: "jpn" }),
|
|
];
|
|
expect(resolve(streams, 1, player)).toEqual({ kind: "select", trackId: 2 });
|
|
});
|
|
|
|
test("same-language tracks disambiguate by ordinal among matches", () => {
|
|
const streams = [
|
|
emb(0, { Language: "eng", Title: "Full" }),
|
|
emb(1, { Language: "eng", Title: "SDH" }),
|
|
];
|
|
const player = [
|
|
track({ id: 1, external: false, language: "eng", title: "Full" }),
|
|
track({ id: 2, external: false, language: "eng", title: "SDH" }),
|
|
];
|
|
expect(resolve(streams, 1, player)).toEqual({ kind: "select", trackId: 2 });
|
|
});
|
|
|
|
test("falls back to embedded ordinal when no language/title info", () => {
|
|
const streams = [emb(0), emb(1)];
|
|
const player = [
|
|
track({ id: 1, external: false }),
|
|
track({ id: 2, external: false }),
|
|
];
|
|
expect(resolve(streams, 1, player)).toEqual({ kind: "select", trackId: 2 });
|
|
});
|
|
});
|
|
|
|
describe("compareTracksForMenu — jellyfin-web order", () => {
|
|
test("externals sort after embedded despite lower Index", () => {
|
|
const sorted = [
|
|
ext(0, { Language: "eng" }),
|
|
emb(7, { Language: "fra" }),
|
|
].sort(compareTracksForMenu);
|
|
expect(sorted.map((s) => s.Index)).toEqual([7, 0]);
|
|
});
|
|
|
|
test("forced then default float to the top within a group", () => {
|
|
const sorted = [
|
|
emb(2, { Language: "eng" }),
|
|
emb(1, { Language: "eng", IsDefault: true }),
|
|
emb(0, { Language: "eng", IsForced: true }),
|
|
].sort(compareTracksForMenu);
|
|
expect(sorted.map((s) => s.Index)).toEqual([0, 1, 2]);
|
|
});
|
|
|
|
test("full Okiku order: embedded first, externals last by Index", () => {
|
|
const streams = [
|
|
ext(0, { Language: "eng" }),
|
|
ext(1, { Language: "eng" }),
|
|
ext(2, { Language: "fra" }),
|
|
ext(3, { Language: "fra" }),
|
|
emb(7, { Language: "fra", Title: "French" }),
|
|
];
|
|
expect([...streams].sort(compareTracksForMenu).map((s) => s.Index)).toEqual(
|
|
[7, 0, 1, 2, 3],
|
|
);
|
|
});
|
|
});
|