Files
jellytau/src/lib/components/library/RelatedItemsSection.svelte
T
dtourolle ad48d89dfe chore(format): run prettier over src/ and scripts/
Formatting was configured but never enforced: `bun run format:check`
reported 199 unformatted files and ran in no workflow and in no git hook,
so .prettierrc (printWidth 100, trailing commas) described an intention
rather than the tree.

This is the one-time sweep that makes the check gateable. Whitespace and
token-reflow only -- no behavioural change: `bun run check` reports 0
errors and all 1053 frontend tests pass before and after.

Kept out of every other commit on purpose. A 199-file diff mixed with
real changes is unreviewable, and the next commit turns format:check
into a hard CI gate so this cannot silently accumulate again.
2026-08-21 17:41:44 +02:00

178 lines
5.2 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";
import { createLogger } from "$lib/utils/logger";
const log = createLogger("RelatedItemsSection");
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) {
log.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) {
log.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) {
log.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";
log.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>