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}