jellytau/src-tauri/src/player/mpv_backend.rs
Duncan Tourolle 6866f03c55 Architecture remediation A/B/F: poison-tolerant locks, graceful backend init, doc fixes
Workstream A — poison-tolerant locking:
- Add utils/lock.rs with MutexSafe/RwLockSafe extension traits that recover a
  poisoned std::sync lock instead of panicking, plus unit tests.
- Replace all 153 .lock().unwrap() and 4 .read()/.write().unwrap() production
  sites with _safe variants across 14 files, eliminating the player
  crash-cascade class. Tokio async mutexes are unchanged.

Workstream B — graceful backend init:
- create_player_backend no longer panics when MPV/ExoPlayer fail to initialize;
  it falls back to NullBackend and emits a backend-init-failed event so the UI
  can show "playback unavailable" instead of the app crashing. Fatal DB-setup
  panics are kept.

Workstream F — doc reconciliation:
- Rewrite software-architecture.md's inaccurate "thin UI / ~800 lines" claims to
  reflect reality (~20.5k non-test frontend) and document the events+polling
  hybrid plus the new locking/backend-init behavior.
2026-06-20 16:03:54 +02:00

550 lines
22 KiB
Rust

use crate::utils::lock::MutexSafe;
use log::{debug, error, info, warn};
use super::backend::{PlayerBackend, PlayerError};
use super::events::{PlayerEventEmitter, PlayerStatusEvent};
use super::media::{MediaItem, MediaSource};
use super::state::PlayerState;
use crate::settings::AudioSettings;
use crate::playback_reporting::{PlaybackReporter, EventThrottler, PlaybackOperation};
use crate::utils::conversions::{seconds_to_ticks, volume_to_percent};
use libmpv::Mpv;
use std::process::Command;
use std::sync::{Arc, Mutex};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tokio::sync::Mutex as TokioMutex;
/// MPV-based player backend for Linux
///
/// Uses libmpv for audio playback with full control over playback state,
/// position tracking, and event handling.
pub struct MpvBackend {
mpv: Arc<Mpv>,
state: Arc<Mutex<InternalState>>,
event_emitter: Option<Arc<dyn PlayerEventEmitter>>,
audio_settings: AudioSettings,
playback_reporter: Arc<TokioMutex<Option<PlaybackReporter>>>,
position_throttler: Arc<EventThrottler>,
last_seek_time: Arc<AtomicU64>,
}
struct InternalState {
current_media: Option<MediaItem>,
volume: f32,
}
/// Detect which audio system is available on the system
fn detect_audio_system() -> String {
info!("[MpvBackend] Detecting audio system...");
// Try PulseAudio/PipeWire first (most common on modern Linux)
if let Ok(output) = Command::new("pactl").arg("info").output() {
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
if stdout.contains("PipeWire") {
info!("[MpvBackend] Detected PipeWire (with PulseAudio compatibility)");
return "pulse".to_string();
} else if stdout.contains("PulseAudio") {
info!("[MpvBackend] Detected PulseAudio");
return "pulse".to_string();
}
}
}
// Try detecting PipeWire directly
if let Ok(output) = Command::new("pw-cli").arg("info").arg("0").output() {
if output.status.success() {
info!("[MpvBackend] Detected PipeWire");
return "pulse".to_string(); // PipeWire works with pulse driver
}
}
// Check if ALSA is available
if std::path::Path::new("/proc/asound/cards").exists() {
info!("[MpvBackend] Falling back to ALSA");
return "alsa".to_string();
}
// Default fallback
warn!("[MpvBackend] Could not detect audio system, using 'auto'");
"auto".to_string()
}
/// Helper to get stream URL from MediaItem
fn get_stream_url(media: &MediaItem) -> String {
match &media.source {
MediaSource::Remote { stream_url, .. } => stream_url.clone(),
MediaSource::Local { file_path, .. } => {
format!("file://{}", file_path.to_string_lossy())
}
MediaSource::DirectUrl { url } => url.clone(),
}
}
impl MpvBackend {
/// Create a new MPV backend
pub fn new(
event_emitter: Option<Arc<dyn PlayerEventEmitter>>,
playback_reporter: Arc<TokioMutex<Option<PlaybackReporter>>>,
position_throttler: Arc<EventThrottler>,
) -> Result<Self, PlayerError> {
info!("[MpvBackend] Initializing MPV backend...");
// MPV requires LC_NUMERIC to be set to "C" locale
// Set it before initializing MPV, then restore it after
use std::ffi::CString;
unsafe {
let c_locale = CString::new("C").unwrap();
libc::setlocale(libc::LC_NUMERIC, c_locale.as_ptr());
}
let mpv = Mpv::new().map_err(|e| PlayerError {
message: format!("Failed to initialize MPV: {:?}", e),
})?;
// Detect and configure audio output
let audio_driver = detect_audio_system();
info!("[MpvBackend] Configuring audio output driver: {}", audio_driver);
mpv.set_property("ao", audio_driver.as_str())
.map_err(|e| PlayerError {
message: format!("Failed to set audio output to '{}': {:?}. Make sure audio system is working.", audio_driver, e),
})?;
// Enable verbose logging for audio initialization
mpv.set_property("msg-level", "all=warn,ao=debug")
.unwrap_or_else(|e| {
warn!("[MpvBackend] Warning: Could not set MPV log level: {:?}", e);
});
// Configure MPV for audio playback
mpv.set_property("audio-display", "no")
.map_err(|e| PlayerError {
message: format!("Failed to configure MPV audio-display: {:?}", e),
})?;
mpv.set_property("video", "no")
.map_err(|e| PlayerError {
message: format!("Failed to configure MPV video: {:?}", e),
})?;
// Set volume to 100% (we'll control via MPV's volume property)
mpv.set_property("volume", 100i64)
.map_err(|e| PlayerError {
message: format!("Failed to set initial volume: {:?}", e),
})?;
let state = Arc::new(Mutex::new(InternalState {
current_media: None,
volume: 1.0,
}));
let backend = MpvBackend {
mpv: Arc::new(mpv),
state,
event_emitter,
audio_settings: AudioSettings::default(),
playback_reporter,
position_throttler,
last_seek_time: Arc::new(AtomicU64::new(0)),
};
// Start event loop in background thread
backend.start_event_loop();
info!("[MpvBackend] Initialized successfully");
Ok(backend)
}
/// Start the MPV event loop in a background thread
fn start_event_loop(&self) {
let mpv = self.mpv.clone();
let event_emitter = self.event_emitter.clone();
let state = self.state.clone();
let reporter = self.playback_reporter.clone();
let throttler = self.position_throttler.clone();
std::thread::spawn(move || {
info!("[MpvBackend] Event loop started");
let mut ev_ctx = mpv.create_event_context();
ev_ctx.disable_deprecated_events().unwrap_or_else(|e| {
error!("[MpvBackend] Failed to disable deprecated events: {:?}", e);
});
loop {
match ev_ctx.wait_event(1.0) {
Some(Ok(event)) => match event {
libmpv::events::Event::StartFile => {
debug!("[MpvBackend] Starting file");
}
libmpv::events::Event::FileLoaded => {
info!("[MpvBackend] File loaded");
// Get duration
if let Ok(duration) = mpv.get_property::<f64>("duration") {
if let Some(emitter) = &event_emitter {
emitter.emit(PlayerStatusEvent::MediaLoaded { duration });
}
}
}
libmpv::events::Event::PlaybackRestart => {
debug!("[MpvBackend] Playback started/resumed");
let media_id = state.lock_safe().current_media.as_ref().map(|m| m.id.clone());
if let Some(emitter) = &event_emitter {
emitter.emit(PlayerStatusEvent::StateChanged {
state: "playing".to_string(),
media_id,
});
}
}
libmpv::events::Event::PropertyChange { name, .. } if name == "pause" => {
// Handle pause state changes
if let Ok(is_paused) = mpv.get_property::<bool>("pause") {
let media_id = state.lock_safe().current_media.as_ref().map(|m| m.id.clone());
if let Some(emitter) = &event_emitter {
emitter.emit(PlayerStatusEvent::StateChanged {
state: if is_paused { "paused" } else { "playing" }.to_string(),
media_id,
});
}
}
}
libmpv::events::Event::EndFile(reason) => {
debug!("[MpvBackend] End file with reason: {}", reason);
// Only emit PlaybackEnded for natural track completion (EOF = 0)
// Don't emit for Stop (2), Quit (3), Error (4), or other reasons
// Constants from MPV_END_FILE_REASON enum: EOF=0, STOP=2, QUIT=3, ERROR=4
const MPV_END_FILE_REASON_EOF: u32 = 0;
const MPV_END_FILE_REASON_STOP: u32 = 2;
const MPV_END_FILE_REASON_QUIT: u32 = 3;
const MPV_END_FILE_REASON_ERROR: u32 = 4;
if reason == MPV_END_FILE_REASON_EOF {
debug!("[MpvBackend] Track finished naturally (EOF), emitting PlaybackEnded");
if let Some(emitter) = &event_emitter {
emitter.emit(PlayerStatusEvent::PlaybackEnded);
}
} else if reason == MPV_END_FILE_REASON_STOP {
debug!("[MpvBackend] Track stopped (loading new track), NOT emitting PlaybackEnded");
// Don't emit - user is loading a new track
} else if reason == MPV_END_FILE_REASON_QUIT {
debug!("[MpvBackend] Player quitting, NOT emitting PlaybackEnded");
// Don't emit - player is shutting down
} else if reason == MPV_END_FILE_REASON_ERROR {
warn!("[MpvBackend] Track ended with error, NOT emitting PlaybackEnded");
// Don't emit - we should handle errors separately
} else {
debug!("[MpvBackend] Unknown end file reason {}, NOT emitting PlaybackEnded", reason);
}
}
libmpv::events::Event::Shutdown => {
info!("[MpvBackend] Shutdown event received");
break;
}
_ => {}
},
Some(Err(e)) => {
error!("[MpvBackend] Event error: {:?}", e);
}
None => {
// Timeout, continue
}
}
std::thread::sleep(Duration::from_millis(10));
}
info!("[MpvBackend] Event loop ended");
});
// Start position update thread
let mpv_for_position = self.mpv.clone();
let emitter_for_position = self.event_emitter.clone();
let state_for_position = self.state.clone();
let reporter_for_position = reporter.clone();
let throttler_for_position = throttler.clone();
let last_seek_time_for_position = self.last_seek_time.clone();
std::thread::spawn(move || {
loop {
std::thread::sleep(Duration::from_millis(250));
// Get current position and duration
// Note: We emit position updates even when paused so scrubbing works
if let (Ok(pos), Ok(dur)) = (
mpv_for_position.get_property::<f64>("time-pos"),
mpv_for_position.get_property::<f64>("duration"),
) {
// Check if we recently seeked - skip position updates briefly after seeks
// to avoid "jumping to zero" visual glitches while MPV is seeking
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis() as u64;
let last_seek = last_seek_time_for_position.load(Ordering::Relaxed);
let time_since_seek = now.saturating_sub(last_seek);
// Skip position updates for 150ms after a seek to let MPV stabilize
if time_since_seek < 150 {
continue;
}
// Emit position update event (even when paused, for scrubbing)
if let Some(emitter) = &emitter_for_position {
emitter.emit(PlayerStatusEvent::PositionUpdate {
position: pos,
duration: dur,
});
}
// Check if we're playing for progress reporting
let is_paused = mpv_for_position.get_property::<bool>("pause").unwrap_or(true);
// Only report progress to server when playing (not paused)
if !is_paused {
// Throttled progress reporting (every 30s)
let jellyfin_id = {
let state = state_for_position.lock_safe();
state.current_media.as_ref()
.and_then(|m| m.jellyfin_id().map(|s| s.to_string()))
};
if let Some(item_id) = jellyfin_id {
if throttler_for_position.should_report(&item_id) {
let position_ticks = seconds_to_ticks(pos);
let reporter_clone = reporter_for_position.clone();
let item_id_clone = item_id.clone();
// Spawn async task to report progress
// Check if we're in a Tokio runtime, otherwise spawn a new thread with its own runtime
if let Ok(handle) = tokio::runtime::Handle::try_current() {
handle.spawn(async move {
let reporter_guard = reporter_clone.lock().await;
if let Some(reporter_instance) = reporter_guard.as_ref() {
let operation = PlaybackOperation::Progress {
item_id: item_id_clone.clone(),
position_ticks,
is_paused: false,
};
match reporter_instance.report(operation, true).await {
Ok(_) => debug!("[MpvBackend] Reported progress for {}", item_id_clone),
Err(e) => warn!("[MpvBackend] Failed to report progress: {}", e),
}
}
});
} else {
// Fallback: spawn in a new thread with its own runtime
std::thread::spawn(move || {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async move {
let reporter_guard = reporter_clone.lock().await;
if let Some(reporter_instance) = reporter_guard.as_ref() {
let operation = PlaybackOperation::Progress {
item_id: item_id_clone.clone(),
position_ticks,
is_paused: false,
};
match reporter_instance.report(operation, true).await {
Ok(_) => debug!("[MpvBackend] Reported progress for {}", item_id_clone),
Err(e) => warn!("[MpvBackend] Failed to report progress: {}", e),
}
}
});
});
}
throttler_for_position.mark_reported(&item_id);
}
}
}
}
}
});
}
}
impl PlayerBackend for MpvBackend {
fn load(&mut self, media: &MediaItem) -> Result<(), PlayerError> {
let stream_url = get_stream_url(media);
info!("[MpvBackend] Loading: {} - {}", media.title, stream_url);
// Update state
{
let mut state = self.state.lock_safe();
state.current_media = Some(media.clone());
}
// Load the media file
self.mpv
.command("loadfile", &[&stream_url])
.map_err(|e| PlayerError {
message: format!("Failed to load file: {:?}", e),
})?;
debug!("[MpvBackend] Load command sent successfully");
Ok(())
}
fn play(&mut self) -> Result<(), PlayerError> {
debug!("[MpvBackend] Play command");
self.mpv
.set_property("pause", false)
.map_err(|e| PlayerError {
message: format!("Failed to play: {:?}", e),
})?;
Ok(())
}
fn pause(&mut self) -> Result<(), PlayerError> {
debug!("[MpvBackend] Pause command");
self.mpv
.set_property("pause", true)
.map_err(|e| PlayerError {
message: format!("Failed to pause: {:?}", e),
})?;
Ok(())
}
fn stop(&mut self) -> Result<(), PlayerError> {
debug!("[MpvBackend] Stop command");
self.mpv.command("stop", &[]).map_err(|e| PlayerError {
message: format!("Failed to stop: {:?}", e),
})?;
let mut state = self.state.lock_safe();
state.current_media = None;
Ok(())
}
fn seek(&mut self, position: f64) -> Result<(), PlayerError> {
debug!("[MpvBackend] Seek to {} seconds", position);
// Record the seek time to suppress position updates briefly
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis() as u64;
self.last_seek_time.store(now, Ordering::Relaxed);
self.mpv
.set_property("time-pos", position)
.map_err(|e| PlayerError {
message: format!("Failed to seek: {:?}", e),
})?;
Ok(())
}
fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError> {
let clamped = volume.clamp(0.0, 1.0);
debug!("[MpvBackend] Set volume to {}", clamped);
// MPV expects volume as percentage (0-100)
let mpv_volume = volume_to_percent(clamped as f64) as i64;
self.mpv
.set_property("volume", mpv_volume)
.map_err(|e| PlayerError {
message: format!("Failed to set volume: {:?}", e),
})?;
let mut state = self.state.lock_safe();
state.volume = clamped;
Ok(())
}
fn position(&self) -> f64 {
self.mpv
.get_property::<f64>("time-pos")
.unwrap_or(0.0)
}
fn duration(&self) -> Option<f64> {
self.mpv
.get_property::<f64>("duration")
.ok()
.filter(|d| *d > 0.0)
}
fn state(&self) -> PlayerState {
let state = self.state.lock_safe();
if let Some(ref media) = state.current_media {
let is_paused = self.mpv.get_property::<bool>("pause").unwrap_or(true);
let position = self.position();
let duration = self.duration().unwrap_or(0.0);
if is_paused {
PlayerState::Paused {
media: media.clone(),
position,
duration,
}
} else {
PlayerState::Playing {
media: media.clone(),
position,
duration,
}
}
} else {
PlayerState::Idle
}
}
fn volume(&self) -> f32 {
let state = self.state.lock_safe();
state.volume
}
fn set_audio_settings(&mut self, settings: &AudioSettings) -> Result<(), PlayerError> {
info!("[MpvBackend] Applying audio settings");
self.audio_settings = settings.clone();
// Apply gapless playback
if settings.gapless_playback {
self.mpv
.set_property("gapless-audio", "yes")
.map_err(|e| PlayerError {
message: format!("Failed to enable gapless: {:?}", e),
})?;
} else {
self.mpv
.set_property("gapless-audio", "no")
.map_err(|e| PlayerError {
message: format!("Failed to disable gapless: {:?}", e),
})?;
}
// TODO: Implement crossfade via MPV audio filters if needed
// TODO: Implement volume normalization if needed
Ok(())
}
fn audio_settings(&self) -> AudioSettings {
self.audio_settings.clone()
}
}
impl Drop for MpvBackend {
fn drop(&mut self) {
info!("[MpvBackend] Shutting down");
// MPV will be automatically cleaned up
}
}