yts 0.1.0

lib and cli tool for crawling movies out of yts.mx
Documentation
use chrono::serde::ts_nanoseconds_option;
use chrono::{DateTime, Utc};
use colored::Colorize;
use hyphenation::{Language, Load, Standard};
use prettytable::{format, Cell, Row, Table};
use serde::Deserialize;
use std::fmt::{self};
use textwrap::{fill, Options as TextWrapOptions};
use url::Url;

#[derive(Deserialize, Clone, Debug)]
#[allow(dead_code)]
pub struct ListResponse {
    pub status: Option<String>,
    pub status_message: Option<String>,
    pub data: Option<Data>,
    #[serde(rename = "@meta")]
    pub meta: Option<Meta>,
}

#[derive(Deserialize, Clone, Debug)]
#[allow(dead_code)]
pub struct Data {
    pub movie_count: Option<u64>,
    pub limit: Option<u32>,
    pub page_number: Option<u32>,
    pub movies: Option<Vec<Movie>>,
}

#[derive(Deserialize, Clone, Debug)]
#[allow(dead_code)]
pub struct Movie {
    pub id: Option<u32>,
    pub url: Option<Url>,
    pub imdb_code: Option<String>,
    pub title: Option<String>,
    pub title_long: Option<String>,
    pub year: Option<u16>,
    pub rating: Option<f32>,
    pub runtime: Option<u16>,
    pub genres: Option<Vec<String>>,
    pub summary: Option<String>,
    pub description_full: Option<String>,
    pub synopsis: Option<String>,
    pub yt_trailer_code: Option<String>,
    pub language: Option<String>,
    pub mpa_rating: Option<String>,
    pub background_image: Option<String>,
    pub background_image_original: Option<String>,
    pub small_cover_image: Option<String>,
    pub medium_cover_image: Option<String>,
    pub large_cover_image: Option<String>,
    pub state: Option<String>,
    pub torrents: Option<Vec<Torrent>>,
    pub date_uploaded: Option<String>,
    #[serde(with = "ts_nanoseconds_option")]
    pub date_uploaded_unix: Option<DateTime<Utc>>,
}

#[derive(Deserialize, Clone, Debug)]
#[allow(dead_code)]
pub struct Torrent {
    pub url: Option<Url>,
    pub hash: Option<String>,
    pub quality: Option<String>,
    #[serde(rename = "type")]
    pub ty_pe: Option<String>,
    pub seeders: Option<u32>,
    pub peers: Option<u32>,
    pub size: Option<String>,
    pub size_bytes: Option<u64>,
    pub date_uploaded: Option<String>,
    #[serde(with = "ts_nanoseconds_option")]
    pub date_uploaded_unix: Option<DateTime<Utc>>,
}

#[derive(Deserialize, Clone, Debug)]
#[allow(dead_code)]
pub struct Meta {
    #[serde(with = "ts_nanoseconds_option")]
    pub server_time: Option<DateTime<Utc>>,
    pub server_timezone: Option<String>,
    pub api_version: Option<u8>,
    pub execution_time: Option<String>,
}

impl Movie {
    /// Returns the string representation for the id. It can be empty.
    pub fn id(&self) -> String {
        match self.id {
            Some(id) if id > 0 => format!("{}", id),
            _ => "".to_string(),
        }
    }

    /// Returns the string representation for the rating. It can be empty.
    pub fn rating(&self) -> String {
        match self.rating {
            Some(rating) if rating > 0.0 => format!("{:.1}", rating),
            _ => "".to_string(),
        }
    }

    /// Returns the string representation for the year. It can be empty.
    pub fn year(&self) -> String {
        match self.year {
            Some(year) if year > 0 => format!("{:<4}", year),
            _ => "".to_string(),
        }
    }

    /// Returns the string representation for the title. It can be empty.
    pub fn title(&self) -> String {
        self.title.to_owned().unwrap_or_else(|| "???".to_string())
    }

    /// Returns the string representation for the long title (including year). It can be empty.
    pub fn title_long(&self) -> String {
        self.title_long.to_owned().unwrap_or_else(|| "".to_string())
    }

    /// Returns the string representation for the yts url. It can be empty.
    pub fn url(&self) -> String {
        match &self.url {
            Some(url) => url.to_string(),
            _ => "".to_string(),
        }
    }

    /// Returns the string representation for the youtube trailer. It can be empty.
    pub fn youtube(&self) -> String {
        match &self.yt_trailer_code {
            Some(trailer_code) if !trailer_code.is_empty() => {
                format!("https://www.youtube.com/watch?v={}", trailer_code)
            }
            _ => "".into(),
        }
    }

    /// Returns the string representation for the imdb link. It can be empty.
    pub fn imdb(&self) -> String {
        match &self.imdb_code {
            Some(imdb) if !imdb.is_empty() => format!("https://www.imdb.com/title/{}/", imdb),
            _ => "".into(),
        }
    }

