Fix for offline mode
🏗️ Build and Test JellyTau / Run Tests (pull_request) Successful in 4m21s
Traceability Validation / Check Requirement Traces (pull_request) Successful in 20s
🏗️ Build and Test JellyTau / Android Compile Check (pull_request) Successful in 4m54s

This commit is contained in:
2026-07-03 19:37:34 +02:00
parent c58cc0cf46
commit 2d141e5bf4
13 changed files with 790 additions and 143 deletions
+84 -14
View File
@@ -196,11 +196,41 @@ impl HybridRepository {
#[async_trait]
impl MediaRepository for HybridRepository {
async fn get_libraries(&self) -> Result<Vec<Library>, RepoError> {
// Libraries change infrequently, try cache first with fast timeout
let cache_future = self.cache_with_timeout(self.offline.get_libraries());
let server_future = self.online.get_libraries();
// 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;
self.parallel_race(cache_future, server_future).await
if let Ok(libs) = &cache_result {
if libs.has_content() {
debug!("[HybridRepo] Cache hit for libraries, returning immediately");
let online = Arc::clone(&self.online);
let offline = Arc::clone(&self.offline);
tokio::spawn(async move {
if let Ok(server_libs) = online.get_libraries().await {
if !server_libs.is_empty() {
if let Err(e) = offline.save_libraries_to_cache(&server_libs).await {
warn!("[HybridRepo] Background library cache update failed: {:?}", e);
}
}
}
});
return cache_result;
}
}
// Cache miss — fetch from server and persist for offline use.
match self.online.get_libraries().await {
Ok(server_libs) => {
if !server_libs.is_empty() {
if let Err(e) = self.offline.save_libraries_to_cache(&server_libs).await {
warn!("[HybridRepo] Failed to cache {} libraries: {:?}", server_libs.len(), e);
}
}
Ok(server_libs)
}
Err(e) => cache_result.or(Err(e)),
}
}
async fn get_items(&self, parent_id: &str, options: Option<GetItemsOptions>) -> Result<SearchResult, RepoError> {
@@ -377,20 +407,60 @@ impl MediaRepository for HybridRepository {
}
async fn get_genres(&self, parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
let offline = Arc::clone(&self.offline);
let online = Arc::clone(&self.online);
// Cache-first (100ms). On a cache hit, refresh the cached genre catalog
// from the server in the background. On a miss, fetch from the server and
// persist so the full genre list is available offline. Mirrors
// get_libraries — NOT parallel_race, whose "any non-empty cache wins"
// rule would pin genres to whatever sparse set the local albums yield.
let parent_id_str = parent_id.map(|s| s.to_string());
let parent_id_clone = parent_id_str.clone();
let cache_future = self.cache_with_timeout(async move {
offline.get_genres(parent_id_str.as_deref()).await
});
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 server_future = async move {
online.get_genres(parent_id_clone.as_deref()).await
};
if let Ok(genres) = &cache_result {
if genres.has_content() {
debug!("[HybridRepo] Cache hit for genres, returning immediately");
let online = Arc::clone(&self.online);
let offline = Arc::clone(&self.offline);
let pid = parent_id_str.clone();
tokio::spawn(async move {
if let Ok(server_genres) = online.get_genres(pid.as_deref()).await {
if !server_genres.is_empty() {
if let Err(e) =
offline.save_genres_to_cache(pid.as_deref(), &server_genres).await
{
warn!("[HybridRepo] Background genre cache update failed: {:?}", e);
}
}
}
});
return cache_result;
}
}
self.parallel_race(cache_future, server_future).await
// Cache miss — fetch from server and persist for offline use.
match self.online.get_genres(parent_id_str.as_deref()).await {
Ok(server_genres) => {
if !server_genres.is_empty() {
if let Err(e) = self
.offline
.save_genres_to_cache(parent_id_str.as_deref(), &server_genres)
.await
{
warn!(
"[HybridRepo] Failed to cache {} genres: {:?}",
server_genres.len(),
e
);
}
}
Ok(server_genres)
}
Err(e) => cache_result.or(Err(e)),
}
}
async fn search(&self, query: &str, options: Option<SearchOptions>) -> Result<SearchResult, RepoError> {
+361 -72
View File
@@ -359,6 +359,94 @@ impl OfflineRepository {
Ok(count)
}
/// Cache the library (view) list from the server into the local database.
/// Called by HybridRepository after a successful online fetch so the list is
/// available offline. Without this, the `libraries` table stays empty and
/// offline startup shows no libraries at all.
pub async fn save_libraries_to_cache(&self, libraries: &[Library]) -> Result<usize, RepoError> {
if libraries.is_empty() {
return Ok(0);
}
let mut count = 0;
for (idx, lib) in libraries.iter().enumerate() {
let query = Query::with_params(
"INSERT OR REPLACE INTO libraries (id, server_id, name, collection_type, image_tag, sort_order, synced_at)
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)",
vec![
QueryParam::String(lib.id.clone()),
QueryParam::String(self.server_id.clone()),
QueryParam::String(lib.name.clone()),
QueryParam::String(lib.collection_type.clone()),
lib.image_tag.clone().map(QueryParam::String).unwrap_or(QueryParam::Null),
QueryParam::Int(idx as i32),
],
);
self.db_service.execute(query).await
.map_err(|e| RepoError::Database { message: e })?;
count += 1;
}
Ok(count)
}
/// Cache the full server genre catalog for a library, so offline (and the
/// hybrid cache-first race) can return the complete list instead of only the
/// genres derivable from locally-cached albums. Replaces the scope's rows
/// wholesale so genres removed on the server don't linger.
pub async fn save_genres_to_cache(
&self,
parent_id: Option<&str>,
genres: &[Genre],
) -> Result<usize, RepoError> {
if genres.is_empty() {
return Ok(0);
}
// library_id is part of the primary key; NULL keys don't de-dupe in
// SQLite, so store the "no library" scope as an empty string.
let library_id = parent_id.unwrap_or("").to_string();
let server_id = self.server_id.clone();
let genres: Vec<(String, String, Option<u32>)> = genres
.iter()
.map(|g| (g.id.clone(), g.name.clone(), g.album_count))
.collect();
let saved = genres.len();
self.db_service
.transaction(move |tx| {
use crate::storage::db_service::{Query, QueryParam};
// Clear the scope's existing genres, then re-insert the fresh set.
tx.execute(Query::with_params(
"DELETE FROM genres WHERE server_id = ? AND library_id = ?",
vec![
QueryParam::String(server_id.clone()),
QueryParam::String(library_id.clone()),
],
))?;
for (id, name, album_count) in &genres {
tx.execute(Query::with_params(
"INSERT OR REPLACE INTO genres (id, server_id, library_id, name, album_count, synced_at)
VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)",
vec![
QueryParam::String(id.clone()),
QueryParam::String(server_id.clone()),
QueryParam::String(library_id.clone()),
QueryParam::String(name.clone()),
album_count.map(|c| QueryParam::Int(c as i32)).unwrap_or(QueryParam::Null),
],
))?;
}
Ok(())
})
.await
.map_err(|e| RepoError::Database { message: e })?;
Ok(saved)
}
/// Cache playlist items from server into local database
/// Called by HybridRepository after fetching from online
pub async fn save_playlist_items_to_cache(
@@ -413,26 +501,17 @@ impl OfflineRepository {
#[async_trait]
impl MediaRepository for OfflineRepository {
async fn get_libraries(&self) -> Result<Vec<Library>, RepoError> {
// Only show libraries that have downloaded content
// Check both direct children and nested children (e.g., albums inside library)
// Return every cached library for this server. We deliberately do NOT
// gate on `items.library_id` here: that column is not populated in the
// cache (the Jellyfin client doesn't parse it), so the old
// `INNER JOIN items i ON i.library_id = l.id` matched nothing and left
// offline startup with zero libraries. Navigating into a library still
// filters to downloaded content via get_items, so listing all cached
// libraries is correct — it's the "local first" list the UI browses.
let query = Query::with_params(
"SELECT DISTINCT l.id, l.name, l.collection_type, l.image_tag
"SELECT l.id, l.name, l.collection_type, l.image_tag
FROM libraries l
INNER JOIN items i ON i.library_id = l.id
WHERE l.server_id = ?
AND (
-- Direct playable items with downloads
(i.item_type IN ('Audio', 'Movie', 'Episode')
AND EXISTS (SELECT 1 FROM downloads d WHERE d.item_id = i.id AND d.status = 'completed'))
OR
-- Container items with downloaded children
(i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
AND EXISTS (
SELECT 1 FROM items children
INNER JOIN downloads d ON children.id = d.item_id
WHERE children.parent_id = i.id AND d.status = 'completed'
))
)
ORDER BY l.sort_order ASC, l.name ASC",
vec![QueryParam::String(self.server_id.clone())],
);
@@ -494,7 +573,7 @@ impl MediaRepository for OfflineRepository {
-- Containers with downloaded children
SELECT DISTINCT i.id
FROM items i
INNER JOIN items children ON children.parent_id = i.id
INNER JOIN items children ON (children.parent_id = i.id OR children.album_id = i.id OR children.season_id = i.id OR children.series_id = i.id)
INNER JOIN downloads d ON children.id = d.item_id
WHERE d.status = 'completed'
AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
@@ -513,17 +592,38 @@ impl MediaRepository for OfflineRepository {
i.parent_index_number, i.is_folder, i.premiere_date
FROM items i
INNER JOIN available_items ai ON i.id = ai.id
WHERE i.server_id = ? AND i.parent_id = ?{}
WHERE i.server_id = ?
AND (
i.parent_id = ? OR i.album_id = ? OR i.season_id = ? OR i.series_id = ?
-- When the requested parent is a LIBRARY, there is no per-item
-- link back to it (library_id/parent_id are NULL in the cache),
-- so match every item on the server and let the type filter
-- (e.g. MusicAlbum / Movie / Series) narrow it. This is what
-- makes library landing pages show albums/movies/shows offline.
OR EXISTS (
SELECT 1 FROM libraries l
WHERE l.id = ? AND l.server_id = i.server_id
)
){}
ORDER BY {}
LIMIT {} OFFSET {}",
type_filter, order_by, limit, start_index
);
// The requested id is compared against every hierarchy-linkage column
// because `parent_id` is not populated for cached items — music tracks
// link to their album via `album_id`, episodes to their season/series
// via `season_id`/`series_id`, and a library parent matches via the
// `libraries` EXISTS clause. See [[offline-libraries-never-cached]].
let query = Query::with_params(
sql,
vec![
QueryParam::String(self.server_id.clone()),
QueryParam::String(parent_id.to_string()),
QueryParam::String(parent_id.to_string()), // i.parent_id = ?
QueryParam::String(parent_id.to_string()), // i.album_id = ?
QueryParam::String(parent_id.to_string()), // i.season_id = ?
QueryParam::String(parent_id.to_string()), // i.series_id = ?
QueryParam::String(parent_id.to_string()), // libraries.id = ?
],
);
@@ -566,7 +666,7 @@ impl MediaRepository for OfflineRepository {
-- Containers with downloaded children
SELECT DISTINCT i.id
FROM items i
INNER JOIN items children ON children.parent_id = i.id
INNER JOIN items children ON (children.parent_id = i.id OR children.album_id = i.id OR children.season_id = i.id OR children.series_id = i.id)
INNER JOIN downloads d ON children.id = d.item_id
WHERE d.status = 'completed'
AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
@@ -611,7 +711,7 @@ impl MediaRepository for OfflineRepository {
-- Containers with downloaded children
SELECT DISTINCT i.id
FROM items i
INNER JOIN items children ON children.parent_id = i.id
INNER JOIN items children ON (children.parent_id = i.id OR children.album_id = i.id OR children.season_id = i.id OR children.series_id = i.id)
INNER JOIN downloads d ON children.id = d.item_id
WHERE d.status = 'completed'
AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
@@ -744,7 +844,7 @@ impl MediaRepository for OfflineRepository {
-- Containers with downloaded children (Albums)
SELECT DISTINCT i.id
FROM items i
INNER JOIN items children ON children.parent_id = i.id
INNER JOIN items children ON (children.parent_id = i.id OR children.album_id = i.id OR children.season_id = i.id OR children.series_id = i.id)
INNER JOIN downloads d ON children.id = d.item_id
WHERE d.status = 'completed'
AND i.item_type = 'MusicAlbum'
@@ -849,59 +949,32 @@ impl MediaRepository for OfflineRepository {
}
async fn get_genres(&self, parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
// Derive genres from cached albums, tallying how many albums carry each
// so the frontend can rank by popularity. We scope to MusicAlbum (genres
// power the music landing) and read every matching row — NOT DISTINCT —
// so the per-genre counts are real. Genres are stored as a JSON array
// string per item.
let (sql, params) = if let Some(pid) = parent_id {
(
"SELECT genres FROM items WHERE server_id = ? AND library_id = ? \
AND item_type = 'MusicAlbum' AND genres IS NOT NULL",
vec![
QueryParam::String(self.server_id.clone()),
QueryParam::String(pid.to_string()),
],
)
} else {
(
"SELECT genres FROM items WHERE server_id = ? \
AND item_type = 'MusicAlbum' AND genres IS NOT NULL",
vec![QueryParam::String(self.server_id.clone())],
)
};
// Read the cached server genre catalog (populated by the hybrid repo via
// save_genres_to_cache). This is the FULL genre list for the library, not
// just the genres derivable from locally-cached albums — so offline keeps
// the same variety the server has. library_id NULL is stored as ''.
let library_id = parent_id.unwrap_or("").to_string();
let query = Query::with_params(sql, params);
let query = Query::with_params(
"SELECT id, name, album_count FROM genres WHERE server_id = ? AND library_id = ?",
vec![
QueryParam::String(self.server_id.clone()),
QueryParam::String(library_id),
],
);
let genres_rows: Vec<String> = self
let genres: Vec<Genre> = self
.db_service
.query_many(query, |row| row.get(0))
.query_many(query, |row| {
Ok(Genre {
id: row.get(0)?,
name: row.get(1)?,
album_count: row.get::<_, Option<i64>>(2)?.map(|c| c as u32),
})
})
.await
.map_err(|e| RepoError::Database { message: e })?;
// genre name -> album count
let mut counts: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
for genres_json in genres_rows {
if let Ok(genres_vec) = serde_json::from_str::<Vec<String>>(&genres_json) {
// De-dupe within one album so a genre listed twice counts once.
let mut seen = std::collections::HashSet::new();
for genre in genres_vec {
if seen.insert(genre.clone()) {
*counts.entry(genre).or_insert(0) += 1;
}
}
}
}
let genres = counts
.into_iter()
.map(|(name, count)| Genre {
id: name.clone(),
name,
album_count: Some(count),
})
.collect();
Ok(genres)
}
@@ -943,7 +1016,7 @@ impl MediaRepository for OfflineRepository {
-- Containers with downloaded children
SELECT DISTINCT i.id
FROM items i
INNER JOIN items children ON children.parent_id = i.id
INNER JOIN items children ON (children.parent_id = i.id OR children.album_id = i.id OR children.season_id = i.id OR children.series_id = i.id)
INNER JOIN downloads d ON children.id = d.item_id
WHERE d.status = 'completed'
AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
@@ -1157,7 +1230,7 @@ impl MediaRepository for OfflineRepository {
-- Containers with downloaded children
SELECT DISTINCT i.id
FROM items i
INNER JOIN items children ON children.parent_id = i.id
INNER JOIN items children ON (children.parent_id = i.id OR children.album_id = i.id OR children.season_id = i.id OR children.series_id = i.id)
INNER JOIN downloads d ON children.id = d.item_id
WHERE d.status = 'completed'
AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
@@ -1541,6 +1614,32 @@ mod tests {
);
CREATE INDEX idx_playlist_items_playlist ON playlist_items(playlist_id, sort_order);
CREATE TABLE downloads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
item_id TEXT NOT NULL,
status TEXT NOT NULL
);
CREATE TABLE libraries (
id TEXT PRIMARY KEY,
server_id TEXT NOT NULL,
name TEXT NOT NULL,
collection_type TEXT,
image_tag TEXT,
sort_order INTEGER DEFAULT 0,
synced_at TEXT
);
CREATE TABLE genres (
id TEXT NOT NULL,
server_id TEXT NOT NULL,
library_id TEXT,
name TEXT NOT NULL,
album_count INTEGER,
synced_at TEXT DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (server_id, library_id, name)
);
"#).unwrap();
// Insert a test server
@@ -1721,6 +1820,196 @@ mod tests {
assert_eq!(result.unwrap(), 3);
}
/// Regression: a MusicAlbum whose tracks link via `album_id` (and have a
/// NULL `parent_id`, which is how the Jellyfin cache actually stores them)
/// must be recognized as available offline when a track is downloaded.
///
/// Before the fix, `get_item(album_id)` only matched children by
/// `children.parent_id = i.id`, so a fully-downloaded album returned
/// NotFound offline and playback fell through to the (unreachable) server.
#[tokio::test]
async fn test_get_item_album_available_via_album_id_link() {
use crate::storage::db_service::DatabaseService;
let db_service = create_test_db();
for sql in [
// Album container (no children by parent_id).
"INSERT INTO items (id, server_id, name, item_type, album_id, parent_id) \
VALUES ('album-1', 'test-server', 'Hadestown', 'MusicAlbum', NULL, NULL)",
// Track linked to the album ONLY via album_id, parent_id NULL.
"INSERT INTO items (id, server_id, name, item_type, album_id, parent_id) \
VALUES ('track-1', 'test-server', 'Wait For Me', 'Audio', 'album-1', NULL)",
"INSERT INTO downloads (item_id, status) VALUES ('track-1', 'completed')",
] {
db_service.execute(Query::new(sql)).await.unwrap();
}
let repo = OfflineRepository::new(
db_service.clone(),
"test-server".to_string(),
"test-user".to_string(),
);
// The downloaded track itself resolves offline.
assert!(
repo.get_item("track-1").await.is_ok(),
"downloaded track should be available offline"
);
// The album must also resolve offline because it has a downloaded child
// linked by album_id (not parent_id).
let album = repo.get_item("album-1").await;
assert!(
album.is_ok(),
"album with an album_id-linked downloaded track should be available offline, got {:?}",
album.err()
);
assert_eq!(album.unwrap().id, "album-1");
// Browsing into the album (get_items) must return its tracks even though
// they link by album_id and have a NULL parent_id. This is the call
// play_album_track makes to build the queue.
let tracks = repo.get_items("album-1", None).await.unwrap();
assert_eq!(tracks.items.len(), 1, "get_items(album_id) should return the track");
assert_eq!(tracks.items[0].id, "track-1");
}
/// Regression: TV episodes link to their season/series via `season_id` /
/// `series_id` (NOT `parent_id`, which is NULL in the cache). A downloaded
/// episode must make both its Season and Series available offline, and
/// browsing either container must return the episode.
#[tokio::test]
async fn test_get_item_tv_available_via_season_series_link() {
use crate::storage::db_service::DatabaseService;
let db_service = create_test_db();
for sql in [
"INSERT INTO items (id, server_id, name, item_type, parent_id) \
VALUES ('series-1', 'test-server', 'Gilmore Girls', 'Series', NULL)",
"INSERT INTO items (id, server_id, name, item_type, series_id, parent_id) \
VALUES ('season-1', 'test-server', 'Season 1', 'Season', 'series-1', NULL)",
// Episode links to both season and series; parent_id NULL.
"INSERT INTO items (id, server_id, name, item_type, season_id, series_id, parent_id) \
VALUES ('ep-1', 'test-server', 'Pilot', 'Episode', 'season-1', 'series-1', NULL)",
"INSERT INTO downloads (item_id, status) VALUES ('ep-1', 'completed')",
] {
db_service.execute(Query::new(sql)).await.unwrap();
}
let repo = OfflineRepository::new(
db_service.clone(),
"test-server".to_string(),
"test-user".to_string(),
);
assert!(repo.get_item("ep-1").await.is_ok(), "downloaded episode available offline");
assert!(
repo.get_item("season-1").await.is_ok(),
"season with a season_id-linked downloaded episode should be available offline"
);
assert!(
repo.get_item("series-1").await.is_ok(),
"series with a series_id-linked downloaded episode should be available offline"
);
// Browsing the season returns the episode.
let season_items = repo.get_items("season-1", None).await.unwrap();
assert!(
season_items.items.iter().any(|i| i.id == "ep-1"),
"get_items(season_id) should return the episode"
);
// Browsing the series returns the episode (via series_id link).
let series_items = repo.get_items("series-1", None).await.unwrap();
assert!(
series_items.items.iter().any(|i| i.id == "ep-1"),
"get_items(series_id) should surface the downloaded episode"
);
}
/// Regression: offline startup must list cached libraries. Previously
/// `get_libraries` joined on `items.library_id` (always NULL in the cache),
/// so it returned nothing offline and the app showed no libraries at all.
/// The list must round-trip through `save_libraries_to_cache`.
#[tokio::test]
async fn test_libraries_cache_roundtrip_available_offline() {
let db_service = create_test_db();
let repo = OfflineRepository::new(
db_service.clone(),
"test-server".to_string(),
"test-user".to_string(),
);
// Empty cache → no libraries (this is the state that fell through to the
// server and hung offline).
assert!(repo.get_libraries().await.unwrap().is_empty());
// Simulate the online path persisting the server's library list.
let server_libs = vec![
Library { id: "music".into(), name: "Music".into(), collection_type: "music".into(), image_tag: None },
Library { id: "movies".into(), name: "Movies".into(), collection_type: "movies".into(), image_tag: Some("tag".into()) },
];
let saved = repo.save_libraries_to_cache(&server_libs).await.unwrap();
assert_eq!(saved, 2);
// Now offline get_libraries returns them without touching the server.
let offline_libs = repo.get_libraries().await.unwrap();
let names: Vec<&str> = offline_libs.iter().map(|l| l.name.as_str()).collect();
assert_eq!(names, vec!["Music", "Movies"], "cached libraries available offline in sort order");
// Re-saving is idempotent (INSERT OR REPLACE), not duplicating rows.
repo.save_libraries_to_cache(&server_libs).await.unwrap();
assert_eq!(repo.get_libraries().await.unwrap().len(), 2);
}
/// Regression: the music/TV/movie landing pages lost genre variety because
/// offline `get_genres` derived genres from cached albums only — so the
/// hybrid cache-first race pinned the list to whatever sparse set the local
/// albums yielded instead of the server's full catalog. The full genre list
/// must round-trip through `save_genres_to_cache` and come back scoped by
/// library.
#[tokio::test]
async fn test_genres_cache_roundtrip_scoped_by_library() {
let db_service = create_test_db();
let repo = OfflineRepository::new(
db_service.clone(),
"test-server".to_string(),
"test-user".to_string(),
);
// Empty cache → no genres.
assert!(repo.get_genres(Some("music-lib")).await.unwrap().is_empty());
// Simulate the online path persisting the server's full genre catalog.
let server_genres = vec![
Genre { id: "g1".into(), name: "Rock".into(), album_count: Some(42) },
Genre { id: "g2".into(), name: "Jazz".into(), album_count: Some(17) },
Genre { id: "g3".into(), name: "Ambient".into(), album_count: None },
];
let saved = repo.save_genres_to_cache(Some("music-lib"), &server_genres).await.unwrap();
assert_eq!(saved, 3);
// Offline get_genres returns the full set for that library, counts intact.
let mut offline_genres = repo.get_genres(Some("music-lib")).await.unwrap();
offline_genres.sort_by(|a, b| a.name.cmp(&b.name));
let names: Vec<&str> = offline_genres.iter().map(|g| g.name.as_str()).collect();
assert_eq!(names, vec!["Ambient", "Jazz", "Rock"]);
let rock = offline_genres.iter().find(|g| g.name == "Rock").unwrap();
assert_eq!(rock.album_count, Some(42));
// Genres are scoped: a different library sees nothing.
assert!(repo.get_genres(Some("other-lib")).await.unwrap().is_empty());
// Re-saving replaces the scope's rows (server removed "Jazz").
let updated = vec![
Genre { id: "g1".into(), name: "Rock".into(), album_count: Some(50) },
];
repo.save_genres_to_cache(Some("music-lib"), &updated).await.unwrap();
let after = repo.get_genres(Some("music-lib")).await.unwrap();
assert_eq!(after.len(), 1, "stale genres removed on refresh");
assert_eq!(after[0].album_count, Some(50), "counts updated on refresh");
}
// ===== Playlist Tests =====
/// Helper to seed items into the DB for playlist tests
+135 -8
View File
@@ -144,6 +144,17 @@ impl OnlineRepository {
/// Make authenticated GET request
async fn get_json<T: for<'de> Deserialize<'de>>(&self, endpoint: &str) -> Result<T, RepoError> {
// Fast-fail when connectivity is known-offline. Without this every request
// still runs the full HTTP retry/backoff cycle (~7s) before giving up,
// which stalls cache-miss paths and makes offline browsing feel janky.
// The offline recovery probe (connectivity monitor) flips us back to
// reachable the moment the server returns, so this never sticks.
if let Some(reporter) = &self.connectivity {
if !reporter.is_reachable().await {
return Err(RepoError::Offline);
}
}
let result = self.get_json_inner(endpoint).await;
self.report_outcome(&result).await;
result
@@ -1413,12 +1424,43 @@ impl MediaRepository for OnlineRepository {
quality: &str,
media_source_id: Option<&str>,
) -> String {
let mut url = format!("{}/Videos/{}/download", self.server_url, item_id);
// NOTE: Jellyfin's `/Videos/{id}/download` endpoint is not universally
// available (returns 404 on many server configs), which silently broke
// every movie/TV download. Use the progressive `stream.mp4` endpoint
// instead — it is always present and supports HTTP Range, which the
// download worker relies on for resume.
let mut url = format!("{}/Videos/{}/stream.mp4", self.server_url, item_id);
let mut params = vec![format!("api_key={}", self.access_token)];
// Add quality parameter if not "original"
if quality != "original" {
params.push(format!("quality={}", quality));
// Map the frontend quality preset to concrete transcode params. For
// "original" we request a direct static copy (no transcode) which is
// byte-range resumable; other presets ask the server to transcode.
match quality {
"high" => {
params.push("videoBitrate=8000000".to_string());
params.push("maxHeight=1080".to_string());
params.push("audioBitrate=384000".to_string());
params.push("videoCodec=h264".to_string());
params.push("audioCodec=aac".to_string());
}
"medium" => {
params.push("videoBitrate=4000000".to_string());
params.push("maxHeight=720".to_string());
params.push("audioBitrate=256000".to_string());
params.push("videoCodec=h264".to_string());
params.push("audioCodec=aac".to_string());
}
"low" => {
params.push("videoBitrate=1500000".to_string());
params.push("maxHeight=480".to_string());
params.push("audioBitrate=128000".to_string());
params.push("videoCodec=h264".to_string());
params.push("audioCodec=aac".to_string());
}
// "original" (and any unknown value) → direct, resumable copy.
_ => {
params.push("Static=true".to_string());
}
}
// Add media source ID if provided
@@ -1426,10 +1468,8 @@ impl MediaRepository for OnlineRepository {
params.push(format!("mediaSourceId={}", source_id));
}
if !params.is_empty() {
url.push('?');
url.push_str(&params.join("&"));
}
url.push('?');
url.push_str(&params.join("&"));
url
}
@@ -1759,6 +1799,25 @@ mod tests {
}
}
/// When connectivity is known-offline, `get_json` must fast-fail with
/// `RepoError::Offline` instead of running the full HTTP retry cycle (~7s).
/// This is what keeps offline browsing snappy. `test.server.com` is
/// unroutable, so if the guard were absent this would hang on retries; the
/// assertion returning promptly with `Offline` proves the short-circuit.
#[tokio::test]
async fn test_get_json_fast_fails_when_offline() {
let (repo, reporter) = create_test_repository_with_connectivity();
reporter.mark_unreachable_for_test().await;
assert!(!reporter.is_reachable().await, "precondition: offline");
let result: Result<serde_json::Value, RepoError> = repo.get_json("/System/Info").await;
assert!(
matches!(result, Err(RepoError::Offline)),
"known-offline get_json should return Offline immediately, got {:?}",
result
);
}
/// A network error routes through the debounced path. A single failure stays
/// online (debounce window not yet elapsed).
#[tokio::test]
@@ -1887,6 +1946,74 @@ mod tests {
assert_eq!(tags.primary(), None);
}
// ===== Video download URL (real impl) =====
//
// These exercise the PRODUCTION `OnlineRepository::get_video_download_url`,
// not a mock. A prior mock in online_integration_test.rs used the correct
// `stream.mp4` endpoint while the real impl shipped `/Videos/{id}/download`,
// which returns 404 on real servers and silently broke every movie/TV
// download. Assert the real builder targets the resumable stream endpoint.
//
// @req-test: DR-013 - Repository pattern for online/offline data access
#[test]
fn test_video_download_url_uses_stream_not_download_endpoint() {
let repo = create_test_repository();
let url = repo.get_video_download_url("item123", "original", None);
// Must NOT use the /download endpoint (404 on real servers).
assert!(
!url.contains("/download"),
"download URL must not use the broken /Videos/{{id}}/download endpoint: {url}"
);
// Must use the progressive, range-resumable stream endpoint.
assert!(
url.contains("/Videos/item123/stream.mp4"),
"download URL must target /Videos/{{id}}/stream.mp4: {url}"
);
assert!(url.contains("api_key=test-access-token"), "url: {url}");
}
#[test]
fn test_video_download_url_original_is_static_direct_copy() {
let repo = create_test_repository();
let url = repo.get_video_download_url("item123", "original", None);
// "original" must request a direct static copy (byte-range resumable),
// with no transcode params.
assert!(url.contains("Static=true"), "url: {url}");
assert!(!url.contains("videoBitrate"), "original must not transcode: {url}");
assert!(!url.contains("maxHeight"), "original must not transcode: {url}");
}
#[test]
fn test_video_download_url_quality_presets_transcode() {
let repo = create_test_repository();
for (quality, height) in [("high", "1080"), ("medium", "720"), ("low", "480")] {
let url = repo.get_video_download_url("item123", quality, None);
assert!(
url.contains("/Videos/item123/stream.mp4"),
"{quality} must use stream.mp4: {url}"
);
assert!(url.contains("videoBitrate="), "{quality} must set bitrate: {url}");
assert!(
url.contains(&format!("maxHeight={height}")),
"{quality} must cap height at {height}: {url}"
);
assert!(url.contains("videoCodec=h264"), "{quality}: {url}");
// Transcoded presets must not also ask for a static copy.
assert!(!url.contains("Static=true"), "{quality} must not be Static: {url}");
}
}
#[test]
fn test_video_download_url_passes_media_source_id() {
let repo = create_test_repository();
let url = repo.get_video_download_url("item123", "original", Some("src-42"));
assert!(url.contains("mediaSourceId=src-42"), "url: {url}");
}
#[test]
fn test_jellyfin_item_deserialize_with_image_tags() {
// Test full JellyfinItem deserialization with ImageTags