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
+22 -5
View File
@@ -3,6 +3,7 @@
import { get } from "svelte/store";
import { page } from "$app/stores";
import { platform } from "@tauri-apps/plugin-os";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import "../app.css";
import { auth, needsReauth, isAuthenticated } from "$lib/stores/auth";
import { connectivity, isConnected } from "$lib/stores/connectivity";
@@ -10,7 +11,8 @@
import { initWebviewAudio, cleanupWebviewAudio } from "$lib/services/webviewAudio";
import { downloads, initDownloadEvents, cleanupDownloadEvents } from "$lib/stores/downloads";
import { syncService } from "$lib/services/syncService";
import { onReconnected as onCatalogReconnected, syncCatalog, refreshSyncStatus, showServerCatalog, lastCatalogSync } from "$lib/services/offlineCatalog";
import { clearFavorite } from "$lib/stores/favorites";
import { onReconnected as onCatalogReconnected, refreshSyncStatus, showServerCatalog, lastCatalogSync } from "$lib/services/offlineCatalog";
import { playbackMode } from "$lib/stores/playbackMode";
import { sessions } from "$lib/stores/sessions";
import ReauthModal from "$lib/components/auth/ReauthModal.svelte";
@@ -34,6 +36,7 @@
/** Teardown for the network-transport reporter (WiFi-only gate). */
let stopNetworkReporting: (() => void) | null = null;
let stopFavoritesListener: UnlistenFn | null = null;
/** Teardown for the native window-inset subscription (safe areas). */
let stopSafeArea: (() => void) | null = null;
@@ -115,6 +118,18 @@
// Initialize download event listener
await initDownloadEvents();
// Favourite state can change behind the UI: another Jellyfin client marks
// something, or the Rust drain pushes toggles queued while offline. Drop
// the session overrides for those ids so the next render reads the freshly
// cached server value rather than a stale local guess.
// TRACES: UR-069 | DR-120
stopFavoritesListener = await listen<{ itemIds: string[] }>(
"favorites-changed",
(event) => {
for (const id of event.payload?.itemIds ?? []) clearFavorite(id);
}
);
// Report the network transport to the backend and keep it current, so the
// WiFi-only download gate has real data to act on (UR-053). No-op on
// desktop, where the backend defaults to unmetered.
@@ -134,10 +149,10 @@
// Start sync service for offline mutation queue
syncService.start();
// Kick off a best-effort full-catalog pre-sync so the whole server catalog
// is browsable (greyed out) offline, and load the last-sync hint for the
// offline banner. Non-blocking — no-ops when not connected.
void syncCatalog();
// Load the last-sync hint for the offline banner. The catalog *index* is no
// longer kicked off from here: the Rust background indexer (DR-109) owns
// when to re-index, so a long session no longer searches a stale catalog and
// a restart no longer forces a full crawl regardless of freshness.
void refreshSyncStatus();
// Initialize playback mode and session monitoring
@@ -147,6 +162,8 @@
onDestroy(() => {
stopNetworkReporting?.();
stopFavoritesListener?.();
stopFavoritesListener = null;
stopSafeArea?.();
cleanupPlayerEvents();
cleanupWebviewAudio();
+38
View File
@@ -133,6 +133,11 @@
const nextUpItems = $derived($home.nextUpItems);
const latestItems = $derived($home.latestItems);
const recentlyPlayedAudio = $derived($home.recentlyPlayedAudio);
// Favourite rows. Each is hidden when empty, so a fresh install shows none.
// TRACES: UR-067 | DR-118
const favoriteMovies = $derived($home.favoriteMovies);
const favoriteShows = $derived($home.favoriteShows);
const favoriteMusic = $derived($home.favoriteMusic);
const resumeMovies = $derived($home.resumeMovies);
const isLoading = $derived($home.isLoading);
</script>
@@ -218,6 +223,39 @@
/>
{/if}
<!-- Favourites. Hidden entirely when a category has nothing in it —
empty rows on a fresh install read as broken. ux-flows §5C.2.
TRACES: UR-067 | DR-118 -->
{#if favoriteMovies.length > 0}
<Carousel
title="Favourite Movies"
items={favoriteMovies}
onItemClick={handleItemClick}
onItemLongPress={handleItemLongPress}
showAll={() => goto("/library/favorites?scope=movies")}
/>
{/if}
{#if favoriteShows.length > 0}
<Carousel
title="Favourite Shows"
items={favoriteShows}
onItemClick={handleItemClick}
onItemLongPress={handleItemLongPress}
showAll={() => goto("/library/favorites?scope=tv")}
/>
{/if}
{#if favoriteMusic.length > 0}
<Carousel
title="Favourite Music"
items={favoriteMusic}
onItemClick={handleItemClick}
onItemLongPress={handleItemLongPress}
showAll={() => goto("/library/favorites?scope=music")}
/>
{/if}
<!-- Quick Access -->
<div class="pt-4 px-4">
<button
+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>
+22 -4
View File
@@ -4,6 +4,7 @@
import { goto } from "$app/navigation";
import { convertFileSrc } from "@tauri-apps/api/core";
import { commands } from "$lib/api/bindings";
import { resolveVideoSource } from "$lib/player/localSource";
import type { PlayQueueRequest } from "$lib/api/bindings";
import type { MediaItem, MediaKind } from "$lib/api/types";
import { auth } from "$lib/stores/auth";
@@ -299,11 +300,28 @@
// Playback API now detects HEVC/10-bit and returns transcoded URL when needed
console.log("loadAndPlay: Using video stream, directPlay:", playbackInfo.directPlay, "needsTranscoding:", playbackInfo.needsTranscoding);
mediaSourceId = playbackInfo.mediaSourceId;
videoNeedsTranscoding = playbackInfo.needsTranscoding;
// Use the stream URL from playback info (already transcoded if needed)
streamUrl = playbackInfo.streamUrl;
console.log("loadAndPlay: Using stream URL:", streamUrl);
// Prefer a completed download over streaming. Audio has done this
// since the queue is built; video previously always streamed, so a
// downloaded film re-spent bandwidth already spent and would not play
// at all offline. Rust returns null when nothing is downloaded or the
// file has gone, so this falls back to the server on its own.
// TRACES: UR-071 | DR-123
const localPath = await commands.playerLocalMediaPath(id);
const source = resolveVideoSource({
localPath,
remoteUrl: playbackInfo.streamUrl,
remoteNeedsTranscoding: playbackInfo.needsTranscoding,
toAssetUrl: convertFileSrc,
});
streamUrl = source.url;
videoNeedsTranscoding = source.needsTranscoding;
console.log(
source.isLocal
? "loadAndPlay: Playing downloaded file from disk"
: `loadAndPlay: Using stream URL: ${streamUrl}`
);
// Set initial position for video player to seek to after load
// Use explicit startPosition, or fall back to retrieved progress from database
+4
View File
@@ -69,6 +69,10 @@
albumAffinityThreshold: 3,
storageLimit: 10 * 1024 * 1024 * 1024,
wifiOnly: false,
// Placeholder only — replaced by the backend's value on load. How long a
// temporary (auto-cached) download lives before it is reclaimed; the policy
// itself is Rust's (DR-127).
temporaryTtlHours: 24 * 7,
});
// Whether the platform can actually detect the network type. On desktop it