Merge branch 'frontend-domain-model' into ci-docs-publish-fix
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 8m34s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m20s
Traceability Validation / Check Requirement Traces (push) Successful in 15s
Build & Release / Run Tests (push) Successful in 5m18s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 4m38s
Build & Release / Build Linux (push) Successful in 17m22s
Build & Release / Build Android (push) Successful in 22m44s
Build & Release / Create Release (push) Successful in 14s

This commit is contained in:
2026-07-23 22:18:59 +02:00
67 changed files with 998 additions and 589 deletions
+174 -19
View File
@@ -670,15 +670,15 @@ async storageDeleteUser(userId: string) : Promise<null> {
* Update playback progress in local database
* This stores the progress locally for offline access and "continue watching"
*/
async storageUpdatePlaybackProgress(userId: string, itemId: string, positionTicks: number) : Promise<null> {
return await TAURI_INVOKE("storage_update_playback_progress", { userId, itemId, positionTicks });
async storageUpdatePlaybackProgress(userId: string, itemId: string, positionMs: number) : Promise<null> {
return await TAURI_INVOKE("storage_update_playback_progress", { userId, itemId, positionMs });
},
/**
* Update playback progress with context in local database
* This stores the progress along with playback context (container vs single)
*/
async storageUpdatePlaybackContext(userId: string, itemId: string, positionTicks: number, contextType: string | null, contextId: string | null) : Promise<null> {
return await TAURI_INVOKE("storage_update_playback_context", { userId, itemId, positionTicks, contextType, contextId });
async storageUpdatePlaybackContext(userId: string, itemId: string, positionMs: number, contextType: string | null, contextId: string | null) : Promise<null> {
return await TAURI_INVOKE("storage_update_playback_context", { userId, itemId, positionMs, contextType, contextId });
},
/**
* Mark item as played in local database
@@ -1332,20 +1332,20 @@ async repositoryOpenLiveStream(handle: string, itemId: string) : Promise<LiveStr
/**
* Report playback start
*/
async repositoryReportPlaybackStart(handle: string, itemId: string, positionTicks: number) : Promise<null> {
return await TAURI_INVOKE("repository_report_playback_start", { handle, itemId, positionTicks });
async repositoryReportPlaybackStart(handle: string, itemId: string, positionMs: number) : Promise<null> {
return await TAURI_INVOKE("repository_report_playback_start", { handle, itemId, positionMs });
},
/**
* Report playback progress
*/
async repositoryReportPlaybackProgress(handle: string, itemId: string, positionTicks: number) : Promise<null> {
return await TAURI_INVOKE("repository_report_playback_progress", { handle, itemId, positionTicks });
async repositoryReportPlaybackProgress(handle: string, itemId: string, positionMs: number) : Promise<null> {
return await TAURI_INVOKE("repository_report_playback_progress", { handle, itemId, positionMs });
},
/**
* Report playback stopped
*/
async repositoryReportPlaybackStopped(handle: string, itemId: string, positionTicks: number) : Promise<null> {
return await TAURI_INVOKE("repository_report_playback_stopped", { handle, itemId, positionTicks });
async repositoryReportPlaybackStopped(handle: string, itemId: string, positionMs: number) : Promise<null> {
return await TAURI_INVOKE("repository_report_playback_stopped", { handle, itemId, positionMs });
},
/**
* Get image URL for an item
@@ -1802,7 +1802,22 @@ export type LmsSyncGroup = { masterMac: string; masterName?: string; slaveMacs?:
/**
* Media item
*/
export type MediaItem = { id: string; name: string; type: string;
export type MediaItem = { id: string; name: string;
/**
* Legacy Jellyfin item-type string (`"Audio"`, `"MusicAlbum"`, …).
*
* Dual-carry migration (docs/specs/frontend-domain-model.md): `kind` below
* is the neutral replacement. This field stays while the frontend migrates
* off it, then is removed in a later phase. New Rust code should read
* `kind`, not this.
*/
type: string;
/**
* Provider-neutral classification — the replacement for `item_type`.
* Populated by the Jellyfin mapping; defaults to `Other` for the handful of
* construction sites that have not been migrated yet.
*/
kind?: MediaKind;
/**
* Whether this item is a folder/container (vs a playable leaf). Used to
* decide whether a channel item drills into a list or plays directly.
@@ -1812,7 +1827,63 @@ isFolder?: boolean; serverId: string; parentId?: string | null; libraryId?: stri
* ISO-8601 release/air date (Jellyfin `PremiereDate`). Used to sort
* podcast episodes by release date.
*/
premiereDate?: string | null; communityRating?: number | null; officialRating?: string | null; runTimeTicks?: number | null; primaryImageTag?: string | null; backdropImageTags?: string[] | null; parentBackdropImageTags?: string[] | null; albumId?: string | null; albumName?: string | null; albumArtist?: string | null; artists?: string[] | null; artistItems?: ArtistItem[] | null; indexNumber?: number | null; parentIndexNumber?: number | null; seriesId?: string | null; seriesName?: string | null; seasonId?: string | null; seasonName?: string | null; userData?: UserData | null; mediaStreams?: MediaStream[] | null; mediaSources?: MediaSource[] | null; people?: Person[] | null }
premiereDate?: string | null; communityRating?: number | null; officialRating?: string | null;
/**
* Legacy Jellyfin duration in ticks (100 ns units). Being replaced by
* `duration_ms`; dual-carried while the frontend migrates
* (docs/specs/frontend-domain-model.md). New code should read `duration_ms`.
*/
runTimeTicks?: number | null;
/**
* Duration in milliseconds — the neutral replacement for `runtime_ticks`.
* Ticks never reach the frontend; this does.
*/
durationMs?: number | null;
/**
* Legacy Jellyfin primary image tag. Being replaced by `image_id`;
* dual-carried while the frontend migrates. New code should read `image_id`.
*/
primaryImageTag?: string | null;
/**
* Neutral image identifier the frontend resolves to a URL via the image
* command — the replacement for `primary_image_tag`. Same value today
* (Jellyfin's tag is the id); the rename removes the provider term.
*/
imageId?: string | null; backdropImageTags?: string[] | null; parentBackdropImageTags?: string[] | null; albumId?: string | null; albumName?: string | null; albumArtist?: string | null; artists?: string[] | null; artistItems?: ArtistItem[] | null; indexNumber?: number | null; parentIndexNumber?: number | null; seriesId?: string | null; seriesName?: string | null; seasonId?: string | null; seasonName?: string | null; userData?: UserData | null; mediaStreams?: MediaStream[] | null; mediaSources?: MediaSource[] | null; people?: Person[] | null }
/**
* The kind of a media item — provider-neutral classification.
*
* Replaces the stringly-typed `item_type` that carried Jellyfin's vocabulary
* (`"Audio"`, `"MusicAlbum"`, …) across the boundary. A closed enum means a
* typo or an unhandled kind is a compile error on the frontend, not a silent
* runtime miss across ~127 comparison sites.
*/
export type MediaKind = "track" | "album" | "artist" | "playlist" | "movie" | "series" | "season" | "episode" | "person" |
/**
* A channel *container* the user drills into (Jellyfin `Channel`).
*/
"channel" | "folder" |
/**
* A live TV channel — playable, but a live stream with no seekable
* timeline (no resume/seek). Jellyfin `TvChannel`/`LiveTvChannel`.
*/
"liveChannel" |
/**
* A playable leaf inside a channel (Jellyfin `ChannelFolderItem` that is
* not itself a folder) — e.g. a plugin-channel VOD item that has no
* dedicated item type but carries its own media streams. Playable and
* seekable, unlike `LiveChannel`. Distinct from `Channel` (the container)
* and from `Other` so the UI can route it to playback.
*/
"channelItem" |
/**
* A kind we do not model explicitly. Reached only for provider item types
* that map to nothing meaningful; consumers treat it like an opaque
* container. The mapping must be *total* — it never panics — so this is the
* safe sink for unknown strings. Also the `Default`, so a defaulted
* `MediaItem` (see the dual-carry migration) is inert rather than a lie.
*/
"other"
/**
* Media session type tracking the high-level playback context
*/
@@ -1841,13 +1912,26 @@ export type MediaSource = { id: string; name: string; container?: string | null;
/**
* Media stream information (audio, video, subtitle tracks)
*/
export type MediaStream = { type: string; codec?: string | null; language?: string | null; displayTitle?: string | null; index: number; isDefault: boolean; isForced: boolean }
export type MediaStream = {
/**
* Legacy Jellyfin stream type string ("Audio"/"Video"/"Subtitle"). Being
* replaced by `kind`; dual-carried while the frontend migrates.
*/
type: string;
/**
* Provider-neutral stream classification — replaces `stream_type`.
*/
kind?: StreamKind; codec?: string | null; language?: string | null; displayTitle?: string | null; index: number; isDefault: boolean; isForced: boolean }
export type MediaType = "audio" | "video"
/**
* Lightweight media item for merged playback state
* Converts from both local MediaItem and remote NowPlayingItem
*/
export type MergedMediaItem = { id: string; title: string; artist: string | null; album: string | null; albumId: string | null; duration: number | null; primaryImageTag: string | null; mediaType: string }
export type MergedMediaItem = { id: string; title: string; artist: string | null; album: string | null; albumId: string | null; duration: number | null; primaryImageTag: string | null;
/**
* Neutral image identifier — replaces `primary_image_tag` (same value).
*/
imageId: string | null; mediaType: string }
/**
* Argument struct for [`set_network_state`].
*
@@ -1995,7 +2079,12 @@ export type PlaybackMode = { type: "local" } | { type: "remote"; session_id: str
/**
* Playback progress info
*/
export type PlaybackProgress = { itemId: string; positionTicks: number; isPlayed: boolean; isFavorite: boolean; playCount: number }
export type PlaybackProgress = { itemId: string;
/**
* Resume position in milliseconds. Stored as Jellyfin ticks in the DB;
* converted here so the frontend never sees ticks.
*/
positionMs: number; isPlayed: boolean; isFavorite: boolean; playCount: number }
/**
* Represents a media item that can be played
*
@@ -2039,9 +2128,17 @@ artistItems?: ArtistItem[] | null;
*/
artists?: string[] | null;
/**
* Primary image tag for artwork
* Primary image tag for artwork.
*
* Legacy Jellyfin name; being replaced by `image_id` (same value). Dual-carried
* while the frontend migrates (docs/specs/frontend-domain-model.md).
*/
primaryImageTag?: string | null;
/**
* Neutral image identifier the frontend resolves to a URL — replaces
* `primary_image_tag`.
*/
imageId?: string | null;
/**
* Item type (Audio, Movie, Episode, etc.)
*/
@@ -2276,7 +2373,22 @@ export type PlaylistEntry =
/**
* The underlying media item
*/
({ id: string; name: string; type: string;
({ id: string; name: string;
/**
* Legacy Jellyfin item-type string (`"Audio"`, `"MusicAlbum"`, …).
*
* Dual-carry migration (docs/specs/frontend-domain-model.md): `kind` below
* is the neutral replacement. This field stays while the frontend migrates
* off it, then is removed in a later phase. New Rust code should read
* `kind`, not this.
*/
type: string;
/**
* Provider-neutral classification — the replacement for `item_type`.
* Populated by the Jellyfin mapping; defaults to `Other` for the handful of
* construction sites that have not been migrated yet.
*/
kind?: MediaKind;
/**
* Whether this item is a folder/container (vs a playable leaf). Used to
* decide whether a channel item drills into a list or plays directly.
@@ -2286,7 +2398,29 @@ isFolder?: boolean; serverId: string; parentId?: string | null; libraryId?: stri
* ISO-8601 release/air date (Jellyfin `PremiereDate`). Used to sort
* podcast episodes by release date.
*/
premiereDate?: string | null; communityRating?: number | null; officialRating?: string | null; runTimeTicks?: number | null; primaryImageTag?: string | null; backdropImageTags?: string[] | null; parentBackdropImageTags?: string[] | null; albumId?: string | null; albumName?: string | null; albumArtist?: string | null; artists?: string[] | null; artistItems?: ArtistItem[] | null; indexNumber?: number | null; parentIndexNumber?: number | null; seriesId?: string | null; seriesName?: string | null; seasonId?: string | null; seasonName?: string | null; userData?: UserData | null; mediaStreams?: MediaStream[] | null; mediaSources?: MediaSource[] | null; people?: Person[] | null }) & {
premiereDate?: string | null; communityRating?: number | null; officialRating?: string | null;
/**
* Legacy Jellyfin duration in ticks (100 ns units). Being replaced by
* `duration_ms`; dual-carried while the frontend migrates
* (docs/specs/frontend-domain-model.md). New code should read `duration_ms`.
*/
runTimeTicks?: number | null;
/**
* Duration in milliseconds — the neutral replacement for `runtime_ticks`.
* Ticks never reach the frontend; this does.
*/
durationMs?: number | null;
/**
* Legacy Jellyfin primary image tag. Being replaced by `image_id`;
* dual-carried while the frontend migrates. New code should read `image_id`.
*/
primaryImageTag?: string | null;
/**
* Neutral image identifier the frontend resolves to a URL via the image
* command — the replacement for `primary_image_tag`. Same value today
* (Jellyfin's tag is the id); the rename removes the provider term.
*/
imageId?: string | null; backdropImageTags?: string[] | null; parentBackdropImageTags?: string[] | null; albumId?: string | null; albumName?: string | null; albumArtist?: string | null; artists?: string[] | null; artistItems?: ArtistItem[] | null; indexNumber?: number | null; parentIndexNumber?: number | null; seriesId?: string | null; seriesName?: string | null; seasonId?: string | null; seasonName?: string | null; userData?: UserData | null; mediaStreams?: MediaStream[] | null; mediaSources?: MediaSource[] | null; people?: Person[] | null }) & {
/**
* The playlist-scoped entry ID (Jellyfin's PlaylistItemId)
*/
@@ -2391,6 +2525,15 @@ export type SmartCacheStats = { total_size: number; storage_limit: number; avail
* Storage statistics for downloads
*/
export type StorageStats = { total_bytes: number; total_items: number; albums: AlbumStorageInfo[] }
/**
* The kind of a media stream within an item (audio track, video track,
* subtitle, …) — provider-neutral, replacing the stringly Jellyfin stream type.
*/
export type StreamKind = "audio" | "video" | "subtitle" |
/**
* Any stream kind we do not model explicitly (e.g. embedded image, data).
*/
"other"
/**
* Represents a subtitle track
*/
@@ -2430,7 +2573,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
*/
+1 -1
View File
@@ -470,7 +470,7 @@ describe("RepositoryClient", () => {
expect(invoke).toHaveBeenCalledWith("repository_report_playback_progress", {
handle: "test-handle-123",
itemId: "item123",
positionTicks: 5000000,
positionMs: 5000000,
});
});
});
+6 -6
View File
@@ -164,16 +164,16 @@ export class RepositoryClient {
return commands.repositoryGetPlaybackInfo(this.ensureHandle(), itemId);
}
async reportPlaybackStart(itemId: string, positionTicks: number): Promise<void> {
await commands.repositoryReportPlaybackStart(this.ensureHandle(), itemId, positionTicks);
async reportPlaybackStart(itemId: string, positionMs: number): Promise<void> {
await commands.repositoryReportPlaybackStart(this.ensureHandle(), itemId, positionMs);
}
async reportPlaybackProgress(itemId: string, positionTicks: number): Promise<void> {
await commands.repositoryReportPlaybackProgress(this.ensureHandle(), itemId, positionTicks);
async reportPlaybackProgress(itemId: string, positionMs: number): Promise<void> {
await commands.repositoryReportPlaybackProgress(this.ensureHandle(), itemId, positionMs);
}
async reportPlaybackStopped(itemId: string, positionTicks: number): Promise<void> {
await commands.repositoryReportPlaybackStopped(this.ensureHandle(), itemId, positionTicks);
async reportPlaybackStopped(itemId: string, positionMs: number): Promise<void> {
await commands.repositoryReportPlaybackStopped(this.ensureHandle(), itemId, positionMs);
}
// ===== Stream URL Methods (via Rust) =====
+1
View File
@@ -14,6 +14,7 @@ export type {
Library,
LiveStreamInfo,
MediaItem,
MediaKind,
MediaSource,
MediaStream,
Person,
+6 -6
View File
@@ -32,7 +32,7 @@
}
// 2. For episodes, try series/season backdrops
if (currentItem.type === "Episode") {
if (currentItem.kind === "episode") {
if (currentItem.seriesId && currentItem.parentBackdropImageTags?.[0]) {
return { itemId: currentItem.seriesId, imageType: "Backdrop" as const, tag: currentItem.parentBackdropImageTags[0] };
}
@@ -45,17 +45,17 @@
}
// 3. For music tracks, try album backdrop
if (currentItem.type === "Audio" && currentItem.albumId) {
if (currentItem.kind === "track" && currentItem.albumId) {
return { itemId: currentItem.albumId, imageType: "Backdrop" as const, tag: undefined };
}
// 4. Fall back to primary image
if (currentItem.primaryImageTag) {
return { itemId: currentItem.id, imageType: "Primary" as const, tag: currentItem.primaryImageTag };
if (currentItem.imageId) {
return { itemId: currentItem.id, imageType: "Primary" as const, tag: currentItem.imageId };
}
// 5. Last resort for audio: album primary
if (currentItem.type === "Audio" && currentItem.albumId) {
if (currentItem.kind === "track" && currentItem.albumId) {
return { itemId: currentItem.albumId, imageType: "Primary" as const, tag: undefined };
}
@@ -190,7 +190,7 @@
onclick={() => {
// Navigate to full series detail page with cast/crew/related content
// (even for episodes, show the series page so users see cast and related items)
if (currentItem.type === "Episode" && currentItem.seriesId) {
if (currentItem.kind === "episode" && currentItem.seriesId) {
goto(`/library/${currentItem.seriesId}`);
} else {
goto(`/library/${currentItem.id}`);
@@ -43,7 +43,7 @@
sortBy: "DateCreated",
sortOrder: "Descending"
});
albums = albumsResult.items.filter(item => item.type === "MusicAlbum");
albums = albumsResult.items.filter(item => item.kind === "album");
} catch (e) {
console.warn("Failed to load albums:", e);
} finally {
@@ -58,7 +58,7 @@
sortBy: "CommunityRating",
sortOrder: "Descending"
});
topTracks = tracksResult.items.filter(item => item.type === "Audio");
topTracks = tracksResult.items.filter(item => item.kind === "track");
} catch (e) {
console.warn("Failed to load tracks:", e);
} finally {
@@ -76,7 +76,7 @@
sortOrder: "Descending"
});
relatedArtists = relatedResult.items
.filter(item => item.id !== artist.id && item.type === "MusicArtist")
.filter(item => item.id !== artist.id && item.kind === "artist")
.slice(0, 6);
}
} catch (e) {
@@ -112,12 +112,12 @@
<!-- Artist Info -->
<div class="flex flex-col items-center text-center py-12">
<!-- Artist Image -->
{#if artist.primaryImageTag}
{#if artist.imageId}
<div class="mb-6 rounded-full overflow-hidden w-40 h-40 shadow-lg">
<CachedImage
itemId={artist.id}
imageType="Primary"
tag={artist.primaryImageTag}
tag={artist.imageId}
maxWidth={400}
alt={artist.name}
class="w-full h-full object-cover"
@@ -160,11 +160,11 @@
class="group cursor-pointer"
>
<div class="aspect-square bg-[var(--color-surface)] rounded-lg overflow-hidden mb-2 group-hover:opacity-80 transition-opacity">
{#if album.primaryImageTag}
{#if album.imageId}
<CachedImage
itemId={album.id}
imageType="Primary"
tag={album.primaryImageTag}
tag={album.imageId}
maxWidth={200}
alt={album.name}
class="w-full h-full object-cover"
@@ -218,11 +218,11 @@
class="group text-center"
>
<div class="w-32 h-32 bg-[var(--color-surface)] rounded-full overflow-hidden mb-2 mx-auto group-hover:opacity-80 transition-opacity">
{#if relatedArtist.primaryImageTag}
{#if relatedArtist.imageId}
<CachedImage
itemId={relatedArtist.id}
imageType="Primary"
tag={relatedArtist.primaryImageTag}
tag={relatedArtist.imageId}
maxWidth={200}
alt={relatedArtist.name}
class="w-full h-full object-cover"
@@ -77,8 +77,8 @@
if (episode.backdropImageTags?.[0]) {
return { itemId: episode.id, imageType: "Backdrop" as const, tag: episode.backdropImageTags[0] };
}
if (episode.primaryImageTag) {
return { itemId: episode.id, imageType: "Primary" as const, tag: episode.primaryImageTag };
if (episode.imageId) {
return { itemId: episode.id, imageType: "Primary" as const, tag: episode.imageId };
}
if (series.backdropImageTags?.[0]) {
return { itemId: series.id, imageType: "Backdrop" as const, tag: series.backdropImageTags[0] };
@@ -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));
</script>
@@ -246,7 +246,7 @@
<CachedImage
itemId={ep.id}
imageType="Primary"
tag={ep.primaryImageTag}
tag={ep.imageId}
maxWidth={400}
alt={ep.name}
class="w-full h-full object-cover transition-transform {isCurrent ? '' : 'group-hover/card:scale-105'}"
+4 -4
View File
@@ -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);
</script>
@@ -59,7 +59,7 @@
<CachedImage
itemId={episode.id}
imageType="Primary"
tag={episode.primaryImageTag}
tag={episode.imageId}
maxWidth={320}
alt={episode.name}
class="w-full h-full object-cover transition-transform group-hover/row:scale-105"
@@ -234,7 +234,7 @@
<CachedImage
itemId={item.id}
imageType="Primary"
tag={item.primaryImageTag}
tag={item.imageId}
maxWidth={300}
alt={item.name}
class="w-full h-full object-cover group-hover:scale-105 transition-transform"
+16 -13
View File
@@ -1,32 +1,35 @@
<script lang="ts">
import { goto } from "$app/navigation";
import type { MediaKind } from "$lib/api/types";
interface Props {
genres: string[];
maxShow?: number; // Default: unlimited
clickable?: boolean; // Default: true
itemType?: string; // Determines which genre browse page to open
itemKind?: MediaKind; // Determines which genre browse page to open
}
let {
genres,
maxShow,
clickable = true,
itemType
itemKind
}: Props = $props();
// Map the item type to its genre-browse route
function genreBasePath(type: string | undefined): string {
switch (type) {
case "MusicAlbum":
case "MusicArtist":
case "Audio":
// Map the item kind to its genre-browse route
function genreBasePath(kind: MediaKind | undefined): string {
switch (kind) {
case "album":
case "artist":
case "track":
case "playlist":
return "/library/music/genres";
case "Series":
case "Season":
case "Episode":
case "series":
case "season":
case "episode":
return "/library/shows/genres";
case "Movie":
case "movie":
return "/library/movies/genres";
default:
return "/library/movies/genres";
@@ -43,7 +46,7 @@
function handleGenreClick(genre: string) {
if (clickable) {
goto(`${genreBasePath(itemType)}?genre=${encodeURIComponent(genre)}`);
goto(`${genreBasePath(itemKind)}?genre=${encodeURIComponent(genre)}`);
}
}
</script>
@@ -19,7 +19,7 @@
}
function getImageTag(item: MediaItem | Library): string | undefined {
return "primaryImageTag" in item ? (item.primaryImageTag ?? undefined) : ("imageTag" in item ? (item.imageTag ?? undefined) : undefined);
return "imageId" in item ? (item.imageId ?? undefined) : ("imageTag" in item ? (item.imageTag ?? undefined) : undefined);
}
function getSubtitle(item: MediaItem | Library): string {
@@ -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 @@
<div class="space-y-1">
{#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}
+5 -5
View File
@@ -101,11 +101,11 @@
};
const isMusicType = $derived(
"type" in item && (item.type === "Audio" || item.type === "MusicAlbum" || item.type === "MusicArtist" || item.type === "Playlist")
"kind" in item && (item.kind === "track" || item.kind === "album" || item.kind === "artist" || item.kind === "playlist")
);
const aspectRatio = $derived(() => {
if ("type" in item) {
if ("kind" in item) {
return isMusicType ? "aspect-square" : "aspect-[2/3]";
}
// Library
@@ -113,16 +113,16 @@
});
const imageTag = $derived(
"primaryImageTag" in item ? item.primaryImageTag : ("imageTag" in item ? item.imageTag : undefined)
"imageId" in item ? item.imageId : ("imageTag" in item ? item.imageTag : undefined)
);
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(() => {
@@ -31,8 +31,8 @@
});
// Separate movies and series
movies = result.items.filter(item => item.type === "Movie");
series = result.items.filter(item => item.type === "Series");
movies = result.items.filter(item => item.kind === "movie");
series = result.items.filter(item => item.kind === "series");
} catch (e) {
console.error("Failed to load filmography:", e);
} finally {
@@ -53,7 +53,7 @@
<CachedImage
itemId={person.id}
imageType="Primary"
tag={person.primaryImageTag}
tag={person.imageId}
maxWidth={400}
alt={person.name}
class="w-full rounded-lg shadow-lg"
@@ -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(() => {
@@ -146,11 +146,11 @@
<div class="flex gap-6 pt-4">
<!-- Playlist artwork -->
<div class="flex-shrink-0 w-48">
{#if playlist.primaryImageTag}
{#if playlist.imageId}
<CachedImage
itemId={playlist.id}
imageType="Primary"
tag={playlist.primaryImageTag}
tag={playlist.imageId}
maxWidth={400}
alt={playlist.name}
class="w-full rounded-lg shadow-lg"
@@ -2,12 +2,12 @@
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { auth } from "$lib/stores/auth";
import type { MediaItem, Person } from "$lib/api/types";
import type { MediaItem, MediaKind, Person } from "$lib/api/types";
import MediaCard from "./MediaCard.svelte";
interface Props {
currentItemId: string;
itemType: "Movie" | "Series" | "MusicAlbum" | "Audio";
itemKind: MediaKind;
genres?: string[];
people?: Person[];
artistIds?: string[];
@@ -16,7 +16,7 @@
let {
currentItemId,
itemType,
itemKind,
genres = [],
people = [],
artistIds = [],
@@ -45,9 +45,9 @@
let items: MediaItem[] = [];
// First, try to use the Jellyfin Similar Items API (preferred method)
// First, try to use the Similar Items API (preferred method)
// This works for Movies and Series (most common cases)
if (["Movie", "Series"].includes(itemType)) {
if (itemKind === "movie" || itemKind === "series") {
try {
const result = await repo.getSimilarItems(currentItemId, limit);
items = result.items.filter(item => item.id !== currentItemId);
@@ -65,10 +65,14 @@
// Fallback: Load by genres using search (works for all item types)
if (genres && genres.length > 0) {
try {
// Search by first genre to find related items
// Search by first genre to find related items. This single-kind query
// maps the neutral kind to the concrete Jellyfin item type it needs.
const searchTerm = genres[0];
const itemTypeForKind: Record<string, string> = {
movie: "Movie", series: "Series", album: "MusicAlbum", track: "Audio", artist: "MusicArtist",
};
const result = await repo.search(searchTerm, {
includeItemTypes: itemType === "MusicAlbum" ? ["MusicAlbum"] : itemType === "Audio" ? ["Audio"] : [itemType],
includeItemTypes: [itemTypeForKind[itemKind] ?? "Movie"],
limit: limit * 2
});
@@ -79,7 +83,7 @@
}
// For music albums, also try to load by artist (if we don't have enough from similar API)
if (itemType === "MusicAlbum" && artistIds && artistIds.length > 0 && items.length === 0) {
if (itemKind === "album" && artistIds && artistIds.length > 0 && items.length === 0) {
try {
// Search for other albums by artist name from first artist
const result = await repo.search(artistIds[0], {
@@ -109,14 +113,14 @@
}
function getTitle(): string {
switch (itemType) {
case "Movie":
switch (itemKind) {
case "movie":
return "Related Movies";
case "Series":
case "series":
return "Related Shows";
case "MusicAlbum":
case "album":
return "Related Albums";
case "Audio":
case "track":
return "Related Tracks";
default:
return "Related Items";
@@ -133,7 +137,7 @@
{#if loading}
<!-- Skeleton loading state -->
{@const isMusicContent = itemType === "MusicAlbum" || itemType === "Audio"}
{@const isMusicContent = itemKind === "album" || itemKind === "track"}
<div class="grid grid-cols-2 md:grid-cols-6 gap-4">
{#each Array(6) as _}
<div class="animate-pulse">
@@ -28,7 +28,7 @@
<CachedImage
itemId={season.id}
imageType="Primary"
tag={season.primaryImageTag}
tag={season.imageId}
maxWidth={200}
alt={seasonName}
class="w-full h-full object-cover"
+2 -2
View File
@@ -283,7 +283,7 @@
<!-- Duration -->
<div class="text-gray-400 text-right">
{formatDuration(track.runTimeTicks)}
{formatDuration(track.durationMs)}
</div>
<!-- Download Button Placeholder -->
@@ -421,7 +421,7 @@
</p>
</div>
<div class="text-gray-400 text-sm {showDownload ? 'mr-20' : 'mr-12'}">
{formatDuration(track.runTimeTicks)}
{formatDuration(track.durationMs)}
</div>
</button>
+4 -4
View File
@@ -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,
},
];
+2 -2
View File
@@ -143,7 +143,7 @@
<CachedImage
itemId={artworkItemId}
imageType="Primary"
tag={displayMedia?.primaryImageTag}
tag={displayMedia?.imageId}
maxWidth={800}
alt=""
class="w-full h-full object-cover blur-3xl opacity-30"
@@ -223,7 +223,7 @@
<CachedImage
itemId={artworkItemId}
imageType="Primary"
tag={displayMedia?.primaryImageTag}
tag={displayMedia?.imageId}
maxWidth={500}
alt={displayMedia?.name}
class="w-full h-full object-cover"
+1 -1
View File
@@ -326,7 +326,7 @@
<CachedImage
itemId={displayMedia.albumId || displayMedia.id}
imageType="Primary"
tag={displayMedia.primaryImageTag}
tag={displayMedia.imageId}
maxWidth={100}
alt={displayMedia?.name}
class="w-full h-full object-cover"
@@ -78,11 +78,11 @@
<div
class="relative flex-shrink-0 w-28 h-16 rounded-lg overflow-hidden bg-gray-800"
>
{#if imageId && $nextEpisodeItem.primaryImageTag}
{#if imageId && $nextEpisodeItem.imageId}
<CachedImage
itemId={imageId}
imageType="Primary"
tag={$nextEpisodeItem.primaryImageTag}
tag={$nextEpisodeItem.imageId}
maxHeight={200}
alt={$nextEpisodeItem.name}
class="w-full h-full object-cover"
+6 -6
View File
@@ -36,9 +36,9 @@
let dragDisabled = $state(true);
const flipDurationMs = 200;
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 mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, "0")}`;
@@ -184,11 +184,11 @@
<!-- Artwork -->
<div class="w-10 h-10 rounded bg-gray-800 flex-shrink-0 overflow-hidden">
{#if item.primaryImageTag}
{#if item.imageId}
<CachedImage
itemId={item.id}
imageType="Primary"
tag={item.primaryImageTag}
tag={item.imageId}
maxWidth={80}
alt={item.name}
class="w-full h-full object-cover"
@@ -210,7 +210,7 @@
<!-- Duration -->
<span class="text-xs text-gray-500 flex-shrink-0">
{formatDuration(item.runTimeTicks)}
{formatDuration(item.durationMs)}
</span>
</button>
@@ -97,8 +97,8 @@ function makeEpisode(): MediaItem {
return {
id: "ep1",
name: "Episode 1",
type: "Episode",
runTimeTicks: 24 * 60 * 10_000_000, // 24 min
kind: "episode",
durationMs: 24 * 60 * 1000, // 24 min
} as MediaItem;
}
+10 -10
View File
@@ -165,9 +165,9 @@
// Use known duration from media item (runTimeTicks is in 10M ticks/second)
// Fallback to video element duration for direct streams
const duration = $derived.by(() => {
// Explicitly check if runTimeTicks exists and is a valid number
if (media && media.runTimeTicks && media.runTimeTicks > 0) {
return media.runTimeTicks / 10_000_000;
// Explicitly check if durationMs exists and is a valid number
if (media && media.durationMs && media.durationMs > 0) {
return media.durationMs / 1000;
}
// Otherwise use the video element's duration
return videoDuration;
@@ -180,7 +180,7 @@
console.log("[VideoPlayer] No media or mediaStreams available");
return [];
}
const tracks = media.mediaStreams.filter(stream => stream.type === "Audio");
const tracks = media.mediaStreams.filter(stream => stream.kind === "audio");
console.log("[VideoPlayer] Found audio tracks:", tracks.length, tracks);
return tracks;
});
@@ -243,7 +243,7 @@
console.log("[VideoPlayer] No media or mediaStreams available for subtitles");
return [];
}
const tracks = media.mediaStreams.filter(stream => stream.type === "Subtitle");
const tracks = media.mediaStreams.filter(stream => stream.kind === "subtitle");
console.log("[VideoPlayer] Found subtitle tracks:", tracks.length, tracks);
return tracks;
});
@@ -386,7 +386,7 @@
// Check if we're near the end of the video - if so, this is likely
// end-of-stream rather than a real error. Jellyfin transcoded HLS
// streams may not always terminate cleanly with #EXT-X-ENDLIST.
const knownDuration = media?.runTimeTicks ? media.runTimeTicks / 10_000_000 : videoDuration;
const knownDuration = media?.durationMs ? media.durationMs / 1000 : videoDuration;
const effectiveTime = currentTime + seekOffset;
const isNearEnd = knownDuration > 0 && effectiveTime > 0 && effectiveTime / knownDuration > 0.9;
@@ -520,7 +520,7 @@
// Build subtitle tracks for native player
const subtitleTracks = [];
if (media.mediaStreams && mediaSourceId) {
const subtitles = media.mediaStreams.filter(s => s.type === "Subtitle");
const subtitles = media.mediaStreams.filter(s => s.kind === "subtitle");
for (const sub of subtitles) {
try {
const url = await getSubtitleUrl(sub.index);
@@ -1223,7 +1223,7 @@
needsTranscoding: false,
// Now-playing metadata so the lockscreen/miniplayer show the item.
artist: media.seriesName ?? null,
primaryImageTag: media.primaryImageTag ?? null,
primaryImageTag: media.imageId ?? null,
serverId: media.serverId ?? null,
// Real duration so the lockscreen scrubber has a range to draw.
durationSeconds: duration > 0 ? duration : null,
@@ -1626,11 +1626,11 @@
{#if !isMediaReady}
<div class="absolute inset-0 flex items-center justify-center bg-black">
<!-- Poster/Title Card -->
{#if media?.primaryImageTag}
{#if media?.imageId}
<CachedImage
itemId={media.id}
imageType="Primary"
tag={media.primaryImageTag}
tag={media.imageId}
maxHeight={1080}
alt={media?.name || "Video"}
class="max-w-full max-h-full object-contain"
@@ -121,11 +121,11 @@
class="w-full flex items-center gap-3 p-3 hover:bg-[var(--color-surface-hover)] rounded-lg transition-colors disabled:opacity-50"
>
<div class="w-10 h-10 flex-shrink-0 rounded overflow-hidden">
{#if playlist.primaryImageTag}
{#if playlist.imageId}
<CachedImage
itemId={playlist.id}
imageType="Primary"
tag={playlist.primaryImageTag}
tag={playlist.imageId}
maxWidth={80}
alt={playlist.name}
class="w-full h-full object-cover"
+9 -9
View File
@@ -29,7 +29,7 @@ vi.mock("$lib/stores/auth", () => ({
getItem: vi.fn(async (id: string) => ({
id,
name: "Test Item",
runTimeTicks: 100000000,
durationMs: 10000,
})),
})),
},
@@ -61,7 +61,7 @@ describe("playback reporting service", () => {
(c) => c[0] === "storage_update_playback_context"
);
expect(call).toBeDefined();
expect(call![1]).toHaveProperty("positionTicks", 600000000); // 60 seconds
expect(call![1]).toHaveProperty("positionMs", 60000); // 60 seconds
});
it("should use single context by default", async () => {
@@ -121,7 +121,7 @@ describe("playback reporting service", () => {
const call = invokeSpy.mock.calls.find(
(c) => c[0] === "storage_update_playback_progress"
);
expect(call![1]).toHaveProperty("positionTicks", 450000000); // 45 seconds
expect(call![1]).toHaveProperty("positionMs", 45000); // 45 seconds
});
});
@@ -155,7 +155,7 @@ describe("playback reporting service", () => {
expect(mockRepo.reportPlaybackStopped).toHaveBeenCalled();
});
it("should convert seconds to ticks for server report", async () => {
it("should convert seconds to milliseconds for server report", async () => {
const { auth } = await import("$lib/stores/auth");
const authModule = vi.mocked(auth);
const mockRepo = {
@@ -167,7 +167,7 @@ describe("playback reporting service", () => {
expect(mockRepo.reportPlaybackStopped).toHaveBeenCalledWith(
"item-123",
900000000 // 90 seconds in ticks
90000 // 90 seconds in ms
);
});
@@ -207,7 +207,7 @@ describe("playback reporting service", () => {
getItem: vi.fn(async () => ({
id: "item-123",
name: "Item",
runTimeTicks: 100000000,
durationMs: 10000,
})),
};
authModule.getRepository = vi.fn(() => mockRepo as any);
@@ -217,11 +217,11 @@ describe("playback reporting service", () => {
expect(mockRepo.getItem).toHaveBeenCalledWith("item-123");
expect(mockRepo.reportPlaybackStopped).toHaveBeenCalledWith(
"item-123",
100000000
10000
);
});
it("should handle items without runTimeTicks", async () => {
it("should handle items without durationMs", async () => {
const { auth } = await import("$lib/stores/auth");
const authModule = vi.mocked(auth);
const mockRepo = {
@@ -229,7 +229,7 @@ describe("playback reporting service", () => {
getItem: vi.fn(async () => ({
id: "item-123",
name: "Item",
runTimeTicks: null,
durationMs: null,
})),
};
authModule.getRepository = vi.fn(() => mockRepo as any);
+9 -9
View File
@@ -26,7 +26,7 @@ export async function reportPlaybackStart(
contextType: "container" | "single" = "single",
contextId: string | null = null
): Promise<void> {
const positionTicks = Math.floor(positionSeconds * 10000000);
const positionMs = Math.floor(positionSeconds * 1000);
const userId = auth.getUserId();
console.log(
@@ -42,7 +42,7 @@ export async function reportPlaybackStart(
// Update local DB with context (always works, even offline)
if (userId) {
try {
await commands.storageUpdatePlaybackContext(userId, itemId, positionTicks, contextType, contextId);
await commands.storageUpdatePlaybackContext(userId, itemId, positionMs, contextType, contextId);
} catch (e) {
console.error("[PlaybackReporting] Failed to update playback context:", e);
}
@@ -62,7 +62,7 @@ export async function reportPlaybackProgress(
positionSeconds: number,
_isPaused = false
): Promise<void> {
const positionTicks = Math.floor(positionSeconds * 10000000);
const positionMs = Math.floor(positionSeconds * 1000);
const userId = auth.getUserId();
// Reduce logging for frequent progress updates
@@ -73,7 +73,7 @@ export async function reportPlaybackProgress(
// Update local DB only (progress updates are frequent, don't report to server)
if (userId) {
try {
await commands.storageUpdatePlaybackProgress(userId, itemId, positionTicks);
await commands.storageUpdatePlaybackProgress(userId, itemId, positionMs);
} catch (e) {
console.error("[PlaybackReporting] Failed to update local progress:", e);
}
@@ -89,7 +89,7 @@ export async function reportPlaybackProgress(
* TRACES: UR-005, UR-025 | DR-028
*/
export async function reportPlaybackStopped(itemId: string, positionSeconds: number): Promise<void> {
const positionTicks = Math.floor(positionSeconds * 10000000);
const positionMs = Math.floor(positionSeconds * 1000);
const userId = auth.getUserId();
console.log("[PlaybackReporting] reportPlaybackStopped - itemId:", itemId, "positionSeconds:", positionSeconds);
@@ -97,7 +97,7 @@ export async function reportPlaybackStopped(itemId: string, positionSeconds: num
// Update local DB first (always works, even offline)
if (userId) {
try {
await commands.storageUpdatePlaybackProgress(userId, itemId, positionTicks);
await commands.storageUpdatePlaybackProgress(userId, itemId, positionMs);
} catch (e) {
console.error("[PlaybackReporting] Failed to update local progress:", e);
}
@@ -108,7 +108,7 @@ export async function reportPlaybackStopped(itemId: string, positionSeconds: num
try {
// Get the repository to check if we should queue
const repo = auth.getRepository();
await repo.reportPlaybackStopped(itemId, positionTicks);
await repo.reportPlaybackStopped(itemId, positionMs);
} catch (e) {
console.error("[PlaybackReporting] Failed to report to server:", e);
// Server error - could queue, but for now just log
@@ -140,8 +140,8 @@ export async function markAsPlayed(itemId: string): Promise<void> {
const repo = auth.getRepository();
const item = await repo.getItem(itemId);
if (item.runTimeTicks) {
await repo.reportPlaybackStopped(itemId, item.runTimeTicks);
if (item.durationMs) {
await repo.reportPlaybackStopped(itemId, item.durationMs);
}
} catch (e) {
console.error("[PlaybackReporting] Failed to report as played:", e);
@@ -73,8 +73,8 @@ function makeItem(overrides: Partial<MediaItem> = {}): MediaItem {
return {
id: "track-1",
name: "Test Track",
type: "Audio",
runTimeTicks: null,
kind: "track",
durationMs: null,
...overrides,
} as MediaItem;
}
@@ -102,7 +102,7 @@ describe("Player Events — pause must not zero the slider duration", () => {
it("preserves the live duration across pause when runTimeTicks is missing", async () => {
// runTimeTicks is null — the previous code recomputed duration as 0 here,
// which collapsed the slider's max and snapped the thumb to the start.
const item = makeItem({ runTimeTicks: null });
const item = makeItem({ durationMs: null });
currentQueueItemStore.set(item);
const { initPlayerEvents } = await import("./playerEvents");
@@ -130,8 +130,8 @@ describe("Player Events — pause must not zero the slider duration", () => {
});
it("falls back to the runTimeTicks estimate when no live duration is known yet", async () => {
// 70s in ticks (1 tick = 100ns) → 700_000_000.
const item = makeItem({ runTimeTicks: 700_000_000 });
// 70 seconds = 70_000 ms.
const item = makeItem({ durationMs: 70_000 });
currentQueueItemStore.set(item);
const { initPlayerEvents } = await import("./playerEvents");
+1 -1
View File
@@ -170,7 +170,7 @@ function handlePositionUpdate(position: number, duration: number): void {
* with 0 when runTimeTicks is missing (which would zero the slider's max).
*/
function resolveDuration(currentItem: MediaItem, isSameTrack: boolean): number {
const estimate = currentItem.runTimeTicks ? currentItem.runTimeTicks / 10000000 : 0;
const estimate = currentItem.durationMs ? currentItem.durationMs / 1000 : 0;
if (isSameTrack) {
const live = get(playbackDuration);
if (live > 0) {
+3 -3
View File
@@ -104,12 +104,12 @@ class SyncService {
*/
async queuePlaybackProgress(
itemId: string,
positionTicks: number
positionMs: number
): Promise<number> {
// Update local state first
await commands.storageUpdatePlaybackProgress(auth.getUserId() ?? "", itemId, positionTicks);
await commands.storageUpdatePlaybackProgress(auth.getUserId() ?? "", itemId, positionMs);
return this.queueMutation("update_progress", itemId, { positionTicks });
return this.queueMutation("update_progress", itemId, { positionMs });
}
/**
+1 -1
View File
@@ -202,7 +202,7 @@ function createLibraryStore() {
const repo = auth.getRepository();
const item = await repo.getItem(itemId);
console.log(`[LibraryStore] loadItem(${itemId}): ${item.name} (${item.type})`);
console.log(`[LibraryStore] loadItem(${itemId}): ${item.name} (${item.kind})`);
console.log(`[LibraryStore] - Has people? ${item.people ? `YES (${item.people.length})` : 'NO'}`);
if (item.people && item.people.length > 0) {
item.people.forEach((p, i) => {
+1 -1
View File
@@ -45,7 +45,7 @@ function createMoviesStore() {
/** Artwork check for hero candidates: needs a backdrop or a primary image. */
const hasArt = (i: MediaItem) =>
!!(i.backdropImageTags && i.backdropImageTags.length > 0) || !!i.primaryImageTag;
!!(i.backdropImageTags && i.backdropImageTags.length > 0) || !!i.imageId;
async function loadSections(libraryId: string) {
update(s => ({
+1 -1
View File
@@ -55,7 +55,7 @@ function createMusicStore() {
/** Artwork check for hero candidates: needs a primary image or backdrop. */
const hasArt = (i: MediaItem) =>
!!i.primaryImageTag || !!(i.backdropImageTags && i.backdropImageTags.length > 0);
!!i.imageId || !!(i.backdropImageTags && i.backdropImageTags.length > 0);
async function loadSections(libraryId: string) {
update(s => ({
+15 -7
View File
@@ -9,7 +9,7 @@
*/
import { writable, derived } from "svelte/store";
import type { MediaItem, ItemType } from "$lib/api/types";
import type { MediaItem, MediaKind } from "$lib/api/types";
import type { NowPlayingItem } from "$lib/api/bindings";
import { isRemoteMode } from "./playbackMode";
import { selectedSession } from "./sessions";
@@ -24,7 +24,7 @@ export interface MergedMediaItem {
album: string | null;
albumId: string | null;
duration: number | null;
primaryImageTag: string | null;
imageId: string | null;
mediaType: "audio" | "video";
}
@@ -170,16 +170,24 @@ export const isMuted = derived(player, ($p) => $p.muted);
* falls back to the `artists` string list when `artistItems` is absent.)
*/
function nowPlayingToMediaItem(npi: NowPlayingItem): MediaItem {
// NowPlayingItem is remote-session data still carrying Jellyfin field names
// (Type, runTimeTicks, primaryImageTag). Map it onto the neutral MediaItem the
// UI consumes. Only the coarse audio/video split matters here for display.
const kind: MediaKind =
npi.Type === "Movie" ? "movie" :
npi.Type === "Episode" ? "episode" :
npi.Type === "MusicAlbum" ? "album" :
"track";
return {
id: npi.id ?? "",
name: npi.name ?? "",
type: (npi.Type ?? "Audio") as ItemType,
kind,
serverId: "",
albumName: npi.album,
albumId: npi.albumId,
artists: npi.artists,
primaryImageTag: npi.primaryImageTag ?? npi.albumPrimaryImageTag,
runTimeTicks: npi.runTimeTicks,
imageId: npi.primaryImageTag ?? npi.albumPrimaryImageTag,
durationMs: npi.runTimeTicks != null ? Math.floor(npi.runTimeTicks / 10000) : null,
} as MediaItem;
}
@@ -263,8 +271,8 @@ export const mergedVolume = derived(
*/
function isVideoItem(item: MediaItem | null): boolean {
if (!item) return false;
const type = item.type;
if (type === "Movie" || type === "Episode" || type === "TvChannel") {
const kind = item.kind;
if (kind === "movie" || kind === "episode" || kind === "liveChannel") {
return true;
}
// Backend PlayerMediaItem carries a lowercase mediaType discriminator that is
+4 -4
View File
@@ -27,8 +27,8 @@ vi.mock("./queue", () => ({ currentQueueItem }));
// Imported after the mocks so player.ts picks up the mocked stores.
const { player, shouldShowAudioMiniPlayer } = await import("./player");
const audioItem = { id: "a1", type: "Audio" } as unknown as MediaItem;
const videoItem = { id: "v1", type: "Movie" } as unknown as MediaItem;
const audioItem = { id: "a1", kind: "track" } as unknown as MediaItem;
const videoItem = { id: "v1", kind: "movie" } as unknown as MediaItem;
describe("shouldShowAudioMiniPlayer", () => {
beforeEach(() => {
@@ -90,8 +90,8 @@ describe("shouldShowAudioMiniPlayer", () => {
});
it("hides for a live TV channel", () => {
const channelItem = { id: "c1", type: "TvChannel" } as unknown as MediaItem;
player.setPlaying(channelItem, 0, 0);
const liveChannelItem = { id: "c1", kind: "liveChannel" } as unknown as MediaItem;
player.setPlaying(liveChannelItem, 0, 0);
expect(get(shouldShowAudioMiniPlayer)).toBe(false);
});
+2 -2
View File
@@ -50,7 +50,7 @@ function createTvStore() {
const hasArt = (i: MediaItem) =>
!!(i.backdropImageTags && i.backdropImageTags.length > 0) ||
!!(i.parentBackdropImageTags && i.parentBackdropImageTags.length > 0) ||
!!i.primaryImageTag;
!!i.imageId;
async function loadSections(libraryId: string) {
update(s => ({
@@ -81,7 +81,7 @@ function createTvStore() {
// Resume items are already video-only from the server, but keep episodes
// (and the occasional movie that lives in a mixed library) defensively.
const continueWatching = resume.filter(i => i.type === "Episode" || i.type === "Movie");
const continueWatching = resume.filter(i => i.kind === "episode" || i.kind === "movie");
// Mix the hero: in-progress episodes first (most personal), then next-up,
// recent additions, and random series from across the library.
+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);
-138
View File
@@ -1,138 +0,0 @@
/**
* Jellyfin Field Mapping Tests
*/
import { describe, it, expect } from "vitest";
import {
SORT_FIELD_MAP,
getJellyfinSortField,
normalizeSortOrder,
ITEM_TYPES,
ITEM_TYPE_GROUPS,
} from "./jellyfinFieldMapping";
describe("Jellyfin Field Mapping", () => {
describe("SORT_FIELD_MAP", () => {
it("should map frontend sort keys to Jellyfin fields", () => {
expect(SORT_FIELD_MAP.title).toBe("SortName");
expect(SORT_FIELD_MAP.artist).toBe("Artist");
expect(SORT_FIELD_MAP.album).toBe("Album");
expect(SORT_FIELD_MAP.year).toBe("ProductionYear");
expect(SORT_FIELD_MAP.recent).toBe("DatePlayed");
expect(SORT_FIELD_MAP.added).toBe("DateCreated");
expect(SORT_FIELD_MAP.rating).toBe("CommunityRating");
});
it("should have all common audio sorts", () => {
expect(SORT_FIELD_MAP).toHaveProperty("title");
expect(SORT_FIELD_MAP).toHaveProperty("artist");
expect(SORT_FIELD_MAP).toHaveProperty("album");
expect(SORT_FIELD_MAP).toHaveProperty("year");
expect(SORT_FIELD_MAP).toHaveProperty("recent");
});
it("should have fallback sort names", () => {
expect(SORT_FIELD_MAP.name).toBe("SortName");
});
it("should map aliases to same fields", () => {
expect(SORT_FIELD_MAP.title).toBe(SORT_FIELD_MAP.name);
expect(SORT_FIELD_MAP.recent).toBe("DatePlayed");
expect(SORT_FIELD_MAP.dateAdded).toBe("DateCreated");
expect(SORT_FIELD_MAP.datePlayed).toBe("DatePlayed");
});
});
describe("getJellyfinSortField()", () => {
it("should return mapped field for known keys", () => {
expect(getJellyfinSortField("artist")).toBe("Artist");
expect(getJellyfinSortField("album")).toBe("Album");
expect(getJellyfinSortField("year")).toBe("ProductionYear");
});
it("should fallback to SortName for unknown keys", () => {
expect(getJellyfinSortField("unknown")).toBe("SortName");
expect(getJellyfinSortField("")).toBe("SortName");
expect(getJellyfinSortField("invalidKey")).toBe("SortName");
});
it("should be case-sensitive", () => {
// Should work with exact case
expect(getJellyfinSortField("title")).toBe("SortName");
// Unknown case variations fallback to default
expect(getJellyfinSortField("Title")).toBe("SortName");
expect(getJellyfinSortField("TITLE")).toBe("SortName");
});
});
describe("normalizeSortOrder()", () => {
it("should accept valid ascending orders", () => {
expect(normalizeSortOrder("Ascending")).toBe("Ascending");
expect(normalizeSortOrder("ascending")).toBe("Ascending");
expect(normalizeSortOrder("asc")).toBe("Ascending");
expect(normalizeSortOrder(undefined)).toBe("Ascending");
});
it("should accept valid descending orders", () => {
expect(normalizeSortOrder("Descending")).toBe("Descending");
expect(normalizeSortOrder("descending")).toBe("Descending");
expect(normalizeSortOrder("desc")).toBe("Descending");
});
it("should default to Ascending for unknown values", () => {
expect(normalizeSortOrder("invalid")).toBe("Ascending");
expect(normalizeSortOrder("random")).toBe("Ascending");
expect(normalizeSortOrder("")).toBe("Ascending");
});
});
describe("ITEM_TYPES", () => {
it("should define audio types", () => {
expect(ITEM_TYPES.AUDIO).toBe("Audio");
expect(ITEM_TYPES.MUSIC_ALBUM).toBe("MusicAlbum");
expect(ITEM_TYPES.MUSIC_ARTIST).toBe("MusicArtist");
});
it("should define video types", () => {
expect(ITEM_TYPES.MOVIE).toBe("Movie");
expect(ITEM_TYPES.SERIES).toBe("Series");
expect(ITEM_TYPES.EPISODE).toBe("Episode");
});
it("should have consistent case", () => {
// Jellyfin API uses CamelCase
expect(ITEM_TYPES.MUSIC_ALBUM).toBe("MusicAlbum");
expect(ITEM_TYPES.MUSIC_ARTIST).toBe("MusicArtist");
expect(ITEM_TYPES.MUSIC_VIDEO).toBe("MusicVideo");
});
});
describe("ITEM_TYPE_GROUPS", () => {
it("should group audio types correctly", () => {
expect(ITEM_TYPE_GROUPS.audio).toContain(ITEM_TYPES.AUDIO);
expect(ITEM_TYPE_GROUPS.audio).toContain(ITEM_TYPES.MUSIC_ALBUM);
expect(ITEM_TYPE_GROUPS.audio).toContain(ITEM_TYPES.MUSIC_ARTIST);
expect(ITEM_TYPE_GROUPS.audio.length).toBe(3);
});
it("should group video types correctly", () => {
expect(ITEM_TYPE_GROUPS.video).toContain(ITEM_TYPES.MOVIE);
expect(ITEM_TYPE_GROUPS.video).toContain(ITEM_TYPES.SERIES);
expect(ITEM_TYPE_GROUPS.video).toContain(ITEM_TYPES.EPISODE);
});
it("should provide movie and TV show subgroups", () => {
expect(ITEM_TYPE_GROUPS.movies).toEqual([ITEM_TYPES.MOVIE]);
expect(ITEM_TYPE_GROUPS.tvshows).toContain(ITEM_TYPES.SERIES);
expect(ITEM_TYPE_GROUPS.tvshows).toContain(ITEM_TYPES.EPISODE);
});
it("should have music alias for audio", () => {
expect(ITEM_TYPE_GROUPS.music).toEqual(ITEM_TYPE_GROUPS.audio);
});
it("should provide episodes filter", () => {
expect(ITEM_TYPE_GROUPS.episodes).toEqual([ITEM_TYPES.EPISODE]);
});
});
});
-95
View File
@@ -1,95 +0,0 @@
/**
* Jellyfin Field Mapping
*
* Maps frontend sort option keys to Jellyfin API field names.
* This provides the single source of truth for how different UI sort options
* translate to backend database queries.
*/
/**
* Maps friendly sort names to Jellyfin API field names
* Used by all library views for consistent sorting
*/
export const SORT_FIELD_MAP = {
// Default/fallback sorts
title: "SortName",
name: "SortName",
// Audio-specific sorts
artist: "Artist",
album: "Album",
year: "ProductionYear",
recent: "DatePlayed",
added: "DateCreated",
rating: "CommunityRating",
duration: "RunTimeTicks",
// Video-specific sorts
dateAdded: "DateCreated",
datePlayed: "DatePlayed",
IMDBRating: "CommunityRating",
// Video series sorts
premiered: "PremiereDate",
episodeCount: "ChildCount",
} as const;
/**
* Type-safe sort field names
*/
export type SortField = keyof typeof SORT_FIELD_MAP;
/**
* Get Jellyfin API field name for a frontend sort key
* @param key Frontend sort key (e.g., "artist")
* @returns Jellyfin field name (e.g., "Artist")
*/
export function getJellyfinSortField(key: string): string {
const field = SORT_FIELD_MAP[key as SortField];
return field || "SortName"; // Fallback to title sort
}
/**
* Validate sort order string
* @param order Sort order value
* @returns Valid sort order for Jellyfin API
*/
export function normalizeSortOrder(order: string | undefined): "Ascending" | "Descending" {
if (order === "Descending" || order === "desc" || order === "descending") {
return "Descending";
}
return "Ascending";
}
/**
* Jellyfin ItemType constants for filtering
* Used in getItems() and search() calls
*/
export const ITEM_TYPES = {
// Audio types
AUDIO: "Audio",
MUSIC_ALBUM: "MusicAlbum",
MUSIC_ARTIST: "MusicArtist",
MUSIC_VIDEO: "MusicVideo",
// Video types
MOVIE: "Movie",
SERIES: "Series",
SEASON: "Season",
EPISODE: "Episode",
// Playlist
PLAYLIST: "Playlist",
} as const;
/**
* Predefined item type groups for easy filtering
*/
export const ITEM_TYPE_GROUPS = {
audio: [ITEM_TYPES.AUDIO, ITEM_TYPES.MUSIC_ALBUM, ITEM_TYPES.MUSIC_ARTIST],
music: [ITEM_TYPES.AUDIO, ITEM_TYPES.MUSIC_ALBUM, ITEM_TYPES.MUSIC_ARTIST],
video: [ITEM_TYPES.MOVIE, ITEM_TYPES.SERIES, ITEM_TYPES.EPISODE],
movies: [ITEM_TYPES.MOVIE],
tvshows: [ITEM_TYPES.SERIES, ITEM_TYPES.SEASON, ITEM_TYPES.EPISODE],
episodes: [ITEM_TYPES.EPISODE],
} as const;
+31
View File
@@ -0,0 +1,31 @@
// Presentation helpers for the neutral MediaKind.
//
// MediaKind is the app's provider-neutral item classification (defined in Rust,
// generated into bindings). This module holds *display* concerns over it —
// human-readable labels — which are presentation, not domain, and so live in
// the frontend.
import type { MediaKind } from "$lib/api/types";
const KIND_LABELS: Record<MediaKind, string> = {
track: "Song",
album: "Album",
artist: "Artist",
playlist: "Playlist",
movie: "Movie",
series: "Series",
season: "Season",
episode: "Episode",
person: "Person",
channel: "Channel",
liveChannel: "Live TV",
channelItem: "Channel",
folder: "Folder",
other: "",
};
/** Human-readable label for a media kind (e.g. "Album"), or "" if unknown. */
export function kindLabel(kind: MediaKind | null | undefined): string {
if (!kind) return "";
return KIND_LABELS[kind] ?? "";
}
+13 -11
View File
@@ -1,29 +1,31 @@
/**
* Playback unit conversion utilities
* Playback unit utilities.
*
* Jellyfin uses "ticks" for time values where 10 million ticks = 1 second.
* This module provides type-safe conversion functions to eliminate magic numbers
* and prevent conversion bugs across the codebase.
* The app's own media model speaks milliseconds (see the domain migration in
* docs/specs/frontend-domain-model.md), so catalog code does NOT use the tick
* helpers here. Ticks survive only at the **remote Jellyfin session boundary**
* `SessionInfo.playState.positionTicks` and `NowPlayingItem.runTimeTicks` arrive
* straight from a live Jellyfin session and are converted here for display.
* `formatTime`/`calculateProgress` are plain seconds-based presentation helpers.
*/
/**
* Number of Jellyfin ticks per second (10 million)
* Number of Jellyfin ticks per second (10 million).
*
* Only for the remote-session boundary (see module doc); catalog durations are
* milliseconds and never touch this.
*/
export const TICKS_PER_SECOND = 10_000_000;
/**
* Convert seconds to Jellyfin ticks
* @param seconds - Time in seconds (e.g., 90.5 for 1 minute 30.5 seconds)
* @returns Time in Jellyfin ticks
* Convert seconds to Jellyfin ticks. Remote-session boundary only.
*/
export function secondsToTicks(seconds: number): number {
return Math.floor(seconds * TICKS_PER_SECOND);
}
/**
* Convert Jellyfin ticks to seconds
* @param ticks - Time in Jellyfin ticks
* @returns Time in seconds
* Convert Jellyfin session ticks to seconds. Remote-session boundary only.
*/
export function ticksToSeconds(ticks: number | null | undefined): number {
return (ticks ?? 0) / TICKS_PER_SECOND;
+8 -9
View File
@@ -57,14 +57,13 @@
});
function handleItemClick(item: MediaItem) {
switch (item.type) {
case "Series":
case "Season":
case "MusicAlbum":
case "MusicArtist":
case "Folder":
case "Channel":
case "ChannelFolderItem":
switch (item.kind) {
case "series":
case "season":
case "album":
case "artist":
case "folder":
case "channel":
goto(`/library/${item.id}`);
break;
default:
@@ -100,7 +99,7 @@
const heroItems = $derived($home.heroItems);
const resumeItems = $derived($home.resumeItems.filter(
i => i.type === "Movie" || i.type === "Episode"
i => i.kind === "movie" || i.kind === "episode"
));
const nextUpItems = $derived($home.nextUpItems);
const latestItems = $derived($home.latestItems);
+13 -16
View File
@@ -127,30 +127,27 @@
// Prevent accidental taps during scrolling (Android)
if (scrollGuard.isScrollActive()) return;
if ("type" in item) {
if ("kind" in item) {
// It's a MediaItem
const mediaItem = item as MediaItem;
// A ChannelFolderItem can be a folder (drill in) or a playable leaf.
if (mediaItem.type === "ChannelFolderItem" && !mediaItem.isFolder) {
// A playable channel leaf plays directly; a channel container drills in.
if (mediaItem.kind === "channelItem") {
goto(`/player/${mediaItem.id}`);
return;
}
switch (mediaItem.type) {
case "Series":
case "Movie":
case "MusicAlbum":
case "MusicArtist":
case "Folder":
case "CollectionFolder":
case "Playlist":
case "Channel":
case "ChannelFolderItem":
switch (mediaItem.kind) {
case "series":
case "movie":
case "album":
case "artist":
case "folder":
case "playlist":
case "channel":
// Navigate to detail view
goto(`/library/${mediaItem.id}`);
break;
case "Episode":
case "TvChannel":
// Episodes and live TV channels play directly
case "episode":
// Episodes play directly
goto(`/player/${mediaItem.id}`);
break;
default:
+54 -54
View File
@@ -4,6 +4,7 @@
import { page } from "$app/stores";
import { goto } from "$app/navigation";
import { navigateBack } from "$lib/utils/navigation";
import { kindLabel } from "$lib/utils/mediaKind";
import { commands } from "$lib/api/bindings";
import type { MediaItem, Library } from "$lib/api/types";
import { library, libraryItems, isLibraryLoading, currentLibrary, libraries } from "$lib/stores/library";
@@ -84,7 +85,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) => {
@@ -94,7 +95,7 @@
// Set currentLibrary for music items if not already set
// This ensures navigation to music library pages works correctly
if ((item?.type === "MusicAlbum" || item?.type === "MusicArtist" || item?.type === "Audio") && !$currentLibrary) {
if ((item?.kind === "album" || item?.kind === "artist" || item?.kind === "track") && !$currentLibrary) {
// Find the music library
if ($libraries.length === 0) {
await library.loadLibraries();
@@ -110,8 +111,8 @@
// Ensure cast/crew data is loaded for Movies, Series, and Episodes
// Some APIs/caches may not include people data on first load
if ((item?.type === "Movie" || item?.type === "Series" || item?.type === "Episode") && (!item.people || item.people.length === 0)) {
console.log(`[LibraryDetail] ⚠ People data missing, reloading ${item?.type}...`);
if ((item?.kind === "movie" || item?.kind === "series" || item?.kind === "episode") && (!item.people || item.people.length === 0)) {
console.log(`[LibraryDetail] ⚠ People data missing, reloading ${item?.kind}...`);
try {
const repo = auth.getRepository();
const fullItem = await repo.getItem(itemId);
@@ -124,20 +125,20 @@
});
}
} catch (e) {
console.warn(`Could not reload ${item?.type} with full cast data:`, e);
console.warn(`Could not reload ${item?.kind} with full cast data:`, e);
}
}
// For Series, load seasons and their episodes
if (item?.type === "Series") {
const seasons = $libraryItems.filter((i) => i.type === "Season");
if (item?.kind === "series") {
const seasons = $libraryItems.filter((i) => i.kind === "season");
const repo = auth.getRepository();
// Load episodes for each season in parallel
const seasonDataPromises = seasons.map(async (season) => {
const result = await repo.getItems(season.id, { limit: 100 });
const episodes = result.items
.filter((i) => i.type === "Episode")
.filter((i) => i.kind === "episode")
.sort((a, b) => (a.indexNumber || 0) - (b.indexNumber || 0));
return { season, episodes };
});
@@ -170,9 +171,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,28 +184,27 @@
}
function handleItemClick(clickedItem: MediaItem | Library) {
if (!("type" in clickedItem)) {
if (!("kind" in clickedItem)) {
// Library item - navigate to library
goto(`/library/${clickedItem.id}`);
return;
}
// A ChannelFolderItem can be either a folder (drill in) or a playable leaf.
// Route non-folder channel items straight to the player.
if (clickedItem.type === "ChannelFolderItem" && !clickedItem.isFolder) {
if (clickedItem.kind === "channelItem" || clickedItem.kind === "liveChannel") {
goto(`/player/${clickedItem.id}`);
return;
}
switch (clickedItem.type) {
case "Series":
case "Season":
case "MusicAlbum":
case "MusicArtist":
case "Folder":
case "Playlist":
case "Channel":
case "ChannelFolderItem":
case "Episode":
case "Movie":
switch (clickedItem.kind) {
case "series":
case "season":
case "album":
case "artist":
case "folder":
case "playlist":
case "channel":
case "episode":
case "movie":
goto(`/library/${clickedItem.id}`);
break;
default:
@@ -223,9 +223,9 @@
async function handlePlayAll() {
// For single items (Episode, Movie), play the item directly
if (item?.type === "Episode" || item?.type === "Movie") {
if (item?.kind === "episode" || item?.kind === "movie") {
goto(`/player/${itemId}`);
} else if (item?.type === "MusicAlbum" && $libraryItems.length > 0) {
} else if (item?.kind === "album" && $libraryItems.length > 0) {
// For albums, use the backend command (backend fetches and queues all tracks)
try {
const repo = auth.getRepository();
@@ -248,7 +248,7 @@
}
async function handleShufflePlay() {
if (item?.type === "MusicAlbum" && $libraryItems.length > 0) {
if (item?.kind === "album" && $libraryItems.length > 0) {
// For albums, use the backend command with shuffle
try {
const repo = auth.getRepository();
@@ -284,7 +284,7 @@
);
const isMusicItem = $derived(
item?.type === "Audio" || item?.type === "MusicAlbum" || item?.type === "MusicArtist" || item?.type === "Playlist"
item?.kind === "track" || item?.kind === "album" || item?.kind === "artist" || item?.kind === "playlist"
);
function handleBackToSeries() {
@@ -331,13 +331,13 @@
</div>
{:else if item}
<!-- Person Detail View - shown for Person items -->
{#if item.type === "Person"}
{#if item.kind === "person"}
<div class="pt-4">
<BackButton onClick={goBack} label="Back" />
</div>
<PersonDetailView person={item} />
<!-- Episode Focus View - shown when navigating with ?episode param -->
{:else if item.type === "Series" && focusedEpisode}
{:else if item.kind === "series" && focusedEpisode}
<EpisodeFocusView
episode={focusedEpisode}
series={item}
@@ -355,11 +355,11 @@
<div class="flex gap-6">
<!-- Poster -->
<div class="flex-shrink-0 w-48">
{#if item.primaryImageTag}
{#if item.imageId}
<CachedImage
itemId={item.id}
imageType="Primary"
tag={item.primaryImageTag}
tag={item.imageId}
maxWidth={400}
alt={item.name}
class="w-full {isMusicItem ? 'aspect-square' : ''} rounded-lg shadow-lg"
@@ -381,7 +381,7 @@
<div class="flex-1 space-y-4">
<div>
<h1 class="text-3xl font-bold text-white">{item.name}</h1>
{#if item.type === "Episode" && (item.parentIndexNumber || item.indexNumber)}
{#if item.kind === "episode" && (item.parentIndexNumber || item.indexNumber)}
<p class="text-lg text-gray-400 mt-1">
{#if item.parentIndexNumber}Season {item.parentIndexNumber}{/if}
{#if item.parentIndexNumber && item.indexNumber}, {/if}
@@ -403,11 +403,11 @@
<!-- Metadata -->
<div class="flex items-center gap-4 text-sm text-gray-400">
{#if item.type}
<span class="px-2 py-1 bg-[var(--color-surface)] rounded">{item.type}</span>
{#if kindLabel(item.kind)}
<span class="px-2 py-1 bg-[var(--color-surface)] rounded">{kindLabel(item.kind)}</span>
{/if}
{#if item.runTimeTicks}
<span>{formatDuration(item.runTimeTicks)}</span>
{#if item.durationMs}
<span>{formatDuration(item.durationMs)}</span>
{/if}
{#if item.communityRating}
<span class="flex items-center gap-1">
@@ -430,7 +430,7 @@
</svg>
Play
</button>
{#if item.type !== "Episode" && item.type !== "Movie"}
{#if item.kind !== "episode" && item.kind !== "movie"}
<button
onclick={handleShufflePlay}
class="px-6 py-2 bg-[var(--color-surface)] hover:bg-[var(--color-surface-hover)] rounded-lg font-medium flex items-center gap-2 transition-colors"
@@ -441,26 +441,26 @@
Shuffle
</button>
{/if}
{#if item.type === "MusicAlbum"}
{#if item.kind === "album"}
<AlbumDownloadButton
albumId={item.id}
albumName={item.name}
tracks={$libraryItems}
/>
{:else if item.type === "Series"}
{:else if item.kind === "series"}
<SeriesDownloadButton
seriesId={item.id}
seriesName={item.name}
episodeCount={allEpisodes.length || undefined}
/>
{:else if item.type === "Movie"}
{:else if item.kind === "movie"}
<VideoDownloadButton
itemId={item.id}
itemName={item.name}
isMovie={true}
size="lg"
/>
{:else if item.type === "Episode"}
{:else if item.kind === "episode"}
<VideoDownloadButton
itemId={item.id}
itemName={item.name}
@@ -478,7 +478,7 @@
</div>
<!-- Crew Links - for Movies and Series -->
{#if item.people && (item.type === "Movie" || item.type === "Series")}
{#if item.people && (item.kind === "movie" || item.kind === "series")}
<div class="space-y-2">
{#if item.people.some(p => p.type === "Director")}
<CrewLinks
@@ -512,24 +512,24 @@
<!-- Genre Tags -->
{#if item.genres?.length}
<div>
<GenreTags genres={item.genres ?? undefined} maxShow={6} itemType={item.type} />
<GenreTags genres={item.genres ?? undefined} maxShow={6} itemKind={item.kind} />
</div>
{/if}
<!-- Cast / Related — for Movies and Episodes these sit above the content
block; for Series they render *below* the seasons instead, so
continuation content precedes discovery content (UX §5B.4). -->
{#if item.type !== "Series"}
{#if item.kind !== "series"}
<!-- Cast Section - for Movies and Episodes -->
{#if (item.type === "Movie" || item.type === "Episode") && item.people?.length}
{#if (item.kind === "movie" || item.kind === "episode") && item.people?.length}
<CastSection people={item.people ?? undefined} />
{/if}
<!-- Related Items Section - for Movies -->
{#if item.type === "Movie" && (item.genres?.length || item.people?.length)}
{#if item.kind === "movie" && (item.genres?.length || item.people?.length)}
<RelatedItemsSection
currentItemId={item.id}
itemType={item.type}
itemKind={item.kind}
genres={item.genres ?? undefined}
people={item.people ?? undefined}
limit={12}
@@ -539,7 +539,7 @@
<!-- Content items -->
<div>
{#if item.type === "MusicAlbum"}
{#if item.kind === "album"}
<!-- Tracks in list view -->
<div class="space-y-8">
<div class="space-y-4">
@@ -558,14 +558,14 @@
{#if item.genres?.length || item.artistItems?.length}
<RelatedItemsSection
currentItemId={item.id}
itemType="MusicAlbum"
itemKind="album"
genres={item.genres ?? undefined}
artistIds={item.artistItems?.map(a => a.id)}
limit={12}
/>
{/if}
</div>
{:else if item.type === "Series"}
{:else if item.kind === "series"}
<!-- Series: Seasons with episodes -->
<div class="space-y-8">
{#if $isLibraryLoading}
@@ -595,17 +595,17 @@
{#if item.genres?.length || item.people?.length}
<RelatedItemsSection
currentItemId={item.id}
itemType={item.type}
itemKind={item.kind}
genres={item.genres ?? undefined}
people={item.people ?? undefined}
limit={12}
/>
{/if}
</div>
{:else if item.type === "MusicArtist"}
{:else if item.kind === "artist"}
<!-- Enhanced artist detail view with discography -->
<ArtistDetailView artist={item} />
{:else if item.type === "Playlist"}
{:else if item.kind === "playlist"}
<!-- Playlist detail view with track management -->
<PlaylistDetailView playlist={item} />
{:else}
+1 -1
View File
@@ -56,7 +56,7 @@
});
function handleItemClick(item: MediaItem) {
if (item.type === "Folder") {
if (item.kind === "folder") {
goto(`/library/${item.id}`);
} else {
// Movies play directly.
+24 -25
View File
@@ -5,7 +5,7 @@
import { convertFileSrc } from "@tauri-apps/api/core";
import { commands } from "$lib/api/bindings";
import type { PlayQueueRequest } from "$lib/api/bindings";
import type { MediaItem } from "$lib/api/types";
import type { MediaItem, MediaKind } from "$lib/api/types";
import { auth } from "$lib/stores/auth";
import { library } from "$lib/stores/library";
import { queue, currentQueueItem, isShuffle, repeatMode, hasNext as hasNextStore, hasPrevious as hasPreviousStore } from "$lib/stores/queue";
@@ -100,7 +100,7 @@
// Only for audio content - video content uses direct loading and shouldn't be affected by audio queue
$effect(() => {
const queueItem = $currentQueueItem;
const currentIsVideo = currentMedia?.type === "Movie" || currentMedia?.type === "Episode";
const currentIsVideo = currentMedia?.kind === "movie" || currentMedia?.kind === "episode";
if (queueItem && queueItem.id !== currentMedia?.id && !currentIsVideo) {
currentMedia = queueItem;
}
@@ -110,8 +110,8 @@
// treat it as video when it carries a video media stream.
function isVideoChannelItem(item: MediaItem): boolean {
return (
item.type === "ChannelFolderItem" &&
(item.mediaStreams?.some((s) => s.type === "Video") ?? false)
item.kind === "channelItem" &&
(item.mediaStreams?.some((s) => s.kind === "video") ?? false)
);
}
@@ -125,14 +125,13 @@
console.log("loadAndPlay: Loading item", id);
// Load item details
const item = await library.loadItem(id);
console.log("loadAndPlay: Loaded item", item.name, "type:", item.type);
console.log("loadAndPlay: Loaded item", item.name, "kind:", item.kind);
currentMedia = item;
// Check if this is a non-playable collection type that should be viewed in library instead
const collectionTypes = ["MusicAlbum", "MusicArtist", "Series", "Season", "Folder", "CollectionFolder", "Playlist", "Channel"];
// A ChannelFolderItem that is itself a folder is a container, not playable.
if (collectionTypes.includes(item.type) || (item.type === "ChannelFolderItem" && item.isFolder)) {
console.log("loadAndPlay: Redirecting collection type to library:", item.type);
const collectionKinds: MediaKind[] = ["album", "artist", "series", "season", "folder", "playlist", "channel"];
if (item.kind && collectionKinds.includes(item.kind)) {
console.log("loadAndPlay: Redirecting collection type to library:", item.kind);
goto(`/library/${id}`);
return;
}
@@ -144,8 +143,8 @@
const alreadyPlayingMedia = get(storeCurrentMedia);
if (alreadyPlayingMedia?.id === id && !startPosition && !forceRestart) {
console.log("loadAndPlay: Track already playing, showing UI without restarting");
isLive = item.type === "TvChannel";
isVideo = item.type === "Movie" || item.type === "Episode" || isLive || isVideoChannelItem(item);
isLive = item.kind === "liveChannel";
isVideo = item.kind === "movie" || item.kind === "episode" || isLive || isVideoChannelItem(item);
isPlaying = true;
loading = false;
// hasNext/hasPrevious come from the event-driven queue store.
@@ -158,8 +157,8 @@
// Determine if this is video content (Movie, Episode, live TV channels, and
// channel leaf items that carry a video stream).
isLive = item.type === "TvChannel";
isVideo = item.type === "Movie" || item.type === "Episode" || isLive || isVideoChannelItem(item);
isLive = item.kind === "liveChannel";
isVideo = item.kind === "movie" || item.kind === "episode" || isLive || isVideoChannelItem(item);
// When switching to video, stop audio playback and clear the queue
// This prevents audio from continuing in the background and clears stale state
@@ -185,9 +184,9 @@
const progress = await commands.storageGetPlaybackProgress(userId, id);
console.log("Resume check - retrieved progress:", progress);
if (progress && progress.positionTicks > 0 && item.runTimeTicks) {
const positionSeconds = progress.positionTicks / 10_000_000;
const totalSeconds = item.runTimeTicks / 10_000_000;
if (progress && progress.positionMs > 0 && item.durationMs) {
const positionSeconds = progress.positionMs / 1000;
const totalSeconds = item.durationMs / 1000;
const progressPercent = (positionSeconds / totalSeconds) * 100;
console.log("Resume check - positionSeconds:", positionSeconds, "totalSeconds:", totalSeconds, "progressPercent:", progressPercent);
@@ -206,7 +205,7 @@
console.log("Resume check - NOT showing dialog. Position > 30?", positionSeconds > 30, "Progress < 90?", progressPercent < 90);
}
} else {
console.log("Resume check - No valid progress found. Has progress?", !!progress, "Has position?", progress?.positionTicks, "Has runtime?", !!item.runTimeTicks);
console.log("Resume check - No valid progress found. Has progress?", !!progress, "Has position?", progress?.positionMs, "Has runtime?", !!item.durationMs);
}
} catch (e) {
console.error("Failed to check saved progress:", e);
@@ -330,7 +329,7 @@
sortOrder: "Ascending",
limit: 500,
});
const audioTracks = result.items.filter(t => t.type === "Audio");
const audioTracks = result.items.filter(t => t.kind === "track");
if (audioTracks.length > 0) {
// Find the index of the current item in the tracks
@@ -354,9 +353,9 @@
title: t.name,
artist: t.artists?.join(", ") || null,
album: t.albumName || null,
duration: t.runTimeTicks ? t.runTimeTicks / 10000000 : null,
artworkUrl: t.primaryImageTag
? repo.getImageUrl(t.albumId || t.id, "Primary", { maxWidth: 300, tag: t.primaryImageTag })
duration: t.durationMs ? t.durationMs / 1000 : null,
artworkUrl: t.imageId
? repo.getImageUrl(t.albumId || t.id, "Primary", { maxWidth: 300, tag: t.imageId })
: null,
mediaType: "audio",
streamUrl: trackStreamUrl,
@@ -428,7 +427,7 @@
loading = false;
// Fetch next episode for video episodes (for skip button)
console.log("[NextEpisode] Post-load check: isVideo=", isVideo, "currentMedia=", currentMedia?.type, currentMedia?.name);
console.log("[NextEpisode] Post-load check: isVideo=", isVideo, "currentMedia=", currentMedia?.kind, currentMedia?.name);
if (isVideo && currentMedia) {
fetchNextEpisode(currentMedia);
} else {
@@ -566,8 +565,8 @@
async function fetchNextEpisode(media: MediaItem) {
nextEpisode = null;
console.log("[NextEpisode] fetchNextEpisode called:", { type: media.type, seriesId: media.seriesId, seasonId: media.seasonId, indexNumber: media.indexNumber, id: media.id, name: media.name });
if (media.type !== "Episode" || !media.seasonId || media.indexNumber == null) {
console.log("[NextEpisode] fetchNextEpisode called:", { kind: media.kind, seriesId: media.seriesId, seasonId: media.seasonId, indexNumber: media.indexNumber, id: media.id, name: media.name });
if (media.kind !== "episode" || !media.seasonId || media.indexNumber == null) {
console.log("[NextEpisode] Skipping - not an episode or missing seasonId/indexNumber");
return;
}
@@ -575,7 +574,7 @@
const repo = auth.getRepository();
// Fetch all episodes in the season sorted by episode number
const result = await repo.getItems(media.seasonId, { sortBy: "IndexNumber", sortOrder: "Ascending", limit: 500 });
const episodes = result.items.filter(e => e.type === "Episode");
const episodes = result.items.filter(e => e.kind === "episode");
console.log("[NextEpisode] Season has", episodes.length, "episodes, current index:", media.indexNumber);
// Find the episode after the current one by index number