diff --git a/Cargo.toml b/Cargo.toml index d4941bc..28eede5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "putioarr" -description = "put.io to sonarr/radarr/whisparr proxy" +description = "put.io to sonarr/radarr/whisparr/lidarr proxy" authors = ["Wouter de Bie - Password: @@ -29,7 +29,7 @@ Docker images are based on [linuxserver.io](https://linuxserver.io) images. #### Usage -The first time you run your docker container, run it without the `-d` option, since you'll need a put.io API key. When no configuration is found, it will present you a link and a code that will generate an API key. After the key is generated, putioarr will write a default config in your config volume (see `docker compose` and `docker cli` below). Modify the config (like username, password and sonarr/radarr/whisparr configuration) in order to properly use putioarr. +The first time you run your docker container, run it without the `-d` option, since you'll need a put.io API key. When no configuration is found, it will present you a link and a code that will generate an API key. After the key is generated, putioarr will write a default config in your config volume (see `docker compose` and `docker cli` below). Modify the config (like username, password and sonarr/radarr/whisparr/lidarr configuration) in order to properly use putioarr. #### Supported Architectures @@ -88,7 +88,7 @@ Container images are configured using parameters passed at runtime (such as thos ## Behavior -The proxy will upload torrents or magnet links to put.io. It will then continue to monitor transfers. When a transfer is completed, all files belonging to the transfer will be downloaded to the specified download directory. The proxy will remove the files after sonarr/radarr/whisparr has imported them and put.io is done seeding. The proxy will skip directories named "Sample". +The proxy will upload torrents or magnet links to put.io. It will then continue to monitor transfers. When a transfer is completed, all files belonging to the transfer will be downloaded to the specified download directory. The proxy will remove the files after sonarr/radarr/whisparr/lidarr has imported them and put.io is done seeding. The proxy will skip directories named "Sample". ## Configuration A configuration file can be specified using `-c`, but the default configuration file location is: @@ -97,12 +97,12 @@ A configuration file can be specified using `-c`, but the default configuration TOML is used as the configuration format: ``` -# Required. Username and password that sonarr/radarr/whisparr use to connect to the proxy +# Required. Username and password that sonarr/radarr/whisparr/lidarr use to connect to the proxy username = "myusername" password = "mypassword" # Required. Directory where the proxy will download files to. This directory has to be readable by -# sonarr/radarr/whisparr in order to import downloads +# sonarr/radarr/whisparr/lidarr in order to import downloads download_directory = "/path/to/downloads" # Optional bind address, default "0.0.0.0" @@ -150,7 +150,7 @@ api_key = "MYRADARRAPIKEY" - Better Error handling and retry behavior - The session ID provided is hard coded. Not sure if it matters. - (Add option to not delete downloads) -- Figure out a better way to map a transfer to a completed import. Since a transfer can contain multiple files (e.g. a whole season) we currently check if all video files have been imported. Most of the time this is fine, except when there are sample videos. sonarr/radarr/whisparr will not import samples, but will make no mention of the fact that the sample was skipped. Right now we check against the `skip_directories` list, which works, but might be tedious. +- Figure out a better way to map a transfer to a completed import. Since a transfer can contain multiple files (e.g. a whole season) we currently check if all video files have been imported. Most of the time this is fine, except when there are sample videos. sonarr/radarr/whisparr/lidarr will not import samples, but will make no mention of the fact that the sample was skipped. Right now we check against the `skip_directories` list, which works, but might be tedious. - Automatically pick the right putio proxy based on speed ## Thanks diff --git a/root/defaults/config.toml b/root/defaults/config.toml index a376b18..a300633 100644 --- a/root/defaults/config.toml +++ b/root/defaults/config.toml @@ -50,3 +50,8 @@ api_key = "" # url = "http://mywhisparrhost:6969/radarr" # Can be found in Radarr: Settings -> General # api_key = "MYWHISPARRAPIKEY" + +# [lidarr] +# url = "http://mylidarrhost:6969/lidarr" +# Can be found in Lidarr: Settings -> General +# api_key = "MYLIDARRAPIKEY" diff --git a/src/download_system/transfer.rs b/src/download_system/transfer.rs index e0f92fe..8839faf 100644 --- a/src/download_system/transfer.rs +++ b/src/download_system/transfer.rs @@ -1,6 +1,6 @@ use crate::{ services::{ - arr, + arr::ArrApp, putio::{self, PutIOTransfer}, }, AppData, @@ -10,7 +10,7 @@ use anyhow::Result; use async_channel::Sender; use async_recursion::async_recursion; use colored::*; -use log::{error, info, warn}; +use log::{debug, error, info, warn}; use serde::{Deserialize, Serialize}; use std::{fmt::Display, path::Path}; use tokio::time::sleep; @@ -28,16 +28,7 @@ pub struct Transfer { impl Transfer { pub async fn is_imported(&self) -> bool { let targets = self.targets.as_ref().unwrap().clone(); - let mut check_services = Vec::<(&str, String, String)>::new(); - if let Some(a) = &self.app_data.config.sonarr { - check_services.push(("Sonarr", a.url.clone(), a.api_key.clone())) - } - if let Some(a) = &self.app_data.config.radarr { - check_services.push(("Radarr", a.url.clone(), a.api_key.clone())) - } - if let Some(a) = &self.app_data.config.whisparr { - check_services.push(("Whisparr", a.url.clone(), a.api_key.clone())) - } + let apps = ArrApp::from_config(&self.app_data.config); let targets = targets .into_iter() @@ -49,24 +40,19 @@ impl Transfer { let mut results = Vec::::new(); for target in targets { let mut service_results = vec![]; - for (service_name, url, key) in &check_services { - let service_result = match arr::check_imported(&target.to, key, url).await { + for app in &apps { + let service_result = match app.check_imported(&target).await { Ok(r) => r, Err(e) => { - error!("Error retrieving history from {}: {}", service_name, e); + error!("Error retrieving history from {}: {}", app, e); false } }; if service_result { - info!( - "{}: found imported by {}", - &target, - service_name.bright_blue() - ); + info!("{}: found imported by {}", &target, app); } service_results.push(service_result) } - // Check if ANY of the service_results are true and put the outcome in results results.push(service_results.into_iter().any(|x| x)); } // Check if all targets have been imported @@ -143,6 +129,7 @@ async fn recurse_download_targets( to, top_level, transfer_hash: hash.to_string(), + media_type: None, }); for file in response.files { @@ -159,7 +146,7 @@ async fn recurse_download_targets( } } } - "VIDEO" => { + "VIDEO" | "AUDIO" => { // Get download URL for file let url = putio::url(&app_data.config.putio.api_key, response.parent.id).await?; targets.push(DownloadTarget { @@ -168,9 +155,16 @@ async fn recurse_download_targets( to, top_level, transfer_hash: hash.to_string(), + media_type: MediaType::from_file_type_str(response.parent.file_type.as_str()), }); } - _ => {} + _ => { + debug!( + "{}: skipping filetype {}", + response.parent.name, + response.parent.file_type.as_str() + ); + } } Ok(targets) @@ -183,6 +177,22 @@ pub enum TransferMessage { Imported(Transfer), } +#[derive(PartialEq, Debug, Serialize, Deserialize, Clone)] +pub enum MediaType { + Audio, + Video, +} + +impl MediaType { + pub fn from_file_type_str(file_type: &str) -> Option { + match file_type { + "AUDIO" => Some(Self::Audio), + "VIDEO" => Some(Self::Video), + _ => None, + } + } +} + #[derive(Debug, Serialize, Deserialize, Clone)] pub struct DownloadTarget { pub from: Option, @@ -190,6 +200,7 @@ pub struct DownloadTarget { pub target_type: TargetType, pub top_level: bool, pub transfer_hash: String, + pub media_type: Option, } impl Display for DownloadTarget { diff --git a/src/main.rs b/src/main.rs index 9b15f64..9a7784e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,4 @@ -use crate::{http::routes, services::putio}; +use crate::{http::routes, services::arr, services::putio}; use actix_web::{web, App, HttpServer}; use anyhow::{bail, Context, Result}; use clap::{Parser, Subcommand}; @@ -55,9 +55,10 @@ pub struct Config { uid: u32, username: String, putio: PutioConfig, - sonarr: Option, - radarr: Option, - whisparr: Option, + sonarr: Option, + radarr: Option, + whisparr: Option, + lidarr: Option, } #[derive(Debug, Deserialize, Serialize, Clone)] @@ -65,12 +66,6 @@ pub struct PutioConfig { api_key: String, } -#[derive(Debug, Deserialize, Serialize, Clone)] -pub struct ArrConfig { - url: String, - api_key: String, -} - pub struct AppData { pub config: Config, } diff --git a/src/services/arr.rs b/src/services/arr.rs index 9a57cf6..3286a6d 100644 --- a/src/services/arr.rs +++ b/src/services/arr.rs @@ -1,6 +1,14 @@ +use crate::{download_system::transfer, Config}; use anyhow::{bail, Result}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use std::collections::HashMap; +use std::fmt; + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct ArrConfig { + url: String, + api_key: String, +} #[derive(Deserialize, Debug)] #[serde(rename_all = "camelCase")] @@ -16,44 +24,137 @@ pub struct ArrHistoryRecord { pub data: HashMap>, } -pub async fn check_imported(target: &str, api_key: &str, base_url: &str) -> Result { - let client = reqwest::Client::new(); - let mut inspected = 0; - let mut page = 0; - loop { - let url = format!( - "{base_url}/api/v3/history?includeSeries=false&includeEpisode=false&page={page}&pageSize=1000"); +#[derive(Debug)] +pub enum ArrAppType { + Lidarr, + Radarr, + Sonarr, + Whisparr, +} - let response = client.get(&url).header("X-Api-Key", api_key).send().await?; +pub struct ArrApp { + app_type: ArrAppType, + config: ArrConfig, + client: reqwest::Client, + media_type: transfer::MediaType, +} - let status = response.status(); +impl ArrApp { + pub fn new(app_type: ArrAppType, config: &ArrConfig, media_type: transfer::MediaType) -> Self { + Self { + app_type: app_type, + config: config.clone(), + client: reqwest::Client::new(), + media_type: media_type, + } + } - if !status.is_success() { - bail!("url: {}, status: {}", url, status); + pub fn from_config(config: &Config) -> Vec { + let mut apps = vec![]; + if let Some(c) = &config.lidarr { + apps.push(Self::new(ArrAppType::Lidarr, c, transfer::MediaType::Audio)); + } + if let Some(c) = &config.radarr { + apps.push(Self::new(ArrAppType::Radarr, c, transfer::MediaType::Video)); + } + if let Some(c) = &config.sonarr { + apps.push(Self::new(ArrAppType::Sonarr, c, transfer::MediaType::Video)); } + if let Some(c) = &config.whisparr { + apps.push(Self::new( + ArrAppType::Whisparr, + c, + transfer::MediaType::Video, + )); + } + apps + } - let bytes = response.bytes().await?; - let json: serde_json::Result = serde_json::from_slice(&bytes); - if json.is_err() { - bail!("url: {url}, status: {status}, body: {bytes:?}"); + fn url(&self, page: u32) -> String { + match &self.app_type { + ArrAppType::Radarr | ArrAppType::Sonarr | ArrAppType::Whisparr => { + format!( + "{0}/api/v3/history?includeSeries=false&includeEpisode=false&page={page}&pageSize=1000", self.config.url) + } + ArrAppType::Lidarr => { + format!( + "{0}/api/v1/history?includeArtist=false&includeAlbum=false&includeTrack=false&page={page}&pageSize=1000", self.config.url) + } } - let history_response: ArrHistoryResponse = json?; + } - for record in history_response.records { - if record.event_type == "downloadFolderImported" - && record.data["droppedPath"].as_ref().unwrap() == target - { - return Ok(true); - } else { - inspected += 1; - continue; + async fn get(&self, page: u32) -> Result { + self.client + .get(self.url(page)) + .header("X-Api-Key", &self.config.api_key) + .send() + .await + } + + fn should_handle(&self, target: &transfer::DownloadTarget) -> Result { + match &target.media_type { + Some(mt) => { + if *mt != self.media_type { + Ok(false) + } else { + Ok(true) + } + } + None => { + bail!("Cannot check files with no media type: {}", target); } } + } - if history_response.total_records < inspected { - page += 1; - } else { + pub async fn check_imported(&self, target: &transfer::DownloadTarget) -> Result { + if !self.should_handle(target)? { return Ok(false); } + let mut inspected = 0; + let mut page = 0; + loop { + let response = self.get(page).await?; + let status = response.status(); + + if !status.is_success() { + bail!("url: {}, status: {}", self.url(page), status); + } + + let bytes = response.bytes().await?; + let json: serde_json::Result = serde_json::from_slice(&bytes); + if json.is_err() { + bail!( + "url: {}, status: {}, body: {:?}", + self.url(page), + status, + bytes + ); + } + let history_response: ArrHistoryResponse = json?; + + for record in history_response.records { + if (record.event_type == "downloadFolderImported" + || record.event_type == "trackFileImported") + && record.data["droppedPath"].as_ref().unwrap() == &target.to + { + return Ok(true); + } else { + inspected += 1; + continue; + } + } + + if history_response.total_records < inspected { + page += 1; + } else { + return Ok(false); + } + } + } +} + +impl fmt::Display for ArrApp { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{:?}", self.app_type) } } diff --git a/src/services/mod.rs b/src/services/mod.rs index 65e28ff..546a52b 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -1,3 +1,3 @@ -pub mod transmission; -pub mod putio; pub mod arr; +pub mod putio; +pub mod transmission; diff --git a/src/services/putio.rs b/src/services/putio.rs index a0bfdfd..94be154 100644 --- a/src/services/putio.rs +++ b/src/services/putio.rs @@ -38,8 +38,7 @@ impl PutIOTransfer { } #[derive(Debug, Deserialize)] -pub struct AccountInfoResponse { -} +pub struct AccountInfoResponse {} pub async fn account_info(api_token: &str) -> Result { let client = reqwest::Client::new(); diff --git a/src/utils.rs b/src/utils.rs index 79ac853..dc18efc 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -55,10 +55,15 @@ url = "http://myradarrhost:7878/radarr" api_key = "MYRADARRAPIKEY" [whisparr] -url = "http://mywhisparrhost:6969/radarr" +url = "http://mywhisparrhost:6969/whisparr" # Can be found in Settings -> General api_key = "MYWHISPARRAPIKEY" +[lidarr] +url = "http://mylidarrhost:6969/lidarr" +# Can be found in Settings -> General +api_key = "MYLIDARRAPIKEY" + "#; #[derive(Serialize)]