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.
This commit is contained in:
2026-08-04 17:35:17 +02:00
parent c55ff45692
commit 62873cab3d
52 changed files with 6110 additions and 191 deletions
@@ -6,6 +6,8 @@
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;
@@ -126,7 +128,15 @@
{/if}
<!-- Artist Name -->
<h1 class="text-4xl font-bold text-white mb-4">{artist.name}</h1>
<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}
@@ -4,6 +4,8 @@
import { truncateMiddle } from "$lib/utils/truncateMiddle";
import type { MediaItem } from "$lib/api/types";
import CachedImage from "$lib/components/common/CachedImage.svelte";
import FavoriteButton from "$lib/components/FavoriteButton.svelte";
import { favoriteOverrides, resolveIsFavorite } from "$lib/stores/favorites";
import {
isCurrentEpisode as isSameEpisode,
adjacentEpisodes as computeAdjacent,
@@ -166,8 +168,8 @@
</div>
{/if}
<!-- Play button -->
<div class="pt-2">
<!-- Play button + favourite. TRACES: UR-068 | DR-119 -->
<div class="pt-2 flex items-center gap-3">
<button
onclick={handlePlay}
class="px-8 py-3 bg-white text-black hover:bg-white/90 rounded-lg font-semibold text-lg flex items-center gap-2 transition-colors"
@@ -177,6 +179,11 @@
</svg>
{progress > 0 && progress < 95 ? "Resume" : "Play"}
</button>
<FavoriteButton
itemId={episode.id}
isFavorite={resolveIsFavorite(episode, $favoriteOverrides)}
size="lg"
/>
</div>
</div>
</div>
@@ -1,4 +1,4 @@
<!-- TRACES: UR-007, UR-029, UR-030 | DR-007, DR-032, DR-033 -->
<!-- TRACES: UR-007, UR-029, UR-030, UR-067 | DR-007, DR-032, DR-033, DR-116 -->
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
@@ -56,6 +56,7 @@
let gridWrapper = $state<HTMLDivElement | null>(null);
let searchQuery = $state("");
let debouncedSearchQuery = $state("");
let favoritesOnly = $state(false);
let sortBy = $state<string>("");
let sortOrder = $state<"Ascending" | "Descending">("Ascending");
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
@@ -140,6 +141,9 @@
sortOrder,
recursive: true,
limit: 10000,
// Narrows the listing in place; the backend owns what "favourite"
// resolves to online vs offline. TRACES: UR-067 | DR-116
favoritesOnly: favoritesOnly ? true : undefined,
});
items = excludePodcasts(result.items);
}
@@ -154,6 +158,12 @@
searchQuery = query;
}
/// TRACES: UR-067 | DR-116
function toggleFavoritesOnly() {
favoritesOnly = !favoritesOnly;
loadItems();
}
// Debounce search input (300ms delay) - skip initial mount to avoid duplicate load
$effect(() => {
const _query = searchQuery; // track for reactivity
@@ -266,6 +276,33 @@
<SearchBar value={searchQuery} placeholder={searchPlaceholder} onInput={handleSearch} />
</div>
<!-- Favourites filter. Session-scoped on purpose: a persisted filter that
hides most of a library reads as data loss on the next launch
(ux-flows §5C.2). Hidden while searching, which has no favourites
filter of its own. TRACES: UR-067 | DR-116 -->
{#if !debouncedSearchQuery.trim()}
<button
onclick={toggleFavoritesOnly}
aria-pressed={favoritesOnly}
class="px-3 py-2 rounded-lg text-sm font-medium flex items-center gap-2 transition-colors
{favoritesOnly
? 'bg-[var(--color-jellyfin)] text-white'
: 'bg-[var(--color-surface)] text-gray-400 hover:text-white'}"
title={favoritesOnly ? "Showing favourites only" : "Show favourites only"}
>
<svg
class="w-4 h-4"
fill={favoritesOnly ? "currentColor" : "none"}
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
Favourites
</button>
{/if}
<!-- Sort (only show if there are sort options) -->
{#if config.sortOptions.length > 0}
<SortButtonGroup options={config.sortOptions} selected={sortBy} onSelect={handleSort} />
+45 -8
View File
@@ -1,4 +1,4 @@
<!-- TRACES: UR-051, UR-052 | DR-068, DR-078 -->
<!-- TRACES: UR-051, UR-052, UR-068 | DR-068, DR-078, DR-119 -->
<script lang="ts">
import type { MediaItem, Library } from "$lib/api/types";
import { truncateMiddle } from "$lib/utils/truncateMiddle";
@@ -7,6 +7,8 @@
import { showServerCatalog } from "$lib/services/offlineCatalog";
import { auth } from "$lib/stores/auth";
import CachedImage from "$lib/components/common/CachedImage.svelte";
import FavoriteButton from "$lib/components/FavoriteButton.svelte";
import { favoriteOverrides, resolveIsFavorite } from "$lib/stores/favorites";
interface Props {
item: MediaItem | Library;
@@ -37,9 +39,15 @@
* TRACES: UR-058 | DR-087
*/
onLongPress?: () => void;
/**
* Show the favourite heart on the artwork. On by default for media items;
* surfaces that are not about the item itself can opt out.
* TRACES: UR-068 | DR-119
*/
showFavorite?: boolean;
}
let { item, size = "medium", showProgress = false, showDownloadStatus = true, sizeLabel, downloadedBadge, onRemove, onclick, onLongPress }: Props = $props();
let { item, size = "medium", showProgress = false, showDownloadStatus = true, sizeLabel, downloadedBadge, onRemove, onclick, onLongPress, showFavorite = true }: Props = $props();
// Long-press detection. We arm a timer on pointerdown; if it fires before the
// pointer is released (or moves too far), we treat it as a long press and set a
@@ -120,6 +128,13 @@
isMediaItem && !$isConnected && $showServerCatalog && !isDownloaded && !isActivelyDownloading
);
// The heart is about an item, so libraries never get one, and a greyed
// server-only card has nothing actionable to offer. TRACES: UR-068 | DR-119
const showHeart = $derived(showFavorite && isMediaItem && !isServerOnly);
const isFavorited = $derived(
isMediaItem ? resolveIsFavorite(item as MediaItem, $favoriteOverrides) : false
);
let queueError = $state<string | null>(null);
// Queue this item for download on next reconnect. Offline, this just persists
@@ -250,12 +265,34 @@
</div>
{/if}
<!-- Played indicator -->
{#if "userData" in item && item.userData?.isPlayed}
<div class="absolute top-2 right-2">
<svg class="w-5 h-5 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
</svg>
<!-- Top-right status stack: played tick, then the favourite heart. Grouped
so the two never land on the same pixels when both apply. -->
{#if ("userData" in item && item.userData?.isPlayed) || showHeart}
<div class="absolute top-2 right-2 flex flex-col items-end gap-1">
{#if "userData" in item && item.userData?.isPlayed}
<svg class="w-5 h-5 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
</svg>
{/if}
{#if showHeart}
<!-- Always visible on touch (no hover to reveal it); on pointer
devices an unfavourited heart stays out of the way until the card
is hovered or focused. A favourited one is always shown — it is
state, not an affordance. TRACES: UR-068 | DR-119 -->
<div
class="transition-opacity {isFavorited
? ''
: 'opacity-100 [@media(hover:hover)]:opacity-0 [@media(hover:hover)]:group-hover/card:opacity-100 [@media(hover:hover)]:group-focus-within/card:opacity-100'}"
>
<FavoriteButton
itemId={item.id}
isFavorite={isFavorited}
size="sm"
variant="overlay"
stopPropagation
/>
</div>
{/if}
</div>
{/if}
@@ -7,6 +7,8 @@
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 {
@@ -214,6 +216,13 @@
</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"