Skip to main content

romm_cli/core/
startup_library_snapshot.rs

1//! Compact on-disk snapshot of library **metadata** (platforms + merged collections).
2//!
3//! Used by the TUI to paint the library screen immediately on entry while a
4//! background refresh reconciles with the API. Full ROM lists remain on-demand
5//! and continue to use [`crate::core::cache::RomCache`].
6
7use std::path::PathBuf;
8
9use serde::{Deserialize, Serialize};
10
11use crate::client::RommClient;
12use crate::endpoints::collections::{
13    merge_all_collection_sources, ListCollections, ListSmartCollections, ListVirtualCollections,
14};
15use crate::endpoints::platforms::ListPlatforms;
16use crate::types::{Collection, Platform};
17
18const SNAPSHOT_VERSION: u32 = 1;
19const DEFAULT_FILE: &str = "library-metadata-snapshot.json";
20
21/// On-disk JSON envelope.
22#[derive(Debug, Serialize, Deserialize)]
23struct SnapshotFile {
24    version: u32,
25    /// Unix timestamp (seconds) when saved.
26    saved_at_secs: u64,
27    platforms: Vec<Platform>,
28    collections: Vec<Collection>,
29    #[serde(default)]
30    collection_digest: Vec<CollectionDigestEntry>,
31}
32
33/// Compact digest used to quickly detect collection-summary changes.
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35pub struct CollectionDigestEntry {
36    pub key: String,
37    pub rom_count: u64,
38}
39
40/// Result of a live metadata fetch (background or cold path).
41#[derive(Debug, Clone)]
42pub struct LibraryMetadataFetch {
43    pub platforms: Vec<Platform>,
44    pub collections: Vec<Collection>,
45    pub collection_digest: Vec<CollectionDigestEntry>,
46    pub warnings: Vec<String>,
47}
48
49/// Load a snapshot from disk if present and valid.
50pub fn load_snapshot() -> Option<LibraryMetadataFetch> {
51    let path = snapshot_path();
52    let data = std::fs::read_to_string(&path).ok()?;
53    let file: SnapshotFile = serde_json::from_str(&data).ok()?;
54    if file.version != SNAPSHOT_VERSION {
55        return None;
56    }
57    let collection_digest = if file.collection_digest.is_empty() {
58        build_collection_digest_from_collections(&file.collections)
59    } else {
60        file.collection_digest.clone()
61    };
62    Some(LibraryMetadataFetch {
63        platforms: file.platforms,
64        collections: file.collections,
65        collection_digest,
66        warnings: Vec::new(),
67    })
68}
69
70/// Persist merged metadata for next startup.
71pub fn save_snapshot(platforms: &[Platform], collections: &[Collection]) {
72    let path = snapshot_path();
73    let file = SnapshotFile {
74        version: SNAPSHOT_VERSION,
75        saved_at_secs: unix_now_secs(),
76        platforms: platforms.to_vec(),
77        collections: collections.to_vec(),
78        collection_digest: build_collection_digest_from_collections(collections),
79    };
80    if let Some(parent) = path.parent() {
81        if let Err(err) = std::fs::create_dir_all(parent) {
82            tracing::warn!(
83                "Failed to create library metadata snapshot directory {:?}: {}",
84                parent,
85                err
86            );
87            return;
88        }
89    }
90    match serde_json::to_string(&file) {
91        Ok(json) => {
92            if let Err(err) = std::fs::write(&path, json) {
93                tracing::warn!(
94                    "Failed to write library metadata snapshot {:?}: {}",
95                    path.display(),
96                    err
97                );
98            }
99        }
100        Err(err) => tracing::warn!("Failed to serialize library metadata snapshot: {}", err),
101    }
102}
103
104/// Effective path to the snapshot file.
105pub fn snapshot_effective_path() -> PathBuf {
106    snapshot_path()
107}
108
109fn snapshot_path() -> PathBuf {
110    if let Ok(p) = std::env::var("ROMM_LIBRARY_METADATA_SNAPSHOT_PATH") {
111        return PathBuf::from(p);
112    }
113    if let Ok(dir) = std::env::var("ROMM_TEST_LIBRARY_SNAPSHOT_DIR") {
114        return PathBuf::from(dir).join(DEFAULT_FILE);
115    }
116    default_snapshot_path()
117}
118
119fn default_snapshot_path() -> PathBuf {
120    if let Some(dir) = dirs::cache_dir() {
121        return dir.join("romm-cli").join(DEFAULT_FILE);
122    }
123    PathBuf::from(DEFAULT_FILE)
124}
125
126fn unix_now_secs() -> u64 {
127    std::time::SystemTime::now()
128        .duration_since(std::time::UNIX_EPOCH)
129        .map(|d| d.as_secs())
130        .unwrap_or(0)
131}
132
133/// Build a lightweight digest keyed by collection identity (manual/smart/virtual).
134pub fn build_collection_digest_from_collections(
135    collections: &[Collection],
136) -> Vec<CollectionDigestEntry> {
137    collections
138        .iter()
139        .map(|c| CollectionDigestEntry {
140            key: if c.is_virtual {
141                format!("virtual:{}", c.virtual_id.clone().unwrap_or_default())
142            } else if c.is_smart {
143                format!("smart:{}", c.id)
144            } else {
145                format!("manual:{}", c.id)
146            },
147            rom_count: c.rom_count.unwrap_or(0),
148        })
149        .collect()
150}
151
152/// Fetch platforms and merged collections from the API (same behavior as the
153/// former synchronous TUI main-menu path, including virtual collection timeout).
154pub async fn fetch_merged_library_metadata(client: &RommClient) -> LibraryMetadataFetch {
155    use std::time::Duration;
156
157    let mut warnings = Vec::new();
158
159    let platforms = match client.call(&ListPlatforms).await {
160        Ok(p) => p,
161        Err(e) => {
162            warnings.push(format!("GET /api/platforms: {e:#}"));
163            Vec::new()
164        }
165    };
166
167    let manual = match client.call(&ListCollections).await {
168        Ok(c) => c.into_vec(),
169        Err(e) => {
170            warnings.push(format!("GET /api/collections: {e:#}"));
171            Vec::new()
172        }
173    };
174    let smart = match client.call(&ListSmartCollections).await {
175        Ok(c) => c.into_vec(),
176        Err(e) => {
177            warnings.push(format!("GET /api/collections/smart: {e:#}"));
178            Vec::new()
179        }
180    };
181    let virtual_rows =
182        match tokio::time::timeout(Duration::from_secs(3), client.call(&ListVirtualCollections))
183            .await
184        {
185            Ok(Ok(v)) => v,
186            Ok(Err(e)) => {
187                warnings.push(format!("GET /api/collections/virtual?type=all: {e:#}"));
188                Vec::new()
189            }
190            Err(_) => {
191                warnings
192                    .push("GET /api/collections/virtual?type=all: timed out after 3s".to_string());
193                Vec::new()
194            }
195        };
196
197    let collections = merge_all_collection_sources(manual, smart, virtual_rows);
198
199    LibraryMetadataFetch {
200        platforms,
201        collection_digest: build_collection_digest_from_collections(&collections),
202        collections,
203        warnings,
204    }
205}
206
207/// Stage-A refresh: collections summary only (manual/smart/virtual merge).
208pub async fn fetch_collection_summaries(client: &RommClient) -> LibraryMetadataFetch {
209    let mut warnings = Vec::new();
210    let manual = match client.call(&ListCollections).await {
211        Ok(c) => c.into_vec(),
212        Err(e) => {
213            warnings.push(format!("GET /api/collections: {e:#}"));
214            Vec::new()
215        }
216    };
217    let smart = match client.call(&ListSmartCollections).await {
218        Ok(c) => c.into_vec(),
219        Err(e) => {
220            warnings.push(format!("GET /api/collections/smart: {e:#}"));
221            Vec::new()
222        }
223    };
224    let virtual_rows = match client.call(&ListVirtualCollections).await {
225        Ok(v) => v,
226        Err(e) => {
227            warnings.push(format!("GET /api/collections/virtual?type=all: {e:#}"));
228            Vec::new()
229        }
230    };
231    let collections = merge_all_collection_sources(manual, smart, virtual_rows);
232
233    LibraryMetadataFetch {
234        // Stage-A intentionally skips platform refresh for faster collection-first UI updates.
235        platforms: Vec::new(),
236        collection_digest: build_collection_digest_from_collections(&collections),
237        collections,
238        warnings,
239    }
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245    use crate::types::Collection;
246    use std::sync::{Mutex, MutexGuard, OnceLock};
247
248    fn env_lock() -> &'static Mutex<()> {
249        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
250        LOCK.get_or_init(|| Mutex::new(()))
251    }
252
253    struct TestEnv {
254        _guard: MutexGuard<'static, ()>,
255    }
256
257    impl TestEnv {
258        fn new() -> Self {
259            let guard = env_lock().lock().expect("env lock");
260            std::env::remove_var("ROMM_LIBRARY_METADATA_SNAPSHOT_PATH");
261            std::env::remove_var("ROMM_TEST_LIBRARY_SNAPSHOT_DIR");
262            Self { _guard: guard }
263        }
264    }
265
266    impl Drop for TestEnv {
267        fn drop(&mut self) {
268            std::env::remove_var("ROMM_LIBRARY_METADATA_SNAPSHOT_PATH");
269            std::env::remove_var("ROMM_TEST_LIBRARY_SNAPSHOT_DIR");
270        }
271    }
272
273    fn sample_fetch() -> LibraryMetadataFetch {
274        LibraryMetadataFetch {
275            platforms: vec![Platform {
276                id: 1,
277                slug: "nes".into(),
278                fs_slug: "nes".into(),
279                rom_count: 2,
280                name: "NES".into(),
281                igdb_slug: None,
282                moby_slug: None,
283                hltb_slug: None,
284                custom_name: None,
285                igdb_id: None,
286                sgdb_id: None,
287                moby_id: None,
288                launchbox_id: None,
289                ss_id: None,
290                ra_id: None,
291                hasheous_id: None,
292                tgdb_id: None,
293                flashpoint_id: None,
294                category: None,
295                generation: None,
296                family_name: None,
297                family_slug: None,
298                url: None,
299                url_logo: None,
300                firmware: vec![],
301                aspect_ratio: None,
302                created_at: "".into(),
303                updated_at: "".into(),
304                fs_size_bytes: 0,
305                is_unidentified: false,
306                is_identified: true,
307                missing_from_fs: false,
308                display_name: Some("Nintendo Entertainment System".into()),
309            }],
310            collections: vec![Collection {
311                id: 10,
312                name: "Favorites".into(),
313                collection_type: None,
314                rom_count: Some(1),
315                is_smart: false,
316                is_virtual: false,
317                virtual_id: None,
318            }],
319            collection_digest: vec![CollectionDigestEntry {
320                key: "manual:10".into(),
321                rom_count: 1,
322            }],
323            warnings: vec![],
324        }
325    }
326
327    #[test]
328    fn save_and_load_round_trip() {
329        let _env = TestEnv::new();
330        let dir = std::env::temp_dir().join(format!(
331            "romm-lib-snap-{}",
332            std::time::SystemTime::now()
333                .duration_since(std::time::UNIX_EPOCH)
334                .unwrap()
335                .as_nanos()
336        ));
337        std::fs::create_dir_all(&dir).unwrap();
338        std::env::set_var("ROMM_TEST_LIBRARY_SNAPSHOT_DIR", &dir);
339
340        let fetch = sample_fetch();
341        save_snapshot(&fetch.platforms, &fetch.collections);
342
343        let loaded = load_snapshot().expect("snapshot should load");
344        assert_eq!(loaded.platforms.len(), 1);
345        assert_eq!(loaded.collections.len(), 1);
346        assert_eq!(loaded.platforms[0].id, 1);
347        assert_eq!(loaded.collections[0].id, 10);
348        assert_eq!(loaded.collection_digest.len(), 1);
349
350        let _ = std::fs::remove_dir_all(&dir);
351    }
352
353    #[test]
354    fn corrupt_file_returns_none() {
355        let _env = TestEnv::new();
356        let dir = std::env::temp_dir().join(format!(
357            "romm-lib-snap-bad-{}",
358            std::time::SystemTime::now()
359                .duration_since(std::time::UNIX_EPOCH)
360                .unwrap()
361                .as_nanos()
362        ));
363        std::fs::create_dir_all(&dir).unwrap();
364        std::env::set_var("ROMM_TEST_LIBRARY_SNAPSHOT_DIR", &dir);
365        let path = dir.join(DEFAULT_FILE);
366        std::fs::write(&path, b"not json {{{").unwrap();
367        assert!(load_snapshot().is_none());
368        let _ = std::fs::remove_dir_all(&dir);
369    }
370
371    #[test]
372    fn wrong_version_returns_none() {
373        let _env = TestEnv::new();
374        let dir = std::env::temp_dir().join(format!(
375            "romm-lib-snap-ver-{}",
376            std::time::SystemTime::now()
377                .duration_since(std::time::UNIX_EPOCH)
378                .unwrap()
379                .as_nanos()
380        ));
381        std::fs::create_dir_all(&dir).unwrap();
382        std::env::set_var("ROMM_TEST_LIBRARY_SNAPSHOT_DIR", &dir);
383        let path = dir.join(DEFAULT_FILE);
384        let bad = serde_json::json!({
385            "version": 999,
386            "saved_at_secs": 0,
387            "platforms": [],
388            "collections": []
389        });
390        std::fs::write(&path, bad.to_string()).unwrap();
391        assert!(load_snapshot().is_none());
392        let _ = std::fs::remove_dir_all(&dir);
393    }
394}