fix: several small fixes
All checks were successful
🏗️ Build and Test JellyTau / Run Tests (pull_request) Successful in 3m51s
Traceability Validation / Check Requirement Traces (pull_request) Successful in 22s
🏗️ Build and Test JellyTau / Build Android APK (pull_request) Successful in 18m26s

This commit is contained in:
Duncan Tourolle 2026-06-25 20:02:01 +02:00
parent 1836615dc0
commit 2811e1b7ca
6 changed files with 69 additions and 37 deletions

View File

@ -82,6 +82,7 @@ impl ConnectivityReporter {
/// Current reachability as seen by this reporter (shared with the monitor /// Current reachability as seen by this reporter (shared with the monitor
/// and the UI). Useful for callers that want to branch on connectivity. /// and the UI). Useful for callers that want to branch on connectivity.
#[allow(dead_code)] // public API; currently only exercised by cross-module tests
pub async fn is_reachable(&self) -> bool { pub async fn is_reachable(&self) -> bool {
self.status.read().await.is_server_reachable self.status.read().await.is_server_reachable
} }

View File

@ -22,7 +22,14 @@ use super::{MediaSessionType, SleepTimerMode};
/// ///
/// TRACES: UR-005, UR-019, UR-023, UR-026 | DR-001, DR-028, DR-047 /// TRACES: UR-005, UR-019, UR-023, UR-026 | DR-001, DR-028, DR-047
#[derive(Debug, Clone, Serialize, Deserialize, specta::Type, tauri_specta::Event)] #[derive(Debug, Clone, Serialize, Deserialize, specta::Type, tauri_specta::Event)]
#[serde(tag = "type", rename_all = "snake_case", rename_all_fields = "camelCase")] // NOTE: fields are intentionally snake_case on the wire. specta generates the
// TypeScript bindings with snake_case field names (it does not apply serde's
// `rename_all_fields`), so adding `rename_all_fields = "camelCase"` here makes
// serde emit camelCase payloads that no longer match the generated schema —
// tauri-specta then silently drops those events (e.g. state_changed,
// queue_changed never reach the frontend, so the mini player never appears).
// Keep serde and specta agreeing: snake_case fields, snake_case variant tags.
#[serde(tag = "type", rename_all = "snake_case")]
pub enum PlayerStatusEvent { pub enum PlayerStatusEvent {
/// Playback position updated (emitted periodically during playback) /// Playback position updated (emitted periodically during playback)
PositionUpdate { PositionUpdate {

View File

@ -830,41 +830,57 @@ 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> {
// Extract unique genres from cached items // Derive genres from cached albums, tallying how many albums carry each
let mut genre_set = std::collections::HashSet::new(); // 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 { let (sql, params) = if let Some(pid) = parent_id {
( (
"SELECT DISTINCT genres FROM items WHERE server_id = ? AND library_id = ? AND genres IS NOT NULL", "SELECT genres FROM items 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(pid.to_string()),
] ],
) )
} else { } else {
( (
"SELECT DISTINCT genres FROM items WHERE server_id = ? AND genres IS NOT NULL", "SELECT genres FROM items WHERE server_id = ? \
vec![QueryParam::String(self.server_id.clone())] AND item_type = 'MusicAlbum' AND genres IS NOT NULL",
vec![QueryParam::String(self.server_id.clone())],
) )
}; };
let query = Query::with_params(sql, params); let query = Query::with_params(sql, params);
let genres_rows: Vec<String> = self.db_service.query_many(query, |row| row.get(0)) let genres_rows: Vec<String> = self
.db_service
.query_many(query, |row| row.get(0))
.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 { for genres_json in genres_rows {
if let Ok(genres_vec) = serde_json::from_str::<Vec<String>>(&genres_json) { 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 { for genre in genres_vec {
genre_set.insert(genre); if seen.insert(genre.clone()) {
*counts.entry(genre).or_insert(0) += 1;
}
} }
} }
} }
let genres = genre_set let genres = counts
.into_iter() .into_iter()
.map(|name| Genre { id: name.clone(), name, album_count: None }) .map(|(name, count)| Genre {
id: name.clone(),
name,
album_count: Some(count),
})
.collect(); .collect();
Ok(genres) Ok(genres)

View File

@ -580,8 +580,10 @@ impl MediaRepository for OnlineRepository {
} }
} }
// Request image fields for list views (People only needed in get_item detail view) // Request image fields for list views (People only needed in get_item
endpoint.push_str("&Fields=BackdropImageTags,ParentBackdropImageTags"); // detail view). Genres is needed so cached items carry their genres,
// which lets the offline store derive genre lists + per-genre counts.
endpoint.push_str("&Fields=BackdropImageTags,ParentBackdropImageTags,Genres");
let response: ItemsResponse = self.get_json(&endpoint).await?; let response: ItemsResponse = self.get_json(&endpoint).await?;
@ -854,10 +856,17 @@ impl MediaRepository for OnlineRepository {
.collect(); .collect();
let with_counts = genres.iter().filter(|g| g.album_count.is_some()).count(); let with_counts = genres.iter().filter(|g| g.album_count.is_some()).count();
log::debug!( // TEMP DIAGNOSTIC: dump the first few genres with their counts so we can
"get_genres: {} genres, {} carry counts (AlbumCount/ChildCount)", // see whether the server populates any count field. Remove once known.
log::warn!(
"get_genres: {} genres, {} carry counts. sample: {:?}",
genres.len(), genres.len(),
with_counts with_counts,
genres
.iter()
.take(8)
.map(|g| (g.name.as_str(), g.album_count))
.collect::<Vec<_>>()
); );
Ok(genres) Ok(genres)
@ -890,8 +899,9 @@ impl MediaRepository for OnlineRepository {
} }
} }
// Request image fields for list views (People only needed in get_item detail view) // Request image fields for list views (plus Genres so cached items
endpoint.push_str("&Fields=BackdropImageTags,ParentBackdropImageTags"); // carry genres for offline genre lists/counts).
endpoint.push_str("&Fields=BackdropImageTags,ParentBackdropImageTags,Genres");
let response: ItemsResponse = self.get_json(&endpoint).await?; let response: ItemsResponse = self.get_json(&endpoint).await?;
Ok(SearchResult { Ok(SearchResult {

View File

@ -25,18 +25,22 @@
let { children } = $props(); let { children } = $props();
onMount(async () => { onMount(async () => {
// Initialize auth state (restore session from secure storage) // Detect platform first (synchronously, before any await) so the global
await auth.initialize(); // mini player's Android visibility gate is correct from the first render.
isInitialized.set(true); // Android needs the global mini player because the library layout hides its
// own; if this ran after `await auth.initialize()` the store stayed false
// Detect platform (Android needs global mini player) // long enough that the mini player never appeared on library routes.
try { try {
const platformName = await platform(); const platformName = platform();
isAndroid.set(platformName === "android"); isAndroid.set(platformName === "android");
} catch (err) { } catch (err) {
console.error("Platform detection failed:", err); console.error("Platform detection failed:", err);
} }
// Initialize auth state (restore session from secure storage)
await auth.initialize();
isInitialized.set(true);
// Initialize player event listener for push-based updates // Initialize player event listener for push-based updates
await initPlayerEvents(); await initPlayerEvents();

View File

@ -3,11 +3,11 @@
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { page } from "$app/stores"; import { page } from "$app/stores";
import { commands } from "$lib/api/bindings"; import { commands } from "$lib/api/bindings";
import { platform } from "@tauri-apps/plugin-os";
import { auth, isAuthenticated, isLoading as isAuthLoading, currentUser } from "$lib/stores/auth"; import { auth, isAuthenticated, isLoading as isAuthLoading, currentUser } from "$lib/stores/auth";
import { library } from "$lib/stores/library"; import { library } from "$lib/stores/library";
import { currentMedia, isPlaying, playbackPosition, playbackDuration, shouldShowAudioMiniPlayer } from "$lib/stores/player"; import { currentMedia, isPlaying, playbackPosition, playbackDuration, shouldShowAudioMiniPlayer } from "$lib/stores/player";
import { isShuffle, repeatMode, hasNext as hasNextStore, hasPrevious as hasPreviousStore } from "$lib/stores/queue"; import { isShuffle, repeatMode, hasNext as hasNextStore, hasPrevious as hasPreviousStore } from "$lib/stores/queue";
import { isAndroid } from "$lib/stores/appState";
import { useScrollGuard } from "$lib/composables/useScrollGuard"; import { useScrollGuard } from "$lib/composables/useScrollGuard";
import Search from "$lib/components/Search.svelte"; import Search from "$lib/components/Search.svelte";
import MiniPlayer from "$lib/components/player/MiniPlayer.svelte"; import MiniPlayer from "$lib/components/player/MiniPlayer.svelte";
@ -31,17 +31,11 @@
const repeat = $derived($repeatMode); const repeat = $derived($repeatMode);
const hasNext = $derived($hasNextStore); const hasNext = $derived($hasNextStore);
const hasPrevious = $derived($hasPreviousStore); const hasPrevious = $derived($hasPreviousStore);
let isAndroid = $state(false); // Platform comes from the shared appState store (set once in the root layout),
// so this layout and the root layout never disagree about Android — otherwise
// both can suppress their mini players and none appears. See $lib/stores/appState.
onMount(() => { onMount(() => {
// Detect platform
try {
const platformName = platform();
isAndroid = platformName === "android";
} catch (err) {
console.error("Platform detection failed:", err);
}
return () => { return () => {
scrollGuard.cleanup(); scrollGuard.cleanup();
}; };
@ -213,7 +207,7 @@
<!-- Main content (with padding for bottom nav bar and mini player) --> <!-- Main content (with padding for bottom nav bar and mini player) -->
<main <main
class="flex-1 overflow-y-auto p-4" class="flex-1 overflow-y-auto p-4"
style="padding-bottom: {$shouldShowAudioMiniPlayer ? (isAndroid ? '11rem' : '7rem') : '5rem'}; overscroll-behavior: contain" style="padding-bottom: {$shouldShowAudioMiniPlayer ? ($isAndroid ? '11rem' : '7rem') : '5rem'}; overscroll-behavior: contain"
onscroll={scrollGuard.onScroll} onscroll={scrollGuard.onScroll}
> >
{@render children()} {@render children()}
@ -221,7 +215,7 @@
<!-- Mini Player (only show on non-Android platforms - Android uses global mini player) --> <!-- Mini Player (only show on non-Android platforms - Android uses global mini player) -->
<!-- Hide on player page since full player is already there --> <!-- Hide on player page since full player is already there -->
{#if !isAndroid && !$page.url.pathname.startsWith('/player/')} {#if !$isAndroid && !$page.url.pathname.startsWith('/player/')}
<MiniPlayer <MiniPlayer
media={$currentMedia} media={$currentMedia}
isPlaying={$isPlaying} isPlaying={$isPlaying}