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.
166 lines
5.0 KiB
Svelte
166 lines
5.0 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from "svelte";
|
|
import { goto } from "$app/navigation";
|
|
import { auth } from "$lib/stores/auth";
|
|
import type { MediaItem, MediaKind, Person } from "$lib/api/types";
|
|
import MediaCard from "./MediaCard.svelte";
|
|
|
|
interface Props {
|
|
currentItemId: string;
|
|
itemKind: MediaKind;
|
|
genres?: string[];
|
|
people?: Person[];
|
|
artistIds?: string[];
|
|
limit?: number;
|
|
}
|
|
|
|
let {
|
|
currentItemId,
|
|
itemKind,
|
|
genres = [],
|
|
people = [],
|
|
artistIds = [],
|
|
limit = 12
|
|
}: Props = $props();
|
|
|
|
let relatedItems = $state<MediaItem[]>([]);
|
|
let loading = $state(true);
|
|
let error = $state<string | null>(null);
|
|
|
|
onMount(async () => {
|
|
await loadRelatedItems();
|
|
});
|
|
|
|
async function loadRelatedItems() {
|
|
loading = true;
|
|
error = null;
|
|
relatedItems = [];
|
|
|
|
try {
|
|
const repo = auth.getRepository();
|
|
if (!repo) {
|
|
error = "Not authenticated";
|
|
return;
|
|
}
|
|
|
|
let items: MediaItem[] = [];
|
|
|
|
// First, try to use the Similar Items API (preferred method)
|
|
// This works for Movies and Series (most common cases)
|
|
if (itemKind === "movie" || itemKind === "series") {
|
|
try {
|
|
const result = await repo.getSimilarItems(currentItemId, limit);
|
|
items = result.items.filter(item => item.id !== currentItemId);
|
|
|
|
if (items.length > 0) {
|
|
relatedItems = items.slice(0, limit);
|
|
return; // Success - return early
|
|
}
|
|
} catch (e) {
|
|
console.warn("Failed to load similar items from API:", e);
|
|
// Fall through to genre-based loading
|
|
}
|
|
}
|
|
|
|
// 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. 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: [itemTypeForKind[itemKind] ?? "Movie"],
|
|
limit: limit * 2
|
|
});
|
|
|
|
items = result.items.filter(item => item.id !== currentItemId);
|
|
} catch (e) {
|
|
console.warn("Failed to load related items by genre:", e);
|
|
}
|
|
}
|
|
|
|
// For music albums, also try to load by artist (if we don't have enough from similar API)
|
|
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], {
|
|
includeItemTypes: ["MusicAlbum"],
|
|
limit: limit * 2
|
|
});
|
|
|
|
const artistAlbums = result.items.filter(item => item.id !== currentItemId);
|
|
items = [...items, ...artistAlbums];
|
|
} catch (e) {
|
|
console.warn("Failed to load albums by artist:", e);
|
|
}
|
|
}
|
|
|
|
// Remove duplicates and limit results
|
|
const uniqueItems = Array.from(
|
|
new Map(items.map(item => [item.id, item])).values()
|
|
).slice(0, limit);
|
|
|
|
relatedItems = uniqueItems;
|
|
} catch (e) {
|
|
error = e instanceof Error ? e.message : "Failed to load related items";
|
|
console.error("Error loading related items:", e);
|
|
} finally {
|
|
loading = false;
|
|
}
|
|
}
|
|
|
|
function getTitle(): string {
|
|
switch (itemKind) {
|
|
case "movie":
|
|
return "Related Movies";
|
|
case "series":
|
|
return "Related Shows";
|
|
case "album":
|
|
return "Related Albums";
|
|
case "track":
|
|
return "Related Tracks";
|
|
default:
|
|
return "Related Items";
|
|
}
|
|
}
|
|
|
|
function handleItemClick(item: MediaItem) {
|
|
goto(`/library/${item.id}`);
|
|
}
|
|
</script>
|
|
|
|
<div class="space-y-4">
|
|
<h2 class="text-xl font-semibold text-white">{getTitle()}</h2>
|
|
|
|
{#if loading}
|
|
<!-- Skeleton loading state -->
|
|
{@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">
|
|
<div class="{isMusicContent ? 'aspect-square' : 'aspect-[2/3]'} bg-[var(--color-surface)] rounded-lg mb-2"></div>
|
|
<div class="h-4 bg-[var(--color-surface)] rounded w-3/4 mb-1"></div>
|
|
<div class="h-3 bg-[var(--color-surface)] rounded w-1/2"></div>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{:else if error}
|
|
<div class="text-center py-8 text-gray-400">
|
|
<p>Could not load related items</p>
|
|
</div>
|
|
{:else if relatedItems.length === 0}
|
|
<div class="text-center py-8 text-gray-400">
|
|
<p>No related items found</p>
|
|
</div>
|
|
{:else}
|
|
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
|
{#each relatedItems as item (item.id)}
|
|
<MediaCard {item} onclick={() => handleItemClick(item)} />
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|