mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-05-22 14:56:38 +01:00
feat(chapters): add pure chapter helpers
This commit is contained in:
57
utils/chapters.test.ts
Normal file
57
utils/chapters.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
60
utils/chapters.ts
Normal file
60
utils/chapters.ts
Normal file
@@ -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)}`;
|
||||
};
|
||||
Reference in New Issue
Block a user