perf(db): one logical container per item, indexed; no whole-table reads

Listings matched children on four columns at once (parent_id, album_id,
season_id, series_id) because Jellyfin's ParentId is the storage parent,
not the logical one. The OR defeated the planner into a full scan, and it
was wrong: every episode carries its series id, so a series listed all its
episodes beside its seasons (on both the browse and Downloads surfaces).

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

Benchmark (~110k items, desktop): series listing ~80 ms -> <1 ms;
migration 027 upgrades that database in ~0.1 s (0.2 s on the Fairphone).
Tests first: a series listing its episodes, and the Downloads series
drill, both failed before the change.
This commit is contained in:
2026-09-24 04:45:44 +02:00
parent e21ddae737
commit c0545a245f
4 changed files with 679 additions and 302 deletions
+49 -18
View File
@@ -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
+398 -277
View File
@@ -290,6 +290,74 @@ impl OfflineRepository {
}
}
/// A container row to create if it was never cached. See `save_to_cache`.
struct ContainerPlaceholder {
id: String,
name: String,
item_type: &'static str,
series_id: Option<String>,
series_name: Option<String>,
album_artist: Option<String>,
}
/// The logical containers `item` lists under, as far as its own fields name
/// them — the same rule as `container_id` in migration 027: an episode's
/// season and series, a season's series, a track's album.
///
/// TRACES: UR-002, UR-007 | DR-013
fn container_placeholders(item: &MediaItem) -> Vec<ContainerPlaceholder> {
let mut out = Vec::new();
let series = |item: &MediaItem| {
item.series_id.clone().map(|id| ContainerPlaceholder {
id,
name: item
.series_name
.clone()
.unwrap_or_else(|| "Series".to_string()),
item_type: "Series",
series_id: None,
series_name: None,
album_artist: None,
})
};
match item.item_type.as_str() {
"Episode" => {
if let Some(id) = item.season_id.clone() {
out.push(ContainerPlaceholder {
id,
name: item
.season_name
.clone()
.unwrap_or_else(|| "Season".to_string()),
item_type: "Season",
series_id: item.series_id.clone(),
series_name: item.series_name.clone(),
album_artist: None,
});
}
out.extend(series(item));
}
"Season" => out.extend(series(item)),
"Audio" => {
if let Some(id) = item.album_id.clone() {
out.push(ContainerPlaceholder {
id,
name: item
.album_name
.clone()
.unwrap_or_else(|| "Album".to_string()),
item_type: "MusicAlbum",
series_id: None,
series_name: None,
album_artist: item.album_artist.clone(),
});
}
}
_ => {}
}
out
}
/// A `user_data` row's columns, starting at `offset`, in the order
/// `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))
);
}
}
+61
View File
@@ -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`.
+164
View File
@@ -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::*;