Skip to main content

romm_api/endpoints/
roms.rs

1//! ROM-related API endpoints.
2//!
3//! This module contains endpoint definitions for searching, retrieving,
4//! updating, and deleting ROMs, as well as managing ROM metadata and identification.
5
6use crate::types::RomList;
7
8use super::Endpoint;
9use serde_json::{json, Value};
10
11fn push_bool(q: &mut Vec<(String, String)>, key: &str, v: Option<bool>) {
12    if let Some(b) = v {
13        q.push((key.into(), b.to_string()));
14    }
15}
16
17fn push_str(q: &mut Vec<(String, String)>, key: &str, v: &Option<String>) {
18    if let Some(s) = v {
19        if !s.is_empty() {
20            q.push((key.into(), s.clone()));
21        }
22    }
23}
24
25fn push_str_list(q: &mut Vec<(String, String)>, key: &str, items: &[String]) {
26    for it in items {
27        q.push((key.into(), it.clone()));
28    }
29}
30
31/// Retrieve ROMs with optional filters (`GET /api/roms`).
32#[derive(Debug, Default, Clone)]
33pub struct GetRoms {
34    pub search_term: Option<String>,
35    /// When set, emits one `platform_ids` query entry.
36    pub platform_id: Option<u64>,
37    /// Additional platform IDs (repeat `platform_ids` in the query).
38    pub platform_ids: Vec<u64>,
39    pub collection_id: Option<u64>,
40    pub smart_collection_id: Option<u64>,
41    pub virtual_collection_id: Option<String>,
42    pub matched: Option<bool>,
43    pub favorite: Option<bool>,
44    pub duplicate: Option<bool>,
45    pub last_played: Option<bool>,
46    pub playable: Option<bool>,
47    pub missing: Option<bool>,
48    pub has_ra: Option<bool>,
49    pub verified: Option<bool>,
50    pub group_by_meta_id: Option<bool>,
51    pub genres: Vec<String>,
52    pub franchises: Vec<String>,
53    pub collections: Vec<String>,
54    pub companies: Vec<String>,
55    pub age_ratings: Vec<String>,
56    pub statuses: Vec<String>,
57    pub regions: Vec<String>,
58    pub languages: Vec<String>,
59    pub player_counts: Vec<String>,
60    pub genres_logic: Option<String>,
61    pub franchises_logic: Option<String>,
62    pub collections_logic: Option<String>,
63    pub companies_logic: Option<String>,
64    pub age_ratings_logic: Option<String>,
65    pub regions_logic: Option<String>,
66    pub languages_logic: Option<String>,
67    pub statuses_logic: Option<String>,
68    pub player_counts_logic: Option<String>,
69    pub order_by: Option<String>,
70    pub order_dir: Option<String>,
71    pub updated_after: Option<String>,
72    pub with_char_index: Option<bool>,
73    pub with_filter_values: Option<bool>,
74    pub limit: Option<u32>,
75    pub offset: Option<u32>,
76}
77
78impl Endpoint for GetRoms {
79    type Output = RomList;
80
81    fn method(&self) -> &'static str {
82        "GET"
83    }
84
85    fn path(&self) -> String {
86        "/api/roms".into()
87    }
88
89    fn query(&self) -> Vec<(String, String)> {
90        let mut q = Vec::new();
91
92        if let Some(term) = &self.search_term {
93            q.push(("search_term".into(), term.clone()));
94        }
95
96        let mut seen = std::collections::HashSet::new();
97        for pid in &self.platform_ids {
98            if seen.insert(*pid) {
99                q.push(("platform_ids".into(), pid.to_string()));
100            }
101        }
102        if let Some(pid) = self.platform_id {
103            if seen.insert(pid) {
104                q.push(("platform_ids".into(), pid.to_string()));
105            }
106        }
107
108        if let Some(cid) = self.collection_id {
109            q.push(("collection_id".into(), cid.to_string()));
110        }
111        if let Some(sid) = self.smart_collection_id {
112            q.push(("smart_collection_id".into(), sid.to_string()));
113        }
114        if let Some(ref vid) = self.virtual_collection_id {
115            q.push(("virtual_collection_id".into(), vid.clone()));
116        }
117
118        push_bool(&mut q, "matched", self.matched);
119        push_bool(&mut q, "favorite", self.favorite);
120        push_bool(&mut q, "duplicate", self.duplicate);
121        push_bool(&mut q, "last_played", self.last_played);
122        push_bool(&mut q, "playable", self.playable);
123        push_bool(&mut q, "missing", self.missing);
124        push_bool(&mut q, "has_ra", self.has_ra);
125        push_bool(&mut q, "verified", self.verified);
126        push_bool(&mut q, "group_by_meta_id", self.group_by_meta_id);
127        push_bool(&mut q, "with_char_index", self.with_char_index);
128        push_bool(&mut q, "with_filter_values", self.with_filter_values);
129
130        push_str_list(&mut q, "genres", &self.genres);
131        push_str_list(&mut q, "franchises", &self.franchises);
132        push_str_list(&mut q, "collections", &self.collections);
133        push_str_list(&mut q, "companies", &self.companies);
134        push_str_list(&mut q, "age_ratings", &self.age_ratings);
135        push_str_list(&mut q, "statuses", &self.statuses);
136        push_str_list(&mut q, "regions", &self.regions);
137        push_str_list(&mut q, "languages", &self.languages);
138        push_str_list(&mut q, "player_counts", &self.player_counts);
139
140        push_str(&mut q, "genres_logic", &self.genres_logic);
141        push_str(&mut q, "franchises_logic", &self.franchises_logic);
142        push_str(&mut q, "collections_logic", &self.collections_logic);
143        push_str(&mut q, "companies_logic", &self.companies_logic);
144        push_str(&mut q, "age_ratings_logic", &self.age_ratings_logic);
145        push_str(&mut q, "regions_logic", &self.regions_logic);
146        push_str(&mut q, "languages_logic", &self.languages_logic);
147        push_str(&mut q, "statuses_logic", &self.statuses_logic);
148        push_str(&mut q, "player_counts_logic", &self.player_counts_logic);
149
150        push_str(&mut q, "order_by", &self.order_by);
151        push_str(&mut q, "order_dir", &self.order_dir);
152        push_str(&mut q, "updated_after", &self.updated_after);
153
154        if let Some(limit) = self.limit {
155            q.push(("limit".into(), limit.to_string()));
156        }
157        if let Some(offset) = self.offset {
158            q.push(("offset".into(), offset.to_string()));
159        }
160
161        q
162    }
163}
164
165/// Retrieve a single ROM by ID.
166#[derive(Debug, Clone)]
167pub struct GetRom {
168    pub id: u64,
169}
170
171impl Endpoint for GetRom {
172    type Output = crate::types::Rom;
173
174    fn method(&self) -> &'static str {
175        "GET"
176    }
177
178    fn path(&self) -> String {
179        format!("/api/roms/{}", self.id)
180    }
181}
182
183/// `GET /api/roms/by-hash`
184#[derive(Debug, Default, Clone)]
185pub struct GetRomByHash {
186    pub crc_hash: Option<String>,
187    pub md5_hash: Option<String>,
188    pub sha1_hash: Option<String>,
189}
190
191impl Endpoint for GetRomByHash {
192    type Output = Value;
193
194    fn method(&self) -> &'static str {
195        "GET"
196    }
197
198    fn path(&self) -> String {
199        "/api/roms/by-hash".into()
200    }
201
202    fn query(&self) -> Vec<(String, String)> {
203        let mut q = Vec::new();
204        push_str(&mut q, "crc_hash", &self.crc_hash);
205        push_str(&mut q, "md5_hash", &self.md5_hash);
206        push_str(&mut q, "sha1_hash", &self.sha1_hash);
207        q
208    }
209}
210
211/// `GET /api/roms/by-metadata-provider`
212#[derive(Debug, Default, Clone)]
213pub struct GetRomByMetadataProvider {
214    pub igdb_id: Option<i64>,
215    pub moby_id: Option<i64>,
216    pub ss_id: Option<i64>,
217    pub ra_id: Option<i64>,
218    pub launchbox_id: Option<i64>,
219    pub hasheous_id: Option<i64>,
220    pub tgdb_id: Option<i64>,
221    pub flashpoint_id: Option<String>,
222    pub hltb_id: Option<i64>,
223}
224
225impl Endpoint for GetRomByMetadataProvider {
226    type Output = Value;
227
228    fn method(&self) -> &'static str {
229        "GET"
230    }
231
232    fn path(&self) -> String {
233        "/api/roms/by-metadata-provider".into()
234    }
235
236    fn query(&self) -> Vec<(String, String)> {
237        let mut q = Vec::new();
238        if let Some(v) = self.igdb_id {
239            q.push(("igdb_id".into(), v.to_string()));
240        }
241        if let Some(v) = self.moby_id {
242            q.push(("moby_id".into(), v.to_string()));
243        }
244        if let Some(v) = self.ss_id {
245            q.push(("ss_id".into(), v.to_string()));
246        }
247        if let Some(v) = self.ra_id {
248            q.push(("ra_id".into(), v.to_string()));
249        }
250        if let Some(v) = self.launchbox_id {
251            q.push(("launchbox_id".into(), v.to_string()));
252        }
253        if let Some(v) = self.hasheous_id {
254            q.push(("hasheous_id".into(), v.to_string()));
255        }
256        if let Some(v) = self.tgdb_id {
257            q.push(("tgdb_id".into(), v.to_string()));
258        }
259        push_str(&mut q, "flashpoint_id", &self.flashpoint_id);
260        if let Some(v) = self.hltb_id {
261            q.push(("hltb_id".into(), v.to_string()));
262        }
263        q
264    }
265}
266
267/// `GET /api/roms/filters`
268#[derive(Debug, Default, Clone)]
269pub struct GetRomFilters;
270
271impl Endpoint for GetRomFilters {
272    type Output = Value;
273
274    fn method(&self) -> &'static str {
275        "GET"
276    }
277
278    fn path(&self) -> String {
279        "/api/roms/filters".into()
280    }
281}
282
283/// `POST /api/roms/delete`
284#[derive(Debug, Clone)]
285pub struct DeleteRoms {
286    pub roms: Vec<u64>,
287    pub delete_from_fs: Vec<u64>,
288}
289
290impl Endpoint for DeleteRoms {
291    type Output = Value;
292
293    fn method(&self) -> &'static str {
294        "POST"
295    }
296
297    fn path(&self) -> String {
298        "/api/roms/delete".into()
299    }
300
301    fn body(&self) -> Option<Value> {
302        Some(json!({
303            "roms": self.roms,
304            "delete_from_fs": self.delete_from_fs,
305        }))
306    }
307}
308
309/// `PUT /api/roms/{id}/props` — RomM accepts JSON body plus optional query flags.
310#[derive(Debug, Clone)]
311pub struct PutRomUserProps {
312    pub rom_id: u64,
313    pub body: Value,
314    pub update_last_played: bool,
315    pub remove_last_played: bool,
316}
317
318impl Endpoint for PutRomUserProps {
319    type Output = Value;
320
321    fn method(&self) -> &'static str {
322        "PUT"
323    }
324
325    fn path(&self) -> String {
326        format!("/api/roms/{}/props", self.rom_id)
327    }
328
329    fn query(&self) -> Vec<(String, String)> {
330        let mut q = Vec::new();
331        if self.update_last_played {
332            q.push(("update_last_played".into(), "true".into()));
333        }
334        if self.remove_last_played {
335            q.push(("remove_last_played".into(), "true".into()));
336        }
337        q
338    }
339
340    fn body(&self) -> Option<Value> {
341        Some(self.body.clone())
342    }
343}
344
345/// `GET /api/roms/{id}/notes`
346#[derive(Debug, Clone)]
347pub struct GetRomNotes {
348    pub rom_id: u64,
349    pub public_only: Option<bool>,
350    pub search: Option<String>,
351    pub tags: Vec<String>,
352}
353
354impl Endpoint for GetRomNotes {
355    type Output = Value;
356
357    fn method(&self) -> &'static str {
358        "GET"
359    }
360
361    fn path(&self) -> String {
362        format!("/api/roms/{}/notes", self.rom_id)
363    }
364
365    fn query(&self) -> Vec<(String, String)> {
366        let mut q = Vec::new();
367        push_bool(&mut q, "public_only", self.public_only);
368        push_str(&mut q, "search", &self.search);
369        push_str_list(&mut q, "tags", &self.tags);
370        q
371    }
372}
373
374/// `POST /api/roms/{id}/notes`
375#[derive(Debug, Clone)]
376pub struct PostRomNote {
377    pub rom_id: u64,
378    pub body: Value,
379}
380
381impl Endpoint for PostRomNote {
382    type Output = Value;
383
384    fn method(&self) -> &'static str {
385        "POST"
386    }
387
388    fn path(&self) -> String {
389        format!("/api/roms/{}/notes", self.rom_id)
390    }
391
392    fn body(&self) -> Option<Value> {
393        Some(self.body.clone())
394    }
395}
396
397/// `PUT /api/roms/{id}/notes/{note_id}`
398#[derive(Debug, Clone)]
399pub struct PutRomNote {
400    pub rom_id: u64,
401    pub note_id: u64,
402    pub body: Value,
403}
404
405impl Endpoint for PutRomNote {
406    type Output = Value;
407
408    fn method(&self) -> &'static str {
409        "PUT"
410    }
411
412    fn path(&self) -> String {
413        format!("/api/roms/{}/notes/{}", self.rom_id, self.note_id)
414    }
415
416    fn body(&self) -> Option<Value> {
417        Some(self.body.clone())
418    }
419}
420
421/// `DELETE /api/roms/{id}/notes/{note_id}`
422#[derive(Debug, Clone)]
423pub struct DeleteRomNote {
424    pub rom_id: u64,
425    pub note_id: u64,
426}
427
428impl Endpoint for DeleteRomNote {
429    type Output = Value;
430
431    fn method(&self) -> &'static str {
432        "DELETE"
433    }
434
435    fn path(&self) -> String {
436        format!("/api/roms/{}/notes/{}", self.rom_id, self.note_id)
437    }
438}
439
440/// `GET /api/search/cover`
441#[derive(Debug, Clone)]
442pub struct GetSearchCover {
443    pub search_term: String,
444}
445
446impl Endpoint for GetSearchCover {
447    type Output = Value;
448
449    fn method(&self) -> &'static str {
450        "GET"
451    }
452
453    fn path(&self) -> String {
454        "/api/search/cover".into()
455    }
456
457    fn query(&self) -> Vec<(String, String)> {
458        vec![("search_term".into(), self.search_term.clone())]
459    }
460}
461
462/// `GET /api/search/roms`
463#[derive(Debug, Clone)]
464pub struct GetSearchRoms {
465    pub rom_id: u64,
466    pub search_term: Option<String>,
467    pub search_by: Option<String>,
468}
469
470impl Endpoint for GetSearchRoms {
471    type Output = Value;
472
473    fn method(&self) -> &'static str {
474        "GET"
475    }
476
477    fn path(&self) -> String {
478        "/api/search/roms".into()
479    }
480
481    fn query(&self) -> Vec<(String, String)> {
482        let mut q = vec![("rom_id".into(), self.rom_id.to_string())];
483        push_str(&mut q, "search_term", &self.search_term);
484        push_str(&mut q, "search_by", &self.search_by);
485        q
486    }
487}
488
489#[cfg(test)]
490mod tests {
491    use super::{Endpoint, GetRoms};
492
493    #[test]
494    fn get_roms_query_sends_collection_id() {
495        let ep = GetRoms {
496            collection_id: Some(7),
497            limit: Some(100),
498            ..Default::default()
499        };
500        let q = ep.query();
501        assert!(q.iter().any(|(k, v)| k == "collection_id" && v == "7"));
502        assert!(!q.iter().any(|(k, _)| k == "smart_collection_id"));
503    }
504
505    #[test]
506    fn get_roms_query_sends_smart_collection_id() {
507        let ep = GetRoms {
508            smart_collection_id: Some(3),
509            limit: Some(50),
510            ..Default::default()
511        };
512        let q = ep.query();
513        assert!(q
514            .iter()
515            .any(|(k, v)| k == "smart_collection_id" && v == "3"));
516        assert!(!q.iter().any(|(k, _)| k == "collection_id"));
517        assert!(!q.iter().any(|(k, _)| k == "virtual_collection_id"));
518    }
519
520    #[test]
521    fn get_roms_query_sends_virtual_collection_id() {
522        let ep = GetRoms {
523            virtual_collection_id: Some("recent".into()),
524            limit: Some(10),
525            ..Default::default()
526        };
527        let q = ep.query();
528        assert!(q
529            .iter()
530            .any(|(k, v)| k == "virtual_collection_id" && v == "recent"));
531        assert!(!q.iter().any(|(k, _)| k == "collection_id"));
532    }
533
534    #[test]
535    fn get_roms_dedupes_platform_ids_with_platform_id() {
536        let ep = GetRoms {
537            platform_id: Some(5),
538            platform_ids: vec![5, 7],
539            ..Default::default()
540        };
541        let q = ep.query();
542        let ids: Vec<_> = q
543            .iter()
544            .filter(|(k, _)| k == "platform_ids")
545            .map(|(_, v)| v.as_str())
546            .collect();
547        assert_eq!(ids, vec!["5", "7"]);
548    }
549}