    /// Returns the string representation for the movie genres. It can be empty.
    pub fn genres(&self) -> String {
        match &self.genres {
            Some(genres) => genres
                .iter()
                .map(|g| g.to_lowercase())
                .collect::<Vec<String>>()
                .join(", "),
            None => "".to_string(),
        }
    }

    /// Returns the string representation for the movie summary. It can be empty.
    pub fn text(&self, description_type: MovieDescription) -> String {
        use MovieDescription::*;
        match description_type {
            Summary => self.summary.clone().unwrap_or_else(|| "".to_string()),
            Description => self
                .description_full
                .clone()
                .unwrap_or_else(|| "".to_string()),
            Synopsis => self.synopsis.clone().unwrap_or_else(|| "".to_string()),
        }
    }
}

#[derive(Debug)]
pub enum MovieDescription {
    Summary,
    Description,
    Synopsis,
}

impl fmt::Display for ListResponse {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        let mut table = Table::new();
        table.set_format(*format::consts::FORMAT_NO_COLSEP); // NO_BORDER?

        let data = match &self.data {
            Some(data) => data,
            None => {
                writeln!(f, "missing data from response")?;
                return Ok(());
            }
        };

        let movies = match &data.movies {
            Some(movies) if !movies.is_empty() => movies,
            _ => {
                writeln!(f, "no movies in response")?;
                return Ok(());
            }
        };

        for movie in movies {
            let left = format!(
                "{rating}\n\n{year}\n{genres}\n\n{id}",
                rating = movie.rating().as_str().green(),
                year = movie.year().as_str().green(),
                genres = fill(movie.genres().as_str(), 12),
                id = movie.id(),
            );
            let right = format!(
                "{title}\n{url}\n{yt}\n{imdb}\n\n{summary}",
                title = movie.title().as_str().bright_green(),
                url = movie.url(),
                yt = movie.youtube(),
                imdb = movie.imdb(),
                summary = {
                    let text = movie.text(MovieDescription::Summary);
                    let dictionary = Standard::from_embedded(Language::EnglishUS).unwrap();
                    let options = TextWrapOptions::new(90).splitter(dictionary);
                    fill(text.as_str(), &options)
                },
            );
            let cells = vec![Cell::new(right.as_str()), Cell::new(left.as_str())];
            table.add_row(Row::new(cells));
        }

        f.write_fmt(format_args!("{}", table))?;

        Ok(())
    }
}

impl fmt::Display for Movie {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(
            f,
            "{id:<6} {rating} {year} {title} {genres}\n\t{url:60}{youtube}",
            id = self.id(),
            title = self.title(),
            rating = self.rating(),
            year = self.year(),
            url = self.url(),
            genres = self.genres(),
            youtube = self.youtube(),
        )
    }
}

#[cfg(test)]
mod tests {
    use crate::parse::api::ListResponse;
    use url::Url;
    static JSON: &str = include_str!("test-data/list.json");

    #[test]
    fn parses_api_list() {
        let response: ListResponse =
            serde_json::from_str(JSON).expect("expected a parsed response");

        assert_eq!(
            response.status,
            Some("ok".to_string()),
            "response_status = '{}'",
            "ok"
        );
        assert_eq!(
            response.status_message,
            Some("Query was successful".to_string())
        );

        let data = response.data.expect("there should be some data here");
        assert_eq!(data.movie_count, Some(31474));
        assert_eq!(data.limit, Some(2));
        assert_eq!(data.page_number, Some(1));
        let movies = data.movies.expect("there should be movies here");
        assert_eq!(movies.len(), 2, "2 movies?");
        let movie = movies.first().unwrap();
        assert_eq!(
            movie.url,
            Some(Url::parse("https://yts.mx/movies/la-via-dei-babbuini-1974").unwrap())
        );
        assert_eq!(movie.imdb_code, Some("tt0144665".to_string()));
        assert_eq!(movie.title, Some("La via dei babbuini".to_string()));
        assert_eq!(movie.year, Some(1974));
        assert_eq!(movie.rating, Some(6.8));
        assert_eq!(
            movie
                .genres
                .clone()
                .expect("there should be genres here")
                .first()
                .expect("there should be a genre here"),
            "Comedy"
        );
        assert!(movie
            .summary
            .clone()
            .unwrap_or_else(|| "".to_string())
            .starts_with("This is a probably underrated brave attempt"));
        let torrent = movie
            .torrents
            .as_ref()
            .expect("missing torrents")
            .first()
            .expect("missing first torrent");
        assert_eq!(torrent.ty_pe, Some("web".to_string()));
        assert_eq!(
            torrent.url,
            Some(
                Url::parse(
                    "https://yts.mx/torrent/download/673B3BA1335C6D1F5035C086A98676BF6C738276"
                )
                .unwrap()
            )
        );
        let meta = response.meta.expect("there's a @meta section in the json");
        assert_eq!(meta.server_time.unwrap().timestamp_nanos(), 1622039993)
    }
}