Skip to main content

animedb_api/
lib.rs

1//! GraphQL service layer for the `animedb` catalog crate.
2//!
3//! This crate exposes:
4//!
5//! - a reusable GraphQL schema builder via [`build_schema`]
6//! - an Axum router via [`build_router`]
7//! - a binary entry point in `main.rs` for running the service directly
8
9use animedb::{
10    AniListProvider, AnimeDb, CanonicalMedia, FieldProvenance, ImdbProvider, JikanProvider,
11    KitsuProvider, MediaKind, PersistedSyncState, RemoteCatalog, SearchHit, SearchOptions,
12    SourceName, StoredMedia, SyncMode, SyncReport, SyncRequest, TvmazeProvider,
13};
14use async_graphql::http::{GraphQLPlaygroundConfig, playground_source};
15use async_graphql::{
16    Context, EmptySubscription, Enum, InputObject, Object, Result as GraphQLResult, Schema,
17    SimpleObject,
18};
19use async_graphql_axum::{GraphQLRequest, GraphQLResponse};
20use axum::extract::State;
21use axum::response::{Html, IntoResponse};
22use axum::routing::{get, post};
23use axum::{Json, Router};
24use std::path::PathBuf;
25
26pub type AnimeDbSchema = Schema<QueryRoot, MutationRoot, EmptySubscription>;
27
28/// Shared application state injected into the GraphQL schema.
29#[derive(Clone)]
30pub struct AppState {
31    pub database_path: PathBuf,
32}
33
34/// Builds the GraphQL schema backed by one SQLite database path.
35pub fn build_schema(database_path: impl Into<PathBuf>) -> AnimeDbSchema {
36    Schema::build(QueryRoot, MutationRoot, EmptySubscription)
37        .data(AppState {
38            database_path: database_path.into(),
39        })
40        .finish()
41}
42
43/// Builds the Axum router that serves Playground, GraphQL, and health endpoints.
44pub fn build_router(schema: AnimeDbSchema) -> Router {
45    Router::new()
46        .route("/", get(playground).post(graphql_handler))
47        .route("/graphql", post(graphql_handler))
48        .route("/healthz", get(healthz))
49        .with_state(schema)
50}
51
52pub struct QueryRoot;
53
54#[Object]
55impl QueryRoot {
56    async fn health(&self) -> &'static str {
57        "ok"
58    }
59
60    async fn media(&self, ctx: &Context<'_>, id: i64) -> GraphQLResult<Option<MediaObject>> {
61        let state = ctx.data_unchecked::<AppState>().clone();
62        let media = tokio::task::spawn_blocking(move || {
63            let db = AnimeDb::open(&state.database_path)?;
64            db.media().get_media(id).map(MediaObject::from).map(Some)
65        })
66        .await
67        .map_err(join_error)?
68        .or_else(not_found_is_none)?;
69
70        Ok(media)
71    }
72
73    async fn media_by_external_id(
74        &self,
75        ctx: &Context<'_>,
76        source: SourceNameObject,
77        source_id: String,
78    ) -> GraphQLResult<Option<MediaObject>> {
79        let state = ctx.data_unchecked::<AppState>().clone();
80        let source = source.into_model();
81        let media = tokio::task::spawn_blocking(move || {
82            let db = AnimeDb::open(&state.database_path)?;
83            db.media()
84                .get_by_external_id(source, &source_id)
85                .map(MediaObject::from)
86                .map(Some)
87        })
88        .await
89        .map_err(join_error)?
90        .or_else(not_found_is_none)?;
91
92        Ok(media)
93    }
94
95    async fn search(
96        &self,
97        ctx: &Context<'_>,
98        query: String,
99        options: Option<SearchInput>,
100    ) -> GraphQLResult<Vec<SearchHitObject>> {
101        let state = ctx.data_unchecked::<AppState>().clone();
102        let options = options.unwrap_or_default().into_model();
103        let hits = tokio::task::spawn_blocking(move || {
104            let db = AnimeDb::open(&state.database_path)?;
105            db.search_repo()
106                .search(&query, options)
107                .map(|items| items.into_iter().map(SearchHitObject::from).collect())
108        })
109        .await
110        .map_err(join_error)??;
111
112        Ok(hits)
113    }
114
115    async fn sync_state(
116        &self,
117        ctx: &Context<'_>,
118        source: SourceNameObject,
119        scope: String,
120    ) -> GraphQLResult<Option<SyncStateObject>> {
121        let state = ctx.data_unchecked::<AppState>().clone();
122        let source = source.into_model();
123        let sync_state = tokio::task::spawn_blocking(move || {
124            let db = AnimeDb::open(&state.database_path)?;
125            db.sync_state()
126                .load_sync_state(source, &scope)
127                .map(SyncStateObject::from)
128                .map(Some)
129        })
130        .await
131        .map_err(join_error)?
132        .or_else(not_found_is_none)?;
133
134        Ok(sync_state)
135    }
136
137    async fn remote_search(
138        &self,
139        source: SourceNameObject,
140        query: String,
141        options: Option<SearchInput>,
142    ) -> GraphQLResult<Vec<MediaObject>> {
143        let options = options.unwrap_or_default().into_model();
144        let media = tokio::task::spawn_blocking(move || search_remote(source, &query, options))
145            .await
146            .map_err(join_error)??;
147
148        Ok(media)
149    }
150
151    async fn remote_media(
152        &self,
153        source: SourceNameObject,
154        source_id: String,
155        media_kind: MediaKindObject,
156    ) -> GraphQLResult<Option<MediaObject>> {
157        let media =
158            tokio::task::spawn_blocking(move || get_remote_media(source, &source_id, media_kind))
159                .await
160                .map_err(join_error)??;
161
162        Ok(media)
163    }
164}
165
166pub struct MutationRoot;
167
168#[Object]
169impl MutationRoot {
170    async fn generate_database(
171        &self,
172        ctx: &Context<'_>,
173        max_pages: Option<i32>,
174    ) -> GraphQLResult<SyncReportObject> {
175        let state = ctx.data_unchecked::<AppState>().clone();
176        let max_pages = max_pages.map(|value| value.max(1) as usize);
177
178        let report = tokio::task::spawn_blocking(move || {
179            let mut db = AnimeDb::open(&state.database_path)?;
180            if let Some(max_pages) = max_pages {
181                sync_with_max_pages(&mut db, max_pages)
182            } else {
183                db.sync_default_sources()
184            }
185        })
186        .await
187        .map_err(join_error)??;
188
189        Ok(report.into())
190    }
191
192    async fn sync_database(
193        &self,
194        ctx: &Context<'_>,
195        input: Option<SyncInput>,
196    ) -> GraphQLResult<SyncReportObject> {
197        let state = ctx.data_unchecked::<AppState>().clone();
198        let input = input.unwrap_or_default();
199
200        let report = tokio::task::spawn_blocking(move || {
201            let mut db = AnimeDb::open(&state.database_path)?;
202            if let Some(source) = input.source {
203                let mut request = SyncRequest::new(source.into_model());
204                if let Some(kind) = input.media_kind {
205                    request = request.with_media_kind(kind.into_model());
206                }
207                if let Some(page_size) = input.page_size {
208                    request = request.with_page_size(page_size.max(1) as usize);
209                }
210                if let Some(max_pages) = input.max_pages {
211                    request = request.with_max_pages(max_pages.max(1) as usize);
212                }
213
214                let outcome = match source {
215                    SourceNameObject::Anilist => {
216                        db.sync_from(&AniListProvider::default(), request)?
217                    }
218                    SourceNameObject::Jikan => db.sync_from(&JikanProvider::default(), request)?,
219                    SourceNameObject::Kitsu => db.sync_from(&KitsuProvider::default(), request)?,
220                    SourceNameObject::Tvmaze => {
221                        db.sync_from(&TvmazeProvider::default(), request)?
222                    }
223                    SourceNameObject::Imdb => db.sync_from(&ImdbProvider::default(), request)?,
224                    SourceNameObject::Myanimelist => {
225                        return Err(animedb::Error::Validation(
226                            "sync direto para MyAnimeList não existe; use AniList, Jikan, Kitsu, Tvmaze ou Imdb"
227                                .into(),
228                        ));
229                    }
230                };
231                Ok(SyncReport {
232                    total_upserted_records: outcome.upserted_records,
233                    outcomes: vec![outcome],
234                })
235            } else if let Some(max_pages) = input.max_pages {
236                sync_with_max_pages(&mut db, max_pages.max(1) as usize)
237            } else {
238                db.sync_default_sources()
239            }
240        })
241        .await
242        .map_err(join_error)??;
243
244        Ok(report.into())
245    }
246}
247
248#[derive(Default, InputObject)]
249struct SearchInput {
250    limit: Option<i32>,
251    offset: Option<i32>,
252    media_kind: Option<MediaKindObject>,
253    format: Option<String>,
254}
255
256impl SearchInput {
257    fn into_model(self) -> SearchOptions {
258        let mut options = SearchOptions::default();
259        if let Some(limit) = self.limit {
260            options = options.with_limit(limit.max(1) as usize);
261        }
262        if let Some(offset) = self.offset {
263            options = options.with_offset(offset.max(0) as usize);
264        }
265        if let Some(media_kind) = self.media_kind {
266            options = options.with_media_kind(media_kind.into_model());
267        }
268        if let Some(format) = self.format {
269            options = options.with_format(format);
270        }
271        options
272    }
273}
274
275#[derive(Default, InputObject)]
276struct SyncInput {
277    source: Option<SourceNameObject>,
278    media_kind: Option<MediaKindObject>,
279    max_pages: Option<i32>,
280    page_size: Option<i32>,
281}
282
283#[derive(Copy, Clone, Eq, PartialEq, Enum)]
284enum MediaKindObject {
285    Anime,
286    Manga,
287    Show,
288    Movie,
289}
290
291impl MediaKindObject {
292    fn into_model(self) -> MediaKind {
293        match self {
294            Self::Anime => MediaKind::Anime,
295            Self::Manga => MediaKind::Manga,
296            Self::Show => MediaKind::Show,
297            Self::Movie => MediaKind::Movie,
298        }
299    }
300}
301
302#[derive(Copy, Clone, Eq, PartialEq, Enum)]
303enum SourceNameObject {
304    Anilist,
305    Myanimelist,
306    Jikan,
307    Kitsu,
308    Tvmaze,
309    Imdb,
310}
311
312impl SourceNameObject {
313    fn into_model(self) -> SourceName {
314        match self {
315            Self::Anilist => SourceName::AniList,
316            Self::Myanimelist => SourceName::MyAnimeList,
317            Self::Jikan => SourceName::Jikan,
318            Self::Kitsu => SourceName::Kitsu,
319            Self::Tvmaze => SourceName::Tvmaze,
320            Self::Imdb => SourceName::Imdb,
321        }
322    }
323}
324
325#[derive(SimpleObject)]
326struct ExternalIdObject {
327    source: SourceNameObject,
328    source_id: String,
329    url: Option<String>,
330}
331
332#[derive(SimpleObject)]
333struct SourcePayloadObject {
334    source: SourceNameObject,
335    source_id: String,
336    url: Option<String>,
337    remote_updated_at: Option<String>,
338    raw_json: Option<String>,
339}
340
341#[derive(SimpleObject)]
342struct MediaObject {
343    id: i64,
344    media_kind: MediaKindObject,
345    name: String,
346    title_display: String,
347    title_romaji: Option<String>,
348    title_english: Option<String>,
349    title_native: Option<String>,
350    synopsis: Option<String>,
351    format: Option<String>,
352    status: Option<String>,
353    season: Option<String>,
354    season_year: Option<i32>,
355    episodes: Option<i32>,
356    chapters: Option<i32>,
357    volumes: Option<i32>,
358    country_of_origin: Option<String>,
359    cover_image: Option<String>,
360    banner_image: Option<String>,
361    provider_rating: Option<f64>,
362    nsfw: bool,
363    aliases: Vec<String>,
364    genres: Vec<String>,
365    tags: Vec<String>,
366    external_ids: Vec<ExternalIdObject>,
367    source_payloads: Vec<SourcePayloadObject>,
368    field_provenance: Vec<FieldProvenanceObject>,
369}
370
371impl MediaObject {
372    fn from_canonical(media: CanonicalMedia) -> Self {
373        Self {
374            id: -1,
375            media_kind: media.media_kind.into(),
376            name: media.title_display.clone(),
377            title_display: media.title_display,
378            title_romaji: media.title_romaji,
379            title_english: media.title_english,
380            title_native: media.title_native,
381            synopsis: media.synopsis,
382            format: media.format,
383            status: media.status,
384            season: media.season,
385            season_year: media.season_year,
386            episodes: media.episodes,
387            chapters: media.chapters,
388            volumes: media.volumes,
389            country_of_origin: media.country_of_origin,
390            cover_image: media.cover_image,
391            banner_image: media.banner_image,
392            provider_rating: media.provider_rating,
393            nsfw: media.nsfw,
394            aliases: media.aliases,
395            genres: media.genres,
396            tags: media.tags,
397            external_ids: media
398                .external_ids
399                .into_iter()
400                .map(ExternalIdObject::from)
401                .collect(),
402            source_payloads: media
403                .source_payloads
404                .into_iter()
405                .map(SourcePayloadObject::from)
406                .collect(),
407            field_provenance: media
408                .field_provenance
409                .into_iter()
410                .map(FieldProvenanceObject::from)
411                .collect(),
412        }
413    }
414}
415
416impl From<StoredMedia> for MediaObject {
417    fn from(media: StoredMedia) -> Self {
418        Self {
419            id: media.id,
420            media_kind: media.media_kind.into(),
421            name: media.title_display.clone(),
422            title_display: media.title_display,
423            title_romaji: media.title_romaji,
424            title_english: media.title_english,
425            title_native: media.title_native,
426            synopsis: media.synopsis,
427            format: media.format,
428            status: media.status,
429            season: media.season,
430            season_year: media.season_year,
431            episodes: media.episodes,
432            chapters: media.chapters,
433            volumes: media.volumes,
434            country_of_origin: media.country_of_origin,
435            cover_image: media.cover_image,
436            banner_image: media.banner_image,
437            provider_rating: media.provider_rating,
438            nsfw: media.nsfw,
439            aliases: media.aliases,
440            genres: media.genres,
441            tags: media.tags,
442            external_ids: media
443                .external_ids
444                .into_iter()
445                .map(ExternalIdObject::from)
446                .collect(),
447            source_payloads: media
448                .source_payloads
449                .into_iter()
450                .map(SourcePayloadObject::from)
451                .collect(),
452            field_provenance: media
453                .field_provenance
454                .into_iter()
455                .map(FieldProvenanceObject::from)
456                .collect(),
457        }
458    }
459}
460
461#[derive(SimpleObject)]
462struct FieldProvenanceObject {
463    field_name: String,
464    source: SourceNameObject,
465    source_id: String,
466    score: f64,
467    reason: String,
468    updated_at: String,
469}
470
471impl From<FieldProvenance> for FieldProvenanceObject {
472    fn from(value: FieldProvenance) -> Self {
473        Self {
474            field_name: value.field_name,
475            source: value.source.into(),
476            source_id: value.source_id,
477            score: value.score,
478            reason: value.reason,
479            updated_at: value.updated_at,
480        }
481    }
482}
483
484#[derive(SimpleObject)]
485struct SearchHitObject {
486    media_id: i64,
487    media_kind: MediaKindObject,
488    name: String,
489    title_display: String,
490    synopsis: Option<String>,
491    score: f64,
492}
493
494impl From<SearchHit> for SearchHitObject {
495    fn from(hit: SearchHit) -> Self {
496        Self {
497            media_id: hit.media_id,
498            media_kind: hit.media_kind.into(),
499            name: hit.title_display.clone(),
500            title_display: hit.title_display,
501            synopsis: hit.synopsis,
502            score: hit.score,
503        }
504    }
505}
506
507#[derive(SimpleObject)]
508struct SyncOutcomeObject {
509    source: SourceNameObject,
510    media_kind: Option<MediaKindObject>,
511    fetched_pages: usize,
512    upserted_records: usize,
513    last_cursor_page: Option<usize>,
514}
515
516#[derive(SimpleObject)]
517struct SyncReportObject {
518    total_upserted_records: usize,
519    outcomes: Vec<SyncOutcomeObject>,
520}
521
522impl From<SyncReport> for SyncReportObject {
523    fn from(report: SyncReport) -> Self {
524        Self {
525            total_upserted_records: report.total_upserted_records,
526            outcomes: report
527                .outcomes
528                .into_iter()
529                .map(SyncOutcomeObject::from)
530                .collect(),
531        }
532    }
533}
534
535impl From<animedb::SyncOutcome> for SyncOutcomeObject {
536    fn from(outcome: animedb::SyncOutcome) -> Self {
537        Self {
538            source: outcome.source.into(),
539            media_kind: outcome.media_kind.map(Into::into),
540            fetched_pages: outcome.fetched_pages,
541            upserted_records: outcome.upserted_records,
542            last_cursor_page: outcome.last_cursor.map(|cursor| cursor.page),
543        }
544    }
545}
546
547#[derive(SimpleObject)]
548struct SyncStateObject {
549    source: SourceNameObject,
550    scope: String,
551    cursor_page: Option<usize>,
552    last_success_at: Option<String>,
553    last_error: Option<String>,
554    last_page: Option<i64>,
555    mode: SyncModeObject,
556}
557
558impl From<PersistedSyncState> for SyncStateObject {
559    fn from(state: PersistedSyncState) -> Self {
560        Self {
561            source: state.source.into(),
562            scope: state.scope,
563            cursor_page: state.cursor.map(|cursor| cursor.page),
564            last_success_at: state.last_success_at,
565            last_error: state.last_error,
566            last_page: state.last_page,
567            mode: state.mode.into(),
568        }
569    }
570}
571
572#[derive(Copy, Clone, Eq, PartialEq, Enum)]
573enum SyncModeObject {
574    Full,
575    Incremental,
576}
577
578impl From<SyncMode> for SyncModeObject {
579    fn from(mode: SyncMode) -> Self {
580        match mode {
581            SyncMode::Full => Self::Full,
582            SyncMode::Incremental => Self::Incremental,
583        }
584    }
585}
586
587impl From<MediaKind> for MediaKindObject {
588    fn from(value: MediaKind) -> Self {
589        match value {
590            MediaKind::Anime => Self::Anime,
591            MediaKind::Manga => Self::Manga,
592            MediaKind::Show => Self::Show,
593            MediaKind::Movie => Self::Movie,
594        }
595    }
596}
597
598impl From<SourceName> for SourceNameObject {
599    fn from(value: SourceName) -> Self {
600        match value {
601            SourceName::AniList => Self::Anilist,
602            SourceName::MyAnimeList => Self::Myanimelist,
603            SourceName::Jikan => Self::Jikan,
604            SourceName::Kitsu => Self::Kitsu,
605            SourceName::Tvmaze => Self::Tvmaze,
606            SourceName::Imdb => Self::Imdb,
607        }
608    }
609}
610
611impl From<animedb::ExternalId> for ExternalIdObject {
612    fn from(value: animedb::ExternalId) -> Self {
613        Self {
614            source: value.source.into(),
615            source_id: value.source_id,
616            url: value.url,
617        }
618    }
619}
620
621impl From<animedb::SourcePayload> for SourcePayloadObject {
622    fn from(value: animedb::SourcePayload) -> Self {
623        Self {
624            source: value.source.into(),
625            source_id: value.source_id,
626            url: value.url,
627            remote_updated_at: value.remote_updated_at,
628            raw_json: value.raw_json.map(|raw| raw.to_string()),
629        }
630    }
631}
632
633async fn graphql_handler(
634    State(schema): State<AnimeDbSchema>,
635    req: GraphQLRequest,
636) -> GraphQLResponse {
637    schema.execute(req.into_inner()).await.into()
638}
639
640async fn playground() -> impl IntoResponse {
641    Html(playground_source(GraphQLPlaygroundConfig::new("/graphql")))
642}
643
644async fn healthz(State(schema): State<AnimeDbSchema>) -> impl IntoResponse {
645    let ok = tokio::task::spawn_blocking(move || {
646        let db = AnimeDb::open(schema.data::<AppState>().unwrap().database_path.as_path())
647            .map_err(|e| e.to_string())?;
648        db.connection()
649            .query_row("SELECT 1", [], |_| Ok(()))
650            .map_err(|e| format!("{e}"))
651    })
652    .await
653    .map_err(|e| format!("{e}"))
654    .is_ok();
655
656    Json(serde_json::json!({ "status": if ok { "ok" } else { "error" } }))
657}
658
659fn not_found_is_none<T>(error: animedb::Error) -> GraphQLResult<Option<T>> {
660    match error {
661        animedb::Error::NotFound => Ok(None),
662        other => Err(async_graphql::Error::new(other.to_string())),
663    }
664}
665
666fn join_error(error: tokio::task::JoinError) -> async_graphql::Error {
667    async_graphql::Error::new(error.to_string())
668}
669
670fn search_remote(
671    source: SourceNameObject,
672    query: &str,
673    options: SearchOptions,
674) -> animedb::Result<Vec<MediaObject>> {
675    match source {
676        SourceNameObject::Anilist => {
677            let remote = RemoteCatalog::new(AniListProvider::default());
678            remote
679                .search(query, options)
680                .map(|items| items.into_iter().map(MediaObject::from_canonical).collect())
681        }
682        SourceNameObject::Jikan => {
683            let remote = RemoteCatalog::new(JikanProvider::default());
684            remote
685                .search(query, options)
686                .map(|items| items.into_iter().map(MediaObject::from_canonical).collect())
687        }
688        SourceNameObject::Kitsu => {
689            let remote = RemoteCatalog::new(KitsuProvider::default());
690            remote
691                .search(query, options)
692                .map(|items| items.into_iter().map(MediaObject::from_canonical).collect())
693        }
694        SourceNameObject::Tvmaze => {
695            let remote = RemoteCatalog::new(TvmazeProvider::default());
696            remote
697                .search(query, options)
698                .map(|items| items.into_iter().map(MediaObject::from_canonical).collect())
699        }
700        SourceNameObject::Imdb => Err(animedb::Error::Validation(
701            "IMDb remote search requires downloading the full dataset; use sync instead".into(),
702        )),
703        SourceNameObject::Myanimelist => Err(animedb::Error::Validation(
704            "consulta remota direta para MyAnimeList nao esta disponivel".into(),
705        )),
706    }
707}
708
709fn get_remote_media(
710    source: SourceNameObject,
711    source_id: &str,
712    media_kind: MediaKindObject,
713) -> animedb::Result<Option<MediaObject>> {
714    match source {
715        SourceNameObject::Anilist => {
716            let remote = RemoteCatalog::new(AniListProvider::default());
717            let collection = match media_kind {
718                MediaKindObject::Anime => remote.anime_metadata(),
719                MediaKindObject::Manga => remote.manga_metadata(),
720                MediaKindObject::Show | MediaKindObject::Movie => remote.anime_metadata(),
721            };
722            collection
723                .by_id(source_id)
724                .map(|media| media.map(MediaObject::from_canonical))
725        }
726        SourceNameObject::Jikan => {
727            let remote = RemoteCatalog::new(JikanProvider::default());
728            let collection = match media_kind {
729                MediaKindObject::Anime => remote.anime_metadata(),
730                MediaKindObject::Manga => remote.manga_metadata(),
731                MediaKindObject::Show | MediaKindObject::Movie => remote.anime_metadata(),
732            };
733            collection
734                .by_id(source_id)
735                .map(|media| media.map(MediaObject::from_canonical))
736        }
737        SourceNameObject::Kitsu => {
738            let remote = RemoteCatalog::new(KitsuProvider::default());
739            let collection = match media_kind {
740                MediaKindObject::Anime => remote.anime_metadata(),
741                MediaKindObject::Manga => remote.manga_metadata(),
742                MediaKindObject::Show | MediaKindObject::Movie => remote.anime_metadata(),
743            };
744            collection
745                .by_id(source_id)
746                .map(|media| media.map(MediaObject::from_canonical))
747        }
748        SourceNameObject::Tvmaze => {
749            let remote = RemoteCatalog::new(TvmazeProvider::default());
750            let collection = remote.show_metadata();
751            collection
752                .by_id(source_id)
753                .map(|media| media.map(MediaObject::from_canonical))
754        }
755        SourceNameObject::Imdb => Err(animedb::Error::Validation(
756            "IMDb remote lookup requires downloading the full dataset; use sync instead".into(),
757        )),
758        SourceNameObject::Myanimelist => Err(animedb::Error::Validation(
759            "consulta remota direta para MyAnimeList nao esta disponivel".into(),
760        )),
761    }
762}
763
764fn sync_with_max_pages(db: &mut AnimeDb, max_pages: usize) -> animedb::Result<SyncReport> {
765    let anilist = AniListProvider::default();
766    let jikan = JikanProvider::default();
767    let kitsu = KitsuProvider::default();
768    let tvmaze = TvmazeProvider::default();
769    let imdb = ImdbProvider::default();
770    let mut outcomes = Vec::new();
771
772    for media_kind in [MediaKind::Anime, MediaKind::Manga] {
773        outcomes.push(
774            db.sync_from(
775                &anilist,
776                SyncRequest::new(SourceName::AniList)
777                    .with_media_kind(media_kind)
778                    .with_max_pages(max_pages),
779            )?,
780        );
781        outcomes.push(
782            db.sync_from(
783                &jikan,
784                SyncRequest::new(SourceName::Jikan)
785                    .with_media_kind(media_kind)
786                    .with_max_pages(max_pages),
787            )?,
788        );
789        outcomes.push(
790            db.sync_from(
791                &kitsu,
792                SyncRequest::new(SourceName::Kitsu)
793                    .with_media_kind(media_kind)
794                    .with_max_pages(max_pages),
795            )?,
796        );
797    }
798
799    outcomes.push(
800        db.sync_from(
801            &tvmaze,
802            SyncRequest::new(SourceName::Tvmaze)
803                .with_media_kind(MediaKind::Show)
804                .with_max_pages(max_pages),
805        )?,
806    );
807
808    for media_kind in [MediaKind::Show, MediaKind::Movie] {
809        outcomes.push(
810            db.sync_from(
811                &imdb,
812                SyncRequest::new(SourceName::Imdb)
813                    .with_media_kind(media_kind)
814                    .with_max_pages(max_pages),
815            )?,
816        );
817    }
818
819    let total_upserted_records = outcomes.iter().map(|item| item.upserted_records).sum();
820
821    Ok(SyncReport {
822        outcomes,
823        total_upserted_records,
824    })
825}