yts_api/
lib.rs

1use bytes::Bytes;
2use http_body_util::BodyExt;
3use http_body_util::Empty;
4use hyper_tls::HttpsConnector;
5use hyper_util::client::legacy::Client;
6use hyper_util::rt::TokioExecutor;
7use parse_display::Display;
8use serde::{Deserialize, Serialize};
9
10use std::fmt;
11
12#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
13#[serde(rename_all = "snake_case")]
14pub enum Status {
15    Ok,
16    Other(String),
17}
18
19#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
20pub struct Torrent {
21    pub url: String,
22    pub hash: String,
23    pub quality: String,
24    #[serde(rename = "type")]
25    pub _type: String,
26    pub seeds: u32,
27    pub peers: u32,
28    pub size: String,
29    pub size_bytes: u64,
30    pub date_uploaded: String,
31    pub date_uploaded_unix: u64,
32}
33
34#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
35pub struct Actor {
36    name: String,
37    character_name: String,
38    imdb_code: String,
39    url_small_image: Option<String>,
40}
41
42#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
43pub struct Movie {
44    pub id: u32,
45    pub url: String,
46    pub imdb_code: String,
47    pub title: String,
48    pub title_english: String,
49    pub title_long: String,
50    pub slug: String,
51    pub year: u32,
52    pub rating: f32,
53    pub runtime: u32,
54    pub genres: Vec<String>,
55    pub summary: Option<String>,
56    pub description_intro: Option<String>,
57    pub description_full: String,
58    pub synopsis: Option<String>,
59    pub yt_trailer_code: String,
60    pub language: String,
61    pub mpa_rating: String,
62    pub background_image: String,
63    pub background_image_original: String,
64    pub small_cover_image: String,
65    pub medium_cover_image: String,
66    pub large_cover_image: String,
67    pub medium_screenshot_image1: Option<String>,
68    pub medium_screenshot_image2: Option<String>,
69    pub medium_screenshot_image3: Option<String>,
70    pub large_screenshot_image1: Option<String>,
71    pub large_screenshot_image2: Option<String>,
72    pub large_screenshot_image3: Option<String>,
73    pub state: Option<Status>,
74    pub torrents: Vec<Torrent>,
75    pub date_uploaded: String,
76    pub date_uploaded_unix: u64,
77    pub download_count: Option<u32>,
78    pub like_count: Option<u32>,
79    pub cast: Option<Vec<Actor>>,
80}
81
82#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
83pub struct MovieDetail {
84    pub movie: Movie,
85}
86
87#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
88pub struct MovieList {
89    pub movie_count: u32,
90    pub limit: u32,
91    pub page_number: u32,
92    #[serde(skip_serializing_if = "Vec::is_empty", default)]
93    pub movies: Vec<Movie>,
94}
95
96#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
97#[serde(untagged)]
98pub enum Data {
99    MovieList(MovieList),
100    MovieDetails(MovieDetail),
101}
102
103#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
104pub struct Response {
105    pub status: Status,
106    pub status_message: String,
107    pub data: Option<Data>,
108}
109
110#[derive(Display, Copy, Clone, Debug)]
111pub enum Quality {
112    #[display("720p")]
113    Q720p,
114    #[display("1080p")]
115    Q1080p,
116    #[display("2160p")]
117    Q2160p,
118    #[display("3D")]
119    Q3D,
120}
121
122#[derive(Display, Copy, Clone, Debug)]
123#[display(style = "snake_case")]
124pub enum Sort {
125    Title,
126    Year,
127    Rating,
128    Peers,
129    Seeds,
130    DownloadCount,
131    LikeCount,
132    DateAdded,
133}
134
135#[derive(Display, Copy, Clone, Debug)]
136#[display(style = "snake_case")]
137pub enum Order {
138    Desc,
139    Asc,
140}
141
142pub trait ApiEndpoint {
143    fn get_url(&self) -> String;
144}
145
146/// Add a query parameter to the given URL
147fn add_query(url: &mut String, name: &str, value: Option<impl fmt::Display>) {
148    if let Some(value) = value {
149        url.push_str(&format!("{}={}&", name, value));
150    }
151}
152
153/// Helper to execute an API endpoint
154async fn execute(url: &str) -> Result<Data, Box<dyn std::error::Error + Send + Sync>> {
155    let https = HttpsConnector::new();
156    let client = Client::builder(TokioExecutor::new()).build::<_, Empty<Bytes>>(https);
157
158    let mut res = client.get(url.parse()?).await?;
159    let mut bytes = Vec::new();
160    while let Some(frame) = res.body_mut().frame().await {
161        let frame = frame?;
162        if let Some(data) = frame.data_ref() {
163            bytes.extend(data);
164        }
165    }
166    let body = String::from_utf8(bytes)?;
167    let response: Response = serde_json::from_str(&body)?;
168    if let Status::Other(status) = response.status {
169        return Err(format!("{}: {}", status, response.status_message).into());
170    }
171    let data = response.data.ok_or("Data missing")?;
172    Ok(data)
173}
174
175#[derive(Clone, Debug, Default)]
176pub struct ListMovies<'a> {
177    /// The limit of results per page that has been set
178    limit: Option<u8>,
179    /// Used to see the next page of movies, eg limit=15 and page=2 will show you movies 15-30
180    page: Option<u32>,
181    /// Used to filter by a given quality
182    quality: Option<Quality>,
183    /// Used to filter movie by a given minimum IMDb rating
184    minimum_rating: Option<u8>,
185    /// Used for movie search, matching on: Movie Title/IMDb Code, Actor Name/IMDb Code, Director
186    /// Name/IMDb Code
187    query_term: Option<&'a str>,
188    /// Used to filter by a given genre (See http://www.imdb.com/genre/ for full list)
189    genre: Option<&'a str>,
190    /// Sorts the results by choosen value
191    sort_by: Option<Sort>,
192    /// Orders the results by either Ascending or Descending order
193    order_by: Option<Order>,
194    /// Returns the list with the Rotten Tomatoes rating included
195    wirth_rt_ratings: Option<bool>,
196}
197
198impl<'a> ListMovies<'a> {
199    pub fn new() -> ListMovies<'a> {
200        ListMovies::default()
201    }
202
203    pub fn limit(&mut self, limit: u8) -> &mut Self {
204        assert!(limit > 1 && limit <= 50, "limit out of range");
205        self.limit = Some(limit);
206        self
207    }
208
209    pub fn page(&mut self, page: u32) -> &mut Self {
210        assert!(page > 1, "page out of range");
211        self.page = Some(page);
212        self
213    }
214
215    pub fn quality(&mut self, quality: Quality) -> &mut Self {
216        self.quality = Some(quality);
217        self
218    }
219
220    pub fn query_term(&mut self, query_term: &'a str) -> &mut Self {
221        self.query_term = Some(query_term);
222        self
223    }
224
225    pub fn genre(&mut self, genre: &'a str) -> &mut Self {
226        self.genre = Some(genre);
227        self
228    }
229
230    pub fn sort_by(&mut self, sort_by: Sort) -> &mut Self {
231        self.sort_by = Some(sort_by);
232        self
233    }
234
235    pub fn order_by(&mut self, order_by: Order) -> &mut Self {
236        self.order_by = Some(order_by);
237        self
238    }
239
240    pub fn wirth_rt_ratings(&mut self, wirth_rt_ratings: bool) -> &mut Self {
241        self.wirth_rt_ratings = Some(wirth_rt_ratings);
242        self
243    }
244
245    pub async fn execute(&self) -> Result<MovieList, Box<dyn std::error::Error + Send + Sync>> {
246        let data = execute(&self.get_url()).await?;
247        match data {
248            Data::MovieList(movie_list) => Ok(movie_list),
249            _ => Err("Wrong data received".into()),
250        }
251    }
252}
253
254impl<'a> ApiEndpoint for ListMovies<'a> {
255    fn get_url(&self) -> String {
256        let mut url = "https://yts.mx/api/v2/list_movies.json?".to_owned();
257
258        add_query(&mut url, "limit", self.limit);
259        add_query(&mut url, "page", self.page);
260        add_query(&mut url, "quality", self.quality);
261        add_query(&mut url, "minimum_rating", self.minimum_rating);
262        add_query(&mut url, "query_term", self.query_term);
263        add_query(&mut url, "genre", self.genre);
264        add_query(&mut url, "sort_by", self.sort_by);
265        add_query(&mut url, "order_by", self.order_by);
266        add_query(&mut url, "wirth_rt_ratings", self.wirth_rt_ratings);
267        url
268    }
269}
270
271#[derive(Clone, Debug)]
272pub struct MovieDetails {
273    /// The ID of the movie
274    movie_id: u32,
275    /// When set the data returned will include the added image URLs
276    with_images: Option<bool>,
277    /// When set the data returned will include the added information about the cast
278    with_cast: Option<bool>,
279}
280
281impl MovieDetails {
282    pub fn new(movie_id: u32) -> MovieDetails {
283        MovieDetails {
284            movie_id,
285            with_images: None,
286            with_cast: None,
287        }
288    }
289
290    pub fn with_images(&mut self, with_images: bool) -> &mut Self {
291        self.with_images = Some(with_images);
292        self
293    }
294
295    pub fn with_cast(&mut self, with_cast: bool) -> &mut Self {
296        self.with_cast = Some(with_cast);
297        self
298    }
299
300    pub async fn execute(&self) -> Result<MovieDetail, Box<dyn std::error::Error + Send + Sync>> {
301        let data = execute(&self.get_url()).await?;
302        match data {
303            Data::MovieDetails(movie) => Ok(movie),
304            _ => Err("Wrong data received".into()),
305        }
306    }
307}
308
309impl ApiEndpoint for MovieDetails {
310    fn get_url(&self) -> String {
311        let mut url = "https://yts.mx/api/v2/movie_details.json?".to_owned();
312        add_query(&mut url, "movie_id", Some(self.movie_id));
313        add_query(&mut url, "with_images", self.with_images);
314        add_query(&mut url, "with_cast", self.with_cast);
315        url
316    }
317}
318
319#[deprecated(
320    since = "0.2.0",
321    note = "Use ListMovies::new().query_term(...).execute() instead"
322)]
323pub async fn list_movies(
324    query_term: &str,
325) -> Result<MovieList, Box<dyn std::error::Error + Send + Sync>> {
326    ListMovies::new().query_term(query_term).execute().await
327}
328
329#[cfg(test)]
330mod tests {
331    static TEST_DATA: &str = include_str!("test/test.json");
332
333    use super::*;
334
335    #[test]
336    fn list_movies_url_build_empty() {
337        let url = ListMovies::new().get_url();
338        assert_eq!(url, "https://yts.mx/api/v2/list_movies.json?");
339    }
340
341    #[test]
342    fn list_movies_url_query_term() {
343        let url = ListMovies::new().query_term("test").get_url();
344        assert_eq!(
345            url,
346            "https://yts.mx/api/v2/list_movies.json?query_term=test&"
347        );
348    }
349
350    #[test]
351    fn deserialize_test_data() {
352        let response: Response = serde_json::from_str(TEST_DATA).unwrap();
353        assert_eq!(response.status, Status::Ok);
354        assert_eq!(response.status_message, "Query was successful");
355        let data = response.data.unwrap();
356        let movie_list = match data {
357            Data::MovieList(movie_list) => movie_list,
358            _ => panic!("Wrong data"),
359        };
360        assert_eq!(movie_list.movie_count, 10);
361        assert_eq!(movie_list.limit, 20);
362        assert_eq!(movie_list.page_number, 1);
363        assert_eq!(movie_list.movies.len(), 10);
364    }
365
366    #[test]
367    fn deserialize_empty_test_data() {
368        static TEST_DATA: &str = include_str!("test/test_empty.json");
369        let response: Response = serde_json::from_str(TEST_DATA).unwrap();
370        assert_eq!(response.status, Status::Ok);
371        assert_eq!(response.status_message, "Query was successful");
372        let data = response.data.unwrap();
373        let movie_list = match data {
374            Data::MovieList(movie_list) => movie_list,
375            _ => panic!("Wrong data"),
376        };
377        assert_eq!(movie_list.movie_count, 0);
378        assert_eq!(movie_list.limit, 20);
379        assert_eq!(movie_list.page_number, 1);
380        assert_eq!(movie_list.movies.len(), 0);
381    }
382
383    #[test]
384    fn deserialize_movie_details() {
385        static TEST_DATA: &str = include_str!("test/test_movie_details.json");
386        let response: Response = serde_json::from_str(TEST_DATA).unwrap();
387        assert_eq!(response.status, Status::Ok);
388        assert_eq!(response.status_message, "Query was successful");
389        let data = response.data.unwrap();
390        let movie_details = match data {
391            Data::MovieDetails(movie_details) => movie_details,
392            _ => panic!("Wrong data"),
393        };
394        assert_eq!(movie_details.movie.id, 10);
395    }
396
397    #[test]
398    fn deserialize_movie_details_full() {
399        static TEST_DATA: &str = include_str!("test/test_movie_details_full.json");
400
401        let response: Response = serde_json::from_str(TEST_DATA).unwrap();
402        assert_eq!(response.status, Status::Ok);
403        assert_eq!(response.status_message, "Query was successful");
404        let data = response.data.unwrap();
405        let movie_details = match data {
406            Data::MovieDetails(movie_details) => movie_details,
407            _ => panic!("Wrong data"),
408        };
409        assert_eq!(movie_details.movie.id, 15);
410    }
411}