Fix for offline mode
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user