perf(series): the episode list no longer waits on the server

Opening Frasier on a Fairphone took ~5 s to render the episode list
although every episode was cached. Three causes:

- resolve_series_view waited for Next Up and resume before returning the
  episodes, and Next Up was server-first. The episode list now returns as
  soon as the episodes are in (with_hints); hints that have answered are
  used, late ones dropped, and the picker falls back to local watch state.
  Next Up is cache-first like every other query.
- The page loaded itself six times per open: onMount plus a mount-time
  $effect, the reachability effect's first run posing as a reconnect, and
  a double mount. All triggers now share one coalesced load per item
  (createCoalescedLoader); refresh triggers get one re-run after it.
- The root layout rendered the route in two branches that each rendered
  children; the page store deciding between them updates a flush late, so
  navigating Search -> library page mounted the page twice. One element
  now renders the route and only its classes change.

On the device: one load per open, seasons from cache in 14 ms, episodes
and the Resume button up in under a second (was ~5 s).
This commit is contained in:
2026-09-24 04:45:44 +02:00
parent c0545a245f
commit a676f4aba8
7 changed files with 334 additions and 67 deletions
+24
View File
@@ -666,6 +666,14 @@ render behind it. There is no measurement and no reserved padding. If you
restructure the shell, preserve the scroll containment — reintroducing padding restructure the shell, preserve the scroll containment — reintroducing padding
math reintroduces the bug. math reintroduces the bug.
**The route renders in exactly one element.** `+layout.svelte` switches the
wrapper's *classes* between the shell scroller and the plain clipped box that
layout-owning routes (library, settings, player) get — it must not switch
between two branches that each render `children`. The page store that decides
the mode can update a flush after the new route renders, so two branches
mounted a page under one and then remounted it under the other: every
navigation between the two kinds of route loaded the page twice (DR-295).
### AccountMenu ### AccountMenu
One component for both breakpoints, anchored to the username/avatar (a real One component for both breakpoints, anchored to the username/avatar (a real
@@ -719,6 +727,22 @@ hero button labelled `Resume S2E4` / `Play S1E1`.
A season is not a destination: `/library/<seasonId>` redirects to its series A season is not a destination: `/library/<seasonId>` redirects to its series
(DR-103). Video library routes collapse to one per library (DR-105). (DR-103). Video library routes collapse to one per library (DR-105).
**The episode list never waits for Next Up or resume.** `resolve_series_view`
(`series_progress.rs`, `with_hints`) returns as soon as the episodes are in;
Next Up and resume are used if they have answered by then and dropped if not,
and `pick_current_episode` falls back to the episodes' own watch state. They
only refine which episode is current, and waiting for them held the list for
the server's 23 s although every episode was cached. Next Up is cache-first
like every other query (03-data-flow).
**One load per item, however many triggers.** The detail page loads through
`createCoalescedLoader` (`utils/coalescedLoader.ts`): calls for the item
already loading share that load, and callers that know the data changed
(`fresh`: reconnect, filter change, mark watched, clear history) get exactly
one re-run after it. `onMount`, a mount-time `$effect`, the reachability
effect's first run and the double mount above used to each start a full load —
six per open, about seventy requests in flight.
`episodeStrip.ts` holds the pure logic for the "More Episodes" strip, extracted `episodeStrip.ts` holds the pure logic for the "More Episodes" strip, extracted
from the component because it had three distinct bugs that markup made from the component because it had three distinct bugs that markup made
untestable: the strip collapsing to just the current episode while real siblings untestable: the strip collapsing to just the current episode while real siblings
+25 -15
View File
@@ -1000,22 +1000,32 @@ impl MediaRepository for HybridRepository {
series_id: Option<&str>, series_id: Option<&str>,
limit: Option<usize>, limit: Option<usize>,
) -> Result<Vec<MediaItem>, RepoError> { ) -> Result<Vec<MediaItem>, RepoError> {
// Next Up is dynamic, so the server's answer is preferred — but when the // Cache-first like every other query: the local answer is computed
// server cannot answer, the cache's stands in. It used to be server-only, // from the same watch state the cache refreshes from the server in the
// and the TV landing page loads Next Up in one `Promise.all` with its // background (`user_data_mirror_query`), and whichever answers first
// other rows, so offline that single failure blanked the whole page with // with content wins. It used to wait for the server outright, which
// Continue Watching and Latest sitting in the cache (DR-294). // held the series page's episode list for 2-3 s on a phone. An empty
// TRACES: UR-002 | DR-294 | UT-261 // local answer still defers to the server, and a failed server falls
match self.online.get_next_up_episodes(series_id, limit).await { // back to the cache — offline, the TV page's Next Up row must not blank
Ok(items) => Ok(items.without_excluded()), // the page (DR-294).
Err(e) => { // TRACES: UR-002 | DR-013, DR-294 | UT-261
debug!("[HybridRepo] Next Up from server failed ({e}); using the cache"); let offline = Arc::clone(&self.offline);
self.offline let online = Arc::clone(&self.online);
.get_next_up_episodes(series_id, limit) let series = series_id.map(str::to_string);
let series_for_server = series.clone();
let cache_future =
Self::cache_leg(
async move { offline.get_next_up_episodes(series.as_deref(), limit).await },
)
.await;
let server_future = async move {
online
.get_next_up_episodes(series_for_server.as_deref(), limit)
.await .await
.map(ExcludeHidden::without_excluded) };
}
} Self::parallel_race(cache_future, server_future).await
} }
async fn get_recently_played_audio( async fn get_recently_played_audio(
+84 -3
View File
@@ -287,8 +287,8 @@ pub async fn resolve_series_view(
repo: &dyn MediaRepository, repo: &dyn MediaRepository,
series_id: &str, series_id: &str,
) -> Result<SeriesView, RepoError> { ) -> Result<SeriesView, RepoError> {
let (episodes, next_up, resume) = futures_util::join!( let (episodes, (next_up, resume)) = with_hints(fetch_series_episodes(repo, series_id), async {
fetch_series_episodes(repo, series_id), futures_util::join!(
async { async {
repo.get_next_up_episodes(Some(series_id), Some(1)) repo.get_next_up_episodes(Some(series_id), Some(1))
.await .await
@@ -299,12 +299,44 @@ pub async fn resolve_series_view(
.await .await
.unwrap_or_default() .unwrap_or_default()
}, },
); )
})
.await;
let episodes = episodes?; let episodes = episodes?;
let current = pick_current_episode(series_id, &episodes, &next_up, &resume); let current = pick_current_episode(series_id, &episodes, &next_up, &resume);
Ok(SeriesView { episodes, current }) Ok(SeriesView { episodes, current })
} }
/// Run `primary` and `hints` together, but never hold `primary` back for
/// `hints`: once `primary` is ready, the hints are taken if they have already
/// answered and dropped (`H::default()`) if not.
///
/// For the series view the primary is the episode list and the hints are Next
/// Up and resume, which only refine which episode is "current" — and the
/// picker falls back to the episodes' own watch state without them. Waiting
/// for them made the episode list wait for the server (2-3 s on a phone)
/// although every episode was in the cache in 50 ms. The cache legs of the
/// hints usually answer before the episodes do, so they are normally kept.
///
/// TRACES: UR-062 | DR-101, DR-295
async fn with_hints<P, H>(
primary: impl std::future::Future<Output = P>,
hints: impl std::future::Future<Output = H>,
) -> (P, H)
where
H: Default,
{
use futures_util::future::{select, Either};
use futures_util::FutureExt;
let primary = std::pin::pin!(primary);
let hints = std::pin::pin!(hints);
match select(primary, hints).await {
Either::Left((primary, hints)) => (primary, hints.now_or_never().unwrap_or_default()),
Either::Right((hints, primary)) => (primary.await, hints),
}
}
/// Resolve the current episode, fetching everything the policy needs. /// Resolve the current episode, fetching everything the policy needs.
/// ///
/// Next Up and resume are best-effort: offline they fail or come back empty, and /// Next Up and resume are best-effort: offline they fail or come back empty, and
@@ -404,6 +436,55 @@ mod tests {
assert_eq!(episodes.len(), 9, "every season but the failing one"); assert_eq!(episodes.len(), 9, "every season but the failing one");
} }
/// The episode list must not wait for Next Up or resume.
///
/// The series page rendered its episodes only once Next Up had come back
/// from the server — 2-3 s on a phone while the page's other requests were
/// in flight — although every episode was in the cache after 50 ms. Those
/// two only refine which episode is "current", and the picker falls back
/// to the episodes' own watch state without them.
///
/// TRACES: UR-062 | DR-101, DR-295
#[tokio::test]
async fn the_episode_list_does_not_wait_for_slow_hints() {
let started = std::time::Instant::now();
let (episodes, hints) = with_hints(
async {
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
vec![episode("e1", 1, 1)]
},
async {
tokio::time::sleep(std::time::Duration::from_millis(2000)).await;
vec![episode("from-server", 1, 2)]
},
)
.await;
let elapsed = started.elapsed();
assert_eq!(episodes.len(), 1);
assert!(hints.is_empty(), "late hints are dropped, not waited for");
assert!(
elapsed < std::time::Duration::from_millis(500),
"the episode list waited {elapsed:?} for Next Up / resume"
);
}
/// Hints that are already in (a cache answer) are used.
///
/// TRACES: UR-062 | DR-101, DR-295
#[tokio::test]
async fn hints_that_answer_first_are_kept() {
let (_, hints) = with_hints(
async {
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
vec![episode("e1", 1, 1)]
},
async { vec![episode("cached", 1, 2)] },
)
.await;
assert_eq!(hints.len(), 1);
}
fn watched(mut item: MediaItem) -> MediaItem { fn watched(mut item: MediaItem) -> MediaItem {
item.user_data = Some(UserData { item.user_data = Some(UserData {
is_played: Some(true), is_played: Some(true),
+86
View File
@@ -0,0 +1,86 @@
import { describe, it, expect, vi } from "vitest";
import { createCoalescedLoader } from "./coalescedLoader";
/**
* TRACES: UR-062 | DR-295
*
* The series page loaded itself six times on every open: `onMount` and a
* `$effect` both ran on mount, the "server became reachable" effect fired on
* its first run, and navigation updates re-ran the effect. Each load repeated
* the item, the season list and the whole series view — about six times a
* dozen requests in flight at once, which alone slowed every server call on a
* phone to 2-3 s.
*/
function deferred() {
let resolve!: () => void;
const promise = new Promise<void>((r) => (resolve = r));
return { promise, resolve };
}
describe("createCoalescedLoader", () => {
it("shares one run between calls for the same key while it is in flight", async () => {
const gate = deferred();
const run = vi.fn(() => gate.promise);
const loader = createCoalescedLoader(run);
const calls = [1, 2, 3, 4, 5, 6].map(() => loader.load("frasier"));
gate.resolve();
await Promise.all(calls);
expect(run).toHaveBeenCalledTimes(1);
});
it("re-runs once after the in-flight load when a caller needs fresh data", async () => {
const gates = [deferred(), deferred()];
let n = 0;
const run = vi.fn(() => gates[n++].promise);
const loader = createCoalescedLoader(run);
const first = loader.load("frasier");
// e.g. "mark watched" finished while the page was still loading: the
// in-flight load may predate the change, so it must not be the answer.
const fresh = loader.load("frasier", { fresh: true });
const fresh2 = loader.load("frasier", { fresh: true });
gates[0].resolve();
await vi.waitFor(() => expect(run).toHaveBeenCalledTimes(2));
gates[1].resolve();
// Every caller is answered by the load that includes the re-run.
await Promise.all([first, fresh, fresh2]);
expect(run).toHaveBeenCalledTimes(2);
});
it("does not share a run between different keys", async () => {
const run = vi.fn(() => Promise.resolve());
const loader = createCoalescedLoader(run);
await Promise.all([loader.load("frasier"), loader.load("cheers")]);
expect(run).toHaveBeenCalledTimes(2);
expect(run).toHaveBeenNthCalledWith(1, "frasier");
expect(run).toHaveBeenNthCalledWith(2, "cheers");
});
it("runs again once the previous load has finished", async () => {
const run = vi.fn(() => Promise.resolve());
const loader = createCoalescedLoader(run);
await loader.load("frasier");
await loader.load("frasier");
expect(run).toHaveBeenCalledTimes(2);
});
it("releases the key when a load fails", async () => {
const run = vi
.fn<(key: string) => Promise<void>>()
.mockRejectedValueOnce(new Error("offline"))
.mockResolvedValueOnce(undefined);
const loader = createCoalescedLoader(run);
await expect(loader.load("frasier")).rejects.toThrow("offline");
await loader.load("frasier");
expect(run).toHaveBeenCalledTimes(2);
});
});
+55
View File
@@ -0,0 +1,55 @@
/**
* Load one keyed thing at a time, however many triggers ask for it.
*
* Calls for the key already loading share that load instead of starting their
* own. A caller that knows the data changed (`fresh` — after "mark watched",
* on reconnect, when a filter flips) must not be answered by a load that may
* predate the change, so it gets exactly one re-run once the current load
* ends, however many such callers there were.
*
* Exists because the series page loaded itself six times on every open —
* `onMount`, a mount-time `$effect`, the reachability effect's first run and
* navigation updates each started a full load — putting about six times a
* dozen requests in flight at once.
*
* TRACES: UR-062 | DR-295
*/
export interface CoalescedLoader {
/** Load `key`. `fresh`: the caller knows the data changed. */
load(key: string, options?: { fresh?: boolean }): Promise<void>;
}
interface InFlight {
key: string;
/** Settles when this load and any re-run it owes have finished. */
done: Promise<void>;
rerun: boolean;
}
export function createCoalescedLoader(run: (key: string) => Promise<void>): CoalescedLoader {
let inFlight: InFlight | null = null;
return {
load(key, options = {}) {
if (inFlight && inFlight.key === key) {
if (options.fresh) inFlight.rerun = true;
return inFlight.done;
}
const entry: InFlight = { key, rerun: false, done: Promise.resolve() };
entry.done = (async () => {
try {
await run(key);
while (entry.rerun) {
entry.rerun = false;
await run(key);
}
} finally {
if (inFlight === entry) inFlight = null;
}
})();
inFlight = entry;
return entry.done;
},
};
}
+16 -15
View File
@@ -73,7 +73,8 @@
// a new page inherits the previous page's offset. Must be registered here at // a new page inherits the previous page's offset. Must be registered here at
// init, alongside the tracker above, for the same reason. (DR-156) // init, alongside the tracker above, for the same reason. (DR-156)
let shellScroller = $state<HTMLElement>(); let shellScroller = $state<HTMLElement>();
useScrollRestore(() => shellScroller, "shell"); // Owned routes scroll inside their own column; the shell box does not.
useScrollRestore(() => (routeOwnsLayout ? undefined : shellScroller), "shell");
// Layout-shell visibility rules live in one pure, unit-tested module // Layout-shell visibility rules live in one pure, unit-tested module
// ($lib/utils/layoutShell) so they can't drift per route/platform. // ($lib/utils/layoutShell) so they can't drift per route/platform.
@@ -357,29 +358,29 @@
scrolling internally. All other top-level pages render directly here, so scrolling internally. All other top-level pages render directly here, so
this wrapper must scroll and reserve the fixed bottom UI's measured this wrapper must scroll and reserve the fixed bottom UI's measured
height so the mini player / bottom nav never overlap the last rows. --> height so the mini player / bottom nav never overlap the last rows. -->
{#if routeOwnsLayout}
<!-- These routes own their own full-height flex column (header + scroller
+ their own in-flow BottomUi), so the root just clips and steps back. -->
<div class="flex-1 overflow-hidden">
{@render children()}
</div>
{:else}
<!-- Shared header (account menu, desktop nav) as a flex-shrink-0 sibling <!-- Shared header (account menu, desktop nav) as a flex-shrink-0 sibling
above the scroller, so it never eats into the scroller's bounds. --> above the scroller, so it never eats into the scroller's bounds. -->
{#if showGlobalHeader} {#if !routeOwnsLayout && showGlobalHeader}
<AppHeader /> <AppHeader />
{/if} {/if}
<!-- Scroller is flex-1/min-h-0; the in-flow BottomUi below is a flex <!-- ONE element renders the route, whatever the layout mode; only its
sibling, so the list is physically bounded above it and can never classes change. Routes that own their full-height column (header +
render behind it. No measurement, no reserved padding. --> scroller + their own in-flow BottomUi) get a plain clipped box; every
other route gets the shell scroller (flex-1/min-h-0, bounded above the
in-flow BottomUi, so no measurement or reserved padding).
This used to be two branches, each rendering `children`. The page
store that decides the mode can update a flush after the new route
renders, so navigating between the two kinds of route (Search → a
library page) mounted the page under one branch and then *remounted*
it under the other — every load it started, twice (DR-295). -->
<div <div
bind:this={shellScroller} bind:this={shellScroller}
class="flex-1 overflow-y-auto min-h-0" class={routeOwnsLayout ? "flex-1 overflow-hidden" : "flex-1 overflow-y-auto min-h-0"}
style="overscroll-behavior: contain" style={routeOwnsLayout ? undefined : "overscroll-behavior: contain"}
> >
{@render children()} {@render children()}
</div> </div>
{/if}
<!-- Re-authentication modal --> <!-- Re-authentication modal -->
<ReauthModal isOpen={$needsReauth} /> <ReauthModal isOpen={$needsReauth} />
+25 -15
View File
@@ -1,6 +1,6 @@
<!-- TRACES: UR-035, UR-038, UR-048, UR-058, UR-062 | DR-043, DR-062, DR-102, DR-103, DR-142 --> <!-- TRACES: UR-035, UR-038, UR-048, UR-058, UR-062 | DR-043, DR-062, DR-102, DR-103, DR-142 -->
<script lang="ts"> <script lang="ts">
import { onMount, untrack } from "svelte"; import { untrack } from "svelte";
import { formatDuration } from "$lib/utils/duration"; import { formatDuration } from "$lib/utils/duration";
import { page } from "$app/stores"; import { page } from "$app/stores";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
@@ -51,6 +51,7 @@
type SeasonData, type SeasonData,
} from "$lib/components/library/seriesNavigation"; } from "$lib/components/library/seriesNavigation";
import { createLogger } from "$lib/utils/logger"; import { createLogger } from "$lib/utils/logger";
import { createCoalescedLoader } from "$lib/utils/coalescedLoader";
const log = createLogger("LibraryDetail"); const log = createLogger("LibraryDetail");
@@ -65,18 +66,27 @@
// preference, so it resets with each load (DR-107). // preference, so it resets with each load (DR-107).
let expandedSeasons = $state<Set<string>>(new Set()); let expandedSeasons = $state<Set<string>>(new Set());
// Track if we've done an initial load and previous server state // Track if we've done an initial load and previous server state. The
// previous state starts as "unknown" (null): the effect's first run only
// records it. Starting at `false` made that first run look like a
// reconnect and force a second, fresh load of the page on every open.
let hasLoadedOnce = false; let hasLoadedOnce = false;
let previousServerReachable = false; let previousServerReachable: boolean | null = null;
const itemId = $derived($page.params.id); const itemId = $derived($page.params.id);
const focusedEpisodeId = $derived($page.url.searchParams.get("episode")); const focusedEpisodeId = $derived($page.url.searchParams.get("episode"));
onMount(async () => { // Every trigger below goes through one coalesced loader: they used to each
await loadItem(); // start a full load, so opening a page loaded it six times over (DR-295).
hasLoadedOnce = true; // `fresh` marks triggers that know the data changed.
}); const loader = createCoalescedLoader(() => loadItemNow());
function loadItem(options?: { fresh?: boolean }): Promise<void> {
if (!itemId) return Promise.resolve();
return loader.load(itemId, options);
}
const reloadFresh = () => loadItem({ fresh: true });
// Runs on mount and whenever the item changes.
$effect(() => { $effect(() => {
if (itemId) { if (itemId) {
loadItem(); loadItem();
@@ -89,8 +99,8 @@
const serverReachable = $isServerReachable; const serverReachable = $isServerReachable;
// If server just became reachable and we've already loaded, reload to get fresh data // If server just became reachable and we've already loaded, reload to get fresh data
if (serverReachable && !previousServerReachable && hasLoadedOnce && itemId) { if (serverReachable && previousServerReachable === false && hasLoadedOnce && itemId) {
loadItem(); reloadFresh();
} }
previousServerReachable = serverReachable; previousServerReachable = serverReachable;
@@ -100,10 +110,10 @@
// contents follow the filter the same way a library listing does. // contents follow the filter the same way a library listing does.
// TRACES: UR-052 | DR-143 // TRACES: UR-052 | DR-143
useOfflineFilterReload(() => { useOfflineFilterReload(() => {
if (itemId) loadItem(); if (itemId) reloadFresh();
}); });
async function loadItem() { async function loadItemNow() {
if (!itemId) return; if (!itemId) return;
// Only show spinner when navigating to a different item // Only show spinner when navigating to a different item
// untrack prevents $effect from tracking `item` as a dependency (avoids infinite loop) // untrack prevents $effect from tracking `item` as a dependency (avoids infinite loop)
@@ -579,13 +589,13 @@
watched={allEpisodes.length > 0 && allEpisodes.every((e) => e.userData?.isPlayed)} watched={allEpisodes.length > 0 && allEpisodes.every((e) => e.userData?.isPlayed)}
scope="series" scope="series"
showLabel={true} showLabel={true}
onChanged={loadItem} onChanged={reloadFresh}
/> />
<ClearHistoryButton <ClearHistoryButton
itemId={item.id} itemId={item.id}
itemName={item.name} itemName={item.name}
scope="series" scope="series"
onCleared={loadItem} onCleared={reloadFresh}
/> />
{:else if item.kind === "movie"} {:else if item.kind === "movie"}
<VideoDownloadButton <VideoDownloadButton
@@ -600,7 +610,7 @@
watched={item.userData?.isPlayed ?? false} watched={item.userData?.isPlayed ?? false}
scope="episode" scope="episode"
showLabel={true} showLabel={true}
onChanged={loadItem} onChanged={reloadFresh}
/> />
{/if} {/if}
<!-- Favourite. Sits with Play/Download rather than in the header, <!-- Favourite. Sits with Play/Download rather than in the header,
@@ -732,7 +742,7 @@
expanded={expandedSeasons.has(season.id)} expanded={expandedSeasons.has(season.id)}
onToggle={() => toggleSeason(season.id)} onToggle={() => toggleSeason(season.id)}
onEpisodeClick={handleEpisodeClick} onEpisodeClick={handleEpisodeClick}
onHistoryCleared={loadItem} onHistoryCleared={reloadFresh}
/> />
{/each} {/each}
{/if} {/if}