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
+4 -1
View File
@@ -125,8 +125,11 @@ impl OfflineRepository {
self.db_service self.db_service
.query_optional(query, |row| { .query_optional(query, |row| {
let playback_position_ticks: Option<i64> = row.get(0).ok();
Ok(UserData { Ok(UserData {
playback_position_ticks: row.get(0).ok(), playback_position_ticks,
playback_position_ms: playback_position_ticks
.map(crate::domain::ticks_to_ms),
is_played: row.get::<_, Option<i32>>(1).ok().flatten().map(|v| v != 0), is_played: row.get::<_, Option<i32>>(1).ok().flatten().map(|v| v != 0),
is_favorite: row.get::<_, Option<i32>>(2).ok().flatten().map(|v| v != 0), is_favorite: row.get::<_, Option<i32>>(2).ok().flatten().map(|v| v != 0),
play_count: row.get(3).ok(), play_count: row.get(3).ok(),
+8
View File
@@ -42,8 +42,16 @@ pub struct Library {
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)] #[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct UserData { pub struct UserData {
/// Legacy Jellyfin resume position in ticks. Being replaced by
/// `playback_position_ms`; dual-carried while the frontend migrates
/// (docs/specs/frontend-domain-model.md). New code should read the ms field.
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub playback_position_ticks: Option<i64>, pub playback_position_ticks: Option<i64>,
/// Resume position in milliseconds — the neutral replacement for
/// `playback_position_ticks`. Populated from ticks by the mapping; the
/// frontend never divides ticks itself.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub playback_position_ms: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub is_played: Option<bool>, pub is_played: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
+13 -1
View File
@@ -2538,7 +2538,19 @@ export type User = { id: string; name: string; serverId: string; primaryImageTag
/** /**
* User-specific data for an item (playback state, favorites, etc.) * User-specific data for an item (playback state, favorites, etc.)
*/ */
export type UserData = { playbackPositionTicks?: number | null; isPlayed?: boolean | null; isFavorite?: boolean | null; playCount?: number | null; lastPlayedDate?: string | null; playbackContextType?: string | null; playbackContextId?: string | null } export type UserData = {
/**
* Legacy Jellyfin resume position in ticks. Being replaced by
* `playback_position_ms`; dual-carried while the frontend migrates
* (docs/specs/frontend-domain-model.md). New code should read the ms field.
*/
playbackPositionTicks?: number | null;
/**
* Resume position in milliseconds — the neutral replacement for
* `playback_position_ticks`. Populated from ticks by the mapping; the
* frontend never divides ticks itself.
*/
playbackPositionMs?: number | null; isPlayed?: boolean | null; isFavorite?: boolean | null; playCount?: number | null; lastPlayedDate?: string | null; playbackContextType?: string | null; playbackContextId?: string | null }
/** /**
* User info returned to frontend * User info returned to frontend
*/ */
@@ -86,9 +86,9 @@
return null; return null;
}); });
function formatDuration(ticks?: number | null): string { function formatDuration(ms?: number | null): string {
if (!ticks) return ""; if (!ms) return "";
const seconds = Math.floor(ticks / 10000000); const seconds = Math.floor(ms / 1000);
const hours = Math.floor(seconds / 3600); const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60); const minutes = Math.floor((seconds % 3600) / 60);
@@ -99,10 +99,10 @@
} }
function getProgress(ep: MediaItem): number { function getProgress(ep: MediaItem): number {
if (!ep.userData || !ep.runTimeTicks) { if (!ep.userData || !ep.durationMs) {
return 0; return 0;
} }
return ((ep.userData.playbackPositionTicks ?? 0) / ep.runTimeTicks) * 100; return ((ep.userData.playbackPositionMs ?? 0) / ep.durationMs) * 100;
} }
function handlePlay() { function handlePlay() {
@@ -116,7 +116,7 @@
const episodeLabel = $derived( const episodeLabel = $derived(
`S${episode.parentIndexNumber || 1}E${episode.indexNumber || 1}` `S${episode.parentIndexNumber || 1}E${episode.indexNumber || 1}`
); );
const duration = $derived(formatDuration(episode.runTimeTicks)); const duration = $derived(formatDuration(episode.durationMs));
const progress = $derived(getProgress(episode)); const progress = $derived(getProgress(episode));
</script> </script>
+3 -3
View File
@@ -38,13 +38,13 @@
const downloadProgress = $derived(downloadInfo?.progress || 0); const downloadProgress = $derived(downloadInfo?.progress || 0);
const progress = $derived(() => { const progress = $derived(() => {
if (!episode.userData || !episode.runTimeTicks) { if (!episode.userData || !episode.durationMs) {
return 0; return 0;
} }
return ((episode.userData.playbackPositionTicks ?? 0) / episode.runTimeTicks) * 100; return ((episode.userData.playbackPositionMs ?? 0) / episode.durationMs) * 100;
}); });
const duration = $derived(formatDuration(episode.runTimeTicks)); const duration = $derived(formatDuration(episode.durationMs));
const episodeNumber = $derived(episode.indexNumber || 0); const episodeNumber = $derived(episode.indexNumber || 0);
</script> </script>
@@ -42,10 +42,10 @@
function getProgress(item: MediaItem | Library): number { function getProgress(item: MediaItem | Library): number {
if (!showProgress || !("userData" in item) || !item.userData || !("runTimeTicks" in item) || !item.runTimeTicks) { if (!showProgress || !("userData" in item) || !item.userData || !("durationMs" in item) || !item.durationMs) {
return 0; return 0;
} }
return ((item.userData.playbackPositionTicks ?? 0) / item.runTimeTicks) * 100; return ((item.userData.playbackPositionMs ?? 0) / item.durationMs) * 100;
} }
function getTrackNumber(item: MediaItem | Library): string { function getTrackNumber(item: MediaItem | Library): string {
@@ -59,7 +59,7 @@
<div class="space-y-1"> <div class="space-y-1">
{#each items as item, index (item.id)} {#each items as item, index (item.id)}
{@const subtitle = getSubtitle(item)} {@const subtitle = getSubtitle(item)}
{@const duration = "runTimeTicks" in item ? formatDuration(item.runTimeTicks) : ""} {@const duration = "durationMs" in item ? formatDuration(item.durationMs) : ""}
{@const progress = getProgress(item)} {@const progress = getProgress(item)}
{@const trackNum = getTrackNumber(item)} {@const trackNum = getTrackNumber(item)}
{@const isPlayed = "userData" in item && item.userData?.isPlayed} {@const isPlayed = "userData" in item && item.userData?.isPlayed}
+2 -2
View File
@@ -119,10 +119,10 @@
const maxWidth = $derived(size === "large" ? 400 : size === "medium" ? 300 : 200); const maxWidth = $derived(size === "large" ? 400 : size === "medium" ? 300 : 200);
const progress = $derived(() => { const progress = $derived(() => {
if (!showProgress || !("userData" in item) || !item.userData || !item.runTimeTicks) { if (!showProgress || !("userData" in item) || !item.userData || !item.durationMs) {
return 0; return 0;
} }
return ((item.userData.playbackPositionTicks ?? 0) / item.runTimeTicks) * 100; return ((item.userData.playbackPositionMs ?? 0) / item.durationMs) * 100;
}); });
const subtitle = $derived(() => { const subtitle = $derived(() => {
@@ -25,7 +25,7 @@
const tracks = $derived(entries.map(e => ({ ...e } as MediaItem))); const tracks = $derived(entries.map(e => ({ ...e } as MediaItem)));
const totalDuration = $derived( const totalDuration = $derived(
entries.reduce((sum, e) => sum + (e.runTimeTicks ?? 0), 0) entries.reduce((sum, e) => sum + (e.durationMs ?? 0), 0)
); );
onMount(() => { onMount(() => {
+2 -2
View File
@@ -283,7 +283,7 @@
<!-- Duration --> <!-- Duration -->
<div class="text-gray-400 text-right"> <div class="text-gray-400 text-right">
{formatDuration(track.runTimeTicks)} {formatDuration(track.durationMs)}
</div> </div>
<!-- Download Button Placeholder --> <!-- Download Button Placeholder -->
@@ -421,7 +421,7 @@
</p> </p>
</div> </div>
<div class="text-gray-400 text-sm {showDownload ? 'mr-20' : 'mr-12'}"> <div class="text-gray-400 text-sm {showDownload ? 'mr-20' : 'mr-12'}">
{formatDuration(track.runTimeTicks)} {formatDuration(track.durationMs)}
</div> </div>
</button> </button>
+4 -4
View File
@@ -72,7 +72,7 @@ describe("TrackList", () => {
artists: ["Artist 1"], artists: ["Artist 1"],
albumName: "Album 1", albumName: "Album 1",
albumId: "album-1", albumId: "album-1",
runTimeTicks: 1800000000, // 3 minutes durationMs: 180000, // 3 minutes
primaryImageTag: "tag1", primaryImageTag: "tag1",
indexNumber: 1, indexNumber: 1,
}, },
@@ -84,7 +84,7 @@ describe("TrackList", () => {
artists: ["Artist 2"], artists: ["Artist 2"],
albumName: "Album 2", albumName: "Album 2",
albumId: "album-2", albumId: "album-2",
runTimeTicks: 2400000000, // 4 minutes durationMs: 240000, // 4 minutes
primaryImageTag: "tag2", primaryImageTag: "tag2",
indexNumber: 2, indexNumber: 2,
}, },
@@ -96,7 +96,7 @@ describe("TrackList", () => {
artists: ["Artist 3", "Artist 4"], artists: ["Artist 3", "Artist 4"],
albumName: "Album 3", albumName: "Album 3",
albumId: "album-3", albumId: "album-3",
runTimeTicks: 3000000000, // 5 minutes durationMs: 300000, // 5 minutes
indexNumber: 3, indexNumber: 3,
}, },
]; ];
@@ -187,7 +187,7 @@ describe("TrackList", () => {
const tracksWithoutDuration: MediaItem[] = [ const tracksWithoutDuration: MediaItem[] = [
{ {
...mockTracks[0], ...mockTracks[0],
runTimeTicks: undefined, durationMs: undefined,
}, },
]; ];
+1 -1
View File
@@ -210,7 +210,7 @@
<!-- Duration --> <!-- Duration -->
<span class="text-xs text-gray-500 flex-shrink-0"> <span class="text-xs text-gray-500 flex-shrink-0">
{formatDuration(item.runTimeTicks)} {formatDuration(item.durationMs)}
</span> </span>
</button> </button>
+16 -17
View File
@@ -8,36 +8,35 @@ import { describe, it, expect } from "vitest";
import { formatDuration, formatSecondsDuration } from "./duration"; import { formatDuration, formatSecondsDuration } from "./duration";
describe("formatDuration", () => { describe("formatDuration", () => {
it("should format duration from Jellyfin ticks (mm:ss format)", () => { it("should format duration from milliseconds (mm:ss format)", () => {
// 1 second = 10,000,000 ticks expect(formatDuration(1000)).toBe("0:01");
expect(formatDuration(10000000)).toBe("0:01"); expect(formatDuration(6000)).toBe("0:06");
expect(formatDuration(60000000)).toBe("0:06"); expect(formatDuration(60000)).toBe("1:00");
expect(formatDuration(600000000)).toBe("1:00"); expect(formatDuration(600000)).toBe("10:00");
expect(formatDuration(6000000000)).toBe("10:00"); expect(formatDuration(3661000)).toBe("61:01");
expect(formatDuration(36610000000)).toBe("61:01");
}); });
it("should format duration with hh:mm:ss format", () => { it("should format duration with hh:mm:ss format", () => {
// 1 hour = 3600 seconds = 36,000,000,000 ticks // 1 hour = 3600 seconds = 3,600,000 ms
expect(formatDuration(36000000000, "hh:mm:ss")).toBe("1:00:00"); expect(formatDuration(3600000, "hh:mm:ss")).toBe("1:00:00");
expect(formatDuration(36100000000, "hh:mm:ss")).toBe("1:00:10"); expect(formatDuration(3610000, "hh:mm:ss")).toBe("1:00:10");
expect(formatDuration(36610000000, "hh:mm:ss")).toBe("1:01:01"); 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(undefined)).toBe("");
expect(formatDuration(0)).toBe(""); expect(formatDuration(0)).toBe("");
}); });
it("should pad seconds with leading zero", () => { it("should pad seconds with leading zero", () => {
expect(formatDuration(5000000)).toBe("0:00"); expect(formatDuration(500)).toBe("0:00");
expect(formatDuration(50000000)).toBe("0:05"); expect(formatDuration(5000)).toBe("0:05");
expect(formatDuration(150000000)).toBe("0:15"); expect(formatDuration(15000)).toBe("0:15");
}); });
it("should handle large durations", () => { it("should handle large durations", () => {
// 2 hours 30 minutes 45 seconds = 9045 seconds * 10,000,000 ticks/second // 2 hours 30 minutes 45 seconds = 9045 seconds = 9,045,000 ms
expect(formatDuration(90450000000, "hh:mm:ss")).toBe("2:30:45"); 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 * Convert a millisecond duration to a formatted string.
* @param ticks Duration in Jellyfin ticks (10M ticks = 1 second) * @param ms Duration in milliseconds
* @param format Format type: "mm:ss" (default) or "hh:mm:ss" * @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 { export function formatDuration(ms?: number | null, format: "mm:ss" | "hh:mm:ss" = "mm:ss"): string {
if (!ticks) return ""; if (!ms) return "";
// Jellyfin uses 10,000,000 ticks per second const totalSeconds = Math.floor(ms / 1000);
const TICKS_PER_SECOND = 10000000;
const totalSeconds = Math.floor(ticks / TICKS_PER_SECOND);
if (format === "hh:mm:ss") { if (format === "hh:mm:ss") {
const hours = Math.floor(totalSeconds / 3600); const hours = Math.floor(totalSeconds / 3600);
+7 -7
View File
@@ -84,7 +84,7 @@
try { try {
item = await library.loadItem(itemId); item = await library.loadItem(itemId);
console.log(`[LibraryDetail] ✓ Loaded item: ${item?.name} (${item?.type})`); console.log(`[LibraryDetail] ✓ Loaded item: ${item?.name} (${item?.kind})`);
console.log(`[LibraryDetail] - Has people? ${item?.people ? `YES (${item.people.length})` : 'NO'}`); console.log(`[LibraryDetail] - Has people? ${item?.people ? `YES (${item.people.length})` : 'NO'}`);
if (item?.people) { if (item?.people) {
item.people.forEach((p, i) => { item.people.forEach((p, i) => {
@@ -170,9 +170,9 @@
// Images now handled by CachedImage component // Images now handled by CachedImage component
function formatDuration(ticks?: number): string { function formatDuration(ms?: number | null): string {
if (!ticks) return ""; if (!ms) return "";
const seconds = Math.floor(ticks / 10000000); const seconds = Math.floor(ms / 1000);
const hours = Math.floor(seconds / 3600); const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60); const minutes = Math.floor((seconds % 3600) / 60);
@@ -183,7 +183,7 @@
} }
function handleItemClick(clickedItem: MediaItem | Library) { function handleItemClick(clickedItem: MediaItem | Library) {
if (!("type" in clickedItem)) { if (!("kind" in clickedItem)) {
// Library item - navigate to library // Library item - navigate to library
goto(`/library/${clickedItem.id}`); goto(`/library/${clickedItem.id}`);
return; return;
@@ -405,8 +405,8 @@
{#if item.type} {#if item.type}
<span class="px-2 py-1 bg-[var(--color-surface)] rounded">{item.type}</span> <span class="px-2 py-1 bg-[var(--color-surface)] rounded">{item.type}</span>
{/if} {/if}
{#if item.runTimeTicks} {#if item.durationMs}
<span>{formatDuration(item.runTimeTicks)}</span> <span>{formatDuration(item.durationMs)}</span>
{/if} {/if}
{#if item.communityRating} {#if item.communityRating}
<span class="flex items-center gap-1"> <span class="flex items-center gap-1">