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
+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::*;