Layout and search fix
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 2m4s
🏗️ Build and Test JellyTau / Android Compile Check (push) Has been skipped
Traceability Validation / Check Requirement Traces (push) Successful in 23s
Build & Release / Run Tests (push) Failing after 2m45s
Build & Release / Build Linux (push) Has been skipped
Build & Release / Build Android (push) Has been skipped
Build & Release / Create Release (push) Has been skipped

This commit is contained in:
2026-07-11 19:55:55 +02:00
parent a2cd9978f0
commit 2a1f1689b4
20 changed files with 991 additions and 995 deletions
+14
View File
@@ -159,6 +159,20 @@ pub async fn catalog_sync_status(
Ok(CatalogSyncStatus { last_synced_at })
}
/// Control whether offline library queries reveal the full synced catalog
/// (greyed-out, non-downloaded media) or only downloaded/local media.
///
/// The frontend calls this from the "Show all server media" toggle: pass `true`
/// when online, or when offline with the toggle on; pass `false` when offline
/// with the toggle off so library pages show downloaded media only. Fixes the
/// bug where offline library pages showed every server item regardless of the
/// toggle.
#[tauri::command]
#[specta::specta]
pub fn set_show_server_catalog(show: bool) {
crate::repository::offline::set_include_catalog_browse(show);
}
#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ResumeQueuedResult {
+18
View File
@@ -696,6 +696,11 @@ pub async fn player_stop(
let controller = player.0.lock().await;
controller.stop().map_err(|e| e.to_string())?;
// A genuine local stop returns the manager to Idle so it no longer
// reports Local (or a stale Remote) — otherwise a later play/pause would
// route to the wrong device.
playback_mode.0.set_mode(crate::playback_mode::PlaybackMode::Idle);
// Handle session state based on type (local playback only)
{
let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
@@ -1538,6 +1543,9 @@ pub async fn player_play_album_track(
play_selection_on_remote(&controller, session_id, &media_items, start_index).await?;
controller.set_queue(media_items, start_index).map_err(|e| e.to_string())?;
} else {
// Local playback is now authoritative (see player_play_tracks); set it
// before starting so the mode-changed event precedes the state events.
playback_mode.0.set_mode(crate::playback_mode::PlaybackMode::Local);
controller
.play_queue(media_items, start_index)
.map_err(|e| e.to_string())?;
@@ -1714,6 +1722,16 @@ pub async fn player_play_tracks(
.set_queue(media_items, request.start_index)
.map_err(|e| e.to_string())?;
} else {
// Starting local playback makes Local the authoritative mode. Without
// this, a prior Remote mode lingers in the manager and later play/pause
// commands route back to the (stopped) remote session. Set it BEFORE
// starting playback so the PlaybackModeChanged event reaches the frontend
// ahead of the state_changed events it will emit — otherwise the frontend
// (still thinking it's remote) filters those state events out. Skip during
// a transfer: transfer_to_local drives the mode itself once complete.
if !playback_mode.0.is_transferring() {
playback_mode.0.set_mode(crate::playback_mode::PlaybackMode::Local);
}
controller
.play_queue_from(media_items, request.start_index, request.start_position)
.map_err(|e| e.to_string())?;
+5 -1
View File
@@ -23,7 +23,7 @@ use log::{error, info};
use log::warn;
use commands::{
sync_full_catalog, catalog_sync_status, resume_queued_downloads,
sync_full_catalog, catalog_sync_status, set_show_server_catalog, resume_queued_downloads,
cancel_download, clear_stale_downloads, delete_album_downloads, delete_all_downloads, delete_download,
download_album, download_item, download_item_and_start, download_video, download_series, download_season,
get_download_storage_stats, get_downloads, get_download_manager_stats, set_max_concurrent_downloads,
@@ -588,6 +588,7 @@ fn specta_builder() -> Builder<tauri::Wry> {
enqueue_video_downloads,
sync_full_catalog,
catalog_sync_status,
set_show_server_catalog,
resume_queued_downloads,
get_download_manager_stats,
set_max_concurrent_downloads,
@@ -924,6 +925,9 @@ pub fn run() {
player_arc.clone(),
);
let playback_mode_arc = Arc::new(playback_mode_manager);
// Broadcast mode changes so the frontend's mirror store reconciles to
// this authoritative one (prevents remote/local control desync).
playback_mode_arc.set_event_emitter(event_emitter.clone());
let playback_mode_wrapper = PlaybackModeManagerWrapper(playback_mode_arc.clone());
app.manage(playback_mode_wrapper);
+119 -4
View File
@@ -9,7 +9,7 @@ use tokio::sync::Mutex as TokioMutex;
use tokio::time::{sleep, Duration};
use crate::jellyfin::JellyfinClient;
use crate::player::{PlayerController, QueueContext};
use crate::player::{PlayerController, PlayerEventEmitter, PlayerStatusEvent, QueueContext};
/// Playback mode - local device, remote session, or idle
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -48,6 +48,9 @@ pub struct PlaybackModeManager {
player_controller: Arc<TokioMutex<PlayerController>>,
current_mode: Arc<RwLock<PlaybackMode>>,
is_transferring: Arc<AtomicBool>,
/// Optional emitter used to notify the frontend when the mode changes, so its
/// mirror store stays in sync with this authoritative one. `None` in tests.
event_emitter: Arc<Mutex<Option<Arc<dyn PlayerEventEmitter>>>>,
}
impl PlaybackModeManager {
@@ -61,19 +64,53 @@ impl PlaybackModeManager {
player_controller,
current_mode: Arc::new(RwLock::new(PlaybackMode::Idle)),
is_transferring: Arc::new(AtomicBool::new(false)),
event_emitter: Arc::new(Mutex::new(None)),
}
}
/// Wire the event emitter so `set_mode` notifies the frontend. Called once
/// during setup; safe to leave unset (tests do), in which case mode changes
/// simply aren't broadcast.
pub fn set_event_emitter(&self, emitter: Arc<dyn PlayerEventEmitter>) {
*self.event_emitter.lock_safe() = Some(emitter);
}
/// Get current playback mode
pub fn get_mode(&self) -> PlaybackMode {
self.current_mode.read_safe().clone()
}
/// Set playback mode (internal use)
/// Set playback mode (internal use).
///
/// Broadcasts a `PlaybackModeChanged` event when the mode actually changes so
/// the frontend's mirror store reconciles to this authoritative value. The
/// write lock is released before emitting to avoid holding it across the
/// emitter call.
pub fn set_mode(&self, mode: PlaybackMode) {
log::info!("[PlaybackMode] Setting mode to: {:?}", mode);
let mut current = self.current_mode.write_safe();
*current = mode;
let changed = {
let mut current = self.current_mode.write_safe();
let changed = *current != mode;
*current = mode.clone();
changed
};
if !changed {
return;
}
let (mode_str, session_id) = match &mode {
PlaybackMode::Local => ("local".to_string(), None),
PlaybackMode::Idle => ("idle".to_string(), None),
PlaybackMode::Remote { session_id } => ("remote".to_string(), Some(session_id.clone())),
};
if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
emitter.emit(PlayerStatusEvent::PlaybackModeChanged {
mode: mode_str,
session_id,
});
}
}
/// Check if currently transferring
@@ -702,6 +739,84 @@ mod tests {
);
}
/// Capturing emitter so we can assert what `set_mode` broadcasts.
struct CapturingEmitter {
events: Mutex<Vec<PlayerStatusEvent>>,
}
impl PlayerEventEmitter for CapturingEmitter {
fn emit(&self, event: PlayerStatusEvent) {
self.events.lock().unwrap().push(event);
}
}
fn manager_with_emitter() -> (PlaybackModeManager, Arc<CapturingEmitter>) {
let emitter = Arc::new(CapturingEmitter {
events: Mutex::new(Vec::new()),
});
let manager = PlaybackModeManager::new(
Arc::new(Mutex::new(None)),
Arc::new(TokioMutex::new(crate::player::PlayerController::default())),
);
manager.set_event_emitter(emitter.clone());
(manager, emitter)
}
/// set_mode broadcasts a PlaybackModeChanged event with the right payload so
/// the frontend can reconcile its mirror store to this authoritative one.
#[test]
fn test_set_mode_emits_change_event() {
let (manager, emitter) = manager_with_emitter();
manager.set_mode(PlaybackMode::Remote {
session_id: "sess-1".to_string(),
});
manager.set_mode(PlaybackMode::Local);
manager.set_mode(PlaybackMode::Idle);
let events = emitter.events.lock().unwrap();
assert_eq!(events.len(), 3, "one event per real mode change");
match &events[0] {
PlayerStatusEvent::PlaybackModeChanged { mode, session_id } => {
assert_eq!(mode, "remote");
assert_eq!(session_id.as_deref(), Some("sess-1"));
}
other => panic!("expected PlaybackModeChanged, got {:?}", other),
}
match &events[1] {
PlayerStatusEvent::PlaybackModeChanged { mode, session_id } => {
assert_eq!(mode, "local");
assert_eq!(session_id.as_deref(), None);
}
other => panic!("expected PlaybackModeChanged, got {:?}", other),
}
match &events[2] {
PlayerStatusEvent::PlaybackModeChanged { mode, session_id } => {
assert_eq!(mode, "idle");
assert_eq!(session_id.as_deref(), None);
}
other => panic!("expected PlaybackModeChanged, got {:?}", other),
}
}
/// Setting the same mode twice must not re-emit — the frontend reconciler
/// (and the event channel) shouldn't be spammed on no-op transitions.
#[test]
fn test_set_mode_deduplicates_no_op() {
let (manager, emitter) = manager_with_emitter();
manager.set_mode(PlaybackMode::Local);
manager.set_mode(PlaybackMode::Local);
manager.set_mode(PlaybackMode::Local);
assert_eq!(
emitter.events.lock().unwrap().len(),
1,
"repeated identical mode set emits only once"
);
}
/// The resume position handed to a remote session is derived from a live
/// playback position. Guards the seconds->ticks conversion and the
/// at-the-start threshold (Bug: casting restarted the track from 0).
+15
View File
@@ -124,6 +124,21 @@ pub enum PlayerStatusEvent {
/// All active controllable sessions from Jellyfin
sessions: Vec<crate::jellyfin::client::SessionInfo>,
},
/// The authoritative playback mode changed in the Rust backend.
///
/// The Rust `PlaybackModeManager` is the single source of truth for which
/// device playback commands route to (local vs a remote session). The
/// frontend keeps a mirror store for the UI; without this event that mirror
/// drifts out of sync (e.g. a mode transition happens inside a transfer or a
/// local stop that the frontend never learns about), and controls then route
/// to the wrong device — the classic "it keeps playing on the remote" bug.
/// The frontend reconciles its store to this payload whenever it fires.
PlaybackModeChanged {
/// New mode: "local", "remote", or "idle".
mode: String,
/// Session id when `mode == "remote"`, otherwise `None`.
session_id: Option<String>,
},
/// The user asked to disconnect from the remote session and resume locally.
///
/// Emitted when the lockscreen Stop button is pressed while casting. The
+91 -7
View File
@@ -1,5 +1,6 @@
// Offline repository - queries SQLite database for cached data
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use async_trait::async_trait;
use log::debug;
@@ -7,6 +8,30 @@ use log::debug;
use super::{MediaRepository, types::*};
use crate::storage::db_service::{DatabaseService, Query, QueryParam, RusqliteService};
/// Whether offline library queries may include catalog items that are merely
/// *browsed/synced* but not downloaded (the greyed-out "browse the whole server"
/// view). Defaults to `true` so online browsing (which reads this same cache as
/// a fast path) still sees the full catalog.
///
/// While offline, the frontend drives this from the "Show all server media"
/// toggle: OFF means library pages show only downloaded/local media, ON reveals
/// the full greyed-out catalog. See `set_include_catalog_browse` and the
/// `showServerCatalog` UI flag. Fixes the bug where offline library pages showed
/// every server item regardless of the toggle.
static INCLUDE_CATALOG_BROWSE: AtomicBool = AtomicBool::new(true);
/// Set whether offline `get_items` includes non-downloaded (synced-only) catalog
/// items. Called from the frontend: `true` when online or when the offline
/// "Show all server media" toggle is on; `false` when offline with the toggle
/// off (show downloaded/local media only).
pub fn set_include_catalog_browse(include: bool) {
INCLUDE_CATALOG_BROWSE.store(include, Ordering::Relaxed);
}
fn include_catalog_browse() -> bool {
INCLUDE_CATALOG_BROWSE.load(Ordering::Relaxed)
}
pub struct OfflineRepository {
db_service: Arc<RusqliteService>,
server_id: String,
@@ -558,7 +583,20 @@ impl MediaRepository for OfflineRepository {
// Use CTE to find items that are either:
// 1. Playable items (Audio, Movie, Episode) with completed downloads (offline mode)
// 2. Container items (MusicAlbum, Series, Season) with at least one downloaded child (offline mode)
// 3. Cached items with recent synced_at timestamp (online mode - for fast browsing)
// 3. Cached items with recent synced_at timestamp (fast online browsing, or the
// offline "Show all server media" catalog view) — only when the catalog-browse
// flag is set. When offline with the toggle off, this branch is omitted so the
// page shows downloaded/local media only. See `set_include_catalog_browse`.
let catalog_branch = if include_catalog_browse() {
"UNION
-- Cached items for fast browsing (online) or the offline catalog view
SELECT DISTINCT i.id
FROM items i
WHERE i.synced_at IS NOT NULL"
} else {
""
};
let sql = format!(
"WITH available_items AS (
-- Playable items with completed downloads
@@ -578,12 +616,7 @@ impl MediaRepository for OfflineRepository {
WHERE d.status = 'completed'
AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
UNION
-- Cached items for fast browsing (when online)
SELECT DISTINCT i.id
FROM items i
WHERE i.synced_at IS NOT NULL
{catalog_branch}
)
SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id, i.overview, i.genres,
i.runtime_ticks, i.production_year, i.community_rating, i.official_rating,
@@ -1874,6 +1907,57 @@ mod tests {
assert_eq!(tracks.items[0].id, "track-1");
}
/// Regression: offline library pages must honor the "Show all server media"
/// toggle. With `include_catalog_browse` off, `get_items` returns only
/// downloaded media — not the whole synced catalog. With it on, the full
/// (synced-but-not-downloaded) catalog is revealed. Fixes the bug where
/// offline library pages showed every server item regardless of the toggle.
#[tokio::test]
async fn test_get_items_toggle_gates_synced_catalog() {
use crate::storage::db_service::DatabaseService;
let db_service = create_test_db();
for sql in [
// Two movies in a library, both merely synced (browsed) — no download.
"INSERT INTO items (id, server_id, name, item_type, library_id, synced_at) \
VALUES ('movie-dl', 'test-server', 'Downloaded', 'Movie', 'lib-1', '2026-01-01')",
"INSERT INTO items (id, server_id, name, item_type, library_id, synced_at) \
VALUES ('movie-cat', 'test-server', 'CatalogOnly', 'Movie', 'lib-1', '2026-01-01')",
// Only the first movie is actually downloaded.
"INSERT INTO downloads (item_id, status) VALUES ('movie-dl', 'completed')",
// A library row so the library-parent EXISTS clause matches.
"INSERT INTO libraries (id, server_id, name) VALUES ('lib-1', 'test-server', 'Movies')",
] {
db_service.execute(Query::new(sql)).await.unwrap();
}
let repo = OfflineRepository::new(
db_service.clone(),
"test-server".to_string(),
"test-user".to_string(),
);
let opts = Some(GetItemsOptions {
include_item_types: Some(vec!["Movie".to_string()]),
..Default::default()
});
// Toggle OFF: only the downloaded movie is returned.
set_include_catalog_browse(false);
let local_only = repo.get_items("lib-1", opts.clone()).await.unwrap();
let ids: Vec<&str> = local_only.items.iter().map(|i| i.id.as_str()).collect();
assert_eq!(ids, vec!["movie-dl"], "toggle off should show downloaded media only");
// Toggle ON: both the downloaded and the catalog-only movie are returned.
set_include_catalog_browse(true);
let full_catalog = repo.get_items("lib-1", opts).await.unwrap();
let mut ids: Vec<&str> = full_catalog.items.iter().map(|i| i.id.as_str()).collect();
ids.sort();
assert_eq!(ids, vec!["movie-cat", "movie-dl"], "toggle on should reveal the full catalog");
// Restore default for other tests sharing this process-global flag.
set_include_catalog_browse(true);
}
/// 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