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:
2026-07-22 21:52:07 +02:00
parent 4e6ab017d4
commit 3fbf6afdbc
72 changed files with 6728 additions and 2338 deletions
+61 -16
View File
@@ -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))?;
+17 -5
View File
@@ -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
);
}
}
}
+48 -18
View File
@@ -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 {
+62 -28
View File
@@ -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;
+5 -1
View File
@@ -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>>);
+3 -2
View File
@@ -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
+4 -1
View File
@@ -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())
+246 -67
View File
@@ -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");
+6 -1
View File
@@ -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};
+2 -2
View File
@@ -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::*;
+35 -15
View File
@@ -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()));
+32 -7
View File
@@ -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
+65 -26
View File
@@ -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)
}
+80 -16
View File
@@ -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 {
+2
View File
@@ -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;
+11 -4
View File
@@ -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 {
+33 -12
View File
@@ -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))
}
+88 -33
View File
@@ -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))
}
+2 -2
View File
@@ -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>);
+140 -50
View File
@@ -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::*;
+43 -24
View File
@@ -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")]
+44 -26
View File
@@ -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()));
+6 -9
View File
@@ -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())
+35 -10
View File
@@ -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
View File
@@ -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 {
+5 -2
View File
@@ -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;
}
+19 -19
View File
@@ -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();
+1 -1
View File
@@ -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;
+26 -13
View File
@@ -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]
+132 -54
View File
@@ -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::*;
+12 -40
View File
@@ -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,
}
}
+1 -1
View File
@@ -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
View File
@@ -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 -45
View File
@@ -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"),
];
+5 -5
View File
@@ -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;
+122 -47
View File
@@ -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 {
+126 -84
View File
@@ -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 -1
View File
@@ -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)]
+12 -2
View File
@@ -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,
+7 -5
View File
@@ -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
View File
@@ -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(&current).await;
let is_episode =
current.media_type == MediaType::Video && self.is_episode_item(&current).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 &current_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!()
}
}
+42 -20
View File
@@ -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> {
+9 -2
View File
@@ -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() {
+24 -17
View File
@@ -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(),
+30 -9
View File
@@ -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());
+3 -1
View File
@@ -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());
File diff suppressed because it is too large Load Diff
+7 -10
View File
@@ -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
///
File diff suppressed because it is too large Load Diff
+357 -91
View File
@@ -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");
}
+13 -4
View File
@@ -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");
}
+35 -24
View File
@@ -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
+26 -16
View File
@@ -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"]);
}
+36 -29
View File
@@ -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);
+47 -22
View File
@@ -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());
}