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
|
||||
[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
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
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
|
||||
(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
|
||||
from the component because it had three distinct bugs that markup made
|
||||
untestable: the strip collapsing to just the current episode while real siblings
|
||||
|
||||
@@ -243,9 +243,14 @@ CREATE TABLE items (
|
||||
last_sync DATETIME,
|
||||
|
||||
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
|
||||
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_library ON items(library_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
|
||||
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,
|
||||
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
|
||||
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,
|
||||
downloaded, or a container with a downloaded child). It used to be a CTE
|
||||
that built the id of *every* available item in the database before filtering
|
||||
to the parent: ~80 ms on a desktop for a 100k-item cache, whatever the parent.
|
||||
- **A parent that is not a library is matched on the hierarchy columns alone**
|
||||
(`parent_id`/`album_id`/`season_id`/`series_id`), which SQLite answers with a
|
||||
multi-index `OR`. Whether the parent is a library is looked up first; the
|
||||
library clause can only match for a library, and inside the same `OR` it
|
||||
forced a full scan.
|
||||
- **`+i.server_id`**: the app never runs `ANALYZE`, and without statistics the
|
||||
planner prefers the `server_id` index — which every row shares — over the
|
||||
hierarchy indexes. The unary `+` takes it out of consideration.
|
||||
- **Children are matched on `items.container_id`** (migration 027), a VIRTUAL
|
||||
generated column holding the item's *logical* container: an episode's season
|
||||
(else series, else parent), a season's series, a track's album, otherwise the
|
||||
parent. 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 — so listings used to match on four
|
||||
columns at once. That `OR` defeated the planner into walking the whole table,
|
||||
and it was wrong: every episode carries its series id, so a series listed all
|
||||
its episodes beside its seasons. Being generated, the column covers every
|
||||
write path (cache, downloads, catalog crawl) without any of them knowing, and
|
||||
cannot drift from the columns it is computed from.
|
||||
- **`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
|
||||
500 rows), not once per row.
|
||||
|
||||
Result: 80 ms → 1.5 ms on the desktop benchmark;
|
||||
`listing_a_non_library_parent_uses_the_hierarchy_indexes` asserts the plan.
|
||||
`storage::tests::write_bench_database` (ignored) writes a phone-sized
|
||||
catalogue for timing queries with the `sqlite3` CLI.
|
||||
Measured on a ~110k-item benchmark catalogue (desktop): a series listing went
|
||||
from ~80 ms to under 1 ms; migration 027 upgrades an existing database of that
|
||||
size in ~0.1 s. `listing_a_non_library_parent_uses_the_container_index` and
|
||||
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
|
||||
|
||||
|
||||
+686
-569
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jellytau",
|
||||
"version": "0.13.1",
|
||||
"version": "0.13.2",
|
||||
"description": "A cross-platform Jellyfin client built with Tauri, SvelteKit and Rust.",
|
||||
"author": "Duncan Tourolle <duncan@tourolle.paris>",
|
||||
"license": "MIT",
|
||||
|
||||
Generated
+1
-1
@@ -2275,7 +2275,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "jellytau"
|
||||
version = "0.13.1"
|
||||
version = "0.13.2"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"argon2",
|
||||
|
||||
@@ -4,7 +4,7 @@ name = "jellytau"
|
||||
# `player-conformance`, and a second binary makes a bare `cargo run` —
|
||||
# which `tauri dev` issues — ambiguous.
|
||||
default-run = "jellytau"
|
||||
version = "0.13.1"
|
||||
version = "0.13.2"
|
||||
description = "A cross-platform Jellyfin client"
|
||||
authors = ["Duncan Tourolle <duncan@tourolle.paris>"]
|
||||
license = "MIT"
|
||||
|
||||
@@ -1000,22 +1000,32 @@ impl MediaRepository for HybridRepository {
|
||||
series_id: Option<&str>,
|
||||
limit: Option<usize>,
|
||||
) -> Result<Vec<MediaItem>, RepoError> {
|
||||
// Next Up is dynamic, so the server's answer is preferred — but when the
|
||||
// server cannot answer, the cache's stands in. It used to be server-only,
|
||||
// and the TV landing page loads Next Up in one `Promise.all` with its
|
||||
// other rows, so offline that single failure blanked the whole page with
|
||||
// Continue Watching and Latest sitting in the cache (DR-294).
|
||||
// TRACES: UR-002 | DR-294 | UT-261
|
||||
match self.online.get_next_up_episodes(series_id, limit).await {
|
||||
Ok(items) => Ok(items.without_excluded()),
|
||||
Err(e) => {
|
||||
debug!("[HybridRepo] Next Up from server failed ({e}); using the cache");
|
||||
self.offline
|
||||
.get_next_up_episodes(series_id, limit)
|
||||
// Cache-first like every other query: the local answer is computed
|
||||
// from the same watch state the cache refreshes from the server in the
|
||||
// background (`user_data_mirror_query`), and whichever answers first
|
||||
// with content wins. It used to wait for the server outright, which
|
||||
// held the series page's episode list for 2-3 s on a phone. An empty
|
||||
// local answer still defers to the server, and a failed server falls
|
||||
// back to the cache — offline, the TV page's Next Up row must not blank
|
||||
// the page (DR-294).
|
||||
// TRACES: UR-002 | DR-013, DR-294 | UT-261
|
||||
let offline = Arc::clone(&self.offline);
|
||||
let online = Arc::clone(&self.online);
|
||||
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
|
||||
.map(ExcludeHidden::without_excluded)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Self::parallel_race(cache_future, server_future).await
|
||||
}
|
||||
|
||||
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
|
||||
/// `playback_position_ticks, is_played, is_favorite, play_count,
|
||||
/// 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
|
||||
/// 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
|
||||
/// cache whatever the parent — several times that on a phone.
|
||||
///
|
||||
/// A parent that is not a library is matched on the hierarchy columns alone,
|
||||
/// which SQLite serves with one index lookup per column. The library clause is
|
||||
/// dropped there rather than evaluated: it can only match when the parent *is*
|
||||
/// a library, and inside the same `OR` it forced a scan of every row. The
|
||||
/// `+` on `server_id` stops the planner — which has no statistics, the app
|
||||
/// never runs `ANALYZE` — from choosing the server index, which every row
|
||||
/// shares.
|
||||
/// Children are matched on `container_id`, the logical container resolved by
|
||||
/// migration 027 (an episode's season, a season's series, a track's album,
|
||||
/// otherwise the parent) — one index range, already in display order. It
|
||||
/// replaced a four-column `OR` that was both slow and wrong: every episode
|
||||
/// carries its series id, so a series listed its episodes beside its seasons.
|
||||
/// The library clause is added only when the parent *is* a library; inside the
|
||||
/// 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
|
||||
/// parent); the type-filter values; with the favourites filter, the user id.
|
||||
/// Bind order: server id; the parent id (twice for a library parent); the
|
||||
/// type-filter values; with the favourites filter, the user id.
|
||||
///
|
||||
/// TRACES: UR-002, UR-007 | DR-013, DR-277
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -350,16 +464,12 @@ fn items_listing_sql(
|
||||
limit: usize,
|
||||
start_index: usize,
|
||||
) -> String {
|
||||
let catalog_available = if include_catalog {
|
||||
"i.synced_at IS NOT NULL OR "
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let available = available_sql("i", include_catalog);
|
||||
let parent_match = if parent_is_library {
|
||||
format!(
|
||||
"i.server_id = ?
|
||||
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
|
||||
-- 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
|
||||
@@ -397,9 +507,7 @@ fn items_listing_sql(
|
||||
library_type_matches_item!()
|
||||
)
|
||||
} else {
|
||||
"+i.server_id = ?
|
||||
AND (i.parent_id = ? OR i.album_id = ? OR i.season_id = ? OR i.series_id = ?)"
|
||||
.to_string()
|
||||
"+i.server_id = ? AND i.container_id = ?".to_string()
|
||||
};
|
||||
format!(
|
||||
"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
|
||||
FROM items i
|
||||
WHERE {parent_match}
|
||||
AND (
|
||||
{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}
|
||||
AND {available}{type_filter}{favorites_filter}
|
||||
ORDER BY {order_by}
|
||||
LIMIT {limit} OFFSET {start_index}"
|
||||
)
|
||||
@@ -679,6 +770,50 @@ impl OfflineRepository {
|
||||
// TRACES: UR-007 | DR-278
|
||||
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
|
||||
// whatever order the server returned children and parents in.
|
||||
let mut parent_ids = std::collections::HashSet::new();
|
||||
@@ -728,6 +863,9 @@ impl OfflineRepository {
|
||||
// TRACES: UR-002, UR-007 | DR-012
|
||||
self.db_service
|
||||
.transaction_without_foreign_keys(move |tx| {
|
||||
for placeholder in placeholders {
|
||||
tx.execute(placeholder)?;
|
||||
}
|
||||
for stub in stubs {
|
||||
tx.execute(stub)?;
|
||||
}
|
||||
@@ -748,6 +886,26 @@ impl OfflineRepository {
|
||||
.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.
|
||||
///
|
||||
/// `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.
|
||||
///
|
||||
/// 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
|
||||
// cached) still surface. This mirrors the online music library, which
|
||||
// routes to a dedicated albums view. See [[offline-libraries-never-cached]].
|
||||
let sql = format!(
|
||||
"{cte}
|
||||
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
|
||||
INNER JOIN downloaded_items di ON i.id = di.id
|
||||
WHERE i.server_id = ?
|
||||
let parent_is_library = self.is_library(parent_id).await?;
|
||||
let downloaded = downloaded_sql("i", PLAYABLE_TYPES, CONTAINER_TYPES);
|
||||
let downloaded_parent = downloaded_sql("parent", PLAYABLE_TYPES, CONTAINER_TYPES);
|
||||
// A non-library parent lists its children by `container_id` (migration
|
||||
// 027) — series → seasons → episodes, one index range. The library
|
||||
// clause is only added for a library, where it is the whole point.
|
||||
let parent_match = if parent_is_library {
|
||||
format!(
|
||||
"i.server_id = ?
|
||||
AND (
|
||||
i.parent_id = ? OR i.album_id = ? OR i.season_id = ? OR i.series_id = ?
|
||||
i.container_id = ?
|
||||
OR (
|
||||
EXISTS (
|
||||
SELECT 1 FROM libraries l
|
||||
@@ -1254,31 +1392,38 @@ impl OfflineRepository {
|
||||
)
|
||||
-- Top-level only: hide leaves whose container is downloaded.
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM downloaded_items parent
|
||||
WHERE parent.id = i.album_id
|
||||
OR parent.id = i.season_id
|
||||
OR parent.id = i.series_id
|
||||
OR parent.id = i.parent_id
|
||||
SELECT 1 FROM items parent
|
||||
WHERE parent.id IN (i.album_id, i.season_id, i.series_id, i.parent_id)
|
||||
AND {downloaded_parent}
|
||||
)
|
||||
)
|
||||
){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
|
||||
LIMIT {limit} OFFSET {start_index}",
|
||||
cte = Self::DOWNLOADED_ITEMS_CTE,
|
||||
membership = Self::LIBRARY_HOLDS_ITEM,
|
||||
);
|
||||
|
||||
let query = Query::with_params(
|
||||
sql,
|
||||
vec![
|
||||
let mut params = vec![
|
||||
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()),
|
||||
QueryParam::String(parent_id.to_string()),
|
||||
],
|
||||
);
|
||||
];
|
||||
if parent_is_library {
|
||||
params.push(QueryParam::String(parent_id.to_string()));
|
||||
}
|
||||
let query = Query::with_params(sql, params);
|
||||
|
||||
let cached_items: Vec<CachedItem> = self
|
||||
.db_service
|
||||
@@ -1307,19 +1452,18 @@ impl OfflineRepository {
|
||||
// completed download of a given media kind qualifies that library.
|
||||
let query = Query::with_params(
|
||||
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
|
||||
WHERE l.server_id = ?
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM items i
|
||||
INNER JOIN downloaded_items di ON i.id = di.id
|
||||
WHERE i.server_id = l.server_id
|
||||
AND {membership}
|
||||
AND {downloaded}
|
||||
)
|
||||
ORDER BY l.sort_order ASC, l.name ASC",
|
||||
cte = Self::DOWNLOADED_ITEMS_CTE,
|
||||
membership = Self::LIBRARY_HOLDS_ITEM,
|
||||
downloaded = downloaded_sql("i", PLAYABLE_TYPES, CONTAINER_TYPES),
|
||||
),
|
||||
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`.
|
||||
let parent_is_library = self
|
||||
.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 parent_is_library = self.is_library(parent_id).await?;
|
||||
|
||||
let sql = items_listing_sql(
|
||||
parent_is_library,
|
||||
@@ -1635,17 +1765,11 @@ impl MediaRepository for OfflineRepository {
|
||||
start_index,
|
||||
);
|
||||
|
||||
// The requested id is compared against every hierarchy-linkage column
|
||||
// because `parent_id` is not populated for cached items — music tracks
|
||||
// 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]].
|
||||
// Children are matched on `container_id` (see `items_listing_sql`); a
|
||||
// library parent also matches through the `libraries` EXISTS clause.
|
||||
let mut params = vec![
|
||||
QueryParam::String(self.server_id.clone()),
|
||||
QueryParam::String(parent_id.to_string()), // i.parent_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 = ?
|
||||
QueryParam::String(parent_id.to_string()), // i.container_id = ?
|
||||
];
|
||||
if parent_is_library {
|
||||
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> {
|
||||
// Check if item is available offline (either downloaded itself or has downloaded children)
|
||||
let query = Query::with_params(
|
||||
"WITH downloaded_items AS (
|
||||
-- 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,
|
||||
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
|
||||
INNER JOIN downloaded_items di ON i.id = di.id
|
||||
WHERE i.id = ?",
|
||||
WHERE i.id = ? AND {}",
|
||||
downloaded_sql("i", PLAYABLE_TYPES, CONTAINER_TYPES)
|
||||
),
|
||||
vec![QueryParam::String(item_id.to_string())],
|
||||
);
|
||||
|
||||
@@ -1745,42 +1853,27 @@ impl MediaRepository for OfflineRepository {
|
||||
|
||||
let query = Query::with_params(
|
||||
format!(
|
||||
"WITH downloaded_items AS (
|
||||
-- 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,
|
||||
"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
|
||||
INNER JOIN downloaded_items di ON i.id = di.id
|
||||
WHERE i.server_id = ? AND i.library_id = ?
|
||||
AND {downloaded}
|
||||
-- Collapse leaves into the container that was added: a new
|
||||
-- 14-track album should read as one album, not 14 songs. Only
|
||||
-- drops a leaf when its own container is present in the same
|
||||
-- result, so a standalone track or movie still appears.
|
||||
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)
|
||||
AND {downloaded_parent}
|
||||
)
|
||||
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![
|
||||
QueryParam::String(self.server_id.clone()),
|
||||
@@ -1895,25 +1988,7 @@ impl MediaRepository for OfflineRepository {
|
||||
// Only shows items that are downloaded or have downloaded children
|
||||
let query = Query::with_params(
|
||||
format!(
|
||||
"WITH downloaded_items 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 (
|
||||
"WITH ranked_plays AS (
|
||||
SELECT
|
||||
CASE
|
||||
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
|
||||
FROM ranked_plays rp
|
||||
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",
|
||||
limit_val
|
||||
limit_val,
|
||||
downloaded = downloaded_sql("i", "'Audio'", "'MusicAlbum'")
|
||||
),
|
||||
vec![
|
||||
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
|
||||
// downloads-only, which meant a user with no downloads got nothing from
|
||||
// the local index and every keystroke fell through to the server.
|
||||
let catalog_branch = if 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 available = available_sql("i", include_catalog_browse());
|
||||
|
||||
let sql = format!(
|
||||
"WITH available_items AS (
|
||||
-- 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,
|
||||
"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,
|
||||
@@ -2117,11 +2163,10 @@ impl MediaRepository for OfflineRepository {
|
||||
i.season_name, i.parent_index_number, i.is_folder, i.premiere_date
|
||||
FROM items i
|
||||
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 ?{}
|
||||
WHERE i.server_id = ? AND items_fts MATCH ? AND {}{}
|
||||
ORDER BY rank
|
||||
LIMIT {}",
|
||||
catalog_branch, type_filter, limit
|
||||
available, type_filter, limit
|
||||
);
|
||||
|
||||
let mut params = vec![
|
||||
@@ -2304,46 +2349,20 @@ impl MediaRepository for OfflineRepository {
|
||||
_ => String::new(),
|
||||
};
|
||||
|
||||
let catalog_branch = if include_catalog_browse() {
|
||||
"UNION
|
||||
|
||||
SELECT DISTINCT i.id
|
||||
FROM items i
|
||||
WHERE i.synced_at IS NOT NULL"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let available = available_sql("i", include_catalog_browse());
|
||||
|
||||
let sql = format!(
|
||||
"WITH available_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')
|
||||
|
||||
{catalog_branch}
|
||||
)
|
||||
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,
|
||||
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
|
||||
INNER JOIN available_items ai ON i.id = ai.id
|
||||
INNER JOIN user_data ud ON ud.item_id = i.id
|
||||
WHERE i.server_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
|
||||
LIMIT {} OFFSET {}",
|
||||
type_filter, limit, start_index
|
||||
@@ -2459,25 +2478,7 @@ impl MediaRepository for OfflineRepository {
|
||||
// Filter by downloads using CTE
|
||||
let query = Query::with_params(
|
||||
format!(
|
||||
"WITH downloaded_items AS (
|
||||
-- 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,
|
||||
"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,
|
||||
@@ -2485,10 +2486,11 @@ impl MediaRepository for OfflineRepository {
|
||||
i.season_name, i.parent_index_number, i.is_folder, i.premiere_date
|
||||
FROM items i
|
||||
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 = ?
|
||||
WHERE i.server_id = ? AND ip.person_id = ? AND {downloaded}
|
||||
ORDER BY i.production_year DESC, i.sort_name ASC
|
||||
LIMIT {}", limit
|
||||
LIMIT {}",
|
||||
limit,
|
||||
downloaded = downloaded_sql("i", PLAYABLE_TYPES, CONTAINER_TYPES)
|
||||
),
|
||||
vec![
|
||||
QueryParam::String(self.server_id.clone()),
|
||||
@@ -2918,8 +2920,18 @@ mod tests {
|
||||
season_name TEXT,
|
||||
parent_index_number INTEGER,
|
||||
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
|
||||
-- 001) so search can be exercised in tests at all.
|
||||
@@ -3983,11 +3995,16 @@ mod tests {
|
||||
"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();
|
||||
assert!(
|
||||
series_items.items.iter().any(|i| i.id == "ep-1"),
|
||||
"get_items(series_id) should surface the downloaded episode"
|
||||
let ids: Vec<&str> = series_items.items.iter().map(|i| i.id.as_str()).collect();
|
||||
assert_eq!(
|
||||
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.
|
||||
let in_series = repo.get_downloaded_items("series-1", None).await.unwrap();
|
||||
assert!(
|
||||
in_series.items.iter().any(|i| i.id == "season-1"),
|
||||
"series drill returns the season"
|
||||
let in_series_ids: Vec<&str> = in_series.items.iter().map(|i| i.id.as_str()).collect();
|
||||
assert_eq!(
|
||||
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();
|
||||
assert!(
|
||||
@@ -5800,7 +5819,7 @@ mod tests {
|
||||
///
|
||||
/// TRACES: UR-002 | DR-013
|
||||
#[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;
|
||||
let db = crate::storage::Database::open_in_memory().unwrap();
|
||||
let conn = db.connection();
|
||||
@@ -5817,16 +5836,14 @@ mod tests {
|
||||
);
|
||||
let mut stmt = conn.prepare(&format!("EXPLAIN QUERY PLAN {sql}")).unwrap();
|
||||
let plan: Vec<String> = stmt
|
||||
.query_map(rusqlite::params!["srv", "p", "p", "p", "p"], |row| {
|
||||
row.get::<_, String>(3)
|
||||
})
|
||||
.query_map(rusqlite::params!["srv", "p"], |row| row.get::<_, String>(3))
|
||||
.unwrap()
|
||||
.map(Result::unwrap)
|
||||
.collect();
|
||||
let plan = plan.join("\n");
|
||||
assert!(
|
||||
plan.contains("MULTI-INDEX OR"),
|
||||
"expected an index lookup per hierarchy column, got:\n{plan}"
|
||||
plan.contains("idx_items_container"),
|
||||
"expected a container index lookup, got:\n{plan}"
|
||||
);
|
||||
assert!(
|
||||
!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,
|
||||
series_id: &str,
|
||||
) -> Result<SeriesView, RepoError> {
|
||||
let (episodes, next_up, resume) = futures_util::join!(
|
||||
fetch_series_episodes(repo, series_id),
|
||||
let (episodes, (next_up, resume)) = with_hints(fetch_series_episodes(repo, series_id), async {
|
||||
futures_util::join!(
|
||||
async {
|
||||
repo.get_next_up_episodes(Some(series_id), Some(1))
|
||||
.await
|
||||
@@ -299,12 +299,44 @@ pub async fn resolve_series_view(
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
},
|
||||
);
|
||||
)
|
||||
})
|
||||
.await;
|
||||
let episodes = episodes?;
|
||||
let current = pick_current_episode(series_id, &episodes, &next_up, &resume);
|
||||
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.
|
||||
///
|
||||
/// 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");
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
item.user_data = Some(UserData {
|
||||
is_played: Some(true),
|
||||
|
||||
@@ -65,6 +65,18 @@ impl Database {
|
||||
let conn = Arc::new(Mutex::new(conn));
|
||||
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.
|
||||
let readers = (0..READER_CONNECTIONS)
|
||||
.map(|_| Self::open_reader(path))
|
||||
@@ -1000,6 +1012,55 @@ mod tests {
|
||||
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
|
||||
/// queries with the `sqlite3` CLI. Not a test; run explicitly with
|
||||
/// `--ignored`.
|
||||
|
||||
@@ -31,6 +31,7 @@ pub const MIGRATIONS: &[(&str, &str)] = &[
|
||||
("024_multi_user_profiles", MIGRATION_024),
|
||||
("025_backfill_item_library_id", MIGRATION_025),
|
||||
("026_server_catalog_generation", MIGRATION_026),
|
||||
("027_items_container_id", MIGRATION_027),
|
||||
];
|
||||
|
||||
/// Initial schema migration
|
||||
@@ -941,6 +942,169 @@ const MIGRATION_026: &str = r#"
|
||||
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)]
|
||||
mod migration_024_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "JellyTau",
|
||||
"version": "0.13.1",
|
||||
"version": "0.13.2",
|
||||
"identifier": "com.dtourolle.jellytau",
|
||||
"build": {
|
||||
"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
|
||||
// init, alongside the tracker above, for the same reason. (DR-156)
|
||||
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
|
||||
// ($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
|
||||
this wrapper must scroll and reserve the fixed bottom UI's measured
|
||||
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
|
||||
above the scroller, so it never eats into the scroller's bounds. -->
|
||||
{#if showGlobalHeader}
|
||||
{#if !routeOwnsLayout && showGlobalHeader}
|
||||
<AppHeader />
|
||||
{/if}
|
||||
<!-- Scroller is flex-1/min-h-0; the in-flow BottomUi below is a flex
|
||||
sibling, so the list is physically bounded above it and can never
|
||||
render behind it. No measurement, no reserved padding. -->
|
||||
<!-- ONE element renders the route, whatever the layout mode; only its
|
||||
classes change. Routes that own their full-height column (header +
|
||||
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
|
||||
bind:this={shellScroller}
|
||||
class="flex-1 overflow-y-auto min-h-0"
|
||||
style="overscroll-behavior: contain"
|
||||
class={routeOwnsLayout ? "flex-1 overflow-hidden" : "flex-1 overflow-y-auto min-h-0"}
|
||||
style={routeOwnsLayout ? undefined : "overscroll-behavior: contain"}
|
||||
>
|
||||
{@render children()}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Re-authentication modal -->
|
||||
<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 -->
|
||||
<script lang="ts">
|
||||
import { onMount, untrack } from "svelte";
|
||||
import { untrack } from "svelte";
|
||||
import { formatDuration } from "$lib/utils/duration";
|
||||
import { page } from "$app/stores";
|
||||
import { goto } from "$app/navigation";
|
||||
@@ -51,6 +51,7 @@
|
||||
type SeasonData,
|
||||
} from "$lib/components/library/seriesNavigation";
|
||||
import { createLogger } from "$lib/utils/logger";
|
||||
import { createCoalescedLoader } from "$lib/utils/coalescedLoader";
|
||||
|
||||
const log = createLogger("LibraryDetail");
|
||||
|
||||
@@ -65,18 +66,27 @@
|
||||
// preference, so it resets with each load (DR-107).
|
||||
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 previousServerReachable = false;
|
||||
let previousServerReachable: boolean | null = null;
|
||||
|
||||
const itemId = $derived($page.params.id);
|
||||
const focusedEpisodeId = $derived($page.url.searchParams.get("episode"));
|
||||
|
||||
onMount(async () => {
|
||||
await loadItem();
|
||||
hasLoadedOnce = true;
|
||||
});
|
||||
// Every trigger below goes through one coalesced loader: they used to each
|
||||
// start a full load, so opening a page loaded it six times over (DR-295).
|
||||
// `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(() => {
|
||||
if (itemId) {
|
||||
loadItem();
|
||||
@@ -89,8 +99,8 @@
|
||||
const serverReachable = $isServerReachable;
|
||||
|
||||
// If server just became reachable and we've already loaded, reload to get fresh data
|
||||
if (serverReachable && !previousServerReachable && hasLoadedOnce && itemId) {
|
||||
loadItem();
|
||||
if (serverReachable && previousServerReachable === false && hasLoadedOnce && itemId) {
|
||||
reloadFresh();
|
||||
}
|
||||
|
||||
previousServerReachable = serverReachable;
|
||||
@@ -100,10 +110,10 @@
|
||||
// contents follow the filter the same way a library listing does.
|
||||
// TRACES: UR-052 | DR-143
|
||||
useOfflineFilterReload(() => {
|
||||
if (itemId) loadItem();
|
||||
if (itemId) reloadFresh();
|
||||
});
|
||||
|
||||
async function loadItem() {
|
||||
async function loadItemNow() {
|
||||
if (!itemId) return;
|
||||
// Only show spinner when navigating to a different item
|
||||
// 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)}
|
||||
scope="series"
|
||||
showLabel={true}
|
||||
onChanged={loadItem}
|
||||
onChanged={reloadFresh}
|
||||
/>
|
||||
<ClearHistoryButton
|
||||
itemId={item.id}
|
||||
itemName={item.name}
|
||||
scope="series"
|
||||
onCleared={loadItem}
|
||||
onCleared={reloadFresh}
|
||||
/>
|
||||
{:else if item.kind === "movie"}
|
||||
<VideoDownloadButton
|
||||
@@ -600,7 +610,7 @@
|
||||
watched={item.userData?.isPlayed ?? false}
|
||||
scope="episode"
|
||||
showLabel={true}
|
||||
onChanged={loadItem}
|
||||
onChanged={reloadFresh}
|
||||
/>
|
||||
{/if}
|
||||
<!-- Favourite. Sits with Play/Download rather than in the header,
|
||||
@@ -732,7 +742,7 @@
|
||||
expanded={expandedSeasons.has(season.id)}
|
||||
onToggle={() => toggleSeason(season.id)}
|
||||
onEpisodeClick={handleEpisodeClick}
|
||||
onHistoryCleared={loadItem}
|
||||
onHistoryCleared={reloadFresh}
|
||||
/>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user