`cargo clippy --all-targets` went from 51 warnings (23 in the lib) to zero. Most were mechanical — needless borrows, `assert_eq!` against a bool literal, `vec!` where an array does, `or_insert_with(Vec::new)`, a loop index used only to index — and were applied with `clippy --fix`, then reviewed line by line. That review caught one auto-fix that was *not* semantically neutral: dropping the redundant `use hostname;` left its `#[cfg(target_os = "linux")]` orphaned directly above `SERVICE_NAME`, which would have silently cfg'd the constant out of every non-Linux build. Removed the stray attribute with the import. Where a lint asked for a risky change rather than a better one, it is suppressed with a comment saying why: - `too_many_arguments` on five `#[tauri::command]` handlers and `ThumbnailCache::save_thumbnail` — most of the arity is `State<'_, _>` injection, and a parameter struct would change the IPC contract and the generated TypeScript for no readability gain. - `large_enum_variant` on `PlayerStatusEvent` and `AutoplayDecision` — both are serde + specta wire types emitted a handful of times a second, never bulk allocated; boxing would have to stay invisible to the generated bindings while every match arm gained a deref. - `await_holding_lock` on the `hybrid`/`offline` test modules — the guard is a test-only serialisation lock for the process-global `INCLUDE_CATALOG_BROWSE` flag, and the await it spans *is* the critical section. Each `#[tokio::test]` gets its own single-threaded runtime, so this is not the production deadlock class the lint targets; restructuring would reintroduce the flag race. Real fixes elsewhere: `JellyfinItem::to_media_item` takes `self` by value, so it is now `into_media_item`; the five-tuple episode row in the download commands has a named `EpisodeRow` alias; the mpv `PropertyChange` arm matches `name: "pause"` instead of guarding on it. Also converted the last 27 raw `.lock().unwrap()` call sites to `lock_safe()`, completing the `MutexSafe`/`RwLockSafe` convention. All of them turned out to be in test modules — production code was already clean — so this is consistency rather than a fix. The two raw locks in `utils/lock.rs` stay raw on purpose: those tests deliberately poison a mutex to prove the helpers recover from it. Pure refactoring: all 698 tests still pass.
1306 lines
50 KiB
Rust
1306 lines
50 KiB
Rust
use crate::utils::lock::{MutexSafe, RwLockSafe};
|
|
use log::{debug, error, info};
|
|
use serde::{Deserialize, Serialize};
|
|
use std::sync::{
|
|
atomic::{AtomicBool, Ordering},
|
|
Arc, Mutex, RwLock,
|
|
};
|
|
use tokio::sync::Mutex as TokioMutex;
|
|
use tokio::time::{sleep, Duration};
|
|
|
|
use crate::jellyfin::JellyfinClient;
|
|
use crate::player::{PlayerController, PlayerEventEmitter, PlayerStatusEvent, QueueContext};
|
|
|
|
/// Playback mode - local device, remote session, or idle
|
|
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
#[serde(tag = "type", rename_all = "lowercase")]
|
|
pub enum PlaybackMode {
|
|
Local,
|
|
Remote { session_id: String },
|
|
Idle,
|
|
}
|
|
|
|
/// Number of Jellyfin ticks per second (100ns units).
|
|
const TICKS_PER_SECOND: f64 = 10_000_000.0;
|
|
|
|
/// Below this many seconds we treat the position as "at the start" and don't
|
|
/// send a resume position, so a fresh track casts from 0 rather than ~0.
|
|
const RESUME_THRESHOLD_SECONDS: f64 = 0.5;
|
|
|
|
/// Volume level (0-100) the remote volume slider starts at. The real level is
|
|
/// corrected by the session poller once the remote session reports its volume.
|
|
const DEFAULT_REMOTE_VOLUME: i32 = 50;
|
|
|
|
/// Convert a live playback position (seconds) into the `StartPositionTicks` to
|
|
/// hand to a remote session, or `None` if we're effectively at the start.
|
|
///
|
|
/// Pure helper so the resume-position math is unit-testable without a remote
|
|
/// session or HTTP. The *source* of `position_seconds` matters too: callers
|
|
/// must pass the live backend position (`PlayerController::position()`), not the
|
|
/// snapshot embedded in `PlayerState`, which is stale mid-track on Android.
|
|
fn start_position_ticks_from_seconds(position_seconds: f64) -> Option<i64> {
|
|
if position_seconds > RESUME_THRESHOLD_SECONDS {
|
|
Some((position_seconds * TICKS_PER_SECOND) as i64)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
/// Platform hook for attaching/detaching the OS remote-volume control.
|
|
///
|
|
/// On Android, entering remote mode hands the `MediaSession` a
|
|
/// `VolumeProviderCompat` so hardware volume buttons and the system slider drive
|
|
/// the *remote* session; leaving remote mode must hand it back to the local
|
|
/// media stream. Behind a trait so the routing rule (see
|
|
/// [`PlaybackModeManager::set_mode`]) is unit-testable off-device — the real
|
|
/// implementation is JNI and only exists on Android.
|
|
pub trait RemoteVolumeControl: Send + Sync {
|
|
/// Attach remote-volume control (and, on Android, start the playback service).
|
|
fn enable(&self, initial_volume: i32);
|
|
/// Return volume control to the local device speaker.
|
|
fn disable(&self);
|
|
}
|
|
|
|
/// Production hook: forwards to the Android JNI bridge; no-op elsewhere.
|
|
struct PlatformRemoteVolumeControl;
|
|
|
|
impl RemoteVolumeControl for PlatformRemoteVolumeControl {
|
|
#[allow(unused_variables)]
|
|
fn enable(&self, initial_volume: i32) {
|
|
#[cfg(target_os = "android")]
|
|
{
|
|
if let Err(e) = crate::player::enable_remote_volume(initial_volume) {
|
|
log::warn!(
|
|
"[PlaybackMode] Failed to enable remote volume/service: {}",
|
|
e
|
|
);
|
|
// Non-fatal - continue; the next poll tick will retry metadata.
|
|
}
|
|
}
|
|
}
|
|
|
|
fn disable(&self) {
|
|
#[cfg(target_os = "android")]
|
|
{
|
|
if let Err(e) = crate::player::disable_remote_volume() {
|
|
log::warn!("[PlaybackMode] Failed to disable remote volume: {}", e);
|
|
// Non-fatal - the mode change itself has already happened.
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Manages playback mode transfers between local and remote sessions
|
|
pub struct PlaybackModeManager {
|
|
jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>,
|
|
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>>>>,
|
|
/// Platform hook for OS-level remote volume routing (swapped in tests).
|
|
remote_volume: Arc<dyn RemoteVolumeControl>,
|
|
}
|
|
|
|
impl PlaybackModeManager {
|
|
/// Create a new playback mode manager
|
|
pub fn new(
|
|
jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>,
|
|
player_controller: Arc<TokioMutex<PlayerController>>,
|
|
) -> Self {
|
|
Self {
|
|
jellyfin_client,
|
|
player_controller,
|
|
current_mode: Arc::new(RwLock::new(PlaybackMode::Idle)),
|
|
is_transferring: Arc::new(AtomicBool::new(false)),
|
|
event_emitter: Arc::new(Mutex::new(None)),
|
|
remote_volume: Arc::new(PlatformRemoteVolumeControl),
|
|
}
|
|
}
|
|
|
|
/// Construct with a custom remote-volume hook (tests).
|
|
#[cfg(test)]
|
|
fn with_remote_volume(
|
|
jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>,
|
|
player_controller: Arc<TokioMutex<PlayerController>>,
|
|
remote_volume: Arc<dyn RemoteVolumeControl>,
|
|
) -> Self {
|
|
Self {
|
|
jellyfin_client,
|
|
player_controller,
|
|
current_mode: Arc::new(RwLock::new(PlaybackMode::Idle)),
|
|
is_transferring: Arc::new(AtomicBool::new(false)),
|
|
event_emitter: Arc::new(Mutex::new(None)),
|
|
remote_volume,
|
|
}
|
|
}
|
|
|
|
/// 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).
|
|
///
|
|
/// 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.
|
|
///
|
|
/// Also owns **OS volume routing**, which is derived from the transition
|
|
/// rather than from each call site: entering remote mode attaches the remote
|
|
/// volume control, and *any* exit from remote mode hands it back to the local
|
|
/// speaker. Doing this per-call-site is what caused the bug where stopping a
|
|
/// remote session (`player_stop` → Idle) left Android stuck on the remote
|
|
/// volume slider — only the transfer-to-local path tore it down.
|
|
///
|
|
/// TRACES: UR-010 | DR-059, IR-021
|
|
pub fn set_mode(&self, mode: PlaybackMode) {
|
|
log::info!("[PlaybackMode] Setting mode to: {:?}", mode);
|
|
let (changed, was_remote) = {
|
|
let mut current = self.current_mode.write_safe();
|
|
let changed = *current != mode;
|
|
let was_remote = matches!(*current, PlaybackMode::Remote { .. });
|
|
*current = mode.clone();
|
|
(changed, was_remote)
|
|
};
|
|
|
|
if !changed {
|
|
return;
|
|
}
|
|
|
|
// Volume routing follows the transition. Note remote->remote (switching
|
|
// target session) re-arms rather than releasing control.
|
|
let is_remote = matches!(mode, PlaybackMode::Remote { .. });
|
|
if is_remote {
|
|
self.remote_volume.enable(DEFAULT_REMOTE_VOLUME);
|
|
} else if was_remote {
|
|
log::info!("[PlaybackMode] Leaving remote mode - restoring local volume control");
|
|
self.remote_volume.disable();
|
|
}
|
|
|
|
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,
|
|
});
|
|
}
|
|
}
|
|
|
|
/// Start the Android playback service and hand it remote-volume control.
|
|
///
|
|
/// Must run on EVERY transition into remote mode, because it is what starts
|
|
/// the foreground service. Without a running service there is no media
|
|
/// notification (the lockscreen card is missing) AND system volume buttons
|
|
/// aren't intercepted for the remote session (remote volume control dead).
|
|
/// Both symptoms share this one cause, so this must not be skipped on any
|
|
/// remote-entry path (notably the empty-queue early return in
|
|
/// `transfer_to_remote_inner`). No-op / non-Android builds do nothing.
|
|
///
|
|
/// [`set_mode`](Self::set_mode) already arms this on entry into remote mode;
|
|
/// calling it again is harmless (the service start is idempotent) and keeps
|
|
/// the guarantee when the mode was already remote, which `set_mode` skips as
|
|
/// a no-op transition.
|
|
fn enable_remote_control(&self) {
|
|
self.remote_volume.enable(DEFAULT_REMOTE_VOLUME);
|
|
}
|
|
|
|
/// Check if currently transferring
|
|
pub fn is_transferring(&self) -> bool {
|
|
self.is_transferring.load(Ordering::Relaxed)
|
|
}
|
|
|
|
/// Set the transferring flag directly.
|
|
///
|
|
/// The remote->local transfer is driven from the frontend in two steps
|
|
/// (`player_play_tracks` to start local playback, then
|
|
/// `playback_mode_transfer_to_local` to stop the remote). The first step's
|
|
/// routing depends on this flag: while it's set, `player_play_tracks` plays
|
|
/// locally instead of casting back to the remote session. The frontend must
|
|
/// raise the flag *before* that first call and lower it when the sequence is
|
|
/// done (or aborts), so it can't be left stuck on.
|
|
pub fn set_transferring(&self, transferring: bool) {
|
|
self.is_transferring.store(transferring, Ordering::Relaxed);
|
|
}
|
|
|
|
/// Send volume command to remote session
|
|
/// Commands: "SetVolume", "VolumeUp", "VolumeDown"
|
|
#[allow(dead_code)] // Called from Android JNI callback
|
|
pub async fn send_remote_volume_command(
|
|
&self,
|
|
command: &str,
|
|
volume: i32,
|
|
) -> Result<(), String> {
|
|
log::info!(
|
|
"[PlaybackMode] send_remote_volume_command ENTERED: command={}, volume={}",
|
|
command,
|
|
volume
|
|
);
|
|
|
|
// Get the current session ID
|
|
let session_id = match self.get_mode() {
|
|
PlaybackMode::Remote { session_id } => session_id,
|
|
_ => {
|
|
log::warn!("[PlaybackMode] Ignoring remote volume command - not in remote mode");
|
|
return Ok(());
|
|
}
|
|
};
|
|
|
|
log::info!(
|
|
"[PlaybackMode] Current mode is Remote, session_id={}",
|
|
session_id
|
|
);
|
|
|
|
// Get Jellyfin client
|
|
let client = {
|
|
log::info!("[PlaybackMode] Attempting to lock Jellyfin client...");
|
|
let client_opt = self.jellyfin_client.lock().map_err(|e| {
|
|
log::error!("[PlaybackMode] Failed to lock Jellyfin client: {}", e);
|
|
format!("Failed to lock Jellyfin client: {}", e)
|
|
})?;
|
|
|
|
log::info!("[PlaybackMode] Jellyfin client lock acquired");
|
|
|
|
match client_opt.as_ref() {
|
|
Some(c) => {
|
|
log::info!("[PlaybackMode] Jellyfin client is configured, cloning...");
|
|
c.clone()
|
|
}
|
|
None => {
|
|
log::error!("[PlaybackMode] Jellyfin client is NOT configured!");
|
|
return Err("Jellyfin client not configured".to_string());
|
|
}
|
|
}
|
|
};
|
|
|
|
log::info!("[PlaybackMode] About to call client.session_set_volume...");
|
|
|
|
// Send the volume command
|
|
log::info!(
|
|
"[PlaybackMode] Sending {} command to session {} (volume: {})",
|
|
command,
|
|
session_id,
|
|
volume
|
|
);
|
|
let result = client.session_set_volume(session_id, volume).await;
|
|
|
|
match &result {
|
|
Ok(_) => log::info!("[PlaybackMode] session_set_volume returned Ok"),
|
|
Err(e) => log::error!("[PlaybackMode] session_set_volume returned Err: {}", e),
|
|
}
|
|
|
|
result
|
|
}
|
|
|
|
/// Extract Jellyfin item IDs from queue items
|
|
/// Returns (item_ids, adjusted_current_index)
|
|
fn extract_jellyfin_ids(
|
|
&self,
|
|
items: &[crate::player::MediaItem],
|
|
original_index: usize,
|
|
) -> Result<(Vec<String>, usize), String> {
|
|
let mut jellyfin_ids: Vec<String> = Vec::new();
|
|
let mut adjusted_index: Option<usize> = None;
|
|
let mut jellyfin_item_count = 0;
|
|
|
|
for (i, item) in items.iter().enumerate() {
|
|
if let Some(id) = item.jellyfin_id() {
|
|
jellyfin_ids.push(id.to_string());
|
|
|
|
// If this is the currently playing item, record its new index
|
|
if i == original_index {
|
|
adjusted_index = Some(jellyfin_item_count);
|
|
}
|
|
|
|
jellyfin_item_count += 1;
|
|
}
|
|
}
|
|
|
|
// Ensure the currently playing item has a Jellyfin ID
|
|
let final_index = match adjusted_index {
|
|
Some(idx) => idx,
|
|
None => {
|
|
log::warn!(
|
|
"[PlaybackMode] Currently playing item (index {}) does not have a Jellyfin ID",
|
|
original_index
|
|
);
|
|
return Err(
|
|
"Cannot transfer: currently playing item is not from Jellyfin".to_string(),
|
|
);
|
|
}
|
|
};
|
|
|
|
log::info!(
|
|
"[PlaybackMode] Extracted {} Jellyfin IDs from queue (original index: {} -> adjusted: {})",
|
|
jellyfin_ids.len(),
|
|
original_index,
|
|
final_index
|
|
);
|
|
|
|
Ok((jellyfin_ids, final_index))
|
|
}
|
|
|
|
/// Transfer playback from local device to remote Jellyfin session
|
|
pub async fn transfer_to_remote(
|
|
&self,
|
|
session_id: String,
|
|
position_override: Option<f64>,
|
|
) -> Result<(), String> {
|
|
debug!("[PlaybackMode] transfer_to_remote ENTERED");
|
|
debug!("[PlaybackMode] session_id: {}", session_id);
|
|
log::info!(
|
|
"[PlaybackMode] Transferring to remote session: {}",
|
|
session_id
|
|
);
|
|
|
|
// Set transferring flag
|
|
debug!("[PlaybackMode] Setting is_transferring flag");
|
|
self.is_transferring.store(true, Ordering::Relaxed);
|
|
debug!("[PlaybackMode] Flag set, calling transfer_to_remote_inner");
|
|
|
|
// Perform the transfer
|
|
let result = self
|
|
.transfer_to_remote_inner(&session_id, position_override)
|
|
.await;
|
|
|
|
// Clear transferring flag
|
|
self.is_transferring.store(false, Ordering::Relaxed);
|
|
|
|
result
|
|
}
|
|
|
|
async fn transfer_to_remote_inner(
|
|
&self,
|
|
session_id: &str,
|
|
position_override: Option<f64>,
|
|
) -> Result<(), String> {
|
|
log::info!("[PlaybackMode] transfer_to_remote_inner ENTERED");
|
|
debug!(
|
|
"[PlaybackMode] transfer_to_remote_inner: session_id={}",
|
|
session_id
|
|
);
|
|
|
|
// If we're already controlling a remote session, that *old* session — not
|
|
// the idle local player — is the source of truth for the current track and
|
|
// position. Capture it so we can resume there and stop it afterwards.
|
|
let previous_remote_session = match self.get_mode() {
|
|
PlaybackMode::Remote { session_id: prev } if prev != session_id => Some(prev),
|
|
_ => None,
|
|
};
|
|
|
|
// Get current player state and queue context
|
|
let (queue_ids, mut current_index, mut position_seconds, queue_context) = {
|
|
log::info!("[PlaybackMode] Acquiring player controller lock...");
|
|
debug!("[PlaybackMode] Acquiring player controller lock...");
|
|
let player = self.player_controller.lock().await;
|
|
log::info!("[PlaybackMode] Player controller lock acquired");
|
|
debug!("[PlaybackMode] Player controller lock acquired");
|
|
|
|
let queue_arc = player.queue();
|
|
let queue = queue_arc.lock_safe();
|
|
|
|
let original_index = queue.current_index().unwrap_or(0);
|
|
let items = queue.items();
|
|
|
|
log::info!(
|
|
"[PlaybackMode] Queue has {} items, original_index={}",
|
|
items.len(),
|
|
original_index
|
|
);
|
|
debug!(
|
|
"[PlaybackMode] Queue has {} items, original_index={}",
|
|
items.len(),
|
|
original_index
|
|
);
|
|
|
|
// Log each item's jellyfin_id for debugging
|
|
for (i, item) in items.iter().enumerate() {
|
|
let jf_id = item.jellyfin_id().unwrap_or("NONE");
|
|
log::debug!(
|
|
"[PlaybackMode] Item {}: id={}, jellyfin_id={}",
|
|
i,
|
|
item.id,
|
|
jf_id
|
|
);
|
|
}
|
|
|
|
let (ids, adjusted_index) = self.extract_jellyfin_ids(items, original_index)?;
|
|
// Prefer the frontend-supplied position when available. The backend
|
|
// position is unreliable as a transfer source: on Linux, *video* plays
|
|
// in the HTML5 <video> element and the MPV backend is never loaded, so
|
|
// PlayerController::position() is always 0; only the frontend knows the
|
|
// true position. We fall back to the live backend position (correct for
|
|
// Linux audio via MPV) when the frontend doesn't pass one.
|
|
let position = match position_override {
|
|
Some(p) => {
|
|
log::info!("[PlaybackMode] Using frontend position override: {:.2}s", p);
|
|
p
|
|
}
|
|
None => player.position(),
|
|
};
|
|
let context = queue.context().clone();
|
|
|
|
log::info!(
|
|
"[PlaybackMode] Queue context: {:?}, {} items, current index: {}",
|
|
context,
|
|
ids.len(),
|
|
adjusted_index
|
|
);
|
|
debug!(
|
|
"[PlaybackMode] Extracted {} jellyfin IDs, adjusted_index={}, position={:.2}s",
|
|
ids.len(),
|
|
adjusted_index,
|
|
position
|
|
);
|
|
|
|
(ids, adjusted_index, position, context)
|
|
};
|
|
|
|
// If queue is empty, just switch mode
|
|
if queue_ids.is_empty() {
|
|
log::info!("[PlaybackMode] Queue is empty, just switching mode");
|
|
self.set_mode(PlaybackMode::Remote {
|
|
session_id: session_id.to_string(),
|
|
});
|
|
// Start the service + remote-volume control here too — otherwise this
|
|
// early return leaves remote mode with no media notification and no
|
|
// volume interception (lockscreen card missing + remote volume dead).
|
|
self.enable_remote_control();
|
|
return Ok(());
|
|
}
|
|
|
|
log::info!(
|
|
"[PlaybackMode] Queue has {} items, current index: {}, position: {:.2}s",
|
|
queue_ids.len(),
|
|
current_index,
|
|
position_seconds
|
|
);
|
|
|
|
// Get Jellyfin client for remote transfer
|
|
log::info!("[PlaybackMode] Getting Jellyfin client for transfer...");
|
|
debug!("[PlaybackMode] Getting Jellyfin client for transfer...");
|
|
let client = {
|
|
let client_opt = self.jellyfin_client.lock().map_err(|e| {
|
|
log::error!("[PlaybackMode] Failed to lock Jellyfin client: {}", e);
|
|
error!("[PlaybackMode] Failed to lock Jellyfin client: {}", e);
|
|
format!("Failed to lock Jellyfin client: {}", e)
|
|
})?;
|
|
|
|
match client_opt.as_ref() {
|
|
Some(c) => {
|
|
log::info!("[PlaybackMode] Jellyfin client is configured");
|
|
debug!("[PlaybackMode] Jellyfin client is configured");
|
|
c.clone()
|
|
}
|
|
None => {
|
|
log::error!("[PlaybackMode] Jellyfin client NOT configured!");
|
|
error!("[PlaybackMode] Jellyfin client NOT configured!");
|
|
return Err("Jellyfin client not configured".to_string());
|
|
}
|
|
}
|
|
};
|
|
|
|
// Remote -> remote switch: take the current track and position from the
|
|
// session we're leaving, since the local player is idle and reports 0.
|
|
if let Some(ref prev_session_id) = previous_remote_session {
|
|
log::info!(
|
|
"[PlaybackMode] Remote->remote switch; reading state from previous session {}",
|
|
prev_session_id
|
|
);
|
|
match client.get_session(prev_session_id).await {
|
|
Ok(Some(session)) => {
|
|
// Resume at the previous session's position.
|
|
if let Some(ticks) =
|
|
session.play_state.as_ref().and_then(|ps| ps.position_ticks)
|
|
{
|
|
position_seconds = ticks as f64 / TICKS_PER_SECOND;
|
|
log::info!(
|
|
"[PlaybackMode] Using previous remote position: {:.2}s",
|
|
position_seconds
|
|
);
|
|
}
|
|
// Resume on whichever track the previous session reached.
|
|
if let Some(now_id) = session
|
|
.now_playing_item
|
|
.as_ref()
|
|
.and_then(|i| i.id.as_deref())
|
|
{
|
|
if let Some(idx) = queue_ids.iter().position(|id| id == now_id) {
|
|
log::info!(
|
|
"[PlaybackMode] Previous session is on track {} (queue index {})",
|
|
now_id,
|
|
idx
|
|
);
|
|
current_index = idx;
|
|
} else {
|
|
log::warn!(
|
|
"[PlaybackMode] Previous session's track {} not found in queue; keeping index {}",
|
|
now_id,
|
|
current_index
|
|
);
|
|
}
|
|
}
|
|
}
|
|
Ok(None) => log::warn!(
|
|
"[PlaybackMode] Previous remote session not found while reading state"
|
|
),
|
|
Err(e) => log::warn!(
|
|
"[PlaybackMode] Failed to read previous remote session: {}",
|
|
e
|
|
),
|
|
}
|
|
}
|
|
|
|
// Calculate position in ticks (from the live position read above)
|
|
let start_position_ticks = start_position_ticks_from_seconds(position_seconds);
|
|
|
|
// Log queue context for debugging (context is tracked but we always send track IDs)
|
|
match &queue_context {
|
|
QueueContext::Album {
|
|
album_id,
|
|
album_name,
|
|
} => {
|
|
log::info!(
|
|
"[PlaybackMode] Transferring album '{}' (ID: {}) with {} tracks to remote",
|
|
album_name,
|
|
album_id,
|
|
queue_ids.len()
|
|
);
|
|
}
|
|
QueueContext::Playlist {
|
|
playlist_id,
|
|
playlist_name,
|
|
} => {
|
|
log::info!(
|
|
"[PlaybackMode] Transferring playlist '{}' (ID: {}) with {} tracks to remote",
|
|
playlist_name,
|
|
playlist_id,
|
|
queue_ids.len()
|
|
);
|
|
}
|
|
QueueContext::Custom => {
|
|
log::info!(
|
|
"[PlaybackMode] Transferring custom queue with {} tracks to remote",
|
|
queue_ids.len()
|
|
);
|
|
}
|
|
}
|
|
|
|
// Always send individual track IDs - Jellyfin's play_on_session expects track IDs,
|
|
// not album/playlist container IDs
|
|
let expected_item_id = queue_ids
|
|
.get(current_index)
|
|
.cloned()
|
|
.ok_or("Invalid start index")?;
|
|
|
|
// Send play command to remote session with all track IDs
|
|
log::info!(
|
|
"[PlaybackMode] Sending play command to remote session: {} ({} tracks, starting at index {}, position: {:.2}s)",
|
|
session_id,
|
|
queue_ids.len(),
|
|
current_index,
|
|
position_seconds
|
|
);
|
|
debug!(
|
|
"[PlaybackMode] Calling play_on_session: session={}, tracks={}, index={}, position_ticks={:?}",
|
|
session_id,
|
|
queue_ids.len(),
|
|
current_index,
|
|
start_position_ticks
|
|
);
|
|
|
|
// Log first few track IDs for debugging
|
|
if !queue_ids.is_empty() {
|
|
let preview: Vec<&str> = queue_ids.iter().take(3).map(|s| s.as_str()).collect();
|
|
debug!("[PlaybackMode] First track IDs: {:?}...", preview);
|
|
}
|
|
|
|
client
|
|
.play_on_session(
|
|
session_id.to_string(),
|
|
queue_ids.clone(),
|
|
current_index,
|
|
start_position_ticks,
|
|
)
|
|
.await
|
|
.map_err(|e| {
|
|
log::error!("[PlaybackMode] Failed to send play command: {}", e);
|
|
error!("[PlaybackMode] Failed to send play command: {}", e);
|
|
format!("Failed to start playback on remote session: {}", e)
|
|
})?;
|
|
|
|
log::info!("[PlaybackMode] Play command sent successfully");
|
|
info!("[PlaybackMode] Play command sent successfully to remote session");
|
|
|
|
// Wait for remote session to load the track (poll with timeout)
|
|
log::info!("[PlaybackMode] Waiting for remote session to load track...");
|
|
|
|
let mut attempts = 0;
|
|
let max_attempts = 50; // 5 seconds max (50 * 100ms)
|
|
let mut track_loaded = false;
|
|
|
|
while attempts < max_attempts {
|
|
sleep(Duration::from_millis(100)).await;
|
|
attempts += 1;
|
|
|
|
match client.get_session(session_id).await {
|
|
Ok(Some(session)) => {
|
|
if let Some(now_playing) = &session.now_playing_item {
|
|
if now_playing.id.as_deref() == Some(&expected_item_id) {
|
|
log::info!(
|
|
"[PlaybackMode] Remote session loaded track '{}' after {}ms",
|
|
now_playing.name.as_deref().unwrap_or("Unknown"),
|
|
attempts * 100
|
|
);
|
|
track_loaded = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
Ok(None) => {
|
|
log::warn!("[PlaybackMode] Remote session not found while polling");
|
|
return Err("Remote session not found".to_string());
|
|
}
|
|
Err(e) => {
|
|
log::warn!(
|
|
"[PlaybackMode] Error polling session (attempt {}): {}",
|
|
attempts,
|
|
e
|
|
);
|
|
// Continue polling - transient errors are OK
|
|
}
|
|
}
|
|
}
|
|
|
|
if !track_loaded {
|
|
log::error!("[PlaybackMode] Timeout waiting for remote session to load track");
|
|
return Err("Remote session did not load track in time".to_string());
|
|
}
|
|
|
|
// Resume at the right position. We send StartPositionTicks in the play
|
|
// command above, but some Jellyfin client/server combinations ignore it
|
|
// and start from 0. Now that the track is confirmed loaded, issue an
|
|
// explicit seek as well (mirrors how the local resume path works). This
|
|
// is the reliable mechanism; StartPositionTicks is best-effort.
|
|
if let Some(ticks) = start_position_ticks {
|
|
log::info!(
|
|
"[PlaybackMode] Seeking remote session to resume position: {} ticks",
|
|
ticks
|
|
);
|
|
if let Err(e) = client.session_seek(session_id.to_string(), ticks).await {
|
|
// Non-fatal: the track is already playing, just not at the
|
|
// resume point. Log and continue rather than failing the transfer.
|
|
log::warn!("[PlaybackMode] Resume seek on remote failed: {}", e);
|
|
}
|
|
}
|
|
|
|
// Remote -> remote switch: stop the session we just left so we don't end
|
|
// up with two devices playing at once. Do this only after the new session
|
|
// is confirmed playing, so a failure here doesn't leave us with silence.
|
|
if let Some(prev_session_id) = previous_remote_session {
|
|
log::info!(
|
|
"[PlaybackMode] Stopping previous remote session {}",
|
|
prev_session_id
|
|
);
|
|
if let Err(e) = client.send_session_command(prev_session_id, "Stop").await {
|
|
log::warn!(
|
|
"[PlaybackMode] Failed to stop previous remote session: {}",
|
|
e
|
|
);
|
|
}
|
|
}
|
|
|
|
// Stop local playback (queue should remain intact for remote session)
|
|
log::info!("[PlaybackMode] Stopping local playback - queue should NOT be cleared");
|
|
{
|
|
let player = self.player_controller.lock().await;
|
|
|
|
// Log queue state BEFORE stop
|
|
{
|
|
let queue_arc = player.queue();
|
|
let queue = queue_arc.lock_safe();
|
|
info!(
|
|
"[PlaybackMode] BEFORE STOP: Queue has {} items, current_index={:?}",
|
|
queue.items().len(),
|
|
queue.current_index()
|
|
);
|
|
}
|
|
|
|
player
|
|
.stop()
|
|
.map_err(|e| format!("Failed to stop playback: {}", e))?;
|
|
|
|
// Log queue state AFTER stop (should be unchanged)
|
|
{
|
|
let queue_arc = player.queue();
|
|
let queue = queue_arc.lock_safe();
|
|
info!(
|
|
"[PlaybackMode] AFTER STOP: Queue has {} items, current_index={:?}",
|
|
queue.items().len(),
|
|
queue.current_index()
|
|
);
|
|
}
|
|
}
|
|
|
|
// Update mode to remote
|
|
self.set_mode(PlaybackMode::Remote {
|
|
session_id: session_id.to_string(),
|
|
});
|
|
|
|
// Start the service + remote-volume control (intercepts volume buttons,
|
|
// and starts the foreground service that renders the lockscreen card).
|
|
self.enable_remote_control();
|
|
|
|
log::info!("[PlaybackMode] Successfully transferred to remote");
|
|
Ok(())
|
|
}
|
|
|
|
/// Transfer playback from remote session back to local device
|
|
pub async fn transfer_to_local(
|
|
&self,
|
|
current_item_id: String,
|
|
position_ticks: i64,
|
|
) -> Result<(), String> {
|
|
log::info!("[PlaybackMode] Transferring to local playback");
|
|
|
|
// Set transferring flag
|
|
self.is_transferring.store(true, Ordering::Relaxed);
|
|
|
|
// Perform the transfer
|
|
let result = self
|
|
.transfer_to_local_inner(¤t_item_id, position_ticks)
|
|
.await;
|
|
|
|
// Clear transferring flag
|
|
self.is_transferring.store(false, Ordering::Relaxed);
|
|
|
|
result
|
|
}
|
|
|
|
async fn transfer_to_local_inner(
|
|
&self,
|
|
current_item_id: &str,
|
|
position_ticks: i64,
|
|
) -> Result<(), String> {
|
|
// Get current remote session info
|
|
let session_id = match self.get_mode() {
|
|
PlaybackMode::Remote { session_id } => session_id,
|
|
_ => return Err("Not in remote playback mode".to_string()),
|
|
};
|
|
|
|
let position_seconds = position_ticks as f64 / 10_000_000.0;
|
|
|
|
log::info!(
|
|
"[PlaybackMode] Transfer to local: session={}, item_id={}, position={:.2}s",
|
|
session_id,
|
|
current_item_id,
|
|
position_seconds
|
|
);
|
|
|
|
// Get Jellyfin client for stopping remote playback
|
|
let client = {
|
|
let client_opt = self
|
|
.jellyfin_client
|
|
.lock()
|
|
.map_err(|e| format!("Failed to lock Jellyfin client: {}", e))?;
|
|
|
|
client_opt
|
|
.as_ref()
|
|
.ok_or("Jellyfin client not configured")?
|
|
.clone()
|
|
};
|
|
|
|
// Stop remote playback
|
|
log::info!(
|
|
"[PlaybackMode] Stopping remote playback on session: {}",
|
|
session_id
|
|
);
|
|
match client
|
|
.send_session_command(session_id.clone(), "Stop")
|
|
.await
|
|
{
|
|
Ok(_) => log::info!("[PlaybackMode] Stop command sent successfully"),
|
|
Err(e) => {
|
|
log::warn!(
|
|
"[PlaybackMode] Failed to stop remote session (non-fatal): {}",
|
|
e
|
|
);
|
|
// Don't fail the transfer if we can't stop the remote session
|
|
// The user is already playing locally, so this is not critical
|
|
}
|
|
}
|
|
|
|
// For now, we'll return an error indicating that the TypeScript side needs to handle
|
|
// loading the media item, since we don't have access to the repository here yet.
|
|
// This will be improved in Phase 3 when repository is migrated to Rust.
|
|
log::debug!("[PlaybackMode] Cannot load media item in Rust yet - frontend handled it");
|
|
|
|
// Update mode to local. This also returns volume control to the local
|
|
// device speaker — set_mode owns that for every exit from remote mode.
|
|
self.set_mode(PlaybackMode::Local);
|
|
|
|
log::info!("[PlaybackMode] Successfully transferred to local");
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// Test PlaybackMode enum serialization to JSON
|
|
///
|
|
/// @req-test: DR-003 - Playback mode manager (Local/Remote/Idle states)
|
|
/// @req-test: UR-010 - Control playback of Jellyfin remote sessions
|
|
#[test]
|
|
fn test_playback_mode_serialization() {
|
|
let mode_idle = PlaybackMode::Idle;
|
|
let json = serde_json::to_string(&mode_idle).unwrap();
|
|
assert_eq!(json, r#"{"type":"idle"}"#);
|
|
|
|
let mode_local = PlaybackMode::Local;
|
|
let json = serde_json::to_string(&mode_local).unwrap();
|
|
assert_eq!(json, r#"{"type":"local"}"#);
|
|
|
|
let mode_remote = PlaybackMode::Remote {
|
|
session_id: "abc123".to_string(),
|
|
};
|
|
let json = serde_json::to_string(&mode_remote).unwrap();
|
|
assert!(json.contains(r#""type":"remote""#));
|
|
assert!(json.contains(r#""session_id":"abc123""#));
|
|
}
|
|
|
|
/// Test PlaybackMode enum deserialization from JSON
|
|
///
|
|
/// @req-test: DR-003 - Playback mode manager (Local/Remote/Idle states)
|
|
#[test]
|
|
fn test_playback_mode_deserialization() {
|
|
let json = r#"{"type":"idle"}"#;
|
|
let mode: PlaybackMode = serde_json::from_str(json).unwrap();
|
|
assert_eq!(mode, PlaybackMode::Idle);
|
|
|
|
let json = r#"{"type":"local"}"#;
|
|
let mode: PlaybackMode = serde_json::from_str(json).unwrap();
|
|
assert_eq!(mode, PlaybackMode::Local);
|
|
|
|
let json = r#"{"type":"remote","session_id":"test_session"}"#;
|
|
let mode: PlaybackMode = serde_json::from_str(json).unwrap();
|
|
assert_eq!(
|
|
mode,
|
|
PlaybackMode::Remote {
|
|
session_id: "test_session".to_string()
|
|
}
|
|
);
|
|
}
|
|
|
|
/// 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_safe().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_safe();
|
|
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),
|
|
}
|
|
}
|
|
|
|
/// Records enable/disable calls so tests can assert volume routing.
|
|
struct RecordingVolumeControl {
|
|
calls: Mutex<Vec<&'static str>>,
|
|
}
|
|
|
|
impl RemoteVolumeControl for RecordingVolumeControl {
|
|
fn enable(&self, _initial_volume: i32) {
|
|
self.calls.lock_safe().push("enable");
|
|
}
|
|
fn disable(&self) {
|
|
self.calls.lock_safe().push("disable");
|
|
}
|
|
}
|
|
|
|
fn manager_with_volume_control() -> (PlaybackModeManager, Arc<RecordingVolumeControl>) {
|
|
let volume = Arc::new(RecordingVolumeControl {
|
|
calls: Mutex::new(Vec::new()),
|
|
});
|
|
let manager = PlaybackModeManager::with_remote_volume(
|
|
Arc::new(Mutex::new(None)),
|
|
Arc::new(TokioMutex::new(crate::player::PlayerController::default())),
|
|
volume.clone(),
|
|
);
|
|
(manager, volume)
|
|
}
|
|
|
|
/// Leaving remote mode must hand volume control back to the local device.
|
|
///
|
|
/// Stopping a remote session (`player_stop`) drives the manager
|
|
/// Remote -> Idle without going through `transfer_to_local`. Before this was
|
|
/// centralised in `set_mode`, only the transfer path tore the Android
|
|
/// `VolumeProviderCompat` down, so a plain stop left the system stuck on the
|
|
/// remote volume slider with no way back to the phone speaker.
|
|
///
|
|
/// @req-test: UR-010 - Control playback of Jellyfin remote sessions
|
|
#[test]
|
|
fn test_leaving_remote_mode_restores_local_volume() {
|
|
let (manager, volume) = manager_with_volume_control();
|
|
|
|
manager.set_mode(PlaybackMode::Remote {
|
|
session_id: "sess-1".to_string(),
|
|
});
|
|
// The stop path: remote -> idle, no transfer involved.
|
|
manager.set_mode(PlaybackMode::Idle);
|
|
|
|
assert_eq!(
|
|
*volume.calls.lock_safe(),
|
|
vec!["enable", "disable"],
|
|
"remote->idle must return volume control to the local speaker"
|
|
);
|
|
}
|
|
|
|
/// The same must hold for remote -> local (transfer back to this device).
|
|
///
|
|
/// @req-test: UR-010 - Control playback of Jellyfin remote sessions
|
|
#[test]
|
|
fn test_remote_to_local_restores_local_volume() {
|
|
let (manager, volume) = manager_with_volume_control();
|
|
|
|
manager.set_mode(PlaybackMode::Remote {
|
|
session_id: "sess-1".to_string(),
|
|
});
|
|
manager.set_mode(PlaybackMode::Local);
|
|
|
|
assert_eq!(
|
|
*volume.calls.lock_safe(),
|
|
vec!["enable", "disable"],
|
|
"remote->local must return volume control to the local speaker"
|
|
);
|
|
}
|
|
|
|
/// Volume routing must not be touched by transitions that never involve
|
|
/// remote mode — an idle->local start would otherwise issue a pointless
|
|
/// `setPlaybackToLocal` on every playback start.
|
|
///
|
|
/// @req-test: UR-010 - Control playback of Jellyfin remote sessions
|
|
#[test]
|
|
fn test_non_remote_transitions_leave_volume_routing_alone() {
|
|
let (manager, volume) = manager_with_volume_control();
|
|
|
|
manager.set_mode(PlaybackMode::Local);
|
|
manager.set_mode(PlaybackMode::Idle);
|
|
manager.set_mode(PlaybackMode::Local);
|
|
|
|
assert!(
|
|
volume.calls.lock_safe().is_empty(),
|
|
"local/idle transitions must not touch remote volume routing"
|
|
);
|
|
}
|
|
|
|
/// Switching directly between two remote sessions stays remote: control must
|
|
/// remain attached (re-armed for the new session), never handed back local.
|
|
///
|
|
/// @req-test: UR-010 - Control playback of Jellyfin remote sessions
|
|
#[test]
|
|
fn test_remote_to_remote_keeps_remote_volume() {
|
|
let (manager, volume) = manager_with_volume_control();
|
|
|
|
manager.set_mode(PlaybackMode::Remote {
|
|
session_id: "sess-1".to_string(),
|
|
});
|
|
manager.set_mode(PlaybackMode::Remote {
|
|
session_id: "sess-2".to_string(),
|
|
});
|
|
|
|
assert_eq!(
|
|
*volume.calls.lock_safe(),
|
|
vec!["enable", "enable"],
|
|
"remote->remote re-arms control without releasing it to local"
|
|
);
|
|
}
|
|
|
|
/// 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_safe().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).
|
|
#[test]
|
|
fn test_start_position_ticks_from_seconds() {
|
|
// Mid-track positions convert to ticks (10M ticks per second).
|
|
assert_eq!(start_position_ticks_from_seconds(5.0), Some(50_000_000));
|
|
assert_eq!(
|
|
start_position_ticks_from_seconds(123.45),
|
|
Some(1_234_500_000)
|
|
);
|
|
|
|
// At/near the start, send no resume position so the track casts from 0.
|
|
assert_eq!(start_position_ticks_from_seconds(0.0), None);
|
|
assert_eq!(start_position_ticks_from_seconds(0.5), None);
|
|
|
|
// Just past the threshold resumes rather than restarting.
|
|
assert!(start_position_ticks_from_seconds(0.6).is_some());
|
|
}
|
|
|
|
// Tests for extract_jellyfin_ids - verify all track IDs are sent to remote, not just album/playlist ID
|
|
mod extract_jellyfin_ids_tests {
|
|
use crate::player::{MediaItem, MediaSource, MediaType};
|
|
use std::sync::{Arc, Mutex};
|
|
use tokio::sync::Mutex as TokioMutex;
|
|
|
|
fn create_test_item_with_jellyfin_id(id: &str, jellyfin_id: &str) -> MediaItem {
|
|
MediaItem {
|
|
id: id.to_string(),
|
|
title: format!("Track {}", id),
|
|
name: Some(format!("Track {}", id)),
|
|
artist: Some("Test Artist".to_string()),
|
|
album: Some("Test Album".to_string()),
|
|
album_name: Some("Test Album".to_string()),
|
|
album_id: Some("album_123".to_string()),
|
|
artist_items: None,
|
|
artists: Some(vec!["Test Artist".to_string()]),
|
|
primary_image_tag: None,
|
|
image_id: None,
|
|
item_type: Some("Audio".to_string()),
|
|
playlist_id: None,
|
|
duration: Some(180.0),
|
|
artwork_url: None,
|
|
media_type: MediaType::Audio,
|
|
source: MediaSource::Remote {
|
|
stream_url: format!("http://example.com/{}.mp3", id),
|
|
jellyfin_item_id: jellyfin_id.to_string(),
|
|
},
|
|
video_codec: None,
|
|
needs_transcoding: false,
|
|
video_width: None,
|
|
video_height: None,
|
|
subtitles: vec![],
|
|
series_id: None,
|
|
server_id: None,
|
|
}
|
|
}
|
|
|
|
fn create_test_item_local(id: &str) -> MediaItem {
|
|
MediaItem {
|
|
id: id.to_string(),
|
|
title: format!("Local Track {}", id),
|
|
name: Some(format!("Local Track {}", id)),
|
|
artist: Some("Test Artist".to_string()),
|
|
album: None,
|
|
album_name: None,
|
|
album_id: None,
|
|
artist_items: None,
|
|
artists: Some(vec!["Test Artist".to_string()]),
|
|
primary_image_tag: None,
|
|
image_id: None,
|
|
item_type: Some("Audio".to_string()),
|
|
playlist_id: None,
|
|
duration: Some(180.0),
|
|
artwork_url: None,
|
|
media_type: MediaType::Audio,
|
|
source: MediaSource::DirectUrl {
|
|
url: format!("http://example.com/{}.mp3", id),
|
|
},
|
|
video_codec: None,
|
|
needs_transcoding: false,
|
|
video_width: None,
|
|
video_height: None,
|
|
subtitles: vec![],
|
|
series_id: None,
|
|
server_id: None,
|
|
}
|
|
}
|
|
|
|
/// Test extracting all Jellyfin track IDs from album
|
|
///
|
|
/// Verifies that all individual track IDs are extracted, not just the album ID.
|
|
///
|
|
/// @req-test: UR-010 - Control playback of Jellyfin remote sessions
|
|
/// @req-test: DR-003 - Playback mode manager (Jellyfin ID extraction)
|
|
/// @req-test: IR-012 - Jellyfin Sessions API for remote playback control
|
|
#[test]
|
|
fn test_extract_all_jellyfin_ids_from_album() {
|
|
// Simulate an album with 5 tracks - all should be extracted
|
|
let items: Vec<MediaItem> = (1..=5)
|
|
.map(|i| {
|
|
create_test_item_with_jellyfin_id(
|
|
&format!("track_{}", i),
|
|
&format!("jf_track_{}", i),
|
|
)
|
|
})
|
|
.collect();
|
|
|
|
let manager = super::PlaybackModeManager::new(
|
|
Arc::new(Mutex::new(None)),
|
|
Arc::new(TokioMutex::new(crate::player::PlayerController::default())),
|
|
);
|
|
|
|
let result = manager.extract_jellyfin_ids(&items, 2);
|
|
assert!(result.is_ok());
|
|
|
|
let (ids, index) = result.unwrap();
|
|
|
|
// All 5 track IDs should be extracted (not just the album ID)
|
|
assert_eq!(ids.len(), 5, "All 5 track IDs should be extracted");
|
|
assert_eq!(ids[0], "jf_track_1");
|
|
assert_eq!(ids[1], "jf_track_2");
|
|
assert_eq!(ids[2], "jf_track_3");
|
|
assert_eq!(ids[3], "jf_track_4");
|
|
assert_eq!(ids[4], "jf_track_5");
|
|
|
|
// Index should point to track 3 (original index 2)
|
|
assert_eq!(index, 2, "Current index should be preserved");
|
|
}
|
|
|
|
/// Test extracting Jellyfin IDs filters out local items
|
|
///
|
|
/// @req-test: UR-010 - Control playback of remote sessions (local filtering)
|
|
/// @req-test: DR-003 - Playback mode manager (local item filtering)
|
|
#[test]
|
|
fn test_extract_filters_local_items() {
|
|
// Mix of Jellyfin and local items - only Jellyfin items should be extracted
|
|
let items = vec![
|
|
create_test_item_with_jellyfin_id("1", "jf_1"),
|
|
create_test_item_local("2"), // Local, no Jellyfin ID
|
|
create_test_item_with_jellyfin_id("3", "jf_3"),
|
|
create_test_item_with_jellyfin_id("4", "jf_4"),
|
|
];
|
|
|
|
let manager = super::PlaybackModeManager::new(
|
|
Arc::new(Mutex::new(None)),
|
|
Arc::new(TokioMutex::new(crate::player::PlayerController::default())),
|
|
);
|
|
|
|
// Playing track 3 (index 2 in original, should become index 1 after filtering)
|
|
let result = manager.extract_jellyfin_ids(&items, 2);
|
|
assert!(result.is_ok());
|
|
|
|
let (ids, index) = result.unwrap();
|
|
|
|
// Only 3 Jellyfin tracks should be extracted
|
|
assert_eq!(ids.len(), 3);
|
|
assert_eq!(ids[0], "jf_1");
|
|
assert_eq!(ids[1], "jf_3");
|
|
assert_eq!(ids[2], "jf_4");
|
|
|
|
// Index should be adjusted (track 3 is now at position 1)
|
|
assert_eq!(index, 1);
|
|
}
|
|
|
|
/// Test extraction fails when current item is local
|
|
///
|
|
/// @req-test: DR-003 - Playback mode manager (error handling)
|
|
/// @req-test: UR-010 - Control playback of remote sessions (validation)
|
|
#[test]
|
|
fn test_extract_fails_when_current_item_is_local() {
|
|
// Current item has no Jellyfin ID - should fail
|
|
let items = vec![
|
|
create_test_item_with_jellyfin_id("1", "jf_1"),
|
|
create_test_item_local("2"), // Local, no Jellyfin ID
|
|
create_test_item_with_jellyfin_id("3", "jf_3"),
|
|
];
|
|
|
|
let manager = super::PlaybackModeManager::new(
|
|
Arc::new(Mutex::new(None)),
|
|
Arc::new(TokioMutex::new(crate::player::PlayerController::default())),
|
|
);
|
|
|
|
// Playing the local track (index 1) should fail
|
|
let result = manager.extract_jellyfin_ids(&items, 1);
|
|
assert!(result.is_err());
|
|
assert!(result.unwrap_err().contains("not from Jellyfin"));
|
|
}
|
|
|
|
/// Test extraction fails on empty queue
|
|
///
|
|
/// @req-test: DR-003 - Playback mode manager (edge case: empty queue)
|
|
#[test]
|
|
fn test_extract_empty_queue() {
|
|
let items: Vec<MediaItem> = vec![];
|
|
|
|
let manager = super::PlaybackModeManager::new(
|
|
Arc::new(Mutex::new(None)),
|
|
Arc::new(TokioMutex::new(crate::player::PlayerController::default())),
|
|
);
|
|
|
|
let result = manager.extract_jellyfin_ids(&items, 0);
|
|
assert!(result.is_err());
|
|
}
|
|
}
|
|
}
|