Fix for offline mode
This commit is contained in:
parent
c58cc0cf46
commit
2d141e5bf4
@ -1422,13 +1422,14 @@ pub async fn player_play_album_track(
|
||||
info!(" [{}] {} (ID: {})", idx, track.name, track.id);
|
||||
}
|
||||
|
||||
// Find the index of the requested track
|
||||
// Validate the requested track exists in the album (its position in the
|
||||
// final queue is computed after building, since offline tracks are skipped).
|
||||
info!("Looking for track_id: {}", request.track_id);
|
||||
let start_index = tracks.iter()
|
||||
let album_index = tracks.iter()
|
||||
.position(|t| t.id == request.track_id)
|
||||
.ok_or_else(|| format!("Track {} not found in album", request.track_id))?;
|
||||
|
||||
info!("Track {} is at index {} in album", request.track_id, start_index);
|
||||
info!("Track {} is at index {} in album", request.track_id, album_index);
|
||||
|
||||
// Convert tracks to MediaItems
|
||||
let mut media_items = Vec::new();
|
||||
@ -1443,13 +1444,21 @@ pub async fn player_play_album_track(
|
||||
jellyfin_item_id: Some(jellyfin_id.clone()),
|
||||
}
|
||||
} else {
|
||||
// Get stream URL from repository (works online/offline)
|
||||
let stream_url = repository.get_audio_stream_url(&track.id).await
|
||||
.map_err(|e| format!("Failed to get stream URL for {}: {}", track.name, e))?;
|
||||
|
||||
MediaSource::Remote {
|
||||
stream_url,
|
||||
jellyfin_item_id: jellyfin_id.clone(),
|
||||
// Non-downloaded track: needs a stream URL from the server. When the
|
||||
// server is unreachable (offline), skip this track rather than failing
|
||||
// the whole album — downloaded tracks must still be playable.
|
||||
match repository.get_audio_stream_url(&track.id).await {
|
||||
Ok(stream_url) => MediaSource::Remote {
|
||||
stream_url,
|
||||
jellyfin_item_id: jellyfin_id.clone(),
|
||||
},
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"[Player] Skipping track {} ({}) — no local download and stream URL unavailable: {}",
|
||||
track.name, track.id, e
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@ -1489,6 +1498,18 @@ pub async fn player_play_album_track(
|
||||
media_items.push(media_item);
|
||||
}
|
||||
|
||||
if media_items.is_empty() {
|
||||
return Err("No playable tracks available (offline and nothing downloaded)".to_string());
|
||||
}
|
||||
|
||||
// Tracks with no local download and no reachable server were skipped above,
|
||||
// so positions shifted. Re-locate the requested track in the built queue.
|
||||
// If the tapped track itself was skipped, fall back to the first item.
|
||||
let start_index = media_items
|
||||
.iter()
|
||||
.position(|item| item.id == request.track_id)
|
||||
.unwrap_or(0);
|
||||
|
||||
info!("Built queue with {} media items, starting at index {}", media_items.len(), start_index);
|
||||
|
||||
// Handle shuffle before setting queue
|
||||
|
||||
@ -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> {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(¶ms.join("&"));
|
||||
}
|
||||
url.push('?');
|
||||
url.push_str(¶ms.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
|
||||
|
||||
@ -201,6 +201,7 @@ mod tests {
|
||||
"thumbnails",
|
||||
"playlists",
|
||||
"playlist_items",
|
||||
"genres",
|
||||
];
|
||||
|
||||
for table in expected_tables {
|
||||
|
||||
@ -23,6 +23,7 @@ pub const MIGRATIONS: &[(&str, &str)] = &[
|
||||
("016_autoplay_max_episodes", MIGRATION_016),
|
||||
("017_downloads_resume_url", MIGRATION_017),
|
||||
("018_items_is_folder", MIGRATION_018),
|
||||
("019_genres_cache", MIGRATION_019),
|
||||
];
|
||||
|
||||
/// Initial schema migration
|
||||
@ -693,3 +694,23 @@ ALTER TABLE items ADD COLUMN is_folder INTEGER DEFAULT 0;
|
||||
-- Force re-fetch of all cached items so is_folder is populated from the server.
|
||||
UPDATE items SET synced_at = NULL;
|
||||
"#;
|
||||
|
||||
/// Migration to cache the full server genre catalog. Previously genres were
|
||||
/// derived on the fly from cached albums, which meant offline (and the hybrid
|
||||
/// cache-first race) only ever saw genres for the handful of locally-cached
|
||||
/// albums — collapsing the variety on the music/TV/movie landing pages. This
|
||||
/// table stores the complete genre list per library so offline has the real
|
||||
/// catalog and cache-first routing returns the right thing.
|
||||
const MIGRATION_019: &str = r#"
|
||||
CREATE TABLE IF NOT EXISTS 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)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_genres_scope ON genres(server_id, library_id);
|
||||
"#;
|
||||
|
||||
@ -64,7 +64,8 @@ impl ThumbnailCache {
|
||||
image_type: &str,
|
||||
tag: &str,
|
||||
) -> Option<PathBuf> {
|
||||
let query = Query::with_params(
|
||||
// Primary lookup: exact (item_id, image_type, tag) match.
|
||||
let exact = Query::with_params(
|
||||
"SELECT file_path FROM thumbnails
|
||||
WHERE item_id = ? AND image_type = ? AND image_tag = ?",
|
||||
vec![
|
||||
@ -74,12 +75,60 @@ impl ThumbnailCache {
|
||||
],
|
||||
);
|
||||
|
||||
let path_str: String = db.query_optional(query, |row| row.get(0)).await.ok()??;
|
||||
let path = PathBuf::from(&path_str);
|
||||
if let Ok(Some(path_str)) = db.query_optional(exact, |row| row.get::<_, String>(0)).await {
|
||||
let path = PathBuf::from(&path_str);
|
||||
if path.exists() {
|
||||
self.touch(&db, item_id, image_type, Some(tag)).await;
|
||||
return Some(path);
|
||||
}
|
||||
// File gone — drop the stale row and fall through to the tag-agnostic
|
||||
// lookup below (another cached image for this item may still exist).
|
||||
let _ = db.execute(Query::with_params(
|
||||
"DELETE FROM thumbnails WHERE item_id = ? AND image_type = ? AND image_tag = ?",
|
||||
vec![
|
||||
QueryParam::String(item_id.to_string()),
|
||||
QueryParam::String(image_type.to_string()),
|
||||
QueryParam::String(tag.to_string()),
|
||||
],
|
||||
)).await;
|
||||
}
|
||||
|
||||
// Fallback: any cached image for this item + type, newest first. The
|
||||
// `image_tag` is a cache-busting version, and callers don't always pass
|
||||
// the same tag the image was cached under — e.g. the mini player asks for
|
||||
// the album image using the *track's* primary_image_tag. Ignoring the tag
|
||||
// here lets those still resolve offline instead of hitting the server.
|
||||
let any_tag = Query::with_params(
|
||||
"SELECT file_path FROM thumbnails
|
||||
WHERE item_id = ? AND image_type = ?
|
||||
ORDER BY cached_at DESC LIMIT 1",
|
||||
vec![
|
||||
QueryParam::String(item_id.to_string()),
|
||||
QueryParam::String(image_type.to_string()),
|
||||
],
|
||||
);
|
||||
|
||||
let path_str: String = db.query_optional(any_tag, |row| row.get(0)).await.ok()??;
|
||||
let path = PathBuf::from(&path_str);
|
||||
if path.exists() {
|
||||
// Update last_accessed for LRU tracking
|
||||
let update_query = Query::with_params(
|
||||
self.touch(&db, item_id, image_type, None).await;
|
||||
Some(path)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Update `last_accessed` for LRU tracking. When `tag` is `Some`, scope to
|
||||
/// that exact row; when `None`, touch every row for the item + type.
|
||||
async fn touch(
|
||||
&self,
|
||||
db: &Arc<RusqliteService>,
|
||||
item_id: &str,
|
||||
image_type: &str,
|
||||
tag: Option<&str>,
|
||||
) {
|
||||
let query = match tag {
|
||||
Some(tag) => Query::with_params(
|
||||
"UPDATE thumbnails SET last_accessed = CURRENT_TIMESTAMP
|
||||
WHERE item_id = ? AND image_type = ? AND image_tag = ?",
|
||||
vec![
|
||||
@ -87,23 +136,17 @@ impl ThumbnailCache {
|
||||
QueryParam::String(image_type.to_string()),
|
||||
QueryParam::String(tag.to_string()),
|
||||
],
|
||||
);
|
||||
let _ = db.execute(update_query).await;
|
||||
Some(path)
|
||||
} else {
|
||||
// Clean up stale database entry
|
||||
let delete_query = Query::with_params(
|
||||
"DELETE FROM thumbnails
|
||||
WHERE item_id = ? AND image_type = ? AND image_tag = ?",
|
||||
),
|
||||
None => Query::with_params(
|
||||
"UPDATE thumbnails SET last_accessed = CURRENT_TIMESTAMP
|
||||
WHERE item_id = ? AND image_type = ?",
|
||||
vec![
|
||||
QueryParam::String(item_id.to_string()),
|
||||
QueryParam::String(image_type.to_string()),
|
||||
QueryParam::String(tag.to_string()),
|
||||
],
|
||||
);
|
||||
let _ = db.execute(delete_query).await;
|
||||
None
|
||||
}
|
||||
),
|
||||
};
|
||||
let _ = db.execute(query).await;
|
||||
}
|
||||
|
||||
/// Save thumbnail to cache
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { page } from "$app/stores";
|
||||
import { truncateMiddle } from "$lib/utils/truncateMiddle";
|
||||
import { goto } from "$app/navigation";
|
||||
import { navigateBack } from "$lib/utils/navigation";
|
||||
@ -53,6 +54,16 @@
|
||||
|
||||
onMount(async () => {
|
||||
await loadGenres();
|
||||
// Auto-select a genre when linked with ?genre=<name> (e.g. from a genre tag)
|
||||
const requestedGenre = $page.url.searchParams.get("genre");
|
||||
if (requestedGenre) {
|
||||
const match = genres.find(
|
||||
(g) => g.name.toLowerCase() === requestedGenre.toLowerCase(),
|
||||
);
|
||||
if (match) {
|
||||
await loadGenreItems(match);
|
||||
}
|
||||
}
|
||||
markLoaded();
|
||||
});
|
||||
|
||||
|
||||
@ -5,14 +5,34 @@
|
||||
genres: string[];
|
||||
maxShow?: number; // Default: unlimited
|
||||
clickable?: boolean; // Default: true
|
||||
itemType?: string; // Determines which genre browse page to open
|
||||
}
|
||||
|
||||
let {
|
||||
genres,
|
||||
maxShow,
|
||||
clickable = true
|
||||
clickable = true,
|
||||
itemType
|
||||
}: Props = $props();
|
||||
|
||||
// Map the item type to its genre-browse route
|
||||
function genreBasePath(type: string | undefined): string {
|
||||
switch (type) {
|
||||
case "MusicAlbum":
|
||||
case "MusicArtist":
|
||||
case "Audio":
|
||||
return "/library/music/genres";
|
||||
case "Series":
|
||||
case "Season":
|
||||
case "Episode":
|
||||
return "/library/shows/genres";
|
||||
case "Movie":
|
||||
return "/library/movies/genres";
|
||||
default:
|
||||
return "/library/movies/genres";
|
||||
}
|
||||
}
|
||||
|
||||
const displayGenres = $derived(
|
||||
maxShow ? genres.slice(0, maxShow) : genres
|
||||
);
|
||||
@ -23,9 +43,7 @@
|
||||
|
||||
function handleGenreClick(genre: string) {
|
||||
if (clickable) {
|
||||
// Navigate to genre browse page
|
||||
// For now, we'll use a simple navigation - could be enhanced with a proper genre browse page
|
||||
goto(`/search?genre=${encodeURIComponent(genre)}`);
|
||||
goto(`${genreBasePath(itemType)}?genre=${encodeURIComponent(genre)}`);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -19,6 +19,7 @@
|
||||
import { goto } from "$app/navigation";
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import { commands } from "$lib/api/bindings";
|
||||
import {
|
||||
mergedMedia,
|
||||
mergedIsPlaying,
|
||||
@ -70,6 +71,27 @@
|
||||
// In remote mode, this automatically uses the remote session's nowPlayingItem
|
||||
const displayMedia = $derived($mergedMedia || $currentQueueItem);
|
||||
const displayIsPlaying = $derived($mergedIsPlaying);
|
||||
|
||||
// The player's MediaItem doesn't carry favorite state, so read it from the
|
||||
// local user_data cache (offline-safe — same source the optimistic toggle
|
||||
// writes to). Re-runs whenever the current track changes.
|
||||
let isFavorite = $state(false);
|
||||
let favoriteLoadedFor = "";
|
||||
$effect(() => {
|
||||
const id = displayMedia?.id;
|
||||
if (!id || id === favoriteLoadedFor) return;
|
||||
favoriteLoadedFor = id;
|
||||
isFavorite = false;
|
||||
const userId = auth.getUserId();
|
||||
if (!userId) return;
|
||||
commands
|
||||
.storageGetPlaybackProgress(userId, id)
|
||||
.then((p) => {
|
||||
// Guard against a race if the track changed while awaiting.
|
||||
if (displayMedia?.id === id) isFavorite = p?.isFavorite ?? false;
|
||||
})
|
||||
.catch(() => {});
|
||||
});
|
||||
const displayPosition = $derived($mergedPosition);
|
||||
const displayDuration = $derived($mergedDuration);
|
||||
|
||||
@ -358,7 +380,7 @@
|
||||
{#if displayMedia}
|
||||
<FavoriteButton
|
||||
itemId={displayMedia?.id ?? ""}
|
||||
isFavorite={displayMedia?.userData?.isFavorite ?? false}
|
||||
bind:isFavorite
|
||||
size="sm"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
// Favorites service - Handles toggling favorite status with optimistic updates
|
||||
// TRACES: UR-017 | DR-021
|
||||
|
||||
import { get } from "svelte/store";
|
||||
import { commands } from "$lib/api/bindings";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import { isConnected } from "$lib/stores/connectivity";
|
||||
|
||||
/**
|
||||
* Toggle the favorite status of an item.
|
||||
@ -31,21 +33,31 @@ export async function toggleFavorite(
|
||||
// 1. Update local database first (optimistic update)
|
||||
await commands.storageToggleFavorite(userId, itemId, newIsFavorite);
|
||||
|
||||
// 2. Sync to Jellyfin server
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
if (newIsFavorite) {
|
||||
await repo.markFavorite(itemId);
|
||||
} else {
|
||||
await repo.unmarkFavorite(itemId);
|
||||
}
|
||||
// 2. Sync to Jellyfin server.
|
||||
//
|
||||
// Only attempt this when we're actually connected. When offline, the server
|
||||
// call can hang on a long network timeout rather than failing fast — which
|
||||
// blocks the caller (and leaves the favorite button greyed out with a wait
|
||||
// cursor) until the request finally gives up, effectively only recovering
|
||||
// once we're back online. The local DB write above keeps the pending_sync
|
||||
// flag set, so the change still syncs later; we just don't block the UI on
|
||||
// an unreachable server here.
|
||||
if (get(isConnected)) {
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
if (newIsFavorite) {
|
||||
await repo.markFavorite(itemId);
|
||||
} else {
|
||||
await repo.unmarkFavorite(itemId);
|
||||
}
|
||||
|
||||
// 3. Mark as synced
|
||||
await commands.storageMarkSynced(userId, itemId);
|
||||
} catch (error) {
|
||||
console.error("Failed to sync favorite to server:", error);
|
||||
// Favorite is stored locally and will be synced later
|
||||
// via sync queue (when implemented)
|
||||
// 3. Mark as synced
|
||||
await commands.storageMarkSynced(userId, itemId);
|
||||
} catch (error) {
|
||||
console.error("Failed to sync favorite to server:", error);
|
||||
// Favorite is stored locally and will be synced later
|
||||
// via sync queue (when implemented)
|
||||
}
|
||||
}
|
||||
|
||||
return newIsFavorite;
|
||||
|
||||
@ -36,7 +36,10 @@ function createHomeStore() {
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
|
||||
const [resume, nextUp, latest, recentAudio, resumeMovies] = await Promise.all([
|
||||
// Use allSettled so one failing section (e.g. Next Up is online-only and
|
||||
// rejects offline) doesn't wipe out the whole homepage. Each section falls
|
||||
// back to an empty list; cached sections (resume/latest/recent) still show.
|
||||
const settled = await Promise.allSettled([
|
||||
repo.getResumeItems(undefined, 12),
|
||||
repo.getNextUpEpisodes(undefined, 12),
|
||||
repo.getLatestItems("", 16),
|
||||
@ -44,6 +47,15 @@ function createHomeStore() {
|
||||
repo.getResumeMovies(12),
|
||||
]);
|
||||
|
||||
const valueOr = <T>(i: number, fallback: T): T =>
|
||||
settled[i].status === "fulfilled" ? (settled[i] as PromiseFulfilledResult<T>).value : fallback;
|
||||
|
||||
const resume = valueOr(0, [] as typeof initialState.resumeItems);
|
||||
const nextUp = valueOr(1, [] as typeof initialState.nextUpItems);
|
||||
const latest = valueOr(2, [] as typeof initialState.latestItems);
|
||||
const recentAudio = valueOr(3, [] as typeof initialState.recentlyPlayedAudio);
|
||||
const resumeMovies = valueOr(4, [] as typeof initialState.resumeMovies);
|
||||
|
||||
// Use resume items or latest as hero items
|
||||
const hero = resume.length >= 3 ? resume.slice(0, 5) : latest.slice(0, 5);
|
||||
|
||||
|
||||
@ -511,7 +511,7 @@
|
||||
<!-- Genre Tags -->
|
||||
{#if item.genres?.length}
|
||||
<div>
|
||||
<GenreTags genres={item.genres ?? undefined} maxShow={6} />
|
||||
<GenreTags genres={item.genres ?? undefined} maxShow={6} itemType={item.type} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user