1use 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#[derive(Debug, Serialize, Deserialize)]
23struct SnapshotFile {
24 version: u32,
25 saved_at_secs: u64,
27 platforms: Vec<Platform>,
28 collections: Vec<Collection>,
29 #[serde(default)]
30 collection_digest: Vec<CollectionDigestEntry>,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35pub struct CollectionDigestEntry {
36 pub key: String,
37 pub rom_count: u64,
38}
39
40#[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
49pub 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
70pub 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
104pub 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
133pub 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
152pub 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
207pub 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 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}