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
🏗️ 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:
+174
-19
@@ -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
|
||||
*/
|
||||
|
||||
@@ -470,7 +470,7 @@ describe("RepositoryClient", () => {
|
||||
expect(invoke).toHaveBeenCalledWith("repository_report_playback_progress", {
|
||||
handle: "test-handle-123",
|
||||
itemId: "item123",
|
||||
positionTicks: 5000000,
|
||||
positionMs: 5000000,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) =====
|
||||
|
||||
@@ -14,6 +14,7 @@ export type {
|
||||
Library,
|
||||
LiveStreamInfo,
|
||||
MediaItem,
|
||||
MediaKind,
|
||||
MediaSource,
|
||||
MediaStream,
|
||||
Person,
|
||||
|
||||
@@ -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'}"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 => ({
|
||||
|
||||
@@ -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 => ({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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] ?? "";
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
});
|
||||
|
||||
function handleItemClick(item: MediaItem) {
|
||||
if (item.type === "Folder") {
|
||||
if (item.kind === "folder") {
|
||||
goto(`/library/${item.id}`);
|
||||
} else {
|
||||
// Movies play directly.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user