feat(search): answer search from a local index; tier downloads by lifetime

Search's instant leg read only downloaded items, so with no downloads it
returned nothing and every keystroke fell through to a full Recursive=true
server query. It now reads the whole synced catalog through the same
availability CTE get_items uses, gated on the same include_catalog_browse
flag so search and browse cannot diverge. (UR-065, DR-108)

Also fixes three defects found while confirming that:

- items_fts grew by a full duplicate index every catalog pass. INSERT OR
  REPLACE fires no AFTER DELETE trigger without recursive_triggers, so the
  old index row was orphaned, and a TEXT PRIMARY KEY meant the replacement
  took a fresh rowid and inserted a second entry. Now a real upsert, with
  migration 021 rebuilding existing indexes. (DR-110)
- DELETE FROM items existed nowhere, so server-side deletions never
  propagated. Adds a post-crawl mark-and-sweep, scoped to crawled types,
  skipping downloaded items, and refusing to run after a partial crawl
  because items.parent_id cascades. (DR-110)
- The index omitted MusicArtist, Playlist and People, which search groups
  results by. Adds them plus people_fts (migration 022). (DR-111)

Re-indexing moves from a frontend startup call to a Rust background task
with a 6h TTL, so a long session no longer searches a stale catalog and a
restart no longer forces a crawl regardless of freshness. (DR-109, IR-030)

Downloads gain a lifetime tier. Eviction selected every completed row by
age with no download_source filter, so hitting the storage limit deleted
the oldest download -- typically one saved deliberately for offline -- to
make room for a precached track. It now reclaims only 'auto' rows, and
expired ones are reclaimed first, before live cache is evicted.
(DR-126, DR-127)

Downloaded video and audio-only handoffs now play from disk instead of
streaming; the video path had never consulted downloads at all. No
transcode is involved: MPV runs video=no and ExoPlayer has no surface for
an Audio item. (DR-123 in part, DR-128)

FTS queries are built as quoted phrases so apostrophes, hyphens and
slashes are data rather than operator syntax, and the item-type filter is
bound rather than interpolated.

Specs: docs/specs/catalog-index-search.md,
docs/specs/read-through-media-cache.md

Includes concurrently-developed favourites browsing and background-audio
stream-end handling; the two workstreams share offline.rs, lib.rs and
online.rs, so no subset of files builds independently.
This commit is contained in:
2026-08-04 17:35:17 +02:00
parent c55ff45692
commit 62873cab3d
52 changed files with 6110 additions and 191 deletions
+109
View File
@@ -41,12 +41,34 @@ impl HybridRepository {
}
}
/// The signed-in user this repository acts for.
///
/// TRACES: UR-069 | DR-120
pub fn user_id(&self) -> &str {
self.online.user_id()
}
/// Download raw bytes from a URL using the shared authenticated HTTP client.
/// Delegates to online repository for connection reuse and proper auth.
pub async fn download_bytes(&self, url: &str) -> Result<Vec<u8>, String> {
self.online.download_bytes(url).await
}
/// Remove catalog entries the server no longer has. Cache-only, so it goes
/// straight to the offline repository. Callers must only invoke this after a
/// crawl in which every library succeeded — see
/// `OfflineRepository::prune_stale_catalog` for why a partial crawl must not
/// sweep.
///
/// TRACES: UR-065 | DR-110
pub async fn prune_stale_catalog(
&self,
cutoff: &str,
item_types: &[String],
) -> Result<usize, RepoError> {
self.offline.prune_stale_catalog(cutoff, item_types).await
}
/// Query the JRay plugin for actors on screen at time `t`. Online-only
/// (the plugin lives on the Jellyfin server); empty when JRay isn't present.
pub async fn get_jray_actors(
@@ -113,6 +135,41 @@ impl HybridRepository {
.await
}
/// Favourites held locally, without touching the server. Backs the instant
/// leg of the two-phase favourites read in the command layer.
///
/// TRACES: UR-067 | DR-115
pub async fn get_favorites_cache_only(
&self,
scope: SearchScope,
options: Option<GetItemsOptions>,
) -> Result<SearchResult, RepoError> {
let offline = Arc::clone(&self.offline);
self.cache_with_timeout(async move { offline.get_favorites(scope, options).await })
.await
}
/// Favourites straight from the server, persisted to the cache on the way
/// through — which is also what mirrors their favourite flags into
/// `user_data` (DR-114), so the next offline read agrees with the server.
///
/// TRACES: UR-067 | DR-115
pub async fn get_favorites_server_only(
&self,
scope: SearchScope,
options: Option<GetItemsOptions>,
) -> Result<SearchResult, RepoError> {
let result = self.online.get_favorites(scope, options).await?;
if !result.items.is_empty() {
// Favourites span libraries, so there is no single parent to file
// them under; the parent id is only used for stub rows.
if let Err(e) = self.offline.save_to_cache("favorites", &result.items).await {
debug!("[HybridRepo] Failed to cache favourites: {:?}", e);
}
}
Ok(result)
}
/// Fetch a folder's items from the live server and persist them to the
/// offline cache synchronously (unlike `get_items`, which saves in a
/// fire-and-forget background task after a 100ms cache race).
@@ -790,6 +847,42 @@ impl MediaRepository for HybridRepository {
self.parallel_race(cache_future, server_future).await
}
/// TRACES: UR-067 | DR-115
async fn get_favorites(
&self,
scope: SearchScope,
options: Option<GetItemsOptions>,
) -> Result<SearchResult, RepoError> {
let offline = Arc::clone(&self.offline);
let online = Arc::clone(&self.online);
let opts_clone = options.clone();
let cache_result = self
.cache_with_timeout(async move { offline.get_favorites(scope, opts_clone).await })
.await;
// Downloads-only gate: with "Show all server media" off, an empty local
// result means "nothing favourited is on this device" and is
// authoritative. Falling through to the server here would re-pad the
// page with the full favourited catalog and defeat the filter (DR-080).
if !crate::repository::offline::include_catalog_browse() {
if let Ok(data) = &cache_result {
return Ok(data.clone());
}
}
if let Ok(data) = &cache_result {
if data.has_content() {
return Ok(data.clone());
}
}
match online.get_favorites(scope, options).await {
Ok(data) => Ok(data),
Err(e) => cache_result.or(Err(e)),
}
}
async fn get_similar_items(
&self,
item_id: &str,
@@ -1133,6 +1226,14 @@ mod tests {
unimplemented!()
}
async fn get_favorites(
&self,
_scope: SearchScope,
_options: Option<GetItemsOptions>,
) -> Result<SearchResult, RepoError> {
unimplemented!()
}
async fn clear_watch_history(&self, _item_id: &str) -> Result<(), RepoError> {
unimplemented!()
}
@@ -1395,6 +1496,14 @@ mod tests {
unimplemented!()
}
async fn get_favorites(
&self,
_scope: SearchScope,
_options: Option<GetItemsOptions>,
) -> Result<SearchResult, RepoError> {
unimplemented!()
}
async fn clear_watch_history(&self, _item_id: &str) -> Result<(), RepoError> {
unimplemented!()
}