diff --git a/src-tauri/src/repository/offline.rs b/src-tauri/src/repository/offline.rs index cf21714e..63eb100e 100644 --- a/src-tauri/src/repository/offline.rs +++ b/src-tauri/src/repository/offline.rs @@ -125,8 +125,11 @@ impl OfflineRepository { self.db_service .query_optional(query, |row| { + let playback_position_ticks: Option = row.get(0).ok(); 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>(1).ok().flatten().map(|v| v != 0), is_favorite: row.get::<_, Option>(2).ok().flatten().map(|v| v != 0), play_count: row.get(3).ok(), diff --git a/src-tauri/src/repository/types.rs b/src-tauri/src/repository/types.rs index 796b927c..668c0432 100644 --- a/src-tauri/src/repository/types.rs +++ b/src-tauri/src/repository/types.rs @@ -42,8 +42,16 @@ pub struct Library { #[derive(specta::Type, Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] 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")] pub playback_position_ticks: Option, + /// 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, #[serde(skip_serializing_if = "Option::is_none")] pub is_played: Option, #[serde(skip_serializing_if = "Option::is_none")] diff --git a/src/lib/api/bindings.ts b/src/lib/api/bindings.ts index a295a9e7..2949f456 100644 --- a/src/lib/api/bindings.ts +++ b/src/lib/api/bindings.ts @@ -2538,7 +2538,19 @@ export type User = { id: string; name: string; serverId: string; primaryImageTag /** * 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 */ diff --git a/src/lib/components/library/EpisodeFocusView.svelte b/src/lib/components/library/EpisodeFocusView.svelte index 86fb965b..4617daad 100644 --- a/src/lib/components/library/EpisodeFocusView.svelte +++ b/src/lib/components/library/EpisodeFocusView.svelte @@ -86,9 +86,9 @@ return null; }); - function formatDuration(ticks?: number | null): string { - if (!ticks) return ""; - const seconds = Math.floor(ticks / 10000000); + function formatDuration(ms?: number | null): string { + if (!ms) return ""; + const seconds = Math.floor(ms / 1000); const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); @@ -99,10 +99,10 @@ } function getProgress(ep: MediaItem): number { - if (!ep.userData || !ep.runTimeTicks) { + if (!ep.userData || !ep.durationMs) { return 0; } - return ((ep.userData.playbackPositionTicks ?? 0) / ep.runTimeTicks) * 100; + return ((ep.userData.playbackPositionMs ?? 0) / ep.durationMs) * 100; } function handlePlay() { @@ -116,7 +116,7 @@ const episodeLabel = $derived( `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)); diff --git a/src/lib/components/library/EpisodeRow.svelte b/src/lib/components/library/EpisodeRow.svelte index 598ff3ce..caa4f86e 100644 --- a/src/lib/components/library/EpisodeRow.svelte +++ b/src/lib/components/library/EpisodeRow.svelte @@ -38,13 +38,13 @@ const downloadProgress = $derived(downloadInfo?.progress || 0); const progress = $derived(() => { - if (!episode.userData || !episode.runTimeTicks) { + if (!episode.userData || !episode.durationMs) { 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); diff --git a/src/lib/components/library/LibraryListView.svelte b/src/lib/components/library/LibraryListView.svelte index d5b40d62..e830a33c 100644 --- a/src/lib/components/library/LibraryListView.svelte +++ b/src/lib/components/library/LibraryListView.svelte @@ -42,10 +42,10 @@ 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 ((item.userData.playbackPositionTicks ?? 0) / item.runTimeTicks) * 100; + return ((item.userData.playbackPositionMs ?? 0) / item.durationMs) * 100; } function getTrackNumber(item: MediaItem | Library): string { @@ -59,7 +59,7 @@
{#each items as item, index (item.id)} {@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 trackNum = getTrackNumber(item)} {@const isPlayed = "userData" in item && item.userData?.isPlayed} diff --git a/src/lib/components/library/MediaCard.svelte b/src/lib/components/library/MediaCard.svelte index 7957c622..8f2b86b1 100644 --- a/src/lib/components/library/MediaCard.svelte +++ b/src/lib/components/library/MediaCard.svelte @@ -119,10 +119,10 @@ const maxWidth = $derived(size === "large" ? 400 : size === "medium" ? 300 : 200); const progress = $derived(() => { - if (!showProgress || !("userData" in item) || !item.userData || !item.runTimeTicks) { + if (!showProgress || !("userData" in item) || !item.userData || !item.durationMs) { return 0; } - return ((item.userData.playbackPositionTicks ?? 0) / item.runTimeTicks) * 100; + return ((item.userData.playbackPositionMs ?? 0) / item.durationMs) * 100; }); const subtitle = $derived(() => { diff --git a/src/lib/components/library/PlaylistDetailView.svelte b/src/lib/components/library/PlaylistDetailView.svelte index 355cdbb9..ca44d1ab 100644 --- a/src/lib/components/library/PlaylistDetailView.svelte +++ b/src/lib/components/library/PlaylistDetailView.svelte @@ -25,7 +25,7 @@ const tracks = $derived(entries.map(e => ({ ...e } as MediaItem))); const totalDuration = $derived( - entries.reduce((sum, e) => sum + (e.runTimeTicks ?? 0), 0) + entries.reduce((sum, e) => sum + (e.durationMs ?? 0), 0) ); onMount(() => { diff --git a/src/lib/components/library/TrackList.svelte b/src/lib/components/library/TrackList.svelte index a624f41b..bf8288ad 100644 --- a/src/lib/components/library/TrackList.svelte +++ b/src/lib/components/library/TrackList.svelte @@ -283,7 +283,7 @@
- {formatDuration(track.runTimeTicks)} + {formatDuration(track.durationMs)}
@@ -421,7 +421,7 @@

- {formatDuration(track.runTimeTicks)} + {formatDuration(track.durationMs)}
diff --git a/src/lib/components/library/TrackList.test.ts b/src/lib/components/library/TrackList.test.ts index a5495fb5..b8b8bb0f 100644 --- a/src/lib/components/library/TrackList.test.ts +++ b/src/lib/components/library/TrackList.test.ts @@ -72,7 +72,7 @@ describe("TrackList", () => { artists: ["Artist 1"], albumName: "Album 1", albumId: "album-1", - runTimeTicks: 1800000000, // 3 minutes + durationMs: 180000, // 3 minutes primaryImageTag: "tag1", indexNumber: 1, }, @@ -84,7 +84,7 @@ describe("TrackList", () => { artists: ["Artist 2"], albumName: "Album 2", albumId: "album-2", - runTimeTicks: 2400000000, // 4 minutes + durationMs: 240000, // 4 minutes primaryImageTag: "tag2", indexNumber: 2, }, @@ -96,7 +96,7 @@ describe("TrackList", () => { artists: ["Artist 3", "Artist 4"], albumName: "Album 3", albumId: "album-3", - runTimeTicks: 3000000000, // 5 minutes + durationMs: 300000, // 5 minutes indexNumber: 3, }, ]; @@ -187,7 +187,7 @@ describe("TrackList", () => { const tracksWithoutDuration: MediaItem[] = [ { ...mockTracks[0], - runTimeTicks: undefined, + durationMs: undefined, }, ]; diff --git a/src/lib/components/player/Queue.svelte b/src/lib/components/player/Queue.svelte index c0fc8317..005aec9a 100644 --- a/src/lib/components/player/Queue.svelte +++ b/src/lib/components/player/Queue.svelte @@ -210,7 +210,7 @@ - {formatDuration(item.runTimeTicks)} + {formatDuration(item.durationMs)} diff --git a/src/lib/utils/duration.test.ts b/src/lib/utils/duration.test.ts index 6d1ffd01..5772b870 100644 --- a/src/lib/utils/duration.test.ts +++ b/src/lib/utils/duration.test.ts @@ -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"); }); }); diff --git a/src/lib/utils/duration.ts b/src/lib/utils/duration.ts index 6d9df150..ca728f6d 100644 --- a/src/lib/utils/duration.ts +++ b/src/lib/utils/duration.ts @@ -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); diff --git a/src/routes/library/[id]/+page.svelte b/src/routes/library/[id]/+page.svelte index faf0a628..b95f63d6 100644 --- a/src/routes/library/[id]/+page.svelte +++ b/src/routes/library/[id]/+page.svelte @@ -84,7 +84,7 @@ try { 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'}`); if (item?.people) { item.people.forEach((p, i) => { @@ -170,9 +170,9 @@ // Images now handled by CachedImage component - function formatDuration(ticks?: number): string { - if (!ticks) return ""; - const seconds = Math.floor(ticks / 10000000); + function formatDuration(ms?: number | null): string { + if (!ms) return ""; + const seconds = Math.floor(ms / 1000); const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); @@ -183,7 +183,7 @@ } function handleItemClick(clickedItem: MediaItem | Library) { - if (!("type" in clickedItem)) { + if (!("kind" in clickedItem)) { // Library item - navigate to library goto(`/library/${clickedItem.id}`); return; @@ -405,8 +405,8 @@ {#if item.type} {item.type} {/if} - {#if item.runTimeTicks} - {formatDuration(item.runTimeTicks)} + {#if item.durationMs} + {formatDuration(item.durationMs)} {/if} {#if item.communityRating}