diff --git a/Cargo.lock b/Cargo.lock index 487bad03ae..c7e18594fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -535,6 +535,17 @@ version = "4.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ecc7ab41815b3c653ccd2978ec3255c81349336702dfdf62ee6f7069b12a3aae" +[[package]] +name = "async-timer" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5fa6ed76cb2aa820707b4eb9ec46f42da9ce70b0eafab5e5e34942b38a44d5" +dependencies = [ + "libc", + "wasm-bindgen", + "winapi 0.3.9", +] + [[package]] name = "async-tls" version = "0.10.0" @@ -2258,6 +2269,12 @@ dependencies = [ "thiserror", ] +[[package]] +name = "harsh" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6fce2283849822530a18d7d8eeb1719ac65a27cfb6649c0dc8dfd2d2cc5edfb" + [[package]] name = "hashbrown" version = "0.12.3" @@ -2980,6 +2997,15 @@ dependencies = [ "version_check", ] +[[package]] +name = "nanoid" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ffa00dec017b5b1a8b7cf5e2c008bfda1aa7e0697ac1508b491fdf2622fb4d8" +dependencies = [ + "rand 0.8.5", +] + [[package]] name = "net2" version = "0.2.39" @@ -3859,6 +3885,18 @@ dependencies = [ "winreg", ] +[[package]] +name = "retainer" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8c01a8276c14d0f8d51ebcf8a48f0748f9f73f5f6b29e688126e6a52bcb145" +dependencies = [ + "async-lock", + "async-timer", + "log", + "rand 0.8.5", +] + [[package]] name = "retry-policies" version = "0.1.2" @@ -4148,7 +4186,7 @@ checksum = "dc31bd9b61a32c31f9650d18add92aa83a49ba979c143eefd27fe7177b05bd5f" [[package]] name = "ryot" -version = "1.13.2" +version = "1.14.0-beta.1" dependencies = [ "anyhow", "apalis", @@ -4168,14 +4206,17 @@ dependencies = [ "enum_meta", "futures 0.3.28", "graphql_client", + "harsh", "http", "http-types", "isolang", "itertools", "markdown", "mime_guess", + "nanoid", "quick-xml", "regex", + "retainer", "rstest", "rust-embed", "rust_decimal", diff --git a/README.md b/README.md index 8977da767c..050454e633 100644 --- a/README.md +++ b/README.md @@ -54,9 +54,9 @@ special tool on your computer or phone that lets you keep track of all these dig ## 🚀 Features - ✅ [Supports](https://github.com/IgnisDa/ryot/discussions/4) tracking media - and fitness. + and fitness - ✅ Import data from Goodreads, MediaTracker, Trakt, Movary, StoryGraph -- ✅ Integration with Kodi, Audiobookshelf +- ✅ Integration with Jellyfin, Kodi, Audiobookshelf - ✅ Self-hosted - ✅ PWA enabled - ✅ Documented GraphQL API diff --git a/apps/backend/Cargo.toml b/apps/backend/Cargo.toml index c999eec9b6..48ebe83b9c 100644 --- a/apps/backend/Cargo.toml +++ b/apps/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ryot" -version = "1.13.2" +version = "1.14.0-beta.1" edition = "2021" repository = "https://github.com/IgnisDa/ryot" license = "GPL-V3" @@ -29,14 +29,17 @@ dotenvy = "0.15.7" enum_meta = "0.6.0" futures = "0.3.28" graphql_client = "0.13.0" +harsh = "0.2.2" http = "0.2.9" http-types = "2.12.0" isolang = { version = "2.3.0", features = ["list_languages"] } itertools = "0.10.5" markdown = "1.0.0-alpha.10" mime_guess = "2.0.4" +nanoid = "0.4.0" quick-xml = { version = "0.28.2", features = ["serde", "serialize"] } regex = "1.8.1" +retainer = "0.3.0" rust-embed = "6.6.1" rust_decimal = "1.29.1" rust_decimal_macros = "1.29.1" diff --git a/apps/backend/src/config.rs b/apps/backend/src/config.rs index c1f3f0a176..601afa319a 100644 --- a/apps/backend/src/config.rs +++ b/apps/backend/src/config.rs @@ -324,6 +324,9 @@ pub struct IntegrationConfig { /// every `n` hours. #[setting(default = 2)] pub pull_every: i32, + /// The salt used to hash user IDs. + #[setting(default = format!("{}", PROJECT_NAME))] + pub hasher_salt: String, } impl IsFeatureEnabled for FileStorageConfig { @@ -386,6 +389,11 @@ pub struct ServerConfig { /// are running the server on `localhost`. /// [More information](https://github.com/IgnisDa/ryot/issues/23) pub insecure_cookie: bool, + /// The hours in which a media can be marked as seen again for a user. This + /// is used so that the same media can not be used marked as started when + /// it has been already marked as seen in the last `n` hours. + #[setting(default = 2)] + pub progress_update_threshold: u64, } #[derive(Debug, Serialize, Deserialize, Clone, Config)] @@ -453,6 +461,7 @@ impl AppConfig { cl.file_storage.s3_access_key_id = gt(); cl.file_storage.s3_secret_access_key = gt(); cl.file_storage.s3_url = gt(); + cl.integration.hasher_salt = gt(); cl.movies.tmdb.access_token = gt(); cl.podcasts.listennotes.api_token = gt(); cl.shows.tmdb.access_token = gt(); diff --git a/apps/backend/src/entities/user.rs b/apps/backend/src/entities/user.rs index fe9b6afdf8..f971ecff04 100644 --- a/apps/backend/src/entities/user.rs +++ b/apps/backend/src/entities/user.rs @@ -11,7 +11,7 @@ use serde::{Deserialize, Serialize}; use crate::{ migrator::UserLot, - users::{UserPreferences, UserYankIntegrations}, + users::{UserPreferences, UserSinkIntegrations, UserYankIntegrations}, }; fn get_hasher() -> Argon2<'static> { @@ -33,6 +33,8 @@ pub struct Model { pub preferences: UserPreferences, #[graphql(skip)] pub yank_integrations: Option, + #[graphql(skip)] + pub sink_integrations: UserSinkIntegrations, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/apps/backend/src/graphql.rs b/apps/backend/src/graphql.rs index 79a5d21766..1ec9ffb07a 100644 --- a/apps/backend/src/graphql.rs +++ b/apps/backend/src/graphql.rs @@ -5,7 +5,7 @@ use crate::{ fitness::exercise::resolver::{ExerciseMutation, ExerciseQuery}, importer::{ImporterMutation, ImporterQuery}, miscellaneous::resolver::{MiscellaneousMutation, MiscellaneousQuery}, - utils::{AppServices, MemoryAuthDb}, + utils::AppServices, }; #[derive(Debug, SimpleObject, Serialize, Deserialize)] @@ -21,13 +21,12 @@ pub struct MutationRoot(MiscellaneousMutation, ImporterMutation, ExerciseMutatio pub type GraphqlSchema = Schema; -pub async fn get_schema(app_services: &AppServices, auth_db: MemoryAuthDb) -> GraphqlSchema { +pub async fn get_schema(app_services: &AppServices) -> GraphqlSchema { Schema::build( QueryRoot::default(), MutationRoot::default(), EmptySubscription, ) - .data(auth_db) .data(app_services.media_service.clone()) .data(app_services.importer_service.clone()) .data(app_services.exercise_service.clone()) diff --git a/apps/backend/src/importer/goodreads.rs b/apps/backend/src/importer/goodreads.rs index 7b45bb6720..33ad053b4a 100644 --- a/apps/backend/src/importer/goodreads.rs +++ b/apps/backend/src/importer/goodreads.rs @@ -1,7 +1,7 @@ use async_graphql::Result; use chrono::{DateTime, Utc}; use itertools::Itertools; -use rust_decimal::{prelude::FromPrimitive, Decimal}; +use rust_decimal::Decimal; use rust_decimal_macros::dec; use serde::{Deserialize, Serialize}; @@ -79,8 +79,7 @@ pub async fn import(input: DeployGoodreadsImportInput) -> Result { let rating: Decimal = d.user_rating.parse().unwrap(); if rating != dec!(0) { // DEV: Rates items out of 5 - single_review.rating = - Some(rating.saturating_mul(Decimal::from_u8(20).unwrap())) + single_review.rating = Some(rating.saturating_mul(dec!(20))) } }; if single_review.review.is_some() || single_review.rating.is_some() { diff --git a/apps/backend/src/importer/media_tracker.rs b/apps/backend/src/importer/media_tracker.rs index 78c10965a5..6ad7f486bd 100644 --- a/apps/backend/src/importer/media_tracker.rs +++ b/apps/backend/src/importer/media_tracker.rs @@ -1,7 +1,8 @@ // Responsible for importing from https://github.com/bonukai/MediaTracker. use async_graphql::Result; -use rust_decimal::{prelude::FromPrimitive, Decimal}; +use rust_decimal::Decimal; +use rust_decimal_macros::dec; use sea_orm::prelude::DateTimeUtc; use serde::{Deserialize, Serialize}; use serde_with::{formats::Flexible, serde_as, TimestampMilliSeconds}; @@ -295,9 +296,7 @@ pub async fn import(input: DeployMediaTrackerImportInput) -> Result, ) -> Result> { - let user_id = user_id_from_ctx(gql_ctx).await?; - gql_ctx - .data_unchecked::>() - .media_import_reports(user_id) - .await + let service = gql_ctx.data_unchecked::>(); + let user_id = service.user_id_from_ctx(gql_ctx).await?; + service.media_import_reports(user_id).await } } @@ -189,11 +188,9 @@ impl ImporterMutation { gql_ctx: &Context<'_>, input: DeployImportJobInput, ) -> Result { - let user_id = user_id_from_ctx(gql_ctx).await?; - gql_ctx - .data_unchecked::>() - .deploy_import_job(user_id, input) - .await + let service = gql_ctx.data_unchecked::>(); + let user_id = service.user_id_from_ctx(gql_ctx).await?; + service.deploy_import_job(user_id, input).await } } @@ -203,6 +200,12 @@ pub struct ImporterService { import_media: SqliteStorage, } +impl AuthProvider for ImporterService { + fn get_auth_db(&self) -> &MemoryDatabase { + self.media_service.get_auth_db() + } +} + impl ImporterService { #[allow(clippy::too_many_arguments)] pub fn new( diff --git a/apps/backend/src/importer/movary.rs b/apps/backend/src/importer/movary.rs index e75ecd44ea..244f4b4b58 100644 --- a/apps/backend/src/importer/movary.rs +++ b/apps/backend/src/importer/movary.rs @@ -1,7 +1,8 @@ use async_graphql::Result; use chrono::{DateTime, NaiveDate, NaiveDateTime, NaiveTime, Utc}; use csv::Reader; -use rust_decimal::{prelude::FromPrimitive, Decimal}; +use rust_decimal::Decimal; +use rust_decimal_macros::dec; use serde::{Deserialize, Serialize}; use crate::{ @@ -64,11 +65,7 @@ pub async fn import(input: DeployMovaryImportInput) -> Result { reviews: vec![ImportItemRating { id: None, // DEV: Rates items out of 10 - rating: Some( - record - .user_rating - .saturating_mul(Decimal::from_u16(10).unwrap()), - ), + rating: Some(record.user_rating.saturating_mul(dec!(10))), review: None, }], collections: vec![], diff --git a/apps/backend/src/importer/story_graph.rs b/apps/backend/src/importer/story_graph.rs index e9fbf16f01..842a1920e6 100644 --- a/apps/backend/src/importer/story_graph.rs +++ b/apps/backend/src/importer/story_graph.rs @@ -3,7 +3,8 @@ use chrono::{DateTime, NaiveDate, NaiveDateTime, NaiveTime, Utc}; use convert_case::{Case, Casing}; use csv::Reader; use itertools::Itertools; -use rust_decimal::{prelude::FromPrimitive, Decimal}; +use rust_decimal::Decimal; +use rust_decimal_macros::dec; use serde::{Deserialize, Serialize}; use crate::{ @@ -110,7 +111,7 @@ pub async fn import( rating: record .rating // DEV: Rates items out of 10 - .map(|d| d.saturating_mul(Decimal::from_u8(10).unwrap())), + .map(|d| d.saturating_mul(dec!(10))), review: record.review.map(|r| ImportItemReview { date: None, spoiler: false, diff --git a/apps/backend/src/integrations/mod.rs b/apps/backend/src/integrations/mod.rs index b3588a50ad..714266f324 100644 --- a/apps/backend/src/integrations/mod.rs +++ b/apps/backend/src/integrations/mod.rs @@ -1,4 +1,6 @@ -use anyhow::{anyhow, Result}; +use anyhow::{anyhow, bail, Result}; +use rust_decimal::{prelude::ToPrimitive, Decimal}; +use rust_decimal_macros::dec; use serde::{Deserialize, Serialize}; use surf::{http::headers::AUTHORIZATION, Client}; @@ -8,11 +10,14 @@ use crate::{ }; #[derive(Debug, Clone)] -pub struct YankIntegrationMedia { +pub struct IntegrationMedia { pub identifier: String, pub lot: MetadataLot, pub source: MetadataSource, pub progress: i32, + pub show_season_number: Option, + pub show_episode_number: Option, + pub podcast_episode_number: Option, } #[derive(Debug)] @@ -23,17 +28,96 @@ impl IntegrationService { Self } + pub async fn jellyfin_progress(&self, payload: &str) -> Result { + mod models { + use super::*; + + #[derive(Serialize, Deserialize, Debug, Clone)] + #[serde(rename_all = "PascalCase")] + pub struct JellyfinWebhookSessionPlayStatePayload { + pub position_ticks: Decimal, + } + #[derive(Serialize, Deserialize, Debug, Clone)] + #[serde(rename_all = "PascalCase")] + pub struct JellyfinWebhookSessionPayload { + pub play_state: JellyfinWebhookSessionPlayStatePayload, + } + #[derive(Serialize, Deserialize, Debug, Clone)] + #[serde(rename_all = "PascalCase")] + pub struct JellyfinWebhookItemProviderIdsPayload { + pub tmdb: Option, + } + #[derive(Serialize, Deserialize, Debug, Clone)] + #[serde(rename_all = "PascalCase")] + pub struct JellyfinWebhookItemUserDataPayload { + pub played: bool, + } + #[derive(Serialize, Deserialize, Debug, Clone)] + #[serde(rename_all = "PascalCase")] + pub struct JellyfinWebhookItemPayload { + pub run_time_ticks: Decimal, + #[serde(rename = "Type")] + pub item_type: String, + pub provider_ids: JellyfinWebhookItemProviderIdsPayload, + pub user_data: JellyfinWebhookItemUserDataPayload, + #[serde(rename = "ParentIndexNumber")] + pub season_number: Option, + #[serde(rename = "IndexNumber")] + pub episode_number: Option, + } + #[derive(Serialize, Deserialize, Debug, Clone)] + #[serde(rename_all = "PascalCase")] + pub struct JellyfinWebhookPayload { + pub event: Option, + pub item: JellyfinWebhookItemPayload, + pub series: Option, + pub session: JellyfinWebhookSessionPayload, + } + } + // std::fs::write("tmp/output.json", payload)?; + let payload = serde_json::from_str::(payload)?; + let identifier = if let Some(id) = payload.item.provider_ids.tmdb.as_ref() { + Some(id.clone()) + } else { + payload + .series + .as_ref() + .and_then(|s| s.provider_ids.tmdb.clone()) + }; + if let Some(identifier) = identifier { + let lot = match payload.item.item_type.as_str() { + "Episode" => MetadataLot::Show, + "Movie" => MetadataLot::Movie, + _ => bail!("Only movies and shows supported"), + }; + Ok(IntegrationMedia { + identifier, + lot, + source: MetadataSource::Tmdb, + progress: (payload.session.play_state.position_ticks / payload.item.run_time_ticks + * dec!(100)) + .to_i32() + .unwrap(), + podcast_episode_number: None, + show_season_number: payload.item.season_number, + show_episode_number: payload.item.episode_number, + }) + } else { + bail!("No TMDb ID associated with this media") + } + } + pub async fn audiobookshelf_progress( &self, base_url: &str, access_token: &str, - ) -> Result> { + ) -> Result> { mod models { use super::*; #[derive(Debug, Serialize, Deserialize)] pub struct ItemProgress { - pub progress: f32, + pub progress: Decimal, } #[derive(Debug, Serialize, Deserialize)] pub struct ItemMetadata { @@ -75,11 +159,14 @@ impl IntegrationService { .body_json() .await .unwrap(); - media_items.push(YankIntegrationMedia { + media_items.push(IntegrationMedia { identifier: asin, lot: MetadataLot::AudioBook, source: MetadataSource::Audible, - progress: (resp.progress * 100_f32) as i32, + progress: (resp.progress * dec!(100)).to_i32().unwrap(), + show_season_number: None, + show_episode_number: None, + podcast_episode_number: None, }); } } diff --git a/apps/backend/src/main.rs b/apps/backend/src/main.rs index 1540b63f2e..464524338e 100644 --- a/apps/backend/src/main.rs +++ b/apps/backend/src/main.rs @@ -23,23 +23,19 @@ use async_graphql_axum::{GraphQLRequest, GraphQLResponse}; use aws_sdk_s3::config::Region; use axum::{ body::{boxed, Full}, - extract::Multipart, + extract::{Multipart, Path}, headers::{authorization::Bearer, Authorization}, http::{header, HeaderMap, Method, StatusCode, Uri}, response::{Html, IntoResponse, Response}, routing::{get, post, Router}, Extension, Json, Server, TypedHeader, }; -use darkbird::{ - document::{Document, FullText, Indexer, MaterializedView, Range, RangeField, Tags}, - Options, Storage, StorageType, -}; +use darkbird::{Options, Storage, StorageType}; use http::header::AUTHORIZATION; use itertools::Itertools; use rust_embed::RustEmbed; -use sea_orm::{prelude::DateTimeUtc, ConnectOptions, Database, DatabaseConnection}; +use sea_orm::{ConnectOptions, Database, DatabaseConnection}; use sea_orm_migration::MigratorTrait; -use serde::{Deserialize, Serialize}; use serde_json::json; use sqlx::SqlitePool; use tokio::try_join; @@ -48,7 +44,6 @@ use tower_http::{ catch_panic::CatchPanicLayer as TowerCatchPanicLayer, cors::CorsLayer as TowerCorsLayer, trace::TraceLayer as TowerTraceLayer, }; -use utils::MemoryAuthDb; use uuid::Uuid; use crate::{ @@ -64,7 +59,8 @@ use crate::{ migrator::Migrator, miscellaneous::resolver::MiscellaneousService, utils::{ - create_app_services, user_id_from_token, BASE_DIR, COOKIE_NAME, PROJECT_NAME, VERSION, + create_app_services, user_id_from_token, MemoryAuthData, BASE_DIR, COOKIE_NAME, + PROJECT_NAME, VERSION, }, }; @@ -200,7 +196,7 @@ async fn main() -> Result<()> { .unwrap(); } - let schema = get_schema(&app_services, auth_db.clone()).await; + let schema = get_schema(&app_services).await; let cors = TowerCorsLayer::new() .allow_methods([Method::GET, Method::POST]) @@ -215,7 +211,13 @@ async fn main() -> Result<()> { ) .allow_credentials(true); - let app = Router::new() + let webhook_routes = Router::new().route( + "/integrations/:integration/:user_hash_id", + post(integration_webhook), + ); + + let app_routes = Router::new() + .nest("/webhooks", webhook_routes) .route("/config", get(config_handler)) .route("/upload", post(upload_handler)) .route("/graphql", get(graphql_playground).post(graphql_handler)) @@ -225,7 +227,6 @@ async fn main() -> Result<()> { .layer(Extension(app_services.file_storage_service.clone())) .layer(Extension(schema)) .layer(Extension(config.clone())) - .layer(Extension(auth_db.clone())) .layer(TowerTraceLayer::new_for_http()) .layer(TowerCatchPanicLayer::new()) .layer(CookieManagerLayer::new()) @@ -352,7 +353,7 @@ async fn main() -> Result<()> { let http = async { Server::bind(&addr) - .serve(app.into_make_service()) + .serve(app_routes.into_make_service()) .await .map_err(|e| IoError::new(IoErrorKind::Interrupted, e)) }; @@ -475,50 +476,26 @@ async fn upload_handler( async fn export( Extension(media_service): Extension>, - Extension(auth_db): Extension, TypedHeader(authorization): TypedHeader>, ) -> Result, (StatusCode, Json)> { - let user_id = user_id_from_token(authorization.token().to_owned(), &auth_db) + let user_id = user_id_from_token(authorization.token().to_owned(), &media_service.auth_db) .await .map_err(|e| (StatusCode::FORBIDDEN, Json(json!({"err": e.message}))))?; let resp = media_service.json_export(user_id).await.unwrap(); Ok(Json(json!(resp))) } -#[derive(Serialize, Deserialize, Clone, Debug)] -pub struct MemoryAuthData { - pub user_id: i32, - pub last_used_on: DateTimeUtc, -} - -impl Document for MemoryAuthData {} - -impl Indexer for MemoryAuthData { - fn extract(&self) -> Vec { - vec![] - } -} - -impl Tags for MemoryAuthData { - fn get_tags(&self) -> Vec { - vec![] - } -} - -impl Range for MemoryAuthData { - fn get_fields(&self) -> Vec { - vec![] - } -} - -impl MaterializedView for MemoryAuthData { - fn filter(&self) -> Option { - None - } -} - -impl FullText for MemoryAuthData { - fn get_content(&self) -> Option { - None - } +async fn integration_webhook( + Path((integration, user_hash_id)): Path<(String, String)>, + Extension(media_service): Extension>, + payload: String, +) -> std::result::Result { + media_service + .process_integration_webhook(user_hash_id, integration, payload) + .await + .map_err(|e| { + tracing::error!("{:?}", e); + StatusCode::UNPROCESSABLE_ENTITY + })?; + Ok(StatusCode::OK) } diff --git a/apps/backend/src/migrator/m20230417_000002_create_user.rs b/apps/backend/src/migrator/m20230417_000002_create_user.rs index 57035e6af1..9904925de7 100644 --- a/apps/backend/src/migrator/m20230417_000002_create_user.rs +++ b/apps/backend/src/migrator/m20230417_000002_create_user.rs @@ -48,6 +48,8 @@ pub enum User { Preferences, // This field can be `NULL` if the user has not enabled any yank integration YankIntegrations, + // This field can be `NULL` if the user has not enabled any sink integration + SinkIntegrations, } #[async_trait::async_trait] @@ -70,6 +72,7 @@ impl MigrationTrait for Migration { .col(ColumnDef::new(User::Lot).string_len(1).not_null()) .col(ColumnDef::new(User::Preferences).json().not_null()) .col(ColumnDef::new(User::YankIntegrations).json()) + .col(ColumnDef::new(User::SinkIntegrations).json()) .to_owned(), ) .await?; diff --git a/apps/backend/src/migrator/m20230702_000014_add_user_integrations_field.rs b/apps/backend/src/migrator/m20230702_000014_add_user_integrations_field.rs index dd07756cd9..e9838060f7 100644 --- a/apps/backend/src/migrator/m20230702_000014_add_user_integrations_field.rs +++ b/apps/backend/src/migrator/m20230702_000014_add_user_integrations_field.rs @@ -13,15 +13,16 @@ impl MigrationName for Migration { #[async_trait::async_trait] impl MigrationTrait for Migration { async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { - manager - .alter_table( - Table::alter() - .table(User::Table) - .add_column_if_not_exists(ColumnDef::new(User::YankIntegrations).json()) - .to_owned(), - ) - .await - .ok(); + if !manager.has_column("user", "yank_integrations").await? { + manager + .alter_table( + Table::alter() + .table(User::Table) + .add_column_if_not_exists(ColumnDef::new(User::YankIntegrations).json()) + .to_owned(), + ) + .await?; + } Ok(()) } diff --git a/apps/backend/src/migrator/m20230717_000018_add_user_sink_integrations_field.rs b/apps/backend/src/migrator/m20230717_000018_add_user_sink_integrations_field.rs new file mode 100644 index 0000000000..5da4c6474b --- /dev/null +++ b/apps/backend/src/migrator/m20230717_000018_add_user_sink_integrations_field.rs @@ -0,0 +1,43 @@ +use sea_orm::{ActiveValue, EntityTrait}; +use sea_orm_migration::prelude::*; + +use crate::{ + entities::{prelude::User as UserModel, user}, + migrator::m20230417_000002_create_user::User, + users::UserSinkIntegrations, +}; + +pub struct Migration; + +impl MigrationName for Migration { + fn name(&self) -> &str { + "m20230717_000018_add_user_sink_integrations_field" + } +} + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + if !manager.has_column("user", "sink_integrations").await? { + let db = manager.get_connection(); + manager + .alter_table( + Table::alter() + .table(User::Table) + .add_column_if_not_exists(ColumnDef::new(User::SinkIntegrations).json()) + .to_owned(), + ) + .await?; + let mut user = user::ActiveModel { + ..Default::default() + }; + user.sink_integrations = ActiveValue::Set(UserSinkIntegrations(vec![])); + UserModel::update_many().set(user).exec(db).await?; + } + Ok(()) + } + + async fn down(&self, _manager: &SchemaManager) -> Result<(), DbErr> { + Ok(()) + } +} diff --git a/apps/backend/src/migrator/mod.rs b/apps/backend/src/migrator/mod.rs index bb399ec8ef..733c7dcf1a 100644 --- a/apps/backend/src/migrator/mod.rs +++ b/apps/backend/src/migrator/mod.rs @@ -18,6 +18,7 @@ mod m20230702_000014_add_user_integrations_field; mod m20230707_000015_add_description_and_visibility_fields; mod m20230712_000016_remove_identifier_fields; mod m20230717_000017_change_rating_value; +mod m20230717_000018_add_user_sink_integrations_field; pub use m20230410_000001_create_metadata::{ Metadata, MetadataImageLot, MetadataLot, MetadataSource, @@ -50,6 +51,7 @@ impl MigratorTrait for Migrator { Box::new(m20230707_000015_add_description_and_visibility_fields::Migration), Box::new(m20230712_000016_remove_identifier_fields::Migration), Box::new(m20230717_000017_change_rating_value::Migration), + Box::new(m20230717_000018_add_user_sink_integrations_field::Migration), ] } } diff --git a/apps/backend/src/miscellaneous/resolver.rs b/apps/backend/src/miscellaneous/resolver.rs index 043e65ff5d..4b762b53f1 100644 --- a/apps/backend/src/miscellaneous/resolver.rs +++ b/apps/backend/src/miscellaneous/resolver.rs @@ -1,5 +1,6 @@ -use std::{collections::HashSet, sync::Arc}; +use std::{collections::HashSet, sync::Arc, time::Duration}; +use anyhow::anyhow; use apalis::{prelude::Storage as ApalisStorage, sqlite::SqliteStorage}; use argon2::{Argon2, PasswordHash, PasswordVerifier}; use async_graphql::{Context, Enum, Error, InputObject, Object, Result, SimpleObject, Union}; @@ -7,12 +8,15 @@ use chrono::{NaiveDate, Utc}; use cookie::{time::Duration as CookieDuration, time::OffsetDateTime, Cookie}; use enum_meta::Meta; use futures::TryStreamExt; +use harsh::Harsh; use http::header::SET_COOKIE; use itertools::Itertools; use markdown::{ to_html as markdown_to_html, to_html_with_options as markdown_to_html_opts, CompileOptions, Options, }; +use nanoid::nanoid; +use retainer::Cache; use rust_decimal::Decimal; use sea_orm::{ prelude::DateTimeUtc, ActiveModelTrait, ActiveValue, ColumnTrait, ConnectionTrait, @@ -28,6 +32,7 @@ use serde::{Deserialize, Serialize}; use strum::IntoEnumIterator; use uuid::Uuid; +use crate::traits::AuthProvider; use crate::{ background::{AfterMediaSeenJob, RecalculateUserSummaryJob, UpdateMetadataJob, UserCreatedJob}, config::AppConfig, @@ -43,7 +48,7 @@ use crate::{ file_storage::FileStorageService, graphql::IdObject, importer::ImportResultResponse, - integrations::IntegrationService, + integrations::{IntegrationMedia, IntegrationService}, migrator::{ MediaImportSource, Metadata as TempMetadata, MetadataImageLot, MetadataLot, MetadataSource, Review as TempReview, Seen as TempSeen, UserLot, UserToMetadata as TempUserToMetadata, @@ -74,12 +79,12 @@ use crate::{ }, traits::{IsFeatureEnabled, MediaProvider, MediaProviderLanguages}, users::{ - UserPreferences, UserYankIntegration, UserYankIntegrationSetting, UserYankIntegrations, + UserPreferences, UserSinkIntegration, UserSinkIntegrationSetting, UserSinkIntegrations, + UserYankIntegration, UserYankIntegrationSetting, UserYankIntegrations, }, utils::{ - get_case_insensitive_like_query, user_auth_token_from_ctx, user_id_from_ctx, - user_id_from_token, MemoryAuthDb, SearchInput, AUTHOR, COOKIE_NAME, PAGE_LIMIT, - REPOSITORY_LINK, VERSION, + get_case_insensitive_like_query, user_id_from_token, MemoryDatabase, SearchInput, AUTHOR, + COOKIE_NAME, PAGE_LIMIT, REPOSITORY_LINK, VERSION, }, MemoryAuthData, }; @@ -105,17 +110,23 @@ struct CreateCustomMediaInput { anime_specifics: Option, } +#[derive(Enum, Serialize, Deserialize, Clone, Debug, Copy, PartialEq, Eq)] +enum UserIntegrationLot { + Yank, + Sink, +} + #[derive(Enum, Serialize, Deserialize, Clone, Debug, Copy, PartialEq, Eq)] enum UserYankIntegrationLot { Audiobookshelf, } #[derive(Debug, Serialize, Deserialize, SimpleObject, Clone)] -struct GraphqlUserYankIntegration { +struct GraphqlUserIntegration { id: usize, - lot: UserYankIntegrationLot, description: String, timestamp: DateTimeUtc, + lot: UserIntegrationLot, } #[derive(Debug, Serialize, Deserialize, InputObject, Clone)] @@ -126,6 +137,16 @@ struct CreateUserYankIntegrationInput { token: String, } +#[derive(Enum, Serialize, Deserialize, Clone, Debug, Copy, PartialEq, Eq)] +enum UserSinkIntegrationLot { + Jellyfin, +} + +#[derive(Debug, Serialize, Deserialize, InputObject, Clone)] +struct CreateUserSinkIntegrationInput { + lot: UserSinkIntegrationLot, +} + #[derive(Enum, Clone, Debug, Copy, PartialEq, Eq)] enum CreateCustomMediaErrorVariant { LotDoesNotMatchSpecifics, @@ -408,6 +429,15 @@ struct CoreDetails { default_credentials: bool, } +#[derive(Debug, Ord, PartialEq, Eq, PartialOrd, Clone)] +struct ProgressUpdateCache { + user_id: i32, + metadata_id: i32, + show_season_number: Option, + show_episode_number: Option, + podcast_episode_number: Option, +} + fn create_cookie( ctx: &Context<'_>, api_key: &str, @@ -428,10 +458,14 @@ fn create_cookie( Ok(()) } -fn get_hasher() -> Argon2<'static> { +fn get_password_hasher() -> Argon2<'static> { Argon2::default() } +fn get_id_hasher(salt: &str) -> Harsh { + Harsh::builder().length(10).salt(salt).build().unwrap() +} + #[derive(Default)] pub struct MiscellaneousQuery; @@ -459,11 +493,9 @@ impl MiscellaneousQuery { gql_ctx: &Context<'_>, metadata_id: i32, ) -> Result> { - let user_id = user_id_from_ctx(gql_ctx).await?; - gql_ctx - .data_unchecked::>() - .media_item_reviews(&user_id, &metadata_id) - .await + let service = gql_ctx.data_unchecked::>(); + let user_id = service.user_id_from_ctx(gql_ctx).await?; + service.media_item_reviews(&user_id, &metadata_id).await } /// Get all collections for the currently logged in user. @@ -472,11 +504,9 @@ impl MiscellaneousQuery { gql_ctx: &Context<'_>, input: Option, ) -> Result> { - let user_id = user_id_from_ctx(gql_ctx).await?; - gql_ctx - .data_unchecked::>() - .collections(&user_id, input) - .await + let service = gql_ctx.data_unchecked::>(); + let user_id = service.user_id_from_ctx(gql_ctx).await?; + service.collections(&user_id, input).await } /// Get a list of collections in which a media is present. @@ -485,11 +515,9 @@ impl MiscellaneousQuery { gql_ctx: &Context<'_>, metadata_id: i32, ) -> Result> { - let user_id = user_id_from_ctx(gql_ctx).await?; - gql_ctx - .data_unchecked::>() - .media_in_collections(user_id, metadata_id) - .await + let service = gql_ctx.data_unchecked::>(); + let user_id = service.user_id_from_ctx(gql_ctx).await?; + service.media_in_collections(user_id, metadata_id).await } /// Get the contents of a collection and respect visibility. @@ -498,11 +526,9 @@ impl MiscellaneousQuery { gql_ctx: &Context<'_>, input: CollectionContentsInput, ) -> Result { - let user_id = user_id_from_ctx(gql_ctx).await.ok(); - gql_ctx - .data_unchecked::>() - .collection_contents(user_id, input) - .await + let service = gql_ctx.data_unchecked::>(); + let user_id = service.user_id_from_ctx(gql_ctx).await.ok(); + service.collection_contents(user_id, input).await } /// Get details about a media present in the database. @@ -523,11 +549,9 @@ impl MiscellaneousQuery { gql_ctx: &Context<'_>, metadata_id: i32, ) -> Result> { - let user_id = user_id_from_ctx(gql_ctx).await?; - gql_ctx - .data_unchecked::>() - .seen_history(metadata_id, user_id) - .await + let service = gql_ctx.data_unchecked::>(); + let user_id = service.user_id_from_ctx(gql_ctx).await?; + service.seen_history(metadata_id, user_id).await } /// Get all the media items related to a user for a specific media type. @@ -536,11 +560,9 @@ impl MiscellaneousQuery { gql_ctx: &Context<'_>, input: MediaListInput, ) -> Result> { - let user_id = user_id_from_ctx(gql_ctx).await?; - gql_ctx - .data_unchecked::>() - .media_list(user_id, input) - .await + let service = gql_ctx.data_unchecked::>(); + let user_id = service.user_id_from_ctx(gql_ctx).await?; + service.media_list(user_id, input).await } /// Get a presigned URL (valid for 90 minutes) for a given key. @@ -562,11 +584,9 @@ impl MiscellaneousQuery { /// Get a user's preferences. async fn user_preferences(&self, gql_ctx: &Context<'_>) -> Result { - let user_id = user_id_from_ctx(gql_ctx).await?; - gql_ctx - .data_unchecked::>() - .user_preferences(user_id) - .await + let service = gql_ctx.data_unchecked::>(); + let user_id = service.user_id_from_ctx(gql_ctx).await?; + service.user_preferences(user_id).await } /// Search for a list of media for a given type. @@ -621,50 +641,40 @@ impl MiscellaneousQuery { /// Get details about the currently logged in user. async fn users(&self, gql_ctx: &Context<'_>) -> Result> { - let user_id = user_id_from_ctx(gql_ctx).await?; - gql_ctx - .data_unchecked::>() - .users(user_id) - .await + let service = gql_ctx.data_unchecked::>(); + let user_id = service.user_id_from_ctx(gql_ctx).await?; + service.users(user_id).await } /// Get details about the currently logged in user. async fn user_details(&self, gql_ctx: &Context<'_>) -> Result { - let token = user_auth_token_from_ctx(gql_ctx)?; - gql_ctx - .data_unchecked::>() - .user_details(&token) - .await + let service = gql_ctx.data_unchecked::>(); + let token = service.user_auth_token_from_ctx(gql_ctx)?; + service.user_details(&token).await } /// Get a summary of all the media items that have been consumed by this user. async fn user_summary(&self, gql_ctx: &Context<'_>) -> Result { - let user_id = user_id_from_ctx(gql_ctx).await?; - gql_ctx - .data_unchecked::>() - .user_summary(&user_id) - .await + let service = gql_ctx.data_unchecked::>(); + let user_id = service.user_id_from_ctx(gql_ctx).await?; + service.user_summary(&user_id).await } - /// Get all the yank based integrations for the currently logged in user. - async fn user_yank_integrations( + /// Get all the integrations for the currently logged in user. + async fn user_integrations( &self, gql_ctx: &Context<'_>, - ) -> Result> { - let user_id = user_id_from_ctx(gql_ctx).await?; - gql_ctx - .data_unchecked::>() - .user_yank_integrations(user_id) - .await + ) -> Result> { + let service = gql_ctx.data_unchecked::>(); + let user_id = service.user_id_from_ctx(gql_ctx).await?; + service.user_integrations(user_id).await } /// Get all the auth tokens issued to the currently logged in user. async fn user_auth_tokens(&self, gql_ctx: &Context<'_>) -> Result> { - let user_id = user_id_from_ctx(gql_ctx).await?; - gql_ctx - .data_unchecked::>() - .user_auth_tokens(user_id) - .await + let service = gql_ctx.data_unchecked::>(); + let user_id = service.user_id_from_ctx(gql_ctx).await?; + service.user_auth_tokens(user_id).await } } @@ -675,20 +685,16 @@ pub struct MiscellaneousMutation; impl MiscellaneousMutation { /// Create or update a review. async fn post_review(&self, gql_ctx: &Context<'_>, input: PostReviewInput) -> Result { - let user_id = user_id_from_ctx(gql_ctx).await?; - gql_ctx - .data_unchecked::>() - .post_review(&user_id, input) - .await + let service = gql_ctx.data_unchecked::>(); + let user_id = service.user_id_from_ctx(gql_ctx).await?; + service.post_review(&user_id, input).await } /// Delete a review if it belongs to the user. async fn delete_review(&self, gql_ctx: &Context<'_>, review_id: i32) -> Result { - let user_id = user_id_from_ctx(gql_ctx).await?; - gql_ctx - .data_unchecked::>() - .delete_review(&user_id, review_id) - .await + let service = gql_ctx.data_unchecked::>(); + let user_id = service.user_id_from_ctx(gql_ctx).await?; + service.delete_review(&user_id, review_id).await } /// Create a new collection for the logged in user or edit details of an existing one. @@ -697,11 +703,9 @@ impl MiscellaneousMutation { gql_ctx: &Context<'_>, input: CreateOrUpdateCollectionInput, ) -> Result { - let user_id = user_id_from_ctx(gql_ctx).await?; - gql_ctx - .data_unchecked::>() - .create_or_update_collection(&user_id, input) - .await + let service = gql_ctx.data_unchecked::>(); + let user_id = service.user_id_from_ctx(gql_ctx).await?; + service.create_or_update_collection(&user_id, input).await } /// Add a media item to a collection if it is not there, otherwise do nothing. @@ -710,11 +714,9 @@ impl MiscellaneousMutation { gql_ctx: &Context<'_>, input: AddMediaToCollection, ) -> Result { - let user_id = user_id_from_ctx(gql_ctx).await?; - gql_ctx - .data_unchecked::>() - .add_media_to_collection(&user_id, input) - .await + let service = gql_ctx.data_unchecked::>(); + let user_id = service.user_id_from_ctx(gql_ctx).await?; + service.add_media_to_collection(&user_id, input).await } /// Remove a media item from a collection if it is not there, otherwise do nothing. @@ -724,9 +726,9 @@ impl MiscellaneousMutation { metadata_id: i32, collection_name: String, ) -> Result { - let user_id = user_id_from_ctx(gql_ctx).await?; - gql_ctx - .data_unchecked::>() + let service = gql_ctx.data_unchecked::>(); + let user_id = service.user_id_from_ctx(gql_ctx).await?; + service .remove_media_item_from_collection(&user_id, &metadata_id, &collection_name) .await } @@ -737,20 +739,16 @@ impl MiscellaneousMutation { gql_ctx: &Context<'_>, collection_name: String, ) -> Result { - let user_id = user_id_from_ctx(gql_ctx).await?; - gql_ctx - .data_unchecked::>() - .delete_collection(&user_id, &collection_name) - .await + let service = gql_ctx.data_unchecked::>(); + let user_id = service.user_id_from_ctx(gql_ctx).await?; + service.delete_collection(&user_id, &collection_name).await } /// Delete a seen item from a user's history. async fn delete_seen_item(&self, gql_ctx: &Context<'_>, seen_id: i32) -> Result { - let user_id = user_id_from_ctx(gql_ctx).await?; - gql_ctx - .data_unchecked::>() - .delete_seen_item(seen_id, user_id) - .await + let service = gql_ctx.data_unchecked::>(); + let user_id = service.user_id_from_ctx(gql_ctx).await?; + service.delete_seen_item(seen_id, user_id).await } /// Deploy jobs to update all media item's metadata. @@ -767,11 +765,9 @@ impl MiscellaneousMutation { gql_ctx: &Context<'_>, input: CreateCustomMediaInput, ) -> Result { - let user_id = user_id_from_ctx(gql_ctx).await?; - gql_ctx - .data_unchecked::>() - .create_custom_media(input, &user_id) - .await + let service = gql_ctx.data_unchecked::>(); + let user_id = service.user_id_from_ctx(gql_ctx).await?; + service.create_custom_media(input, &user_id).await } /// Mark a user's progress on a specific media item. @@ -780,11 +776,9 @@ impl MiscellaneousMutation { gql_ctx: &Context<'_>, input: ProgressUpdateInput, ) -> Result { - let user_id = user_id_from_ctx(gql_ctx).await?; - gql_ctx - .data_unchecked::>() - .progress_update(input, user_id) - .await + let service = gql_ctx.data_unchecked::>(); + let user_id = service.user_id_from_ctx(gql_ctx).await?; + service.progress_update(input, user_id).await } /// Deploy a job to update a media item's metadata. @@ -850,29 +844,23 @@ impl MiscellaneousMutation { /// Logout a user from the server, deleting their login token. async fn logout_user(&self, gql_ctx: &Context<'_>) -> Result { - let user_id = user_auth_token_from_ctx(gql_ctx)?; - gql_ctx - .data_unchecked::>() - .logout_user(&user_id, gql_ctx) - .await + let service = gql_ctx.data_unchecked::>(); + let user_id = service.user_auth_token_from_ctx(gql_ctx)?; + service.logout_user(&user_id, gql_ctx).await } /// Update a user's profile details. async fn update_user(&self, gql_ctx: &Context<'_>, input: UpdateUserInput) -> Result { - let user_id = user_id_from_ctx(gql_ctx).await?; - gql_ctx - .data_unchecked::>() - .update_user(&user_id, input) - .await + let service = gql_ctx.data_unchecked::>(); + let user_id = service.user_id_from_ctx(gql_ctx).await?; + service.update_user(&user_id, input).await } /// Delete all summaries for the currently logged in user and then generate one from scratch. pub async fn regenerate_user_summary(&self, gql_ctx: &Context<'_>) -> Result { - let user_id = user_id_from_ctx(gql_ctx).await?; - gql_ctx - .data_unchecked::>() - .regenerate_user_summary(user_id) - .await + let service = gql_ctx.data_unchecked::>(); + let user_id = service.user_id_from_ctx(gql_ctx).await?; + service.regenerate_user_summary(user_id).await } /// Change a user's feature preferences @@ -881,20 +869,27 @@ impl MiscellaneousMutation { gql_ctx: &Context<'_>, input: UpdateUserFeaturePreferenceInput, ) -> Result { - let user_id = user_id_from_ctx(gql_ctx).await?; - gql_ctx - .data_unchecked::>() - .update_user_feature_preference(input, user_id) - .await + let service = gql_ctx.data_unchecked::>(); + let user_id = service.user_id_from_ctx(gql_ctx).await?; + service.update_user_feature_preference(input, user_id).await } /// Generate an auth token without any expiry async fn generate_application_token(&self, gql_ctx: &Context<'_>) -> Result { - let user_id = user_id_from_ctx(gql_ctx).await?; - gql_ctx - .data_unchecked::>() - .generate_application_token(user_id) - .await + let service = gql_ctx.data_unchecked::>(); + let user_id = service.user_id_from_ctx(gql_ctx).await?; + service.generate_application_token(user_id).await + } + + /// Create a sink based integrations for the currently logged in user. + async fn create_user_sink_integration( + &self, + gql_ctx: &Context<'_>, + input: CreateUserSinkIntegrationInput, + ) -> Result { + let service = gql_ctx.data_unchecked::>(); + let user_id = service.user_id_from_ctx(gql_ctx).await?; + service.create_user_sink_integration(user_id, input).await } /// Create a yank based integrations for the currently logged in user. @@ -903,57 +898,50 @@ impl MiscellaneousMutation { gql_ctx: &Context<'_>, input: CreateUserYankIntegrationInput, ) -> Result { - let user_id = user_id_from_ctx(gql_ctx).await?; - gql_ctx - .data_unchecked::>() - .create_user_yank_integration(user_id, input) - .await + let service = gql_ctx.data_unchecked::>(); + let user_id = service.user_id_from_ctx(gql_ctx).await?; + service.create_user_yank_integration(user_id, input).await } - /// Delete a yank based integrations for the currently logged in user. - async fn delete_user_yank_integration( + /// Delete an integration for the currently logged in user. + async fn delete_user_integration( &self, gql_ctx: &Context<'_>, - yank_integration_id: usize, + integration_id: usize, + integration_lot: UserIntegrationLot, ) -> Result { - let user_id = user_id_from_ctx(gql_ctx).await?; - gql_ctx - .data_unchecked::>() - .delete_user_yank_integration(user_id, yank_integration_id) + let service = gql_ctx.data_unchecked::>(); + let user_id = service.user_id_from_ctx(gql_ctx).await?; + service + .delete_user_integration(user_id, integration_id, integration_lot) .await } /// Yank data from all integrations for the currently logged in user async fn yank_integration_data(&self, gql_ctx: &Context<'_>) -> Result { - let user_id = user_id_from_ctx(gql_ctx).await?; - gql_ctx - .data_unchecked::>() - .yank_integrations_data_for_user(user_id) - .await + let service = gql_ctx.data_unchecked::>(); + let user_id = service.user_id_from_ctx(gql_ctx).await?; + service.yank_integrations_data_for_user(user_id).await } /// Delete an auth token for the currently logged in user. async fn delete_user_auth_token(&self, gql_ctx: &Context<'_>, token: String) -> Result { - let user_id = user_id_from_ctx(gql_ctx).await?; - gql_ctx - .data_unchecked::>() - .delete_user_auth_token(user_id, token) - .await + let service = gql_ctx.data_unchecked::>(); + let user_id = service.user_id_from_ctx(gql_ctx).await?; + service.delete_user_auth_token(user_id, token).await } /// Delete a user. The account making the user must an Admin. async fn delete_user(&self, gql_ctx: &Context<'_>, to_delete_user_id: i32) -> Result { - let user_id = user_id_from_ctx(gql_ctx).await?; - gql_ctx - .data_unchecked::>() - .delete_user(user_id, to_delete_user_id) - .await + let service = gql_ctx.data_unchecked::>(); + let user_id = service.user_id_from_ctx(gql_ctx).await?; + service.delete_user(user_id, to_delete_user_id).await } } pub struct MiscellaneousService { pub db: DatabaseConnection, - pub auth_db: MemoryAuthDb, + pub auth_db: MemoryDatabase, pub config: Arc, pub file_storage: Arc, pub audible_service: AudibleService, @@ -971,13 +959,20 @@ pub struct MiscellaneousService { pub update_metadata: SqliteStorage, pub recalculate_user_summary: SqliteStorage, pub user_created: SqliteStorage, + seen_progress_cache: Arc>, +} + +impl AuthProvider for MiscellaneousService { + fn get_auth_db(&self) -> &MemoryDatabase { + &self.auth_db + } } impl MiscellaneousService { #[allow(clippy::too_many_arguments)] pub async fn new( db: &DatabaseConnection, - auth_db: &MemoryAuthDb, + auth_db: &MemoryDatabase, config: Arc, file_storage: Arc, after_media_seen: &SqliteStorage, @@ -997,10 +992,16 @@ impl MiscellaneousService { let anilist_manga_service = AnilistMangaService::new(&config.manga.anilist).await; let integration_service = IntegrationService::new().await; + let seen_progress_cache = Arc::new(Cache::new()); + let cache_clone = seen_progress_cache.clone(); + + tokio::spawn(async move { cache_clone.monitor(4, 0.25, Duration::from_secs(3)).await }); + Self { db: db.clone(), auth_db: auth_db.clone(), config, + seen_progress_cache, file_storage, audible_service, google_books_service, @@ -1573,11 +1574,25 @@ impl MiscellaneousService { }) } + // DEV: First we update progress only if media has not been consumed for + // this user in the last `n` duration. pub async fn progress_update( &self, input: ProgressUpdateInput, user_id: i32, ) -> Result { + let cache = ProgressUpdateCache { + user_id, + metadata_id: input.metadata_id, + show_season_number: input.show_season_number, + show_episode_number: input.show_episode_number, + podcast_episode_number: input.podcast_episode_number, + }; + + if self.seen_progress_cache.get(&cache).await.is_some() { + return Err(Error::new("Progress was updated within the specified threshold, will not continue with progress update")); + } + let prev_seen = Seen::find() .filter(seen::Column::Progress.lt(100)) .filter(seen::Column::UserId.eq(user_id)) @@ -1747,6 +1762,15 @@ impl MiscellaneousService { let id = seen_item.id; let metadata = self.generic_metadata(input.metadata_id).await?; let mut storage = self.after_media_seen.clone(); + if seen_item.progress == 100 { + self.seen_progress_cache + .insert( + cache, + (), + Duration::from_secs(self.config.server.progress_update_threshold * 3600), + ) + .await; + } storage .push(AfterMediaSeenJob { seen: seen_item, @@ -2781,6 +2805,7 @@ impl MiscellaneousService { password: ActiveValue::Set(password.to_owned()), lot: ActiveValue::Set(lot), preferences: ActiveValue::Set(UserPreferences::default()), + sink_integrations: ActiveValue::Set(UserSinkIntegrations(vec![])), ..Default::default() }; let user = user.insert(&self.db).await.unwrap(); @@ -2806,7 +2831,7 @@ impl MiscellaneousService { }; let user = user.unwrap(); let parsed_hash = PasswordHash::new(&user.password).unwrap(); - if get_hasher() + if get_password_hasher() .verify_password(password.as_bytes(), &parsed_hash) .is_err() { @@ -3095,39 +3120,76 @@ impl MiscellaneousService { } async fn generate_application_token(&self, user_id: i32) -> Result { - let api_token = Uuid::new_v4().to_string(); + let api_token = nanoid!(10); self.set_auth_token(&api_token, &user_id) .await .map_err(|_| Error::new("Could not set auth token"))?; Ok(api_token) } - async fn user_yank_integrations( - &self, - user_id: i32, - ) -> Result> { + async fn user_integrations(&self, user_id: i32) -> Result> { let user = self.user_by_id(user_id).await?; - let integrations = if let Some(i) = user.yank_integrations { + let mut all_integrations = vec![]; + let yank_integrations = if let Some(i) = user.yank_integrations { i.0 } else { vec![] }; - Ok(integrations - .into_iter() - .map(|i| { - let (lot, description) = match i.settings { - UserYankIntegrationSetting::Audiobookshelf { base_url, .. } => { - (UserYankIntegrationLot::Audiobookshelf, base_url) - } - }; - GraphqlUserYankIntegration { - id: i.id, - lot, - description, - timestamp: i.timestamp, + yank_integrations.into_iter().for_each(|i| { + let description = match i.settings { + UserYankIntegrationSetting::Audiobookshelf { base_url, .. } => { + format!("Audiobookshelf URL: {}", base_url) + } + }; + all_integrations.push(GraphqlUserIntegration { + id: i.id, + lot: UserIntegrationLot::Yank, + description, + timestamp: i.timestamp, + }) + }); + let sink_integrations = user.sink_integrations.0; + sink_integrations.into_iter().for_each(|i| { + let description = match i.settings { + UserSinkIntegrationSetting::Jellyfin { slug } => { + format!("Jellyfin slug: {}", slug) } + }; + all_integrations.push(GraphqlUserIntegration { + id: i.id, + lot: UserIntegrationLot::Sink, + description, + timestamp: i.timestamp, }) - .collect()) + }); + Ok(all_integrations) + } + + async fn create_user_sink_integration( + &self, + user_id: i32, + input: CreateUserSinkIntegrationInput, + ) -> Result { + let user = self.user_by_id(user_id).await?; + let mut integrations = user.sink_integrations.clone().0; + let new_integration_id = integrations.len() + 1; + let new_integration = UserSinkIntegration { + id: new_integration_id, + timestamp: Utc::now(), + settings: match input.lot { + UserSinkIntegrationLot::Jellyfin => { + let slug = get_id_hasher(&self.config.integration.hasher_salt) + .encode(&[user_id.try_into().unwrap()]); + let slug = format!("{}--{}", slug, nanoid!(5)); + UserSinkIntegrationSetting::Jellyfin { slug } + } + }, + }; + integrations.push(new_integration); + let mut user: user::ActiveModel = user.into(); + user.sink_integrations = ActiveValue::Set(UserSinkIntegrations(integrations)); + user.update(&self.db).await?; + Ok(new_integration_id) } async fn create_user_yank_integration( @@ -3161,29 +3223,43 @@ impl MiscellaneousService { Ok(new_integration_id) } - async fn delete_user_yank_integration( + async fn delete_user_integration( &self, user_id: i32, - yank_integration_id: usize, + integration_id: usize, + integration_type: UserIntegrationLot, ) -> Result { let user = self.user_by_id(user_id).await?; - let integrations = if let Some(i) = user.yank_integrations.clone() { - i.0 - } else { - vec![] - }; - let remaining_integrations = integrations - .into_iter() - .filter(|i| i.id != yank_integration_id) - .collect_vec(); - let update_value = if remaining_integrations.is_empty() { - None - } else { - Some(UserYankIntegrations(remaining_integrations)) + let mut user_db: user::ActiveModel = user.clone().into(); + match integration_type { + UserIntegrationLot::Yank => { + let integrations = if let Some(i) = user.yank_integrations.clone() { + i.0 + } else { + vec![] + }; + let remaining_integrations = integrations + .into_iter() + .filter(|i| i.id != integration_id) + .collect_vec(); + let update_value = if remaining_integrations.is_empty() { + None + } else { + Some(UserYankIntegrations(remaining_integrations)) + }; + user_db.yank_integrations = ActiveValue::Set(update_value); + } + UserIntegrationLot::Sink => { + let integrations = user.sink_integrations.clone().0; + let remaining_integrations = integrations + .into_iter() + .filter(|i| i.id != integration_id) + .collect_vec(); + let update_value = UserSinkIntegrations(remaining_integrations); + user_db.sink_integrations = ActiveValue::Set(update_value); + } }; - let mut user: user::ActiveModel = user.into(); - user.yank_integrations = ActiveValue::Set(update_value); - user.update(&self.db).await?; + user_db.update(&self.db).await?; Ok(true) } @@ -3293,26 +3369,10 @@ impl MiscellaneousService { } } let mut updated_count = 0; - for pu in progress_updates.iter() { - if !(1..=95).contains(&pu.progress) { - continue; - } else { - updated_count += 1; + for pu in progress_updates.into_iter() { + if let Some(_) = self.integration_progress_update(pu, user_id).await.ok() { + updated_count += 1 } - let IdObject { id } = self.commit_media(pu.lot, pu.source, &pu.identifier).await?; - self.progress_update( - ProgressUpdateInput { - metadata_id: id, - progress: Some(pu.progress), - date: Some(Utc::now().date_naive()), - show_season_number: None, - show_episode_number: None, - podcast_episode_number: None, - }, - user_id, - ) - .await - .ok(); } Ok(updated_count) } else { @@ -3407,6 +3467,68 @@ impl MiscellaneousService { Ok(false) } } + + pub async fn process_integration_webhook( + &self, + user_hash_id: String, + integration: String, + payload: String, + ) -> Result<()> { + let integration = match integration.as_str() { + "jellyfin" => UserSinkIntegrationLot::Jellyfin, + _ => return Err(anyhow!("Incorrect integration requested").into()), + }; + let (user_hash, _) = user_hash_id + .split_once("--") + .ok_or(anyhow!("Unexpected format"))?; + let user_id = get_id_hasher(&self.config.integration.hasher_salt).decode(user_hash)?; + let user_id: i32 = user_id + .first() + .ok_or(anyhow!("Incorrect hash id provided"))? + .to_owned() + .try_into()?; + let user = self.user_by_id(user_id).await?; + for db_integration in user.sink_integrations.0.into_iter() { + let progress = match db_integration.settings { + UserSinkIntegrationSetting::Jellyfin { slug } => { + if slug == user_hash_id && integration == UserSinkIntegrationLot::Jellyfin { + self.integration_service + .jellyfin_progress(&payload) + .await + .ok() + } else { + None + } + } + }; + if let Some(pu) = progress { + self.integration_progress_update(pu, user_id).await.ok(); + } + } + Ok(()) + } + + async fn integration_progress_update(&self, pu: IntegrationMedia, user_id: i32) -> Result<()> { + if pu.progress < 2 { + return Err(Error::new("Progress outside bound")); + } + let progress = if pu.progress > 95 { 100 } else { pu.progress }; + let IdObject { id } = self.commit_media(pu.lot, pu.source, &pu.identifier).await?; + self.progress_update( + ProgressUpdateInput { + metadata_id: id, + progress: Some(progress), + date: Some(Utc::now().date_naive()), + show_season_number: pu.show_season_number, + show_episode_number: pu.show_episode_number, + podcast_episode_number: pu.podcast_episode_number, + }, + user_id, + ) + .await + .ok(); + Ok(()) + } } fn modify_seen_elements(all_seen: &mut [seen::Model]) { diff --git a/apps/backend/src/traits/mod.rs b/apps/backend/src/traits/mod.rs index 0971091630..2b24b640a9 100644 --- a/apps/backend/src/traits/mod.rs +++ b/apps/backend/src/traits/mod.rs @@ -1,9 +1,14 @@ use anyhow::Result; +use async_graphql::{Context, Error, Result as GraphqlResult}; use async_trait::async_trait; -use crate::models::{ - media::{MediaDetails, MediaSearchItem}, - SearchResults, +use crate::{ + models::{ + media::{MediaDetails, MediaSearchItem}, + SearchResults, + }, + utils::{user_id_from_token, MemoryDatabase}, + GqlCtx, }; #[async_trait] @@ -33,3 +38,20 @@ pub trait IsFeatureEnabled { true } } + +#[async_trait] +pub trait AuthProvider { + fn get_auth_db(&self) -> &MemoryDatabase; + + fn user_auth_token_from_ctx(&self, ctx: &Context<'_>) -> GraphqlResult { + let ctx = ctx.data_unchecked::(); + ctx.auth_token + .clone() + .ok_or_else(|| Error::new("The auth token is not present".to_owned())) + } + + async fn user_id_from_ctx(&self, ctx: &Context<'_>) -> GraphqlResult { + let token = self.user_auth_token_from_ctx(ctx)?; + user_id_from_token(token, self.get_auth_db()).await + } +} diff --git a/apps/backend/src/users.rs b/apps/backend/src/users.rs index 430177346e..64fe7f1467 100644 --- a/apps/backend/src/users.rs +++ b/apps/backend/src/users.rs @@ -55,3 +55,20 @@ pub struct UserYankIntegration { #[derive(Debug, Serialize, Deserialize, Clone, Eq, PartialEq, FromJsonQueryResult)] pub struct UserYankIntegrations(pub Vec); + +#[derive(Debug, Serialize, Deserialize, Clone, Eq, PartialEq, FromJsonQueryResult)] +#[serde(tag = "t", content = "d")] +pub enum UserSinkIntegrationSetting { + Jellyfin { slug: String }, +} + +#[derive(Debug, Serialize, Deserialize, Clone, Eq, PartialEq, FromJsonQueryResult)] +pub struct UserSinkIntegration { + pub id: usize, + pub settings: UserSinkIntegrationSetting, + /// the date and time it was added on + pub timestamp: DateTimeUtc, +} + +#[derive(Debug, Serialize, Deserialize, Clone, Eq, PartialEq, FromJsonQueryResult)] +pub struct UserSinkIntegrations(pub Vec); diff --git a/apps/backend/src/utils.rs b/apps/backend/src/utils.rs index 3125f5b33b..4f3a075ba6 100644 --- a/apps/backend/src/utils.rs +++ b/apps/backend/src/utils.rs @@ -1,15 +1,22 @@ -use std::fs::File; -use std::io::Read; -use std::path::PathBuf; -use std::sync::Arc; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::{ + fs::File, + io::Read, + path::PathBuf, + sync::Arc, + time::{SystemTime, UNIX_EPOCH}, +}; use apalis::sqlite::SqliteStorage; -use async_graphql::{Context, Error, InputObject, Result, SimpleObject}; +use async_graphql::{Error, InputObject, Result, SimpleObject}; use chrono::{NaiveDate, Utc}; -use darkbird::Storage; +use darkbird::{ + document::{Document, FullText, Indexer, MaterializedView, Range, RangeField, Tags}, + Storage, +}; use http_types::headers::HeaderName; -use sea_orm::{ActiveModelTrait, ActiveValue, ConnectionTrait, DatabaseConnection}; +use sea_orm::{ + prelude::DateTimeUtc, ActiveModelTrait, ActiveValue, ConnectionTrait, DatabaseConnection, +}; use sea_query::{BinOper, Expr, Func, SimpleExpr}; use serde::{ de::{self, DeserializeOwned}, @@ -32,10 +39,9 @@ use crate::{ fitness::exercise::resolver::ExerciseService, importer::ImporterService, miscellaneous::resolver::MiscellaneousService, - GqlCtx, MemoryAuthData, }; -pub type MemoryAuthDb = Arc>; +pub type MemoryDatabase = Arc>; pub static VERSION: &str = env!("CARGO_PKG_VERSION"); pub static BASE_DIR: &str = env!("CARGO_MANIFEST_DIR"); @@ -57,7 +63,7 @@ pub struct AppServices { #[allow(clippy::too_many_arguments)] pub async fn create_app_services( db: DatabaseConnection, - auth_db: MemoryAuthDb, + auth_db: MemoryDatabase, s3_client: aws_sdk_s3::Client, config: Arc, import_media_job: &SqliteStorage, @@ -130,20 +136,7 @@ where Ok(()) } -pub fn user_auth_token_from_ctx(ctx: &Context<'_>) -> Result { - let ctx = ctx.data_unchecked::(); - ctx.auth_token - .clone() - .ok_or_else(|| Error::new("The auth token is not present".to_owned())) -} - -pub async fn user_id_from_ctx(ctx: &Context<'_>) -> Result { - let auth_db = ctx.data_unchecked::(); - let token = user_auth_token_from_ctx(ctx)?; - user_id_from_token(token, auth_db).await -} - -pub async fn user_id_from_token(token: String, auth_db: &MemoryAuthDb) -> Result { +pub async fn user_id_from_token(token: String, auth_db: &MemoryDatabase) -> Result { let found_token = auth_db.lookup(&token); match found_token { Some(t) => { @@ -235,3 +228,41 @@ where Box::new(Func::lower(Expr::val(format!("%{}%", v))).into()), ) } + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct MemoryAuthData { + pub user_id: i32, + pub last_used_on: DateTimeUtc, +} + +impl Document for MemoryAuthData {} + +impl Indexer for MemoryAuthData { + fn extract(&self) -> Vec { + vec![] + } +} + +impl Tags for MemoryAuthData { + fn get_tags(&self) -> Vec { + vec![] + } +} + +impl Range for MemoryAuthData { + fn get_fields(&self) -> Vec { + vec![] + } +} + +impl MaterializedView for MemoryAuthData { + fn filter(&self) -> Option { + None + } +} + +impl FullText for MemoryAuthData { + fn get_content(&self) -> Option { + None + } +} diff --git a/apps/frontend/src/pages/settings.tsx b/apps/frontend/src/pages/settings.tsx index 5cdda3a447..633bd8042b 100644 --- a/apps/frontend/src/pages/settings.tsx +++ b/apps/frontend/src/pages/settings.tsx @@ -34,14 +34,16 @@ import { useDisclosure } from "@mantine/hooks"; import { modals } from "@mantine/modals"; import { notifications } from "@mantine/notifications"; import { + CreateUserSinkIntegrationDocument, + type CreateUserSinkIntegrationMutationVariables, CreateUserYankIntegrationDocument, type CreateUserYankIntegrationMutationVariables, DeleteUserAuthTokenDocument, type DeleteUserAuthTokenMutationVariables, DeleteUserDocument, + DeleteUserIntegrationDocument, + type DeleteUserIntegrationMutationVariables, type DeleteUserMutationVariables, - DeleteUserYankIntegrationDocument, - type DeleteUserYankIntegrationMutationVariables, DeployImportJobDocument, type DeployImportJobMutationVariables, GenerateApplicationTokenDocument, @@ -60,9 +62,10 @@ import { UserAuthTokensDocument, UserDetailsDocument, type UserInput, + UserIntegrationsDocument, UserLot, + UserSinkIntegrationLot, UserYankIntegrationLot, - UserYankIntegrationsDocument, UsersDocument, YankIntegrationDataDocument, type YankIntegrationDataMutationVariables, @@ -136,8 +139,8 @@ const storyGraphImportFormSchema = z.object({ type StoryGraphImportFormSchema = z.infer; const createUserYankIntegrationSchema = z.object({ - baseUrl: z.string().url(), - token: z.string(), + baseUrl: z.string().url().optional(), + token: z.string().optional(), }); type CreateUserYankIntegationSchema = z.infer< typeof createUserYankIntegrationSchema @@ -177,6 +180,8 @@ const Page: NextPageWithLayout = () => { ] = useDisclosure(false); const [createUserYankIntegrationLot, setCreateUserYankIntegrationLot] = useState(); + const [createUserSinkIntegrationLot, setCreateUserSinkIntegrationLot] = + useState(); const [deployImportSource, setDeployImportSource] = useState(); @@ -246,11 +251,11 @@ const Page: NextPageWithLayout = () => { { staleTime: Infinity }, ); - const userYankIntegrations = useQuery(["userYankIntegrations"], async () => { - const { userYankIntegrations } = await gqlClient.request( - UserYankIntegrationsDocument, + const userIntegrations = useQuery(["userIntegrations"], async () => { + const { userIntegrations } = await gqlClient.request( + UserIntegrationsDocument, ); - return userYankIntegrations; + return userIntegrations; }); const users = useQuery(["users"], async () => { @@ -354,22 +359,35 @@ const Page: NextPageWithLayout = () => { return createUserYankIntegration; }, onSuccess: () => { - userYankIntegrations.refetch(); + userIntegrations.refetch(); }, }); - const deleteUserYankIntegration = useMutation({ + const createUserSinkIntegration = useMutation({ mutationFn: async ( - variables: DeleteUserYankIntegrationMutationVariables, + variables: CreateUserSinkIntegrationMutationVariables, ) => { - const { deleteUserYankIntegration } = await gqlClient.request( - DeleteUserYankIntegrationDocument, + const { createUserSinkIntegration } = await gqlClient.request( + CreateUserSinkIntegrationDocument, variables, ); - return deleteUserYankIntegration; + return createUserSinkIntegration; }, onSuccess: () => { - userYankIntegrations.refetch(); + userIntegrations.refetch(); + }, + }); + + const deleteUserIntegration = useMutation({ + mutationFn: async (variables: DeleteUserIntegrationMutationVariables) => { + const { deleteUserIntegration } = await gqlClient.request( + DeleteUserIntegrationDocument, + variables, + ); + return deleteUserIntegration; + }, + onSuccess: () => { + userIntegrations.refetch(); }, }); @@ -480,7 +498,7 @@ const Page: NextPageWithLayout = () => { languageInformation.data && userAuthTokens.data && userPrefs.data && - userYankIntegrations.data ? ( + userIntegrations.data ? ( <> Settings | Ryot @@ -853,18 +871,12 @@ const Page: NextPageWithLayout = () => { - {userYankIntegrations.data.length > 0 ? ( - userYankIntegrations.data.map((i, idx) => ( + {userIntegrations.data.length > 0 ? ( + userIntegrations.data.map((i, idx) => ( - {i.lot} - - Connected to{" "} - - {i.description}{" "} - - + {i.description} {formatTimeAgo(i.timestamp)} ) : null} + diff --git a/apps/kodi/addon.xml b/apps/kodi/addon.xml index f576ca250c..617d782a33 100644 --- a/apps/kodi/addon.xml +++ b/apps/kodi/addon.xml @@ -1,5 +1,5 @@ - + diff --git a/apps/kodi/resources/lib/scrobbler.py b/apps/kodi/resources/lib/scrobbler.py index b833dff50b..bcf085ba58 100644 --- a/apps/kodi/resources/lib/scrobbler.py +++ b/apps/kodi/resources/lib/scrobbler.py @@ -10,7 +10,6 @@ class Scrobbler: def __init__(self) -> None: self.__addon__ = xbmcaddon.Addon() self.media_cache = {} - self.seen_cache = {} def scrobble(self, player: xbmc.Player): if player.isPlaying() is False: @@ -108,17 +107,11 @@ def scrobble(self, player: xbmc.Player): xbmc.LOGDEBUG, ) - marked_as_seen = self.seen_cache.get(ryot_media_id) - if progress > 90: - if not marked_as_seen: - self.seen_cache[ryot_media_id] = datetime.now() - ryot_tracker.update_progress( - ryot_media_id, 100, season_number, episode_number - ) - return - if (datetime.now() - marked_as_seen).seconds < 8 * 60 * 60: - return + ryot_tracker.update_progress( + ryot_media_id, 100, season_number, episode_number + ) + return ryot_tracker.update_progress( ryot_media_id, progress, season_number, episode_number diff --git a/docs/content/configuration.md b/docs/content/configuration.md index c4168e06c4..df39081a40 100644 --- a/docs/content/configuration.md +++ b/docs/content/configuration.md @@ -10,7 +10,7 @@ Ryot serves the final configuration loaded at the `/config` endpoint as JSON ([example](https://ryot.fly.dev/config)). This can also be treated as a [health endpoint](https://learn.microsoft.com/en-us/azure/architecture/patterns/health-endpoint-monitoring). -!!! note +!!! info The defaults can be inspected in the [config]({{ extra.file_path }}/apps/backend/src/config.rs) builder. diff --git a/docs/content/guides/importing.md b/docs/content/importing.md similarity index 100% rename from docs/content/guides/importing.md rename to docs/content/importing.md diff --git a/docs/content/index.md b/docs/content/index.md index b1c3ad7bc9..93d2fa416f 100644 --- a/docs/content/index.md +++ b/docs/content/index.md @@ -59,7 +59,7 @@ volumes: running HTTPs. In addition to the `latest` tag, we also publish an `unstable` tag from the latest -pre-release (or release, whichever is newer). +pre-release or release, whichever is newer. ## Quick-run a release @@ -74,7 +74,7 @@ $ eget ignisda/ryot ## Compile and run from source -- Install [moonrepo](https://moonrepo.dev/) +First install [moonrepo](https://moonrepo.dev/) ```bash # Build the frontend diff --git a/docs/content/guides/integrations.md b/docs/content/integrations.md similarity index 69% rename from docs/content/guides/integrations.md rename to docs/content/integrations.md index 8c1872d9d9..fe7b59d41a 100644 --- a/docs/content/guides/integrations.md +++ b/docs/content/integrations.md @@ -7,17 +7,17 @@ be of two types: periodic interval. - _Sink_: An external client publishes progress updates to the Ryot server. +!!! info + + An item is marked as started when it has more than _2%_ progress and + marked as completed when it has more than _95%_ progress. + ## Yank plugins For each integration you want to enable, credentials for the external server must be saved to your profile. To do so, go to the "Settings" tab and add a new integration under the "Integrations" tab. -!!!note - - An item is marked as started when it has more than _2%_ progress and - marked as completed when it has more than _95%_ progress. - ### Audiobookshelf The [Audiobookshelf](https://www.audiobookshelf.org) integration can sync all @@ -33,6 +33,31 @@ media which have a match from _Audible_. To start, go to the "Settings" tab and generate a new application token from under the "Tokens" tab. It will look like this: `e96fca00-18b1-467c-80f0-8534e09ed790`. +### Jellyfin + +Automatically add new [Jellyin](https://jellyfin.org/) movie and show plays to +Movary. It will work for all the media that have been a valid TMDb ID attached +to their metadata. + +!!! info + + Requires the + [unofficial webhook plugin](https://github.com/shemanaev/jellyfin-plugin-webhooks) + to be installed and active in Jellyfin. + +1. Generate a slug in the integration settings page. Copy the newly generated +slug. +2. In the Jellyfin webhook plugin settings, add a new webhook using the +following settings: + - Webhook Url => `/webhooks/integrations/jellyfin/` + - Payload format => `Default` + - Listen to events only for => Choose your user + - Events => `Play`, `Pause`, `Resume`, and `Stop` + +!!! tip + + Keep your webhook url private to prevent abuse. + ### Kodi The [Kodi](https://kodi.tv/) integration allows syncing the current movie or TV diff --git a/docs/includes/backend-config-schema.ts b/docs/includes/backend-config-schema.ts index e3a5015d9a..b80c5acfe7 100644 --- a/docs/includes/backend-config-schema.ts +++ b/docs/includes/backend-config-schema.ts @@ -92,6 +92,8 @@ export interface FileStorageConfig { } export interface IntegrationConfig { + /** The salt used to hash user IDs. */ + hasher_salt: string; /** * Sync data from [yank](/docs/guides/integrations.md) based integrations * every `n` hours. @@ -178,6 +180,13 @@ export interface ServerConfig { * [More information](https://github.com/IgnisDa/ryot/issues/23) */ insecure_cookie: boolean; + /** + * The hours in which a media can be marked as seen again for a user. This + * is used so that the same media can not be used marked as started when + * it has been already marked as seen in the last `n` hours. + * @default 2 + */ + progress_update_threshold: number; } export interface ShowsTmdbConfig { diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 8bc8a7daa2..9478d56aac 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -38,6 +38,7 @@ markdown_extensions: - admonition - pymdownx.details - pymdownx.superfences + - pymdownx.tilde extra: file_path: https://github.com/ignisda/ryot/tree/main diff --git a/libs/generated/src/graphql/backend/gql.ts b/libs/generated/src/graphql/backend/gql.ts index d072969fa6..c17afc7ced 100644 --- a/libs/generated/src/graphql/backend/gql.ts +++ b/libs/generated/src/graphql/backend/gql.ts @@ -17,13 +17,14 @@ const documents = { "mutation CommitMedia($lot: MetadataLot!, $source: MetadataSource!, $identifier: String!) {\n commitMedia(lot: $lot, source: $source, identifier: $identifier) {\n id\n }\n}": types.CommitMediaDocument, "mutation CreateCustomMedia($input: CreateCustomMediaInput!) {\n createCustomMedia(input: $input) {\n __typename\n ... on IdObject {\n id\n }\n ... on CreateCustomMediaError {\n error\n }\n }\n}": types.CreateCustomMediaDocument, "mutation CreateOrUpdateCollection($input: CreateOrUpdateCollectionInput!) {\n createOrUpdateCollection(input: $input) {\n id\n }\n}": types.CreateOrUpdateCollectionDocument, + "mutation CreateUserSinkIntegration($input: CreateUserSinkIntegrationInput!) {\n createUserSinkIntegration(input: $input)\n}": types.CreateUserSinkIntegrationDocument, "mutation CreateUserYankIntegration($input: CreateUserYankIntegrationInput!) {\n createUserYankIntegration(input: $input)\n}": types.CreateUserYankIntegrationDocument, "mutation DeleteCollection($collectionName: String!) {\n deleteCollection(collectionName: $collectionName)\n}": types.DeleteCollectionDocument, "mutation DeleteReview($reviewId: Int!) {\n deleteReview(reviewId: $reviewId)\n}": types.DeleteReviewDocument, "mutation DeleteSeenItem($seenId: Int!) {\n deleteSeenItem(seenId: $seenId) {\n id\n }\n}": types.DeleteSeenItemDocument, "mutation DeleteUser($toDeleteUserId: Int!) {\n deleteUser(toDeleteUserId: $toDeleteUserId)\n}": types.DeleteUserDocument, "mutation DeleteUserAuthToken($token: String!) {\n deleteUserAuthToken(token: $token)\n}": types.DeleteUserAuthTokenDocument, - "mutation DeleteUserYankIntegration($yankIntegrationId: Int!) {\n deleteUserYankIntegration(yankIntegrationId: $yankIntegrationId)\n}": types.DeleteUserYankIntegrationDocument, + "mutation DeleteUserIntegration($integrationId: Int!, $integrationLot: UserIntegrationLot!) {\n deleteUserIntegration(\n integrationId: $integrationId\n integrationLot: $integrationLot\n )\n}": types.DeleteUserIntegrationDocument, "mutation DeployImportJob($input: DeployImportJobInput!) {\n deployImportJob(input: $input)\n}": types.DeployImportJobDocument, "mutation DeployUpdateMetadataJob($metadataId: Int!) {\n deployUpdateMetadataJob(metadataId: $metadataId)\n}": types.DeployUpdateMetadataJobDocument, "mutation GenerateApplicationToken {\n generateApplicationToken\n}": types.GenerateApplicationTokenDocument, @@ -57,9 +58,9 @@ const documents = { "query SeenHistory($metadataId: Int!) {\n seenHistory(metadataId: $metadataId) {\n id\n progress\n dropped\n startedOn\n finishedOn\n lastUpdatedOn\n showInformation {\n episode\n season\n }\n podcastInformation {\n episode\n }\n }\n}": types.SeenHistoryDocument, "query UserAuthTokens {\n userAuthTokens {\n lastUsedOn\n token\n }\n}": types.UserAuthTokensDocument, "query UserDetails {\n userDetails {\n __typename\n ... on User {\n id\n email\n name\n lot\n }\n }\n}": types.UserDetailsDocument, + "query UserIntegrations {\n userIntegrations {\n id\n lot\n description\n timestamp\n }\n}": types.UserIntegrationsDocument, "query UserPreferences {\n userPreferences {\n featuresEnabled {\n anime\n audioBooks\n books\n manga\n movies\n podcasts\n shows\n videoGames\n }\n }\n}": types.UserPreferencesDocument, "query UserSummary {\n userSummary {\n calculatedOn\n media {\n manga {\n chapters\n read\n }\n books {\n pages\n read\n }\n movies {\n runtime\n watched\n }\n anime {\n episodes\n watched\n }\n podcasts {\n runtime\n played\n playedEpisodes\n }\n videoGames {\n played\n }\n shows {\n runtime\n watchedEpisodes\n watchedSeasons\n watched\n }\n audioBooks {\n runtime\n played\n }\n }\n }\n}": types.UserSummaryDocument, - "query UserYankIntegrations {\n userYankIntegrations {\n id\n lot\n description\n timestamp\n }\n}": types.UserYankIntegrationsDocument, "query Users {\n users {\n id\n name\n lot\n }\n}": types.UsersDocument, }; @@ -93,6 +94,10 @@ export function graphql(source: "mutation CreateCustomMedia($input: CreateCustom * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ export function graphql(source: "mutation CreateOrUpdateCollection($input: CreateOrUpdateCollectionInput!) {\n createOrUpdateCollection(input: $input) {\n id\n }\n}"): (typeof documents)["mutation CreateOrUpdateCollection($input: CreateOrUpdateCollectionInput!) {\n createOrUpdateCollection(input: $input) {\n id\n }\n}"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "mutation CreateUserSinkIntegration($input: CreateUserSinkIntegrationInput!) {\n createUserSinkIntegration(input: $input)\n}"): (typeof documents)["mutation CreateUserSinkIntegration($input: CreateUserSinkIntegrationInput!) {\n createUserSinkIntegration(input: $input)\n}"]; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ @@ -120,7 +125,7 @@ export function graphql(source: "mutation DeleteUserAuthToken($token: String!) { /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ -export function graphql(source: "mutation DeleteUserYankIntegration($yankIntegrationId: Int!) {\n deleteUserYankIntegration(yankIntegrationId: $yankIntegrationId)\n}"): (typeof documents)["mutation DeleteUserYankIntegration($yankIntegrationId: Int!) {\n deleteUserYankIntegration(yankIntegrationId: $yankIntegrationId)\n}"]; +export function graphql(source: "mutation DeleteUserIntegration($integrationId: Int!, $integrationLot: UserIntegrationLot!) {\n deleteUserIntegration(\n integrationId: $integrationId\n integrationLot: $integrationLot\n )\n}"): (typeof documents)["mutation DeleteUserIntegration($integrationId: Int!, $integrationLot: UserIntegrationLot!) {\n deleteUserIntegration(\n integrationId: $integrationId\n integrationLot: $integrationLot\n )\n}"]; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ @@ -256,15 +261,15 @@ export function graphql(source: "query UserDetails {\n userDetails {\n __typ /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ -export function graphql(source: "query UserPreferences {\n userPreferences {\n featuresEnabled {\n anime\n audioBooks\n books\n manga\n movies\n podcasts\n shows\n videoGames\n }\n }\n}"): (typeof documents)["query UserPreferences {\n userPreferences {\n featuresEnabled {\n anime\n audioBooks\n books\n manga\n movies\n podcasts\n shows\n videoGames\n }\n }\n}"]; +export function graphql(source: "query UserIntegrations {\n userIntegrations {\n id\n lot\n description\n timestamp\n }\n}"): (typeof documents)["query UserIntegrations {\n userIntegrations {\n id\n lot\n description\n timestamp\n }\n}"]; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ -export function graphql(source: "query UserSummary {\n userSummary {\n calculatedOn\n media {\n manga {\n chapters\n read\n }\n books {\n pages\n read\n }\n movies {\n runtime\n watched\n }\n anime {\n episodes\n watched\n }\n podcasts {\n runtime\n played\n playedEpisodes\n }\n videoGames {\n played\n }\n shows {\n runtime\n watchedEpisodes\n watchedSeasons\n watched\n }\n audioBooks {\n runtime\n played\n }\n }\n }\n}"): (typeof documents)["query UserSummary {\n userSummary {\n calculatedOn\n media {\n manga {\n chapters\n read\n }\n books {\n pages\n read\n }\n movies {\n runtime\n watched\n }\n anime {\n episodes\n watched\n }\n podcasts {\n runtime\n played\n playedEpisodes\n }\n videoGames {\n played\n }\n shows {\n runtime\n watchedEpisodes\n watchedSeasons\n watched\n }\n audioBooks {\n runtime\n played\n }\n }\n }\n}"]; +export function graphql(source: "query UserPreferences {\n userPreferences {\n featuresEnabled {\n anime\n audioBooks\n books\n manga\n movies\n podcasts\n shows\n videoGames\n }\n }\n}"): (typeof documents)["query UserPreferences {\n userPreferences {\n featuresEnabled {\n anime\n audioBooks\n books\n manga\n movies\n podcasts\n shows\n videoGames\n }\n }\n}"]; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ -export function graphql(source: "query UserYankIntegrations {\n userYankIntegrations {\n id\n lot\n description\n timestamp\n }\n}"): (typeof documents)["query UserYankIntegrations {\n userYankIntegrations {\n id\n lot\n description\n timestamp\n }\n}"]; +export function graphql(source: "query UserSummary {\n userSummary {\n calculatedOn\n media {\n manga {\n chapters\n read\n }\n books {\n pages\n read\n }\n movies {\n runtime\n watched\n }\n anime {\n episodes\n watched\n }\n podcasts {\n runtime\n played\n playedEpisodes\n }\n videoGames {\n played\n }\n shows {\n runtime\n watchedEpisodes\n watchedSeasons\n watched\n }\n audioBooks {\n runtime\n played\n }\n }\n }\n}"): (typeof documents)["query UserSummary {\n userSummary {\n calculatedOn\n media {\n manga {\n chapters\n read\n }\n books {\n pages\n read\n }\n movies {\n runtime\n watched\n }\n anime {\n episodes\n watched\n }\n podcasts {\n runtime\n played\n playedEpisodes\n }\n videoGames {\n played\n }\n shows {\n runtime\n watchedEpisodes\n watchedSeasons\n watched\n }\n audioBooks {\n runtime\n played\n }\n }\n }\n}"]; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ diff --git a/libs/generated/src/graphql/backend/graphql.ts b/libs/generated/src/graphql/backend/graphql.ts index afc7f06778..9999713e38 100644 --- a/libs/generated/src/graphql/backend/graphql.ts +++ b/libs/generated/src/graphql/backend/graphql.ts @@ -149,6 +149,10 @@ export type CreateOrUpdateCollectionInput = { visibility?: InputMaybe; }; +export type CreateUserSinkIntegrationInput = { + lot: UserSinkIntegrationLot; +}; + export type CreateUserYankIntegrationInput = { baseUrl: Scalars['String']; lot: UserYankIntegrationLot; @@ -318,10 +322,10 @@ export type GraphqlMediaDetails = { videoGameSpecifics?: Maybe; }; -export type GraphqlUserYankIntegration = { +export type GraphqlUserIntegration = { description: Scalars['String']; id: Scalars['Int']; - lot: UserYankIntegrationLot; + lot: UserIntegrationLot; timestamp: Scalars['DateTime']; }; @@ -524,6 +528,8 @@ export type MutationRoot = { createCustomMedia: CreateCustomMediaResult; /** Create a new collection for the logged in user or edit details of an existing one. */ createOrUpdateCollection: IdObject; + /** Create a sink based integrations for the currently logged in user. */ + createUserSinkIntegration: Scalars['Int']; /** Create a yank based integrations for the currently logged in user. */ createUserYankIntegration: Scalars['Int']; /** Delete a collection. */ @@ -536,8 +542,8 @@ export type MutationRoot = { deleteUser: Scalars['Boolean']; /** Delete an auth token for the currently logged in user. */ deleteUserAuthToken: Scalars['Boolean']; - /** Delete a yank based integrations for the currently logged in user. */ - deleteUserYankIntegration: Scalars['Boolean']; + /** Delete an integration for the currently logged in user. */ + deleteUserIntegration: Scalars['Boolean']; /** Add job to import data from various sources. */ deployImportJob: Scalars['String']; /** Deploy a job to download update the exercise library */ @@ -601,6 +607,11 @@ export type MutationRootCreateOrUpdateCollectionArgs = { }; +export type MutationRootCreateUserSinkIntegrationArgs = { + input: CreateUserSinkIntegrationInput; +}; + + export type MutationRootCreateUserYankIntegrationArgs = { input: CreateUserYankIntegrationInput; }; @@ -631,8 +642,9 @@ export type MutationRootDeleteUserAuthTokenArgs = { }; -export type MutationRootDeleteUserYankIntegrationArgs = { - yankIntegrationId: Scalars['Int']; +export type MutationRootDeleteUserIntegrationArgs = { + integrationId: Scalars['Int']; + integrationLot: UserIntegrationLot; }; @@ -793,12 +805,12 @@ export type QueryRoot = { userAuthTokens: Array; /** Get details about the currently logged in user. */ userDetails: UserDetailsResult; + /** Get all the integrations for the currently logged in user. */ + userIntegrations: Array; /** Get a user's preferences. */ userPreferences: UserPreferences; /** Get a summary of all the media items that have been consumed by this user. */ userSummary: UserSummary; - /** Get all the yank based integrations for the currently logged in user. */ - userYankIntegrations: Array; /** Get details about the currently logged in user. */ users: Array; }; @@ -1034,6 +1046,11 @@ export type UserInput = { username: Scalars['String']; }; +export enum UserIntegrationLot { + Sink = 'SINK', + Yank = 'YANK' +} + export enum UserLot { Admin = 'ADMIN', Normal = 'NORMAL' @@ -1054,6 +1071,10 @@ export type UserPreferences = { featuresEnabled: UserFeaturesEnabledPreferences; }; +export enum UserSinkIntegrationLot { + Jellyfin = 'JELLYFIN' +} + export type UserSummary = { calculatedOn: Scalars['DateTime']; media: UserMediaSummary; @@ -1110,6 +1131,13 @@ export type CreateOrUpdateCollectionMutationVariables = Exact<{ export type CreateOrUpdateCollectionMutation = { createOrUpdateCollection: { id: number } }; +export type CreateUserSinkIntegrationMutationVariables = Exact<{ + input: CreateUserSinkIntegrationInput; +}>; + + +export type CreateUserSinkIntegrationMutation = { createUserSinkIntegration: number }; + export type CreateUserYankIntegrationMutationVariables = Exact<{ input: CreateUserYankIntegrationInput; }>; @@ -1152,12 +1180,13 @@ export type DeleteUserAuthTokenMutationVariables = Exact<{ export type DeleteUserAuthTokenMutation = { deleteUserAuthToken: boolean }; -export type DeleteUserYankIntegrationMutationVariables = Exact<{ - yankIntegrationId: Scalars['Int']; +export type DeleteUserIntegrationMutationVariables = Exact<{ + integrationId: Scalars['Int']; + integrationLot: UserIntegrationLot; }>; -export type DeleteUserYankIntegrationMutation = { deleteUserYankIntegration: boolean }; +export type DeleteUserIntegrationMutation = { deleteUserIntegration: boolean }; export type DeployImportJobMutationVariables = Exact<{ input: DeployImportJobInput; @@ -1372,6 +1401,11 @@ export type UserDetailsQueryVariables = Exact<{ [key: string]: never; }>; export type UserDetailsQuery = { userDetails: { __typename: 'User', id: number, email?: string | null, name: string, lot: UserLot } | { __typename: 'UserDetailsError' } }; +export type UserIntegrationsQueryVariables = Exact<{ [key: string]: never; }>; + + +export type UserIntegrationsQuery = { userIntegrations: Array<{ id: number, lot: UserIntegrationLot, description: string, timestamp: Date }> }; + export type UserPreferencesQueryVariables = Exact<{ [key: string]: never; }>; @@ -1382,11 +1416,6 @@ export type UserSummaryQueryVariables = Exact<{ [key: string]: never; }>; export type UserSummaryQuery = { userSummary: { calculatedOn: Date, media: { manga: { chapters: number, read: number }, books: { pages: number, read: number }, movies: { runtime: number, watched: number }, anime: { episodes: number, watched: number }, podcasts: { runtime: number, played: number, playedEpisodes: number }, videoGames: { played: number }, shows: { runtime: number, watchedEpisodes: number, watchedSeasons: number, watched: number }, audioBooks: { runtime: number, played: number } } } }; -export type UserYankIntegrationsQueryVariables = Exact<{ [key: string]: never; }>; - - -export type UserYankIntegrationsQuery = { userYankIntegrations: Array<{ id: number, lot: UserYankIntegrationLot, description: string, timestamp: Date }> }; - export type UsersQueryVariables = Exact<{ [key: string]: never; }>; @@ -1397,13 +1426,14 @@ export const AddMediaToCollectionDocument = {"kind":"Document","definitions":[{" export const CommitMediaDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CommitMedia"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"lot"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"MetadataLot"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"source"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"MetadataSource"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"identifier"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"commitMedia"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"lot"},"value":{"kind":"Variable","name":{"kind":"Name","value":"lot"}}},{"kind":"Argument","name":{"kind":"Name","value":"source"},"value":{"kind":"Variable","name":{"kind":"Name","value":"source"}}},{"kind":"Argument","name":{"kind":"Name","value":"identifier"},"value":{"kind":"Variable","name":{"kind":"Name","value":"identifier"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]} as unknown as DocumentNode; export const CreateCustomMediaDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateCustomMedia"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"CreateCustomMediaInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createCustomMedia"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"__typename"}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"IdObject"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"CreateCustomMediaError"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"error"}}]}}]}}]}}]} as unknown as DocumentNode; export const CreateOrUpdateCollectionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateOrUpdateCollection"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"CreateOrUpdateCollectionInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createOrUpdateCollection"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]} as unknown as DocumentNode; +export const CreateUserSinkIntegrationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateUserSinkIntegration"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"CreateUserSinkIntegrationInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createUserSinkIntegration"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]} as unknown as DocumentNode; export const CreateUserYankIntegrationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateUserYankIntegration"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"CreateUserYankIntegrationInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createUserYankIntegration"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]} as unknown as DocumentNode; export const DeleteCollectionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteCollection"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"collectionName"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteCollection"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"collectionName"},"value":{"kind":"Variable","name":{"kind":"Name","value":"collectionName"}}}]}]}}]} as unknown as DocumentNode; export const DeleteReviewDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteReview"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"reviewId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteReview"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"reviewId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"reviewId"}}}]}]}}]} as unknown as DocumentNode; export const DeleteSeenItemDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteSeenItem"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"seenId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteSeenItem"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"seenId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"seenId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]} as unknown as DocumentNode; export const DeleteUserDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteUser"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"toDeleteUserId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteUser"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"toDeleteUserId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"toDeleteUserId"}}}]}]}}]} as unknown as DocumentNode; export const DeleteUserAuthTokenDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteUserAuthToken"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"token"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteUserAuthToken"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"token"},"value":{"kind":"Variable","name":{"kind":"Name","value":"token"}}}]}]}}]} as unknown as DocumentNode; -export const DeleteUserYankIntegrationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteUserYankIntegration"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"yankIntegrationId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteUserYankIntegration"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"yankIntegrationId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"yankIntegrationId"}}}]}]}}]} as unknown as DocumentNode; +export const DeleteUserIntegrationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteUserIntegration"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"integrationId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"integrationLot"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UserIntegrationLot"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteUserIntegration"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"integrationId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"integrationId"}}},{"kind":"Argument","name":{"kind":"Name","value":"integrationLot"},"value":{"kind":"Variable","name":{"kind":"Name","value":"integrationLot"}}}]}]}}]} as unknown as DocumentNode; export const DeployImportJobDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeployImportJob"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"DeployImportJobInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deployImportJob"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]} as unknown as DocumentNode; export const DeployUpdateMetadataJobDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeployUpdateMetadataJob"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"metadataId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deployUpdateMetadataJob"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"metadataId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"metadataId"}}}]}]}}]} as unknown as DocumentNode; export const GenerateApplicationTokenDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"GenerateApplicationToken"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"generateApplicationToken"}}]}}]} as unknown as DocumentNode; @@ -1437,7 +1467,7 @@ export const ReviewByIdDocument = {"kind":"Document","definitions":[{"kind":"Ope export const SeenHistoryDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"SeenHistory"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"metadataId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"seenHistory"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"metadataId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"metadataId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"progress"}},{"kind":"Field","name":{"kind":"Name","value":"dropped"}},{"kind":"Field","name":{"kind":"Name","value":"startedOn"}},{"kind":"Field","name":{"kind":"Name","value":"finishedOn"}},{"kind":"Field","name":{"kind":"Name","value":"lastUpdatedOn"}},{"kind":"Field","name":{"kind":"Name","value":"showInformation"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"episode"}},{"kind":"Field","name":{"kind":"Name","value":"season"}}]}},{"kind":"Field","name":{"kind":"Name","value":"podcastInformation"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"episode"}}]}}]}}]}}]} as unknown as DocumentNode; export const UserAuthTokensDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"UserAuthTokens"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"userAuthTokens"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"lastUsedOn"}},{"kind":"Field","name":{"kind":"Name","value":"token"}}]}}]}}]} as unknown as DocumentNode; export const UserDetailsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"UserDetails"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"userDetails"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"__typename"}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"User"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"lot"}}]}}]}}]}}]} as unknown as DocumentNode; +export const UserIntegrationsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"UserIntegrations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"userIntegrations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"lot"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"timestamp"}}]}}]}}]} as unknown as DocumentNode; export const UserPreferencesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"UserPreferences"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"userPreferences"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"featuresEnabled"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"anime"}},{"kind":"Field","name":{"kind":"Name","value":"audioBooks"}},{"kind":"Field","name":{"kind":"Name","value":"books"}},{"kind":"Field","name":{"kind":"Name","value":"manga"}},{"kind":"Field","name":{"kind":"Name","value":"movies"}},{"kind":"Field","name":{"kind":"Name","value":"podcasts"}},{"kind":"Field","name":{"kind":"Name","value":"shows"}},{"kind":"Field","name":{"kind":"Name","value":"videoGames"}}]}}]}}]}}]} as unknown as DocumentNode; export const UserSummaryDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"UserSummary"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"userSummary"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"calculatedOn"}},{"kind":"Field","name":{"kind":"Name","value":"media"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"manga"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"chapters"}},{"kind":"Field","name":{"kind":"Name","value":"read"}}]}},{"kind":"Field","name":{"kind":"Name","value":"books"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"pages"}},{"kind":"Field","name":{"kind":"Name","value":"read"}}]}},{"kind":"Field","name":{"kind":"Name","value":"movies"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"runtime"}},{"kind":"Field","name":{"kind":"Name","value":"watched"}}]}},{"kind":"Field","name":{"kind":"Name","value":"anime"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"episodes"}},{"kind":"Field","name":{"kind":"Name","value":"watched"}}]}},{"kind":"Field","name":{"kind":"Name","value":"podcasts"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"runtime"}},{"kind":"Field","name":{"kind":"Name","value":"played"}},{"kind":"Field","name":{"kind":"Name","value":"playedEpisodes"}}]}},{"kind":"Field","name":{"kind":"Name","value":"videoGames"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"played"}}]}},{"kind":"Field","name":{"kind":"Name","value":"shows"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"runtime"}},{"kind":"Field","name":{"kind":"Name","value":"watchedEpisodes"}},{"kind":"Field","name":{"kind":"Name","value":"watchedSeasons"}},{"kind":"Field","name":{"kind":"Name","value":"watched"}}]}},{"kind":"Field","name":{"kind":"Name","value":"audioBooks"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"runtime"}},{"kind":"Field","name":{"kind":"Name","value":"played"}}]}}]}}]}}]}}]} as unknown as DocumentNode; -export const UserYankIntegrationsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"UserYankIntegrations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"userYankIntegrations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"lot"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"timestamp"}}]}}]}}]} as unknown as DocumentNode; export const UsersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"Users"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"users"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"lot"}}]}}]}}]} as unknown as DocumentNode; \ No newline at end of file diff --git a/libs/graphql/src/backend/mutations/CreateUserSinkIntegration.gql b/libs/graphql/src/backend/mutations/CreateUserSinkIntegration.gql new file mode 100644 index 0000000000..32ea5f2eea --- /dev/null +++ b/libs/graphql/src/backend/mutations/CreateUserSinkIntegration.gql @@ -0,0 +1,3 @@ +mutation CreateUserSinkIntegration($input: CreateUserSinkIntegrationInput!) { + createUserSinkIntegration(input: $input) +} diff --git a/libs/graphql/src/backend/mutations/DeleteUserIntegration.gql b/libs/graphql/src/backend/mutations/DeleteUserIntegration.gql new file mode 100644 index 0000000000..6fa7369bf0 --- /dev/null +++ b/libs/graphql/src/backend/mutations/DeleteUserIntegration.gql @@ -0,0 +1,6 @@ +mutation DeleteUserIntegration($integrationId: Int!, $integrationLot: UserIntegrationLot!) { + deleteUserIntegration( + integrationId: $integrationId + integrationLot: $integrationLot + ) +} diff --git a/libs/graphql/src/backend/mutations/DeleteUserYankIntegration.gql b/libs/graphql/src/backend/mutations/DeleteUserYankIntegration.gql deleted file mode 100644 index 2bc531018a..0000000000 --- a/libs/graphql/src/backend/mutations/DeleteUserYankIntegration.gql +++ /dev/null @@ -1,3 +0,0 @@ -mutation DeleteUserYankIntegration($yankIntegrationId: Int!) { - deleteUserYankIntegration(yankIntegrationId: $yankIntegrationId) -} diff --git a/libs/graphql/src/backend/queries/UserIntegrations.gql b/libs/graphql/src/backend/queries/UserIntegrations.gql new file mode 100644 index 0000000000..5e5f6e97e6 --- /dev/null +++ b/libs/graphql/src/backend/queries/UserIntegrations.gql @@ -0,0 +1,8 @@ +query UserIntegrations { + userIntegrations { + id + lot + description + timestamp + } +} diff --git a/libs/graphql/src/backend/queries/UserYankIntegrations.gql b/libs/graphql/src/backend/queries/UserYankIntegrations.gql deleted file mode 100644 index 31a5933d0b..0000000000 --- a/libs/graphql/src/backend/queries/UserYankIntegrations.gql +++ /dev/null @@ -1,8 +0,0 @@ -query UserYankIntegrations { - userYankIntegrations { - id - lot - description - timestamp - } -}