Skip to content

Commit 66dff65

Browse files
authored
Radarr and Sonarr integration (#936)
* build(backend): add radarr api client * feat(backend): add new lot for integration * feat(database): add migrations for new stuff * feat(backend): adapt to new database schema * chore(frontend): set default value * feat(backend): add new source * feat(frontend): do not allow editing readarr integration * feat(frontend): do not ask for progress for push integrations * feat(database): columns to store destination specifics * feat(backend): allow adding destination specifics to integration * feat(frontend): allow adding radarr integration * feat(backend): sync integrations stuff * chore(frontend): adapt to new gql schema * refactor(backend): change name of function * feat(backend): sync collection ids too * feat(frontend): ask for sync collection ids as well * fix(frontend): no show allowed * feat(frontend): allow editing push integrations * feat(database,backend,frontend): change name for preference to disable integrations * feat(backend): stub functions to add stuff to radarr * feat(backend): allow adding movies to radarr * refactor(backend): move function out of integration * refactor(backend): import with prefix * refactor(backend): inline immediate return * fix(frontend): make collections searchable * feat(config): change name of config param * docs: add instructions for radarr integration * chore(frontend): hide controls for profile id * feat(backend): select more columns * feat(database): add column for tracking system information for collection_to_entity * feat(backend): store cte extra information * refactor(backend): send movies to radarr in a loop * chore(backend): use unused result * fix(backend): do not sync if already done * chore(backend): always return true from function * feat(backend): update collection_to_entity when radarr sync complete * fix(backend): log when movie already synced * feat(database): add new column for destination * Revert "feat(database): add new column for destination" This reverts commit 2b31cfd. * feat(database): merge columns into one * feat(backend): adapt to new database schema * chore(frontend): adapt to new gql schema * feat(backend): respect disable_integrations general preference * feat(backend): store exactly which integration was synced * chore(database): comment * feat(database): account for incorrect finished shows calculations * build(backend): add sonarr deps * docs: add info about sonnar integration * feat(backend): add new integration provider * refactor(frontend): component to create dyanmic arr inputs * feat(frontend): allow creating radarr integration * feat(backend): stub function for sonarr push * feat(backend): store integrations synced to sonarr * refactor(backend): use internal function for pushing data * feat(backend): call function for sonarr * feat(backend): send shows to sonarr * fix(backend): send random title * docs: remove extra text * feat(database): add column to track external ids * feat(backend): update external ids correctly * refactor(backend): change name to match database schema * feat(backend): fetch external identifiers for tmdb movies and shows * fix(backend): use a better function for imports * fix(backend): send tvdb id * fix(backend): allow sending ids * fix(backend): handle arr service push * refactor(backend): extract into variable
1 parent 90809a6 commit 66dff65

28 files changed

+898
-196
lines changed

Cargo.lock

+248-13
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

apps/backend/Cargo.toml

+2
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ mime_guess = "=2.0.5"
5454
nanoid = { workspace = true }
5555
openidconnect = "=3.5.0"
5656
paginate = "=1.1.11"
57+
radarr-api-rs = "=3.0.1"
5758
rand = "=0.9.0-alpha.1"
5859
regex = "=1.10.5"
5960
# FIXME: Upgrade once https://github.com/seanmonstar/reqwest/pull/1620 is merged
@@ -76,6 +77,7 @@ serde_json = { workspace = true }
7677
serde_with = { version = "=3.9.0", features = ["chrono_0_4"] }
7778
serde-xml-rs = "=0.6.0"
7879
slug = "=0.1.5"
80+
sonarr-api-rs = "=3.0.0"
7981
strum = { workspace = true }
8082
struson = { version = "=0.5.0", features = ["serde"] }
8183
tokio = { version = "=1.38.1", features = ["full"] }

apps/backend/src/background.rs

+17-6
Original file line numberDiff line numberDiff line change
@@ -41,12 +41,17 @@ pub async fn background_jobs(
4141
Ok(())
4242
}
4343

44-
pub async fn yank_integrations_data(
44+
pub async fn sync_integrations_data(
4545
_information: ScheduledJob,
4646
misc_service: Data<Arc<MiscellaneousService>>,
4747
) -> Result<(), Error> {
4848
tracing::trace!("Getting data from yanked integrations for all users");
4949
misc_service.yank_integrations_data().await.unwrap();
50+
tracing::trace!("Sending data for push integrations for all users");
51+
misc_service
52+
.send_data_for_push_integrations()
53+
.await
54+
.unwrap();
5055
Ok(())
5156
}
5257

@@ -55,7 +60,7 @@ pub async fn yank_integrations_data(
5560
// The background jobs which cannot be throttled.
5661
#[derive(Debug, Deserialize, Serialize, Display)]
5762
pub enum CoreApplicationJob {
58-
YankIntegrationsData(String),
63+
SyncIntegrationsData(String),
5964
BulkProgressUpdate(String, Vec<ProgressUpdateInput>),
6065
}
6166

@@ -71,10 +76,16 @@ pub async fn perform_core_application_job(
7176
tracing::trace!("Started job: {:#?}", name);
7277
let start = Instant::now();
7378
let status = match information {
74-
CoreApplicationJob::YankIntegrationsData(user_id) => misc_service
75-
.yank_integrations_data_for_user(&user_id)
76-
.await
77-
.is_ok(),
79+
CoreApplicationJob::SyncIntegrationsData(user_id) => {
80+
misc_service
81+
.push_integrations_data_for_user(&user_id)
82+
.await
83+
.ok();
84+
misc_service
85+
.yank_integrations_data_for_user(&user_id)
86+
.await
87+
.is_ok()
88+
}
7889
CoreApplicationJob::BulkProgressUpdate(user_id, input) => misc_service
7990
.bulk_progress_update(user_id, input)
8091
.await

apps/backend/src/entities/collection_to_entity.rs

+4
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ use sea_orm::entity::prelude::*;
55
use serde::{Deserialize, Serialize};
66
use uuid::Uuid;
77

8+
use crate::models::CollectionToEntitySystemInformation;
9+
810
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
911
#[sea_orm(table_name = "collection_to_entity")]
1012
pub struct Model {
@@ -21,6 +23,8 @@ pub struct Model {
2123
pub exercise_id: Option<String>,
2224
pub workout_id: Option<String>,
2325
pub information: Option<serde_json::Value>,
26+
#[sea_orm(column_type = "Json")]
27+
pub system_information: CollectionToEntitySystemInformation,
2428
}
2529

2630
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]

apps/backend/src/entities/integration.rs

+7-6
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@
22
33
use async_graphql::{InputObject, SimpleObject};
44
use async_trait::async_trait;
5-
use database::{IntegrationLot, IntegrationSource};
5+
use database::{IntegrationLot, IntegrationProvider};
66
use nanoid::nanoid;
77
use sea_orm::{entity::prelude::*, ActiveValue};
88

9-
use crate::models::media::IntegrationSourceSpecifics;
9+
use crate::models::media::IntegrationProviderSpecifics;
1010

1111
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, SimpleObject, InputObject)]
1212
#[sea_orm(table_name = "integration")]
@@ -15,19 +15,20 @@ pub struct Model {
1515
#[sea_orm(primary_key, auto_increment = false)]
1616
#[graphql(skip_input)]
1717
pub id: String,
18-
pub minimum_progress: Decimal,
19-
pub maximum_progress: Decimal,
18+
pub minimum_progress: Option<Decimal>,
19+
pub maximum_progress: Option<Decimal>,
2020
#[graphql(skip)]
2121
pub user_id: String,
2222
pub lot: IntegrationLot,
23-
pub source: IntegrationSource,
23+
pub provider: IntegrationProvider,
2424
pub is_disabled: Option<bool>,
2525
#[graphql(skip_input)]
2626
pub created_on: DateTimeUtc,
2727
#[graphql(skip_input)]
2828
pub last_triggered_on: Option<DateTimeUtc>,
2929
#[sea_orm(column_type = "Json")]
30-
pub source_specifics: Option<IntegrationSourceSpecifics>,
30+
#[graphql(skip)]
31+
pub provider_specifics: Option<IntegrationProviderSpecifics>,
3132
}
3233

3334
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]

apps/backend/src/entities/metadata.rs

+5-3
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@ use sea_orm::{entity::prelude::*, ActiveValue};
99
use serde::{Deserialize, Serialize};
1010

1111
use crate::models::media::{
12-
AnimeSpecifics, AudioBookSpecifics, BookSpecifics, MangaSpecifics, MetadataFreeCreator,
13-
MetadataImage, MetadataStateChanges, MetadataVideo, MovieSpecifics, PodcastSpecifics,
14-
ShowSpecifics, VideoGameSpecifics, VisualNovelSpecifics, WatchProvider,
12+
AnimeSpecifics, AudioBookSpecifics, BookSpecifics, ExternalIdentifiers, MangaSpecifics,
13+
MetadataFreeCreator, MetadataImage, MetadataStateChanges, MetadataVideo, MovieSpecifics,
14+
PodcastSpecifics, ShowSpecifics, VideoGameSpecifics, VisualNovelSpecifics, WatchProvider,
1515
};
1616

1717
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize, Default)]
@@ -41,6 +41,8 @@ pub struct Model {
4141
pub free_creators: Option<Vec<MetadataFreeCreator>>,
4242
#[sea_orm(column_type = "Json")]
4343
pub watch_providers: Option<Vec<WatchProvider>>,
44+
#[sea_orm(column_type = "Json")]
45+
pub external_identifiers: Option<ExternalIdentifiers>,
4446
pub audio_book_specifics: Option<AudioBookSpecifics>,
4547
pub book_specifics: Option<BookSpecifics>,
4648
pub movie_specifics: Option<MovieSpecifics>,

apps/backend/src/integrations.rs

+73
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,33 @@ use std::future::Future;
33
use anyhow::{anyhow, bail, Result};
44
use async_graphql::Result as GqlResult;
55
use database::{MediaLot, MediaSource};
6+
use radarr_api_rs::{
7+
apis::{
8+
configuration::{ApiKey as RadarrApiKey, Configuration as RadarrConfiguration},
9+
movie_api::api_v3_movie_post as radarr_api_v3_movie_post,
10+
},
11+
models::{AddMovieOptions as RadarrAddMovieOptions, MovieResource as RadarrMovieResource},
12+
};
613
use regex::Regex;
714
use reqwest::header::{HeaderValue, AUTHORIZATION};
815
use rust_decimal::Decimal;
916
use rust_decimal_macros::dec;
1017
use sea_orm::{ColumnTrait, Condition, DatabaseConnection, EntityTrait, QueryFilter};
1118
use sea_query::{extension::postgres::PgExpr, Alias, Expr, Func};
1219
use serde::{Deserialize, Serialize};
20+
use sonarr_api_rs::{
21+
apis::{
22+
configuration::{ApiKey as SonarrApiKey, Configuration as SonarrConfiguration},
23+
series_api::api_v3_series_post as sonarr_api_v3_series_post,
24+
},
25+
models::{AddSeriesOptions as SonarrAddSeriesOptions, SeriesResource as SonarrSeriesResource},
26+
};
1327

1428
use crate::{
1529
entities::{metadata, prelude::Metadata},
1630
models::{audiobookshelf_models, media::CommitMediaInput},
1731
providers::google_books::GoogleBooksService,
32+
traits::TraceOk,
1833
utils::{get_base_http_client, ilike_sql},
1934
};
2035

@@ -517,4 +532,62 @@ impl IntegrationService {
517532
}
518533
Ok((media_items, vec![]))
519534
}
535+
536+
pub async fn radarr_push(
537+
&self,
538+
radarr_base_url: String,
539+
radarr_api_key: String,
540+
radarr_profile_id: i32,
541+
radarr_root_folder_path: String,
542+
tmdb_id: String,
543+
) -> Result<()> {
544+
let mut configuration = RadarrConfiguration::new();
545+
configuration.base_path = radarr_base_url;
546+
configuration.api_key = Some(RadarrApiKey {
547+
key: radarr_api_key,
548+
prefix: None,
549+
});
550+
let mut resource = RadarrMovieResource::new();
551+
resource.tmdb_id = Some(tmdb_id.parse().unwrap());
552+
resource.quality_profile_id = Some(radarr_profile_id);
553+
resource.root_folder_path = Some(Some(radarr_root_folder_path.clone()));
554+
resource.monitored = Some(true);
555+
let mut options = RadarrAddMovieOptions::new();
556+
options.search_for_movie = Some(true);
557+
resource.add_options = Some(Box::new(options));
558+
radarr_api_v3_movie_post(&configuration, Some(resource))
559+
.await
560+
.trace_ok();
561+
Ok(())
562+
}
563+
564+
pub async fn sonarr_push(
565+
&self,
566+
sonarr_base_url: String,
567+
sonarr_api_key: String,
568+
sonarr_profile_id: i32,
569+
sonarr_root_folder_path: String,
570+
tvdb_id: String,
571+
) -> Result<()> {
572+
let mut configuration = SonarrConfiguration::new();
573+
configuration.base_path = sonarr_base_url;
574+
configuration.api_key = Some(SonarrApiKey {
575+
key: sonarr_api_key,
576+
prefix: None,
577+
});
578+
let mut resource = SonarrSeriesResource::new();
579+
resource.title = Some(Some(tvdb_id.clone()));
580+
resource.tvdb_id = Some(tvdb_id.parse().unwrap());
581+
resource.quality_profile_id = Some(sonarr_profile_id);
582+
resource.root_folder_path = Some(Some(sonarr_root_folder_path.clone()));
583+
resource.monitored = Some(true);
584+
resource.season_folder = Some(true);
585+
let mut options = SonarrAddSeriesOptions::new();
586+
options.search_for_missing_episodes = Some(true);
587+
resource.add_options = Some(Box::new(options));
588+
sonarr_api_v3_series_post(&configuration, Some(resource))
589+
.await
590+
.trace_ok();
591+
Ok(())
592+
}
520593
}

