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:
+55
-4
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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