jellytau/src/lib/components/library/RelatedItemsSection.svelte
Duncan Tourolle c959c07ab4
Some checks failed
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 31s
🏗️ Build and Test JellyTau / Build Android APK (push) Has been skipped
Traceability Validation / Check Requirement Traces (push) Failing after 3s
Fixes for tests
2026-06-20 15:28:15 +02:00

162 lines
4.9 KiB
Svelte

<script lang="ts">
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { auth } from "$lib/stores/auth";
import type { MediaItem, Person } from "$lib/api/types";
import MediaCard from "./MediaCard.svelte";
interface Props {
currentItemId: string;
itemType: "Movie" | "Series" | "MusicAlbum" | "Audio";
genres?: string[];
people?: Person[];
artistIds?: string[];
limit?: number;
}
let {
currentItemId,
itemType,
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 Jellyfin Similar Items API (preferred method)
// This works for Movies and Series (most common cases)
if (["Movie", "Series"].includes(itemType)) {
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
const searchTerm = genres[0];
const result = await repo.search(searchTerm, {
includeItemTypes: itemType === "MusicAlbum" ? ["MusicAlbum"] : itemType === "Audio" ? ["Audio"] : [itemType],
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 (itemType === "MusicAlbum" && 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 (itemType) {
case "Movie":
return "Related Movies";
case "Series":
return "Related Shows";
case "MusicAlbum":
return "Related Albums";
case "Audio":
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 = itemType === "MusicAlbum" || itemType === "Audio"}
<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>