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:
Gauvain
2026-05-27 16:39:05 +02:00
parent 1545790528
commit 8d33aa3ba1
2 changed files with 228 additions and 0 deletions

90
utils/chapters.ts Normal file
View 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)}`;
};