Compare commits

..
3 Commits
Author SHA1 Message Date
dtourolle 73fd8a1dfe chore(release): v0.13.2
🏗️ Build and Test JellyTau / Run Tests (push) Skipped
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
🏗️ Build and Test JellyTau / Supply Chain (push) Successful in 4m15s
📱 Test APK / Build test APK (push) Successful in 20m30s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 3m46s
Traceability Validation / Check Requirement Traces (push) Successful in 14s
Build & Release / Run Tests (push) Successful in 10m32s
Build & Release / Build Linux (push) Successful in 14m6s
Build & Release / Build Windows (push) Successful in 10m50s
Build & Release / Build Android (push) Successful in 20m20s
Build & Release / Create Release (push) Successful in 37s
A series opens with its episodes in under a second: the episode list no
longer waits on the server, a page loads once instead of six times, and
the local database finds a container's children by index.
2026-09-24 04:47:07 +02:00
dtourolle a676f4aba8 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).
2026-09-24 04:45:44 +02:00
dtourolle c0545a245f perf(db): one logical container per item, indexed; no whole-table reads
Listings matched children on four columns at once (parent_id, album_id,
season_id, series_id) because Jellyfin's ParentId is the storage parent,
not the logical one. The OR defeated the planner into a full scan, and it
was wrong: every episode carries its series id, so a series listed all its
episodes beside its seasons (on both the browse and Downloads surfaces).

- Migration 027 adds items.container_id, a VIRTUAL generated column
  (episode -> season/series/parent, season -> series, track -> album,
  else parent) indexed with (sort_name, name), so a listing is one
  ordered index range and every write path is covered untouched.
- Containers never cached (an episode that arrived via Next Up) get
  placeholders named from the child's own fields, in the migration and
  on every cache write, so offline navigation stays series -> season.
- The six queries that built the set of every downloaded item in a CTE
  (get_item, latest, recently played, search, favourites, by-person,
  Downloads) now check availability per row with one shared predicate.
- PRAGMA optimize at open gives the planner statistics.

