1use 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#[derive(Clone)]
30pub struct AppState {
31 pub database_path: PathBuf,
32}
33
34pub 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
43pub 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}