simkl 0.1.0

Library to build queries for SIMKL and decoding JSON responses using Serde
Documentation
use serde::{Deserialize, Serialize};

use crate::{MediaIds, StandardMediaObject};

pub const CHECKIN_URL: &str = "https://api.simkl.com/checkin";

/// The episode to check in for a show.
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct CheckinEpisode {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub season: Option<u32>,
    pub number: u32,
}

/// A show item used in a checkin request body.
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct ShowCheckinItem {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub title: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub year: Option<u16>,
    pub ids: MediaIds,
    pub episode: CheckinEpisode,
}

/// The request body for `POST /checkin`.
///
/// Exactly one of `movie` or `show` should be set. Use the [`CheckinPayload::movie`] and
/// [`CheckinPayload::show`] constructors to build a correctly-shaped payload.
///
/// A 409 response means a checkin is already in progress; deserialize the body as
/// [`CheckinConflict`] to read the expiry time.
#[derive(Debug, Clone, Default, Serialize)]
pub struct CheckinPayload {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub movie: Option<StandardMediaObject>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub show: Option<ShowCheckinItem>,
}

impl CheckinPayload {
    /// Build a movie checkin payload.
    pub fn movie(item: StandardMediaObject) -> Self {
        Self {
            movie: Some(item),
            show: None,
        }
    }

    /// Build a show/episode checkin payload.
    pub fn show(item: ShowCheckinItem) -> Self {
        Self {
            movie: None,
            show: Some(item),
        }
    }
}

/// The response body returned by a successful `POST /checkin`.
///
/// The API echoes back the checked-in object in the corresponding field.
#[derive(Default, Debug, Clone, Deserialize)]
pub struct CheckinResponse {
    pub movie: Option<StandardMediaObject>,
    pub show: Option<StandardMediaObject>,
    pub anime: Option<StandardMediaObject>,
}

/// Returned as the body of a 409 response when a checkin is already in progress.
#[derive(Debug, Clone, Deserialize)]
pub struct CheckinConflict {
    pub expires_at: String,
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::MediaIds;

    fn guardians() -> StandardMediaObject {
        StandardMediaObject {
            title: "Guardians of the Galaxy".to_string(),
            year: 2014,
            ids: MediaIds {
                slug: Some("guardians-of-the-galaxy-2014".to_string()),
                imdb: Some("tt2015381".to_string()),
                tmdb: Some(118340),
                ..MediaIds::default()
            },
            seasons: None,
            episodes: None,
        }
    }

    fn breaking_bad_episode() -> ShowCheckinItem {
        ShowCheckinItem {
            title: Some("Breaking Bad".to_string()),
            year: Some(2008),
            ids: MediaIds {
                tvdb: Some(81189),
                imdb: Some("tt0903747".to_string()),
                ..MediaIds::default()
            },
            episode: CheckinEpisode {
                season: Some(2),
                number: 7,
            },
        }
    }

    #[test]
    fn movie_payload_serializes_only_movie_key() {
        let payload = CheckinPayload::movie(guardians());
        let json = serde_json::to_string(&payload).expect("serialization failed");

        assert!(json.contains("\"movie\""), "expected 'movie' key in JSON");
        assert!(
            !json.contains("\"show\""),
            "unexpected 'show' key in movie JSON"
        );
        assert!(
            json.contains("Guardians of the Galaxy"),
            "expected movie title in JSON"
        );
        assert!(json.contains("tt2015381"), "expected imdb id in JSON");
    }

    #[test]
    fn show_payload_serializes_only_show_key() {
        let payload = CheckinPayload::show(breaking_bad_episode());
        let json = serde_json::to_string(&payload).expect("serialization failed");

        assert!(json.contains("\"show\""), "expected 'show' key in JSON");
        assert!(
            !json.contains("\"movie\""),
            "unexpected 'movie' key in show JSON"
        );
        assert!(json.contains("Breaking Bad"), "expected show title in JSON");
        assert!(
            json.contains("\"episode\""),
            "expected 'episode' key in JSON"
        );
        assert!(
            json.contains("\"season\""),
            "expected 'season' key in episode JSON"
        );
        assert!(
            json.contains("\"number\""),
            "expected 'number' key in episode JSON"
        );
    }

    #[test]
    fn episode_skips_absent_season() {
        let episode = CheckinEpisode {
            season: None,
            number: 3,
        };
        let json = serde_json::to_string(&episode).expect("serialization failed");
        assert!(
            !json.contains("\"season\""),
            "absent season should be skipped"
        );
        assert!(json.contains("\"number\""), "number must always be present");
    }

    #[test]
    fn checkin_url_is_correct() {
        assert_eq!(CHECKIN_URL, "https://api.simkl.com/checkin");
    }
}