First working POC

This commit is contained in:
2026-01-26 22:21:54 +01:00
commit cfddc1edea
255 changed files with 77606 additions and 0 deletions
+239
View File
@@ -0,0 +1,239 @@
use std::sync::Arc;
use tauri::State;
use crate::auth::{AuthManager, SessionVerifier, ServerInfo, AuthResult, Session};
/// Wrapper for AuthManager to manage in Tauri state
pub struct AuthManagerWrapper(pub Arc<AuthManager>);
/// Wrapper for SessionVerifier to manage in Tauri state
pub struct SessionVerifierWrapper(pub Arc<tokio::sync::Mutex<Option<SessionVerifier>>>);
/// Initialize the auth manager (call on app startup)
/// Restores session from storage if available
#[tauri::command]
pub async fn auth_initialize(
auth_manager: State<'_, AuthManagerWrapper>,
database: State<'_, crate::commands::DatabaseWrapper>,
credentials: State<'_, crate::commands::CredentialStoreWrapper>,
) -> Result<Option<Session>, String> {
// First check if we already have a session in memory
if let Some(session) = auth_manager.0.get_session().await {
return Ok(Some(session));
}
// Try to restore session from storage
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);
}
};
// Create session object from active session with normalized URL
let normalized_url = crate::auth::AuthManager::normalize_url(&active_session.server_url);
let session = Session {
user_id: active_session.user_id,
username: active_session.username,
server_id: active_session.server_id,
server_url: normalized_url,
server_name: active_session.server_name,
access_token: active_session.access_token,
verified: false, // Will be verified in background
needs_reauth: false,
};
// 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);
Ok(Some(session))
}
/// Connect to a Jellyfin server and get server info
#[tauri::command]
pub async fn auth_connect_to_server(
server_url: String,
auth_manager: State<'_, AuthManagerWrapper>,
) -> Result<ServerInfo, String> {
auth_manager.0.connect_to_server(&server_url).await
}
/// Login with username and password
#[tauri::command]
pub async fn auth_login(
server_url: String,
username: String,
password: String,
device_id: String,
auth_manager: State<'_, AuthManagerWrapper>,
) -> Result<AuthResult, String> {
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);
let session = Session {
user_id: result.user.id.clone(),
username: result.user.name.clone(),
server_id: result.server_id.clone(),
server_url: normalized_url,
server_name: String::new(), // Will be set by frontend
access_token: result.access_token.clone(),
verified: true,
needs_reauth: false,
};
auth_manager.0.set_session(Some(session)).await;
Ok(result)
}
/// Verify current session
#[tauri::command]
pub async fn auth_verify_session(
server_url: String,
user_id: String,
access_token: String,
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 {
Ok(_) => Ok(true),
Err(e) => {
log::warn!("[AuthCommands] Session verification failed: {}", e);
Ok(false)
}
}
}
/// Logout (clear session and call Jellyfin logout endpoint)
#[tauri::command]
pub async fn auth_logout(
server_url: String,
access_token: String,
device_id: String,
auth_manager: State<'_, AuthManagerWrapper>,
session_verifier: State<'_, SessionVerifierWrapper>,
) -> Result<(), String> {
// Stop session verification
let mut verifier_guard = session_verifier.0.lock().await;
if let Some(verifier) = verifier_guard.take() {
verifier.stop();
}
drop(verifier_guard);
// Call Jellyfin logout endpoint
auth_manager.0.logout(&server_url, &access_token, &device_id).await?;
// Clear session
auth_manager.0.set_session(None).await;
Ok(())
}
/// Get current session
#[tauri::command]
pub async fn auth_get_session(
auth_manager: State<'_, AuthManagerWrapper>,
) -> Result<Option<Session>, String> {
Ok(auth_manager.0.get_session().await)
}
/// Set current session (for restoration from storage)
#[tauri::command]
pub async fn auth_set_session(
session: Option<Session>,
auth_manager: State<'_, AuthManagerWrapper>,
) -> Result<(), String> {
// Normalize the server URL if session is provided
let normalized_session = session.map(|mut s| {
s.server_url = crate::auth::AuthManager::normalize_url(&s.server_url);
s
});
auth_manager.0.set_session(normalized_session).await;
Ok(())
}
/// Start background session verification
#[tauri::command]
pub async fn auth_start_verification(
device_id: String,
app_handle: tauri::AppHandle,
auth_manager: State<'_, AuthManagerWrapper>,
session_verifier: State<'_, SessionVerifierWrapper>,
) -> Result<(), String> {
let mut verifier_guard = session_verifier.0.lock().await;
// Stop existing verifier if any
if let Some(verifier) = verifier_guard.take() {
verifier.stop();
}
// Get AuthManager Arc
let manager = auth_manager.0.clone();
// Create new verifier
let mut verifier = SessionVerifier::new(manager, device_id);
verifier.set_app_handle(app_handle);
verifier.start().await;
*verifier_guard = Some(verifier);
Ok(())
}
/// Stop background session verification
#[tauri::command]
pub async fn auth_stop_verification(
session_verifier: State<'_, SessionVerifierWrapper>,
) -> Result<(), String> {
let mut verifier_guard = session_verifier.0.lock().await;
if let Some(verifier) = verifier_guard.take() {
verifier.stop();
}
Ok(())
}
/// Re-authenticate with password (when session expired)
#[tauri::command]
pub async fn auth_reauthenticate(
password: String,
device_id: String,
auth_manager: State<'_, AuthManagerWrapper>,
) -> Result<AuthResult, String> {
// Get current session to extract server_url and username
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?;
// Update session with new token
let updated_session = Session {
user_id: result.user.id.clone(),
username: result.user.name.clone(),
server_id: result.server_id.clone(),
server_url: session.server_url,
server_name: session.server_name,
access_token: result.access_token.clone(),
verified: true,
needs_reauth: false,
};
auth_manager.0.set_session(Some(updated_session)).await;
Ok(result)
}
+76
View File
@@ -0,0 +1,76 @@
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>>);
/// Check if the server is currently reachable
#[tauri::command]
pub async fn connectivity_check_server(
state: State<'_, ConnectivityMonitorWrapper>,
) -> Result<bool, String> {
let monitor = state.0.lock().await;
Ok(monitor.check_reachability().await)
}
/// Set the server URL and trigger an immediate check
#[tauri::command]
pub async fn connectivity_set_server_url(
url: String,
state: State<'_, ConnectivityMonitorWrapper>,
) -> Result<(), String> {
let monitor = state.0.lock().await;
monitor.set_server_url(url).await;
Ok(())
}
/// Get the current connectivity status
#[tauri::command]
pub async fn connectivity_get_status(
state: State<'_, ConnectivityMonitorWrapper>,
) -> Result<ConnectivityStatus, String> {
let monitor = state.0.lock().await;
Ok(monitor.get_status().await)
}
/// Start monitoring connectivity with adaptive polling
#[tauri::command]
pub async fn connectivity_start_monitoring(
state: State<'_, ConnectivityMonitorWrapper>,
) -> Result<(), String> {
let monitor = state.0.lock().await;
monitor.start_monitoring().await;
Ok(())
}
/// Stop monitoring connectivity
#[tauri::command]
pub async fn connectivity_stop_monitoring(
state: State<'_, ConnectivityMonitorWrapper>,
) -> Result<(), String> {
let monitor = state.0.lock().await;
monitor.stop_monitoring();
Ok(())
}
/// Mark the server as reachable (called after successful API calls)
#[tauri::command]
pub async fn connectivity_mark_reachable(
state: State<'_, ConnectivityMonitorWrapper>,
) -> Result<(), String> {
let monitor = state.0.lock().await;
monitor.mark_reachable().await;
Ok(())
}
/// Mark the server as unreachable (called after failed API calls)
#[tauri::command]
pub async fn connectivity_mark_unreachable(
error: Option<String>,
state: State<'_, ConnectivityMonitorWrapper>,
) -> Result<(), String> {
let monitor = state.0.lock().await;
monitor.mark_unreachable(error).await;
Ok(())
}
+74
View File
@@ -0,0 +1,74 @@
//! Tauri commands for unit conversions and formatting
//!
//! 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,
};
/// Format time in seconds to MM:SS display string
///
/// # Arguments
/// * `seconds` - Time in seconds
///
/// # Returns
/// Formatted string like "3:45" or "12:09"
#[tauri::command]
pub fn format_time_seconds(seconds: f64) -> String {
format_time(seconds)
}
/// Format time in seconds to HH:MM:SS or MM:SS display string
///
/// Automatically chooses format based on duration:
/// - Less than 1 hour: Returns MM:SS format
/// - 1 hour or more: Returns HH:MM:SS format
///
/// # Arguments
/// * `seconds` - Time in seconds
///
/// # Returns
/// Formatted string like "1:23:45" or "3:45"
#[tauri::command]
pub fn format_time_seconds_long(seconds: f64) -> String {
format_time_long(seconds)
}
/// Convert Jellyfin ticks to seconds
///
/// # Arguments
/// * `ticks` - Time in Jellyfin ticks (10,000,000 ticks = 1 second)
///
/// # Returns
/// Time in seconds
#[tauri::command]
pub fn convert_ticks_to_seconds(ticks: i64) -> f64 {
ticks_to_seconds(ticks)
}
/// Calculate progress percentage from position and duration
///
/// # Arguments
/// * `position` - Current position in seconds
/// * `duration` - Total duration in seconds
///
/// # Returns
/// Progress as percentage (0.0 to 100.0)
#[tauri::command]
pub fn calc_progress(position: f64, duration: f64) -> f64 {
calculate_progress(position, duration)
}
/// Convert percentage volume (0-100) to normalized (0.0-1.0)
///
/// # Arguments
/// * `percent` - Volume as percentage (0 to 100)
///
/// # Returns
/// Normalized volume (0.0 to 1.0)
#[tauri::command]
pub fn convert_percent_to_volume(percent: f64) -> f64 {
percent_to_volume(percent)
}
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
pub mod auth;
pub mod connectivity;
pub mod conversions;
pub mod download;
pub mod offline;
pub mod playback_mode;
pub mod playback_reporting;
pub mod player;
pub mod repository;
pub mod sessions;
pub mod storage;
pub mod sync;
pub use auth::*;
pub use connectivity::*;
pub use conversions::*;
pub use download::*;
pub use offline::*;
pub use playback_mode::*;
#[allow(unused_imports)] // Used when playback_reporting is fully integrated
pub use playback_reporting::*;
pub use player::*;
pub use repository::{*, RepositoryManager, RepositoryManagerWrapper};
pub use sessions::*;
pub use storage::*;
pub use sync::*;
+154
View File
@@ -0,0 +1,154 @@
//! Tauri commands for offline data access
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tauri::State;
use super::DatabaseWrapper;
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OfflineItem {
pub id: String,
pub name: String,
pub item_type: String,
pub album_id: Option<String>,
pub album_name: Option<String>,
pub artists: Option<String>,
pub runtime_ticks: Option<i64>,
pub primary_image_tag: Option<String>,
}
/// Check if an item is available offline
#[tauri::command]
pub async fn offline_is_available(
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())
};
let query = Query::with_params(
"SELECT COUNT(*) FROM downloads WHERE item_id = ? AND status = 'completed'",
vec![QueryParam::String(item_id)],
);
let count: i64 = db_service
.query_one(query, |row| row.get(0))
.await
.map_err(|e| e.to_string())?;
Ok(count > 0)
}
/// Get all offline items for a user
#[tauri::command]
pub async fn offline_get_items(
db: State<'_, DatabaseWrapper>,
user_id: String,
) -> Result<Vec<OfflineItem>, String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
let query = Query::with_params(
"SELECT i.id, i.name, i.item_type, i.album_id, i.album_name, i.artists,
i.runtime_ticks, i.primary_image_tag
FROM items i
INNER JOIN downloads d ON i.id = d.item_id
WHERE d.user_id = ? AND d.status = 'completed'
ORDER BY d.completed_at DESC",
vec![QueryParam::String(user_id)],
);
db_service
.query_many(query, |row| {
Ok(OfflineItem {
id: row.get(0)?,
name: row.get(1)?,
item_type: row.get(2)?,
album_id: row.get(3)?,
album_name: row.get(4)?,
artists: row.get(5)?,
runtime_ticks: row.get(6)?,
primary_image_tag: row.get(7)?,
})
})
.await
.map_err(|e| e.to_string())
}
/// Search offline items
#[tauri::command]
pub async fn offline_search(
db: State<'_, DatabaseWrapper>,
user_id: String,
query: String,
) -> Result<Vec<OfflineItem>, String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
let search_query = format!("%{}%", query.to_lowercase());
let db_query = Query::with_params(
"SELECT i.id, i.name, i.item_type, i.album_id, i.album_name, i.artists,
i.runtime_ticks, i.primary_image_tag
FROM items i
INNER JOIN downloads d ON i.id = d.item_id
WHERE d.user_id = ? AND d.status = 'completed'
AND (LOWER(i.name) LIKE ? OR LOWER(i.artists) LIKE ? OR LOWER(i.album_name) LIKE ?)
ORDER BY i.name
LIMIT 50",
vec![
QueryParam::String(user_id),
QueryParam::String(search_query.clone()),
QueryParam::String(search_query.clone()),
QueryParam::String(search_query),
],
);
db_service
.query_many(db_query, |row| {
Ok(OfflineItem {
id: row.get(0)?,
name: row.get(1)?,
item_type: row.get(2)?,
album_id: row.get(3)?,
album_name: row.get(4)?,
artists: row.get(5)?,
runtime_ticks: row.get(6)?,
primary_image_tag: row.get(7)?,
})
})
.await
.map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_offline_item_serialization() {
let item = OfflineItem {
id: "123".to_string(),
name: "Test Song".to_string(),
item_type: "Audio".to_string(),
album_id: Some("album1".to_string()),
album_name: Some("Test Album".to_string()),
artists: Some("Artist 1".to_string()),
runtime_ticks: Some(180000000),
primary_image_tag: Some("tag123".to_string()),
};
let json = serde_json::to_string(&item).unwrap();
assert!(json.contains("\"itemType\":\"Audio\""));
assert!(json.contains("\"albumName\":\"Test Album\""));
}
}
+129
View File
@@ -0,0 +1,129 @@
use std::sync::Arc;
use tauri::State;
use crate::playback_mode::{PlaybackMode, PlaybackModeManager};
/// Wrapper for PlaybackModeManager to manage in Tauri state
pub struct PlaybackModeManagerWrapper(pub Arc<PlaybackModeManager>);
/// Get the current playback mode
#[tauri::command]
pub fn playback_mode_get_current(
manager: State<'_, PlaybackModeManagerWrapper>,
) -> Result<PlaybackMode, String> {
Ok(manager.0.get_mode())
}
/// Set the playback mode (internal/testing use)
#[tauri::command]
pub fn playback_mode_set(
manager: State<'_, PlaybackModeManagerWrapper>,
mode: PlaybackMode,
) -> Result<(), String> {
manager.0.set_mode(mode);
Ok(())
}
/// Check if currently transferring between playback modes
#[tauri::command]
pub fn playback_mode_is_transferring(
manager: State<'_, PlaybackModeManagerWrapper>,
) -> Result<bool, String> {
Ok(manager.0.is_transferring())
}
/// Transfer playback from local device to a remote Jellyfin session
#[tauri::command]
pub async fn playback_mode_transfer_to_remote(
manager: State<'_, PlaybackModeManagerWrapper>,
session_id: String,
) -> Result<(), String> {
log::info!(
"[PlaybackModeCommands] Transferring to remote session: {}",
session_id
);
manager.0.transfer_to_remote(session_id).await
}
/// Transfer playback from remote session back to local device
///
/// Parameters:
/// - current_item_id: The Jellyfin item ID currently playing on remote
/// - position_ticks: Current playback position in ticks (10,000 ticks = 1ms)
#[tauri::command]
pub async fn playback_mode_transfer_to_local(
manager: State<'_, PlaybackModeManagerWrapper>,
current_item_id: String,
position_ticks: i64,
) -> Result<(), String> {
log::info!(
"[PlaybackModeCommands] Transferring to local: item_id={}, position={}",
current_item_id,
position_ticks
);
manager
.0
.transfer_to_local(current_item_id, position_ticks)
.await
}
/// Get remote session status (for polling position/duration)
#[tauri::command]
pub async fn playback_mode_get_remote_status(
manager: State<'_, PlaybackModeManagerWrapper>,
player: State<'_, crate::commands::PlayerStateWrapper>,
) -> Result<RemoteSessionStatus, String> {
let mode = manager.0.get_mode();
if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
// Get Jellyfin client from player controller - clone before await
let client = {
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()
};
// Get session info
match client.get_session(&session_id).await {
Ok(Some(session)) => {
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()
.and_then(|item| item.run_time_ticks)
.unwrap_or(0);
let is_paused = session.play_state.as_ref()
.and_then(|ps| ps.is_paused)
.unwrap_or(true);
Ok(RemoteSessionStatus {
position: position_ticks as f64 / 10_000_000.0,
duration: if duration_ticks > 0 {
Some(duration_ticks as f64 / 10_000_000.0)
} else {
None
},
is_playing: !is_paused,
now_playing_item: session.now_playing_item.clone(),
})
}
Ok(None) => Err("Remote session not found".to_string()),
Err(e) => Err(format!("Failed to get session status: {}", e)),
}
} else {
Err("Not in remote playback mode".to_string())
}
}
/// Remote session status for UI updates
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RemoteSessionStatus {
pub position: f64,
pub duration: Option<f64>,
pub is_playing: bool,
pub now_playing_item: Option<crate::jellyfin::NowPlayingItem>,
}
@@ -0,0 +1,184 @@
//! Tauri commands for playback reporting operations
//!
//! These commands provide frontend access to the Rust playback reporting system,
//! replacing the TypeScript implementation with native Rust reporting.
//!
//! Commands are registered but not yet called from the frontend.
//! Dead code warnings are suppressed until frontend migration is complete.
#![allow(dead_code)]
use std::sync::Arc;
use tauri::State;
use tokio::sync::Mutex as TokioMutex;
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::utils::conversions::seconds_to_ticks;
/// Tauri state wrapper for PlaybackReporter
pub struct PlaybackReporterWrapper(pub Arc<TokioMutex<Option<PlaybackReporter>>>);
/// Initialize playback reporter (called after login)
#[tauri::command]
pub async fn playback_reporter_init(
reporter_wrapper: State<'_, PlaybackReporterWrapper>,
db: State<'_, DatabaseWrapper>,
server_url: String,
user_id: String,
access_token: String,
device_id: String,
) -> Result<(), String> {
log::info!("[PlaybackReporter] Initializing for user: {}", user_id);
// Get database service
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
// Create JellyfinClient
let jellyfin_config = JellyfinConfig {
server_url,
access_token,
device_id,
};
let jellyfin_client = JellyfinClient::new(jellyfin_config)
.map_err(|e| format!("Failed to create JellyfinClient: {}", e))?;
// Create PlaybackReporter
let reporter = PlaybackReporter::new(
db_service,
Arc::new(TokioMutex::new(Some(jellyfin_client))),
user_id.clone(),
);
// Store in wrapper
*reporter_wrapper.0.lock().await = Some(reporter);
log::info!("[PlaybackReporter] Initialized successfully for user: {}", user_id);
Ok(())
}
/// Destroy playback reporter (called on logout)
#[tauri::command]
pub async fn playback_reporter_destroy(
reporter_wrapper: State<'_, PlaybackReporterWrapper>,
) -> Result<(), String> {
log::info!("[PlaybackReporter] Destroying reporter");
*reporter_wrapper.0.lock().await = None;
Ok(())
}
/// Report playback start
#[tauri::command]
pub async fn playback_report_start(
reporter: State<'_, PlaybackReporterWrapper>,
connectivity: State<'_, ConnectivityMonitorWrapper>,
item_id: String,
position_seconds: f64,
context_type: Option<String>,
context_id: Option<String>,
) -> Result<(), String> {
let reporter_guard = reporter.0.lock().await;
let reporter_instance = reporter_guard
.as_ref()
.ok_or("PlaybackReporter not initialized")?;
let position_ticks = seconds_to_ticks(position_seconds);
let context = context_type.map(|ct| PlaybackContext {
context_type: ct,
context_id,
});
let operation = PlaybackOperation::Start {
item_id,
position_ticks,
context,
};
let monitor = connectivity.0.lock().await;
let is_online = monitor.get_status().await.is_server_reachable;
drop(monitor);
reporter_instance.report(operation, is_online).await
}
/// Report playback progress
#[tauri::command]
pub async fn playback_report_progress(
reporter: State<'_, PlaybackReporterWrapper>,
connectivity: State<'_, ConnectivityMonitorWrapper>,
item_id: String,
position_seconds: f64,
is_paused: bool,
) -> Result<(), String> {
let reporter_guard = reporter.0.lock().await;
let reporter_instance = reporter_guard
.as_ref()
.ok_or("PlaybackReporter not initialized")?;
let position_ticks = seconds_to_ticks(position_seconds);
let operation = PlaybackOperation::Progress {
item_id,
position_ticks,
is_paused,
};
let monitor = connectivity.0.lock().await;
let is_online = monitor.get_status().await.is_server_reachable;
drop(monitor);
reporter_instance.report(operation, is_online).await
}
/// Report playback stopped
#[tauri::command]
pub async fn playback_report_stopped(
reporter: State<'_, PlaybackReporterWrapper>,
connectivity: State<'_, ConnectivityMonitorWrapper>,
item_id: String,
position_seconds: f64,
) -> Result<(), String> {
let reporter_guard = reporter.0.lock().await;
let reporter_instance = reporter_guard
.as_ref()
.ok_or("PlaybackReporter not initialized")?;
let position_ticks = seconds_to_ticks(position_seconds);
let operation = PlaybackOperation::Stopped {
item_id,
position_ticks,
};
let monitor = connectivity.0.lock().await;
let is_online = monitor.get_status().await.is_server_reachable;
drop(monitor);
reporter_instance.report(operation, is_online).await
}
/// Mark item as played
#[tauri::command]
pub async fn playback_mark_played(
reporter: State<'_, PlaybackReporterWrapper>,
connectivity: State<'_, ConnectivityMonitorWrapper>,
item_id: String,
) -> Result<(), String> {
let reporter_guard = reporter.0.lock().await;
let reporter_instance = reporter_guard
.as_ref()
.ok_or("PlaybackReporter not initialized")?;
let operation = PlaybackOperation::MarkPlayed { item_id };
let monitor = connectivity.0.lock().await;
let is_online = monitor.get_status().await.is_server_reachable;
drop(monitor);
reporter_instance.report(operation, is_online).await
}
File diff suppressed because it is too large Load Diff
+435
View File
@@ -0,0 +1,435 @@
// Tauri commands for repository access
// Uses handle-based system: UUID -> Arc<HybridRepository>
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use log::{debug, error, info};
use tauri::State;
use uuid::Uuid;
use crate::jellyfin::HttpClient;
use crate::repository::{HybridRepository, MediaRepository, OnlineRepository, OfflineRepository, types::*};
/// Repository handle manager
pub struct RepositoryManager {
repositories: Arc<Mutex<HashMap<String, Arc<HybridRepository>>>>,
}
impl RepositoryManager {
pub fn new() -> Self {
Self {
repositories: Arc::new(Mutex::new(HashMap::new())),
}
}
pub fn create(&self, handle: String, repository: HybridRepository) {
let mut repos = self.repositories.lock().unwrap();
repos.insert(handle, Arc::new(repository));
}
pub fn get(&self, handle: &str) -> Option<Arc<HybridRepository>> {
let repos = self.repositories.lock().unwrap();
repos.get(handle).cloned()
}
pub fn destroy(&self, handle: &str) {
let mut repos = self.repositories.lock().unwrap();
repos.remove(handle);
}
}
/// Wrapper for Tauri state
pub struct RepositoryManagerWrapper(pub RepositoryManager);
/// Create a new repository instance
/// Returns a handle (UUID) for accessing the repository
#[tauri::command]
pub async fn repository_create(
manager: State<'_, RepositoryManagerWrapper>,
db: State<'_, crate::commands::storage::DatabaseWrapper>,
server_url: String,
user_id: String,
access_token: String,
server_id: String,
) -> Result<String, String> {
info!("[REPO] repository_create called for user: {}", user_id);
// Create HTTP client for online repository
debug!("[REPO] Creating HTTP client...");
let http_config = crate::jellyfin::HttpConfig::default();
let http_client = HttpClient::new(http_config).map_err(|e| {
error!("[REPO] HTTP client creation failed: {}", e);
e.to_string()
})?;
debug!("[REPO] HTTP client created successfully");
// Create online repository
debug!("[REPO] Creating online repository...");
let online = OnlineRepository::new(Arc::new(http_client), server_url, user_id.clone(), access_token);
debug!("[REPO] Online repository created");
// Create offline repository with async-safe database service
debug!("[REPO] Creating database service...");
let db_service = {
let database = db.0.lock().map_err(|e| {
error!("[REPO] Database lock failed: {}", e);
e.to_string()
})?;
debug!("[REPO] Database lock acquired, getting service...");
Arc::new(database.service())
}; // Lock is released here
debug!("[REPO] Database service created");
debug!("[REPO] Creating offline repository...");
let offline = OfflineRepository::new(db_service, server_id, user_id);
debug!("[REPO] Offline repository created");
// Create hybrid repository
debug!("[REPO] Creating hybrid repository...");
let hybrid = HybridRepository::new(online, offline);
debug!("[REPO] Hybrid repository created");
// Generate handle and store repository
let uuid = Uuid::new_v4();
let handle = format!("{}", uuid);
info!("[REPO] Generated handle: {}", handle);
// Store repository synchronously
debug!("[REPO] Storing repository...");
manager.0.create(handle.clone(), hybrid);
info!("[REPO] Repository stored successfully");
Ok(handle)
}
/// Destroy a repository instance
#[tauri::command]
pub async fn repository_destroy(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
) -> Result<(), String> {
manager.0.destroy(&handle);
Ok(())
}
/// Get libraries
#[tauri::command]
pub async fn repository_get_libraries(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
) -> Result<Vec<Library>, String> {
debug!("[REPO] get_libraries called with handle: {}", handle);
let repo = manager.0.get(&handle).ok_or_else(|| {
error!("[REPO] Repository not found for handle: {}", handle);
"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)
})
}
/// Get items in a container (library, folder, album, etc.)
#[tauri::command]
pub async fn repository_get_items(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
parent_id: String,
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)
.await
.map_err(|e| format!("{:?}", e))
}
/// Get a single item by ID
#[tauri::command]
pub async fn repository_get_item(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
item_id: String,
) -> Result<MediaItem, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().get_item(&item_id)
.await
.map_err(|e| format!("{:?}", e))
}
/// Get latest items in a library
#[tauri::command]
pub async fn repository_get_latest_items(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
parent_id: String,
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)
.await
.map_err(|e| format!("{:?}", e))
}
/// Get resume items (continue watching/listening)
#[tauri::command]
pub async fn repository_get_resume_items(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
parent_id: Option<String>,
limit: Option<usize>,
) -> Result<Vec<MediaItem>, String> {
debug!("[REPO] get_resume_items called with handle: {}", handle);
let repo = manager.0.get(&handle).ok_or_else(|| {
error!("[REPO] Repository not found for handle: {}", handle);
"Repository not found".to_string()
})?;
debug!("[REPO] Repository found, fetching resume items...");
repo.as_ref().get_resume_items(parent_id.as_deref(), limit)
.await
.map_err(|e| {
error!("[REPO] Error fetching resume items: {:?}", e);
format!("{:?}", e)
})
}
/// Get next up episodes
#[tauri::command]
pub async fn repository_get_next_up_episodes(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
series_id: Option<String>,
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)
.await
.map_err(|e| format!("{:?}", e))
}
/// Get recently played audio
#[tauri::command]
pub async fn repository_get_recently_played_audio(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
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)
.await
.map_err(|e| format!("{:?}", e))
}
/// Get resume movies
#[tauri::command]
pub async fn repository_get_resume_movies(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
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)
.await
.map_err(|e| format!("{:?}", e))
}
/// Get genres for a library
#[tauri::command]
pub async fn repository_get_genres(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
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())
.await
.map_err(|e| format!("{:?}", e))
}
/// Search for items
#[tauri::command]
pub async fn repository_search(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
query: String,
options: Option<SearchOptions>,
) -> Result<SearchResult, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().search(&query, options)
.await
.map_err(|e| format!("{:?}", e))
}
/// Get playback info for an item
#[tauri::command]
pub async fn repository_get_playback_info(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
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)
.await
.map_err(|e| format!("{:?}", e))
}
/// Get video stream URL with optional seeking support
#[tauri::command]
pub async fn repository_get_video_stream_url(
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_video_stream_url(
&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]
pub async fn repository_get_audio_stream_url(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
item_id: String,
) -> Result<String, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref()
.get_audio_stream_url(&item_id)
.await
.map_err(|e| format!("{:?}", e))
}
/// Report playback start
#[tauri::command]
pub async fn repository_report_playback_start(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
item_id: String,
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)
.await
.map_err(|e| format!("{:?}", e))
}
/// Report playback progress
#[tauri::command]
pub async fn repository_report_playback_progress(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
item_id: String,
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)
.await
.map_err(|e| format!("{:?}", e))
}
/// Report playback stopped
#[tauri::command]
pub async fn repository_report_playback_stopped(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
item_id: String,
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)
.await
.map_err(|e| format!("{:?}", e))
}
/// Get image URL for an item
#[tauri::command]
pub fn repository_get_image_url(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
item_id: String,
image_type: ImageType,
options: Option<ImageOptions>,
) -> Result<String, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
Ok(repo.as_ref().get_image_url(&item_id, image_type, options))
}
/// Mark an item as favorite
#[tauri::command]
pub async fn repository_mark_favorite(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
item_id: String,
) -> Result<(), String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().mark_favorite(&item_id)
.await
.map_err(|e| format!("{:?}", e))
}
/// Unmark an item as favorite
#[tauri::command]
pub async fn repository_unmark_favorite(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
item_id: String,
) -> Result<(), String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().unmark_favorite(&item_id)
.await
.map_err(|e| format!("{:?}", e))
}
/// Get person details
#[tauri::command]
pub async fn repository_get_person(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
person_id: String,
) -> Result<MediaItem, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().get_person(&person_id)
.await
.map_err(|e| format!("{:?}", e))
}
/// Get items by person (actor, director, etc.)
#[tauri::command]
pub async fn repository_get_items_by_person(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
person_id: String,
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)
.await
.map_err(|e| format!("{:?}", e))
}
/// Get similar/related items for a media item
#[tauri::command]
pub async fn repository_get_similar_items(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
item_id: String,
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)
.await
.map_err(|e| format!("{:?}", e))
}
+32
View File
@@ -0,0 +1,32 @@
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>);
/// Set polling frequency hint based on UI state
#[tauri::command]
pub fn sessions_set_polling_hint(
poller: State<'_, SessionPollerWrapper>,
hint: String,
) -> Result<(), String> {
let parsed_hint = match hint.as_str() {
"cast_active" => PollingHint::CastActive,
"cast_discovery" => PollingHint::CastDiscovery,
"normal" => PollingHint::Normal,
_ => return Err(format!("Invalid polling hint: {}", hint)),
};
poller.0.set_polling_hint(parsed_hint);
Ok(())
}
/// Manually trigger a session poll (for refresh button)
#[tauri::command]
pub async fn sessions_poll_now(
poller: State<'_, SessionPollerWrapper>,
) -> Result<Vec<SessionInfo>, String> {
poller.0.poll_now().await
}
File diff suppressed because it is too large Load Diff
+235
View File
@@ -0,0 +1,235 @@
//! Tauri commands for sync queue operations
//!
//! The sync queue stores mutations (favorites, playback progress, etc.)
//! that need to be synced to the Jellyfin server when connectivity is restored.
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tauri::State;
use super::storage::DatabaseWrapper;
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
/// Sync queue item returned to frontend
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SyncQueueItem {
pub id: i64,
pub user_id: String,
pub operation: String,
pub item_id: Option<String>,
pub payload: Option<String>,
pub status: String,
pub retry_count: i32,
pub created_at: Option<String>,
pub error_message: Option<String>,
}
/// Queue a mutation for sync to server
#[tauri::command]
pub async fn sync_queue_mutation(
db: State<'_, DatabaseWrapper>,
user_id: String,
operation: String,
item_id: Option<String>,
payload: Option<String>,
) -> Result<i64, String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
let query = Query::with_params(
"INSERT INTO sync_queue (user_id, operation, item_id, payload, status, created_at)
VALUES (?, ?, ?, ?, 'pending', CURRENT_TIMESTAMP)",
vec![
QueryParam::String(user_id),
QueryParam::String(operation),
item_id.map(QueryParam::String).unwrap_or(QueryParam::Null),
payload.map(QueryParam::String).unwrap_or(QueryParam::Null),
],
);
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())?;
Ok(id)
}
/// Get all pending sync operations for a user
#[tauri::command]
pub async fn sync_get_pending(
db: State<'_, DatabaseWrapper>,
user_id: String,
limit: Option<i32>,
) -> Result<Vec<SyncQueueItem>, String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
let sql = if let Some(l) = limit {
format!(
"SELECT id, user_id, operation, item_id, payload, status, retry_count, created_at, error_message
FROM sync_queue
WHERE user_id = ? AND status IN ('pending', 'failed')
ORDER BY created_at ASC
LIMIT {}",
l
)
} else {
"SELECT id, user_id, operation, item_id, payload, status, retry_count, created_at, error_message
FROM sync_queue
WHERE user_id = ? AND status IN ('pending', 'failed')
ORDER BY created_at ASC".to_string()
};
let query = Query::with_params(sql, vec![QueryParam::String(user_id)]);
db_service
.query_many(query, |row| {
Ok(SyncQueueItem {
id: row.get(0)?,
user_id: row.get(1)?,
operation: row.get(2)?,
item_id: row.get(3)?,
payload: row.get(4)?,
status: row.get(5)?,
retry_count: row.get(6)?,
created_at: row.get(7)?,
error_message: row.get(8)?,
})
})
.await
.map_err(|e| e.to_string())
}
/// Mark a sync operation as in progress
#[tauri::command]
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())
};
let query = Query::with_params(
"UPDATE sync_queue SET status = 'processing' WHERE id = ?",
vec![QueryParam::Int64(id)],
);
db_service.execute(query).await.map_err(|e| e.to_string())?;
Ok(())
}
/// Mark a sync operation as completed
#[tauri::command]
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())
};
let query = Query::with_params(
"UPDATE sync_queue SET status = 'completed', processed_at = CURRENT_TIMESTAMP WHERE id = ?",
vec![QueryParam::Int64(id)],
);
db_service.execute(query).await.map_err(|e| e.to_string())?;
Ok(())
}
/// Mark a sync operation as failed with error message
#[tauri::command]
pub async fn sync_mark_failed(
db: State<'_, DatabaseWrapper>,
id: i64,
error: String,
) -> Result<(), String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
let query = Query::with_params(
"UPDATE sync_queue
SET status = 'failed',
retry_count = retry_count + 1,
error_message = ?,
processed_at = CURRENT_TIMESTAMP
WHERE id = ?",
vec![QueryParam::String(error), QueryParam::Int64(id)],
);
db_service.execute(query).await.map_err(|e| e.to_string())?;
Ok(())
}
/// Get count of pending sync operations for a user
#[tauri::command]
pub async fn sync_get_pending_count(
db: State<'_, DatabaseWrapper>,
user_id: String,
) -> Result<i32, String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
let query = Query::with_params(
"SELECT COUNT(*) FROM sync_queue WHERE user_id = ? AND status IN ('pending', 'failed')",
vec![QueryParam::String(user_id)],
);
db_service
.query_one(query, |row| row.get(0))
.await
.map_err(|e| e.to_string())
}
/// Delete completed sync operations older than specified days
#[tauri::command]
pub async fn sync_cleanup_completed(
db: State<'_, DatabaseWrapper>,
days_old: i32,
) -> Result<i32, String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
let query = Query::with_params(
"DELETE FROM sync_queue
WHERE status = 'completed'
AND processed_at < datetime('now', ?)",
vec![QueryParam::String(format!("-{} days", days_old))],
);
let deleted = db_service.execute(query).await.map_err(|e| e.to_string())?;
Ok(deleted as i32)
}
/// Delete all sync operations for a user (used during logout)
#[tauri::command]
pub async fn sync_clear_user(
db: State<'_, DatabaseWrapper>,
user_id: String,
) -> Result<(), String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
let query = Query::with_params(
"DELETE FROM sync_queue WHERE user_id = ?",
vec![QueryParam::String(user_id)],
);
db_service.execute(query).await.map_err(|e| e.to_string())?;
Ok(())
}