Compare commits

...

4 Commits

Author SHA1 Message Date
68c8602230 Merge pull request 'fix-launcher-offline-mode' (#9) from fix-android-launcher-icon-conflict into master
All checks were successful
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 5m18s
Traceability Validation / Check Requirement Traces (push) Successful in 21s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 4m25s
Reviewed-on: #9
2026-07-03 17:58:30 +00:00
2d141e5bf4 Fix for offline mode
All checks were successful
🏗️ Build and Test JellyTau / Run Tests (pull_request) Successful in 4m21s
Traceability Validation / Check Requirement Traces (pull_request) Successful in 20s
🏗️ Build and Test JellyTau / Android Compile Check (pull_request) Successful in 4m54s
2026-07-03 19:37:34 +02:00
c58cc0cf46 CI: replace broken per-commit Android APK build with a fast compile check
build-and-test.yml built a full APK on every master push without running
sync-android-sources.sh, so it used the wrong (Tauri-default) sources, was
unsigned, and duplicated the ~15min build that build-release.yml does properly
on tags. Replace it with cargo check --target aarch64-linux-android (~1min),
which catches Android Rust breakage without linking, bundling, or signing.
The signed release APK remains a tag-only artifact from build-release.yml.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 18:48:14 +02:00
8938e3fdba Android launcher: drop monochrome (themed) icon, keep color only
The monochrome adaptive-icon layer produced a poor themed-icon rendering.
Remove the <monochrome> reference from mipmap-anydpi-v26/ic_launcher.xml and
delete the ic_launcher_monochrome.png files so Android always uses the color
adaptive icon (background + foreground). sync-android-sources.sh also drops any
monochrome layer Tauri regenerates.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 18:47:50 +02:00
21 changed files with 813 additions and 183 deletions

View File

@ -60,16 +60,24 @@ jobs:
cargo test cargo test
cd .. cd ..
build: # Fast per-commit Android compile check. This does NOT build a shippable APK:
name: Build Android 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 runs-on: linux/amd64
needs: test needs: test
container: container:
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
env: env:
ANDROID_HOME: /opt/android-sdk 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 NDK_HOME: /opt/android-sdk/ndk/27.0.11902837
ANDROID_NDK_HOME: /opt/android-sdk/ndk/27.0.11902837
steps: steps:
- name: Checkout repository - name: Checkout repository
@ -97,42 +105,13 @@ jobs:
${{ runner.os }}-bun- ${{ runner.os }}-bun-
- name: Install dependencies - name: Install dependencies
run: | run: bun install
bun install
- name: Build frontend - name: Cargo check (aarch64-linux-android)
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
run: | 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 cd src-tauri
echo "" | bunx tauri android init cargo check --target aarch64-linux-android --lib
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

View File

@ -65,6 +65,11 @@ if [ -d "$RES_SRC" ]; then
cp "$dir"/* "$RES_DST/$name/" cp "$dir"/* "$RES_DST/$name/"
echo " Copied res: $name" echo " Copied res: $name"
done 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 fi
echo "✓ Android sources synced successfully" echo "✓ Android sources synced successfully"

View File

@ -2,5 +2,4 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@mipmap/ic_launcher_background"/> <background android:drawable="@mipmap/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/> <foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
</adaptive-icon> </adaptive-icon>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

View File

@ -1422,13 +1422,14 @@ pub async fn player_play_album_track(
info!(" [{}] {} (ID: {})", idx, track.name, track.id); 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); 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) .position(|t| t.id == request.track_id)
.ok_or_else(|| format!("Track {} not found in album", 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 // Convert tracks to MediaItems
let mut media_items = Vec::new(); let mut media_items = Vec::new();
@ -1443,13 +1444,21 @@ pub async fn player_play_album_track(
jellyfin_item_id: Some(jellyfin_id.clone()), jellyfin_item_id: Some(jellyfin_id.clone()),
} }
} else { } else {
// Get stream URL from repository (works online/offline) // Non-downloaded track: needs a stream URL from the server. When the
let stream_url = repository.get_audio_stream_url(&track.id).await // server is unreachable (offline), skip this track rather than failing
.map_err(|e| format!("Failed to get stream URL for {}: {}", track.name, e))?; // the whole album — downloaded tracks must still be playable.
match repository.get_audio_stream_url(&track.id).await {
MediaSource::Remote { Ok(stream_url) => MediaSource::Remote {
stream_url, stream_url,
jellyfin_item_id: jellyfin_id.clone(), 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); 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); info!("Built queue with {} media items, starting at index {}", media_items.len(), start_index);
// Handle shuffle before setting queue // Handle shuffle before setting queue

View File

@ -196,11 +196,41 @@ impl HybridRepository {
#[async_trait] #[async_trait]
impl MediaRepository for HybridRepository { impl MediaRepository for HybridRepository {
async fn get_libraries(&self) -> Result<Vec<Library>, RepoError> { async fn get_libraries(&self) -> Result<Vec<Library>, RepoError> {
// Libraries change infrequently, try cache first with fast timeout // Cache-first (100ms). On a cache hit, refresh the cache from the server
let cache_future = self.cache_with_timeout(self.offline.get_libraries()); // in the background. On a miss, fetch from the server and persist so the
let server_future = self.online.get_libraries(); // list is available on the next (possibly offline) startup.
let cache_result = self.cache_with_timeout(self.offline.get_libraries()).await;
self.parallel_race(cache_future, server_future).await if let Ok(libs) = &cache_result {
if libs.has_content() {
debug!("[HybridRepo] Cache hit for libraries, returning immediately");
let online = Arc::clone(&self.online);
let offline = Arc::clone(&self.offline);
tokio::spawn(async move {
if let Ok(server_libs) = online.get_libraries().await {
if !server_libs.is_empty() {
if let Err(e) = offline.save_libraries_to_cache(&server_libs).await {
warn!("[HybridRepo] Background library cache update failed: {:?}", e);
}
}
}
});
return cache_result;
}
}
// Cache miss — fetch from server and persist for offline use.
match self.online.get_libraries().await {
Ok(server_libs) => {
if !server_libs.is_empty() {
if let Err(e) = self.offline.save_libraries_to_cache(&server_libs).await {
warn!("[HybridRepo] Failed to cache {} libraries: {:?}", server_libs.len(), e);
}
}
Ok(server_libs)
}
Err(e) => cache_result.or(Err(e)),
}
} }
async fn get_items(&self, parent_id: &str, options: Option<GetItemsOptions>) -> Result<SearchResult, RepoError> { async fn get_items(&self, parent_id: &str, options: Option<GetItemsOptions>) -> Result<SearchResult, RepoError> {
@ -377,20 +407,60 @@ impl MediaRepository for HybridRepository {
} }
async fn get_genres(&self, parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> { async fn get_genres(&self, parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
let offline = Arc::clone(&self.offline); // Cache-first (100ms). On a cache hit, refresh the cached genre catalog
let online = Arc::clone(&self.online); // 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_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 { let cache_offline = Arc::clone(&self.offline);
offline.get_genres(parent_id_str.as_deref()).await 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;
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;
}
}
let server_future = async move { // Cache miss — fetch from server and persist for offline use.
online.get_genres(parent_id_clone.as_deref()).await match self.online.get_genres(parent_id_str.as_deref()).await {
}; Ok(server_genres) => {
if !server_genres.is_empty() {
self.parallel_race(cache_future, server_future).await if let Err(e) = self
.offline
.save_genres_to_cache(parent_id_str.as_deref(), &server_genres)
.await
{
warn!(
"[HybridRepo] Failed to cache {} genres: {:?}",
server_genres.len(),
e
);
}
}
Ok(server_genres)
}
Err(e) => cache_result.or(Err(e)),
}
} }
async fn search(&self, query: &str, options: Option<SearchOptions>) -> Result<SearchResult, RepoError> { async fn search(&self, query: &str, options: Option<SearchOptions>) -> Result<SearchResult, RepoError> {

View File

@ -359,6 +359,94 @@ impl OfflineRepository {
Ok(count) Ok(count)
} }
/// Cache the library (view) list from the server into the local database.
/// Called by HybridRepository after a successful online fetch so the list is
/// available offline. Without this, the `libraries` table stays empty and
/// offline startup shows no libraries at all.
pub async fn save_libraries_to_cache(&self, libraries: &[Library]) -> Result<usize, RepoError> {
if libraries.is_empty() {
return Ok(0);
}
let mut count = 0;
for (idx, lib) in libraries.iter().enumerate() {
let query = Query::with_params(
"INSERT OR REPLACE INTO libraries (id, server_id, name, collection_type, image_tag, sort_order, synced_at)
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)",
vec![
QueryParam::String(lib.id.clone()),
QueryParam::String(self.server_id.clone()),
QueryParam::String(lib.name.clone()),
QueryParam::String(lib.collection_type.clone()),
lib.image_tag.clone().map(QueryParam::String).unwrap_or(QueryParam::Null),
QueryParam::Int(idx as i32),
],
);
self.db_service.execute(query).await
.map_err(|e| RepoError::Database { message: e })?;
count += 1;
}
Ok(count)
}
/// Cache the full server genre catalog for a library, so offline (and the
/// hybrid cache-first race) can return the complete list instead of only the
/// genres derivable from locally-cached albums. Replaces the scope's rows
/// wholesale so genres removed on the server don't linger.
pub async fn save_genres_to_cache(
&self,
parent_id: Option<&str>,
genres: &[Genre],
) -> Result<usize, RepoError> {
if genres.is_empty() {
return Ok(0);
}
// library_id is part of the primary key; NULL keys don't de-dupe in
// SQLite, so store the "no library" scope as an empty string.
let library_id = parent_id.unwrap_or("").to_string();
let server_id = self.server_id.clone();
let genres: Vec<(String, String, Option<u32>)> = genres
.iter()
.map(|g| (g.id.clone(), g.name.clone(), g.album_count))
.collect();
let saved = genres.len();
self.db_service
.transaction(move |tx| {
use crate::storage::db_service::{Query, QueryParam};
// Clear the scope's existing genres, then re-insert the fresh set.
tx.execute(Query::with_params(
"DELETE FROM genres WHERE server_id = ? AND library_id = ?",
vec![
QueryParam::String(server_id.clone()),
QueryParam::String(library_id.clone()),
],
))?;
for (id, name, album_count) in &genres {
tx.execute(Query::with_params(
"INSERT OR REPLACE INTO genres (id, server_id, library_id, name, album_count, synced_at)
VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)",
vec![
QueryParam::String(id.clone()),
QueryParam::String(server_id.clone()),
QueryParam::String(library_id.clone()),
QueryParam::String(name.clone()),
album_count.map(|c| QueryParam::Int(c as i32)).unwrap_or(QueryParam::Null),
],
))?;
}
Ok(())
})
.await
.map_err(|e| RepoError::Database { message: e })?;
Ok(saved)
}
/// Cache playlist items from server into local database /// Cache playlist items from server into local database
/// Called by HybridRepository after fetching from online /// Called by HybridRepository after fetching from online
pub async fn save_playlist_items_to_cache( pub async fn save_playlist_items_to_cache(
@ -413,26 +501,17 @@ impl OfflineRepository {
#[async_trait] #[async_trait]
impl MediaRepository for OfflineRepository { impl MediaRepository for OfflineRepository {
async fn get_libraries(&self) -> Result<Vec<Library>, RepoError> { async fn get_libraries(&self) -> Result<Vec<Library>, RepoError> {
// Only show libraries that have downloaded content // Return every cached library for this server. We deliberately do NOT
// Check both direct children and nested children (e.g., albums inside library) // 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( 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 FROM libraries l
INNER JOIN items i ON i.library_id = l.id
WHERE l.server_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", ORDER BY l.sort_order ASC, l.name ASC",
vec![QueryParam::String(self.server_id.clone())], vec![QueryParam::String(self.server_id.clone())],
); );
@ -494,7 +573,7 @@ impl MediaRepository for OfflineRepository {
-- Containers with downloaded children -- Containers with downloaded children
SELECT DISTINCT i.id SELECT DISTINCT i.id
FROM items i 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 INNER JOIN downloads d ON children.id = d.item_id
WHERE d.status = 'completed' WHERE d.status = 'completed'
AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder') 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 i.parent_index_number, i.is_folder, i.premiere_date
FROM items i FROM items i
INNER JOIN available_items ai ON i.id = ai.id 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 {} ORDER BY {}
LIMIT {} OFFSET {}", LIMIT {} OFFSET {}",
type_filter, order_by, limit, start_index 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( let query = Query::with_params(
sql, sql,
vec![ vec![
QueryParam::String(self.server_id.clone()), 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 -- Containers with downloaded children
SELECT DISTINCT i.id SELECT DISTINCT i.id
FROM items i 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 INNER JOIN downloads d ON children.id = d.item_id
WHERE d.status = 'completed' WHERE d.status = 'completed'
AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder') AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
@ -611,7 +711,7 @@ impl MediaRepository for OfflineRepository {
-- Containers with downloaded children -- Containers with downloaded children
SELECT DISTINCT i.id SELECT DISTINCT i.id
FROM items i 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 INNER JOIN downloads d ON children.id = d.item_id
WHERE d.status = 'completed' WHERE d.status = 'completed'
AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder') AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
@ -744,7 +844,7 @@ impl MediaRepository for OfflineRepository {
-- Containers with downloaded children (Albums) -- Containers with downloaded children (Albums)
SELECT DISTINCT i.id SELECT DISTINCT i.id
FROM items i 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 INNER JOIN downloads d ON children.id = d.item_id
WHERE d.status = 'completed' WHERE d.status = 'completed'
AND i.item_type = 'MusicAlbum' AND i.item_type = 'MusicAlbum'
@ -849,59 +949,32 @@ impl MediaRepository for OfflineRepository {
} }
async fn get_genres(&self, parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> { async fn get_genres(&self, parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
// Derive genres from cached albums, tallying how many albums carry each // Read the cached server genre catalog (populated by the hybrid repo via
// so the frontend can rank by popularity. We scope to MusicAlbum (genres // save_genres_to_cache). This is the FULL genre list for the library, not
// power the music landing) and read every matching row — NOT DISTINCT — // just the genres derivable from locally-cached albums — so offline keeps
// so the per-genre counts are real. Genres are stored as a JSON array // the same variety the server has. library_id NULL is stored as ''.
// string per item. let library_id = parent_id.unwrap_or("").to_string();
let (sql, params) = if let Some(pid) = parent_id {
( let query = Query::with_params(
"SELECT genres FROM items WHERE server_id = ? AND library_id = ? \ "SELECT id, name, album_count FROM genres WHERE server_id = ? AND library_id = ?",
AND item_type = 'MusicAlbum' AND genres IS NOT NULL",
vec![ vec![
QueryParam::String(self.server_id.clone()), QueryParam::String(self.server_id.clone()),
QueryParam::String(pid.to_string()), QueryParam::String(library_id),
], ],
) );
} else {
(
"SELECT genres FROM items WHERE server_id = ? \
AND item_type = 'MusicAlbum' AND genres IS NOT NULL",
vec![QueryParam::String(self.server_id.clone())],
)
};
let query = Query::with_params(sql, params); let genres: Vec<Genre> = self
let genres_rows: Vec<String> = self
.db_service .db_service
.query_many(query, |row| row.get(0)) .query_many(query, |row| {
Ok(Genre {
id: row.get(0)?,
name: row.get(1)?,
album_count: row.get::<_, Option<i64>>(2)?.map(|c| c as u32),
})
})
.await .await
.map_err(|e| RepoError::Database { message: e })?; .map_err(|e| RepoError::Database { message: e })?;
// genre name -> album count
let mut counts: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
for genres_json in genres_rows {
if let Ok(genres_vec) = serde_json::from_str::<Vec<String>>(&genres_json) {
// De-dupe within one album so a genre listed twice counts once.
let mut seen = std::collections::HashSet::new();
for genre in genres_vec {
if seen.insert(genre.clone()) {
*counts.entry(genre).or_insert(0) += 1;
}
}
}
}
let genres = counts
.into_iter()
.map(|(name, count)| Genre {
id: name.clone(),
name,
album_count: Some(count),
})
.collect();
Ok(genres) Ok(genres)
} }
@ -943,7 +1016,7 @@ impl MediaRepository for OfflineRepository {
-- Containers with downloaded children -- Containers with downloaded children
SELECT DISTINCT i.id SELECT DISTINCT i.id
FROM items i 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 INNER JOIN downloads d ON children.id = d.item_id
WHERE d.status = 'completed' WHERE d.status = 'completed'
AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder') AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
@ -1157,7 +1230,7 @@ impl MediaRepository for OfflineRepository {
-- Containers with downloaded children -- Containers with downloaded children
SELECT DISTINCT i.id SELECT DISTINCT i.id
FROM items i 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 INNER JOIN downloads d ON children.id = d.item_id
WHERE d.status = 'completed' WHERE d.status = 'completed'
AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder') 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 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(); "#).unwrap();
// Insert a test server // Insert a test server
@ -1721,6 +1820,196 @@ mod tests {
assert_eq!(result.unwrap(), 3); 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 ===== // ===== Playlist Tests =====
/// Helper to seed items into the DB for playlist tests /// Helper to seed items into the DB for playlist tests

View File

@ -144,6 +144,17 @@ impl OnlineRepository {
/// Make authenticated GET request /// Make authenticated GET request
async fn get_json<T: for<'de> Deserialize<'de>>(&self, endpoint: &str) -> Result<T, RepoError> { async fn get_json<T: for<'de> Deserialize<'de>>(&self, endpoint: &str) -> Result<T, RepoError> {
// Fast-fail when connectivity is known-offline. Without this every request
// still runs the full HTTP retry/backoff cycle (~7s) before giving up,
// which stalls cache-miss paths and makes offline browsing feel janky.
// The offline recovery probe (connectivity monitor) flips us back to
// reachable the moment the server returns, so this never sticks.
if let Some(reporter) = &self.connectivity {
if !reporter.is_reachable().await {
return Err(RepoError::Offline);
}
}
let result = self.get_json_inner(endpoint).await; let result = self.get_json_inner(endpoint).await;
self.report_outcome(&result).await; self.report_outcome(&result).await;
result result
@ -1413,12 +1424,43 @@ impl MediaRepository for OnlineRepository {
quality: &str, quality: &str,
media_source_id: Option<&str>, media_source_id: Option<&str>,
) -> String { ) -> 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)]; let mut params = vec![format!("api_key={}", self.access_token)];
// Add quality parameter if not "original" // Map the frontend quality preset to concrete transcode params. For
if quality != "original" { // "original" we request a direct static copy (no transcode) which is
params.push(format!("quality={}", quality)); // 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 // Add media source ID if provided
@ -1426,10 +1468,8 @@ impl MediaRepository for OnlineRepository {
params.push(format!("mediaSourceId={}", source_id)); params.push(format!("mediaSourceId={}", source_id));
} }
if !params.is_empty() {
url.push('?'); url.push('?');
url.push_str(&params.join("&")); url.push_str(&params.join("&"));
}
url url
} }
@ -1759,6 +1799,25 @@ mod tests {
} }
} }
/// When connectivity is known-offline, `get_json` must fast-fail with
/// `RepoError::Offline` instead of running the full HTTP retry cycle (~7s).
/// This is what keeps offline browsing snappy. `test.server.com` is
/// unroutable, so if the guard were absent this would hang on retries; the
/// assertion returning promptly with `Offline` proves the short-circuit.
#[tokio::test]
async fn test_get_json_fast_fails_when_offline() {
let (repo, reporter) = create_test_repository_with_connectivity();
reporter.mark_unreachable_for_test().await;
assert!(!reporter.is_reachable().await, "precondition: offline");
let result: Result<serde_json::Value, RepoError> = repo.get_json("/System/Info").await;
assert!(
matches!(result, Err(RepoError::Offline)),
"known-offline get_json should return Offline immediately, got {:?}",
result
);
}
/// A network error routes through the debounced path. A single failure stays /// A network error routes through the debounced path. A single failure stays
/// online (debounce window not yet elapsed). /// online (debounce window not yet elapsed).
#[tokio::test] #[tokio::test]
@ -1887,6 +1946,74 @@ mod tests {
assert_eq!(tags.primary(), None); 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] #[test]
fn test_jellyfin_item_deserialize_with_image_tags() { fn test_jellyfin_item_deserialize_with_image_tags() {
// Test full JellyfinItem deserialization with ImageTags // Test full JellyfinItem deserialization with ImageTags

View File

@ -201,6 +201,7 @@ mod tests {
"thumbnails", "thumbnails",
"playlists", "playlists",
"playlist_items", "playlist_items",
"genres",
]; ];
for table in expected_tables { for table in expected_tables {

View File

@ -23,6 +23,7 @@ pub const MIGRATIONS: &[(&str, &str)] = &[
("016_autoplay_max_episodes", MIGRATION_016), ("016_autoplay_max_episodes", MIGRATION_016),
("017_downloads_resume_url", MIGRATION_017), ("017_downloads_resume_url", MIGRATION_017),
("018_items_is_folder", MIGRATION_018), ("018_items_is_folder", MIGRATION_018),
("019_genres_cache", MIGRATION_019),
]; ];
/// Initial schema migration /// 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. -- Force re-fetch of all cached items so is_folder is populated from the server.
UPDATE items SET synced_at = NULL; 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);
"#;

View File

@ -64,7 +64,8 @@ impl ThumbnailCache {
image_type: &str, image_type: &str,
tag: &str, tag: &str,
) -> Option<PathBuf> { ) -> Option<PathBuf> {
let query = Query::with_params( // Primary lookup: exact (item_id, image_type, tag) match.
let exact = Query::with_params(
"SELECT file_path FROM thumbnails "SELECT file_path FROM thumbnails
WHERE item_id = ? AND image_type = ? AND image_tag = ?", WHERE item_id = ? AND image_type = ? AND image_tag = ?",
vec![ vec![
@ -74,12 +75,60 @@ impl ThumbnailCache {
], ],
); );
let path_str: String = db.query_optional(query, |row| row.get(0)).await.ok()??; if let Ok(Some(path_str)) = db.query_optional(exact, |row| row.get::<_, String>(0)).await {
let path = PathBuf::from(&path_str); let path = PathBuf::from(&path_str);
if path.exists() { if path.exists() {
// Update last_accessed for LRU tracking self.touch(&db, item_id, image_type, Some(tag)).await;
let update_query = Query::with_params( 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() {
self.touch(&db, item_id, image_type, None).await;
Some(path)
} else {
None
}
}
/// Update `last_accessed` for LRU tracking. When `tag` is `Some`, scope to
/// that exact row; when `None`, touch every row for the item + type.
async fn touch(
&self,
db: &Arc<RusqliteService>,
item_id: &str,
image_type: &str,
tag: Option<&str>,
) {
let query = match tag {
Some(tag) => Query::with_params(
"UPDATE thumbnails SET last_accessed = CURRENT_TIMESTAMP "UPDATE thumbnails SET last_accessed = CURRENT_TIMESTAMP
WHERE item_id = ? AND image_type = ? AND image_tag = ?", WHERE item_id = ? AND image_type = ? AND image_tag = ?",
vec![ vec![
@ -87,23 +136,17 @@ impl ThumbnailCache {
QueryParam::String(image_type.to_string()), QueryParam::String(image_type.to_string()),
QueryParam::String(tag.to_string()), QueryParam::String(tag.to_string()),
], ],
); ),
let _ = db.execute(update_query).await; None => Query::with_params(
Some(path) "UPDATE thumbnails SET last_accessed = CURRENT_TIMESTAMP
} else { WHERE item_id = ? AND image_type = ?",
// Clean up stale database entry
let delete_query = Query::with_params(
"DELETE FROM thumbnails
WHERE item_id = ? AND image_type = ? AND image_tag = ?",
vec![ vec![
QueryParam::String(item_id.to_string()), QueryParam::String(item_id.to_string()),
QueryParam::String(image_type.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 /// Save thumbnail to cache

View File

@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
import { page } from "$app/stores";
import { truncateMiddle } from "$lib/utils/truncateMiddle"; import { truncateMiddle } from "$lib/utils/truncateMiddle";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { navigateBack } from "$lib/utils/navigation"; import { navigateBack } from "$lib/utils/navigation";
@ -53,6 +54,16 @@
onMount(async () => { onMount(async () => {
await loadGenres(); await loadGenres();
// Auto-select a genre when linked with ?genre=<name> (e.g. from a genre tag)
const requestedGenre = $page.url.searchParams.get("genre");
if (requestedGenre) {
const match = genres.find(
(g) => g.name.toLowerCase() === requestedGenre.toLowerCase(),
);
if (match) {
await loadGenreItems(match);
}
}
markLoaded(); markLoaded();
}); });

View File

@ -5,14 +5,34 @@
genres: string[]; genres: string[];
maxShow?: number; // Default: unlimited maxShow?: number; // Default: unlimited
clickable?: boolean; // Default: true clickable?: boolean; // Default: true
itemType?: string; // Determines which genre browse page to open
} }
let { let {
genres, genres,
maxShow, maxShow,
clickable = true clickable = true,
itemType
}: Props = $props(); }: Props = $props();
// Map the item type to its genre-browse route
function genreBasePath(type: string | undefined): string {
switch (type) {
case "MusicAlbum":
case "MusicArtist":
case "Audio":
return "/library/music/genres";
case "Series":
case "Season":
case "Episode":
return "/library/shows/genres";
case "Movie":
return "/library/movies/genres";
default:
return "/library/movies/genres";
}
}
const displayGenres = $derived( const displayGenres = $derived(
maxShow ? genres.slice(0, maxShow) : genres maxShow ? genres.slice(0, maxShow) : genres
); );
@ -23,9 +43,7 @@
function handleGenreClick(genre: string) { function handleGenreClick(genre: string) {
if (clickable) { if (clickable) {
// Navigate to genre browse page goto(`${genreBasePath(itemType)}?genre=${encodeURIComponent(genre)}`);
// For now, we'll use a simple navigation - could be enhanced with a proper genre browse page
goto(`/search?genre=${encodeURIComponent(genre)}`);
} }
} }
</script> </script>

View File

@ -19,6 +19,7 @@
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import type { MediaItem } from "$lib/api/types"; import type { MediaItem } from "$lib/api/types";
import { auth } from "$lib/stores/auth"; import { auth } from "$lib/stores/auth";
import { commands } from "$lib/api/bindings";
import { import {
mergedMedia, mergedMedia,
mergedIsPlaying, mergedIsPlaying,
@ -70,6 +71,27 @@
// In remote mode, this automatically uses the remote session's nowPlayingItem // In remote mode, this automatically uses the remote session's nowPlayingItem
const displayMedia = $derived($mergedMedia || $currentQueueItem); const displayMedia = $derived($mergedMedia || $currentQueueItem);
const displayIsPlaying = $derived($mergedIsPlaying); 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 displayPosition = $derived($mergedPosition);
const displayDuration = $derived($mergedDuration); const displayDuration = $derived($mergedDuration);
@ -358,7 +380,7 @@
{#if displayMedia} {#if displayMedia}
<FavoriteButton <FavoriteButton
itemId={displayMedia?.id ?? ""} itemId={displayMedia?.id ?? ""}
isFavorite={displayMedia?.userData?.isFavorite ?? false} bind:isFavorite
size="sm" size="sm"
/> />
{/if} {/if}

View File

@ -1,8 +1,10 @@
// Favorites service - Handles toggling favorite status with optimistic updates // Favorites service - Handles toggling favorite status with optimistic updates
// TRACES: UR-017 | DR-021 // TRACES: UR-017 | DR-021
import { get } from "svelte/store";
import { commands } from "$lib/api/bindings"; import { commands } from "$lib/api/bindings";
import { auth } from "$lib/stores/auth"; import { auth } from "$lib/stores/auth";
import { isConnected } from "$lib/stores/connectivity";
/** /**
* Toggle the favorite status of an item. * Toggle the favorite status of an item.
@ -31,7 +33,16 @@ export async function toggleFavorite(
// 1. Update local database first (optimistic update) // 1. Update local database first (optimistic update)
await commands.storageToggleFavorite(userId, itemId, newIsFavorite); await commands.storageToggleFavorite(userId, itemId, newIsFavorite);
// 2. Sync to Jellyfin server // 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 { try {
const repo = auth.getRepository(); const repo = auth.getRepository();
if (newIsFavorite) { if (newIsFavorite) {
@ -47,6 +58,7 @@ export async function toggleFavorite(
// Favorite is stored locally and will be synced later // Favorite is stored locally and will be synced later
// via sync queue (when implemented) // via sync queue (when implemented)
} }
}
return newIsFavorite; return newIsFavorite;
} }

View File

@ -36,7 +36,10 @@ function createHomeStore() {
try { try {
const repo = auth.getRepository(); 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.getResumeItems(undefined, 12),
repo.getNextUpEpisodes(undefined, 12), repo.getNextUpEpisodes(undefined, 12),
repo.getLatestItems("", 16), repo.getLatestItems("", 16),
@ -44,6 +47,15 @@ function createHomeStore() {
repo.getResumeMovies(12), repo.getResumeMovies(12),
]); ]);
const valueOr = <T>(i: number, fallback: T): T =>
settled[i].status === "fulfilled" ? (settled[i] as PromiseFulfilledResult<T>).value : fallback;
const resume = valueOr(0, [] as typeof initialState.resumeItems);
const nextUp = valueOr(1, [] as typeof initialState.nextUpItems);
const latest = valueOr(2, [] as typeof initialState.latestItems);
const recentAudio = valueOr(3, [] as typeof initialState.recentlyPlayedAudio);
const resumeMovies = valueOr(4, [] as typeof initialState.resumeMovies);
// Use resume items or latest as hero items // Use resume items or latest as hero items
const hero = resume.length >= 3 ? resume.slice(0, 5) : latest.slice(0, 5); const hero = resume.length >= 3 ? resume.slice(0, 5) : latest.slice(0, 5);

View File

@ -511,7 +511,7 @@
<!-- Genre Tags --> <!-- Genre Tags -->
{#if item.genres?.length} {#if item.genres?.length}
<div> <div>
<GenreTags genres={item.genres ?? undefined} maxShow={6} /> <GenreTags genres={item.genres ?? undefined} maxShow={6} itemType={item.type} />
</div> </div>
{/if} {/if}