use std::sync::Arc;
use crate::error::{Error, Result};
use crate::merge::merge_canonical_episodes_by_effective_number;
use crate::model::{
CanonicalEpisode, CanonicalMedia, ExternalId, MediaKind, SearchOptions, SourceName,
};
use crate::provider::{
AniListProvider, ImdbProvider, JikanProvider, KitsuProvider, Provider, ProviderRegistry,
TvmazeProvider, default_registry,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RemoteSource {
AniList,
Jikan,
Kitsu,
Tvmaze,
Imdb,
}
impl RemoteSource {
pub fn as_str(self) -> &'static str {
match self {
Self::AniList => "anilist",
Self::Jikan => "jikan",
Self::Kitsu => "kitsu",
Self::Tvmaze => "tvmaze",
Self::Imdb => "imdb",
}
}
}
pub struct RemoteApi {
provider: Arc<dyn Provider>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EpisodeFetchCandidate {
pub source: SourceName,
pub source_id: String,
}
impl Default for RemoteApi {
fn default() -> Self {
Self::anilist()
}
}
impl RemoteApi {
pub fn anilist() -> Self {
Self::with_provider(AniListProvider::new())
}
pub fn jikan() -> Self {
Self::with_provider(JikanProvider::new())
}
pub fn kitsu() -> Self {
Self::with_provider(KitsuProvider::new())
}
pub fn tvmaze() -> Self {
Self::with_provider(TvmazeProvider::new())
}
pub fn imdb() -> Self {
Self::with_provider(ImdbProvider::new())
}
pub fn with_provider<P: Provider + 'static>(provider: P) -> Self {
Self {
provider: Arc::new(provider),
}
}
pub fn provider(&self) -> &dyn Provider {
self.provider.as_ref()
}
pub fn search(&self, query: &str, options: SearchOptions) -> Result<Vec<CanonicalMedia>> {
self.provider.search(query, options)
}
pub fn fetch_trending(&self, media_kind: MediaKind) -> Result<Vec<CanonicalMedia>> {
self.provider.fetch_trending(media_kind)
}
pub fn fetch_recommendations(
&self,
media_kind: MediaKind,
source_id: &str,
) -> Result<Vec<CanonicalMedia>> {
self.provider.fetch_recommendations(media_kind, source_id)
}
pub fn fetch_related(
&self,
media_kind: MediaKind,
source_id: &str,
) -> Result<Vec<CanonicalMedia>> {
self.provider.fetch_related(media_kind, source_id)
}
pub fn fetch_episodes(
&self,
media_kind: MediaKind,
source_id: &str,
) -> Result<Vec<CanonicalEpisode>> {
self.provider.fetch_episodes(media_kind, source_id)
}
pub fn episode_fetch_candidates(
media_kind: MediaKind,
external_ids: &[ExternalId],
) -> Vec<EpisodeFetchCandidate> {
let mut candidates = Vec::new();
fn push_unique(
candidates: &mut Vec<EpisodeFetchCandidate>,
source: SourceName,
source_id: &str,
) {
if !candidates
.iter()
.any(|item| item.source == source && item.source_id == source_id)
{
candidates.push(EpisodeFetchCandidate {
source,
source_id: source_id.to_string(),
});
}
}
for external_id in external_ids {
match (media_kind, external_id.source) {
(MediaKind::Anime, SourceName::Jikan | SourceName::MyAnimeList) => {
push_unique(&mut candidates, SourceName::Jikan, &external_id.source_id);
}
(MediaKind::Anime, SourceName::Kitsu) => {
push_unique(&mut candidates, SourceName::Kitsu, &external_id.source_id);
}
(MediaKind::Show, SourceName::Tvmaze) => {
push_unique(&mut candidates, SourceName::Tvmaze, &external_id.source_id);
}
_ => {}
}
}
candidates
}
pub fn fetch_episodes_from_external_ids(
media_kind: MediaKind,
external_ids: &[ExternalId],
) -> Result<Vec<CanonicalEpisode>> {
let candidates = Self::episode_fetch_candidates(media_kind, external_ids);
Self::fetch_episodes_from_candidates(media_kind, &candidates)
}
pub fn fetch_merged_episodes_from_external_ids(
media_kind: MediaKind,
external_ids: &[ExternalId],
) -> Result<Vec<CanonicalEpisode>> {
let episodes = Self::fetch_episodes_from_external_ids(media_kind, external_ids)?;
Ok(merge_canonical_episodes_by_effective_number(&episodes))
}
pub fn fetch_episodes_from_candidates(
media_kind: MediaKind,
candidates: &[EpisodeFetchCandidate],
) -> Result<Vec<CanonicalEpisode>> {
let registry = default_registry();
Self::fetch_episodes_from_candidates_with_registry(media_kind, candidates, ®istry)
}
pub fn fetch_merged_episodes_from_candidates(
media_kind: MediaKind,
candidates: &[EpisodeFetchCandidate],
) -> Result<Vec<CanonicalEpisode>> {
let episodes = Self::fetch_episodes_from_candidates(media_kind, candidates)?;
Ok(merge_canonical_episodes_by_effective_number(&episodes))
}
pub fn fetch_episodes_from_candidates_with_registry(
media_kind: MediaKind,
candidates: &[EpisodeFetchCandidate],
registry: &ProviderRegistry,
) -> Result<Vec<CanonicalEpisode>> {
let mut episodes = Vec::new();
let mut failures = Vec::new();
for candidate in candidates {
match registry
.get(candidate.source)
.and_then(|provider| provider.fetch_episodes(media_kind, &candidate.source_id))
{
Ok(mut fetched) => episodes.append(&mut fetched),
Err(err) => failures.push(format!(
"{}:{} failed: {err}",
candidate.source, candidate.source_id
)),
}
}
if episodes.is_empty() && !failures.is_empty() {
return Err(Error::Validation(format!(
"episode aggregation failed for all candidates: {}",
failures.join("; ")
)));
}
Ok(episodes)
}
pub fn fetch_merged_episodes_from_candidates_with_registry(
media_kind: MediaKind,
candidates: &[EpisodeFetchCandidate],
registry: &ProviderRegistry,
) -> Result<Vec<CanonicalEpisode>> {
let episodes =
Self::fetch_episodes_from_candidates_with_registry(media_kind, candidates, registry)?;
Ok(merge_canonical_episodes_by_effective_number(&episodes))
}
pub fn anime_metadata(&self) -> RemoteCollection {
RemoteCollection::new(
Arc::clone(&self.provider),
SearchOptions::default().with_media_kind(MediaKind::Anime),
)
}
pub fn manga_metadata(&self) -> RemoteCollection {
RemoteCollection::new(
Arc::clone(&self.provider),
SearchOptions::default().with_media_kind(MediaKind::Manga),
)
}
pub fn movie_metadata(&self) -> RemoteCollection {
RemoteCollection::new(
Arc::clone(&self.provider),
SearchOptions::default()
.with_media_kind(MediaKind::Anime)
.with_format("MOVIE"),
)
}
pub fn show_metadata(&self) -> RemoteCollection {
RemoteCollection::new(
Arc::clone(&self.provider),
SearchOptions::default().with_media_kind(MediaKind::Show),
)
}
pub fn tv_movie_metadata(&self) -> RemoteCollection {
RemoteCollection::new(
Arc::clone(&self.provider),
SearchOptions::default().with_media_kind(MediaKind::Movie),
)
}
}
pub struct RemoteCollection {
provider: Arc<dyn Provider>,
options: SearchOptions,
}
impl RemoteCollection {
fn new(provider: Arc<dyn Provider>, options: SearchOptions) -> Self {
Self { provider, options }
}
pub fn options(&self) -> &SearchOptions {
&self.options
}
pub fn search(&self, query: &str) -> Result<Vec<CanonicalMedia>> {
self.provider.search(query, self.options.clone())
}
pub fn by_id(&self, source_id: &str) -> Result<Option<CanonicalMedia>> {
let kind = self.options.media_kind.unwrap_or(MediaKind::Anime);
let item = self.provider.get_by_id(kind, source_id)?;
Ok(item.filter(|m| {
self.options
.format
.as_ref()
.map(|fmt| m.format.as_ref().map(|v| v.eq_ignore_ascii_case(fmt)) == Some(true))
.unwrap_or(true)
}))
}
pub fn trending(&self) -> Result<Vec<CanonicalMedia>> {
let kind = self.options.media_kind.unwrap_or(MediaKind::Anime);
self.provider.fetch_trending(kind)
}
pub fn recommendations(&self, source_id: &str) -> Result<Vec<CanonicalMedia>> {
let kind = self.options.media_kind.unwrap_or(MediaKind::Anime);
self.provider.fetch_recommendations(kind, source_id)
}
pub fn related(&self, source_id: &str) -> Result<Vec<CanonicalMedia>> {
let kind = self.options.media_kind.unwrap_or(MediaKind::Anime);
self.provider.fetch_related(kind, source_id)
}
pub fn episodes(&self, source_id: &str) -> Result<Vec<CanonicalEpisode>> {
let kind = self.options.media_kind.unwrap_or(MediaKind::Anime);
self.provider.fetch_episodes(kind, source_id)
}
}
impl From<RemoteSource> for RemoteApi {
fn from(source: RemoteSource) -> Self {
match source {
RemoteSource::AniList => Self::anilist(),
RemoteSource::Jikan => Self::jikan(),
RemoteSource::Kitsu => Self::kitsu(),
RemoteSource::Tvmaze => Self::tvmaze(),
RemoteSource::Imdb => Self::imdb(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{CanonicalEpisode, SyncCursor, SyncRequest};
use crate::provider::FetchPage;
use std::sync::Mutex;
#[test]
fn default_remote_api_uses_anilist() {
let api = RemoteApi::default();
assert_eq!(api.provider().source(), SourceName::AniList);
}
#[test]
fn movie_collection_defaults_to_anime_movie_format() {
let col = RemoteApi::jikan().movie_metadata();
assert_eq!(col.options().media_kind, Some(MediaKind::Anime));
assert_eq!(col.options().format.as_deref(), Some("MOVIE"));
}
#[test]
fn kitsu_provider_has_correct_source() {
let api = RemoteApi::kitsu();
assert_eq!(api.provider().source(), SourceName::Kitsu);
}
#[test]
fn custom_provider_via_with_provider() {
let api = RemoteApi::with_provider(TvmazeProvider::new());
assert_eq!(api.provider().source(), SourceName::Tvmaze);
}
#[test]
fn episode_fetch_candidates_map_external_ids_to_episode_providers() {
let external_ids = vec![
ExternalId {
source: SourceName::AniList,
source_id: "1".into(),
url: None,
},
ExternalId {
source: SourceName::MyAnimeList,
source_id: "19".into(),
url: None,
},
ExternalId {
source: SourceName::Jikan,
source_id: "19".into(),
url: None,
},
ExternalId {
source: SourceName::Kitsu,
source_id: "42".into(),
url: None,
},
];
let candidates = RemoteApi::episode_fetch_candidates(MediaKind::Anime, &external_ids);
assert_eq!(
candidates,
vec![
EpisodeFetchCandidate {
source: SourceName::Jikan,
source_id: "19".into()
},
EpisodeFetchCandidate {
source: SourceName::Kitsu,
source_id: "42".into()
}
]
);
}
#[test]
fn episode_aggregation_collects_successes_and_ignores_failed_candidates() {
struct FakeProvider {
source: SourceName,
calls: Mutex<Vec<String>>,
}
impl Provider for FakeProvider {
fn source(&self) -> SourceName {
self.source
}
fn fetch_page(&self, _request: &SyncRequest, _cursor: SyncCursor) -> Result<FetchPage> {
Ok(FetchPage {
items: Vec::new(),
next_cursor: None,
})
}
fn search(&self, _query: &str, _options: SearchOptions) -> Result<Vec<CanonicalMedia>> {
Ok(Vec::new())
}
fn get_by_id(
&self,
_media_kind: MediaKind,
_source_id: &str,
) -> Result<Option<CanonicalMedia>> {
Ok(None)
}
fn fetch_episodes(
&self,
media_kind: MediaKind,
source_id: &str,
) -> Result<Vec<CanonicalEpisode>> {
self.calls.lock().unwrap().push(source_id.to_string());
if self.source == SourceName::Kitsu {
return Err(Error::Validation("test failure".into()));
}
Ok(vec![CanonicalEpisode {
source: self.source,
source_id: format!("ep-{source_id}"),
media_kind,
season_number: Some(1),
episode_number: Some(1),
absolute_number: Some(1),
title_display: Some("Episode 1".into()),
title_original: None,
synopsis: None,
air_date: None,
runtime_minutes: None,
thumbnail_url: None,
raw_titles_json: None,
raw_json: None,
}])
}
}
let mut registry = ProviderRegistry::new();
registry.register(Arc::new(FakeProvider {
source: SourceName::Jikan,
calls: Mutex::new(Vec::new()),
}));
registry.register(Arc::new(FakeProvider {
source: SourceName::Kitsu,
calls: Mutex::new(Vec::new()),
}));
let candidates = vec![
EpisodeFetchCandidate {
source: SourceName::Jikan,
source_id: "19".into(),
},
EpisodeFetchCandidate {
source: SourceName::Kitsu,
source_id: "42".into(),
},
];
let episodes = RemoteApi::fetch_episodes_from_candidates_with_registry(
MediaKind::Anime,
&candidates,
®istry,
)
.unwrap();
assert_eq!(episodes.len(), 1);
assert_eq!(episodes[0].source, SourceName::Jikan);
}
#[test]
fn merged_episode_fetch_deduplicates_provider_results() {
struct FakeProvider {
source: SourceName,
}
impl Provider for FakeProvider {
fn source(&self) -> SourceName {
self.source
}
fn fetch_page(&self, _request: &SyncRequest, _cursor: SyncCursor) -> Result<FetchPage> {
Ok(FetchPage {
items: Vec::new(),
next_cursor: None,
})
}
fn search(&self, _query: &str, _options: SearchOptions) -> Result<Vec<CanonicalMedia>> {
Ok(Vec::new())
}
fn get_by_id(
&self,
_media_kind: MediaKind,
_source_id: &str,
) -> Result<Option<CanonicalMedia>> {
Ok(None)
}
fn fetch_episodes(
&self,
media_kind: MediaKind,
source_id: &str,
) -> Result<Vec<CanonicalEpisode>> {
let mut episode = CanonicalEpisode {
source: self.source,
source_id: format!("ep-{source_id}"),
media_kind,
season_number: Some(1),
episode_number: Some(1),
absolute_number: Some(1),
title_display: Some(format!("{} title", self.source)),
title_original: None,
synopsis: None,
air_date: None,
runtime_minutes: None,
thumbnail_url: None,
raw_titles_json: None,
raw_json: None,
};
if self.source == SourceName::Kitsu {
episode.runtime_minutes = Some(24);
}
Ok(vec![episode])
}
}
let mut registry = ProviderRegistry::new();
registry.register(Arc::new(FakeProvider {
source: SourceName::Jikan,
}));
registry.register(Arc::new(FakeProvider {
source: SourceName::Kitsu,
}));
let candidates = vec![
EpisodeFetchCandidate {
source: SourceName::Jikan,
source_id: "19".into(),
},
EpisodeFetchCandidate {
source: SourceName::Kitsu,
source_id: "42".into(),
},
];
let episodes = RemoteApi::fetch_merged_episodes_from_candidates_with_registry(
MediaKind::Anime,
&candidates,
®istry,
)
.unwrap();
assert_eq!(episodes.len(), 1);
assert_eq!(episodes[0].source, SourceName::Jikan);
assert_eq!(episodes[0].title_display.as_deref(), Some("jikan title"));
assert_eq!(episodes[0].runtime_minutes, Some(24));
}
}