Skip to main content

jellytau_lib/download/
cache.rs

1//! Smart caching engine for predictive downloads
2
3#[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/// Smart caching configuration
14#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
15#[serde(rename_all = "camelCase")]
16pub struct CacheConfig {
17    /// Enable queue pre-caching
18    pub queue_precache_enabled: bool,
19    /// Number of tracks to pre-cache from queue
20    pub queue_precache_count: usize,
21    /// Enable album affinity detection
22    pub album_affinity_enabled: bool,
23    /// Threshold for album affinity (tracks played before caching)
24    pub album_affinity_threshold: usize,
25    /// Storage limit in bytes (0 = unlimited)
26    pub storage_limit: u64,
27    /// Only cache on WiFi
28    pub wifi_only: bool,
29    /// How long a temporary (`download_source = 'auto'`) download lives before
30    /// it is reclaimed, in hours. 0 disables expiry, leaving space pressure as
31    /// the only reclaim trigger.
32    ///
33    /// TRACES: UR-071 | DR-127
34    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, // Preload next 3 tracks by default
42            album_affinity_enabled: true,
43            album_affinity_threshold: 3,
44            storage_limit: 10 * 1024 * 1024 * 1024, // 10GB
45            wifi_only: false,                       // Allow preloading on any connection by default
46            // A week: long enough that re-watching over a weekend still hits
47            // disk, short enough that a one-off play does not hold space
48            // indefinitely.
49            temporary_ttl_hours: 24 * 7,
50        }
51    }
52}
53
54/// Smart caching engine
55#[derive(Clone)]
56pub struct SmartCache {
57    config: Arc<Mutex<CacheConfig>>,
58    /// Track recently played items per album
59    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    /// Update configuration
71    pub fn update_config(&self, config: CacheConfig) {
72        if let Ok(mut cfg) = self.config.lock() {
73            *cfg = config;
74        }
75    }
76
77    /// Check if should pre-cache queue items.
78    ///
79    /// Note this deliberately does NOT consult `wifi_only`. It used to return
80    /// `queue_precache_enabled && !wifi_only`, which disabled precaching
81    /// outright whenever the user enabled WiFi-only — regardless of the network
82    /// actually in use. The network check now lives in the download queue pump
83    /// (`downloads_allowed_on_current_network`), which is the single gate for
84    /// all download traffic, so this only answers "is precaching enabled?".
85    ///
86    /// TRACES: UR-053 | DR-074
87    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    /// Get number of queue items to pre-cache
95    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    /// Track that an item was played
103    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    /// Check if album affinity threshold reached for caching
115    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    /// Get configuration
128    pub fn get_config(&self) -> Option<CacheConfig> {
129        self.config.lock().ok().map(|cfg| cfg.clone())
130    }
131
132    /// Get all tracked albums with their play counts
133    /// Returns Vec<(album_id, unique_tracks_played)>
134    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    // ============= Async versions for DatabaseService =============
148
149    /// Get total download size for a user (async version)
150    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    /// Check if storage limit allows download (async version)
170    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        // Clone config to avoid holding lock across await
177        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; // Unlimited
186        }
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    /// Reclaim temporary downloads whose life limit has passed.
197    ///
198    /// The time-based half of the temporary tier (DR-127); [`evict_lru_async`]
199    /// is the space-pressure half. A row is reclaimed by whichever fires first.
200    ///
201    /// Scoped to `download_source = 'auto'` for the same reason eviction is: a
202    /// `'user'` row is someone's own download and has no expiry. `COALESCE`
203    /// guards rows predating migration 012, whose source is NULL and whose
204    /// provenance must therefore be treated as the user's.
205    ///
206    /// Expiry is normally *derived* — `completed_at` plus the configured TTL —
207    /// rather than stamped at completion. That means a TTL change applies to
208    /// entries already on disk instead of only to future ones, and entries
209    /// predating the column expire without a backfill. `expires_at` is honoured
210    /// as a per-row override when something sets it.
211    ///
212    /// `now` is passed in rather than read from the clock so the policy is
213    /// testable without sleeping. Both sides go through SQLite's `datetime()`
214    /// because `completed_at` is written as `CURRENT_TIMESTAMP`
215    /// (`YYYY-MM-DD HH:MM:SS`) while callers pass RFC-3339 (`…T…+00:00`) — a raw
216    /// string comparison between the two formats is wrong, since `' ' < 'T'`.
217    ///
218    /// Returns the number of entries reclaimed.
219    ///
220    /// TRACES: UR-071 | DR-127 | UT-120
221    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        // 0 disables time-based reclaim; space pressure remains the only trigger.
232        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            // Best-effort on the file: a missing one still needs its row gone,
260            // or the sweep retries it forever.
261            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    /// Evict least recently used items to make space (async version).
279    ///
280    /// The space-pressure half of the temporary tier; [`reclaim_expired_async`]
281    /// is the time-based half.
282    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        // Get limit without holding lock across await
293        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); // No eviction needed
300        }
301
302        let to_free = (current_size + space_needed) - limit;
303        let mut freed: u64 = 0;
304
305        // Only the *temporary* tier is evictable. `download_source = 'auto'` is
306        // precache — the cache put it there, the cache may reclaim it. A 'user'
307        // row is a download someone explicitly asked for; deleting it to make
308        // room for a predictive fetch is data loss, and because the old query
309        // ordered purely by `completed_at ASC` it took the oldest — typically
310        // exactly the film saved for a flight.
311        //
312        // COALESCE, not `= 'auto'` alone: migration 012 added the column with a
313        // 'user' default, but rows predating it can be NULL, and an unknown
314        // provenance must be treated as the user's, never as disposable.
315        //
316        // Freeing less than requested is the correct outcome when only user
317        // downloads remain — the caller surfaces "unable to free enough space"
318        // rather than silently deleting them.
319        //
320        // TRACES: UR-071 | DR-126 | UT-108
321        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            // Delete file
340            let _ = std::fs::remove_file(&file_path);
341            debug!("[SmartCache] Evicted: {} ({} bytes)", file_path, size);
342
343            // Delete from database
344            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); // wifi_only is false by default for easier preloading
371    }
372
373    #[test]
374    fn test_album_affinity_tracking() {
375        let cache = SmartCache::new(CacheConfig::default());
376
377        // Track plays from same album
378        cache.track_play("track1", Some("album1"));
379        cache.track_play("track2", Some("album1"));
380
381        // Below threshold
382        assert!(!cache.should_cache_album("album1").unwrap_or(false));
383
384        cache.track_play("track3", Some("album1"));
385
386        // At threshold - should cache
387        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        // wifi_only must not short-circuit precaching: the network gate lives in
412        // the download pump, which checks the *actual* transport. Enabling
413        // WiFi-only while on WiFi should still precache.
414        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    /// Expiry reclaims only temporary entries that are actually past their life
425    /// limit — never a user's download (which has no expiry), and never a
426    /// temporary entry still within its life.
427    ///
428    /// TRACES: UR-071 | DR-127 | UT-120
429    #[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        // `completed_at` is in SQLite's CURRENT_TIMESTAMP format (space, not
452        // 'T'), deliberately: the query has to compare it against an RFC-3339
453        // "now" and must not do so as raw strings.
454        for (path, source, completed, expires) in [
455            // Completed long ago, no override => derived expiry has passed.
456            ("/tmp/jt-expired.mp4", "auto", "2026-01-01 00:00:00", None),
457            // Completed yesterday => still inside the 7-day default TTL.
458            ("/tmp/jt-fresh.mp4", "auto", "2026-05-31 00:00:00", None),
459            // Old, but an explicit override keeps it alive.
460            (
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            // A user download must never carry an expiry, but assert the sweep
467            // ignores it even if one were somehow set.
468            (
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        // TTL of 0 disables time-based reclaim entirely.
526        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    /// Eviction must only reclaim *temporary* (`download_source = 'auto'`)
541    /// downloads — the precache tier. A download the user explicitly asked for
542    /// is their file: it may be deleted by them, never by the cache making room
543    /// for a predictive fetch.
544    ///
545    /// Before the fix, `evict_lru_async` selected every completed row ordered by
546    /// `completed_at ASC` with no source filter, so hitting the storage limit
547    /// deleted the *oldest* download — typically the film someone downloaded for
548    /// a flight — in favour of a newer auto-precached track.
549    ///
550    /// TRACES: UR-071 | DR-126 | UT-108
551    #[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        // The user's own download is the OLDEST, so a purely time-ordered
573        // eviction would take it first.
574        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        // A newer, auto-precached item.
581        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        // 1200 bytes held against a 1000 limit: eviction must free something.
597        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        // Empty - can download
651        assert!(cache.can_download_async(&db_service, "user1", 500).await);
652
653        // Add some downloads
654        {
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        // Total would be 1100 > 1000
664        assert!(!cache.can_download_async(&db_service, "user1", 500).await);
665
666        // Smaller size fits
667        assert!(cache.can_download_async(&db_service, "user1", 300).await);
668    }
669}