From 93d198ce212a1a69c2924a306ac6e763d25dfea8 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Thu, 23 Jul 2026 21:40:58 +0200 Subject: [PATCH] domain: primaryImageTag -> imageId end-to-end (phase 4a/4b) Rust: PlayerMediaItem and MergedMediaItem gain image_id (dual-carry), populated from primary_image_tag at every construction/conversion site. Regenerated bindings. Frontend: all catalog + player + merged readers now use imageId. The NowPlayingItem->MediaItem bridge (player.ts) properly maps the remote session's Jellyfin fields (Type, runTimeTicks, primaryImageTag) onto the neutral kind/durationMs/imageId. Types that are genuinely out of scope (Person, NowPlayingItem, PlayItemRequest) keep primaryImageTag. Rust 456, frontend 644, check clean. --- src-tauri/src/commands/player/mod.rs | 17 +++++++++++++---- src-tauri/src/commands/player/queue.rs | 2 ++ src-tauri/src/domain/from_jellyfin.rs | 5 ++++- src-tauri/src/playback_mode/mod.rs | 2 ++ src-tauri/src/player/backend.rs | 3 +++ src-tauri/src/player/media.rs | 15 ++++++++++++++- src-tauri/src/player/mod.rs | 1 + src-tauri/src/player/queue.rs | 1 + src-tauri/src/player/session.rs | 2 ++ src-tauri/src/player/state.rs | 1 + src-tauri/src/repository/offline.rs | 3 +-- src/lib/api/bindings.ts | 16 ++++++++++++++-- .../components/library/ArtistDetailView.svelte | 12 ++++++------ .../components/library/EpisodeFocusView.svelte | 6 +++--- src/lib/components/library/EpisodeRow.svelte | 2 +- .../library/GenericGenreBrowser.svelte | 2 +- .../components/library/LibraryListView.svelte | 2 +- src/lib/components/library/MediaCard.svelte | 2 +- .../components/library/PersonDetailView.svelte | 2 +- .../library/PlaylistDetailView.svelte | 4 ++-- .../components/library/SeasonSection.svelte | 2 +- src/lib/components/player/AudioPlayer.svelte | 4 ++-- src/lib/components/player/MiniPlayer.svelte | 2 +- .../components/player/NextEpisodePopup.svelte | 4 ++-- src/lib/components/player/Queue.svelte | 4 ++-- src/lib/components/player/VideoPlayer.svelte | 6 +++--- .../playlist/AddToPlaylistModal.svelte | 4 ++-- src/lib/stores/movies.ts | 2 +- src/lib/stores/music.ts | 2 +- src/lib/stores/player.ts | 18 +++++++++++++----- src/lib/stores/tv.ts | 2 +- src/routes/library/[id]/+page.svelte | 4 ++-- src/routes/player/[id]/+page.svelte | 4 ++-- 33 files changed, 107 insertions(+), 51 deletions(-) diff --git a/src-tauri/src/commands/player/mod.rs b/src-tauri/src/commands/player/mod.rs index d4f93b89..a3c39100 100644 --- a/src-tauri/src/commands/player/mod.rs +++ b/src-tauri/src/commands/player/mod.rs @@ -106,6 +106,8 @@ pub struct MergedMediaItem { pub album_id: Option, pub duration: Option, pub primary_image_tag: Option, + /// Neutral image identifier — replaces `primary_image_tag` (same value). + pub image_id: Option, pub media_type: String, } @@ -120,6 +122,7 @@ impl From<&crate::player::MediaItem> for MergedMediaItem { album_id: item.album_id.clone(), duration: item.duration, primary_image_tag: item.primary_image_tag.clone(), + image_id: item.primary_image_tag.clone(), media_type: match item.media_type { crate::player::MediaType::Audio => "audio".to_string(), crate::player::MediaType::Video => "video".to_string(), @@ -142,6 +145,7 @@ impl From<&crate::jellyfin::client::NowPlayingItem> for MergedMediaItem { album_id: item.album_id.clone(), duration: item.run_time_ticks.map(|ticks| ticks as f64 / 10_000_000.0), primary_image_tag: item.primary_image_tag.clone(), + image_id: item.primary_image_tag.clone(), media_type: item .item_type .clone() @@ -361,10 +365,11 @@ pub(super) async fn create_media_item( artist_items: None, // Not available from video-only request artists: None, // Not available from video-only request primary_image_tag: None, // Not available from video-only request - item_type: None, // Not available from video-only request - playlist_id: None, // Not available from video-only request - duration: None, // Not available from video-only request - artwork_url: None, // Not available from video-only request + image_id: None, + item_type: None, // Not available from video-only request + playlist_id: None, // Not available from video-only request + duration: None, // Not available from video-only request + artwork_url: None, // Not available from video-only request media_type: crate::player::MediaType::Video, // Video-only request source, video_codec: Some(req.video_codec), @@ -595,6 +600,7 @@ pub async fn player_enter_background_audio( artist_items: None, artists: None, primary_image_tag: item.primary_image_tag.clone(), + image_id: item.primary_image_tag.clone(), item_type: None, playlist_id: None, // Carry the real duration so the lockscreen MediaSession can draw a scrubber. @@ -1771,6 +1777,7 @@ pub async fn player_play_album_track( artist_items: track.artist_items.clone(), // For clickable artist links artists: track.artists.clone(), // Fallback artist info primary_image_tag: track.primary_image_tag.clone(), // For frontend image display + image_id: track.primary_image_tag.clone(), item_type: Some(track.item_type.clone()), // Frontend compatibility playlist_id: None, duration: track.runtime_ticks.map(|t| t as f64 / 10_000_000.0), @@ -1966,6 +1973,7 @@ pub async fn player_play_tracks( artist_items: track.artist_items.clone(), // For clickable artist links artists: track.artists.clone(), // Fallback artist info primary_image_tag: track.primary_image_tag.clone(), // For frontend image display + image_id: track.primary_image_tag.clone(), item_type: Some(track.item_type.clone()), // Frontend compatibility playlist_id: None, // Set based on context below duration: track.runtime_ticks.map(|t| t as f64 / 10_000_000.0), @@ -2428,6 +2436,7 @@ mod tests { artist_items: None, artists: None, primary_image_tag: None, + image_id: None, item_type: None, playlist_id: None, duration: None, diff --git a/src-tauri/src/commands/player/queue.rs b/src-tauri/src/commands/player/queue.rs index b479620b..93a1f5f5 100644 --- a/src-tauri/src/commands/player/queue.rs +++ b/src-tauri/src/commands/player/queue.rs @@ -211,6 +211,7 @@ pub async fn player_add_track_by_id( artist_items: track.artist_items.clone(), // For clickable artist links artists: track.artists.clone(), // Fallback artist info primary_image_tag: track.primary_image_tag.clone(), // For frontend image display + image_id: track.primary_image_tag.clone(), item_type: Some(track.item_type.clone()), // Frontend compatibility playlist_id: None, duration: track.runtime_ticks.map(|t| t as f64 / 10_000_000.0), @@ -329,6 +330,7 @@ pub async fn player_add_tracks_by_ids( artist_items: track.artist_items.clone(), // For clickable artist links artists: track.artists.clone(), // Fallback artist info primary_image_tag: track.primary_image_tag.clone(), // For frontend image display + image_id: track.primary_image_tag.clone(), item_type: Some(track.item_type.clone()), // Frontend compatibility playlist_id: None, duration: track.runtime_ticks.map(|t| t as f64 / 10_000_000.0), diff --git a/src-tauri/src/domain/from_jellyfin.rs b/src-tauri/src/domain/from_jellyfin.rs index b97a05dc..37b53ab2 100644 --- a/src-tauri/src/domain/from_jellyfin.rs +++ b/src-tauri/src/domain/from_jellyfin.rs @@ -119,7 +119,10 @@ mod tests { #[test] fn channel_and_container_types_map() { - assert_eq!(kind_from_jellyfin("TvChannel", false), MediaKind::LiveChannel); + assert_eq!( + kind_from_jellyfin("TvChannel", false), + MediaKind::LiveChannel + ); assert_eq!(kind_from_jellyfin("Channel", false), MediaKind::Channel); assert_eq!( kind_from_jellyfin("CollectionFolder", true), diff --git a/src-tauri/src/playback_mode/mod.rs b/src-tauri/src/playback_mode/mod.rs index 59523ef5..e15fb947 100644 --- a/src-tauri/src/playback_mode/mod.rs +++ b/src-tauri/src/playback_mode/mod.rs @@ -948,6 +948,7 @@ mod tests { artist_items: None, artists: Some(vec!["Test Artist".to_string()]), primary_image_tag: None, + image_id: None, item_type: Some("Audio".to_string()), playlist_id: None, duration: Some(180.0), @@ -979,6 +980,7 @@ mod tests { artist_items: None, artists: Some(vec!["Test Artist".to_string()]), primary_image_tag: None, + image_id: None, item_type: Some("Audio".to_string()), playlist_id: None, duration: Some(180.0), diff --git a/src-tauri/src/player/backend.rs b/src-tauri/src/player/backend.rs index a3874b7e..b1280f47 100644 --- a/src-tauri/src/player/backend.rs +++ b/src-tauri/src/player/backend.rs @@ -380,6 +380,7 @@ mod tests { artist_items: None, artists: Some(vec!["Test Artist".to_string()]), primary_image_tag: None, + image_id: None, item_type: Some("Audio".to_string()), playlist_id: None, duration: Some(180.0), @@ -438,6 +439,7 @@ mod tests { artist_items: None, artists: Some(vec!["Test Artist".to_string()]), primary_image_tag: None, + image_id: None, item_type: Some("Audio".to_string()), playlist_id: None, duration: Some(180.0), @@ -490,6 +492,7 @@ mod tests { artist_items: None, artists: Some(vec!["Test Artist".to_string()]), primary_image_tag: None, + image_id: None, item_type: Some("Audio".to_string()), playlist_id: None, duration: Some(180.0), diff --git a/src-tauri/src/player/media.rs b/src-tauri/src/player/media.rs index e24863ff..7bf70ecc 100644 --- a/src-tauri/src/player/media.rs +++ b/src-tauri/src/player/media.rs @@ -67,9 +67,16 @@ pub struct MediaItem { /// Artists as array of strings (fallback when artist_items not available) #[serde(default, skip_serializing_if = "Option::is_none")] pub artists: Option>, - /// 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). #[serde(default, skip_serializing_if = "Option::is_none")] pub primary_image_tag: Option, + /// Neutral image identifier the frontend resolves to a URL — replaces + /// `primary_image_tag`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub image_id: Option, /// Item type (Audio, Movie, Episode, etc.) #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")] pub item_type: Option, @@ -345,6 +352,7 @@ mod tests { artist_items: None, artists: None, primary_image_tag: None, + image_id: None, item_type: None, playlist_id: None, duration: None, @@ -380,6 +388,7 @@ mod tests { artist_items: None, artists: None, primary_image_tag: None, + image_id: None, item_type: None, playlist_id: None, duration: None, @@ -414,6 +423,7 @@ mod tests { artist_items: None, artists: None, primary_image_tag: None, + image_id: None, item_type: None, playlist_id: None, duration: None, @@ -448,6 +458,7 @@ mod tests { artist_items: None, artists: None, primary_image_tag: None, + image_id: None, item_type: None, playlist_id: None, duration: None, @@ -489,6 +500,7 @@ mod tests { artist_items: None, artists: None, primary_image_tag: None, + image_id: None, item_type: None, playlist_id: None, duration: None, @@ -523,6 +535,7 @@ mod tests { artist_items: None, artists: None, primary_image_tag: None, + image_id: None, item_type: Some("Movie".to_string()), playlist_id: None, duration: Some(120.0), diff --git a/src-tauri/src/player/mod.rs b/src-tauri/src/player/mod.rs index d81f2c2c..7ece882d 100644 --- a/src-tauri/src/player/mod.rs +++ b/src-tauri/src/player/mod.rs @@ -1397,6 +1397,7 @@ mod tests { artist_items: None, artists: Some(vec!["Test Artist".to_string()]), primary_image_tag: None, + image_id: None, item_type: Some("Audio".to_string()), playlist_id: None, duration: Some(180.0), diff --git a/src-tauri/src/player/queue.rs b/src-tauri/src/player/queue.rs index 60800b18..335c847f 100644 --- a/src-tauri/src/player/queue.rs +++ b/src-tauri/src/player/queue.rs @@ -551,6 +551,7 @@ mod tests { artist_items: None, artists: Some(vec!["Artist".to_string()]), primary_image_tag: None, + image_id: None, item_type: Some("Audio".to_string()), playlist_id: None, duration: Some(180.0), diff --git a/src-tauri/src/player/session.rs b/src-tauri/src/player/session.rs index 4713e65d..d8b78fd7 100644 --- a/src-tauri/src/player/session.rs +++ b/src-tauri/src/player/session.rs @@ -242,6 +242,7 @@ mod tests { artist_items: None, artists: Some(vec!["Test Artist".to_string()]), primary_image_tag: None, + image_id: None, item_type: Some("Audio".to_string()), playlist_id: None, duration: Some(180.0), @@ -272,6 +273,7 @@ mod tests { artist_items: None, artists: None, primary_image_tag: None, + image_id: None, item_type: Some("Movie".to_string()), playlist_id: None, duration: Some(7200.0), diff --git a/src-tauri/src/player/state.rs b/src-tauri/src/player/state.rs index 7e2a60d4..9eb61a0c 100644 --- a/src-tauri/src/player/state.rs +++ b/src-tauri/src/player/state.rs @@ -323,6 +323,7 @@ mod tests { artist_items: None, artists: None, primary_image_tag: None, + image_id: None, item_type: Some("Video".to_string()), playlist_id: None, duration: Some(100.0), diff --git a/src-tauri/src/repository/offline.rs b/src-tauri/src/repository/offline.rs index 63eb100e..9f0a6b0c 100644 --- a/src-tauri/src/repository/offline.rs +++ b/src-tauri/src/repository/offline.rs @@ -128,8 +128,7 @@ impl OfflineRepository { let playback_position_ticks: Option = row.get(0).ok(); Ok(UserData { playback_position_ticks, - playback_position_ms: playback_position_ticks - .map(crate::domain::ticks_to_ms), + playback_position_ms: playback_position_ticks.map(crate::domain::ticks_to_ms), is_played: row.get::<_, Option>(1).ok().flatten().map(|v| v != 0), is_favorite: row.get::<_, Option>(2).ok().flatten().map(|v| v != 0), play_count: row.get(3).ok(), diff --git a/src/lib/api/bindings.ts b/src/lib/api/bindings.ts index 2949f456..45e5d5de 100644 --- a/src/lib/api/bindings.ts +++ b/src/lib/api/bindings.ts @@ -1918,7 +1918,11 @@ 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`]. * @@ -2110,9 +2114,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.) */ diff --git a/src/lib/components/library/ArtistDetailView.svelte b/src/lib/components/library/ArtistDetailView.svelte index 02bf9ea4..1cf7bce6 100644 --- a/src/lib/components/library/ArtistDetailView.svelte +++ b/src/lib/components/library/ArtistDetailView.svelte @@ -112,12 +112,12 @@
- {#if artist.primaryImageTag} + {#if artist.imageId}
- {#if album.primaryImageTag} + {#if album.imageId}
- {#if relatedArtist.primaryImageTag} + {#if relatedArtist.imageId}
- {#if playlist.primaryImageTag} + {#if playlist.imageId} - {#if imageId && $nextEpisodeItem.primaryImageTag} + {#if imageId && $nextEpisodeItem.imageId}
- {#if item.primaryImageTag} + {#if item.imageId} 0 ? duration : null, @@ -1626,11 +1626,11 @@ {#if !isMediaReady}
- {#if media?.primaryImageTag} + {#if media?.imageId}
- {#if playlist.primaryImageTag} + {#if playlist.imageId} - !!(i.backdropImageTags && i.backdropImageTags.length > 0) || !!i.primaryImageTag; + !!(i.backdropImageTags && i.backdropImageTags.length > 0) || !!i.imageId; async function loadSections(libraryId: string) { update(s => ({ diff --git a/src/lib/stores/music.ts b/src/lib/stores/music.ts index 66ac526a..2795b1f8 100644 --- a/src/lib/stores/music.ts +++ b/src/lib/stores/music.ts @@ -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 => ({ diff --git a/src/lib/stores/player.ts b/src/lib/stores/player.ts index d1b49efd..756e7947 100644 --- a/src/lib/stores/player.ts +++ b/src/lib/stores/player.ts @@ -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; } diff --git a/src/lib/stores/tv.ts b/src/lib/stores/tv.ts index 87e6da19..8f58bdef 100644 --- a/src/lib/stores/tv.ts +++ b/src/lib/stores/tv.ts @@ -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 => ({ diff --git a/src/routes/library/[id]/+page.svelte b/src/routes/library/[id]/+page.svelte index 0f669526..d6c9af7f 100644 --- a/src/routes/library/[id]/+page.svelte +++ b/src/routes/library/[id]/+page.svelte @@ -355,11 +355,11 @@
- {#if item.primaryImageTag} + {#if item.imageId}