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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user