nako-metadata-scraper 0.1.0-alpha.2

Official Nako metadata scraper Addon Sidecar.
Documentation
use serde::{Deserialize, Serialize};

use crate::engine::{MetadataQuery, ProviderMetadataCandidate};

use super::{BANGUMI_PROVIDER_ID, mapper::BangumiSubjectCandidate};

#[derive(Debug, Serialize)]
pub(super) struct BangumiSubjectSearchRequest {
    pub(super) keyword: String,
    pub(super) sort: &'static str,
    pub(super) filter: BangumiSubjectSearchFilter,
}

#[derive(Debug, Serialize)]
pub(super) struct BangumiSubjectSearchFilter {
    #[serde(rename = "type")]
    pub(super) subject_type: Vec<u8>,
    pub(super) nsfw: bool,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub(super) air_date: Option<[String; 2]>,
}

#[derive(Debug, Deserialize)]
pub(super) struct BangumiSubjectSearchResponse {
    #[serde(default)]
    pub(super) data: Vec<BangumiSubject>,
}

impl BangumiSubjectSearchResponse {
    pub(super) fn from_value(value: serde_json::Value) -> anyhow::Result<Self> {
        let items = value
            .get("data")
            .ok_or_else(|| {
                anyhow::anyhow!(
                    "failed to parse Bangumi subject search response: missing field `data`"
                )
            })?
            .as_array()
            .ok_or_else(|| {
                anyhow::anyhow!(
                    "failed to parse Bangumi subject search response: `data` must be an array"
                )
            })?;
        let mut skipped_count = 0usize;
        let data = items
            .iter()
            .filter_map(|item| match serde_json::from_value::<BangumiSubject>(item.clone()) {
                Ok(subject) if subject.id > 0 => Some(subject),
                Ok(_) => {
                    skipped_count += 1;
                    tracing::warn!(
                        provider = BANGUMI_PROVIDER_ID,
                        "skipping Bangumi search subject item with zero id"
                    );
                    None
                }
                Err(error) => {
                    skipped_count += 1;
                    tracing::warn!(provider = BANGUMI_PROVIDER_ID, %error, "skipping malformed Bangumi search subject item");
                    None
                }
            })
            .collect();
        if !items.is_empty() && skipped_count == items.len() {
            anyhow::bail!("all Bangumi search subject items were malformed");
        }
        Ok(Self { data })
    }
}

#[derive(Clone, Debug, Default, Deserialize)]
pub(super) struct BangumiSubject {
    pub(super) id: u64,
    #[serde(rename = "type")]
    pub(super) subject_type: Option<u8>,
    pub(super) name: Option<String>,
    pub(super) name_cn: Option<String>,
    #[serde(alias = "air_date")]
    pub(super) date: Option<String>,
    #[serde(alias = "short_summary")]
    pub(super) summary: Option<String>,
    pub(super) platform: Option<String>,
    pub(super) images: Option<BangumiImages>,
    pub(super) nsfw: Option<bool>,
    pub(super) locked: Option<bool>,
    pub(super) series: Option<bool>,
    pub(super) volumes: Option<u32>,
    pub(super) eps: Option<u32>,
    pub(super) total_episodes: Option<u32>,
    pub(super) air_weekday: Option<u8>,
    pub(super) rank: Option<u32>,
    pub(super) score: Option<f64>,
    pub(super) rating: Option<BangumiRating>,
    pub(super) collection_total: Option<u32>,
    pub(super) collection: Option<BangumiSubjectCollection>,
    #[serde(default)]
    pub(super) infobox: Vec<BangumiInfoboxItem>,
    #[serde(default)]
    pub(super) meta_tags: Vec<String>,
    #[serde(default)]
    pub(super) tags: Vec<BangumiTag>,
}

impl BangumiSubject {
    pub(super) fn from_value(value: serde_json::Value) -> anyhow::Result<Self> {
        serde_json::from_value(value)
            .map_err(|error| anyhow::anyhow!("failed to parse Bangumi subject response: {error}"))
    }

    pub(super) fn into_degraded_candidate(
        self,
        query: &MetadataQuery,
    ) -> ProviderMetadataCandidate {
        BangumiSubjectCandidate {
            search: self.clone(),
            detail: self,
            degraded: true,
        }
        .into_candidate(query)
    }
}

#[derive(Clone, Debug, Default, Deserialize)]
pub(super) struct BangumiImages {
    pub(super) large: Option<String>,
    pub(super) common: Option<String>,
    pub(super) medium: Option<String>,
    pub(super) small: Option<String>,
    pub(super) grid: Option<String>,
}

#[derive(Clone, Debug, Deserialize)]
pub(super) struct BangumiRating {
    pub(super) rank: Option<u32>,
    pub(super) total: Option<u32>,
    pub(super) score: Option<f64>,
}

#[derive(Clone, Debug, Default, Deserialize)]
pub(super) struct BangumiSubjectCollection {
    pub(super) wish: Option<u32>,
    pub(super) collect: Option<u32>,
    pub(super) doing: Option<u32>,
    pub(super) on_hold: Option<u32>,
    pub(super) dropped: Option<u32>,
}

impl BangumiSubjectCollection {
    pub(super) fn total(&self) -> Option<u32> {
        let total = self
            .wish
            .unwrap_or_default()
            .saturating_add(self.collect.unwrap_or_default())
            .saturating_add(self.doing.unwrap_or_default())
            .saturating_add(self.on_hold.unwrap_or_default())
            .saturating_add(self.dropped.unwrap_or_default());
        (total > 0).then_some(total)
    }
}

#[derive(Clone, Debug, Deserialize)]
pub(super) struct BangumiTag {
    pub(super) name: Option<String>,
    pub(super) count: Option<u32>,
}

#[derive(Clone, Debug, Deserialize)]
pub(super) struct BangumiInfoboxItem {
    pub(super) key: Option<String>,
    #[serde(default)]
    pub(super) value: serde_json::Value,
}