Files
jellytau/src-tauri/src/playback_mode/mod.rs
T
dtourolle f1d25c4f4d Add support for fusing/unfusing JellyLMS zones into synchronized
multi-room groups, addressed by MAC (derived from the `lms-{mac}` device id).
2026-06-26 19:27:37 +02:00

901 lines
36 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, 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;
/// 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
}
}
/// 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>,
}
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)),
}
}
/// Get current playback mode
pub fn get_mode(&self) -> PlaybackMode {
self.current_mode.read_safe().clone()
}
/// Set playback mode (internal use)
pub fn set_mode(&self, mode: PlaybackMode) {
log::info!("[PlaybackMode] Setting mode to: {:?}", mode);
let mut current = self.current_mode.write_safe();
*current = mode;
}
/// 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(),
});
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.len() > 0 {
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(),
});
// Enable remote volume control on Android (intercepts volume buttons)
#[cfg(target_os = "android")]
{
if let Err(e) = crate::player::enable_remote_volume(50) {
log::warn!("[PlaybackMode] Failed to enable remote volume: {}", e);
// Non-fatal - continue with transfer
}
}
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(&current_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
self.set_mode(PlaybackMode::Local);
// Disable remote volume control on Android (return to system volume)
#[cfg(target_os = "android")]
{
if let Err(e) = crate::player::disable_remote_volume() {
log::warn!("[PlaybackMode] Failed to disable remote volume: {}", e);
// Non-fatal - continue with transfer
}
}
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()
}
);
}
/// 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,
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,
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());
}
}
}