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
+13 -16
View File
@@ -127,30 +127,27 @@
// Prevent accidental taps during scrolling (Android)
if (scrollGuard.isScrollActive()) return;
if ("type" in item) {
if ("kind" in item) {
// It's a MediaItem
const mediaItem = item as MediaItem;
// A ChannelFolderItem can be a folder (drill in) or a playable leaf.
if (mediaItem.type === "ChannelFolderItem" && !mediaItem.isFolder) {
// A playable channel leaf plays directly; a channel container drills in.
if (mediaItem.kind === "channelItem") {
goto(`/player/${mediaItem.id}`);
return;
}
switch (mediaItem.type) {
case "Series":
case "Movie":
case "MusicAlbum":
case "MusicArtist":
case "Folder":
case "CollectionFolder":
case "Playlist":
case "Channel":
case "ChannelFolderItem":
switch (mediaItem.kind) {
case "series":
case "movie":
case "album":
case "artist":
case "folder":
case "playlist":
case "channel":
// Navigate to detail view
goto(`/library/${mediaItem.id}`);
break;
case "Episode":
case "TvChannel":
// Episodes and live TV channels play directly
case "episode":
// Episodes play directly
goto(`/player/${mediaItem.id}`);
break;
default:
+42 -43
View File
@@ -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 @@
</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}
@@ -381,7 +380,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}
@@ -430,7 +429,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 +440,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 +477,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 +511,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 +538,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 +557,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 +594,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}