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:
@@ -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}
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user