cameo 0.2.0

Unified movie/TV show database SDK for Rust
Documentation
//! AniList media → unified model conversions.

use super::resolve_date;
use crate::{
    providers::anilist::response::{AniListMedia, AniListMediaDetail},
    unified::{
        genre::Genre,
        media_id::MediaId,
        models::{
            Company, UnifiedMovie, UnifiedMovieDetails, UnifiedSearchResult, UnifiedTvShow,
            UnifiedTvShowDetails,
        },
    },
};

// ── Private helpers ───────────────────────────────────────────────────────────

/// Resolve the best English title from an AniList media entry.
fn resolve_title(media: &AniListMedia) -> String {
    media
        .title
        .as_ref()
        .and_then(|t| t.english.clone().or_else(|| t.romaji.clone()))
        .unwrap_or_default()
}

/// Resolve the best English title from an AniList detail entry.
fn resolve_detail_title(media: &AniListMediaDetail) -> String {
    media
        .title
        .as_ref()
        .and_then(|t| t.english.clone().or_else(|| t.romaji.clone()))
        .unwrap_or_default()
}

/// Resolve the cover image URL (prefer extra-large).
fn resolve_cover(media: &AniListMedia) -> Option<String> {
    media
        .cover_image
        .as_ref()
        .and_then(|c| c.extra_large.clone().or_else(|| c.large.clone()))
}

fn resolve_cover_detail(media: &AniListMediaDetail) -> Option<String> {
    media
        .cover_image
        .as_ref()
        .and_then(|c| c.extra_large.clone().or_else(|| c.large.clone()))
}

/// Convert AniList `averageScore` (0–100) to a 0–10 vote average.
fn score_to_vote_average(score: Option<i32>) -> Option<f64> {
    score.map(|s| s as f64 / 10.0)
}

/// Convert an ISO 3166-1 alpha-2 country code to a rough ISO 639-1 language code.
///
/// This is a best-effort mapping for common anime origins.
fn country_to_language(country: &str) -> String {
    match country {
        "JP" => "ja",
        "CN" => "zh",
        "KR" => "ko",
        "TW" => "zh",
        _ => country,
    }
    .to_string()
}

fn map_genres(genres: &Option<Vec<String>>) -> Vec<Genre> {
    genres
        .as_deref()
        .unwrap_or_default()
        .iter()
        .map(|g| Genre::from_anilist_genre(g))
        .collect()
}

// ── AniListMedia → UnifiedMovie ───────────────────────────────────────────────

/// Convert an AniList media entry to a [`UnifiedMovie`].
pub fn anilist_media_to_movie(m: AniListMedia) -> UnifiedMovie {
    UnifiedMovie {
        provider_id: MediaId::anilist(m.id as u64),
        title: resolve_title(&m),
        original_title: m.title.as_ref().and_then(|t| t.native.clone()),
        overview: m.description.clone(),
        release_date: m.start_date.as_ref().and_then(resolve_date),
        poster_url: resolve_cover(&m),
        backdrop_url: m.banner_image.clone(),
        genres: map_genres(&m.genres),
        popularity: m.popularity.map(|p| p as f64),
        vote_average: score_to_vote_average(m.average_score),
        // AniList exposes an average score but no vote count.
        vote_count: None,
        original_language: m.country_of_origin.as_deref().map(country_to_language),
        adult: m.is_adult,
    }
}

// ── AniListMedia → UnifiedTvShow ──────────────────────────────────────────────

