Merge branch 'frontend-domain-model' into ci-docs-publish-fix
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 8m34s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m20s
Traceability Validation / Check Requirement Traces (push) Successful in 15s
Build & Release / Run Tests (push) Successful in 5m18s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 4m38s
Build & Release / Build Linux (push) Successful in 17m22s
Build & Release / Build Android (push) Successful in 22m44s
Build & Release / Create Release (push) Successful in 14s

This commit is contained in:
2026-07-23 22:18:59 +02:00
67 changed files with 998 additions and 589 deletions
+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) {
@@ -112,12 +112,12 @@
<!-- Artist Info -->
<div class="flex flex-col items-center text-center py-12">
<!-- Artist Image -->
{#if artist.primaryImageTag}
{#if artist.imageId}
<div class="mb-6 rounded-full overflow-hidden w-40 h-40 shadow-lg">
<CachedImage
itemId={artist.id}
imageType="Primary"
tag={artist.primaryImageTag}
tag={artist.imageId}
maxWidth={400}
alt={artist.name}
class="w-full h-full object-cover"
@@ -160,11 +160,11 @@
class="group cursor-pointer"
>
<div class="aspect-square bg-[var(--color-surface)] rounded-lg overflow-hidden mb-2 group-hover:opacity-80 transition-opacity">
{#if album.primaryImageTag}
{#if album.imageId}
<CachedImage
itemId={album.id}
imageType="Primary"
tag={album.primaryImageTag}
tag={album.imageId}
maxWidth={200}
alt={album.name}
class="w-full h-full object-cover"
@@ -218,11 +218,11 @@
class="group text-center"
>
<div class="w-32 h-32 bg-[var(--color-surface)] rounded-full overflow-hidden mb-2 mx-auto group-hover:opacity-80 transition-opacity">
{#if relatedArtist.primaryImageTag}
{#if relatedArtist.imageId}
<CachedImage
itemId={relatedArtist.id}
imageType="Primary"
tag={relatedArtist.primaryImageTag}
tag={relatedArtist.imageId}
maxWidth={200}
alt={relatedArtist.name}
class="w-full h-full object-cover"
@@ -77,8 +77,8 @@
if (episode.backdropImageTags?.[0]) {
return { itemId: episode.id, imageType: "Backdrop" as const, tag: episode.backdropImageTags[0] };
}
if (episode.primaryImageTag) {
return { itemId: episode.id, imageType: "Primary" as const, tag: episode.primaryImageTag };
if (episode.imageId) {
return { itemId: episode.id, imageType: "Primary" as const, tag: episode.imageId };
}
if (series.backdropImageTags?.[0]) {
return { itemId: series.id, imageType: "Backdrop" as const, tag: series.backdropImageTags[0] };
@@ -86,9 +86,9 @@
return null;
});
function formatDuration(ticks?: number | null): string {
if (!ticks) return "";
const seconds = Math.floor(ticks / 10000000);
function formatDuration(ms?: number | null): string {
if (!ms) return "";
const seconds = Math.floor(ms / 1000);
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
@@ -99,10 +99,10 @@
}
function getProgress(ep: MediaItem): number {
if (!ep.userData || !ep.runTimeTicks) {
if (!ep.userData || !ep.durationMs) {
return 0;
}
return ((ep.userData.playbackPositionTicks ?? 0) / ep.runTimeTicks) * 100;
return ((ep.userData.playbackPositionMs ?? 0) / ep.durationMs) * 100;
}
function handlePlay() {
@@ -116,7 +116,7 @@
const episodeLabel = $derived(
`S${episode.parentIndexNumber || 1}E${episode.indexNumber || 1}`
);
const duration = $derived(formatDuration(episode.runTimeTicks));
const duration = $derived(formatDuration(episode.durationMs));
const progress = $derived(getProgress(episode));
</script>
@@ -246,7 +246,7 @@
<CachedImage
itemId={ep.id}
imageType="Primary"
tag={ep.primaryImageTag}
tag={ep.imageId}
maxWidth={400}
alt={ep.name}
class="w-full h-full object-cover transition-transform {isCurrent ? '' : 'group-hover/card:scale-105'}"
+4 -4
View File
@@ -38,13 +38,13 @@
const downloadProgress = $derived(downloadInfo?.progress || 0);
const progress = $derived(() => {
if (!episode.userData || !episode.runTimeTicks) {
if (!episode.userData || !episode.durationMs) {
return 0;
}
return ((episode.userData.playbackPositionTicks ?? 0) / episode.runTimeTicks) * 100;
return ((episode.userData.playbackPositionMs ?? 0) / episode.durationMs) * 100;
});
const duration = $derived(formatDuration(episode.runTimeTicks));
const duration = $derived(formatDuration(episode.durationMs));
const episodeNumber = $derived(episode.indexNumber || 0);
</script>
@@ -59,7 +59,7 @@
<CachedImage
itemId={episode.id}
imageType="Primary"
tag={episode.primaryImageTag}
tag={episode.imageId}
maxWidth={320}
alt={episode.name}
class="w-full h-full object-cover transition-transform group-hover/row:scale-105"
@@ -234,7 +234,7 @@
<CachedImage
itemId={item.id}
imageType="Primary"
tag={item.primaryImageTag}
tag={item.imageId}
maxWidth={300}
alt={item.name}
class="w-full h-full object-cover group-hover:scale-105 transition-transform"
+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>
@@ -19,7 +19,7 @@
}
function getImageTag(item: MediaItem | Library): string | undefined {
return "primaryImageTag" in item ? (item.primaryImageTag ?? undefined) : ("imageTag" in item ? (item.imageTag ?? undefined) : undefined);
return "imageId" in item ? (item.imageId ?? undefined) : ("imageTag" in item ? (item.imageTag ?? undefined) : undefined);
}
function getSubtitle(item: MediaItem | Library): string {
@@ -42,10 +42,10 @@
function getProgress(item: MediaItem | Library): number {
if (!showProgress || !("userData" in item) || !item.userData || !("runTimeTicks" in item) || !item.runTimeTicks) {
if (!showProgress || !("userData" in item) || !item.userData || !("durationMs" in item) || !item.durationMs) {
return 0;
}
return ((item.userData.playbackPositionTicks ?? 0) / item.runTimeTicks) * 100;
return ((item.userData.playbackPositionMs ?? 0) / item.durationMs) * 100;
}
function getTrackNumber(item: MediaItem | Library): string {
@@ -59,7 +59,7 @@
<div class="space-y-1">
{#each items as item, index (item.id)}
{@const subtitle = getSubtitle(item)}
{@const duration = "runTimeTicks" in item ? formatDuration(item.runTimeTicks) : ""}
{@const duration = "durationMs" in item ? formatDuration(item.durationMs) : ""}
{@const progress = getProgress(item)}
{@const trackNum = getTrackNumber(item)}
{@const isPlayed = "userData" in item && item.userData?.isPlayed}
+5 -5
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
@@ -113,16 +113,16 @@
});
const imageTag = $derived(
"primaryImageTag" in item ? item.primaryImageTag : ("imageTag" in item ? item.imageTag : undefined)
"imageId" in item ? item.imageId : ("imageTag" in item ? item.imageTag : undefined)
);
const maxWidth = $derived(size === "large" ? 400 : size === "medium" ? 300 : 200);
const progress = $derived(() => {
if (!showProgress || !("userData" in item) || !item.userData || !item.runTimeTicks) {
if (!showProgress || !("userData" in item) || !item.userData || !item.durationMs) {
return 0;
}
return ((item.userData.playbackPositionTicks ?? 0) / item.runTimeTicks) * 100;
return ((item.userData.playbackPositionMs ?? 0) / item.durationMs) * 100;
});
const subtitle = $derived(() => {
@@ -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 {
@@ -53,7 +53,7 @@
<CachedImage
itemId={person.id}
imageType="Primary"
tag={person.primaryImageTag}
tag={person.imageId}
maxWidth={400}
alt={person.name}
class="w-full rounded-lg shadow-lg"
@@ -25,7 +25,7 @@
const tracks = $derived(entries.map(e => ({ ...e } as MediaItem)));
const totalDuration = $derived(
entries.reduce((sum, e) => sum + (e.runTimeTicks ?? 0), 0)
entries.reduce((sum, e) => sum + (e.durationMs ?? 0), 0)
);
onMount(() => {
@@ -146,11 +146,11 @@
<div class="flex gap-6 pt-4">
<!-- Playlist artwork -->
<div class="flex-shrink-0 w-48">
{#if playlist.primaryImageTag}
{#if playlist.imageId}
<CachedImage
itemId={playlist.id}
imageType="Primary"
tag={playlist.primaryImageTag}
tag={playlist.imageId}
maxWidth={400}
alt={playlist.name}
class="w-full rounded-lg shadow-lg"
@@ -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">
@@ -28,7 +28,7 @@
<CachedImage
itemId={season.id}
imageType="Primary"
tag={season.primaryImageTag}
tag={season.imageId}
maxWidth={200}
alt={seasonName}
class="w-full h-full object-cover"
+2 -2
View File
@@ -283,7 +283,7 @@
<!-- Duration -->
<div class="text-gray-400 text-right">
{formatDuration(track.runTimeTicks)}
{formatDuration(track.durationMs)}
</div>
<!-- Download Button Placeholder -->
@@ -421,7 +421,7 @@
</p>
</div>
<div class="text-gray-400 text-sm {showDownload ? 'mr-20' : 'mr-12'}">
{formatDuration(track.runTimeTicks)}
{formatDuration(track.durationMs)}
</div>
</button>
+4 -4
View File
@@ -72,7 +72,7 @@ describe("TrackList", () => {
artists: ["Artist 1"],
albumName: "Album 1",
albumId: "album-1",
runTimeTicks: 1800000000, // 3 minutes
durationMs: 180000, // 3 minutes
primaryImageTag: "tag1",
indexNumber: 1,
},
@@ -84,7 +84,7 @@ describe("TrackList", () => {
artists: ["Artist 2"],
albumName: "Album 2",
albumId: "album-2",
runTimeTicks: 2400000000, // 4 minutes
durationMs: 240000, // 4 minutes
primaryImageTag: "tag2",
indexNumber: 2,
},
@@ -96,7 +96,7 @@ describe("TrackList", () => {
artists: ["Artist 3", "Artist 4"],
albumName: "Album 3",
albumId: "album-3",
runTimeTicks: 3000000000, // 5 minutes
durationMs: 300000, // 5 minutes
indexNumber: 3,
},
];
@@ -187,7 +187,7 @@ describe("TrackList", () => {
const tracksWithoutDuration: MediaItem[] = [
{
...mockTracks[0],
runTimeTicks: undefined,
durationMs: undefined,
},
];
+2 -2
View File
@@ -143,7 +143,7 @@
<CachedImage
itemId={artworkItemId}
imageType="Primary"
tag={displayMedia?.primaryImageTag}
tag={displayMedia?.imageId}
maxWidth={800}
alt=""
class="w-full h-full object-cover blur-3xl opacity-30"
@@ -223,7 +223,7 @@
<CachedImage
itemId={artworkItemId}
imageType="Primary"
tag={displayMedia?.primaryImageTag}
tag={displayMedia?.imageId}
maxWidth={500}
alt={displayMedia?.name}
class="w-full h-full object-cover"
+1 -1
View File
@@ -326,7 +326,7 @@
<CachedImage
itemId={displayMedia.albumId || displayMedia.id}
imageType="Primary"
tag={displayMedia.primaryImageTag}
tag={displayMedia.imageId}
maxWidth={100}
alt={displayMedia?.name}
class="w-full h-full object-cover"
@@ -78,11 +78,11 @@
<div
class="relative flex-shrink-0 w-28 h-16 rounded-lg overflow-hidden bg-gray-800"
>
{#if imageId && $nextEpisodeItem.primaryImageTag}
{#if imageId && $nextEpisodeItem.imageId}
<CachedImage
itemId={imageId}
imageType="Primary"
tag={$nextEpisodeItem.primaryImageTag}
tag={$nextEpisodeItem.imageId}
maxHeight={200}
alt={$nextEpisodeItem.name}
class="w-full h-full object-cover"
+6 -6
View File
@@ -36,9 +36,9 @@
let dragDisabled = $state(true);
const flipDurationMs = 200;
function formatDuration(ticks?: number | null): string {
if (!ticks) return "";
const seconds = Math.floor(ticks / 10000000);
function formatDuration(ms?: number | null): string {
if (!ms) return "";
const seconds = Math.floor(ms / 1000);
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, "0")}`;
@@ -184,11 +184,11 @@
<!-- Artwork -->
<div class="w-10 h-10 rounded bg-gray-800 flex-shrink-0 overflow-hidden">
{#if item.primaryImageTag}
{#if item.imageId}
<CachedImage
itemId={item.id}
imageType="Primary"
tag={item.primaryImageTag}
tag={item.imageId}
maxWidth={80}
alt={item.name}
class="w-full h-full object-cover"
@@ -210,7 +210,7 @@
<!-- Duration -->
<span class="text-xs text-gray-500 flex-shrink-0">
{formatDuration(item.runTimeTicks)}
{formatDuration(item.durationMs)}
</span>
</button>
@@ -97,8 +97,8 @@ function makeEpisode(): MediaItem {
return {
id: "ep1",
name: "Episode 1",
type: "Episode",
runTimeTicks: 24 * 60 * 10_000_000, // 24 min
kind: "episode",
durationMs: 24 * 60 * 1000, // 24 min
} as MediaItem;
}
+10 -10
View File
@@ -165,9 +165,9 @@
// Use known duration from media item (runTimeTicks is in 10M ticks/second)
// Fallback to video element duration for direct streams
const duration = $derived.by(() => {
// Explicitly check if runTimeTicks exists and is a valid number
if (media && media.runTimeTicks && media.runTimeTicks > 0) {
return media.runTimeTicks / 10_000_000;
// Explicitly check if durationMs exists and is a valid number
if (media && media.durationMs && media.durationMs > 0) {
return media.durationMs / 1000;
}
// Otherwise use the video element's duration
return videoDuration;
@@ -180,7 +180,7 @@
console.log("[VideoPlayer] No media or mediaStreams available");
return [];
}
const tracks = media.mediaStreams.filter(stream => stream.type === "Audio");
const tracks = media.mediaStreams.filter(stream => stream.kind === "audio");
console.log("[VideoPlayer] Found audio tracks:", tracks.length, tracks);
return tracks;
});
@@ -243,7 +243,7 @@
console.log("[VideoPlayer] No media or mediaStreams available for subtitles");
return [];
}
const tracks = media.mediaStreams.filter(stream => stream.type === "Subtitle");
const tracks = media.mediaStreams.filter(stream => stream.kind === "subtitle");
console.log("[VideoPlayer] Found subtitle tracks:", tracks.length, tracks);
return tracks;
});
@@ -386,7 +386,7 @@
// Check if we're near the end of the video - if so, this is likely
// end-of-stream rather than a real error. Jellyfin transcoded HLS
// streams may not always terminate cleanly with #EXT-X-ENDLIST.
const knownDuration = media?.runTimeTicks ? media.runTimeTicks / 10_000_000 : videoDuration;
const knownDuration = media?.durationMs ? media.durationMs / 1000 : videoDuration;
const effectiveTime = currentTime + seekOffset;
const isNearEnd = knownDuration > 0 && effectiveTime > 0 && effectiveTime / knownDuration > 0.9;
@@ -520,7 +520,7 @@
// Build subtitle tracks for native player
const subtitleTracks = [];
if (media.mediaStreams && mediaSourceId) {
const subtitles = media.mediaStreams.filter(s => s.type === "Subtitle");
const subtitles = media.mediaStreams.filter(s => s.kind === "subtitle");
for (const sub of subtitles) {
try {
const url = await getSubtitleUrl(sub.index);
@@ -1223,7 +1223,7 @@
needsTranscoding: false,
// Now-playing metadata so the lockscreen/miniplayer show the item.
artist: media.seriesName ?? null,
primaryImageTag: media.primaryImageTag ?? null,
primaryImageTag: media.imageId ?? null,
serverId: media.serverId ?? null,
// Real duration so the lockscreen scrubber has a range to draw.
durationSeconds: duration > 0 ? duration : null,
@@ -1626,11 +1626,11 @@
{#if !isMediaReady}
<div class="absolute inset-0 flex items-center justify-center bg-black">
<!-- Poster/Title Card -->
{#if media?.primaryImageTag}
{#if media?.imageId}
<CachedImage
itemId={media.id}
imageType="Primary"
tag={media.primaryImageTag}
tag={media.imageId}
maxHeight={1080}
alt={media?.name || "Video"}
class="max-w-full max-h-full object-contain"
@@ -121,11 +121,11 @@
class="w-full flex items-center gap-3 p-3 hover:bg-[var(--color-surface-hover)] rounded-lg transition-colors disabled:opacity-50"
>
<div class="w-10 h-10 flex-shrink-0 rounded overflow-hidden">
{#if playlist.primaryImageTag}
{#if playlist.imageId}
<CachedImage
itemId={playlist.id}
imageType="Primary"
tag={playlist.primaryImageTag}
tag={playlist.imageId}
maxWidth={80}
alt={playlist.name}
class="w-full h-full object-cover"