tasvideos_api_rs/
lib.rs

1
2//! API Wrapper for the [TASVideos API](https://demo.tasvideos.org/api/).
3//! 
4//! This crate provides synchronous functions for each supported endpoint of the API.
5//! 
6//! All requests can be used without an API key, however, the [`USER_AGENT`] string should be set
7//! before making any requests. 
8
9use std::io::Read;
10use once_cell::sync::Lazy;
11use parking_lot::Mutex;
12use serde::{Deserialize, Serialize};
13use ureq::Request;
14use Filter::*;
15
16/// The `User-Agent` header used in all requests made from this crate.
17/// 
18/// Change using [`set_user_agent`].
19static USER_AGENT: Lazy<Mutex<String>> = Lazy::new(|| Mutex::new(String::new()));
20
21/// The base URL used in all requests to the TASVideos API.
22static API_URL: &'static str = "https://tasvideos.org/api/v1/";
23
24/// Sets the `User-Agent` header used in all API requests.
25/// 
26/// This string should include some contact method (e.g. email), and either your name or the name
27/// of your software.
28/// 
29/// # Example
30/// ```rust
31/// tasvideos_api_rs::set_user_agent("John Smith (johnsmith@gmail.com)");
32/// ```
33pub fn set_user_agent(agent: &str) {
34    *USER_AGENT.try_lock().expect("unable to get lock while setting user agent") = agent.to_string();
35}
36
37pub const MAX_PAGE_LENGTH: u32 = 100;
38
39#[derive(Debug)]
40pub enum Error {
41    Ureq(ureq::Error),
42    Io(std::io::Error),
43}
44impl From<ureq::Error> for Error {
45    fn from(value: ureq::Error) -> Self {
46        Self::Ureq(value)
47    }
48}
49impl From<std::io::Error> for Error {
50    fn from(value: std::io::Error) -> Self {
51        Self::Io(value)
52    }
53}
54
55pub type Result<T> = std::result::Result<T, Error>;
56
57
58/// Some endpoints can use these filters to refine what data is returned.
59/// 
60/// Endpoints that support filters will accept a `Vec<QueryFilter>` argument. The order of filters
61/// doesn't matter. Using multiple of the same kind of filter is not recommended, and may produce
62/// undefined behavior. Each endpoint may not support all available filter types, refer to the docs
63/// on each function to know what is supported. Any unsupported filters will be quietly ignored.
64#[derive(Clone, Eq, PartialEq, Debug)]
65pub enum Filter {
66    Statuses(Vec<String>),
67    Users(Vec<String>),
68    Systems(Vec<String>),
69    ClassNames(Vec<String>),
70    StartYear(i32),
71    EndYear(i32),
72    GenreNames(Vec<String>),
73    TagNames(Vec<String>),
74    FlagNames(Vec<String>),
75    AuthorIds(Vec<i32>),
76    ShowObsoleted(bool),
77    GameIds(Vec<i32>),
78    Games(Vec<i32>),
79    GameGroupIds(Vec<i32>),
80    PageSize(u32),
81    CurrentPage(u32),
82    Sorts(Vec<String>),
83}
84impl Filter {
85    pub fn as_query(&self) -> (&'static str, String) {
86        match self {
87            Statuses(x) => ("Statuses", x.join(",")),
88            Users(x) => ("User", x.join(",")),
89            Systems(x) => ("Systems", x.join(",")),
90            ClassNames(x) => ("ClassNames", x.join(",")),
91            StartYear(x) => ("StartYear", x.to_string()),
92            EndYear(x) => ("EndYear", x.to_string()),
93            GenreNames(x) => ("GenreNames", x.join(",")),
94            TagNames(x) => ("TagNames", x.join(",")),
95            FlagNames(x) => ("FlagNames", x.join(",")),
96            AuthorIds(x) => ("AuthorIds", itertools::intersperse(x.iter().map(|x| x.to_string()), ",".into()).collect()),
97            ShowObsoleted(x) => ("ShowObsoleted", x.to_string()),
98            GameIds(x) => ("GameIds", itertools::intersperse(x.iter().map(|x| x.to_string()), ",".into()).collect()),
99            Games(x) => ("Games", itertools::intersperse(x.iter().map(|x| x.to_string()), ",".into()).collect()),
100            GameGroupIds(x) => ("GameGroupIds", itertools::intersperse(x.iter().map(|x| x.to_string()), ",".into()).collect()),
101            PageSize(x) => ("PageSize", x.to_string()),
102            CurrentPage(x) => ("CurrentPage", x.to_string()),
103            Sorts(x) => ("Sort", x.join(",")),
104        }
105    }
106}
107
108/// Represents a TASVideos game version (e.g. ROM).
109#[derive(Clone, Deserialize, Serialize, Eq, PartialEq, Debug, Default)]
110#[serde(rename_all = "camelCase")]
111pub struct GameVersion {
112    pub id: Option<i32>,
113    pub md5: Option<String>,
114    pub sha1: Option<String>,
115    pub name: Option<String>,
116    #[serde(rename = "type")]
117    pub kind: Option<i32>,
118    pub region: Option<String>,
119    pub version: Option<String>,
120    pub system_code: Option<String>,
121}
122
123/// Represents a TASVideos game.
124#[derive(Clone, Deserialize, Serialize, Eq, PartialEq, Debug, Default)]
125#[serde(rename_all = "camelCase")]
126pub struct Game {
127    pub id: Option<i32>,
128    pub versions: Option<Vec<GameVersion>>,
129    pub display_name: Option<String>,
130    pub abbreviation: Option<String>,
131    pub aliases: Option<String>,
132    pub screenshot_url: Option<String>,
133}
134
135/// Represents a TASVideos movie publication.
136#[derive(Clone, Deserialize, Serialize, PartialEq, Debug, Default)]
137#[serde(rename_all = "camelCase")]
138pub struct Publication {
139    pub id: Option<i32>,
140    pub title: Option<String>,
141    pub branch: Option<String>,
142    pub emulator_version: Option<String>,
143    pub class: Option<String>,
144    pub system_code: Option<String>,
145    pub submission_id: Option<i32>,
146    pub game_id: Option<i32>,
147    pub game_version_id: Option<i32>,
148    pub obsoleted_by_id: Option<i32>,
149    pub frames: Option<i32>,
150    pub rerecord_count: Option<i32>,
151    pub system_frame_rate: Option<f64>,
152    pub movie_file_name: Option<String>,
153    pub additional_authors: Option<String>,
154    pub authors: Option<Vec<String>>,
155    pub tags: Option<Vec<String>>,
156    pub flags: Option<Vec<String>>,
157    pub urls: Option<Vec<String>>,
158    pub file_paths: Option<Vec<String>>,
159    pub create_timestamp: Option<String>,
160}
161
162/// Represents a TASVideos movie submission.
163#[derive(Clone, Deserialize, Serialize, PartialEq, Debug, Default)]
164#[serde(rename_all = "camelCase")]
165pub struct Submission {
166    pub id: Option<i32>,
167    pub publication_id: Option<i32>,
168    pub title: Option<String>,
169    pub intended_class: Option<String>,
170    pub judge: Option<String>,
171    pub publisher: Option<String>,
172    pub status: Option<String>,
173    pub movie_extension: Option<String>,
174    pub game_id: Option<i32>,
175    pub game_name: Option<String>,
176    pub game_version_id: Option<i32>,
177    pub game_version: Option<String>,
178    pub system_code: Option<String>,
179    pub system_frame_rate: Option<f64>,
180    pub frames: Option<i32>,
181    pub rerecord_count: Option<i32>,
182    pub encode_embed_link: Option<String>,
183    pub branch: Option<String>,
184    pub rom_name: Option<String>,
185    pub emulator_version: Option<String>,
186    pub movie_start_type: Option<i32>,
187    pub additional_authors: Option<String>,
188    pub authors: Option<Vec<String>>,
189    pub create_timestamp: Option<String>,
190}
191
192/// Describes a possible framerate for a gaming system.
193#[derive(Clone, Deserialize, Serialize, PartialEq, Debug, Default)]
194#[serde(rename_all = "camelCase")]
195pub struct SystemFrameRate {
196    pub id: Option<i32>,
197    pub frame_rate: Option<f64>,
198    pub region_code: Option<String>,
199    pub preliminary: Option<bool>,
200    pub obsolete: Option<bool>,
201}
202
203/// Represents a gaming system (e.g. GBA, N64, Genesis, etc.) used by TASVideos.
204#[derive(Clone, Deserialize, Serialize, PartialEq, Debug, Default)]
205#[serde(rename_all = "camelCase")]
206pub struct System {
207    pub id: Option<i32>,
208    pub code: Option<String>,
209    pub display_name: Option<String>,
210    pub system_frame_rates: Option<Vec<SystemFrameRate>>,
211}
212
213
214/// Creates a GET request builder for the TASVideos API.
215/// 
216/// The URL is set by appending the predefined [`API_URL`] with the provided endpoint.
217/// A `User-Agent` header (using the static [`USER_AGENT`]) and the appropriate `Accept` header are also set.
218/// 
219/// This can be used to create custom requests to the API, such as using an endpoint that's unimplemented in this crate.
220pub fn get(endpoint: &str) -> Request {
221    let mut builder = ureq::get(&[API_URL, endpoint].concat()).set("accept", "application/json");
222    
223    let Some(agent) = USER_AGENT.try_lock() else { return builder };
224    
225    if !agent.is_empty() {
226        builder = builder.set("user-agent", agent.as_str());
227    }
228    
229    builder
230}
231
232
233/// Executes a GET request to the `/Games/{id}` endpoint to retrieve a specific [`game`].
234/// 
235/// # Errors
236/// 
237/// If any request error occurs, or if no game with this `id` is found (resulting in a parsing error),
238/// the error will be returned.
239/// 
240/// [`game`]: Game
241pub fn get_game(id: i32) -> Result<Game> {
242    let resp = get(&format!("Games/{id}")).call()?;
243    
244    Ok(resp.into_json()?)
245}
246
247/// Executes a GET request to the `/Games` endpoint to retrieve a list of [`games`].
248/// 
249/// Unless configured with a filter, only the first 100 (or fewer) games will be returned.
250/// 
251/// Optional search filters:
252/// 
253/// [`Systems`], [`PageSize`], [`CurrentPage`], [`Sorts`]
254/// 
255/// # Errors
256/// 
257/// Any request or parsing errors will be returned. 
258/// 
259/// [`games`]: Game
260pub fn get_games<F: IntoIterator<Item = Filter>>(filters: F) -> Result<Vec<Game>> {
261    let filters = filters.into_iter()
262        .filter(|f| match f {
263            Systems(_) | PageSize(_) | CurrentPage(_) | Sorts(_) => true,
264            _ => false,
265        })
266        .map(|f| f.as_query());
267    
268    let mut req = get("Games");
269    for f in filters {
270        req = req.query(f.0, &f.1);
271    }
272    
273    let resp = req.call()?;
274    
275    Ok(resp.into_json()?)
276}
277
278/// Executes one or more GET requests to the `/Games` endpoint to retreive all available [`games`].
279/// 
280/// This will likely make multiple API calls, in [`MAX_PAGE_LENGTH`] increments, until no more entries are
281/// found or until an error occurs.
282/// 
283/// Optional search filters:
284/// 
285/// [`System`], [`Sorts`]
286/// 
287/// # Errors
288/// 
289/// Any request or parsing errors will be returned. 
290/// 
291/// [`games`]: Game
292pub fn get_games_all<F: IntoIterator<Item = Filter>>(filters: F) -> Result<Vec<Game>> {
293    let mut filters: Vec<Filter> = filters.into_iter().collect();
294    filters.retain(|f| match f {
295        PageSize(_) | CurrentPage(_) => false,
296        _ => true,
297    });
298    
299    filters.push(PageSize(MAX_PAGE_LENGTH));
300    filters.push(CurrentPage(1));
301    
302    let mut games = Vec::new();
303    loop {
304        let data = get_games(filters.clone())?;
305        
306        let len = data.len();
307        games.extend(data);
308        
309        if len < MAX_PAGE_LENGTH as usize {
310            break
311        }
312        
313        for filter in filters.iter_mut() { match filter {
314            CurrentPage(x) => *x += 1,
315            _ => ()
316        }}
317    }
318    
319    Ok(games)
320}
321
322
323
324/// Executes a GET request to the `/Publications/{id}` endpoint to retrieve a specific [`publication`].
325/// 
326/// # Errors
327/// 
328/// If any request error occurs, or if no publication with this `id` is found (resulting in a parsing error),
329/// the error will be returned.
330/// 
331/// [`publication`]: Publication
332pub fn get_publication(id: i32) -> Result<Publication> {
333    let resp = get(&format!("Publications/{id}")).call()?;
334    
335    Ok(resp.into_json()?)
336}
337
338/// Executes a GET request to the `/Publications` endpoint to retrieve a list of [`publications`].
339/// 
340/// Unless configured with a filter, only the first 100 (or fewer) publications will be returned.
341/// 
342/// Optional search filters:
343/// 
344/// [`Systems`], [`ClassNames`], [`StartYear`], [`EndYear`], [`GenreNames`], [`TagNames`],
345/// [`FlagNames`], [`AuthorIds`], [`ShowObsoleted`], [`GameIds`], [`GameGroupIds`],
346/// [`PageSize`], [`CurrentPage`], [`Sorts`]
347/// 
348/// # Errors
349/// 
350/// Any request or parsing errors will be returned. 
351/// 
352/// [`publications`]: Publication
353pub fn get_publications<F: IntoIterator<Item = Filter>>(filters: F) -> Result<Vec<Publication>> {
354    let filters = filters.into_iter()
355        .filter(|f| match f {
356            Systems(_) | ClassNames(_) | StartYear(_) | EndYear(_) | GenreNames(_) |
357                TagNames(_) | FlagNames(_) | AuthorIds(_) | ShowObsoleted(_) | GameIds(_) |
358                GameGroupIds(_) | PageSize(_) | CurrentPage(_) | Sorts(_) => true,
359            _ => false,
360        })
361        .map(|f| f.as_query());
362    
363    let mut req = get("Publications");
364    for f in filters {
365        req = req.query(f.0, &f.1);
366    }
367    
368    let resp = req.call()?;
369    
370    Ok(resp.into_json()?)
371}
372
373/// Executes one or more GET requests to the `/Publications` endpoint to retreive all available [`publications`].
374/// 
375/// This will likely make multiple API calls, in [`MAX_PAGE_LENGTH`] increments, until no more entries are
376/// found or until an error occurs.
377/// 
378/// Optional search filters:
379/// 
380/// [`Systems`], [`ClassNames`], [`StartYear`], [`EndYear`], [`GenreNames`], [`TagNames`],
381/// [`FlagNames`], [`AuthorIds`], [`ShowObsoleted`], [`GameIds`], [`GameGroupIds`], [`Sorts`]
382/// 
383/// # Errors
384/// 
385/// Any request or parsing errors will be returned. 
386/// 
387/// [`publications`]: Publication
388pub fn get_publications_all<F: IntoIterator<Item = Filter>>(filters: F) -> Result<Vec<Publication>> {
389    let mut filters: Vec<Filter> = filters.into_iter().collect();
390    filters.retain(|f| match f {
391        PageSize(_) | CurrentPage(_) => false,
392        _ => true,
393    });
394    
395    filters.push(PageSize(MAX_PAGE_LENGTH));
396    filters.push(CurrentPage(1));
397    
398    let mut publications = Vec::new();
399    loop {
400        let data = get_publications(filters.clone())?;
401        
402        let len = data.len();
403        publications.extend(data);
404        
405        if len < MAX_PAGE_LENGTH as usize {
406            break
407        }
408        
409        for filter in filters.iter_mut() { match filter {
410            CurrentPage(x) => *x += 1,
411            _ => ()
412        }}
413    }
414    
415    Ok(publications)
416}
417
418/// Attempts to download the publication's movie file.
419/// 
420/// Returned data _should_ be a ZIP archive.
421pub fn get_publication_movie(id: i32) -> Result<Vec<u8>> {
422    let resp = ureq::get(&format!("https://tasvideos.org/{id}M?handler=Download"))
423        .set("user-agent", USER_AGENT.try_lock().unwrap().as_str())
424        .call()?;
425    
426    let cap = resp
427        .header("content-length")
428        .map(|s| usize::from_str_radix(s, 10).ok())
429        .flatten()
430        .unwrap_or(8192);
431    
432    let mut buf = Vec::with_capacity(cap);
433    resp.into_reader().read_to_end(&mut buf)?;
434    
435    Ok(buf)
436}
437
438
439/// Executes a GET request to the `/Submissions/{id}` endpoint to retrieve a specific [`submission`].
440/// 
441/// # Errors
442/// 
443/// If any request error occurs, or if no submission with this `id` is found (resulting in a parsing error),
444/// the error will be returned.
445/// 
446/// [`submission`]: Submission
447pub fn get_submission(id: i32) -> Result<Submission> {
448    let resp = get(&format!("Submissions/{id}")).call()?;
449    
450    Ok(resp.into_json()?)
451}
452
453/// Executes a GET request to the `/Submissions` endpoint to retrieve a list of [`submissions`].
454/// 
455/// Unless configured with a filter, only the first 100 (or fewer) submissions will be returned.
456/// 
457/// Optional search filters:
458/// 
459/// [`Statuses`], [`Users`], [`StartYear`], [`EndYear`], [`Systems`], [`Games`],
460/// [`PageSize`], [`CurrentPage`], [`Sorts`]
461/// 
462/// # Errors
463/// 
464/// Any request or parsing errors will be returned. 
465/// 
466/// [`submissions`]: Submission
467pub fn get_submissions<F: IntoIterator<Item = Filter>>(filters: F) -> Result<Vec<Submission>> {
468    let filters = filters.into_iter()
469            .filter(|f| match f {
470                Statuses(_) | Users(_) | StartYear(_) | EndYear(_) | Systems(_) |
471                    Games(_) | PageSize(_) | CurrentPage(_) | Sorts(_) => true,
472                _ => false,
473            })
474        .map(|f| f.as_query());
475    
476    let mut req = get("Submissions");
477    for f in filters {
478        req = req.query(f.0, &f.1);
479    }
480    
481    let resp = req.call()?;
482    
483    Ok(resp.into_json()?)
484}
485
486/// Executes one or more GET requests to the `/Submissions` endpoint to retreive all available [`submissions`].
487/// 
488/// This will likely make multiple API calls, in [`MAX_PAGE_LENGTH`] increments, until no more entries are
489/// found or until an error occurs.
490/// 
491/// Optional search filters:
492/// 
493/// [`Statuses`], [`Users`], [`StartYear`], [`EndYear`], [`Systems`], [`Games`], [`Sorts`]
494/// 
495/// # Errors
496/// 
497/// Any request or parsing errors will be returned. 
498/// 
499/// [`submissions`]: Submission
500pub fn get_submissions_all<F: IntoIterator<Item = Filter>>(filters: F) -> Result<Vec<Submission>> {
501    let mut filters: Vec<Filter> = filters.into_iter().collect();
502    filters.retain(|f| match f {
503        PageSize(_) | CurrentPage(_) => false,
504        _ => true,
505    });
506    
507    filters.push(PageSize(MAX_PAGE_LENGTH));
508    filters.push(CurrentPage(1));
509    
510    let mut submissions = Vec::new();
511    loop {
512        let data = get_submissions(filters.clone())?;
513        
514        let len = data.len();
515        submissions.extend(data);
516        
517        if len < MAX_PAGE_LENGTH as usize {
518            break
519        }
520        
521        for filter in filters.iter_mut() { match filter {
522            CurrentPage(x) => *x += 1,
523            _ => ()
524        }}
525    }
526    
527    Ok(submissions)
528}
529
530/// Attempts to download the submission's movie file.
531/// 
532/// Returned data _should_ be a ZIP archive.
533pub fn get_submission_movie(id: i32) -> Result<Vec<u8>> {
534    let resp = ureq::get(&format!("https://tasvideos.org/{id}S?handler=Download"))
535        .set("user-agent", USER_AGENT.try_lock().unwrap().as_str())
536        .call()?;
537    
538    let cap = resp
539        .header("content-length")
540        .map(|s| usize::from_str_radix(s, 10).ok())
541        .flatten()
542        .unwrap_or(8192);
543    
544    let mut buf = Vec::with_capacity(cap);
545    resp.into_reader().read_to_end(&mut buf)?;
546    
547    Ok(buf)
548}
549
550/// Attempts to download a userfile.
551/// 
552/// Returned data _should_ be gzip compressed.
553pub fn get_userfile(id: u64) -> Result<(Vec<u8>, Option<String>)> {
554    let resp = ureq::get(&format!("https://tasvideos.org/{id}S?handler=Download"))
555        .set("user-agent", USER_AGENT.try_lock().unwrap().as_str())
556        .call()?;
557    
558    let filename = resp.header("content-disposition")
559        .filter(|header| !header.is_empty())
560        .map(|header| {
561            header.split(';')
562                .map(|pair| pair.split_once('='))
563                .filter_map(|pair| pair)
564                .find(|(key, _)| key == &"filename")
565                .map(|(_, val)| val.to_string())
566        })
567        .flatten();
568    
569    let cap = resp
570        .header("content-length")
571        .map(|s| usize::from_str_radix(s, 10).ok())
572        .flatten()
573        .unwrap_or(8192);
574    
575    let mut buf = Vec::with_capacity(cap);
576    resp.into_reader().read_to_end(&mut buf)?;
577    
578    Ok((buf, filename))
579}
580
581/// Executes a GET request to the `/Systems/{id}` endpoint to retrieve a specific [`system`].
582/// 
583/// # Errors
584/// 
585/// If any request error occurs, or if no system with this `id` is found (resulting in a parsing error),
586/// the error will be returned.
587/// 
588/// [`system`]: System
589pub fn get_system(id: i32) -> Result<System> {
590    let resp = get(&format!("Systems/{id}")).call()?;
591    
592    Ok(resp.into_json()?)
593}
594
595/// Executes a GET request to the `/Systems` endpoint to retrieve a list of all [`systems`].
596/// 
597/// Unlike similar endpoints, this endpoint doesn't accept any filters, and should always return
598/// all available systems, using a single request.
599/// 
600/// # Errors
601/// 
602/// Any request or parsing errors will be returned. 
603/// 
604/// [`systems`]: System
605pub fn get_systems() -> Result<Vec<System>> {
606    let resp = get("Systems").call()?;
607    
608    Ok(resp.into_json()?)
609}