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
+315 -5
View File
@@ -14,10 +14,12 @@
//! that were queued offline (they have `stream_url IS NULL`), mirroring the
//! heal-and-pump pattern in `player_preload_upcoming`.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use log::{info, warn};
use tauri::State;
use tauri::{Emitter, Manager, State};
use crate::commands::download::{pump_download_queue, DownloadManagerWrapper};
use crate::commands::repository::RepositoryManagerWrapper;
@@ -29,16 +31,79 @@ use crate::storage::db_service::{DatabaseService, Query, QueryParam};
/// full-catalog sync.
const LAST_CATALOG_SYNC_KEY: &str = "last_catalog_sync";
/// How long an index stays fresh before a re-index is due.
///
/// This lives in Rust rather than being a frontend constant because it decides
/// *whether the local cache is authoritative* — the same class of decision as
/// `include_catalog_browse`, and squarely the "sync policy" the spec review
/// checklist keeps out of the presentation layer. If it later becomes
/// user-configurable it stays a Rust-owned setting edited through a command.
const CATALOG_INDEX_TTL: Duration = Duration::from_secs(6 * 60 * 60);
/// How often the scheduler wakes to *check* staleness. Far shorter than the TTL
/// because a tick is nearly free — one indexed `app_settings` lookup — and it is
/// what makes the indexer responsive to events it cannot subscribe to: signing
/// in, and coming back online. The TTL, not the tick, decides whether a crawl
/// actually happens.
const CATALOG_INDEX_TICK: Duration = Duration::from_secs(5 * 60);
/// Delay before the first staleness check, to let sign-in complete and the
/// repository be registered. Without it the first check runs against an empty
/// repository manager and a fresh install would sit unindexed until the next
/// tick.
const CATALOG_INDEX_FIRST_CHECK: Duration = Duration::from_secs(15);
/// Kebab-case, per the project's event convention.
pub const CATALOG_INDEX_EVENT: &str = "catalog-index-event";
/// Guards against two passes running at once. Replaces the frontend's
/// `syncInProgress` boolean in `offlineCatalog.ts`, which could not see a pass
/// started by the scheduler.
static INDEX_IN_PROGRESS: AtomicBool = AtomicBool::new(false);
/// Clears [`INDEX_IN_PROGRESS`] however the pass leaves — including on the `?`
/// early return when `get_libraries` fails, which a plain store at the end of
/// the function would leak.
struct IndexPassGuard;
impl Drop for IndexPassGuard {
fn drop(&mut self) {
INDEX_IN_PROGRESS.store(false, Ordering::SeqCst);
}
}
/// Progress of a background index pass, for the staleness hint in the UI.
#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CatalogIndexEvent {
/// `started` | `finished` | `failed`
pub state: String,
pub items_cached: usize,
pub items_pruned: usize,
pub libraries_failed: usize,
/// Present on `failed`.
pub error: Option<String>,
}
/// Item types worth caching for offline browsing: containers the library
/// landing pages render plus the playable leaves users queue for download.
/// `MusicArtist` and `Playlist` are here because search groups results by them
/// (UR-060's Artists group). Without them in the crawl, the local index can
/// never answer an artist query and those groups can only ever be filled by the
/// server leg. Keep this in step with what `prune_stale_catalog` is allowed to
/// sweep — the crawl is only authoritative for the types it asks for.
///
/// TRACES: UR-065, UR-060 | DR-111
const CATALOG_ITEM_TYPES: &[&str] = &[
"MusicAlbum",
"MusicArtist",
"Movie",
"Series",
"Season",
"Episode",
"Audio",
"BoxSet",
"Playlist",
];
#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)]
@@ -48,6 +113,9 @@ pub struct CatalogSyncResult {
pub items_cached: usize,
/// Libraries that failed to sync (e.g. server hiccup); best-effort.
pub libraries_failed: usize,
/// Entries removed because the server no longer has them. Always 0 when any
/// library failed, since a partial crawl cannot prove an item is gone.
pub items_pruned: usize,
}
#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)]
@@ -71,8 +139,6 @@ pub async fn sync_full_catalog(
db: State<'_, DatabaseWrapper>,
handle: String,
) -> Result<CatalogSyncResult, String> {
use crate::repository::MediaRepository;
let repo = repository.0.get(&handle).ok_or("Repository not found")?;
let db_service = {
@@ -80,6 +146,27 @@ pub async fn sync_full_catalog(
Arc::new(database.service())
};
run_index_pass(repo, db_service).await
}
/// One full-catalog indexing pass, shared by the [`sync_full_catalog`] command
/// and the background scheduler (DR-109) so there is exactly one implementation
/// and one concurrency guard.
///
/// TRACES: UR-065 | DR-109, DR-110
pub(crate) async fn run_index_pass(
repo: Arc<crate::repository::HybridRepository>,
db_service: Arc<crate::storage::db_service::RusqliteService>,
) -> Result<CatalogSyncResult, String> {
use crate::repository::MediaRepository;
// One pass at a time. The command and the scheduler can both land here, and
// two concurrent crawls would double the server load and race on the sweep.
if INDEX_IN_PROGRESS.swap(true, Ordering::SeqCst) {
return Err("A catalog index pass is already running".to_string());
}
let _guard = IndexPassGuard;
let libraries = repo.get_libraries().await.map_err(|e| e.to_string())?;
info!(
"[Catalog] Full sync starting across {} libraries",
@@ -88,6 +175,10 @@ pub async fn sync_full_catalog(
let include_types: Vec<String> = CATALOG_ITEM_TYPES.iter().map(|s| s.to_string()).collect();
// Taken before the crawl: every row the crawl writes gets a `synced_at`
// newer than this, so anything still older afterwards is gone server-side.
let pass_started_at = chrono::Utc::now().to_rfc3339();
let mut items_cached = 0usize;
let mut libraries_failed = 0usize;
@@ -118,6 +209,35 @@ pub async fn sync_full_catalog(
}
}
// Propagate server-side deletions — but only after a *complete* crawl.
// `sync_full_catalog` is best-effort per library, and `items.parent_id` is
// ON DELETE CASCADE, so sweeping when a library failed to fetch could
// cascade a whole series away because one request timed out.
let mut items_pruned = 0usize;
if libraries_failed == 0 && !libraries.is_empty() {
match repo
.prune_stale_catalog(&pass_started_at, &include_types)
.await
{
Ok(removed) => {
items_pruned = removed;
if removed > 0 {
info!(
"[Catalog] Pruned {} entries no longer on the server",
removed
);
}
}
Err(e) => warn!("[Catalog] Prune of stale catalog entries failed: {:?}", e),
}
} else if libraries_failed > 0 {
info!(
"[Catalog] Skipping stale-entry prune: {} librar{} failed to sync, so the crawl is not authoritative",
libraries_failed,
if libraries_failed == 1 { "y" } else { "ies" }
);
}
// Record the sync time so callers can skip re-syncing too eagerly.
let now = chrono::Utc::now().to_rfc3339();
let upsert = Query::with_params(
@@ -133,16 +253,169 @@ pub async fn sync_full_catalog(
}
info!(
"[Catalog] Full sync complete: {} items cached, {} libraries failed",
items_cached, libraries_failed
"[Catalog] Full sync complete: {} items cached, {} pruned, {} libraries failed",
items_cached, items_pruned, libraries_failed
);
Ok(CatalogSyncResult {
items_cached,
libraries_failed,
items_pruned,
})
}
/// Whether an index pass is due, given when one last completed.
///
/// Pure so the policy is unit-testable without a clock, a server, or a database.
/// `None` (never indexed) and an unparseable stored value both mean "due" — a
/// corrupt timestamp should trigger a re-index, not silently freeze the catalog.
///
/// TRACES: UR-065 | DR-109 | UT-115
pub(crate) fn index_is_due(
last_synced_at: Option<&str>,
now: chrono::DateTime<chrono::Utc>,
ttl: Duration,
) -> bool {
let Some(raw) = last_synced_at else {
return true;
};
let Ok(last) = chrono::DateTime::parse_from_rfc3339(raw) else {
return true;
};
now.signed_duration_since(last.with_timezone(&chrono::Utc))
.to_std()
.map(|elapsed| elapsed >= ttl)
// Negative elapsed => the stored stamp is in the future (clock skew).
// Not due; a future stamp will age into due-ness on its own.
.unwrap_or(false)
}
/// Read the last-sync timestamp straight from `app_settings`.
async fn read_last_sync(
db_service: &Arc<crate::storage::db_service::RusqliteService>,
) -> Option<String> {
db_service
.query_optional(
Query::with_params(
"SELECT value FROM app_settings WHERE key = ?",
vec![QueryParam::String(LAST_CATALOG_SYNC_KEY.to_string())],
),
|row| row.get(0),
)
.await
.ok()
.flatten()
}
/// Start the background catalog indexer.
///
/// Replaces the frontend's startup-only `syncCatalog()` call: index freshness is
/// sync policy and belongs in Rust (see the layer assignment in
/// docs/specs/catalog-index-search.md). Ticks every [`CATALOG_INDEX_TICK`] and
/// runs a pass when a repository exists, the server is reachable, and the index
/// is older than [`CATALOG_INDEX_TTL`].
///
/// TRACES: UR-065 | DR-109, IR-030
pub fn spawn_catalog_indexer(app: tauri::AppHandle) {
tauri::async_runtime::spawn(async move {
// Check shortly after launch, then on every tick — not tick-then-check,
// which would leave a fresh install unindexed for a full tick.
tokio::time::sleep(CATALOG_INDEX_FIRST_CHECK).await;
loop {
if let Err(e) = maybe_run_scheduled_pass(&app).await {
// Never fatal — a failed pass leaves the existing index in place
// and we retry on the next tick.
warn!("[Catalog] Scheduled index pass skipped: {}", e);
}
tokio::time::sleep(CATALOG_INDEX_TICK).await;
}
});
}
/// One scheduler tick: check the preconditions, then index if due.
async fn maybe_run_scheduled_pass(app: &tauri::AppHandle) -> Result<(), String> {
if INDEX_IN_PROGRESS.load(Ordering::SeqCst) {
return Ok(());
}
let db_service = {
let db = app.state::<DatabaseWrapper>();
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
if !index_is_due(
read_last_sync(&db_service).await.as_deref(),
chrono::Utc::now(),
CATALOG_INDEX_TTL,
) {
return Ok(());
}
// Offline: leave the index alone. The crawl would fail every library and,
// more importantly, a partial crawl must never reach the deletion sweep.
{
let monitor = app.state::<crate::commands::connectivity::ConnectivityMonitorWrapper>();
let monitor = monitor.0.lock().await;
if !monitor.get_status().await.is_server_reachable {
return Ok(());
}
}
let repo = {
let manager = app.state::<RepositoryManagerWrapper>();
let handles = manager.0.handles();
let Some(handle) = handles.first() else {
// Not signed in yet.
return Ok(());
};
manager.0.get(handle).ok_or("Repository not found")?
};
info!("[Catalog] Index is stale; starting a scheduled pass");
let _ = app.emit(
CATALOG_INDEX_EVENT,
CatalogIndexEvent {
state: "started".to_string(),
items_cached: 0,
items_pruned: 0,
libraries_failed: 0,
error: None,
},
);
match run_index_pass(repo, db_service).await {
Ok(result) => {
let _ = app.emit(
CATALOG_INDEX_EVENT,
CatalogIndexEvent {
state: "finished".to_string(),
items_cached: result.items_cached,
items_pruned: result.items_pruned,
libraries_failed: result.libraries_failed,
error: None,
},
);
Ok(())
}
Err(e) => {
let _ = app.emit(
CATALOG_INDEX_EVENT,
CatalogIndexEvent {
state: "failed".to_string(),
items_cached: 0,
items_pruned: 0,
libraries_failed: 0,
error: Some(e.clone()),
},
);
Err(e)
}
}
}
/// Report the last-synced timestamp so the UI can show a hint / decide whether
/// to trigger a fresh sync.
#[tauri::command]
@@ -378,6 +651,43 @@ mod tests {
use rusqlite::Connection;
use std::sync::Mutex;
/// The re-index policy. Pure, so it is testable without a clock, a server or
/// a database — which is the reason it was factored out of the scheduler.
///
/// TRACES: UR-065 | DR-109 | UT-115
#[test]
fn test_index_is_due() {
let ttl = Duration::from_secs(6 * 60 * 60);
let now = chrono::DateTime::parse_from_rfc3339("2026-08-04T12:00:00+00:00")
.unwrap()
.with_timezone(&chrono::Utc);
// Never indexed => due. This is the first-run case.
assert!(index_is_due(None, now, ttl));
// Indexed 7 hours ago => past the 6h TTL => due.
assert!(index_is_due(Some("2026-08-04T05:00:00+00:00"), now, ttl));
// Indexed 1 hour ago => fresh => not due. This is what stops the
// scheduler re-crawling every tick.
assert!(!index_is_due(Some("2026-08-04T11:00:00+00:00"), now, ttl));
// Exactly at the TTL boundary counts as due.
assert!(index_is_due(Some("2026-08-04T06:00:00+00:00"), now, ttl));
// A corrupt stored value must trigger a re-index, not freeze the
// catalog forever behind an unparseable timestamp.
assert!(index_is_due(Some("not-a-timestamp"), now, ttl));
assert!(index_is_due(Some(""), now, ttl));
// A timestamp in the future (clock skew, or a restored backup) is not
// due — it ages into due-ness rather than causing a crawl every tick.
assert!(!index_is_due(Some("2026-08-05T00:00:00+00:00"), now, ttl));
// Offsets other than UTC are compared as instants, not as strings.
assert!(!index_is_due(Some("2026-08-04T13:30:00+02:00"), now, ttl));
}
fn test_db() -> Arc<RusqliteService> {
let conn = Connection::open_in_memory().unwrap();
conn.execute_batch(
+13
View File
@@ -270,6 +270,19 @@ pub async fn download_item(
if !can_download {
warn!("Storage limit reached. Attempting to free space...");
// Reclaim expired temporary entries first: they are dead weight, so
// freeing them may avoid evicting cache that is still within its
// life. Best-effort — a failure here just means eviction does more.
// TRACES: UR-071 | DR-127
match cache_arc
.reclaim_expired_async(&db_service, &user_id, &chrono::Utc::now().to_rfc3339())
.await
{
Ok(n) if n > 0 => info!("Reclaimed {} expired cache entries", n),
Ok(_) => {}
Err(e) => warn!("Expired-entry reclaim failed: {}", e),
}
// Try to evict LRU items to make space
match cache_arc
.evict_lru_async(&db_service, &user_id, size as u64)
+367
View File
@@ -0,0 +1,367 @@
//! Pushing favourite toggles made while the server was unreachable.
//!
//! Favouriting works offline: `storage_toggle_favorite` writes the local
//! `user_data` row and sets `pending_sync = 1`. Until DR-120 nothing ever
//! cleared that flag — the offline `mark_favorite`/`unmark_favorite` are no-ops
//! and `syncService.queueFavorite` had no callers — so an offline toggle was
//! silently lost.
//!
//! The drain lives in Rust, not the frontend, because it must run whether or
//! not any view is mounted; a drain started by a component dies with it.
//!
//! TRACES: UR-069 | DR-120 | UT-103
use std::sync::Arc;
use async_trait::async_trait;
use log::{debug, info, warn};
use tauri::{Emitter, Listener, Manager};
use crate::repository::types::RepoError;
use crate::repository::MediaRepository;
use crate::storage::db_service::{DatabaseService, Query, QueryParam, RusqliteService};
/// The subset of the repository the drain needs.
///
/// Narrow on purpose: a test double for `MediaRepository` would be forty
/// unimplemented methods, which is how a drain ends up untested.
#[async_trait]
pub trait FavoriteSink: Send + Sync {
async fn push_favorite(&self, item_id: &str, is_favorite: bool) -> Result<(), RepoError>;
}
#[async_trait]
impl<T: MediaRepository + ?Sized> FavoriteSink for T {
async fn push_favorite(&self, item_id: &str, is_favorite: bool) -> Result<(), RepoError> {
if is_favorite {
self.mark_favorite(item_id).await
} else {
self.unmark_favorite(item_id).await
}
}
}
/// A local favourite change still waiting to reach the server.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PendingFavorite {
pub item_id: String,
pub is_favorite: bool,
}
/// Read every favourite change this user has pending.
async fn read_pending(
db: &Arc<RusqliteService>,
user_id: &str,
) -> Result<Vec<PendingFavorite>, String> {
db.query_many(
Query::with_params(
"SELECT item_id, is_favorite FROM user_data \
WHERE user_id = ? AND pending_sync = 1 AND is_favorite IS NOT NULL",
vec![QueryParam::String(user_id.to_string())],
),
|row| {
Ok(PendingFavorite {
item_id: row.get::<_, String>(0)?,
is_favorite: row.get::<_, Option<i32>>(1)?.unwrap_or(0) != 0,
})
},
)
.await
}
/// Push pending favourite changes to the server and clear their flags.
///
/// Returns the ids that reached the server, for the `favorites-changed` event.
/// A row whose push fails keeps `pending_sync = 1` and is retried on the next
/// reconnect rather than being dropped.
///
/// TRACES: UR-069 | DR-120 | UT-103
pub async fn drain_pending_favorites(
db: &Arc<RusqliteService>,
sink: &dyn FavoriteSink,
user_id: &str,
) -> Result<Vec<String>, String> {
let pending = read_pending(db, user_id).await?;
if pending.is_empty() {
return Ok(Vec::new());
}
info!(
"[Favorites] Pushing {} favourite change(s) queued while offline",
pending.len()
);
let mut pushed = Vec::new();
for change in pending {
match sink
.push_favorite(&change.item_id, change.is_favorite)
.await
{
Ok(()) => {
let cleared = db
.execute(Query::with_params(
"UPDATE user_data SET pending_sync = 0, synced_at = ? \
WHERE user_id = ? AND item_id = ?",
vec![
QueryParam::String(chrono::Utc::now().to_rfc3339()),
QueryParam::String(user_id.to_string()),
QueryParam::String(change.item_id.clone()),
],
))
.await;
match cleared {
Ok(_) => pushed.push(change.item_id),
// The server took it; failing to clear the flag only means
// we push it again next time, which is harmless.
Err(e) => warn!(
"[Favorites] Pushed {} but could not clear pending_sync: {}",
change.item_id, e
),
}
}
Err(e) => {
// Still pending — retried on the next reconnect.
debug!(
"[Favorites] Deferring {}, server rejected the push: {:?}",
change.item_id, e
);
}
}
}
Ok(pushed)
}
/// Drain on every offline→online transition.
///
/// Hooks the `connectivity:reconnected` event the `ConnectivityMonitor`
/// already emits, rather than polling — reachability is derived from real
/// traffic (DR-055) and this just reacts to it.
///
/// TRACES: UR-069 | DR-120
pub fn spawn_favorites_drain(app: tauri::AppHandle) {
let handle = app.clone();
app.listen("connectivity:reconnected", move |_event| {
let app = handle.clone();
tauri::async_runtime::spawn(async move {
if let Err(e) = run_drain(&app).await {
warn!("[Favorites] Drain skipped: {}", e);
}
});
});
}
async fn run_drain(app: &tauri::AppHandle) -> Result<(), String> {
let db_service: Arc<RusqliteService> = {
let db = app.state::<crate::commands::storage::DatabaseWrapper>();
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
let (repo, user_id) = {
let manager = app.state::<crate::commands::repository::RepositoryManagerWrapper>();
let handles = manager.0.handles();
let Some(handle) = handles.first() else {
// Not signed in — nothing to push on behalf of.
return Ok(());
};
let repo = manager.0.get(handle).ok_or("Repository not found")?;
let user_id = repo.user_id().to_string();
(repo, user_id)
};
let pushed = drain_pending_favorites(&db_service, repo.as_ref(), &user_id).await?;
if !pushed.is_empty() {
let event = crate::commands::repository::FavoritesChangedEvent { item_ids: pushed };
if let Err(e) = app.emit(crate::commands::repository::FAVORITES_CHANGED_EVENT, &event) {
warn!("[Favorites] Failed to emit change event: {}", e);
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use rusqlite::Connection;
use std::sync::Mutex;
/// Records what the server was asked to do, and can be told to fail.
struct RecordingSink {
calls: Mutex<Vec<(String, bool)>>,
fail_for: Option<String>,
}
impl RecordingSink {
fn new() -> Self {
Self {
calls: Mutex::new(Vec::new()),
fail_for: None,
}
}
fn failing_for(item_id: &str) -> Self {
Self {
calls: Mutex::new(Vec::new()),
fail_for: Some(item_id.to_string()),
}
}
fn calls(&self) -> Vec<(String, bool)> {
let mut calls = self.calls.lock().unwrap().clone();
calls.sort();
calls
}
}
#[async_trait]
impl FavoriteSink for RecordingSink {
async fn push_favorite(&self, item_id: &str, is_favorite: bool) -> Result<(), RepoError> {
if self.fail_for.as_deref() == Some(item_id) {
return Err(RepoError::Offline);
}
self.calls
.lock()
.unwrap()
.push((item_id.to_string(), is_favorite));
Ok(())
}
}
fn test_db() -> Arc<RusqliteService> {
let conn = Connection::open_in_memory().unwrap();
conn.execute_batch(
r#"
CREATE TABLE user_data (
user_id TEXT NOT NULL,
item_id TEXT NOT NULL,
is_favorite INTEGER,
synced_at TEXT,
pending_sync INTEGER DEFAULT 0,
PRIMARY KEY (user_id, item_id)
);
"#,
)
.unwrap();
Arc::new(RusqliteService::new(Arc::new(Mutex::new(conn))))
}
async fn seed(db: &Arc<RusqliteService>, rows: &[(&str, &str, i32, i32)]) {
for (user, item, fav, pending) in rows {
db.execute(Query::with_params(
"INSERT INTO user_data (user_id, item_id, is_favorite, pending_sync) \
VALUES (?, ?, ?, ?)",
vec![
QueryParam::String(user.to_string()),
QueryParam::String(item.to_string()),
QueryParam::Int(*fav),
QueryParam::Int(*pending),
],
))
.await
.unwrap();
}
}
async fn pending_flag(db: &Arc<RusqliteService>, item_id: &str) -> Option<i32> {
db.query_optional(
Query::with_params(
"SELECT pending_sync FROM user_data WHERE item_id = ?",
vec![QueryParam::String(item_id.to_string())],
),
|row| row.get::<_, Option<i32>>(0),
)
.await
.unwrap()
.flatten()
}
/// UT-103 — the core of the bug: a favourite toggled while offline reaches
/// the server on reconnect, and stops being pending.
///
/// TRACES: UR-069 | DR-120 | UT-103
#[tokio::test]
async fn test_drain_pushes_pending_favorites_and_clears_the_flag() {
let db = test_db();
seed(
&db,
&[
("u1", "marked-offline", 1, 1),
("u1", "unmarked-offline", 0, 1),
// Already synced — must not be pushed again.
("u1", "already-synced", 1, 0),
],
)
.await;
let sink = RecordingSink::new();
let pushed = drain_pending_favorites(&db, &sink, "u1").await.unwrap();
assert_eq!(
sink.calls(),
vec![
("marked-offline".to_string(), true),
("unmarked-offline".to_string(), false),
],
"both pending changes push, with their direction preserved"
);
assert_eq!(pushed.len(), 2);
assert_eq!(pending_flag(&db, "marked-offline").await, Some(0));
assert_eq!(pending_flag(&db, "unmarked-offline").await, Some(0));
}
/// A push that fails keeps its row pending, so the change is retried rather
/// than dropped on the floor.
///
/// TRACES: UR-069 | DR-120 | UT-103
#[tokio::test]
async fn test_drain_leaves_failed_pushes_pending() {
let db = test_db();
seed(&db, &[("u1", "ok", 1, 1), ("u1", "boom", 1, 1)]).await;
let sink = RecordingSink::failing_for("boom");
let pushed = drain_pending_favorites(&db, &sink, "u1").await.unwrap();
assert_eq!(pushed, vec!["ok".to_string()]);
assert_eq!(pending_flag(&db, "ok").await, Some(0));
assert_eq!(
pending_flag(&db, "boom").await,
Some(1),
"a failed push must stay queued for the next reconnect"
);
}
/// Another user's queued changes are not pushed with this user's token.
///
/// TRACES: UR-069 | DR-120 | UT-103
#[tokio::test]
async fn test_drain_only_touches_the_given_user() {
let db = test_db();
seed(&db, &[("u1", "mine", 1, 1), ("u2", "theirs", 1, 1)]).await;
let sink = RecordingSink::new();
let pushed = drain_pending_favorites(&db, &sink, "u1").await.unwrap();
assert_eq!(pushed, vec!["mine".to_string()]);
assert_eq!(pending_flag(&db, "theirs").await, Some(1));
}
/// Nothing pending means no server calls at all — a reconnect must not
/// generate traffic just because it happened.
///
/// TRACES: UR-069 | DR-120 | UT-103
#[tokio::test]
async fn test_drain_is_a_noop_when_nothing_is_pending() {
let db = test_db();
seed(&db, &[("u1", "synced", 1, 0)]).await;
let sink = RecordingSink::new();
let pushed = drain_pending_favorites(&db, &sink, "u1").await.unwrap();
assert!(pushed.is_empty());
assert!(sink.calls().is_empty());
}
}
+1
View File
@@ -7,6 +7,7 @@ pub mod connectivity;
pub mod conversions;
pub mod device;
pub mod download;
pub mod favorites;
pub mod offline;
pub mod playback_mode;
pub mod playback_reporting;
+221 -19
View File
@@ -379,16 +379,49 @@ pub(super) async fn create_media_item(
})
}
/// Check if an item has a completed download
pub(super) async fn check_for_local_download(
db: &DatabaseWrapper,
/// Pick the source for an audio-only handoff.
///
/// A downloaded file wins over the audio-only stream URL. No transcode or audio
/// extraction is involved or wanted: the native backends already play a video
/// container without decoding its video — the Linux MPV backend is configured
/// with `video: no`, and ExoPlayer simply has no surface to render to when the
/// item is `MediaType::Audio`. Producing a separate audio-only file would cost
/// CPU and battery, need an encoder the project does not ship, and leave a
/// second artifact to keep in step with the first.
///
/// TRACES: UR-071 | DR-128 | UT-119
pub(super) fn background_audio_source(
local_path: Option<String>,
stream_url: String,
item_id: &str,
) -> MediaSource {
match local_path {
Some(path) => MediaSource::Local {
file_path: PathBuf::from(path),
jellyfin_item_id: Some(item_id.to_string()),
},
None => MediaSource::Remote {
stream_url,
jellyfin_item_id: item_id.to_string(),
},
}
}
/// Resolve the on-disk file backing a completed download, if there is one.
///
/// A `downloads` row is not proof of a file: it can outlive the bytes (manual
/// deletion, a cleared cache directory, a restored database). Every caller wants
/// "can I play this from disk right now", so existence is checked here rather
/// than trusted from the row.
///
/// Split out from [`check_for_local_download`] so the resolution is testable
/// without a `DatabaseWrapper`, and reusable by the video path.
///
/// TRACES: UR-071 | DR-123 | UT-116
pub(super) async fn resolve_local_media_path<S: DatabaseService>(
db_service: &Arc<S>,
item_id: &str,
) -> Result<Option<String>, String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
let query = Query::with_params(
"SELECT file_path FROM downloads WHERE item_id = ? AND status = 'completed' LIMIT 1",
vec![QueryParam::String(item_id.to_string())],
@@ -399,22 +432,58 @@ pub(super) async fn check_for_local_download(
.await
.map_err(|e| e.to_string())?;
// Verify the file actually exists on disk
if let Some(ref file_path) = path {
if std::path::Path::new(file_path).exists() {
Ok(path)
} else {
match path {
Some(ref file_path) if std::path::Path::new(file_path).exists() => Ok(path),
Some(file_path) => {
warn!(
"[Player] Download entry exists in DB but file not found: {}",
file_path
);
Ok(None)
}
} else {
Ok(None)
None => Ok(None),
}
}
/// Check if an item has a completed download
pub(super) async fn check_for_local_download(
db: &DatabaseWrapper,
item_id: &str,
) -> Result<Option<String>, String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
resolve_local_media_path(&db_service, item_id).await
}
/// The on-disk path for a downloaded item, for playback surfaces that resolve
/// their own source rather than going through the queue.
///
/// The video player is the reason this exists: audio has preferred local files
/// since queue construction, but video asks the repository for a stream URL and
/// never consults `downloads`, so a downloaded film was still streamed — costing
/// bandwidth that had already been spent and failing outright when offline.
///
/// Returns `None` when nothing is downloaded *or* the file is missing, so the
/// caller falls back to streaming.
///
/// TRACES: UR-071 | DR-123 | UT-116
#[tauri::command]
#[specta::specta]
pub async fn player_local_media_path(
db: State<'_, DatabaseWrapper>,
item_id: String,
) -> Result<Option<String>, String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
resolve_local_media_path(&db_service, &item_id).await
}
/// Re-point queued streaming items at completed local downloads.
///
/// Sources are resolved once when the queue is built, so downloads that finish
@@ -574,6 +643,7 @@ pub async fn player_play_item(
pub async fn player_enter_background_audio(
player: State<'_, PlayerStateWrapper>,
session: State<'_, MediaSessionManagerWrapper>,
db: State<'_, DatabaseWrapper>,
item: PlayItemRequest,
position_seconds: f64,
) -> Result<PlayerStatus, String> {
@@ -582,6 +652,19 @@ pub async fn player_enter_background_audio(
item.title, position_seconds
);
// Prefer the downloaded file over the audio-only stream URL the frontend
// resolved. Handing the native backend a local video container yields
// audio-only playback for free — no transcode, no second artifact.
// TRACES: UR-071 | DR-128
let local_path = check_for_local_download(&db, &item.id).await?;
if local_path.is_some() {
info!(
"player_enter_background_audio: using downloaded file for {}",
item.id
);
}
let source = background_audio_source(local_path, item.stream_url, &item.id);
// Build an AUDIO media item pointing at the audio-only stream. We do not use
// create_media_item() because that hardcodes MediaType::Video; background
// audio must be Audio so no video decode is started.
@@ -605,10 +688,7 @@ pub async fn player_enter_background_audio(
duration: item.duration_seconds,
artwork_url: None,
media_type: MediaType::Audio,
source: MediaSource::Remote {
stream_url: item.stream_url,
jellyfin_item_id: item.id.clone(),
},
source,
video_codec: None,
needs_transcoding: false,
video_width: None,
@@ -2379,6 +2459,128 @@ pub async fn player_disable_jellyfin(player: State<'_, PlayerStateWrapper>) -> R
#[cfg(test)]
mod tests {
/// The audio-only handoff must play a downloaded file when there is one,
/// rather than fetching an audio-only stream for media already on disk.
///
/// TRACES: UR-071 | DR-128 | UT-119
#[test]
fn test_background_audio_source_prefers_local_file() {
use super::background_audio_source;
use crate::player::MediaSource;
use std::path::PathBuf;
let local = background_audio_source(
Some("/downloads/ep1.mkv".to_string()),
"https://server/audio-only".to_string(),
"ep-1",
);
match local {
MediaSource::Local {
file_path,
jellyfin_item_id,
} => {
assert_eq!(file_path, PathBuf::from("/downloads/ep1.mkv"));
// The Jellyfin id must survive so progress still syncs back.
assert_eq!(jellyfin_item_id.as_deref(), Some("ep-1"));
}
other => panic!("expected a local source, got {:?}", other),
}
let remote = background_audio_source(None, "https://server/audio-only".to_string(), "ep-1");
match remote {
MediaSource::Remote {
stream_url,
jellyfin_item_id,
} => {
assert_eq!(stream_url, "https://server/audio-only");
assert_eq!(jellyfin_item_id, "ep-1");
}
other => panic!("expected a remote source, got {:?}", other),
}
}
/// A downloaded item must resolve to its file, and a `downloads` row whose
/// file has gone must resolve to `None` so the caller falls back to
/// streaming instead of handing the player a path that cannot be opened.
///
/// TRACES: UR-071 | DR-123 | UT-116
#[tokio::test]
async fn test_resolve_local_media_path() {
use super::resolve_local_media_path;
use crate::storage::db_service::{DatabaseService, Query, RusqliteService};
use rusqlite::Connection;
use std::sync::{Arc, Mutex};
let conn = Connection::open_in_memory().unwrap();
conn.execute(
"CREATE TABLE downloads (id INTEGER PRIMARY KEY, item_id TEXT, status TEXT, file_path TEXT)",
[],
)
.unwrap();
let db_service = Arc::new(RusqliteService::new(Arc::new(Mutex::new(conn))));
// A real file on disk, so the existence check passes.
let present = std::env::temp_dir().join("jellytau-resolve-local-test.mp4");
std::fs::write(&present, b"x").unwrap();
let present_str = present.to_string_lossy().to_string();
for (item, status, path) in [
("downloaded", "completed", present_str.as_str()),
("still-going", "downloading", present_str.as_str()),
(
"file-gone",
"completed",
"/nonexistent/jellytau/missing.mp4",
),
] {
db_service
.execute(Query::with_params(
"INSERT INTO downloads (item_id, status, file_path) VALUES (?, ?, ?)",
vec![
crate::storage::db_service::QueryParam::String(item.to_string()),
crate::storage::db_service::QueryParam::String(status.to_string()),
crate::storage::db_service::QueryParam::String(path.to_string()),
],
))
.await
.unwrap();
}
assert_eq!(
resolve_local_media_path(&db_service, "downloaded")
.await
.unwrap()
.as_deref(),
Some(present_str.as_str()),
"a completed download with its file present must resolve"
);
assert_eq!(
resolve_local_media_path(&db_service, "still-going")
.await
.unwrap(),
None,
"an in-progress download is not playable from disk"
);
assert_eq!(
resolve_local_media_path(&db_service, "file-gone")
.await
.unwrap(),
None,
"a row whose file has gone must fall back to streaming, not hand over a dead path"
);
assert_eq!(
resolve_local_media_path(&db_service, "never-heard-of-it")
.await
.unwrap(),
None
);
let _ = std::fs::remove_file(&present);
}
/// Queue items enqueued as Remote must flip to Local once a completed
/// download exists on disk — this is what makes preloaded tracks (and
/// offline playback after a connection drop) actually use the cache.
+18 -1
View File
@@ -142,7 +142,7 @@ pub async fn player_play_next_episode(
/// - Frontend when audio track ends via backend event - no itemId/repositoryHandle needed
/// - Android JNI callback also triggers this logic directly
///
/// TRACES: UR-023, UR-026, UR-040 | DR-047, DR-052
/// TRACES: UR-023, UR-026, UR-040 | DR-047, DR-052, DR-129
#[tauri::command]
#[specta::specta]
pub async fn player_on_playback_ended(
@@ -257,6 +257,23 @@ pub async fn player_on_playback_ended(
.await;
}
}
AutoplayDecision::ResumeStream { position } => {
// The stream was cut short by the network, not by the media ending.
// Re-open it where it died — no queue clearing, no PlaybackEnded, and
// above all no leaving the player parked in ExoPlayer's STATE_ENDED,
// where the next play intent restarts the item from 0:00.
log::info!(
"[Autoplay] Decision: Resume truncated stream at {:.1}s",
position
);
let controller = controller_arc.lock().await;
if let Err(e) = controller.resume_stream_at(position).await {
log::error!("[Autoplay] Failed to resume truncated stream: {}", e);
if let Some(emitter) = controller.event_emitter() {
emitter.emit(PlayerStatusEvent::PlaybackEnded);
}
}
}
}
Ok(())
+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();
+17 -2
View File
@@ -47,10 +47,25 @@ pub async fn storage_save_person(
};
let query = Query::with_params(
"INSERT OR REPLACE INTO people (
// A real UPSERT, not INSERT OR REPLACE — `people` is now backed by the
// `people_fts` index (migration 022), and REPLACE would orphan an index
// entry on every re-cache: it fires no AFTER DELETE trigger without
// `recursive_triggers`, and reassigns the rowid that `content_rowid`
// refers to. Same defect as DR-110 fixed for `items`.
//
// TRACES: UR-065 | DR-110, DR-111
"INSERT INTO people (
id, server_id, name, overview, primary_image_tag,
premiere_date, end_date, synced_at
) VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)",
) VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(id) DO UPDATE SET
server_id = excluded.server_id,
name = excluded.name,
overview = excluded.overview,
primary_image_tag = excluded.primary_image_tag,
premiere_date = excluded.premiere_date,
end_date = excluded.end_date,
synced_at = CURRENT_TIMESTAMP",
vec![
QueryParam::String(person.id),
QueryParam::String(person.server_id),