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