Files
jellytau/src/lib/components/library/EpisodeFocusView.svelte
T
dtourolle 62873cab3d 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.
2026-08-04 17:35:17 +02:00

277 lines
9.8 KiB
Svelte

<!-- TRACES: UR-048 | DR-061, DR-062 -->
<script lang="ts">
import { goto } from "$app/navigation";
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,
stripCardLabel,
} from "./episodeStrip";
interface Props {
episode: MediaItem;
series: MediaItem;
allEpisodes: MediaItem[];
onBack?: () => void;
}
let { episode, series, allEpisodes, onBack }: Props = $props();
// Pure logic lives in ./episodeStrip.ts (unit-tested). Wrap for local use.
function isCurrentEpisode(ep: MediaItem): boolean {
return isSameEpisode(ep, episode);
}
const adjacentEpisodes = $derived(() => computeAdjacent(episode, allEpisodes));
// Compute best backdrop source (no fetch, pure derivation)
const backdropSource = $derived.by(() => {
if (episode.backdropImageTags?.[0]) {
return { itemId: episode.id, imageType: "Backdrop" as const, tag: episode.backdropImageTags[0] };
}
if (episode.imageId) {
return { itemId: episode.id, imageType: "Primary" as const, tag: episode.imageId };
}
if (series.backdropImageTags?.[0]) {
return { itemId: series.id, imageType: "Backdrop" as const, tag: series.backdropImageTags[0] };
}
return null;
});
function formatDuration(ms?: number | null): string {
if (!ms) return "";
const seconds = Math.floor(ms / 1000);
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (hours > 0) {
return `${hours}h ${minutes}m`;
}
return `${minutes}m`;
}
function getProgress(ep: MediaItem): number {
if (!ep.userData || !ep.durationMs) {
return 0;
}
return ((ep.userData.playbackPositionMs ?? 0) / ep.durationMs) * 100;
}
function handlePlay() {
goto(`/player/${episode.id}`);
}
function handleEpisodeClick(ep: MediaItem) {
goto(`/library/${series.id}?episode=${ep.id}`);
}
const episodeLabel = $derived(
`S${episode.parentIndexNumber || 1}E${episode.indexNumber || 1}`
);
const duration = $derived(formatDuration(episode.durationMs));
const progress = $derived(getProgress(episode));
</script>
<div class="space-y-8">
<!-- Hero section -->
<div class="relative h-[450px] rounded-xl overflow-hidden">
{#if backdropSource}
<CachedImage
itemId={backdropSource.itemId}
imageType={backdropSource.imageType}
tag={backdropSource.tag}
maxWidth={1920}
alt={episode.name}
class="absolute inset-0 w-full h-full object-cover"
/>
{:else}
<div class="absolute inset-0 bg-gradient-to-br from-[var(--color-jellyfin)] to-purple-900"></div>
{/if}
<!-- Gradient overlay -->
<div class="absolute inset-0 bg-gradient-to-r from-black/90 via-black/60 to-transparent"></div>
<div class="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent"></div>
<!-- Back button -->
{#if onBack}
<button
onclick={onBack}
class="absolute top-4 left-4 p-2 rounded-full bg-black/50 hover:bg-black/70 transition-colors"
title="Back to series"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>
{/if}
<!-- Content -->
<div class="relative h-full flex flex-col justify-end p-8 max-w-3xl">
<div class="space-y-4">
<!-- Series name -->
<p class="text-gray-300 text-lg">{series.name}</p>
<!-- Episode title -->
<h1 class="text-4xl font-bold text-white drop-shadow-lg">
{truncateMiddle(episode.name, 64)}
</h1>
<!-- Metadata -->
<div class="flex items-center gap-4 text-sm text-gray-200">
<span class="px-2 py-1 bg-[var(--color-jellyfin)] rounded font-semibold">
{episodeLabel}
</span>
{#if duration}
<span>{duration}</span>
{/if}
{#if episode.communityRating}
<span class="flex items-center gap-1">
<svg class="w-4 h-4 text-yellow-400" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
</svg>
{episode.communityRating.toFixed(1)}
</span>
{/if}
{#if episode.userData?.isPlayed}
<span class="flex items-center gap-1 text-[var(--color-jellyfin)]">
<svg class="w-4 h-4" 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>
Watched
</span>
{/if}
</div>
<!-- Overview -->
{#if episode.overview}
<p class="text-gray-200 line-clamp-3 text-lg leading-relaxed max-w-2xl">
{episode.overview}
</p>
{/if}
<!-- Progress bar if in progress -->
{#if progress > 0 && progress < 95}
<div class="w-64">
<div class="h-1 bg-gray-700 rounded-full overflow-hidden">
<div
class="h-full bg-[var(--color-jellyfin)]"
style="width: {progress}%"
></div>
</div>
<p class="text-xs text-gray-400 mt-1">
{Math.round(progress)}% watched
</p>
</div>
{/if}
<!-- 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"
>
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
{progress > 0 && progress < 95 ? "Resume" : "Play"}
</button>
<FavoriteButton
itemId={episode.id}
isFavorite={resolveIsFavorite(episode, $favoriteOverrides)}
size="lg"
/>
</div>
</div>
</div>
</div>
<!-- Adjacent episodes -->
<div class="space-y-4">
<h2 class="text-xl font-semibold text-white">More Episodes</h2>
<div class="flex gap-4 overflow-x-auto pb-4 scrollbar-thin scrollbar-thumb-gray-700 scrollbar-track-transparent">
{#each adjacentEpisodes() as ep (ep.id)}
{@const isCurrent = isCurrentEpisode(ep)}
{@const epProgress = getProgress(ep)}
<button
onclick={() => !isCurrent && handleEpisodeClick(ep)}
class="flex-shrink-0 w-64 text-left group/card {isCurrent ? 'ring-2 ring-yellow-400 rounded-lg' : ''}"
disabled={isCurrent}
>
<!-- Thumbnail -->
<div class="relative aspect-video rounded-lg overflow-hidden bg-[var(--color-surface)]">
<CachedImage
itemId={ep.id}
imageType="Primary"
tag={ep.imageId}
maxWidth={400}
alt={ep.name}
class="w-full h-full object-cover transition-transform {isCurrent ? '' : 'group-hover/card:scale-105'}"
/>
<!-- Hover overlay -->
{#if !isCurrent}
<div class="absolute inset-0 bg-black/0 group-hover/card:bg-black/30 transition-colors flex items-center justify-center">
<div class="opacity-0 group-hover/card:opacity-100 transition-opacity">
<div class="w-12 h-12 rounded-full bg-[var(--color-jellyfin)] flex items-center justify-center">
<svg class="w-6 h-6 text-white ml-1" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
</div>
</div>
</div>
{/if}
<!-- Now Playing indicator -->
{#if isCurrent}
<div class="absolute top-2 left-2 px-2 py-1 bg-yellow-400 text-black rounded text-xs font-semibold">
Current
</div>
{/if}
<!-- Progress bar -->
{#if epProgress > 0}
<div class="absolute bottom-0 left-0 right-0 h-1 bg-gray-800">
<div
class="h-full bg-[var(--color-jellyfin)]"
style="width: {epProgress}%"
></div>
</div>
{/if}
<!-- Played indicator -->
{#if ep.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>
</div>
{/if}
</div>
<!-- Episode info -->
<div class="mt-2 space-y-1">
<div class="flex items-center gap-2">
<span class="text-[var(--color-jellyfin)] text-sm font-semibold whitespace-nowrap">
{stripCardLabel(ep, episode)}
</span>
<p class="text-white font-medium truncate {isCurrent ? 'text-yellow-400' : 'group-hover/card:text-[var(--color-jellyfin)]'} transition-colors">
{ep.name}
</p>
</div>
{#if ep.overview}
<p class="text-gray-400 text-sm line-clamp-2">
{ep.overview}
</p>
{/if}
</div>
</button>
{/each}
</div>
</div>
</div>