diff --git a/.gitea/workflows/build-and-test.yml b/.gitea/workflows/build-and-test.yml index f8c57c3..1fbd334 100644 --- a/.gitea/workflows/build-and-test.yml +++ b/.gitea/workflows/build-and-test.yml @@ -60,16 +60,24 @@ jobs: cargo test cd .. - build: - name: Build Android APK + # Fast per-commit Android compile check. This does NOT build a shippable APK: + # the full signed release APK is built only on tag pushes by build-release.yml + # (which runs sync-android-sources.sh + signing). Running the full bundle here + # too would duplicate a ~15min build and, without the sync step, produced an + # unsigned APK missing our custom sources/icons/proguard rules anyway. + # `cargo check` for the Android target (~1min) catches Android-specific Rust + # breakage without linking, bundling, or signing. + android-check: + name: Android Compile Check runs-on: linux/amd64 needs: test container: image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest env: ANDROID_HOME: /opt/android-sdk - NDK_VERSION: 27.0.11902837 + ANDROID_SDK_ROOT: /opt/android-sdk NDK_HOME: /opt/android-sdk/ndk/27.0.11902837 + ANDROID_NDK_HOME: /opt/android-sdk/ndk/27.0.11902837 steps: - name: Checkout repository @@ -97,42 +105,13 @@ jobs: ${{ runner.os }}-bun- - name: Install dependencies - run: | - bun install + run: bun install - - name: Build frontend - run: bun run build - - - name: Ensure Android NDK - run: | - if [ ! -d "$NDK_HOME" ]; then - echo "NDK not found at $NDK_HOME, installing ndk;$NDK_VERSION" - yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --sdk_root="$ANDROID_HOME" "ndk;$NDK_VERSION" - fi - echo "Using NDK at $NDK_HOME" - ls "$NDK_HOME" - - - name: Initialize Android project + - name: Cargo check (aarch64-linux-android) run: | + TC="$NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin" + export CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER="$TC/aarch64-linux-android24-clang" + export CC_aarch64_linux_android="$TC/aarch64-linux-android24-clang" + export AR_aarch64_linux_android="$TC/llvm-ar" cd src-tauri - echo "" | bunx tauri android init - cd .. - - - name: Build Android APK - id: build - run: | - mkdir -p artifacts - bun run tauri android build --apk true --target aarch64 - - # Find the generated APK file - ARTIFACT=$(find src-tauri/gen/android/app/build/outputs/apk -name "*.apk" -type f -print -quit) - echo "artifact=${ARTIFACT}" >> $GITHUB_OUTPUT - echo "Found artifact: ${ARTIFACT}" - - - name: Upload build artifact - uses: actions/upload-artifact@v3 - with: - name: jellytau-apk - path: ${{ steps.build.outputs.artifact }} - retention-days: 30 - if-no-files-found: error + cargo check --target aarch64-linux-android --lib diff --git a/scripts/sync-android-sources.sh b/scripts/sync-android-sources.sh index 1f40be6..c1f3d4a 100755 --- a/scripts/sync-android-sources.sh +++ b/scripts/sync-android-sources.sh @@ -65,6 +65,11 @@ if [ -d "$RES_SRC" ]; then cp "$dir"/* "$RES_DST/$name/" echo " Copied res: $name" done + # We ship only the color adaptive icon (background + foreground). Drop any + # monochrome layer Tauri may generate: the themed-icon monochrome doesn't + # render well, and our adaptive-icon xml no longer references it, so a stray + # ic_launcher_monochrome.png would just be dead weight. + rm -f "$RES_DST"/mipmap-*/ic_launcher_monochrome.png fi echo "✓ Android sources synced successfully" diff --git a/src-tauri/android/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/src-tauri/android/src/main/res/mipmap-anydpi-v26/ic_launcher.xml index 345888d..fcd3a1e 100644 --- a/src-tauri/android/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/src-tauri/android/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -2,5 +2,4 @@ - \ No newline at end of file diff --git a/src-tauri/android/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png b/src-tauri/android/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png deleted file mode 100644 index a801e66..0000000 Binary files a/src-tauri/android/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png and /dev/null differ diff --git a/src-tauri/android/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png b/src-tauri/android/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png deleted file mode 100644 index 0af8348..0000000 Binary files a/src-tauri/android/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png and /dev/null differ diff --git a/src-tauri/android/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png b/src-tauri/android/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png deleted file mode 100644 index d13335b..0000000 Binary files a/src-tauri/android/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png and /dev/null differ diff --git a/src-tauri/android/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png b/src-tauri/android/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png deleted file mode 100644 index 97c21ad..0000000 Binary files a/src-tauri/android/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png and /dev/null differ diff --git a/src-tauri/android/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png b/src-tauri/android/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png deleted file mode 100644 index 61ad01d..0000000 Binary files a/src-tauri/android/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png and /dev/null differ diff --git a/src-tauri/src/commands/player/mod.rs b/src-tauri/src/commands/player/mod.rs index 9ea3c7a..8de8725 100644 --- a/src-tauri/src/commands/player/mod.rs +++ b/src-tauri/src/commands/player/mod.rs @@ -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 diff --git a/src-tauri/src/repository/hybrid.rs b/src-tauri/src/repository/hybrid.rs index ad1e3b8..36bff86 100644 --- a/src-tauri/src/repository/hybrid.rs +++ b/src-tauri/src/repository/hybrid.rs @@ -196,11 +196,41 @@ impl HybridRepository { #[async_trait] impl MediaRepository for HybridRepository { async fn get_libraries(&self) -> Result, 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) -> Result { @@ -377,20 +407,60 @@ impl MediaRepository for HybridRepository { } async fn get_genres(&self, parent_id: Option<&str>) -> Result, 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) -> Result { diff --git a/src-tauri/src/repository/offline.rs b/src-tauri/src/repository/offline.rs index f0eb33a..88e11d4 100644 --- a/src-tauri/src/repository/offline.rs +++ b/src-tauri/src/repository/offline.rs @@ -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 { + 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 { + 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)> = 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, 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, 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 = self + let genres: Vec = 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>(2)?.map(|c| c as u32), + }) + }) .await .map_err(|e| RepoError::Database { message: e })?; - // genre name -> album count - let mut counts: std::collections::HashMap = std::collections::HashMap::new(); - for genres_json in genres_rows { - if let Ok(genres_vec) = serde_json::from_str::>(&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 diff --git a/src-tauri/src/repository/online.rs b/src-tauri/src/repository/online.rs index 7f62a18..da7e7c9 100644 --- a/src-tauri/src/repository/online.rs +++ b/src-tauri/src/repository/online.rs @@ -144,6 +144,17 @@ impl OnlineRepository { /// Make authenticated GET request async fn get_json Deserialize<'de>>(&self, endpoint: &str) -> Result { + // 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 = 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 diff --git a/src-tauri/src/storage/mod.rs b/src-tauri/src/storage/mod.rs index 8cb5589..814d5bc 100644 --- a/src-tauri/src/storage/mod.rs +++ b/src-tauri/src/storage/mod.rs @@ -201,6 +201,7 @@ mod tests { "thumbnails", "playlists", "playlist_items", + "genres", ]; for table in expected_tables { diff --git a/src-tauri/src/storage/schema.rs b/src-tauri/src/storage/schema.rs index 237fbdf..5be635e 100644 --- a/src-tauri/src/storage/schema.rs +++ b/src-tauri/src/storage/schema.rs @@ -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); +"#; diff --git a/src-tauri/src/thumbnail/cache.rs b/src-tauri/src/thumbnail/cache.rs index 4980af7..a418873 100644 --- a/src-tauri/src/thumbnail/cache.rs +++ b/src-tauri/src/thumbnail/cache.rs @@ -64,7 +64,8 @@ impl ThumbnailCache { image_type: &str, tag: &str, ) -> Option { - 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, + 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 diff --git a/src/lib/components/library/GenericGenreBrowser.svelte b/src/lib/components/library/GenericGenreBrowser.svelte index 82232ea..257825f 100644 --- a/src/lib/components/library/GenericGenreBrowser.svelte +++ b/src/lib/components/library/GenericGenreBrowser.svelte @@ -1,5 +1,6 @@ diff --git a/src/lib/components/player/MiniPlayer.svelte b/src/lib/components/player/MiniPlayer.svelte index 34ed958..232b194 100644 --- a/src/lib/components/player/MiniPlayer.svelte +++ b/src/lib/components/player/MiniPlayer.svelte @@ -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} {/if} diff --git a/src/lib/services/favorites.ts b/src/lib/services/favorites.ts index c39c9f0..fa579e1 100644 --- a/src/lib/services/favorites.ts +++ b/src/lib/services/favorites.ts @@ -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; diff --git a/src/lib/stores/home.ts b/src/lib/stores/home.ts index 0386a2b..f67a137 100644 --- a/src/lib/stores/home.ts +++ b/src/lib/stores/home.ts @@ -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 = (i: number, fallback: T): T => + settled[i].status === "fulfilled" ? (settled[i] as PromiseFulfilledResult).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); diff --git a/src/routes/library/[id]/+page.svelte b/src/routes/library/[id]/+page.svelte index f34ec5c..a139836 100644 --- a/src/routes/library/[id]/+page.svelte +++ b/src/routes/library/[id]/+page.svelte @@ -511,7 +511,7 @@ {#if item.genres?.length}
- +
{/if}