Files
jellytau/src/lib/components/library/PlaylistDetailView.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

312 lines
10 KiB
Svelte

<script lang="ts">
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { playerController } from "$lib/player";
import type { MediaItem, PlaylistEntry } from "$lib/api/types";
import { auth } from "$lib/stores/auth";
import { toast } from "$lib/stores/toast";
import TrackList from "./TrackList.svelte";
import CachedImage from "$lib/components/common/CachedImage.svelte";
import FavoriteButton from "$lib/components/FavoriteButton.svelte";
import { favoriteOverrides } from "$lib/stores/favorites";
import { formatDuration } from "$lib/utils/duration";
interface Props {
playlist: MediaItem;
}
let { playlist }: Props = $props();
let entries = $state<PlaylistEntry[]>([]);
let loading = $state(true);
let editingName = $state(false);
let editName = $state("");
let showDeleteConfirm = $state(false);
// Extract MediaItem[] from PlaylistEntry[] for TrackList
const tracks = $derived(entries.map(e => ({ ...e } as MediaItem)));
const totalDuration = $derived(
entries.reduce((sum, e) => sum + (e.durationMs ?? 0), 0)
);
onMount(() => {
loadPlaylistItems();
});
async function loadPlaylistItems() {
loading = true;
try {
const repo = auth.getRepository();
entries = await repo.getPlaylistItems(playlist.id);
} catch (e) {
console.error("[PlaylistDetail] Failed to load items:", e);
toast.error("Failed to load playlist items");
} finally {
loading = false;
}
}
async function handlePlayAll() {
if (entries.length === 0) return;
try {
const trackIds = entries.map(e => e.id);
await playerController.playTracks({
trackIds,
startIndex: 0,
shuffle: false,
context: {
type: "playlist",
playlistId: playlist.id,
playlistName: playlist.name,
},
});
} catch (e) {
console.error("[PlaylistDetail] Failed to play all:", e);
toast.error("Failed to play playlist");
}
}
async function handleShufflePlay() {
if (entries.length === 0) return;
try {
const trackIds = entries.map(e => e.id);
await playerController.playTracks({
trackIds,
startIndex: 0,
shuffle: true,
context: {
type: "playlist",
playlistId: playlist.id,
playlistName: playlist.name,
},
});
} catch (e) {
console.error("[PlaylistDetail] Failed to shuffle play:", e);
toast.error("Failed to shuffle playlist");
}
}
async function handleRename() {
const trimmed = editName.trim();
if (!trimmed || trimmed === playlist.name) {
editingName = false;
editName = playlist.name;
return;
}
try {
const repo = auth.getRepository();
await repo.renamePlaylist(playlist.id, trimmed);
playlist.name = trimmed;
toast.success("Playlist renamed");
} catch (e) {
console.error("[PlaylistDetail] Failed to rename:", e);
toast.error("Failed to rename playlist");
editName = playlist.name;
} finally {
editingName = false;
}
}
async function handleDelete() {
try {
const repo = auth.getRepository();
await repo.deletePlaylist(playlist.id);
toast.success("Playlist deleted");
goto("/library");
} catch (e) {
console.error("[PlaylistDetail] Failed to delete:", e);
toast.error("Failed to delete playlist");
} finally {
showDeleteConfirm = false;
}
}
async function handleRemoveTrack(entry: PlaylistEntry) {
try {
const repo = auth.getRepository();
await repo.removeFromPlaylist(playlist.id, [entry.playlistItemId]);
entries = entries.filter(e => e.playlistItemId !== entry.playlistItemId);
toast.success("Track removed");
} catch (e) {
console.error("[PlaylistDetail] Failed to remove track:", e);
toast.error("Failed to remove track");
}
}
function handleRenameKeydown(e: KeyboardEvent) {
if (e.key === "Enter") handleRename();
if (e.key === "Escape") {
editingName = false;
editName = playlist.name;
}
}
</script>
<div class="space-y-6">
<!-- Header -->
<div class="flex gap-6 pt-4">
<!-- Playlist artwork -->
<div class="flex-shrink-0 w-48">
{#if playlist.imageId}
<CachedImage
itemId={playlist.id}
imageType="Primary"
tag={playlist.imageId}
maxWidth={400}
alt={playlist.name}
class="w-full rounded-lg shadow-lg"
/>
{:else}
<div class="w-full aspect-square bg-[var(--color-surface)] rounded-lg flex items-center justify-center">
<svg class="w-16 h-16 text-gray-600" fill="currentColor" viewBox="0 0 24 24">
<path d="M15 6H3v2h12V6zm0 4H3v2h12v-2zM3 16h8v-2H3v2zM17 6v8.18c-.31-.11-.65-.18-1-.18-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3V8h3V6h-5z"/>
</svg>
</div>
{/if}
</div>
<!-- Info -->
<div class="flex-1 space-y-4">
<div>
{#if editingName}
<input
type="text"
bind:value={editName}
onkeydown={handleRenameKeydown}
onblur={handleRename}
class="text-3xl font-bold bg-transparent border-b-2 border-[var(--color-jellyfin)] text-white outline-none w-full"
/>
{:else}
<button
class="text-3xl font-bold text-white cursor-pointer hover:text-[var(--color-jellyfin)] transition-colors bg-transparent border-none p-0 text-left"
onclick={() => { editingName = true; editName = playlist.name; }}
title="Click to rename"
>
{playlist.name}
</button>
{/if}
<p class="text-sm text-gray-400 mt-1">
{entries.length} track{entries.length !== 1 ? "s" : ""}
{#if totalDuration > 0}
&middot; {formatDuration(totalDuration)}
{/if}
</p>
</div>
<!-- Actions -->
<div class="flex gap-3 flex-wrap">
<button
onclick={handlePlayAll}
disabled={entries.length === 0}
class="px-6 py-2 bg-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin-dark)] disabled:opacity-50 disabled:cursor-not-allowed rounded-lg font-medium flex items-center gap-2 transition-colors"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
Play All
</button>
<button
onclick={handleShufflePlay}
disabled={entries.length === 0}
class="px-6 py-2 bg-[var(--color-surface)] hover:bg-[var(--color-surface-hover)] disabled:opacity-50 disabled:cursor-not-allowed rounded-lg font-medium flex items-center gap-2 transition-colors"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M10.59 9.17L5.41 4 4 5.41l5.17 5.17 1.42-1.41zM14.5 4l2.04 2.04L4 18.59 5.41 20 17.96 7.46 20 9.5V4h-5.5zm.33 9.41l-1.41 1.41 3.13 3.13L14.5 20H20v-5.5l-2.04 2.04-3.13-3.13z"/>
</svg>
Shuffle
</button>
<!-- TRACES: UR-068 | DR-119 -->
<FavoriteButton
itemId={playlist.id}
isFavorite={$favoriteOverrides.get(playlist.id) ?? false}
size="lg"
className="self-center"
/>
<button
onclick={() => showDeleteConfirm = true}
class="px-4 py-2 bg-[var(--color-surface)] hover:bg-red-900/50 text-red-400 hover:text-red-300 rounded-lg font-medium flex items-center gap-2 transition-colors"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/>
</svg>
Delete
</button>
</div>
{#if playlist.overview}
<p class="text-gray-300 leading-relaxed max-w-2xl">{playlist.overview}</p>
{/if}
</div>
</div>
<!-- Tracks -->
<div class="space-y-4">
<h2 class="text-xl font-semibold text-white">Tracks</h2>
<TrackList
{tracks}
{loading}
showArtist={true}
showAlbum={true}
context={{ type: "playlist", playlistId: playlist.id, playlistName: playlist.name }}
/>
{#if !loading && entries.length > 0}
<div class="space-y-1 mt-4">
{#each entries as entry, i (entry.playlistItemId)}
<div class="flex items-center justify-end px-4 -mt-1">
<button
onclick={() => handleRemoveTrack(entry)}
class="text-gray-500 hover:text-red-400 p-1 transition-colors"
title="Remove from playlist"
>
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M19 13H5v-2h14v2z"/>
</svg>
</button>
</div>
{/each}
</div>
{/if}
</div>
<!-- Delete Confirmation Dialog -->
{#if showDeleteConfirm}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 bg-black/60 flex items-center justify-center z-50"
onclick={() => showDeleteConfirm = false}
onkeydown={(e) => { if (e.key === "Escape") showDeleteConfirm = false; }}
role="dialog"
aria-modal="true"
tabindex="-1"
>
<div
class="bg-[var(--color-surface)] rounded-xl p-6 max-w-sm mx-4 space-y-4"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
role="presentation"
>
<h3 class="text-lg font-semibold text-white">Delete Playlist?</h3>
<p class="text-gray-300">
Are you sure you want to delete "{playlist.name}"? This action cannot be undone.
</p>
<div class="flex gap-3 justify-end">
<button
onclick={() => showDeleteConfirm = false}
class="px-4 py-2 bg-[var(--color-surface-hover)] hover:bg-gray-600 rounded-lg transition-colors"
>
Cancel
</button>
<button
onclick={handleDelete}
class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors"
>
Delete
</button>
</div>
</div>
</div>
{/if}
</div>