domain: flip catalog frontend off Jellyfin item-type strings (phase 2a)

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.
This commit is contained in:
2026-07-23 21:12:20 +02:00
parent 55fa26377a
commit 772e9ca6d5
15 changed files with 167 additions and 134 deletions
+18 -1
View File
@@ -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
+1
View File
@@ -14,6 +14,7 @@ export type {
Library,
LiveStreamInfo,
MediaItem,
MediaKind,
MediaSource,
MediaStream,
Person,
+6 -6
View File
@@ -32,7 +32,7 @@
}
// 2. For episodes, try series/season backdrops
if (currentItem.type === "Episode") {
if (currentItem.kind === "episode") {
if (currentItem.seriesId && currentItem.parentBackdropImageTags?.[0]) {
return { itemId: currentItem.seriesId, imageType: "Backdrop" as const, tag: currentItem.parentBackdropImageTags[0] };
}
@@ -45,17 +45,17 @@
}
// 3. For music tracks, try album backdrop
if (currentItem.type === "Audio" && currentItem.albumId) {
if (currentItem.kind === "track" && currentItem.albumId) {
return { itemId: currentItem.albumId, imageType: "Backdrop" as const, tag: undefined };
}
// 4. Fall back to primary image
if (currentItem.primaryImageTag) {
return { itemId: currentItem.id, imageType: "Primary" as const, tag: currentItem.primaryImageTag };
if (currentItem.imageId) {
return { itemId: currentItem.id, imageType: "Primary" as const, tag: currentItem.imageId };
}
// 5. Last resort for audio: album primary
if (currentItem.type === "Audio" && currentItem.albumId) {
if (currentItem.kind === "track" && currentItem.albumId) {
return { itemId: currentItem.albumId, imageType: "Primary" as const, tag: undefined };
}
@@ -190,7 +190,7 @@
onclick={() => {
// Navigate to full series detail page with cast/crew/related content
// (even for episodes, show the series page so users see cast and related items)
if (currentItem.type === "Episode" && currentItem.seriesId) {
if (currentItem.kind === "episode" && currentItem.seriesId) {
goto(`/library/${currentItem.seriesId}`);
} else {
goto(`/library/${currentItem.id}`);
@@ -43,7 +43,7 @@
sortBy: "DateCreated",
sortOrder: "Descending"
});
albums = albumsResult.items.filter(item => item.type === "MusicAlbum");
albums = albumsResult.items.filter(item => item.kind === "album");
} catch (e) {
console.warn("Failed to load albums:", e);
} finally {
@@ -58,7 +58,7 @@
sortBy: "CommunityRating",
sortOrder: "Descending"
});
topTracks = tracksResult.items.filter(item => item.type === "Audio");
topTracks = tracksResult.items.filter(item => item.kind === "track");
} catch (e) {
console.warn("Failed to load tracks:", e);
} finally {
@@ -76,7 +76,7 @@
sortOrder: "Descending"
});
relatedArtists = relatedResult.items
.filter(item => item.id !== artist.id && item.type === "MusicArtist")
.filter(item => item.id !== artist.id && item.kind === "artist")
.slice(0, 6);
}
} catch (e) {
+16 -13
View File
@@ -1,32 +1,35 @@
<script lang="ts">
import { goto } from "$app/navigation";
import type { MediaKind } from "$lib/api/types";
interface Props {
genres: string[];
maxShow?: number; // Default: unlimited
clickable?: boolean; // Default: true
itemType?: string; // Determines which genre browse page to open
itemKind?: MediaKind; // Determines which genre browse page to open
}
let {
genres,
maxShow,
clickable = true,
itemType
itemKind
}: Props = $props();
// Map the item type to its genre-browse route
function genreBasePath(type: string | undefined): string {
switch (type) {
case "MusicAlbum":
case "MusicArtist":
case "Audio":
// Map the item kind to its genre-browse route
function genreBasePath(kind: MediaKind | undefined): string {
switch (kind) {
case "album":
case "artist":
case "track":
case "playlist":
return "/library/music/genres";
case "Series":
case "Season":
case "Episode":
case "series":
case "season":
case "episode":
return "/library/shows/genres";
case "Movie":
case "movie":
return "/library/movies/genres";
default:
return "/library/movies/genres";
@@ -43,7 +46,7 @@
function handleGenreClick(genre: string) {
if (clickable) {
goto(`${genreBasePath(itemType)}?genre=${encodeURIComponent(genre)}`);
goto(`${genreBasePath(itemKind)}?genre=${encodeURIComponent(genre)}`);
}
}
</script>
+2 -2
View File
@@ -101,11 +101,11 @@
};
const isMusicType = $derived(
"type" in item && (item.type === "Audio" || item.type === "MusicAlbum" || item.type === "MusicArtist" || item.type === "Playlist")
"kind" in item && (item.kind === "track" || item.kind === "album" || item.kind === "artist" || item.kind === "playlist")
);
const aspectRatio = $derived(() => {
if ("type" in item) {
if ("kind" in item) {
return isMusicType ? "aspect-square" : "aspect-[2/3]";
}
// Library
@@ -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 {
@@ -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">
+1 -1
View File
@@ -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.