From 772e9ca6d5678ad32791ea031b08b3d603b47568 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Thu, 23 Jul 2026 21:12:20 +0200 Subject: [PATCH] domain: flip catalog frontend off Jellyfin item-type strings (phase 2a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrate catalog MediaItem consumers from stringly item.type ("Audio", "MusicAlbum", …) to the neutral item.kind enum across all classification logic: home, library detail, player routing, artist/person/related/genre components, tv store. Model refinements found during migration (each a real distinction the flat item_type collapsed): - MediaKind::LiveChannel — live TV (playable, non-seekable) vs - MediaKind::ChannelItem — channel VOD leaf (playable, seekable) vs - MediaKind::Channel — channel container (drill-in). TvChannel->LiveChannel, non-folder ChannelFolderItem->ChannelItem. RelatedItemsSection and GenreTags props migrated from Jellyfin type strings to MediaKind; MediaKind re-exported from api/types. Deferred by design: display {item.type} text, ResultsCounter labels, Person.type (role), stream.type (phase 4), and all runTimeTicks/tick math (coupled to playbackPositionTicks — phase 3). Old fields still dual-carried so nothing breaks. Rust 456 + 7 domain tests, frontend 644 tests, check clean. --- src-tauri/src/domain/from_jellyfin.rs | 17 ++-- src-tauri/src/domain/media.rs | 10 +++ src/lib/api/bindings.ts | 19 ++++- src/lib/api/types.ts | 1 + src/lib/components/home/HeroBanner.svelte | 12 +-- .../library/ArtistDetailView.svelte | 6 +- src/lib/components/library/GenreTags.svelte | 29 ++++--- src/lib/components/library/MediaCard.svelte | 4 +- .../library/PersonDetailView.svelte | 4 +- .../library/RelatedItemsSection.svelte | 32 ++++--- src/lib/stores/tv.ts | 2 +- src/routes/+page.svelte | 17 ++-- src/routes/library/+page.svelte | 29 +++---- src/routes/library/[id]/+page.svelte | 85 +++++++++---------- src/routes/player/[id]/+page.svelte | 34 ++++---- 15 files changed, 167 insertions(+), 134 deletions(-) diff --git a/src-tauri/src/domain/from_jellyfin.rs b/src-tauri/src/domain/from_jellyfin.rs index 06411dcc..b97a05dc 100644 --- a/src-tauri/src/domain/from_jellyfin.rs +++ b/src-tauri/src/domain/from_jellyfin.rs @@ -50,18 +50,20 @@ pub fn kind_from_jellyfin(item_type: &str, is_folder: bool) -> MediaKind { MediaKind::Person } - // Live TV / channels - "TvChannel" | "LiveTvChannel" | "Channel" => MediaKind::Channel, + // A live TV channel: playable, but a non-seekable live stream. + "TvChannel" | "LiveTvChannel" => MediaKind::LiveChannel, + // A bare channel is a container the user drills into. + "Channel" => MediaKind::Channel, // Containers "Folder" | "CollectionFolder" | "UserView" | "BoxSet" => MediaKind::Folder, - // ChannelFolderItem is a container when it is a folder, else a leaf we - // do not model further. + // ChannelFolderItem is a container when it is a folder, else a playable + // channel leaf (distinct kind so the UI can route it to playback). "ChannelFolderItem" => { if is_folder { MediaKind::Folder } else { - MediaKind::Other + MediaKind::ChannelItem } } @@ -117,7 +119,8 @@ mod tests { #[test] fn channel_and_container_types_map() { - assert_eq!(kind_from_jellyfin("TvChannel", false), MediaKind::Channel); + 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), MediaKind::Folder @@ -133,7 +136,7 @@ mod tests { ); assert_eq!( kind_from_jellyfin("ChannelFolderItem", false), - MediaKind::Other + MediaKind::ChannelItem ); } diff --git a/src-tauri/src/domain/media.rs b/src-tauri/src/domain/media.rs index 021d79c2..235b46d3 100644 --- a/src-tauri/src/domain/media.rs +++ b/src-tauri/src/domain/media.rs @@ -36,8 +36,18 @@ pub enum MediaKind { // Cast/crew Person, // Containers / live TV + /// 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 diff --git a/src/lib/api/bindings.ts b/src/lib/api/bindings.ts index 84369664..a295a9e7 100644 --- a/src/lib/api/bindings.ts +++ b/src/lib/api/bindings.ts @@ -1858,7 +1858,24 @@ imageId?: string | null; backdropImageTags?: string[] | null; parentBackdropImag * 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" | "channel" | "folder" | +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 diff --git a/src/lib/api/types.ts b/src/lib/api/types.ts index ec303636..98ad9789 100644 --- a/src/lib/api/types.ts +++ b/src/lib/api/types.ts @@ -14,6 +14,7 @@ export type { Library, LiveStreamInfo, MediaItem, + MediaKind, MediaSource, MediaStream, Person, diff --git a/src/lib/components/home/HeroBanner.svelte b/src/lib/components/home/HeroBanner.svelte index 4d86509e..018bea7c 100644 --- a/src/lib/components/home/HeroBanner.svelte +++ b/src/lib/components/home/HeroBanner.svelte @@ -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}`); diff --git a/src/lib/components/library/ArtistDetailView.svelte b/src/lib/components/library/ArtistDetailView.svelte index 9e29babf..02bf9ea4 100644 --- a/src/lib/components/library/ArtistDetailView.svelte +++ b/src/lib/components/library/ArtistDetailView.svelte @@ -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) { diff --git a/src/lib/components/library/GenreTags.svelte b/src/lib/components/library/GenreTags.svelte index 5173d245..6f871515 100644 --- a/src/lib/components/library/GenreTags.svelte +++ b/src/lib/components/library/GenreTags.svelte @@ -1,32 +1,35 @@ diff --git a/src/lib/components/library/MediaCard.svelte b/src/lib/components/library/MediaCard.svelte index 39b06731..7957c622 100644 --- a/src/lib/components/library/MediaCard.svelte +++ b/src/lib/components/library/MediaCard.svelte @@ -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 diff --git a/src/lib/components/library/PersonDetailView.svelte b/src/lib/components/library/PersonDetailView.svelte index 399f9bdb..a7dbc1c5 100644 --- a/src/lib/components/library/PersonDetailView.svelte +++ b/src/lib/components/library/PersonDetailView.svelte @@ -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 { diff --git a/src/lib/components/library/RelatedItemsSection.svelte b/src/lib/components/library/RelatedItemsSection.svelte index 10cbd420..9757bf21 100644 --- a/src/lib/components/library/RelatedItemsSection.svelte +++ b/src/lib/components/library/RelatedItemsSection.svelte @@ -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 = { + 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} - {@const isMusicContent = itemType === "MusicAlbum" || itemType === "Audio"} + {@const isMusicContent = itemKind === "album" || itemKind === "track"}
{#each Array(6) as _}
diff --git a/src/lib/stores/tv.ts b/src/lib/stores/tv.ts index ea163d35..87e6da19 100644 --- a/src/lib/stores/tv.ts +++ b/src/lib/stores/tv.ts @@ -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. diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 361f9231..0b0a12ce 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -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); diff --git a/src/routes/library/+page.svelte b/src/routes/library/+page.svelte index 9d1301d5..28d941e1 100644 --- a/src/routes/library/+page.svelte +++ b/src/routes/library/+page.svelte @@ -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: diff --git a/src/routes/library/[id]/+page.svelte b/src/routes/library/[id]/+page.svelte index 890e549e..faf0a628 100644 --- a/src/routes/library/[id]/+page.svelte +++ b/src/routes/library/[id]/+page.svelte @@ -94,7 +94,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 +110,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 +124,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 }; }); @@ -190,21 +190,20 @@ } // 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 +222,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 +247,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 +283,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 +330,13 @@
{:else if item} - {#if item.type === "Person"} + {#if item.kind === "person"}
- {:else if item.type === "Series" && focusedEpisode} + {:else if item.kind === "series" && focusedEpisode}

{item.name}

- {#if item.type === "Episode" && (item.parentIndexNumber || item.indexNumber)} + {#if item.kind === "episode" && (item.parentIndexNumber || item.indexNumber)}

{#if item.parentIndexNumber}Season {item.parentIndexNumber}{/if} {#if item.parentIndexNumber && item.indexNumber}, {/if} @@ -430,7 +429,7 @@ Play - {#if item.type !== "Episode" && item.type !== "Movie"} + {#if item.kind !== "episode" && item.kind !== "movie"}