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
+15
View File
@@ -201,6 +201,20 @@
<div>
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-white">Your Libraries</h1>
<div class="flex items-center gap-1">
<!-- Favourites cut across libraries, so they live beside the library
list rather than inside one. ux-flows §5C.2.
TRACES: UR-067 | DR-117 -->
<button
onclick={() => goto('/library/favorites')}
class="p-2 rounded-lg hover:bg-white/10 transition-colors text-gray-400 hover:text-white"
title="Favourites"
aria-label="Favourites"
>
<svg class="w-6 h-6" fill="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>
</button>
<button
onclick={() => goto('/settings')}
class="p-2 rounded-lg hover:bg-white/10 transition-colors text-gray-400 hover:text-white"
@@ -211,6 +225,7 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</button>
</div>
</div>
{#if $isLibraryLoading}
+10
View File
@@ -19,6 +19,8 @@
import SeriesDownloadButton from "$lib/components/library/SeriesDownloadButton.svelte";
import ClearHistoryButton from "$lib/components/library/ClearHistoryButton.svelte";
import VideoDownloadButton from "$lib/components/library/VideoDownloadButton.svelte";
import FavoriteButton from "$lib/components/FavoriteButton.svelte";
import { favoriteOverrides, resolveIsFavorite } from "$lib/stores/favorites";
import CastSection from "$lib/components/library/CastSection.svelte";
import PersonDetailView from "$lib/components/library/PersonDetailView.svelte";
import RelatedItemsSection from "$lib/components/library/RelatedItemsSection.svelte";
@@ -557,6 +559,14 @@
size="lg"
/>
{/if}
<!-- Favourite. Sits with Play/Download rather than in the header,
per ux-flows §5B.3/§5B.4. TRACES: UR-068 | DR-119 -->
<FavoriteButton
itemId={item.id}
isFavorite={resolveIsFavorite(item, $favoriteOverrides)}
size="lg"
className="self-center"
/>
</div>
<!-- Overview -->
+144
View File
@@ -0,0 +1,144 @@
<!--
Favourites — everything the viewer has hearted, across every library.
Scope tabs are `?scope=`, so a tab is linkable and survives a back press (the
same convention as the video library `?view=` tabs). Each tab sends an opaque
`SearchScope`; what it *means* in Jellyfin item types is expanded in Rust
(`SearchScope::item_types`), never here — see docs/specs/scoped-search-boundary.md.
ux-flows §5C.2. TRACES: UR-067 | DR-117
-->
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import { page } from "$app/stores";
import { goto } from "$app/navigation";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import { auth } from "$lib/stores/auth";
import { isServerReachable } from "$lib/stores/connectivity";
import { useServerReachabilityReload } from "$lib/composables/useServerReachabilityReload";
import { navigateBack } from "$lib/utils/navigation";
import { favoriteOverrides, retainFavorites } from "$lib/stores/favorites";
import LibraryGrid from "$lib/components/library/LibraryGrid.svelte";
import BackButton from "$lib/components/common/BackButton.svelte";
import type { MediaItem, Library } from "$lib/api/types";
import {
FAVORITE_SCOPES,
FAVORITE_SCOPE_LABELS,
resolveFavoritesScope,
favoritesRouteUrl,
emptyStateMessage,
} from "$lib/utils/favoritesView";
const scope = $derived(resolveFavoritesScope($page.url.searchParams.get("scope")));
let items = $state<MediaItem[]>([]);
let loading = $state(true);
let loadError = $state<string | null>(null);
let unlistenFavorites: UnlistenFn | null = null;
// Un-hearting here must remove the card immediately rather than wait for a
// refetch; a newly hearted item stays put. TRACES: UR-067 | DR-117 | UT-106
const visibleItems = $derived(retainFavorites(items, $favoriteOverrides));
async function load(currentScope = scope) {
loading = true;
loadError = null;
try {
const repo = auth.getRepository();
const result = await repo.getFavorites(currentScope, { limit: 500 });
items = result.items;
} catch (error) {
console.error("Failed to load favorites:", error);
loadError = "Could not load your favourites.";
items = [];
} finally {
loading = false;
markLoaded();
}
}
// Reload when the tab changes.
let loadedScope = "";
$effect(() => {
if (scope === loadedScope) return;
loadedScope = scope;
load(scope);
});
onMount(async () => {
// The backend reports ids whose favourite state changed behind our back —
// a favourite marked in another client, or pending toggles pushed on
// reconnect. Refetch rather than patch: the scope decides what belongs.
// TRACES: UR-069 | DR-120
unlistenFavorites = await listen("favorites-changed", () => {
load(scope);
});
});
onDestroy(() => {
unlistenFavorites?.();
unlistenFavorites = null;
});
const { markLoaded } = useServerReachabilityReload(() => load(scope));
function selectScope(next: (typeof FAVORITE_SCOPES)[number]) {
if (next === scope) return;
// replaceState: switching tabs is not a back-press-worthy navigation step.
goto(favoritesRouteUrl(next), { replaceState: true, noScroll: true });
}
function handleItemClick(item: MediaItem | Library) {
goto(`/library/${item.id}`);
}
</script>
<div class="space-y-4">
<div class="flex items-center gap-3 px-4 pt-4">
<BackButton onClick={() => navigateBack("/library")} />
<h1 class="text-2xl font-bold text-white">Favourites</h1>
</div>
<nav class="flex items-center gap-1 px-4" aria-label="Favourite categories">
{#each FAVORITE_SCOPES as tab (tab)}
<button
onclick={() => selectScope(tab)}
aria-current={tab === scope ? "page" : undefined}
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors
{tab === scope
? 'bg-[var(--color-jellyfin)] text-white'
: 'text-gray-400 hover:text-white hover:bg-white/10'}"
>
{FAVORITE_SCOPE_LABELS[tab]}
</button>
{/each}
</nav>
<div class="px-4 pb-8">
{#if loading}
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
{#each Array(12) 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"></div>
</div>
{/each}
</div>
{:else if loadError}
<p class="text-gray-400 py-12 text-center">{loadError}</p>
{:else if visibleItems.length === 0}
<div class="py-16 text-center space-y-2">
<p class="text-gray-300">{emptyStateMessage(scope)}</p>
{#if !$isServerReachable}
<p class="text-sm text-gray-500">
Offline — showing favourites available on this device.
</p>
{/if}
</div>
{:else}
<!-- Card shape follows the media, not the page (§5A.1), so a mixed All
tab reads as posters, squares and thumbnails side by side. -->
<LibraryGrid items={visibleItems} onItemClick={handleItemClick} />
{/if}
</div>
</div>