Background-audio handoff for video + repository/player refactor
Hand video playback off to a native audio-only stream when the app is backgrounded or locked, with no on-device video decode (UR-040). Adds player_enter/exit_background_audio commands, an audio-only stream URL for video items across the repository layer, and the frontend handoff state machine wired into VideoPlayer. Includes accompanying repository/offline/player refactoring and regenerates the traceability matrix.
This commit is contained in:
+1574
-655
File diff suppressed because it is too large
Load Diff
@@ -23,13 +23,33 @@ class MainActivity : TauriActivity() {
|
||||
private val audioManager by lazy { getSystemService(Context.AUDIO_SERVICE) as AudioManager }
|
||||
|
||||
/**
|
||||
* Whether backgrounding the app during video should auto-enter PiP.
|
||||
* The frontend clears this when video isn't the active local surface
|
||||
* (e.g. remote/cast playback) via AndroidPictureInPicture.setAutoEnterEnabled.
|
||||
* Coarse override for whether backgrounding the app should auto-enter PiP.
|
||||
*
|
||||
* This is NOT what excludes audio/browsing/cast from PiP — that is the
|
||||
* PictureInPictureManager.canEnterPip guard, which requires a local video
|
||||
* surface to be actively rendering and is re-checked in onUserLeaveHint. This
|
||||
* flag is only toggled by the background-audio feature (via
|
||||
* AndroidPictureInPicture.setAutoEnterEnabled) so background-audio mode and
|
||||
* auto-PiP stay mutually exclusive.
|
||||
*/
|
||||
@Volatile
|
||||
private var autoEnterPipEnabled = true
|
||||
|
||||
/**
|
||||
* Whether the user armed background-audio mode on the current video (UR-040).
|
||||
* When true, leaving the app hands audio off to the native ExoPlayer audio
|
||||
* service (frontend-driven) instead of entering PiP, and video decode stops.
|
||||
* The frontend sets this via AndroidBackgroundAudio.setEnabled.
|
||||
*/
|
||||
@Volatile
|
||||
private var backgroundAudioEnabled = false
|
||||
|
||||
/**
|
||||
* The WebView carrying the Svelte UI, cached once found so lifecycle overrides
|
||||
* can dispatch DOM events into it (native → frontend signalling).
|
||||
*/
|
||||
private var mediaWebView: WebView? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
@@ -49,15 +69,62 @@ class MainActivity : TauriActivity() {
|
||||
* Called when the user leaves the app via Home or the gesture equivalent
|
||||
* (but NOT via Back). This is the standard hook for auto-entering PiP so
|
||||
* video keeps playing in a floating window instead of being backgrounded.
|
||||
*
|
||||
* TRACES: UR-041 | IR-026 | DR-053
|
||||
*/
|
||||
override fun onUserLeaveHint() {
|
||||
super.onUserLeaveHint()
|
||||
if (autoEnterPipEnabled && PictureInPictureManager.canEnterPip(this)) {
|
||||
// Never enter PiP while background-audio mode is armed — the two are mutually
|
||||
// exclusive (the handoff runs from onStop instead).
|
||||
if (autoEnterPipEnabled && !backgroundAudioEnabled &&
|
||||
PictureInPictureManager.canEnterPip(this)) {
|
||||
android.util.Log.d("MainActivity", "User leaving with video active - entering PiP")
|
||||
PictureInPictureManager.enterPip(this)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The app is no longer visible (Home, app switch, or screen lock). When
|
||||
* background-audio mode is armed, tell the frontend to hand video playback off
|
||||
* to the native audio service. onStop (rather than onUserLeaveHint) is used
|
||||
* because it fires on screen-lock too, which is the primary use case (UR-040).
|
||||
*
|
||||
* TRACES: UR-040 | IR-025
|
||||
*/
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
if (backgroundAudioEnabled) {
|
||||
dispatchWebEvent("jellytau-background")
|
||||
}
|
||||
}
|
||||
|
||||
/** The app is visible again — tell the frontend to resume WebView video. */
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
if (backgroundAudioEnabled) {
|
||||
dispatchWebEvent("jellytau-foreground")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch a DOM CustomEvent into the WebView (native → frontend). Mirrors the
|
||||
* evaluateJavascript pattern already used to unmute video elements. Posted to
|
||||
* the WebView thread; safe no-op if the WebView isn't found yet.
|
||||
*/
|
||||
private fun dispatchWebEvent(name: String) {
|
||||
val webView = mediaWebView ?: run {
|
||||
android.util.Log.w("MainActivity", "dispatchWebEvent('$name'): no WebView")
|
||||
return
|
||||
}
|
||||
webView.post {
|
||||
webView.evaluateJavascript(
|
||||
"window.dispatchEvent(new CustomEvent('$name'));",
|
||||
null
|
||||
)
|
||||
android.util.Log.d("MainActivity", "Dispatched web event: $name")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPictureInPictureModeChanged(
|
||||
isInPictureInPictureMode: Boolean,
|
||||
newConfig: android.content.res.Configuration
|
||||
@@ -85,6 +152,7 @@ class MainActivity : TauriActivity() {
|
||||
}
|
||||
|
||||
android.util.Log.d("MainActivity", "WebView found! Configuring settings...")
|
||||
mediaWebView = webView
|
||||
|
||||
// Add JavaScript interface for audio focus control
|
||||
webView.addJavascriptInterface(object : Any() {
|
||||
@@ -129,6 +197,23 @@ class MainActivity : TauriActivity() {
|
||||
}, "AndroidPictureInPicture")
|
||||
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidPictureInPicture' added")
|
||||
|
||||
// Add JavaScript interface for background-audio mode (UR-040). The frontend
|
||||
// arms/disarms it via the player toggle; the Activity uses the flag in its
|
||||
// lifecycle overrides to decide between the audio handoff and PiP.
|
||||
webView.addJavascriptInterface(object : Any() {
|
||||
/** Frontend arms/disarms background-audio mode for the current video. */
|
||||
@JavascriptInterface
|
||||
fun setEnabled(enabled: Boolean) {
|
||||
backgroundAudioEnabled = enabled
|
||||
android.util.Log.d("MainActivity", "backgroundAudioEnabled = $enabled")
|
||||
}
|
||||
|
||||
/** Whether background audio is available on this device (needs PiP-era APIs unnecessary; audio service always present on Android). */
|
||||
@JavascriptInterface
|
||||
fun isSupported(): Boolean = true
|
||||
}, "AndroidBackgroundAudio")
|
||||
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidBackgroundAudio' added")
|
||||
|
||||
// Set WebChromeClient to handle video playback and audio focus
|
||||
webView.webChromeClient = object : WebChromeClient() {
|
||||
override fun onShowCustomView(view: View?, callback: CustomViewCallback?) {
|
||||
@@ -152,7 +237,6 @@ class MainActivity : TauriActivity() {
|
||||
domStorageEnabled = true
|
||||
allowFileAccess = true
|
||||
allowContentAccess = true
|
||||
setRenderPriority(WebSettings.RenderPriority.HIGH)
|
||||
mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
|
||||
|
||||
android.util.Log.d("MainActivity", "WebView fully configured for media playback")
|
||||
|
||||
@@ -18,6 +18,8 @@ import com.dtourolle.jellytau.player.JellyTauPlayer
|
||||
/**
|
||||
* Drives Android picture-in-picture for native (ExoPlayer) video playback.
|
||||
*
|
||||
* TRACES: UR-041 | IR-026 | DR-053
|
||||
*
|
||||
* PiP shrinks the whole Activity into a floating window, so the only thing that
|
||||
* should remain visible is the video SurfaceView that [VideoOverlayManager]
|
||||
* attached at the bottom of the z-order. The WebView carrying the Svelte UI is
|
||||
|
||||
+61
-16
@@ -4,8 +4,8 @@ use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use crate::jellyfin::http_client::HttpClient;
|
||||
use crate::connectivity::ConnectivityMonitor;
|
||||
use crate::jellyfin::http_client::HttpClient;
|
||||
|
||||
pub use session_verifier::SessionVerifier;
|
||||
|
||||
@@ -99,7 +99,10 @@ impl AuthManager {
|
||||
}
|
||||
|
||||
/// Set the connectivity monitor (for marking server reachability)
|
||||
pub fn set_connectivity_monitor(&mut self, monitor: Arc<tokio::sync::Mutex<ConnectivityMonitor>>) {
|
||||
pub fn set_connectivity_monitor(
|
||||
&mut self,
|
||||
monitor: Arc<tokio::sync::Mutex<ConnectivityMonitor>>,
|
||||
) {
|
||||
self.connectivity_monitor = Some(monitor);
|
||||
}
|
||||
|
||||
@@ -133,9 +136,17 @@ impl AuthManager {
|
||||
|
||||
log::info!("[AuthManager] Connecting to server: {}", normalized_url);
|
||||
|
||||
match self.http_client.get_json_fast::<PublicSystemInfo>(&endpoint).await {
|
||||
match self
|
||||
.http_client
|
||||
.get_json_fast::<PublicSystemInfo>(&endpoint)
|
||||
.await
|
||||
{
|
||||
Ok(info) => {
|
||||
log::info!("[AuthManager] Connected to server: {} ({})", info.server_name, info.version);
|
||||
log::info!(
|
||||
"[AuthManager] Connected to server: {} ({})",
|
||||
info.server_name,
|
||||
info.version
|
||||
);
|
||||
|
||||
// Mark server as reachable
|
||||
if let Some(monitor) = &self.connectivity_monitor {
|
||||
@@ -181,7 +192,10 @@ impl AuthManager {
|
||||
let auth_header = HttpClient::build_auth_header(None, device_id);
|
||||
|
||||
// Build request manually for custom headers
|
||||
let request = self.http_client.client.post(&endpoint)
|
||||
let request = self
|
||||
.http_client
|
||||
.client
|
||||
.post(&endpoint)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("X-Emby-Authorization", auth_header)
|
||||
.json(&serde_json::json!({
|
||||
@@ -192,19 +206,31 @@ impl AuthManager {
|
||||
.map_err(|e| format!("Failed to build request: {}", e))?;
|
||||
|
||||
// Use retry logic
|
||||
let response = self.http_client.request_with_retry(request).await
|
||||
let response = self
|
||||
.http_client
|
||||
.request_with_retry(request)
|
||||
.await
|
||||
.map_err(|e| format!("Login request failed: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
let error_text = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
return Err(format!("Login failed: HTTP {}: {}", status, error_text));
|
||||
}
|
||||
|
||||
let auth_response: AuthenticateByNameResponse = response.json().await
|
||||
let auth_response: AuthenticateByNameResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse login response: {}", e))?;
|
||||
|
||||
log::info!("[AuthManager] Login successful for user: {} ({})", auth_response.user.name, auth_response.user.id);
|
||||
log::info!(
|
||||
"[AuthManager] Login successful for user: {} ({})",
|
||||
auth_response.user.name,
|
||||
auth_response.user.id
|
||||
);
|
||||
|
||||
// Mark server as reachable
|
||||
if let Some(monitor) = &self.connectivity_monitor {
|
||||
@@ -243,13 +269,19 @@ impl AuthManager {
|
||||
let auth_header = HttpClient::build_auth_header(Some(access_token), device_id);
|
||||
|
||||
// Build request manually for custom headers
|
||||
let request = self.http_client.client.get(&endpoint)
|
||||
let request = self
|
||||
.http_client
|
||||
.client
|
||||
.get(&endpoint)
|
||||
.header("X-Emby-Authorization", auth_header)
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to build request: {}", e))?;
|
||||
|
||||
// Use retry logic
|
||||
let response = self.http_client.request_with_retry(request).await
|
||||
let response = self
|
||||
.http_client
|
||||
.request_with_retry(request)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
log::warn!("[AuthManager] Session verification failed: {}", e);
|
||||
format!("Session verification failed: {}", e)
|
||||
@@ -257,24 +289,34 @@ impl AuthManager {
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
let error_text = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
|
||||
// Mark server as unreachable for auth errors
|
||||
if status.as_u16() == 401 || status.as_u16() == 403 {
|
||||
log::warn!("[AuthManager] Session invalid: HTTP {}", status);
|
||||
if let Some(monitor) = &self.connectivity_monitor {
|
||||
let monitor = monitor.lock().await;
|
||||
monitor.mark_unreachable(Some(format!("Authentication failed: {}", status))).await;
|
||||
monitor
|
||||
.mark_unreachable(Some(format!("Authentication failed: {}", status)))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
return Err(format!("HTTP {}: {}", status, error_text));
|
||||
}
|
||||
|
||||
let user_response: JellyfinUser = response.json().await
|
||||
let user_response: JellyfinUser = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse user response: {}", e))?;
|
||||
|
||||
log::info!("[AuthManager] Session verified successfully for: {}", user_response.name);
|
||||
log::info!(
|
||||
"[AuthManager] Session verified successfully for: {}",
|
||||
user_response.name
|
||||
);
|
||||
|
||||
// Mark server as reachable
|
||||
if let Some(monitor) = &self.connectivity_monitor {
|
||||
@@ -306,7 +348,10 @@ impl AuthManager {
|
||||
let auth_header = HttpClient::build_auth_header(Some(access_token), device_id);
|
||||
|
||||
// Build request
|
||||
let request = self.http_client.client.post(&endpoint)
|
||||
let request = self
|
||||
.http_client
|
||||
.client
|
||||
.post(&endpoint)
|
||||
.header("X-Emby-Authorization", auth_header)
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to build request: {}", e))?;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use serde::Serialize;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use serde::Serialize;
|
||||
|
||||
use super::{AuthManager, User};
|
||||
|
||||
@@ -65,7 +65,10 @@ impl SessionVerifier {
|
||||
let session = auth_manager.get_session().await;
|
||||
|
||||
if let Some(session) = session {
|
||||
log::debug!("[SessionVerifier] Verifying session for: {}", session.username);
|
||||
log::debug!(
|
||||
"[SessionVerifier] Verifying session for: {}",
|
||||
session.username
|
||||
);
|
||||
|
||||
// Verify the session
|
||||
match auth_manager
|
||||
@@ -113,7 +116,10 @@ impl SessionVerifier {
|
||||
reason: "Session expired".to_string(),
|
||||
};
|
||||
if let Err(e) = app.emit("auth:needs-reauth", event) {
|
||||
log::error!("[SessionVerifier] Failed to emit event: {}", e);
|
||||
log::error!(
|
||||
"[SessionVerifier] Failed to emit event: {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,12 +137,18 @@ impl SessionVerifier {
|
||||
message: e.clone(),
|
||||
};
|
||||
if let Err(e) = app.emit("auth:network-error", event) {
|
||||
log::error!("[SessionVerifier] Failed to emit event: {}", e);
|
||||
log::error!(
|
||||
"[SessionVerifier] Failed to emit event: {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Unknown error - log but don't invalidate
|
||||
log::error!("[SessionVerifier] Unknown error during verification: {}", e);
|
||||
log::error!(
|
||||
"[SessionVerifier] Unknown error during verification: {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
//! Authentication and session-lifecycle commands.
|
||||
//!
|
||||
//! TRACES: UR-042 | IR-009, IR-014, JA-002 | DR-054
|
||||
|
||||
use std::sync::Arc;
|
||||
use tauri::State;
|
||||
|
||||
use crate::auth::{AuthManager, SessionVerifier, ServerInfo, AuthResult, Session};
|
||||
use crate::auth::{AuthManager, AuthResult, ServerInfo, Session, SessionVerifier};
|
||||
|
||||
/// Wrapper for AuthManager to manage in Tauri state
|
||||
pub struct AuthManagerWrapper(pub Arc<AuthManager>);
|
||||
@@ -27,17 +31,18 @@ pub async fn auth_initialize(
|
||||
log::info!("[AuthManager] Restoring session from storage...");
|
||||
|
||||
// Use the existing storage_get_active_session function
|
||||
let active_session = match crate::commands::storage::storage_get_active_session(database, credentials).await {
|
||||
Ok(Some(session)) => session,
|
||||
Ok(None) => {
|
||||
log::info!("[AuthManager] No active session in storage");
|
||||
return Ok(None);
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("[AuthManager] Failed to get active session: {}", e);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
let active_session =
|
||||
match crate::commands::storage::storage_get_active_session(database, credentials).await {
|
||||
Ok(Some(session)) => session,
|
||||
Ok(None) => {
|
||||
log::info!("[AuthManager] No active session in storage");
|
||||
return Ok(None);
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("[AuthManager] Failed to get active session: {}", e);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
// Create session object from active session with normalized URL
|
||||
let normalized_url = crate::auth::AuthManager::normalize_url(&active_session.server_url)?;
|
||||
@@ -56,7 +61,11 @@ pub async fn auth_initialize(
|
||||
// Store in AuthManager
|
||||
auth_manager.0.set_session(Some(session.clone())).await;
|
||||
|
||||
log::info!("[AuthManager] Session restored for user: {} with normalized URL: {}", session.username, session.server_url);
|
||||
log::info!(
|
||||
"[AuthManager] Session restored for user: {} with normalized URL: {}",
|
||||
session.username,
|
||||
session.server_url
|
||||
);
|
||||
Ok(Some(session))
|
||||
}
|
||||
|
||||
@@ -80,7 +89,10 @@ pub async fn auth_login(
|
||||
device_id: String,
|
||||
auth_manager: State<'_, AuthManagerWrapper>,
|
||||
) -> Result<AuthResult, String> {
|
||||
let result = auth_manager.0.login(&server_url, &username, &password, &device_id).await?;
|
||||
let result = auth_manager
|
||||
.0
|
||||
.login(&server_url, &username, &password, &device_id)
|
||||
.await?;
|
||||
|
||||
// Create session from auth result with normalized URL
|
||||
let normalized_url = crate::auth::AuthManager::normalize_url(&server_url)?;
|
||||
@@ -111,7 +123,11 @@ pub async fn auth_verify_session(
|
||||
device_id: String,
|
||||
auth_manager: State<'_, AuthManagerWrapper>,
|
||||
) -> Result<bool, String> {
|
||||
match auth_manager.0.verify_session(&server_url, &user_id, &access_token, &device_id).await {
|
||||
match auth_manager
|
||||
.0
|
||||
.verify_session(&server_url, &user_id, &access_token, &device_id)
|
||||
.await
|
||||
{
|
||||
Ok(_) => Ok(true),
|
||||
Err(e) => {
|
||||
log::warn!("[AuthCommands] Session verification failed: {}", e);
|
||||
@@ -138,7 +154,10 @@ pub async fn auth_logout(
|
||||
drop(verifier_guard);
|
||||
|
||||
// Call Jellyfin logout endpoint
|
||||
auth_manager.0.logout(&server_url, &access_token, &device_id).await?;
|
||||
auth_manager
|
||||
.0
|
||||
.logout(&server_url, &access_token, &device_id)
|
||||
.await?;
|
||||
|
||||
// Clear session
|
||||
auth_manager.0.set_session(None).await;
|
||||
@@ -228,11 +247,22 @@ pub async fn auth_reauthenticate(
|
||||
auth_manager: State<'_, AuthManagerWrapper>,
|
||||
) -> Result<AuthResult, String> {
|
||||
// Get current session to extract server_url and username
|
||||
let session = auth_manager.0.get_session().await
|
||||
let session = auth_manager
|
||||
.0
|
||||
.get_session()
|
||||
.await
|
||||
.ok_or_else(|| "No active session to re-authenticate".to_string())?;
|
||||
|
||||
// Re-login with stored credentials
|
||||
let result = auth_manager.0.login(&session.server_url, &session.username, &password, &device_id).await?;
|
||||
let result = auth_manager
|
||||
.0
|
||||
.login(
|
||||
&session.server_url,
|
||||
&session.username,
|
||||
&password,
|
||||
&device_id,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Update session with new token
|
||||
let updated_session = Session {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! Tauri commands for the offline "browse & queue" feature.
|
||||
//!
|
||||
//! TRACES: UR-002, UR-007, UR-024 | JA-004, JA-016 | DR-012, DR-027
|
||||
//!
|
||||
//! Two backend pieces support browsing the full server catalog while offline
|
||||
//! and queueing downloads that fire on reconnect:
|
||||
//!
|
||||
@@ -17,8 +19,8 @@ use std::sync::Arc;
|
||||
use log::{info, warn};
|
||||
use tauri::State;
|
||||
|
||||
use crate::commands::repository::RepositoryManagerWrapper;
|
||||
use crate::commands::download::{pump_download_queue, DownloadManagerWrapper};
|
||||
use crate::commands::repository::RepositoryManagerWrapper;
|
||||
use crate::commands::storage::DatabaseWrapper;
|
||||
use crate::repository::types::GetItemsOptions;
|
||||
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
|
||||
@@ -79,7 +81,10 @@ pub async fn sync_full_catalog(
|
||||
};
|
||||
|
||||
let libraries = repo.get_libraries().await.map_err(|e| e.to_string())?;
|
||||
info!("[Catalog] Full sync starting across {} libraries", libraries.len());
|
||||
info!(
|
||||
"[Catalog] Full sync starting across {} libraries",
|
||||
libraries.len()
|
||||
);
|
||||
|
||||
let include_types: Vec<String> = CATALOG_ITEM_TYPES.iter().map(|s| s.to_string()).collect();
|
||||
|
||||
@@ -104,7 +109,10 @@ pub async fn sync_full_catalog(
|
||||
items_cached += items.len();
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("[Catalog] Failed to sync library '{}': {:?}", library.name, e);
|
||||
warn!(
|
||||
"[Catalog] Failed to sync library '{}': {:?}",
|
||||
library.name, e
|
||||
);
|
||||
libraries_failed += 1;
|
||||
}
|
||||
}
|
||||
@@ -208,10 +216,16 @@ where
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if rows.is_empty() {
|
||||
return Ok(ResumeQueuedResult { resolved: 0, failed: 0 });
|
||||
return Ok(ResumeQueuedResult {
|
||||
resolved: 0,
|
||||
failed: 0,
|
||||
});
|
||||
}
|
||||
|
||||
info!("[Catalog] Resolving {} offline-queued downloads on reconnect", rows.len());
|
||||
info!(
|
||||
"[Catalog] Resolving {} offline-queued downloads on reconnect",
|
||||
rows.len()
|
||||
);
|
||||
|
||||
let mut resolved = 0usize;
|
||||
let mut failed = 0usize;
|
||||
@@ -240,7 +254,10 @@ where
|
||||
Ok(n) if n > 0 => resolved += 1,
|
||||
Ok(_) => {} // already resolved by someone else; not a failure
|
||||
Err(e) => {
|
||||
warn!("[Catalog] Failed to persist URL for download {}: {}", download_id, e);
|
||||
warn!(
|
||||
"[Catalog] Failed to persist URL for download {}: {}",
|
||||
download_id, e
|
||||
);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
@@ -309,17 +326,22 @@ pub async fn resume_queued_downloads(
|
||||
let repo = Arc::clone(&repo_for_resolve);
|
||||
async move {
|
||||
if media_type == "video" {
|
||||
Some(<HybridRepository as MediaRepository>::get_video_download_url(
|
||||
repo.as_ref(),
|
||||
&item_id,
|
||||
&quality,
|
||||
None,
|
||||
))
|
||||
Some(
|
||||
<HybridRepository as MediaRepository>::get_video_download_url(
|
||||
repo.as_ref(),
|
||||
&item_id,
|
||||
&quality,
|
||||
None,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
match repo.get_audio_stream_url(&item_id).await {
|
||||
Ok(url) => Some(url),
|
||||
Err(e) => {
|
||||
warn!("[Catalog] Failed to resolve audio URL for {}: {:?}", item_id, e);
|
||||
warn!(
|
||||
"[Catalog] Failed to resolve audio URL for {}: {:?}",
|
||||
item_id, e
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -341,7 +363,10 @@ pub async fn resume_queued_downloads(
|
||||
pump_download_queue(app, db_service, active_downloads).await;
|
||||
}
|
||||
|
||||
info!("[Catalog] Resume complete: {} resolved, {} failed", resolved, failed);
|
||||
info!(
|
||||
"[Catalog] Resume complete: {} resolved, {} failed",
|
||||
resolved, failed
|
||||
);
|
||||
|
||||
Ok(ResumeQueuedResult { resolved, failed })
|
||||
}
|
||||
@@ -384,14 +409,21 @@ mod tests {
|
||||
vec![
|
||||
QueryParam::String(item_id.to_string()),
|
||||
QueryParam::String(status.to_string()),
|
||||
stream_url.map(|s| QueryParam::String(s.to_string())).unwrap_or(QueryParam::Null),
|
||||
media_type.map(|s| QueryParam::String(s.to_string())).unwrap_or(QueryParam::Null),
|
||||
stream_url
|
||||
.map(|s| QueryParam::String(s.to_string()))
|
||||
.unwrap_or(QueryParam::Null),
|
||||
media_type
|
||||
.map(|s| QueryParam::String(s.to_string()))
|
||||
.unwrap_or(QueryParam::Null),
|
||||
],
|
||||
);
|
||||
db.execute(q).await.unwrap();
|
||||
}
|
||||
|
||||
async fn get_row(db: &Arc<RusqliteService>, item_id: &str) -> (String, Option<String>, Option<String>) {
|
||||
async fn get_row(
|
||||
db: &Arc<RusqliteService>,
|
||||
item_id: &str,
|
||||
) -> (String, Option<String>, Option<String>) {
|
||||
let q = Query::with_params(
|
||||
"SELECT status, stream_url, target_dir FROM downloads WHERE item_id = ?",
|
||||
vec![QueryParam::String(item_id.to_string())],
|
||||
@@ -411,11 +443,12 @@ mod tests {
|
||||
// A completed row: irrelevant.
|
||||
insert_download(&db, "done", "completed", Some("http://done/url"), None).await;
|
||||
|
||||
let out = resolve_pending_download_urls(&db, "/data/downloads", |item_id, _mt, _q| async move {
|
||||
Some(format!("http://resolved/{item_id}"))
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let out =
|
||||
resolve_pending_download_urls(&db, "/data/downloads", |item_id, _mt, _q| async move {
|
||||
Some(format!("http://resolved/{item_id}"))
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.resolved, 1);
|
||||
assert_eq!(out.failed, 0);
|
||||
@@ -455,12 +488,13 @@ mod tests {
|
||||
let db = test_db();
|
||||
insert_download(&db, "vid-1", "pending", None, Some("video")).await;
|
||||
|
||||
let out = resolve_pending_download_urls(&db, "/data", |item_id, media_type, _q| async move {
|
||||
assert_eq!(media_type, "video");
|
||||
Some(format!("http://transcode/{item_id}"))
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let out =
|
||||
resolve_pending_download_urls(&db, "/data", |item_id, media_type, _q| async move {
|
||||
assert_eq!(media_type, "video");
|
||||
Some(format!("http://transcode/{item_id}"))
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.resolved, 1);
|
||||
let (_s, url, _t) = get_row(&db, "vid-1").await;
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
//! Server-reachability / connectivity commands.
|
||||
//!
|
||||
//! TRACES: UR-043 | IR-027 | DR-055
|
||||
|
||||
use crate::connectivity::{ConnectivityMonitor, ConnectivityStatus};
|
||||
use std::sync::Arc;
|
||||
use tauri::State;
|
||||
use crate::connectivity::{ConnectivityMonitor, ConnectivityStatus};
|
||||
|
||||
/// Wrapper for ConnectivityMonitor managed state
|
||||
pub struct ConnectivityMonitorWrapper(pub Arc<tokio::sync::Mutex<ConnectivityMonitor>>);
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
//! Tauri commands for unit conversions and formatting
|
||||
//!
|
||||
//! TRACES: UR-005 | DR-009
|
||||
//!
|
||||
//! These commands expose conversion utilities to the frontend,
|
||||
//! allowing centralized conversion logic in Rust.
|
||||
|
||||
use crate::utils::conversions::{
|
||||
format_time, format_time_long, calculate_progress,
|
||||
ticks_to_seconds, percent_to_volume,
|
||||
calculate_progress, format_time, format_time_long, percent_to_volume, ticks_to_seconds,
|
||||
};
|
||||
|
||||
/// Format time in seconds to MM:SS display string
|
||||
|
||||
@@ -81,7 +81,10 @@ pub async fn device_get_id(db: State<'_, DatabaseWrapper>) -> Result<String, Str
|
||||
/// TRACES: UR-009 | DR-011
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn device_set_id(device_id: String, db: State<'_, DatabaseWrapper>) -> Result<(), String> {
|
||||
pub async fn device_set_id(
|
||||
device_id: String,
|
||||
db: State<'_, DatabaseWrapper>,
|
||||
) -> Result<(), String> {
|
||||
let db_service = {
|
||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||
Arc::new(database.service())
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
#[cfg(test)]
|
||||
use crate::utils::lock::MutexSafe;
|
||||
use log::{debug, error, info, warn};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tauri::{Manager, State};
|
||||
use log::{debug, error, info, warn};
|
||||
|
||||
use super::{DatabaseWrapper, SmartCacheWrapper};
|
||||
use crate::download::{DownloadInfo, DownloadManager};
|
||||
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
|
||||
use super::{DatabaseWrapper, SmartCacheWrapper};
|
||||
|
||||
// Cohesive command clusters in their own submodules, re-exported so the command
|
||||
// names remain at `commands::download::*` (invoke_handler unchanged).
|
||||
@@ -111,7 +111,13 @@ pub async fn download_item_and_start(
|
||||
request: DownloadItemAndStartRequest,
|
||||
) -> Result<i64, String> {
|
||||
let DownloadItemAndStartRequest {
|
||||
item_id, user_id, stream_url, target_dir, item_name, artist_name, album_name,
|
||||
item_id,
|
||||
user_id,
|
||||
stream_url,
|
||||
target_dir,
|
||||
item_name,
|
||||
artist_name,
|
||||
album_name,
|
||||
} = request;
|
||||
// Sanitize filename
|
||||
let safe_name = sanitize_filename(item_name.as_deref().unwrap_or(&item_id));
|
||||
@@ -132,7 +138,8 @@ pub async fn download_item_and_start(
|
||||
album_name,
|
||||
expected_size: None,
|
||||
},
|
||||
).await?;
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Start the download immediately
|
||||
start_download(
|
||||
@@ -142,7 +149,8 @@ pub async fn download_item_and_start(
|
||||
download_id,
|
||||
stream_url,
|
||||
target_dir,
|
||||
).await?;
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(download_id)
|
||||
}
|
||||
@@ -156,7 +164,15 @@ pub async fn download_item(
|
||||
request: DownloadItemRequest,
|
||||
) -> Result<i64, String> {
|
||||
let DownloadItemRequest {
|
||||
item_id, user_id, file_path, mime_type, priority, item_name, artist_name, album_name, expected_size,
|
||||
item_id,
|
||||
user_id,
|
||||
file_path,
|
||||
mime_type,
|
||||
priority,
|
||||
item_name,
|
||||
artist_name,
|
||||
album_name,
|
||||
expected_size,
|
||||
} = request;
|
||||
let db_service = {
|
||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||
@@ -172,18 +188,24 @@ pub async fn download_item(
|
||||
};
|
||||
|
||||
// Check if we have space
|
||||
let can_download = cache_arc.can_download_async(&db_service, &user_id, size as u64).await;
|
||||
let can_download = cache_arc
|
||||
.can_download_async(&db_service, &user_id, size as u64)
|
||||
.await;
|
||||
|
||||
if !can_download {
|
||||
warn!("Storage limit reached. Attempting to free space...");
|
||||
|
||||
// Try to evict LRU items to make space
|
||||
match cache_arc.evict_lru_async(&db_service, &user_id, size as u64).await {
|
||||
match cache_arc
|
||||
.evict_lru_async(&db_service, &user_id, size as u64)
|
||||
.await
|
||||
{
|
||||
Ok(freed) if freed > 0 => {
|
||||
info!("Freed {} bytes, proceeding with download", freed);
|
||||
}
|
||||
Ok(_) => {
|
||||
let storage_limit = cache_arc.get_config().map(|c| c.storage_limit).unwrap_or(0);
|
||||
let storage_limit =
|
||||
cache_arc.get_config().map(|c| c.storage_limit).unwrap_or(0);
|
||||
return Err(format!(
|
||||
"Storage limit reached ({} bytes). Unable to free enough space.",
|
||||
storage_limit
|
||||
@@ -220,7 +242,10 @@ pub async fn download_item(
|
||||
],
|
||||
);
|
||||
|
||||
db_service.execute(insert_query).await.map_err(|e| e.to_string())?;
|
||||
db_service
|
||||
.execute(insert_query)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Query for the download ID by unique constraint columns
|
||||
// NOTE: last_insert_rowid() doesn't work reliably with UPSERT - it only updates on INSERT, not UPDATE
|
||||
@@ -291,12 +316,18 @@ pub async fn download_album(
|
||||
],
|
||||
);
|
||||
|
||||
db_service.execute(insert_query).await.map_err(|e| e.to_string())?;
|
||||
db_service
|
||||
.execute(insert_query)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Query for the actual download ID (last_insert_rowid doesn't work with UPSERT)
|
||||
let id_query = Query::with_params(
|
||||
"SELECT id FROM downloads WHERE item_id = ? AND user_id = ?",
|
||||
vec![QueryParam::String(track_id), QueryParam::String(user_id.clone())],
|
||||
vec![
|
||||
QueryParam::String(track_id),
|
||||
QueryParam::String(user_id.clone()),
|
||||
],
|
||||
);
|
||||
|
||||
let download_id: i64 = db_service
|
||||
@@ -317,8 +348,17 @@ pub async fn download_video(
|
||||
request: DownloadVideoRequest,
|
||||
) -> Result<i64, String> {
|
||||
let DownloadVideoRequest {
|
||||
item_id, user_id, file_path, mime_type, priority, item_name, quality_preset,
|
||||
series_name, season_name, episode_number, season_number,
|
||||
item_id,
|
||||
user_id,
|
||||
file_path,
|
||||
mime_type,
|
||||
priority,
|
||||
item_name,
|
||||
quality_preset,
|
||||
series_name,
|
||||
season_name,
|
||||
episode_number,
|
||||
season_number,
|
||||
} = request;
|
||||
let db_service = {
|
||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||
@@ -358,7 +398,10 @@ pub async fn download_video(
|
||||
],
|
||||
);
|
||||
|
||||
db_service.execute(insert_query).await.map_err(|e| e.to_string())?;
|
||||
db_service
|
||||
.execute(insert_query)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Query for the download ID by unique constraint columns
|
||||
let id_query = Query::with_params(
|
||||
@@ -403,7 +446,13 @@ pub async fn download_series(
|
||||
|
||||
let episodes: Vec<(String, String, Option<String>, Option<i32>, Option<i32>)> = db_service
|
||||
.query_many(episodes_query, |row| {
|
||||
Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?))
|
||||
Ok((
|
||||
row.get(0)?,
|
||||
row.get(1)?,
|
||||
row.get(2)?,
|
||||
row.get(3)?,
|
||||
row.get(4)?,
|
||||
))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
@@ -413,7 +462,9 @@ pub async fn download_series(
|
||||
// Queue each episode with descending priority (first episodes download first)
|
||||
// Priority starts high and decreases so earlier episodes finish first
|
||||
let total_episodes = episodes.len() as i32;
|
||||
for (idx, (episode_id, episode_name, season_name, episode_number, season_number)) in episodes.into_iter().enumerate() {
|
||||
for (idx, (episode_id, episode_name, season_name, episode_number, season_number)) in
|
||||
episodes.into_iter().enumerate()
|
||||
{
|
||||
let priority = 1000 - idx as i32; // High priority for first episodes
|
||||
|
||||
// Create path like: videos/SeriesName/S01E01_Title.mp4
|
||||
@@ -425,7 +476,12 @@ pub async fn download_series(
|
||||
episode_num,
|
||||
sanitize_filename(&episode_name)
|
||||
);
|
||||
let file_path = format!("{}/{}/{}", base_path, sanitize_filename(&series_name), file_name);
|
||||
let file_path = format!(
|
||||
"{}/{}/{}",
|
||||
base_path,
|
||||
sanitize_filename(&series_name),
|
||||
file_name
|
||||
);
|
||||
|
||||
let insert_query = Query::with_params(
|
||||
"INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at,
|
||||
@@ -450,17 +506,29 @@ pub async fn download_series(
|
||||
QueryParam::String(episode_name),
|
||||
QueryParam::String(quality.clone()),
|
||||
QueryParam::String(series_name.clone()),
|
||||
season_name.map(QueryParam::String).unwrap_or(QueryParam::Null),
|
||||
episode_number.map(QueryParam::Int).unwrap_or(QueryParam::Null),
|
||||
season_number.map(QueryParam::Int).unwrap_or(QueryParam::Null),
|
||||
season_name
|
||||
.map(QueryParam::String)
|
||||
.unwrap_or(QueryParam::Null),
|
||||
episode_number
|
||||
.map(QueryParam::Int)
|
||||
.unwrap_or(QueryParam::Null),
|
||||
season_number
|
||||
.map(QueryParam::Int)
|
||||
.unwrap_or(QueryParam::Null),
|
||||
],
|
||||
);
|
||||
|
||||
db_service.execute(insert_query).await.map_err(|e| e.to_string())?;
|
||||
db_service
|
||||
.execute(insert_query)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let id_query = Query::with_params(
|
||||
"SELECT id FROM downloads WHERE item_id = ? AND user_id = ?",
|
||||
vec![QueryParam::String(episode_id), QueryParam::String(user_id.clone())],
|
||||
vec![
|
||||
QueryParam::String(episode_id),
|
||||
QueryParam::String(user_id.clone()),
|
||||
],
|
||||
);
|
||||
|
||||
let download_id: i64 = db_service
|
||||
@@ -471,7 +539,10 @@ pub async fn download_series(
|
||||
download_ids.push(download_id);
|
||||
}
|
||||
|
||||
info!("[download_series] Queued {} episodes for series '{}'", total_episodes, series_name);
|
||||
info!(
|
||||
"[download_series] Queued {} episodes for series '{}'",
|
||||
total_episodes, series_name
|
||||
);
|
||||
Ok(download_ids)
|
||||
}
|
||||
|
||||
@@ -524,7 +595,12 @@ pub async fn download_season(
|
||||
episode_num,
|
||||
sanitize_filename(&episode_name)
|
||||
);
|
||||
let file_path = format!("{}/{}/{}", base_path, sanitize_filename(&series_name), file_name);
|
||||
let file_path = format!(
|
||||
"{}/{}/{}",
|
||||
base_path,
|
||||
sanitize_filename(&series_name),
|
||||
file_name
|
||||
);
|
||||
|
||||
let insert_query = Query::with_params(
|
||||
"INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at,
|
||||
@@ -550,11 +626,17 @@ pub async fn download_season(
|
||||
],
|
||||
);
|
||||
|
||||
db_service.execute(insert_query).await.map_err(|e| e.to_string())?;
|
||||
db_service
|
||||
.execute(insert_query)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let id_query = Query::with_params(
|
||||
"SELECT id FROM downloads WHERE item_id = ? AND user_id = ?",
|
||||
vec![QueryParam::String(episode_id), QueryParam::String(user_id.clone())],
|
||||
vec![
|
||||
QueryParam::String(episode_id),
|
||||
QueryParam::String(user_id.clone()),
|
||||
],
|
||||
);
|
||||
|
||||
let download_id: i64 = db_service
|
||||
@@ -565,11 +647,15 @@ pub async fn download_season(
|
||||
download_ids.push(download_id);
|
||||
}
|
||||
|
||||
info!("[download_season] Queued {} episodes for {} - {}", download_ids.len(), series_name, season_name);
|
||||
info!(
|
||||
"[download_season] Queued {} episodes for {} - {}",
|
||||
download_ids.len(),
|
||||
series_name,
|
||||
season_name
|
||||
);
|
||||
Ok(download_ids)
|
||||
}
|
||||
|
||||
|
||||
/// Helper to compute download statistics from a list of downloads
|
||||
#[allow(dead_code)]
|
||||
fn compute_download_stats(downloads: &[DownloadInfo]) -> DownloadStats {
|
||||
@@ -674,7 +760,10 @@ pub async fn get_downloads(
|
||||
/// Pause a download
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn pause_download(db: State<'_, DatabaseWrapper>, download_id: i64) -> Result<(), String> {
|
||||
pub async fn pause_download(
|
||||
db: State<'_, DatabaseWrapper>,
|
||||
download_id: i64,
|
||||
) -> Result<(), String> {
|
||||
let db_service = {
|
||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||
Arc::new(database.service())
|
||||
@@ -692,7 +781,10 @@ pub async fn pause_download(db: State<'_, DatabaseWrapper>, download_id: i64) ->
|
||||
/// Resume a paused download
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn resume_download(db: State<'_, DatabaseWrapper>, download_id: i64) -> Result<(), String> {
|
||||
pub async fn resume_download(
|
||||
db: State<'_, DatabaseWrapper>,
|
||||
download_id: i64,
|
||||
) -> Result<(), String> {
|
||||
let db_service = {
|
||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||
Arc::new(database.service())
|
||||
@@ -738,13 +830,20 @@ pub async fn cancel_download(
|
||||
vec![QueryParam::Int64(download_id)],
|
||||
);
|
||||
|
||||
db_service.execute(delete_query).await.map_err(|e| e.to_string())?;
|
||||
db_service
|
||||
.execute(delete_query)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Unregister from download manager (in case it was active)
|
||||
{
|
||||
let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
|
||||
manager.unregister_download(download_id);
|
||||
info!("Cancelled download {}. Active downloads: {}", download_id, manager.active_count());
|
||||
info!(
|
||||
"Cancelled download {}. Active downloads: {}",
|
||||
download_id,
|
||||
manager.active_count()
|
||||
);
|
||||
}
|
||||
|
||||
// Delete partial file if exists
|
||||
@@ -800,7 +899,10 @@ pub async fn mark_download_failed(
|
||||
|
||||
let query = Query::with_params(
|
||||
"UPDATE downloads SET status = 'failed', error_message = ? WHERE id = ?",
|
||||
vec![QueryParam::String(error_message), QueryParam::Int64(download_id)],
|
||||
vec![
|
||||
QueryParam::String(error_message),
|
||||
QueryParam::Int64(download_id),
|
||||
],
|
||||
);
|
||||
|
||||
db_service.execute(query).await.map_err(|e| e.to_string())?;
|
||||
@@ -834,7 +936,10 @@ pub async fn start_download(
|
||||
})?;
|
||||
|
||||
if !manager.can_start_download() {
|
||||
warn!("Cannot start download: maximum concurrent downloads ({}) reached", manager.max_concurrent());
|
||||
warn!(
|
||||
"Cannot start download: maximum concurrent downloads ({}) reached",
|
||||
manager.max_concurrent()
|
||||
);
|
||||
debug!(" Active downloads: {}", manager.active_count());
|
||||
return Err(format!(
|
||||
"Maximum concurrent downloads ({}) reached. Please wait for existing downloads to complete.",
|
||||
@@ -845,12 +950,19 @@ pub async fn start_download(
|
||||
// Register this download as active
|
||||
let registered = manager.register_download(download_id);
|
||||
if !registered {
|
||||
warn!("Failed to register download {}: already registered or limit reached", download_id);
|
||||
warn!(
|
||||
"Failed to register download {}: already registered or limit reached",
|
||||
download_id
|
||||
);
|
||||
return Err("Download already in progress or limit reached".to_string());
|
||||
}
|
||||
|
||||
info!("Download {} registered. Active downloads: {}/{}",
|
||||
download_id, manager.active_count(), manager.max_concurrent());
|
||||
info!(
|
||||
"Download {} registered. Active downloads: {}/{}",
|
||||
download_id,
|
||||
manager.active_count(),
|
||||
manager.max_concurrent()
|
||||
);
|
||||
}
|
||||
|
||||
// Get download info from DB
|
||||
@@ -868,21 +980,23 @@ pub async fn start_download(
|
||||
);
|
||||
|
||||
let (item_id, file_path, file_size): (String, String, Option<i64>) = db_service
|
||||
.query_one(info_query, |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
|
||||
.query_one(info_query, |row| {
|
||||
Ok((row.get(0)?, row.get(1)?, row.get(2)?))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("Failed to query download info: {}", e);
|
||||
e.to_string()
|
||||
})?;
|
||||
|
||||
debug!(" Retrieved: item_id={}, file_path={}, file_size={:?}", item_id, file_path, file_size);
|
||||
debug!(
|
||||
" Retrieved: item_id={}, file_path={}, file_size={:?}",
|
||||
item_id, file_path, file_size
|
||||
);
|
||||
|
||||
// Make a HEAD request to get the file size from Content-Length header
|
||||
debug!("Making HEAD request to get file size...");
|
||||
let head_response = reqwest::Client::new()
|
||||
.head(&stream_url)
|
||||
.send()
|
||||
.await;
|
||||
let head_response = reqwest::Client::new().head(&stream_url).send().await;
|
||||
|
||||
let file_size_from_server = match head_response {
|
||||
Ok(response) => {
|
||||
@@ -893,7 +1007,11 @@ pub async fn start_download(
|
||||
.and_then(|v| v.parse::<i64>().ok());
|
||||
|
||||
if let Some(size) = size {
|
||||
debug!(" Got file size from server: {} bytes ({} MB)", size, size / 1024 / 1024);
|
||||
debug!(
|
||||
" Got file size from server: {} bytes ({} MB)",
|
||||
size,
|
||||
size / 1024 / 1024
|
||||
);
|
||||
} else {
|
||||
warn!(" Server didn't provide Content-Length header");
|
||||
}
|
||||
@@ -929,7 +1047,10 @@ pub async fn start_download(
|
||||
)
|
||||
};
|
||||
|
||||
db_service.execute(update_query).await.map_err(|e| e.to_string())?;
|
||||
db_service
|
||||
.execute(update_query)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Emit started event
|
||||
let started_event = DownloadEvent::Started {
|
||||
@@ -937,7 +1058,10 @@ pub async fn start_download(
|
||||
item_id: item_id.clone(),
|
||||
};
|
||||
debug!("Emitting download-event: {:?}", started_event);
|
||||
debug!(" Serialized: {}", serde_json::to_string(&started_event).unwrap_or_default());
|
||||
debug!(
|
||||
" Serialized: {}",
|
||||
serde_json::to_string(&started_event).unwrap_or_default()
|
||||
);
|
||||
match app.emit("download-event", started_event) {
|
||||
Ok(_) => debug!(" Event emitted successfully"),
|
||||
Err(e) => error!(" Event emit failed: {:?}", e),
|
||||
@@ -998,7 +1122,10 @@ pub async fn enqueue_download(
|
||||
QueryParam::Int64(download_id),
|
||||
],
|
||||
);
|
||||
db_service.execute(update_query).await.map_err(|e| e.to_string())?;
|
||||
db_service
|
||||
.execute(update_query)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Kick the pump: it will start as many pending downloads as there are slots.
|
||||
let active_downloads = {
|
||||
@@ -1056,7 +1183,9 @@ pub async fn enqueue_video_downloads(
|
||||
};
|
||||
|
||||
// Build the transcode URL (pure URL builder, no server round-trip).
|
||||
let stream_url = repo.as_ref().get_video_download_url(&item_id, &quality, None);
|
||||
let stream_url = repo
|
||||
.as_ref()
|
||||
.get_video_download_url(&item_id, &quality, None);
|
||||
|
||||
let update_query = Query::with_params(
|
||||
"UPDATE downloads SET status = 'pending', stream_url = ?, target_dir = ? WHERE id = ?",
|
||||
@@ -1067,7 +1196,10 @@ pub async fn enqueue_video_downloads(
|
||||
],
|
||||
);
|
||||
if let Err(e) = db_service.execute(update_query).await {
|
||||
warn!("[enqueue_video] Failed to persist URL for download {}: {}", download_id, e);
|
||||
warn!(
|
||||
"[enqueue_video] Failed to persist URL for download {}: {}",
|
||||
download_id, e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1137,7 +1269,13 @@ pub(crate) async fn pump_download_queue(
|
||||
|
||||
let candidates: Vec<(i64, String, String, String, String)> = match db_service
|
||||
.query_many(next_query, |row| {
|
||||
Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?))
|
||||
Ok((
|
||||
row.get(0)?,
|
||||
row.get(1)?,
|
||||
row.get(2)?,
|
||||
row.get(3)?,
|
||||
row.get(4)?,
|
||||
))
|
||||
})
|
||||
.await
|
||||
{
|
||||
@@ -1189,7 +1327,10 @@ pub(crate) async fn pump_download_queue(
|
||||
vec![QueryParam::Int64(download_id)],
|
||||
);
|
||||
if let Err(e) = db_service.execute(update_query).await {
|
||||
error!("[pump] Failed to mark download {} downloading: {}", download_id, e);
|
||||
error!(
|
||||
"[pump] Failed to mark download {} downloading: {}",
|
||||
download_id, e
|
||||
);
|
||||
if let Ok(mut a) = active_downloads.lock() {
|
||||
a.remove(&download_id);
|
||||
}
|
||||
@@ -1228,8 +1369,8 @@ fn spawn_download_worker(
|
||||
target_path: std::path::PathBuf,
|
||||
active_downloads: Arc<Mutex<std::collections::HashSet<i64>>>,
|
||||
) {
|
||||
use crate::download::{DownloadTask, DownloadWorker};
|
||||
use crate::download::events::DownloadEvent;
|
||||
use crate::download::{DownloadTask, DownloadWorker};
|
||||
use tauri::Emitter;
|
||||
|
||||
let task = DownloadTask {
|
||||
@@ -1265,7 +1406,11 @@ fn spawn_download_worker(
|
||||
// Free the slot before pumping so the next download can take it.
|
||||
if let Ok(mut active) = active_downloads.lock() {
|
||||
active.remove(&download_id);
|
||||
debug!(" Unregistered download {}. Active downloads: {}", download_id, active.len());
|
||||
debug!(
|
||||
" Unregistered download {}. Active downloads: {}",
|
||||
download_id,
|
||||
active.len()
|
||||
);
|
||||
}
|
||||
|
||||
// The pump runs downloads in the background, so the terminal status MUST
|
||||
@@ -1281,7 +1426,10 @@ fn spawn_download_worker(
|
||||
let database = match db.0.lock() {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
error!("[pump] Failed to lock database after download {}: {}", download_id, e);
|
||||
error!(
|
||||
"[pump] Failed to lock database after download {}: {}",
|
||||
download_id, e
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -1290,7 +1438,10 @@ fn spawn_download_worker(
|
||||
|
||||
match result {
|
||||
Ok(res) => {
|
||||
info!("Download completed successfully: {} bytes", res.bytes_downloaded);
|
||||
info!(
|
||||
"Download completed successfully: {} bytes",
|
||||
res.bytes_downloaded
|
||||
);
|
||||
let file_path = target_path.to_string_lossy().to_string();
|
||||
|
||||
let update = Query::with_params(
|
||||
@@ -1305,7 +1456,10 @@ fn spawn_download_worker(
|
||||
],
|
||||
);
|
||||
if let Err(e) = db_service.execute(update).await {
|
||||
error!("[pump] Failed to persist completed status for download {}: {}", download_id, e);
|
||||
error!(
|
||||
"[pump] Failed to persist completed status for download {}: {}",
|
||||
download_id, e
|
||||
);
|
||||
}
|
||||
|
||||
let completed_event = DownloadEvent::Completed {
|
||||
@@ -1329,7 +1483,10 @@ fn spawn_download_worker(
|
||||
],
|
||||
);
|
||||
if let Err(db_err) = db_service.execute(update).await {
|
||||
error!("[pump] Failed to persist failed status for download {}: {}", download_id, db_err);
|
||||
error!(
|
||||
"[pump] Failed to persist failed status for download {}: {}",
|
||||
download_id, db_err
|
||||
);
|
||||
}
|
||||
|
||||
let failed_event = DownloadEvent::Failed {
|
||||
@@ -1352,7 +1509,10 @@ fn spawn_download_worker(
|
||||
/// Delete a completed download
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn delete_download(db: State<'_, DatabaseWrapper>, download_id: i64) -> Result<(), String> {
|
||||
pub async fn delete_download(
|
||||
db: State<'_, DatabaseWrapper>,
|
||||
download_id: i64,
|
||||
) -> Result<(), String> {
|
||||
let db_service = {
|
||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||
Arc::new(database.service())
|
||||
@@ -1376,7 +1536,10 @@ pub async fn delete_download(db: State<'_, DatabaseWrapper>, download_id: i64) -
|
||||
vec![QueryParam::Int64(download_id)],
|
||||
);
|
||||
|
||||
db_service.execute(delete_query).await.map_err(|e| e.to_string())?;
|
||||
db_service
|
||||
.execute(delete_query)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Delete actual file if exists
|
||||
if let Some(path) = file_path {
|
||||
@@ -1413,8 +1576,12 @@ fn map_download_row(row: &rusqlite::Row) -> rusqlite::Result<DownloadInfo> {
|
||||
episode_number: row.get(20)?,
|
||||
season_number: row.get(21)?,
|
||||
quality_preset: row.get(22)?,
|
||||
media_type: row.get::<_, Option<String>>(23)?.unwrap_or_else(|| "audio".to_string()),
|
||||
download_source: row.get::<_, Option<String>>(24)?.unwrap_or_else(|| "user".to_string()),
|
||||
media_type: row
|
||||
.get::<_, Option<String>>(23)?
|
||||
.unwrap_or_else(|| "audio".to_string()),
|
||||
download_source: row
|
||||
.get::<_, Option<String>>(24)?
|
||||
.unwrap_or_else(|| "user".to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1500,7 +1667,10 @@ pub async fn get_download_storage_stats(
|
||||
/// Delete all downloads for a user
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn delete_all_downloads(db: State<'_, DatabaseWrapper>, user_id: String) -> Result<i64, String> {
|
||||
pub async fn delete_all_downloads(
|
||||
db: State<'_, DatabaseWrapper>,
|
||||
user_id: String,
|
||||
) -> Result<i64, String> {
|
||||
let db_service = {
|
||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||
Arc::new(database.service())
|
||||
@@ -1598,7 +1768,10 @@ pub async fn delete_album_downloads(
|
||||
"SELECT d.file_path FROM downloads d
|
||||
JOIN items i ON d.item_id = i.id
|
||||
WHERE d.user_id = ? AND i.album_id = ? AND d.status = 'completed'",
|
||||
vec![QueryParam::String(user_id.clone()), QueryParam::String(album_id.clone())],
|
||||
vec![
|
||||
QueryParam::String(user_id.clone()),
|
||||
QueryParam::String(album_id.clone()),
|
||||
],
|
||||
);
|
||||
|
||||
let file_paths: Vec<String> = db_service
|
||||
@@ -1665,7 +1838,6 @@ pub async fn set_max_concurrent_downloads(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
// TRACES: UR-011, UR-018 | DR-015, DR-018 | UT-042, UT-043
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
@@ -1834,7 +2006,10 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(status, "pending", "Status should be reset to pending after UPSERT");
|
||||
assert_eq!(
|
||||
status, "pending",
|
||||
"Status should be reset to pending after UPSERT"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2069,7 +2244,11 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
let status: String = conn
|
||||
.query_row("SELECT status FROM downloads WHERE id = ?1", params![id], |row| row.get(0))
|
||||
.query_row(
|
||||
"SELECT status FROM downloads WHERE id = ?1",
|
||||
params![id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(status, "downloading");
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
//! Pinning commands - protect an item's cached metadata from cache clearing.
|
||||
//!
|
||||
//! TRACES: UR-044 | DR-056
|
||||
|
||||
use std::sync::Arc;
|
||||
use tauri::State;
|
||||
@@ -45,7 +47,10 @@ pub async fn unpin_item(db: State<'_, DatabaseWrapper>, item_id: String) -> Resu
|
||||
/// Check if an item is pinned
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn is_item_pinned(db: State<'_, DatabaseWrapper>, item_id: String) -> Result<bool, String> {
|
||||
pub async fn is_item_pinned(
|
||||
db: State<'_, DatabaseWrapper>,
|
||||
item_id: String,
|
||||
) -> Result<bool, String> {
|
||||
let db_service = {
|
||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||
Arc::new(database.service())
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
//! Smart-cache statistics/config and album recommendation commands.
|
||||
//!
|
||||
//! TRACES: UR-045 | DR-057
|
||||
|
||||
use std::sync::Arc;
|
||||
use log::info;
|
||||
use std::sync::Arc;
|
||||
use tauri::State;
|
||||
|
||||
use crate::commands::{DatabaseWrapper, SmartCacheWrapper};
|
||||
|
||||
@@ -25,11 +25,11 @@ pub use device::*;
|
||||
pub use download::*;
|
||||
pub use offline::*;
|
||||
pub use playback_mode::*;
|
||||
#[allow(unused_imports)] // Used when playback_reporting is fully integrated
|
||||
#[allow(unused_imports)] // Used when playback_reporting is fully integrated
|
||||
pub use playback_reporting::*;
|
||||
pub use player::*;
|
||||
pub use playlist::*;
|
||||
pub use repository::{*, RepositoryManager, RepositoryManagerWrapper};
|
||||
pub use repository::{RepositoryManager, RepositoryManagerWrapper, *};
|
||||
pub use sessions::*;
|
||||
pub use storage::*;
|
||||
pub use sync::*;
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
//! Playback-mode transfer commands (local ↔ remote).
|
||||
//!
|
||||
//! TRACES: UR-010 | DR-059
|
||||
|
||||
use std::sync::Arc;
|
||||
use tauri::State;
|
||||
|
||||
@@ -105,21 +109,30 @@ pub async fn playback_mode_get_remote_status(
|
||||
let controller = player.0.lock().await;
|
||||
let client_arc = controller.jellyfin_client();
|
||||
let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
|
||||
client_opt.as_ref().ok_or("Jellyfin client not configured")?.clone()
|
||||
client_opt
|
||||
.as_ref()
|
||||
.ok_or("Jellyfin client not configured")?
|
||||
.clone()
|
||||
};
|
||||
|
||||
// Get session info
|
||||
match client.get_session(&session_id).await {
|
||||
Ok(Some(session)) => {
|
||||
let position_ticks = session.play_state.as_ref()
|
||||
let position_ticks = session
|
||||
.play_state
|
||||
.as_ref()
|
||||
.and_then(|ps| ps.position_ticks)
|
||||
.unwrap_or(0);
|
||||
|
||||
let duration_ticks = session.now_playing_item.as_ref()
|
||||
let duration_ticks = session
|
||||
.now_playing_item
|
||||
.as_ref()
|
||||
.and_then(|item| item.run_time_ticks)
|
||||
.unwrap_or(0);
|
||||
|
||||
let is_paused = session.play_state.as_ref()
|
||||
let is_paused = session
|
||||
.play_state
|
||||
.as_ref()
|
||||
.and_then(|ps| ps.is_paused)
|
||||
.unwrap_or(true);
|
||||
|
||||
@@ -224,17 +237,20 @@ mod tests {
|
||||
fn test_playback_mode_deserialization_from_frontend() {
|
||||
// Test what frontend sends for Idle mode
|
||||
let idle_json = r#"{"type":"idle"}"#;
|
||||
let mode: PlaybackMode = serde_json::from_str(idle_json).expect("Failed to deserialize idle");
|
||||
let mode: PlaybackMode =
|
||||
serde_json::from_str(idle_json).expect("Failed to deserialize idle");
|
||||
assert_eq!(mode, PlaybackMode::Idle);
|
||||
|
||||
// Test what frontend sends for Local mode
|
||||
let local_json = r#"{"type":"local"}"#;
|
||||
let mode: PlaybackMode = serde_json::from_str(local_json).expect("Failed to deserialize local");
|
||||
let mode: PlaybackMode =
|
||||
serde_json::from_str(local_json).expect("Failed to deserialize local");
|
||||
assert_eq!(mode, PlaybackMode::Local);
|
||||
|
||||
// Test what frontend sends for Remote mode
|
||||
let remote_json = r#"{"type":"remote","session_id":"session-123"}"#;
|
||||
let mode: PlaybackMode = serde_json::from_str(remote_json).expect("Failed to deserialize remote");
|
||||
let mode: PlaybackMode =
|
||||
serde_json::from_str(remote_json).expect("Failed to deserialize remote");
|
||||
match mode {
|
||||
PlaybackMode::Remote { session_id } => assert_eq!(session_id, "session-123"),
|
||||
_ => panic!("Expected Remote mode"),
|
||||
@@ -247,8 +263,8 @@ mod tests {
|
||||
|
||||
// Test Search context (the recently fixed issue)
|
||||
let search_json = r#"{"type":"search","searchQuery":"test query"}"#;
|
||||
let context: PlayTracksContext = serde_json::from_str(search_json)
|
||||
.expect("Failed to deserialize search context");
|
||||
let context: PlayTracksContext =
|
||||
serde_json::from_str(search_json).expect("Failed to deserialize search context");
|
||||
match context {
|
||||
PlayTracksContext::Search { search_query } => {
|
||||
assert_eq!(search_query, "test query");
|
||||
@@ -257,11 +273,15 @@ mod tests {
|
||||
}
|
||||
|
||||
// Test Playlist context
|
||||
let playlist_json = r#"{"type":"playlist","playlistId":"pl-123","playlistName":"My Playlist"}"#;
|
||||
let context: PlayTracksContext = serde_json::from_str(playlist_json)
|
||||
.expect("Failed to deserialize playlist context");
|
||||
let playlist_json =
|
||||
r#"{"type":"playlist","playlistId":"pl-123","playlistName":"My Playlist"}"#;
|
||||
let context: PlayTracksContext =
|
||||
serde_json::from_str(playlist_json).expect("Failed to deserialize playlist context");
|
||||
match context {
|
||||
PlayTracksContext::Playlist { playlist_id, playlist_name } => {
|
||||
PlayTracksContext::Playlist {
|
||||
playlist_id,
|
||||
playlist_name,
|
||||
} => {
|
||||
assert_eq!(playlist_id, "pl-123");
|
||||
assert_eq!(playlist_name, "My Playlist");
|
||||
}
|
||||
@@ -270,8 +290,8 @@ mod tests {
|
||||
|
||||
// Test Custom context
|
||||
let custom_json = r#"{"type":"custom","label":"Custom Queue"}"#;
|
||||
let context: PlayTracksContext = serde_json::from_str(custom_json)
|
||||
.expect("Failed to deserialize custom context");
|
||||
let context: PlayTracksContext =
|
||||
serde_json::from_str(custom_json).expect("Failed to deserialize custom context");
|
||||
match context {
|
||||
PlayTracksContext::Custom { label } => {
|
||||
assert_eq!(label, Some("Custom Queue".to_string()));
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! Tauri commands for playback reporting operations
|
||||
//!
|
||||
//! TRACES: UR-025, UR-019 | IR-015, JA-010, JA-011, JA-012 | DR-028
|
||||
//!
|
||||
//! These commands provide frontend access to the Rust playback reporting system,
|
||||
//! replacing the TypeScript implementation with native Rust reporting.
|
||||
//!
|
||||
@@ -16,7 +18,7 @@ use crate::commands::connectivity::ConnectivityMonitorWrapper;
|
||||
use crate::commands::storage::DatabaseWrapper;
|
||||
use crate::jellyfin::client::JellyfinClient;
|
||||
use crate::jellyfin::JellyfinConfig;
|
||||
use crate::playback_reporting::{PlaybackReporter, PlaybackOperation, PlaybackContext};
|
||||
use crate::playback_reporting::{PlaybackContext, PlaybackOperation, PlaybackReporter};
|
||||
use crate::utils::conversions::seconds_to_ticks;
|
||||
|
||||
/// Tauri state wrapper for PlaybackReporter
|
||||
@@ -61,7 +63,10 @@ pub async fn playback_reporter_init(
|
||||
// Store in wrapper
|
||||
*reporter_wrapper.0.lock().await = Some(reporter);
|
||||
|
||||
log::info!("[PlaybackReporter] Initialized successfully for user: {}", user_id);
|
||||
log::info!(
|
||||
"[PlaybackReporter] Initialized successfully for user: {}",
|
||||
user_id
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -205,7 +210,12 @@ mod tests {
|
||||
};
|
||||
|
||||
// Verify enum variant can be created and pattern matched
|
||||
if let PlaybackOperation::Start { item_id, position_ticks, context } = operation {
|
||||
if let PlaybackOperation::Start {
|
||||
item_id,
|
||||
position_ticks,
|
||||
context,
|
||||
} = operation
|
||||
{
|
||||
assert_eq!(item_id, "item-123");
|
||||
assert_eq!(position_ticks, 15_000_000);
|
||||
assert!(context.is_some());
|
||||
@@ -225,7 +235,10 @@ mod tests {
|
||||
context: None,
|
||||
};
|
||||
|
||||
if let PlaybackOperation::Start { item_id, context, .. } = operation {
|
||||
if let PlaybackOperation::Start {
|
||||
item_id, context, ..
|
||||
} = operation
|
||||
{
|
||||
assert_eq!(item_id, "item-789");
|
||||
assert!(context.is_none());
|
||||
} else {
|
||||
@@ -241,7 +254,12 @@ mod tests {
|
||||
is_paused: true,
|
||||
};
|
||||
|
||||
if let PlaybackOperation::Progress { item_id, position_ticks, is_paused } = operation {
|
||||
if let PlaybackOperation::Progress {
|
||||
item_id,
|
||||
position_ticks,
|
||||
is_paused,
|
||||
} = operation
|
||||
{
|
||||
assert_eq!(item_id, "item-999");
|
||||
assert_eq!(position_ticks, 30_000_000);
|
||||
assert!(is_paused);
|
||||
@@ -272,7 +290,11 @@ mod tests {
|
||||
position_ticks: 120_000_000,
|
||||
};
|
||||
|
||||
if let PlaybackOperation::Stopped { item_id, position_ticks } = operation {
|
||||
if let PlaybackOperation::Stopped {
|
||||
item_id,
|
||||
position_ticks,
|
||||
} = operation
|
||||
{
|
||||
assert_eq!(item_id, "item-111");
|
||||
assert_eq!(position_ticks, 120_000_000);
|
||||
} else {
|
||||
@@ -364,7 +386,10 @@ mod tests {
|
||||
};
|
||||
|
||||
let cloned = operation.clone();
|
||||
if let PlaybackOperation::Progress { item_id, is_paused, .. } = cloned {
|
||||
if let PlaybackOperation::Progress {
|
||||
item_id, is_paused, ..
|
||||
} = cloned
|
||||
{
|
||||
assert_eq!(item_id, "item-clone");
|
||||
assert!(is_paused);
|
||||
} else {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,6 @@
|
||||
//! Queue manipulation commands (add / remove / move / skip).
|
||||
//!
|
||||
//! TRACES: UR-015 | DR-005, DR-020
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -150,16 +152,25 @@ pub async fn player_add_track_by_id(
|
||||
) -> Result<QueueStatus, String> {
|
||||
use crate::player::queue::AddPosition;
|
||||
|
||||
info!("player_add_track_by_id called: track_id={}, position={}",
|
||||
request.track_id, request.position);
|
||||
info!(
|
||||
"player_add_track_by_id called: track_id={}, position={}",
|
||||
request.track_id, request.position
|
||||
);
|
||||
|
||||
// Get repository (hybrid - supports offline/online)
|
||||
let repository = repository_manager.0.get(&repository_handle)
|
||||
let repository = repository_manager
|
||||
.0
|
||||
.get(&repository_handle)
|
||||
.ok_or("Repository not found - user may need to log in")?;
|
||||
|
||||
// Fetch track metadata via repository
|
||||
info!("Fetching metadata for track {} via repository", request.track_id);
|
||||
let track = repository.get_item(&request.track_id).await
|
||||
info!(
|
||||
"Fetching metadata for track {} via repository",
|
||||
request.track_id
|
||||
);
|
||||
let track = repository
|
||||
.get_item(&request.track_id)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch track metadata: {}", e))?;
|
||||
|
||||
// Check for local download first
|
||||
@@ -173,7 +184,9 @@ pub async fn player_add_track_by_id(
|
||||
}
|
||||
} else {
|
||||
// Get stream URL from repository (works online/offline)
|
||||
let stream_url = repository.get_audio_stream_url(&track.id).await
|
||||
let stream_url = repository
|
||||
.get_audio_stream_url(&track.id)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to get stream URL for {}: {}", track.name, e))?;
|
||||
|
||||
MediaSource::Remote {
|
||||
@@ -188,23 +201,30 @@ pub async fn player_add_track_by_id(
|
||||
id: track.id.clone(),
|
||||
title: track.name.clone(),
|
||||
name: Some(track.name.clone()), // Frontend compatibility
|
||||
artist: track.album_artist.clone().or_else(|| track.artists.as_ref().and_then(|a| a.first().cloned())),
|
||||
artist: track
|
||||
.album_artist
|
||||
.clone()
|
||||
.or_else(|| track.artists.as_ref().and_then(|a| a.first().cloned())),
|
||||
album: track.album_name.clone(),
|
||||
album_name: track.album_name.clone(), // Frontend compatibility
|
||||
album_id: track.album_id.clone(),
|
||||
artist_items: track.artist_items.clone(), // For clickable artist links
|
||||
artists: track.artists.clone(), // Fallback artist info
|
||||
artists: track.artists.clone(), // Fallback artist info
|
||||
primary_image_tag: track.primary_image_tag.clone(), // For frontend image display
|
||||
item_type: Some(track.item_type.clone()), // Frontend compatibility
|
||||
playlist_id: None,
|
||||
duration: track.runtime_ticks.map(|t| t as f64 / 10_000_000.0),
|
||||
artwork_url: primary_image_tag_for_url.and_then(|tag| {
|
||||
track.album_id.as_ref().map(|album_id| {
|
||||
repository.get_image_url(album_id, ImageType::Primary, Some(ImageOptions {
|
||||
max_width: Some(300),
|
||||
tag: Some(tag),
|
||||
..Default::default()
|
||||
}))
|
||||
repository.get_image_url(
|
||||
album_id,
|
||||
ImageType::Primary,
|
||||
Some(ImageOptions {
|
||||
max_width: Some(300),
|
||||
tag: Some(tag),
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
})
|
||||
}),
|
||||
media_type: MediaType::Audio,
|
||||
@@ -250,18 +270,25 @@ pub async fn player_add_tracks_by_ids(
|
||||
) -> Result<QueueStatus, String> {
|
||||
use crate::player::queue::AddPosition;
|
||||
|
||||
info!("player_add_tracks_by_ids called: {} tracks, position={}",
|
||||
request.track_ids.len(), request.position);
|
||||
info!(
|
||||
"player_add_tracks_by_ids called: {} tracks, position={}",
|
||||
request.track_ids.len(),
|
||||
request.position
|
||||
);
|
||||
|
||||
// Get repository (hybrid - supports offline/online)
|
||||
let repository = repository_manager.0.get(&repository_handle)
|
||||
let repository = repository_manager
|
||||
.0
|
||||
.get(&repository_handle)
|
||||
.ok_or("Repository not found - user may need to log in")?;
|
||||
|
||||
// Fetch metadata and build MediaItems for all tracks
|
||||
let mut media_items = Vec::new();
|
||||
for track_id in &request.track_ids {
|
||||
info!("Fetching metadata for track {} via repository", track_id);
|
||||
let track = repository.get_item(track_id).await
|
||||
let track = repository
|
||||
.get_item(track_id)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch track metadata for {}: {}", track_id, e))?;
|
||||
|
||||
// Check for local download first
|
||||
@@ -275,7 +302,9 @@ pub async fn player_add_tracks_by_ids(
|
||||
}
|
||||
} else {
|
||||
// Get stream URL from repository (works online/offline)
|
||||
let stream_url = repository.get_audio_stream_url(&track.id).await
|
||||
let stream_url = repository
|
||||
.get_audio_stream_url(&track.id)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to get stream URL for {}: {}", track.name, e))?;
|
||||
|
||||
MediaSource::Remote {
|
||||
@@ -290,23 +319,30 @@ pub async fn player_add_tracks_by_ids(
|
||||
id: track.id.clone(),
|
||||
title: track.name.clone(),
|
||||
name: Some(track.name.clone()), // Frontend compatibility
|
||||
artist: track.album_artist.clone().or_else(|| track.artists.as_ref().and_then(|a| a.first().cloned())),
|
||||
artist: track
|
||||
.album_artist
|
||||
.clone()
|
||||
.or_else(|| track.artists.as_ref().and_then(|a| a.first().cloned())),
|
||||
album: track.album_name.clone(),
|
||||
album_name: track.album_name.clone(), // Frontend compatibility
|
||||
album_id: track.album_id.clone(),
|
||||
artist_items: track.artist_items.clone(), // For clickable artist links
|
||||
artists: track.artists.clone(), // Fallback artist info
|
||||
artists: track.artists.clone(), // Fallback artist info
|
||||
primary_image_tag: track.primary_image_tag.clone(), // For frontend image display
|
||||
item_type: Some(track.item_type.clone()), // Frontend compatibility
|
||||
playlist_id: None,
|
||||
duration: track.runtime_ticks.map(|t| t as f64 / 10_000_000.0),
|
||||
artwork_url: primary_image_tag_for_url.and_then(|tag| {
|
||||
track.album_id.as_ref().map(|album_id| {
|
||||
repository.get_image_url(album_id, ImageType::Primary, Some(ImageOptions {
|
||||
max_width: Some(300),
|
||||
tag: Some(tag),
|
||||
..Default::default()
|
||||
}))
|
||||
repository.get_image_url(
|
||||
album_id,
|
||||
ImageType::Primary,
|
||||
Some(ImageOptions {
|
||||
max_width: Some(300),
|
||||
tag: Some(tag),
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
})
|
||||
}),
|
||||
media_type: MediaType::Audio,
|
||||
@@ -339,7 +375,10 @@ pub async fn player_add_tracks_by_ids(
|
||||
drop(queue_lock);
|
||||
controller.emit_queue_changed();
|
||||
|
||||
info!("Successfully added {} tracks to queue", request.track_ids.len());
|
||||
info!(
|
||||
"Successfully added {} tracks to queue",
|
||||
request.track_ids.len()
|
||||
);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! Remote Jellyfin session control commands (casting to another device).
|
||||
//!
|
||||
//! TRACES: UR-010, UR-046 | IR-012, IR-028, JA-022, JA-023, JA-025, JA-026 | DR-037, DR-058
|
||||
//!
|
||||
//! These thin command adapters forward control actions to the active Jellyfin
|
||||
//! session via the player's configured `JellyfinClient`.
|
||||
|
||||
@@ -17,22 +19,36 @@ pub async fn remote_play_on_session(
|
||||
item_ids: Vec<String>,
|
||||
start_index: usize,
|
||||
) -> Result<(), String> {
|
||||
log::info!("[RemoteSession] Playing {} items on session {} (start index: {})", item_ids.len(), session_id, start_index);
|
||||
log::info!(
|
||||
"[RemoteSession] Playing {} items on session {} (start index: {})",
|
||||
item_ids.len(),
|
||||
session_id,
|
||||
start_index
|
||||
);
|
||||
log::info!("[RemoteSession] Item IDs: {:?}", item_ids);
|
||||
|
||||
let client_opt = {
|
||||
let controller = player.0.lock().await;
|
||||
controller.jellyfin_client().lock().map_err(|e| e.to_string())?.clone()
|
||||
controller
|
||||
.jellyfin_client()
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?
|
||||
.clone()
|
||||
};
|
||||
|
||||
if let Some(client) = client_opt {
|
||||
log::info!("[RemoteSession] Jellyfin client IS configured, calling play_on_session");
|
||||
client.play_on_session(session_id, item_ids, start_index, None).await?;
|
||||
client
|
||||
.play_on_session(session_id, item_ids, start_index, None)
|
||||
.await?;
|
||||
log::info!("[RemoteSession] Successfully started playback on remote session");
|
||||
Ok(())
|
||||
} else {
|
||||
log::error!("[RemoteSession] Jellyfin client is NOT configured! User needs to log out/in or restart app");
|
||||
Err("Jellyfin client not configured - please restart the app or log out and log back in".to_string())
|
||||
Err(
|
||||
"Jellyfin client not configured - please restart the app or log out and log back in"
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,11 +60,19 @@ pub async fn remote_send_command(
|
||||
session_id: String,
|
||||
command: String,
|
||||
) -> Result<(), String> {
|
||||
log::info!("[RemoteSession] Sending command '{}' to session {}", command, session_id);
|
||||
log::info!(
|
||||
"[RemoteSession] Sending command '{}' to session {}",
|
||||
command,
|
||||
session_id
|
||||
);
|
||||
|
||||
let client_opt = {
|
||||
let controller = player.0.lock().await;
|
||||
controller.jellyfin_client().lock().map_err(|e| e.to_string())?.clone()
|
||||
controller
|
||||
.jellyfin_client()
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?
|
||||
.clone()
|
||||
};
|
||||
|
||||
if let Some(client) = client_opt {
|
||||
@@ -68,11 +92,19 @@ pub async fn remote_session_seek(
|
||||
session_id: String,
|
||||
position_ticks: i64,
|
||||
) -> Result<(), String> {
|
||||
log::info!("[RemoteSession] Seeking to {} ticks on session {}", position_ticks, session_id);
|
||||
log::info!(
|
||||
"[RemoteSession] Seeking to {} ticks on session {}",
|
||||
position_ticks,
|
||||
session_id
|
||||
);
|
||||
|
||||
let client_opt = {
|
||||
let controller = player.0.lock().await;
|
||||
controller.jellyfin_client().lock().map_err(|e| e.to_string())?.clone()
|
||||
controller
|
||||
.jellyfin_client()
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?
|
||||
.clone()
|
||||
};
|
||||
|
||||
if let Some(client) = client_opt {
|
||||
@@ -92,11 +124,19 @@ pub async fn remote_session_set_volume(
|
||||
session_id: String,
|
||||
volume: i32,
|
||||
) -> Result<(), String> {
|
||||
log::info!("[RemoteSession] Setting volume to {} on session {}", volume, session_id);
|
||||
log::info!(
|
||||
"[RemoteSession] Setting volume to {} on session {}",
|
||||
volume,
|
||||
session_id
|
||||
);
|
||||
|
||||
let client_opt = {
|
||||
let controller = player.0.lock().await;
|
||||
controller.jellyfin_client().lock().map_err(|e| e.to_string())?.clone()
|
||||
controller
|
||||
.jellyfin_client()
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?
|
||||
.clone()
|
||||
};
|
||||
|
||||
if let Some(client) = client_opt {
|
||||
@@ -119,7 +159,11 @@ pub async fn remote_session_toggle_mute(
|
||||
|
||||
let client_opt = {
|
||||
let controller = player.0.lock().await;
|
||||
controller.jellyfin_client().lock().map_err(|e| e.to_string())?.clone()
|
||||
controller
|
||||
.jellyfin_client()
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?
|
||||
.clone()
|
||||
};
|
||||
|
||||
if let Some(client) = client_opt {
|
||||
@@ -145,7 +189,11 @@ pub async fn lms_get_sync_groups(
|
||||
) -> Result<Vec<LmsSyncGroup>, String> {
|
||||
let client_opt = {
|
||||
let controller = player.0.lock().await;
|
||||
controller.jellyfin_client().lock().map_err(|e| e.to_string())?.clone()
|
||||
controller
|
||||
.jellyfin_client()
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?
|
||||
.clone()
|
||||
};
|
||||
|
||||
if let Some(client) = client_opt {
|
||||
@@ -164,11 +212,19 @@ pub async fn lms_create_sync_group(
|
||||
master_mac: String,
|
||||
slave_macs: Vec<String>,
|
||||
) -> Result<(), String> {
|
||||
log::info!("[LmsSync] Fusing zones: master={}, slaves={:?}", master_mac, slave_macs);
|
||||
log::info!(
|
||||
"[LmsSync] Fusing zones: master={}, slaves={:?}",
|
||||
master_mac,
|
||||
slave_macs
|
||||
);
|
||||
|
||||
let client_opt = {
|
||||
let controller = player.0.lock().await;
|
||||
controller.jellyfin_client().lock().map_err(|e| e.to_string())?.clone()
|
||||
controller
|
||||
.jellyfin_client()
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?
|
||||
.clone()
|
||||
};
|
||||
|
||||
if let Some(client) = client_opt {
|
||||
@@ -189,7 +245,11 @@ pub async fn lms_unsync_player(
|
||||
|
||||
let client_opt = {
|
||||
let controller = player.0.lock().await;
|
||||
controller.jellyfin_client().lock().map_err(|e| e.to_string())?.clone()
|
||||
controller
|
||||
.jellyfin_client()
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?
|
||||
.clone()
|
||||
};
|
||||
|
||||
if let Some(client) = client_opt {
|
||||
@@ -210,7 +270,11 @@ pub async fn lms_dissolve_sync_group(
|
||||
|
||||
let client_opt = {
|
||||
let controller = player.0.lock().await;
|
||||
controller.jellyfin_client().lock().map_err(|e| e.to_string())?.clone()
|
||||
controller
|
||||
.jellyfin_client()
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?
|
||||
.clone()
|
||||
};
|
||||
|
||||
if let Some(client) = client_opt {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! Media session state commands.
|
||||
//!
|
||||
//! TRACES: UR-005 | DR-009
|
||||
//!
|
||||
//! Read and dismiss the current media session (the Now Playing surface backing
|
||||
//! lockscreen/notification controls).
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
//! Audio and video playback settings commands.
|
||||
//!
|
||||
//! TRACES: UR-022, UR-031, UR-032, UR-033 | DR-025, DR-034, DR-035, DR-036
|
||||
|
||||
use tauri::State;
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! Sleep-timer and autoplay commands.
|
||||
//!
|
||||
//! TRACES: UR-026, UR-023 | DR-029, DR-047, DR-049
|
||||
//!
|
||||
//! Thin command adapters over `PlayerController`'s sleep-timer and autoplay
|
||||
//! logic, plus persistence of autoplay settings to the database.
|
||||
|
||||
@@ -7,8 +9,8 @@ use std::sync::Arc;
|
||||
use tauri::State;
|
||||
|
||||
use super::{
|
||||
create_media_item, get_player_status, DatabaseWrapper, PlayItemRequest, PlayerStatus,
|
||||
PlayerStateWrapper,
|
||||
create_media_item, get_player_status, DatabaseWrapper, PlayItemRequest, PlayerStateWrapper,
|
||||
PlayerStatus,
|
||||
};
|
||||
use crate::player::{AutoplaySettings, SleepTimerMode, SleepTimerState};
|
||||
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
|
||||
@@ -127,7 +129,9 @@ pub async fn player_play_next_episode(
|
||||
let media_item = create_media_item(item, Some(&db)).await?;
|
||||
|
||||
let controller = player.0.lock().await;
|
||||
controller.play_item(media_item).map_err(|e| e.to_string())?;
|
||||
controller
|
||||
.play_item(media_item)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(get_player_status(&controller))
|
||||
}
|
||||
@@ -164,7 +168,10 @@ pub async fn player_on_playback_ended(
|
||||
if let Some(repo) = repo {
|
||||
controller.on_video_playback_ended(id, repo).await?
|
||||
} else {
|
||||
log::warn!("[Autoplay] No repository available for video autoplay (itemId: {})", id);
|
||||
log::warn!(
|
||||
"[Autoplay] No repository available for video autoplay (itemId: {})",
|
||||
id
|
||||
);
|
||||
AutoplayDecision::Stop
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
use log::debug;
|
||||
use tauri::State;
|
||||
|
||||
use crate::repository::{MediaRepository, types::*};
|
||||
use super::repository::RepositoryManagerWrapper;
|
||||
use crate::repository::{types::*, MediaRepository};
|
||||
|
||||
/// Create a new playlist
|
||||
#[tauri::command]
|
||||
@@ -21,7 +21,8 @@ pub async fn playlist_create(
|
||||
debug!("[PLAYLIST] create called: name={}", name);
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
let ids = item_ids.unwrap_or_default();
|
||||
repo.as_ref().create_playlist(&name, &ids)
|
||||
repo.as_ref()
|
||||
.create_playlist(&name, &ids)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
@@ -36,7 +37,8 @@ pub async fn playlist_delete(
|
||||
) -> Result<(), String> {
|
||||
debug!("[PLAYLIST] delete called: id={}", playlist_id);
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
repo.as_ref().delete_playlist(&playlist_id)
|
||||
repo.as_ref()
|
||||
.delete_playlist(&playlist_id)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
@@ -50,9 +52,13 @@ pub async fn playlist_rename(
|
||||
playlist_id: String,
|
||||
name: String,
|
||||
) -> Result<(), String> {
|
||||
debug!("[PLAYLIST] rename called: id={}, name={}", playlist_id, name);
|
||||
debug!(
|
||||
"[PLAYLIST] rename called: id={}, name={}",
|
||||
playlist_id, name
|
||||
);
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
repo.as_ref().rename_playlist(&playlist_id, &name)
|
||||
repo.as_ref()
|
||||
.rename_playlist(&playlist_id, &name)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
@@ -67,7 +73,8 @@ pub async fn playlist_get_items(
|
||||
) -> Result<Vec<PlaylistEntry>, String> {
|
||||
debug!("[PLAYLIST] get_items called: id={}", playlist_id);
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
repo.as_ref().get_playlist_items(&playlist_id)
|
||||
repo.as_ref()
|
||||
.get_playlist_items(&playlist_id)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
@@ -81,9 +88,14 @@ pub async fn playlist_add_items(
|
||||
playlist_id: String,
|
||||
item_ids: Vec<String>,
|
||||
) -> Result<(), String> {
|
||||
debug!("[PLAYLIST] add_items called: id={}, count={}", playlist_id, item_ids.len());
|
||||
debug!(
|
||||
"[PLAYLIST] add_items called: id={}, count={}",
|
||||
playlist_id,
|
||||
item_ids.len()
|
||||
);
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
repo.as_ref().add_to_playlist(&playlist_id, &item_ids)
|
||||
repo.as_ref()
|
||||
.add_to_playlist(&playlist_id, &item_ids)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
@@ -97,9 +109,14 @@ pub async fn playlist_remove_items(
|
||||
playlist_id: String,
|
||||
entry_ids: Vec<String>,
|
||||
) -> Result<(), String> {
|
||||
debug!("[PLAYLIST] remove_items called: id={}, count={}", playlist_id, entry_ids.len());
|
||||
debug!(
|
||||
"[PLAYLIST] remove_items called: id={}, count={}",
|
||||
playlist_id,
|
||||
entry_ids.len()
|
||||
);
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
repo.as_ref().remove_from_playlist(&playlist_id, &entry_ids)
|
||||
repo.as_ref()
|
||||
.remove_from_playlist(&playlist_id, &entry_ids)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
@@ -114,9 +131,13 @@ pub async fn playlist_move_item(
|
||||
item_id: String,
|
||||
new_index: u32,
|
||||
) -> Result<(), String> {
|
||||
debug!("[PLAYLIST] move_item called: playlist={}, item={}, index={}", playlist_id, item_id, new_index);
|
||||
debug!(
|
||||
"[PLAYLIST] move_item called: playlist={}, item={}, index={}",
|
||||
playlist_id, item_id, new_index
|
||||
);
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
repo.as_ref().move_playlist_item(&playlist_id, &item_id, new_index)
|
||||
repo.as_ref()
|
||||
.move_playlist_item(&playlist_id, &item_id, new_index)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
|
||||
@@ -13,7 +13,9 @@ use tauri::{AppHandle, Emitter, State};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::jellyfin::HttpClient;
|
||||
use crate::repository::{HybridRepository, MediaRepository, OnlineRepository, OfflineRepository, types::*};
|
||||
use crate::repository::{
|
||||
types::*, HybridRepository, MediaRepository, OfflineRepository, OnlineRepository,
|
||||
};
|
||||
|
||||
/// Repository handle manager
|
||||
pub struct RepositoryManager {
|
||||
@@ -81,8 +83,13 @@ pub async fn repository_create(
|
||||
|
||||
// Create online repository wired to connectivity reporting
|
||||
debug!("[REPO] Creating online repository...");
|
||||
let online = OnlineRepository::new(Arc::new(http_client), server_url, user_id.clone(), access_token)
|
||||
.with_connectivity(connectivity_reporter);
|
||||
let online = OnlineRepository::new(
|
||||
Arc::new(http_client),
|
||||
server_url,
|
||||
user_id.clone(),
|
||||
access_token,
|
||||
)
|
||||
.with_connectivity(connectivity_reporter);
|
||||
debug!("[REPO] Online repository created");
|
||||
|
||||
// Create offline repository with async-safe database service
|
||||
@@ -151,12 +158,10 @@ pub async fn repository_get_libraries(
|
||||
"Repository not found".to_string()
|
||||
})?;
|
||||
debug!("[REPO] Repository found, fetching libraries...");
|
||||
repo.as_ref().get_libraries()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("[REPO] Error fetching libraries: {:?}", e);
|
||||
format!("{:?}", e)
|
||||
})
|
||||
repo.as_ref().get_libraries().await.map_err(|e| {
|
||||
error!("[REPO] Error fetching libraries: {:?}", e);
|
||||
format!("{:?}", e)
|
||||
})
|
||||
}
|
||||
|
||||
/// Get items in a container (library, folder, album, etc.)
|
||||
@@ -169,7 +174,8 @@ pub async fn repository_get_items(
|
||||
options: Option<GetItemsOptions>,
|
||||
) -> Result<SearchResult, String> {
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
repo.as_ref().get_items(&parent_id, options)
|
||||
repo.as_ref()
|
||||
.get_items(&parent_id, options)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
@@ -183,7 +189,8 @@ pub async fn repository_get_item(
|
||||
item_id: String,
|
||||
) -> Result<MediaItem, String> {
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
repo.as_ref().get_item(&item_id)
|
||||
repo.as_ref()
|
||||
.get_item(&item_id)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
@@ -200,7 +207,8 @@ pub async fn repository_jray_actors_at(
|
||||
t: f64,
|
||||
) -> Result<Vec<crate::repository::JRayActor>, String> {
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
repo.as_ref().get_jray_actors(&item_id, t)
|
||||
repo.as_ref()
|
||||
.get_jray_actors(&item_id, t)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
@@ -215,7 +223,8 @@ pub async fn repository_get_latest_items(
|
||||
limit: Option<usize>,
|
||||
) -> Result<Vec<MediaItem>, String> {
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
repo.as_ref().get_latest_items(&parent_id, limit)
|
||||
repo.as_ref()
|
||||
.get_latest_items(&parent_id, limit)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
@@ -235,7 +244,8 @@ pub async fn repository_get_resume_items(
|
||||
"Repository not found".to_string()
|
||||
})?;
|
||||
debug!("[REPO] Repository found, fetching resume items...");
|
||||
repo.as_ref().get_resume_items(parent_id.as_deref(), limit)
|
||||
repo.as_ref()
|
||||
.get_resume_items(parent_id.as_deref(), limit)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("[REPO] Error fetching resume items: {:?}", e);
|
||||
@@ -253,7 +263,8 @@ pub async fn repository_get_next_up_episodes(
|
||||
limit: Option<usize>,
|
||||
) -> Result<Vec<MediaItem>, String> {
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
repo.as_ref().get_next_up_episodes(series_id.as_deref(), limit)
|
||||
repo.as_ref()
|
||||
.get_next_up_episodes(series_id.as_deref(), limit)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
@@ -267,7 +278,8 @@ pub async fn repository_get_recently_played_audio(
|
||||
limit: Option<usize>,
|
||||
) -> Result<Vec<MediaItem>, String> {
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
repo.as_ref().get_recently_played_audio(limit)
|
||||
repo.as_ref()
|
||||
.get_recently_played_audio(limit)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
@@ -281,7 +293,8 @@ pub async fn repository_get_resume_movies(
|
||||
limit: Option<usize>,
|
||||
) -> Result<Vec<MediaItem>, String> {
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
repo.as_ref().get_resume_movies(limit)
|
||||
repo.as_ref()
|
||||
.get_resume_movies(limit)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
@@ -296,7 +309,8 @@ pub async fn repository_get_rediscover_albums(
|
||||
limit: Option<usize>,
|
||||
) -> Result<Vec<MediaItem>, String> {
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
repo.as_ref().get_rediscover_albums(parent_id.as_deref(), limit)
|
||||
repo.as_ref()
|
||||
.get_rediscover_albums(parent_id.as_deref(), limit)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
@@ -310,7 +324,8 @@ pub async fn repository_get_genres(
|
||||
parent_id: Option<String>,
|
||||
) -> Result<Vec<Genre>, String> {
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
repo.as_ref().get_genres(parent_id.as_deref())
|
||||
repo.as_ref()
|
||||
.get_genres(parent_id.as_deref())
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
@@ -363,8 +378,7 @@ pub async fn repository_search(
|
||||
tauri::async_runtime::spawn(async move {
|
||||
match repo_bg.search_server_only(&query, options).await {
|
||||
Ok(server_result) => {
|
||||
let merged =
|
||||
HybridRepository::merge_search_results(cache_for_merge, server_result);
|
||||
let merged = HybridRepository::merge_search_results(cache_for_merge, server_result);
|
||||
let event = SearchUpdateEvent {
|
||||
request_id,
|
||||
result: merged,
|
||||
@@ -376,7 +390,10 @@ pub async fn repository_search(
|
||||
Err(e) => {
|
||||
// Server failed — the cache results are already on screen, so
|
||||
// just log. (Offline / unreachable server falls here.)
|
||||
warn!("[Search] Server search failed, keeping cache results: {:?}", e);
|
||||
warn!(
|
||||
"[Search] Server search failed, keeping cache results: {:?}",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -393,7 +410,8 @@ pub async fn repository_get_playback_info(
|
||||
item_id: String,
|
||||
) -> Result<PlaybackInfo, String> {
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
repo.as_ref().get_playback_info(&item_id)
|
||||
repo.as_ref()
|
||||
.get_playback_info(&item_id)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
@@ -421,6 +439,31 @@ pub async fn repository_get_video_stream_url(
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
|
||||
/// Get an audio-only stream URL for a *video* item (background-audio handoff).
|
||||
///
|
||||
/// TRACES: UR-040 | JA-032 | UT-061
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn repository_get_audio_only_stream_url_for_video(
|
||||
manager: State<'_, RepositoryManagerWrapper>,
|
||||
handle: String,
|
||||
item_id: String,
|
||||
media_source_id: Option<String>,
|
||||
start_time_seconds: Option<f64>,
|
||||
audio_stream_index: Option<i32>,
|
||||
) -> Result<String, String> {
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
repo.as_ref()
|
||||
.get_audio_only_stream_url_for_video(
|
||||
&item_id,
|
||||
media_source_id.as_deref(),
|
||||
start_time_seconds,
|
||||
audio_stream_index,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
|
||||
/// Get audio stream URL for a track
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
@@ -489,7 +532,8 @@ pub async fn repository_report_playback_start(
|
||||
position_ticks: i64,
|
||||
) -> Result<(), String> {
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
repo.as_ref().report_playback_start(&item_id, position_ticks)
|
||||
repo.as_ref()
|
||||
.report_playback_start(&item_id, position_ticks)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
@@ -504,7 +548,8 @@ pub async fn repository_report_playback_progress(
|
||||
position_ticks: i64,
|
||||
) -> Result<(), String> {
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
repo.as_ref().report_playback_progress(&item_id, position_ticks)
|
||||
repo.as_ref()
|
||||
.report_playback_progress(&item_id, position_ticks)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
@@ -519,7 +564,8 @@ pub async fn repository_report_playback_stopped(
|
||||
position_ticks: i64,
|
||||
) -> Result<(), String> {
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
repo.as_ref().report_playback_stopped(&item_id, position_ticks)
|
||||
repo.as_ref()
|
||||
.report_playback_stopped(&item_id, position_ticks)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
@@ -551,7 +597,9 @@ pub fn repository_get_subtitle_url(
|
||||
format: String,
|
||||
) -> Result<String, String> {
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
Ok(repo.as_ref().get_subtitle_url(&item_id, &media_source_id, stream_index, &format))
|
||||
Ok(repo
|
||||
.as_ref()
|
||||
.get_subtitle_url(&item_id, &media_source_id, stream_index, &format))
|
||||
}
|
||||
|
||||
/// Get video download URL with quality preset
|
||||
@@ -566,7 +614,9 @@ pub fn repository_get_video_download_url(
|
||||
media_source_id: Option<String>,
|
||||
) -> Result<String, String> {
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
Ok(repo.as_ref().get_video_download_url(&item_id, &quality, media_source_id.as_deref()))
|
||||
Ok(repo
|
||||
.as_ref()
|
||||
.get_video_download_url(&item_id, &quality, media_source_id.as_deref()))
|
||||
}
|
||||
|
||||
/// Mark an item as favorite
|
||||
@@ -578,7 +628,8 @@ pub async fn repository_mark_favorite(
|
||||
item_id: String,
|
||||
) -> Result<(), String> {
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
repo.as_ref().mark_favorite(&item_id)
|
||||
repo.as_ref()
|
||||
.mark_favorite(&item_id)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
@@ -592,7 +643,8 @@ pub async fn repository_unmark_favorite(
|
||||
item_id: String,
|
||||
) -> Result<(), String> {
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
repo.as_ref().unmark_favorite(&item_id)
|
||||
repo.as_ref()
|
||||
.unmark_favorite(&item_id)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
@@ -606,7 +658,8 @@ pub async fn repository_get_person(
|
||||
person_id: String,
|
||||
) -> Result<MediaItem, String> {
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
repo.as_ref().get_person(&person_id)
|
||||
repo.as_ref()
|
||||
.get_person(&person_id)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
@@ -621,7 +674,8 @@ pub async fn repository_get_items_by_person(
|
||||
options: Option<GetItemsOptions>,
|
||||
) -> Result<SearchResult, String> {
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
repo.as_ref().get_items_by_person(&person_id, options)
|
||||
repo.as_ref()
|
||||
.get_items_by_person(&person_id, options)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
@@ -636,7 +690,8 @@ pub async fn repository_get_similar_items(
|
||||
limit: Option<usize>,
|
||||
) -> Result<SearchResult, String> {
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
repo.as_ref().get_similar_items(&item_id, limit)
|
||||
repo.as_ref()
|
||||
.get_similar_items(&item_id, limit)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
//! TRACES: UR-010 | JA-021 | DR-037
|
||||
|
||||
use crate::jellyfin::client::SessionInfo;
|
||||
use crate::session_poller::{PollingHint, SessionPollerManager};
|
||||
use std::sync::Arc;
|
||||
use tauri::State;
|
||||
use crate::session_poller::{PollingHint, SessionPollerManager};
|
||||
use crate::jellyfin::client::SessionInfo;
|
||||
|
||||
/// Tauri state wrapper for SessionPollerManager
|
||||
pub struct SessionPollerWrapper(pub Arc<SessionPollerManager>);
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
//! Tauri commands for database/storage operations
|
||||
//!
|
||||
//! TRACES: UR-002, UR-011, UR-012, UR-017, UR-019, UR-025, UR-047 | IR-013 | DR-012, DR-013, DR-022, DR-060
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
@@ -7,8 +9,8 @@ use serde::{Deserialize, Serialize};
|
||||
use tauri::State;
|
||||
|
||||
use crate::credentials::CredentialStore;
|
||||
use crate::storage::Database;
|
||||
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
|
||||
use crate::storage::Database;
|
||||
use crate::thumbnail::ThumbnailCache;
|
||||
|
||||
use super::SmartCacheWrapper;
|
||||
@@ -86,7 +88,8 @@ pub fn storage_get_path(db: State<DatabaseWrapper>) -> Result<String, String> {
|
||||
let db_path = database.path();
|
||||
|
||||
// Return the parent directory instead of the database file path
|
||||
let storage_dir = db_path.parent()
|
||||
let storage_dir = db_path
|
||||
.parent()
|
||||
.ok_or_else(|| "Database path has no parent directory".to_string())?;
|
||||
|
||||
Ok(storage_dir.to_string_lossy().to_string())
|
||||
@@ -160,13 +163,16 @@ pub async fn storage_save_server(
|
||||
/// Get all saved servers
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn storage_get_servers(db: State<'_, DatabaseWrapper>) -> Result<Vec<ServerInfo>, String> {
|
||||
pub async fn storage_get_servers(
|
||||
db: State<'_, DatabaseWrapper>,
|
||||
) -> Result<Vec<ServerInfo>, String> {
|
||||
let db_service = {
|
||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||
Arc::new(database.service())
|
||||
};
|
||||
|
||||
let query = Query::new("SELECT id, name, url, version FROM servers ORDER BY last_connected_at DESC");
|
||||
let query =
|
||||
Query::new("SELECT id, name, url, version FROM servers ORDER BY last_connected_at DESC");
|
||||
|
||||
let servers = db_service
|
||||
.query_many(query, |row| {
|
||||
@@ -221,7 +227,10 @@ pub async fn storage_delete_server(
|
||||
vec![QueryParam::String(server_id)],
|
||||
);
|
||||
|
||||
db_service.execute(delete_query).await.map_err(|e| e.to_string())?;
|
||||
db_service
|
||||
.execute(delete_query)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -237,7 +246,10 @@ pub async fn storage_save_user(
|
||||
username: String,
|
||||
access_token: Option<String>,
|
||||
) -> Result<bool, String> {
|
||||
info!("storage_save_user called: id={}, server_id={}, username={}", id, server_id, username);
|
||||
info!(
|
||||
"storage_save_user called: id={}, server_id={}, username={}",
|
||||
id, server_id, username
|
||||
);
|
||||
|
||||
let (db_service, db_path) = {
|
||||
let database = db.0.lock().map_err(|e| {
|
||||
@@ -277,7 +289,10 @@ pub async fn storage_save_user(
|
||||
"SELECT COUNT(*) FROM users WHERE id = ?",
|
||||
vec![QueryParam::String(id.clone())],
|
||||
);
|
||||
let verify_count: i32 = db_service.query_one(verify_query, |row| row.get(0)).await.unwrap_or(-1);
|
||||
let verify_count: i32 = db_service
|
||||
.query_one(verify_query, |row| row.get(0))
|
||||
.await
|
||||
.unwrap_or(-1);
|
||||
debug!("VERIFY: {} users with id={} after insert", verify_count, id);
|
||||
debug!("Database path: {:?}", db_path);
|
||||
|
||||
@@ -355,14 +370,20 @@ pub async fn storage_set_active_user(
|
||||
|
||||
// Deactivate ALL users globally (since we only connect to one server at a time)
|
||||
let deactivate_query = Query::new("UPDATE users SET is_active = 0");
|
||||
db_service.execute(deactivate_query).await.map_err(|e| e.to_string())?;
|
||||
db_service
|
||||
.execute(deactivate_query)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Activate the specified user and update last_login_at
|
||||
let activate_query = Query::with_params(
|
||||
"UPDATE users SET is_active = 1, last_login_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
vec![QueryParam::String(user_id.clone())],
|
||||
);
|
||||
let rows_affected = db_service.execute(activate_query).await.map_err(|e| e.to_string())?;
|
||||
let rows_affected = db_service
|
||||
.execute(activate_query)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
debug!("storage_set_active_user: {} rows affected", rows_affected);
|
||||
|
||||
@@ -372,7 +393,10 @@ pub async fn storage_set_active_user(
|
||||
|
||||
// Verify the user is now active
|
||||
let verify_query = Query::new("SELECT COUNT(*) FROM users WHERE is_active = 1");
|
||||
let verify_count: i32 = db_service.query_one(verify_query, |row| row.get(0)).await.unwrap_or(-1);
|
||||
let verify_count: i32 = db_service
|
||||
.query_one(verify_query, |row| row.get(0))
|
||||
.await
|
||||
.unwrap_or(-1);
|
||||
debug!("VERIFY: {} active users after set_active", verify_count);
|
||||
debug!("Database path: {:?}", db_path);
|
||||
|
||||
@@ -434,12 +458,21 @@ pub async fn storage_get_active_session(
|
||||
|
||||
// Debug: count total users and active users
|
||||
let total_query = Query::new("SELECT COUNT(*) FROM users");
|
||||
let total_users: i32 = db_service.query_one(total_query, |row| row.get(0)).await.unwrap_or(-1);
|
||||
let total_users: i32 = db_service
|
||||
.query_one(total_query, |row| row.get(0))
|
||||
.await
|
||||
.unwrap_or(-1);
|
||||
|
||||
let active_query = Query::new("SELECT COUNT(*) FROM users WHERE is_active = 1");
|
||||
let active_users: i32 = db_service.query_one(active_query, |row| row.get(0)).await.unwrap_or(-1);
|
||||
let active_users: i32 = db_service
|
||||
.query_one(active_query, |row| row.get(0))
|
||||
.await
|
||||
.unwrap_or(-1);
|
||||
|
||||
debug!("Database state: {} total users, {} active users", total_users, active_users);
|
||||
debug!(
|
||||
"Database state: {} total users, {} active users",
|
||||
total_users, active_users
|
||||
);
|
||||
debug!("Database path: {:?}", db_path);
|
||||
|
||||
// Find active user with their server info, ordered by most recently logged in
|
||||
@@ -449,18 +482,21 @@ pub async fn storage_get_active_session(
|
||||
JOIN servers s ON u.server_id = s.id
|
||||
WHERE u.is_active = 1
|
||||
ORDER BY u.last_login_at DESC
|
||||
LIMIT 1"
|
||||
LIMIT 1",
|
||||
);
|
||||
|
||||
let result = db_service.query_optional(session_query, |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, String>(3)?,
|
||||
row.get::<_, String>(4)?,
|
||||
))
|
||||
}).await.map_err(|e| e.to_string())?;
|
||||
let result = db_service
|
||||
.query_optional(session_query, |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, String>(3)?,
|
||||
row.get::<_, String>(4)?,
|
||||
))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
match result {
|
||||
Some((user_id, username, server_id, server_url, server_name)) => {
|
||||
@@ -478,7 +514,7 @@ pub async fn storage_get_active_session(
|
||||
server_name,
|
||||
access_token,
|
||||
}))
|
||||
},
|
||||
}
|
||||
Err(e) => {
|
||||
// Token not found or error - session is invalid
|
||||
warn!("Failed to get token from secure storage: {:?}", e);
|
||||
@@ -638,8 +674,12 @@ pub async fn storage_update_playback_context(
|
||||
QueryParam::String(user_id.clone()),
|
||||
QueryParam::String(item_id.clone()),
|
||||
QueryParam::Int64(position_ticks),
|
||||
context_type.map(QueryParam::String).unwrap_or(QueryParam::Null),
|
||||
context_id.map(QueryParam::String).unwrap_or(QueryParam::Null),
|
||||
context_type
|
||||
.map(QueryParam::String)
|
||||
.unwrap_or(QueryParam::Null),
|
||||
context_id
|
||||
.map(QueryParam::String)
|
||||
.unwrap_or(QueryParam::Null),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -721,14 +761,23 @@ pub async fn storage_mark_played(
|
||||
});
|
||||
|
||||
if !tracks.is_empty() {
|
||||
info!("Auto-queueing {} tracks from album for download", tracks.len());
|
||||
info!(
|
||||
"Auto-queueing {} tracks from album for download",
|
||||
tracks.len()
|
||||
);
|
||||
|
||||
// Queue each track with high priority (50) and mark as auto-downloaded
|
||||
for (track_id, track_name, artist_name, album_name) in tracks {
|
||||
// Generate a sanitized file path (simplified version)
|
||||
let sanitized_name = track_name
|
||||
.chars()
|
||||
.map(|c| if c.is_alphanumeric() || c == ' ' || c == '-' || c == '_' { c } else { '_' })
|
||||
.map(|c| {
|
||||
if c.is_alphanumeric() || c == ' ' || c == '-' || c == '_' {
|
||||
c
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect::<String>();
|
||||
let file_path = format!("downloads/{}/{}.mp3", album_id, sanitized_name);
|
||||
|
||||
@@ -1123,7 +1172,10 @@ pub async fn storage_search_items(
|
||||
limit_clause
|
||||
);
|
||||
|
||||
let query_obj = Query::with_params(sql, vec![QueryParam::String(server_id), QueryParam::String(fts_query)]);
|
||||
let query_obj = Query::with_params(
|
||||
sql,
|
||||
vec![QueryParam::String(server_id), QueryParam::String(fts_query)],
|
||||
);
|
||||
|
||||
let items = db_service
|
||||
.query_many(query_obj, row_to_cached_item)
|
||||
@@ -1181,7 +1233,8 @@ pub async fn storage_save_item(
|
||||
};
|
||||
|
||||
// Generate sort_name from name (remove leading "The ", "A ", etc.)
|
||||
let sort_name = item.name
|
||||
let sort_name = item
|
||||
.name
|
||||
.strip_prefix("The ")
|
||||
.or_else(|| item.name.strip_prefix("A "))
|
||||
.or_else(|| item.name.strip_prefix("An "))
|
||||
@@ -1202,28 +1255,66 @@ pub async fn storage_save_item(
|
||||
vec![
|
||||
QueryParam::String(item.id),
|
||||
QueryParam::String(server_id),
|
||||
item.library_id.map(QueryParam::String).unwrap_or(QueryParam::Null),
|
||||
item.parent_id.map(QueryParam::String).unwrap_or(QueryParam::Null),
|
||||
item.library_id
|
||||
.map(QueryParam::String)
|
||||
.unwrap_or(QueryParam::Null),
|
||||
item.parent_id
|
||||
.map(QueryParam::String)
|
||||
.unwrap_or(QueryParam::Null),
|
||||
QueryParam::String(item.name),
|
||||
QueryParam::String(sort_name),
|
||||
QueryParam::String(item.item_type),
|
||||
item.overview.map(QueryParam::String).unwrap_or(QueryParam::Null),
|
||||
item.genres.map(QueryParam::String).unwrap_or(QueryParam::Null),
|
||||
item.runtime_ticks.map(QueryParam::Int64).unwrap_or(QueryParam::Null),
|
||||
item.production_year.map(QueryParam::Int).unwrap_or(QueryParam::Null),
|
||||
item.community_rating.map(QueryParam::Float).unwrap_or(QueryParam::Null),
|
||||
item.official_rating.map(QueryParam::String).unwrap_or(QueryParam::Null),
|
||||
item.primary_image_tag.map(QueryParam::String).unwrap_or(QueryParam::Null),
|
||||
item.album_id.map(QueryParam::String).unwrap_or(QueryParam::Null),
|
||||
item.album_name.map(QueryParam::String).unwrap_or(QueryParam::Null),
|
||||
item.album_artist.map(QueryParam::String).unwrap_or(QueryParam::Null),
|
||||
item.artists.map(QueryParam::String).unwrap_or(QueryParam::Null),
|
||||
item.index_number.map(QueryParam::Int).unwrap_or(QueryParam::Null),
|
||||
item.series_id.map(QueryParam::String).unwrap_or(QueryParam::Null),
|
||||
item.series_name.map(QueryParam::String).unwrap_or(QueryParam::Null),
|
||||
item.season_id.map(QueryParam::String).unwrap_or(QueryParam::Null),
|
||||
item.season_name.map(QueryParam::String).unwrap_or(QueryParam::Null),
|
||||
item.parent_index_number.map(QueryParam::Int).unwrap_or(QueryParam::Null),
|
||||
item.overview
|
||||
.map(QueryParam::String)
|
||||
.unwrap_or(QueryParam::Null),
|
||||
item.genres
|
||||
.map(QueryParam::String)
|
||||
.unwrap_or(QueryParam::Null),
|
||||
item.runtime_ticks
|
||||
.map(QueryParam::Int64)
|
||||
.unwrap_or(QueryParam::Null),
|
||||
item.production_year
|
||||
.map(QueryParam::Int)
|
||||
.unwrap_or(QueryParam::Null),
|
||||
item.community_rating
|
||||
.map(QueryParam::Float)
|
||||
.unwrap_or(QueryParam::Null),
|
||||
item.official_rating
|
||||
.map(QueryParam::String)
|
||||
.unwrap_or(QueryParam::Null),
|
||||
item.primary_image_tag
|
||||
.map(QueryParam::String)
|
||||
.unwrap_or(QueryParam::Null),
|
||||
item.album_id
|
||||
.map(QueryParam::String)
|
||||
.unwrap_or(QueryParam::Null),
|
||||
item.album_name
|
||||
.map(QueryParam::String)
|
||||
.unwrap_or(QueryParam::Null),
|
||||
item.album_artist
|
||||
.map(QueryParam::String)
|
||||
.unwrap_or(QueryParam::Null),
|
||||
item.artists
|
||||
.map(QueryParam::String)
|
||||
.unwrap_or(QueryParam::Null),
|
||||
item.index_number
|
||||
.map(QueryParam::Int)
|
||||
.unwrap_or(QueryParam::Null),
|
||||
item.series_id
|
||||
.map(QueryParam::String)
|
||||
.unwrap_or(QueryParam::Null),
|
||||
item.series_name
|
||||
.map(QueryParam::String)
|
||||
.unwrap_or(QueryParam::Null),
|
||||
item.season_id
|
||||
.map(QueryParam::String)
|
||||
.unwrap_or(QueryParam::Null),
|
||||
item.season_name
|
||||
.map(QueryParam::String)
|
||||
.unwrap_or(QueryParam::Null),
|
||||
item.parent_index_number
|
||||
.map(QueryParam::Int)
|
||||
.unwrap_or(QueryParam::Null),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1256,7 +1347,6 @@ pub async fn storage_get_pending_sync_count(
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
//! Person/cast metadata cache commands.
|
||||
//!
|
||||
//! TRACES: UR-035, UR-036 | IR-023 | DR-040, DR-041
|
||||
|
||||
use std::sync::Arc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use tauri::State;
|
||||
|
||||
use super::DatabaseWrapper;
|
||||
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
|
||||
|
||||
|
||||
/// Cached person info returned to frontend
|
||||
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -54,10 +55,22 @@ pub async fn storage_save_person(
|
||||
QueryParam::String(person.id),
|
||||
QueryParam::String(person.server_id),
|
||||
QueryParam::String(person.name),
|
||||
person.overview.map(QueryParam::String).unwrap_or(QueryParam::Null),
|
||||
person.primary_image_tag.map(QueryParam::String).unwrap_or(QueryParam::Null),
|
||||
person.premiere_date.map(QueryParam::String).unwrap_or(QueryParam::Null),
|
||||
person.end_date.map(QueryParam::String).unwrap_or(QueryParam::Null),
|
||||
person
|
||||
.overview
|
||||
.map(QueryParam::String)
|
||||
.unwrap_or(QueryParam::Null),
|
||||
person
|
||||
.primary_image_tag
|
||||
.map(QueryParam::String)
|
||||
.unwrap_or(QueryParam::Null),
|
||||
person
|
||||
.premiere_date
|
||||
.map(QueryParam::String)
|
||||
.unwrap_or(QueryParam::Null),
|
||||
person
|
||||
.end_date
|
||||
.map(QueryParam::String)
|
||||
.unwrap_or(QueryParam::Null),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -117,25 +130,32 @@ pub async fn storage_save_item_people(
|
||||
let associations_clone = associations.clone();
|
||||
|
||||
// Use transaction for batch insert
|
||||
db_service.transaction(move |tx| {
|
||||
for assoc in &associations_clone {
|
||||
let query = Query::with_params(
|
||||
"INSERT OR REPLACE INTO item_people (
|
||||
db_service
|
||||
.transaction(move |tx| {
|
||||
for assoc in &associations_clone {
|
||||
let query = Query::with_params(
|
||||
"INSERT OR REPLACE INTO item_people (
|
||||
item_id, person_id, server_id, person_type, role, sort_order, synced_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)",
|
||||
vec![
|
||||
QueryParam::String(assoc.item_id.clone()),
|
||||
QueryParam::String(assoc.person_id.clone()),
|
||||
QueryParam::String(assoc.server_id.clone()),
|
||||
QueryParam::String(assoc.person_type.clone()),
|
||||
assoc.role.clone().map(QueryParam::String).unwrap_or(QueryParam::Null),
|
||||
QueryParam::Int(assoc.sort_order),
|
||||
],
|
||||
);
|
||||
tx.execute(query)?;
|
||||
}
|
||||
Ok(())
|
||||
}).await.map_err(|e| e.to_string())?;
|
||||
vec![
|
||||
QueryParam::String(assoc.item_id.clone()),
|
||||
QueryParam::String(assoc.person_id.clone()),
|
||||
QueryParam::String(assoc.server_id.clone()),
|
||||
QueryParam::String(assoc.person_type.clone()),
|
||||
assoc
|
||||
.role
|
||||
.clone()
|
||||
.map(QueryParam::String)
|
||||
.unwrap_or(QueryParam::Null),
|
||||
QueryParam::Int(assoc.sort_order),
|
||||
],
|
||||
);
|
||||
tx.execute(query)?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -176,4 +196,3 @@ pub async fn storage_get_item_people(
|
||||
|
||||
Ok(people)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
//! Per-series preferred audio track commands.
|
||||
//!
|
||||
//! TRACES: UR-021 | DR-024
|
||||
|
||||
use std::sync::Arc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use tauri::State;
|
||||
|
||||
use super::DatabaseWrapper;
|
||||
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
|
||||
|
||||
|
||||
/// Audio track preference for a series
|
||||
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
//! Thumbnail cache and image-URL commands.
|
||||
//!
|
||||
//! TRACES: UR-007 | JA-028 | DR-016
|
||||
|
||||
use std::sync::{Arc, OnceLock};
|
||||
use tokio::sync::Semaphore;
|
||||
use serde::Deserialize;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
use tauri::State;
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
use super::{DatabaseWrapper, ThumbnailCacheWrapper};
|
||||
use crate::commands::repository::RepositoryManagerWrapper;
|
||||
@@ -11,7 +13,6 @@ use crate::repository::types::{ImageOptions, ImageType};
|
||||
use crate::repository::MediaRepository;
|
||||
use crate::thumbnail::{ThumbnailCacheStats, ThumbnailWorker};
|
||||
|
||||
|
||||
/// Get cached thumbnail path, returns None if not cached
|
||||
/// Also updates last_accessed timestamp for LRU tracking
|
||||
#[tauri::command]
|
||||
@@ -28,7 +29,8 @@ pub async fn thumbnail_get_cached(
|
||||
Arc::new(database.service())
|
||||
};
|
||||
|
||||
let result = thumbnail_cache.0
|
||||
let result = thumbnail_cache
|
||||
.0
|
||||
.get_cached_path(db_service, &item_id, &image_type, &tag)
|
||||
.await
|
||||
.map(|p| p.to_string_lossy().to_string());
|
||||
@@ -61,7 +63,10 @@ pub async fn thumbnail_save(
|
||||
Arc::new(database.service())
|
||||
};
|
||||
|
||||
let path = thumbnail_cache.0.save_thumbnail(db_service, &item_id, &image_type, &tag, &data, None, None).await?;
|
||||
let path = thumbnail_cache
|
||||
.0
|
||||
.save_thumbnail(db_service, &item_id, &image_type, &tag, &data, None, None)
|
||||
.await?;
|
||||
Ok(path.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
@@ -179,7 +184,7 @@ pub async fn image_get_url(
|
||||
repository_handle: String,
|
||||
request: GetImageRequest,
|
||||
) -> Result<String, String> {
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
|
||||
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
|
||||
use std::fs;
|
||||
|
||||
let tag = request.tag.as_deref().unwrap_or("default");
|
||||
@@ -191,14 +196,18 @@ pub async fn image_get_url(
|
||||
};
|
||||
|
||||
// Check cache first
|
||||
if let Some(cached_path) = thumbnail_cache.0.get_cached_path(
|
||||
db_service.clone(),
|
||||
&request.item_id,
|
||||
&request.image_type,
|
||||
tag,
|
||||
).await {
|
||||
let image_data = fs::read(&cached_path)
|
||||
.map_err(|e| format!("Failed to read cached image: {}", e))?;
|
||||
if let Some(cached_path) = thumbnail_cache
|
||||
.0
|
||||
.get_cached_path(
|
||||
db_service.clone(),
|
||||
&request.item_id,
|
||||
&request.image_type,
|
||||
tag,
|
||||
)
|
||||
.await
|
||||
{
|
||||
let image_data =
|
||||
fs::read(&cached_path).map_err(|e| format!("Failed to read cached image: {}", e))?;
|
||||
let base64_data = BASE64.encode(&image_data);
|
||||
let mime_type = mime_from_ext(cached_path.extension().and_then(|s| s.to_str()));
|
||||
return Ok(format!("data:{};base64,{}", mime_type, base64_data));
|
||||
@@ -206,10 +215,14 @@ pub async fn image_get_url(
|
||||
|
||||
// Not cached — fetch from server and cache.
|
||||
// Acquire semaphore to limit concurrent downloads (prevents connection pool starvation).
|
||||
let _permit = image_semaphore().acquire().await
|
||||
let _permit = image_semaphore()
|
||||
.acquire()
|
||||
.await
|
||||
.map_err(|_| "Image download semaphore closed".to_string())?;
|
||||
|
||||
let repository = repository_manager.0.get(&repository_handle)
|
||||
let repository = repository_manager
|
||||
.0
|
||||
.get(&repository_handle)
|
||||
.ok_or_else(|| "Repository not found - user may need to log in".to_string())?;
|
||||
|
||||
let image_type_enum = match request.image_type.as_str() {
|
||||
@@ -229,18 +242,23 @@ pub async fn image_get_url(
|
||||
};
|
||||
|
||||
let server_url = repository.get_image_url(&request.item_id, image_type_enum, Some(options));
|
||||
let image_data = repository.download_bytes(&server_url).await
|
||||
let image_data = repository
|
||||
.download_bytes(&server_url)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to download image: {}", e))?;
|
||||
|
||||
let cached_path = thumbnail_cache.0.save_thumbnail(
|
||||
db_service,
|
||||
&request.item_id,
|
||||
&request.image_type,
|
||||
tag,
|
||||
&image_data,
|
||||
request.max_width.map(|w| w as i32),
|
||||
request.max_height.map(|h| h as i32),
|
||||
).await?;
|
||||
let cached_path = thumbnail_cache
|
||||
.0
|
||||
.save_thumbnail(
|
||||
db_service,
|
||||
&request.item_id,
|
||||
&request.image_type,
|
||||
tag,
|
||||
&image_data,
|
||||
request.max_width.map(|w| w as i32),
|
||||
request.max_height.map(|h| h as i32),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let base64_data = BASE64.encode(&image_data);
|
||||
let mime_type = mime_from_ext(cached_path.extension().and_then(|s| s.to_str()));
|
||||
|
||||
@@ -53,7 +53,10 @@ pub async fn sync_queue_mutation(
|
||||
);
|
||||
|
||||
db_service.execute(query).await.map_err(|e| e.to_string())?;
|
||||
let id = db_service.last_insert_rowid().await.map_err(|e| e.to_string())?;
|
||||
let id = db_service
|
||||
.last_insert_rowid()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(id)
|
||||
}
|
||||
@@ -110,10 +113,7 @@ pub async fn sync_get_pending(
|
||||
/// Mark a sync operation as in progress
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn sync_mark_processing(
|
||||
db: State<'_, DatabaseWrapper>,
|
||||
id: i64,
|
||||
) -> Result<(), String> {
|
||||
pub async fn sync_mark_processing(db: State<'_, DatabaseWrapper>, id: i64) -> Result<(), String> {
|
||||
let db_service = {
|
||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||
Arc::new(database.service())
|
||||
@@ -131,10 +131,7 @@ pub async fn sync_mark_processing(
|
||||
/// Mark a sync operation as completed
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn sync_mark_completed(
|
||||
db: State<'_, DatabaseWrapper>,
|
||||
id: i64,
|
||||
) -> Result<(), String> {
|
||||
pub async fn sync_mark_completed(db: State<'_, DatabaseWrapper>, id: i64) -> Result<(), String> {
|
||||
let db_service = {
|
||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||
Arc::new(database.service())
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::RwLock;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use serde::{Serialize, Deserialize};
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use crate::jellyfin::http_client::HttpClient;
|
||||
|
||||
@@ -170,9 +170,15 @@ impl ConnectivityReporter {
|
||||
if let Some(app_handle) = &self.app_handle {
|
||||
let event = ConnectivityChangeEvent { is_reachable };
|
||||
if let Err(e) = app_handle.emit("connectivity:changed", event) {
|
||||
log::error!("[ConnectivityMonitor] Failed to emit connectivity change event: {}", e);
|
||||
log::error!(
|
||||
"[ConnectivityMonitor] Failed to emit connectivity change event: {}",
|
||||
e
|
||||
);
|
||||
} else {
|
||||
log::info!("[ConnectivityMonitor] Emitted connectivity change: {}", is_reachable);
|
||||
log::info!(
|
||||
"[ConnectivityMonitor] Emitted connectivity change: {}",
|
||||
is_reachable
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -181,7 +187,10 @@ impl ConnectivityReporter {
|
||||
async fn emit_server_reconnected(&self) {
|
||||
if let Some(app_handle) = &self.app_handle {
|
||||
if let Err(e) = app_handle.emit("connectivity:reconnected", ()) {
|
||||
log::error!("[ConnectivityMonitor] Failed to emit reconnection event: {}", e);
|
||||
log::error!(
|
||||
"[ConnectivityMonitor] Failed to emit reconnection event: {}",
|
||||
e
|
||||
);
|
||||
} else {
|
||||
log::info!("[ConnectivityMonitor] Emitted server reconnected event");
|
||||
}
|
||||
@@ -233,7 +242,14 @@ impl ConnectivityMonitor {
|
||||
// Check new server immediately
|
||||
log::info!("[ConnectivityMonitor] Checking reachability of new server...");
|
||||
let is_reachable = self.check_reachability().await;
|
||||
log::info!("[ConnectivityMonitor] New server is {}", if is_reachable { "REACHABLE" } else { "UNREACHABLE" });
|
||||
log::info!(
|
||||
"[ConnectivityMonitor] New server is {}",
|
||||
if is_reachable {
|
||||
"REACHABLE"
|
||||
} else {
|
||||
"UNREACHABLE"
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// Get current connectivity status
|
||||
@@ -298,11 +314,16 @@ impl ConnectivityMonitor {
|
||||
return;
|
||||
}
|
||||
|
||||
log::info!("[ConnectivityMonitor] Starting connectivity monitoring (offline recovery probe)");
|
||||
log::info!(
|
||||
"[ConnectivityMonitor] Starting connectivity monitoring (offline recovery probe)"
|
||||
);
|
||||
|
||||
// Perform an immediate check so startup reflects reality quickly.
|
||||
let is_reachable = self.check_reachability().await;
|
||||
log::info!("[ConnectivityMonitor] Initial connectivity check: {}", if is_reachable { "ONLINE" } else { "OFFLINE" });
|
||||
log::info!(
|
||||
"[ConnectivityMonitor] Initial connectivity check: {}",
|
||||
if is_reachable { "ONLINE" } else { "OFFLINE" }
|
||||
);
|
||||
|
||||
let is_monitoring = Arc::clone(&self.is_monitoring);
|
||||
let server_url = Arc::clone(&self.server_url);
|
||||
@@ -452,7 +473,9 @@ mod tests {
|
||||
let reporter = test_reporter();
|
||||
|
||||
// Force offline.
|
||||
reporter.apply_probe_result(false, Some("down".to_string())).await;
|
||||
reporter
|
||||
.apply_probe_result(false, Some("down".to_string()))
|
||||
.await;
|
||||
assert!(!is_reachable(&reporter).await);
|
||||
|
||||
// A single success brings us straight back online.
|
||||
@@ -490,7 +513,9 @@ mod tests {
|
||||
assert!(!is_reachable(&reporter).await);
|
||||
|
||||
// Should not panic or change state.
|
||||
reporter.report_network_failure(Some("still down".to_string())).await;
|
||||
reporter
|
||||
.report_network_failure(Some("still down".to_string()))
|
||||
.await;
|
||||
assert!(!is_reachable(&reporter).await);
|
||||
}
|
||||
}
|
||||
|
||||
+101
-47
@@ -105,13 +105,21 @@ impl CredentialStore {
|
||||
}
|
||||
|
||||
/// Save an access token for a user
|
||||
pub fn save_token(&self, user_id: &str, token: &str) -> Result<CredentialResult, CredentialError> {
|
||||
pub fn save_token(
|
||||
&self,
|
||||
user_id: &str,
|
||||
token: &str,
|
||||
) -> Result<CredentialResult, CredentialError> {
|
||||
if self.using_keyring {
|
||||
log::debug!("Saving token for user {} to keyring", user_id);
|
||||
self.save_to_keyring(user_id, token)?;
|
||||
Ok(CredentialResult::Keyring)
|
||||
} else {
|
||||
log::debug!("Saving token for user {} to encrypted file at {:?}", user_id, self.credentials_path);
|
||||
log::debug!(
|
||||
"Saving token for user {} to encrypted file at {:?}",
|
||||
user_id,
|
||||
self.credentials_path
|
||||
);
|
||||
self.save_to_file(user_id, token)?;
|
||||
log::debug!("Successfully saved token to encrypted file");
|
||||
Ok(CredentialResult::EncryptedFile)
|
||||
@@ -124,7 +132,11 @@ impl CredentialStore {
|
||||
log::debug!("Getting token for user {} from keyring", user_id);
|
||||
self.get_from_keyring(user_id)
|
||||
} else {
|
||||
log::debug!("Getting token for user {} from encrypted file at {:?}", user_id, self.credentials_path);
|
||||
log::debug!(
|
||||
"Getting token for user {} from encrypted file at {:?}",
|
||||
user_id,
|
||||
self.credentials_path
|
||||
);
|
||||
let result = self.get_from_file(user_id);
|
||||
if result.is_ok() {
|
||||
log::debug!("Successfully retrieved token from encrypted file");
|
||||
@@ -197,7 +209,7 @@ impl CredentialStore {
|
||||
.arg("__nonexistent_test__")
|
||||
.output()
|
||||
{
|
||||
Ok(_) => true, // If command runs (even with no results), secret-tool is available
|
||||
Ok(_) => true, // If command runs (even with no results), secret-tool is available
|
||||
Err(_) => false, // Command not found or can't execute
|
||||
}
|
||||
}
|
||||
@@ -232,8 +244,8 @@ impl CredentialStore {
|
||||
{
|
||||
// Use secret-tool directly on Linux as a workaround for keyring-rs library issues
|
||||
// See Technical Debt section in README.md for details
|
||||
use std::process::{Command, Stdio};
|
||||
use std::io::Write;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
let key = format!("access_token:{}", user_id);
|
||||
let mut child = Command::new("secret-tool")
|
||||
@@ -248,20 +260,27 @@ impl CredentialStore {
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.map_err(|e| CredentialError::Keyring(format!("Failed to spawn secret-tool: {}", e)))?;
|
||||
.map_err(|e| {
|
||||
CredentialError::Keyring(format!("Failed to spawn secret-tool: {}", e))
|
||||
})?;
|
||||
|
||||
if let Some(mut stdin) = child.stdin.take() {
|
||||
stdin.write_all(token.as_bytes())
|
||||
.map_err(|e| CredentialError::Keyring(format!("Failed to write to secret-tool: {}", e)))?;
|
||||
stdin.write_all(token.as_bytes()).map_err(|e| {
|
||||
CredentialError::Keyring(format!("Failed to write to secret-tool: {}", e))
|
||||
})?;
|
||||
}
|
||||
|
||||
let status = child.wait()
|
||||
.map_err(|e| CredentialError::Keyring(format!("Failed to wait for secret-tool: {}", e)))?;
|
||||
let status = child.wait().map_err(|e| {
|
||||
CredentialError::Keyring(format!("Failed to wait for secret-tool: {}", e))
|
||||
})?;
|
||||
|
||||
if status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(CredentialError::Keyring(format!("secret-tool failed with status: {}", status)))
|
||||
Err(CredentialError::Keyring(format!(
|
||||
"secret-tool failed with status: {}",
|
||||
status
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,7 +309,11 @@ impl CredentialStore {
|
||||
use std::process::Command;
|
||||
|
||||
let key = format!("access_token:{}", user_id);
|
||||
log::debug!("Looking up token with service={}, username={}", SERVICE_NAME, key);
|
||||
log::debug!(
|
||||
"Looking up token with service={}, username={}",
|
||||
SERVICE_NAME,
|
||||
key
|
||||
);
|
||||
|
||||
let output = Command::new("secret-tool")
|
||||
.arg("lookup")
|
||||
@@ -299,18 +322,29 @@ impl CredentialStore {
|
||||
.arg("username")
|
||||
.arg(&key)
|
||||
.output()
|
||||
.map_err(|e| CredentialError::Keyring(format!("Failed to run secret-tool: {}", e)))?;
|
||||
.map_err(|e| {
|
||||
CredentialError::Keyring(format!("Failed to run secret-tool: {}", e))
|
||||
})?;
|
||||
|
||||
if output.status.success() {
|
||||
log::debug!("secret-tool lookup succeeded, token length: {}", output.stdout.len());
|
||||
log::debug!(
|
||||
"secret-tool lookup succeeded, token length: {}",
|
||||
output.stdout.len()
|
||||
);
|
||||
let token = String::from_utf8(output.stdout)
|
||||
.map_err(|e| CredentialError::Keyring(format!("Invalid UTF-8 in token: {}", e)))?
|
||||
.map_err(|e| {
|
||||
CredentialError::Keyring(format!("Invalid UTF-8 in token: {}", e))
|
||||
})?
|
||||
.trim()
|
||||
.to_string();
|
||||
Ok(token)
|
||||
} else {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
log::warn!("secret-tool lookup failed with status: {} stderr: {}", output.status, stderr);
|
||||
log::warn!(
|
||||
"secret-tool lookup failed with status: {} stderr: {}",
|
||||
output.status,
|
||||
stderr
|
||||
);
|
||||
Err(CredentialError::NotFound)
|
||||
}
|
||||
}
|
||||
@@ -348,13 +382,18 @@ impl CredentialStore {
|
||||
.arg("username")
|
||||
.arg(&key)
|
||||
.status()
|
||||
.map_err(|e| CredentialError::Keyring(format!("Failed to run secret-tool: {}", e)))?;
|
||||
.map_err(|e| {
|
||||
CredentialError::Keyring(format!("Failed to run secret-tool: {}", e))
|
||||
})?;
|
||||
|
||||
// secret-tool clear returns success even if entry doesn't exist
|
||||
if status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(CredentialError::Keyring(format!("secret-tool clear failed with status: {}", status)))
|
||||
Err(CredentialError::Keyring(format!(
|
||||
"secret-tool clear failed with status: {}",
|
||||
status
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -398,10 +437,7 @@ impl CredentialStore {
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
// Try to read Android build properties from /system/build.prop
|
||||
let build_prop_paths = [
|
||||
"/system/build.prop",
|
||||
"/vendor/build.prop",
|
||||
];
|
||||
let build_prop_paths = ["/system/build.prop", "/vendor/build.prop"];
|
||||
|
||||
for path in &build_prop_paths {
|
||||
if let Ok(content) = fs::read_to_string(path) {
|
||||
@@ -410,7 +446,8 @@ impl CredentialStore {
|
||||
if line.starts_with("ro.build.fingerprint=")
|
||||
|| line.starts_with("ro.serialno=")
|
||||
|| line.starts_with("ro.build.id=")
|
||||
|| line.starts_with("ro.product.model=") {
|
||||
|| line.starts_with("ro.product.model=")
|
||||
{
|
||||
hasher.update(line.as_bytes());
|
||||
}
|
||||
}
|
||||
@@ -439,8 +476,8 @@ impl CredentialStore {
|
||||
return Ok(serde_json::json!({}));
|
||||
}
|
||||
|
||||
let encrypted_data =
|
||||
fs::read_to_string(&self.credentials_path).map_err(|e| CredentialError::Io(e.to_string()))?;
|
||||
let encrypted_data = fs::read_to_string(&self.credentials_path)
|
||||
.map_err(|e| CredentialError::Io(e.to_string()))?;
|
||||
|
||||
if encrypted_data.is_empty() {
|
||||
return Ok(serde_json::json!({}));
|
||||
@@ -456,19 +493,21 @@ impl CredentialStore {
|
||||
fs::create_dir_all(parent).map_err(|e| CredentialError::Io(e.to_string()))?;
|
||||
}
|
||||
|
||||
let json = serde_json::to_string(data).map_err(|e| CredentialError::Encryption(e.to_string()))?;
|
||||
let json =
|
||||
serde_json::to_string(data).map_err(|e| CredentialError::Encryption(e.to_string()))?;
|
||||
let encrypted = self.encrypt(&json)?;
|
||||
|
||||
fs::write(&self.credentials_path, encrypted).map_err(|e| CredentialError::Io(e.to_string()))
|
||||
}
|
||||
|
||||
fn encrypt(&self, plaintext: &str) -> Result<String, CredentialError> {
|
||||
let cipher =
|
||||
Aes256Gcm::new_from_slice(&self.encryption_key).map_err(|e| CredentialError::Encryption(e.to_string()))?;
|
||||
let cipher = Aes256Gcm::new_from_slice(&self.encryption_key)
|
||||
.map_err(|e| CredentialError::Encryption(e.to_string()))?;
|
||||
|
||||
// Generate a random nonce
|
||||
let mut nonce_bytes = [0u8; 12];
|
||||
getrandom::getrandom(&mut nonce_bytes).map_err(|e| CredentialError::Encryption(e.to_string()))?;
|
||||
getrandom::getrandom(&mut nonce_bytes)
|
||||
.map_err(|e| CredentialError::Encryption(e.to_string()))?;
|
||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||
|
||||
let ciphertext = cipher
|
||||
@@ -488,14 +527,16 @@ impl CredentialStore {
|
||||
.map_err(|e| CredentialError::Encryption(e.to_string()))?;
|
||||
|
||||
if combined.len() < 12 {
|
||||
return Err(CredentialError::Encryption("Invalid encrypted data".to_string()));
|
||||
return Err(CredentialError::Encryption(
|
||||
"Invalid encrypted data".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let (nonce_bytes, ciphertext) = combined.split_at(12);
|
||||
let nonce = Nonce::from_slice(nonce_bytes);
|
||||
|
||||
let cipher =
|
||||
Aes256Gcm::new_from_slice(&self.encryption_key).map_err(|e| CredentialError::Encryption(e.to_string()))?;
|
||||
let cipher = Aes256Gcm::new_from_slice(&self.encryption_key)
|
||||
.map_err(|e| CredentialError::Encryption(e.to_string()))?;
|
||||
|
||||
let plaintext = cipher
|
||||
.decrypt(nonce, ciphertext)
|
||||
@@ -686,32 +727,39 @@ mod android_keystore {
|
||||
.attach_current_thread()
|
||||
.map_err(|e| CredentialError::Keyring(format!("Failed to attach thread: {}", e)))?;
|
||||
|
||||
let instance = get_secure_storage_instance(&mut env)
|
||||
.map_err(|e| CredentialError::Keyring(e))?;
|
||||
let instance =
|
||||
get_secure_storage_instance(&mut env).map_err(|e| CredentialError::Keyring(e))?;
|
||||
|
||||
let key = format!("access_token:{}", user_id);
|
||||
let key_jstring = env
|
||||
.new_string(&key)
|
||||
.map_err(|e| CredentialError::Keyring(format!("Failed to create key string: {}", e)))?;
|
||||
let token_jstring = env
|
||||
.new_string(token)
|
||||
.map_err(|e| CredentialError::Keyring(format!("Failed to create token string: {}", e)))?;
|
||||
let token_jstring = env.new_string(token).map_err(|e| {
|
||||
CredentialError::Keyring(format!("Failed to create token string: {}", e))
|
||||
})?;
|
||||
|
||||
let result = env
|
||||
.call_method(
|
||||
instance,
|
||||
"saveToken",
|
||||
"(Ljava/lang/String;Ljava/lang/String;)Z",
|
||||
&[JValue::Object(&key_jstring.into()), JValue::Object(&token_jstring.into())],
|
||||
&[
|
||||
JValue::Object(&key_jstring.into()),
|
||||
JValue::Object(&token_jstring.into()),
|
||||
],
|
||||
)
|
||||
.map_err(|e| CredentialError::Keyring(format!("Failed to call saveToken: {}", e)))?
|
||||
.z()
|
||||
.map_err(|e| CredentialError::Keyring(format!("Failed to get boolean result: {}", e)))?;
|
||||
.map_err(|e| {
|
||||
CredentialError::Keyring(format!("Failed to get boolean result: {}", e))
|
||||
})?;
|
||||
|
||||
if result {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(CredentialError::Keyring("saveToken returned false".to_string()))
|
||||
Err(CredentialError::Keyring(
|
||||
"saveToken returned false".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -725,8 +773,8 @@ mod android_keystore {
|
||||
.attach_current_thread()
|
||||
.map_err(|e| CredentialError::Keyring(format!("Failed to attach thread: {}", e)))?;
|
||||
|
||||
let instance = get_secure_storage_instance(&mut env)
|
||||
.map_err(|e| CredentialError::Keyring(e))?;
|
||||
let instance =
|
||||
get_secure_storage_instance(&mut env).map_err(|e| CredentialError::Keyring(e))?;
|
||||
|
||||
let key = format!("access_token:{}", user_id);
|
||||
let key_jstring = env
|
||||
@@ -767,8 +815,8 @@ mod android_keystore {
|
||||
.attach_current_thread()
|
||||
.map_err(|e| CredentialError::Keyring(format!("Failed to attach thread: {}", e)))?;
|
||||
|
||||
let instance = get_secure_storage_instance(&mut env)
|
||||
.map_err(|e| CredentialError::Keyring(e))?;
|
||||
let instance =
|
||||
get_secure_storage_instance(&mut env).map_err(|e| CredentialError::Keyring(e))?;
|
||||
|
||||
let key = format!("access_token:{}", user_id);
|
||||
let key_jstring = env
|
||||
@@ -784,19 +832,25 @@ mod android_keystore {
|
||||
)
|
||||
.map_err(|e| CredentialError::Keyring(format!("Failed to call deleteToken: {}", e)))?
|
||||
.z()
|
||||
.map_err(|e| CredentialError::Keyring(format!("Failed to get boolean result: {}", e)))?;
|
||||
.map_err(|e| {
|
||||
CredentialError::Keyring(format!("Failed to get boolean result: {}", e))
|
||||
})?;
|
||||
|
||||
if result {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(CredentialError::Keyring("deleteToken returned false".to_string()))
|
||||
Err(CredentialError::Keyring(
|
||||
"deleteToken returned false".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export Android keystore functions at the module level for easier access
|
||||
#[cfg(target_os = "android")]
|
||||
pub use android_keystore::{initialize_secure_storage, test_keystore_available as android_test_keystore_available};
|
||||
pub use android_keystore::{
|
||||
initialize_secure_storage, test_keystore_available as android_test_keystore_available,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
@@ -36,7 +36,7 @@ impl Default for CacheConfig {
|
||||
album_affinity_enabled: true,
|
||||
album_affinity_threshold: 3,
|
||||
storage_limit: 10 * 1024 * 1024 * 1024, // 10GB
|
||||
wifi_only: false, // Allow preloading on any connection by default
|
||||
wifi_only: false, // Allow preloading on any connection by default
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -225,7 +225,10 @@ impl SmartCache {
|
||||
"DELETE FROM downloads WHERE id = ?",
|
||||
vec![QueryParam::Int64(id)],
|
||||
);
|
||||
db_service.execute(delete_query).await.map_err(|e| e.to_string())?;
|
||||
db_service
|
||||
.execute(delete_query)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
freed += size as u64;
|
||||
}
|
||||
|
||||
@@ -8,16 +8,10 @@ use serde::{Deserialize, Serialize};
|
||||
pub enum DownloadEvent {
|
||||
/// Download has been queued
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Queued {
|
||||
download_id: i64,
|
||||
item_id: String,
|
||||
},
|
||||
Queued { download_id: i64, item_id: String },
|
||||
/// Download has started
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Started {
|
||||
download_id: i64,
|
||||
item_id: String,
|
||||
},
|
||||
Started { download_id: i64, item_id: String },
|
||||
/// Download progress update
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Progress {
|
||||
@@ -43,16 +37,10 @@ pub enum DownloadEvent {
|
||||
},
|
||||
/// Download paused
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Paused {
|
||||
download_id: i64,
|
||||
item_id: String,
|
||||
},
|
||||
Paused { download_id: i64, item_id: String },
|
||||
/// Download cancelled
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Cancelled {
|
||||
download_id: i64,
|
||||
item_id: String,
|
||||
},
|
||||
Cancelled { download_id: i64, item_id: String },
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -98,9 +86,21 @@ mod tests {
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
assert!(json.contains("\"type\":\"completed\""));
|
||||
// Verify camelCase field names
|
||||
assert!(json.contains("\"downloadId\":42"), "Expected downloadId (camelCase), got: {}", json);
|
||||
assert!(json.contains("\"itemId\":\"song456\""), "Expected itemId (camelCase), got: {}", json);
|
||||
assert!(json.contains("\"filePath\":"), "Expected filePath (camelCase), got: {}", json);
|
||||
assert!(
|
||||
json.contains("\"downloadId\":42"),
|
||||
"Expected downloadId (camelCase), got: {}",
|
||||
json
|
||||
);
|
||||
assert!(
|
||||
json.contains("\"itemId\":\"song456\""),
|
||||
"Expected itemId (camelCase), got: {}",
|
||||
json
|
||||
);
|
||||
assert!(
|
||||
json.contains("\"filePath\":"),
|
||||
"Expected filePath (camelCase), got: {}",
|
||||
json
|
||||
);
|
||||
|
||||
// Verify roundtrip
|
||||
let deserialized: DownloadEvent = serde_json::from_str(&json).unwrap();
|
||||
|
||||
@@ -11,8 +11,8 @@ pub mod events;
|
||||
pub mod worker;
|
||||
|
||||
use crate::utils::lock::MutexSafe;
|
||||
use std::path::PathBuf;
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
pub use worker::DownloadWorker;
|
||||
|
||||
@@ -60,7 +60,11 @@ impl DownloadWorker {
|
||||
}
|
||||
|
||||
/// Attempt a single download
|
||||
async fn try_download<F>(&self, task: &DownloadTask, on_progress: &F) -> Result<DownloadResult, DownloadError>
|
||||
async fn try_download<F>(
|
||||
&self,
|
||||
task: &DownloadTask,
|
||||
on_progress: &F,
|
||||
) -> Result<DownloadResult, DownloadError>
|
||||
where
|
||||
F: Fn(u64, Option<u64>) + Send + Sync,
|
||||
{
|
||||
@@ -74,10 +78,7 @@ impl DownloadWorker {
|
||||
// Check for partial download
|
||||
let temp_path = task.target_path.with_extension("part");
|
||||
let existing_bytes = if temp_path.exists() {
|
||||
fs::metadata(&temp_path)
|
||||
.await
|
||||
.map(|m| m.len())
|
||||
.unwrap_or(0)
|
||||
fs::metadata(&temp_path).await.map(|m| m.len()).unwrap_or(0)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
@@ -105,14 +106,17 @@ impl DownloadWorker {
|
||||
.get(reqwest::header::CONTENT_LENGTH)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.parse::<u64>().ok())
|
||||
.map(|len| if existing_bytes > 0 { len + existing_bytes } else { len });
|
||||
.map(|len| {
|
||||
if existing_bytes > 0 {
|
||||
len + existing_bytes
|
||||
} else {
|
||||
len
|
||||
}
|
||||
});
|
||||
|
||||
// Open file for appending
|
||||
let mut file = if existing_bytes > 0 {
|
||||
fs::OpenOptions::new()
|
||||
.append(true)
|
||||
.open(&temp_path)
|
||||
.await
|
||||
fs::OpenOptions::new().append(true).open(&temp_path).await
|
||||
} else {
|
||||
fs::File::create(&temp_path).await
|
||||
}
|
||||
@@ -206,9 +210,18 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_exponential_backoff() {
|
||||
assert_eq!(DownloadWorker::exponential_backoff(1), Duration::from_secs(5));
|
||||
assert_eq!(DownloadWorker::exponential_backoff(2), Duration::from_secs(15));
|
||||
assert_eq!(DownloadWorker::exponential_backoff(3), Duration::from_secs(45));
|
||||
assert_eq!(
|
||||
DownloadWorker::exponential_backoff(1),
|
||||
Duration::from_secs(5)
|
||||
);
|
||||
assert_eq!(
|
||||
DownloadWorker::exponential_backoff(2),
|
||||
Duration::from_secs(15)
|
||||
);
|
||||
assert_eq!(
|
||||
DownloadWorker::exponential_backoff(3),
|
||||
Duration::from_secs(45)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -72,7 +72,8 @@ impl JellyfinClient {
|
||||
|
||||
log::debug!("[JellyfinClient] GET {}", endpoint);
|
||||
|
||||
let response = self.http_client
|
||||
let response = self
|
||||
.http_client
|
||||
.get(&url)
|
||||
.header("X-Emby-Authorization", self.get_auth_header())
|
||||
.send()
|
||||
@@ -83,13 +84,24 @@ impl JellyfinClient {
|
||||
})?;
|
||||
|
||||
let status = response.status();
|
||||
log::debug!("[JellyfinClient] Response status for {}: {}", endpoint, status);
|
||||
log::debug!(
|
||||
"[JellyfinClient] Response status for {}: {}",
|
||||
endpoint,
|
||||
status
|
||||
);
|
||||
|
||||
if !status.is_success() {
|
||||
let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
let error_text = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
log::error!("[JellyfinClient] Request failed: {} {}", status, endpoint);
|
||||
log::error!("[JellyfinClient] Response: {}", error_text);
|
||||
return Err(format!("Jellyfin API error {}: {}", status.as_u16(), error_text));
|
||||
return Err(format!(
|
||||
"Jellyfin API error {}: {}",
|
||||
status.as_u16(),
|
||||
error_text
|
||||
));
|
||||
}
|
||||
|
||||
// Get the response text first so we can log it
|
||||
@@ -100,9 +112,15 @@ impl JellyfinClient {
|
||||
|
||||
// Log the raw response for sessions endpoint to help debug
|
||||
if endpoint.contains("/Sessions") {
|
||||
debug!("[JellyfinClient] Raw response for {}: {}", endpoint,
|
||||
debug!(
|
||||
"[JellyfinClient] Raw response for {}: {}",
|
||||
endpoint,
|
||||
if response_text.len() > 500 {
|
||||
format!("{}... (truncated, {} bytes total)", &response_text[..500], response_text.len())
|
||||
format!(
|
||||
"{}... (truncated, {} bytes total)",
|
||||
&response_text[..500],
|
||||
response_text.len()
|
||||
)
|
||||
} else {
|
||||
response_text.clone()
|
||||
}
|
||||
@@ -112,7 +130,8 @@ impl JellyfinClient {
|
||||
// Parse the response text as JSON
|
||||
let data = serde_json::from_str::<T>(&response_text).map_err(|e| {
|
||||
log::error!("[JellyfinClient] Failed to parse response: {}", e);
|
||||
log::error!("[JellyfinClient] Response was: {}",
|
||||
log::error!(
|
||||
"[JellyfinClient] Response was: {}",
|
||||
if response_text.len() > 200 {
|
||||
format!("{}...", &response_text[..200])
|
||||
} else {
|
||||
@@ -132,7 +151,8 @@ impl JellyfinClient {
|
||||
|
||||
log::debug!("[JellyfinClient] POST {} to {}", endpoint, url);
|
||||
|
||||
let response: reqwest::Response = self.http_client
|
||||
let response: reqwest::Response = self
|
||||
.http_client
|
||||
.post(&url)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("X-Emby-Authorization", self.get_auth_header())
|
||||
@@ -145,13 +165,24 @@ impl JellyfinClient {
|
||||
})?;
|
||||
|
||||
let status = response.status();
|
||||
log::debug!("[JellyfinClient] Response status for {}: {}", endpoint, status);
|
||||
log::debug!(
|
||||
"[JellyfinClient] Response status for {}: {}",
|
||||
endpoint,
|
||||
status
|
||||
);
|
||||
|
||||
if !status.is_success() {
|
||||
let error_text: String = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
let error_text: String = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
log::error!("[JellyfinClient] Request failed: {} {}", status, endpoint);
|
||||
log::error!("[JellyfinClient] Response: {}", error_text);
|
||||
return Err(format!("Jellyfin API error {}: {}", status.as_u16(), error_text));
|
||||
return Err(format!(
|
||||
"Jellyfin API error {}: {}",
|
||||
status.as_u16(),
|
||||
error_text
|
||||
));
|
||||
}
|
||||
|
||||
log::debug!("[JellyfinClient] Request successful for {}", endpoint);
|
||||
@@ -193,7 +224,7 @@ impl JellyfinClient {
|
||||
}
|
||||
|
||||
/// Report playback progress to Jellyfin
|
||||
#[allow(dead_code)] // Will be used when playback_reporting is integrated
|
||||
#[allow(dead_code)] // Will be used when playback_reporting is integrated
|
||||
pub async fn report_playback_progress(
|
||||
&self,
|
||||
item_id: String,
|
||||
@@ -220,9 +251,17 @@ impl JellyfinClient {
|
||||
start_position_ticks: Option<i64>,
|
||||
) -> Result<(), String> {
|
||||
log::info!("[JellyfinClient] Playing on session: {}", session_id);
|
||||
log::info!("[JellyfinClient] Item IDs: {:?}, Start index: {}", item_ids, start_index);
|
||||
debug!("[JellyfinClient] play_on_session called: session={}, {} items, start_index={}",
|
||||
session_id, item_ids.len(), start_index);
|
||||
log::info!(
|
||||
"[JellyfinClient] Item IDs: {:?}, Start index: {}",
|
||||
item_ids,
|
||||
start_index
|
||||
);
|
||||
debug!(
|
||||
"[JellyfinClient] play_on_session called: session={}, {} items, start_index={}",
|
||||
session_id,
|
||||
item_ids.len(),
|
||||
start_index
|
||||
);
|
||||
|
||||
// Build URL with query parameters (Jellyfin expects PascalCase query params)
|
||||
let mut url = format!(
|
||||
@@ -244,10 +283,15 @@ impl JellyfinClient {
|
||||
log::info!("[JellyfinClient] POST {}", url);
|
||||
debug!("[JellyfinClient] Full URL length: {} chars", url.len());
|
||||
// Don't log full URL as it may contain sensitive tokens, just log the endpoint
|
||||
debug!("[JellyfinClient] POST to Sessions/{}/Playing with {} itemIds", session_id, item_ids.len());
|
||||
debug!(
|
||||
"[JellyfinClient] POST to Sessions/{}/Playing with {} itemIds",
|
||||
session_id,
|
||||
item_ids.len()
|
||||
);
|
||||
|
||||
debug!("[JellyfinClient] Sending HTTP POST request...");
|
||||
let response = self.http_client
|
||||
let response = self
|
||||
.http_client
|
||||
.post(&url)
|
||||
.header("X-Emby-Authorization", self.get_auth_header())
|
||||
.send()
|
||||
@@ -263,10 +307,21 @@ impl JellyfinClient {
|
||||
debug!("[JellyfinClient] Response status: {}", status);
|
||||
|
||||
if !status.is_success() {
|
||||
let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
let error_text = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
log::error!("[JellyfinClient] Request failed: {}", error_text);
|
||||
error!("[JellyfinClient] API error {}: {}", status.as_u16(), error_text);
|
||||
return Err(format!("Jellyfin API error {}: {}", status.as_u16(), error_text));
|
||||
error!(
|
||||
"[JellyfinClient] API error {}: {}",
|
||||
status.as_u16(),
|
||||
error_text
|
||||
);
|
||||
return Err(format!(
|
||||
"Jellyfin API error {}: {}",
|
||||
status.as_u16(),
|
||||
error_text
|
||||
));
|
||||
}
|
||||
|
||||
log::info!("[JellyfinClient] Successfully sent play command to remote session");
|
||||
@@ -280,7 +335,11 @@ impl JellyfinClient {
|
||||
session_id: String,
|
||||
command: &str,
|
||||
) -> Result<(), String> {
|
||||
self.post(&format!("/Sessions/{}/Playing/{}", session_id, command), &serde_json::json!({})).await
|
||||
self.post(
|
||||
&format!("/Sessions/{}/Playing/{}", session_id, command),
|
||||
&serde_json::json!({}),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Seek on a remote session
|
||||
@@ -298,7 +357,8 @@ impl JellyfinClient {
|
||||
self.config.server_url, session_id, position_ticks
|
||||
);
|
||||
|
||||
let response = self.http_client
|
||||
let response = self
|
||||
.http_client
|
||||
.post(&url)
|
||||
.header("X-Emby-Authorization", self.get_auth_header())
|
||||
.send()
|
||||
@@ -307,11 +367,22 @@ impl JellyfinClient {
|
||||
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
return Err(format!("Jellyfin API error {}: {}", status.as_u16(), error_text));
|
||||
let error_text = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
return Err(format!(
|
||||
"Jellyfin API error {}: {}",
|
||||
status.as_u16(),
|
||||
error_text
|
||||
));
|
||||
}
|
||||
|
||||
log::info!("[JellyfinClient] Seek to {} ticks on session {}", position_ticks, session_id);
|
||||
log::info!(
|
||||
"[JellyfinClient] Seek to {} ticks on session {}",
|
||||
position_ticks,
|
||||
session_id
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -332,46 +403,42 @@ impl JellyfinClient {
|
||||
payload["Arguments"] = args;
|
||||
}
|
||||
|
||||
log::info!("[JellyfinClient] Sending GeneralCommand '{}' to session {} with payload: {}",
|
||||
command_name, session_id, serde_json::to_string(&payload).unwrap_or_default());
|
||||
log::info!(
|
||||
"[JellyfinClient] Sending GeneralCommand '{}' to session {} with payload: {}",
|
||||
command_name,
|
||||
session_id,
|
||||
serde_json::to_string(&payload).unwrap_or_default()
|
||||
);
|
||||
|
||||
self.post(
|
||||
&format!("/Sessions/{}/Command", session_id),
|
||||
&payload
|
||||
).await
|
||||
self.post(&format!("/Sessions/{}/Command", session_id), &payload)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Set volume on a remote session
|
||||
pub async fn session_set_volume(
|
||||
&self,
|
||||
session_id: String,
|
||||
volume: i32,
|
||||
) -> Result<(), String> {
|
||||
pub async fn session_set_volume(&self, session_id: String, volume: i32) -> Result<(), String> {
|
||||
self.send_general_command(
|
||||
&session_id,
|
||||
"SetVolume",
|
||||
Some(serde_json::json!({ "Volume": volume.to_string() })),
|
||||
).await
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Toggle mute on a remote session
|
||||
pub async fn session_toggle_mute(
|
||||
&self,
|
||||
session_id: String,
|
||||
) -> Result<(), String> {
|
||||
pub async fn session_toggle_mute(&self, session_id: String) -> Result<(), String> {
|
||||
log::info!("[JellyfinClient] Toggling mute on session {}", session_id);
|
||||
|
||||
self.send_general_command(
|
||||
&session_id,
|
||||
"ToggleMute",
|
||||
None,
|
||||
).await
|
||||
self.send_general_command(&session_id, "ToggleMute", None)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Get all active sessions
|
||||
pub async fn get_sessions(&self) -> Result<Vec<SessionInfo>, String> {
|
||||
let sessions: Vec<SessionInfo> = self.get("/Sessions").await?;
|
||||
info!("[JellyfinClient] Fetched {} sessions from API", sessions.len());
|
||||
info!(
|
||||
"[JellyfinClient] Fetched {} sessions from API",
|
||||
sessions.len()
|
||||
);
|
||||
for session in &sessions {
|
||||
debug!("[JellyfinClient] Session: id={:?}, device={:?}, client={:?}, supportsRemoteControl={}",
|
||||
session.id, session.device_name, session.client, session.supports_remote_control);
|
||||
@@ -382,7 +449,9 @@ impl JellyfinClient {
|
||||
/// Get a specific session by ID
|
||||
pub async fn get_session(&self, session_id: &str) -> Result<Option<SessionInfo>, String> {
|
||||
let sessions = self.get_sessions().await?;
|
||||
Ok(sessions.into_iter().find(|s| s.id.as_deref() == Some(session_id)))
|
||||
Ok(sessions
|
||||
.into_iter()
|
||||
.find(|s| s.id.as_deref() == Some(session_id)))
|
||||
}
|
||||
|
||||
// --- JellyLMS multi-room sync groups -----------------------------------
|
||||
@@ -415,12 +484,14 @@ impl JellyfinClient {
|
||||
|
||||
/// Remove a single LMS player from whatever sync group it's in.
|
||||
pub async fn lms_unsync_player(&self, mac: &str) -> Result<(), String> {
|
||||
self.delete(&format!("/JellyLms/SyncGroups/Players/{}", mac)).await
|
||||
self.delete(&format!("/JellyLms/SyncGroups/Players/{}", mac))
|
||||
.await
|
||||
}
|
||||
|
||||
/// Dissolve an entire LMS sync group, identified by its master's MAC.
|
||||
pub async fn lms_dissolve_sync_group(&self, master_mac: &str) -> Result<(), String> {
|
||||
self.delete(&format!("/JellyLms/SyncGroups/{}", master_mac)).await
|
||||
self.delete(&format!("/JellyLms/SyncGroups/{}", master_mac))
|
||||
.await
|
||||
}
|
||||
|
||||
/// Make a DELETE request to the Jellyfin API (used by the JellyLMS endpoints).
|
||||
@@ -429,7 +500,8 @@ impl JellyfinClient {
|
||||
|
||||
log::debug!("[JellyfinClient] DELETE {}", endpoint);
|
||||
|
||||
let response = self.http_client
|
||||
let response = self
|
||||
.http_client
|
||||
.delete(&url)
|
||||
.header("X-Emby-Authorization", self.get_auth_header())
|
||||
.send()
|
||||
@@ -438,8 +510,15 @@ impl JellyfinClient {
|
||||
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
return Err(format!("Jellyfin API error {}: {}", status.as_u16(), error_text));
|
||||
let error_text = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
return Err(format!(
|
||||
"Jellyfin API error {}: {}",
|
||||
status.as_u16(),
|
||||
error_text
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -570,7 +649,6 @@ pub struct PlayState {
|
||||
pub shuffle_mode: Option<String>,
|
||||
}
|
||||
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -40,7 +40,7 @@ pub enum ErrorKind {
|
||||
/// Enhanced HTTP client with retry logic and error classification
|
||||
#[derive(Clone)]
|
||||
pub struct HttpClient {
|
||||
pub(crate) client: Client, // Make accessible within crate for custom requests
|
||||
pub(crate) client: Client, // Make accessible within crate for custom requests
|
||||
config: HttpConfig,
|
||||
}
|
||||
|
||||
@@ -131,18 +131,15 @@ impl HttpClient {
|
||||
/// Check if a request should be retried based on the error
|
||||
pub fn should_retry(error: &reqwest::Error) -> bool {
|
||||
match Self::classify_error(error) {
|
||||
ErrorKind::Network => true, // Retry network errors
|
||||
ErrorKind::Server => true, // Retry 5xx server errors
|
||||
ErrorKind::Network => true, // Retry network errors
|
||||
ErrorKind::Server => true, // Retry 5xx server errors
|
||||
ErrorKind::Authentication => false, // Don't retry 401/403
|
||||
ErrorKind::Client => false, // Don't retry other 4xx errors
|
||||
ErrorKind::Client => false, // Don't retry other 4xx errors
|
||||
}
|
||||
}
|
||||
|
||||
/// Make a request with automatic retry on network errors
|
||||
pub async fn request_with_retry(
|
||||
&self,
|
||||
request: Request,
|
||||
) -> Result<Response, reqwest::Error> {
|
||||
pub async fn request_with_retry(&self, request: Request) -> Result<Response, reqwest::Error> {
|
||||
let max_retries = self.config.max_retries;
|
||||
let mut last_error: Option<reqwest::Error> = None;
|
||||
|
||||
@@ -192,31 +189,6 @@ impl HttpClient {
|
||||
Err(last_error.unwrap())
|
||||
}
|
||||
|
||||
/// Make a GET request with retry
|
||||
pub async fn get_with_retry(&self, url: &str) -> Result<Response, reqwest::Error> {
|
||||
let request = self.client.get(url).build()?;
|
||||
self.request_with_retry(request).await
|
||||
}
|
||||
|
||||
/// Make a GET request and deserialize JSON response with retry
|
||||
pub async fn get_json_with_retry<T: DeserializeOwned>(
|
||||
&self,
|
||||
url: &str,
|
||||
) -> Result<T, String> {
|
||||
let response = self.get_with_retry(url).await
|
||||
.map_err(|e| format!("Request failed: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let error_text = response.text().await
|
||||
.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
return Err(format!("HTTP {}: {}", status, error_text));
|
||||
}
|
||||
|
||||
response.json::<T>().await
|
||||
.map_err(|e| format!("Failed to parse JSON: {}", e))
|
||||
}
|
||||
|
||||
/// Make a GET request and deserialize JSON with a short timeout and no retries.
|
||||
///
|
||||
/// Intended for the initial "connect to server" probe on the login screen:
|
||||
@@ -258,17 +230,17 @@ impl HttpClient {
|
||||
|
||||
/// Quick ping to check if a server is reachable (no retry)
|
||||
pub async fn ping(&self, url: &str) -> bool {
|
||||
let request = self.client.get(url)
|
||||
let request = self
|
||||
.client
|
||||
.get(url)
|
||||
.timeout(Duration::from_secs(5)) // Shorter timeout for ping
|
||||
.build();
|
||||
|
||||
match request {
|
||||
Ok(req) => {
|
||||
match self.client.execute(req).await {
|
||||
Ok(response) => response.status().is_success(),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
Ok(req) => match self.client.execute(req).await {
|
||||
Ok(response) => response.status().is_success(),
|
||||
Err(_) => false,
|
||||
},
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ pub struct PlaybackStoppedRequest {
|
||||
/// Request body for reporting playback progress
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
#[allow(dead_code)] // Will be used when playback_reporting is integrated
|
||||
#[allow(dead_code)] // Will be used when playback_reporting is integrated
|
||||
pub struct PlaybackProgressRequest {
|
||||
pub item_id: String,
|
||||
pub position_ticks: i64,
|
||||
|
||||
+314
-117
@@ -14,120 +14,285 @@ mod storage;
|
||||
mod thumbnail;
|
||||
pub mod utils;
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::sync::Mutex as TokioMutex;
|
||||
use tauri::{Emitter, Manager};
|
||||
use tauri_specta::Builder;
|
||||
use log::{error, info};
|
||||
#[cfg(target_os = "android")]
|
||||
use log::warn;
|
||||
use log::{error, info};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tauri::{Emitter, Manager};
|
||||
use tauri_specta::Builder;
|
||||
use tokio::sync::Mutex as TokioMutex;
|
||||
|
||||
use commands::{
|
||||
sync_full_catalog, catalog_sync_status, set_show_server_catalog, resume_queued_downloads,
|
||||
cancel_download, clear_stale_downloads, delete_album_downloads, delete_all_downloads, delete_download,
|
||||
download_album, download_item, download_item_and_start, download_video, download_series, download_season,
|
||||
get_download_storage_stats, get_downloads, get_download_manager_stats, set_max_concurrent_downloads,
|
||||
get_smart_cache_stats, update_smart_cache_config, get_smart_cache_config, get_album_recommendations,
|
||||
get_album_affinity_status,
|
||||
mark_download_completed, mark_download_failed, start_download, enqueue_download, enqueue_video_downloads,
|
||||
pin_item, unpin_item, is_item_pinned,
|
||||
offline_get_items, offline_is_available, offline_search, pause_download, resume_download,
|
||||
player_cycle_repeat, player_get_audio_settings, player_get_queue, player_get_status,
|
||||
player_get_video_settings, player_next, player_pause, player_play, player_play_album_track,
|
||||
player_play_item, player_play_queue, player_play_tracks, player_previous, player_seek, player_seek_video, player_set_audio_settings, player_set_audio_track, player_switch_audio_track,
|
||||
player_set_subtitle_track, player_set_video_settings, player_set_volume, player_toggle_mute, player_stop, player_toggle,
|
||||
player_toggle_shuffle,
|
||||
// Sleep timer and autoplay commands
|
||||
player_set_sleep_timer, player_cancel_sleep_timer, player_get_sleep_timer,
|
||||
player_get_autoplay_settings, player_set_autoplay_settings,
|
||||
player_cancel_autoplay_countdown, player_play_next_episode, player_on_playback_ended,
|
||||
// HTML5 video state-report commands
|
||||
player_report_state, player_report_position, player_report_media_loaded,
|
||||
// Queue manipulation commands
|
||||
player_add_to_queue, player_add_track_by_id, player_add_tracks_by_ids,
|
||||
player_remove_from_queue, player_move_in_queue, player_skip_to,
|
||||
// Preload commands
|
||||
player_preload_upcoming, player_set_cache_config, player_get_cache_config,
|
||||
// Jellyfin reporting commands
|
||||
player_configure_jellyfin, player_disable_jellyfin,
|
||||
// Session management commands
|
||||
player_get_session, player_dismiss_session,
|
||||
// Remote session control commands
|
||||
remote_play_on_session, remote_send_command, remote_session_seek, remote_session_set_volume,
|
||||
remote_session_toggle_mute,
|
||||
// LMS multi-room sync group commands
|
||||
lms_get_sync_groups, lms_create_sync_group, lms_unsync_player, lms_dissolve_sync_group,
|
||||
// Session polling commands
|
||||
sessions_set_polling_hint, sessions_poll_now, SessionPollerWrapper,
|
||||
// Playback mode commands
|
||||
playback_mode_get_current, playback_mode_set, playback_mode_is_transferring,
|
||||
playback_mode_transfer_to_remote, playback_mode_transfer_to_local, playback_mode_set_transferring,
|
||||
playback_mode_get_remote_status,
|
||||
// Playback reporting commands
|
||||
playback_reporter_init, playback_reporter_destroy,
|
||||
playback_report_start, playback_report_progress, playback_report_stopped,
|
||||
playback_mark_played, PlaybackReporterWrapper,
|
||||
// Auth commands
|
||||
auth_initialize, auth_connect_to_server, auth_login, auth_verify_session,
|
||||
auth_logout, auth_get_session, auth_set_session, auth_start_verification,
|
||||
auth_stop_verification, auth_reauthenticate,
|
||||
// Device commands
|
||||
device_get_id, device_set_id,
|
||||
// Connectivity commands
|
||||
connectivity_check_server, connectivity_set_server_url, connectivity_get_status,
|
||||
connectivity_start_monitoring, connectivity_stop_monitoring,
|
||||
connectivity_mark_reachable, connectivity_mark_unreachable,
|
||||
// Storage commands
|
||||
storage_delete_server, storage_delete_user, storage_get_access_token,
|
||||
storage_get_active_session, storage_get_active_user, storage_get_path,
|
||||
storage_get_playback_progress, storage_get_security_status, storage_get_servers, storage_get_size,
|
||||
storage_get_users, storage_init, storage_mark_played, storage_mark_synced, storage_save_server,
|
||||
storage_save_user, storage_set_active_user, storage_toggle_favorite, storage_update_playback_progress,
|
||||
storage_update_playback_context,
|
||||
// Offline cache commands
|
||||
storage_get_libraries, storage_get_items, storage_get_item, storage_search_items,
|
||||
storage_save_library, storage_save_item, storage_get_pending_sync_count,
|
||||
// Sync queue commands
|
||||
sync_queue_mutation, sync_get_pending, sync_mark_processing, sync_mark_completed,
|
||||
sync_mark_failed, sync_get_pending_count, sync_cleanup_completed, sync_clear_user,
|
||||
// Thumbnail cache and image commands
|
||||
thumbnail_get_cached, thumbnail_save, thumbnail_get_stats, thumbnail_set_limit,
|
||||
thumbnail_clear_cache, thumbnail_delete_item, image_get_url,
|
||||
// People cache commands
|
||||
storage_save_person, storage_get_person, storage_save_item_people, storage_get_item_people,
|
||||
// Series audio preferences
|
||||
storage_save_series_audio_preference, storage_get_series_audio_preference,
|
||||
// Repository commands
|
||||
repository_create, repository_destroy, repository_get_libraries, repository_get_items,
|
||||
repository_get_item, repository_jray_actors_at, repository_get_latest_items, repository_get_resume_items,
|
||||
repository_get_next_up_episodes, repository_get_recently_played_audio, repository_get_resume_movies,
|
||||
repository_get_rediscover_albums,
|
||||
repository_get_genres, repository_search, repository_get_playback_info,
|
||||
repository_get_video_stream_url, repository_get_audio_stream_url,
|
||||
repository_get_live_tv_channels, repository_get_channels, repository_open_live_stream,
|
||||
repository_report_playback_start, repository_report_playback_progress, repository_report_playback_stopped,
|
||||
repository_get_image_url, repository_mark_favorite, repository_unmark_favorite,
|
||||
repository_get_person, repository_get_items_by_person, repository_get_similar_items,
|
||||
repository_get_subtitle_url, repository_get_video_download_url,
|
||||
// Playlist commands
|
||||
playlist_create, playlist_delete, playlist_rename, playlist_get_items,
|
||||
playlist_add_items, playlist_remove_items, playlist_move_item,
|
||||
// Conversion commands
|
||||
format_time_seconds, format_time_seconds_long, convert_ticks_to_seconds,
|
||||
calc_progress, convert_percent_to_volume,
|
||||
AuthManagerWrapper, SessionVerifierWrapper,
|
||||
ConnectivityMonitorWrapper, CredentialStoreWrapper, DatabaseWrapper, PlayerStateWrapper,
|
||||
MediaSessionManagerWrapper, VideoSettingsWrapper, ThumbnailCacheWrapper, SmartCacheWrapper,
|
||||
PlaybackModeManagerWrapper, RepositoryManagerWrapper, DownloadManagerWrapper,
|
||||
};
|
||||
#[cfg(target_os = "android")]
|
||||
use playback_mode::PlaybackModeManager;
|
||||
use auth::AuthManager;
|
||||
use commands::{
|
||||
auth_connect_to_server,
|
||||
auth_get_session,
|
||||
// Auth commands
|
||||
auth_initialize,
|
||||
auth_login,
|
||||
auth_logout,
|
||||
auth_reauthenticate,
|
||||
auth_set_session,
|
||||
auth_start_verification,
|
||||
auth_stop_verification,
|
||||
auth_verify_session,
|
||||
calc_progress,
|
||||
cancel_download,
|
||||
catalog_sync_status,
|
||||
clear_stale_downloads,
|
||||
// Connectivity commands
|
||||
connectivity_check_server,
|
||||
connectivity_get_status,
|
||||
connectivity_mark_reachable,
|
||||
connectivity_mark_unreachable,
|
||||
connectivity_set_server_url,
|
||||
connectivity_start_monitoring,
|
||||
connectivity_stop_monitoring,
|
||||
convert_percent_to_volume,
|
||||
convert_ticks_to_seconds,
|
||||
delete_album_downloads,
|
||||
delete_all_downloads,
|
||||
delete_download,
|
||||
// Device commands
|
||||
device_get_id,
|
||||
device_set_id,
|
||||
download_album,
|
||||
download_item,
|
||||
download_item_and_start,
|
||||
download_season,
|
||||
download_series,
|
||||
download_video,
|
||||
enqueue_download,
|
||||
enqueue_video_downloads,
|
||||
// Conversion commands
|
||||
format_time_seconds,
|
||||
format_time_seconds_long,
|
||||
get_album_affinity_status,
|
||||
get_album_recommendations,
|
||||
get_download_manager_stats,
|
||||
get_download_storage_stats,
|
||||
get_downloads,
|
||||
get_smart_cache_config,
|
||||
get_smart_cache_stats,
|
||||
image_get_url,
|
||||
is_item_pinned,
|
||||
lms_create_sync_group,
|
||||
lms_dissolve_sync_group,
|
||||
// LMS multi-room sync group commands
|
||||
lms_get_sync_groups,
|
||||
lms_unsync_player,
|
||||
mark_download_completed,
|
||||
mark_download_failed,
|
||||
offline_get_items,
|
||||
offline_is_available,
|
||||
offline_search,
|
||||
pause_download,
|
||||
pin_item,
|
||||
playback_mark_played,
|
||||
// Playback mode commands
|
||||
playback_mode_get_current,
|
||||
playback_mode_get_remote_status,
|
||||
playback_mode_is_transferring,
|
||||
playback_mode_set,
|
||||
playback_mode_set_transferring,
|
||||
playback_mode_transfer_to_local,
|
||||
playback_mode_transfer_to_remote,
|
||||
playback_report_progress,
|
||||
playback_report_start,
|
||||
playback_report_stopped,
|
||||
playback_reporter_destroy,
|
||||
// Playback reporting commands
|
||||
playback_reporter_init,
|
||||
// Queue manipulation commands
|
||||
player_add_to_queue,
|
||||
player_add_track_by_id,
|
||||
player_add_tracks_by_ids,
|
||||
player_cancel_autoplay_countdown,
|
||||
player_cancel_sleep_timer,
|
||||
// Jellyfin reporting commands
|
||||
player_configure_jellyfin,
|
||||
player_cycle_repeat,
|
||||
player_disable_jellyfin,
|
||||
player_dismiss_session,
|
||||
player_enter_background_audio,
|
||||
player_exit_background_audio,
|
||||
player_get_audio_settings,
|
||||
player_get_autoplay_settings,
|
||||
player_get_cache_config,
|
||||
player_get_queue,
|
||||
// Session management commands
|
||||
player_get_session,
|
||||
player_get_sleep_timer,
|
||||
player_get_status,
|
||||
player_get_video_settings,
|
||||
player_move_in_queue,
|
||||
player_next,
|
||||
player_on_playback_ended,
|
||||
player_pause,
|
||||
player_play,
|
||||
player_play_album_track,
|
||||
player_play_item,
|
||||
player_play_next_episode,
|
||||
player_play_queue,
|
||||
player_play_tracks,
|
||||
// Preload commands
|
||||
player_preload_upcoming,
|
||||
player_previous,
|
||||
player_remove_from_queue,
|
||||
player_report_media_loaded,
|
||||
player_report_position,
|
||||
// HTML5 video state-report commands
|
||||
player_report_state,
|
||||
player_seek,
|
||||
player_seek_video,
|
||||
player_set_audio_settings,
|
||||
player_set_audio_track,
|
||||
player_set_autoplay_settings,
|
||||
player_set_cache_config,
|
||||
// Sleep timer and autoplay commands
|
||||
player_set_sleep_timer,
|
||||
player_set_subtitle_track,
|
||||
player_set_video_settings,
|
||||
player_set_volume,
|
||||
player_skip_to,
|
||||
player_stop,
|
||||
player_switch_audio_track,
|
||||
player_toggle,
|
||||
player_toggle_mute,
|
||||
player_toggle_shuffle,
|
||||
playlist_add_items,
|
||||
// Playlist commands
|
||||
playlist_create,
|
||||
playlist_delete,
|
||||
playlist_get_items,
|
||||
playlist_move_item,
|
||||
playlist_remove_items,
|
||||
playlist_rename,
|
||||
// Remote session control commands
|
||||
remote_play_on_session,
|
||||
remote_send_command,
|
||||
remote_session_seek,
|
||||
remote_session_set_volume,
|
||||
remote_session_toggle_mute,
|
||||
// Repository commands
|
||||
repository_create,
|
||||
repository_destroy,
|
||||
repository_get_audio_only_stream_url_for_video,
|
||||
repository_get_audio_stream_url,
|
||||
repository_get_channels,
|
||||
repository_get_genres,
|
||||
repository_get_image_url,
|
||||
repository_get_item,
|
||||
repository_get_items,
|
||||
repository_get_items_by_person,
|
||||
repository_get_latest_items,
|
||||
repository_get_libraries,
|
||||
repository_get_live_tv_channels,
|
||||
repository_get_next_up_episodes,
|
||||
repository_get_person,
|
||||
repository_get_playback_info,
|
||||
repository_get_recently_played_audio,
|
||||
repository_get_rediscover_albums,
|
||||
repository_get_resume_items,
|
||||
repository_get_resume_movies,
|
||||
repository_get_similar_items,
|
||||
repository_get_subtitle_url,
|
||||
repository_get_video_download_url,
|
||||
repository_get_video_stream_url,
|
||||
repository_jray_actors_at,
|
||||
repository_mark_favorite,
|
||||
repository_open_live_stream,
|
||||
repository_report_playback_progress,
|
||||
repository_report_playback_start,
|
||||
repository_report_playback_stopped,
|
||||
repository_search,
|
||||
repository_unmark_favorite,
|
||||
resume_download,
|
||||
resume_queued_downloads,
|
||||
sessions_poll_now,
|
||||
// Session polling commands
|
||||
sessions_set_polling_hint,
|
||||
set_max_concurrent_downloads,
|
||||
set_show_server_catalog,
|
||||
start_download,
|
||||
// Storage commands
|
||||
storage_delete_server,
|
||||
storage_delete_user,
|
||||
storage_get_access_token,
|
||||
storage_get_active_session,
|
||||
storage_get_active_user,
|
||||
storage_get_item,
|
||||
storage_get_item_people,
|
||||
storage_get_items,
|
||||
// Offline cache commands
|
||||
storage_get_libraries,
|
||||
storage_get_path,
|
||||
storage_get_pending_sync_count,
|
||||
storage_get_person,
|
||||
storage_get_playback_progress,
|
||||
storage_get_security_status,
|
||||
storage_get_series_audio_preference,
|
||||
storage_get_servers,
|
||||
storage_get_size,
|
||||
storage_get_users,
|
||||
storage_init,
|
||||
storage_mark_played,
|
||||
storage_mark_synced,
|
||||
storage_save_item,
|
||||
storage_save_item_people,
|
||||
storage_save_library,
|
||||
// People cache commands
|
||||
storage_save_person,
|
||||
// Series audio preferences
|
||||
storage_save_series_audio_preference,
|
||||
storage_save_server,
|
||||
storage_save_user,
|
||||
storage_search_items,
|
||||
storage_set_active_user,
|
||||
storage_toggle_favorite,
|
||||
storage_update_playback_context,
|
||||
storage_update_playback_progress,
|
||||
sync_cleanup_completed,
|
||||
sync_clear_user,
|
||||
sync_full_catalog,
|
||||
sync_get_pending,
|
||||
sync_get_pending_count,
|
||||
sync_mark_completed,
|
||||
sync_mark_failed,
|
||||
sync_mark_processing,
|
||||
// Sync queue commands
|
||||
sync_queue_mutation,
|
||||
thumbnail_clear_cache,
|
||||
thumbnail_delete_item,
|
||||
// Thumbnail cache and image commands
|
||||
thumbnail_get_cached,
|
||||
thumbnail_get_stats,
|
||||
thumbnail_save,
|
||||
thumbnail_set_limit,
|
||||
unpin_item,
|
||||
update_smart_cache_config,
|
||||
AuthManagerWrapper,
|
||||
ConnectivityMonitorWrapper,
|
||||
CredentialStoreWrapper,
|
||||
DatabaseWrapper,
|
||||
DownloadManagerWrapper,
|
||||
MediaSessionManagerWrapper,
|
||||
PlaybackModeManagerWrapper,
|
||||
PlaybackReporterWrapper,
|
||||
PlayerStateWrapper,
|
||||
RepositoryManagerWrapper,
|
||||
SessionPollerWrapper,
|
||||
SessionVerifierWrapper,
|
||||
SmartCacheWrapper,
|
||||
ThumbnailCacheWrapper,
|
||||
VideoSettingsWrapper,
|
||||
};
|
||||
use connectivity::ConnectivityMonitor;
|
||||
use credentials::CredentialStore;
|
||||
use download::cache::{CacheConfig as SmartCacheConfig, SmartCache};
|
||||
use download::DownloadManager;
|
||||
use jellyfin::{HttpClient, HttpConfig};
|
||||
#[cfg(target_os = "android")]
|
||||
use playback_mode::PlaybackModeManager;
|
||||
use player::{MediaSessionManager, PlayerBackend, PlayerController, TauriEventEmitter};
|
||||
// NullBackend is used both for platforms without a native backend AND as a graceful
|
||||
// fallback when a native backend (MPV/ExoPlayer) fails to initialize, so the app can
|
||||
@@ -138,7 +303,7 @@ use player::NullBackend;
|
||||
use player::MpvBackend;
|
||||
use settings::VideoSettings;
|
||||
use storage::Database;
|
||||
use thumbnail::{ThumbnailCache, CacheConfig as ThumbnailCacheConfig};
|
||||
use thumbnail::{CacheConfig as ThumbnailCacheConfig, ThumbnailCache};
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
use credentials::initialize_secure_storage;
|
||||
@@ -147,7 +312,9 @@ use credentials::initialize_secure_storage;
|
||||
use player::ExoPlayerBackend;
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
use player::{MediaCommandHandler, RemoteVolumeHandler, set_media_command_handler, set_remote_volume_handler};
|
||||
use player::{
|
||||
set_media_command_handler, set_remote_volume_handler, MediaCommandHandler, RemoteVolumeHandler,
|
||||
};
|
||||
|
||||
/// Handler for media commands from Android MediaSession (lockscreen/notification controls).
|
||||
///
|
||||
@@ -210,7 +377,11 @@ impl MediaSessionHandler {
|
||||
"play" => client.send_session_command(session_id, "Unpause").await,
|
||||
"pause" => client.send_session_command(session_id, "Pause").await,
|
||||
"next" => client.send_session_command(session_id, "NextTrack").await,
|
||||
"previous" => client.send_session_command(session_id, "PreviousTrack").await,
|
||||
"previous" => {
|
||||
client
|
||||
.send_session_command(session_id, "PreviousTrack")
|
||||
.await
|
||||
}
|
||||
cmd if cmd.starts_with("seek:") => match cmd[5..].parse::<f64>() {
|
||||
Ok(seconds) => {
|
||||
let ticks = (seconds * 10_000_000.0) as i64;
|
||||
@@ -297,7 +468,10 @@ impl RemoteVolumeHandler for RemoteVolumeSessionHandler {
|
||||
log::info!("[RemoteVolume] Spawning async task to send volume command...");
|
||||
tauri::async_runtime::spawn(async move {
|
||||
log::info!("[RemoteVolume] Async task started, calling send_remote_volume_command...");
|
||||
match playback_mode.send_remote_volume_command(&command_str, volume).await {
|
||||
match playback_mode
|
||||
.send_remote_volume_command(&command_str, volume)
|
||||
.await
|
||||
{
|
||||
Ok(_) => log::info!("[RemoteVolume] Volume command completed successfully"),
|
||||
Err(e) => log::error!("[RemoteVolume] Failed to send volume command: {}", e),
|
||||
}
|
||||
@@ -355,9 +529,16 @@ fn create_player_backend(
|
||||
Ok(java_vm) => {
|
||||
match java_vm.attach_current_thread() {
|
||||
Ok(mut env) => {
|
||||
let context_obj = unsafe { jni::objects::JObject::from_raw(ctx.context().cast()) };
|
||||
let context_obj =
|
||||
unsafe { jni::objects::JObject::from_raw(ctx.context().cast()) };
|
||||
|
||||
match ExoPlayerBackend::new(&mut env, &context_obj, _event_emitter.clone(), playback_reporter.clone(), position_throttler.clone()) {
|
||||
match ExoPlayerBackend::new(
|
||||
&mut env,
|
||||
&context_obj,
|
||||
_event_emitter.clone(),
|
||||
playback_reporter.clone(),
|
||||
position_throttler.clone(),
|
||||
) {
|
||||
Ok(backend) => {
|
||||
info!("Successfully initialized ExoPlayer backend for Android");
|
||||
return Box::new(backend);
|
||||
@@ -370,13 +551,21 @@ fn create_player_backend(
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
emit_backend_init_failed(&app_handle, "exoplayer", format!("attach JNI thread failed: {}", e));
|
||||
emit_backend_init_failed(
|
||||
&app_handle,
|
||||
"exoplayer",
|
||||
format!("attach JNI thread failed: {}", e),
|
||||
);
|
||||
return Box::new(NullBackend::new());
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
emit_backend_init_failed(&app_handle, "exoplayer", format!("create JavaVM failed: {}", e));
|
||||
emit_backend_init_failed(
|
||||
&app_handle,
|
||||
"exoplayer",
|
||||
format!("create JavaVM failed: {}", e),
|
||||
);
|
||||
return Box::new(NullBackend::new());
|
||||
}
|
||||
}
|
||||
@@ -440,6 +629,8 @@ fn specta_builder() -> Builder<tauri::Wry> {
|
||||
.commands(tauri_specta::collect_commands![
|
||||
// Player commands
|
||||
player_play_item,
|
||||
player_enter_background_audio,
|
||||
player_exit_background_audio,
|
||||
player_play_queue,
|
||||
player_play_album_track,
|
||||
player_play_tracks,
|
||||
@@ -656,6 +847,7 @@ fn specta_builder() -> Builder<tauri::Wry> {
|
||||
repository_get_playback_info,
|
||||
repository_get_video_stream_url,
|
||||
repository_get_audio_stream_url,
|
||||
repository_get_audio_only_stream_url_for_video,
|
||||
repository_get_live_tv_channels,
|
||||
repository_get_channels,
|
||||
repository_open_live_stream,
|
||||
@@ -722,7 +914,12 @@ fn enable_linux_hardware_video_decoding() {
|
||||
#[cfg(target_os = "linux")]
|
||||
fn log_available_vaapi_decoders() {
|
||||
const HW_DECODERS: &[&str] = &[
|
||||
"vah264dec", "vah265dec", "vavp9dec", "vaav1dec", "vampeg2dec", "vavp8dec",
|
||||
"vah264dec",
|
||||
"vah265dec",
|
||||
"vavp9dec",
|
||||
"vaav1dec",
|
||||
"vampeg2dec",
|
||||
"vavp8dec",
|
||||
];
|
||||
|
||||
let available: Vec<&str> = HW_DECODERS
|
||||
@@ -1055,7 +1252,6 @@ pub fn run() {
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
|
||||
|
||||
#[cfg(test)]
|
||||
mod specta_bindings {
|
||||
/// Generates `src/lib/api/bindings.ts`. Run with `cargo test export_typescript_bindings`.
|
||||
@@ -1063,9 +1259,10 @@ mod specta_bindings {
|
||||
fn export_typescript_bindings() {
|
||||
super::specta_builder()
|
||||
.export(
|
||||
specta_typescript::Typescript::default().bigint(specta_typescript::BigIntExportBehavior::Number),
|
||||
specta_typescript::Typescript::default()
|
||||
.bigint(specta_typescript::BigIntExportBehavior::Number),
|
||||
"../src/lib/api/bindings.ts",
|
||||
)
|
||||
.expect("failed to export typescript bindings");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,7 +127,10 @@ impl PlaybackModeManager {
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
if let Err(e) = crate::player::enable_remote_volume(50) {
|
||||
log::warn!("[PlaybackMode] Failed to enable remote volume/service: {}", e);
|
||||
log::warn!(
|
||||
"[PlaybackMode] Failed to enable remote volume/service: {}",
|
||||
e
|
||||
);
|
||||
// Non-fatal - continue; the next poll tick will retry metadata.
|
||||
}
|
||||
}
|
||||
@@ -154,8 +157,16 @@ impl PlaybackModeManager {
|
||||
/// Send volume command to remote session
|
||||
/// Commands: "SetVolume", "VolumeUp", "VolumeDown"
|
||||
#[allow(dead_code)] // Called from Android JNI callback
|
||||
pub async fn send_remote_volume_command(&self, command: &str, volume: i32) -> Result<(), String> {
|
||||
log::info!("[PlaybackMode] send_remote_volume_command ENTERED: command={}, volume={}", command, volume);
|
||||
pub async fn send_remote_volume_command(
|
||||
&self,
|
||||
command: &str,
|
||||
volume: i32,
|
||||
) -> Result<(), String> {
|
||||
log::info!(
|
||||
"[PlaybackMode] send_remote_volume_command ENTERED: command={}, volume={}",
|
||||
command,
|
||||
volume
|
||||
);
|
||||
|
||||
// Get the current session ID
|
||||
let session_id = match self.get_mode() {
|
||||
@@ -166,18 +177,18 @@ impl PlaybackModeManager {
|
||||
}
|
||||
};
|
||||
|
||||
log::info!("[PlaybackMode] Current mode is Remote, session_id={}", session_id);
|
||||
log::info!(
|
||||
"[PlaybackMode] Current mode is Remote, session_id={}",
|
||||
session_id
|
||||
);
|
||||
|
||||
// Get Jellyfin client
|
||||
let client = {
|
||||
log::info!("[PlaybackMode] Attempting to lock Jellyfin client...");
|
||||
let client_opt = self
|
||||
.jellyfin_client
|
||||
.lock()
|
||||
.map_err(|e| {
|
||||
log::error!("[PlaybackMode] Failed to lock Jellyfin client: {}", e);
|
||||
format!("Failed to lock Jellyfin client: {}", e)
|
||||
})?;
|
||||
let client_opt = self.jellyfin_client.lock().map_err(|e| {
|
||||
log::error!("[PlaybackMode] Failed to lock Jellyfin client: {}", e);
|
||||
format!("Failed to lock Jellyfin client: {}", e)
|
||||
})?;
|
||||
|
||||
log::info!("[PlaybackMode] Jellyfin client lock acquired");
|
||||
|
||||
@@ -196,7 +207,12 @@ impl PlaybackModeManager {
|
||||
log::info!("[PlaybackMode] About to call client.session_set_volume...");
|
||||
|
||||
// Send the volume command
|
||||
log::info!("[PlaybackMode] Sending {} command to session {} (volume: {})", command, session_id, volume);
|
||||
log::info!(
|
||||
"[PlaybackMode] Sending {} command to session {} (volume: {})",
|
||||
command,
|
||||
session_id,
|
||||
volume
|
||||
);
|
||||
let result = client.session_set_volume(session_id, volume).await;
|
||||
|
||||
match &result {
|
||||
@@ -209,8 +225,11 @@ impl PlaybackModeManager {
|
||||
|
||||
/// Extract Jellyfin item IDs from queue items
|
||||
/// Returns (item_ids, adjusted_current_index)
|
||||
fn extract_jellyfin_ids(&self, items: &[crate::player::MediaItem], original_index: usize) -> Result<(Vec<String>, usize), String> {
|
||||
|
||||
fn extract_jellyfin_ids(
|
||||
&self,
|
||||
items: &[crate::player::MediaItem],
|
||||
original_index: usize,
|
||||
) -> Result<(Vec<String>, usize), String> {
|
||||
let mut jellyfin_ids: Vec<String> = Vec::new();
|
||||
let mut adjusted_index: Option<usize> = None;
|
||||
let mut jellyfin_item_count = 0;
|
||||
@@ -236,7 +255,9 @@ impl PlaybackModeManager {
|
||||
"[PlaybackMode] Currently playing item (index {}) does not have a Jellyfin ID",
|
||||
original_index
|
||||
);
|
||||
return Err("Cannot transfer: currently playing item is not from Jellyfin".to_string());
|
||||
return Err(
|
||||
"Cannot transfer: currently playing item is not from Jellyfin".to_string(),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -269,7 +290,9 @@ impl PlaybackModeManager {
|
||||
debug!("[PlaybackMode] Flag set, calling transfer_to_remote_inner");
|
||||
|
||||
// Perform the transfer
|
||||
let result = self.transfer_to_remote_inner(&session_id, position_override).await;
|
||||
let result = self
|
||||
.transfer_to_remote_inner(&session_id, position_override)
|
||||
.await;
|
||||
|
||||
// Clear transferring flag
|
||||
self.is_transferring.store(false, Ordering::Relaxed);
|
||||
@@ -283,7 +306,10 @@ impl PlaybackModeManager {
|
||||
position_override: Option<f64>,
|
||||
) -> Result<(), String> {
|
||||
log::info!("[PlaybackMode] transfer_to_remote_inner ENTERED");
|
||||
debug!("[PlaybackMode] transfer_to_remote_inner: session_id={}", session_id);
|
||||
debug!(
|
||||
"[PlaybackMode] transfer_to_remote_inner: session_id={}",
|
||||
session_id
|
||||
);
|
||||
|
||||
// If we're already controlling a remote session, that *old* session — not
|
||||
// the idle local player — is the source of truth for the current track and
|
||||
@@ -307,13 +333,26 @@ impl PlaybackModeManager {
|
||||
let original_index = queue.current_index().unwrap_or(0);
|
||||
let items = queue.items();
|
||||
|
||||
log::info!("[PlaybackMode] Queue has {} items, original_index={}", items.len(), original_index);
|
||||
debug!("[PlaybackMode] Queue has {} items, original_index={}", items.len(), original_index);
|
||||
log::info!(
|
||||
"[PlaybackMode] Queue has {} items, original_index={}",
|
||||
items.len(),
|
||||
original_index
|
||||
);
|
||||
debug!(
|
||||
"[PlaybackMode] Queue has {} items, original_index={}",
|
||||
items.len(),
|
||||
original_index
|
||||
);
|
||||
|
||||
// Log each item's jellyfin_id for debugging
|
||||
for (i, item) in items.iter().enumerate() {
|
||||
let jf_id = item.jellyfin_id().unwrap_or("NONE");
|
||||
log::debug!("[PlaybackMode] Item {}: id={}, jellyfin_id={}", i, item.id, jf_id);
|
||||
log::debug!(
|
||||
"[PlaybackMode] Item {}: id={}, jellyfin_id={}",
|
||||
i,
|
||||
item.id,
|
||||
jf_id
|
||||
);
|
||||
}
|
||||
|
||||
let (ids, adjusted_index) = self.extract_jellyfin_ids(items, original_index)?;
|
||||
@@ -372,14 +411,11 @@ impl PlaybackModeManager {
|
||||
log::info!("[PlaybackMode] Getting Jellyfin client for transfer...");
|
||||
debug!("[PlaybackMode] Getting Jellyfin client for transfer...");
|
||||
let client = {
|
||||
let client_opt = self
|
||||
.jellyfin_client
|
||||
.lock()
|
||||
.map_err(|e| {
|
||||
log::error!("[PlaybackMode] Failed to lock Jellyfin client: {}", e);
|
||||
error!("[PlaybackMode] Failed to lock Jellyfin client: {}", e);
|
||||
format!("Failed to lock Jellyfin client: {}", e)
|
||||
})?;
|
||||
let client_opt = self.jellyfin_client.lock().map_err(|e| {
|
||||
log::error!("[PlaybackMode] Failed to lock Jellyfin client: {}", e);
|
||||
error!("[PlaybackMode] Failed to lock Jellyfin client: {}", e);
|
||||
format!("Failed to lock Jellyfin client: {}", e)
|
||||
})?;
|
||||
|
||||
match client_opt.as_ref() {
|
||||
Some(c) => {
|
||||
@@ -405,7 +441,9 @@ impl PlaybackModeManager {
|
||||
match client.get_session(prev_session_id).await {
|
||||
Ok(Some(session)) => {
|
||||
// Resume at the previous session's position.
|
||||
if let Some(ticks) = session.play_state.as_ref().and_then(|ps| ps.position_ticks) {
|
||||
if let Some(ticks) =
|
||||
session.play_state.as_ref().and_then(|ps| ps.position_ticks)
|
||||
{
|
||||
position_seconds = ticks as f64 / TICKS_PER_SECOND;
|
||||
log::info!(
|
||||
"[PlaybackMode] Using previous remote position: {:.2}s",
|
||||
@@ -413,7 +451,11 @@ impl PlaybackModeManager {
|
||||
);
|
||||
}
|
||||
// Resume on whichever track the previous session reached.
|
||||
if let Some(now_id) = session.now_playing_item.as_ref().and_then(|i| i.id.as_deref()) {
|
||||
if let Some(now_id) = session
|
||||
.now_playing_item
|
||||
.as_ref()
|
||||
.and_then(|i| i.id.as_deref())
|
||||
{
|
||||
if let Some(idx) = queue_ids.iter().position(|id| id == now_id) {
|
||||
log::info!(
|
||||
"[PlaybackMode] Previous session is on track {} (queue index {})",
|
||||
@@ -430,8 +472,13 @@ impl PlaybackModeManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None) => log::warn!("[PlaybackMode] Previous remote session not found while reading state"),
|
||||
Err(e) => log::warn!("[PlaybackMode] Failed to read previous remote session: {}", e),
|
||||
Ok(None) => log::warn!(
|
||||
"[PlaybackMode] Previous remote session not found while reading state"
|
||||
),
|
||||
Err(e) => log::warn!(
|
||||
"[PlaybackMode] Failed to read previous remote session: {}",
|
||||
e
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -440,7 +487,10 @@ impl PlaybackModeManager {
|
||||
|
||||
// Log queue context for debugging (context is tracked but we always send track IDs)
|
||||
match &queue_context {
|
||||
QueueContext::Album { album_id, album_name } => {
|
||||
QueueContext::Album {
|
||||
album_id,
|
||||
album_name,
|
||||
} => {
|
||||
log::info!(
|
||||
"[PlaybackMode] Transferring album '{}' (ID: {}) with {} tracks to remote",
|
||||
album_name,
|
||||
@@ -448,7 +498,10 @@ impl PlaybackModeManager {
|
||||
queue_ids.len()
|
||||
);
|
||||
}
|
||||
QueueContext::Playlist { playlist_id, playlist_name } => {
|
||||
QueueContext::Playlist {
|
||||
playlist_id,
|
||||
playlist_name,
|
||||
} => {
|
||||
log::info!(
|
||||
"[PlaybackMode] Transferring playlist '{}' (ID: {}) with {} tracks to remote",
|
||||
playlist_name,
|
||||
@@ -540,7 +593,11 @@ impl PlaybackModeManager {
|
||||
return Err("Remote session not found".to_string());
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("[PlaybackMode] Error polling session (attempt {}): {}", attempts, e);
|
||||
log::warn!(
|
||||
"[PlaybackMode] Error polling session (attempt {}): {}",
|
||||
attempts,
|
||||
e
|
||||
);
|
||||
// Continue polling - transient errors are OK
|
||||
}
|
||||
}
|
||||
@@ -572,9 +629,15 @@ impl PlaybackModeManager {
|
||||
// up with two devices playing at once. Do this only after the new session
|
||||
// is confirmed playing, so a failure here doesn't leave us with silence.
|
||||
if let Some(prev_session_id) = previous_remote_session {
|
||||
log::info!("[PlaybackMode] Stopping previous remote session {}", prev_session_id);
|
||||
log::info!(
|
||||
"[PlaybackMode] Stopping previous remote session {}",
|
||||
prev_session_id
|
||||
);
|
||||
if let Err(e) = client.send_session_command(prev_session_id, "Stop").await {
|
||||
log::warn!("[PlaybackMode] Failed to stop previous remote session: {}", e);
|
||||
log::warn!(
|
||||
"[PlaybackMode] Failed to stop previous remote session: {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -594,7 +657,9 @@ impl PlaybackModeManager {
|
||||
);
|
||||
}
|
||||
|
||||
player.stop().map_err(|e| format!("Failed to stop playback: {}", e))?;
|
||||
player
|
||||
.stop()
|
||||
.map_err(|e| format!("Failed to stop playback: {}", e))?;
|
||||
|
||||
// Log queue state AFTER stop (should be unchanged)
|
||||
{
|
||||
@@ -677,11 +742,20 @@ impl PlaybackModeManager {
|
||||
};
|
||||
|
||||
// Stop remote playback
|
||||
log::info!("[PlaybackMode] Stopping remote playback on session: {}", session_id);
|
||||
match client.send_session_command(session_id.clone(), "Stop").await {
|
||||
log::info!(
|
||||
"[PlaybackMode] Stopping remote playback on session: {}",
|
||||
session_id
|
||||
);
|
||||
match client
|
||||
.send_session_command(session_id.clone(), "Stop")
|
||||
.await
|
||||
{
|
||||
Ok(_) => log::info!("[PlaybackMode] Stop command sent successfully"),
|
||||
Err(e) => {
|
||||
log::warn!("[PlaybackMode] Failed to stop remote session (non-fatal): {}", e);
|
||||
log::warn!(
|
||||
"[PlaybackMode] Failed to stop remote session (non-fatal): {}",
|
||||
e
|
||||
);
|
||||
// Don't fail the transfer if we can't stop the remote session
|
||||
// The user is already playing locally, so this is not critical
|
||||
}
|
||||
@@ -843,7 +917,10 @@ mod tests {
|
||||
fn test_start_position_ticks_from_seconds() {
|
||||
// Mid-track positions convert to ticks (10M ticks per second).
|
||||
assert_eq!(start_position_ticks_from_seconds(5.0), Some(50_000_000));
|
||||
assert_eq!(start_position_ticks_from_seconds(123.45), Some(1_234_500_000));
|
||||
assert_eq!(
|
||||
start_position_ticks_from_seconds(123.45),
|
||||
Some(1_234_500_000)
|
||||
);
|
||||
|
||||
// At/near the start, send no resume position so the track casts from 0.
|
||||
assert_eq!(start_position_ticks_from_seconds(0.0), None);
|
||||
@@ -931,7 +1008,12 @@ mod tests {
|
||||
fn test_extract_all_jellyfin_ids_from_album() {
|
||||
// Simulate an album with 5 tracks - all should be extracted
|
||||
let items: Vec<MediaItem> = (1..=5)
|
||||
.map(|i| create_test_item_with_jellyfin_id(&format!("track_{}", i), &format!("jf_track_{}", i)))
|
||||
.map(|i| {
|
||||
create_test_item_with_jellyfin_id(
|
||||
&format!("track_{}", i),
|
||||
&format!("jf_track_{}", i),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let manager = super::PlaybackModeManager::new(
|
||||
@@ -965,7 +1047,7 @@ mod tests {
|
||||
// Mix of Jellyfin and local items - only Jellyfin items should be extracted
|
||||
let items = vec![
|
||||
create_test_item_with_jellyfin_id("1", "jf_1"),
|
||||
create_test_item_local("2"), // Local, no Jellyfin ID
|
||||
create_test_item_local("2"), // Local, no Jellyfin ID
|
||||
create_test_item_with_jellyfin_id("3", "jf_3"),
|
||||
create_test_item_with_jellyfin_id("4", "jf_4"),
|
||||
];
|
||||
@@ -1000,7 +1082,7 @@ mod tests {
|
||||
// Current item has no Jellyfin ID - should fail
|
||||
let items = vec![
|
||||
create_test_item_with_jellyfin_id("1", "jf_1"),
|
||||
create_test_item_local("2"), // Local, no Jellyfin ID
|
||||
create_test_item_local("2"), // Local, no Jellyfin ID
|
||||
create_test_item_with_jellyfin_id("3", "jf_3"),
|
||||
];
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
pub mod reporter;
|
||||
pub mod throttle;
|
||||
pub mod sync_processor;
|
||||
pub mod throttle;
|
||||
|
||||
pub use reporter::{PlaybackReporter, PlaybackOperation, PlaybackContext};
|
||||
#[allow(unused_imports)] // Will be used when position updates are hooked
|
||||
pub use throttle::EventThrottler;
|
||||
#[allow(unused_imports)] // Will be used when sync processor is integrated
|
||||
pub use reporter::{PlaybackContext, PlaybackOperation, PlaybackReporter};
|
||||
#[allow(unused_imports)] // Will be used when sync processor is integrated
|
||||
pub use sync_processor::SyncProcessor;
|
||||
#[allow(unused_imports)] // Will be used when position updates are hooked
|
||||
pub use throttle::EventThrottler;
|
||||
|
||||
@@ -14,7 +14,7 @@ use crate::storage::db_service::{DatabaseService, Query, QueryParam, RusqliteSer
|
||||
/// Playback context information
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PlaybackContext {
|
||||
pub context_type: String, // "container" or "single"
|
||||
pub context_type: String, // "container" or "single"
|
||||
pub context_id: Option<String>,
|
||||
}
|
||||
|
||||
@@ -65,7 +65,11 @@ impl PlaybackReporter {
|
||||
///
|
||||
/// Always updates local DB first, then attempts server sync if online.
|
||||
/// If server sync fails, operation is queued for retry.
|
||||
pub async fn report(&self, operation: PlaybackOperation, is_online: bool) -> Result<(), String> {
|
||||
pub async fn report(
|
||||
&self,
|
||||
operation: PlaybackOperation,
|
||||
is_online: bool,
|
||||
) -> Result<(), String> {
|
||||
log::info!("[PlaybackReporter] Reporting operation: {:?}", operation);
|
||||
|
||||
// Always update local DB first (works offline)
|
||||
@@ -93,7 +97,11 @@ impl PlaybackReporter {
|
||||
/// Updates local database with playback info
|
||||
async fn update_local_db(&self, operation: &PlaybackOperation) -> Result<(), String> {
|
||||
match operation {
|
||||
PlaybackOperation::Start { item_id, position_ticks, context } => {
|
||||
PlaybackOperation::Start {
|
||||
item_id,
|
||||
position_ticks,
|
||||
context,
|
||||
} => {
|
||||
let query = Query::with_params(
|
||||
"INSERT INTO user_data (user_id, item_id, playback_position_ticks, last_played_at,
|
||||
playback_context_type, playback_context_id, pending_sync)
|
||||
@@ -113,12 +121,22 @@ impl PlaybackReporter {
|
||||
],
|
||||
);
|
||||
|
||||
self.db_service.execute(query).await.map_err(|e| e.to_string())?;
|
||||
self.db_service
|
||||
.execute(query)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
log::debug!("[PlaybackReporter] Updated local DB for start: {}", item_id);
|
||||
}
|
||||
|
||||
PlaybackOperation::Progress { item_id, position_ticks, is_paused: _ } |
|
||||
PlaybackOperation::Stopped { item_id, position_ticks } => {
|
||||
PlaybackOperation::Progress {
|
||||
item_id,
|
||||
position_ticks,
|
||||
is_paused: _,
|
||||
}
|
||||
| PlaybackOperation::Stopped {
|
||||
item_id,
|
||||
position_ticks,
|
||||
} => {
|
||||
let query = Query::with_params(
|
||||
"INSERT INTO user_data (user_id, item_id, playback_position_ticks, last_played_at, pending_sync)
|
||||
VALUES (?, ?, ?, CURRENT_TIMESTAMP, 1)
|
||||
@@ -133,8 +151,14 @@ impl PlaybackReporter {
|
||||
],
|
||||
);
|
||||
|
||||
self.db_service.execute(query).await.map_err(|e| e.to_string())?;
|
||||
log::debug!("[PlaybackReporter] Updated local DB for progress/stop: {}", item_id);
|
||||
self.db_service
|
||||
.execute(query)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
log::debug!(
|
||||
"[PlaybackReporter] Updated local DB for progress/stop: {}",
|
||||
item_id
|
||||
);
|
||||
}
|
||||
|
||||
PlaybackOperation::MarkPlayed { item_id } => {
|
||||
@@ -152,8 +176,14 @@ impl PlaybackReporter {
|
||||
],
|
||||
);
|
||||
|
||||
self.db_service.execute(query).await.map_err(|e| e.to_string())?;
|
||||
log::debug!("[PlaybackReporter] Updated local DB for mark played: {}", item_id);
|
||||
self.db_service
|
||||
.execute(query)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
log::debug!(
|
||||
"[PlaybackReporter] Updated local DB for mark played: {}",
|
||||
item_id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,34 +193,57 @@ impl PlaybackReporter {
|
||||
/// Syncs to Jellyfin server
|
||||
async fn sync_to_server(&self, operation: &PlaybackOperation) -> Result<(), String> {
|
||||
let client_guard = self.jellyfin_client.lock().await;
|
||||
let client = client_guard.as_ref().ok_or("JellyfinClient not initialized")?;
|
||||
let client = client_guard
|
||||
.as_ref()
|
||||
.ok_or("JellyfinClient not initialized")?;
|
||||
|
||||
match operation {
|
||||
PlaybackOperation::Start { item_id, position_ticks, .. } => {
|
||||
client.report_playback_start(
|
||||
item_id.clone(),
|
||||
*position_ticks,
|
||||
None, // play_session_id
|
||||
).await?;
|
||||
PlaybackOperation::Start {
|
||||
item_id,
|
||||
position_ticks,
|
||||
..
|
||||
} => {
|
||||
client
|
||||
.report_playback_start(
|
||||
item_id.clone(),
|
||||
*position_ticks,
|
||||
None, // play_session_id
|
||||
)
|
||||
.await?;
|
||||
log::info!("[PlaybackReporter] Reported start to server: {}", item_id);
|
||||
}
|
||||
|
||||
PlaybackOperation::Progress { item_id, position_ticks, is_paused } => {
|
||||
client.report_playback_progress(
|
||||
item_id.clone(),
|
||||
*position_ticks,
|
||||
*is_paused,
|
||||
None, // play_session_id
|
||||
).await?;
|
||||
log::debug!("[PlaybackReporter] Reported progress to server: {} (paused: {})", item_id, is_paused);
|
||||
PlaybackOperation::Progress {
|
||||
item_id,
|
||||
position_ticks,
|
||||
is_paused,
|
||||
} => {
|
||||
client
|
||||
.report_playback_progress(
|
||||
item_id.clone(),
|
||||
*position_ticks,
|
||||
*is_paused,
|
||||
None, // play_session_id
|
||||
)
|
||||
.await?;
|
||||
log::debug!(
|
||||
"[PlaybackReporter] Reported progress to server: {} (paused: {})",
|
||||
item_id,
|
||||
is_paused
|
||||
);
|
||||
}
|
||||
|
||||
PlaybackOperation::Stopped { item_id, position_ticks } => {
|
||||
client.report_playback_stopped(
|
||||
item_id.clone(),
|
||||
*position_ticks,
|
||||
None, // play_session_id
|
||||
).await?;
|
||||
PlaybackOperation::Stopped {
|
||||
item_id,
|
||||
position_ticks,
|
||||
} => {
|
||||
client
|
||||
.report_playback_stopped(
|
||||
item_id.clone(),
|
||||
*position_ticks,
|
||||
None, // play_session_id
|
||||
)
|
||||
.await?;
|
||||
log::info!("[PlaybackReporter] Reported stop to server: {}", item_id);
|
||||
}
|
||||
|
||||
@@ -199,12 +252,13 @@ impl PlaybackReporter {
|
||||
// For now, report as stopped at max position
|
||||
// TODO: Fetch item runtime from DB or assume 100% completion
|
||||
let max_ticks = i64::MAX; // Temporary - should be actual runtime
|
||||
client.report_playback_stopped(
|
||||
item_id.clone(),
|
||||
max_ticks,
|
||||
None,
|
||||
).await?;
|
||||
log::info!("[PlaybackReporter] Reported mark played to server: {}", item_id);
|
||||
client
|
||||
.report_playback_stopped(item_id.clone(), max_ticks, None)
|
||||
.await?;
|
||||
log::info!(
|
||||
"[PlaybackReporter] Reported mark played to server: {}",
|
||||
item_id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,13 +268,21 @@ impl PlaybackReporter {
|
||||
/// Queues operation for later sync
|
||||
async fn queue_for_sync(&self, operation: &PlaybackOperation) -> Result<(), String> {
|
||||
let (op_name, item_id, payload) = match operation {
|
||||
PlaybackOperation::Start { item_id, position_ticks, context } => {
|
||||
PlaybackOperation::Start {
|
||||
item_id,
|
||||
position_ticks,
|
||||
context,
|
||||
} => {
|
||||
let payload_data = serde_json::json!({
|
||||
"position_ticks": position_ticks,
|
||||
"context_type": context.as_ref().map(|c| &c.context_type),
|
||||
"context_id": context.as_ref().and_then(|c| c.context_id.as_ref()),
|
||||
});
|
||||
("report_playback_start", Some(item_id.clone()), Some(payload_data.to_string()))
|
||||
(
|
||||
"report_playback_start",
|
||||
Some(item_id.clone()),
|
||||
Some(payload_data.to_string()),
|
||||
)
|
||||
}
|
||||
|
||||
PlaybackOperation::Progress { .. } => {
|
||||
@@ -230,11 +292,18 @@ impl PlaybackReporter {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
PlaybackOperation::Stopped { item_id, position_ticks } => {
|
||||
PlaybackOperation::Stopped {
|
||||
item_id,
|
||||
position_ticks,
|
||||
} => {
|
||||
let payload_data = serde_json::json!({
|
||||
"position_ticks": position_ticks,
|
||||
});
|
||||
("report_playback_stopped", Some(item_id.clone()), Some(payload_data.to_string()))
|
||||
(
|
||||
"report_playback_stopped",
|
||||
Some(item_id.clone()),
|
||||
Some(payload_data.to_string()),
|
||||
)
|
||||
}
|
||||
|
||||
PlaybackOperation::MarkPlayed { item_id } => {
|
||||
@@ -253,7 +322,10 @@ impl PlaybackReporter {
|
||||
],
|
||||
);
|
||||
|
||||
self.db_service.execute(query).await.map_err(|e| e.to_string())?;
|
||||
self.db_service
|
||||
.execute(query)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
log::info!("[PlaybackReporter] Queued operation: {}", op_name);
|
||||
|
||||
Ok(())
|
||||
@@ -269,7 +341,10 @@ impl PlaybackReporter {
|
||||
],
|
||||
);
|
||||
|
||||
self.db_service.execute(query).await.map_err(|e| e.to_string())?;
|
||||
self.db_service
|
||||
.execute(query)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
log::debug!("[PlaybackReporter] Marked as synced: {}", item_id);
|
||||
|
||||
Ok(())
|
||||
@@ -278,10 +353,10 @@ impl PlaybackReporter {
|
||||
/// Extracts item_id from operation
|
||||
fn get_item_id(&self, operation: &PlaybackOperation) -> Option<String> {
|
||||
match operation {
|
||||
PlaybackOperation::Start { item_id, .. } |
|
||||
PlaybackOperation::Progress { item_id, .. } |
|
||||
PlaybackOperation::Stopped { item_id, .. } |
|
||||
PlaybackOperation::MarkPlayed { item_id } => Some(item_id.clone()),
|
||||
PlaybackOperation::Start { item_id, .. }
|
||||
| PlaybackOperation::Progress { item_id, .. }
|
||||
| PlaybackOperation::Stopped { item_id, .. }
|
||||
| PlaybackOperation::MarkPlayed { item_id } => Some(item_id.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
#![allow(dead_code)]
|
||||
#![allow(unused_imports)]
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::Mutex as TokioMutex;
|
||||
|
||||
@@ -17,9 +17,9 @@ use crate::storage::db_service::RusqliteService;
|
||||
|
||||
/// Configuration for sync processor
|
||||
pub struct SyncConfig {
|
||||
pub max_retries: u32, // 5
|
||||
pub base_retry_delay_ms: u64, // 1000ms
|
||||
pub batch_size: usize, // 10 items
|
||||
pub max_retries: u32, // 5
|
||||
pub base_retry_delay_ms: u64, // 1000ms
|
||||
pub batch_size: usize, // 10 items
|
||||
}
|
||||
|
||||
impl Default for SyncConfig {
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
//! through JNI calls to Kotlin code.
|
||||
|
||||
use crate::utils::lock::MutexSafe;
|
||||
use log::debug;
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use tokio::sync::Mutex as TokioMutex;
|
||||
use log::debug;
|
||||
|
||||
use jni::objects::{GlobalRef, JClass, JObject, JString, JValue};
|
||||
use jni::sys::{jboolean, jdouble, jfloat, jint};
|
||||
@@ -17,7 +17,7 @@ use super::backend::{PlayerBackend, PlayerError};
|
||||
use super::events::{PlayerStatusEvent, SharedEventEmitter};
|
||||
use super::media::{MediaItem, MediaType};
|
||||
use super::state::PlayerState;
|
||||
use crate::playback_reporting::{PlaybackReporter, EventThrottler, PlaybackOperation};
|
||||
use crate::playback_reporting::{EventThrottler, PlaybackOperation, PlaybackReporter};
|
||||
use crate::utils::conversions::seconds_to_ticks;
|
||||
|
||||
/// Global reference to the JavaVM for JNI callbacks
|
||||
@@ -33,10 +33,12 @@ static EVENT_EMITTER: OnceLock<SharedEventEmitter> = OnceLock::new();
|
||||
static SHARED_STATE: OnceLock<Arc<Mutex<ExoPlayerState>>> = OnceLock::new();
|
||||
|
||||
/// Global handler for media session commands from Android lockscreen/notification
|
||||
static MEDIA_COMMAND_HANDLER: OnceLock<Arc<dyn MediaCommandHandler + Send + Sync>> = OnceLock::new();
|
||||
static MEDIA_COMMAND_HANDLER: OnceLock<Arc<dyn MediaCommandHandler + Send + Sync>> =
|
||||
OnceLock::new();
|
||||
|
||||
/// Global handler for remote volume changes from Android volume buttons
|
||||
static REMOTE_VOLUME_HANDLER: OnceLock<Arc<dyn RemoteVolumeHandler + Send + Sync>> = OnceLock::new();
|
||||
static REMOTE_VOLUME_HANDLER: OnceLock<Arc<dyn RemoteVolumeHandler + Send + Sync>> =
|
||||
OnceLock::new();
|
||||
|
||||
/// Global player controller for autoplay decisions
|
||||
static PLAYER_CONTROLLER: OnceLock<Arc<TokioMutex<super::PlayerController>>> = OnceLock::new();
|
||||
@@ -87,9 +89,9 @@ impl DetectedCodecs {
|
||||
|
||||
/// Public function to get detected codecs (for use in repository layer)
|
||||
pub fn get_detected_codecs() -> Option<(String, String)> {
|
||||
DETECTED_CODECS.get().map(|codecs| {
|
||||
(codecs.video_codecs_string(), codecs.audio_codecs_string())
|
||||
})
|
||||
DETECTED_CODECS
|
||||
.get()
|
||||
.map(|codecs| (codecs.video_codecs_string(), codecs.audio_codecs_string()))
|
||||
}
|
||||
|
||||
/// Trait for handling media commands from Android MediaSession.
|
||||
@@ -194,9 +196,9 @@ impl ExoPlayerBackend {
|
||||
let _ = JAVA_VM.set(vm);
|
||||
|
||||
// Store the Context as a global reference for later use
|
||||
let context_global = env
|
||||
.new_global_ref(context)
|
||||
.map_err(|e| PlayerError::playback_failed(format!("Failed to create global context ref: {}", e)))?;
|
||||
let context_global = env.new_global_ref(context).map_err(|e| {
|
||||
PlayerError::playback_failed(format!("Failed to create global context ref: {}", e))
|
||||
})?;
|
||||
let _ = APP_CONTEXT.set(context_global);
|
||||
|
||||
// Store the event emitter
|
||||
@@ -217,11 +219,16 @@ impl ExoPlayerBackend {
|
||||
.call_method(context, "getClassLoader", "()Ljava/lang/ClassLoader;", &[])
|
||||
.map_err(|e| PlayerError::playback_failed(format!("Failed to get ClassLoader: {}", e)))?
|
||||
.l()
|
||||
.map_err(|e| PlayerError::playback_failed(format!("Failed to convert ClassLoader: {}", e)))?;
|
||||
.map_err(|e| {
|
||||
PlayerError::playback_failed(format!("Failed to convert ClassLoader: {}", e))
|
||||
})?;
|
||||
|
||||
// Load the JellyTauPlayer class using the app's class loader
|
||||
let player_class_name = env.new_string("com.dtourolle.jellytau.player.JellyTauPlayer")
|
||||
.map_err(|e| PlayerError::playback_failed(format!("Failed to create class name string: {}", e)))?;
|
||||
let player_class_name = env
|
||||
.new_string("com.dtourolle.jellytau.player.JellyTauPlayer")
|
||||
.map_err(|e| {
|
||||
PlayerError::playback_failed(format!("Failed to create class name string: {}", e))
|
||||
})?;
|
||||
|
||||
let player_class_obj = env
|
||||
.call_method(
|
||||
@@ -230,9 +237,13 @@ impl ExoPlayerBackend {
|
||||
"(Ljava/lang/String;)Ljava/lang/Class;",
|
||||
&[JValue::Object(&player_class_name.into())],
|
||||
)
|
||||
.map_err(|e| PlayerError::playback_failed(format!("Failed to load JellyTauPlayer class: {}", e)))?
|
||||
.map_err(|e| {
|
||||
PlayerError::playback_failed(format!("Failed to load JellyTauPlayer class: {}", e))
|
||||
})?
|
||||
.l()
|
||||
.map_err(|e| PlayerError::playback_failed(format!("Failed to convert to Class: {}", e)))?;
|
||||
.map_err(|e| {
|
||||
PlayerError::playback_failed(format!("Failed to convert to Class: {}", e))
|
||||
})?;
|
||||
|
||||
// Cast to JClass for static method calls
|
||||
let player_class = JClass::from(player_class_obj);
|
||||
@@ -244,7 +255,9 @@ impl ExoPlayerBackend {
|
||||
"(Landroid/content/Context;)V",
|
||||
&[JValue::Object(context)],
|
||||
)
|
||||
.map_err(|e| PlayerError::playback_failed(format!("Failed to initialize JellyTauPlayer: {}", e)))?;
|
||||
.map_err(|e| {
|
||||
PlayerError::playback_failed(format!("Failed to initialize JellyTauPlayer: {}", e))
|
||||
})?;
|
||||
|
||||
// Get the singleton instance
|
||||
let player_obj = env
|
||||
@@ -254,14 +267,21 @@ impl ExoPlayerBackend {
|
||||
"()Lcom/dtourolle/jellytau/player/JellyTauPlayer;",
|
||||
&[],
|
||||
)
|
||||
.map_err(|e| PlayerError::playback_failed(format!("Failed to get JellyTauPlayer instance: {}", e)))?
|
||||
.map_err(|e| {
|
||||
PlayerError::playback_failed(format!(
|
||||
"Failed to get JellyTauPlayer instance: {}",
|
||||
e
|
||||
))
|
||||
})?
|
||||
.l()
|
||||
.map_err(|e| PlayerError::playback_failed(format!("Failed to convert to object: {}", e)))?;
|
||||
.map_err(|e| {
|
||||
PlayerError::playback_failed(format!("Failed to convert to object: {}", e))
|
||||
})?;
|
||||
|
||||
// Create a global reference to keep the player alive
|
||||
let player_ref = env
|
||||
.new_global_ref(player_obj)
|
||||
.map_err(|e| PlayerError::playback_failed(format!("Failed to create global ref: {}", e)))?;
|
||||
let player_ref = env.new_global_ref(player_obj).map_err(|e| {
|
||||
PlayerError::playback_failed(format!("Failed to create global ref: {}", e))
|
||||
})?;
|
||||
|
||||
Ok(Self {
|
||||
player_ref,
|
||||
@@ -271,16 +291,18 @@ impl ExoPlayerBackend {
|
||||
|
||||
/// Call a void method on the player with no arguments
|
||||
fn call_player_method(&self, method: &str) -> Result<(), PlayerError> {
|
||||
let vm = JAVA_VM.get().ok_or_else(|| {
|
||||
PlayerError::playback_failed("JavaVM not initialized")
|
||||
})?;
|
||||
let vm = JAVA_VM
|
||||
.get()
|
||||
.ok_or_else(|| PlayerError::playback_failed("JavaVM not initialized"))?;
|
||||
|
||||
let mut env = vm
|
||||
.attach_current_thread()
|
||||
.map_err(|e| PlayerError::playback_failed(format!("Failed to attach thread: {}", e)))?;
|
||||
|
||||
env.call_method(&self.player_ref, method, "()V", &[])
|
||||
.map_err(|e| PlayerError::playback_failed(format!("Failed to call {}: {}", method, e)))?;
|
||||
.map_err(|e| {
|
||||
PlayerError::playback_failed(format!("Failed to call {}: {}", method, e))
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -314,43 +336,46 @@ impl PlayerBackend for ExoPlayerBackend {
|
||||
state.is_loaded = false;
|
||||
}
|
||||
|
||||
let vm = JAVA_VM.get().ok_or_else(|| {
|
||||
PlayerError::playback_failed("JavaVM not initialized")
|
||||
})?;
|
||||
let vm = JAVA_VM
|
||||
.get()
|
||||
.ok_or_else(|| PlayerError::playback_failed("JavaVM not initialized"))?;
|
||||
|
||||
let mut env = vm
|
||||
.attach_current_thread()
|
||||
.map_err(|e| PlayerError::playback_failed(format!("Failed to attach thread: {}", e)))?;
|
||||
|
||||
// Create JNI strings for required parameters
|
||||
let url_jstring = env
|
||||
.new_string(&url)
|
||||
.map_err(|e| PlayerError::playback_failed(format!("Failed to create URL string: {}", e)))?;
|
||||
let url_jstring = env.new_string(&url).map_err(|e| {
|
||||
PlayerError::playback_failed(format!("Failed to create URL string: {}", e))
|
||||
})?;
|
||||
|
||||
let media_id_jstring = env
|
||||
.new_string(&media_id)
|
||||
.map_err(|e| PlayerError::playback_failed(format!("Failed to create media ID string: {}", e)))?;
|
||||
let media_id_jstring = env.new_string(&media_id).map_err(|e| {
|
||||
PlayerError::playback_failed(format!("Failed to create media ID string: {}", e))
|
||||
})?;
|
||||
|
||||
let title_jstring = env
|
||||
.new_string(&title)
|
||||
.map_err(|e| PlayerError::playback_failed(format!("Failed to create title string: {}", e)))?;
|
||||
let title_jstring = env.new_string(&title).map_err(|e| {
|
||||
PlayerError::playback_failed(format!("Failed to create title string: {}", e))
|
||||
})?;
|
||||
|
||||
// Create JNI strings for optional parameters (null if None)
|
||||
let artist_jstring = match &artist {
|
||||
Some(a) => Some(env.new_string(a)
|
||||
.map_err(|e| PlayerError::playback_failed(format!("Failed to create artist string: {}", e)))?),
|
||||
Some(a) => Some(env.new_string(a).map_err(|e| {
|
||||
PlayerError::playback_failed(format!("Failed to create artist string: {}", e))
|
||||
})?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let album_jstring = match &album {
|
||||
Some(a) => Some(env.new_string(a)
|
||||
.map_err(|e| PlayerError::playback_failed(format!("Failed to create album string: {}", e)))?),
|
||||
Some(a) => Some(env.new_string(a).map_err(|e| {
|
||||
PlayerError::playback_failed(format!("Failed to create album string: {}", e))
|
||||
})?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let artwork_jstring = match &artwork_url {
|
||||
Some(a) => Some(env.new_string(a)
|
||||
.map_err(|e| PlayerError::playback_failed(format!("Failed to create artwork string: {}", e)))?),
|
||||
Some(a) => Some(env.new_string(a).map_err(|e| {
|
||||
PlayerError::playback_failed(format!("Failed to create artwork string: {}", e))
|
||||
})?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
@@ -376,16 +401,16 @@ impl PlayerBackend for ExoPlayerBackend {
|
||||
MediaType::Video => "video",
|
||||
MediaType::Audio => "audio",
|
||||
};
|
||||
let media_type_jstring = env
|
||||
.new_string(media_type_str)
|
||||
.map_err(|e| PlayerError::playback_failed(format!("Failed to create media type string: {}", e)))?;
|
||||
let media_type_jstring = env.new_string(media_type_str).map_err(|e| {
|
||||
PlayerError::playback_failed(format!("Failed to create media type string: {}", e))
|
||||
})?;
|
||||
|
||||
// Serialize subtitles to JSON for passing to Kotlin
|
||||
let subtitles_json = serde_json::to_string(&media.subtitles)
|
||||
.unwrap_or_else(|_| "[]".to_string());
|
||||
let subtitles_jstring = env
|
||||
.new_string(&subtitles_json)
|
||||
.map_err(|e| PlayerError::playback_failed(format!("Failed to create subtitles JSON string: {}", e)))?;
|
||||
let subtitles_json =
|
||||
serde_json::to_string(&media.subtitles).unwrap_or_else(|_| "[]".to_string());
|
||||
let subtitles_jstring = env.new_string(&subtitles_json).map_err(|e| {
|
||||
PlayerError::playback_failed(format!("Failed to create subtitles JSON string: {}", e))
|
||||
})?;
|
||||
|
||||
// Call loadWithMetadata for MediaSession support (lockscreen controls)
|
||||
debug!("[Android] Loading media: url={}, id={}, title={}, artist={:?}, album={:?}, duration_ms={}, type={}, subtitles={}",
|
||||
@@ -414,7 +439,10 @@ impl PlayerBackend for ExoPlayerBackend {
|
||||
env.exception_describe().ok();
|
||||
env.exception_clear().ok();
|
||||
}
|
||||
return Err(PlayerError::playback_failed(format!("Failed to call loadWithMetadata: {}", e)));
|
||||
return Err(PlayerError::playback_failed(format!(
|
||||
"Failed to call loadWithMetadata: {}",
|
||||
e
|
||||
)));
|
||||
}
|
||||
|
||||
debug!("[Android] Successfully called loadWithMetadata");
|
||||
@@ -443,9 +471,9 @@ impl PlayerBackend for ExoPlayerBackend {
|
||||
}
|
||||
|
||||
fn seek(&mut self, position: f64) -> Result<(), PlayerError> {
|
||||
let vm = JAVA_VM.get().ok_or_else(|| {
|
||||
PlayerError::playback_failed("JavaVM not initialized")
|
||||
})?;
|
||||
let vm = JAVA_VM
|
||||
.get()
|
||||
.ok_or_else(|| PlayerError::playback_failed("JavaVM not initialized"))?;
|
||||
|
||||
let mut env = vm
|
||||
.attach_current_thread()
|
||||
@@ -465,9 +493,9 @@ impl PlayerBackend for ExoPlayerBackend {
|
||||
fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError> {
|
||||
let clamped = volume.clamp(0.0, 1.0);
|
||||
|
||||
let vm = JAVA_VM.get().ok_or_else(|| {
|
||||
PlayerError::playback_failed("JavaVM not initialized")
|
||||
})?;
|
||||
let vm = JAVA_VM
|
||||
.get()
|
||||
.ok_or_else(|| PlayerError::playback_failed("JavaVM not initialized"))?;
|
||||
|
||||
let mut env = vm
|
||||
.attach_current_thread()
|
||||
@@ -502,9 +530,9 @@ impl PlayerBackend for ExoPlayerBackend {
|
||||
}
|
||||
|
||||
fn set_audio_track(&mut self, stream_index: i32) -> Result<(), PlayerError> {
|
||||
let vm = JAVA_VM.get().ok_or_else(|| {
|
||||
PlayerError::playback_failed("JavaVM not initialized")
|
||||
})?;
|
||||
let vm = JAVA_VM
|
||||
.get()
|
||||
.ok_or_else(|| PlayerError::playback_failed("JavaVM not initialized"))?;
|
||||
|
||||
let mut env = vm
|
||||
.attach_current_thread()
|
||||
@@ -516,15 +544,17 @@ impl PlayerBackend for ExoPlayerBackend {
|
||||
"(I)V",
|
||||
&[JValue::Int(stream_index)],
|
||||
)
|
||||
.map_err(|e| PlayerError::playback_failed(format!("Failed to call setAudioTrack: {}", e)))?;
|
||||
.map_err(|e| {
|
||||
PlayerError::playback_failed(format!("Failed to call setAudioTrack: {}", e))
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_subtitle_track(&mut self, stream_index: Option<i32>) -> Result<(), PlayerError> {
|
||||
let vm = JAVA_VM.get().ok_or_else(|| {
|
||||
PlayerError::playback_failed("JavaVM not initialized")
|
||||
})?;
|
||||
let vm = JAVA_VM
|
||||
.get()
|
||||
.ok_or_else(|| PlayerError::playback_failed("JavaVM not initialized"))?;
|
||||
|
||||
let mut env = vm
|
||||
.attach_current_thread()
|
||||
@@ -539,7 +569,9 @@ impl PlayerBackend for ExoPlayerBackend {
|
||||
"(I)V",
|
||||
&[JValue::Int(index)],
|
||||
)
|
||||
.map_err(|e| PlayerError::playback_failed(format!("Failed to call setSubtitleTrack: {}", e)))?;
|
||||
.map_err(|e| {
|
||||
PlayerError::playback_failed(format!("Failed to call setSubtitleTrack: {}", e))
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -603,7 +635,11 @@ fn report_android_progress(position: f64) {
|
||||
if !state.state.is_playing() {
|
||||
return;
|
||||
}
|
||||
match state.current_media.as_ref().and_then(|m| m.jellyfin_id().map(|s| s.to_string())) {
|
||||
match state
|
||||
.current_media
|
||||
.as_ref()
|
||||
.and_then(|m| m.jellyfin_id().map(|s| s.to_string()))
|
||||
{
|
||||
Some(id) => id,
|
||||
None => return,
|
||||
}
|
||||
@@ -664,10 +700,7 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
|
||||
state: JString,
|
||||
media_id: JString,
|
||||
) {
|
||||
let state_str: String = env
|
||||
.get_string(&state)
|
||||
.map(|s| s.into())
|
||||
.unwrap_or_default();
|
||||
let state_str: String = env.get_string(&state).map(|s| s.into()).unwrap_or_default();
|
||||
|
||||
let media_id_opt: Option<String> = if media_id.is_null() {
|
||||
None
|
||||
@@ -769,7 +802,11 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
|
||||
// Log queue state before advancing
|
||||
let queue_info = {
|
||||
let queue = ctrl.queue.lock_safe();
|
||||
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);
|
||||
|
||||
@@ -779,7 +816,11 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
|
||||
// Log queue state after advancing
|
||||
let queue_info = {
|
||||
let queue = ctrl.queue.lock_safe();
|
||||
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);
|
||||
|
||||
@@ -1002,7 +1043,8 @@ fn start_playback_service() -> Result<(), String> {
|
||||
.map_err(|e| format!("Failed to convert ClassLoader: {}", e))?;
|
||||
|
||||
// Load the JellyTauPlayer class using the app's class loader
|
||||
let player_class_name = env.new_string("com.dtourolle.jellytau.player.JellyTauPlayer")
|
||||
let player_class_name = env
|
||||
.new_string("com.dtourolle.jellytau.player.JellyTauPlayer")
|
||||
.map_err(|e| format!("Failed to create class name string: {}", e))?;
|
||||
|
||||
let player_class_obj = env
|
||||
@@ -1036,13 +1078,8 @@ fn start_playback_service() -> Result<(), String> {
|
||||
}
|
||||
|
||||
// Call startPlaybackService() on the player instance
|
||||
env.call_method(
|
||||
&player_obj,
|
||||
"startPlaybackService",
|
||||
"()V",
|
||||
&[],
|
||||
)
|
||||
.map_err(|e| format!("Failed to start playback service: {}", e))?;
|
||||
env.call_method(&player_obj, "startPlaybackService", "()V", &[])
|
||||
.map_err(|e| format!("Failed to start playback service: {}", e))?;
|
||||
|
||||
log::info!("[Android] JellyTauPlaybackService start requested");
|
||||
Ok(())
|
||||
@@ -1056,7 +1093,10 @@ fn start_playback_service() -> Result<(), String> {
|
||||
/// @param initial_volume Initial volume level (0-100)
|
||||
#[cfg(target_os = "android")]
|
||||
pub fn enable_remote_volume(initial_volume: i32) -> Result<(), String> {
|
||||
log::info!("[Android] Enabling remote volume control (volume={})", initial_volume);
|
||||
log::info!(
|
||||
"[Android] Enabling remote volume control (volume={})",
|
||||
initial_volume
|
||||
);
|
||||
|
||||
// Ensure the playback service is started first
|
||||
start_playback_service()?;
|
||||
@@ -1078,7 +1118,8 @@ pub fn enable_remote_volume(initial_volume: i32) -> Result<(), String> {
|
||||
.map_err(|e| format!("Failed to convert ClassLoader: {}", e))?;
|
||||
|
||||
// Load the JellyTauPlaybackService class using the app's class loader
|
||||
let service_class_name = env.new_string("com.dtourolle.jellytau.player.JellyTauPlaybackService")
|
||||
let service_class_name = env
|
||||
.new_string("com.dtourolle.jellytau.player.JellyTauPlaybackService")
|
||||
.map_err(|e| format!("Failed to create class name string: {}", e))?;
|
||||
|
||||
let service_class_obj = env
|
||||
@@ -1146,7 +1187,8 @@ pub fn disable_remote_volume() -> Result<(), String> {
|
||||
.map_err(|e| format!("Failed to convert ClassLoader: {}", e))?;
|
||||
|
||||
// Load the JellyTauPlaybackService class using the app's class loader
|
||||
let service_class_name = env.new_string("com.dtourolle.jellytau.player.JellyTauPlaybackService")
|
||||
let service_class_name = env
|
||||
.new_string("com.dtourolle.jellytau.player.JellyTauPlaybackService")
|
||||
.map_err(|e| format!("Failed to create class name string: {}", e))?;
|
||||
|
||||
let service_class_obj = env
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Autoplay decision logic
|
||||
// TRACES: UR-023, UR-026 | DR-047, DR-048, DR-029
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::repository::types::MediaItem;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Autoplay decision result - determines what happens after playback ends
|
||||
#[derive(specta::Type, Debug, Clone, Serialize)]
|
||||
|
||||
@@ -163,7 +163,12 @@ impl PlayerBackend for NullBackend {
|
||||
}
|
||||
|
||||
fn play(&mut self) -> Result<(), PlayerError> {
|
||||
if let PlayerState::Paused { media, position, duration } = &self.state {
|
||||
if let PlayerState::Paused {
|
||||
media,
|
||||
position,
|
||||
duration,
|
||||
} = &self.state
|
||||
{
|
||||
self.state = PlayerState::Playing {
|
||||
media: media.clone(),
|
||||
position: *position,
|
||||
@@ -174,7 +179,12 @@ impl PlayerBackend for NullBackend {
|
||||
}
|
||||
|
||||
fn pause(&mut self) -> Result<(), PlayerError> {
|
||||
if let PlayerState::Playing { media, position, duration } = &self.state {
|
||||
if let PlayerState::Playing {
|
||||
media,
|
||||
position,
|
||||
duration,
|
||||
} = &self.state
|
||||
{
|
||||
self.state = PlayerState::Paused {
|
||||
media: media.clone(),
|
||||
position: *position,
|
||||
|
||||
@@ -138,8 +138,12 @@ impl MediaItem {
|
||||
/// Get the Jellyfin item ID if available
|
||||
pub fn jellyfin_id(&self) -> Option<&str> {
|
||||
match &self.source {
|
||||
MediaSource::Remote { jellyfin_item_id, .. } => Some(jellyfin_item_id),
|
||||
MediaSource::Local { jellyfin_item_id, .. } => jellyfin_item_id.as_deref(),
|
||||
MediaSource::Remote {
|
||||
jellyfin_item_id, ..
|
||||
} => Some(jellyfin_item_id),
|
||||
MediaSource::Local {
|
||||
jellyfin_item_id, ..
|
||||
} => jellyfin_item_id.as_deref(),
|
||||
MediaSource::DirectUrl { .. } => None,
|
||||
}
|
||||
}
|
||||
@@ -151,9 +155,7 @@ impl MediaItem {
|
||||
pub fn playback_url(&self) -> String {
|
||||
match &self.source {
|
||||
MediaSource::Remote { stream_url, .. } => stream_url.clone(),
|
||||
MediaSource::Local { file_path, .. } => {
|
||||
file_path.to_string_lossy().to_string()
|
||||
}
|
||||
MediaSource::Local { file_path, .. } => file_path.to_string_lossy().to_string(),
|
||||
MediaSource::DirectUrl { url } => url.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
+397
-90
@@ -42,8 +42,8 @@ pub use mpv_backend::MpvBackend;
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
pub use android::{
|
||||
MediaCommandHandler, RemoteVolumeHandler, enable_remote_volume, disable_remote_volume,
|
||||
set_media_command_handler, set_remote_volume_handler, get_detected_codecs,
|
||||
disable_remote_volume, enable_remote_volume, get_detected_codecs, set_media_command_handler,
|
||||
set_remote_volume_handler, MediaCommandHandler, RemoteVolumeHandler,
|
||||
};
|
||||
|
||||
/// Metadata for the lockscreen / media notification.
|
||||
@@ -53,6 +53,9 @@ pub use android::{
|
||||
/// poller fills this in from the remote Jellyfin session and pushes it to the
|
||||
/// notification so the lockscreen stays in sync while casting.
|
||||
#[derive(Debug, Clone)]
|
||||
// Fields are read only by the Android MediaSession bridge; on other platforms
|
||||
// `update_lockscreen_metadata` is a no-op, so they're constructed but unread.
|
||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||
pub struct LockscreenMetadata {
|
||||
pub title: String,
|
||||
pub artist: String,
|
||||
@@ -84,9 +87,11 @@ use std::time::Duration;
|
||||
use tokio::sync::Mutex as TokioMutex;
|
||||
|
||||
use crate::jellyfin::JellyfinClient;
|
||||
use crate::settings::AudioSettings;
|
||||
use crate::playback_reporting::{
|
||||
EventThrottler, PlaybackContext, PlaybackOperation, PlaybackReporter,
|
||||
};
|
||||
use crate::repository::MediaRepository;
|
||||
use crate::playback_reporting::{PlaybackReporter, EventThrottler, PlaybackOperation, PlaybackContext};
|
||||
use crate::settings::AudioSettings;
|
||||
|
||||
/// Central player controller that coordinates playback
|
||||
pub struct PlayerController {
|
||||
@@ -157,7 +162,10 @@ impl PlayerController {
|
||||
pub fn set_jellyfin_client(&self, client: Option<JellyfinClient>) {
|
||||
let mut jellyfin = self.jellyfin_client.lock_safe();
|
||||
*jellyfin = client;
|
||||
log::info!("[PlayerController] Jellyfin client configured: {}", jellyfin.is_some());
|
||||
log::info!(
|
||||
"[PlayerController] Jellyfin client configured: {}",
|
||||
jellyfin.is_some()
|
||||
);
|
||||
}
|
||||
|
||||
/// Get a reference to the Jellyfin client (for remote session control)
|
||||
@@ -180,7 +188,10 @@ impl PlayerController {
|
||||
pub async fn set_playback_reporter(&self, reporter: Option<PlaybackReporter>) {
|
||||
let mut reporter_guard = self.playback_reporter.lock().await;
|
||||
*reporter_guard = reporter;
|
||||
log::info!("[PlayerController] Playback reporter configured: {}", reporter_guard.is_some());
|
||||
log::info!(
|
||||
"[PlayerController] Playback reporter configured: {}",
|
||||
reporter_guard.is_some()
|
||||
);
|
||||
}
|
||||
|
||||
/// Get a reference to the playback reporter (for backend position updates)
|
||||
@@ -219,7 +230,10 @@ impl PlayerController {
|
||||
|
||||
let mut count = self.autoplay_episode_count.lock_safe();
|
||||
*count += 1;
|
||||
debug!("[PlayerController] Autoplay episode count: {}/{}", *count, max);
|
||||
debug!(
|
||||
"[PlayerController] Autoplay episode count: {}/{}",
|
||||
*count, max
|
||||
);
|
||||
|
||||
*count >= max
|
||||
}
|
||||
@@ -228,7 +242,10 @@ impl PlayerController {
|
||||
fn reset_autoplay_count(&self) {
|
||||
let mut count = self.autoplay_episode_count.lock_safe();
|
||||
if *count > 0 {
|
||||
debug!("[PlayerController] Resetting autoplay episode counter (was {})", *count);
|
||||
debug!(
|
||||
"[PlayerController] Resetting autoplay episode counter (was {})",
|
||||
*count
|
||||
);
|
||||
}
|
||||
*count = 0;
|
||||
}
|
||||
@@ -259,7 +276,10 @@ impl PlayerController {
|
||||
/// item, but MPV must not start a redundant decode for it.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn set_current_item(&self, item: MediaItem) -> Result<(), PlayerError> {
|
||||
debug!("[PlayerController] set_current_item (no backend load): {}", item.title);
|
||||
debug!(
|
||||
"[PlayerController] set_current_item (no backend load): {}",
|
||||
item.title
|
||||
);
|
||||
|
||||
self.reset_autoplay_count();
|
||||
|
||||
@@ -446,7 +466,9 @@ impl PlayerController {
|
||||
// Get current playback info before stopping
|
||||
let jellyfin_id = {
|
||||
let queue = self.queue.lock_safe();
|
||||
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 = {
|
||||
@@ -522,7 +544,10 @@ impl PlayerController {
|
||||
queue.next().cloned()
|
||||
};
|
||||
|
||||
debug!("[PlayerController] next: {:?}", next_item.as_ref().map(|i| &i.title));
|
||||
debug!(
|
||||
"[PlayerController] next: {:?}",
|
||||
next_item.as_ref().map(|i| &i.title)
|
||||
);
|
||||
|
||||
if let Some(item) = next_item {
|
||||
self.load_and_play(&item)
|
||||
@@ -554,7 +579,10 @@ impl PlayerController {
|
||||
queue.previous().cloned()
|
||||
};
|
||||
|
||||
debug!("[PlayerController] previous: {:?}", prev_item.as_ref().map(|i| &i.title));
|
||||
debug!(
|
||||
"[PlayerController] previous: {:?}",
|
||||
prev_item.as_ref().map(|i| &i.title)
|
||||
);
|
||||
|
||||
if let Some(item) = prev_item {
|
||||
self.load_and_play(&item)
|
||||
@@ -707,7 +735,9 @@ impl PlayerController {
|
||||
timer.update_remaining_seconds();
|
||||
|
||||
// Time-based timer expired: stop playback
|
||||
if matches!(timer.mode, SleepTimerMode::Time { .. }) && timer.remaining_seconds == 0 {
|
||||
if matches!(timer.mode, SleepTimerMode::Time { .. })
|
||||
&& timer.remaining_seconds == 0
|
||||
{
|
||||
debug!("[SleepTimer] Time-based timer expired, stopping playback");
|
||||
timer.cancel();
|
||||
|
||||
@@ -843,7 +873,10 @@ impl PlayerController {
|
||||
// Check why playback ended
|
||||
let end_reason = self.take_end_reason();
|
||||
|
||||
debug!("[PlayerController] on_playback_ended: end_reason={:?}", end_reason);
|
||||
debug!(
|
||||
"[PlayerController] on_playback_ended: end_reason={:?}",
|
||||
end_reason
|
||||
);
|
||||
|
||||
// Only proceed with autoplay logic if track finished naturally
|
||||
match end_reason {
|
||||
@@ -907,8 +940,8 @@ impl PlayerController {
|
||||
}
|
||||
SleepTimerMode::Episodes { .. } => {
|
||||
// Only count TV episodes (not audio tracks or movies)
|
||||
let is_episode = current.media_type == MediaType::Video
|
||||
&& self.is_episode_item(¤t).await;
|
||||
let is_episode =
|
||||
current.media_type == MediaType::Video && self.is_episode_item(¤t).await;
|
||||
|
||||
if is_episode {
|
||||
let should_stop = self.sleep_timer.lock_safe().decrement_episode();
|
||||
@@ -936,7 +969,10 @@ impl PlayerController {
|
||||
match self.fetch_next_episode_for_item(jellyfin_id, repo).await {
|
||||
Ok(next) => next,
|
||||
Err(e) => {
|
||||
warn!("[PlayerController] Next-episode lookup failed for {}: {}", jellyfin_id, e);
|
||||
warn!(
|
||||
"[PlayerController] Next-episode lookup failed for {}: {}",
|
||||
jellyfin_id, e
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -950,11 +986,14 @@ impl PlayerController {
|
||||
// Check if auto-play episode limit is reached
|
||||
let limit_reached = self.increment_autoplay_count();
|
||||
if limit_reached {
|
||||
debug!("[PlayerController] Auto-play episode limit reached ({} episodes)", settings.max_episodes);
|
||||
debug!(
|
||||
"[PlayerController] Auto-play episode limit reached ({} episodes)",
|
||||
settings.max_episodes
|
||||
);
|
||||
}
|
||||
|
||||
return Ok(AutoplayDecision::ShowNextEpisodePopup {
|
||||
current_episode: next_ep.0, // Repository MediaItem
|
||||
current_episode: next_ep.0, // Repository MediaItem
|
||||
next_episode: next_ep.1,
|
||||
countdown_seconds: settings.countdown_seconds,
|
||||
auto_advance: settings.enabled && !limit_reached,
|
||||
@@ -993,10 +1032,16 @@ impl PlayerController {
|
||||
// Clear any stale end_reason (e.g., UserStop from stopping audio before video)
|
||||
let stale_reason = self.take_end_reason();
|
||||
if stale_reason.is_some() {
|
||||
debug!("[PlayerController] Cleared stale end_reason for video: {:?}", stale_reason);
|
||||
debug!(
|
||||
"[PlayerController] Cleared stale end_reason for video: {:?}",
|
||||
stale_reason
|
||||
);
|
||||
}
|
||||
|
||||
log::info!("[PlayerController] on_video_playback_ended: item_id={}", item_id);
|
||||
log::info!(
|
||||
"[PlayerController] on_video_playback_ended: item_id={}",
|
||||
item_id
|
||||
);
|
||||
|
||||
// Check sleep timer state
|
||||
let timer_mode = {
|
||||
@@ -1035,7 +1080,10 @@ impl PlayerController {
|
||||
let next_ep_result = match self.fetch_next_episode_for_item(item_id, &repo).await {
|
||||
Ok(next) => next,
|
||||
Err(e) => {
|
||||
warn!("[PlayerController] Next-episode lookup failed for {}: {}", item_id, e);
|
||||
warn!(
|
||||
"[PlayerController] Next-episode lookup failed for {}: {}",
|
||||
item_id, e
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
@@ -1044,7 +1092,10 @@ impl PlayerController {
|
||||
|
||||
let limit_reached = self.increment_autoplay_count();
|
||||
if limit_reached {
|
||||
debug!("[PlayerController] Auto-play episode limit reached ({} episodes)", settings.max_episodes);
|
||||
debug!(
|
||||
"[PlayerController] Auto-play episode limit reached ({} episodes)",
|
||||
settings.max_episodes
|
||||
);
|
||||
}
|
||||
|
||||
return Ok(AutoplayDecision::ShowNextEpisodePopup {
|
||||
@@ -1077,11 +1128,18 @@ impl PlayerController {
|
||||
&self,
|
||||
item_id: &str,
|
||||
repo: &Arc<dyn crate::repository::MediaRepository>,
|
||||
) -> Result<Option<(crate::repository::types::MediaItem, crate::repository::types::MediaItem)>, String> {
|
||||
) -> Result<
|
||||
Option<(
|
||||
crate::repository::types::MediaItem,
|
||||
crate::repository::types::MediaItem,
|
||||
)>,
|
||||
String,
|
||||
> {
|
||||
use crate::repository::types::GetItemsOptions;
|
||||
|
||||
// Get the current item details from repository
|
||||
let current_repo_item = repo.get_item(item_id)
|
||||
let current_repo_item = repo
|
||||
.get_item(item_id)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to get current item: {}", e))?;
|
||||
|
||||
@@ -1089,7 +1147,9 @@ impl PlayerController {
|
||||
let season_id = match ¤t_repo_item.season_id {
|
||||
Some(sid) => sid.clone(),
|
||||
None => {
|
||||
log::info!("[PlayerController] Current item has no season_id, cannot find next episode");
|
||||
log::info!(
|
||||
"[PlayerController] Current item has no season_id, cannot find next episode"
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
@@ -1103,7 +1163,8 @@ impl PlayerController {
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let result = repo.get_items(&season_id, Some(options))
|
||||
let result = repo
|
||||
.get_items(&season_id, Some(options))
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch season episodes: {}", e))?;
|
||||
|
||||
@@ -1111,26 +1172,45 @@ impl PlayerController {
|
||||
// (offline repo ignores sort_by and sorts by sort_name instead)
|
||||
let mut episodes = result.items;
|
||||
episodes.sort_by_key(|e| e.index_number.unwrap_or(i32::MAX));
|
||||
log::info!("[PlayerController] Season has {} episodes, looking for next after {}", episodes.len(), current_repo_item.id);
|
||||
log::info!(
|
||||
"[PlayerController] Season has {} episodes, looking for next after {}",
|
||||
episodes.len(),
|
||||
current_repo_item.id
|
||||
);
|
||||
|
||||
// Find the current episode by ID and return the next one
|
||||
if let Some(current_idx) = episodes.iter().position(|e| e.id == current_repo_item.id) {
|
||||
if current_idx + 1 < episodes.len() {
|
||||
let next = &episodes[current_idx + 1];
|
||||
log::info!("[PlayerController] Found next episode: {} (index {})", next.name, current_idx + 1);
|
||||
log::info!(
|
||||
"[PlayerController] Found next episode: {} (index {})",
|
||||
next.name,
|
||||
current_idx + 1
|
||||
);
|
||||
return Ok(Some((current_repo_item, next.clone())));
|
||||
} else {
|
||||
log::info!("[PlayerController] Current episode is the last in the season");
|
||||
}
|
||||
} else {
|
||||
log::info!("[PlayerController] Current episode not found in season episodes (ids: {:?})", episodes.iter().map(|e| e.id.as_str()).take(20).collect::<Vec<_>>());
|
||||
log::info!(
|
||||
"[PlayerController] Current episode not found in season episodes (ids: {:?})",
|
||||
episodes
|
||||
.iter()
|
||||
.map(|e| e.id.as_str())
|
||||
.take(20)
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Start autoplay countdown thread
|
||||
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
|
||||
let cancel_flag = Arc::new(Mutex::new(false));
|
||||
*self.countdown_cancel.lock_safe() = Some(cancel_flag.clone());
|
||||
@@ -1169,7 +1249,11 @@ impl Default for PlayerController {
|
||||
fn default() -> Self {
|
||||
let playback_reporter = Arc::new(TokioMutex::new(None));
|
||||
let position_throttler = Arc::new(EventThrottler::new());
|
||||
Self::new(Box::new(NullBackend::new()), playback_reporter, position_throttler)
|
||||
Self::new(
|
||||
Box::new(NullBackend::new()),
|
||||
playback_reporter,
|
||||
position_throttler,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1332,8 +1416,16 @@ mod tests {
|
||||
let queue = controller.queue();
|
||||
let queue_lock = queue.lock_safe();
|
||||
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().unwrap().id, "item_0", "Current item should be item_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"
|
||||
);
|
||||
}
|
||||
|
||||
// Skip to next track
|
||||
@@ -1343,15 +1435,35 @@ mod tests {
|
||||
{
|
||||
let queue = controller.queue();
|
||||
let queue_lock = queue.lock_safe();
|
||||
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().unwrap().id, "item_1", "Current item should be item_1");
|
||||
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().unwrap().id,
|
||||
"item_1",
|
||||
"Current item should be item_1"
|
||||
);
|
||||
|
||||
// Verify all original items are still present
|
||||
let current_items = queue_lock.items();
|
||||
for (i, original) in items_clone.iter().enumerate() {
|
||||
assert_eq!(current_items[i].id, original.id, "Item {} should still be in queue", i);
|
||||
assert_eq!(current_items[i].title, original.title, "Item {} title should be unchanged", i);
|
||||
assert_eq!(
|
||||
current_items[i].id, original.id,
|
||||
"Item {} should still be in queue",
|
||||
i
|
||||
);
|
||||
assert_eq!(
|
||||
current_items[i].title, original.title,
|
||||
"Item {} title should be unchanged",
|
||||
i
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1362,9 +1474,21 @@ mod tests {
|
||||
{
|
||||
let queue = controller.queue();
|
||||
let queue_lock = queue.lock_safe();
|
||||
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().unwrap().id, "item_2", "Current item should be item_2");
|
||||
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().unwrap().id,
|
||||
"item_2",
|
||||
"Current item should be item_2"
|
||||
);
|
||||
}
|
||||
|
||||
// Skip multiple times to reach the end
|
||||
@@ -1375,9 +1499,21 @@ mod tests {
|
||||
{
|
||||
let queue = controller.queue();
|
||||
let queue_lock = queue.lock_safe();
|
||||
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().unwrap().id, "item_4", "Current item should be item_4");
|
||||
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().unwrap().id,
|
||||
"item_4",
|
||||
"Current item should be item_4"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1397,7 +1533,11 @@ mod tests {
|
||||
{
|
||||
let queue = controller.queue();
|
||||
let queue_lock = queue.lock_safe();
|
||||
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"
|
||||
);
|
||||
}
|
||||
|
||||
// Try to skip past the end (without repeat mode)
|
||||
@@ -1408,7 +1548,11 @@ mod tests {
|
||||
{
|
||||
let queue = controller.queue();
|
||||
let queue_lock = queue.lock_safe();
|
||||
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
|
||||
// or become None (depending on implementation)
|
||||
// The key is the queue items themselves should be preserved
|
||||
@@ -1437,9 +1581,21 @@ mod tests {
|
||||
{
|
||||
let queue = controller.queue();
|
||||
let queue_lock = queue.lock_safe();
|
||||
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().unwrap().id, "item_0", "Should be back at item_0");
|
||||
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().unwrap().id,
|
||||
"item_0",
|
||||
"Should be back at item_0"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1456,7 +1612,11 @@ mod tests {
|
||||
{
|
||||
let queue = controller.queue();
|
||||
let queue_lock = queue.lock_safe();
|
||||
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"
|
||||
);
|
||||
}
|
||||
|
||||
// Go to previous track
|
||||
@@ -1466,14 +1626,30 @@ mod tests {
|
||||
{
|
||||
let queue = controller.queue();
|
||||
let queue_lock = queue.lock_safe();
|
||||
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().unwrap().id, "item_2", "Current item should be item_2");
|
||||
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().unwrap().id,
|
||||
"item_2",
|
||||
"Current item should be item_2"
|
||||
);
|
||||
|
||||
// Verify all original items are still present
|
||||
let current_items = queue_lock.items();
|
||||
for (i, original) in items_clone.iter().enumerate() {
|
||||
assert_eq!(current_items[i].id, original.id, "Item {} should still be in queue", i);
|
||||
assert_eq!(
|
||||
current_items[i].id, original.id,
|
||||
"Item {} should still be in queue",
|
||||
i
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1491,15 +1667,27 @@ mod tests {
|
||||
|
||||
// Seek to 30 seconds
|
||||
controller.seek(30.0).unwrap();
|
||||
assert_eq!(controller.position(), 30.0, "Position should be 30 after seeking");
|
||||
assert_eq!(
|
||||
controller.position(),
|
||||
30.0,
|
||||
"Position should be 30 after seeking"
|
||||
);
|
||||
|
||||
// Seek to 60 seconds
|
||||
controller.seek(60.0).unwrap();
|
||||
assert_eq!(controller.position(), 60.0, "Position should be 60 after seeking");
|
||||
assert_eq!(
|
||||
controller.position(),
|
||||
60.0,
|
||||
"Position should be 60 after seeking"
|
||||
);
|
||||
|
||||
// Seek backward to 15 seconds
|
||||
controller.seek(15.0).unwrap();
|
||||
assert_eq!(controller.position(), 15.0, "Position should be 15 after seeking backward");
|
||||
assert_eq!(
|
||||
controller.position(),
|
||||
15.0,
|
||||
"Position should be 15 after seeking backward"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1518,10 +1706,17 @@ mod tests {
|
||||
|
||||
// Seek while paused
|
||||
controller.seek(45.0).unwrap();
|
||||
assert_eq!(controller.position(), 45.0, "Position should update while paused");
|
||||
assert_eq!(
|
||||
controller.position(),
|
||||
45.0,
|
||||
"Position should update while paused"
|
||||
);
|
||||
|
||||
// Verify still paused after seeking
|
||||
assert!(controller.state().is_paused(), "Should still be paused after seeking");
|
||||
assert!(
|
||||
controller.state().is_paused(),
|
||||
"Should still be paused after seeking"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1540,10 +1735,17 @@ mod tests {
|
||||
|
||||
// Seek while playing
|
||||
controller.seek(20.0).unwrap();
|
||||
assert_eq!(controller.position(), 20.0, "Position should update while playing");
|
||||
assert_eq!(
|
||||
controller.position(),
|
||||
20.0,
|
||||
"Position should update while playing"
|
||||
);
|
||||
|
||||
// Verify still playing after seeking
|
||||
assert!(controller.state().is_playing(), "Should still be playing after seeking");
|
||||
assert!(
|
||||
controller.state().is_playing(),
|
||||
"Should still be playing after seeking"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1558,7 +1760,12 @@ mod tests {
|
||||
|
||||
for pos in positions {
|
||||
controller.seek(pos).unwrap();
|
||||
assert_eq!(controller.position(), pos, "Position should match after seeking to {}", pos);
|
||||
assert_eq!(
|
||||
controller.position(),
|
||||
pos,
|
||||
"Position should match after seeking to {}",
|
||||
pos
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1575,9 +1782,17 @@ mod tests {
|
||||
{
|
||||
let queue = controller.queue();
|
||||
let queue_lock = queue.lock_safe();
|
||||
assert_eq!(queue_lock.current_index(), Some(1), "Should start at index 1");
|
||||
assert_eq!(
|
||||
queue_lock.current_index(),
|
||||
Some(1),
|
||||
"Should start at index 1"
|
||||
);
|
||||
}
|
||||
assert_eq!(controller.position(), 42.5, "Should resume at the requested position");
|
||||
assert_eq!(
|
||||
controller.position(),
|
||||
42.5,
|
||||
"Should resume at the requested position"
|
||||
);
|
||||
}
|
||||
|
||||
/// A None / near-zero start position starts the track from the beginning.
|
||||
@@ -1613,7 +1828,11 @@ mod tests {
|
||||
|
||||
// Seek back to zero
|
||||
controller.seek(0.0).unwrap();
|
||||
assert_eq!(controller.position(), 0.0, "Should be able to seek to position 0");
|
||||
assert_eq!(
|
||||
controller.position(),
|
||||
0.0,
|
||||
"Should be able to seek to position 0"
|
||||
);
|
||||
}
|
||||
|
||||
// Autoplay decision tests
|
||||
@@ -1990,7 +2209,10 @@ mod tests {
|
||||
total_record_count: self.episodes.len(),
|
||||
})
|
||||
}
|
||||
async fn get_item(&self, item_id: &str) -> Result<repo_types::MediaItem, repo_types::RepoError> {
|
||||
async fn get_item(
|
||||
&self,
|
||||
item_id: &str,
|
||||
) -> Result<repo_types::MediaItem, repo_types::RepoError> {
|
||||
self.episodes
|
||||
.iter()
|
||||
.find(|e| e.id == item_id)
|
||||
@@ -1999,55 +2221,109 @@ mod tests {
|
||||
message: format!("{} not found", item_id),
|
||||
})
|
||||
}
|
||||
async fn get_latest_items(&self, _: &str, _: Option<usize>) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
|
||||
async fn get_latest_items(
|
||||
&self,
|
||||
_: &str,
|
||||
_: Option<usize>,
|
||||
) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn get_resume_items(&self, _: Option<&str>, _: Option<usize>) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
|
||||
async fn get_resume_items(
|
||||
&self,
|
||||
_: Option<&str>,
|
||||
_: Option<usize>,
|
||||
) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn get_next_up_episodes(&self, _: Option<&str>, _: Option<usize>) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
|
||||
async fn get_next_up_episodes(
|
||||
&self,
|
||||
_: Option<&str>,
|
||||
_: Option<usize>,
|
||||
) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn get_recently_played_audio(&self, _: Option<usize>) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
|
||||
async fn get_recently_played_audio(
|
||||
&self,
|
||||
_: Option<usize>,
|
||||
) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn get_rediscover_albums(&self, _: Option<&str>, _: Option<usize>) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
|
||||
async fn get_rediscover_albums(
|
||||
&self,
|
||||
_: Option<&str>,
|
||||
_: Option<usize>,
|
||||
) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn get_resume_movies(&self, _: Option<usize>) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
|
||||
async fn get_resume_movies(
|
||||
&self,
|
||||
_: Option<usize>,
|
||||
) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn get_genres(&self, _: Option<&str>) -> Result<Vec<repo_types::Genre>, repo_types::RepoError> {
|
||||
async fn get_genres(
|
||||
&self,
|
||||
_: Option<&str>,
|
||||
) -> Result<Vec<repo_types::Genre>, repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn search(&self, _: &str, _: Option<repo_types::SearchOptions>) -> Result<repo_types::SearchResult, repo_types::RepoError> {
|
||||
async fn search(
|
||||
&self,
|
||||
_: &str,
|
||||
_: Option<repo_types::SearchOptions>,
|
||||
) -> Result<repo_types::SearchResult, repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn get_playback_info(&self, _: &str) -> Result<repo_types::PlaybackInfo, repo_types::RepoError> {
|
||||
async fn get_playback_info(
|
||||
&self,
|
||||
_: &str,
|
||||
) -> Result<repo_types::PlaybackInfo, repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn get_audio_stream_url(&self, _: &str) -> Result<String, repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn get_live_tv_channels(&self) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
|
||||
async fn get_live_tv_channels(
|
||||
&self,
|
||||
) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn get_channels(&self) -> Result<repo_types::SearchResult, repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn open_live_stream(&self, _: &str) -> Result<repo_types::LiveStreamInfo, repo_types::RepoError> {
|
||||
async fn open_live_stream(
|
||||
&self,
|
||||
_: &str,
|
||||
) -> Result<repo_types::LiveStreamInfo, repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn report_playback_start(&self, _: &str, _: i64) -> Result<(), repo_types::RepoError> {
|
||||
async fn report_playback_start(
|
||||
&self,
|
||||
_: &str,
|
||||
_: i64,
|
||||
) -> Result<(), repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn report_playback_progress(&self, _: &str, _: i64) -> Result<(), repo_types::RepoError> {
|
||||
async fn report_playback_progress(
|
||||
&self,
|
||||
_: &str,
|
||||
_: i64,
|
||||
) -> Result<(), repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn report_playback_stopped(&self, _: &str, _: i64) -> Result<(), repo_types::RepoError> {
|
||||
async fn report_playback_stopped(
|
||||
&self,
|
||||
_: &str,
|
||||
_: i64,
|
||||
) -> Result<(), repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
fn get_image_url(&self, _: &str, _: repo_types::ImageType, _: Option<repo_types::ImageOptions>) -> String {
|
||||
fn get_image_url(
|
||||
&self,
|
||||
_: &str,
|
||||
_: repo_types::ImageType,
|
||||
_: Option<repo_types::ImageOptions>,
|
||||
) -> String {
|
||||
unimplemented!()
|
||||
}
|
||||
fn get_subtitle_url(&self, _: &str, _: &str, _: i32, _: &str) -> String {
|
||||
@@ -2062,16 +2338,31 @@ mod tests {
|
||||
async fn unmark_favorite(&self, _: &str) -> Result<(), repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn get_person(&self, _: &str) -> Result<repo_types::MediaItem, repo_types::RepoError> {
|
||||
async fn get_person(
|
||||
&self,
|
||||
_: &str,
|
||||
) -> Result<repo_types::MediaItem, repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn get_items_by_person(&self, _: &str, _: Option<repo_types::GetItemsOptions>) -> Result<repo_types::SearchResult, repo_types::RepoError> {
|
||||
async fn get_items_by_person(
|
||||
&self,
|
||||
_: &str,
|
||||
_: Option<repo_types::GetItemsOptions>,
|
||||
) -> Result<repo_types::SearchResult, repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn get_similar_items(&self, _: &str, _: Option<usize>) -> Result<repo_types::SearchResult, repo_types::RepoError> {
|
||||
async fn get_similar_items(
|
||||
&self,
|
||||
_: &str,
|
||||
_: Option<usize>,
|
||||
) -> Result<repo_types::SearchResult, repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn create_playlist(&self, _: &str, _: &[String]) -> Result<repo_types::PlaylistCreatedResult, repo_types::RepoError> {
|
||||
async fn create_playlist(
|
||||
&self,
|
||||
_: &str,
|
||||
_: &[String],
|
||||
) -> Result<repo_types::PlaylistCreatedResult, repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn delete_playlist(&self, _: &str) -> Result<(), repo_types::RepoError> {
|
||||
@@ -2080,16 +2371,32 @@ mod tests {
|
||||
async fn rename_playlist(&self, _: &str, _: &str) -> Result<(), repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn get_playlist_items(&self, _: &str) -> Result<Vec<repo_types::PlaylistEntry>, repo_types::RepoError> {
|
||||
async fn get_playlist_items(
|
||||
&self,
|
||||
_: &str,
|
||||
) -> Result<Vec<repo_types::PlaylistEntry>, repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn add_to_playlist(&self, _: &str, _: &[String]) -> Result<(), repo_types::RepoError> {
|
||||
async fn add_to_playlist(
|
||||
&self,
|
||||
_: &str,
|
||||
_: &[String],
|
||||
) -> Result<(), repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn remove_from_playlist(&self, _: &str, _: &[String]) -> Result<(), repo_types::RepoError> {
|
||||
async fn remove_from_playlist(
|
||||
&self,
|
||||
_: &str,
|
||||
_: &[String],
|
||||
) -> Result<(), repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn move_playlist_item(&self, _: &str, _: &str, _: u32) -> Result<(), repo_types::RepoError> {
|
||||
async fn move_playlist_item(
|
||||
&self,
|
||||
_: &str,
|
||||
_: &str,
|
||||
_: u32,
|
||||
) -> Result<(), repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
use crate::utils::lock::MutexSafe;
|
||||
use log::{debug, error, info, warn};
|
||||
use super::backend::{PlayerBackend, PlayerError};
|
||||
use super::events::{PlayerEventEmitter, PlayerStatusEvent};
|
||||
use super::media::{MediaItem, MediaSource};
|
||||
use super::state::PlayerState;
|
||||
use crate::playback_reporting::{EventThrottler, PlaybackOperation, PlaybackReporter};
|
||||
use crate::settings::AudioSettings;
|
||||
use crate::playback_reporting::{PlaybackReporter, EventThrottler, PlaybackOperation};
|
||||
use crate::utils::conversions::{seconds_to_ticks, volume_to_percent};
|
||||
use crate::utils::lock::MutexSafe;
|
||||
use libmpv::Mpv;
|
||||
use log::{debug, error, info, warn};
|
||||
use std::process::Command;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
use tokio::sync::Mutex as TokioMutex;
|
||||
|
||||
@@ -104,11 +104,17 @@ impl MpvBackend {
|
||||
|
||||
// Detect and configure audio output
|
||||
let audio_driver = detect_audio_system();
|
||||
info!("[MpvBackend] Configuring audio output driver: {}", audio_driver);
|
||||
info!(
|
||||
"[MpvBackend] Configuring audio output driver: {}",
|
||||
audio_driver
|
||||
);
|
||||
|
||||
mpv.set_property("ao", audio_driver.as_str())
|
||||
.map_err(|e| PlayerError {
|
||||
message: format!("Failed to set audio output to '{}': {:?}. Make sure audio system is working.", audio_driver, e),
|
||||
message: format!(
|
||||
"Failed to set audio output to '{}': {:?}. Make sure audio system is working.",
|
||||
audio_driver, e
|
||||
),
|
||||
})?;
|
||||
|
||||
// Enable verbose logging for audio initialization
|
||||
@@ -123,10 +129,9 @@ impl MpvBackend {
|
||||
message: format!("Failed to configure MPV audio-display: {:?}", e),
|
||||
})?;
|
||||
|
||||
mpv.set_property("video", "no")
|
||||
.map_err(|e| PlayerError {
|
||||
message: format!("Failed to configure MPV video: {:?}", e),
|
||||
})?;
|
||||
mpv.set_property("video", "no").map_err(|e| PlayerError {
|
||||
message: format!("Failed to configure MPV video: {:?}", e),
|
||||
})?;
|
||||
|
||||
// Set volume to 100% (we'll control via MPV's volume property)
|
||||
mpv.set_property("volume", 100i64)
|
||||
@@ -191,7 +196,11 @@ impl MpvBackend {
|
||||
libmpv::events::Event::PlaybackRestart => {
|
||||
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_safe()
|
||||
.current_media
|
||||
.as_ref()
|
||||
.map(|m| m.id.clone());
|
||||
|
||||
if let Some(emitter) = &event_emitter {
|
||||
emitter.emit(PlayerStatusEvent::StateChanged {
|
||||
@@ -203,11 +212,16 @@ impl MpvBackend {
|
||||
libmpv::events::Event::PropertyChange { name, .. } if name == "pause" => {
|
||||
// Handle pause state changes
|
||||
if let Ok(is_paused) = mpv.get_property::<bool>("pause") {
|
||||
let media_id = state.lock_safe().current_media.as_ref().map(|m| m.id.clone());
|
||||
let media_id = state
|
||||
.lock_safe()
|
||||
.current_media
|
||||
.as_ref()
|
||||
.map(|m| m.id.clone());
|
||||
|
||||
if let Some(emitter) = &event_emitter {
|
||||
emitter.emit(PlayerStatusEvent::StateChanged {
|
||||
state: if is_paused { "paused" } else { "playing" }.to_string(),
|
||||
state: if is_paused { "paused" } else { "playing" }
|
||||
.to_string(),
|
||||
media_id,
|
||||
});
|
||||
}
|
||||
@@ -303,14 +317,18 @@ impl MpvBackend {
|
||||
}
|
||||
|
||||
// Check if we're playing for progress reporting
|
||||
let is_paused = mpv_for_position.get_property::<bool>("pause").unwrap_or(true);
|
||||
let is_paused = mpv_for_position
|
||||
.get_property::<bool>("pause")
|
||||
.unwrap_or(true);
|
||||
|
||||
// Only report progress to server when playing (not paused)
|
||||
if !is_paused {
|
||||
// Throttled progress reporting (every 30s)
|
||||
let jellyfin_id = {
|
||||
let state = state_for_position.lock_safe();
|
||||
state.current_media.as_ref()
|
||||
state
|
||||
.current_media
|
||||
.as_ref()
|
||||
.and_then(|m| m.jellyfin_id().map(|s| s.to_string()))
|
||||
};
|
||||
|
||||
@@ -333,8 +351,14 @@ impl MpvBackend {
|
||||
};
|
||||
|
||||
match reporter_instance.report(operation, true).await {
|
||||
Ok(_) => debug!("[MpvBackend] Reported progress for {}", item_id_clone),
|
||||
Err(e) => warn!("[MpvBackend] Failed to report progress: {}", e),
|
||||
Ok(_) => debug!(
|
||||
"[MpvBackend] Reported progress for {}",
|
||||
item_id_clone
|
||||
),
|
||||
Err(e) => warn!(
|
||||
"[MpvBackend] Failed to report progress: {}",
|
||||
e
|
||||
),
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -468,9 +492,7 @@ impl PlayerBackend for MpvBackend {
|
||||
}
|
||||
|
||||
fn position(&self) -> f64 {
|
||||
self.mpv
|
||||
.get_property::<f64>("time-pos")
|
||||
.unwrap_or(0.0)
|
||||
self.mpv.get_property::<f64>("time-pos").unwrap_or(0.0)
|
||||
}
|
||||
|
||||
fn duration(&self) -> Option<f64> {
|
||||
|
||||
@@ -86,7 +86,10 @@ mod tests {
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
|
||||
let count = *counter.lock().unwrap();
|
||||
assert_eq!(count, 1, "Fallback pattern should execute async code successfully");
|
||||
assert_eq!(
|
||||
count, 1,
|
||||
"Fallback pattern should execute async code successfully"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test that position update logic works in a thread
|
||||
@@ -113,7 +116,11 @@ mod tests {
|
||||
handle.join().unwrap();
|
||||
|
||||
let recorded_positions = positions.lock().unwrap();
|
||||
assert_eq!(recorded_positions.len(), 5, "Should have recorded 5 position updates");
|
||||
assert_eq!(
|
||||
recorded_positions.len(),
|
||||
5,
|
||||
"Should have recorded 5 position updates"
|
||||
);
|
||||
|
||||
// Verify positions are increasing
|
||||
for (i, pos) in recorded_positions.iter().enumerate() {
|
||||
|
||||
@@ -149,7 +149,10 @@ impl QueueManager {
|
||||
}
|
||||
|
||||
let insert_index = match position {
|
||||
AddPosition::Next => self.current_index.map(|i| i + 1).unwrap_or(self.items.len()),
|
||||
AddPosition::Next => self
|
||||
.current_index
|
||||
.map(|i| i + 1)
|
||||
.unwrap_or(self.items.len()),
|
||||
AddPosition::End => self.items.len(),
|
||||
};
|
||||
|
||||
@@ -167,10 +170,7 @@ impl QueueManager {
|
||||
|
||||
// Regenerate shuffle order if shuffle is on
|
||||
if self.shuffle {
|
||||
self.shuffle_order = self.generate_shuffle_order(
|
||||
self.items.len(),
|
||||
self.current_index,
|
||||
);
|
||||
self.shuffle_order = self.generate_shuffle_order(self.items.len(), self.current_index);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,7 +199,8 @@ impl QueueManager {
|
||||
|
||||
// Update shuffle order
|
||||
if self.shuffle {
|
||||
self.shuffle_order = self.shuffle_order
|
||||
self.shuffle_order = self
|
||||
.shuffle_order
|
||||
.iter()
|
||||
.filter(|&&i| i != index)
|
||||
.map(|&i| if i > index { i - 1 } else { i })
|
||||
@@ -239,7 +240,10 @@ impl QueueManager {
|
||||
} else if self.repeat == RepeatMode::All {
|
||||
0
|
||||
} else {
|
||||
log::debug!("[Queue] next() at end of queue (index {}), no next track", current);
|
||||
log::debug!(
|
||||
"[Queue] next() at end of queue (index {}), no next track",
|
||||
current
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
@@ -269,8 +273,11 @@ impl QueueManager {
|
||||
if let Some(prev) = self.history.pop() {
|
||||
// Safety check: ensure the history entry is valid
|
||||
if prev >= self.items.len() {
|
||||
log::warn!("[Queue] Invalid history entry {} (queue has {} items), clearing history",
|
||||
prev, self.items.len());
|
||||
log::warn!(
|
||||
"[Queue] Invalid history entry {} (queue has {} items), clearing history",
|
||||
prev,
|
||||
self.items.len()
|
||||
);
|
||||
self.history.clear();
|
||||
return None;
|
||||
}
|
||||
@@ -334,10 +341,7 @@ impl QueueManager {
|
||||
self.shuffle = !self.shuffle;
|
||||
|
||||
if self.shuffle && !self.items.is_empty() {
|
||||
self.shuffle_order = self.generate_shuffle_order(
|
||||
self.items.len(),
|
||||
self.current_index,
|
||||
);
|
||||
self.shuffle_order = self.generate_shuffle_order(self.items.len(), self.current_index);
|
||||
} else {
|
||||
self.shuffle_order.clear();
|
||||
}
|
||||
@@ -371,7 +375,8 @@ impl QueueManager {
|
||||
true
|
||||
} else if self.shuffle {
|
||||
let pos = self.shuffle_order.iter().position(|&i| i == current);
|
||||
pos.map(|p| p + 1 < self.shuffle_order.len()).unwrap_or(false)
|
||||
pos.map(|p| p + 1 < self.shuffle_order.len())
|
||||
.unwrap_or(false)
|
||||
} else {
|
||||
current + 1 < self.items.len()
|
||||
}
|
||||
@@ -473,8 +478,7 @@ impl QueueManager {
|
||||
// Update shuffle order if shuffle is on
|
||||
if self.shuffle && !self.shuffle_order.is_empty() {
|
||||
// Regenerate shuffle order to maintain consistency
|
||||
self.shuffle_order =
|
||||
self.generate_shuffle_order(self.items.len(), self.current_index);
|
||||
self.shuffle_order = self.generate_shuffle_order(self.items.len(), self.current_index);
|
||||
}
|
||||
|
||||
true
|
||||
@@ -486,7 +490,10 @@ impl QueueManager {
|
||||
if let Some(current_index) = self.current_index {
|
||||
if let Some(item) = self.items.get_mut(current_index) {
|
||||
// Only update if it's a Remote source
|
||||
if let MediaSource::Remote { jellyfin_item_id, .. } = &item.source {
|
||||
if let MediaSource::Remote {
|
||||
jellyfin_item_id, ..
|
||||
} = &item.source
|
||||
{
|
||||
item.source = MediaSource::Remote {
|
||||
stream_url: new_url,
|
||||
jellyfin_item_id: jellyfin_item_id.clone(),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use super::media::MediaItem;
|
||||
/**
|
||||
* Media Session Management
|
||||
*
|
||||
@@ -7,10 +8,8 @@
|
||||
*
|
||||
* See docs/architecture/01-rust-backend.md for the state machine diagram.
|
||||
*/
|
||||
|
||||
use log::info;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use super::media::MediaItem;
|
||||
|
||||
/// Media session type tracking the high-level playback context
|
||||
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
@@ -98,7 +97,10 @@ impl MediaSessionManager {
|
||||
/// Start an audio session with a queue
|
||||
/// Transitions: Idle → Audio(active), Any → Audio(active)
|
||||
pub fn start_audio_session(&mut self, first_item: MediaItem) {
|
||||
info!("[MediaSession] Starting audio session: {}", first_item.title);
|
||||
info!(
|
||||
"[MediaSession] Starting audio session: {}",
|
||||
first_item.title
|
||||
);
|
||||
self.current = MediaSessionType::Audio {
|
||||
last_item: Some(first_item),
|
||||
is_active: true,
|
||||
@@ -107,7 +109,11 @@ impl MediaSessionManager {
|
||||
|
||||
/// Update audio session with new track (during playback)
|
||||
pub fn update_audio_track(&mut self, item: MediaItem) {
|
||||
if let MediaSessionType::Audio { last_item, is_active } = &mut self.current {
|
||||
if let MediaSessionType::Audio {
|
||||
last_item,
|
||||
is_active,
|
||||
} = &mut self.current
|
||||
{
|
||||
info!("[MediaSession] Updating audio track: {}", item.title);
|
||||
*last_item = Some(item);
|
||||
*is_active = true;
|
||||
@@ -171,8 +177,14 @@ impl MediaSessionManager {
|
||||
|
||||
/// Advance to next episode in TV session
|
||||
pub fn tv_session_next_episode(&mut self, next_item: MediaItem) {
|
||||
if let MediaSessionType::TvShow { item, is_active, .. } = &mut self.current {
|
||||
info!("[MediaSession] Advancing to next episode: {}", next_item.title);
|
||||
if let MediaSessionType::TvShow {
|
||||
item, is_active, ..
|
||||
} = &mut self.current
|
||||
{
|
||||
info!(
|
||||
"[MediaSession] Advancing to next episode: {}",
|
||||
next_item.title
|
||||
);
|
||||
*item = next_item;
|
||||
*is_active = true;
|
||||
}
|
||||
@@ -294,7 +306,10 @@ mod tests {
|
||||
|
||||
assert!(matches!(
|
||||
manager.current(),
|
||||
MediaSessionType::Audio { is_active: true, .. }
|
||||
MediaSessionType::Audio {
|
||||
is_active: true,
|
||||
..
|
||||
}
|
||||
));
|
||||
assert!(manager.should_show_miniplayer());
|
||||
|
||||
@@ -307,7 +322,10 @@ mod tests {
|
||||
manager.audio_session_inactive();
|
||||
assert!(matches!(
|
||||
manager.current(),
|
||||
MediaSessionType::Audio { is_active: false, .. }
|
||||
MediaSessionType::Audio {
|
||||
is_active: false,
|
||||
..
|
||||
}
|
||||
));
|
||||
assert!(manager.should_show_miniplayer()); // Still shows!
|
||||
|
||||
@@ -330,7 +348,10 @@ mod tests {
|
||||
|
||||
assert!(matches!(
|
||||
manager.current(),
|
||||
MediaSessionType::Movie { is_active: true, .. }
|
||||
MediaSessionType::Movie {
|
||||
is_active: true,
|
||||
..
|
||||
}
|
||||
));
|
||||
assert!(manager.should_show_video_player());
|
||||
|
||||
|
||||
@@ -199,7 +199,9 @@ mod tests {
|
||||
#[test]
|
||||
fn test_player_state_loading() {
|
||||
let media = create_test_media_item("item-1", "Test Item");
|
||||
let state = PlayerState::Loading { media: media.clone() };
|
||||
let state = PlayerState::Loading {
|
||||
media: media.clone(),
|
||||
};
|
||||
assert!(matches!(state, PlayerState::Loading { .. }));
|
||||
assert_eq!(state.position(), None);
|
||||
assert!(!state.is_playing());
|
||||
|
||||
+473
-163
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,12 @@
|
||||
pub mod types;
|
||||
pub mod online;
|
||||
pub mod offline;
|
||||
pub mod hybrid;
|
||||
pub mod offline;
|
||||
pub mod online;
|
||||
pub mod types;
|
||||
|
||||
pub use types::*;
|
||||
pub use online::{OnlineRepository, JRayActor};
|
||||
pub use offline::OfflineRepository;
|
||||
pub use hybrid::HybridRepository;
|
||||
pub use offline::OfflineRepository;
|
||||
pub use online::{JRayActor, OnlineRepository};
|
||||
pub use types::*;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
@@ -242,10 +242,7 @@ pub trait MediaRepository: Send + Sync {
|
||||
///
|
||||
/// @req: UR-014 - Make and edit playlists of music that sync back to Jellyfin
|
||||
/// @req: JA-019 - Get/create/update playlists
|
||||
async fn get_playlist_items(
|
||||
&self,
|
||||
playlist_id: &str,
|
||||
) -> Result<Vec<PlaylistEntry>, RepoError>;
|
||||
async fn get_playlist_items(&self, playlist_id: &str) -> Result<Vec<PlaylistEntry>, RepoError>;
|
||||
|
||||
/// Add items to a playlist
|
||||
///
|
||||
|
||||
+439
-143
File diff suppressed because it is too large
Load Diff
@@ -1,15 +1,15 @@
|
||||
//! TRACES: UR-002, UR-007 | DR-013 | IR-010
|
||||
|
||||
use std::sync::Arc;
|
||||
use async_trait::async_trait;
|
||||
use log::{debug, error, info};
|
||||
#[cfg(target_os = "android")]
|
||||
use log::warn;
|
||||
use log::{debug, error, info};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::{types::*, MediaRepository};
|
||||
use crate::connectivity::ConnectivityReporter;
|
||||
use crate::jellyfin::HttpClient;
|
||||
use super::{MediaRepository, types::*};
|
||||
|
||||
/// A single actor returned by the JRay plugin's "context at time t" endpoint.
|
||||
///
|
||||
@@ -108,22 +108,34 @@ impl OnlineRepository {
|
||||
/// Download raw bytes from a URL using the shared authenticated HTTP client.
|
||||
/// Used by thumbnail cache to download images with proper auth and connection reuse.
|
||||
pub async fn download_bytes(&self, url: &str) -> Result<Vec<u8>, String> {
|
||||
let request = self.http_client.client.get(url)
|
||||
let request = self
|
||||
.http_client
|
||||
.client
|
||||
.get(url)
|
||||
.header("X-Emby-Authorization", self.auth_header())
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to build request: {}", e))?;
|
||||
|
||||
let response = self.http_client.request_with_retry(request).await
|
||||
let response = self
|
||||
.http_client
|
||||
.request_with_retry(request)
|
||||
.await
|
||||
.map_err(|e| format!("Download failed: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
let body_preview = if body.len() > 200 { &body[..200] } else { &body };
|
||||
let body_preview = if body.len() > 200 {
|
||||
&body[..200]
|
||||
} else {
|
||||
&body
|
||||
};
|
||||
return Err(format!("HTTP {} ({})", status, body_preview.trim()));
|
||||
}
|
||||
|
||||
response.bytes().await
|
||||
response
|
||||
.bytes()
|
||||
.await
|
||||
.map(|b| b.to_vec())
|
||||
.map_err(|e| format!("Failed to read bytes: {}", e))
|
||||
}
|
||||
@@ -132,7 +144,11 @@ impl OnlineRepository {
|
||||
/// the given item. Returns an empty list when the plugin isn't installed or
|
||||
/// has no truth data for the item (HTTP 404), so callers can treat "no JRay"
|
||||
/// and "nobody on screen" identically. Other failures propagate.
|
||||
pub async fn get_jray_actors(&self, item_id: &str, t: f64) -> Result<Vec<JRayActor>, RepoError> {
|
||||
pub async fn get_jray_actors(
|
||||
&self,
|
||||
item_id: &str,
|
||||
t: f64,
|
||||
) -> Result<Vec<JRayActor>, RepoError> {
|
||||
let endpoint = format!("/Plugins/JRay/Items/{}/jray?t={}", item_id, t);
|
||||
match self.get_json::<JRayContext>(&endpoint).await {
|
||||
Ok(context) => Ok(context.actors),
|
||||
@@ -160,18 +176,29 @@ impl OnlineRepository {
|
||||
result
|
||||
}
|
||||
|
||||
async fn get_json_inner<T: for<'de> Deserialize<'de>>(&self, endpoint: &str) -> Result<T, RepoError> {
|
||||
async fn get_json_inner<T: for<'de> Deserialize<'de>>(
|
||||
&self,
|
||||
endpoint: &str,
|
||||
) -> Result<T, RepoError> {
|
||||
let url = format!("{}{}", self.server_url, endpoint);
|
||||
|
||||
let request = self.http_client.client.get(&url)
|
||||
let request = self
|
||||
.http_client
|
||||
.client
|
||||
.get(&url)
|
||||
.header("X-Emby-Authorization", self.auth_header())
|
||||
.build()
|
||||
.map_err(|e| RepoError::Network {
|
||||
message: format!("Failed to build request: {}", e),
|
||||
})?;
|
||||
|
||||
let response = self.http_client.request_with_retry(request).await
|
||||
.map_err(|e| RepoError::Network { message: e.to_string() })?;
|
||||
let response = self
|
||||
.http_client
|
||||
.request_with_retry(request)
|
||||
.await
|
||||
.map_err(|e| RepoError::Network {
|
||||
message: e.to_string(),
|
||||
})?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
@@ -197,9 +224,18 @@ impl OnlineRepository {
|
||||
|
||||
// Try to deserialize and log the raw JSON on error
|
||||
serde_json::from_str(&text).map_err(|e| {
|
||||
error!("[OnlineRepo] Failed to deserialize {} response: {}", endpoint, e);
|
||||
error!("[OnlineRepo] Response body (first 1000 chars): {}",
|
||||
if text.len() > 1000 { &text[..1000] } else { &text });
|
||||
error!(
|
||||
"[OnlineRepo] Failed to deserialize {} response: {}",
|
||||
endpoint, e
|
||||
);
|
||||
error!(
|
||||
"[OnlineRepo] Response body (first 1000 chars): {}",
|
||||
if text.len() > 1000 {
|
||||
&text[..1000]
|
||||
} else {
|
||||
&text
|
||||
}
|
||||
);
|
||||
RepoError::Server {
|
||||
message: format!("Failed to parse response: {}", e),
|
||||
}
|
||||
@@ -213,10 +249,17 @@ impl OnlineRepository {
|
||||
result
|
||||
}
|
||||
|
||||
async fn post_json_inner<T: Serialize>(&self, endpoint: &str, body: &T) -> Result<(), RepoError> {
|
||||
async fn post_json_inner<T: Serialize>(
|
||||
&self,
|
||||
endpoint: &str,
|
||||
body: &T,
|
||||
) -> Result<(), RepoError> {
|
||||
let url = format!("{}{}", self.server_url, endpoint);
|
||||
|
||||
let request = self.http_client.client.post(&url)
|
||||
let request = self
|
||||
.http_client
|
||||
.client
|
||||
.post(&url)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("X-Emby-Authorization", self.auth_header())
|
||||
.json(body)
|
||||
@@ -225,8 +268,13 @@ impl OnlineRepository {
|
||||
message: format!("Failed to build request: {}", e),
|
||||
})?;
|
||||
|
||||
let response = self.http_client.request_with_retry(request).await
|
||||
.map_err(|e| RepoError::Network { message: e.to_string() })?;
|
||||
let response = self
|
||||
.http_client
|
||||
.request_with_retry(request)
|
||||
.await
|
||||
.map_err(|e| RepoError::Network {
|
||||
message: e.to_string(),
|
||||
})?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
@@ -268,7 +316,10 @@ impl OnlineRepository {
|
||||
debug!("[HTTP] Request body:\n{}", json);
|
||||
}
|
||||
|
||||
let request = self.http_client.client.post(&url)
|
||||
let request = self
|
||||
.http_client
|
||||
.client
|
||||
.post(&url)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("X-Emby-Authorization", self.auth_header())
|
||||
.json(body)
|
||||
@@ -277,14 +328,22 @@ impl OnlineRepository {
|
||||
message: format!("Failed to build request: {}", e),
|
||||
})?;
|
||||
|
||||
let response = self.http_client.request_with_retry(request).await
|
||||
.map_err(|e| RepoError::Network { message: e.to_string() })?;
|
||||
let response = self
|
||||
.http_client
|
||||
.request_with_retry(request)
|
||||
.await
|
||||
.map_err(|e| RepoError::Network {
|
||||
message: e.to_string(),
|
||||
})?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
|
||||
// Capture response body for error details
|
||||
let error_body = response.text().await.unwrap_or_else(|_| "Failed to read error body".to_string());
|
||||
let error_body = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "Failed to read error body".to_string());
|
||||
error!("[HTTP] Error response ({}): {}", status, error_body);
|
||||
|
||||
if status.as_u16() == 401 || status.as_u16() == 403 {
|
||||
@@ -325,8 +384,7 @@ impl OnlineRepository {
|
||||
audio_stream_index: Option<i32>,
|
||||
) -> Result<String, RepoError> {
|
||||
// Convert seconds to ticks (10,000,000 ticks per second)
|
||||
let start_time_ticks = start_time_seconds
|
||||
.map(|seconds| (seconds * 10_000_000.0) as i64);
|
||||
let start_time_ticks = start_time_seconds.map(|seconds| (seconds * 10_000_000.0) as i64);
|
||||
|
||||
// Use provided audio stream index, or default to 0
|
||||
let audio_index = audio_stream_index.unwrap_or(0).to_string();
|
||||
@@ -370,6 +428,69 @@ impl OnlineRepository {
|
||||
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
/// Get an **audio-only** stream URL for a *video* item, for the
|
||||
/// background-audio handoff (UR-040).
|
||||
///
|
||||
/// TRACES: UR-040 | JA-032 | UT-059
|
||||
///
|
||||
/// This deliberately targets `/Audio/{id}/universal`, NOT the video stream:
|
||||
/// the server extracts/transcodes only the item's audio track and streams
|
||||
/// pure audio bytes — no video frames reach the device, so there is no client
|
||||
/// video decode while backgrounded. Do NOT "optimize" this to reuse the
|
||||
/// `/Videos/.../master.m3u8` URL: that would keep the device decoding video,
|
||||
/// defeating the entire point of the feature.
|
||||
///
|
||||
/// `AudioStreamIndex` carries the user's currently-selected audio track over
|
||||
/// from the video player; `StartTimeTicks` resumes at the handoff position.
|
||||
/// `universal` lets the server pick direct-play vs transcode per codec/device.
|
||||
///
|
||||
/// The stream is a **progressive** container (mp3 over plain HTTP), NOT HLS:
|
||||
/// ExoPlayer plays this natively, whereas an HLS/`ts` transcode on the
|
||||
/// `/universal` endpoint (no `.m3u8` in the path) fails its progressive
|
||||
/// loader with `ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED`. mp3 is universally
|
||||
/// decodable and supports mid-stream `StartTimeTicks`.
|
||||
pub async fn get_audio_only_stream_url_for_video(
|
||||
&self,
|
||||
item_id: &str,
|
||||
media_source_id: Option<&str>,
|
||||
start_time_seconds: Option<f64>,
|
||||
audio_stream_index: Option<i32>,
|
||||
) -> Result<String, RepoError> {
|
||||
let audio_index = audio_stream_index.unwrap_or(0).to_string();
|
||||
|
||||
let mut params = vec![
|
||||
("UserId", self.user_id.clone()),
|
||||
("api_key", self.access_token.clone()),
|
||||
("DeviceId", "jellytau-tauri".to_string()),
|
||||
("AudioStreamIndex", audio_index),
|
||||
// Progressive mp3 over HTTP — ExoPlayer-friendly; no HLS/ts.
|
||||
("Container", "mp3".to_string()),
|
||||
("AudioCodec", "mp3".to_string()),
|
||||
("TranscodingContainer", "mp3".to_string()),
|
||||
("TranscodingProtocol", "http".to_string()),
|
||||
("MaxStreamingBitrate", "384000".to_string()),
|
||||
];
|
||||
|
||||
if let Some(source_id) = media_source_id {
|
||||
params.push(("MediaSourceId", source_id.to_string()));
|
||||
}
|
||||
|
||||
if let Some(seconds) = start_time_seconds {
|
||||
let ticks = (seconds * 10_000_000.0) as i64;
|
||||
params.push(("StartTimeTicks", ticks.to_string()));
|
||||
}
|
||||
|
||||
let query = params
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{}={}", k, v))
|
||||
.collect::<Vec<_>>()
|
||||
.join("&");
|
||||
|
||||
let url = format!("{}/Audio/{}/universal?{}", self.server_url, item_id, query);
|
||||
|
||||
Ok(url)
|
||||
}
|
||||
}
|
||||
|
||||
// Jellyfin API response types (PascalCase from server)
|
||||
@@ -707,7 +828,10 @@ impl MediaRepository for OnlineRepository {
|
||||
limit: Option<usize>,
|
||||
) -> Result<Vec<MediaItem>, RepoError> {
|
||||
let limit_str = limit.unwrap_or(16);
|
||||
let mut endpoint = format!("/Shows/NextUp?UserId={}&Limit={}&Fields=BackdropImageTags,ParentBackdropImageTags", self.user_id, limit_str);
|
||||
let mut endpoint = format!(
|
||||
"/Shows/NextUp?UserId={}&Limit={}&Fields=BackdropImageTags,ParentBackdropImageTags",
|
||||
self.user_id, limit_str
|
||||
);
|
||||
|
||||
if let Some(sid) = series_id {
|
||||
endpoint.push_str(&format!("&SeriesId={}", sid));
|
||||
@@ -753,14 +877,19 @@ impl MediaRepository for OnlineRepository {
|
||||
|
||||
for item in items {
|
||||
// Use album_id if available, fall back to album_name for grouping
|
||||
let group_key = item.album_id.clone()
|
||||
.or_else(|| item.album_name.clone());
|
||||
let group_key = item.album_id.clone().or_else(|| item.album_name.clone());
|
||||
|
||||
if let Some(key) = group_key {
|
||||
debug!("[get_recently_played_audio] Grouping item '{}' into album '{}'", item.name, key);
|
||||
debug!(
|
||||
"[get_recently_played_audio] Grouping item '{}' into album '{}'",
|
||||
item.name, key
|
||||
);
|
||||
album_map.entry(key).or_insert_with(Vec::new).push(item);
|
||||
} else {
|
||||
debug!("[get_recently_played_audio] No album_id or album_name for item: '{}'", item.name);
|
||||
debug!(
|
||||
"[get_recently_played_audio] No album_id or album_name for item: '{}'",
|
||||
item.name
|
||||
);
|
||||
ungrouped.push(item);
|
||||
}
|
||||
}
|
||||
@@ -770,17 +899,29 @@ impl MediaRepository for OnlineRepository {
|
||||
.into_iter()
|
||||
.map(|(album_id, tracks)| {
|
||||
let first_track = &tracks[0];
|
||||
let most_recent = tracks.iter()
|
||||
let most_recent = tracks
|
||||
.iter()
|
||||
.max_by(|a, b| {
|
||||
let date_a = a.user_data.as_ref().and_then(|ud| ud.last_played_date.as_deref()).unwrap_or("");
|
||||
let date_b = b.user_data.as_ref().and_then(|ud| ud.last_played_date.as_deref()).unwrap_or("");
|
||||
let date_a = a
|
||||
.user_data
|
||||
.as_ref()
|
||||
.and_then(|ud| ud.last_played_date.as_deref())
|
||||
.unwrap_or("");
|
||||
let date_b = b
|
||||
.user_data
|
||||
.as_ref()
|
||||
.and_then(|ud| ud.last_played_date.as_deref())
|
||||
.unwrap_or("");
|
||||
date_b.cmp(date_a)
|
||||
})
|
||||
.unwrap_or(first_track);
|
||||
|
||||
MediaItem {
|
||||
id: album_id,
|
||||
name: first_track.album_name.clone().unwrap_or_else(|| "Unknown Album".to_string()),
|
||||
name: first_track
|
||||
.album_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| "Unknown Album".to_string()),
|
||||
item_type: "MusicAlbum".to_string(),
|
||||
is_folder: true,
|
||||
server_id: first_track.server_id.clone(),
|
||||
@@ -820,9 +961,15 @@ impl MediaRepository for OnlineRepository {
|
||||
|
||||
// Return only the requested limit
|
||||
let final_result: Vec<MediaItem> = result.into_iter().take(limit_val).collect();
|
||||
debug!("[get_recently_played_audio] Returning {} items after grouping", final_result.len());
|
||||
debug!(
|
||||
"[get_recently_played_audio] Returning {} items after grouping",
|
||||
final_result.len()
|
||||
);
|
||||
for item in &final_result {
|
||||
debug!("[get_recently_played_audio] Return: name={}, type={}", item.name, item.item_type);
|
||||
debug!(
|
||||
"[get_recently_played_audio] Return: name={}, type={}",
|
||||
item.name, item.item_type
|
||||
);
|
||||
}
|
||||
Ok(final_result)
|
||||
}
|
||||
@@ -1061,8 +1208,8 @@ impl MediaRepository for OnlineRepository {
|
||||
|
||||
// Get detected codecs from Android MediaCodecList or use platform defaults
|
||||
#[cfg(target_os = "android")]
|
||||
let (video_codecs, audio_codecs) = crate::player::get_detected_codecs()
|
||||
.unwrap_or_else(|| {
|
||||
let (video_codecs, audio_codecs) =
|
||||
crate::player::get_detected_codecs().unwrap_or_else(|| {
|
||||
warn!("[DeviceProfile] Codec detection not complete, using conservative defaults");
|
||||
("h264,hevc".to_string(), "aac,mp3".to_string())
|
||||
});
|
||||
@@ -1073,10 +1220,8 @@ impl MediaRepository for OnlineRepository {
|
||||
// (Audio-only files still direct-play via MPV, but the PlaybackInfo
|
||||
// profile is shared, so we keep the broadly-supported audio codecs.)
|
||||
#[cfg(all(not(target_os = "android"), target_os = "linux"))]
|
||||
let (video_codecs, audio_codecs) = (
|
||||
"h264".to_string(),
|
||||
"aac,mp3,opus,vorbis,flac".to_string(),
|
||||
);
|
||||
let (video_codecs, audio_codecs) =
|
||||
("h264".to_string(), "aac,mp3,opus,vorbis,flac".to_string());
|
||||
|
||||
#[cfg(all(not(target_os = "android"), not(target_os = "linux")))]
|
||||
let (video_codecs, audio_codecs) = (
|
||||
@@ -1139,25 +1284,31 @@ impl MediaRepository for OnlineRepository {
|
||||
// POST to PlaybackInfo with device profile containing detected codecs
|
||||
let request_body = PlaybackInfoRequest {
|
||||
user_id: self.user_id.clone(),
|
||||
audio_stream_index: 0, // Request first audio stream
|
||||
audio_stream_index: 0, // Request first audio stream
|
||||
subtitle_stream_index: None,
|
||||
start_time_ticks: 0,
|
||||
is_playback: true,
|
||||
auto_open_live_stream: true,
|
||||
max_streaming_bitrate: 20_000_000, // 20 Mbps
|
||||
device_profile: Some(device_profile), // Now sending profile with detected codecs
|
||||
max_streaming_bitrate: 20_000_000, // 20 Mbps
|
||||
device_profile: Some(device_profile), // Now sending profile with detected codecs
|
||||
};
|
||||
|
||||
let response: PlaybackInfoResponse = self.post_json_response(&endpoint, &request_body).await?;
|
||||
let response: PlaybackInfoResponse =
|
||||
self.post_json_response(&endpoint, &request_body).await?;
|
||||
let source = response.media_sources.first().ok_or(RepoError::NotFound {
|
||||
message: "No media sources available".to_string(),
|
||||
})?;
|
||||
|
||||
// Log available media streams for debugging
|
||||
info!("PlaybackInfo MediaSource has {} streams", source.media_streams.len());
|
||||
info!(
|
||||
"PlaybackInfo MediaSource has {} streams",
|
||||
source.media_streams.len()
|
||||
);
|
||||
for stream in &source.media_streams {
|
||||
info!(" Stream type={}, index={}, codec={:?}",
|
||||
stream.stream_type, stream.index, stream.codec);
|
||||
info!(
|
||||
" Stream type={}, index={}, codec={:?}",
|
||||
stream.stream_type, stream.index, stream.codec
|
||||
);
|
||||
}
|
||||
|
||||
// Use TranscodingUrl from response if available (Streamyfin pattern)
|
||||
@@ -1265,12 +1416,15 @@ impl MediaRepository for OnlineRepository {
|
||||
max_streaming_bitrate: 20_000_000,
|
||||
};
|
||||
|
||||
let response: OpenLiveStreamResponse =
|
||||
self.post_json_response(&endpoint, &request).await?;
|
||||
let response: OpenLiveStreamResponse = self.post_json_response(&endpoint, &request).await?;
|
||||
|
||||
let source = response.media_sources.into_iter().next().ok_or(RepoError::NotFound {
|
||||
message: "No live media source returned".to_string(),
|
||||
})?;
|
||||
let source = response
|
||||
.media_sources
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or(RepoError::NotFound {
|
||||
message: "No live media source returned".to_string(),
|
||||
})?;
|
||||
|
||||
// The transcoding URL is server-relative; make it absolute. If the server
|
||||
// did not provide one (rare for live), fall back to the HLS master endpoint.
|
||||
@@ -1410,11 +1564,7 @@ impl MediaRepository for OnlineRepository {
|
||||
) -> String {
|
||||
format!(
|
||||
"{}/Videos/{}/{}/Subtitles/{}/{}",
|
||||
self.server_url,
|
||||
item_id,
|
||||
media_source_id,
|
||||
stream_index,
|
||||
format
|
||||
self.server_url, item_id, media_source_id, stream_index, format
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1484,15 +1634,23 @@ impl MediaRepository for OnlineRepository {
|
||||
let url = format!("{}{}", self.server_url, endpoint);
|
||||
|
||||
let result = async {
|
||||
let request = self.http_client.client.delete(&url)
|
||||
let request = self
|
||||
.http_client
|
||||
.client
|
||||
.delete(&url)
|
||||
.header("X-Emby-Authorization", self.auth_header())
|
||||
.build()
|
||||
.map_err(|e| RepoError::Network {
|
||||
message: format!("Failed to build request: {}", e),
|
||||
})?;
|
||||
|
||||
let response = self.http_client.request_with_retry(request).await
|
||||
.map_err(|e| RepoError::Network { message: e.to_string() })?;
|
||||
let response = self
|
||||
.http_client
|
||||
.request_with_retry(request)
|
||||
.await
|
||||
.map_err(|e| RepoError::Network {
|
||||
message: e.to_string(),
|
||||
})?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(RepoError::Server {
|
||||
@@ -1578,15 +1736,18 @@ impl MediaRepository for OnlineRepository {
|
||||
name: &str,
|
||||
item_ids: &[String],
|
||||
) -> Result<PlaylistCreatedResult, RepoError> {
|
||||
info!("[OnlineRepo] Creating playlist '{}' with {} items", name, item_ids.len());
|
||||
info!(
|
||||
"[OnlineRepo] Creating playlist '{}' with {} items",
|
||||
name,
|
||||
item_ids.len()
|
||||
);
|
||||
let body = serde_json::json!({
|
||||
"Name": name,
|
||||
"Ids": item_ids,
|
||||
"MediaType": "Audio",
|
||||
"UserId": self.user_id,
|
||||
});
|
||||
let response: CreatePlaylistResponse =
|
||||
self.post_json_response("/Playlists", &body).await?;
|
||||
let response: CreatePlaylistResponse = self.post_json_response("/Playlists", &body).await?;
|
||||
Ok(PlaylistCreatedResult { id: response.id })
|
||||
}
|
||||
|
||||
@@ -1595,15 +1756,23 @@ impl MediaRepository for OnlineRepository {
|
||||
let endpoint = format!("/Items/{}", playlist_id);
|
||||
let url = format!("{}{}", self.server_url, endpoint);
|
||||
|
||||
let request = self.http_client.client.delete(&url)
|
||||
let request = self
|
||||
.http_client
|
||||
.client
|
||||
.delete(&url)
|
||||
.header("X-Emby-Authorization", self.auth_header())
|
||||
.build()
|
||||
.map_err(|e| RepoError::Network {
|
||||
message: format!("Failed to build request: {}", e),
|
||||
})?;
|
||||
|
||||
let response = self.http_client.request_with_retry(request).await
|
||||
.map_err(|e| RepoError::Network { message: e.to_string() })?;
|
||||
let response = self
|
||||
.http_client
|
||||
.request_with_retry(request)
|
||||
.await
|
||||
.map_err(|e| RepoError::Network {
|
||||
message: e.to_string(),
|
||||
})?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(RepoError::Server {
|
||||
@@ -1615,15 +1784,16 @@ impl MediaRepository for OnlineRepository {
|
||||
}
|
||||
|
||||
async fn rename_playlist(&self, playlist_id: &str, name: &str) -> Result<(), RepoError> {
|
||||
info!("[OnlineRepo] Renaming playlist {} to '{}'", playlist_id, name);
|
||||
info!(
|
||||
"[OnlineRepo] Renaming playlist {} to '{}'",
|
||||
playlist_id, name
|
||||
);
|
||||
let endpoint = format!("/Items/{}", playlist_id);
|
||||
self.post_json(&endpoint, &serde_json::json!({ "Name": name })).await
|
||||
self.post_json(&endpoint, &serde_json::json!({ "Name": name }))
|
||||
.await
|
||||
}
|
||||
|
||||
async fn get_playlist_items(
|
||||
&self,
|
||||
playlist_id: &str,
|
||||
) -> Result<Vec<PlaylistEntry>, RepoError> {
|
||||
async fn get_playlist_items(&self, playlist_id: &str) -> Result<Vec<PlaylistEntry>, RepoError> {
|
||||
let endpoint = format!(
|
||||
"/Playlists/{}/Items?UserId={}&Fields=PrimaryImageTag,Artists,AlbumId,Album,AlbumArtist,RunTimeTicks,ArtistItems&StartIndex=0&Limit=10000",
|
||||
playlist_id, self.user_id
|
||||
@@ -1675,15 +1845,23 @@ impl MediaRepository for OnlineRepository {
|
||||
let endpoint = format!("/Playlists/{}/Items?EntryIds={}", playlist_id, ids_param);
|
||||
let url = format!("{}{}", self.server_url, endpoint);
|
||||
|
||||
let request = self.http_client.client.delete(&url)
|
||||
let request = self
|
||||
.http_client
|
||||
.client
|
||||
.delete(&url)
|
||||
.header("X-Emby-Authorization", self.auth_header())
|
||||
.build()
|
||||
.map_err(|e| RepoError::Network {
|
||||
message: format!("Failed to build request: {}", e),
|
||||
})?;
|
||||
|
||||
let response = self.http_client.request_with_retry(request).await
|
||||
.map_err(|e| RepoError::Network { message: e.to_string() })?;
|
||||
let response = self
|
||||
.http_client
|
||||
.request_with_retry(request)
|
||||
.await
|
||||
.map_err(|e| RepoError::Network {
|
||||
message: e.to_string(),
|
||||
})?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(RepoError::Server {
|
||||
@@ -1719,9 +1897,8 @@ mod tests {
|
||||
|
||||
fn create_test_repository() -> OnlineRepository {
|
||||
let http_config = crate::jellyfin::HttpConfig::default();
|
||||
let http_client = Arc::new(
|
||||
HttpClient::new(http_config).expect("Failed to create HTTP client for test")
|
||||
);
|
||||
let http_client =
|
||||
Arc::new(HttpClient::new(http_config).expect("Failed to create HTTP client for test"));
|
||||
OnlineRepository::new(
|
||||
http_client,
|
||||
"https://test.server.com".to_string(),
|
||||
@@ -1757,9 +1934,15 @@ mod tests {
|
||||
|
||||
// Drive offline first so we can observe "recover to reachable".
|
||||
for err in [
|
||||
RepoError::Authentication { message: "401".into() },
|
||||
RepoError::NotFound { message: "404".into() },
|
||||
RepoError::Server { message: "500".into() },
|
||||
RepoError::Authentication {
|
||||
message: "401".into(),
|
||||
},
|
||||
RepoError::NotFound {
|
||||
message: "404".into(),
|
||||
},
|
||||
RepoError::Server {
|
||||
message: "500".into(),
|
||||
},
|
||||
] {
|
||||
reporter.mark_unreachable_for_test().await;
|
||||
assert!(!reporter.is_reachable().await, "precondition: offline");
|
||||
@@ -1789,7 +1972,12 @@ mod tests {
|
||||
// Force offline, then a Database/Offline error must leave it offline
|
||||
// (not falsely report reachable).
|
||||
reporter.mark_unreachable_for_test().await;
|
||||
for err in [RepoError::Database { message: "cache".into() }, RepoError::Offline] {
|
||||
for err in [
|
||||
RepoError::Database {
|
||||
message: "cache".into(),
|
||||
},
|
||||
RepoError::Offline,
|
||||
] {
|
||||
let result: Result<(), RepoError> = Err(err);
|
||||
repo.report_outcome(&result).await;
|
||||
assert!(
|
||||
@@ -1825,7 +2013,9 @@ mod tests {
|
||||
let (repo, reporter) = create_test_repository_with_connectivity();
|
||||
assert!(reporter.is_reachable().await, "starts online");
|
||||
|
||||
let result: Result<(), RepoError> = Err(RepoError::Network { message: "timeout".into() });
|
||||
let result: Result<(), RepoError> = Err(RepoError::Network {
|
||||
message: "timeout".into(),
|
||||
});
|
||||
repo.report_outcome(&result).await;
|
||||
|
||||
assert!(
|
||||
@@ -1889,6 +2079,64 @@ mod tests {
|
||||
assert!(url.contains("AudioStreamIndex=0"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_audio_only_stream_url_for_video_carries_track_and_position() {
|
||||
// TRACES: UR-040 | JA-032 | UT-059
|
||||
// Background-audio handoff must request an audio-only stream (no video
|
||||
// decode) that resumes at the current position and keeps the selected
|
||||
// audio track.
|
||||
let repo = create_test_repository();
|
||||
|
||||
let url = repo
|
||||
.get_audio_only_stream_url_for_video("vid-1", Some("source-1"), Some(193.0), Some(2))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
url.starts_with("https://test.server.com/Audio/vid-1/universal?"),
|
||||
"expected audio-only universal endpoint, got: {url}"
|
||||
);
|
||||
// Must NOT be a video stream (no client video decode in background).
|
||||
assert!(
|
||||
!url.contains("/Videos/"),
|
||||
"url must not hit the video endpoint: {url}"
|
||||
);
|
||||
assert!(
|
||||
!url.contains("master.m3u8"),
|
||||
"url must not be a video HLS playlist: {url}"
|
||||
);
|
||||
assert!(url.contains("AudioStreamIndex=2"));
|
||||
assert!(url.contains("MediaSourceId=source-1"));
|
||||
// 193.0 seconds * 10_000_000 ticks/sec
|
||||
assert!(url.contains("StartTimeTicks=1930000000"), "url: {url}");
|
||||
// Progressive mp3 over HTTP — NOT HLS/ts, or ExoPlayer's progressive
|
||||
// loader fails with ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED.
|
||||
assert!(url.contains("TranscodingProtocol=http"), "url: {url}");
|
||||
assert!(url.contains("TranscodingContainer=mp3"), "url: {url}");
|
||||
assert!(
|
||||
!url.contains("TranscodingProtocol=hls"),
|
||||
"url must not be HLS: {url}"
|
||||
);
|
||||
assert!(!url.contains("Container=ts"), "url must not be ts: {url}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_audio_only_stream_url_for_video_omits_position_when_absent() {
|
||||
// TRACES: UR-040 | JA-032 | UT-059
|
||||
let repo = create_test_repository();
|
||||
|
||||
let url = repo
|
||||
.get_audio_only_stream_url_for_video("vid-1", None, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(url.starts_with("https://test.server.com/Audio/vid-1/universal?"));
|
||||
assert!(!url.contains("StartTimeTicks"));
|
||||
assert!(!url.contains("MediaSourceId"));
|
||||
// Defaults to first audio stream.
|
||||
assert!(url.contains("AudioStreamIndex=0"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_audio_stream_url_with_special_characters() {
|
||||
let repo = create_test_repository();
|
||||
@@ -1982,8 +2230,14 @@ mod tests {
|
||||
// "original" must request a direct static copy (byte-range resumable),
|
||||
// with no transcode params.
|
||||
assert!(url.contains("Static=true"), "url: {url}");
|
||||
assert!(!url.contains("videoBitrate"), "original must not transcode: {url}");
|
||||
assert!(!url.contains("maxHeight"), "original must not transcode: {url}");
|
||||
assert!(
|
||||
!url.contains("videoBitrate"),
|
||||
"original must not transcode: {url}"
|
||||
);
|
||||
assert!(
|
||||
!url.contains("maxHeight"),
|
||||
"original must not transcode: {url}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1996,14 +2250,20 @@ mod tests {
|
||||
url.contains("/Videos/item123/stream.mp4"),
|
||||
"{quality} must use stream.mp4: {url}"
|
||||
);
|
||||
assert!(url.contains("videoBitrate="), "{quality} must set bitrate: {url}");
|
||||
assert!(
|
||||
url.contains("videoBitrate="),
|
||||
"{quality} must set bitrate: {url}"
|
||||
);
|
||||
assert!(
|
||||
url.contains(&format!("maxHeight={height}")),
|
||||
"{quality} must cap height at {height}: {url}"
|
||||
);
|
||||
assert!(url.contains("videoCodec=h264"), "{quality}: {url}");
|
||||
// Transcoded presets must not also ask for a static copy.
|
||||
assert!(!url.contains("Static=true"), "{quality} must not be Static: {url}");
|
||||
assert!(
|
||||
!url.contains("Static=true"),
|
||||
"{quality} must not be Static: {url}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2036,7 +2296,10 @@ mod tests {
|
||||
assert_eq!(item.name, "Test Album");
|
||||
assert_eq!(item.item_type, "MusicAlbum");
|
||||
assert!(item.image_tags.is_some());
|
||||
assert_eq!(item.image_tags.unwrap().primary(), Some("tag123".to_string()));
|
||||
assert_eq!(
|
||||
item.image_tags.unwrap().primary(),
|
||||
Some("tag123".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2083,7 +2346,10 @@ mod tests {
|
||||
assert_eq!(media_item.id, "album456");
|
||||
assert_eq!(media_item.name, "Love and Theft");
|
||||
assert_eq!(media_item.item_type, "MusicAlbum");
|
||||
assert_eq!(media_item.primary_image_tag, Some("7ebab4f6a80cd09d".to_string()));
|
||||
assert_eq!(
|
||||
media_item.primary_image_tag,
|
||||
Some("7ebab4f6a80cd09d".to_string())
|
||||
);
|
||||
assert_eq!(media_item.server_id, "test-server-id");
|
||||
}
|
||||
|
||||
|
||||
@@ -561,7 +561,10 @@ mod tests {
|
||||
|
||||
// Verify serialization uses camelCase for frontend
|
||||
let serialized = serde_json::to_string(&person).expect("Failed to serialize");
|
||||
assert!(serialized.contains(r#""type":"Actor""#), "Serialized form should use 'type' not 'Type'");
|
||||
assert!(
|
||||
serialized.contains(r#""type":"Actor""#),
|
||||
"Serialized form should use 'type' not 'Type'"
|
||||
);
|
||||
assert!(serialized.contains(r#""id":"person123""#));
|
||||
assert!(serialized.contains(r#""primaryImageTag":"tag456""#));
|
||||
}
|
||||
@@ -630,9 +633,15 @@ mod tests {
|
||||
|
||||
// Verify that when serialized to frontend, it uses camelCase
|
||||
let serialized = serde_json::to_string(&item).expect("Failed to serialize");
|
||||
let re_parsed: serde_json::Value = serde_json::from_str(&serialized).expect("Failed to parse serialized");
|
||||
let people_array = re_parsed["people"].as_array().expect("people should be array");
|
||||
assert!(people_array[0].get("type").is_some(), "Serialized person should have 'type' field");
|
||||
let re_parsed: serde_json::Value =
|
||||
serde_json::from_str(&serialized).expect("Failed to parse serialized");
|
||||
let people_array = re_parsed["people"]
|
||||
.as_array()
|
||||
.expect("people should be array");
|
||||
assert!(
|
||||
people_array[0].get("type").is_some(),
|
||||
"Serialized person should have 'type' field"
|
||||
);
|
||||
assert_eq!(people_array[0]["type"].as_str().unwrap(), "Actor");
|
||||
}
|
||||
|
||||
|
||||
@@ -7,14 +7,14 @@
|
||||
|
||||
use crate::utils::lock::{MutexSafe, RwLockSafe};
|
||||
use log::{debug, info, warn};
|
||||
use std::sync::{Arc, Mutex, RwLock};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex, RwLock};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::jellyfin::JellyfinClient;
|
||||
use crate::player::PlayerEventEmitter;
|
||||
use crate::playback_mode::{PlaybackMode, PlaybackModeManager};
|
||||
use crate::player::PlayerEventEmitter;
|
||||
|
||||
/// Hint for adjusting poll frequency based on UI state
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -103,10 +103,8 @@ impl SessionPollerManager {
|
||||
|
||||
while is_running.load(Ordering::Relaxed) {
|
||||
// Calculate poll interval based on mode and hint
|
||||
let new_interval = Self::calculate_interval(
|
||||
&mode_manager.get_mode(),
|
||||
*hint.read_safe(),
|
||||
);
|
||||
let new_interval =
|
||||
Self::calculate_interval(&mode_manager.get_mode(), *hint.read_safe());
|
||||
|
||||
interval_ms.store(new_interval, Ordering::Relaxed);
|
||||
|
||||
@@ -158,9 +156,7 @@ impl SessionPollerManager {
|
||||
}
|
||||
|
||||
if let Some(em) = emitter.lock_safe().as_ref() {
|
||||
em.emit(crate::player::PlayerStatusEvent::SessionsUpdated {
|
||||
sessions,
|
||||
});
|
||||
em.emit(crate::player::PlayerStatusEvent::SessionsUpdated { sessions });
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -201,10 +197,7 @@ impl SessionPollerManager {
|
||||
/// album, duration and position to the media notification. Silently does
|
||||
/// nothing if the session isn't found or has no now-playing item (e.g. the
|
||||
/// remote stopped) - the next state change will refresh it.
|
||||
fn push_remote_lockscreen(
|
||||
sessions: &[crate::jellyfin::client::SessionInfo],
|
||||
session_id: &str,
|
||||
) {
|
||||
fn push_remote_lockscreen(sessions: &[crate::jellyfin::client::SessionInfo], session_id: &str) {
|
||||
// 100ns Jellyfin ticks -> milliseconds.
|
||||
const TICKS_PER_MS: i64 = 10_000;
|
||||
|
||||
@@ -251,7 +244,10 @@ impl SessionPollerManager {
|
||||
};
|
||||
|
||||
if let Err(e) = crate::player::update_lockscreen_metadata(&meta) {
|
||||
warn!("[SessionPoller] Failed to update lockscreen metadata: {}", e);
|
||||
warn!(
|
||||
"[SessionPoller] Failed to update lockscreen metadata: {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,7 +258,7 @@ impl SessionPollerManager {
|
||||
PollingHint::CastDiscovery => 15000, // Slow discovery
|
||||
PollingHint::Normal => {
|
||||
match mode {
|
||||
PlaybackMode::Remote { .. } => 2000, // Fast in remote mode
|
||||
PlaybackMode::Remote { .. } => 2000, // Fast in remote mode
|
||||
PlaybackMode::Local | PlaybackMode::Idle => 10000, // Default
|
||||
}
|
||||
}
|
||||
@@ -271,7 +267,10 @@ impl SessionPollerManager {
|
||||
|
||||
/// Manually trigger a poll (for frontend refresh button)
|
||||
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_safe()
|
||||
.clone()
|
||||
.ok_or("Jellyfin client not configured")?;
|
||||
|
||||
client.get_sessions().await
|
||||
@@ -302,7 +301,9 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
SessionPollerManager::calculate_interval(
|
||||
&PlaybackMode::Remote { session_id: "test".to_string() },
|
||||
&PlaybackMode::Remote {
|
||||
session_id: "test".to_string()
|
||||
},
|
||||
PollingHint::CastActive
|
||||
),
|
||||
500
|
||||
@@ -310,16 +311,24 @@ mod tests {
|
||||
|
||||
// CastDiscovery hint should always be 15s regardless of mode
|
||||
assert_eq!(
|
||||
SessionPollerManager::calculate_interval(&PlaybackMode::Idle, PollingHint::CastDiscovery),
|
||||
15000
|
||||
);
|
||||
assert_eq!(
|
||||
SessionPollerManager::calculate_interval(&PlaybackMode::Local, PollingHint::CastDiscovery),
|
||||
SessionPollerManager::calculate_interval(
|
||||
&PlaybackMode::Idle,
|
||||
PollingHint::CastDiscovery
|
||||
),
|
||||
15000
|
||||
);
|
||||
assert_eq!(
|
||||
SessionPollerManager::calculate_interval(
|
||||
&PlaybackMode::Remote { session_id: "test".to_string() },
|
||||
&PlaybackMode::Local,
|
||||
PollingHint::CastDiscovery
|
||||
),
|
||||
15000
|
||||
);
|
||||
assert_eq!(
|
||||
SessionPollerManager::calculate_interval(
|
||||
&PlaybackMode::Remote {
|
||||
session_id: "test".to_string()
|
||||
},
|
||||
PollingHint::CastDiscovery
|
||||
),
|
||||
15000
|
||||
@@ -338,7 +347,9 @@ mod tests {
|
||||
// Remote mode -> 2s
|
||||
assert_eq!(
|
||||
SessionPollerManager::calculate_interval(
|
||||
&PlaybackMode::Remote { session_id: "test".to_string() },
|
||||
&PlaybackMode::Remote {
|
||||
session_id: "test".to_string()
|
||||
},
|
||||
PollingHint::Normal
|
||||
),
|
||||
2000
|
||||
|
||||
@@ -128,7 +128,9 @@ impl DatabaseService for RusqliteService {
|
||||
async fn execute(&self, query: Query) -> DbResult<usize> {
|
||||
let conn = Arc::clone(&self.conn);
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let conn = conn.lock().map_err(|e| format!("Failed to lock connection: {}", e))?;
|
||||
let conn = conn
|
||||
.lock()
|
||||
.map_err(|e| format!("Failed to lock connection: {}", e))?;
|
||||
execute_query(&conn, query)
|
||||
})
|
||||
.await
|
||||
@@ -139,7 +141,9 @@ impl DatabaseService for RusqliteService {
|
||||
let conn = Arc::clone(&self.conn);
|
||||
let sql = sql.to_string();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let conn = conn.lock().map_err(|e| format!("Failed to lock connection: {}", e))?;
|
||||
let conn = conn
|
||||
.lock()
|
||||
.map_err(|e| format!("Failed to lock connection: {}", e))?;
|
||||
conn.execute_batch(&sql)
|
||||
.map_err(|e| format!("Execute batch failed: {}", e))
|
||||
})
|
||||
@@ -154,7 +158,9 @@ impl DatabaseService for RusqliteService {
|
||||
{
|
||||
let conn = Arc::clone(&self.conn);
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let conn = conn.lock().map_err(|e| format!("Failed to lock connection: {}", e))?;
|
||||
let conn = conn
|
||||
.lock()
|
||||
.map_err(|e| format!("Failed to lock connection: {}", e))?;
|
||||
query_one(&conn, query, mapper)
|
||||
})
|
||||
.await
|
||||
@@ -168,7 +174,9 @@ impl DatabaseService for RusqliteService {
|
||||
{
|
||||
let conn = Arc::clone(&self.conn);
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let conn = conn.lock().map_err(|e| format!("Failed to lock connection: {}", e))?;
|
||||
let conn = conn
|
||||
.lock()
|
||||
.map_err(|e| format!("Failed to lock connection: {}", e))?;
|
||||
query_optional(&conn, query, mapper)
|
||||
})
|
||||
.await
|
||||
@@ -182,7 +190,9 @@ impl DatabaseService for RusqliteService {
|
||||
{
|
||||
let conn = Arc::clone(&self.conn);
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let conn = conn.lock().map_err(|e| format!("Failed to lock connection: {}", e))?;
|
||||
let conn = conn
|
||||
.lock()
|
||||
.map_err(|e| format!("Failed to lock connection: {}", e))?;
|
||||
query_many(&conn, query, mapper)
|
||||
})
|
||||
.await
|
||||
@@ -196,7 +206,9 @@ impl DatabaseService for RusqliteService {
|
||||
{
|
||||
let conn = Arc::clone(&self.conn);
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let conn = conn.lock().map_err(|e| format!("Failed to lock connection: {}", e))?;
|
||||
let conn = conn
|
||||
.lock()
|
||||
.map_err(|e| format!("Failed to lock connection: {}", e))?;
|
||||
|
||||
conn.execute("BEGIN TRANSACTION", [])
|
||||
.map_err(|e| format!("Failed to begin transaction: {}", e))?;
|
||||
@@ -224,7 +236,9 @@ impl DatabaseService for RusqliteService {
|
||||
async fn last_insert_rowid(&self) -> DbResult<i64> {
|
||||
let conn = Arc::clone(&self.conn);
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let conn = conn.lock().map_err(|e| format!("Failed to lock connection: {}", e))?;
|
||||
let conn = conn
|
||||
.lock()
|
||||
.map_err(|e| format!("Failed to lock connection: {}", e))?;
|
||||
Ok(conn.last_insert_rowid())
|
||||
})
|
||||
.await
|
||||
@@ -255,7 +269,9 @@ where
|
||||
{
|
||||
match query_one(conn, query, mapper) {
|
||||
Ok(value) => Ok(Some(value)),
|
||||
Err(e) if e.contains("Query returned no rows") || e.contains("QueryReturnedNoRows") => Ok(None),
|
||||
Err(e) if e.contains("Query returned no rows") || e.contains("QueryReturnedNoRows") => {
|
||||
Ok(None)
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
@@ -325,10 +341,7 @@ mod tests {
|
||||
let service = RusqliteService::new(Arc::new(Mutex::new(conn)));
|
||||
|
||||
let query = Query::new("SELECT name FROM test WHERE id = 1");
|
||||
let name: String = service
|
||||
.query_one(query, |row| row.get(0))
|
||||
.await
|
||||
.unwrap();
|
||||
let name: String = service.query_one(query, |row| row.get(0)).await.unwrap();
|
||||
|
||||
assert_eq!(name, "Bob");
|
||||
}
|
||||
@@ -346,10 +359,7 @@ mod tests {
|
||||
let service = RusqliteService::new(Arc::new(Mutex::new(conn)));
|
||||
|
||||
let query = Query::new("SELECT name FROM test ORDER BY id");
|
||||
let names: Vec<String> = service
|
||||
.query_many(query, |row| row.get(0))
|
||||
.await
|
||||
.unwrap();
|
||||
let names: Vec<String> = service.query_many(query, |row| row.get(0)).await.unwrap();
|
||||
|
||||
assert_eq!(names, vec!["Alice", "Bob"]);
|
||||
}
|
||||
|
||||
@@ -14,8 +14,8 @@ use std::sync::{Arc, Mutex};
|
||||
use log::{debug, error, info};
|
||||
use rusqlite::{Connection, Result as SqliteResult};
|
||||
|
||||
use schema::MIGRATIONS;
|
||||
pub use db_service::{DatabaseService, RusqliteService};
|
||||
use schema::MIGRATIONS;
|
||||
|
||||
/// Database connection wrapper with thread-safe access
|
||||
pub struct Database {
|
||||
@@ -113,10 +113,7 @@ impl Database {
|
||||
match conn.execute_batch(sql) {
|
||||
Ok(_) => {
|
||||
info!("Successfully applied migration: {}", name);
|
||||
match conn.execute(
|
||||
"INSERT INTO _migrations (name) VALUES (?1)",
|
||||
[name],
|
||||
) {
|
||||
match conn.execute("INSERT INTO _migrations (name) VALUES (?1)", [name]) {
|
||||
Ok(_) => debug!("Recorded migration: {}", name),
|
||||
Err(e) => {
|
||||
error!("Failed to record migration {}: {}", name, e);
|
||||
@@ -264,9 +261,11 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
let name: String = conn
|
||||
.query_row("SELECT name FROM servers WHERE id = ?1", ["server1"], |row: &rusqlite::Row| {
|
||||
row.get(0)
|
||||
})
|
||||
.query_row(
|
||||
"SELECT name FROM servers WHERE id = ?1",
|
||||
["server1"],
|
||||
|row: &rusqlite::Row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(name, "Updated Server");
|
||||
|
||||
@@ -275,7 +274,9 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
let count: i32 = conn
|
||||
.query_row("SELECT COUNT(*) FROM servers", [], |row: &rusqlite::Row| row.get(0))
|
||||
.query_row("SELECT COUNT(*) FROM servers", [], |row: &rusqlite::Row| {
|
||||
row.get(0)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(count, 0);
|
||||
}
|
||||
@@ -313,16 +314,15 @@ mod tests {
|
||||
assert_eq!(is_active, 1);
|
||||
|
||||
// Update is_active
|
||||
conn.execute(
|
||||
"UPDATE users SET is_active = 0 WHERE id = ?1",
|
||||
["user1"],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute("UPDATE users SET is_active = 0 WHERE id = ?1", ["user1"])
|
||||
.unwrap();
|
||||
|
||||
let is_active: i32 = conn
|
||||
.query_row("SELECT is_active FROM users WHERE id = ?1", ["user1"], |row: &rusqlite::Row| {
|
||||
row.get(0)
|
||||
})
|
||||
.query_row(
|
||||
"SELECT is_active FROM users WHERE id = ?1",
|
||||
["user1"],
|
||||
|row: &rusqlite::Row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(is_active, 0);
|
||||
}
|
||||
@@ -348,9 +348,11 @@ mod tests {
|
||||
|
||||
// Verify user exists
|
||||
let count: i32 = conn
|
||||
.query_row("SELECT COUNT(*) FROM users WHERE server_id = ?1", ["server1"], |row: &rusqlite::Row| {
|
||||
row.get(0)
|
||||
})
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM users WHERE server_id = ?1",
|
||||
["server1"],
|
||||
|row: &rusqlite::Row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(count, 1);
|
||||
|
||||
@@ -360,7 +362,9 @@ mod tests {
|
||||
|
||||
// User should be deleted via CASCADE
|
||||
let count: i32 = conn
|
||||
.query_row("SELECT COUNT(*) FROM users", [], |row: &rusqlite::Row| row.get(0))
|
||||
.query_row("SELECT COUNT(*) FROM users", [], |row: &rusqlite::Row| {
|
||||
row.get(0)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(count, 0);
|
||||
}
|
||||
@@ -677,15 +681,16 @@ mod tests {
|
||||
|
||||
// Initially both users are active (simulating the old bug)
|
||||
let active_count: i32 = conn
|
||||
.query_row("SELECT COUNT(*) FROM users WHERE is_active = 1", [], |row: &rusqlite::Row| {
|
||||
row.get(0)
|
||||
})
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM users WHERE is_active = 1",
|
||||
[],
|
||||
|row: &rusqlite::Row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(active_count, 2);
|
||||
|
||||
// Now simulate setting user1 as active (global deactivation)
|
||||
conn.execute("UPDATE users SET is_active = 0", [])
|
||||
.unwrap();
|
||||
conn.execute("UPDATE users SET is_active = 0", []).unwrap();
|
||||
conn.execute(
|
||||
"UPDATE users SET is_active = 1, last_login_at = CURRENT_TIMESTAMP WHERE id = ?1",
|
||||
["user1"],
|
||||
@@ -694,9 +699,11 @@ mod tests {
|
||||
|
||||
// Only one user should be active now
|
||||
let active_count: i32 = conn
|
||||
.query_row("SELECT COUNT(*) FROM users WHERE is_active = 1", [], |row: &rusqlite::Row| {
|
||||
row.get(0)
|
||||
})
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM users WHERE is_active = 1",
|
||||
[],
|
||||
|row: &rusqlite::Row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(active_count, 1);
|
||||
|
||||
|
||||
@@ -75,7 +75,10 @@ impl ThumbnailCache {
|
||||
],
|
||||
);
|
||||
|
||||
if let Ok(Some(path_str)) = db.query_optional(exact, |row| row.get::<_, String>(0)).await {
|
||||
if let Ok(Some(path_str)) = db
|
||||
.query_optional(exact, |row| row.get::<_, String>(0))
|
||||
.await
|
||||
{
|
||||
let path = PathBuf::from(&path_str);
|
||||
if path.exists() {
|
||||
self.touch(&db, item_id, image_type, Some(tag)).await;
|
||||
@@ -83,14 +86,16 @@ impl ThumbnailCache {
|
||||
}
|
||||
// File gone — drop the stale row and fall through to the tag-agnostic
|
||||
// lookup below (another cached image for this item may still exist).
|
||||
let _ = db.execute(Query::with_params(
|
||||
"DELETE FROM thumbnails WHERE item_id = ? AND image_type = ? AND image_tag = ?",
|
||||
vec![
|
||||
QueryParam::String(item_id.to_string()),
|
||||
QueryParam::String(image_type.to_string()),
|
||||
QueryParam::String(tag.to_string()),
|
||||
],
|
||||
)).await;
|
||||
let _ = db
|
||||
.execute(Query::with_params(
|
||||
"DELETE FROM thumbnails WHERE item_id = ? AND image_type = ? AND image_tag = ?",
|
||||
vec![
|
||||
QueryParam::String(item_id.to_string()),
|
||||
QueryParam::String(image_type.to_string()),
|
||||
QueryParam::String(tag.to_string()),
|
||||
],
|
||||
))
|
||||
.await;
|
||||
}
|
||||
|
||||
// Fallback: any cached image for this item + type, newest first. The
|
||||
@@ -204,7 +209,11 @@ impl ThumbnailCache {
|
||||
}
|
||||
|
||||
/// Ensure there's enough space by evicting LRU items if needed
|
||||
async fn ensure_space(&self, db: Arc<RusqliteService>, needed_bytes: u64) -> Result<(), String> {
|
||||
async fn ensure_space(
|
||||
&self,
|
||||
db: Arc<RusqliteService>,
|
||||
needed_bytes: u64,
|
||||
) -> Result<(), String> {
|
||||
let max_size = {
|
||||
let config = self.config.lock().map_err(|e| e.to_string())?;
|
||||
config.max_size_bytes
|
||||
@@ -236,9 +245,7 @@ impl ThumbnailCache {
|
||||
);
|
||||
|
||||
let items: Vec<(i64, String, i64)> = db
|
||||
.query_many(query, |row| {
|
||||
Ok((row.get(0)?, row.get(1)?, row.get(2)?))
|
||||
})
|
||||
.query_many(query, |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
@@ -276,9 +283,7 @@ impl ThumbnailCache {
|
||||
pub async fn get_item_count(&self, db: Arc<RusqliteService>) -> i64 {
|
||||
let query = Query::new("SELECT COUNT(*) FROM thumbnails");
|
||||
|
||||
db.query_one(query, |row| row.get(0))
|
||||
.await
|
||||
.unwrap_or(0)
|
||||
db.query_one(query, |row| row.get(0)).await.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Get the current cache limit in bytes
|
||||
@@ -297,7 +302,11 @@ impl ThumbnailCache {
|
||||
}
|
||||
|
||||
/// Set the cache limit in bytes
|
||||
pub async fn set_limit(&self, db: Arc<RusqliteService>, limit_bytes: u64) -> Result<(), String> {
|
||||
pub async fn set_limit(
|
||||
&self,
|
||||
db: Arc<RusqliteService>,
|
||||
limit_bytes: u64,
|
||||
) -> Result<(), String> {
|
||||
// Update database setting
|
||||
let query = Query::with_params(
|
||||
"INSERT OR REPLACE INTO cache_settings (key, value, updated_at)
|
||||
@@ -444,14 +453,24 @@ mod tests {
|
||||
// Save a thumbnail
|
||||
let data = b"fake image data";
|
||||
let path = cache
|
||||
.save_thumbnail(conn.clone(), "item1", "Primary", "tag1", data, Some(100), Some(100))
|
||||
.save_thumbnail(
|
||||
conn.clone(),
|
||||
"item1",
|
||||
"Primary",
|
||||
"tag1",
|
||||
data,
|
||||
Some(100),
|
||||
Some(100),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(path.exists());
|
||||
|
||||
// Get cached path
|
||||
let cached = cache.get_cached_path(conn.clone(), "item1", "Primary", "tag1").await;
|
||||
let cached = cache
|
||||
.get_cached_path(conn.clone(), "item1", "Primary", "tag1")
|
||||
.await;
|
||||
assert!(cached.is_some());
|
||||
assert_eq!(cached.unwrap(), path);
|
||||
}
|
||||
@@ -461,7 +480,9 @@ mod tests {
|
||||
let (conn, temp_dir) = setup_test_db();
|
||||
let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), CacheConfig::default());
|
||||
|
||||
let cached = cache.get_cached_path(conn.clone(), "nonexistent", "Primary", "tag1").await;
|
||||
let cached = cache
|
||||
.get_cached_path(conn.clone(), "nonexistent", "Primary", "tag1")
|
||||
.await;
|
||||
assert!(cached.is_none());
|
||||
}
|
||||
|
||||
@@ -488,11 +509,15 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
// First item should be evicted
|
||||
let cached = cache.get_cached_path(conn.clone(), "item1", "Primary", "tag1").await;
|
||||
let cached = cache
|
||||
.get_cached_path(conn.clone(), "item1", "Primary", "tag1")
|
||||
.await;
|
||||
assert!(cached.is_none());
|
||||
|
||||
// Second item should exist
|
||||
let cached = cache.get_cached_path(conn.clone(), "item2", "Primary", "tag2").await;
|
||||
let cached = cache
|
||||
.get_cached_path(conn.clone(), "item2", "Primary", "tag2")
|
||||
.await;
|
||||
assert!(cached.is_some());
|
||||
}
|
||||
|
||||
|
||||
+49
-1
@@ -19,6 +19,40 @@ export const commands = {
|
||||
async playerPlayItem(item: PlayItemRequest) : Promise<PlayerStatus> {
|
||||
return await TAURI_INVOKE("player_play_item", { item });
|
||||
},
|
||||
/**
|
||||
* Enter background-audio mode: hand playback of the currently-watched video off
|
||||
* to the native ExoPlayer *audio* path so the audio keeps playing while the app
|
||||
* is backgrounded/locked, with no client-side video decode (UR-040).
|
||||
*
|
||||
* `stream_url` MUST be an audio-only URL (see
|
||||
* `get_audio_only_stream_url_for_video`). The item is created as
|
||||
* `MediaType::Audio` so it starts an audio session and loads into the native
|
||||
* backend with `mediaType="audio"` — the WebView `<video>` is torn down on the
|
||||
* frontend side, so exactly one audio source is ever active.
|
||||
*
|
||||
* This deliberately goes through the queue-based `play_item` path (NOT a
|
||||
* side-channel) so end-of-track lands in `on_playback_ended`, which already
|
||||
* honors the sleep timer (Time/Episodes/EndOfTrack) and drives autoplay-next.
|
||||
* The sleep-timer state is intentionally left untouched by the handoff.
|
||||
*
|
||||
* TRACES: UR-040 | DR-052 | UT-061, IT-013
|
||||
*/
|
||||
async playerEnterBackgroundAudio(item: PlayItemRequest, positionSeconds: number) : Promise<PlayerStatus> {
|
||||
return await TAURI_INVOKE("player_enter_background_audio", { item, positionSeconds });
|
||||
},
|
||||
/**
|
||||
* Exit background-audio mode: stop the native audio player and return its final
|
||||
* position so the frontend can reload the WebView `<video>` there (UR-040).
|
||||
*
|
||||
* Returns the position in seconds. The sleep timer is intentionally left
|
||||
* untouched — if it fired while backgrounded, playback is already stopped and
|
||||
* this simply reports the last position.
|
||||
*
|
||||
* TRACES: UR-040 | DR-052 | UT-061, IT-013
|
||||
*/
|
||||
async playerExitBackgroundAudio() : Promise<number> {
|
||||
return await TAURI_INVOKE("player_exit_background_audio");
|
||||
},
|
||||
/**
|
||||
* Play a queue of media items
|
||||
*
|
||||
@@ -1206,6 +1240,14 @@ async repositoryGetVideoStreamUrl(handle: string, itemId: string, mediaSourceId:
|
||||
async repositoryGetAudioStreamUrl(handle: string, itemId: string) : Promise<string> {
|
||||
return await TAURI_INVOKE("repository_get_audio_stream_url", { handle, itemId });
|
||||
},
|
||||
/**
|
||||
* Get an audio-only stream URL for a *video* item (background-audio handoff).
|
||||
*
|
||||
* TRACES: UR-040 | JA-032 | UT-061
|
||||
*/
|
||||
async repositoryGetAudioOnlyStreamUrlForVideo(handle: string, itemId: string, mediaSourceId: string | null, startTimeSeconds: number | null, audioStreamIndex: number | null) : Promise<string> {
|
||||
return await TAURI_INVOKE("repository_get_audio_only_stream_url_for_video", { handle, itemId, mediaSourceId, startTimeSeconds, audioStreamIndex });
|
||||
},
|
||||
/**
|
||||
* Get Live TV channels (broadcast / IPTV) for browsing
|
||||
*/
|
||||
@@ -1760,7 +1802,13 @@ videoCodec: string;
|
||||
/**
|
||||
* Whether the video requires server-side transcoding
|
||||
*/
|
||||
needsTranscoding: boolean }
|
||||
needsTranscoding: boolean;
|
||||
/**
|
||||
* Optional now-playing metadata. Used by the background-audio handoff so the
|
||||
* lockscreen/miniplayer show the item (title/subtitle/artwork). Defaulted so
|
||||
* existing video-only callers need not send them.
|
||||
*/
|
||||
artist?: string | null; primaryImageTag?: string | null; serverId?: string | null }
|
||||
/**
|
||||
* Queue context for remote transfer - what type of queue is this?
|
||||
*/
|
||||
|
||||
@@ -390,6 +390,38 @@ describe("RepositoryClient", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should get audio-only stream URL for a video item (camelCase params)", async () => {
|
||||
// TRACES: UR-040 | JA-032 | UT-061
|
||||
const mockUrl = "https://server.com/Audio/item123/universal?AudioStreamIndex=2";
|
||||
(invoke as any).mockResolvedValueOnce(mockUrl);
|
||||
|
||||
const url = await client.getAudioOnlyStreamUrlForVideo("item123", "source456", 193, 2);
|
||||
|
||||
expect(url).toBe(mockUrl);
|
||||
expect(invoke).toHaveBeenCalledWith("repository_get_audio_only_stream_url_for_video", {
|
||||
handle: "test-handle-123",
|
||||
itemId: "item123",
|
||||
mediaSourceId: "source456",
|
||||
startTimeSeconds: 193,
|
||||
audioStreamIndex: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("should default optional params to null for audio-only stream URL", async () => {
|
||||
// TRACES: UR-040 | JA-032 | UT-061
|
||||
(invoke as any).mockResolvedValueOnce("https://server.com/Audio/item123/universal");
|
||||
|
||||
await client.getAudioOnlyStreamUrlForVideo("item123");
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith("repository_get_audio_only_stream_url_for_video", {
|
||||
handle: "test-handle-123",
|
||||
itemId: "item123",
|
||||
mediaSourceId: null,
|
||||
startTimeSeconds: null,
|
||||
audioStreamIndex: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("should report playback progress", async () => {
|
||||
(invoke as any).mockResolvedValueOnce(undefined);
|
||||
|
||||
|
||||
@@ -172,6 +172,26 @@ export class RepositoryClient {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Audio-only stream URL for a video item, for the background-audio handoff.
|
||||
* The server extracts just the audio track — no video is decoded on-device.
|
||||
* TRACES: UR-040 | JA-032
|
||||
*/
|
||||
async getAudioOnlyStreamUrlForVideo(
|
||||
itemId: string,
|
||||
mediaSourceId?: string,
|
||||
startTimeSeconds?: number,
|
||||
audioStreamIndex?: number
|
||||
): Promise<string> {
|
||||
return commands.repositoryGetAudioOnlyStreamUrlForVideo(
|
||||
this.ensureHandle(),
|
||||
itemId,
|
||||
mediaSourceId ?? null,
|
||||
startTimeSeconds ?? null,
|
||||
audioStreamIndex ?? null
|
||||
);
|
||||
}
|
||||
|
||||
// ===== Live TV / Channels =====
|
||||
|
||||
/** Browse Live TV channels (broadcast / IPTV). */
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- TRACES: UR-003, UR-005, UR-020, UR-021, UR-026 | DR-010, DR-023, DR-024 -->
|
||||
<!-- TRACES: UR-003, UR-005, UR-020, UR-021, UR-026, UR-040 | DR-010, DR-023, DR-024, DR-051, DR-052 -->
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy, untrack } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
@@ -18,7 +18,20 @@
|
||||
import { playerController } from "$lib/player";
|
||||
import { Html5PlayerAdapter, type Html5ElementBridge } from "$lib/player/adapters";
|
||||
import { createRustReportHost } from "$lib/player/adapters/rustReportHost";
|
||||
import { isPipSupported, enterPip } from "$lib/utils/pictureInPicture";
|
||||
import { isPipSupported, enterPip, setAutoEnterEnabled } from "$lib/utils/pictureInPicture";
|
||||
import {
|
||||
isBackgroundAudioSupported,
|
||||
setBackgroundAudioEnabled,
|
||||
subscribeAppBackgrounded,
|
||||
subscribeAppForegrounded,
|
||||
} from "$lib/utils/backgroundAudio";
|
||||
import {
|
||||
computeHandoffPosition,
|
||||
initialHandoffState,
|
||||
shouldEnterBackgroundAudio,
|
||||
shouldExitBackgroundAudio,
|
||||
type BackgroundAudioState,
|
||||
} from "./backgroundAudioHandoff";
|
||||
|
||||
interface Props {
|
||||
media: MediaItem | null;
|
||||
@@ -489,6 +502,15 @@
|
||||
|
||||
// Set up progress reporting interval
|
||||
onMount(async () => {
|
||||
// Background-audio lifecycle listeners MUST be registered synchronously —
|
||||
// before any await below — per the native-mode pitfall (an await here can
|
||||
// flip the component into HTML5 mode). Unsubscribers go into nativeUnlisteners
|
||||
// so onDestroy tears them down.
|
||||
if (backgroundAudioSupported) {
|
||||
nativeUnlisteners.push(subscribeAppBackgrounded(enterBackgroundAudioHandoff));
|
||||
nativeUnlisteners.push(subscribeAppForegrounded(exitBackgroundAudioHandoff));
|
||||
}
|
||||
|
||||
// Initialize player via Rust - Rust will decide which backend to use based on platform
|
||||
if (media && currentStreamUrl) {
|
||||
try {
|
||||
@@ -681,12 +703,19 @@
|
||||
clearInterval(debugLogInterval);
|
||||
}
|
||||
|
||||
// Remove native backend event listeners
|
||||
// Remove native backend event listeners (incl. background-audio lifecycle subs)
|
||||
for (const unlisten of nativeUnlisteners) {
|
||||
unlisten();
|
||||
}
|
||||
nativeUnlisteners = [];
|
||||
|
||||
// Re-assert defaults so this player's background-audio choice can't leak into
|
||||
// the next one: disarm background audio and restore auto-PiP.
|
||||
if (backgroundAudioSupported) {
|
||||
setBackgroundAudioEnabled(false);
|
||||
setAutoEnterEnabled(true);
|
||||
}
|
||||
|
||||
// Clean up HLS.js instance - prevent dual audio on unmount
|
||||
if (hls) {
|
||||
console.log("[VideoPlayer] Destroying HLS.js instance on unmount");
|
||||
@@ -815,6 +844,25 @@
|
||||
console.log("[VideoPlayer] Video unmuted on canplay, volume: 1.0");
|
||||
}
|
||||
|
||||
// Returning from background audio: resume the <video> at the position native
|
||||
// audio reached, restoring the prior play/pause state. Takes precedence over
|
||||
// the resume-point seek below (which is for a fresh load, not a handoff).
|
||||
if (pendingForegroundSeek !== null && videoElement) {
|
||||
const seekTo = pendingForegroundSeek;
|
||||
const shouldPlay = pendingForegroundPlay;
|
||||
pendingForegroundSeek = null;
|
||||
pendingForegroundPlay = false;
|
||||
hasPerformedInitialSeek = true;
|
||||
try {
|
||||
videoElement.currentTime = seekTo;
|
||||
currentTime = seekTo;
|
||||
if (shouldPlay) await videoElement.play();
|
||||
} catch (err) {
|
||||
console.error("[VideoPlayer] Failed to resume after background audio:", err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Seek to initial position if resuming playback
|
||||
if (initialPosition && initialPosition > 0 && !hasPerformedInitialSeek && videoElement) {
|
||||
console.log("[VideoPlayer] Seeking to initial position:", initialPosition);
|
||||
@@ -1092,6 +1140,104 @@
|
||||
enterPip();
|
||||
}
|
||||
|
||||
// ===== Background audio (UR-040, Android) =====
|
||||
// Keep the video's audio playing when the app is backgrounded/locked by handing
|
||||
// playback off to the native ExoPlayer audio service; the WebView <video> is
|
||||
// torn down so no video is decoded. Mutually exclusive with auto-PiP.
|
||||
//
|
||||
// Resolved synchronously (no await) for the same native-mode reason as PiP.
|
||||
const backgroundAudioSupported = isBackgroundAudioSupported();
|
||||
let backgroundAudioOn = $state(false); // v1: default OFF each session
|
||||
let handoffState: BackgroundAudioState = { ...initialHandoffState };
|
||||
|
||||
function toggleBackgroundAudio() {
|
||||
backgroundAudioOn = !backgroundAudioOn;
|
||||
// Arm/disarm native background-audio mode AND flip auto-PiP the other way,
|
||||
// so exactly one background behavior is active.
|
||||
setBackgroundAudioEnabled(backgroundAudioOn);
|
||||
setAutoEnterEnabled(!backgroundAudioOn);
|
||||
}
|
||||
|
||||
// App went to background/locked while background-audio is armed: hand off to
|
||||
// native audio and stop the WebView video decode.
|
||||
async function enterBackgroundAudioHandoff() {
|
||||
if (!shouldEnterBackgroundAudio(backgroundAudioOn, handoffState)) return;
|
||||
// `currentTime` is the component's authoritative ABSOLUTE position (the RAF
|
||||
// loop keeps it at seekOffset + element.currentTime, and it survives HLS
|
||||
// transcode segment resets). Reading videoElement.currentTime directly is
|
||||
// wrong for transcoded streams (it's the in-segment offset) and can read 0
|
||||
// if the element is mid-teardown — which shipped audio starting from 0:00.
|
||||
const pos = computeHandoffPosition(currentTime, 0);
|
||||
const wasPlaying = isPlaying;
|
||||
console.log("[VideoPlayer] Background-audio handoff at position:", pos.toFixed(1));
|
||||
handoffState = { active: true, wasPlaying };
|
||||
try {
|
||||
if (!media) return;
|
||||
// Ask the server for an audio-only stream of this video item (no video
|
||||
// decode), carrying the selected audio track and resume position.
|
||||
const audioUrl = await auth.getRepository().getAudioOnlyStreamUrlForVideo(
|
||||
media.id,
|
||||
mediaSourceId ?? undefined,
|
||||
pos,
|
||||
selectedAudioTrackIndex ?? undefined,
|
||||
);
|
||||
await commands.playerEnterBackgroundAudio(
|
||||
{
|
||||
id: media.id,
|
||||
title: media.name,
|
||||
streamUrl: audioUrl,
|
||||
videoCodec: "aac",
|
||||
needsTranscoding: false,
|
||||
// Now-playing metadata so the lockscreen/miniplayer show the item.
|
||||
artist: media.seriesName ?? null,
|
||||
primaryImageTag: media.primaryImageTag ?? null,
|
||||
serverId: media.serverId ?? null,
|
||||
},
|
||||
pos,
|
||||
);
|
||||
// Tear down the WebView <video>/HLS decode AFTER native audio has started,
|
||||
// so there is never a gap — and exactly one audio source is ever live.
|
||||
tearDownHls();
|
||||
if (videoElement) {
|
||||
videoElement.pause();
|
||||
videoElement.removeAttribute("src");
|
||||
videoElement.load();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[VideoPlayer] Background-audio handoff failed:", err);
|
||||
handoffState = { ...initialHandoffState };
|
||||
}
|
||||
}
|
||||
|
||||
// App returned to foreground: stop native audio, reload the WebView <video> at
|
||||
// the position native reached, and restore play/pause.
|
||||
async function exitBackgroundAudioHandoff() {
|
||||
if (!shouldExitBackgroundAudio(handoffState)) return;
|
||||
const wasPlaying = handoffState.wasPlaying;
|
||||
handoffState = { ...initialHandoffState };
|
||||
try {
|
||||
const pos = await commands.playerExitBackgroundAudio();
|
||||
// Reload the video at the returned position. Resetting these re-runs the
|
||||
// HLS init $effect and reveals/seeks the element as on a fresh load.
|
||||
hasPerformedInitialSeek = false;
|
||||
lastAppliedInitialPosition = undefined;
|
||||
seekOffset = 0;
|
||||
isMediaReady = false;
|
||||
// Re-point the element at the (unchanged) video stream URL; assigning a new
|
||||
// reference restarts the HLS effect even if the string is identical.
|
||||
currentStreamUrl = streamUrl;
|
||||
// Seek to where native audio left off once the element is ready again.
|
||||
pendingForegroundSeek = pos;
|
||||
pendingForegroundPlay = wasPlaying;
|
||||
} catch (err) {
|
||||
console.error("[VideoPlayer] Background-audio return failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// Consumed by handleCanPlay after the <video> reloads on foreground.
|
||||
let pendingForegroundSeek: number | null = null;
|
||||
let pendingForegroundPlay = false;
|
||||
|
||||
function toggleFullscreen() {
|
||||
if (!document.fullscreenElement) {
|
||||
document.documentElement.requestFullscreen();
|
||||
@@ -1745,6 +1891,21 @@
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Background audio (Android only) — keep audio playing when the app is
|
||||
backgrounded/locked; video decode stops. Suppresses auto-PiP while on. -->
|
||||
{#if backgroundAudioSupported}
|
||||
<button
|
||||
onclick={toggleBackgroundAudio}
|
||||
class={backgroundAudioOn ? "text-blue-400 hover:text-blue-300" : "text-white hover:text-gray-300"}
|
||||
aria-label="Background audio"
|
||||
aria-pressed={backgroundAudioOn}
|
||||
>
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 1c-4.97 0-9 4.03-9 9v7c0 1.66 1.34 3 3 3h3v-8H5v-2c0-3.87 3.13-7 7-7s7 3.13 7 7v2h-4v8h3c1.66 0 3-1.34 3-3v-7c0-4.97-4.03-9-9-9z" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Fullscreen -->
|
||||
<button onclick={toggleFullscreen} class="text-white hover:text-gray-300" aria-label="Toggle fullscreen">
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
computeHandoffPosition,
|
||||
initialHandoffState,
|
||||
shouldEnterBackgroundAudio,
|
||||
shouldExitBackgroundAudio,
|
||||
type BackgroundAudioState,
|
||||
} from "./backgroundAudioHandoff";
|
||||
|
||||
// TRACES: UR-040 | DR-052 | UT-060
|
||||
|
||||
describe("backgroundAudioHandoff", () => {
|
||||
describe("computeHandoffPosition", () => {
|
||||
it("sums element time and transcode seekOffset (absolute position)", () => {
|
||||
// Transcoded HLS resets element time to 0 after a reload; seekOffset carries
|
||||
// the cumulative offset. The audio stream must resume at the absolute pos.
|
||||
expect(computeHandoffPosition(12, 180)).toBe(192);
|
||||
});
|
||||
|
||||
it("handles a direct stream with no offset", () => {
|
||||
expect(computeHandoffPosition(45, 0)).toBe(45);
|
||||
});
|
||||
|
||||
it("never returns a negative position", () => {
|
||||
expect(computeHandoffPosition(-5, 0)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldEnterBackgroundAudio", () => {
|
||||
it("enters when toggle is on and not already handed off", () => {
|
||||
expect(shouldEnterBackgroundAudio(true, initialHandoffState)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not enter when the toggle is off", () => {
|
||||
expect(shouldEnterBackgroundAudio(false, initialHandoffState)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not double-enter when already active", () => {
|
||||
const active: BackgroundAudioState = { active: true, wasPlaying: true };
|
||||
expect(shouldEnterBackgroundAudio(true, active)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldExitBackgroundAudio", () => {
|
||||
it("exits when a handoff is active", () => {
|
||||
const active: BackgroundAudioState = { active: true, wasPlaying: false };
|
||||
expect(shouldExitBackgroundAudio(active)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not exit when no handoff happened", () => {
|
||||
expect(shouldExitBackgroundAudio(initialHandoffState)).toBe(false);
|
||||
});
|
||||
|
||||
it("exits even if the toggle was turned off while backgrounded", () => {
|
||||
// shouldExit ignores the toggle by design, so turning it off mid-background
|
||||
// still returns cleanly to video on foreground.
|
||||
const active: BackgroundAudioState = { active: true, wasPlaying: true };
|
||||
expect(shouldExitBackgroundAudio(active)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Pure helpers for the video → background-audio handoff (UR-040).
|
||||
*
|
||||
* TRACES: UR-040 | DR-052 | UT-060
|
||||
*
|
||||
* Kept free of Svelte/DOM so the handoff arithmetic and state transitions are
|
||||
* unit-testable without mounting the player. The component
|
||||
* (VideoPlayer.svelte) owns the actual `<video>` teardown and IPC calls.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Absolute playback position to resume the audio stream at.
|
||||
*
|
||||
* Transcoded HLS playback tracks time as `videoElement.currentTime + seekOffset`
|
||||
* (the element resets to 0 after each transcode reload; `seekOffset` carries the
|
||||
* cumulative offset). Background audio must resume at that ABSOLUTE position, so
|
||||
* both terms are summed here — mirroring the `effectiveTime` used elsewhere in
|
||||
* the player.
|
||||
*/
|
||||
export function computeHandoffPosition(elementCurrentTime: number, seekOffset: number): number {
|
||||
const pos = elementCurrentTime + seekOffset;
|
||||
return pos > 0 ? pos : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* The handoff state. `wasPlaying` is captured on the way out so play/pause is
|
||||
* restored when the app returns to the foreground.
|
||||
*/
|
||||
export interface BackgroundAudioState {
|
||||
active: boolean;
|
||||
wasPlaying: boolean;
|
||||
}
|
||||
|
||||
export const initialHandoffState: BackgroundAudioState = {
|
||||
active: false,
|
||||
wasPlaying: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether a background signal should trigger the audio handoff right now.
|
||||
* Only when the toggle is on and we're not already handed off.
|
||||
*/
|
||||
export function shouldEnterBackgroundAudio(
|
||||
toggleOn: boolean,
|
||||
state: BackgroundAudioState
|
||||
): boolean {
|
||||
return toggleOn && !state.active;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a foreground signal should trigger the return to WebView video.
|
||||
* Only when we actually handed off (regardless of the current toggle value, so
|
||||
* turning the toggle off while backgrounded still returns cleanly).
|
||||
*/
|
||||
export function shouldExitBackgroundAudio(state: BackgroundAudioState): boolean {
|
||||
return state.active;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { commands } from "$lib/api/bindings";
|
||||
|
||||
vi.mock("@tauri-apps/api/core");
|
||||
|
||||
// TRACES: UR-040 | DR-052 | UT-061
|
||||
//
|
||||
// Guards the Tauri v2 camelCase param rule for the background-audio commands:
|
||||
// the command NAME stays snake_case; params are camelCase.
|
||||
|
||||
describe("background-audio player commands (param naming)", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("player_enter_background_audio sends item + positionSeconds (camelCase)", async () => {
|
||||
(invoke as any).mockResolvedValueOnce({});
|
||||
|
||||
const item = {
|
||||
id: "vid-1",
|
||||
title: "Episode 1",
|
||||
streamUrl: "https://server/Audio/vid-1/universal?AudioStreamIndex=1",
|
||||
videoCodec: "aac",
|
||||
needsTranscoding: false,
|
||||
};
|
||||
await commands.playerEnterBackgroundAudio(item, 193);
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith("player_enter_background_audio", {
|
||||
item,
|
||||
positionSeconds: 193,
|
||||
});
|
||||
});
|
||||
|
||||
it("player_exit_background_audio takes no params and returns a position", async () => {
|
||||
(invoke as any).mockResolvedValueOnce(193.5);
|
||||
|
||||
const pos = await commands.playerExitBackgroundAudio();
|
||||
|
||||
expect(pos).toBe(193.5);
|
||||
expect(invoke).toHaveBeenCalledWith("player_exit_background_audio");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Background-audio support, Android only.
|
||||
*
|
||||
* TRACES: UR-040 | IR-025, DR-051
|
||||
*
|
||||
* Keeps a video's *audio* playing when the app is backgrounded or the screen is
|
||||
* locked, while video decode stops. This is a HANDOFF: the WebView `<video>`
|
||||
* element (which decodes video) is torn down and the same item is played back
|
||||
* audio-only through the native ExoPlayer foreground service. It is NOT the
|
||||
* WebView staying alive — an Android WebView `<video>` does not keep audio
|
||||
* playing once the app is backgrounded.
|
||||
*
|
||||
* The `AndroidBackgroundAudio` @JavascriptInterface (installed by MainActivity)
|
||||
* carries the toggle state to native; native signals background/foreground back
|
||||
* to the frontend as DOM CustomEvents (`jellytau-background` /
|
||||
* `jellytau-foreground`) — see subscribeAppBackgrounded/Foregrounded below.
|
||||
*
|
||||
* Unsupported (no-op) on every non-Android platform.
|
||||
*/
|
||||
|
||||
interface AndroidBackgroundAudioBridge {
|
||||
setEnabled(enabled: boolean): void;
|
||||
isSupported(): boolean;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
AndroidBackgroundAudio?: AndroidBackgroundAudioBridge;
|
||||
}
|
||||
}
|
||||
|
||||
function bridge(): AndroidBackgroundAudioBridge | undefined {
|
||||
if (typeof window === "undefined") return undefined;
|
||||
return window.AndroidBackgroundAudio;
|
||||
}
|
||||
|
||||
/** Whether background audio is available — used to decide if the toggle renders. */
|
||||
export function isBackgroundAudioSupported(): boolean {
|
||||
try {
|
||||
return bridge()?.isSupported() ?? false;
|
||||
} catch (err) {
|
||||
console.warn("[BgAudio] isSupported check failed:", err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Arm/disarm background-audio mode for the current video. When armed, the native
|
||||
* side runs the audio handoff on background instead of entering PiP.
|
||||
*/
|
||||
export function setBackgroundAudioEnabled(enabled: boolean): void {
|
||||
try {
|
||||
bridge()?.setEnabled(enabled);
|
||||
} catch (err) {
|
||||
console.warn("[BgAudio] Failed to set enabled:", err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to the native "app backgrounded" signal (Home/app-switch/lock).
|
||||
* Returns an unsubscribe function. No-op where unsupported (the event never
|
||||
* fires on non-Android platforms).
|
||||
*/
|
||||
export function subscribeAppBackgrounded(handler: () => void): () => void {
|
||||
if (typeof window === "undefined") return () => {};
|
||||
window.addEventListener("jellytau-background", handler);
|
||||
return () => window.removeEventListener("jellytau-background", handler);
|
||||
}
|
||||
|
||||
/** Subscribe to the native "app foregrounded" signal. Returns an unsubscribe fn. */
|
||||
export function subscribeAppForegrounded(handler: () => void): () => void {
|
||||
if (typeof window === "undefined") return () => {};
|
||||
window.addEventListener("jellytau-foreground", handler);
|
||||
return () => window.removeEventListener("jellytau-foreground", handler);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
/**
|
||||
* Picture-in-picture support, Android only.
|
||||
*
|
||||
* TRACES: UR-041 | IR-026 | DR-053
|
||||
*
|
||||
* Video on Android renders into a native ExoPlayer SurfaceView behind the
|
||||
* WebView, so PiP is driven by the Activity (which shrinks into a floating
|
||||
* window) rather than the HTML5 `requestPictureInPicture()` API. The bridge is
|
||||
@@ -68,8 +70,12 @@ export function enterPip(): void {
|
||||
/**
|
||||
* Enable/disable auto-entering PiP when the user backgrounds the app.
|
||||
*
|
||||
* Disabled while casting: playback is happening on another device, so a PiP
|
||||
* window here would render an empty black box.
|
||||
* This is a coarse frontend override; the authoritative gate is the native
|
||||
* `canEnterPip` guard, which already refuses PiP unless a local video surface
|
||||
* is actively rendering (so audio playback, menu/library browsing, and
|
||||
* remote/cast sessions never enter PiP regardless of this flag). The only
|
||||
* caller today is the background-audio toggle, which disarms auto-PiP so the
|
||||
* two background behaviours stay mutually exclusive.
|
||||
*/
|
||||
export function setAutoEnterEnabled(enabled: boolean): void {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user