animedb 0.1.0

Local-first anime and manga metadata catalog for Rust media servers
Documentation
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::fmt;
use std::str::FromStr;

use crate::error::{Error, Result};

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]
#[serde(rename_all = "snake_case")]
pub enum MediaKind {
    Anime,
    Manga,
}

impl MediaKind {
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Anime => "anime",
            Self::Manga => "manga",
        }
    }
}

impl fmt::Display for MediaKind {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(self.as_str())
    }
}

impl FromStr for MediaKind {
    type Err = Error;

    fn from_str(value: &str) -> Result<Self> {
        match value {
            "anime" | "ANIME" => Ok(Self::Anime),
            "manga" | "MANGA" => Ok(Self::Manga),
            other => Err(Error::Validation(format!(
                "unsupported media kind: {other}"
            ))),
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]
#[serde(rename_all = "snake_case")]
pub enum SourceName {
    AniList,
    MyAnimeList,
    Jikan,
    Kitsu,
}

impl SourceName {
    pub fn as_str(self) -> &'static str {
        match self {
            Self::AniList => "anilist",
            Self::MyAnimeList => "myanimelist",
            Self::Jikan => "jikan",
            Self::Kitsu => "kitsu",
        }
    }
}

impl fmt::Display for SourceName {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(self.as_str())
    }
}

impl FromStr for SourceName {
    type Err = Error;