Benchmark (~110k items, desktop): series listing ~80 ms -> <1 ms;
migration 027 upgrades that database in ~0.1 s (0.2 s on the Fairphone).
Tests first: a series listing its episodes, and the Downloads series
drill, both failed before the change.
2026-09-24 04:45:44 +02:00
17 changed files with 1730 additions and 942 deletions
+27
View File
@@ -9,6 +9,33 @@ generated trace matrix lives in [docs/traceability.md](docs/traceability.md).
For how long each fixed defect had been shipping before it was found, see For how long each fixed defect had been shipping before it was found, see
[docs/defect-windows.md](docs/defect-windows.md). [docs/defect-windows.md](docs/defect-windows.md).
## v0.13.2
A series opens with its episodes in under a second. v0.13.1 fixed the season
list; the episode list below it still took about five seconds on a Fairphone.
### ⚡ Performance
- **The episode list no longer waits for the server.** It used to wait for
"Next Up" and your resume point, fetched from the server first, although every
episode was already on the device. The list now shows as soon as it is
loaded, and Next Up answers from the device first like everything else.
(DR-101, DR-295)
- **A page loads once.** Opening a series loaded it six times over, putting
about seventy requests in flight at once. It now loads once, and re-loads only
when something actually changed (reconnecting, marking watched). (DR-295)
- **The local database finds things by index instead of reading everything.**
Each item now records the one container it is listed under, so a series,
season or album page is a single index lookup — under a millisecond on a
100,000-item library, where it used to scan the whole catalogue. The update
converts an existing library in well under a second, once. (DR-012, DR-013)
### 🐛 Fixes
- **A series lists its seasons, not every episode as well.** Both the library
view and the Downloads view mixed a series' episodes in with its seasons.
(DR-013)
## v0.13.1 ## v0.13.1
Pages answer from the cache again. Found on a Fairphone, where opening a Pages answer from the cache again. Found on a Fairphone, where opening a
+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
+49 -18
View File
@@ -243,9 +243,14 @@ CREATE TABLE items (
last_sync DATETIME, last_sync DATETIME,
UNIQUE(jellyfin_id, server_id) UNIQUE(jellyfin_id, server_id)
-- Logical container (migration 027): episode → season/series, season →
-- series, track → album, else parent. See "Listing query shape".
-- container_id TEXT GENERATED ALWAYS AS (CASE item_type … END) VIRTUAL
); );
-- Performance indexes -- Performance indexes
CREATE INDEX idx_items_container ON items(container_id, sort_name, name);
CREATE INDEX idx_items_server ON items(server_id); CREATE INDEX idx_items_server ON items(server_id);
CREATE INDEX idx_items_library ON items(library_id); CREATE INDEX idx_items_library ON items(library_id);
CREATE INDEX idx_items_parent ON items(parent_id); CREATE INDEX idx_items_parent ON items(parent_id);
@@ -613,7 +618,8 @@ life of the app; nothing else opens the file. Everything goes through
``` ```
Pragmas: `journal_mode = WAL`, `synchronous = NORMAL`, `busy_timeout = 5 s` on Pragmas: `journal_mode = WAL`, `synchronous = NORMAL`, `busy_timeout = 5 s` on
every connection. every connection. `PRAGMA optimize` runs once at open, after migrations, so the planner has
statistics (see "Listing query shape").
**Why.** It used to be one connection behind one `std::sync::Mutex`. WAL was on, **Why.** It used to be one connection behind one `std::sync::Mutex`. WAL was on,
but with a single connection its one benefit — readers running beside a writer — but with a single connection its one benefit — readers running beside a writer —
@@ -665,27 +671,52 @@ for the jobs grouped with it.
`OfflineRepository::get_items` (`items_listing_sql`) is the hot read: every `OfflineRepository::get_items` (`items_listing_sql`) is the hot read: every
library, series, season and album page goes through it, and it must fit the library, series, season and album page goes through it, and it must fit the
100 ms cache fast path on a phone. 100 ms cache fast path on a phone. Every other cached read follows the same
rules.
- **Availability is checked per row, with `EXISTS`** (cached for browsing, - **Children are matched on `items.container_id`** (migration 027), a VIRTUAL
downloaded, or a container with a downloaded child). It used to be a CTE generated column holding the item's *logical* container: an episode's season
that built the id of *every* available item in the database before filtering (else series, else parent), a season's series, a track's album, otherwise the
to the parent: ~80 ms on a desktop for a 100k-item cache, whatever the parent. parent. Jellyfin's `ParentId` is the storage parent, not the logical one — in
- **A parent that is not a library is matched on the hierarchy columns alone** a series without season folders an episode's `ParentId` is the series while
(`parent_id`/`album_id`/`season_id`/`series_id`), which SQLite answers with a its `SeasonId` names a virtual season — so listings used to match on four
multi-index `OR`. Whether the parent is a library is looked up first; the columns at once. That `OR` defeated the planner into walking the whole table,
library clause can only match for a library, and inside the same `OR` it and it was wrong: every episode carries its series id, so a series listed all
forced a full scan. its episodes beside its seasons. Being generated, the column covers every
- **`+i.server_id`**: the app never runs `ANALYZE`, and without statistics the write path (cache, downloads, catalog crawl) without any of them knowing, and
planner prefers the `server_id` index — which every row shares — over the cannot drift from the columns it is computed from.
hierarchy indexes. The unary `+` takes it out of consideration. - **`idx_items_container (container_id, sort_name, name)`** serves a listing as
one index range already in display order — no sort step. `sort_name` is
usually NULL in the cache (the cache never writes it), hence `name` in the
index too.
- **Containers exist even when never browsed.** A series lists its seasons, so
an episode whose season row was never cached (it arrived through Next Up or
Latest) would be unreachable from its series offline. `save_to_cache` — and
migration 027 for rows already on disk — inserts placeholders named from the
child's own fields (`season_name`, `series_name`, `album_name`) with
`synced_at` NULL, so they show only when a download makes them available; the
server's real row replaces them wholesale on the next browse.
- **Availability is a per-row `EXISTS`** (`downloaded_sql` / `available_sql`):
cached for browsing (only with the catalog-browse flag), downloaded, or a
container with a downloaded *descendant* — which is why that one check still
looks at all four link columns (a series is available through an episode two
levels down). It used to be a CTE that built the id of every available item
in the database before filtering, paying for the whole table on every call.
- **The library clause is added only for a library parent** (`is_library`),
never `OR`ed into an ordinary listing, where it forces a full scan.
- **`+i.server_id`** keeps the planner off the server index, which every row
shares. `PRAGMA optimize` at open (`analysis_limit = 400`) gives the planner
statistics, but even with them it chose that index for the old `OR`; the `+`
is the guarantee.
- **User data is fetched in batches** (`with_user_data`, one `IN (…)` query per - **User data is fetched in batches** (`with_user_data`, one `IN (…)` query per
500 rows), not once per row. 500 rows), not once per row.
Result: 80 ms → 1.5 ms on the desktop benchmark; Measured on a ~110k-item benchmark catalogue (desktop): a series listing went
`listing_a_non_library_parent_uses_the_hierarchy_indexes` asserts the plan. from ~80 ms to under 1 ms; migration 027 upgrades an existing database of that
`storage::tests::write_bench_database` (ignored) writes a phone-sized size in ~0.1 s. `listing_a_non_library_parent_uses_the_container_index` and
catalogue for timing queries with the `sqlite3` CLI. migration 027's tests assert the plans; `storage::tests::write_bench_database`
(ignored; set `JELLYTAU_BENCH_DB`) writes the benchmark catalogue for the
`sqlite3` CLI.
## Rust Module Structure ## Rust Module Structure
+686 -569
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "jellytau", "name": "jellytau",
"version": "0.13.1", "version": "0.13.2",
"description": "A cross-platform Jellyfin client built with Tauri, SvelteKit and Rust.", "description": "A cross-platform Jellyfin client built with Tauri, SvelteKit and Rust.",
"author": "Duncan Tourolle <duncan@tourolle.paris>", "author": "Duncan Tourolle <duncan@tourolle.paris>",
"license": "MIT", "license": "MIT",
+1 -1
View File
@@ -2275,7 +2275,7 @@ dependencies = [
[[package]] [[package]]
name = "jellytau" name = "jellytau"
version = "0.13.1" version = "0.13.2"
dependencies = [ dependencies = [
"aes-gcm", "aes-gcm",
"argon2", "argon2",
+1 -1
View File
@@ -4,7 +4,7 @@ name = "jellytau"
# `player-conformance`, and a second binary makes a bare `cargo run` — # `player-conformance`, and a second binary makes a bare `cargo run` —
# which `tauri dev` issues — ambiguous. # which `tauri dev` issues — ambiguous.
default-run = "jellytau" default-run = "jellytau"
version = "0.13.1" version = "0.13.2"
description = "A cross-platform Jellyfin client" description = "A cross-platform Jellyfin client"
authors = ["Duncan Tourolle <duncan@tourolle.paris>"] authors = ["Duncan Tourolle <duncan@tourolle.paris>"]
license = "MIT" license = "MIT"
+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(
+398 -277
View File
@@ -290,6 +290,74 @@ impl OfflineRepository {
} }
} }
/// A container row to create if it was never cached. See `save_to_cache`.
struct ContainerPlaceholder {
id: String,
name: String,
item_type: &'static str,
series_id: Option<String>,
series_name: Option<String>,
album_artist: Option<String>,
}
/// The logical containers `item` lists under, as far as its own fields name
/// them — the same rule as `container_id` in migration 027: an episode's
/// season and series, a season's series, a track's album.
///
/// TRACES: UR-002, UR-007 | DR-013
fn container_placeholders(item: &MediaItem) -> Vec<ContainerPlaceholder> {
let mut out = Vec::new();
let series = |item: &MediaItem| {
item.series_id.clone().map(|id| ContainerPlaceholder {
id,
name: item
.series_name
.clone()
.unwrap_or_else(|| "Series".to_string()),
item_type: "Series",
series_id: None,
series_name: None,
album_artist: None,
})
};
match item.item_type.as_str() {
"Episode" => {
if let Some(id) = item.season_id.clone() {
out.push(ContainerPlaceholder {
id,
name: item
.season_name
.clone()
.unwrap_or_else(|| "Season".to_string()),
item_type: "Season",
series_id: item.series_id.clone(),
series_name: item.series_name.clone(),
album_artist: None,
});
}
out.extend(series(item));
}
"Season" => out.extend(series(item)),
"Audio" => {
if let Some(id) = item.album_id.clone() {
out.push(ContainerPlaceholder {
id,
name: item
.album_name
.clone()
.unwrap_or_else(|| "Album".to_string()),
item_type: "MusicAlbum",
series_id: None,
series_name: None,
album_artist: item.album_artist.clone(),
});
}
}
_ => {}
}
out
}
/// A `user_data` row's columns, starting at `offset`, in the order /// A `user_data` row's columns, starting at `offset`, in the order
/// `playback_position_ticks, is_played, is_favorite, play_count, /// `playback_position_ticks, is_played, is_favorite, play_count,
/// last_played_at, playback_context_type, playback_context_id`. /// last_played_at, playback_context_type, playback_context_id`.
@@ -315,6 +383,51 @@ fn row_to_user_data(row: &rusqlite::Row, offset: usize) -> UserData {
} }
} }
/// Item types that can be downloaded themselves.
const PLAYABLE_TYPES: &str = "'Audio', 'Movie', 'Episode'";
/// Item types that are available when something below them is downloaded.
const CONTAINER_TYPES: &str =
"'MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder'";
/// SQL predicate: the row `alias` is on the device — a `playable` item with a
/// completed download, or a `container` with a downloaded descendant.
///
/// Evaluated per row, with `EXISTS`, on whatever rows the query has already
/// narrowed to. Every query used to build the set of *all* downloaded items
/// in a CTE first and join against it, paying for the whole table on every
/// call however few rows it wanted (see 08-database-design.md → "Listing
/// query shape"). Descendants are found through all four link columns on
/// purpose: a series is available through an episode two levels down.
///
/// TRACES: UR-002, UR-055 | DR-013, DR-082
fn downloaded_sql(alias: &str, playable: &str, containers: &str) -> String {
format!(
"(({a}.item_type IN ({playable})
AND EXISTS (SELECT 1 FROM downloads dl
WHERE dl.item_id = {a}.id AND dl.status = 'completed'))
OR ({a}.item_type IN ({containers})
AND EXISTS (SELECT 1 FROM items dc
INNER JOIN downloads dl ON dl.item_id = dc.id AND dl.status = 'completed'
WHERE dc.parent_id = {a}.id OR dc.album_id = {a}.id
OR dc.season_id = {a}.id OR dc.series_id = {a}.id)))",
a = alias
)
}
/// SQL predicate: the row `alias` can be shown — downloaded (see
/// [`downloaded_sql`]) or, with `include_catalog`, cached for browsing.
/// `include_catalog` is the catalog-browse flag; offline with "Show all server
/// media" off it is false and only downloaded media shows
/// (`set_include_catalog_browse`).
fn available_sql(alias: &str, include_catalog: bool) -> String {
let downloaded = downloaded_sql(alias, PLAYABLE_TYPES, CONTAINER_TYPES);
if include_catalog {
format!("({alias}.synced_at IS NOT NULL OR {downloaded})")
} else {
downloaded
}
}
/// The listing query behind `get_items`: the cached children of one parent /// The listing query behind `get_items`: the cached children of one parent
/// that are available to show. /// that are available to show.
/// ///
@@ -328,16 +441,17 @@ fn row_to_user_data(row: &rusqlite::Row, offset: usize) -> UserData {
/// filtering to the parent, which cost ~80 ms on a desktop for a 100k-item /// filtering to the parent, which cost ~80 ms on a desktop for a 100k-item
/// cache whatever the parent — several times that on a phone. /// cache whatever the parent — several times that on a phone.
/// ///
/// A parent that is not a library is matched on the hierarchy columns alone, /// Children are matched on `container_id`, the logical container resolved by
/// which SQLite serves with one index lookup per column. The library clause is /// migration 027 (an episode's season, a season's series, a track's album,
/// dropped there rather than evaluated: it can only match when the parent *is* /// otherwise the parent) — one index range, already in display order. It
/// a library, and inside the same `OR` it forced a scan of every row. The /// replaced a four-column `OR` that was both slow and wrong: every episode
/// `+` on `server_id` stops the planner — which has no statistics, the app /// carries its series id, so a series listed its episodes beside its seasons.
/// never runs `ANALYZE` — from choosing the server index, which every row /// The library clause is added only when the parent *is* a library; inside the
/// shares. /// same `OR` it forces a scan of every row. The `+` on `server_id` keeps the
/// planner off the server index, which every row shares.
/// ///
/// Bind order: server id; the parent id four times (plus a fifth for a library /// Bind order: server id; the parent id (twice for a library parent); the
/// parent); the type-filter values; with the favourites filter, the user id. /// type-filter values; with the favourites filter, the user id.
/// ///
/// TRACES: UR-002, UR-007 | DR-013, DR-277 /// TRACES: UR-002, UR-007 | DR-013, DR-277
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
@@ -350,16 +464,12 @@ fn items_listing_sql(
limit: usize, limit: usize,
start_index: usize, start_index: usize,
) -> String { ) -> String {
let catalog_available = if include_catalog { let available = available_sql("i", include_catalog);
"i.synced_at IS NOT NULL OR "
} else {
""
};
let parent_match = if parent_is_library { let parent_match = if parent_is_library {
format!( format!(
"i.server_id = ? "i.server_id = ?
AND ( AND (
i.parent_id = ? OR i.album_id = ? OR i.season_id = ? OR i.series_id = ? i.container_id = ?
-- When the requested parent is a LIBRARY, there is no per-item -- When the requested parent is a LIBRARY, there is no per-item
-- link back to it (library_id/parent_id are NULL in the cache), -- link back to it (library_id/parent_id are NULL in the cache),
-- so match every item on the server and let the type filter -- so match every item on the server and let the type filter
@@ -397,9 +507,7 @@ fn items_listing_sql(
library_type_matches_item!() library_type_matches_item!()
) )
} else { } else {
"+i.server_id = ? "+i.server_id = ? AND i.container_id = ?".to_string()
AND (i.parent_id = ? OR i.album_id = ? OR i.season_id = ? OR i.series_id = ?)"
.to_string()
}; };
format!( format!(
"SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id, i.overview, i.genres, "SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id, i.overview, i.genres,
@@ -409,24 +517,7 @@ fn items_listing_sql(
i.parent_index_number, i.is_folder, i.premiere_date i.parent_index_number, i.is_folder, i.premiere_date
FROM items i FROM items i
WHERE {parent_match} WHERE {parent_match}
AND ( AND {available}{type_filter}{favorites_filter}
{catalog_available}(
i.item_type IN ('Audio', 'Movie', 'Episode')
AND EXISTS (
SELECT 1 FROM downloads d
WHERE d.item_id = i.id AND d.status = 'completed'
)
)
OR (
i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
AND EXISTS (
SELECT 1 FROM items children
INNER JOIN downloads d ON d.item_id = children.id AND d.status = 'completed'
WHERE children.parent_id = i.id OR children.album_id = i.id
OR children.season_id = i.id OR children.series_id = i.id
)
)
){type_filter}{favorites_filter}
ORDER BY {order_by} ORDER BY {order_by}
LIMIT {limit} OFFSET {start_index}" LIMIT {limit} OFFSET {start_index}"
) )
@@ -679,6 +770,50 @@ impl OfflineRepository {
// TRACES: UR-007 | DR-278 // TRACES: UR-007 | DR-278
let owning_library = self.resolve_owning_library(parent_id).await; let owning_library = self.resolve_owning_library(parent_id).await;
// Placeholders for the logical containers these items list under
// (`container_id`, migration 027) when those were never cached: an
// episode fetched through Next Up or Latest has a season and series
// this device may never have browsed, and without their rows it would
// be unreachable from its series page offline. Named from the fields
// the child carries; `synced_at` stays NULL so they show only when a
// download makes them available, and the server's real row replaces
// them. Inserted before the parent stubs below, so a season that is
// also a parent gets a Season row rather than a nameless folder.
let mut placeholder_ids = std::collections::HashSet::new();
let mut placeholders: Vec<Query> = Vec::new();
for item in items {
for p in container_placeholders(item) {
if !placeholder_ids.insert(p.id.clone()) {
continue;
}
placeholders.push(Query::with_params(
"INSERT OR IGNORE INTO items
(id, server_id, library_id, name, item_type, is_folder,
series_id, series_name, album_artist)
VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?)",
vec![
QueryParam::String(p.id),
QueryParam::String(self.server_id.clone()),
owning_library
.clone()
.map(QueryParam::String)
.unwrap_or(QueryParam::Null),
QueryParam::String(p.name),
QueryParam::String(p.item_type.to_string()),
p.series_id
.map(QueryParam::String)
.unwrap_or(QueryParam::Null),
p.series_name
.map(QueryParam::String)
.unwrap_or(QueryParam::Null),
p.album_artist
.map(QueryParam::String)
.unwrap_or(QueryParam::Null),
],
));
}
}
// Stub rows for every parent referenced, so `items.parent_id` resolves // Stub rows for every parent referenced, so `items.parent_id` resolves
// whatever order the server returned children and parents in. // whatever order the server returned children and parents in.
let mut parent_ids = std::collections::HashSet::new(); let mut parent_ids = std::collections::HashSet::new();
@@ -728,6 +863,9 @@ impl OfflineRepository {
// TRACES: UR-002, UR-007 | DR-012 // TRACES: UR-002, UR-007 | DR-012
self.db_service self.db_service
.transaction_without_foreign_keys(move |tx| { .transaction_without_foreign_keys(move |tx| {
for placeholder in placeholders {
tx.execute(placeholder)?;
}
for stub in stubs { for stub in stubs {
tx.execute(stub)?; tx.execute(stub)?;
} }
@@ -748,6 +886,26 @@ impl OfflineRepository {
.map_err(|e| RepoError::Database { message: e }) .map_err(|e| RepoError::Database { message: e })
} }
/// Whether `id` is one of this server's libraries. Listings decide this
/// before building their query: the library clause is only correct — and
/// only affordable — when it is.
async fn is_library(&self, id: &str) -> Result<bool, RepoError> {
self.db_service
.query_optional(
Query::with_params(
"SELECT 1 FROM libraries WHERE id = ? AND server_id = ?",
vec![
QueryParam::String(id.to_string()),
QueryParam::String(self.server_id.clone()),
],
),
|row| row.get::<_, i64>(0),
)
.await
.map(|row| row.is_some())
.map_err(|e| RepoError::Database { message: e })
}
/// Which library the children of `parent_id` belong to. /// Which library the children of `parent_id` belong to.
/// ///
/// `Some(parent_id)` when the parent is itself a library, otherwise the /// `Some(parent_id)` when the parent is itself a library, otherwise the
@@ -1170,25 +1328,6 @@ impl OfflineRepository {
)" )"
); );
/// TRACES: UR-055 | DR-082, DR-083
const DOWNLOADED_ITEMS_CTE: &'static str = "
WITH downloaded_items AS (
SELECT DISTINCT i.id
FROM items i
INNER JOIN downloads d ON i.id = d.item_id
WHERE d.status = 'completed'
AND i.item_type IN ('Audio', 'Movie', 'Episode')
UNION
SELECT DISTINCT i.id
FROM items i
INNER JOIN items children ON (children.parent_id = i.id OR children.album_id = i.id OR children.season_id = i.id OR children.series_id = i.id)
INNER JOIN downloads d ON children.id = d.item_id
WHERE d.status = 'completed'
AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
)";
/// Downloaded-only browse: items under `parent_id` that are on the device. /// Downloaded-only browse: items under `parent_id` that are on the device.
/// ///
/// Unlike [`MediaRepository::get_items`], this never includes the /// Unlike [`MediaRepository::get_items`], this never includes the
@@ -1234,18 +1373,17 @@ impl OfflineRepository {
// container (e.g. a downloaded Movie, or a stray track whose album isn't // container (e.g. a downloaded Movie, or a stray track whose album isn't
// cached) still surface. This mirrors the online music library, which // cached) still surface. This mirrors the online music library, which
// routes to a dedicated albums view. See [[offline-libraries-never-cached]]. // routes to a dedicated albums view. See [[offline-libraries-never-cached]].
let sql = format!( let parent_is_library = self.is_library(parent_id).await?;
"{cte} let downloaded = downloaded_sql("i", PLAYABLE_TYPES, CONTAINER_TYPES);
SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id, i.overview, i.genres, let downloaded_parent = downloaded_sql("parent", PLAYABLE_TYPES, CONTAINER_TYPES);
i.runtime_ticks, i.production_year, i.community_rating, i.official_rating, // A non-library parent lists its children by `container_id` (migration
i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists, // 027) — series → seasons → episodes, one index range. The library
i.index_number, i.series_id, i.series_name, i.season_id, i.season_name, // clause is only added for a library, where it is the whole point.
i.parent_index_number, i.is_folder, i.premiere_date let parent_match = if parent_is_library {
FROM items i format!(
INNER JOIN downloaded_items di ON i.id = di.id "i.server_id = ?
WHERE i.server_id = ?
AND ( AND (
i.parent_id = ? OR i.album_id = ? OR i.season_id = ? OR i.series_id = ? i.container_id = ?
OR ( OR (
EXISTS ( EXISTS (
SELECT 1 FROM libraries l SELECT 1 FROM libraries l
@@ -1254,31 +1392,38 @@ impl OfflineRepository {
) )
-- Top-level only: hide leaves whose container is downloaded. -- Top-level only: hide leaves whose container is downloaded.
AND NOT EXISTS ( AND NOT EXISTS (
SELECT 1 FROM downloaded_items parent SELECT 1 FROM items parent
WHERE parent.id = i.album_id WHERE parent.id IN (i.album_id, i.season_id, i.series_id, i.parent_id)
OR parent.id = i.season_id AND {downloaded_parent}
OR parent.id = i.series_id
OR parent.id = i.parent_id
) )
) )
){type_filter} )",
membership = Self::LIBRARY_HOLDS_ITEM,
)
} else {
"+i.server_id = ? AND i.container_id = ?".to_string()
};
let sql = format!(
"SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id, i.overview, i.genres,
i.runtime_ticks, i.production_year, i.community_rating, i.official_rating,
i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists,
i.index_number, i.series_id, i.series_name, i.season_id, i.season_name,
i.parent_index_number, i.is_folder, i.premiere_date
FROM items i
WHERE {parent_match}
AND {downloaded}{type_filter}
ORDER BY i.sort_name ASC, i.name ASC ORDER BY i.sort_name ASC, i.name ASC
LIMIT {limit} OFFSET {start_index}", LIMIT {limit} OFFSET {start_index}",
cte = Self::DOWNLOADED_ITEMS_CTE,
membership = Self::LIBRARY_HOLDS_ITEM,
); );
let query = Query::with_params( let mut params = vec![
sql,
vec![
QueryParam::String(self.server_id.clone()), QueryParam::String(self.server_id.clone()),
QueryParam::String(parent_id.to_string()), QueryParam::String(parent_id.to_string()),
QueryParam::String(parent_id.to_string()), ];
QueryParam::String(parent_id.to_string()), if parent_is_library {
QueryParam::String(parent_id.to_string()), params.push(QueryParam::String(parent_id.to_string()));
QueryParam::String(parent_id.to_string()), }
], let query = Query::with_params(sql, params);
);
let cached_items: Vec<CachedItem> = self let cached_items: Vec<CachedItem> = self
.db_service .db_service
@@ -1307,19 +1452,18 @@ impl OfflineRepository {
// completed download of a given media kind qualifies that library. // completed download of a given media kind qualifies that library.
let query = Query::with_params( let query = Query::with_params(
format!( format!(
"{cte} "SELECT l.id, l.name, l.collection_type, l.image_tag
SELECT l.id, l.name, l.collection_type, l.image_tag
FROM libraries l FROM libraries l
WHERE l.server_id = ? WHERE l.server_id = ?
AND EXISTS ( AND EXISTS (
SELECT 1 FROM items i SELECT 1 FROM items i
INNER JOIN downloaded_items di ON i.id = di.id
WHERE i.server_id = l.server_id WHERE i.server_id = l.server_id
AND {membership} AND {membership}
AND {downloaded}
) )
ORDER BY l.sort_order ASC, l.name ASC", ORDER BY l.sort_order ASC, l.name ASC",
cte = Self::DOWNLOADED_ITEMS_CTE,
membership = Self::LIBRARY_HOLDS_ITEM, membership = Self::LIBRARY_HOLDS_ITEM,
downloaded = downloaded_sql("i", PLAYABLE_TYPES, CONTAINER_TYPES),
), ),
vec![QueryParam::String(self.server_id.clone())], vec![QueryParam::String(self.server_id.clone())],
); );
@@ -1607,23 +1751,9 @@ impl MediaRepository for OfflineRepository {
"" ""
}; };
// Decided up front so the listing can use the hierarchy indexes; see // Decided up front so the listing can use the container index; see
// `items_listing_sql`. // `items_listing_sql`.
let parent_is_library = self let parent_is_library = self.is_library(parent_id).await?;
.db_service
.query_optional(
Query::with_params(
"SELECT 1 FROM libraries WHERE id = ? AND server_id = ?",
vec![
QueryParam::String(parent_id.to_string()),
QueryParam::String(self.server_id.clone()),
],
),
|row| row.get::<_, i64>(0),
)
.await
.map_err(|e| RepoError::Database { message: e })?
.is_some();
let sql = items_listing_sql( let sql = items_listing_sql(
parent_is_library, parent_is_library,
@@ -1635,17 +1765,11 @@ impl MediaRepository for OfflineRepository {
start_index, start_index,
); );
// The requested id is compared against every hierarchy-linkage column // Children are matched on `container_id` (see `items_listing_sql`); a
// because `parent_id` is not populated for cached items — music tracks // library parent also matches through the `libraries` EXISTS clause.
// link to their album via `album_id`, episodes to their season/series
// via `season_id`/`series_id`, and a library parent matches via the
// `libraries` EXISTS clause. See [[offline-libraries-never-cached]].
let mut params = vec![ let mut params = vec![
QueryParam::String(self.server_id.clone()), QueryParam::String(self.server_id.clone()),
QueryParam::String(parent_id.to_string()), // i.parent_id = ? QueryParam::String(parent_id.to_string()), // i.container_id = ?
QueryParam::String(parent_id.to_string()), // i.album_id = ?
QueryParam::String(parent_id.to_string()), // i.season_id = ?
QueryParam::String(parent_id.to_string()), // i.series_id = ?
]; ];
if parent_is_library { if parent_is_library {
params.push(QueryParam::String(parent_id.to_string())); // libraries.id = ? params.push(QueryParam::String(parent_id.to_string())); // libraries.id = ?
@@ -1691,32 +1815,16 @@ impl MediaRepository for OfflineRepository {
async fn get_item(&self, item_id: &str) -> Result<MediaItem, RepoError> { async fn get_item(&self, item_id: &str) -> Result<MediaItem, RepoError> {
// Check if item is available offline (either downloaded itself or has downloaded children) // Check if item is available offline (either downloaded itself or has downloaded children)
let query = Query::with_params( let query = Query::with_params(
"WITH downloaded_items AS ( format!(
-- Playable items with completed downloads "SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id, i.overview, i.genres,
SELECT DISTINCT i.id
FROM items i
INNER JOIN downloads d ON i.id = d.item_id
WHERE d.status = 'completed'
AND i.item_type IN ('Audio', 'Movie', 'Episode')
UNION
-- Containers with downloaded children
SELECT DISTINCT i.id
FROM items i
INNER JOIN items children ON (children.parent_id = i.id OR children.album_id = i.id OR children.season_id = i.id OR children.series_id = i.id)
INNER JOIN downloads d ON children.id = d.item_id
WHERE d.status = 'completed'
AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
)
SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id, i.overview, i.genres,
i.runtime_ticks, i.production_year, i.community_rating, i.official_rating, i.runtime_ticks, i.production_year, i.community_rating, i.official_rating,
i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists, i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists,
i.index_number, i.series_id, i.series_name, i.season_id, i.season_name, i.index_number, i.series_id, i.series_name, i.season_id, i.season_name,
i.parent_index_number, i.is_folder, i.premiere_date i.parent_index_number, i.is_folder, i.premiere_date
FROM items i FROM items i
INNER JOIN downloaded_items di ON i.id = di.id WHERE i.id = ? AND {}",
WHERE i.id = ?", downloaded_sql("i", PLAYABLE_TYPES, CONTAINER_TYPES)
),
vec![QueryParam::String(item_id.to_string())], vec![QueryParam::String(item_id.to_string())],
); );
@@ -1745,42 +1853,27 @@ impl MediaRepository for OfflineRepository {
let query = Query::with_params( let query = Query::with_params(
format!( format!(
"WITH downloaded_items AS ( "SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id, i.overview, i.genres,
-- Playable items with completed downloads
SELECT DISTINCT i.id
FROM items i
INNER JOIN downloads d ON i.id = d.item_id
WHERE d.status = 'completed'
AND i.item_type IN ('Audio', 'Movie', 'Episode')
UNION
-- Containers with downloaded children
SELECT DISTINCT i.id
FROM items i
INNER JOIN items children ON (children.parent_id = i.id OR children.album_id = i.id OR children.season_id = i.id OR children.series_id = i.id)
INNER JOIN downloads d ON children.id = d.item_id
WHERE d.status = 'completed'
AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
)
SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id, i.overview, i.genres,
i.runtime_ticks, i.production_year, i.community_rating, i.official_rating, i.runtime_ticks, i.production_year, i.community_rating, i.official_rating,
i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists, i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists,
i.index_number, i.series_id, i.series_name, i.season_id, i.season_name, i.index_number, i.series_id, i.series_name, i.season_id, i.season_name,
i.parent_index_number, i.is_folder, i.premiere_date i.parent_index_number, i.is_folder, i.premiere_date
FROM items i FROM items i
INNER JOIN downloaded_items di ON i.id = di.id
WHERE i.server_id = ? AND i.library_id = ? WHERE i.server_id = ? AND i.library_id = ?
AND {downloaded}
-- Collapse leaves into the container that was added: a new -- Collapse leaves into the container that was added: a new
-- 14-track album should read as one album, not 14 songs. Only -- 14-track album should read as one album, not 14 songs. Only
-- drops a leaf when its own container is present in the same -- drops a leaf when its own container is present in the same
-- result, so a standalone track or movie still appears. -- result, so a standalone track or movie still appears.
AND NOT EXISTS ( AND NOT EXISTS (
SELECT 1 FROM downloaded_items parent SELECT 1 FROM items parent
WHERE parent.id IN (i.album_id, i.season_id, i.series_id, i.parent_id) WHERE parent.id IN (i.album_id, i.season_id, i.series_id, i.parent_id)
AND {downloaded_parent}
) )
ORDER BY i.synced_at DESC ORDER BY i.synced_at DESC
LIMIT {}", limit_val LIMIT {limit_val}",
downloaded = downloaded_sql("i", PLAYABLE_TYPES, CONTAINER_TYPES),
downloaded_parent = downloaded_sql("parent", PLAYABLE_TYPES, CONTAINER_TYPES),
), ),
vec![ vec![
QueryParam::String(self.server_id.clone()), QueryParam::String(self.server_id.clone()),
@@ -1895,25 +1988,7 @@ impl MediaRepository for OfflineRepository {
// Only shows items that are downloaded or have downloaded children // Only shows items that are downloaded or have downloaded children
let query = Query::with_params( let query = Query::with_params(
format!( format!(
"WITH downloaded_items AS ( "WITH ranked_plays AS (
-- Playable items with completed downloads (Audio tracks)
SELECT DISTINCT i.id
FROM items i
INNER JOIN downloads d ON i.id = d.item_id
WHERE d.status = 'completed'
AND i.item_type = 'Audio'
UNION
-- Containers with downloaded children (Albums)
SELECT DISTINCT i.id
FROM items i
INNER JOIN items children ON (children.parent_id = i.id OR children.album_id = i.id OR children.season_id = i.id OR children.series_id = i.id)
INNER JOIN downloads d ON children.id = d.item_id
WHERE d.status = 'completed'
AND i.item_type = 'MusicAlbum'
),
ranked_plays AS (
SELECT SELECT
CASE CASE
WHEN ud.playback_context_type = 'container' THEN ud.playback_context_id WHEN ud.playback_context_type = 'container' THEN ud.playback_context_id
@@ -1938,9 +2013,10 @@ impl MediaRepository for OfflineRepository {
i.season_name, i.parent_index_number, i.is_folder, i.premiere_date i.season_name, i.parent_index_number, i.is_folder, i.premiere_date
FROM ranked_plays rp FROM ranked_plays rp
JOIN items i ON rp.display_id = i.id JOIN items i ON rp.display_id = i.id
INNER JOIN downloaded_items di ON i.id = di.id WHERE {downloaded}
ORDER BY rp.most_recent_play DESC", ORDER BY rp.most_recent_play DESC",
limit_val limit_val,
downloaded = downloaded_sql("i", "'Audio'", "'MusicAlbum'")
), ),
vec![ vec![
QueryParam::String(self.user_id.clone()), QueryParam::String(self.user_id.clone()),
@@ -2076,40 +2152,10 @@ impl MediaRepository for OfflineRepository {
// never disagree about what is visible. Before DR-108 this leg was // never disagree about what is visible. Before DR-108 this leg was
// downloads-only, which meant a user with no downloads got nothing from // downloads-only, which meant a user with no downloads got nothing from
// the local index and every keystroke fell through to the server. // the local index and every keystroke fell through to the server.
let catalog_branch = if include_catalog_browse() { let available = available_sql("i", include_catalog_browse());
"UNION
-- Synced catalog: fast online search, or the offline
-- 'Show all server media' view. See set_include_catalog_browse.
SELECT DISTINCT i.id
FROM items i
WHERE i.synced_at IS NOT NULL"
} else {
""
};
let sql = format!( let sql = format!(
"WITH available_items AS ( "SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id,
-- Playable items with completed downloads
SELECT DISTINCT i.id
FROM items i
INNER JOIN downloads d ON i.id = d.item_id
WHERE d.status = 'completed'
AND i.item_type IN ('Audio', 'Movie', 'Episode')
UNION
-- Containers with downloaded children
SELECT DISTINCT i.id
FROM items i
INNER JOIN items children ON (children.parent_id = i.id OR children.album_id = i.id OR children.season_id = i.id OR children.series_id = i.id)
INNER JOIN downloads d ON children.id = d.item_id
WHERE d.status = 'completed'
AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
{}
)
SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id,
i.overview, i.genres, i.runtime_ticks, i.production_year, i.overview, i.genres, i.runtime_ticks, i.production_year,
i.community_rating, i.official_rating, i.primary_image_tag, i.community_rating, i.official_rating, i.primary_image_tag,
i.album_id, i.album_name, i.album_artist, i.artists, i.album_id, i.album_name, i.album_artist, i.artists,
@@ -2117,11 +2163,10 @@ impl MediaRepository for OfflineRepository {
i.season_name, i.parent_index_number, i.is_folder, i.premiere_date i.season_name, i.parent_index_number, i.is_folder, i.premiere_date
FROM items i FROM items i
JOIN items_fts fts ON fts.rowid = i.rowid JOIN items_fts fts ON fts.rowid = i.rowid
INNER JOIN available_items ai ON i.id = ai.id WHERE i.server_id = ? AND items_fts MATCH ? AND {}{}
WHERE i.server_id = ? AND items_fts MATCH ?{}
ORDER BY rank ORDER BY rank
LIMIT {}", LIMIT {}",
catalog_branch, type_filter, limit available, type_filter, limit
); );
let mut params = vec![ let mut params = vec![
@@ -2304,46 +2349,20 @@ impl MediaRepository for OfflineRepository {
_ => String::new(), _ => String::new(),
}; };
let catalog_branch = if include_catalog_browse() { let available = available_sql("i", include_catalog_browse());
"UNION
SELECT DISTINCT i.id
FROM items i
WHERE i.synced_at IS NOT NULL"
} else {
""
};
let sql = format!( let sql = format!(
"WITH available_items AS ( "SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id, i.overview, i.genres,
SELECT DISTINCT i.id
FROM items i
INNER JOIN downloads d ON i.id = d.item_id
WHERE d.status = 'completed'
AND i.item_type IN ('Audio', 'Movie', 'Episode')
UNION
SELECT DISTINCT i.id
FROM items i
INNER JOIN items children ON (children.parent_id = i.id OR children.album_id = i.id OR children.season_id = i.id OR children.series_id = i.id)
INNER JOIN downloads d ON children.id = d.item_id
WHERE d.status = 'completed'
AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
{catalog_branch}
)
SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id, i.overview, i.genres,
i.runtime_ticks, i.production_year, i.community_rating, i.official_rating, i.runtime_ticks, i.production_year, i.community_rating, i.official_rating,
i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists, i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists,
i.index_number, i.series_id, i.series_name, i.season_id, i.season_name, i.index_number, i.series_id, i.series_name, i.season_id, i.season_name,
i.parent_index_number, i.is_folder, i.premiere_date i.parent_index_number, i.is_folder, i.premiere_date
FROM items i FROM items i
INNER JOIN available_items ai ON i.id = ai.id
INNER JOIN user_data ud ON ud.item_id = i.id INNER JOIN user_data ud ON ud.item_id = i.id
WHERE i.server_id = ? WHERE i.server_id = ?
AND ud.user_id = ? AND ud.user_id = ?
AND ud.is_favorite = 1{} AND ud.is_favorite = 1
AND {available}{}
ORDER BY i.sort_name ASC, i.name ASC ORDER BY i.sort_name ASC, i.name ASC
LIMIT {} OFFSET {}", LIMIT {} OFFSET {}",
type_filter, limit, start_index type_filter, limit, start_index
@@ -2459,25 +2478,7 @@ impl MediaRepository for OfflineRepository {
// Filter by downloads using CTE // Filter by downloads using CTE
let query = Query::with_params( let query = Query::with_params(
format!( format!(
"WITH downloaded_items AS ( "SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id,
-- Playable items with completed downloads
SELECT DISTINCT i.id
FROM items i
INNER JOIN downloads d ON i.id = d.item_id
WHERE d.status = 'completed'
AND i.item_type IN ('Audio', 'Movie', 'Episode')
UNION
-- Containers with downloaded children
SELECT DISTINCT i.id
FROM items i
INNER JOIN items children ON (children.parent_id = i.id OR children.album_id = i.id OR children.season_id = i.id OR children.series_id = i.id)
INNER JOIN downloads d ON children.id = d.item_id
WHERE d.status = 'completed'
AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
)
SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id,
i.overview, i.genres, i.runtime_ticks, i.production_year, i.overview, i.genres, i.runtime_ticks, i.production_year,
i.community_rating, i.official_rating, i.primary_image_tag, i.community_rating, i.official_rating, i.primary_image_tag,
i.album_id, i.album_name, i.album_artist, i.artists, i.album_id, i.album_name, i.album_artist, i.artists,
@@ -2485,10 +2486,11 @@ impl MediaRepository for OfflineRepository {
i.season_name, i.parent_index_number, i.is_folder, i.premiere_date i.season_name, i.parent_index_number, i.is_folder, i.premiere_date
FROM items i FROM items i
JOIN item_people ip ON i.id = ip.item_id JOIN item_people ip ON i.id = ip.item_id
INNER JOIN downloaded_items di ON i.id = di.id WHERE i.server_id = ? AND ip.person_id = ? AND {downloaded}
WHERE i.server_id = ? AND ip.person_id = ?
ORDER BY i.production_year DESC, i.sort_name ASC ORDER BY i.production_year DESC, i.sort_name ASC
LIMIT {}", limit LIMIT {}",
limit,
downloaded = downloaded_sql("i", PLAYABLE_TYPES, CONTAINER_TYPES)
), ),
vec![ vec![
QueryParam::String(self.server_id.clone()), QueryParam::String(self.server_id.clone()),
@@ -2918,8 +2920,18 @@ mod tests {
season_name TEXT, season_name TEXT,
parent_index_number INTEGER, parent_index_number INTEGER,
synced_at TEXT, synced_at TEXT,
sort_name TEXT sort_name TEXT,
-- Same rule as schema.rs migration 027.
container_id TEXT GENERATED ALWAYS AS (
CASE item_type
WHEN 'Episode' THEN COALESCE(season_id, series_id, parent_id)
WHEN 'Season' THEN COALESCE(series_id, parent_id)
WHEN 'Audio' THEN COALESCE(album_id, parent_id)
ELSE parent_id
END
) VIRTUAL
); );
CREATE INDEX idx_items_container ON items(container_id, sort_name, name);
-- Mirrors the real FTS5 index and its triggers (schema.rs migration -- Mirrors the real FTS5 index and its triggers (schema.rs migration
-- 001) so search can be exercised in tests at all. -- 001) so search can be exercised in tests at all.
@@ -3983,11 +3995,16 @@ mod tests {
"get_items(season_id) should return the episode" "get_items(season_id) should return the episode"
); );
// Browsing the series returns the episode (via series_id link). // Browsing the series returns the season that holds the download —
// series → season → episode, the same hierarchy the server serves.
// (It used to return the episode itself, via its `series_id`, beside
// the seasons; see `a_series_lists_its_seasons_not_their_episodes`.)
let series_items = repo.get_items("series-1", None).await.unwrap(); let series_items = repo.get_items("series-1", None).await.unwrap();
assert!( let ids: Vec<&str> = series_items.items.iter().map(|i| i.id.as_str()).collect();
series_items.items.iter().any(|i| i.id == "ep-1"), assert_eq!(
"get_items(series_id) should surface the downloaded episode" ids,
vec!["season-1"],
"get_items(series_id) should list the season holding the download"
); );
} }
@@ -4396,9 +4413,11 @@ mod tests {
// Drilling into the series returns its season; into the season, the episode. // Drilling into the series returns its season; into the season, the episode.
let in_series = repo.get_downloaded_items("series-1", None).await.unwrap(); let in_series = repo.get_downloaded_items("series-1", None).await.unwrap();
assert!( let in_series_ids: Vec<&str> = in_series.items.iter().map(|i| i.id.as_str()).collect();
in_series.items.iter().any(|i| i.id == "season-1"), assert_eq!(
"series drill returns the season" in_series_ids,
vec!["season-1"],
"series drill returns the season — not the season's episodes"
); );
let in_season = repo.get_downloaded_items("season-1", None).await.unwrap(); let in_season = repo.get_downloaded_items("season-1", None).await.unwrap();
assert!( assert!(
@@ -5800,7 +5819,7 @@ mod tests {
/// ///
/// TRACES: UR-002 | DR-013 /// TRACES: UR-002 | DR-013
#[test] #[test]
fn listing_a_non_library_parent_uses_the_hierarchy_indexes() { fn listing_a_non_library_parent_uses_the_container_index() {
use crate::utils::lock::MutexSafe; use crate::utils::lock::MutexSafe;
let db = crate::storage::Database::open_in_memory().unwrap(); let db = crate::storage::Database::open_in_memory().unwrap();
let conn = db.connection(); let conn = db.connection();
@@ -5817,16 +5836,14 @@ mod tests {
); );
let mut stmt = conn.prepare(&format!("EXPLAIN QUERY PLAN {sql}")).unwrap(); let mut stmt = conn.prepare(&format!("EXPLAIN QUERY PLAN {sql}")).unwrap();
let plan: Vec<String> = stmt let plan: Vec<String> = stmt
.query_map(rusqlite::params!["srv", "p", "p", "p", "p"], |row| { .query_map(rusqlite::params!["srv", "p"], |row| row.get::<_, String>(3))
row.get::<_, String>(3)
})
.unwrap() .unwrap()
.map(Result::unwrap) .map(Result::unwrap)
.collect(); .collect();
let plan = plan.join("\n"); let plan = plan.join("\n");
assert!( assert!(
plan.contains("MULTI-INDEX OR"), plan.contains("idx_items_container"),
"expected an index lookup per hierarchy column, got:\n{plan}" "expected a container index lookup, got:\n{plan}"
); );
assert!( assert!(
!plan.contains("SCAN i") && !plan.contains("idx_items_server"), !plan.contains("SCAN i") && !plan.contains("idx_items_server"),
@@ -5834,4 +5851,108 @@ mod tests {
); );
} }
} }
/// A series lists its seasons — not every episode of every season.
///
/// Children were matched on four columns at once (`parent_id`, `album_id`,
/// `season_id`, `series_id`), and every episode carries its series id, so
/// a cached series answered with its eleven seasons *and* all ~275
/// episodes. With the series page's `limit: 100`, episodes whose names sort
/// before "Season …" could push seasons out of the answer altogether.
///
/// TRACES: UR-002, UR-007 | DR-013
#[tokio::test]
async fn a_series_lists_its_seasons_not_their_episodes() {
let _guard = lock_catalog_browse();
set_include_catalog_browse(true);
let db_service = create_test_db();
for sql in [
"INSERT INTO items (id, server_id, name, item_type, synced_at) \
VALUES ('show', 'test-server', 'Show', 'Series', '2026-01-01')",
"INSERT INTO items (id, server_id, parent_id, series_id, name, item_type, synced_at) \
VALUES ('s1', 'test-server', 'show', 'show', 'Season 1', 'Season', '2026-01-01')",
"INSERT INTO items (id, server_id, parent_id, season_id, series_id, name, item_type, synced_at) \
VALUES ('e1', 'test-server', 's1', 's1', 'show', 'A Pilot', 'Episode', '2026-01-01')",
] {
db_service.execute(Query::new(sql)).await.unwrap();
}
let repo = OfflineRepository::new(
db_service.clone(),
"test-server".to_string(),
"test-user".to_string(),
);
let ids: Vec<String> = repo
.get_items("show", None)
.await
.unwrap()
.items
.into_iter()
.map(|i| i.id)
.collect();
assert_eq!(ids, vec!["s1".to_string()]);
}
/// Caching an episode (or a track) whose season, series (or album) was
/// never cached must leave that container reachable: a series lists its
/// seasons, so without a season row a downloaded episode would be
/// unreachable from its series page offline.
///
/// TRACES: UR-002, UR-007 | DR-013
#[tokio::test]
async fn caching_a_child_creates_its_missing_containers() {
let db_service = create_test_db();
let repo = OfflineRepository::new(
db_service.clone(),
"test-server".to_string(),
"test-user".to_string(),
);
let mut episode = create_test_item("ep", "Pilot", Some("next-up"));
episode.item_type = "Episode".to_string();
episode.season_id = Some("season-9".to_string());
episode.season_name = Some("Season 9".to_string());
episode.series_id = Some("show-9".to_string());
episode.series_name = Some("Show 9".to_string());
let mut track = create_test_item("trk", "Song", Some("next-up"));
track.item_type = "Audio".to_string();
track.album_id = Some("alb-9".to_string());
track.album_name = Some("Record".to_string());
repo.save_to_cache("next-up", &[episode, track])
.await
.unwrap();
let row = |id: &'static str| {
let db_service = db_service.clone();
async move {
db_service
.query_optional(
Query::with_params(
"SELECT name, item_type, container_id FROM items WHERE id = ?",
vec![QueryParam::String(id.to_string())],
),
|r| {
Ok((
r.get::<_, String>(0)?,
r.get::<_, String>(1)?,
r.get::<_, Option<String>>(2)?,
))
},
)
.await
.unwrap()
}
};
assert_eq!(
row("season-9").await,
Some(("Season 9".into(), "Season".into(), Some("show-9".into())))
);
assert_eq!(
row("show-9").await,
Some(("Show 9".into(), "Series".into(), None))
);
assert_eq!(
row("alb-9").await,
Some(("Record".into(), "MusicAlbum".into(), None))
);
}
} }
+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),
+61
View File
@@ -65,6 +65,18 @@ impl Database {
let conn = Arc::new(Mutex::new(conn)); let conn = Arc::new(Mutex::new(conn));
Self::migrate_connection(&conn, MIGRATIONS)?; Self::migrate_connection(&conn, MIGRATIONS)?;
// Planner statistics. Without them SQLite guesses between indexes, and
// guessed badly for the listing query (see 08-database-design.md →
// "Listing query shape"). `optimize` only analyses what is missing or
// stale; `analysis_limit` bounds each table's scan so this stays in the
// milliseconds on a large catalogue. Failure is not fatal.
if let Err(e) = conn
.lock_safe()
.execute_batch("PRAGMA analysis_limit = 400; PRAGMA optimize = 0x10002;")
{
error!("PRAGMA optimize failed: {}", e);
}
// Readers open after migrations, so they only ever see the final schema. // Readers open after migrations, so they only ever see the final schema.
let readers = (0..READER_CONNECTIONS) let readers = (0..READER_CONNECTIONS)
.map(|_| Self::open_reader(path)) .map(|_| Self::open_reader(path))
@@ -1000,6 +1012,55 @@ mod tests {
assert!(busy_timeout > 0, "expected a busy timeout"); assert!(busy_timeout > 0, "expected a busy timeout");
} }
/// The planner gets statistics: the app used to never run `ANALYZE`, so
/// SQLite guessed between indexes — and for the listing query guessed the
/// `server_id` index, which every row shares, turning an index lookup into
/// a walk of the whole catalogue. `PRAGMA optimize` at open refreshes
/// whatever statistics are missing or stale, bounded by `analysis_limit`.
///
/// TRACES: UR-002 | DR-012 | UT-014
#[test]
fn open_gives_the_planner_statistics() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("jellytau.db");
{
let db = Database::open(&path).unwrap();
let conn = db.connection();
let conn = conn.lock_safe();
conn.execute_batch(
"INSERT INTO servers (id, name, url) VALUES ('s', 'S', 'http://s');",
)
.unwrap();
for i in 0..2000 {
conn.execute(
"INSERT INTO items (id, server_id, name, item_type) VALUES (?1, 's', 'n', 'Audio')",
[format!("i{i}")],
)
.unwrap();
}
}
let db = Database::open(&path).unwrap();
let conn = db.connection();
let conn = conn.lock_safe();
let analysed: i64 = conn
.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE name = 'sqlite_stat1'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(analysed, 1, "the planner has no statistics");
let items_stats: i64 = conn
.query_row(
"SELECT COUNT(*) FROM sqlite_stat1 WHERE tbl = 'items'",
[],
|r| r.get(0),
)
.unwrap();
assert!(items_stats > 0, "no statistics for items");
}
/// Writes a phone-sized catalogue to `$JELLYTAU_BENCH_DB` for timing /// Writes a phone-sized catalogue to `$JELLYTAU_BENCH_DB` for timing
/// queries with the `sqlite3` CLI. Not a test; run explicitly with /// queries with the `sqlite3` CLI. Not a test; run explicitly with
/// `--ignored`. /// `--ignored`.
+164
View File
@@ -31,6 +31,7 @@ pub const MIGRATIONS: &[(&str, &str)] = &[
("024_multi_user_profiles", MIGRATION_024), ("024_multi_user_profiles", MIGRATION_024),
("025_backfill_item_library_id", MIGRATION_025), ("025_backfill_item_library_id", MIGRATION_025),
("026_server_catalog_generation", MIGRATION_026), ("026_server_catalog_generation", MIGRATION_026),
("027_items_container_id", MIGRATION_027),
]; ];
/// Initial schema migration /// Initial schema migration
@@ -941,6 +942,169 @@ const MIGRATION_026: &str = r#"
ALTER TABLE servers ADD COLUMN catalog_generation TEXT; ALTER TABLE servers ADD COLUMN catalog_generation TEXT;
"#; "#;
/// One canonical "which container lists this item" link, plus an index that
/// serves a listing in display order.
///
/// Jellyfin's `ParentId` is the *storage* parent, not the logical one: in a
/// series without season folders an episode's `ParentId` is the series while
/// its `SeasonId` names a virtual season, and a cached episode may arrive
/// without its season row at all. So listings matched children on four
/// columns at once (`parent_id`, `album_id`, `season_id`, `series_id`). That
/// was slow — the `OR` defeated the planner into walking the whole table — and
/// wrong: every episode carries its series id, so a series answered with its
/// seasons *and* all their episodes.
///
/// `container_id` resolves the logical container once, by rule: an episode
/// belongs to its season (else its series, else its parent), a season to its
/// series, a track to its album, anything else to its parent. It is a VIRTUAL
/// generated column, so every write path — cache, downloads, catalog crawl —
/// is covered without touching any of them, and it cannot drift from the
/// columns it is computed from. The index covers the listing's
/// `ORDER BY sort_name, name` (`sort_name` is usually NULL in the cache).
///
/// The placeholders keep offline navigation intact: an episode whose season
/// or series row was never cached used to surface directly under the series
/// through the `series_id` match. Now it lists under its season, so the season
/// (and series, and a track's album) must exist. They are built from the
/// names the child rows already carry, with `synced_at` NULL — they only show
/// when a download makes them available, and a real row from the server
/// replaces them wholesale (`save_to_cache` upserts every field).
///
/// TRACES: UR-002, UR-007 | DR-013
const MIGRATION_027: &str = r#"
ALTER TABLE items ADD COLUMN container_id TEXT GENERATED ALWAYS AS (
CASE item_type
WHEN 'Episode' THEN COALESCE(season_id, series_id, parent_id)
WHEN 'Season' THEN COALESCE(series_id, parent_id)
WHEN 'Audio' THEN COALESCE(album_id, parent_id)
ELSE parent_id
END
) VIRTUAL;
CREATE INDEX IF NOT EXISTS idx_items_container ON items(container_id, sort_name, name);
INSERT OR IGNORE INTO items (id, server_id, library_id, name, item_type, is_folder, series_id, series_name)
SELECT season_id, server_id, MAX(library_id), COALESCE(MAX(season_name), 'Season'), 'Season', 1,
MAX(series_id), MAX(series_name)
FROM items
WHERE item_type = 'Episode' AND season_id IS NOT NULL
GROUP BY season_id;
INSERT OR IGNORE INTO items (id, server_id, library_id, name, item_type, is_folder)
SELECT series_id, server_id, MAX(library_id), COALESCE(MAX(series_name), 'Series'), 'Series', 1
FROM items
WHERE item_type IN ('Episode', 'Season') AND series_id IS NOT NULL
GROUP BY series_id;
INSERT OR IGNORE INTO items (id, server_id, library_id, name, item_type, is_folder, album_artist)
SELECT album_id, server_id, MAX(library_id), COALESCE(MAX(album_name), 'Album'), 'MusicAlbum', 1,
MAX(album_artist)
FROM items
WHERE item_type = 'Audio' AND album_id IS NOT NULL
GROUP BY album_id;
"#;
#[cfg(test)]
mod migration_027_tests {
use super::*;
use rusqlite::Connection;
fn pre_027_db() -> Connection {
let conn = Connection::open_in_memory().unwrap();
let upto = MIGRATIONS
.iter()
.position(|(name, _)| *name == "027_items_container_id")
.expect("migration 027 must be registered");
for (_, sql) in &MIGRATIONS[..upto] {
conn.execute_batch(sql).unwrap();
}
conn.execute_batch(
"INSERT INTO servers (id, name, url) VALUES ('s', 'S', 'http://s');
-- An episode cached without its season or series rows.
INSERT INTO items (id, server_id, name, item_type, parent_id, season_id, season_name,
series_id, series_name, library_id)
VALUES ('ep', 's', 'Pilot', 'Episode', NULL, 'season', 'Season 1',
'show', 'Show', NULL);
-- A track cached without its album.
INSERT INTO items (id, server_id, name, item_type, album_id, album_name, album_artist)
VALUES ('trk', 's', 'Song', 'Audio', 'alb', 'Record', 'Band');
-- A folder child: its container is just its parent.
INSERT INTO items (id, server_id, name, item_type) VALUES ('box', 's', 'Box', 'BoxSet');
INSERT INTO items (id, server_id, name, item_type, parent_id)
VALUES ('film', 's', 'Film', 'Movie', 'box');",
)
.unwrap();
conn
}
fn container(conn: &Connection, id: &str) -> Option<String> {
conn.query_row("SELECT container_id FROM items WHERE id = ?1", [id], |r| {
r.get(0)
})
.unwrap()
}
/// TRACES: UR-002, UR-007 | DR-013
#[test]
fn every_item_resolves_to_its_logical_container() {
let conn = pre_027_db();
conn.execute_batch(MIGRATION_027).unwrap();
assert_eq!(container(&conn, "ep").as_deref(), Some("season"));
assert_eq!(container(&conn, "season").as_deref(), Some("show"));
assert_eq!(container(&conn, "trk").as_deref(), Some("alb"));
assert_eq!(container(&conn, "film").as_deref(), Some("box"));
assert_eq!(container(&conn, "show"), None);
}
/// Containers that were never cached get placeholders named from their
/// children, so an offline episode is still reachable series → season.
///
/// TRACES: UR-002, UR-007 | DR-013
#[test]
fn missing_containers_get_named_placeholders() {
let conn = pre_027_db();
conn.execute_batch(MIGRATION_027).unwrap();
let row = |id: &str| -> (String, String, Option<String>) {
conn.query_row(
"SELECT name, item_type, synced_at FROM items WHERE id = ?1",
[id],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)
.unwrap()
};
assert_eq!(row("season"), ("Season 1".into(), "Season".into(), None));
assert_eq!(row("show"), ("Show".into(), "Series".into(), None));
assert_eq!(row("alb"), ("Record".into(), "MusicAlbum".into(), None));
}
/// Listing a container is one index range, already in display order.
///
/// TRACES: UR-002, UR-007 | DR-013
#[test]
fn a_container_listing_is_an_ordered_index_range() {
let conn = pre_027_db();
conn.execute_batch(MIGRATION_027).unwrap();
let plan: Vec<String> = conn
.prepare(
"EXPLAIN QUERY PLAN SELECT id FROM items
WHERE container_id = ?1 ORDER BY sort_name, name",
)
.unwrap()
.query_map(["season"], |r| r.get::<_, String>(3))
.unwrap()
.map(Result::unwrap)
.collect();
let plan = plan.join("\n");
assert!(plan.contains("idx_items_container"), "{plan}");
assert!(
!plan.contains("TEMP B-TREE"),
"listing needs a sort step:\n{plan}"
);
}
}
#[cfg(test)] #[cfg(test)]
mod migration_024_tests { mod migration_024_tests {
use super::*; use super::*;
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "JellyTau", "productName": "JellyTau",
"version": "0.13.1", "version": "0.13.2",
"identifier": "com.dtourolle.jellytau", "identifier": "com.dtourolle.jellytau",
"build": { "build": {
"beforeDevCommand": "bun run dev", "beforeDevCommand": "bun run dev",
+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}