1#[cfg(test)]
4use crate::utils::lock::MutexSafe;
5use log::{debug, info};
6use std::collections::HashMap;
7use std::sync::{Arc, Mutex};
8
9use serde::{Deserialize, Serialize};
10
11use crate::storage::db_service::{DatabaseService, Query, QueryParam};
12
13#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
15#[serde(rename_all = "camelCase")]
16pub struct CacheConfig {
17 pub queue_precache_enabled: bool,
19 pub queue_precache_count: usize,
21 pub album_affinity_enabled: bool,
23 pub album_affinity_threshold: usize,
25 pub storage_limit: u64,
27 pub wifi_only: bool,
29 pub temporary_ttl_hours: u64,
35}
36
37impl Default for CacheConfig {
38 fn default() -> Self {
39 Self {
40 queue_precache_enabled: true,
41 queue_precache_count: 3, album_affinity_enabled: true,
43 album_affinity_threshold: 3,
44 storage_limit: 10 * 1024 * 1024 * 1024, wifi_only: false, temporary_ttl_hours: 24 * 7,
50 }
51 }
52}
53
54#[derive(Clone)]
56pub struct SmartCache {
57 config: Arc<Mutex<CacheConfig>>,
58 album_play_history: Arc<Mutex<HashMap<String, Vec<String>>>>,
60}
61
62impl SmartCache {
63 pub fn new(config: CacheConfig) -> Self {
64 Self {
65 config: Arc::new(Mutex::new(config)),
66 album_play_history: Arc::new(Mutex::new(HashMap::new())),
67 }
68 }
69
70 pub fn update_config(&self, config: CacheConfig) {
72 if let Ok(mut cfg) = self.config.lock() {
73 *cfg = config;
74 }
75 }
76
77 pub fn should_precache_queue(&self) -> bool {
88 self.config
89 .lock()
90 .map(|cfg| cfg.queue_precache_enabled)
91 .unwrap_or(false)
92 }
93
94 pub fn queue_precache_count(&self) -> usize {
96 self.config
97 .lock()
98 .map(|cfg| cfg.queue_precache_count)
99 .unwrap_or(5)
100 }
101
102 pub fn track_play(&self, item_id: &str, album_id: Option<&str>) {
104 if let Some(album) = album_id {
105 if let Ok(mut history) = self.album_play_history.lock() {
106 let plays = history.entry(album.to_string()).or_insert_with(Vec::new);
107 if !plays.contains(&item_id.to_string()) {
108 plays.push(item_id.to_string());
109 }
110 }
111 }
112 }
113
114 pub fn should_cache_album(&self, album_id: &str) -> Option<bool> {
116 let config = self.config.lock().ok()?;
117 if !config.album_affinity_enabled {
118 return Some(false);
119 }
120
121 let history = self.album_play_history.lock().ok()?;
122 let play_count = history.get(album_id).map(|v| v.len()).unwrap_or(0);
123
124 Some(play_count >= config.album_affinity_threshold)
125 }
126
127 pub fn get_config(&self) -> Option<CacheConfig> {
129 self.config.lock().ok().map(|cfg| cfg.clone())
130 }
131
132 pub fn get_album_play_history(&self) -> Vec<(String, usize)> {
135 self.album_play_history
136 .lock()
137 .ok()
138 .map(|history| {
139 history
140 .iter()
141 .map(|(album_id, tracks)| (album_id.clone(), tracks.len()))
142 .collect()
143 })
144 .unwrap_or_default()
145 }
146
147 pub async fn get_total_download_size_async<S: DatabaseService>(
151 &self,
152 db_service: &Arc<S>,
153 user_id: &str,
154 ) -> Result<u64, String> {
155 let query = Query::with_params(
156 "SELECT COALESCE(SUM(file_size), 0) FROM downloads
157 WHERE user_id = ? AND status = 'completed'",
158 vec![QueryParam::String(user_id.to_string())],
159 );
160
161 let size: i64 = db_service
162 .query_one(query, |row| row.get(0))
163 .await
164 .map_err(|e| e.to_string())?;
165
166 Ok(size as u64)
167 }
168
169 pub async fn can_download_async<S: DatabaseService>(
171 &self,
172 db_service: &Arc<S>,
173 user_id: &str,
174 new_size: u64,
175 ) -> bool {
176 let storage_limit = {
178 match self.config.lock() {
179 Ok(cfg) => cfg.storage_limit,
180 Err(_) => return true,
181 }
182 };
183
184 if storage_limit == 0 {
185 return true; }
187
188 let current_size = self
189 .get_total_download_size_async(db_service, user_id)
190 .await
191 .unwrap_or(0);
192
193 current_size + new_size <= storage_limit
194 }
195
196 pub async fn reclaim_expired_async<S: DatabaseService>(
222 &self,
223 db_service: &Arc<S>,
224 user_id: &str,
225 now: &str,
226 ) -> Result<usize, String> {
227 let ttl_hours = {
228 let config = self.config.lock().map_err(|e| e.to_string())?;
229 config.temporary_ttl_hours
230 };
231 if ttl_hours == 0 {
233 return Ok(0);
234 }
235
236 let expired: Vec<(i64, String)> = db_service
237 .query_many(
238 Query::with_params(
239 "SELECT id, file_path FROM downloads
240 WHERE user_id = ?
241 AND COALESCE(download_source, 'user') = 'auto'
242 AND status = 'completed'
243 AND datetime(
244 COALESCE(expires_at, datetime(completed_at, '+' || ? || ' hours'))
245 ) < datetime(?)",
246 vec![
247 QueryParam::String(user_id.to_string()),
248 QueryParam::String(ttl_hours.to_string()),
249 QueryParam::String(now.to_string()),
250 ],
251 ),
252 |row| Ok((row.get(0)?, row.get(1)?)),
253 )
254 .await
255 .map_err(|e| e.to_string())?;
256
257 let mut reclaimed = 0usize;
258 for (id, file_path) in expired {
259 let _ = std::fs::remove_file(&file_path);
262 db_service
263 .execute(Query::with_params(
264 "DELETE FROM downloads WHERE id = ?",
265 vec![QueryParam::Int64(id)],
266 ))
267 .await
268 .map_err(|e| e.to_string())?;
269 reclaimed += 1;
270 }
271
272 if reclaimed > 0 {
273 info!("[SmartCache] Reclaimed {} expired entries", reclaimed);
274 }
275 Ok(reclaimed)
276 }
277
278 pub async fn evict_lru_async<S: DatabaseService>(
283 &self,
284 db_service: &Arc<S>,
285 user_id: &str,
286 space_needed: u64,
287 ) -> Result<u64, String> {
288 let current_size = self
289 .get_total_download_size_async(db_service, user_id)
290 .await?;
291
292 let limit = {
294 let config = self.config.lock().map_err(|e| e.to_string())?;
295 config.storage_limit
296 };
297
298 if limit == 0 || current_size + space_needed <= limit {
299 return Ok(0); }
301
302 let to_free = (current_size + space_needed) - limit;
303 let mut freed: u64 = 0;
304
305 let query = Query::with_params(
322 "SELECT id, file_size, file_path FROM downloads
323 WHERE user_id = ? AND status = 'completed'
324 AND COALESCE(download_source, 'user') = 'auto'
325 ORDER BY completed_at ASC",
326 vec![QueryParam::String(user_id.to_string())],
327 );
328
329 let downloads: Vec<(i64, i64, String)> = db_service
330 .query_many(query, |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
331 .await
332 .map_err(|e| e.to_string())?;
333
334 for (id, size, file_path) in downloads {
335 if freed >= to_free {
336 break;
337 }
338
339 let _ = std::fs::remove_file(&file_path);
341 debug!("[SmartCache] Evicted: {} ({} bytes)", file_path, size);
342
343 let delete_query = Query::with_params(
345 "DELETE FROM downloads WHERE id = ?",
346 vec![QueryParam::Int64(id)],
347 );
348 db_service
349 .execute(delete_query)
350 .await
351 .map_err(|e| e.to_string())?;
352
353 freed += size as u64;
354 }
355
356 info!("[SmartCache] Freed {} bytes ({} needed)", freed, to_free);
357 Ok(freed)
358 }
359}
360
361#[cfg(test)]
362mod tests {
363 use super::*;
364
365 #[test]
366 fn test_default_config() {
367 let config = CacheConfig::default();
368 assert_eq!(config.queue_precache_count, 3);
369 assert_eq!(config.album_affinity_threshold, 3);
370 assert!(!config.wifi_only); }
372
373 #[test]
374 fn test_album_affinity_tracking() {
375 let cache = SmartCache::new(CacheConfig::default());
376
377 cache.track_play("track1", Some("album1"));
379 cache.track_play("track2", Some("album1"));
380
381 assert!(!cache.should_cache_album("album1").unwrap_or(false));
383
384 cache.track_play("track3", Some("album1"));
385
386 assert!(cache.should_cache_album("album1").unwrap_or(false));
388 }
389
390 #[test]
391 fn test_queue_precache_config() {
392 let config = CacheConfig {
393 queue_precache_enabled: false,
394 ..CacheConfig::default()
395 };
396
397 let cache = SmartCache::new(config);
398 assert!(!cache.should_precache_queue());
399
400 let new_config = CacheConfig {
401 wifi_only: false,
402 ..CacheConfig::default()
403 };
404 cache.update_config(new_config);
405
406 assert!(cache.should_precache_queue());
407 }
408
409 #[test]
410 fn test_wifi_only_does_not_disable_precaching() {
411 let config = CacheConfig {
415 queue_precache_enabled: true,
416 wifi_only: true,
417 ..CacheConfig::default()
418 };
419
420 let cache = SmartCache::new(config);
421 assert!(cache.should_precache_queue());
422 }
423
424 #[tokio::test]
430 async fn test_reclaim_expired_only_takes_expired_temporary_entries() {
431 use crate::storage::db_service::RusqliteService;
432 use rusqlite::Connection;
433 use std::sync::{Arc, Mutex};
434
435 let conn = Connection::open_in_memory().unwrap();
436 conn.execute(
437 "CREATE TABLE downloads (
438 id INTEGER PRIMARY KEY,
439 user_id TEXT,
440 status TEXT,
441 file_size INTEGER,
442 file_path TEXT,
443 completed_at TEXT,
444 download_source TEXT DEFAULT 'user',
445 expires_at TEXT
446 )",
447 [],
448 )
449 .unwrap();
450
451 for (path, source, completed, expires) in [
455 ("/tmp/jt-expired.mp4", "auto", "2026-01-01 00:00:00", None),
457 ("/tmp/jt-fresh.mp4", "auto", "2026-05-31 00:00:00", None),
459 (
461 "/tmp/jt-override.mp4",
462 "auto",
463 "2026-01-01 00:00:00",
464 Some("2026-12-01T00:00:00+00:00"),
465 ),
466 (
469 "/tmp/jt-user.mp4",
470 "user",
471 "2026-01-01 00:00:00",
472 Some("2026-01-01T00:00:00+00:00"),
473 ),
474 (
475 "/tmp/jt-user-noexp.mp4",
476 "user",
477 "2026-01-01 00:00:00",
478 None,
479 ),
480 ] {
481 conn.execute(
482 "INSERT INTO downloads (user_id, status, file_size, file_path, download_source, completed_at, expires_at)
483 VALUES ('user1', 'completed', 10, ?1, ?2, ?3, ?4)",
484 rusqlite::params![path, source, completed, expires],
485 )
486 .unwrap();
487 }
488
489 let conn_arc = Arc::new(Mutex::new(conn));
490 let db_service = Arc::new(RusqliteService::new(conn_arc.clone()));
491 let cache = SmartCache::new(CacheConfig::default());
492
493 let reclaimed = cache
494 .reclaim_expired_async(&db_service, "user1", "2026-06-01T00:00:00+00:00")
495 .await
496 .unwrap();
497 assert_eq!(
498 reclaimed, 1,
499 "only the expired temporary entry is reclaimed"
500 );
501
502 let surviving: Vec<String> = {
503 let guard = conn_arc.lock_safe();
504 let mut stmt = guard
505 .prepare("SELECT file_path FROM downloads ORDER BY id")
506 .unwrap();
507 let rows = stmt
508 .query_map([], |row| row.get::<_, String>(0))
509 .unwrap()
510 .map(|r| r.unwrap())
511 .collect();
512 rows
513 };
514 assert_eq!(
515 surviving,
516 vec![
517 "/tmp/jt-fresh.mp4".to_string(),
518 "/tmp/jt-override.mp4".to_string(),
519 "/tmp/jt-user.mp4".to_string(),
520 "/tmp/jt-user-noexp.mp4".to_string(),
521 ],
522 "entries within their life, those with a later override, and every user download must survive"
523 );
524
525 let cache_no_ttl = SmartCache::new(CacheConfig {
527 temporary_ttl_hours: 0,
528 ..Default::default()
529 });
530 assert_eq!(
531 cache_no_ttl
532 .reclaim_expired_async(&db_service, "user1", "2027-01-01T00:00:00+00:00")
533 .await
534 .unwrap(),
535 0,
536 "a zero TTL leaves space pressure as the only reclaim trigger"
537 );
538 }
539
540 #[tokio::test]
552 async fn test_evict_lru_never_deletes_user_downloads() {
553 use crate::storage::db_service::RusqliteService;
554 use rusqlite::Connection;
555 use std::sync::{Arc, Mutex};
556
557 let conn = Connection::open_in_memory().unwrap();
558 conn.execute(
559 "CREATE TABLE downloads (
560 id INTEGER PRIMARY KEY,
561 user_id TEXT,
562 status TEXT,
563 file_size INTEGER,
564 file_path TEXT,
565 completed_at TEXT,
566 download_source TEXT DEFAULT 'user'
567 )",
568 [],
569 )
570 .unwrap();
571
572 conn.execute(
575 "INSERT INTO downloads (user_id, status, file_size, file_path, completed_at, download_source)
576 VALUES ('user1', 'completed', 600, '/tmp/jellytau-test-user.mp4', '2026-01-01', 'user')",
577 [],
578 )
579 .unwrap();
580 conn.execute(
582 "INSERT INTO downloads (user_id, status, file_size, file_path, completed_at, download_source)
583 VALUES ('user1', 'completed', 600, '/tmp/jellytau-test-auto.mp4', '2026-06-01', 'auto')",
584 [],
585 )
586 .unwrap();
587
588 let conn_arc = Arc::new(Mutex::new(conn));
589 let db_service = Arc::new(RusqliteService::new(conn_arc.clone()));
590
591 let cache = SmartCache::new(CacheConfig {
592 storage_limit: 1000,
593 ..Default::default()
594 });
595
596 let freed = cache
598 .evict_lru_async(&db_service, "user1", 0)
599 .await
600 .unwrap();
601 assert!(freed > 0, "eviction should have reclaimed the auto entry");
602
603 let surviving: Vec<String> = {
604 let guard = conn_arc.lock_safe();
605 let mut stmt = guard
606 .prepare("SELECT download_source FROM downloads ORDER BY id")
607 .unwrap();
608 let rows = stmt
609 .query_map([], |row| row.get::<_, String>(0))
610 .unwrap()
611 .map(|r| r.unwrap())
612 .collect();
613 rows
614 };
615
616 assert_eq!(
617 surviving,
618 vec!["user".to_string()],
619 "the user's own download must survive; only the 'auto' entry is evictable"
620 );
621 }
622
623 #[tokio::test]
624 async fn test_storage_limit_check() {
625 use crate::storage::db_service::RusqliteService;
626 use rusqlite::Connection;
627 use std::sync::{Arc, Mutex};
628
629 let conn = Connection::open_in_memory().unwrap();
630 conn.execute(
631 "CREATE TABLE downloads (
632 id INTEGER PRIMARY KEY,
633 user_id TEXT,
634 status TEXT,
635 file_size INTEGER
636 )",
637 [],
638 )
639 .unwrap();
640
641 let conn_arc = Arc::new(Mutex::new(conn));
642 let db_service = Arc::new(RusqliteService::new(conn_arc.clone()));
643
644 let config = CacheConfig {
645 storage_limit: 1000,
646 ..Default::default()
647 };
648 let cache = SmartCache::new(config);
649
650 assert!(cache.can_download_async(&db_service, "user1", 500).await);
652
653 {
655 let conn_guard = conn_arc.lock_safe();
656 conn_guard.execute(
657 "INSERT INTO downloads (user_id, status, file_size) VALUES ('user1', 'completed', 600)",
658 [],
659 )
660 .unwrap();
661 }
662
663 assert!(!cache.can_download_async(&db_service, "user1", 500).await);
665
666 assert!(cache.can_download_async(&db_service, "user1", 300).await);
668 }
669}