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
+55 -4
View File
@@ -238,7 +238,7 @@ async playerPlayNextEpisode(item: PlayItemRequest) : Promise<PlayerStatus> {
* - Frontend when audio track ends via backend event - no itemId/repositoryHandle needed
* - Android JNI callback also triggers this logic directly
*
* TRACES: UR-023, UR-026, UR-040 | DR-047, DR-052
* TRACES: UR-023, UR-026, UR-040 | DR-047, DR-052, DR-129
*/
async playerOnPlaybackEnded(itemId: string | null, repositoryHandle: string | null) : Promise<null> {
return await TAURI_INVOKE("player_on_playback_ended", { itemId, repositoryHandle });
@@ -262,6 +262,23 @@ async playerReportPosition(position: number, duration: number) : Promise<null> {
async playerReportMediaLoaded(duration: number) : Promise<null> {
return await TAURI_INVOKE("player_report_media_loaded", { duration });
},
/**
* The on-disk path for a downloaded item, for playback surfaces that resolve
* their own source rather than going through the queue.
*
* The video player is the reason this exists: audio has preferred local files
* since queue construction, but video asks the repository for a stream URL and
* never consults `downloads`, so a downloaded film was still streamed — costing
* bandwidth that had already been spent and failing outright when offline.
*
* Returns `None` when nothing is downloaded *or* the file is missing, so the
* caller falls back to streaming.
*
* TRACES: UR-071 | DR-123 | UT-116
*/
async playerLocalMediaPath(itemId: string) : Promise<string | null> {
return await TAURI_INVOKE("player_local_media_path", { itemId });
},
/**
* Preload upcoming tracks from the queue
* This queues background downloads for the next N tracks that aren't already downloaded
@@ -1422,6 +1439,20 @@ async repositoryMarkFavorite(handle: string, itemId: string) : Promise<null> {
async repositoryUnmarkFavorite(handle: string, itemId: string) : Promise<null> {
return await TAURI_INVOKE("repository_unmark_favorite", { handle, itemId });
},
/**
* Everything the viewer has favourited, across libraries, narrowed by scope.
*
* Two-phase like `repository_search`: the local answer returns immediately and
* a background server pass emits `favorites-changed` when the server's set
* differs. Without the second phase a favourite marked in another client shows
* up only on the *second* visit to the page, since the cache-first read hands
* back local rows and the refresh is invisible to the frontend.
*
* TRACES: UR-067 | DR-115, DR-120, JA-033 | UT-107
*/
async repositoryGetFavorites(handle: string, scope: SearchScope, options: GetItemsOptions | null) : Promise<SearchResult> {
return await TAURI_INVOKE("repository_get_favorites", { handle, scope, options });
},
/**
* Get person details
*/
@@ -1704,7 +1735,15 @@ storageLimit: number;
/**
* Only cache on WiFi
*/
wifiOnly: boolean }
wifiOnly: boolean;
/**
* How long a temporary (`download_source = 'auto'`) download lives before
* it is reclaimed, in hours. 0 disables expiry, leaving space pressure as
* the only reclaim trigger.
*
* TRACES: UR-071 | DR-127
*/
temporaryTtlHours: number }
/**
* Cached media item returned to frontend
*/
@@ -1729,7 +1768,12 @@ itemsCached: number;
/**
* Libraries that failed to sync (e.g. server hiccup); best-effort.
*/
librariesFailed: number }
librariesFailed: number;
/**
* Entries removed because the server no longer has them. Always 0 when any
* library failed, since a partial crawl cannot prove an item is gone.
*/
itemsPruned: number }
export type CatalogSyncStatus = {
/**
* RFC-3339 timestamp of the last successful sync, if any.
@@ -1837,7 +1881,14 @@ export type GetImageRequest = { itemId: string; imageType: string; maxWidth?: nu
/**
* Options for querying items
*/
export type GetItemsOptions = { startIndex?: number | null; limit?: number | null; sortBy?: string | null; sortOrder?: string | null; includeItemTypes?: string[] | null; recursive?: boolean | null; fields?: string[] | null; genres?: string[] | null }
export type GetItemsOptions = { startIndex?: number | null; limit?: number | null; sortBy?: string | null; sortOrder?: string | null; includeItemTypes?: string[] | null; recursive?: boolean | null; fields?: string[] | null; genres?: string[] | null;
/**
* Restrict the listing to favourited items. Backs the per-library
* favourites toggle; composes with every other filter here.
*
* TRACES: UR-067 | DR-116 | UT-104
*/
favoritesOnly?: boolean | null }
/**
* Image options
*/
+15 -1
View File
@@ -3,7 +3,7 @@
// NO direct HTTP calls - everything routes through Rust backend
import { commands } from "./bindings";
import type { JRayActor, DownloadDiskUsage } from "./bindings";
import type { JRayActor, DownloadDiskUsage, SearchScope } from "./bindings";
import type { QualityPreset } from "./quality-presets";
import type {
Library,
@@ -311,6 +311,20 @@ export class RepositoryClient {
await commands.repositoryUnmarkFavorite(this.ensureHandle(), itemId);
}
/**
* Everything favourited, across libraries, narrowed by an opaque scope the
* backend expands into item types. The frontend never names a Jellyfin type
* here — see docs/specs/scoped-search-boundary.md.
*
* Resolves with the local answer; a later `favorites-changed` event reports
* ids the server disagreed with.
*
* TRACES: UR-067 | DR-115
*/
async getFavorites(scope: SearchScope, options?: GetItemsOptions): Promise<SearchResult> {
return commands.repositoryGetFavorites(this.ensureHandle(), scope, options ?? null);
}
// ===== Person Methods (via Rust) =====
async getPerson(personId: string): Promise<MediaItem> {
+44 -4
View File
@@ -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();
});
@@ -6,6 +6,8 @@
import LibraryGrid from "./LibraryGrid.svelte";
import TrackList from "./TrackList.svelte";
import CachedImage from "$lib/components/common/CachedImage.svelte";
import FavoriteButton from "$lib/components/FavoriteButton.svelte";
import { favoriteOverrides, resolveIsFavorite } from "$lib/stores/favorites";
interface Props {
artist: MediaItem;
@@ -126,7 +128,15 @@
{/if}
<!-- Artist Name -->
<h1 class="text-4xl font-bold text-white mb-4">{artist.name}</h1>
<div class="flex items-center gap-2 mb-4">
<h1 class="text-4xl font-bold text-white">{artist.name}</h1>
<!-- TRACES: UR-068 | DR-119 -->
<FavoriteButton
itemId={artist.id}
isFavorite={resolveIsFavorite(artist, $favoriteOverrides)}
size="lg"
/>
</div>
<!-- Bio -->
{#if artist.overview}
@@ -4,6 +4,8 @@
import { truncateMiddle } from "$lib/utils/truncateMiddle";
import type { MediaItem } from "$lib/api/types";
import CachedImage from "$lib/components/common/CachedImage.svelte";
import FavoriteButton from "$lib/components/FavoriteButton.svelte";
import { favoriteOverrides, resolveIsFavorite } from "$lib/stores/favorites";
import {
isCurrentEpisode as isSameEpisode,
adjacentEpisodes as computeAdjacent,
@@ -166,8 +168,8 @@
</div>
{/if}
<!-- Play button -->
<div class="pt-2">
<!-- Play button + favourite. TRACES: UR-068 | DR-119 -->
<div class="pt-2 flex items-center gap-3">
<button
onclick={handlePlay}
class="px-8 py-3 bg-white text-black hover:bg-white/90 rounded-lg font-semibold text-lg flex items-center gap-2 transition-colors"
@@ -177,6 +179,11 @@
</svg>
{progress > 0 && progress < 95 ? "Resume" : "Play"}
</button>
<FavoriteButton
itemId={episode.id}
isFavorite={resolveIsFavorite(episode, $favoriteOverrides)}
size="lg"
/>
</div>
</div>
</div>
@@ -1,4 +1,4 @@
<!-- TRACES: UR-007, UR-029, UR-030 | DR-007, DR-032, DR-033 -->
<!-- TRACES: UR-007, UR-029, UR-030, UR-067 | DR-007, DR-032, DR-033, DR-116 -->
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
@@ -56,6 +56,7 @@
let gridWrapper = $state<HTMLDivElement | null>(null);
let searchQuery = $state("");
let debouncedSearchQuery = $state("");
let favoritesOnly = $state(false);
let sortBy = $state<string>("");
let sortOrder = $state<"Ascending" | "Descending">("Ascending");
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
@@ -140,6 +141,9 @@
sortOrder,
recursive: true,
limit: 10000,
// Narrows the listing in place; the backend owns what "favourite"
// resolves to online vs offline. TRACES: UR-067 | DR-116
favoritesOnly: favoritesOnly ? true : undefined,
});
items = excludePodcasts(result.items);
}
@@ -154,6 +158,12 @@
searchQuery = query;
}
/// TRACES: UR-067 | DR-116
function toggleFavoritesOnly() {
favoritesOnly = !favoritesOnly;
loadItems();
}
// Debounce search input (300ms delay) - skip initial mount to avoid duplicate load
$effect(() => {
const _query = searchQuery; // track for reactivity
@@ -266,6 +276,33 @@
<SearchBar value={searchQuery} placeholder={searchPlaceholder} onInput={handleSearch} />
</div>
<!-- Favourites filter. Session-scoped on purpose: a persisted filter that
hides most of a library reads as data loss on the next launch
(ux-flows §5C.2). Hidden while searching, which has no favourites
filter of its own. TRACES: UR-067 | DR-116 -->
{#if !debouncedSearchQuery.trim()}
<button
onclick={toggleFavoritesOnly}
aria-pressed={favoritesOnly}
class="px-3 py-2 rounded-lg text-sm font-medium flex items-center gap-2 transition-colors
{favoritesOnly
? 'bg-[var(--color-jellyfin)] text-white'
: 'bg-[var(--color-surface)] text-gray-400 hover:text-white'}"
title={favoritesOnly ? "Showing favourites only" : "Show favourites only"}
>
<svg
class="w-4 h-4"
fill={favoritesOnly ? "currentColor" : "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>
Favourites
</button>
{/if}
<!-- Sort (only show if there are sort options) -->
{#if config.sortOptions.length > 0}
<SortButtonGroup options={config.sortOptions} selected={sortBy} onSelect={handleSort} />
+45 -8
View File
@@ -1,4 +1,4 @@
<!-- TRACES: UR-051, UR-052 | DR-068, DR-078 -->
<!-- TRACES: UR-051, UR-052, UR-068 | DR-068, DR-078, DR-119 -->
<script lang="ts">
import type { MediaItem, Library } from "$lib/api/types";
import { truncateMiddle } from "$lib/utils/truncateMiddle";
@@ -7,6 +7,8 @@
import { showServerCatalog } from "$lib/services/offlineCatalog";
import { auth } from "$lib/stores/auth";
import CachedImage from "$lib/components/common/CachedImage.svelte";
import FavoriteButton from "$lib/components/FavoriteButton.svelte";
import { favoriteOverrides, resolveIsFavorite } from "$lib/stores/favorites";
interface Props {
item: MediaItem | Library;
@@ -37,9 +39,15 @@
* TRACES: UR-058 | DR-087
*/
onLongPress?: () => void;
/**
* Show the favourite heart on the artwork. On by default for media items;
* surfaces that are not about the item itself can opt out.
* TRACES: UR-068 | DR-119
*/
showFavorite?: boolean;
}
let { item, size = "medium", showProgress = false, showDownloadStatus = true, sizeLabel, downloadedBadge, onRemove, onclick, onLongPress }: Props = $props();
let { item, size = "medium", showProgress = false, showDownloadStatus = true, sizeLabel, downloadedBadge, onRemove, onclick, onLongPress, showFavorite = true }: Props = $props();
// Long-press detection. We arm a timer on pointerdown; if it fires before the
// pointer is released (or moves too far), we treat it as a long press and set a
@@ -120,6 +128,13 @@
isMediaItem && !$isConnected && $showServerCatalog && !isDownloaded && !isActivelyDownloading
);
// The heart is about an item, so libraries never get one, and a greyed
// server-only card has nothing actionable to offer. TRACES: UR-068 | DR-119
const showHeart = $derived(showFavorite && isMediaItem && !isServerOnly);
const isFavorited = $derived(
isMediaItem ? resolveIsFavorite(item as MediaItem, $favoriteOverrides) : false
);
let queueError = $state<string | null>(null);
// Queue this item for download on next reconnect. Offline, this just persists
@@ -250,12 +265,34 @@
</div>
{/if}
<!-- Played indicator -->
{#if "userData" in item && item.userData?.isPlayed}
<div class="absolute top-2 right-2">
<svg class="w-5 h-5 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
</svg>
<!-- Top-right status stack: played tick, then the favourite heart. Grouped
so the two never land on the same pixels when both apply. -->
{#if ("userData" in item && item.userData?.isPlayed) || showHeart}
<div class="absolute top-2 right-2 flex flex-col items-end gap-1">
{#if "userData" in item && item.userData?.isPlayed}
<svg class="w-5 h-5 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
</svg>
{/if}
{#if showHeart}
<!-- Always visible on touch (no hover to reveal it); on pointer
devices an unfavourited heart stays out of the way until the card
is hovered or focused. A favourited one is always shown — it is
state, not an affordance. TRACES: UR-068 | DR-119 -->
<div
class="transition-opacity {isFavorited
? ''
: 'opacity-100 [@media(hover:hover)]:opacity-0 [@media(hover:hover)]:group-hover/card:opacity-100 [@media(hover:hover)]:group-focus-within/card:opacity-100'}"
>
<FavoriteButton
itemId={item.id}
isFavorite={isFavorited}
size="sm"
variant="overlay"
stopPropagation
/>
</div>
{/if}
</div>
{/if}
@@ -7,6 +7,8 @@
import { toast } from "$lib/stores/toast";
import TrackList from "./TrackList.svelte";
import CachedImage from "$lib/components/common/CachedImage.svelte";
import FavoriteButton from "$lib/components/FavoriteButton.svelte";
import { favoriteOverrides } from "$lib/stores/favorites";
import { formatDuration } from "$lib/utils/duration";
interface Props {
@@ -214,6 +216,13 @@
</svg>
Shuffle
</button>
<!-- TRACES: UR-068 | DR-119 -->
<FavoriteButton
itemId={playlist.id}
isFavorite={$favoriteOverrides.get(playlist.id) ?? false}
size="lg"
className="self-center"
/>
<button
onclick={() => showDeleteConfirm = true}
class="px-4 py-2 bg-[var(--color-surface)] hover:bg-red-900/50 text-red-400 hover:text-red-300 rounded-lg font-medium flex items-center gap-2 transition-colors"
+73
View File
@@ -0,0 +1,73 @@
import { describe, it, expect } from "vitest";
import { resolveVideoSource } from "./localSource";
// A stand-in for Tauri's convertFileSrc, so the module stays pure.
const toAssetUrl = (p: string) => `asset://localhost/${encodeURIComponent(p)}`;
describe("resolveVideoSource", () => {
it("plays the downloaded file when one exists", () => {
const decision = resolveVideoSource({
localPath: "/home/u/.local/share/jellytau/movie.mp4",
remoteUrl: "https://server/Videos/abc/master.m3u8",
remoteNeedsTranscoding: true,
toAssetUrl,
});
expect(decision.isLocal).toBe(true);
expect(decision.url).toBe(toAssetUrl("/home/u/.local/share/jellytau/movie.mp4"));
});
it("never marks a local file as needing transcoding, even when the remote did", () => {
// The transcoded path re-requests a whole new stream URL on every seek.
// A local file seeks natively; sending it down that route would ask the
// server for a stream we deliberately avoided.
const decision = resolveVideoSource({
localPath: "/downloads/film.mkv",
remoteUrl: "https://server/Videos/abc/master.m3u8",
remoteNeedsTranscoding: true,
toAssetUrl,
});
expect(decision.needsTranscoding).toBe(false);
});
it("streams when nothing is downloaded, preserving the transcoding flag", () => {
const decision = resolveVideoSource({
localPath: null,
remoteUrl: "https://server/Videos/abc/master.m3u8",
remoteNeedsTranscoding: true,
toAssetUrl,
});
expect(decision).toEqual({
url: "https://server/Videos/abc/master.m3u8",
needsTranscoding: true,
isLocal: false,
});
});
it("streams a direct-play remote without claiming it transcodes", () => {
const decision = resolveVideoSource({
localPath: null,
remoteUrl: "https://server/Videos/abc/stream.mp4",
remoteNeedsTranscoding: false,
toAssetUrl,
});
expect(decision.needsTranscoding).toBe(false);
expect(decision.isLocal).toBe(false);
});
it("falls back to streaming for a blank path rather than building a dead asset URL", () => {
for (const localPath of ["", " "]) {
const decision = resolveVideoSource({
localPath,
remoteUrl: "https://server/stream",
remoteNeedsTranscoding: false,
toAssetUrl,
});
expect(decision.isLocal).toBe(false);
expect(decision.url).toBe("https://server/stream");
}
});
});
+49
View File
@@ -0,0 +1,49 @@
/**
* Choosing between a downloaded file and a server stream for video playback.
*
* Audio has preferred local files since the queue is built (the Rust queue
* resolves `MediaSource::Local`), but video asks the repository for a stream URL
* and never consults `downloads` so a downloaded film was streamed anyway,
* spending bandwidth that had already been spent and failing outright offline.
*
* Pure so it can be unit-tested: the component only supplies the two inputs and
* the asset-URL converter.
*
* TRACES: UR-071 | DR-123 | UT-118
*/
export interface VideoSourceInputs {
/** Absolute on-disk path of a completed download, or null to stream. */
localPath: string | null;
/** Stream URL the repository resolved (already transcoded if it had to be). */
remoteUrl: string;
/** Whether the *remote* stream is a transcode. */
remoteNeedsTranscoding: boolean;
/** Usually Tauri's `convertFileSrc`; injected so this module stays pure. */
toAssetUrl: (path: string) => string;
}
export interface VideoSourceDecision {
/** What to hand the `<video>` element. */
url: string;
/**
* Local files are never transcodes, so this is always false for them. It
* matters because the transcoded path re-requests a whole new stream URL on
* every seek; a local file seeks natively and must not go down that route.
*/
needsTranscoding: boolean;
/** True when playing from disk — for logging and the offline badge. */
isLocal: boolean;
}
export function resolveVideoSource(inputs: VideoSourceInputs): VideoSourceDecision {
const { localPath, remoteUrl, remoteNeedsTranscoding, toAssetUrl } = inputs;
// Treat blank/whitespace paths as absent — a malformed `downloads` row must
// not produce an asset URL pointing at nothing.
if (localPath && localPath.trim() !== "") {
return { url: toAssetUrl(localPath), needsTranscoding: false, isLocal: true };
}
return { url: remoteUrl, needsTranscoding: remoteNeedsTranscoding, isLocal: false };
}
+6 -1
View File
@@ -1,10 +1,11 @@
// Favorites service - Handles toggling favorite status with optimistic updates
// TRACES: UR-017 | DR-021
// TRACES: UR-017, UR-068 | DR-021, DR-119
import { get } from "svelte/store";
import { commands } from "$lib/api/bindings";
import { auth } from "$lib/stores/auth";
import { isConnected } from "$lib/stores/connectivity";
import { setFavorite } from "$lib/stores/favorites";
/**
* Toggle the favorite status of an item.
@@ -33,6 +34,10 @@ export async function toggleFavorite(
// 1. Update local database first (optimistic update)
await commands.storageToggleFavorite(userId, itemId, newIsFavorite);
// Publish to every mounted view at once, so the heart on a card, the detail
// page and the Favourites grid never disagree. TRACES: UR-068 | DR-119
setFavorite(itemId, newIsFavorite);
// 2. Sync to Jellyfin server.
//
// Only attempt this when we're actually connected. When offline, the server
+12 -5
View File
@@ -66,9 +66,14 @@ function currentHandle(): string | null {
}
/**
* Walk every library and cache the full catalog. Best-effort and non-blocking:
* safe to call on startup (while online) and on reconnect. No-ops if not
* connected or a sync is already running.
* Force a full re-index now, ignoring freshness.
*
* Routine scheduling is the Rust indexer's job (DR-109) this is the manual
* override, for a "re-index now" affordance. It is deliberately *not* called on
* startup or reconnect any more: doing so forced a full crawl on every launch
* regardless of how fresh the index was.
*
* The backend refuses overlapping passes, so this is safe to call at any time.
*/
export async function syncCatalog(): Promise<void> {
if (syncInProgress) return;
@@ -124,6 +129,8 @@ export async function refreshSyncStatus(): Promise<void> {
*/
export async function onReconnected(): Promise<void> {
await resumeQueued();
// Fire-and-forget: don't block reconnection handling on a potentially long walk.
void syncCatalog();
// Re-indexing on reconnect is the Rust indexer's job (DR-109) — it re-checks
// staleness every tick, so it picks this up without a nudge from here. Queued
// downloads still need resolving from the frontend, which is why this
// function remains.
}
+2 -8
View File
@@ -17,6 +17,7 @@ function makeConfig(overrides: Partial<CacheConfig> = {}): CacheConfig {
albumAffinityThreshold: 0.75,
storageLimit: 2 * 1024 * 1024 * 1024,
wifiOnly: false,
temporaryTtlHours: 24 * 7,
...overrides,
};
}
@@ -177,14 +178,7 @@ describe("preload service", () => {
});
it("should support all config options", async () => {
const config = {
queuePrecacheEnabled: true,
queuePrecacheCount: 5,
albumAffinityEnabled: false,
albumAffinityThreshold: 0.75,
storageLimit: 2 * 1024 * 1024 * 1024,
wifiOnly: true,
};
const config = makeConfig({ wifiOnly: true, albumAffinityEnabled: false });
await expect(updateCacheConfig(config)).resolves.toBeUndefined();
});
+4 -13
View File
@@ -84,19 +84,10 @@ class SyncService {
return id;
}
/**
* Queue a favorite toggle
* Also updates local state immediately
*/
async queueFavorite(itemId: string, isFavorite: boolean): Promise<number> {
// Update local state first
await commands.storageToggleFavorite(auth.getUserId() ?? "", itemId, isFavorite);
return this.queueMutation(
isFavorite ? "mark_favorite" : "unmark_favorite",
itemId
);
}
// NOTE: `queueFavorite` is gone. Favourites are drained by Rust on the
// `connectivity:reconnected` signal (DR-120) — the local write already sets
// `pending_sync`, and a second queue here would push the same change twice.
// See src-tauri/src/commands/favorites.rs.
/**
* Queue playback progress update
+101
View File
@@ -0,0 +1,101 @@
// TRACES: UR-067, UR-068 | DR-117, DR-119 | UT-105, UT-106
import { describe, it, expect, beforeEach } from "vitest";
import { get } from "svelte/store";
import type { MediaItem } from "$lib/api/types";
import {
favoriteOverrides,
setFavorite,
clearFavorite,
clearAllFavorites,
resolveIsFavorite,
isFavoriteNow,
retainFavorites,
} from "./favorites";
function item(id: string, isFavorite?: boolean): MediaItem {
return {
id,
name: `Item ${id}`,
type: "Movie",
kind: "movie",
isFolder: false,
serverId: "s1",
userData: isFavorite === undefined ? undefined : { isFavorite },
} as unknown as MediaItem;
}
describe("favorites store", () => {
beforeEach(() => clearAllFavorites());
describe("resolveIsFavorite (UT-105)", () => {
it("falls back to the server's userData when nothing was toggled here", () => {
expect(resolveIsFavorite(item("a", true), new Map())).toBe(true);
expect(resolveIsFavorite(item("a", false), new Map())).toBe(false);
});
it("treats an item with no userData as not favourited", () => {
expect(resolveIsFavorite(item("a"), new Map())).toBe(false);
});
it("lets a session override win over userData", () => {
// The whole point: after tapping the heart on a card, the item object
// still carries the server's stale value until the next fetch.
expect(resolveIsFavorite(item("a", false), new Map([["a", true]]))).toBe(true);
expect(resolveIsFavorite(item("a", true), new Map([["a", false]]))).toBe(false);
});
it("is false for a missing item rather than throwing", () => {
expect(resolveIsFavorite(null, new Map())).toBe(false);
expect(resolveIsFavorite(undefined, new Map())).toBe(false);
});
});
describe("overrides", () => {
it("publishes a toggle to subscribers", () => {
setFavorite("a", true);
expect(get(favoriteOverrides).get("a")).toBe(true);
expect(isFavoriteNow(item("a", false))).toBe(true);
setFavorite("a", false);
expect(isFavoriteNow(item("a", true))).toBe(false);
});
it("clearing an override hands authority back to the item's userData", () => {
setFavorite("a", false);
expect(isFavoriteNow(item("a", true))).toBe(false);
clearFavorite("a");
expect(isFavoriteNow(item("a", true))).toBe(true);
});
it("replaces the map so Svelte sees a new reference", () => {
const before = get(favoriteOverrides);
setFavorite("a", true);
expect(get(favoriteOverrides)).not.toBe(before);
});
});
describe("retainFavorites (UT-106)", () => {
it("drops an item un-favourited during this session", () => {
const items = [item("a", true), item("b", true)];
const kept = retainFavorites(items, new Map([["a", false]]));
expect(kept.map((i) => i.id)).toEqual(["b"]);
});
it("keeps everything when nothing was toggled", () => {
const items = [item("a", true), item("b", true)];
expect(retainFavorites(items, new Map())).toHaveLength(2);
});
it("keeps an item favourited during this session even if the server said otherwise", () => {
const items = [item("a", false)];
expect(retainFavorites(items, new Map([["a", true]]))).toHaveLength(1);
});
it("drops items the server never marked as favourites", () => {
// A listing fetched with a stale scope should not keep non-favourites.
expect(retainFavorites([item("a")], new Map())).toHaveLength(0);
});
});
});
+89
View File
@@ -0,0 +1,89 @@
// Favourites overlay — in-session heart state shared across every surface.
//
// The durable record lives in Rust (local `user_data` + the Jellyfin server).
// This store holds only what the *current session* has changed, so a heart
// tapped on a card is reflected on the detail page and the item vanishes from
// the Favourites grid without anyone refetching. It is view state, not truth.
//
// TRACES: UR-068 | DR-119 | UT-105
import { derived, get, writable } from "svelte/store";
import type { MediaItem } from "$lib/api/types";
/** Item id → favourite state set during this session. */
const overrides = writable<Map<string, boolean>>(new Map());
export const favoriteOverrides = { subscribe: overrides.subscribe };
/**
* Record a favourite state locally so every mounted view agrees immediately.
* Called by the toggle service after the optimistic local write.
*/
export function setFavorite(itemId: string, isFavorite: boolean): void {
overrides.update((map) => {
const next = new Map(map);
next.set(itemId, isFavorite);
return next;
});
}
/**
* Forget a session override, so the item's own `userData` is authoritative
* again. Used when the backend reports the server's state changed underneath
* us (`favorites-changed`) the fresh fetch that follows carries the truth.
*/
export function clearFavorite(itemId: string): void {
overrides.update((map) => {
if (!map.has(itemId)) return map;
const next = new Map(map);
next.delete(itemId);
return next;
});
}
export function clearAllFavorites(): void {
overrides.set(new Map());
}
/**
* Resolution order: a session override wins, then the item's own server-sent
* `userData`, then "not favourited".
*
* The override has to win, or tapping the heart on a card would flip back the
* moment the (unchanged) item object re-rendered.
*
* TRACES: UR-068 | DR-119 | UT-105
*/
export function resolveIsFavorite(
item: Pick<MediaItem, "id" | "userData"> | null | undefined,
overrideMap: Map<string, boolean>
): boolean {
if (!item) return false;
const override = overrideMap.get(item.id);
if (override !== undefined) return override;
return item.userData?.isFavorite ?? false;
}
/** Non-reactive read, for call sites outside a component. */
export function isFavoriteNow(item: Pick<MediaItem, "id" | "userData">): boolean {
return resolveIsFavorite(item, get(overrides));
}
/**
* Drop the items a listing should no longer show once un-favourited.
*
* Pure so it can be unit-tested without mounting the page: un-hearting on the
* Favourites grid must remove the card, while a *newly* favourited item is
* left alone (it belongs to whatever scope the caller fetched).
*
* TRACES: UR-067 | DR-117, DR-119 | UT-106
*/
export function retainFavorites<T extends Pick<MediaItem, "id" | "userData">>(
items: T[],
overrideMap: Map<string, boolean>
): T[] {
return items.filter((item) => resolveIsFavorite(item, overrideMap));
}
/** Count of items still favourited, for "hide the row when empty" decisions. */
export const hasOverrides = derived(overrides, ($o) => $o.size > 0);
+23 -1
View File
@@ -1,5 +1,5 @@
// Home screen data store - featured items, continue watching, recently added
// TRACES: UR-023, UR-024, UR-034, UR-059 | DR-026, DR-027, DR-038, DR-039, DR-089
// TRACES: UR-023, UR-024, UR-034, UR-059, UR-067 | DR-026, DR-027, DR-038, DR-039, DR-089, DR-118
import { writable, derived } from "svelte/store";
import type { MediaItem } from "$lib/api/types";
import { auth } from "./auth";
@@ -12,6 +12,10 @@ interface HomeState {
latestItems: MediaItem[];
recentlyPlayedAudio: MediaItem[];
resumeMovies: MediaItem[];
/** Favourites per scope. Empty rows are not rendered. TRACES: UR-067 | DR-118 */
favoriteMovies: MediaItem[];
favoriteShows: MediaItem[];
favoriteMusic: MediaItem[];
isLoading: boolean;
error: string | null;
}
@@ -24,6 +28,9 @@ function createHomeStore() {
latestItems: [],
recentlyPlayedAudio: [],
resumeMovies: [],
favoriteMovies: [],
favoriteShows: [],
favoriteMusic: [],
isLoading: false,
error: null,
};
@@ -46,6 +53,11 @@ function createHomeStore() {
repo.getLatestItems("", 16),
repo.getRecentlyPlayedAudio(12), // Backend now handles intelligent grouping
repo.getResumeMovies(12),
// Favourites, one request per row. The scope is opaque — Rust decides
// which item types it covers. TRACES: UR-067 | DR-118
repo.getFavorites("movies", { limit: 20 }),
repo.getFavorites("tv", { limit: 20 }),
repo.getFavorites("music", { limit: 20 }),
]);
const valueOr = <T>(i: number, fallback: T): T =>
@@ -60,6 +72,10 @@ function createHomeStore() {
const latest = valueOr(2, [] as typeof initialState.latestItems);
const recentAudio = valueOr(3, [] as typeof initialState.recentlyPlayedAudio);
const resumeMovies = valueOr(4, [] as typeof initialState.resumeMovies);
const emptyResult = { items: [] as MediaItem[], totalRecordCount: 0 };
const favoriteMovies = valueOr(5, emptyResult).items;
const favoriteShows = valueOr(6, emptyResult).items;
const favoriteMusic = valueOr(7, emptyResult).items;
// Use resume items or latest as hero items
const hero = resume.length >= 3 ? resume.slice(0, 5) : latest.slice(0, 5);
@@ -72,6 +88,9 @@ function createHomeStore() {
latestItems: latest,
recentlyPlayedAudio: recentAudio,
resumeMovies: resumeMovies,
favoriteMovies,
favoriteShows,
favoriteMusic,
isLoading: false,
}));
} catch (error) {
@@ -101,4 +120,7 @@ export const nextUpItems = derived(home, $home => $home.nextUpItems);
export const latestItems = derived(home, $home => $home.latestItems);
export const recentlyPlayedAudio = derived(home, $home => $home.recentlyPlayedAudio);
export const resumeMovies = derived(home, $home => $home.resumeMovies);
export const favoriteMovies = derived(home, $home => $home.favoriteMovies);
export const favoriteShows = derived(home, $home => $home.favoriteShows);
export const favoriteMusic = derived(home, $home => $home.favoriteMusic);
export const isHomeLoading = derived(home, $home => $home.isLoading);
+60
View File
@@ -0,0 +1,60 @@
// TRACES: UR-067 | DR-117
import { describe, it, expect } from "vitest";
import {
FAVORITE_SCOPES,
FAVORITE_SCOPE_LABELS,
resolveFavoritesScope,
favoritesRouteUrl,
emptyStateMessage,
} from "./favoritesView";
describe("favoritesView", () => {
describe("resolveFavoritesScope", () => {
it("round-trips every offered tab", () => {
for (const scope of FAVORITE_SCOPES) {
expect(resolveFavoritesScope(scope)).toBe(scope);
}
});
it("defaults to All for a missing param", () => {
expect(resolveFavoritesScope(null)).toBe("all");
expect(resolveFavoritesScope(undefined)).toBe("all");
expect(resolveFavoritesScope("")).toBe("all");
});
it("defaults to All for a stale or hand-edited param rather than blanking the page", () => {
expect(resolveFavoritesScope("books")).toBe("all");
expect(resolveFavoritesScope("MOVIES")).toBe("all");
});
});
describe("favoritesRouteUrl", () => {
it("omits the default scope so the base URL stays clean", () => {
expect(favoritesRouteUrl("all")).toBe("/library/favorites");
});
it("addresses every other tab explicitly, and round-trips through resolve", () => {
for (const scope of FAVORITE_SCOPES) {
const url = favoritesRouteUrl(scope);
const param = new URL(url, "http://x").searchParams.get("scope");
expect(resolveFavoritesScope(param)).toBe(scope);
}
});
});
it("labels every scope, using the app's vocabulary rather than Jellyfin's", () => {
for (const scope of FAVORITE_SCOPES) {
expect(FAVORITE_SCOPE_LABELS[scope]).toBeTruthy();
}
// "tv" is the backend's scope name; users see "Shows".
expect(FAVORITE_SCOPE_LABELS.tv).toBe("Shows");
});
it("gives each tab its own empty state, telling the user what to do next", () => {
for (const scope of FAVORITE_SCOPES) {
expect(emptyStateMessage(scope)).toContain("heart");
}
expect(emptyStateMessage("movies")).toContain("movies");
});
});
+54
View File
@@ -0,0 +1,54 @@
// Favourites page presentation helpers — which scopes are offered as tabs, what
// they are called, and how a tab is addressed in the URL.
//
// The *labels and tab order* are presentation and live here. What each scope
// MEANS in Jellyfin item types is domain vocabulary and lives in Rust
// (`SearchScope::item_types`); this file must never enumerate item types.
//
// TRACES: UR-067 | DR-117
import type { SearchScope } from "$lib/api/bindings";
/**
* Scopes offered as tabs, in display order. A subset of `SearchScope` chosen
* for presentation the backend accepts more than a page needs to show.
*/
export const FAVORITE_SCOPES = ["all", "movies", "tv", "music"] as const;
export type FavoritesScope = (typeof FAVORITE_SCOPES)[number];
export const FAVORITE_SCOPE_LABELS: Record<FavoritesScope, string> = {
all: "All",
movies: "Movies",
tv: "Shows",
music: "Music",
};
/**
* Resolve the `?scope=` param to a tab, defaulting to All for anything
* missing or unrecognised (a hand-edited or stale URL must not blank the page).
*/
export function resolveFavoritesScope(raw: string | null | undefined): FavoritesScope {
if (!raw) return "all";
return (FAVORITE_SCOPES as readonly string[]).includes(raw) ? (raw as FavoritesScope) : "all";
}
/** URL for a tab. The default scope is omitted, keeping the base URL clean. */
export function favoritesRouteUrl(scope: FavoritesScope): string {
return scope === "all" ? "/library/favorites" : `/library/favorites?scope=${scope}`;
}
/** Per-tab empty state copy (ux-flows §5C.2). */
export function emptyStateMessage(scope: FavoritesScope): string {
const what: Record<FavoritesScope, string> = {
all: "Nothing favourited yet",
movies: "No favourite movies yet",
tv: "No favourite shows yet",
music: "No favourite music yet",
};
return `${what[scope]} — tap the heart on anything you like.`;
}
/** Compile-time guard that every tab is a scope the backend accepts. */
const _scopesAreSearchScopes: readonly SearchScope[] = FAVORITE_SCOPES;
void _scopesAreSearchScopes;