    fn from_str(value: &str) -> Result<Self> {
        match value {
            "anilist" => Ok(Self::AniList),
            "myanimelist" => Ok(Self::MyAnimeList),
            "jikan" => Ok(Self::Jikan),
            "kitsu" => Ok(Self::Kitsu),
            other => Err(Error::Validation(format!("unsupported source: {other}"))),
        }
    }
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ExternalId {
    pub source: SourceName,
    pub source_id: String,
    pub url: Option<String>,
}

impl ExternalId {
    pub fn is_strong_identity(&self) -> bool {
        matches!(self.source, SourceName::MyAnimeList | SourceName::AniList)
    }
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SourcePayload {
    pub source: SourceName,
    pub source_id: String,
    pub url: Option<String>,
    pub remote_updated_at: Option<String>,
    pub raw_json: Option<Value>,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CanonicalMedia {
    pub media_kind: MediaKind,
    pub title_display: String,
    pub title_romaji: Option<String>,
    pub title_english: Option<String>,
    pub title_native: Option<String>,
    pub synopsis: Option<String>,
    pub format: Option<String>,
    pub status: Option<String>,
    pub season: Option<String>,
    pub season_year: Option<i32>,
    pub episodes: Option<i32>,
    pub chapters: Option<i32>,
    pub volumes: Option<i32>,
    pub country_of_origin: Option<String>,
    pub cover_image: Option<String>,
    pub banner_image: Option<String>,
    pub provider_rating: Option<f64>,
    pub nsfw: bool,
    pub aliases: Vec<String>,
    pub genres: Vec<String>,
    pub tags: Vec<String>,
    pub external_ids: Vec<ExternalId>,
    pub source_payloads: Vec<SourcePayload>,
    pub field_provenance: Vec<FieldProvenance>,
}

impl CanonicalMedia {
    pub fn validate(&self) -> Result<()> {
        if self.title_display.trim().is_empty() {
            return Err(Error::Validation("title_display cannot be empty".into()));
        }

        if self.external_ids.is_empty() {
            return Err(Error::Validation(
                "at least one external id is required to persist media".into(),
            ));
        }

        Ok(())
    }

    pub fn name(&self) -> &str {
        &self.title_display
    }
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct StoredMedia {
    pub id: i64,
    pub media_kind: MediaKind,
    pub title_display: String,
    pub title_romaji: Option<String>,
    pub title_english: Option<String>,
    pub title_native: Option<String>,
    pub synopsis: Option<String>,
    pub format: Option<String>,
    pub status: Option<String>,
    pub season: Option<String>,
    pub season_year: Option<i32>,
    pub episodes: Option<i32>,
    pub chapters: Option<i32>,
    pub volumes: Option<i32>,
    pub country_of_origin: Option<String>,
    pub cover_image: Option<String>,
    pub banner_image: Option<String>,
    pub provider_rating: Option<f64>,
    pub nsfw: bool,
    pub aliases: Vec<String>,
    pub genres: Vec<String>,
    pub tags: Vec<String>,
    pub external_ids: Vec<ExternalId>,
    pub source_payloads: Vec<SourcePayload>,
    pub field_provenance: Vec<FieldProvenance>,
}

impl StoredMedia {
    pub fn name(&self) -> &str {
        &self.title_display
    }
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct FieldProvenance {
    pub field_name: String,
    pub source: SourceName,
    pub source_id: String,
    pub score: f64,
    pub reason: String,
    pub updated_at: String,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SearchOptions {
    pub limit: usize,
    pub offset: usize,
    pub media_kind: Option<MediaKind>,
    pub format: Option<String>,
}

impl Default for SearchOptions {
    fn default() -> Self {
        Self {
            limit: 20,
            offset: 0,
            media_kind: None,
            format: None,
        }
    }
}

impl SearchOptions {
    pub fn with_limit(mut self, limit: usize) -> Self {
        self.limit = limit;
        self
    }

    pub fn with_offset(mut self, offset: usize) -> Self {
        self.offset = offset;
        self
    }

    pub fn with_media_kind(mut self, media_kind: MediaKind) -> Self {
        self.media_kind = Some(media_kind);
        self
    }

    pub fn with_format(mut self, format: impl Into<String>) -> Self {
        self.format = Some(format.into());
        self
    }
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SearchHit {
    pub media_id: i64,
    pub media_kind: MediaKind,
    pub title_display: String,
    pub synopsis: Option<String>,
    pub score: f64,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SyncMode {
    Full,
    Incremental,
}

impl SyncMode {
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Full => "full",
            Self::Incremental => "incremental",
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SyncCursor {
    pub page: usize,
}

impl Default for SyncCursor {
    fn default() -> Self {
        Self { page: 1 }
    }
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SyncRequest {
    pub source: SourceName,
    pub media_kind: Option<MediaKind>,
    pub mode: SyncMode,
    pub page_size: usize,
    pub max_pages: Option<usize>,
    pub start_cursor: Option<SyncCursor>,
}

impl SyncRequest {
    pub fn new(source: SourceName) -> Self {
        Self {
            source,
            media_kind: None,
            mode: SyncMode::Full,
            page_size: 50,
            max_pages: None,
            start_cursor: None,
        }
    }

    pub fn with_media_kind(mut self, media_kind: MediaKind) -> Self {
        self.media_kind = Some(media_kind);
        self
    }

    pub fn with_mode(mut self, mode: SyncMode) -> Self {
        self.mode = mode;
        self
    }

    pub fn with_page_size(mut self, page_size: usize) -> Self {
        self.page_size = page_size;
        self
    }

    pub fn with_max_pages(mut self, max_pages: usize) -> Self {
        self.max_pages = Some(max_pages);
        self
    }

    pub fn with_start_cursor(mut self, cursor: SyncCursor) -> Self {
        self.start_cursor = Some(cursor);
        self
    }
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SyncOutcome {
    pub source: SourceName,
    pub media_kind: Option<MediaKind>,
    pub fetched_pages: usize,
    pub upserted_records: usize,
    pub last_cursor: Option<SyncCursor>,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SyncReport {
    pub outcomes: Vec<SyncOutcome>,
    pub total_upserted_records: usize,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PersistedSyncState {
    pub source: SourceName,
    pub scope: String,
    pub cursor: Option<SyncCursor>,
    pub last_success_at: Option<String>,
    pub last_error: Option<String>,
    pub last_page: Option<i64>,
    pub mode: SyncMode,
}