/// Convert an AniList media entry to a [`UnifiedTvShow`].
pub fn anilist_media_to_tv(m: AniListMedia) -> UnifiedTvShow {
    let origin_country = m
        .country_of_origin
        .as_deref()
        .map(|c| vec![c.to_string()])
        .unwrap_or_default();
    let lang = m.country_of_origin.as_deref().map(country_to_language);

    UnifiedTvShow {
        provider_id: MediaId::anilist(m.id as u64),
        name: resolve_title(&m),
        original_name: m.title.as_ref().and_then(|t| t.native.clone()),
        overview: m.description.clone(),
        first_air_date: m.start_date.as_ref().and_then(resolve_date),
        poster_url: resolve_cover(&m),
        backdrop_url: m.banner_image.clone(),
        genres: map_genres(&m.genres),
        popularity: m.popularity.map(|p| p as f64),
        vote_average: score_to_vote_average(m.average_score),
        // AniList exposes an average score but no vote count.
        vote_count: None,
        original_language: lang,
        origin_country,
        adult: m.is_adult,
    }
}

// ── AniListMedia → UnifiedSearchResult ───────────────────────────────────────

/// Dispatch an AniList media entry to a [`UnifiedSearchResult`] based on its
/// `format`.
///
/// Shared unknown-media policy: an entry whose `format` is absent is **omitted**
/// (`None`) rather than coerced to an arbitrary type — the same rule TMDB
/// applies to an unrecognized `media_type`. Any present non-`MOVIE` AniList
/// format is a show.
pub fn anilist_media_to_search_result(m: AniListMedia) -> Option<UnifiedSearchResult> {
    match m.format.as_deref() {
        Some("MOVIE") => Some(UnifiedSearchResult::Movie(anilist_media_to_movie(m))),
        Some(_) => Some(UnifiedSearchResult::TvShow(anilist_media_to_tv(m))),
        None => None,
    }
}

// ── AniListMediaDetail → UnifiedMovieDetails ──────────────────────────────────

/// Convert an AniList media detail entry to [`UnifiedMovieDetails`].
pub fn anilist_media_detail_to_movie_details(m: AniListMediaDetail) -> UnifiedMovieDetails {
    let production_companies: Vec<Company> = m
        .studios
        .as_ref()
        .map(|s| {
            s.nodes
                .iter()
                .filter_map(|n| n.name.clone())
                .map(|name| Company {
                    id: None,
                    name,
                    logo_url: None,
                    origin_country: None,
                })
                .collect()
        })
        .unwrap_or_default();

    let origin_lang = m.country_of_origin.as_deref().map(country_to_language);
    let origin_country = m
        .country_of_origin
        .as_deref()
        .map(|c| vec![c.to_string()])
        .unwrap_or_default();

    let genres: Vec<Genre> = m
        .genres
        .as_deref()
        .unwrap_or_default()
        .iter()
        .map(|g| Genre::from_anilist_genre(g))
        .collect();

    UnifiedMovieDetails {
        movie: UnifiedMovie {
            provider_id: MediaId::anilist(m.id as u64),
            title: resolve_detail_title(&m),
            original_title: m.title.as_ref().and_then(|t| t.native.clone()),
            overview: m.description.clone(),
            release_date: m.start_date.as_ref().and_then(resolve_date),
            poster_url: resolve_cover_detail(&m),
            backdrop_url: m.banner_image.clone(),
            genres,
            popularity: m.popularity.map(|p| p as f64),
            vote_average: score_to_vote_average(m.average_score),
            vote_count: None,
            original_language: origin_lang,
            adult: m.is_adult,
        },
        tagline: None,
        runtime: m.duration.map(|d| d.max(0) as u32),
        budget: None,
        revenue: None,
        status: m.status.clone(),
        homepage: None,
        imdb_id: None,
        production_companies,
        production_countries: origin_country,
        spoken_languages: Vec::new(),
        video: false,
        belongs_to_collection: None,
    }
}

// ── AniListMediaDetail → UnifiedTvShowDetails ─────────────────────────────────

