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
+326
View File
@@ -0,0 +1,326 @@
//! Smart caching engine for predictive downloads
use log::{debug, info};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use serde::{Deserialize, Serialize};
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
/// Smart caching configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CacheConfig {
/// Enable queue pre-caching
pub queue_precache_enabled: bool,
/// Number of tracks to pre-cache from queue
pub queue_precache_count: usize,
/// Enable album affinity detection
pub album_affinity_enabled: bool,
/// Threshold for album affinity (tracks played before caching)
pub album_affinity_threshold: usize,
/// Storage limit in bytes (0 = unlimited)
pub storage_limit: u64,
/// Only cache on WiFi
pub wifi_only: bool,
}
impl Default for CacheConfig {
fn default() -> Self {
Self {
queue_precache_enabled: true,
queue_precache_count: 3, // Preload next 3 tracks by default
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
}
}
}
/// Smart caching engine
#[derive(Clone)]
pub struct SmartCache {
config: Arc<Mutex<CacheConfig>>,
/// Track recently played items per album
album_play_history: Arc<Mutex<HashMap<String, Vec<String>>>>,
}
impl SmartCache {
pub fn new(config: CacheConfig) -> Self {
Self {
config: Arc::new(Mutex::new(config)),
album_play_history: Arc::new(Mutex::new(HashMap::new())),
}
}
/// Update configuration
pub fn update_config(&self, config: CacheConfig) {
if let Ok(mut cfg) = self.config.lock() {
*cfg = config;
}
}
/// Check if should pre-cache queue items
pub fn should_precache_queue(&self) -> bool {
self.config
.lock()
.map(|cfg| cfg.queue_precache_enabled && !cfg.wifi_only)
.unwrap_or(false)
}
/// Get number of queue items to pre-cache
pub fn queue_precache_count(&self) -> usize {
self.config
.lock()
.map(|cfg| cfg.queue_precache_count)
.unwrap_or(5)
}
/// Track that an item was played
pub fn track_play(&self, item_id: &str, album_id: Option<&str>) {
if let Some(album) = album_id {
if let Ok(mut history) = self.album_play_history.lock() {
let plays = history.entry(album.to_string()).or_insert_with(Vec::new);
if !plays.contains(&item_id.to_string()) {
plays.push(item_id.to_string());
}
}
}
}
/// Check if album affinity threshold reached for caching
pub fn should_cache_album(&self, album_id: &str) -> Option<bool> {
let config = self.config.lock().ok()?;
if !config.album_affinity_enabled {
return Some(false);
}
let history = self.album_play_history.lock().ok()?;
let play_count = history.get(album_id).map(|v| v.len()).unwrap_or(0);
Some(play_count >= config.album_affinity_threshold)
}
/// Get configuration
pub fn get_config(&self) -> Option<CacheConfig> {
self.config.lock().ok().map(|cfg| cfg.clone())
}
/// Get all tracked albums with their play counts
/// Returns Vec<(album_id, unique_tracks_played)>
pub fn get_album_play_history(&self) -> Vec<(String, usize)> {
self.album_play_history
.lock()
.ok()
.map(|history| {
history
.iter()
.map(|(album_id, tracks)| (album_id.clone(), tracks.len()))
.collect()
})
.unwrap_or_default()
}
// ============= Async versions for DatabaseService =============
/// Get total download size for a user (async version)
pub async fn get_total_download_size_async<S: DatabaseService>(
&self,
db_service: &Arc<S>,
user_id: &str,
) -> Result<u64, String> {
let query = Query::with_params(
"SELECT COALESCE(SUM(file_size), 0) FROM downloads
WHERE user_id = ? AND status = 'completed'",
vec![QueryParam::String(user_id.to_string())],
);
let size: i64 = db_service
.query_one(query, |row| row.get(0))
.await
.map_err(|e| e.to_string())?;
Ok(size as u64)
}
/// Check if storage limit allows download (async version)
pub async fn can_download_async<S: DatabaseService>(
&self,
db_service: &Arc<S>,
user_id: &str,
new_size: u64,
) -> bool {
// Clone config to avoid holding lock across await
let storage_limit = {
match self.config.lock() {
Ok(cfg) => cfg.storage_limit,
Err(_) => return true,
}
};
if storage_limit == 0 {
return true; // Unlimited
}
let current_size = self
.get_total_download_size_async(db_service, user_id)
.await
.unwrap_or(0);
current_size + new_size <= storage_limit
}
/// Evict least recently used items to make space (async version)
pub async fn evict_lru_async<S: DatabaseService>(
&self,
db_service: &Arc<S>,
user_id: &str,
space_needed: u64,
) -> Result<u64, String> {
let current_size = self
.get_total_download_size_async(db_service, user_id)
.await?;
// Get limit without holding lock across await
let limit = {
let config = self.config.lock().map_err(|e| e.to_string())?;
config.storage_limit
};
if limit == 0 || current_size + space_needed <= limit {
return Ok(0); // No eviction needed
}
let to_free = (current_size + space_needed) - limit;
let mut freed: u64 = 0;
// Get downloads ordered by last access (oldest first)
let query = Query::with_params(
"SELECT id, file_size, file_path FROM downloads
WHERE user_id = ? AND status = 'completed'
ORDER BY completed_at ASC",
vec![QueryParam::String(user_id.to_string())],
);
let downloads: Vec<(i64, i64, String)> = db_service
.query_many(query, |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
.await
.map_err(|e| e.to_string())?;
for (id, size, file_path) in downloads {
if freed >= to_free {
break;
}
// Delete file
let _ = std::fs::remove_file(&file_path);
debug!("[SmartCache] Evicted: {} ({} bytes)", file_path, size);
// Delete from database
let delete_query = Query::with_params(
"DELETE FROM downloads WHERE id = ?",
vec![QueryParam::Int64(id)],
);
db_service.execute(delete_query).await.map_err(|e| e.to_string())?;
freed += size as u64;
}
info!("[SmartCache] Freed {} bytes ({} needed)", freed, to_free);
Ok(freed)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = CacheConfig::default();
assert_eq!(config.queue_precache_count, 3);
assert_eq!(config.album_affinity_threshold, 3);
assert!(!config.wifi_only); // wifi_only is false by default for easier preloading
}
#[test]
fn test_album_affinity_tracking() {
let cache = SmartCache::new(CacheConfig::default());
// Track plays from same album
cache.track_play("track1", Some("album1"));
cache.track_play("track2", Some("album1"));
// Below threshold
assert!(!cache.should_cache_album("album1").unwrap_or(false));
cache.track_play("track3", Some("album1"));
// At threshold - should cache
assert!(cache.should_cache_album("album1").unwrap_or(false));
}
#[test]
fn test_queue_precache_config() {
let mut config = CacheConfig::default();
config.queue_precache_enabled = false;
let cache = SmartCache::new(config);
assert!(!cache.should_precache_queue());
let mut new_config = CacheConfig::default();
new_config.wifi_only = false;
cache.update_config(new_config);
assert!(cache.should_precache_queue());
}
#[tokio::test]
async fn test_storage_limit_check() {
use crate::storage::db_service::RusqliteService;
use rusqlite::Connection;
use std::sync::{Arc, Mutex};
let conn = Connection::open_in_memory().unwrap();
conn.execute(
"CREATE TABLE downloads (
id INTEGER PRIMARY KEY,
user_id TEXT,
status TEXT,
file_size INTEGER
)",
[],
)
.unwrap();
let conn_arc = Arc::new(Mutex::new(conn));
let db_service = Arc::new(RusqliteService::new(conn_arc.clone()));
let config = CacheConfig {
storage_limit: 1000,
..Default::default()
};
let cache = SmartCache::new(config);
// Empty - can download
assert!(cache.can_download_async(&db_service, "user1", 500).await);
// Add some downloads
{
let conn_guard = conn_arc.lock().unwrap();
conn_guard.execute(
"INSERT INTO downloads (user_id, status, file_size) VALUES ('user1', 'completed', 600)",
[],
)
.unwrap();
}
// Total would be 1100 > 1000
assert!(!cache.can_download_async(&db_service, "user1", 500).await);
// Smaller size fits
assert!(cache.can_download_async(&db_service, "user1", 300).await);
}
}
+135
View File
@@ -0,0 +1,135 @@
//! Download events for progress tracking and status updates
use serde::{Deserialize, Serialize};
/// Events emitted during download operations
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum DownloadEvent {
/// Download has been queued
#[serde(rename_all = "camelCase")]
Queued {
download_id: i64,
item_id: String,
},
/// Download has started
#[serde(rename_all = "camelCase")]
Started {
download_id: i64,
item_id: String,
},
/// Download progress update
#[serde(rename_all = "camelCase")]
Progress {
download_id: i64,
item_id: String,
bytes_downloaded: i64,
total_bytes: Option<i64>,
progress: f64, // 0.0 to 1.0
},
/// Download completed successfully
#[serde(rename_all = "camelCase")]
Completed {
download_id: i64,
item_id: String,
file_path: String,
},
/// Download failed with error
#[serde(rename_all = "camelCase")]
Failed {
download_id: i64,
item_id: String,
error: String,
},
/// Download paused
#[serde(rename_all = "camelCase")]
Paused {
download_id: i64,
item_id: String,
},
/// Download cancelled
#[serde(rename_all = "camelCase")]
Cancelled {
download_id: i64,
item_id: String,
},
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_download_event_serialization_roundtrip() {
let event = DownloadEvent::Progress {
download_id: 1,
item_id: "test123".to_string(),
bytes_downloaded: 1024,
total_bytes: Some(2048),
progress: 0.5,
};
let json = serde_json::to_string(&event).unwrap();
let deserialized: DownloadEvent = serde_json::from_str(&json).unwrap();
match deserialized {
DownloadEvent::Progress {
download_id,
item_id,
progress,
..
} => {
assert_eq!(download_id, 1);
assert_eq!(item_id, "test123");
assert_eq!(progress, 0.5);
}
_ => panic!("Wrong variant"),
}
}
#[test]
fn test_download_event_completed() {
let event = DownloadEvent::Completed {
download_id: 42,
item_id: "song456".to_string(),
file_path: "/path/to/file.mp3".to_string(),
};
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);
// Verify roundtrip
let deserialized: DownloadEvent = serde_json::from_str(&json).unwrap();
match deserialized {
DownloadEvent::Completed { file_path, .. } => {
assert_eq!(file_path, "/path/to/file.mp3");
}
_ => panic!("Wrong variant"),
}
}
#[test]
fn test_download_event_failed() {
let event = DownloadEvent::Failed {
download_id: 10,
item_id: "failed_item".to_string(),
error: "Network timeout".to_string(),
};
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains("\"type\":\"failed\""));
// Verify roundtrip
let deserialized: DownloadEvent = serde_json::from_str(&json).unwrap();
match deserialized {
DownloadEvent::Failed { error, .. } => {
assert_eq!(error, "Network timeout");
}
_ => panic!("Wrong variant"),
}
}
}
+210
View File
@@ -0,0 +1,210 @@
//! Download manager for offline media support
//!
//! This module handles downloading media from Jellyfin servers with:
//! - Priority-based queue management
//! - Progress tracking and event emission
//! - Retry logic with exponential backoff
//! - Resume support via HTTP Range requests
pub mod cache;
pub mod events;
pub mod worker;
use std::path::PathBuf;
use std::collections::HashSet;
use std::sync::{Arc, Mutex};
pub use worker::DownloadWorker;
/// Download manager coordinating downloads across workers
pub struct DownloadManager {
/// Maximum concurrent downloads
max_concurrent: usize,
/// Currently active download IDs
active_downloads: Arc<Mutex<HashSet<i64>>>,
}
impl DownloadManager {
/// Create a new download manager
pub fn new(_media_dir: PathBuf) -> Self {
Self {
max_concurrent: 3,
active_downloads: Arc::new(Mutex::new(HashSet::new())),
}
}
/// Get the maximum concurrent downloads
pub fn max_concurrent(&self) -> usize {
self.max_concurrent
}
/// Set the maximum concurrent downloads
pub fn set_max_concurrent(&mut self, max: usize) {
self.max_concurrent = max.max(1); // At least 1
}
/// Check if a new download can be started based on concurrent limit
pub fn can_start_download(&self) -> bool {
let active = self.active_downloads.lock().unwrap();
active.len() < self.max_concurrent
}
/// Get the number of currently active downloads
pub fn active_count(&self) -> usize {
self.active_downloads.lock().unwrap().len()
}
/// Register a download as active
pub fn register_download(&self, download_id: i64) -> bool {
let mut active = self.active_downloads.lock().unwrap();
if active.len() >= self.max_concurrent {
return false;
}
active.insert(download_id)
}
/// Unregister a download when it completes or fails
pub fn unregister_download(&self, download_id: i64) {
let mut active = self.active_downloads.lock().unwrap();
active.remove(&download_id);
}
/// Get a clone of the active downloads set (for internal use)
pub fn get_active_downloads(&self) -> Arc<Mutex<HashSet<i64>>> {
self.active_downloads.clone()
}
}
/// Information about a download
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DownloadInfo {
pub id: i64,
pub item_id: String,
pub user_id: String,
pub file_path: String,
pub file_size: Option<i64>,
pub mime_type: Option<String>,
pub status: String,
pub progress: f64,
pub bytes_downloaded: i64,
pub queued_at: String,
pub started_at: Option<String>,
pub completed_at: Option<String>,
pub error_message: Option<String>,
pub retry_count: i32,
pub priority: i32,
// Item metadata for display (audio)
pub item_name: Option<String>,
pub artist_name: Option<String>,
pub album_name: Option<String>,
// Video-specific metadata
pub series_name: Option<String>,
pub season_name: Option<String>,
pub episode_number: Option<i32>,
pub season_number: Option<i32>,
pub quality_preset: Option<String>,
pub media_type: String,
// Download source tracking
pub download_source: String, // 'user' or 'auto'
}
/// Download task for workers
#[derive(Debug, Clone)]
pub struct DownloadTask {
pub url: String,
pub target_path: PathBuf,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::player::MediaType;
#[test]
fn test_download_manager_set_max_concurrent() {
let media_dir = PathBuf::from("/tmp/jellytau/media");
let mut manager = DownloadManager::new(media_dir);
manager.set_max_concurrent(5);
assert_eq!(manager.max_concurrent(), 5);
// Should clamp to minimum of 1
manager.set_max_concurrent(0);
assert_eq!(manager.max_concurrent(), 1);
}
#[test]
fn test_download_info_serialization() {
let info = DownloadInfo {
id: 1,
item_id: "test123".to_string(),
user_id: "user1".to_string(),
file_path: "/path/to/file.mp3".to_string(),
file_size: Some(1024000),
mime_type: Some("audio/mpeg".to_string()),
status: "downloading".to_string(),
progress: 0.5,
bytes_downloaded: 512000,
queued_at: "2024-01-01T00:00:00Z".to_string(),
started_at: Some("2024-01-01T00:01:00Z".to_string()),
completed_at: None,
error_message: None,
retry_count: 0,
priority: 0,
item_name: Some("Test Song".to_string()),
artist_name: Some("Test Artist".to_string()),
album_name: Some("Test Album".to_string()),
series_name: None,
season_name: None,
episode_number: None,
season_number: None,
quality_preset: Some("original".to_string()),
media_type: "audio".to_string(),
download_source: "user".to_string(),
};
let json = serde_json::to_string(&info).unwrap();
assert!(json.contains("\"status\":\"downloading\""));
assert!(json.contains("\"progress\":0.5"));
assert!(json.contains("\"itemName\":\"Test Song\""));
assert!(json.contains("\"mediaType\":\"audio\""));
}
#[test]
fn test_video_download_info_serialization() {
let info = DownloadInfo {
id: 2,
item_id: "episode123".to_string(),
user_id: "user1".to_string(),
file_path: "/path/to/ShowName/S01E01_Title.mp4".to_string(),
file_size: Some(1024000000),
mime_type: Some("video/mp4".to_string()),
status: "completed".to_string(),
progress: 1.0,
bytes_downloaded: 1024000000,
queued_at: "2024-01-01T00:00:00Z".to_string(),
started_at: Some("2024-01-01T00:01:00Z".to_string()),
completed_at: Some("2024-01-01T01:00:00Z".to_string()),
error_message: None,
retry_count: 0,
priority: 100,
item_name: Some("Episode Title".to_string()),
artist_name: None,
album_name: None,
series_name: Some("Show Name".to_string()),
season_name: Some("Season 1".to_string()),
episode_number: Some(1),
season_number: Some(1),
quality_preset: Some("high".to_string()),
media_type: "video".to_string(),
download_source: "auto".to_string(),
};
let json = serde_json::to_string(&info).unwrap();
assert!(json.contains("\"mediaType\":\"video\""));
assert!(json.contains("\"seriesName\":\"Show Name\""));
assert!(json.contains("\"episodeNumber\":1"));
assert!(json.contains("\"qualityPreset\":\"high\""));
}
}
+214
View File
@@ -0,0 +1,214 @@
//! Download worker for HTTP streaming with progress tracking and retry logic
use log::warn;
use std::time::Duration;
use futures_util::StreamExt;
use tokio::fs;
use tokio::io::AsyncWriteExt;
use super::DownloadTask;
/// Download worker that handles individual download tasks
pub struct DownloadWorker {
/// HTTP client for downloads
client: reqwest::Client,
/// Maximum retry attempts
max_retries: u32,
}
impl DownloadWorker {
pub fn new() -> Self {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(300)) // 5 minute timeout
.build()
.expect("Failed to create HTTP client");
Self {
client,
max_retries: 3,
}
}
/// Download a file with retry logic and progress tracking
pub async fn download(
&self,
task: &DownloadTask,
) -> Result<DownloadResult, DownloadError> {
let mut retries = 0;
loop {
match self.try_download(task).await {
Ok(result) => return Ok(result),
Err(e) if retries < self.max_retries && e.is_retryable() => {
retries += 1;
let delay = Self::exponential_backoff(retries);
warn!(
"Download failed (attempt {}/{}), retrying in {:?}: {}",
retries, self.max_retries, delay, e
);
tokio::time::sleep(delay).await;
}
Err(e) => return Err(e),
}
}
}
/// Attempt a single download
async fn try_download(&self, task: &DownloadTask) -> Result<DownloadResult, DownloadError> {
// Create parent directories
if let Some(parent) = task.target_path.parent() {
fs::create_dir_all(parent)
.await
.map_err(|e| DownloadError::FileSystem(e.to_string()))?;
}
// 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)
} else {
0
};
// Build HTTP request with Range header for resume support
let mut request = self.client.get(&task.url);
if existing_bytes > 0 {
request = request.header("Range", format!("bytes={}-", existing_bytes));
}
// Send request
let response = request
.send()
.await
.map_err(|e| DownloadError::Network(e.to_string()))?;
// Check status
if !response.status().is_success() && response.status().as_u16() != 206 {
return Err(DownloadError::Http(response.status().as_u16()));
}
// Get content length
let _total_bytes = response
.headers()
.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 });
// Open file for appending
let mut file = if existing_bytes > 0 {
fs::OpenOptions::new()
.append(true)
.open(&temp_path)
.await
} else {
fs::File::create(&temp_path).await
}
.map_err(|e| DownloadError::FileSystem(e.to_string()))?;
// Stream download with progress tracking
let mut downloaded = existing_bytes;
let mut stream = response.bytes_stream();
let mut last_progress_emit = std::time::Instant::now();
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|e| DownloadError::Network(e.to_string()))?;
file.write_all(&chunk)
.await
.map_err(|e| DownloadError::FileSystem(e.to_string()))?;
downloaded += chunk.len() as u64;
// Emit progress every 500ms or every MB
if last_progress_emit.elapsed() > Duration::from_millis(500)
|| downloaded % (1024 * 1024) == 0
{
last_progress_emit = std::time::Instant::now();
// Progress events will be emitted by the manager
}
}
file.sync_all()
.await
.map_err(|e| DownloadError::FileSystem(e.to_string()))?;
// Move from .part to final location
fs::rename(&temp_path, &task.target_path)
.await
.map_err(|e| DownloadError::FileSystem(e.to_string()))?;
Ok(DownloadResult {
bytes_downloaded: downloaded,
})
}
/// Calculate exponential backoff delay
fn exponential_backoff(retry_count: u32) -> Duration {
let base_delay = 5; // 5 seconds
let delay_secs = base_delay * 3u64.pow(retry_count - 1); // 5s, 15s, 45s
Duration::from_secs(delay_secs)
}
}
/// Result of a successful download
#[derive(Debug)]
pub struct DownloadResult {
pub bytes_downloaded: u64,
}
/// Download error types
#[derive(Debug)]
pub enum DownloadError {
Network(String),
Http(u16),
FileSystem(String),
}
impl DownloadError {
/// Check if this error is retryable
fn is_retryable(&self) -> bool {
match self {
DownloadError::Network(_) => true,
DownloadError::Http(status) => *status >= 500, // Retry server errors
DownloadError::FileSystem(_) => false,
}
}
}
impl std::fmt::Display for DownloadError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DownloadError::Network(msg) => write!(f, "Network error: {}", msg),
DownloadError::Http(status) => write!(f, "HTTP error {}", status),
DownloadError::FileSystem(msg) => write!(f, "File system error: {}", msg),
}
}
}
impl std::error::Error for DownloadError {}
#[cfg(test)]
mod tests {
use super::*;
#[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));
}
#[test]
fn test_error_retryable() {
assert!(DownloadError::Network("timeout".to_string()).is_retryable());
assert!(DownloadError::Http(500).is_retryable());
assert!(DownloadError::Http(503).is_retryable());
assert!(!DownloadError::Http(404).is_retryable());
assert!(!DownloadError::FileSystem("disk full".to_string()).is_retryable());
}
}