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:
@@ -1,27 +1,60 @@
|
||||
<!-- TRACES: UR-017, UR-068 | DR-021, DR-119 -->
|
||||
<script lang="ts">
|
||||
import { toggleFavorite } from "$lib/services/favorites";
|
||||
import { haptics } from "$lib/utils/haptics";
|
||||
import { toast } from "$lib/stores/toast";
|
||||
import { favoriteOverrides } from "$lib/stores/favorites";
|
||||
|
||||
interface Props {
|
||||
itemId: string;
|
||||
isFavorite?: boolean;
|
||||
size?: "sm" | "md" | "lg";
|
||||
className?: string;
|
||||
/**
|
||||
* "button" (default) is the standalone control used in header/hero rows;
|
||||
* "overlay" is the artwork corner variant used on cards, which needs its
|
||||
* own scrim to stay legible over any poster.
|
||||
*/
|
||||
variant?: "button" | "overlay";
|
||||
/** Stop the click reaching a parent card/row that would navigate or play. */
|
||||
stopPropagation?: boolean;
|
||||
}
|
||||
|
||||
let { itemId, isFavorite = $bindable(false), size = "md", className = "" }: Props = $props();
|
||||
let {
|
||||
itemId,
|
||||
isFavorite = $bindable(false),
|
||||
size = "md",
|
||||
className = "",
|
||||
variant = "button",
|
||||
stopPropagation = false,
|
||||
}: Props = $props();
|
||||
|
||||
let isLoading = $state(false);
|
||||
let isAnimating = $state(false);
|
||||
|
||||
// A toggle from any other surface (or the backend's `favorites-changed`
|
||||
// refresh) wins over the prop we were mounted with — otherwise a heart tapped
|
||||
// on a card would still read empty on the detail page behind it.
|
||||
$effect(() => {
|
||||
const override = $favoriteOverrides.get(itemId);
|
||||
if (override !== undefined && override !== isFavorite) {
|
||||
isFavorite = override;
|
||||
}
|
||||
});
|
||||
|
||||
const sizeClasses = {
|
||||
sm: "w-4 h-4",
|
||||
md: "w-5 h-5",
|
||||
lg: "w-6 h-6",
|
||||
};
|
||||
|
||||
async function handleToggle() {
|
||||
async function handleToggle(event: MouseEvent) {
|
||||
// On a card the heart sits inside a clickable tile; without this, hearting
|
||||
// an item would also open (or play) it.
|
||||
if (stopPropagation) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
}
|
||||
if (isLoading) return;
|
||||
|
||||
isLoading = true;
|
||||
@@ -55,8 +88,15 @@
|
||||
|
||||
// Compute button classes
|
||||
const buttonClass = $derived.by(() => {
|
||||
const baseClasses = "p-2 rounded-full transition-all";
|
||||
const colorClasses = isFavorite ? "text-red-500 hover:text-red-400" : "text-gray-400 hover:text-white";
|
||||
const baseClasses =
|
||||
variant === "overlay"
|
||||
? "p-1.5 rounded-full transition-all bg-black/50 backdrop-blur-sm hover:bg-black/70"
|
||||
: "p-2 rounded-full transition-all";
|
||||
const colorClasses = isFavorite
|
||||
? "text-red-500 hover:text-red-400"
|
||||
: variant === "overlay"
|
||||
? "text-white/80 hover:text-white"
|
||||
: "text-gray-400 hover:text-white";
|
||||
const loadingClasses = isLoading ? "opacity-50 cursor-wait" : "";
|
||||
return `${baseClasses} ${colorClasses} ${loadingClasses} ${className}`.trim();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user