/// Convert an AniList media detail entry to [`UnifiedTvShowDetails`].
pub fn anilist_media_detail_to_tv_details(m: AniListMediaDetail) -> UnifiedTvShowDetails {
    let production_companies: Vec<Company> = m
        .studios
        .as_ref()
        .map(|s| {
            s.nodes
                .iter()
                .filter_map(|n| n.name.clone())
                .map(|name| Company {
                    id: None,
                    name,
                    logo_url: None,
                    origin_country: None,
                })
                .collect()
        })
        .unwrap_or_default();

    let origin_lang = m.country_of_origin.as_deref().map(country_to_language);
    let origin_country = m
        .country_of_origin
        .as_deref()
        .map(|c| vec![c.to_string()])
        .unwrap_or_default();

    let genres: Vec<Genre> = m
        .genres
        .as_deref()
        .unwrap_or_default()
        .iter()
        .map(|g| Genre::from_anilist_genre(g))
        .collect();

    let in_production = matches!(
        m.status.as_deref(),
        Some("RELEASING") | Some("NOT_YET_RELEASED")
    );
    let episode_run_time = m
        .duration
        .map(|d| vec![d.max(0) as u32])
        .unwrap_or_default();

    UnifiedTvShowDetails {
        show: UnifiedTvShow {
            provider_id: MediaId::anilist(m.id as u64),
            name: resolve_detail_title(&m),
            original_name: m.title.as_ref().and_then(|t| t.native.clone()),
            overview: m.description.clone(),
            first_air_date: m.start_date.as_ref().and_then(resolve_date),
            poster_url: resolve_cover_detail(&m),
            backdrop_url: m.banner_image.clone(),
            genres,
            popularity: m.popularity.map(|p| p as f64),
            vote_average: score_to_vote_average(m.average_score),
            vote_count: None,
            original_language: origin_lang,
            origin_country: origin_country.clone(),
            adult: m.is_adult,
        },
        tagline: None,
        // AniList models anime as a single continuous run, not TMDB-style seasons.
        number_of_seasons: None,
        number_of_episodes: m.episodes.map(|e| e.max(0) as u32),
        in_production,
        status: m.status,
        homepage: None,
        networks: Vec::new(),
        production_companies,
        last_air_date: m.end_date.as_ref().and_then(resolve_date),
        // AniList has no TMDB-style show classification; its format lives in `media_format`.
        type_: None,
        media_format: m.format,
        created_by: Vec::new(),
        episode_run_time,
        spoken_languages: Vec::new(),
        production_countries: origin_country,
        seasons: Vec::new(),
    }
}

// ── Tests ─────────────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;
    use crate::providers::anilist::response::AniListMedia;

    fn make_media(id: i32, format: Option<&str>) -> AniListMedia {
        AniListMedia {
            id,
            title: None,
            description: None,
            start_date: None,
            cover_image: None,
            banner_image: None,
            genres: None,
            popularity: None,
            average_score: None,
            format: format.map(String::from),
            country_of_origin: None,
            is_adult: None,
        }
    }

    #[test]
    fn test_score_conversion() {
        assert_eq!(score_to_vote_average(Some(85)), Some(8.5));
        assert_eq!(score_to_vote_average(Some(100)), Some(10.0));
        assert_eq!(score_to_vote_average(None), None);
    }

    #[test]
    fn search_result_movie_format() {
        let result = anilist_media_to_search_result(make_media(1, Some("MOVIE")));
        assert!(matches!(result, Some(UnifiedSearchResult::Movie(_))));
    }

    #[test]
    fn search_result_tv_format() {
        let result = anilist_media_to_search_result(make_media(2, Some("TV")));
        assert!(matches!(result, Some(UnifiedSearchResult::TvShow(_))));
    }

    #[test]
    fn search_result_other_present_format_is_a_show() {
        // Any present non-MOVIE format classifies as a show.
        let result = anilist_media_to_search_result(make_media(3, Some("OVA")));
        assert!(matches!(result, Some(UnifiedSearchResult::TvShow(_))));
    }

    #[test]
    fn search_result_absent_format_is_dropped() {
        // Shared unknown-media policy: no format -> omitted, not coerced.
        let result = anilist_media_to_search_result(make_media(4, None));
        assert!(result.is_none());
    }
}