From df4904d2fd08ef4ed21aa0b79f2be38b7ed2d948 Mon Sep 17 00:00:00 2001 From: Uruk Date: Fri, 22 May 2026 11:47:51 +0200 Subject: [PATCH] feat(chapters): add pure chapter helpers --- utils/chapters.test.ts | 57 +++++++++++++++++++++++++++++++++++++++ utils/chapters.ts | 60 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 utils/chapters.test.ts create mode 100644 utils/chapters.ts diff --git a/utils/chapters.test.ts b/utils/chapters.test.ts new file mode 100644 index 00000000..d1ab690a --- /dev/null +++ b/utils/chapters.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from "bun:test"; +import { + chapterMarkers, + currentChapterIndex, + formatChapterTime, +} 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([]); + }); +}); + +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("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"); + }); +}); diff --git a/utils/chapters.ts b/utils/chapters.ts new file mode 100644 index 00000000..bab4a8c5 --- /dev/null +++ b/utils/chapters.ts @@ -0,0 +1,60 @@ +/** + * 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"; + +const TICKS_PER_MS = 10000; + +export interface ChapterMarker { + /** Chapter start, in milliseconds. */ + positionMs: number; + /** Chapter start as a percentage (0-100) of the media duration. */ + percent: number; +} + +/** Chapter start positions in milliseconds, ascending. */ +export const chapterStartsMs = ( + chapters: ChapterInfo[] | null | undefined, +): number[] => + (chapters ?? []) + .map((c) => (c.StartPositionTicks ?? 0) / TICKS_PER_MS) + .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; +}; + +/** `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)}`; +};