Files
jellytau/src/lib/components/library/ArtistDetailView.svelte
T
dtourolle 62873cab3d feat(search): answer search from a local index; tier downloads by lifetime
Search's instant leg read only downloaded items, so with no downloads it
returned nothing and every keystroke fell through to a full Recursive=true
server query. It now reads the whole synced catalog through the same
availability CTE get_items uses, gated on the same include_catalog_browse
flag so search and browse cannot diverge. (UR-065, DR-108)

Also fixes three defects found while confirming that:

- items_fts grew by a full duplicate index every catalog pass. INSERT OR
  REPLACE fires no AFTER DELETE trigger without recursive_triggers, so the
  old index row was orphaned, and a TEXT PRIMARY KEY meant the replacement
  took a fresh rowid and inserted a second entry. Now a real upsert, with
  migration 021 rebuilding existing indexes. (DR-110)
- DELETE FROM items existed nowhere, so server-side deletions never
  propagated. Adds a post-crawl mark-and-sweep, scoped to crawled types,
  skipping downloaded items, and refusing to run after a partial crawl
  because items.parent_id cascades. (DR-110)
- The index omitted MusicArtist, Playlist and People, which search groups
  results by. Adds them plus people_fts (migration 022). (DR-111)

Re-indexing moves from a frontend startup call to a Rust background task
with a 6h TTL, so a long session no longer searches a stale catalog and a
restart no longer forces a crawl regardless of freshness. (DR-109, IR-030)

Downloads gain a lifetime tier. Eviction selected every completed row by
age with no download_source filter, so hitting the storage limit deleted
the oldest download -- typically one saved deliberately for offline -- to
make room for a precached track. It now reclaims only 'auto' rows, and
expired ones are reclaimed first, before live cache is evicted.
(DR-126, DR-127)

Downloaded video and audio-only handoffs now play from disk instead of
streaming; the video path had never consulted downloads at all. No
transcode is involved: MPV runs video=no and ExoPlayer has no surface for
an Audio item. (DR-123 in part, DR-128)

FTS queries are built as quoted phrases so apostrophes, hyphens and
slashes are data rather than operator syntax, and the item-type filter is
bound rather than interpolated.

Specs: docs/specs/catalog-index-search.md,
docs/specs/read-through-media-cache.md

Includes concurrently-developed favourites browsing and background-audio
stream-end handling; the two workstreams share offline.rs, lib.rs and
online.rs, so no subset of files builds independently.
2026-08-04 17:35:17 +02:00

252 lines
8.1 KiB
Svelte

