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
+153
View File
@@ -41,6 +41,19 @@ impl RepositoryManager {
repos.get(handle).cloned()
}
/// Handles of every live repository.
///
/// The background catalog indexer (DR-109) runs outside any command, so it
/// has no handle passed in and needs to discover one. In practice there is a
/// single signed-in repository; returning all of them avoids inventing an
/// "active" concept the rest of the code does not have.
///
/// TRACES: UR-065 | DR-109
pub fn handles(&self) -> Vec<String> {
let repos = self.repositories.lock_safe();
repos.keys().cloned().collect()
}
pub fn destroy(&self, handle: &str) {
let mut repos = self.repositories.lock_safe();
repos.remove(handle);
@@ -780,6 +793,108 @@ pub async fn repository_mark_favorite(
.map_err(|e| format!("{:?}", e))
}
/// Tauri event announcing that favourite state changed behind the UI's back —
/// either because the server disagreed with the cache on a background refresh,
/// or because pending offline toggles were pushed on reconnect.
///
/// TRACES: UR-069 | DR-120
pub const FAVORITES_CHANGED_EVENT: &str = "favorites-changed";
/// Payload for [`FAVORITES_CHANGED_EVENT`] — the ids whose favourite state
/// actually flipped, so the frontend refreshes those rather than everything.
///
/// TRACES: UR-069 | DR-120 | UT-107
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FavoritesChangedEvent {
pub item_ids: Vec<String>,
}
/// Ids whose favourite state differs between what we showed and what the server
/// has — favourited elsewhere since the cache was written, or un-favourited
/// elsewhere.
///
/// Pulled out of the command so the "emit nothing when nothing changed" rule is
/// testable: an unchanged set must leave a quiet page quiet rather than
/// triggering a refetch on every visit.
///
/// TRACES: UR-069 | DR-120 | UT-107
fn changed_favorite_ids(
cached: &std::collections::HashSet<String>,
server: &std::collections::HashSet<String>,
) -> Vec<String> {
let mut changed: Vec<String> = server.symmetric_difference(cached).cloned().collect();
// Deterministic order so the event payload does not depend on hash seeding.
changed.sort();
changed
}
/// Everything the viewer has favourited, across libraries, narrowed by scope.
///
/// Two-phase like `repository_search`: the local answer returns immediately and
/// a background server pass emits `favorites-changed` when the server's set
/// differs. Without the second phase a favourite marked in another client shows
/// up only on the *second* visit to the page, since the cache-first read hands
/// back local rows and the refresh is invisible to the frontend.
///
/// TRACES: UR-067 | DR-115, DR-120, JA-033 | UT-107
#[tauri::command]
#[specta::specta]
pub async fn repository_get_favorites(
app: AppHandle,
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
scope: SearchScope,
options: Option<GetItemsOptions>,
) -> Result<SearchResult, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
let cache_result = repo
.get_favorites_cache_only(scope, options.clone())
.await
.unwrap_or_else(|e| {
debug!("[Favorites] Cache miss/timeout: {:?}", e);
SearchResult {
items: Vec::new(),
total_record_count: 0,
}
});
// With "Show all server media" off the local answer is authoritative
// (DR-080) — don't go behind the user's back to the server.
if !crate::repository::offline::include_catalog_browse() {
return Ok(cache_result);
}
let repo_bg = repo.clone();
let cached_ids: std::collections::HashSet<String> =
cache_result.items.iter().map(|i| i.id.clone()).collect();
tauri::async_runtime::spawn(async move {
match repo_bg.get_favorites_server_only(scope, options).await {
Ok(server_result) => {
let server_ids: std::collections::HashSet<String> =
server_result.items.iter().map(|i| i.id.clone()).collect();
let changed = changed_favorite_ids(&cached_ids, &server_ids);
if !changed.is_empty() {
let event = FavoritesChangedEvent { item_ids: changed };
if let Err(e) = app.emit(FAVORITES_CHANGED_EVENT, &event) {
error!("[Favorites] Failed to emit change event: {}", e);
}
}
}
Err(e) => {
warn!(
"[Favorites] Server refresh failed, keeping cached favourites: {:?}",
e
);
}
}
});
Ok(cache_result)
}
/// Unmark an item as favorite
#[tauri::command]
#[specta::specta]
@@ -853,6 +968,44 @@ mod tests {
assert!(manager.get("any-handle").is_none());
}
fn ids(values: &[&str]) -> std::collections::HashSet<String> {
values.iter().map(|v| v.to_string()).collect()
}
/// UT-107 — the background refresh reports only what actually changed.
///
/// TRACES: UR-069 | DR-120 | UT-107
#[test]
fn test_changed_favorite_ids_reports_both_directions() {
// Favourited in another client since we cached.
assert_eq!(
changed_favorite_ids(&ids(&["a"]), &ids(&["a", "b"])),
vec!["b".to_string()]
);
// Un-favourited in another client.
assert_eq!(
changed_favorite_ids(&ids(&["a", "b"]), &ids(&["a"])),
vec!["b".to_string()]
);
// Both at once, in a stable order.
assert_eq!(
changed_favorite_ids(&ids(&["a", "b"]), &ids(&["b", "c"])),
vec!["a".to_string(), "c".to_string()]
);
}
/// An unchanged set emits nothing — otherwise every visit to the page would
/// fire an event and trigger a pointless refetch.
///
/// TRACES: UR-069 | DR-120 | UT-107
#[test]
fn test_changed_favorite_ids_is_empty_when_nothing_moved() {
assert!(changed_favorite_ids(&ids(&["a", "b"]), &ids(&["b", "a"])).is_empty());
assert!(changed_favorite_ids(&ids(&[]), &ids(&[])).is_empty());
}
#[test]
fn test_repository_manager_wrapper_structure() {
let manager = RepositoryManager::new();