mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-05-28 17:48:26 +01:00
feat(chapters): add pure helpers and unit tests
Adds dependency-free helpers in utils/chapters.ts for working with Jellyfin chapter markers: - chapterMarkers(chapters, durationMs): markers within range, with precomputed percent for slider overlays. - chapterStartsMs(chapters): sorted start positions in ms (skips entries without StartPositionTicks). - currentChapterIndex(positionMs, chapters): active chapter index for the live playback position (-1 if before the first chapter). - chapterNameAt(positionMs, chapters): the active chapter name, or null if missing/unnamed. - sortedChapters(chapters): chapter entries paired with ms start. - formatChapterTime(positionMs): m:ss or h:mm:ss label. All helpers tolerate null/undefined/empty inputs. 17 unit tests under bun test cover sort order, boundary positions, missing fields, and out-of-range inputs.
This commit is contained in:
138
utils/chapters.test.ts
Normal file
138
utils/chapters.test.ts
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import {
|
||||||
|
chapterMarkers,
|
||||||
|
chapterNameAt,
|
||||||
|
chapterStartsMs,
|
||||||
|
currentChapterIndex,
|
||||||
|
formatChapterTime,
|
||||||
|
sortedChapters,
|
||||||
|
} from "./chapters";
|
||||||
|
|
||||||
|
// Helper: a ChapterInfo with a start in milliseconds.
|
||||||
|
const ch = (ms: number, name?: string) => ({
|
||||||
|
StartPositionTicks: ms * 10000,
|
||||||
|
Name: name,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("chapterMarkers", () => {
|
||||||
|
test("maps chapters to position + percent", () => {
|
||||||
|
expect(chapterMarkers([ch(0), ch(30_000), ch(60_000)], 120_000)).toEqual([
|
||||||
|
{ positionMs: 0, percent: 0 },
|
||||||
|
{ positionMs: 30_000, percent: 25 },
|
||||||
|
{ positionMs: 60_000, percent: 50 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("drops chapters past the duration", () => {
|
||||||
|
expect(chapterMarkers([ch(0), ch(200_000)], 120_000)).toEqual([
|
||||||
|
{ positionMs: 0, percent: 0 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns [] when duration is 0 or chapters missing", () => {
|
||||||
|
expect(chapterMarkers([ch(0)], 0)).toEqual([]);
|
||||||
|
expect(chapterMarkers(null, 120_000)).toEqual([]);
|
||||||
|
expect(chapterMarkers(undefined, 120_000)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("excludes a chapter exactly at the duration", () => {
|
||||||
|
expect(chapterMarkers([ch(0), ch(120_000)], 120_000)).toEqual([
|
||||||
|
{ positionMs: 0, percent: 0 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("skips chapters with no StartPositionTicks", () => {
|
||||||
|
expect(
|
||||||
|
chapterMarkers([{ StartPositionTicks: undefined }, ch(30_000)], 120_000),
|
||||||
|
).toEqual([{ positionMs: 30_000, percent: 25 }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("currentChapterIndex", () => {
|
||||||
|
const chapters = [ch(0), ch(30_000), ch(60_000)];
|
||||||
|
test("returns the chapter containing the position", () => {
|
||||||
|
expect(currentChapterIndex(0, chapters)).toBe(0);
|
||||||
|
expect(currentChapterIndex(15_000, chapters)).toBe(0);
|
||||||
|
expect(currentChapterIndex(30_000, chapters)).toBe(1);
|
||||||
|
expect(currentChapterIndex(90_000, chapters)).toBe(2);
|
||||||
|
});
|
||||||
|
test("returns -1 before the first chapter and for no chapters", () => {
|
||||||
|
expect(currentChapterIndex(-5, chapters)).toBe(-1);
|
||||||
|
expect(currentChapterIndex(10_000, [])).toBe(-1);
|
||||||
|
expect(currentChapterIndex(10_000, null)).toBe(-1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sortedChapters", () => {
|
||||||
|
test("pairs each chapter with its ms start, sorted ascending", () => {
|
||||||
|
const a = ch(60_000, "C");
|
||||||
|
const b = ch(0, "A");
|
||||||
|
const c = ch(30_000, "B");
|
||||||
|
expect(sortedChapters([a, b, c])).toEqual([
|
||||||
|
{ chapter: b, positionMs: 0 },
|
||||||
|
{ chapter: c, positionMs: 30_000 },
|
||||||
|
{ chapter: a, positionMs: 60_000 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
test("returns [] for null/undefined", () => {
|
||||||
|
expect(sortedChapters(null)).toEqual([]);
|
||||||
|
expect(sortedChapters(undefined)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("chapterStartsMs", () => {
|
||||||
|
test("returns sorted ms positions", () => {
|
||||||
|
expect(chapterStartsMs([ch(60_000), ch(0), ch(30_000)])).toEqual([
|
||||||
|
0, 30_000, 60_000,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("skips entries without StartPositionTicks", () => {
|
||||||
|
expect(
|
||||||
|
chapterStartsMs([ch(30_000), { StartPositionTicks: undefined }, ch(0)]),
|
||||||
|
).toEqual([0, 30_000]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns [] for null/undefined/empty", () => {
|
||||||
|
expect(chapterStartsMs(null)).toEqual([]);
|
||||||
|
expect(chapterStartsMs(undefined)).toEqual([]);
|
||||||
|
expect(chapterStartsMs([])).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("chapterNameAt", () => {
|
||||||
|
const named = [
|
||||||
|
{ StartPositionTicks: 0, Name: "Intro" },
|
||||||
|
{ StartPositionTicks: 30_000 * 10000, Name: "Action" },
|
||||||
|
{ StartPositionTicks: 60_000 * 10000, Name: "Outro" },
|
||||||
|
];
|
||||||
|
|
||||||
|
test("returns the chapter name for the active position", () => {
|
||||||
|
expect(chapterNameAt(0, named)).toBe("Intro");
|
||||||
|
expect(chapterNameAt(15_000, named)).toBe("Intro");
|
||||||
|
expect(chapterNameAt(45_000, named)).toBe("Action");
|
||||||
|
expect(chapterNameAt(90_000, named)).toBe("Outro");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns null before the first chapter", () => {
|
||||||
|
expect(chapterNameAt(-1, named)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns null for null/undefined/empty chapters", () => {
|
||||||
|
expect(chapterNameAt(10_000, null)).toBeNull();
|
||||||
|
expect(chapterNameAt(10_000, undefined)).toBeNull();
|
||||||
|
expect(chapterNameAt(10_000, [])).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns null when the active chapter has no Name", () => {
|
||||||
|
expect(chapterNameAt(15_000, [ch(0), ch(30_000)])).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatChapterTime", () => {
|
||||||
|
test("formats m:ss and h:mm:ss", () => {
|
||||||
|
expect(formatChapterTime(65_000)).toBe("1:05");
|
||||||
|
expect(formatChapterTime(3_725_000)).toBe("1:02:05");
|
||||||
|
expect(formatChapterTime(-100)).toBe("0:00");
|
||||||
|
});
|
||||||
|
});
|
||||||
90
utils/chapters.ts
Normal file
90
utils/chapters.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
/**
|
||||||
|
* Pure helpers for Jellyfin chapter markers. Dependency-free so they are
|
||||||
|
* unit-testable under `bun test`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ChapterInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { ticksToMs } from "@/utils/time";
|
||||||
|
|
||||||
|
export interface ChapterMarker {
|
||||||
|
/** Chapter start, in milliseconds. */
|
||||||
|
positionMs: number;
|
||||||
|
/** Chapter start as a percentage (0-100) of the media duration. */
|
||||||
|
percent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChapterEntry {
|
||||||
|
chapter: ChapterInfo;
|
||||||
|
/** Chapter start, in milliseconds. */
|
||||||
|
positionMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Chapters paired with their millisecond start, sorted ascending by start. */
|
||||||
|
export const sortedChapters = (
|
||||||
|
chapters: ChapterInfo[] | null | undefined,
|
||||||
|
): ChapterEntry[] =>
|
||||||
|
(chapters ?? [])
|
||||||
|
.filter((c) => c.StartPositionTicks != null)
|
||||||
|
.map((chapter) => ({
|
||||||
|
chapter,
|
||||||
|
positionMs: ticksToMs(chapter.StartPositionTicks),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.positionMs - b.positionMs);
|
||||||
|
|
||||||
|
/** Chapter start positions in milliseconds, ascending. */
|
||||||
|
export const chapterStartsMs = (
|
||||||
|
chapters: ChapterInfo[] | null | undefined,
|
||||||
|
): number[] =>
|
||||||
|
(chapters ?? [])
|
||||||
|
.filter((c) => c.StartPositionTicks != null)
|
||||||
|
.map((c) => ticksToMs(c.StartPositionTicks))
|
||||||
|
.sort((a, b) => a - b);
|
||||||
|
|
||||||
|
/** Chapter markers within [0, durationMs]; empty when duration is unknown. */
|
||||||
|
export const chapterMarkers = (
|
||||||
|
chapters: ChapterInfo[] | null | undefined,
|
||||||
|
durationMs: number,
|
||||||
|
): ChapterMarker[] => {
|
||||||
|
if (durationMs <= 0) return [];
|
||||||
|
return chapterStartsMs(chapters)
|
||||||
|
.filter((ms) => ms >= 0 && ms < durationMs)
|
||||||
|
.map((ms) => ({ positionMs: ms, percent: (ms / durationMs) * 100 }));
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Index of the chapter containing `positionMs`, or -1 if before the first. */
|
||||||
|
export const currentChapterIndex = (
|
||||||
|
positionMs: number,
|
||||||
|
chapters: ChapterInfo[] | null | undefined,
|
||||||
|
): number => {
|
||||||
|
const starts = chapterStartsMs(chapters);
|
||||||
|
let index = -1;
|
||||||
|
for (let i = 0; i < starts.length; i++) {
|
||||||
|
if (positionMs >= starts[i]) index = i;
|
||||||
|
else break;
|
||||||
|
}
|
||||||
|
return index;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Name of the chapter containing `positionMs`, or null if none / unnamed. */
|
||||||
|
export const chapterNameAt = (
|
||||||
|
positionMs: number,
|
||||||
|
chapters: ChapterInfo[] | null | undefined,
|
||||||
|
): string | null => {
|
||||||
|
const idx = currentChapterIndex(positionMs, chapters);
|
||||||
|
if (idx < 0) return null;
|
||||||
|
const sorted = sortedChapters(chapters);
|
||||||
|
const name = sorted[idx]?.chapter.Name;
|
||||||
|
return name && name.length > 0 ? name : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** `m:ss` (or `h:mm:ss` past an hour) label for a millisecond position. */
|
||||||
|
export const formatChapterTime = (positionMs: number): string => {
|
||||||
|
const total = Math.max(0, Math.floor(positionMs / 1000));
|
||||||
|
const hours = Math.floor(total / 3600);
|
||||||
|
const minutes = Math.floor((total % 3600) / 60);
|
||||||
|
const seconds = total % 60;
|
||||||
|
const pad = (n: number) => String(n).padStart(2, "0");
|
||||||
|
return hours > 0
|
||||||
|
? `${hours}:${pad(minutes)}:${pad(seconds)}`
|
||||||
|
: `${minutes}:${pad(seconds)}`;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user