apps/backend/src/main.rs

+5-5
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ use utils::{COMPILATION_TIMESTAMP, TEMP_DIR};
4848
use crate::{
4949
background::{
5050
background_jobs, perform_application_job, perform_core_application_job,
51-
yank_integrations_data,
51+
sync_integrations_data,
5252
},
5353
entities::prelude::Exercise,
5454
graphql::get_schema,
@@ -102,7 +102,7 @@ async fn main() -> Result<()> {
102102
.map(|f| f.parse().unwrap())
103103
.collect_vec();
104104
let rate_limit_count = config.scheduler.rate_limit_num;
105-
let pull_every_minutes = config.integration.pull_every_minutes;
105+
let sync_every_minutes = config.integration.sync_every_minutes;
106106
let max_file_size = config.server.max_file_size;
107107
let disable_background_jobs = config.server.disable_background_jobs;
108108

@@ -273,18 +273,18 @@ async fn main() -> Result<()> {
273273
)
274274
.register_with_count(
275275
1,
276-
WorkerBuilder::new("yank_integrations_data")
276+
WorkerBuilder::new("sync_integrations_data")
277277
.stream(
278278
CronStream::new_with_timezone(
279-
Schedule::from_str(&format!("0 */{} * * * *", pull_every_minutes))
279+
Schedule::from_str(&format!("0 */{} * * * *", sync_every_minutes))
280280
.unwrap(),
281281
tz,
282282
)
283283
.into_stream(),
284284
)
285285
.layer(ApalisTraceLayer::new())
286286
.data(media_service_3.clone())
287-
.build_fn(yank_integrations_data),
287+
.build_fn(sync_integrations_data),
288288
)
289289
// application jobs
290290
.register_with_count(

0 commit comments

Comments
 (0)