diff --git a/Cargo.lock b/Cargo.lock index cf3ae34eb2..40b3cfc62b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1150,6 +1150,17 @@ dependencies = [ "log", ] +[[package]] +name = "boilermates" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b159337fa36894b98dc2f55dc0ada298afb38d64589bbdbb945128ba4c86ccd9" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "borsh" version = "0.10.3" @@ -4537,7 +4548,7 @@ checksum = "dc31bd9b61a32c31f9650d18add92aa83a49ba979c143eefd27fe7177b05bd5f" [[package]] name = "ryot" -version = "2.19.2" +version = "2.19.3" dependencies = [ "anyhow", "apalis", @@ -4548,6 +4559,7 @@ dependencies = [ "aws-sdk-s3", "axum", "axum-extra", + "boilermates", "chrono", "const-str", "convert_case", diff --git a/apps/backend/Cargo.toml b/apps/backend/Cargo.toml index b298c776ed..ff8e0d50e5 100644 --- a/apps/backend/Cargo.toml +++ b/apps/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ryot" -version = "2.19.2" +version = "2.19.3" edition = "2021" repository = "https://github.com/IgnisDa/ryot" license = "GPL-V3" @@ -27,6 +27,7 @@ axum = { version = "0.6.20", features = ["macros", "multipart"] } axum-extra = { version = "0.8.0", default-features = false, features = [ "cookie", ] } +boilermates = "0.3.0" chrono = "0.4.31" convert_case = "0.6.0" const-str = "0.5.6" diff --git a/apps/backend/src/background.rs b/apps/backend/src/background.rs index 7c7bd0dff7..e139fdf2e1 100644 --- a/apps/backend/src/background.rs +++ b/apps/backend/src/background.rs @@ -3,11 +3,13 @@ use std::{sync::Arc, time::Instant}; use apalis::prelude::{Job, JobContext, JobError}; use sea_orm::prelude::DateTimeUtc; use serde::{Deserialize, Serialize}; +use strum::Display; use crate::{ entities::{metadata, seen}, fitness::resolver::ExerciseService, importer::{DeployImportJobInput, ImporterService}, + migrator::{MetadataLot, MetadataSource}, miscellaneous::resolver::MiscellaneousService, models::{fitness::Exercise, media::PartialMetadataPerson}, }; @@ -82,7 +84,7 @@ pub async fn yank_integrations_data( // Application Jobs -#[derive(Debug, Deserialize, Serialize)] +#[derive(Debug, Deserialize, Serialize, Display)] pub enum ApplicationJob { ImportMedia(i32, DeployImportJobInput), UserCreated(i32), @@ -92,6 +94,7 @@ pub enum ApplicationJob { AfterMediaSeen(seen::Model), RecalculateCalendarEvents, AssociatePersonWithMetadata(i32, PartialMetadataPerson, usize), + AssociateGroupWithMetadata(MetadataLot, MetadataSource, String), } impl Job for ApplicationJob { @@ -102,6 +105,7 @@ pub async fn perform_application_job( information: ApplicationJob, ctx: JobContext, ) -> Result<(), JobError> { + let name = information.to_string(); let importer_service = ctx.data::>().unwrap(); let misc_service = ctx.data::>().unwrap(); let exercise_service = ctx.data::>().unwrap(); @@ -153,8 +157,18 @@ pub async fn perform_application_job( .await .unwrap(); } + ApplicationJob::AssociateGroupWithMetadata(lot, source, group_identifier) => { + misc_service + .associate_group_with_metadata(lot, source, group_identifier) + .await + .unwrap(); + } }; let end = Instant::now(); - tracing::trace!("Job completed, took {}s", (end - start).as_secs()); + tracing::trace!( + "Job {:#?} completed in {}ms", + name, + (end - start).as_millis() + ); Ok(()) } diff --git a/apps/backend/src/entities/metadata_group.rs b/apps/backend/src/entities/metadata_group.rs index 26b415daf4..81d6ab623b 100644 --- a/apps/backend/src/entities/metadata_group.rs +++ b/apps/backend/src/entities/metadata_group.rs @@ -1,6 +1,7 @@ //! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.2 use async_graphql::SimpleObject; +use boilermates::boilermates; use sea_orm::entity::prelude::*; use serde::{Deserialize, Serialize}; @@ -12,7 +13,9 @@ use crate::{ #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize, SimpleObject)] #[sea_orm(table_name = "metadata_group")] #[graphql(name = "MetadataGroup")] +#[boilermates("MetadataGroupWithoutId")] pub struct Model { + #[boilermates(not_in("MetadataGroupWithoutId"))] #[sea_orm(primary_key)] pub id: i32, pub parts: i32, diff --git a/apps/backend/src/entities/partial_metadata.rs b/apps/backend/src/entities/partial_metadata.rs index 658bfd0a2c..4780c829ab 100644 --- a/apps/backend/src/entities/partial_metadata.rs +++ b/apps/backend/src/entities/partial_metadata.rs @@ -2,6 +2,7 @@ use async_graphql::SimpleObject; use async_trait::async_trait; +use boilermates::boilermates; use sea_orm::{entity::prelude::*, ActiveValue}; use serde::{Deserialize, Serialize}; @@ -12,10 +13,16 @@ use crate::{ use super::metadata; +#[boilermates("PartialMetadataWithoutId")] +#[boilermates(attr_for( + "PartialMetadataWithoutId", + "#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash)]" +))] #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize, SimpleObject)] #[sea_orm(table_name = "partial_metadata")] #[graphql(name = "PartialMetadata")] pub struct Model { + #[boilermates(not_in("PartialMetadataWithoutId"))] #[sea_orm(primary_key)] #[graphql(skip)] pub id: i32, @@ -24,6 +31,7 @@ pub struct Model { pub image: Option, pub lot: MetadataLot, pub source: MetadataSource, + #[boilermates(not_in("PartialMetadataWithoutId"))] pub metadata_id: Option, } diff --git a/apps/backend/src/entities/user_measurement.rs b/apps/backend/src/entities/user_measurement.rs index 7865a6accb..001cc6c63f 100644 --- a/apps/backend/src/entities/user_measurement.rs +++ b/apps/backend/src/entities/user_measurement.rs @@ -3,10 +3,13 @@ use async_graphql::{InputObject, SimpleObject}; use sea_orm::entity::prelude::*; use serde::{Deserialize, Serialize}; +use serde_with::skip_serializing_none; use specta::Type; use crate::models::fitness::UserMeasurementStats; +/// An export of a measurement taken at a point in time. +#[skip_serializing_none] #[derive( Clone, Debug, diff --git a/apps/backend/src/importer/goodreads.rs b/apps/backend/src/importer/goodreads.rs index 3fde9a334a..e79c11169c 100644 --- a/apps/backend/src/importer/goodreads.rs +++ b/apps/backend/src/importer/goodreads.rs @@ -131,7 +131,7 @@ pub async fn import(input: DeployGoodreadsImportInput) -> Result { publish_date: None, genres: vec![], suggestions: vec![], - groups: vec![], + group_identifiers: vec![], is_nsfw: None, people: vec![], s3_images: vec![], diff --git a/apps/backend/src/importer/media_tracker.rs b/apps/backend/src/importer/media_tracker.rs index e693c01f7e..c02cd9e2b1 100644 --- a/apps/backend/src/importer/media_tracker.rs +++ b/apps/backend/src/importer/media_tracker.rs @@ -295,7 +295,7 @@ pub async fn import(input: DeployMediaTrackerImportInput) -> Result Result<(), DbErr> { manager - .drop_index(Index::drop().name(PERSON_IDENTIFIER_UNIQUE_KEY).to_owned()) + .drop_index( + Index::drop() + .table(Person::Table) + .name(PERSON_IDENTIFIER_UNIQUE_KEY) + .to_owned(), + ) .await?; manager .create_index( diff --git a/apps/backend/src/migrator/m20230927_remove_useless_tables.rs b/apps/backend/src/migrator/m20230927_remove_useless_tables.rs index 024764d6c3..c363efc25f 100644 --- a/apps/backend/src/migrator/m20230927_remove_useless_tables.rs +++ b/apps/backend/src/migrator/m20230927_remove_useless_tables.rs @@ -37,7 +37,7 @@ impl MigrationTrait for Migration { let message = format!(" This migration will delete all old creators (changes introduced in `v2.19.0`) and associated reviews. You have reviews for {count} creator(s). -Please downgrade to the `v2.19.0`, follow instructions at https://github.com/IgnisDa/ryot/releases/tag/v2.19.0 to migrate this data, and then upgrade again. +Please downgrade to `v2.19.0`, follow instructions at https://github.com/IgnisDa/ryot/releases/tag/v2.19.0 to migrate this data, and then upgrade again. If you want to skip this check, please set the environment variable `{var_name}=1`."); tracing::info!(message); diff --git a/apps/backend/src/miscellaneous/resolver.rs b/apps/backend/src/miscellaneous/resolver.rs index 2e4d38008c..3fc1832332 100644 --- a/apps/backend/src/miscellaneous/resolver.rs +++ b/apps/backend/src/miscellaneous/resolver.rs @@ -51,7 +51,8 @@ use crate::{ config::AppConfig, entities::{ calendar_event, collection, genre, metadata, metadata_group, metadata_to_collection, - metadata_to_genre, metadata_to_partial_metadata, metadata_to_person, partial_metadata, + metadata_to_genre, metadata_to_partial_metadata, metadata_to_person, + partial_metadata::{self, PartialMetadataWithoutId}, partial_metadata_to_metadata_group, person, prelude::{ CalendarEvent, Collection, Genre, Metadata, MetadataGroup, MetadataToCollection, @@ -80,12 +81,11 @@ use crate::{ MediaSearchItemResponse, MediaSearchItemWithLot, MediaSpecifics, MetadataFreeCreators, MetadataGroupListItem, MetadataImage, MetadataImageForMediaDetails, MetadataImageLot, MetadataImages, MetadataVideo, MetadataVideoSource, MetadataVideos, MovieSpecifics, - PartialMetadata, PartialMetadataPerson, PodcastSpecifics, PostReviewInput, - ProgressUpdateError, ProgressUpdateErrorVariant, ProgressUpdateInput, - ProgressUpdateResultUnion, ReviewCommentUser, ReviewComments, - SeenOrReviewOrCalendarEventExtraInformation, SeenPodcastExtraInformation, - SeenShowExtraInformation, ShowSpecifics, UserMediaReminder, UserSummary, - VideoGameSpecifics, Visibility, VisualNovelSpecifics, + PartialMetadataPerson, PodcastSpecifics, PostReviewInput, ProgressUpdateError, + ProgressUpdateErrorVariant, ProgressUpdateInput, ProgressUpdateResultUnion, + ReviewCommentUser, ReviewComments, SeenOrReviewOrCalendarEventExtraInformation, + SeenPodcastExtraInformation, SeenShowExtraInformation, ShowSpecifics, + UserMediaReminder, UserSummary, VideoGameSpecifics, Visibility, VisualNovelSpecifics, }, IdObject, SearchDetails, SearchInput, SearchResults, StoredUrl, }, @@ -2559,8 +2559,8 @@ impl MiscellaneousService { production_status: String, publish_year: Option, publish_date: Option, - suggestions: Vec, - groups: Vec<(metadata_group::Model, Vec)>, + suggestions: Vec, + group_identifiers: Vec, ) -> Result> { let mut notifications = vec![]; @@ -2744,7 +2744,7 @@ impl MiscellaneousService { metadata.source, genres, suggestions, - groups, + group_identifiers, people, ) .await?; @@ -2768,48 +2768,62 @@ impl MiscellaneousService { Ok(()) } + async fn deploy_associate_group_with_metadata_job( + &self, + lot: MetadataLot, + source: MetadataSource, + group_identifier: String, + ) -> Result<()> { + self.perform_application_job + .clone() + .push(ApplicationJob::AssociateGroupWithMetadata( + lot, + source, + group_identifier, + )) + .await?; + Ok(()) + } + pub async fn associate_group_with_metadata( &self, lot: MetadataLot, source: MetadataSource, - (group, associated_items): (metadata_group::Model, Vec), + group_identifier: String, ) -> Result<()> { let existing_group = MetadataGroup::find() - .filter(metadata_group::Column::Identifier.eq(&group.identifier)) + .filter(metadata_group::Column::Identifier.eq(&group_identifier)) .filter(metadata_group::Column::Lot.eq(lot)) .filter(metadata_group::Column::Source.eq(source)) .one(&self.db) .await?; - let group_id = match existing_group { - Some(eg) => { - if eg.title != group.title - || eg.description != group.description - || eg.images != group.images - { - let title = group.title.clone(); - let description = group.description.clone(); - let images = group.images.clone(); - let mut db_group: metadata_group::ActiveModel = group.into(); - db_group.title = ActiveValue::Set(title); - db_group.description = ActiveValue::Set(description); - db_group.images = ActiveValue::Set(images); - db_group.update(&self.db).await?; - } - eg.id - } + let (group_id, associated_items) = match existing_group { + Some(eg) => (eg.id, vec![]), None => { - let mut db_group: metadata_group::ActiveModel = group.into(); + let provider = self.get_media_provider(lot, source).await?; + let (group_details, associated_items) = + provider.group_details(&group_identifier).await?; + let mut db_group: metadata_group::ActiveModel = group_details.into_model(0).into(); db_group.id = ActiveValue::NotSet; let new_group = db_group.insert(&self.db).await?; - new_group.id + (new_group.id, associated_items) } }; for (idx, media) in associated_items.into_iter().enumerate() { let db_partial_metadata = self.create_partial_metadata(media).await?; + PartialMetadataToMetadataGroup::delete_many() + .filter(partial_metadata_to_metadata_group::Column::MetadataGroupId.eq(group_id)) + .filter( + partial_metadata_to_metadata_group::Column::PartialMetadataId + .eq(db_partial_metadata.id), + ) + .exec(&self.db) + .await + .ok(); let intermediate = partial_metadata_to_metadata_group::ActiveModel { metadata_group_id: ActiveValue::Set(group_id), partial_metadata_id: ActiveValue::Set(db_partial_metadata.id), - part: ActiveValue::Set(idx.try_into().unwrap()), + part: ActiveValue::Set((idx + 1).try_into().unwrap()), }; intermediate.insert(&self.db).await.ok(); } @@ -2818,7 +2832,7 @@ impl MiscellaneousService { async fn associate_suggestion_with_metadata( &self, - data: PartialMetadata, + data: PartialMetadataWithoutId, metadata_id: i32, ) -> Result<()> { let db_partial_metadata = self.create_partial_metadata(data).await?; @@ -2833,7 +2847,7 @@ impl MiscellaneousService { async fn create_partial_metadata( &self, - data: PartialMetadata, + data: PartialMetadataWithoutId, ) -> Result { let model = if let Some(c) = PartialMetadataModel::find() .filter(partial_metadata::Column::Identifier.eq(&data.identifier)) @@ -2923,7 +2937,7 @@ impl MiscellaneousService { metadata.source, details.genres, details.suggestions, - details.groups, + details.group_identifiers, details.people, ) .await?; @@ -2937,8 +2951,8 @@ impl MiscellaneousService { lot: MetadataLot, source: MetadataSource, genres: Vec, - suggestions: Vec, - groups: Vec<(metadata_group::Model, Vec)>, + suggestions: Vec, + groups: Vec, people: Vec, ) -> Result<()> { MetadataToPerson::delete_many() @@ -2969,9 +2983,8 @@ impl MiscellaneousService { .await .ok(); } - // DEV: Ideally, we should remove partial_metadata to metadata_group association but does not really matter - for group in groups { - self.associate_group_with_metadata(lot, source, group) + for group_identifier in groups { + self.deploy_associate_group_with_metadata_job(lot, source, group_identifier) .await .ok(); } @@ -3903,7 +3916,7 @@ impl MiscellaneousService { details.publish_year, details.publish_date, details.suggestions, - details.groups, + details.group_identifiers, ) .await? } @@ -4341,7 +4354,7 @@ impl MiscellaneousService { is_nsfw: input.is_nsfw, publish_date: None, suggestions: vec![], - groups: vec![], + group_identifiers: vec![], people: vec![], }; let media = self.commit_media_internal(details).await?; @@ -5985,8 +5998,8 @@ impl MiscellaneousService { db_person } else { let provider = self.get_non_media_provider(person.source).await?; - let person = provider.person_details(person).await?; - let images = person.images.map(|images| { + let provider_person = provider.person_details(person).await?; + let images = provider_person.images.map(|images| { MetadataImages( images .into_iter() @@ -5998,14 +6011,14 @@ impl MiscellaneousService { ) }); let person = person::ActiveModel { - identifier: ActiveValue::Set(person.identifier), - source: ActiveValue::Set(person.source), - name: ActiveValue::Set(person.name), - description: ActiveValue::Set(person.description), - gender: ActiveValue::Set(person.gender), - birth_date: ActiveValue::Set(person.birth_date), - place: ActiveValue::Set(person.place), - website: ActiveValue::Set(person.website), + identifier: ActiveValue::Set(provider_person.identifier), + source: ActiveValue::Set(provider_person.source), + name: ActiveValue::Set(provider_person.name), + description: ActiveValue::Set(provider_person.description), + gender: ActiveValue::Set(provider_person.gender), + birth_date: ActiveValue::Set(provider_person.birth_date), + place: ActiveValue::Set(provider_person.place), + website: ActiveValue::Set(provider_person.website), images: ActiveValue::Set(images), ..Default::default() }; diff --git a/apps/backend/src/models.rs b/apps/backend/src/models.rs index d00dbdbed6..c10ec23d36 100644 --- a/apps/backend/src/models.rs +++ b/apps/backend/src/models.rs @@ -19,7 +19,10 @@ use serde_with::skip_serializing_none; use specta::Type; use crate::{ - entities::{exercise::Model as ExerciseModel, metadata_group, user_measurement}, + entities::{ + exercise::Model as ExerciseModel, partial_metadata::PartialMetadataWithoutId, + user_measurement, + }, file_storage::FileStorageService, migrator::{ ExerciseEquipment, ExerciseForce, ExerciseLevel, ExerciseMechanic, ExerciseMuscle, @@ -694,15 +697,6 @@ pub mod media { Error(ProgressUpdateError), } - #[derive(Debug, Serialize, Deserialize, Clone, Eq, PartialEq, SimpleObject, Hash)] - pub struct PartialMetadata { - pub title: String, - pub image: Option, - pub identifier: String, - pub source: MetadataSource, - pub lot: MetadataLot, - } - #[derive(Debug, Serialize, Deserialize, Clone, Eq, PartialEq, SimpleObject, Hash)] pub struct PartialMetadataPerson { pub identifier: String, @@ -748,8 +742,8 @@ pub mod media { pub publish_year: Option, pub publish_date: Option, pub specifics: MediaSpecifics, - pub suggestions: Vec, - pub groups: Vec<(metadata_group::Model, Vec)>, + pub suggestions: Vec, + pub group_identifiers: Vec, pub provider_rating: Option, } @@ -762,6 +756,8 @@ pub mod media { AlreadyFilled(Box), } + /// A specific instance when an entity was seen. + #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone, Type, Default)] pub struct ImportOrExportMediaItemSeen { /// The progress of media done. If none, it is considered as done. @@ -778,6 +774,8 @@ pub mod media { pub podcast_episode_number: Option, } + /// Review data associated to a rating. + #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone, Type, Default)] pub struct ImportOrExportItemReview { /// The date the review was posted. @@ -788,6 +786,8 @@ pub mod media { pub text: Option, } + /// A rating given to an entity. + #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone, Type, Default)] pub struct ImportOrExportItemRating { /// Data about the review. @@ -805,6 +805,7 @@ pub mod media { } /// Details about a specific media item that needs to be imported or exported. + #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone, Type)] pub struct ImportOrExportMediaItem { /// An string to help identify it in the original source. @@ -824,6 +825,7 @@ pub mod media { } /// Complete export of the user. + #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone, Type)] pub struct ExportAllResponse { /// Data about user's media. @@ -835,6 +837,7 @@ pub mod media { } /// Details about a specific creator item that needs to be exported. + #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone, Type)] pub struct ImportOrExportPersonItem { /// The name of the creator. @@ -955,6 +958,7 @@ pub mod media { #[derive(Clone, Debug, PartialEq, FromJsonQueryResult, Eq, Serialize, Deserialize, Default)] pub struct MetadataVideos(pub Vec); + /// A user that has commented on a review. #[derive( Clone, Debug, @@ -973,6 +977,8 @@ pub mod media { pub name: String, } + /// Comments left in replies to posted reviews. + #[skip_serializing_none] #[derive( Clone, Debug, @@ -1102,6 +1108,7 @@ pub mod fitness { pub name: String, } + /// The actual statistics that were logged in a user measurement. #[skip_serializing_none] #[derive( Debug, diff --git a/apps/backend/src/providers/anilist/mod.rs b/apps/backend/src/providers/anilist/mod.rs index 519ed3f49e..0a520073c6 100644 --- a/apps/backend/src/providers/anilist/mod.rs +++ b/apps/backend/src/providers/anilist/mod.rs @@ -9,12 +9,13 @@ use surf::{http::headers::ACCEPT, Client}; use crate::{ config::AnilistConfig, + entities::partial_metadata::PartialMetadataWithoutId, migrator::{MetadataLot, MetadataSource}, models::{ media::{ AnimeSpecifics, MangaSpecifics, MediaDetails, MediaSearchItem, MediaSpecifics, MetadataImageForMediaDetails, MetadataImageLot, MetadataPerson, MetadataVideo, - MetadataVideoSource, PartialMetadata, PartialMetadataPerson, + MetadataVideoSource, PartialMetadataPerson, }, SearchDetails, SearchResults, StoredUrl, }, @@ -386,7 +387,7 @@ async fn details(client: &Client, id: &str) -> Result { .into_iter() .map(|r| { let data = r.unwrap().media_recommendation.unwrap(); - PartialMetadata { + PartialMetadataWithoutId { title: data.title.unwrap().user_preferred.unwrap(), identifier: data.id.to_string(), source: MetadataSource::Anilist, @@ -426,7 +427,7 @@ async fn details(client: &Client, id: &str) -> Result { specifics, suggestions, provider_rating: score, - groups: vec![], + group_identifiers: vec![], s3_images: vec![], }) } diff --git a/apps/backend/src/providers/audible.rs b/apps/backend/src/providers/audible.rs index 48a9e03e33..603ccae7b8 100644 --- a/apps/backend/src/providers/audible.rs +++ b/apps/backend/src/providers/audible.rs @@ -11,12 +11,14 @@ use surf::{http::headers::ACCEPT, Client}; use crate::{ config::AudibleConfig, - entities::metadata_group, + entities::{ + metadata_group::MetadataGroupWithoutId, partial_metadata::PartialMetadataWithoutId, + }, migrator::{MetadataLot, MetadataSource}, models::{ media::{ AudioBookSpecifics, FreeMetadataCreator, MediaDetails, MediaSearchItem, MediaSpecifics, - MetadataImageForMediaDetails, MetadataImageLot, MetadataImages, PartialMetadata, + MetadataImageForMediaDetails, MetadataImageLot, MetadataImages, }, NamedObject, SearchDetails, SearchResults, }, @@ -171,6 +173,61 @@ impl AudibleService { #[async_trait] impl MediaProvider for AudibleService { + async fn group_details( + &self, + identifier: &str, + ) -> Result<(MetadataGroupWithoutId, Vec)> { + let data: AudibleItemResponse = self + .client + .get(identifier) + .query(&PrimaryQuery::default()) + .unwrap() + .await + .map_err(|e| anyhow!(e))? + .body_json() + .await + .map_err(|e| anyhow!(e))?; + let items = data + .product + .relationships + .unwrap() + .into_iter() + .sorted_by_key(|f| f.sort.parse::().unwrap()) + .map(|i| i.asin) + .collect_vec(); + let mut collection_contents = vec![]; + for i in items { + let mut rsp = self + .client + .get(&i) + .query(&PrimaryQuery::default()) + .unwrap() + .await + .map_err(|e| anyhow!(e))?; + let data: AudibleItemResponse = rsp.body_json().await.map_err(|e| anyhow!(e))?; + collection_contents.push(PartialMetadataWithoutId { + title: data.product.title, + image: data.product.product_images.and_then(|i| i.image_2400), + identifier: i, + source: MetadataSource::Audible, + lot: MetadataLot::AudioBook, + }) + } + Ok(( + MetadataGroupWithoutId { + display_images: vec![], + parts: collection_contents.len().try_into().unwrap(), + identifier: identifier.to_owned(), + title: data.product.title, + description: None, + images: MetadataImages(vec![]), + lot: MetadataLot::AudioBook, + source: MetadataSource::Audible, + }, + collection_contents, + )) + } + async fn details(&self, identifier: &str) -> Result { let mut rsp = self .client @@ -182,7 +239,7 @@ impl MediaProvider for AudibleService { let data: AudibleItemResponse = rsp.body_json().await.map_err(|e| anyhow!(e))?; let mut groups = vec![]; for s in data.product.clone().series.unwrap_or_default() { - groups.push(self.group_details(&s.asin).await?); + groups.push(s.asin); } let mut item = self.audible_response_to_search_response(data.product); let mut suggestions = vec![]; @@ -201,7 +258,7 @@ impl MediaProvider for AudibleService { .await .map_err(|e| anyhow!(e))?; for sim in data.similar_products.into_iter() { - suggestions.push(PartialMetadata { + suggestions.push(PartialMetadataWithoutId { title: sim.title, image: sim.product_images.unwrap().image_500, identifier: sim.asin, @@ -211,7 +268,7 @@ impl MediaProvider for AudibleService { } } item.suggestions = suggestions.into_iter().unique().collect(); - item.groups = groups; + item.group_identifiers = groups; Ok(item) } @@ -270,62 +327,6 @@ impl MediaProvider for AudibleService { } impl AudibleService { - async fn group_details( - &self, - identifier: &str, - ) -> Result<(metadata_group::Model, Vec)> { - let data: AudibleItemResponse = self - .client - .get(identifier) - .query(&PrimaryQuery::default()) - .unwrap() - .await - .map_err(|e| anyhow!(e))? - .body_json() - .await - .map_err(|e| anyhow!(e))?; - let items = data - .product - .relationships - .unwrap() - .into_iter() - .sorted_by_key(|f| f.sort.parse::().unwrap()) - .map(|i| i.asin) - .collect_vec(); - let mut collection_contents = vec![]; - for i in items { - let mut rsp = self - .client - .get(&i) - .query(&PrimaryQuery::default()) - .unwrap() - .await - .map_err(|e| anyhow!(e))?; - let data: AudibleItemResponse = rsp.body_json().await.map_err(|e| anyhow!(e))?; - collection_contents.push(PartialMetadata { - title: data.product.title, - image: data.product.product_images.and_then(|i| i.image_2400), - identifier: i, - source: MetadataSource::Audible, - lot: MetadataLot::AudioBook, - }) - } - Ok(( - metadata_group::Model { - id: 0, - display_images: vec![], - parts: collection_contents.len().try_into().unwrap(), - identifier: identifier.to_owned(), - title: data.product.title, - description: None, - images: MetadataImages(vec![]), - lot: MetadataLot::AudioBook, - source: MetadataSource::Audible, - }, - collection_contents, - )) - } - fn audible_response_to_search_response(&self, item: AudibleItem) -> MediaDetails { let images = Vec::from_iter(item.product_images.unwrap().image_2400.map(|a| { MetadataImageForMediaDetails { @@ -391,7 +392,7 @@ impl AudibleService { videos: vec![], provider_rating: rating, suggestions: vec![], - groups: vec![], + group_identifiers: vec![], people: vec![], s3_images: vec![], } diff --git a/apps/backend/src/providers/google_books.rs b/apps/backend/src/providers/google_books.rs index 49ee88122f..a3de9d52d0 100644 --- a/apps/backend/src/providers/google_books.rs +++ b/apps/backend/src/providers/google_books.rs @@ -227,7 +227,7 @@ impl GoogleBooksService { provider_rating: item.average_rating, // DEV: I could not find a way to get similar books from the API suggestions: vec![], - groups: vec![], + group_identifiers: vec![], videos: vec![], is_nsfw: None, people: vec![], diff --git a/apps/backend/src/providers/igdb.rs b/apps/backend/src/providers/igdb.rs index 70a6b294f1..a3cfac4d3e 100644 --- a/apps/backend/src/providers/igdb.rs +++ b/apps/backend/src/providers/igdb.rs @@ -14,13 +14,15 @@ use surf::{http::headers::AUTHORIZATION, Client}; use crate::{ config::VideoGameConfig, - entities::metadata_group, + entities::{ + metadata_group::MetadataGroupWithoutId, partial_metadata::PartialMetadataWithoutId, + }, migrator::{MetadataLot, MetadataSource}, models::{ media::{ MediaDetails, MediaSearchItem, MediaSpecifics, MetadataImageForMediaDetails, MetadataImageLot, MetadataImages, MetadataPerson, MetadataVideo, MetadataVideoSource, - PartialMetadata, PartialMetadataPerson, VideoGameSpecifics, + PartialMetadataPerson, VideoGameSpecifics, }, IdObject, NamedObject, SearchDetails, SearchResults, StoredUrl, }, @@ -143,6 +145,67 @@ impl IgdbService { #[async_trait] impl MediaProvider for IgdbService { + async fn group_details( + &self, + identifier: &str, + ) -> Result<(MetadataGroupWithoutId, Vec)> { + let client = get_client(&self.config).await; + let req_body = format!( + r" +fields + id, + name, + games.id, + games.name, + games.cover.*, + games.version_parent; +where id = {id}; + ", + id = identifier + ); + let details: IgdbItemResponse = client + .post("collections") + .body_string(req_body) + .await + .map_err(|e| anyhow!(e))? + .body_json::>() + .await + .map_err(|e| anyhow!(e))? + .pop() + .unwrap(); + let items = details + .games + .unwrap_or_default() + .into_iter() + .flat_map(|g| { + if g.version_parent.is_some() { + None + } else { + Some(PartialMetadataWithoutId { + identifier: g.id.to_string(), + title: g.name.unwrap(), + image: g.cover.map(|c| self.get_cover_image_url(c.image_id)), + source: MetadataSource::Igdb, + lot: MetadataLot::VideoGame, + }) + } + }) + .collect_vec(); + Ok(( + MetadataGroupWithoutId { + display_images: vec![], + parts: items.len().try_into().unwrap(), + identifier: details.id.to_string(), + title: details.name.unwrap_or_default(), + description: None, + images: MetadataImages(vec![]), + lot: MetadataLot::VideoGame, + source: MetadataSource::Igdb, + }, + items, + )) + } + async fn person_details(&self, identity: PartialMetadataPerson) -> Result { let client = get_client(&self.config).await; let req_body = format!( @@ -207,11 +270,11 @@ where id = {id}; let mut details: Vec = rsp.body_json().await.map_err(|e| anyhow!(e))?; let detail = details.pop().unwrap(); let groups = match detail.collection.as_ref() { - Some(c) => vec![self.group_details(&c.id.to_string()).await?], + Some(c) => vec![c.id.to_string()], None => vec![], }; let mut game_details = self.igdb_response_to_search_response(detail); - game_details.groups = groups; + game_details.group_identifiers = groups; Ok(game_details) } @@ -268,68 +331,6 @@ offset: {offset}; } impl IgdbService { - async fn group_details( - &self, - identifier: &str, - ) -> Result<(metadata_group::Model, Vec)> { - let client = get_client(&self.config).await; - let req_body = format!( - r" -fields - id, - name, - games.id, - games.name, - games.cover.*, - games.version_parent; -where id = {id}; - ", - id = identifier - ); - let details: IgdbItemResponse = client - .post("collections") - .body_string(req_body) - .await - .map_err(|e| anyhow!(e))? - .body_json::>() - .await - .map_err(|e| anyhow!(e))? - .pop() - .unwrap(); - let items = details - .games - .unwrap_or_default() - .into_iter() - .flat_map(|g| { - if g.version_parent.is_some() { - None - } else { - Some(PartialMetadata { - identifier: g.id.to_string(), - title: g.name.unwrap(), - image: g.cover.map(|c| self.get_cover_image_url(c.image_id)), - source: MetadataSource::Igdb, - lot: MetadataLot::VideoGame, - }) - } - }) - .collect_vec(); - Ok(( - metadata_group::Model { - id: 0, - display_images: vec![], - parts: items.len().try_into().unwrap(), - identifier: details.id.to_string(), - title: details.name.unwrap_or_default(), - description: None, - images: MetadataImages(vec![]), - lot: MetadataLot::VideoGame, - source: MetadataSource::Igdb, - }, - items, - )) - } - fn igdb_response_to_search_response(&self, item: IgdbItemResponse) -> MediaDetails { let mut images = Vec::from_iter(item.cover.map(|a| MetadataImageForMediaDetails { image: self.get_cover_image_url(a.image_id), @@ -408,7 +409,7 @@ where id = {id}; .similar_games .unwrap_or_default() .into_iter() - .map(|g| PartialMetadata { + .map(|g| PartialMetadataWithoutId { title: g.name.unwrap(), image: g.cover.map(|c| self.get_cover_image_url(c.image_id)), identifier: g.id.to_string(), @@ -417,7 +418,7 @@ where id = {id}; }) .collect(), provider_rating: item.rating, - groups: vec![], + group_identifiers: vec![], is_nsfw: None, creators: vec![], s3_images: vec![], diff --git a/apps/backend/src/providers/itunes.rs b/apps/backend/src/providers/itunes.rs index fd9ebe9318..81629096f3 100644 --- a/apps/backend/src/providers/itunes.rs +++ b/apps/backend/src/providers/itunes.rs @@ -182,7 +182,7 @@ impl MediaProvider for ITunesService { provider_rating: None, // DEV: I could not find a way to get similar podcasts from the API suggestions: vec![], - groups: vec![], + group_identifiers: vec![], videos: vec![], is_nsfw: None, people: vec![], diff --git a/apps/backend/src/providers/listennotes.rs b/apps/backend/src/providers/listennotes.rs index 8778bd8391..cd3e8073ff 100644 --- a/apps/backend/src/providers/listennotes.rs +++ b/apps/backend/src/providers/listennotes.rs @@ -13,12 +13,12 @@ use surf::Client; use crate::{ config::PodcastConfig, + entities::partial_metadata::PartialMetadataWithoutId, migrator::{MetadataLot, MetadataSource}, models::{ media::{ FreeMetadataCreator, MediaDetails, MediaSearchItem, MediaSpecifics, - MetadataImageForMediaDetails, MetadataImageLot, PartialMetadata, PodcastEpisode, - PodcastSpecifics, + MetadataImageForMediaDetails, MetadataImageLot, PodcastEpisode, PodcastSpecifics, }, SearchDetails, SearchResults, }, @@ -85,7 +85,7 @@ impl MediaProvider for ListennotesService { details.suggestions = rec_data .recommendations .into_iter() - .map(|r| PartialMetadata { + .map(|r| PartialMetadataWithoutId { title: r.title, image: r.thumbnail, identifier: r.id, @@ -256,7 +256,7 @@ impl ListennotesService { }), provider_rating: podcast_data.listen_score, suggestions: vec![], - groups: vec![], + group_identifiers: vec![], people: vec![], s3_images: vec![], }) diff --git a/apps/backend/src/providers/mal.rs b/apps/backend/src/providers/mal.rs index cc95ca35c2..dcbbe85b65 100644 --- a/apps/backend/src/providers/mal.rs +++ b/apps/backend/src/providers/mal.rs @@ -9,11 +9,12 @@ use surf::Client; use crate::{ config::MalConfig, + entities::partial_metadata::PartialMetadataWithoutId, migrator::{MetadataLot, MetadataSource}, models::{ media::{ AnimeSpecifics, MangaSpecifics, MediaDetails, MediaSearchItem, MediaSpecifics, - MetadataImageForMediaDetails, MetadataImageLot, PartialMetadata, + MetadataImageForMediaDetails, MetadataImageLot, }, NamedObject, SearchDetails, SearchResults, }, @@ -229,7 +230,7 @@ async fn details(client: &Client, media_type: &str, id: &str) -> Result Result Result Result() .await { - suggestions.push(PartialMetadata { + suggestions.push(PartialMetadataWithoutId { title: data.title.unwrap(), image: data.image.unwrap().url.original, identifier: data.series_id.unwrap().to_string(), @@ -273,7 +273,7 @@ impl MediaProvider for MangaUpdatesService { provider_rating: data.bayesian_rating, videos: vec![], publish_date: None, - groups: vec![], + group_identifiers: vec![], is_nsfw: None, creators: vec![], s3_images: vec![], diff --git a/apps/backend/src/providers/openlibrary.rs b/apps/backend/src/providers/openlibrary.rs index 2fb60e72c5..eba1d923c2 100644 --- a/apps/backend/src/providers/openlibrary.rs +++ b/apps/backend/src/providers/openlibrary.rs @@ -13,12 +13,12 @@ use surf_retry::{ExponentialBackoff, RetryMiddleware}; use crate::{ config::OpenlibraryConfig, + entities::partial_metadata::PartialMetadataWithoutId, migrator::{MetadataLot, MetadataSource}, models::{ media::{ BookSpecifics, MediaDetails, MediaSearchItem, MediaSpecifics, - MetadataImageForMediaDetails, MetadataImageLot, MetadataPerson, PartialMetadata, - PartialMetadataPerson, + MetadataImageForMediaDetails, MetadataImageLot, MetadataPerson, PartialMetadataPerson, }, SearchDetails, SearchResults, }, @@ -314,7 +314,7 @@ impl MediaProvider for OpenlibraryService { .next() .and_then(|img| img.value().attr("src")) .map(|src| src.to_string()); - suggestions.push(PartialMetadata { + suggestions.push(PartialMetadataWithoutId { title: name, image, identifier, @@ -342,7 +342,7 @@ impl MediaProvider for OpenlibraryService { publish_date: None, provider_rating: None, videos: vec![], - groups: vec![], + group_identifiers: vec![], is_nsfw: None, creators: vec![], s3_images: vec![], diff --git a/apps/backend/src/providers/tmdb.rs b/apps/backend/src/providers/tmdb.rs index 058e1434fb..c93c37bb68 100644 --- a/apps/backend/src/providers/tmdb.rs +++ b/apps/backend/src/providers/tmdb.rs @@ -13,14 +13,16 @@ use surf::{http::headers::AUTHORIZATION, Client}; use crate::{ config::TmdbConfig, - entities::metadata_group, + entities::{ + metadata_group::MetadataGroupWithoutId, partial_metadata::PartialMetadataWithoutId, + }, migrator::{MetadataLot, MetadataSource}, models::{ media::{ MediaDetails, MediaSearchItem, MediaSpecifics, MetadataImage, MetadataImageForMediaDetails, MetadataImageLot, MetadataImages, MetadataPerson, - MetadataVideo, MetadataVideoSource, MovieSpecifics, PartialMetadata, - PartialMetadataPerson, ShowEpisode, ShowSeason, ShowSpecifics, + MetadataVideo, MetadataVideoSource, MovieSpecifics, PartialMetadataPerson, ShowEpisode, + ShowSeason, ShowSpecifics, }, IdObject, NamedObject, SearchDetails, SearchResults, StoredUrl, }, @@ -210,11 +212,14 @@ impl TmdbMovieService { }, } } +} +#[async_trait] +impl MediaProvider for TmdbMovieService { async fn group_details( &self, identifier: &str, - ) -> Result<(metadata_group::Model, Vec)> { + ) -> Result<(MetadataGroupWithoutId, Vec)> { #[derive(Debug, Serialize, Deserialize, Clone)] struct TmdbCollection { id: i32, @@ -249,7 +254,7 @@ impl TmdbMovieService { let parts = data .parts .into_iter() - .map(|p| PartialMetadata { + .map(|p| PartialMetadataWithoutId { title: p.title.unwrap(), identifier: p.id.to_string(), source: MetadataSource::Tmdb, @@ -258,8 +263,7 @@ impl TmdbMovieService { }) .collect_vec(); Ok(( - metadata_group::Model { - id: 0, + MetadataGroupWithoutId { display_images: vec![], parts: parts.len().try_into().unwrap(), identifier: identifier.to_owned(), @@ -281,10 +285,7 @@ impl TmdbMovieService { parts, )) } -} -#[async_trait] -impl MediaProvider for TmdbMovieService { async fn details(&self, identifier: &str) -> Result { let mut rsp = self .client @@ -397,13 +398,6 @@ impl MediaProvider for TmdbMovieService { .save_all_suggestions(&self.client, "movie", identifier) .await?; - let groups = match data.belongs_to_collection { - Some(c) => Some(self.group_details(&c.id.to_string()).await?), - None => None, - } - .into_iter() - .collect(); - Ok(MediaDetails { identifier: data.id.to_string(), is_nsfw: data.adult, @@ -437,7 +431,6 @@ impl MediaProvider for TmdbMovieService { runtime: data.runtime, }), suggestions, - groups, provider_rating: if let Some(av) = data.vote_average { if av != dec!(0) { Some(av * dec!(10)) @@ -449,6 +442,10 @@ impl MediaProvider for TmdbMovieService { }, creators: vec![], s3_images: vec![], + group_identifiers: Vec::from_iter(data.belongs_to_collection) + .into_iter() + .map(|c| c.id.to_string()) + .collect(), }) } @@ -736,7 +733,7 @@ impl MediaProvider for TmdbShowService { } else { None }, - groups: vec![], + group_identifiers: vec![], creators: vec![], s3_images: vec![], }) @@ -861,7 +858,7 @@ impl TmdbService { client: &Client, typ: &str, identifier: &str, - ) -> Result> { + ) -> Result> { let lot = match typ { "movie" => MetadataLot::Movie, "tv" => MetadataLot::Show, @@ -886,7 +883,7 @@ impl TmdbService { } else { continue; }; - suggestions.push(PartialMetadata { + suggestions.push(PartialMetadataWithoutId { title: name, image: entry.poster_path.map(|p| self.get_cover_image_url(p)), identifier: entry.id.to_string(), diff --git a/apps/backend/src/providers/vndb.rs b/apps/backend/src/providers/vndb.rs index a79e65a149..9ded7db79a 100644 --- a/apps/backend/src/providers/vndb.rs +++ b/apps/backend/src/providers/vndb.rs @@ -241,9 +241,9 @@ impl VndbService { is_nsfw: None, videos: vec![], suggestions: vec![], - groups: vec![], creators: vec![], s3_images: vec![], + group_identifiers: vec![], } } } diff --git a/apps/backend/src/traits.rs b/apps/backend/src/traits.rs index 75e7ac42db..26ec38588c 100644 --- a/apps/backend/src/traits.rs +++ b/apps/backend/src/traits.rs @@ -5,6 +5,9 @@ use async_graphql::{Context, Error, Result as GraphqlResult}; use async_trait::async_trait; use crate::{ + entities::{ + metadata_group::MetadataGroupWithoutId, partial_metadata::PartialMetadataWithoutId, + }, file_storage::FileStorageService, models::{ media::{MediaDetails, MediaSearchItem, MetadataPerson, PartialMetadataPerson}, @@ -15,7 +18,7 @@ use crate::{ #[async_trait] pub trait MediaProvider { - /// Search for something using a particular query and offset. + /// Search for a query. #[allow(unused_variables)] async fn search( &self, @@ -26,17 +29,26 @@ pub trait MediaProvider { bail!("This provider does not support searching media") } - /// Get details about a media item for the particular identifier. + /// Get details about a media item. #[allow(unused_variables)] async fn details(&self, identifier: &str) -> Result { bail!("This provider does not support getting media details") } - /// Get details about a person for the particular details. + /// Get details about a person. #[allow(unused_variables)] async fn person_details(&self, identity: PartialMetadataPerson) -> Result { bail!("This provider does not support getting person details") } + + /// Get details about a group/collection. + #[allow(unused_variables)] + async fn group_details( + &self, + identifier: &str, + ) -> Result<(MetadataGroupWithoutId, Vec)> { + bail!("This provider does not support getting group details") + } } pub trait MediaProviderLanguages { diff --git a/apps/frontend/src/pages/fitness/measurements.tsx b/apps/frontend/src/pages/fitness/measurements.tsx index 8b5e1a4dda..2c4053784d 100644 --- a/apps/frontend/src/pages/fitness/measurements.tsx +++ b/apps/frontend/src/pages/fitness/measurements.tsx @@ -214,9 +214,8 @@ const Page: NextPageWithLayout = () => { e.preventDefault(); const submitData = {}; const formData = new FormData(e.currentTarget); - for (const [name, value] of formData.entries()) { + for (const [name, value] of formData.entries()) if (value !== "") set(submitData, name, value); - } if (Object.keys(submitData).length > 0) { createUserMeasurement.mutate({ // biome-ignore lint/suspicious/noExplicitAny: required diff --git a/apps/frontend/src/pages/settings/imports-and-exports/index.tsx b/apps/frontend/src/pages/settings/imports-and-exports/index.tsx index 4e6e3d62c2..edaad1e7aa 100644 --- a/apps/frontend/src/pages/settings/imports-and-exports/index.tsx +++ b/apps/frontend/src/pages/settings/imports-and-exports/index.tsx @@ -278,6 +278,7 @@ const Page: NextPageWithLayout = () => { onClick={() => { generateAuthToken.mutate({}); }} + loading={generateAuthToken.isLoading} > Create auth token diff --git a/docs/content/guides/exporting.md b/docs/content/guides/exporting.md index bc1b5114ff..2babc8e9be 100644 --- a/docs/content/guides/exporting.md +++ b/docs/content/guides/exporting.md @@ -5,7 +5,7 @@ Users can export either their entire data or individual parts of it. To start, login to your Ryot instance and go to the "Imports and Exports" section in the "Settings" section. Select the "Export" tab and then generate a new auth token. -The base endpoint is `/export/`. So requests will look like: +The endpoint is in the format of `/export/`. So requests will look like: ```bash curl /export/ --header 'X-Auth-Token: ' @@ -20,7 +20,7 @@ The export has the following type: `ExportAllResponse`. ## Media (`type=media`) This will return all media that the user has an -[association](https://github.com/IgnisDa/ryot/blob/main/apps/backend/src/migrator/m20230417_create_user.rs#L11-L18) +[association](https://github.com/IgnisDa/ryot/blob/main/apps/backend/src/migrator/m20230417_create_user.rs#L11-L17) with. The export has the following type: `ImportOrExportMediaItem[]`. diff --git a/docs/includes/export-schema.ts b/docs/includes/export-schema.ts index 6a31741553..329f7e07c4 100644 --- a/docs/includes/export-schema.ts +++ b/docs/includes/export-schema.ts @@ -18,6 +18,9 @@ export type ExportAllResponse = { measurements: ExportUserMeasurementItem[]; }; +/** + * An export of a measurement taken at a point in time. + */ export type ExportUserMeasurementItem = { /** * The date and time this measurement was made. @@ -26,59 +29,68 @@ export type ExportUserMeasurementItem = { /** * The name given to this measurement by the user. */ - name: string | null; + name?: string | null; /** * Any comment associated entered by the user. */ - comment: string | null; + comment?: string | null; /** * The contents of the actual measurement. */ stats: UserMeasurementStats; }; +/** + * A rating given to an entity. + */ export type ImportOrExportItemRating = { /** * Data about the review. */ - review: ImportOrExportItemReview | null; + review?: ImportOrExportItemReview | null; /** * The score of the review. */ - rating: string | null; + rating?: string | null; /** * If for a show, the season for which this review was for. */ - show_season_number: number | null; + show_season_number?: number | null; /** * If for a show, the episode for which this review was for. */ - show_episode_number: number | null; + show_episode_number?: number | null; /** * If for a podcast, the episode for which this review was for. */ - podcast_episode_number: number | null; + podcast_episode_number?: number | null; /** * The comments attached to this review. */ - comments: ImportOrExportItemReviewComment[] | null; + comments?: ImportOrExportItemReviewComment[] | null; }; +/** + * Review data associated to a rating. + */ export type ImportOrExportItemReview = { /** * The date the review was posted. */ - date: string | null; + date?: string | null; /** * Whether to mark the review as a spoiler. Defaults to false. */ - spoiler: boolean | null; + spoiler?: boolean | null; /** * Actual text for the review. */ - text: string | null; + text?: string | null; }; +/** + * Comments left in replies to posted reviews. + */ export type ImportOrExportItemReviewComment = { id: string; text: string; @@ -124,31 +136,34 @@ export type ImportOrExportMediaItem = { collections: string[]; }; +/** + * A specific instance when an entity was seen. + */ export type ImportOrExportMediaItemSeen = { /** * The progress of media done. If none, it is considered as done. */ - progress: number | null; + progress?: number | null; /** * The timestamp when started watching. */ - started_on: string | null; + started_on?: string | null; /** * The timestamp when finished watching. */ - ended_on: string | null; + ended_on?: string | null; /** * If for a show, the season which was seen. */ - show_season_number: number | null; + show_season_number?: number | null; /** * If for a show, the episode which was seen. */ - show_episode_number: number | null; + show_episode_number?: number | null; /** * If for a podcast, the episode which was seen. */ - podcast_episode_number: number | null; + podcast_episode_number?: number | null; }; /** @@ -192,8 +207,14 @@ export type MetadataSource = | "Tmdb" | "Vndb"; +/** + * A user that has commented on a review. + */ export type ReviewCommentUser = { id: number; name: string }; +/** + * The actual statistics that were logged in a user measurement. + */ export type UserMeasurementStats = { weight?: string | null; body_mass_index?: string | null;