Compare commits
No commits in common. "46b9d328abfec582d3ca26c67d5adc47d0858c86" and "0738ef10ecb0d45f4a88f7667b1c03da22db3750" have entirely different histories.
46b9d328ab
...
0738ef10ec
@ -35,8 +35,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
libsoup-3.0-dev \
|
libsoup-3.0-dev \
|
||||||
librsvg2-dev \
|
librsvg2-dev \
|
||||||
libayatana-appindicator3-dev \
|
libayatana-appindicator3-dev \
|
||||||
# mpv player library (linked via libmpv-sys)
|
|
||||||
libmpv-dev \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Install Node.js 20.x from NodeSource
|
# Install Node.js 20.x from NodeSource
|
||||||
|
|||||||
@ -33,8 +33,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
libsoup-3.0-dev \
|
libsoup-3.0-dev \
|
||||||
librsvg2-dev \
|
librsvg2-dev \
|
||||||
libayatana-appindicator3-dev \
|
libayatana-appindicator3-dev \
|
||||||
# mpv player library (linked via libmpv-sys)
|
|
||||||
libmpv-dev \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Install Node.js 20.x from NodeSource
|
# Install Node.js 20.x from NodeSource
|
||||||
|
|||||||
@ -2,21 +2,19 @@
|
|||||||
|
|
||||||
This document describes the current architecture of JellyTau, a cross-platform Jellyfin client built with Tauri, SvelteKit, and Rust.
|
This document describes the current architecture of JellyTau, a cross-platform Jellyfin client built with Tauri, SvelteKit, and Rust.
|
||||||
|
|
||||||
**Last Updated:** 2026-06-20
|
**Last Updated:** 2026-03-01
|
||||||
|
|
||||||
## Architecture Overview
|
## Architecture Overview
|
||||||
|
|
||||||
JellyTau uses a client-server architecture: business logic lives in a comprehensive Rust backend, while a UI-rich Svelte frontend handles presentation and interaction.
|
JellyTau uses a modern client-server architecture with a thin Svelte UI layer and comprehensive Rust backend:
|
||||||
|
|
||||||
### Architecture Principles
|
### Architecture Principles
|
||||||
|
|
||||||
- **Business Logic in Rust**: Core logic — playback, repository, sync, downloads, connectivity — lives in Rust for performance, reliability, and type safety.
|
- **Thin UI Layer**: TypeScript reduced from ~3,300 to ~800 lines
|
||||||
- **Presentation in Svelte**: The frontend (~20.5k non-test lines) owns UI, layout, navigation, and interaction state and invokes Rust commands. It is intentionally UI-heavy, **not** a thin wrapper. Largest pieces: components + routes (~14.6k lines), stores (~3.4k), api/services/utils (~2.4k); `VideoPlayer.svelte` alone is ~1.6k lines.
|
- **Business Logic in Rust**: Performance, reliability, type safety
|
||||||
- **Events + Polling hybrid**: Rust emits events the frontend listens to, and the UI also polls status on short intervals in a few hot spots (e.g. queue status in `library/+layout.svelte`, playback progress in `VideoPlayer.svelte`).
|
- **Event-Driven**: Rust emits events, TypeScript listens and updates UI
|
||||||
- **Handle-Based Resources**: UUID handles for stateful Rust objects.
|
- **Handle-Based Resources**: UUID handles for stateful Rust objects
|
||||||
- **Cache-First**: Parallel queries with intelligent fallback.
|
- **Cache-First**: Parallel queries with intelligent fallback
|
||||||
- **Poison-tolerant locking**: Shared `std::sync` state is accessed via the `MutexSafe`/`RwLockSafe` helpers in `utils/lock.rs`, which recover a poisoned lock instead of cascading a panic across the player.
|
|
||||||
- **Graceful backend init**: If a native player backend (MPV/ExoPlayer) fails to initialize, the app falls back to a no-op backend and emits a `backend-init-failed` event rather than crashing.
|
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart TB
|
flowchart TB
|
||||||
@ -192,11 +190,11 @@ src/lib/
|
|||||||
4. **Database Service** - Async wrapper preventing UI freezing
|
4. **Database Service** - Async wrapper preventing UI freezing
|
||||||
5. **Playback Mode** (303 lines) - Local/remote transfer coordination
|
5. **Playback Mode** (303 lines) - Local/remote transfer coordination
|
||||||
|
|
||||||
**Svelte/TypeScript frontend (~20.5k non-test lines, plus ~9.6k test lines):**
|
**TypeScript Layer (now ~800 lines, down from ~3,300):**
|
||||||
- Components + routes (~14.6k lines) — UI and presentation
|
- Svelte stores (reactive wrappers)
|
||||||
- Stores (~3.4k lines) — reactive state that invokes Rust commands and listens for events
|
- Type definitions
|
||||||
- api / services / utils (~2.4k lines) — typed clients, event listeners, conversion helpers
|
- UI event handling
|
||||||
|
- Tauri command invocation
|
||||||
The frontend is genuinely UI-heavy; business decisions live in Rust, but the UI owns layout, navigation, and interaction state.
|
- Event listeners for Rust events
|
||||||
|
|
||||||
**Total Commands:** 90+ Tauri commands across 14 command modules
|
**Total Commands:** 90+ Tauri commands across 14 command modules
|
||||||
|
|||||||
@ -14,11 +14,6 @@ edition = "2021"
|
|||||||
name = "jellytau_lib"
|
name = "jellytau_lib"
|
||||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||||
|
|
||||||
# Keep debug info minimal to reduce target/ size in CI (line numbers in
|
|
||||||
# backtraces are preserved; the bulky full debuginfo is dropped).
|
|
||||||
[profile.dev]
|
|
||||||
debug = "line-tables-only"
|
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
tauri-build = { version = "2", features = [] }
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,5 @@
|
|||||||
//! Tauri commands for download operations
|
//! Tauri commands for download operations
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
use crate::utils::lock::MutexSafe;
|
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use tauri::State;
|
use tauri::State;
|
||||||
use log::{debug, error, info, warn};
|
use log::{debug, error, info, warn};
|
||||||
@ -1579,7 +1577,7 @@ mod tests {
|
|||||||
fn setup_test_db() -> Database {
|
fn setup_test_db() -> Database {
|
||||||
let db = Database::open_in_memory().unwrap();
|
let db = Database::open_in_memory().unwrap();
|
||||||
let conn = db.connection();
|
let conn = db.connection();
|
||||||
let conn = conn.lock_safe();
|
let conn = conn.lock().unwrap();
|
||||||
|
|
||||||
// Create server
|
// Create server
|
||||||
conn.execute(
|
conn.execute(
|
||||||
@ -1622,7 +1620,7 @@ mod tests {
|
|||||||
fn test_download_item_returns_correct_id_on_insert() {
|
fn test_download_item_returns_correct_id_on_insert() {
|
||||||
let db = setup_test_db();
|
let db = setup_test_db();
|
||||||
let conn = db.connection();
|
let conn = db.connection();
|
||||||
let conn = conn.lock_safe();
|
let conn = conn.lock().unwrap();
|
||||||
|
|
||||||
// Insert a new download
|
// Insert a new download
|
||||||
conn.execute(
|
conn.execute(
|
||||||
@ -1660,7 +1658,7 @@ mod tests {
|
|||||||
fn test_download_item_upsert_returns_correct_id() {
|
fn test_download_item_upsert_returns_correct_id() {
|
||||||
let db = setup_test_db();
|
let db = setup_test_db();
|
||||||
let conn = db.connection();
|
let conn = db.connection();
|
||||||
let conn = conn.lock_safe();
|
let conn = conn.lock().unwrap();
|
||||||
|
|
||||||
// First insert
|
// First insert
|
||||||
conn.execute(
|
conn.execute(
|
||||||
@ -1735,7 +1733,7 @@ mod tests {
|
|||||||
// to get the correct download ID, regardless of whether INSERT or UPDATE occurred.
|
// to get the correct download ID, regardless of whether INSERT or UPDATE occurred.
|
||||||
let db = setup_test_db();
|
let db = setup_test_db();
|
||||||
let conn = db.connection();
|
let conn = db.connection();
|
||||||
let conn = conn.lock_safe();
|
let conn = conn.lock().unwrap();
|
||||||
|
|
||||||
// First insert
|
// First insert
|
||||||
conn.execute(
|
conn.execute(
|
||||||
@ -1783,7 +1781,7 @@ mod tests {
|
|||||||
fn test_download_album_returns_correct_ids() {
|
fn test_download_album_returns_correct_ids() {
|
||||||
let db = setup_test_db();
|
let db = setup_test_db();
|
||||||
let conn = db.connection();
|
let conn = db.connection();
|
||||||
let conn = conn.lock_safe();
|
let conn = conn.lock().unwrap();
|
||||||
|
|
||||||
// Insert downloads for multiple items (simulating album download)
|
// Insert downloads for multiple items (simulating album download)
|
||||||
let track_ids = vec!["item1", "item2", "item3"];
|
let track_ids = vec!["item1", "item2", "item3"];
|
||||||
@ -1829,7 +1827,7 @@ mod tests {
|
|||||||
fn test_download_album_upsert_returns_correct_ids() {
|
fn test_download_album_upsert_returns_correct_ids() {
|
||||||
let db = setup_test_db();
|
let db = setup_test_db();
|
||||||
let conn = db.connection();
|
let conn = db.connection();
|
||||||
let conn = conn.lock_safe();
|
let conn = conn.lock().unwrap();
|
||||||
|
|
||||||
// First download attempt - insert all
|
// First download attempt - insert all
|
||||||
let track_ids = vec!["item1", "item2", "item3"];
|
let track_ids = vec!["item1", "item2", "item3"];
|
||||||
@ -1889,7 +1887,7 @@ mod tests {
|
|||||||
fn test_get_downloads_returns_correct_metadata() {
|
fn test_get_downloads_returns_correct_metadata() {
|
||||||
let db = setup_test_db();
|
let db = setup_test_db();
|
||||||
let conn = db.connection();
|
let conn = db.connection();
|
||||||
let conn = conn.lock_safe();
|
let conn = conn.lock().unwrap();
|
||||||
|
|
||||||
// Insert a download
|
// Insert a download
|
||||||
conn.execute(
|
conn.execute(
|
||||||
@ -1934,7 +1932,7 @@ mod tests {
|
|||||||
fn test_download_status_transitions() {
|
fn test_download_status_transitions() {
|
||||||
let db = setup_test_db();
|
let db = setup_test_db();
|
||||||
let conn = db.connection();
|
let conn = db.connection();
|
||||||
let conn = conn.lock_safe();
|
let conn = conn.lock().unwrap();
|
||||||
|
|
||||||
// Insert pending download
|
// Insert pending download
|
||||||
conn.execute(
|
conn.execute(
|
||||||
@ -1980,7 +1978,7 @@ mod tests {
|
|||||||
fn test_download_progress_updates() {
|
fn test_download_progress_updates() {
|
||||||
let db = setup_test_db();
|
let db = setup_test_db();
|
||||||
let conn = db.connection();
|
let conn = db.connection();
|
||||||
let conn = conn.lock_safe();
|
let conn = conn.lock().unwrap();
|
||||||
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO downloads (item_id, user_id, file_path, status, progress, bytes_downloaded, file_size)
|
"INSERT INTO downloads (item_id, user_id, file_path, status, progress, bytes_downloaded, file_size)
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
//! TRACES: UR-003, UR-004, UR-005, UR-010, UR-020, UR-021 | JA-022, JA-023, JA-024, JA-025, JA-026 | DR-001
|
//! TRACES: UR-003, UR-004, UR-005, UR-010, UR-020, UR-021 | JA-022, JA-023, JA-024, JA-025, JA-026 | DR-001
|
||||||
|
|
||||||
use crate::utils::lock::MutexSafe;
|
|
||||||
use log::{debug, error, info, warn};
|
use log::{debug, error, info, warn};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
@ -12,9 +11,8 @@ use super::DatabaseWrapper;
|
|||||||
use crate::download::cache::{CacheConfig, SmartCache};
|
use crate::download::cache::{CacheConfig, SmartCache};
|
||||||
use crate::jellyfin::{JellyfinClient, JellyfinConfig};
|
use crate::jellyfin::{JellyfinClient, JellyfinConfig};
|
||||||
use crate::player::{
|
use crate::player::{
|
||||||
determine_video_seek_strategy, AutoplaySettings, MediaItem, MediaSource, MediaType,
|
AutoplaySettings, MediaItem, MediaSource, MediaType, MediaSessionManager, PlayerController,
|
||||||
MediaSessionManager, PlayerController, PlayerState, PlayerStatusEvent, QueueContext, RepeatMode,
|
PlayerState, PlayerStatusEvent, QueueContext, RepeatMode, SleepTimerMode, SleepTimerState,
|
||||||
SleepTimerMode, SleepTimerState, VideoSeekStrategy,
|
|
||||||
};
|
};
|
||||||
use crate::repository::{MediaRepository, types::{GetItemsOptions, ImageOptions, ImageType}};
|
use crate::repository::{MediaRepository, types::{GetItemsOptions, ImageOptions, ImageType}};
|
||||||
use crate::settings::{AudioSettings, VideoSettings};
|
use crate::settings::{AudioSettings, VideoSettings};
|
||||||
@ -253,6 +251,61 @@ pub enum VideoSeekResponse {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Internal enum for seek strategy decision (used for testing)
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum VideoSeekStrategy {
|
||||||
|
/// Local file - always use native seek on backend
|
||||||
|
LocalNativeSeek,
|
||||||
|
/// HLS or direct stream with HTML5 - frontend handles seek, skip backend
|
||||||
|
Html5NativeSeek,
|
||||||
|
/// HLS or direct stream with native backend - backend handles seek
|
||||||
|
BackendNativeSeek,
|
||||||
|
/// Transcoded non-HLS with HTML5 - reload stream, frontend handles
|
||||||
|
Html5ReloadStream,
|
||||||
|
/// Transcoded non-HLS with native backend - reload stream, backend handles
|
||||||
|
BackendReloadStream,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Determine the video seek strategy based on stream characteristics
|
||||||
|
///
|
||||||
|
/// This is a pure function extracted for testability.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `is_local` - Whether the file is a local download
|
||||||
|
/// * `is_hls` - Whether the stream URL contains ".m3u8" (HLS stream)
|
||||||
|
/// * `needs_transcoding` - Whether the content needs transcoding
|
||||||
|
/// * `use_html5` - Whether frontend is using HTML5 video element
|
||||||
|
pub fn determine_video_seek_strategy(
|
||||||
|
is_local: bool,
|
||||||
|
is_hls: bool,
|
||||||
|
needs_transcoding: bool,
|
||||||
|
use_html5: bool,
|
||||||
|
) -> VideoSeekStrategy {
|
||||||
|
// Local files always support native seeking via backend
|
||||||
|
if is_local {
|
||||||
|
return VideoSeekStrategy::LocalNativeSeek;
|
||||||
|
}
|
||||||
|
|
||||||
|
// HLS streams and direct play (non-transcoded) support native seeking
|
||||||
|
if is_hls || !needs_transcoding {
|
||||||
|
if use_html5 {
|
||||||
|
// HTML5 backend - frontend handles seeking via videoElement.currentTime
|
||||||
|
// We don't call backend.seek() because video is in HTML5 element, not in MPV
|
||||||
|
VideoSeekStrategy::Html5NativeSeek
|
||||||
|
} else {
|
||||||
|
// Native backend (MPV) - backend handles seeking
|
||||||
|
VideoSeekStrategy::BackendNativeSeek
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Transcoded non-HLS streams need server-side seek (reload from new position)
|
||||||
|
if use_html5 {
|
||||||
|
VideoSeekStrategy::Html5ReloadStream
|
||||||
|
} else {
|
||||||
|
VideoSeekStrategy::BackendReloadStream
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Response for audio track switching operations
|
/// Response for audio track switching operations
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
#[serde(tag = "strategy", rename_all = "camelCase")]
|
#[serde(tag = "strategy", rename_all = "camelCase")]
|
||||||
@ -1238,7 +1291,7 @@ fn get_player_status(controller: &PlayerController) -> PlayerStatus {
|
|||||||
|
|
||||||
fn get_queue_status(controller: &PlayerController) -> QueueStatus {
|
fn get_queue_status(controller: &PlayerController) -> QueueStatus {
|
||||||
let queue = controller.queue();
|
let queue = controller.queue();
|
||||||
let queue_lock = queue.lock_safe();
|
let queue_lock = queue.lock().unwrap();
|
||||||
|
|
||||||
QueueStatus {
|
QueueStatus {
|
||||||
items: queue_lock.items().to_vec(),
|
items: queue_lock.items().to_vec(),
|
||||||
@ -2480,6 +2533,86 @@ pub async fn player_dismiss_session(
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Test video seek strategy for local files
|
||||||
|
#[test]
|
||||||
|
fn test_seek_strategy_local_file() {
|
||||||
|
// Local files always use native backend seek regardless of other flags
|
||||||
|
assert_eq!(
|
||||||
|
determine_video_seek_strategy(true, false, false, false),
|
||||||
|
VideoSeekStrategy::LocalNativeSeek
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
determine_video_seek_strategy(true, false, false, true),
|
||||||
|
VideoSeekStrategy::LocalNativeSeek
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
determine_video_seek_strategy(true, true, true, true),
|
||||||
|
VideoSeekStrategy::LocalNativeSeek
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test video seek strategy for HLS streams
|
||||||
|
#[test]
|
||||||
|
fn test_seek_strategy_hls_stream() {
|
||||||
|
// HLS with HTML5 - frontend handles seek, don't call backend
|
||||||
|
assert_eq!(
|
||||||
|
determine_video_seek_strategy(false, true, false, true),
|
||||||
|
VideoSeekStrategy::Html5NativeSeek
|
||||||
|
);
|
||||||
|
// HLS with native backend - backend handles seek
|
||||||
|
assert_eq!(
|
||||||
|
determine_video_seek_strategy(false, true, false, false),
|
||||||
|
VideoSeekStrategy::BackendNativeSeek
|
||||||
|
);
|
||||||
|
// HLS even with needs_transcoding flag - still native seek (HLS supports it)
|
||||||
|
assert_eq!(
|
||||||
|
determine_video_seek_strategy(false, true, true, true),
|
||||||
|
VideoSeekStrategy::Html5NativeSeek
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test video seek strategy for direct play (non-transcoded) streams
|
||||||
|
#[test]
|
||||||
|
fn test_seek_strategy_direct_play() {
|
||||||
|
// Direct play with HTML5 - frontend handles seek
|
||||||
|
assert_eq!(
|
||||||
|
determine_video_seek_strategy(false, false, false, true),
|
||||||
|
VideoSeekStrategy::Html5NativeSeek
|
||||||
|
);
|
||||||
|
// Direct play with native backend - backend handles seek
|
||||||
|
assert_eq!(
|
||||||
|
determine_video_seek_strategy(false, false, false, false),
|
||||||
|
VideoSeekStrategy::BackendNativeSeek
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test video seek strategy for transcoded non-HLS streams
|
||||||
|
#[test]
|
||||||
|
fn test_seek_strategy_transcoded_non_hls() {
|
||||||
|
// Transcoded non-HLS with HTML5 - need to reload stream, frontend handles
|
||||||
|
assert_eq!(
|
||||||
|
determine_video_seek_strategy(false, false, true, true),
|
||||||
|
VideoSeekStrategy::Html5ReloadStream
|
||||||
|
);
|
||||||
|
// Transcoded non-HLS with native backend - need to reload stream, backend handles
|
||||||
|
assert_eq!(
|
||||||
|
determine_video_seek_strategy(false, false, true, false),
|
||||||
|
VideoSeekStrategy::BackendReloadStream
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test the specific bug fix: HLS + HTML5 should NOT call backend seek
|
||||||
|
/// This was the bug causing "Raw(-10)" errors
|
||||||
|
#[test]
|
||||||
|
fn test_hls_html5_does_not_use_backend_seek() {
|
||||||
|
let strategy = determine_video_seek_strategy(false, true, false, true);
|
||||||
|
// Should be Html5NativeSeek, NOT BackendNativeSeek
|
||||||
|
assert_eq!(strategy, VideoSeekStrategy::Html5NativeSeek);
|
||||||
|
assert_ne!(strategy, VideoSeekStrategy::BackendNativeSeek);
|
||||||
|
}
|
||||||
|
|
||||||
/// Test track index finding in album
|
/// Test track index finding in album
|
||||||
/// This reproduces the bug where clicking songs 1-5 always played song 13
|
/// This reproduces the bug where clicking songs 1-5 always played song 13
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@ -3,7 +3,6 @@
|
|||||||
//!
|
//!
|
||||||
//! TRACES: UR-007, UR-035, UR-036 | JA-004, JA-005, JA-029, JA-030, JA-031
|
//! TRACES: UR-007, UR-035, UR-036 | JA-004, JA-005, JA-029, JA-030, JA-031
|
||||||
|
|
||||||
use crate::utils::lock::MutexSafe;
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
@ -27,17 +26,17 @@ impl RepositoryManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn create(&self, handle: String, repository: HybridRepository) {
|
pub fn create(&self, handle: String, repository: HybridRepository) {
|
||||||
let mut repos = self.repositories.lock_safe();
|
let mut repos = self.repositories.lock().unwrap();
|
||||||
repos.insert(handle, Arc::new(repository));
|
repos.insert(handle, Arc::new(repository));
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get(&self, handle: &str) -> Option<Arc<HybridRepository>> {
|
pub fn get(&self, handle: &str) -> Option<Arc<HybridRepository>> {
|
||||||
let repos = self.repositories.lock_safe();
|
let repos = self.repositories.lock().unwrap();
|
||||||
repos.get(handle).cloned()
|
repos.get(handle).cloned()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn destroy(&self, handle: &str) {
|
pub fn destroy(&self, handle: &str) {
|
||||||
let mut repos = self.repositories.lock_safe();
|
let mut repos = self.repositories.lock().unwrap();
|
||||||
repos.remove(handle);
|
repos.remove(handle);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,5 @@
|
|||||||
//! Smart caching engine for predictive downloads
|
//! Smart caching engine for predictive downloads
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
use crate::utils::lock::MutexSafe;
|
|
||||||
use log::{debug, info};
|
use log::{debug, info};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
@ -311,7 +309,7 @@ mod tests {
|
|||||||
|
|
||||||
// Add some downloads
|
// Add some downloads
|
||||||
{
|
{
|
||||||
let conn_guard = conn_arc.lock_safe();
|
let conn_guard = conn_arc.lock().unwrap();
|
||||||
conn_guard.execute(
|
conn_guard.execute(
|
||||||
"INSERT INTO downloads (user_id, status, file_size) VALUES ('user1', 'completed', 600)",
|
"INSERT INTO downloads (user_id, status, file_size) VALUES ('user1', 'completed', 600)",
|
||||||
[],
|
[],
|
||||||
|
|||||||
@ -10,7 +10,6 @@ pub mod cache;
|
|||||||
pub mod events;
|
pub mod events;
|
||||||
pub mod worker;
|
pub mod worker;
|
||||||
|
|
||||||
use crate::utils::lock::MutexSafe;
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
@ -46,18 +45,18 @@ impl DownloadManager {
|
|||||||
|
|
||||||
/// Check if a new download can be started based on concurrent limit
|
/// Check if a new download can be started based on concurrent limit
|
||||||
pub fn can_start_download(&self) -> bool {
|
pub fn can_start_download(&self) -> bool {
|
||||||
let active = self.active_downloads.lock_safe();
|
let active = self.active_downloads.lock().unwrap();
|
||||||
active.len() < self.max_concurrent
|
active.len() < self.max_concurrent
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the number of currently active downloads
|
/// Get the number of currently active downloads
|
||||||
pub fn active_count(&self) -> usize {
|
pub fn active_count(&self) -> usize {
|
||||||
self.active_downloads.lock_safe().len()
|
self.active_downloads.lock().unwrap().len()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Register a download as active
|
/// Register a download as active
|
||||||
pub fn register_download(&self, download_id: i64) -> bool {
|
pub fn register_download(&self, download_id: i64) -> bool {
|
||||||
let mut active = self.active_downloads.lock_safe();
|
let mut active = self.active_downloads.lock().unwrap();
|
||||||
if active.len() >= self.max_concurrent {
|
if active.len() >= self.max_concurrent {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -66,7 +65,7 @@ impl DownloadManager {
|
|||||||
|
|
||||||
/// Unregister a download when it completes or fails
|
/// Unregister a download when it completes or fails
|
||||||
pub fn unregister_download(&self, download_id: i64) {
|
pub fn unregister_download(&self, download_id: i64) {
|
||||||
let mut active = self.active_downloads.lock_safe();
|
let mut active = self.active_downloads.lock().unwrap();
|
||||||
active.remove(&download_id);
|
active.remove(&download_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -16,7 +16,7 @@ mod utils;
|
|||||||
|
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use tokio::sync::Mutex as TokioMutex;
|
use tokio::sync::Mutex as TokioMutex;
|
||||||
use tauri::{Emitter, Manager};
|
use tauri::Manager;
|
||||||
use log::{error, info};
|
use log::{error, info};
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
use log::warn;
|
use log::warn;
|
||||||
@ -120,9 +120,8 @@ use download::cache::{CacheConfig as SmartCacheConfig, SmartCache};
|
|||||||
use download::DownloadManager;
|
use download::DownloadManager;
|
||||||
use jellyfin::{HttpClient, HttpConfig};
|
use jellyfin::{HttpClient, HttpConfig};
|
||||||
use player::{MediaSessionManager, PlayerBackend, PlayerController, TauriEventEmitter};
|
use player::{MediaSessionManager, PlayerBackend, PlayerController, TauriEventEmitter};
|
||||||
// NullBackend is used both for platforms without a native backend AND as a graceful
|
// NullBackend fallback for platforms without native backends (not Linux or Android)
|
||||||
// fallback when a native backend (MPV/ExoPlayer) fails to initialize, so the app can
|
#[cfg(not(any(target_os = "linux", target_os = "android")))]
|
||||||
// still launch (browse library, manage downloads, see an error) instead of crashing.
|
|
||||||
use player::NullBackend;
|
use player::NullBackend;
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
@ -226,39 +225,13 @@ impl RemoteVolumeHandler for RemoteVolumeSessionHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Payload emitted to the frontend when a native player backend fails to
|
|
||||||
/// initialize and the app falls back to a no-op backend.
|
|
||||||
#[derive(Clone, serde::Serialize)]
|
|
||||||
struct BackendInitError {
|
|
||||||
platform: &'static str,
|
|
||||||
backend: &'static str,
|
|
||||||
message: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Log a backend-initialization failure and notify the frontend, so the UI can
|
|
||||||
/// surface "playback unavailable" instead of the app hard-crashing.
|
|
||||||
fn emit_backend_init_failed(app_handle: &tauri::AppHandle, backend: &'static str, message: String) {
|
|
||||||
error!(
|
|
||||||
"[INIT] Player backend '{}' failed to initialize: {}. Falling back to NullBackend (playback disabled).",
|
|
||||||
backend, message
|
|
||||||
);
|
|
||||||
let _ = app_handle.emit(
|
|
||||||
"backend-init-failed",
|
|
||||||
BackendInitError {
|
|
||||||
platform: std::env::consts::OS,
|
|
||||||
backend,
|
|
||||||
message,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create the appropriate player backend for the current platform.
|
/// Create the appropriate player backend for the current platform.
|
||||||
fn create_player_backend(
|
fn create_player_backend(
|
||||||
app_handle: tauri::AppHandle,
|
app_handle: tauri::AppHandle,
|
||||||
playback_reporter: Arc<tokio::sync::Mutex<Option<playback_reporting::PlaybackReporter>>>,
|
playback_reporter: Arc<tokio::sync::Mutex<Option<playback_reporting::PlaybackReporter>>>,
|
||||||
position_throttler: Arc<playback_reporting::EventThrottler>,
|
position_throttler: Arc<playback_reporting::EventThrottler>,
|
||||||
) -> Box<dyn PlayerBackend> {
|
) -> Box<dyn PlayerBackend> {
|
||||||
let _event_emitter = Arc::new(TauriEventEmitter::new(app_handle.clone()));
|
let _event_emitter = Arc::new(TauriEventEmitter::new(app_handle));
|
||||||
|
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
{
|
{
|
||||||
@ -282,21 +255,17 @@ fn create_player_backend(
|
|||||||
return Box::new(backend);
|
return Box::new(backend);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// Degrade gracefully instead of crashing the app.
|
panic!("FATAL: Failed to initialize ExoPlayer backend on Android: {}. This is a critical error - playback will not work.", e);
|
||||||
emit_backend_init_failed(&app_handle, "exoplayer", e.to_string());
|
|
||||||
return Box::new(NullBackend::new());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
emit_backend_init_failed(&app_handle, "exoplayer", format!("attach JNI thread failed: {}", e));
|
panic!("FATAL: Failed to attach JNI thread on Android: {}. This is a critical error - playback will not work.", e);
|
||||||
return Box::new(NullBackend::new());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
emit_backend_init_failed(&app_handle, "exoplayer", format!("create JavaVM failed: {}", e));
|
panic!("FATAL: Failed to create JavaVM on Android: {}. This is a critical error - playback will not work.", e);
|
||||||
return Box::new(NullBackend::new());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -329,11 +298,7 @@ fn create_player_backend(
|
|||||||
error!("\nAudio playback will NOT work until this is fixed.");
|
error!("\nAudio playback will NOT work until this is fixed.");
|
||||||
error!("========================================\n");
|
error!("========================================\n");
|
||||||
|
|
||||||
// Degrade gracefully: launch with a no-op backend so the user can
|
panic!("Cannot start application: MPV backend initialization failed. See error message above.");
|
||||||
// still browse the library and manage downloads, and the frontend
|
|
||||||
// can show a "playback unavailable" notice via this event.
|
|
||||||
emit_backend_init_failed(&app_handle, "mpv", e.to_string());
|
|
||||||
return Box::new(NullBackend::new());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
use crate::utils::lock::{MutexSafe, RwLockSafe};
|
|
||||||
use log::{debug, error, info};
|
use log::{debug, error, info};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::sync::{
|
use std::sync::{
|
||||||
@ -44,13 +43,13 @@ impl PlaybackModeManager {
|
|||||||
|
|
||||||
/// Get current playback mode
|
/// Get current playback mode
|
||||||
pub fn get_mode(&self) -> PlaybackMode {
|
pub fn get_mode(&self) -> PlaybackMode {
|
||||||
self.current_mode.read_safe().clone()
|
self.current_mode.read().unwrap().clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set playback mode (internal use)
|
/// Set playback mode (internal use)
|
||||||
pub fn set_mode(&self, mode: PlaybackMode) {
|
pub fn set_mode(&self, mode: PlaybackMode) {
|
||||||
log::info!("[PlaybackMode] Setting mode to: {:?}", mode);
|
log::info!("[PlaybackMode] Setting mode to: {:?}", mode);
|
||||||
let mut current = self.current_mode.write_safe();
|
let mut current = self.current_mode.write().unwrap();
|
||||||
*current = mode;
|
*current = mode;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -194,7 +193,7 @@ impl PlaybackModeManager {
|
|||||||
debug!("[PlaybackMode] Player controller lock acquired");
|
debug!("[PlaybackMode] Player controller lock acquired");
|
||||||
|
|
||||||
let queue_arc = player.queue();
|
let queue_arc = player.queue();
|
||||||
let queue = queue_arc.lock_safe();
|
let queue = queue_arc.lock().unwrap();
|
||||||
let state = player.state();
|
let state = player.state();
|
||||||
|
|
||||||
let original_index = queue.current_index().unwrap_or(0);
|
let original_index = queue.current_index().unwrap_or(0);
|
||||||
@ -400,7 +399,7 @@ impl PlaybackModeManager {
|
|||||||
// Log queue state BEFORE stop
|
// Log queue state BEFORE stop
|
||||||
{
|
{
|
||||||
let queue_arc = player.queue();
|
let queue_arc = player.queue();
|
||||||
let queue = queue_arc.lock_safe();
|
let queue = queue_arc.lock().unwrap();
|
||||||
info!(
|
info!(
|
||||||
"[PlaybackMode] BEFORE STOP: Queue has {} items, current_index={:?}",
|
"[PlaybackMode] BEFORE STOP: Queue has {} items, current_index={:?}",
|
||||||
queue.items().len(),
|
queue.items().len(),
|
||||||
@ -413,7 +412,7 @@ impl PlaybackModeManager {
|
|||||||
// Log queue state AFTER stop (should be unchanged)
|
// Log queue state AFTER stop (should be unchanged)
|
||||||
{
|
{
|
||||||
let queue_arc = player.queue();
|
let queue_arc = player.queue();
|
||||||
let queue = queue_arc.lock_safe();
|
let queue = queue_arc.lock().unwrap();
|
||||||
info!(
|
info!(
|
||||||
"[PlaybackMode] AFTER STOP: Queue has {} items, current_index={:?}",
|
"[PlaybackMode] AFTER STOP: Queue has {} items, current_index={:?}",
|
||||||
queue.items().len(),
|
queue.items().len(),
|
||||||
|
|||||||
@ -5,7 +5,6 @@
|
|||||||
|
|
||||||
#![allow(dead_code)]
|
#![allow(dead_code)]
|
||||||
|
|
||||||
use crate::utils::lock::MutexSafe;
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
@ -35,7 +34,7 @@ impl EventThrottler {
|
|||||||
|
|
||||||
/// Checks if enough time has elapsed since the last report for this item
|
/// Checks if enough time has elapsed since the last report for this item
|
||||||
pub fn should_report(&self, item_id: &str) -> bool {
|
pub fn should_report(&self, item_id: &str) -> bool {
|
||||||
let last_times = self.last_report_time.lock_safe();
|
let last_times = self.last_report_time.lock().unwrap();
|
||||||
|
|
||||||
if let Some(last_time) = last_times.get(item_id) {
|
if let Some(last_time) = last_times.get(item_id) {
|
||||||
let elapsed = last_time.elapsed();
|
let elapsed = last_time.elapsed();
|
||||||
@ -55,7 +54,7 @@ impl EventThrottler {
|
|||||||
|
|
||||||
/// Marks the item as reported at the current time
|
/// Marks the item as reported at the current time
|
||||||
pub fn mark_reported(&self, item_id: &str) {
|
pub fn mark_reported(&self, item_id: &str) {
|
||||||
let mut last_times = self.last_report_time.lock_safe();
|
let mut last_times = self.last_report_time.lock().unwrap();
|
||||||
last_times.insert(item_id.to_string(), Instant::now());
|
last_times.insert(item_id.to_string(), Instant::now());
|
||||||
|
|
||||||
log::debug!(
|
log::debug!(
|
||||||
@ -67,14 +66,14 @@ impl EventThrottler {
|
|||||||
|
|
||||||
/// Clears all tracked report times
|
/// Clears all tracked report times
|
||||||
pub fn clear(&self) {
|
pub fn clear(&self) {
|
||||||
let mut last_times = self.last_report_time.lock_safe();
|
let mut last_times = self.last_report_time.lock().unwrap();
|
||||||
last_times.clear();
|
last_times.clear();
|
||||||
log::debug!("[EventThrottler] Cleared all tracked report times");
|
log::debug!("[EventThrottler] Cleared all tracked report times");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Removes a specific item from tracking
|
/// Removes a specific item from tracking
|
||||||
pub fn clear_item(&self, item_id: &str) {
|
pub fn clear_item(&self, item_id: &str) {
|
||||||
let mut last_times = self.last_report_time.lock_safe();
|
let mut last_times = self.last_report_time.lock().unwrap();
|
||||||
last_times.remove(item_id);
|
last_times.remove(item_id);
|
||||||
log::debug!("[EventThrottler] Cleared tracking for {}", item_id);
|
log::debug!("[EventThrottler] Cleared tracking for {}", item_id);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,7 +3,6 @@
|
|||||||
//! This module provides a `PlayerBackend` implementation using Android's ExoPlayer
|
//! This module provides a `PlayerBackend` implementation using Android's ExoPlayer
|
||||||
//! through JNI calls to Kotlin code.
|
//! through JNI calls to Kotlin code.
|
||||||
|
|
||||||
use crate::utils::lock::MutexSafe;
|
|
||||||
use std::sync::{Arc, Mutex, OnceLock};
|
use std::sync::{Arc, Mutex, OnceLock};
|
||||||
use tokio::sync::Mutex as TokioMutex;
|
use tokio::sync::Mutex as TokioMutex;
|
||||||
use log::debug;
|
use log::debug;
|
||||||
@ -304,7 +303,7 @@ impl PlayerBackend for ExoPlayerBackend {
|
|||||||
|
|
||||||
// Update local state
|
// Update local state
|
||||||
{
|
{
|
||||||
let mut state = self.shared_state.lock_safe();
|
let mut state = self.shared_state.lock().unwrap();
|
||||||
state.current_media = Some(media.clone());
|
state.current_media = Some(media.clone());
|
||||||
state.state = PlayerState::Loading {
|
state.state = PlayerState::Loading {
|
||||||
media: media.clone(),
|
media: media.clone(),
|
||||||
@ -431,7 +430,7 @@ impl PlayerBackend for ExoPlayerBackend {
|
|||||||
|
|
||||||
fn stop(&mut self) -> Result<(), PlayerError> {
|
fn stop(&mut self) -> Result<(), PlayerError> {
|
||||||
{
|
{
|
||||||
let mut state = self.shared_state.lock_safe();
|
let mut state = self.shared_state.lock().unwrap();
|
||||||
state.state = PlayerState::Idle;
|
state.state = PlayerState::Idle;
|
||||||
state.is_loaded = false;
|
state.is_loaded = false;
|
||||||
state.current_media = None;
|
state.current_media = None;
|
||||||
@ -480,24 +479,24 @@ impl PlayerBackend for ExoPlayerBackend {
|
|||||||
)
|
)
|
||||||
.map_err(|e| PlayerError::playback_failed(format!("Failed to call setVolume: {}", e)))?;
|
.map_err(|e| PlayerError::playback_failed(format!("Failed to call setVolume: {}", e)))?;
|
||||||
|
|
||||||
self.shared_state.lock_safe().volume = clamped;
|
self.shared_state.lock().unwrap().volume = clamped;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn position(&self) -> f64 {
|
fn position(&self) -> f64 {
|
||||||
self.shared_state.lock_safe().position
|
self.shared_state.lock().unwrap().position
|
||||||
}
|
}
|
||||||
|
|
||||||
fn duration(&self) -> Option<f64> {
|
fn duration(&self) -> Option<f64> {
|
||||||
self.shared_state.lock_safe().duration
|
self.shared_state.lock().unwrap().duration
|
||||||
}
|
}
|
||||||
|
|
||||||
fn state(&self) -> PlayerState {
|
fn state(&self) -> PlayerState {
|
||||||
self.shared_state.lock_safe().state.clone()
|
self.shared_state.lock().unwrap().state.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn volume(&self) -> f32 {
|
fn volume(&self) -> f32 {
|
||||||
self.shared_state.lock_safe().volume
|
self.shared_state.lock().unwrap().volume
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_audio_track(&mut self, stream_index: i32) -> Result<(), PlayerError> {
|
fn set_audio_track(&mut self, stream_index: i32) -> Result<(), PlayerError> {
|
||||||
@ -565,7 +564,7 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
|
|||||||
|
|
||||||
// Update state and get the preserved duration to emit
|
// Update state and get the preserved duration to emit
|
||||||
let duration_to_emit = if let Some(state) = SHARED_STATE.get() {
|
let duration_to_emit = if let Some(state) = SHARED_STATE.get() {
|
||||||
let mut state = state.lock_safe();
|
let mut state = state.lock().unwrap();
|
||||||
state.position = position;
|
state.position = position;
|
||||||
if duration > 0.0 {
|
if duration > 0.0 {
|
||||||
state.duration = Some(duration);
|
state.duration = Some(duration);
|
||||||
@ -607,7 +606,7 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
|
|||||||
|
|
||||||
// Update shared state
|
// Update shared state
|
||||||
if let Some(shared) = SHARED_STATE.get() {
|
if let Some(shared) = SHARED_STATE.get() {
|
||||||
let mut shared = shared.lock_safe();
|
let mut shared = shared.lock().unwrap();
|
||||||
if let Some(media) = shared.current_media.clone() {
|
if let Some(media) = shared.current_media.clone() {
|
||||||
let duration = shared.duration.unwrap_or(0.0);
|
let duration = shared.duration.unwrap_or(0.0);
|
||||||
let position = shared.position;
|
let position = shared.position;
|
||||||
@ -652,7 +651,7 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
|
|||||||
duration: jdouble,
|
duration: jdouble,
|
||||||
) {
|
) {
|
||||||
if let Some(state) = SHARED_STATE.get() {
|
if let Some(state) = SHARED_STATE.get() {
|
||||||
let mut state = state.lock_safe();
|
let mut state = state.lock().unwrap();
|
||||||
state.duration = Some(duration);
|
state.duration = Some(duration);
|
||||||
state.is_loaded = true;
|
state.is_loaded = true;
|
||||||
}
|
}
|
||||||
@ -693,7 +692,7 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
|
|||||||
|
|
||||||
// Log queue state before advancing
|
// Log queue state before advancing
|
||||||
let queue_info = {
|
let queue_info = {
|
||||||
let queue = ctrl.queue.lock_safe();
|
let queue = ctrl.queue.lock().unwrap();
|
||||||
format!("current_index={:?}, len={}", queue.current_index(), queue.items().len())
|
format!("current_index={:?}, len={}", queue.current_index(), queue.items().len())
|
||||||
};
|
};
|
||||||
log::debug!("[Autoplay] Queue state before next(): {}", queue_info);
|
log::debug!("[Autoplay] Queue state before next(): {}", queue_info);
|
||||||
@ -703,7 +702,7 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
|
|||||||
log::info!("[Autoplay] Successfully advanced to next track");
|
log::info!("[Autoplay] Successfully advanced to next track");
|
||||||
// Log queue state after advancing
|
// Log queue state after advancing
|
||||||
let queue_info = {
|
let queue_info = {
|
||||||
let queue = ctrl.queue.lock_safe();
|
let queue = ctrl.queue.lock().unwrap();
|
||||||
format!("current_index={:?}, len={}", queue.current_index(), queue.items().len())
|
format!("current_index={:?}, len={}", queue.current_index(), queue.items().len())
|
||||||
};
|
};
|
||||||
log::debug!("[Autoplay] Queue state after next(): {}", queue_info);
|
log::debug!("[Autoplay] Queue state after next(): {}", queue_info);
|
||||||
@ -812,7 +811,7 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
|
|||||||
muted: jboolean,
|
muted: jboolean,
|
||||||
) {
|
) {
|
||||||
if let Some(state) = SHARED_STATE.get() {
|
if let Some(state) = SHARED_STATE.get() {
|
||||||
state.lock_safe().volume = volume;
|
state.lock().unwrap().volume = volume;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(emitter) = EVENT_EMITTER.get() {
|
if let Some(emitter) = EVENT_EMITTER.get() {
|
||||||
|
|||||||
@ -5,8 +5,6 @@
|
|||||||
//!
|
//!
|
||||||
//! TRACES: UR-005, UR-019, UR-023, UR-026 | DR-001, DR-028, DR-047
|
//! TRACES: UR-005, UR-019, UR-023, UR-026 | DR-001, DR-028, DR-047
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
use crate::utils::lock::MutexSafe;
|
|
||||||
use log::error;
|
use log::error;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@ -170,17 +168,17 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn events(&self) -> Vec<PlayerStatusEvent> {
|
pub fn events(&self) -> Vec<PlayerStatusEvent> {
|
||||||
self.events.lock_safe().clone()
|
self.events.lock().unwrap().clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn clear(&self) {
|
pub fn clear(&self) {
|
||||||
self.events.lock_safe().clear();
|
self.events.lock().unwrap().clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PlayerEventEmitter for TestEventEmitter {
|
impl PlayerEventEmitter for TestEventEmitter {
|
||||||
fn emit(&self, event: PlayerStatusEvent) {
|
fn emit(&self, event: PlayerStatusEvent) {
|
||||||
self.events.lock_safe().push(event);
|
self.events.lock().unwrap().push(event);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -7,7 +7,6 @@ pub mod backend;
|
|||||||
pub mod events;
|
pub mod events;
|
||||||
pub mod media;
|
pub mod media;
|
||||||
pub mod queue;
|
pub mod queue;
|
||||||
pub mod seek;
|
|
||||||
pub mod session;
|
pub mod session;
|
||||||
pub mod sleep_timer;
|
pub mod sleep_timer;
|
||||||
pub mod state;
|
pub mod state;
|
||||||
@ -28,7 +27,6 @@ pub use backend::{NullBackend, PlayerBackend, PlayerError};
|
|||||||
pub use events::{PlayerEventEmitter, PlayerStatusEvent, TauriEventEmitter};
|
pub use events::{PlayerEventEmitter, PlayerStatusEvent, TauriEventEmitter};
|
||||||
pub use media::{MediaItem, MediaSource, MediaType, QueueContext};
|
pub use media::{MediaItem, MediaSource, MediaType, QueueContext};
|
||||||
pub use queue::{QueueManager, RepeatMode};
|
pub use queue::{QueueManager, RepeatMode};
|
||||||
pub use seek::{determine_video_seek_strategy, VideoSeekStrategy};
|
|
||||||
pub use session::{MediaSessionManager, MediaSessionType};
|
pub use session::{MediaSessionManager, MediaSessionType};
|
||||||
pub use sleep_timer::{SleepTimerMode, SleepTimerState};
|
pub use sleep_timer::{SleepTimerMode, SleepTimerState};
|
||||||
pub use state::{EndReason, PlayerState};
|
pub use state::{EndReason, PlayerState};
|
||||||
@ -46,7 +44,6 @@ pub use android::{
|
|||||||
set_media_command_handler, set_remote_volume_handler, get_detected_codecs,
|
set_media_command_handler, set_remote_volume_handler, get_detected_codecs,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::utils::lock::MutexSafe;
|
|
||||||
use log::{debug, error, warn};
|
use log::{debug, error, warn};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
@ -124,7 +121,7 @@ impl PlayerController {
|
|||||||
|
|
||||||
/// Configure the Jellyfin API client for automatic playback reporting
|
/// Configure the Jellyfin API client for automatic playback reporting
|
||||||
pub fn set_jellyfin_client(&self, client: Option<JellyfinClient>) {
|
pub fn set_jellyfin_client(&self, client: Option<JellyfinClient>) {
|
||||||
let mut jellyfin = self.jellyfin_client.lock_safe();
|
let mut jellyfin = self.jellyfin_client.lock().unwrap();
|
||||||
*jellyfin = client;
|
*jellyfin = client;
|
||||||
log::info!("[PlayerController] Jellyfin client configured: {}", jellyfin.is_some());
|
log::info!("[PlayerController] Jellyfin client configured: {}", jellyfin.is_some());
|
||||||
}
|
}
|
||||||
@ -160,24 +157,24 @@ impl PlayerController {
|
|||||||
/// Set the end reason for the next playback end event
|
/// Set the end reason for the next playback end event
|
||||||
fn set_end_reason(&self, reason: EndReason) {
|
fn set_end_reason(&self, reason: EndReason) {
|
||||||
log::debug!("[PlayerController] Setting end reason: {:?}", reason);
|
log::debug!("[PlayerController] Setting end reason: {:?}", reason);
|
||||||
*self.end_reason.lock_safe() = Some(reason);
|
*self.end_reason.lock().unwrap() = Some(reason);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get and clear the current end reason
|
/// Get and clear the current end reason
|
||||||
fn take_end_reason(&self) -> Option<EndReason> {
|
fn take_end_reason(&self) -> Option<EndReason> {
|
||||||
self.end_reason.lock_safe().take()
|
self.end_reason.lock().unwrap().take()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Increment autoplay episode counter. Returns true if limit is reached.
|
/// Increment autoplay episode counter. Returns true if limit is reached.
|
||||||
fn increment_autoplay_count(&self) -> bool {
|
fn increment_autoplay_count(&self) -> bool {
|
||||||
let max = self.autoplay_settings.lock_safe().max_episodes;
|
let max = self.autoplay_settings.lock().unwrap().max_episodes;
|
||||||
|
|
||||||
if max == 0 {
|
if max == 0 {
|
||||||
// Unlimited
|
// Unlimited
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut count = self.autoplay_episode_count.lock_safe();
|
let mut count = self.autoplay_episode_count.lock().unwrap();
|
||||||
*count += 1;
|
*count += 1;
|
||||||
debug!("[PlayerController] Autoplay episode count: {}/{}", *count, max);
|
debug!("[PlayerController] Autoplay episode count: {}/{}", *count, max);
|
||||||
|
|
||||||
@ -186,7 +183,7 @@ impl PlayerController {
|
|||||||
|
|
||||||
/// Reset autoplay episode counter (called on manual play actions)
|
/// Reset autoplay episode counter (called on manual play actions)
|
||||||
fn reset_autoplay_count(&self) {
|
fn reset_autoplay_count(&self) {
|
||||||
let mut count = self.autoplay_episode_count.lock_safe();
|
let mut count = self.autoplay_episode_count.lock().unwrap();
|
||||||
if *count > 0 {
|
if *count > 0 {
|
||||||
debug!("[PlayerController] Resetting autoplay episode counter (was {})", *count);
|
debug!("[PlayerController] Resetting autoplay episode counter (was {})", *count);
|
||||||
}
|
}
|
||||||
@ -202,7 +199,7 @@ impl PlayerController {
|
|||||||
|
|
||||||
// Update queue with this single item
|
// Update queue with this single item
|
||||||
{
|
{
|
||||||
let mut queue = self.queue.lock_safe();
|
let mut queue = self.queue.lock().unwrap();
|
||||||
queue.set_queue(vec![item.clone()], 0);
|
queue.set_queue(vec![item.clone()], 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -220,7 +217,7 @@ impl PlayerController {
|
|||||||
// Set end reason to NewTrackLoaded to prevent autoplay when MPV ends current track
|
// Set end reason to NewTrackLoaded to prevent autoplay when MPV ends current track
|
||||||
self.set_end_reason(EndReason::NewTrackLoaded);
|
self.set_end_reason(EndReason::NewTrackLoaded);
|
||||||
|
|
||||||
let mut backend = self.backend.lock_safe();
|
let mut backend = self.backend.lock().unwrap();
|
||||||
backend.load(item)?;
|
backend.load(item)?;
|
||||||
backend.play()?;
|
backend.play()?;
|
||||||
drop(backend);
|
drop(backend);
|
||||||
@ -300,12 +297,12 @@ impl PlayerController {
|
|||||||
self.reset_autoplay_count();
|
self.reset_autoplay_count();
|
||||||
|
|
||||||
{
|
{
|
||||||
let mut queue = self.queue.lock_safe();
|
let mut queue = self.queue.lock().unwrap();
|
||||||
queue.set_queue(items, start_index);
|
queue.set_queue(items, start_index);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Play the current item (without modifying the queue we just set)
|
// Play the current item (without modifying the queue we just set)
|
||||||
if let Some(item) = self.queue.lock_safe().current().cloned() {
|
if let Some(item) = self.queue.lock().unwrap().current().cloned() {
|
||||||
self.load_and_play(&item)?;
|
self.load_and_play(&item)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -315,19 +312,19 @@ impl PlayerController {
|
|||||||
/// Play/resume playback
|
/// Play/resume playback
|
||||||
pub fn play(&self) -> Result<(), PlayerError> {
|
pub fn play(&self) -> Result<(), PlayerError> {
|
||||||
debug!("[PlayerController] play");
|
debug!("[PlayerController] play");
|
||||||
let mut backend = self.backend.lock_safe();
|
let mut backend = self.backend.lock().unwrap();
|
||||||
backend.play()
|
backend.play()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pause playback
|
/// Pause playback
|
||||||
pub fn pause(&self) -> Result<(), PlayerError> {
|
pub fn pause(&self) -> Result<(), PlayerError> {
|
||||||
let mut backend = self.backend.lock_safe();
|
let mut backend = self.backend.lock().unwrap();
|
||||||
backend.pause()
|
backend.pause()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Toggle play/pause
|
/// Toggle play/pause
|
||||||
pub fn toggle_playback(&self) -> Result<(), PlayerError> {
|
pub fn toggle_playback(&self) -> Result<(), PlayerError> {
|
||||||
let mut backend = self.backend.lock_safe();
|
let mut backend = self.backend.lock().unwrap();
|
||||||
if backend.state().is_playing() {
|
if backend.state().is_playing() {
|
||||||
backend.pause()
|
backend.pause()
|
||||||
} else {
|
} else {
|
||||||
@ -342,16 +339,16 @@ impl PlayerController {
|
|||||||
|
|
||||||
// Get current playback info before stopping
|
// Get current playback info before stopping
|
||||||
let jellyfin_id = {
|
let jellyfin_id = {
|
||||||
let queue = self.queue.lock_safe();
|
let queue = self.queue.lock().unwrap();
|
||||||
queue.current().and_then(|item| item.jellyfin_id().map(|s| s.to_string()))
|
queue.current().and_then(|item| item.jellyfin_id().map(|s| s.to_string()))
|
||||||
};
|
};
|
||||||
|
|
||||||
let position_ticks = {
|
let position_ticks = {
|
||||||
let backend = self.backend.lock_safe();
|
let backend = self.backend.lock().unwrap();
|
||||||
(backend.position() * 10_000_000.0) as i64
|
(backend.position() * 10_000_000.0) as i64
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut backend = self.backend.lock_safe();
|
let mut backend = self.backend.lock().unwrap();
|
||||||
backend.stop()?;
|
backend.stop()?;
|
||||||
drop(backend);
|
drop(backend);
|
||||||
|
|
||||||
@ -415,7 +412,7 @@ impl PlayerController {
|
|||||||
self.reset_autoplay_count();
|
self.reset_autoplay_count();
|
||||||
|
|
||||||
let next_item = {
|
let next_item = {
|
||||||
let mut queue = self.queue.lock_safe();
|
let mut queue = self.queue.lock().unwrap();
|
||||||
queue.next().cloned()
|
queue.next().cloned()
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -438,7 +435,7 @@ impl PlayerController {
|
|||||||
self.reset_autoplay_count();
|
self.reset_autoplay_count();
|
||||||
// If we're more than 3 seconds in, restart current track
|
// If we're more than 3 seconds in, restart current track
|
||||||
{
|
{
|
||||||
let backend = self.backend.lock_safe();
|
let backend = self.backend.lock().unwrap();
|
||||||
if backend.position() > 3.0 {
|
if backend.position() > 3.0 {
|
||||||
debug!("[PlayerController] previous: restarting current track (position > 3s)");
|
debug!("[PlayerController] previous: restarting current track (position > 3s)");
|
||||||
drop(backend);
|
drop(backend);
|
||||||
@ -447,7 +444,7 @@ impl PlayerController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let prev_item = {
|
let prev_item = {
|
||||||
let mut queue = self.queue.lock_safe();
|
let mut queue = self.queue.lock().unwrap();
|
||||||
queue.previous().cloned()
|
queue.previous().cloned()
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -462,40 +459,40 @@ impl PlayerController {
|
|||||||
|
|
||||||
/// Seek to a position in seconds
|
/// Seek to a position in seconds
|
||||||
pub fn seek(&self, position: f64) -> Result<(), PlayerError> {
|
pub fn seek(&self, position: f64) -> Result<(), PlayerError> {
|
||||||
let mut backend = self.backend.lock_safe();
|
let mut backend = self.backend.lock().unwrap();
|
||||||
backend.seek(position)
|
backend.seek(position)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set volume (0.0 - 1.0)
|
/// Set volume (0.0 - 1.0)
|
||||||
pub fn set_volume(&self, volume: f32) -> Result<(), PlayerError> {
|
pub fn set_volume(&self, volume: f32) -> Result<(), PlayerError> {
|
||||||
self.backend.lock_safe().set_volume(volume)
|
self.backend.lock().unwrap().set_volume(volume)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the active audio track by stream index
|
/// Set the active audio track by stream index
|
||||||
pub fn set_audio_track(&self, stream_index: i32) -> Result<(), PlayerError> {
|
pub fn set_audio_track(&self, stream_index: i32) -> Result<(), PlayerError> {
|
||||||
let mut backend = self.backend.lock_safe();
|
let mut backend = self.backend.lock().unwrap();
|
||||||
backend.set_audio_track(stream_index)
|
backend.set_audio_track(stream_index)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the active subtitle track by stream index (None to disable subtitles)
|
/// Set the active subtitle track by stream index (None to disable subtitles)
|
||||||
pub fn set_subtitle_track(&self, stream_index: Option<i32>) -> Result<(), PlayerError> {
|
pub fn set_subtitle_track(&self, stream_index: Option<i32>) -> Result<(), PlayerError> {
|
||||||
let mut backend = self.backend.lock_safe();
|
let mut backend = self.backend.lock().unwrap();
|
||||||
backend.set_subtitle_track(stream_index)
|
backend.set_subtitle_track(stream_index)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get current state
|
/// Get current state
|
||||||
pub fn state(&self) -> PlayerState {
|
pub fn state(&self) -> PlayerState {
|
||||||
self.backend.lock_safe().state()
|
self.backend.lock().unwrap().state()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get current position
|
/// Get current position
|
||||||
pub fn position(&self) -> f64 {
|
pub fn position(&self) -> f64 {
|
||||||
self.backend.lock_safe().position()
|
self.backend.lock().unwrap().position()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get duration
|
/// Get duration
|
||||||
pub fn duration(&self) -> Option<f64> {
|
pub fn duration(&self) -> Option<f64> {
|
||||||
self.backend.lock_safe().duration()
|
self.backend.lock().unwrap().duration()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get queue reference
|
/// Get queue reference
|
||||||
@ -505,27 +502,27 @@ impl PlayerController {
|
|||||||
|
|
||||||
/// Toggle shuffle
|
/// Toggle shuffle
|
||||||
pub fn toggle_shuffle(&self) {
|
pub fn toggle_shuffle(&self) {
|
||||||
self.queue.lock_safe().toggle_shuffle();
|
self.queue.lock().unwrap().toggle_shuffle();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cycle repeat mode
|
/// Cycle repeat mode
|
||||||
pub fn cycle_repeat(&self) {
|
pub fn cycle_repeat(&self) {
|
||||||
self.queue.lock_safe().cycle_repeat();
|
self.queue.lock().unwrap().cycle_repeat();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if shuffle is enabled
|
/// Check if shuffle is enabled
|
||||||
pub fn is_shuffle(&self) -> bool {
|
pub fn is_shuffle(&self) -> bool {
|
||||||
self.queue.lock_safe().is_shuffle()
|
self.queue.lock().unwrap().is_shuffle()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get repeat mode
|
/// Get repeat mode
|
||||||
pub fn repeat_mode(&self) -> RepeatMode {
|
pub fn repeat_mode(&self) -> RepeatMode {
|
||||||
self.queue.lock_safe().repeat_mode()
|
self.queue.lock().unwrap().repeat_mode()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get current volume (0.0 - 1.0)
|
/// Get current volume (0.0 - 1.0)
|
||||||
pub fn volume(&self) -> f32 {
|
pub fn volume(&self) -> f32 {
|
||||||
self.backend.lock_safe().volume()
|
self.backend.lock().unwrap().volume()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if muted
|
/// Check if muted
|
||||||
@ -535,35 +532,35 @@ impl PlayerController {
|
|||||||
|
|
||||||
/// Set audio settings (crossfade, gapless, normalization)
|
/// Set audio settings (crossfade, gapless, normalization)
|
||||||
pub fn set_audio_settings(&mut self, settings: &AudioSettings) -> Result<(), PlayerError> {
|
pub fn set_audio_settings(&mut self, settings: &AudioSettings) -> Result<(), PlayerError> {
|
||||||
self.backend.lock_safe().set_audio_settings(settings)
|
self.backend.lock().unwrap().set_audio_settings(settings)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get current audio settings
|
/// Get current audio settings
|
||||||
pub fn audio_settings(&self) -> AudioSettings {
|
pub fn audio_settings(&self) -> AudioSettings {
|
||||||
self.backend.lock_safe().audio_settings()
|
self.backend.lock().unwrap().audio_settings()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Sleep Timer Methods =====
|
// ===== Sleep Timer Methods =====
|
||||||
|
|
||||||
/// Set the event emitter for notifications
|
/// Set the event emitter for notifications
|
||||||
pub fn set_event_emitter(&self, emitter: Arc<dyn PlayerEventEmitter>) {
|
pub fn set_event_emitter(&self, emitter: Arc<dyn PlayerEventEmitter>) {
|
||||||
let mut event_emitter = self.event_emitter.lock_safe();
|
let mut event_emitter = self.event_emitter.lock().unwrap();
|
||||||
*event_emitter = Some(emitter);
|
*event_emitter = Some(emitter);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the event emitter
|
/// Get the event emitter
|
||||||
pub fn event_emitter(&self) -> Option<Arc<dyn PlayerEventEmitter>> {
|
pub fn event_emitter(&self) -> Option<Arc<dyn PlayerEventEmitter>> {
|
||||||
self.event_emitter.lock_safe().clone()
|
self.event_emitter.lock().unwrap().clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get sleep timer state
|
/// Get sleep timer state
|
||||||
pub fn sleep_timer_state(&self) -> SleepTimerState {
|
pub fn sleep_timer_state(&self) -> SleepTimerState {
|
||||||
self.sleep_timer.lock_safe().clone()
|
self.sleep_timer.lock().unwrap().clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set sleep timer mode (in-memory only, not persisted)
|
/// Set sleep timer mode (in-memory only, not persisted)
|
||||||
pub fn set_sleep_timer(&self, mode: SleepTimerMode) {
|
pub fn set_sleep_timer(&self, mode: SleepTimerMode) {
|
||||||
let mut timer = self.sleep_timer.lock_safe();
|
let mut timer = self.sleep_timer.lock().unwrap();
|
||||||
timer.mode = mode.clone();
|
timer.mode = mode.clone();
|
||||||
if let SleepTimerMode::Time { end_time } = mode {
|
if let SleepTimerMode::Time { end_time } = mode {
|
||||||
let now = chrono::Utc::now().timestamp_millis();
|
let now = chrono::Utc::now().timestamp_millis();
|
||||||
@ -592,7 +589,7 @@ impl PlayerController {
|
|||||||
loop {
|
loop {
|
||||||
std::thread::sleep(Duration::from_secs(1));
|
std::thread::sleep(Duration::from_secs(1));
|
||||||
|
|
||||||
let mut timer = sleep_timer.lock_safe();
|
let mut timer = sleep_timer.lock().unwrap();
|
||||||
if timer.is_active() {
|
if timer.is_active() {
|
||||||
timer.update_remaining_seconds();
|
timer.update_remaining_seconds();
|
||||||
|
|
||||||
@ -602,7 +599,7 @@ impl PlayerController {
|
|||||||
timer.cancel();
|
timer.cancel();
|
||||||
|
|
||||||
// Emit cancelled state
|
// Emit cancelled state
|
||||||
if let Some(emitter) = event_emitter.lock_safe().as_ref() {
|
if let Some(emitter) = event_emitter.lock().unwrap().as_ref() {
|
||||||
emitter.emit(PlayerStatusEvent::SleepTimerChanged {
|
emitter.emit(PlayerStatusEvent::SleepTimerChanged {
|
||||||
mode: SleepTimerMode::Off,
|
mode: SleepTimerMode::Off,
|
||||||
remaining_seconds: 0,
|
remaining_seconds: 0,
|
||||||
@ -611,14 +608,14 @@ impl PlayerController {
|
|||||||
drop(timer);
|
drop(timer);
|
||||||
|
|
||||||
// Stop the backend
|
// Stop the backend
|
||||||
if let Err(e) = backend.lock_safe().stop() {
|
if let Err(e) = backend.lock().unwrap().stop() {
|
||||||
error!("[SleepTimer] Failed to stop playback: {}", e);
|
error!("[SleepTimer] Failed to stop playback: {}", e);
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Emit update event
|
// Emit update event
|
||||||
if let Some(emitter) = event_emitter.lock_safe().as_ref() {
|
if let Some(emitter) = event_emitter.lock().unwrap().as_ref() {
|
||||||
emitter.emit(PlayerStatusEvent::SleepTimerChanged {
|
emitter.emit(PlayerStatusEvent::SleepTimerChanged {
|
||||||
mode: timer.mode.clone(),
|
mode: timer.mode.clone(),
|
||||||
remaining_seconds: timer.remaining_seconds,
|
remaining_seconds: timer.remaining_seconds,
|
||||||
@ -632,9 +629,9 @@ impl PlayerController {
|
|||||||
|
|
||||||
/// Emit sleep timer changed event to frontend
|
/// Emit sleep timer changed event to frontend
|
||||||
fn emit_sleep_timer_changed(&self) {
|
fn emit_sleep_timer_changed(&self) {
|
||||||
let timer = self.sleep_timer.lock_safe().clone();
|
let timer = self.sleep_timer.lock().unwrap().clone();
|
||||||
|
|
||||||
if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
|
if let Some(emitter) = self.event_emitter.lock().unwrap().as_ref() {
|
||||||
emitter.emit(PlayerStatusEvent::SleepTimerChanged {
|
emitter.emit(PlayerStatusEvent::SleepTimerChanged {
|
||||||
mode: timer.mode,
|
mode: timer.mode,
|
||||||
remaining_seconds: timer.remaining_seconds,
|
remaining_seconds: timer.remaining_seconds,
|
||||||
@ -644,12 +641,12 @@ impl PlayerController {
|
|||||||
|
|
||||||
/// Emit queue changed event to frontend
|
/// Emit queue changed event to frontend
|
||||||
pub fn emit_queue_changed(&self) {
|
pub fn emit_queue_changed(&self) {
|
||||||
let queue = self.queue.lock_safe();
|
let queue = self.queue.lock().unwrap();
|
||||||
|
|
||||||
debug!("PlayerController::emit_queue_changed() - Emitting queue with {} items, current_index: {:?}",
|
debug!("PlayerController::emit_queue_changed() - Emitting queue with {} items, current_index: {:?}",
|
||||||
queue.items().len(), queue.current_index());
|
queue.items().len(), queue.current_index());
|
||||||
|
|
||||||
if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
|
if let Some(emitter) = self.event_emitter.lock().unwrap().as_ref() {
|
||||||
emitter.emit(PlayerStatusEvent::QueueChanged {
|
emitter.emit(PlayerStatusEvent::QueueChanged {
|
||||||
items: queue.items().to_vec(),
|
items: queue.items().to_vec(),
|
||||||
current_index: queue.current_index(),
|
current_index: queue.current_index(),
|
||||||
@ -667,19 +664,19 @@ impl PlayerController {
|
|||||||
|
|
||||||
/// Get autoplay settings
|
/// Get autoplay settings
|
||||||
pub fn autoplay_settings(&self) -> AutoplaySettings {
|
pub fn autoplay_settings(&self) -> AutoplaySettings {
|
||||||
self.autoplay_settings.lock_safe().clone()
|
self.autoplay_settings.lock().unwrap().clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set autoplay settings (in-memory only, persistence handled by command layer)
|
/// Set autoplay settings (in-memory only, persistence handled by command layer)
|
||||||
pub fn set_autoplay_settings(&self, settings: AutoplaySettings) {
|
pub fn set_autoplay_settings(&self, settings: AutoplaySettings) {
|
||||||
let validated = settings.with_validated_countdown();
|
let validated = settings.with_validated_countdown();
|
||||||
*self.autoplay_settings.lock_safe() = validated;
|
*self.autoplay_settings.lock().unwrap() = validated;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cancel active autoplay countdown
|
/// Cancel active autoplay countdown
|
||||||
pub fn cancel_autoplay_countdown(&self) {
|
pub fn cancel_autoplay_countdown(&self) {
|
||||||
if let Some(cancel_flag) = self.countdown_cancel.lock_safe().as_ref() {
|
if let Some(cancel_flag) = self.countdown_cancel.lock().unwrap().as_ref() {
|
||||||
*cancel_flag.lock_safe() = true;
|
*cancel_flag.lock().unwrap() = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -722,7 +719,7 @@ impl PlayerController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let current_item = {
|
let current_item = {
|
||||||
let queue = self.queue.lock_safe();
|
let queue = self.queue.lock().unwrap();
|
||||||
queue.current().cloned()
|
queue.current().cloned()
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -732,7 +729,7 @@ impl PlayerController {
|
|||||||
|
|
||||||
// Check sleep timer state
|
// Check sleep timer state
|
||||||
let timer_mode = {
|
let timer_mode = {
|
||||||
let timer = self.sleep_timer.lock_safe();
|
let timer = self.sleep_timer.lock().unwrap();
|
||||||
timer.mode.clone()
|
timer.mode.clone()
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -742,14 +739,14 @@ impl PlayerController {
|
|||||||
let now = chrono::Utc::now().timestamp_millis();
|
let now = chrono::Utc::now().timestamp_millis();
|
||||||
if now >= *end_time {
|
if now >= *end_time {
|
||||||
debug!("[PlayerController] Time-based sleep timer expired at track boundary");
|
debug!("[PlayerController] Time-based sleep timer expired at track boundary");
|
||||||
self.sleep_timer.lock_safe().cancel();
|
self.sleep_timer.lock().unwrap().cancel();
|
||||||
self.emit_sleep_timer_changed();
|
self.emit_sleep_timer_changed();
|
||||||
return Ok(AutoplayDecision::Stop);
|
return Ok(AutoplayDecision::Stop);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
SleepTimerMode::EndOfTrack => {
|
SleepTimerMode::EndOfTrack => {
|
||||||
// Stop at end of track
|
// Stop at end of track
|
||||||
self.sleep_timer.lock_safe().cancel();
|
self.sleep_timer.lock().unwrap().cancel();
|
||||||
self.emit_sleep_timer_changed();
|
self.emit_sleep_timer_changed();
|
||||||
return Ok(AutoplayDecision::Stop);
|
return Ok(AutoplayDecision::Stop);
|
||||||
}
|
}
|
||||||
@ -759,7 +756,7 @@ impl PlayerController {
|
|||||||
&& self.is_episode_item(¤t).await;
|
&& self.is_episode_item(¤t).await;
|
||||||
|
|
||||||
if is_episode {
|
if is_episode {
|
||||||
let should_stop = self.sleep_timer.lock_safe().decrement_episode();
|
let should_stop = self.sleep_timer.lock().unwrap().decrement_episode();
|
||||||
self.emit_sleep_timer_changed();
|
self.emit_sleep_timer_changed();
|
||||||
|
|
||||||
if should_stop {
|
if should_stop {
|
||||||
@ -776,7 +773,7 @@ impl PlayerController {
|
|||||||
// Note: This path is typically not hit for HTML5 video (which uses on_video_playback_ended).
|
// Note: This path is typically not hit for HTML5 video (which uses on_video_playback_ended).
|
||||||
// It's here for the Android ExoPlayer path where video items may be in the backend queue.
|
// It's here for the Android ExoPlayer path where video items may be in the backend queue.
|
||||||
if current.media_type == MediaType::Video && self.is_episode_item(¤t).await {
|
if current.media_type == MediaType::Video && self.is_episode_item(¤t).await {
|
||||||
let repo = self.repository.lock_safe().clone();
|
let repo = self.repository.lock().unwrap().clone();
|
||||||
let jellyfin_id = current.jellyfin_id().unwrap_or(¤t.id);
|
let jellyfin_id = current.jellyfin_id().unwrap_or(¤t.id);
|
||||||
let next_ep_result = if let Some(repo) = &repo {
|
let next_ep_result = if let Some(repo) = &repo {
|
||||||
self.fetch_next_episode_for_item(jellyfin_id, repo).await?
|
self.fetch_next_episode_for_item(jellyfin_id, repo).await?
|
||||||
@ -785,7 +782,7 @@ impl PlayerController {
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
if let Some(next_ep) = next_ep_result {
|
if let Some(next_ep) = next_ep_result {
|
||||||
let settings = self.autoplay_settings.lock_safe().clone();
|
let settings = self.autoplay_settings.lock().unwrap().clone();
|
||||||
|
|
||||||
// Check if auto-play episode limit is reached
|
// Check if auto-play episode limit is reached
|
||||||
let limit_reached = self.increment_autoplay_count();
|
let limit_reached = self.increment_autoplay_count();
|
||||||
@ -806,7 +803,7 @@ impl PlayerController {
|
|||||||
|
|
||||||
// For audio/movies, check if there's a next track in the queue
|
// For audio/movies, check if there's a next track in the queue
|
||||||
let has_next = {
|
let has_next = {
|
||||||
let queue = self.queue.lock_safe();
|
let queue = self.queue.lock().unwrap();
|
||||||
queue.has_next()
|
queue.has_next()
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -840,7 +837,7 @@ impl PlayerController {
|
|||||||
|
|
||||||
// Check sleep timer state
|
// Check sleep timer state
|
||||||
let timer_mode = {
|
let timer_mode = {
|
||||||
let timer = self.sleep_timer.lock_safe();
|
let timer = self.sleep_timer.lock().unwrap();
|
||||||
timer.mode.clone()
|
timer.mode.clone()
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -849,18 +846,18 @@ impl PlayerController {
|
|||||||
let now = chrono::Utc::now().timestamp_millis();
|
let now = chrono::Utc::now().timestamp_millis();
|
||||||
if now >= *end_time {
|
if now >= *end_time {
|
||||||
debug!("[PlayerController] Time-based sleep timer expired at video end");
|
debug!("[PlayerController] Time-based sleep timer expired at video end");
|
||||||
self.sleep_timer.lock_safe().cancel();
|
self.sleep_timer.lock().unwrap().cancel();
|
||||||
self.emit_sleep_timer_changed();
|
self.emit_sleep_timer_changed();
|
||||||
return Ok(AutoplayDecision::Stop);
|
return Ok(AutoplayDecision::Stop);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
SleepTimerMode::EndOfTrack => {
|
SleepTimerMode::EndOfTrack => {
|
||||||
self.sleep_timer.lock_safe().cancel();
|
self.sleep_timer.lock().unwrap().cancel();
|
||||||
self.emit_sleep_timer_changed();
|
self.emit_sleep_timer_changed();
|
||||||
return Ok(AutoplayDecision::Stop);
|
return Ok(AutoplayDecision::Stop);
|
||||||
}
|
}
|
||||||
SleepTimerMode::Episodes { .. } => {
|
SleepTimerMode::Episodes { .. } => {
|
||||||
let should_stop = self.sleep_timer.lock_safe().decrement_episode();
|
let should_stop = self.sleep_timer.lock().unwrap().decrement_episode();
|
||||||
self.emit_sleep_timer_changed();
|
self.emit_sleep_timer_changed();
|
||||||
if should_stop {
|
if should_stop {
|
||||||
return Ok(AutoplayDecision::Stop);
|
return Ok(AutoplayDecision::Stop);
|
||||||
@ -871,7 +868,7 @@ impl PlayerController {
|
|||||||
|
|
||||||
// Fetch next episode for the video that just ended
|
// Fetch next episode for the video that just ended
|
||||||
if let Some(next_ep) = self.fetch_next_episode_for_item(item_id, &repo).await? {
|
if let Some(next_ep) = self.fetch_next_episode_for_item(item_id, &repo).await? {
|
||||||
let settings = self.autoplay_settings.lock_safe().clone();
|
let settings = self.autoplay_settings.lock().unwrap().clone();
|
||||||
|
|
||||||
let limit_reached = self.increment_autoplay_count();
|
let limit_reached = self.increment_autoplay_count();
|
||||||
if limit_reached {
|
if limit_reached {
|
||||||
@ -964,7 +961,7 @@ impl PlayerController {
|
|||||||
pub fn start_autoplay_countdown(&self, _next_item: crate::repository::types::MediaItem, countdown_seconds: u32) {
|
pub fn start_autoplay_countdown(&self, _next_item: crate::repository::types::MediaItem, countdown_seconds: u32) {
|
||||||
// Create cancellation flag
|
// Create cancellation flag
|
||||||
let cancel_flag = Arc::new(Mutex::new(false));
|
let cancel_flag = Arc::new(Mutex::new(false));
|
||||||
*self.countdown_cancel.lock_safe() = Some(cancel_flag.clone());
|
*self.countdown_cancel.lock().unwrap() = Some(cancel_flag.clone());
|
||||||
|
|
||||||
let event_emitter = self.event_emitter.clone();
|
let event_emitter = self.event_emitter.clone();
|
||||||
|
|
||||||
@ -975,7 +972,7 @@ impl PlayerController {
|
|||||||
std::thread::sleep(Duration::from_secs(1));
|
std::thread::sleep(Duration::from_secs(1));
|
||||||
|
|
||||||
// Check cancellation
|
// Check cancellation
|
||||||
if *cancel_flag.lock_safe() {
|
if *cancel_flag.lock().unwrap() {
|
||||||
log::info!("[PlayerController] Autoplay countdown cancelled");
|
log::info!("[PlayerController] Autoplay countdown cancelled");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -983,7 +980,7 @@ impl PlayerController {
|
|||||||
remaining -= 1;
|
remaining -= 1;
|
||||||
|
|
||||||
// Emit countdown tick event
|
// Emit countdown tick event
|
||||||
if let Some(emitter) = event_emitter.lock_safe().as_ref() {
|
if let Some(emitter) = event_emitter.lock().unwrap().as_ref() {
|
||||||
emitter.emit(PlayerStatusEvent::CountdownTick {
|
emitter.emit(PlayerStatusEvent::CountdownTick {
|
||||||
remaining_seconds: remaining,
|
remaining_seconds: remaining,
|
||||||
});
|
});
|
||||||
@ -1084,7 +1081,7 @@ mod tests {
|
|||||||
// Verify initial state
|
// Verify initial state
|
||||||
{
|
{
|
||||||
let queue = controller.queue();
|
let queue = controller.queue();
|
||||||
let queue_lock = queue.lock_safe();
|
let queue_lock = queue.lock().unwrap();
|
||||||
assert_eq!(queue_lock.items().len(), 5, "Queue should have 5 items");
|
assert_eq!(queue_lock.items().len(), 5, "Queue should have 5 items");
|
||||||
assert_eq!(queue_lock.current_index(), Some(0), "Should start at index 0");
|
assert_eq!(queue_lock.current_index(), Some(0), "Should start at index 0");
|
||||||
assert_eq!(queue_lock.current().unwrap().id, "item_0", "Current item should be item_0");
|
assert_eq!(queue_lock.current().unwrap().id, "item_0", "Current item should be item_0");
|
||||||
@ -1096,7 +1093,7 @@ mod tests {
|
|||||||
// Verify queue is intact and index advanced
|
// Verify queue is intact and index advanced
|
||||||
{
|
{
|
||||||
let queue = controller.queue();
|
let queue = controller.queue();
|
||||||
let queue_lock = queue.lock_safe();
|
let queue_lock = queue.lock().unwrap();
|
||||||
assert_eq!(queue_lock.items().len(), 5, "Queue should still have 5 items after skip");
|
assert_eq!(queue_lock.items().len(), 5, "Queue should still have 5 items after skip");
|
||||||
assert_eq!(queue_lock.current_index(), Some(1), "Index should advance to 1");
|
assert_eq!(queue_lock.current_index(), Some(1), "Index should advance to 1");
|
||||||
assert_eq!(queue_lock.current().unwrap().id, "item_1", "Current item should be item_1");
|
assert_eq!(queue_lock.current().unwrap().id, "item_1", "Current item should be item_1");
|
||||||
@ -1115,7 +1112,7 @@ mod tests {
|
|||||||
// Verify queue still intact and index advanced again
|
// Verify queue still intact and index advanced again
|
||||||
{
|
{
|
||||||
let queue = controller.queue();
|
let queue = controller.queue();
|
||||||
let queue_lock = queue.lock_safe();
|
let queue_lock = queue.lock().unwrap();
|
||||||
assert_eq!(queue_lock.items().len(), 5, "Queue should still have 5 items after second skip");
|
assert_eq!(queue_lock.items().len(), 5, "Queue should still have 5 items after second skip");
|
||||||
assert_eq!(queue_lock.current_index(), Some(2), "Index should advance to 2");
|
assert_eq!(queue_lock.current_index(), Some(2), "Index should advance to 2");
|
||||||
assert_eq!(queue_lock.current().unwrap().id, "item_2", "Current item should be item_2");
|
assert_eq!(queue_lock.current().unwrap().id, "item_2", "Current item should be item_2");
|
||||||
@ -1128,7 +1125,7 @@ mod tests {
|
|||||||
// Verify we're at the last item
|
// Verify we're at the last item
|
||||||
{
|
{
|
||||||
let queue = controller.queue();
|
let queue = controller.queue();
|
||||||
let queue_lock = queue.lock_safe();
|
let queue_lock = queue.lock().unwrap();
|
||||||
assert_eq!(queue_lock.items().len(), 5, "Queue should still have 5 items at end");
|
assert_eq!(queue_lock.items().len(), 5, "Queue should still have 5 items at end");
|
||||||
assert_eq!(queue_lock.current_index(), Some(4), "Index should be at last item (4)");
|
assert_eq!(queue_lock.current_index(), Some(4), "Index should be at last item (4)");
|
||||||
assert_eq!(queue_lock.current().unwrap().id, "item_4", "Current item should be item_4");
|
assert_eq!(queue_lock.current().unwrap().id, "item_4", "Current item should be item_4");
|
||||||
@ -1150,7 +1147,7 @@ mod tests {
|
|||||||
// Verify we're at the last item
|
// Verify we're at the last item
|
||||||
{
|
{
|
||||||
let queue = controller.queue();
|
let queue = controller.queue();
|
||||||
let queue_lock = queue.lock_safe();
|
let queue_lock = queue.lock().unwrap();
|
||||||
assert_eq!(queue_lock.current_index(), Some(2), "Should be at last item");
|
assert_eq!(queue_lock.current_index(), Some(2), "Should be at last item");
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1161,7 +1158,7 @@ mod tests {
|
|||||||
// Verify queue is still intact
|
// Verify queue is still intact
|
||||||
{
|
{
|
||||||
let queue = controller.queue();
|
let queue = controller.queue();
|
||||||
let queue_lock = queue.lock_safe();
|
let queue_lock = queue.lock().unwrap();
|
||||||
assert_eq!(queue_lock.items().len(), 3, "Queue should still have 3 items after skip at end");
|
assert_eq!(queue_lock.items().len(), 3, "Queue should still have 3 items after skip at end");
|
||||||
// When we skip past the end, the queue index should stay at the last item
|
// When we skip past the end, the queue index should stay at the last item
|
||||||
// or become None (depending on implementation)
|
// or become None (depending on implementation)
|
||||||
@ -1190,7 +1187,7 @@ mod tests {
|
|||||||
// Verify we wrapped to the first item
|
// Verify we wrapped to the first item
|
||||||
{
|
{
|
||||||
let queue = controller.queue();
|
let queue = controller.queue();
|
||||||
let queue_lock = queue.lock_safe();
|
let queue_lock = queue.lock().unwrap();
|
||||||
assert_eq!(queue_lock.items().len(), 3, "Queue should still have 3 items");
|
assert_eq!(queue_lock.items().len(), 3, "Queue should still have 3 items");
|
||||||
assert_eq!(queue_lock.current_index(), Some(0), "Should wrap to index 0");
|
assert_eq!(queue_lock.current_index(), Some(0), "Should wrap to index 0");
|
||||||
assert_eq!(queue_lock.current().unwrap().id, "item_0", "Should be back at item_0");
|
assert_eq!(queue_lock.current().unwrap().id, "item_0", "Should be back at item_0");
|
||||||
@ -1209,7 +1206,7 @@ mod tests {
|
|||||||
// Verify starting position
|
// Verify starting position
|
||||||
{
|
{
|
||||||
let queue = controller.queue();
|
let queue = controller.queue();
|
||||||
let queue_lock = queue.lock_safe();
|
let queue_lock = queue.lock().unwrap();
|
||||||
assert_eq!(queue_lock.current_index(), Some(3), "Should start at index 3");
|
assert_eq!(queue_lock.current_index(), Some(3), "Should start at index 3");
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1219,7 +1216,7 @@ mod tests {
|
|||||||
// Verify queue is intact and index moved back
|
// Verify queue is intact and index moved back
|
||||||
{
|
{
|
||||||
let queue = controller.queue();
|
let queue = controller.queue();
|
||||||
let queue_lock = queue.lock_safe();
|
let queue_lock = queue.lock().unwrap();
|
||||||
assert_eq!(queue_lock.items().len(), 5, "Queue should still have 5 items after previous");
|
assert_eq!(queue_lock.items().len(), 5, "Queue should still have 5 items after previous");
|
||||||
assert_eq!(queue_lock.current_index(), Some(2), "Index should move to 2");
|
assert_eq!(queue_lock.current_index(), Some(2), "Index should move to 2");
|
||||||
assert_eq!(queue_lock.current().unwrap().id, "item_2", "Current item should be item_2");
|
assert_eq!(queue_lock.current().unwrap().id, "item_2", "Current item should be item_2");
|
||||||
@ -1388,7 +1385,7 @@ mod tests {
|
|||||||
|
|
||||||
// Set sleep timer to end of track
|
// Set sleep timer to end of track
|
||||||
{
|
{
|
||||||
let mut timer = controller.sleep_timer.lock_safe();
|
let mut timer = controller.sleep_timer.lock().unwrap();
|
||||||
timer.mode = SleepTimerMode::EndOfTrack;
|
timer.mode = SleepTimerMode::EndOfTrack;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1403,7 +1400,7 @@ mod tests {
|
|||||||
|
|
||||||
// Verify timer was cancelled
|
// Verify timer was cancelled
|
||||||
{
|
{
|
||||||
let timer = controller.sleep_timer.lock_safe();
|
let timer = controller.sleep_timer.lock().unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
matches!(timer.mode, SleepTimerMode::Off),
|
matches!(timer.mode, SleepTimerMode::Off),
|
||||||
"Sleep timer should be cancelled after EndOfTrack"
|
"Sleep timer should be cancelled after EndOfTrack"
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
use crate::utils::lock::MutexSafe;
|
|
||||||
use log::{debug, error, info, warn};
|
use log::{debug, error, info, warn};
|
||||||
use super::backend::{PlayerBackend, PlayerError};
|
use super::backend::{PlayerBackend, PlayerError};
|
||||||
use super::events::{PlayerEventEmitter, PlayerStatusEvent};
|
use super::events::{PlayerEventEmitter, PlayerStatusEvent};
|
||||||
@ -191,7 +190,7 @@ impl MpvBackend {
|
|||||||
libmpv::events::Event::PlaybackRestart => {
|
libmpv::events::Event::PlaybackRestart => {
|
||||||
debug!("[MpvBackend] Playback started/resumed");
|
debug!("[MpvBackend] Playback started/resumed");
|
||||||
|
|
||||||
let media_id = state.lock_safe().current_media.as_ref().map(|m| m.id.clone());
|
let media_id = state.lock().unwrap().current_media.as_ref().map(|m| m.id.clone());
|
||||||
|
|
||||||
if let Some(emitter) = &event_emitter {
|
if let Some(emitter) = &event_emitter {
|
||||||
emitter.emit(PlayerStatusEvent::StateChanged {
|
emitter.emit(PlayerStatusEvent::StateChanged {
|
||||||
@ -203,7 +202,7 @@ impl MpvBackend {
|
|||||||
libmpv::events::Event::PropertyChange { name, .. } if name == "pause" => {
|
libmpv::events::Event::PropertyChange { name, .. } if name == "pause" => {
|
||||||
// Handle pause state changes
|
// Handle pause state changes
|
||||||
if let Ok(is_paused) = mpv.get_property::<bool>("pause") {
|
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());
|
let media_id = state.lock().unwrap().current_media.as_ref().map(|m| m.id.clone());
|
||||||
|
|
||||||
if let Some(emitter) = &event_emitter {
|
if let Some(emitter) = &event_emitter {
|
||||||
emitter.emit(PlayerStatusEvent::StateChanged {
|
emitter.emit(PlayerStatusEvent::StateChanged {
|
||||||
@ -309,7 +308,7 @@ impl MpvBackend {
|
|||||||
if !is_paused {
|
if !is_paused {
|
||||||
// Throttled progress reporting (every 30s)
|
// Throttled progress reporting (every 30s)
|
||||||
let jellyfin_id = {
|
let jellyfin_id = {
|
||||||
let state = state_for_position.lock_safe();
|
let state = state_for_position.lock().unwrap();
|
||||||
state.current_media.as_ref()
|
state.current_media.as_ref()
|
||||||
.and_then(|m| m.jellyfin_id().map(|s| s.to_string()))
|
.and_then(|m| m.jellyfin_id().map(|s| s.to_string()))
|
||||||
};
|
};
|
||||||
@ -377,7 +376,7 @@ impl PlayerBackend for MpvBackend {
|
|||||||
|
|
||||||
// Update state
|
// Update state
|
||||||
{
|
{
|
||||||
let mut state = self.state.lock_safe();
|
let mut state = self.state.lock().unwrap();
|
||||||
state.current_media = Some(media.clone());
|
state.current_media = Some(media.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -423,7 +422,7 @@ impl PlayerBackend for MpvBackend {
|
|||||||
message: format!("Failed to stop: {:?}", e),
|
message: format!("Failed to stop: {:?}", e),
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let mut state = self.state.lock_safe();
|
let mut state = self.state.lock().unwrap();
|
||||||
state.current_media = None;
|
state.current_media = None;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@ -461,7 +460,7 @@ impl PlayerBackend for MpvBackend {
|
|||||||
message: format!("Failed to set volume: {:?}", e),
|
message: format!("Failed to set volume: {:?}", e),
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let mut state = self.state.lock_safe();
|
let mut state = self.state.lock().unwrap();
|
||||||
state.volume = clamped;
|
state.volume = clamped;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@ -481,7 +480,7 @@ impl PlayerBackend for MpvBackend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn state(&self) -> PlayerState {
|
fn state(&self) -> PlayerState {
|
||||||
let state = self.state.lock_safe();
|
let state = self.state.lock().unwrap();
|
||||||
|
|
||||||
if let Some(ref media) = state.current_media {
|
if let Some(ref media) = state.current_media {
|
||||||
let is_paused = self.mpv.get_property::<bool>("pause").unwrap_or(true);
|
let is_paused = self.mpv.get_property::<bool>("pause").unwrap_or(true);
|
||||||
@ -507,7 +506,7 @@ impl PlayerBackend for MpvBackend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn volume(&self) -> f32 {
|
fn volume(&self) -> f32 {
|
||||||
let state = self.state.lock_safe();
|
let state = self.state.lock().unwrap();
|
||||||
state.volume
|
state.volume
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,143 +0,0 @@
|
|||||||
//! Video seek strategy decision logic.
|
|
||||||
//!
|
|
||||||
//! This is pure logic, extracted from the command layer so it can be unit-tested
|
|
||||||
//! in the player core. The `player_seek_video` command translates the resulting
|
|
||||||
//! [`VideoSeekStrategy`] into a concrete backend/frontend action.
|
|
||||||
|
|
||||||
/// Seek strategy for video playback, derived from a stream's characteristics.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum VideoSeekStrategy {
|
|
||||||
/// Local file - always use native seek on backend
|
|
||||||
LocalNativeSeek,
|
|
||||||
/// HLS or direct stream with HTML5 - frontend handles seek, skip backend
|
|
||||||
Html5NativeSeek,
|
|
||||||
/// HLS or direct stream with native backend - backend handles seek
|
|
||||||
BackendNativeSeek,
|
|
||||||
/// Transcoded non-HLS with HTML5 - reload stream, frontend handles
|
|
||||||
Html5ReloadStream,
|
|
||||||
/// Transcoded non-HLS with native backend - reload stream, backend handles
|
|
||||||
BackendReloadStream,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Determine the video seek strategy based on stream characteristics.
|
|
||||||
///
|
|
||||||
/// This is a pure function extracted for testability.
|
|
||||||
///
|
|
||||||
/// # Arguments
|
|
||||||
/// * `is_local` - Whether the file is a local download
|
|
||||||
/// * `is_hls` - Whether the stream URL contains ".m3u8" (HLS stream)
|
|
||||||
/// * `needs_transcoding` - Whether the content needs transcoding
|
|
||||||
/// * `use_html5` - Whether frontend is using HTML5 video element
|
|
||||||
pub fn determine_video_seek_strategy(
|
|
||||||
is_local: bool,
|
|
||||||
is_hls: bool,
|
|
||||||
needs_transcoding: bool,
|
|
||||||
use_html5: bool,
|
|
||||||
) -> VideoSeekStrategy {
|
|
||||||
// Local files always support native seeking via backend
|
|
||||||
if is_local {
|
|
||||||
return VideoSeekStrategy::LocalNativeSeek;
|
|
||||||
}
|
|
||||||
|
|
||||||
// HLS streams and direct play (non-transcoded) support native seeking
|
|
||||||
if is_hls || !needs_transcoding {
|
|
||||||
if use_html5 {
|
|
||||||
// HTML5 backend - frontend handles seeking via videoElement.currentTime
|
|
||||||
// We don't call backend.seek() because video is in HTML5 element, not in MPV
|
|
||||||
VideoSeekStrategy::Html5NativeSeek
|
|
||||||
} else {
|
|
||||||
// Native backend (MPV) - backend handles seeking
|
|
||||||
VideoSeekStrategy::BackendNativeSeek
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Transcoded non-HLS streams need server-side seek (reload from new position)
|
|
||||||
if use_html5 {
|
|
||||||
VideoSeekStrategy::Html5ReloadStream
|
|
||||||
} else {
|
|
||||||
VideoSeekStrategy::BackendReloadStream
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
/// Test video seek strategy for local files
|
|
||||||
#[test]
|
|
||||||
fn test_seek_strategy_local_file() {
|
|
||||||
// Local files always use native backend seek regardless of other flags
|
|
||||||
assert_eq!(
|
|
||||||
determine_video_seek_strategy(true, false, false, false),
|
|
||||||
VideoSeekStrategy::LocalNativeSeek
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
determine_video_seek_strategy(true, false, false, true),
|
|
||||||
VideoSeekStrategy::LocalNativeSeek
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
determine_video_seek_strategy(true, true, true, true),
|
|
||||||
VideoSeekStrategy::LocalNativeSeek
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Test video seek strategy for HLS streams
|
|
||||||
#[test]
|
|
||||||
fn test_seek_strategy_hls_stream() {
|
|
||||||
// HLS with HTML5 - frontend handles seek, don't call backend
|
|
||||||
assert_eq!(
|
|
||||||
determine_video_seek_strategy(false, true, false, true),
|
|
||||||
VideoSeekStrategy::Html5NativeSeek
|
|
||||||
);
|
|
||||||
// HLS with native backend - backend handles seek
|
|
||||||
assert_eq!(
|
|
||||||
determine_video_seek_strategy(false, true, false, false),
|
|
||||||
VideoSeekStrategy::BackendNativeSeek
|
|
||||||
);
|
|
||||||
// HLS even with needs_transcoding flag - still native seek (HLS supports it)
|
|
||||||
assert_eq!(
|
|
||||||
determine_video_seek_strategy(false, true, true, true),
|
|
||||||
VideoSeekStrategy::Html5NativeSeek
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Test video seek strategy for direct play (non-transcoded) streams
|
|
||||||
#[test]
|
|
||||||
fn test_seek_strategy_direct_play() {
|
|
||||||
// Direct play with HTML5 - frontend handles seek
|
|
||||||
assert_eq!(
|
|
||||||
determine_video_seek_strategy(false, false, false, true),
|
|
||||||
VideoSeekStrategy::Html5NativeSeek
|
|
||||||
);
|
|
||||||
// Direct play with native backend - backend handles seek
|
|
||||||
assert_eq!(
|
|
||||||
determine_video_seek_strategy(false, false, false, false),
|
|
||||||
VideoSeekStrategy::BackendNativeSeek
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Test video seek strategy for transcoded non-HLS streams
|
|
||||||
#[test]
|
|
||||||
fn test_seek_strategy_transcoded_non_hls() {
|
|
||||||
// Transcoded non-HLS with HTML5 - need to reload stream, frontend handles
|
|
||||||
assert_eq!(
|
|
||||||
determine_video_seek_strategy(false, false, true, true),
|
|
||||||
VideoSeekStrategy::Html5ReloadStream
|
|
||||||
);
|
|
||||||
// Transcoded non-HLS with native backend - need to reload stream, backend handles
|
|
||||||
assert_eq!(
|
|
||||||
determine_video_seek_strategy(false, false, true, false),
|
|
||||||
VideoSeekStrategy::BackendReloadStream
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Test the specific bug fix: HLS + HTML5 should NOT call backend seek
|
|
||||||
/// This was the bug causing "Raw(-10)" errors
|
|
||||||
#[test]
|
|
||||||
fn test_hls_html5_does_not_use_backend_seek() {
|
|
||||||
let strategy = determine_video_seek_strategy(false, true, false, true);
|
|
||||||
// Should be Html5NativeSeek, NOT BackendNativeSeek
|
|
||||||
assert_eq!(strategy, VideoSeekStrategy::Html5NativeSeek);
|
|
||||||
assert_ne!(strategy, VideoSeekStrategy::BackendNativeSeek);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -5,8 +5,6 @@
|
|||||||
// @req: DR-012 - Local database for media metadata cache
|
// @req: DR-012 - Local database for media metadata cache
|
||||||
// @req: DR-013 - Repository pattern for online/offline data access
|
// @req: DR-013 - Repository pattern for online/offline data access
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
use crate::utils::lock::MutexSafe;
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
@ -548,16 +546,16 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn get_query_count(&self) -> usize {
|
fn get_query_count(&self) -> usize {
|
||||||
*self.query_count.lock_safe()
|
*self.query_count.lock().unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_save_count(&self) -> usize {
|
fn get_save_count(&self) -> usize {
|
||||||
*self.save_count.lock_safe()
|
*self.save_count.lock().unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn save_to_cache(&self, _parent_id: &str, items: &[MediaItem]) -> Result<usize, RepoError> {
|
async fn save_to_cache(&self, _parent_id: &str, items: &[MediaItem]) -> Result<usize, RepoError> {
|
||||||
*self.save_count.lock_safe() += 1;
|
*self.save_count.lock().unwrap() += 1;
|
||||||
*self.items.lock_safe() = items.to_vec();
|
*self.items.lock().unwrap() = items.to_vec();
|
||||||
Ok(items.len())
|
Ok(items.len())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -569,8 +567,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn get_items(&self, _parent_id: &str, _options: Option<GetItemsOptions>) -> Result<SearchResult, RepoError> {
|
async fn get_items(&self, _parent_id: &str, _options: Option<GetItemsOptions>) -> Result<SearchResult, RepoError> {
|
||||||
*self.query_count.lock_safe() += 1;
|
*self.query_count.lock().unwrap() += 1;
|
||||||
let items = self.items.lock_safe().clone();
|
let items = self.items.lock().unwrap().clone();
|
||||||
let count = items.len();
|
let count = items.len();
|
||||||
Ok(SearchResult {
|
Ok(SearchResult {
|
||||||
items,
|
items,
|
||||||
@ -717,7 +715,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn get_query_count(&self) -> usize {
|
fn get_query_count(&self) -> usize {
|
||||||
*self.query_count.lock_safe()
|
*self.query_count.lock().unwrap()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -728,7 +726,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn get_items(&self, _parent_id: &str, _options: Option<GetItemsOptions>) -> Result<SearchResult, RepoError> {
|
async fn get_items(&self, _parent_id: &str, _options: Option<GetItemsOptions>) -> Result<SearchResult, RepoError> {
|
||||||
*self.query_count.lock_safe() += 1;
|
*self.query_count.lock().unwrap() += 1;
|
||||||
Ok(SearchResult {
|
Ok(SearchResult {
|
||||||
items: self.items.clone(),
|
items: self.items.clone(),
|
||||||
total_record_count: self.items.len(),
|
total_record_count: self.items.len(),
|
||||||
|
|||||||
@ -5,7 +5,6 @@
|
|||||||
//!
|
//!
|
||||||
//! TRACES: UR-010 | JA-021
|
//! TRACES: UR-010 | JA-021
|
||||||
|
|
||||||
use crate::utils::lock::{MutexSafe, RwLockSafe};
|
|
||||||
use log::{debug, info, warn};
|
use log::{debug, info, warn};
|
||||||
use std::sync::{Arc, Mutex, RwLock};
|
use std::sync::{Arc, Mutex, RwLock};
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||||
@ -61,7 +60,7 @@ impl SessionPollerManager {
|
|||||||
|
|
||||||
/// Set event emitter for broadcasting session updates
|
/// Set event emitter for broadcasting session updates
|
||||||
pub fn set_event_emitter(&self, emitter: Arc<dyn PlayerEventEmitter>) {
|
pub fn set_event_emitter(&self, emitter: Arc<dyn PlayerEventEmitter>) {
|
||||||
*self.event_emitter.lock_safe() = Some(emitter);
|
*self.event_emitter.lock().unwrap() = Some(emitter);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Start the background polling thread
|
/// Start the background polling thread
|
||||||
@ -89,7 +88,7 @@ impl SessionPollerManager {
|
|||||||
// Calculate poll interval based on mode and hint
|
// Calculate poll interval based on mode and hint
|
||||||
let new_interval = Self::calculate_interval(
|
let new_interval = Self::calculate_interval(
|
||||||
&mode_manager.get_mode(),
|
&mode_manager.get_mode(),
|
||||||
*hint.read_safe(),
|
*hint.read().unwrap(),
|
||||||
);
|
);
|
||||||
|
|
||||||
interval_ms.store(new_interval, Ordering::Relaxed);
|
interval_ms.store(new_interval, Ordering::Relaxed);
|
||||||
@ -98,7 +97,7 @@ impl SessionPollerManager {
|
|||||||
|
|
||||||
// Fetch sessions
|
// Fetch sessions
|
||||||
let sessions_result = rt.block_on(async {
|
let sessions_result = rt.block_on(async {
|
||||||
let client_opt = client.lock_safe().clone();
|
let client_opt = client.lock().unwrap().clone();
|
||||||
match client_opt {
|
match client_opt {
|
||||||
Some(c) => c.get_sessions().await,
|
Some(c) => c.get_sessions().await,
|
||||||
None => {
|
None => {
|
||||||
@ -112,7 +111,7 @@ impl SessionPollerManager {
|
|||||||
match sessions_result {
|
match sessions_result {
|
||||||
Ok(sessions) => {
|
Ok(sessions) => {
|
||||||
debug!("[SessionPoller] Fetched {} sessions", sessions.len());
|
debug!("[SessionPoller] Fetched {} sessions", sessions.len());
|
||||||
if let Some(em) = emitter.lock_safe().as_ref() {
|
if let Some(em) = emitter.lock().unwrap().as_ref() {
|
||||||
em.emit(crate::player::PlayerStatusEvent::SessionsUpdated {
|
em.emit(crate::player::PlayerStatusEvent::SessionsUpdated {
|
||||||
sessions,
|
sessions,
|
||||||
});
|
});
|
||||||
@ -130,7 +129,7 @@ impl SessionPollerManager {
|
|||||||
info!("[SessionPoller] Polling thread stopped");
|
info!("[SessionPoller] Polling thread stopped");
|
||||||
});
|
});
|
||||||
|
|
||||||
*self.thread_handle.lock_safe() = Some(handle);
|
*self.thread_handle.lock().unwrap() = Some(handle);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stop the polling thread
|
/// Stop the polling thread
|
||||||
@ -139,7 +138,7 @@ impl SessionPollerManager {
|
|||||||
self.is_running.store(false, Ordering::Relaxed);
|
self.is_running.store(false, Ordering::Relaxed);
|
||||||
|
|
||||||
// Join the thread if possible (don't block indefinitely)
|
// Join the thread if possible (don't block indefinitely)
|
||||||
if let Some(handle) = self.thread_handle.lock_safe().take() {
|
if let Some(handle) = self.thread_handle.lock().unwrap().take() {
|
||||||
let _ = handle.join();
|
let _ = handle.join();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -147,7 +146,7 @@ impl SessionPollerManager {
|
|||||||
/// Set UI hint for polling frequency adjustment
|
/// Set UI hint for polling frequency adjustment
|
||||||
pub fn set_polling_hint(&self, hint: PollingHint) {
|
pub fn set_polling_hint(&self, hint: PollingHint) {
|
||||||
debug!("[SessionPoller] Setting polling hint: {:?}", hint);
|
debug!("[SessionPoller] Setting polling hint: {:?}", hint);
|
||||||
*self.current_hint.write_safe() = hint;
|
*self.current_hint.write().unwrap() = hint;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Calculate polling interval based on mode and hint
|
/// Calculate polling interval based on mode and hint
|
||||||
@ -166,7 +165,7 @@ impl SessionPollerManager {
|
|||||||
|
|
||||||
/// Manually trigger a poll (for frontend refresh button)
|
/// Manually trigger a poll (for frontend refresh button)
|
||||||
pub async fn poll_now(&self) -> Result<Vec<crate::jellyfin::client::SessionInfo>, String> {
|
pub async fn poll_now(&self) -> Result<Vec<crate::jellyfin::client::SessionInfo>, String> {
|
||||||
let client = self.jellyfin_client.lock_safe().clone()
|
let client = self.jellyfin_client.lock().unwrap().clone()
|
||||||
.ok_or("Jellyfin client not configured")?;
|
.ok_or("Jellyfin client not configured")?;
|
||||||
|
|
||||||
client.get_sessions().await
|
client.get_sessions().await
|
||||||
|
|||||||
@ -7,7 +7,6 @@ pub mod db_service;
|
|||||||
pub mod models;
|
pub mod models;
|
||||||
pub mod schema;
|
pub mod schema;
|
||||||
|
|
||||||
use crate::utils::lock::MutexSafe;
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
@ -78,7 +77,7 @@ impl Database {
|
|||||||
/// Run all pending migrations
|
/// Run all pending migrations
|
||||||
pub fn migrate(&self) -> SqliteResult<()> {
|
pub fn migrate(&self) -> SqliteResult<()> {
|
||||||
info!("Starting database migrations...");
|
info!("Starting database migrations...");
|
||||||
let conn = self.conn.lock_safe();
|
let conn = self.conn.lock().unwrap();
|
||||||
|
|
||||||
// Create migrations table if it doesn't exist
|
// Create migrations table if it doesn't exist
|
||||||
debug!("Creating _migrations table if it doesn't exist...");
|
debug!("Creating _migrations table if it doesn't exist...");
|
||||||
@ -173,7 +172,7 @@ mod tests {
|
|||||||
fn test_migrations_run() {
|
fn test_migrations_run() {
|
||||||
let db = Database::open_in_memory().unwrap();
|
let db = Database::open_in_memory().unwrap();
|
||||||
let conn = db.connection();
|
let conn = db.connection();
|
||||||
let conn = conn.lock_safe();
|
let conn = conn.lock().unwrap();
|
||||||
|
|
||||||
// Check that tables exist
|
// Check that tables exist
|
||||||
let mut stmt = conn
|
let mut stmt = conn
|
||||||
@ -187,7 +186,7 @@ mod tests {
|
|||||||
fn test_all_tables_created() {
|
fn test_all_tables_created() {
|
||||||
let db = Database::open_in_memory().unwrap();
|
let db = Database::open_in_memory().unwrap();
|
||||||
let conn = db.connection();
|
let conn = db.connection();
|
||||||
let conn = conn.lock_safe();
|
let conn = conn.lock().unwrap();
|
||||||
|
|
||||||
let expected_tables = [
|
let expected_tables = [
|
||||||
"servers",
|
"servers",
|
||||||
@ -219,7 +218,7 @@ mod tests {
|
|||||||
fn test_fts_table_created() {
|
fn test_fts_table_created() {
|
||||||
let db = Database::open_in_memory().unwrap();
|
let db = Database::open_in_memory().unwrap();
|
||||||
let conn = db.connection();
|
let conn = db.connection();
|
||||||
let conn = conn.lock_safe();
|
let conn = conn.lock().unwrap();
|
||||||
|
|
||||||
let exists: Option<String> = conn
|
let exists: Option<String> = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
@ -235,7 +234,7 @@ mod tests {
|
|||||||
fn test_server_crud() {
|
fn test_server_crud() {
|
||||||
let db = Database::open_in_memory().unwrap();
|
let db = Database::open_in_memory().unwrap();
|
||||||
let conn = db.connection();
|
let conn = db.connection();
|
||||||
let conn = conn.lock_safe();
|
let conn = conn.lock().unwrap();
|
||||||
|
|
||||||
// Insert a server
|
// Insert a server
|
||||||
conn.execute(
|
conn.execute(
|
||||||
@ -283,7 +282,7 @@ mod tests {
|
|||||||
fn test_user_crud() {
|
fn test_user_crud() {
|
||||||
let db = Database::open_in_memory().unwrap();
|
let db = Database::open_in_memory().unwrap();
|
||||||
let conn = db.connection();
|
let conn = db.connection();
|
||||||
let conn = conn.lock_safe();
|
let conn = conn.lock().unwrap();
|
||||||
|
|
||||||
// Create a server first (foreign key)
|
// Create a server first (foreign key)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
@ -330,7 +329,7 @@ mod tests {
|
|||||||
fn test_cascade_delete_server_removes_users() {
|
fn test_cascade_delete_server_removes_users() {
|
||||||
let db = Database::open_in_memory().unwrap();
|
let db = Database::open_in_memory().unwrap();
|
||||||
let conn = db.connection();
|
let conn = db.connection();
|
||||||
let conn = conn.lock_safe();
|
let conn = conn.lock().unwrap();
|
||||||
|
|
||||||
// Create server and user
|
// Create server and user
|
||||||
conn.execute(
|
conn.execute(
|
||||||
@ -368,7 +367,7 @@ mod tests {
|
|||||||
fn test_item_insert_and_fts_search() {
|
fn test_item_insert_and_fts_search() {
|
||||||
let db = Database::open_in_memory().unwrap();
|
let db = Database::open_in_memory().unwrap();
|
||||||
let conn = db.connection();
|
let conn = db.connection();
|
||||||
let conn = conn.lock_safe();
|
let conn = conn.lock().unwrap();
|
||||||
|
|
||||||
// Create server first
|
// Create server first
|
||||||
conn.execute(
|
conn.execute(
|
||||||
@ -425,7 +424,7 @@ mod tests {
|
|||||||
fn test_user_data_playback_position() {
|
fn test_user_data_playback_position() {
|
||||||
let db = Database::open_in_memory().unwrap();
|
let db = Database::open_in_memory().unwrap();
|
||||||
let conn = db.connection();
|
let conn = db.connection();
|
||||||
let conn = conn.lock_safe();
|
let conn = conn.lock().unwrap();
|
||||||
|
|
||||||
// Setup: server, user, item
|
// Setup: server, user, item
|
||||||
conn.execute(
|
conn.execute(
|
||||||
@ -488,7 +487,7 @@ mod tests {
|
|||||||
fn test_sync_queue_operations() {
|
fn test_sync_queue_operations() {
|
||||||
let db = Database::open_in_memory().unwrap();
|
let db = Database::open_in_memory().unwrap();
|
||||||
let conn = db.connection();
|
let conn = db.connection();
|
||||||
let conn = conn.lock_safe();
|
let conn = conn.lock().unwrap();
|
||||||
|
|
||||||
// Setup
|
// Setup
|
||||||
conn.execute(
|
conn.execute(
|
||||||
@ -553,7 +552,7 @@ mod tests {
|
|||||||
fn test_downloads_table() {
|
fn test_downloads_table() {
|
||||||
let db = Database::open_in_memory().unwrap();
|
let db = Database::open_in_memory().unwrap();
|
||||||
let conn = db.connection();
|
let conn = db.connection();
|
||||||
let conn = conn.lock_safe();
|
let conn = conn.lock().unwrap();
|
||||||
|
|
||||||
// Setup
|
// Setup
|
||||||
conn.execute(
|
conn.execute(
|
||||||
@ -628,7 +627,7 @@ mod tests {
|
|||||||
|
|
||||||
// Tables should still exist
|
// Tables should still exist
|
||||||
let conn = db.connection();
|
let conn = db.connection();
|
||||||
let conn = conn.lock_safe();
|
let conn = conn.lock().unwrap();
|
||||||
|
|
||||||
let count: i32 = conn
|
let count: i32 = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
@ -644,7 +643,7 @@ mod tests {
|
|||||||
fn test_global_active_user_deactivation() {
|
fn test_global_active_user_deactivation() {
|
||||||
let db = Database::open_in_memory().unwrap();
|
let db = Database::open_in_memory().unwrap();
|
||||||
let conn = db.connection();
|
let conn = db.connection();
|
||||||
let conn = conn.lock_safe();
|
let conn = conn.lock().unwrap();
|
||||||
|
|
||||||
// Create two servers
|
// Create two servers
|
||||||
conn.execute(
|
conn.execute(
|
||||||
@ -714,7 +713,7 @@ mod tests {
|
|||||||
fn test_active_session_query_ordering() {
|
fn test_active_session_query_ordering() {
|
||||||
let db = Database::open_in_memory().unwrap();
|
let db = Database::open_in_memory().unwrap();
|
||||||
let conn = db.connection();
|
let conn = db.connection();
|
||||||
let conn = conn.lock_safe();
|
let conn = conn.lock().unwrap();
|
||||||
|
|
||||||
// Create server
|
// Create server
|
||||||
conn.execute(
|
conn.execute(
|
||||||
|
|||||||
@ -1,111 +0,0 @@
|
|||||||
//! Poison-tolerant locking helpers.
|
|
||||||
//!
|
|
||||||
//! `std::sync::Mutex` and `RwLock` become *poisoned* if a thread panics while
|
|
||||||
//! holding the guard. After that, every `.lock().unwrap()` / `.read().unwrap()`
|
|
||||||
//! / `.write().unwrap()` panics as well — so a single failure can cascade into
|
|
||||||
//! an unrecoverable crash. That is a real risk for stateful subsystems like the
|
|
||||||
//! player, whose locks are touched from many background threads (the MPV event
|
|
||||||
//! loop, sleep/autoplay timers, JNI callbacks, the session poller).
|
|
||||||
//!
|
|
||||||
//! These extension traits recover the guard from a poisoned lock instead of
|
|
||||||
//! panicking. The data behind a poisoned lock may be in an unexpected state,
|
|
||||||
//! but for this application's state (queues, settings, flags) recovering and
|
|
||||||
//! continuing is far preferable to taking down playback entirely.
|
|
||||||
//!
|
|
||||||
//! Use `lock_safe()` / `read_safe()` / `write_safe()` in place of
|
|
||||||
//! `.lock().unwrap()` / `.read().unwrap()` / `.write().unwrap()`.
|
|
||||||
//!
|
|
||||||
//! Note: these apply only to `std::sync` primitives. `tokio::sync::Mutex` does
|
|
||||||
//! not poison, so async code keeps using `.lock().await` unchanged.
|
|
||||||
|
|
||||||
use std::sync::{Mutex, MutexGuard, PoisonError, RwLock, RwLockReadGuard, RwLockWriteGuard};
|
|
||||||
|
|
||||||
/// Poison-tolerant locking for [`std::sync::Mutex`].
|
|
||||||
pub trait MutexSafe<T: ?Sized> {
|
|
||||||
/// Lock the mutex, recovering the inner guard if the lock was poisoned.
|
|
||||||
fn lock_safe(&self) -> MutexGuard<'_, T>;
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T: ?Sized> MutexSafe<T> for Mutex<T> {
|
|
||||||
fn lock_safe(&self) -> MutexGuard<'_, T> {
|
|
||||||
self.lock().unwrap_or_else(PoisonError::into_inner)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Poison-tolerant locking for [`std::sync::RwLock`].
|
|
||||||
pub trait RwLockSafe<T: ?Sized> {
|
|
||||||
/// Acquire a read guard, recovering it if the lock was poisoned.
|
|
||||||
fn read_safe(&self) -> RwLockReadGuard<'_, T>;
|
|
||||||
/// Acquire a write guard, recovering it if the lock was poisoned.
|
|
||||||
fn write_safe(&self) -> RwLockWriteGuard<'_, T>;
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T: ?Sized> RwLockSafe<T> for RwLock<T> {
|
|
||||||
fn read_safe(&self) -> RwLockReadGuard<'_, T> {
|
|
||||||
self.read().unwrap_or_else(PoisonError::into_inner)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_safe(&self) -> RwLockWriteGuard<'_, T> {
|
|
||||||
self.write().unwrap_or_else(PoisonError::into_inner)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use std::sync::Arc;
|
|
||||||
use std::thread;
|
|
||||||
|
|
||||||
/// Run `f` with the panic hook suppressed so deliberately-poisoning panics
|
|
||||||
/// don't spam the test output.
|
|
||||||
fn without_panic_noise<R>(f: impl FnOnce() -> R) -> R {
|
|
||||||
let prev = std::panic::take_hook();
|
|
||||||
std::panic::set_hook(Box::new(|_| {}));
|
|
||||||
let result = f();
|
|
||||||
std::panic::set_hook(prev);
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn lock_safe_recovers_from_poison() {
|
|
||||||
let mutex = Arc::new(Mutex::new(0u32));
|
|
||||||
let poisoner = Arc::clone(&mutex);
|
|
||||||
|
|
||||||
without_panic_noise(|| {
|
|
||||||
let _ = thread::spawn(move || {
|
|
||||||
let mut guard = poisoner.lock().unwrap();
|
|
||||||
*guard = 42;
|
|
||||||
panic!("poison the mutex while holding the guard");
|
|
||||||
})
|
|
||||||
.join();
|
|
||||||
});
|
|
||||||
|
|
||||||
// The mutex is now poisoned: the normal path would panic.
|
|
||||||
assert!(mutex.lock().is_err());
|
|
||||||
|
|
||||||
// lock_safe recovers the guard with the last written value intact.
|
|
||||||
let guard = mutex.lock_safe();
|
|
||||||
assert_eq!(*guard, 42);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rwlock_safe_recovers_from_poison() {
|
|
||||||
let lock = Arc::new(RwLock::new(0u32));
|
|
||||||
let poisoner = Arc::clone(&lock);
|
|
||||||
|
|
||||||
without_panic_noise(|| {
|
|
||||||
let _ = thread::spawn(move || {
|
|
||||||
let mut guard = poisoner.write().unwrap();
|
|
||||||
*guard = 7;
|
|
||||||
panic!("poison the rwlock while holding the write guard");
|
|
||||||
})
|
|
||||||
.join();
|
|
||||||
});
|
|
||||||
|
|
||||||
assert!(lock.write().is_err());
|
|
||||||
assert_eq!(*lock.read_safe(), 7);
|
|
||||||
|
|
||||||
*lock.write_safe() = 9;
|
|
||||||
assert_eq!(*lock.read_safe(), 9);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,2 +1 @@
|
|||||||
pub mod conversions;
|
pub mod conversions;
|
||||||
pub mod lock;
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user