fix(offline): offline mode no longer needs the network
Three defects made "offline" depend on a server it could not reach. A downloaded film would not play offline. The player found the file on disk, then asked the server for the item's PlaybackInfo only to read its media-source id; with no network that retried for seven seconds and failed, and the file was never opened. A completed download now answers playback info from its download row — local path, direct play, item id as media source — and the hybrid repository consults it before the network. "More info" on a downloaded show failed with "Failed to load item". The cache is one SQLite connection behind one mutex, so any write in progress (the catalog sync at every launch, a download finishing) pushes a read past the 100 ms fast path — and get_items, the library list, genres and playlist items discarded such a read, waited on the server, and returned its error over data sitting on disk. They now keep the read running and wait for it when the server fails; the cache-only reads (search, favourites) simply await the cache, having no server to fall back from. Next Up went only to the server, and the TV landing page loads it in one Promise.all with its other rows, so offline it blanked the whole page. It now falls back to the cache. Each fix has a test that failed first against an unreachable server (and, for the cache, a database held past the fast path). DR-294, UT-260, UT-261, UT-263.
This commit is contained in:
@@ -186,6 +186,62 @@ Per-item disk usage comes from `repository_get_download_disk_usage`
|
||||
Downloaded browse cards, detail pages, the device total and the remove
|
||||
confirmation (DR-085).
|
||||
|
||||
## What a Video Download Fetches
|
||||
|
||||
**TRACES**: UR-071, UR-004 | DR-171, DR-293
|
||||
|
||||
An `original`-quality download is the server's untouched file (`Static=true`)
|
||||
unless its audio cannot be decoded by **the renderer that will play it** —
|
||||
`renderer_can_decode_audio`, DR-234's per-platform answer. Only then is the
|
||||
server asked to re-encode the audio on the way down (`allowVideoStreamCopy`
|
||||
keeps the picture byte-for-byte).
|
||||
|
||||
The distinction matters because a transcode is generated as it is sent: no
|
||||
`Content-Length`, `Range` ignored. It measured ~1 MB/s and restarted from byte
|
||||
zero on every network blip, against a direct copy that moved a 910 MB episode in
|
||||
94 s with no retries. On Android the renderer is ExoPlayer with the FFmpeg
|
||||
extension ([05-platform-backends.md](05-platform-backends.md)), which decodes
|
||||
AC-3/E-AC-3/DTS/TrueHD, so Android downloads are always the direct copy. On
|
||||
Linux the webview still renders video and the transcode still applies.
|
||||
|
||||
The policy used to judge against the *webview's* codec list on every platform
|
||||
(DR-171), because a download outlives the native-video setting that was active
|
||||
when it arrived. That reasoning is why Android's webview video path was removed
|
||||
rather than merely defaulted off: a file downloaded as the original must never
|
||||
meet a renderer that cannot decode it.
|
||||
|
||||
## Offline Means No Network
|
||||
|
||||
**TRACES**: UR-002, UR-071 | DR-294
|
||||
|
||||
Three defects made "offline" depend on the network; the invariants that replace
|
||||
them:
|
||||
|
||||
- **A download plays without the server.** Playing a downloaded item asked the
|
||||
server for its `PlaybackInfo` only to read the media-source id; offline that
|
||||
retried for seven seconds, failed, and the file was never opened.
|
||||
`OfflineRepository::local_playback_info` answers for any completed download of
|
||||
the current user — local path, direct play, item id as media source (a download
|
||||
names no source, so the server served its default, which carries the item's id)
|
||||
— and `HybridRepository::get_playback_info` consults it **first**.
|
||||
- **A slow cache read is waited for, never discarded.** The cache is one SQLite
|
||||
connection behind one mutex, so any write in progress (the catalog sync that
|
||||
starts at every launch, a download finishing) pushes a read past the 100 ms fast
|
||||
path. `get_items`, the library list, genres and playlist items used to discard
|
||||
such a read, wait for the server, and — offline — return its error over data on
|
||||
disk; "More info" on a downloaded show failed that way. They now start the read
|
||||
with `cache_try` (which keeps it running) and `settle` on it when the server
|
||||
fails. Cache-only reads (search, favourites) have no server to fall back from,
|
||||
so they simply await the cache.
|
||||
- **Server-only sections degrade, they do not fail a page.** Next Up went only to
|
||||
the server, and the TV landing page loads it in one `Promise.all`, so offline it
|
||||
blanked the whole page. It now falls back to the cache when the server cannot
|
||||
answer.
|
||||
|
||||
What still needs the server, deliberately: streaming anything not downloaded,
|
||||
live TV and channels, reporting playback, and edits (favourites, playlists,
|
||||
played state).
|
||||
|
||||
## Download Commands
|
||||
|
||||
**Location**: `src-tauri/src/commands/download/` — `mod.rs` (the commands below), `pinning.rs`, `smart_cache.rs`
|
||||
|
||||
@@ -235,7 +235,10 @@ impl HybridRepository {
|
||||
) -> Result<SearchResult, RepoError> {
|
||||
let offline = Arc::clone(&self.offline);
|
||||
let query = query.to_string();
|
||||
self.cache_with_timeout(async move { offline.search(&query, options).await })
|
||||
// Cache-only: there is no server to fall back to, so a busy database
|
||||
// delays the answer rather than failing it (DR-294).
|
||||
offline
|
||||
.search(&query, options)
|
||||
.await
|
||||
.map(ExcludeHidden::without_excluded)
|
||||
}
|
||||
@@ -250,7 +253,9 @@ impl HybridRepository {
|
||||
options: Option<GetItemsOptions>,
|
||||
) -> Result<SearchResult, RepoError> {
|
||||
let offline = Arc::clone(&self.offline);
|
||||
self.cache_with_timeout(async move { offline.get_favorites(scope, options).await })
|
||||
// Cache-only, as `search_cache_only` above (DR-294).
|
||||
offline
|
||||
.get_favorites(scope, options)
|
||||
.await
|
||||
.map(ExcludeHidden::without_excluded)
|
||||
}
|
||||
@@ -394,7 +399,7 @@ impl HybridRepository {
|
||||
|
||||
/// Cache-first query: try cache, fall back to server on miss.
|
||||
///
|
||||
/// 1. Check cache (100ms timeout applied by caller via cache_with_timeout)
|
||||
/// 1. Check cache (100ms fast path, via `cache_leg`; a slow read keeps running)
|
||||
/// 2. If cache has meaningful content → return immediately (fast path)
|
||||
/// 3. If cache is empty/stale → query server (fresh data)
|
||||
/// 4. If server fails → return cache even if empty (offline fallback)
|
||||
@@ -523,18 +528,64 @@ impl HybridRepository {
|
||||
}
|
||||
}
|
||||
|
||||
/// Await a cache query that is still running, however long it takes.
|
||||
async fn cache_with_timeout<T>(
|
||||
&self,
|
||||
future: impl std::future::Future<Output = Result<T, RepoError>> + Send,
|
||||
) -> Result<T, RepoError> {
|
||||
timeout(Self::CACHE_FAST_PATH, future)
|
||||
.await
|
||||
.unwrap_or_else(|_| {
|
||||
Err(RepoError::Database {
|
||||
message: "Cache query timeout".to_string(),
|
||||
})
|
||||
/// Start a cache read with [`Self::CACHE_FAST_PATH`] to answer: its result
|
||||
/// if it made it, and otherwise the read itself, still running.
|
||||
///
|
||||
/// The cache-then-server queries used to *discard* a read that missed the
|
||||
/// deadline. The database is one SQLite connection behind one mutex, so any
|
||||
/// write in progress — the catalog sync that starts at every launch, a
|
||||
/// download finishing — pushes a read past 100 ms routinely; offline the
|
||||
/// server then failed too, and the page reported a network error over data
|
||||
/// sitting on disk. Keeping the read lets [`Self::settle`] wait for it.
|
||||
///
|
||||
/// TRACES: UR-002 | DR-013, DR-294
|
||||
async fn cache_try<T>(
|
||||
future: impl std::future::Future<Output = Result<T, RepoError>> + Send + 'static,
|
||||
) -> (
|
||||
Result<T, RepoError>,
|
||||
Option<tokio::task::JoinHandle<Result<T, RepoError>>>,
|
||||
)
|
||||
where
|
||||
T: Send + 'static,
|
||||
{
|
||||
let (fast, slow) = Self::cache_leg(future).await.split();
|
||||
let fast = fast.unwrap_or_else(|| {
|
||||
Err(RepoError::Database {
|
||||
message: "Cache query still running".to_string(),
|
||||
})
|
||||
});
|
||||
(fast, slow)
|
||||
}
|
||||
|
||||
/// The server could not answer: the cache's answer if it has one —
|
||||
/// waiting for a read still in flight — else the server's error.
|
||||
///
|
||||
/// TRACES: UR-002 | DR-294 | UT-263
|
||||
async fn settle<T>(
|
||||
fast: Result<T, RepoError>,
|
||||
slow: Option<tokio::task::JoinHandle<Result<T, RepoError>>>,
|
||||
server_err: RepoError,
|
||||
) -> Result<T, RepoError> {
|
||||
if let Some(handle) = slow {
|
||||
debug!("[HybridRepo] Server failed; waiting for the slow cache read");
|
||||
return match handle.await {
|
||||
Ok(Ok(data)) => Ok(data),
|
||||
_ => Err(server_err),
|
||||
};
|
||||
}
|
||||
fast.or(Err(server_err))
|
||||
}
|
||||
|
||||
/// [`Self::settle`] for `get_items`, whose slow read has not yet had
|
||||
/// exclusions applied.
|
||||
async fn cache_or(
|
||||
fast: Result<SearchResult, RepoError>,
|
||||
slow: Option<tokio::task::JoinHandle<Result<SearchResult, RepoError>>>,
|
||||
server_err: RepoError,
|
||||
) -> Result<SearchResult, RepoError> {
|
||||
Self::settle(fast, slow, server_err)
|
||||
.await
|
||||
.map(ExcludeHidden::without_excluded)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -544,7 +595,9 @@ impl MediaRepository for HybridRepository {
|
||||
// Cache-first (100ms). On a cache hit, refresh the cache from the server
|
||||
// in the background. On a miss, fetch from the server and persist so the
|
||||
// list is available on the next (possibly offline) startup.
|
||||
let cache_result = self.cache_with_timeout(self.offline.get_libraries()).await;
|
||||
let offline_read = Arc::clone(&self.offline);
|
||||
let (cache_result, slow_cache) =
|
||||
Self::cache_try(async move { offline_read.get_libraries().await }).await;
|
||||
|
||||
if let Ok(libs) = &cache_result {
|
||||
if libs.has_content() {
|
||||
@@ -581,7 +634,8 @@ impl MediaRepository for HybridRepository {
|
||||
}
|
||||
Ok(server_libs)
|
||||
}
|
||||
Err(e) => cache_result.or(Err(e)),
|
||||
// TRACES: UR-002 | DR-294 | UT-263
|
||||
Err(e) => Self::settle(cache_result, slow_cache, e).await,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -610,10 +664,16 @@ impl MediaRepository for HybridRepository {
|
||||
// `parallel_race` — it interleaves the downloads-only gate and a
|
||||
// background cache write — so it applies the filter itself.
|
||||
// TRACES: UR-076 | DR-209
|
||||
let cache_result = self
|
||||
.cache_with_timeout(async move { offline.get_items(&parent_id, opts_clone).await })
|
||||
.await
|
||||
.map(ExcludeHidden::without_excluded);
|
||||
//
|
||||
// A read that misses the fast path is kept running, not discarded: if
|
||||
// the server then fails, the cache is the only thing that can answer,
|
||||
// and it is waited for (see the end of this function). Discarding it
|
||||
// is what made a downloaded show fail offline ("Failed to load item")
|
||||
// whenever a write held the database past 100 ms — the catalog sync
|
||||
// that starts at every launch does, routinely. TRACES: UR-002 | DR-294
|
||||
let (cache_result, slow_cache) =
|
||||
Self::cache_try(async move { offline.get_items(&parent_id, opts_clone).await }).await;
|
||||
let cache_result = cache_result.map(ExcludeHidden::without_excluded);
|
||||
|
||||
// Downloads-only gate: when the "Show all server media" toggle is off
|
||||
// (offline), an empty offline result is authoritative — the user asked
|
||||
@@ -697,10 +757,13 @@ impl MediaRepository for HybridRepository {
|
||||
// TRACES: UR-076 | DR-209
|
||||
Ok(server_data.without_excluded())
|
||||
}
|
||||
Ok(Err(e)) => cache_result.or(Err(e)),
|
||||
Err(join_err) => cache_result.or(Err(RepoError::Network {
|
||||
message: format!("Server task failed: {}", join_err),
|
||||
})),
|
||||
Ok(Err(e)) => Self::cache_or(cache_result, slow_cache, e).await,
|
||||
Err(join_err) => {
|
||||
let e = RepoError::Network {
|
||||
message: format!("Server task failed: {}", join_err),
|
||||
};
|
||||
Self::cache_or(cache_result, slow_cache, e).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -806,11 +869,22 @@ impl MediaRepository for HybridRepository {
|
||||
series_id: Option<&str>,
|
||||
limit: Option<usize>,
|
||||
) -> Result<Vec<MediaItem>, RepoError> {
|
||||
// Next up is dynamic, always fetch from server
|
||||
self.online
|
||||
.get_next_up_episodes(series_id, limit)
|
||||
.await
|
||||
.map(ExcludeHidden::without_excluded)
|
||||
// Next Up is dynamic, so the server's answer is preferred — but when the
|
||||
// server cannot answer, the cache's stands in. It used to be server-only,
|
||||
// and the TV landing page loads Next Up in one `Promise.all` with its
|
||||
// other rows, so offline that single failure blanked the whole page with
|
||||
// Continue Watching and Latest sitting in the cache (DR-294).
|
||||
// TRACES: UR-002 | DR-294 | UT-261
|
||||
match self.online.get_next_up_episodes(series_id, limit).await {
|
||||
Ok(items) => Ok(items.without_excluded()),
|
||||
Err(e) => {
|
||||
debug!("[HybridRepo] Next Up from server failed ({e}); using the cache");
|
||||
self.offline
|
||||
.get_next_up_episodes(series_id, limit)
|
||||
.await
|
||||
.map(ExcludeHidden::without_excluded)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_recently_played_audio(
|
||||
@@ -878,9 +952,9 @@ impl MediaRepository for HybridRepository {
|
||||
|
||||
let cache_offline = Arc::clone(&self.offline);
|
||||
let cache_pid = parent_id_str.clone();
|
||||
let cache_result = self
|
||||
.cache_with_timeout(async move { cache_offline.get_genres(cache_pid.as_deref()).await })
|
||||
.await;
|
||||
let (cache_result, slow_cache) =
|
||||
Self::cache_try(async move { cache_offline.get_genres(cache_pid.as_deref()).await })
|
||||
.await;
|
||||
|
||||
if let Ok(genres) = &cache_result {
|
||||
if genres.has_content() {
|
||||
@@ -922,7 +996,7 @@ impl MediaRepository for HybridRepository {
|
||||
}
|
||||
Ok(server_genres)
|
||||
}
|
||||
Err(e) => cache_result.or(Err(e)),
|
||||
Err(e) => Self::settle(cache_result, slow_cache, e).await,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -946,7 +1020,15 @@ impl MediaRepository for HybridRepository {
|
||||
}
|
||||
|
||||
async fn get_playback_info(&self, item_id: &str) -> Result<PlaybackInfo, RepoError> {
|
||||
// Playback info requires server communication for transcoding decisions
|
||||
// A downloaded item is played from disk and needs nothing the server
|
||||
// negotiates, so its answer comes from the download row — first, and
|
||||
// whether or not the server is reachable. Asking the server first is
|
||||
// what made a download unplayable offline (DR-294). Anything not held
|
||||
// locally is a streaming question, and only the server can answer it.
|
||||
// TRACES: UR-002, UR-071 | DR-294 | UT-260
|
||||
if let Some(local) = self.offline.local_playback_info(item_id).await? {
|
||||
return Ok(local);
|
||||
}
|
||||
self.online.get_playback_info(item_id).await
|
||||
}
|
||||
|
||||
@@ -1205,9 +1287,8 @@ impl MediaRepository for HybridRepository {
|
||||
tokio::spawn(async move { online.get_playlist_items(&playlist_id_clone).await });
|
||||
|
||||
// Check cache first (fast, 100ms timeout)
|
||||
let cache_result = self
|
||||
.cache_with_timeout(async move { offline.get_playlist_items(&playlist_id).await })
|
||||
.await;
|
||||
let (cache_result, slow_cache) =
|
||||
Self::cache_try(async move { offline.get_playlist_items(&playlist_id).await }).await;
|
||||
|
||||
// Cache hit: return immediately, update cache in background
|
||||
if let Ok(data) = &cache_result {
|
||||
@@ -1244,10 +1325,13 @@ impl MediaRepository for HybridRepository {
|
||||
});
|
||||
Ok(entries)
|
||||
}
|
||||
Ok(Err(e)) => cache_result.or(Err(e)),
|
||||
Err(join_err) => cache_result.or(Err(RepoError::Network {
|
||||
message: format!("Server task failed: {}", join_err),
|
||||
})),
|
||||
Ok(Err(e)) => Self::settle(cache_result, slow_cache, e).await,
|
||||
Err(join_err) => {
|
||||
let e = RepoError::Network {
|
||||
message: format!("Server task failed: {}", join_err),
|
||||
};
|
||||
Self::settle(cache_result, slow_cache, e).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -115,6 +115,54 @@ pub struct OfflineRepository {
|
||||
}
|
||||
|
||||
impl OfflineRepository {
|
||||
/// Playback info for an item this user has downloaded, built from the
|
||||
/// download row — no server involved. `None` when there is no completed
|
||||
/// local file, which leaves the question to the server.
|
||||
///
|
||||
/// This exists because playing a download asked the server first. The
|
||||
/// player needs only the media-source id (subtitle URLs are keyed by it),
|
||||
/// and fetching that from `/PlaybackInfo` meant a downloaded film would not
|
||||
/// play offline: the call retried for seven seconds and failed, and the
|
||||
/// file on disk was never opened.
|
||||
///
|
||||
/// The media-source id is the item id. A download never names a source —
|
||||
/// the URL carries no `mediaSourceId` — so the server serves its default,
|
||||
/// and Jellyfin gives an item's default source the item's own id.
|
||||
///
|
||||
/// TRACES: UR-002, UR-071 | DR-294 | UT-260
|
||||
pub async fn local_playback_info(
|
||||
&self,
|
||||
item_id: &str,
|
||||
) -> Result<Option<PlaybackInfo>, RepoError> {
|
||||
let rows = self
|
||||
.db_service
|
||||
.query_many(
|
||||
Query::with_params(
|
||||
"SELECT file_path FROM downloads \
|
||||
WHERE item_id = ? AND user_id = ? AND status = 'completed' \
|
||||
AND file_path IS NOT NULL \
|
||||
LIMIT 1",
|
||||
vec![
|
||||
QueryParam::String(item_id.to_string()),
|
||||
QueryParam::String(self.user_id.clone()),
|
||||
],
|
||||
),
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| RepoError::Database { message: e })?;
|
||||
|
||||
Ok(rows.into_iter().next().map(|file_path| PlaybackInfo {
|
||||
media_source_id: item_id.to_string(),
|
||||
// No server session: nothing was negotiated, and a local file has no
|
||||
// transcode job for a session id to name.
|
||||
play_session_id: String::new(),
|
||||
stream_url: file_path,
|
||||
direct_play: true,
|
||||
needs_transcoding: false,
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn new(db_service: Arc<RusqliteService>, server_id: String, user_id: String) -> Self {
|
||||
Self {
|
||||
db_service,
|
||||
@@ -2760,6 +2808,12 @@ mod tests {
|
||||
}
|
||||
|
||||
fn create_test_db() -> Arc<RusqliteService> {
|
||||
Arc::new(RusqliteService::new(create_test_conn()))
|
||||
}
|
||||
|
||||
/// The raw connection behind [`create_test_db`], for tests that need to
|
||||
/// hold its lock — standing in for a concurrent write.
|
||||
fn create_test_conn() -> Arc<Mutex<Connection>> {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
|
||||
// Enable foreign key constraints (they're disabled by default in SQLite)
|
||||
@@ -2868,6 +2922,8 @@ mod tests {
|
||||
CREATE TABLE downloads (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
item_id TEXT NOT NULL,
|
||||
user_id TEXT,
|
||||
file_path TEXT,
|
||||
status TEXT NOT NULL,
|
||||
file_size INTEGER
|
||||
);
|
||||
@@ -2934,10 +2990,267 @@ mod tests {
|
||||
[],
|
||||
).unwrap();
|
||||
|
||||
Arc::new(RusqliteService::new(Arc::new(Mutex::new(conn))))
|
||||
Arc::new(Mutex::new(conn))
|
||||
}
|
||||
|
||||
/// Helper to create a test MediaItem
|
||||
/// The reported bug: offline, a downloaded episode would not play. The
|
||||
/// player found the file on disk, then asked the *server* for the item's
|
||||
/// playback info — only to read its media-source id — and with no network
|
||||
/// that call retried for seven seconds and failed, so playback never began.
|
||||
/// A download that needs the internet to play is not a download.
|
||||
///
|
||||
/// Driven through the real `HybridRepository` against a server that refuses
|
||||
/// connections, because the defect was the hybrid layer sending this call
|
||||
/// only to the server.
|
||||
///
|
||||
/// TRACES: UR-002, UR-071 | DR-294 | UT-260
|
||||
#[tokio::test]
|
||||
async fn test_a_downloaded_item_gets_playback_info_without_the_server() {
|
||||
let db_service = create_test_db();
|
||||
for sql in [
|
||||
"INSERT INTO downloads (item_id, user_id, file_path, status) \
|
||||
VALUES ('ep-6', 'test-user', '/data/videos/S01E06.mp4', 'completed')",
|
||||
// Still downloading: there is no local file to play yet.
|
||||
"INSERT INTO downloads (item_id, user_id, file_path, status) \
|
||||
VALUES ('ep-7', 'test-user', '/data/videos/S01E07.mp4', 'downloading')",
|
||||
// Someone else's download is not this user's local copy.
|
||||
"INSERT INTO downloads (item_id, user_id, file_path, status) \
|
||||
VALUES ('ep-8', 'other-user', '/data/videos/S01E08.mp4', 'completed')",
|
||||
] {
|
||||
db_service.execute(Query::new(sql)).await.unwrap();
|
||||
}
|
||||
let offline = OfflineRepository::new(
|
||||
db_service.clone(),
|
||||
"test-server".to_string(),
|
||||
"test-user".to_string(),
|
||||
);
|
||||
let local = OfflineRepository::new(
|
||||
db_service,
|
||||
"test-server".to_string(),
|
||||
"test-user".to_string(),
|
||||
);
|
||||
// Port 9 on loopback: nothing listens, so every request is refused.
|
||||
let online = crate::repository::OnlineRepository::new(
|
||||
Arc::new(
|
||||
crate::jellyfin::HttpClient::new(crate::jellyfin::HttpConfig::default()).unwrap(),
|
||||
),
|
||||
"http://127.0.0.1:9".to_string(),
|
||||
"test-user".to_string(),
|
||||
"test-token".to_string(),
|
||||
);
|
||||
let hybrid = crate::repository::HybridRepository::new(online, offline);
|
||||
|
||||
let started = std::time::Instant::now();
|
||||
let info = hybrid
|
||||
.get_playback_info("ep-6")
|
||||
.await
|
||||
.expect("a downloaded item must get playback info with no server");
|
||||
assert!(
|
||||
started.elapsed() < std::time::Duration::from_secs(1),
|
||||
"answered locally, not after the network gave up: {:?}",
|
||||
started.elapsed()
|
||||
);
|
||||
assert_eq!(info.stream_url, "/data/videos/S01E06.mp4");
|
||||
assert!(info.direct_play && !info.needs_transcoding);
|
||||
// Jellyfin's default media source shares the item's id, and a download
|
||||
// is always of the default source (no mediaSourceId is requested).
|
||||
assert_eq!(info.media_source_id, "ep-6");
|
||||
|
||||
// Not playable locally → still the server's question to answer.
|
||||
for not_local in ["ep-7", "ep-8", "never-downloaded"] {
|
||||
assert!(
|
||||
local
|
||||
.local_playback_info(not_local)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none(),
|
||||
"{not_local} has no completed local file for this user"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Next Up went only to the server, so offline it failed — and the TV
|
||||
/// landing page loads it together with Continue Watching and Latest in one
|
||||
/// `Promise.all`, so that one failure blanked the whole page ("Failed to
|
||||
/// load TV sections: Offline") with its other rows sitting in the cache.
|
||||
/// With the server unreachable, the answer is the cache's.
|
||||
///
|
||||
/// TRACES: UR-002 | DR-294 | UT-261
|
||||
#[tokio::test]
|
||||
async fn test_next_up_answers_from_the_cache_when_the_server_is_unreachable() {
|
||||
let offline = OfflineRepository::new(
|
||||
create_test_db(),
|
||||
"test-server".to_string(),
|
||||
"test-user".to_string(),
|
||||
);
|
||||
let online = crate::repository::OnlineRepository::new(
|
||||
Arc::new(
|
||||
crate::jellyfin::HttpClient::new(crate::jellyfin::HttpConfig::default()).unwrap(),
|
||||
),
|
||||
"http://127.0.0.1:9".to_string(),
|
||||
"test-user".to_string(),
|
||||
"test-token".to_string(),
|
||||
);
|
||||
let hybrid = crate::repository::HybridRepository::new(online, offline);
|
||||
|
||||
let next_up = hybrid.get_next_up_episodes(None, Some(12)).await;
|
||||
assert!(
|
||||
next_up.is_ok(),
|
||||
"an unreachable server must not fail Next Up offline: {next_up:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The reported bug: offline, "More info" on a downloaded show failed with
|
||||
/// "Failed to load item". Its seasons were in the cache the whole time.
|
||||
///
|
||||
/// `get_items` gave the cache 100 ms and *discarded* a slower answer, then
|
||||
/// waited on the server — which offline fails — and returned the server's
|
||||
/// error. The cache is one SQLite connection behind one mutex, so any write
|
||||
/// in progress (the catalog sync that starts at every launch, a download
|
||||
/// finishing) pushes a read past 100 ms routinely. Every other cached query
|
||||
/// already keeps a slow read alive and waits for it when the server fails;
|
||||
/// `get_items`, which the series page calls for the show and each season,
|
||||
/// did not.
|
||||
///
|
||||
/// TRACES: UR-002 | DR-294 | UT-263
|
||||
#[tokio::test]
|
||||
async fn test_get_items_waits_for_a_slow_cache_when_the_server_is_unreachable() {
|
||||
let _guard = lock_catalog_browse();
|
||||
set_include_catalog_browse(true);
|
||||
let conn = create_test_conn();
|
||||
let db_service = Arc::new(RusqliteService::new(Arc::clone(&conn)));
|
||||
for sql in [
|
||||
"INSERT INTO items (id, server_id, name, item_type, synced_at) \
|
||||
VALUES ('series-1', 'test-server', 'Show', 'Series', '2026-01-01')",
|
||||
"INSERT INTO items (id, server_id, parent_id, name, item_type, synced_at) \
|
||||
VALUES ('season-1', 'test-server', 'series-1', 'Season 1', 'Season', '2026-01-01')",
|
||||
] {
|
||||
db_service.execute(Query::new(sql)).await.unwrap();
|
||||
}
|
||||
let offline = OfflineRepository::new(
|
||||
db_service,
|
||||
"test-server".to_string(),
|
||||
"test-user".to_string(),
|
||||
);
|
||||
let online = crate::repository::OnlineRepository::new(
|
||||
Arc::new(
|
||||
crate::jellyfin::HttpClient::new(crate::jellyfin::HttpConfig::default()).unwrap(),
|
||||
),
|
||||
"http://127.0.0.1:9".to_string(),
|
||||
"test-user".to_string(),
|
||||
"test-token".to_string(),
|
||||
);
|
||||
let hybrid = crate::repository::HybridRepository::new(online, offline);
|
||||
|
||||
// Sanity: with nothing holding the database, the season is found. A
|
||||
// failure here is the fixture, not the bug.
|
||||
let quick = hybrid
|
||||
.get_items("series-1", None)
|
||||
.await
|
||||
.expect("unlocked read");
|
||||
assert_eq!(quick.items.len(), 1, "fixture: the season is cached");
|
||||
|
||||
// Now a write holds the connection for 300 ms — longer than the fast path.
|
||||
let held = Arc::clone(&conn);
|
||||
let writer = std::thread::spawn(move || {
|
||||
let _lock = held.lock().unwrap();
|
||||
std::thread::sleep(std::time::Duration::from_millis(300));
|
||||
});
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
|
||||
let slow = hybrid.get_items("series-1", None).await;
|
||||
writer.join().unwrap();
|
||||
let slow = slow.expect("a slow cache must still answer when the server cannot");
|
||||
assert_eq!(slow.items.len(), 1);
|
||||
assert_eq!(slow.items[0].id, "season-1");
|
||||
}
|
||||
|
||||
/// A hybrid repository whose server refuses every connection, over `conn`.
|
||||
fn hybrid_without_server(conn: &Arc<Mutex<Connection>>) -> crate::repository::HybridRepository {
|
||||
let offline = OfflineRepository::new(
|
||||
Arc::new(RusqliteService::new(Arc::clone(conn))),
|
||||
"test-server".to_string(),
|
||||
"test-user".to_string(),
|
||||
);
|
||||
let online = crate::repository::OnlineRepository::new(
|
||||
Arc::new(
|
||||
crate::jellyfin::HttpClient::new(crate::jellyfin::HttpConfig::default()).unwrap(),
|
||||
),
|
||||
"http://127.0.0.1:9".to_string(),
|
||||
"test-user".to_string(),
|
||||
"test-token".to_string(),
|
||||
);
|
||||
crate::repository::HybridRepository::new(online, offline)
|
||||
}
|
||||
|
||||
/// Hold the database for `ms`, as a concurrent write does.
|
||||
fn hold_database(conn: &Arc<Mutex<Connection>>, ms: u64) -> std::thread::JoinHandle<()> {
|
||||
let held = Arc::clone(conn);
|
||||
let writer = std::thread::spawn(move || {
|
||||
let _lock = held.lock().unwrap();
|
||||
std::thread::sleep(std::time::Duration::from_millis(ms));
|
||||
});
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
writer
|
||||
}
|
||||
|
||||
/// The library list is the first thing an offline launch shows, and it had
|
||||
/// the same defect as `get_items`: a cache read slowed past 100 ms by a write
|
||||
/// was discarded, the server then failed, and the list came back as a
|
||||
/// network error with the libraries on disk.
|
||||
///
|
||||
/// TRACES: UR-002 | DR-294 | UT-263
|
||||
#[tokio::test]
|
||||
async fn test_libraries_wait_for_a_slow_cache_when_the_server_is_unreachable() {
|
||||
let conn = create_test_conn();
|
||||
conn.lock()
|
||||
.unwrap()
|
||||
.execute(
|
||||
"INSERT INTO libraries (id, server_id, name, collection_type) \
|
||||
VALUES ('lib-tv', 'test-server', 'Shows', 'tvshows')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
let hybrid = hybrid_without_server(&conn);
|
||||
|
||||
let writer = hold_database(&conn, 300);
|
||||
let libs = hybrid.get_libraries().await;
|
||||
writer.join().unwrap();
|
||||
|
||||
let libs = libs.expect("a slow cache must still answer when the server cannot");
|
||||
assert_eq!(libs.len(), 1);
|
||||
assert_eq!(libs[0].id, "lib-tv");
|
||||
}
|
||||
|
||||
/// Search and favourites read only the cache — there is no server to fall
|
||||
/// back from — so a 100 ms deadline on them just fails them whenever the
|
||||
/// database is busy, online or not.
|
||||
///
|
||||
/// TRACES: UR-002 | DR-294 | UT-263
|
||||
#[tokio::test]
|
||||
async fn test_cache_only_reads_wait_out_a_busy_database() {
|
||||
let conn = create_test_conn();
|
||||
let hybrid = hybrid_without_server(&conn);
|
||||
// Fixture sanity: with the database free both answer.
|
||||
hybrid
|
||||
.search_cache_only("anything", None)
|
||||
.await
|
||||
.expect("unlocked search");
|
||||
|
||||
let writer = hold_database(&conn, 300);
|
||||
let search = hybrid.search_cache_only("anything", None).await;
|
||||
writer.join().unwrap();
|
||||
search.expect("a busy database delays a cache-only search, it must not fail it");
|
||||
|
||||
let writer = hold_database(&conn, 300);
|
||||
let favourites = hybrid
|
||||
.get_favorites_cache_only(crate::repository::SearchScope::All, None)
|
||||
.await;
|
||||
writer.join().unwrap();
|
||||
favourites.expect("a busy database delays cache-only favourites, it must not fail them");
|
||||
}
|
||||
|
||||
fn create_test_item(id: &str, name: &str, parent_id: Option<&str>) -> MediaItem {
|
||||
MediaItem {
|
||||
id: id.to_string(),
|
||||
|
||||
Reference in New Issue
Block a user