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