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