domain: catalog frontend off Jellyfin ticks -> milliseconds (phase 3a)

The catalog surface now speaks milliseconds, the app's neutral time unit.
Ticks no longer reach library/home components.

Rust:
- UserData gains playback_position_ms (dual-carry), populated from ticks
  at the offline mapping seam via domain::ticks_to_ms.

Frontend:
- formatDuration(duration.ts) and the two local copies now take ms, not
  ticks; all callers pass item.durationMs.
- Progress bars (EpisodeRow, EpisodeFocusView, MediaCard, LibraryListView)
  compute playbackPositionMs / durationMs — unit-consistent, no tick math.
- PlaylistDetailView totalDuration sums durationMs.
- duration.test.ts + TrackList.test.ts fixtures updated to ms.

Deferred: player/session/reporting tick math (Queue, SessionCard,
RemoteControls, playbackReporting, playerEvents) — those cross the
storage/Jellyfin command boundary in ticks and need command-signature
changes (phase 3b). Display {item.type} badge -> kind label (phase 4).

Rust 456, frontend 644, check + check:boundary clean.
This commit is contained in:
2026-07-23 21:30:04 +02:00
parent 3e962a202c
commit 7660a33dfc
14 changed files with 80 additions and 58 deletions
+16 -17
View File
@@ -8,36 +8,35 @@ import { describe, it, expect } from "vitest";
import { formatDuration, formatSecondsDuration } from "./duration";
describe("formatDuration", () => {
it("should format duration from Jellyfin ticks (mm:ss format)", () => {
// 1 second = 10,000,000 ticks
expect(formatDuration(10000000)).toBe("0:01");
expect(formatDuration(60000000)).toBe("0:06");
expect(formatDuration(600000000)).toBe("1:00");
expect(formatDuration(6000000000)).toBe("10:00");
expect(formatDuration(36610000000)).toBe("61:01");
it("should format duration from milliseconds (mm:ss format)", () => {
expect(formatDuration(1000)).toBe("0:01");
expect(formatDuration(6000)).toBe("0:06");
expect(formatDuration(60000)).toBe("1:00");
expect(formatDuration(600000)).toBe("10:00");
expect(formatDuration(3661000)).toBe("61:01");
});
it("should format duration with hh:mm:ss format", () => {
// 1 hour = 3600 seconds = 36,000,000,000 ticks
expect(formatDuration(36000000000, "hh:mm:ss")).toBe("1:00:00");
expect(formatDuration(36100000000, "hh:mm:ss")).toBe("1:00:10");
expect(formatDuration(36610000000, "hh:mm:ss")).toBe("1:01:01");
// 1 hour = 3600 seconds = 3,600,000 ms
expect(formatDuration(3600000, "hh:mm:ss")).toBe("1:00:00");
expect(formatDuration(3610000, "hh:mm:ss")).toBe("1:00:10");
expect(formatDuration(3661000, "hh:mm:ss")).toBe("1:01:01");
});
it("should return empty string for undefined or 0 ticks", () => {
it("should return empty string for undefined or 0 duration", () => {
expect(formatDuration(undefined)).toBe("");
expect(formatDuration(0)).toBe("");
});
it("should pad seconds with leading zero", () => {
expect(formatDuration(5000000)).toBe("0:00");
expect(formatDuration(50000000)).toBe("0:05");
expect(formatDuration(150000000)).toBe("0:15");
expect(formatDuration(500)).toBe("0:00");
expect(formatDuration(5000)).toBe("0:05");
expect(formatDuration(15000)).toBe("0:15");
});
it("should handle large durations", () => {
// 2 hours 30 minutes 45 seconds = 9045 seconds * 10,000,000 ticks/second
expect(formatDuration(90450000000, "hh:mm:ss")).toBe("2:30:45");
// 2 hours 30 minutes 45 seconds = 9045 seconds = 9,045,000 ms
expect(formatDuration(9045000, "hh:mm:ss")).toBe("2:30:45");
});
});
+10 -10
View File
@@ -1,21 +1,21 @@
/**
* Duration formatting utilities
* Duration formatting utilities.
*
* Jellyfin uses "ticks" for duration where 10,000,000 ticks = 1 second
* Durations are milliseconds — the app's neutral time unit. The backend has
* already converted any provider unit (e.g. Jellyfin ticks) before it reaches
* the frontend, so no tick arithmetic lives here.
*/
/**
* Convert Jellyfin ticks to formatted duration string
* @param ticks Duration in Jellyfin ticks (10M ticks = 1 second)
* Convert a millisecond duration to a formatted string.
* @param ms Duration in milliseconds
* @param format Format type: "mm:ss" (default) or "hh:mm:ss"
* @returns Formatted duration string or empty string if no ticks
* @returns Formatted duration string or empty string if no duration
*/
export function formatDuration(ticks?: number | null, format: "mm:ss" | "hh:mm:ss" = "mm:ss"): string {
if (!ticks) return "";
export function formatDuration(ms?: number | null, format: "mm:ss" | "hh:mm:ss" = "mm:ss"): string {
if (!ms) return "";
// Jellyfin uses 10,000,000 ticks per second
const TICKS_PER_SECOND = 10000000;
const totalSeconds = Math.floor(ticks / TICKS_PER_SECOND);
const totalSeconds = Math.floor(ms / 1000);
if (format === "hh:mm:ss") {
const hours = Math.floor(totalSeconds / 3600);