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.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}