diff --git a/src-tauri/src/commands/download.rs b/src-tauri/src/commands/download/mod.rs similarity index 89% rename from src-tauri/src/commands/download.rs rename to src-tauri/src/commands/download/mod.rs index 81f1ad9..d393df5 100644 --- a/src-tauri/src/commands/download.rs +++ b/src-tauri/src/commands/download/mod.rs @@ -10,6 +10,13 @@ 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). +mod pinning; +mod smart_cache; +pub use pinning::*; +pub use smart_cache::*; + /// Wrapper for DownloadManager to be used as Tauri state pub struct DownloadManagerWrapper(pub Mutex); @@ -522,61 +529,6 @@ pub async fn download_season( Ok(download_ids) } -/// Pin an item's metadata (protects from cache clear) -#[tauri::command] -pub async fn pin_item(db: State<'_, DatabaseWrapper>, item_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( - "UPDATE items SET is_pinned = 1 WHERE id = ?", - vec![QueryParam::String(item_id)], - ); - - db_service.execute(query).await.map_err(|e| e.to_string())?; - Ok(()) -} - -/// Unpin an item's metadata -#[tauri::command] -pub async fn unpin_item(db: State<'_, DatabaseWrapper>, item_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( - "UPDATE items SET is_pinned = 0 WHERE id = ?", - vec![QueryParam::String(item_id)], - ); - - db_service.execute(query).await.map_err(|e| e.to_string())?; - Ok(()) -} - -/// Check if an item is pinned -#[tauri::command] -pub async fn is_item_pinned(db: State<'_, DatabaseWrapper>, item_id: String) -> Result { - let db_service = { - let database = db.0.lock().map_err(|e| e.to_string())?; - Arc::new(database.service()) - }; - - let query = Query::with_params( - "SELECT COALESCE(is_pinned, 0) FROM items WHERE id = ?", - vec![QueryParam::String(item_id)], - ); - - let is_pinned: i32 = db_service - .query_optional(query, |row| row.get(0)) - .await - .map_err(|e| e.to_string())? - .unwrap_or(0); - - Ok(is_pinned == 1) -} /// Helper to compute download statistics from a list of downloads #[allow(dead_code)] @@ -1336,220 +1288,6 @@ pub async fn set_max_concurrent_downloads( Ok(()) } -/// SmartCache statistics -#[derive(Debug, Clone, serde::Serialize)] -pub struct SmartCacheStats { - pub total_size: u64, - pub storage_limit: u64, - pub available_space: u64, - pub items_count: i64, - pub config: crate::download::cache::CacheConfig, -} - -/// Get SmartCache statistics -#[tauri::command] -pub async fn get_smart_cache_stats( - db: State<'_, DatabaseWrapper>, - smart_cache: State<'_, SmartCacheWrapper>, - user_id: String, -) -> Result { - let db_service = { - let database = db.0.lock().map_err(|e| e.to_string())?; - Arc::new(database.service()) - }; - - // Clone cache to avoid holding lock across async operations - let cache = { - let guard = smart_cache.0.lock().map_err(|e| e.to_string())?; - guard.clone() - }; - - let total_size = cache - .get_total_download_size_async(&db_service, &user_id) - .await?; - - let config = cache - .get_config() - .ok_or_else(|| "Failed to get cache config".to_string())?; - - let storage_limit = config.storage_limit; - let available_space = storage_limit.saturating_sub(total_size); - - // Get item count - let count_query = Query::with_params( - "SELECT COUNT(*) FROM downloads WHERE user_id = ? AND status = 'completed'", - vec![QueryParam::String(user_id)], - ); - let items_count: i64 = db_service - .query_one(count_query, |row| row.get(0)) - .await - .unwrap_or(0); - - Ok(SmartCacheStats { - total_size, - storage_limit, - available_space, - items_count, - config, - }) -} - -/// Update SmartCache configuration -#[tauri::command] -pub async fn update_smart_cache_config( - smart_cache: State<'_, SmartCacheWrapper>, - config: crate::download::cache::CacheConfig, -) -> Result<(), String> { - let cache = smart_cache.0.lock().map_err(|e| e.to_string())?; - cache.update_config(config); - info!("Updated SmartCache configuration"); - Ok(()) -} - -/// Get SmartCache configuration -#[tauri::command] -pub async fn get_smart_cache_config( - smart_cache: State<'_, SmartCacheWrapper>, -) -> Result { - let cache = smart_cache.0.lock().map_err(|e| e.to_string())?; - cache - .get_config() - .ok_or_else(|| "Failed to get cache config".to_string()) -} - -/// Album recommendation info -#[derive(Debug, Clone, serde::Serialize)] -pub struct AlbumRecommendation { - pub album_id: String, - pub album_name: String, - pub tracks_played: usize, - pub total_tracks: usize, - pub should_download: bool, -} - -/// Album affinity status info -#[derive(Debug, Clone, serde::Serialize)] -#[serde(rename_all = "camelCase")] -pub struct AlbumAffinityStatus { - pub album_id: String, - pub unique_tracks_played: usize, - pub threshold: usize, - pub threshold_reached: bool, -} - -/// Get album recommendations based on play history -#[tauri::command] -pub async fn get_album_recommendations( - db: State<'_, DatabaseWrapper>, - smart_cache: State<'_, SmartCacheWrapper>, - user_id: String, -) -> Result, String> { - let db_service = { - let database = db.0.lock().map_err(|e| e.to_string())?; - Arc::new(database.service()) - }; - - // Clone cache to avoid holding lock across async operations - let cache = { - let guard = smart_cache.0.lock().map_err(|e| e.to_string())?; - guard.clone() - }; - - // Get all albums that user has played tracks from - let query = Query::with_params( - "SELECT DISTINCT i.album_id, a.name - FROM user_data ud - JOIN items i ON ud.item_id = i.id - JOIN items a ON i.album_id = a.id - WHERE ud.user_id = ? - AND ud.play_count > 0 - AND i.item_type = 'Audio' - AND i.album_id IS NOT NULL", - vec![QueryParam::String(user_id.clone())], - ); - - let albums: Vec<(String, String)> = db_service - .query_many(query, |row| Ok((row.get(0)?, row.get(1)?))) - .await - .unwrap_or_default(); - - let mut recommendations = Vec::new(); - - for (album_id, album_name) in albums { - // Check if should cache - let should_download = cache.should_cache_album(&album_id).unwrap_or(false); - - // Get track counts - let tracks_query = Query::with_params( - "SELECT - COUNT(*) as total, - COUNT(ud.id) as played - FROM items i - LEFT JOIN user_data ud ON i.id = ud.item_id AND ud.user_id = ? - WHERE i.album_id = ? AND i.item_type = 'Audio'", - vec![ - QueryParam::String(user_id.clone()), - QueryParam::String(album_id.clone()), - ], - ); - - let (total_tracks, tracks_played): (i64, i64) = db_service - .query_one(tracks_query, |row| Ok((row.get(0)?, row.get(1)?))) - .await - .unwrap_or((0, 0)); - - if tracks_played > 0 { - recommendations.push(AlbumRecommendation { - album_id, - album_name, - tracks_played: tracks_played as usize, - total_tracks: total_tracks as usize, - should_download, - }); - } - } - - // Sort by tracks played (descending) - recommendations.sort_by(|a, b| b.tracks_played.cmp(&a.tracks_played)); - - Ok(recommendations) -} - -/// Get album affinity status for all tracked albums -/// This shows the SmartCache's internal play history and threshold status -#[tauri::command] -pub fn get_album_affinity_status( - smart_cache: State<'_, SmartCacheWrapper>, -) -> Result, String> { - let cache = smart_cache.0.lock().map_err(|e| e.to_string())?; - - // Get the threshold from config - let threshold = cache - .get_config() - .map(|c| c.album_affinity_threshold) - .unwrap_or(3); - - // Get all tracked albums with their play counts - let play_history = cache.get_album_play_history(); - - let mut statuses: Vec = play_history - .into_iter() - .map(|(album_id, unique_tracks_played)| { - let threshold_reached = unique_tracks_played >= threshold; - AlbumAffinityStatus { - album_id, - unique_tracks_played, - threshold, - threshold_reached, - } - }) - .collect(); - - // Sort by play count (descending) - statuses.sort_by(|a, b| b.unique_tracks_played.cmp(&a.unique_tracks_played)); - - Ok(statuses) -} // TRACES: UR-011, UR-018 | DR-015, DR-018 | UT-042, UT-043 #[cfg(test)] diff --git a/src-tauri/src/commands/download/pinning.rs b/src-tauri/src/commands/download/pinning.rs new file mode 100644 index 0000000..8a49fd0 --- /dev/null +++ b/src-tauri/src/commands/download/pinning.rs @@ -0,0 +1,63 @@ +//! Pinning commands - protect an item's cached metadata from cache clearing. + +use std::sync::Arc; +use tauri::State; + +use crate::commands::DatabaseWrapper; +use crate::storage::db_service::{DatabaseService, Query, QueryParam}; + +/// Pin an item's metadata (protects from cache clear) +#[tauri::command] +pub async fn pin_item(db: State<'_, DatabaseWrapper>, item_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( + "UPDATE items SET is_pinned = 1 WHERE id = ?", + vec![QueryParam::String(item_id)], + ); + + db_service.execute(query).await.map_err(|e| e.to_string())?; + Ok(()) +} + +/// Unpin an item's metadata +#[tauri::command] +pub async fn unpin_item(db: State<'_, DatabaseWrapper>, item_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( + "UPDATE items SET is_pinned = 0 WHERE id = ?", + vec![QueryParam::String(item_id)], + ); + + db_service.execute(query).await.map_err(|e| e.to_string())?; + Ok(()) +} + +/// Check if an item is pinned +#[tauri::command] +pub async fn is_item_pinned(db: State<'_, DatabaseWrapper>, item_id: String) -> Result { + let db_service = { + let database = db.0.lock().map_err(|e| e.to_string())?; + Arc::new(database.service()) + }; + + let query = Query::with_params( + "SELECT COALESCE(is_pinned, 0) FROM items WHERE id = ?", + vec![QueryParam::String(item_id)], + ); + + let is_pinned: i32 = db_service + .query_optional(query, |row| row.get(0)) + .await + .map_err(|e| e.to_string())? + .unwrap_or(0); + + Ok(is_pinned == 1) +} diff --git a/src-tauri/src/commands/download/smart_cache.rs b/src-tauri/src/commands/download/smart_cache.rs new file mode 100644 index 0000000..1b0afb8 --- /dev/null +++ b/src-tauri/src/commands/download/smart_cache.rs @@ -0,0 +1,223 @@ +//! Smart-cache statistics/config and album recommendation commands. + +use std::sync::Arc; +use log::info; +use tauri::State; + +use crate::commands::{DatabaseWrapper, SmartCacheWrapper}; +use crate::storage::db_service::{DatabaseService, Query, QueryParam}; + +/// SmartCache statistics +#[derive(Debug, Clone, serde::Serialize)] +pub struct SmartCacheStats { + pub total_size: u64, + pub storage_limit: u64, + pub available_space: u64, + pub items_count: i64, + pub config: crate::download::cache::CacheConfig, +} + +/// Get SmartCache statistics +#[tauri::command] +pub async fn get_smart_cache_stats( + db: State<'_, DatabaseWrapper>, + smart_cache: State<'_, SmartCacheWrapper>, + user_id: String, +) -> Result { + let db_service = { + let database = db.0.lock().map_err(|e| e.to_string())?; + Arc::new(database.service()) + }; + + // Clone cache to avoid holding lock across async operations + let cache = { + let guard = smart_cache.0.lock().map_err(|e| e.to_string())?; + guard.clone() + }; + + let total_size = cache + .get_total_download_size_async(&db_service, &user_id) + .await?; + + let config = cache + .get_config() + .ok_or_else(|| "Failed to get cache config".to_string())?; + + let storage_limit = config.storage_limit; + let available_space = storage_limit.saturating_sub(total_size); + + // Get item count + let count_query = Query::with_params( + "SELECT COUNT(*) FROM downloads WHERE user_id = ? AND status = 'completed'", + vec![QueryParam::String(user_id)], + ); + let items_count: i64 = db_service + .query_one(count_query, |row| row.get(0)) + .await + .unwrap_or(0); + + Ok(SmartCacheStats { + total_size, + storage_limit, + available_space, + items_count, + config, + }) +} + +/// Update SmartCache configuration +#[tauri::command] +pub async fn update_smart_cache_config( + smart_cache: State<'_, SmartCacheWrapper>, + config: crate::download::cache::CacheConfig, +) -> Result<(), String> { + let cache = smart_cache.0.lock().map_err(|e| e.to_string())?; + cache.update_config(config); + info!("Updated SmartCache configuration"); + Ok(()) +} + +/// Get SmartCache configuration +#[tauri::command] +pub async fn get_smart_cache_config( + smart_cache: State<'_, SmartCacheWrapper>, +) -> Result { + let cache = smart_cache.0.lock().map_err(|e| e.to_string())?; + cache + .get_config() + .ok_or_else(|| "Failed to get cache config".to_string()) +} + +/// Album recommendation info +#[derive(Debug, Clone, serde::Serialize)] +pub struct AlbumRecommendation { + pub album_id: String, + pub album_name: String, + pub tracks_played: usize, + pub total_tracks: usize, + pub should_download: bool, +} + +/// Album affinity status info +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AlbumAffinityStatus { + pub album_id: String, + pub unique_tracks_played: usize, + pub threshold: usize, + pub threshold_reached: bool, +} + +/// Get album recommendations based on play history +#[tauri::command] +pub async fn get_album_recommendations( + db: State<'_, DatabaseWrapper>, + smart_cache: State<'_, SmartCacheWrapper>, + user_id: String, +) -> Result, String> { + let db_service = { + let database = db.0.lock().map_err(|e| e.to_string())?; + Arc::new(database.service()) + }; + + // Clone cache to avoid holding lock across async operations + let cache = { + let guard = smart_cache.0.lock().map_err(|e| e.to_string())?; + guard.clone() + }; + + // Get all albums that user has played tracks from + let query = Query::with_params( + "SELECT DISTINCT i.album_id, a.name + FROM user_data ud + JOIN items i ON ud.item_id = i.id + JOIN items a ON i.album_id = a.id + WHERE ud.user_id = ? + AND ud.play_count > 0 + AND i.item_type = 'Audio' + AND i.album_id IS NOT NULL", + vec![QueryParam::String(user_id.clone())], + ); + + let albums: Vec<(String, String)> = db_service + .query_many(query, |row| Ok((row.get(0)?, row.get(1)?))) + .await + .unwrap_or_default(); + + let mut recommendations = Vec::new(); + + for (album_id, album_name) in albums { + // Check if should cache + let should_download = cache.should_cache_album(&album_id).unwrap_or(false); + + // Get track counts + let tracks_query = Query::with_params( + "SELECT + COUNT(*) as total, + COUNT(ud.id) as played + FROM items i + LEFT JOIN user_data ud ON i.id = ud.item_id AND ud.user_id = ? + WHERE i.album_id = ? AND i.item_type = 'Audio'", + vec![ + QueryParam::String(user_id.clone()), + QueryParam::String(album_id.clone()), + ], + ); + + let (total_tracks, tracks_played): (i64, i64) = db_service + .query_one(tracks_query, |row| Ok((row.get(0)?, row.get(1)?))) + .await + .unwrap_or((0, 0)); + + if tracks_played > 0 { + recommendations.push(AlbumRecommendation { + album_id, + album_name, + tracks_played: tracks_played as usize, + total_tracks: total_tracks as usize, + should_download, + }); + } + } + + // Sort by tracks played (descending) + recommendations.sort_by(|a, b| b.tracks_played.cmp(&a.tracks_played)); + + Ok(recommendations) +} + +/// Get album affinity status for all tracked albums +/// This shows the SmartCache's internal play history and threshold status +#[tauri::command] +pub fn get_album_affinity_status( + smart_cache: State<'_, SmartCacheWrapper>, +) -> Result, String> { + let cache = smart_cache.0.lock().map_err(|e| e.to_string())?; + + // Get the threshold from config + let threshold = cache + .get_config() + .map(|c| c.album_affinity_threshold) + .unwrap_or(3); + + // Get all tracked albums with their play counts + let play_history = cache.get_album_play_history(); + + let mut statuses: Vec = play_history + .into_iter() + .map(|(album_id, unique_tracks_played)| { + let threshold_reached = unique_tracks_played >= threshold; + AlbumAffinityStatus { + album_id, + unique_tracks_played, + threshold, + threshold_reached, + } + }) + .collect(); + + // Sort by play count (descending) + statuses.sort_by(|a, b| b.unique_tracks_played.cmp(&a.unique_tracks_played)); + + Ok(statuses) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6e44d6a..d8ae398 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -12,7 +12,7 @@ mod session_poller; pub mod settings; mod storage; mod thumbnail; -mod utils; +pub mod utils; use std::sync::{Arc, Mutex}; use tokio::sync::Mutex as TokioMutex; diff --git a/src-tauri/src/utils/conversions.rs b/src-tauri/src/utils/conversions.rs index 25646ab..60250b1 100644 --- a/src-tauri/src/utils/conversions.rs +++ b/src-tauri/src/utils/conversions.rs @@ -24,6 +24,7 @@ pub const TICKS_PER_SECOND: i64 = 10_000_000; /// /// # Example /// ``` +/// # use jellytau_lib::utils::conversions::*; /// let ticks = seconds_to_ticks(1.5); // 15,000,000 ticks /// ``` #[inline] @@ -41,6 +42,7 @@ pub fn seconds_to_ticks(seconds: f64) -> i64 { /// /// # Example /// ``` +/// # use jellytau_lib::utils::conversions::*; /// let seconds = ticks_to_seconds(15_000_000); // 1.5 seconds /// ``` #[inline] @@ -61,6 +63,7 @@ pub fn ticks_to_seconds(ticks: i64) -> f64 { /// /// # Example /// ``` +/// # use jellytau_lib::utils::conversions::*; /// let normalized = percent_to_volume(75.0); // 0.75 /// ``` #[inline] @@ -81,6 +84,7 @@ pub fn percent_to_volume(percent: f64) -> f64 { /// /// # Example /// ``` +/// # use jellytau_lib::utils::conversions::*; /// let percent = volume_to_percent(0.75); // 75.0 /// ``` #[inline] @@ -99,6 +103,7 @@ pub fn volume_to_percent(volume: f64) -> f64 { /// /// # Example /// ``` +/// # use jellytau_lib::utils::conversions::*; /// let formatted = format_time(225.0); // "3:45" /// ``` pub fn format_time(seconds: f64) -> String { @@ -121,6 +126,7 @@ pub fn format_time(seconds: f64) -> String { /// /// # Example /// ``` +/// # use jellytau_lib::utils::conversions::*; /// let short = format_time_long(225.0); // "3:45" /// let long = format_time_long(5025.0); // "1:23:45" /// ``` @@ -147,6 +153,7 @@ pub fn format_time_long(seconds: f64) -> String { /// /// # Example /// ``` +/// # use jellytau_lib::utils::conversions::*; /// let progress = calculate_progress(45.0, 180.0); // 25.0 /// ``` pub fn calculate_progress(position: f64, duration: f64) -> f64 {