<script lang="ts">
import { onMount } from "svelte";
import { truncateMiddle } from "$lib/utils/truncateMiddle";
import { auth } from "$lib/stores/auth";
import type { MediaItem } from "$lib/api/types";
import LibraryGrid from "./LibraryGrid.svelte";
import TrackList from "./TrackList.svelte";
import CachedImage from "$lib/components/common/CachedImage.svelte";
import FavoriteButton from "$lib/components/FavoriteButton.svelte";
import { favoriteOverrides, resolveIsFavorite } from "$lib/stores/favorites";
interface Props {
artist: MediaItem;
}
let { artist }: Props = $props();
let albums = $state<MediaItem[]>([]);
let singles = $state<MediaItem[]>([]);
let topTracks = $state<MediaItem[]>([]);
let relatedArtists = $state<MediaItem[]>([]);
let albumsLoading = $state(true);
let singlesLoading = $state(true);
let tracksLoading = $state(true);
let artistsLoading = $state(true);
let showSingles = $state(false);
let showAppears = $state(false);
onMount(async () => {
await loadArtistContent();
});
async function loadArtistContent() {
try {
const repo = auth.getRepository();
if (!repo) return;
// Load albums
try {
const albumsResult = await repo.getItems(artist.id, {
includeItemTypes: ["MusicAlbum"],
limit: 50,
sortBy: "DateCreated",
sortOrder: "Descending"
});
albums = albumsResult.items.filter(item => item.kind === "album");
} catch (e) {
console.warn("Failed to load albums:", e);
} finally {
albumsLoading = false;
}
// Load top tracks
try {
const tracksResult = await repo.getItems(artist.id, {
includeItemTypes: ["Audio"],
limit: 10,
sortBy: "CommunityRating",
sortOrder: "Descending"
});
topTracks = tracksResult.items.filter(item => item.kind === "track");
} catch (e) {
console.warn("Failed to load tracks:", e);
} finally {
tracksLoading = false;
}
// Load related artists (by genre)
try {
if (artist.genres && artist.genres.length > 0) {
const relatedResult = await repo.getItems(artist.id, {
includeItemTypes: ["MusicArtist"],
genres: artist.genres.slice(0, 2),
limit: 12,
sortBy: "CommunityRating",
sortOrder: "Descending"
});
relatedArtists = relatedResult.items
.filter(item => item.id !== artist.id && item.kind === "artist")
.slice(0, 6);
}
} catch (e) {
console.warn("Failed to load related artists:", e);
} finally {
artistsLoading = false;
}
singlesLoading = false;
} catch (e) {
console.error("Error loading artist content:", e);
}
}
</script>
<div class="space-y-8">
<!-- Hero Section -->
<div class="relative">
<!-- Backdrop -->
{#if artist.backdropImageTags?.[0]}
<div class="absolute inset-0 -z-10 h-96 overflow-hidden rounded-lg">
<CachedImage
itemId={artist.id}
imageType="Backdrop"
tag={artist.backdropImageTags[0]}
maxWidth={1920}
class="w-full h-full object-cover opacity-40"
/>
<div class="absolute inset-0 bg-gradient-to-b from-transparent to-[var(--color-background)]"></div>
</div>
{/if}
<!-- Artist Info -->
<div class="flex flex-col items-center text-center py-12">
<!-- Artist Image -->
{#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.imageId}
maxWidth={400}
alt={artist.name}
class="w-full h-full object-cover"
/>
</div>
{/if}
<!-- Artist Name -->
<div class="flex items-center gap-2 mb-4">
<h1 class="text-4xl font-bold text-white">{artist.name}</h1>
<!-- TRACES: UR-068 | DR-119 -->
<FavoriteButton
itemId={artist.id}
isFavorite={resolveIsFavorite(artist, $favoriteOverrides)}
size="lg"
/>
</div>
<!-- Bio -->
{#if artist.overview}
<p class="text-gray-300 leading-relaxed max-w-3xl">
{artist.overview}
</p>
{/if}
</div>
</div>
<!-- Albums Section -->
<div class="space-y-4">
<h2 class="text-2xl font-semibold text-white">Albums</h2>
{#if albumsLoading}
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
{#each Array(6) as _}
<div class="animate-pulse">
<div class="aspect-square 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 albums.length === 0}
<p class="text-gray-400">No albums found</p>
{:else}
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
{#each albums as album (album.id)}
<a
href="/library/{album.id}"
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.imageId}
<CachedImage
itemId={album.id}
imageType="Primary"
tag={album.imageId}
maxWidth={200}
alt={album.name}
class="w-full h-full object-cover"
/>
{/if}
</div>
<p class="text-sm font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors">
{truncateMiddle(album.name, 40)}
</p>
{#if album.productionYear}
<p class="text-xs text-gray-400">{album.productionYear}</p>
{/if}
</a>
{/each}
</div>
{/if}
</div>
<!-- Top Tracks Section -->
{#if topTracks.length > 0}
<div class="space-y-4">
<h2 class="text-2xl font-semibold text-white">Top Tracks</h2>
<TrackList
tracks={topTracks}
loading={tracksLoading}
showAlbum={true}
showArtist={false}
showDownload={false}
/>
</div>
{/if}
<!-- Related Artists Section -->
{#if relatedArtists.length > 0}
<div class="space-y-4">
<h2 class="text-2xl font-semibold text-white">Similar Artists</h2>
{#if artistsLoading}
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
{#each Array(6) as _}
<div class="animate-pulse text-center">
<div class="w-32 h-32 bg-[var(--color-surface)] rounded-full mb-2 mx-auto"></div>
<div class="h-4 bg-[var(--color-surface)] rounded w-3/4 mx-auto"></div>
</div>
{/each}
</div>
{:else}
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
{#each relatedArtists as relatedArtist (relatedArtist.id)}
<a
href="/library/{relatedArtist.id}"
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.imageId}
<CachedImage
itemId={relatedArtist.id}
imageType="Primary"
tag={relatedArtist.imageId}
maxWidth={200}
alt={relatedArtist.name}
class="w-full h-full object-cover"
/>
{/if}
</div>
<p class="text-sm font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors">
{relatedArtist.name}
</p>
</a>
{/each}
</div>
{/if}
</div>
{/if}
</div>