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
🏗️ 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:
@@ -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 {
|
||||
|
||||
@@ -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())?;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user