Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73fd8a1dfe | ||
|
|
a676f4aba8 | ||
|
|
c0545a245f |
@@ -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
|
||||||
|
|||||||
@@ -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 2–3 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
|
||||||
|
|||||||
@@ -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
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -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",
|
||||||
|
|||||||
Generated
+1
-1
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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))
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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,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",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
@@ -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} />
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user