bazarr-bulk 0.1.6

A bulk operation CLI tool for Bazarr
use std::{borrow::Borrow, fmt::Debug, process::exit};

use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
use reqwest::Url;
use reqwest_middleware::ClientWithMiddleware;
use serde::de::DeserializeOwned;

use crate::{
    cli::ActionCommands,
    data_types::{
        request::ActionPayload,
        response::{Episode, Movie, PaginatedResponse, TVShow},
    },
};

pub struct Action {
    pub client: ClientWithMiddleware,
    pub base_url: Url,
    pub action: ActionCommands,
    pub ids: Vec<u32>,
    pub offset: u32,
    pub limit: Option<u32>,
    pub pb: ProgressBar,
}

impl Action {
    pub fn new(client: ClientWithMiddleware, base_url: Url) -> Self {
        let pb = ProgressBar::new(0);
        Self {
            client,
            base_url,
            action: ActionCommands::OCRFixes,
            ids: Vec::new(),
            offset: 0,
            limit: None,
            pb,
        }
    }

    async fn get_all<T>(&self, url: Url) -> Result<PaginatedResponse<T>, Box<dyn std::error::Error>>
    where
        T: DeserializeOwned,
        T: Debug,
    {
        let req = self.client.get(url);
        let res = req.send().await?;
        let body: PaginatedResponse<T> = res.json().await?;
        Ok(body)
    }

    async fn limit_records(&self, mut url: Url, query_param: &str) -> Url {
        if !self.ids.is_empty() {
            for id in &self.ids {
                url.query_pairs_mut()
                    .append_pair(query_param, &id.to_string());
            }
        } else if self.limit.is_some() || self.offset > 0 {
            let length = match self.limit {
                Some(val) => val,
                None => std::u32::MAX,
            };
            url.query_pairs_mut()
                .append_pair("length", &length.to_string())
                .append_pair("start", &self.offset.to_string());
        }
        url
    }

    async fn perform(
        &self,
        mut payload: ActionPayload,
    ) -> Result<reqwest::Response, reqwest_middleware::Error> {
        let mut url = self.base_url.clone();
        url.path_segments_mut().unwrap().push("subtitles");
        let action_string: String = self.action.to_string();
        url.query_pairs_mut().append_pair("action", &action_string);
        if let ActionCommands::Sync(sync_options) = &self.action.borrow() {
            payload.reference = sync_options.reference.clone();
            payload.max_offset_seconds = sync_options.max_offset_seconds;
            payload.no_fix_framerate = Some(sync_options.no_fix_framerate);
            payload.gss = Some(sync_options.gss);
        }
        self.client.patch(url).json(&payload).send().await
    }

    async fn process_episode_subtitle(&self, pb: &ProgressBar, episode: Episode) {
        for subtitle in episode.subtitles {
            if !subtitle.is_valid() {
                continue;
            }

            pb.set_message(format!(
                "Performing action {} on {} subtitle of episode {}",
                self.action.to_string(),
                subtitle.audio_language_item.name,
                episode.title,
            ));

            let payload = ActionPayload::new(episode.sonarr_episode_id, "episode", &subtitle);
            match self.perform(payload).await {
                Ok(res) => match res.error_for_status() {
                    Ok(_) => {
                        pb.set_message(format!(
                            "Successfully performed action {} on {} subtitle of episode {}",
                            self.action.to_string(),
                            subtitle.audio_language_item.name,
                            episode.title,
                        ));
                    }
                    Err(err) => {
                        pb.set_message(format!(
                            "Error performing action {} on {} subtitle of episode {}: {}",
                            self.action.to_string(),
                            subtitle.audio_language_item.name,
                            episode.title,
                            err,
                        ));
                    }
                },
                Err(err) => {
                    self.pb
                        .set_message(format!("Error connecting to Bazarr: {}", err));
                    exit(1);
                }
            }
        }
    }

    async fn process_movie_subtitle(&self, movie: Movie) {
        for subtitle in movie.subtitles {
            if !subtitle.is_valid() {
                continue;
            }
            self.pb.set_message(format!(
                "Performing action {} on {} subtitle of movie {}",
                self.action.to_string(),
                subtitle.audio_language_item.name,
                movie.title,
            ));
            let payload = ActionPayload::new(movie.radarr_id, "movie", &subtitle);
            match self.perform(payload).await {
                Ok(res) => match res.error_for_status() {
                    Ok(_) => {
                        self.pb.set_message(format!(
                            "Successfully performed action {} on {} subtitle of movie {}",
                            self.action.to_string(),
                            subtitle.audio_language_item.name,
                            movie.title,
                        ));
                    }
                    Err(err) => {
                        self.pb.set_message(format!(
                            "Error performing action {} on {} subtitle of episode {}: {}",
                            self.action.to_string(),
                            subtitle.audio_language_item.name,
                            movie.title,
                            err,
                        ));
                    }
                },
                Err(err) => {
                    self.pb
                        .set_message(format!("Error connecting to Bazarr: {}", err));
                    exit(1);
                }
            }
        }
    }

    pub async fn movies(&self) -> Result<(), Box<dyn std::error::Error>> {
        self.pb.set_style(
            ProgressStyle::with_template("[{bar:60.green/yellow}] {pos:>7}/{len:7} Movies\n{msg}")
                .unwrap()
                .progress_chars("##-"),
        );
        let mut url = self.base_url.clone();
        url.path_segments_mut().unwrap().push("movies");
        url = self.limit_records(url, "radarrid[]").await;
        let response = self.get_all::<Movie>(url).await?;
        let num_movies: u64 = response.data.len() as u64;
        if num_movies == 0 {
            self.pb.finish_with_message("No movies found");
            return Ok(());
        }

        self.pb.set_length(num_movies);
        for movie in response.data {
            self.process_movie_subtitle(movie).await;
            self.pb.inc(1);
        }
        self.pb.finish_with_message(format!(
            "Finished performing action {} on all movies",
            self.action.to_string(),
        ));
        Ok(())
    }

    pub async fn tv_shows(&self) -> Result<(), Box<dyn std::error::Error>> {
        let mp = MultiProgress::new();
        let pb_main = mp.add(self.pb.clone());
        pb_main.set_style(
            ProgressStyle::with_template(
                "[{bar:60.green/yellow}] {pos:>7}/{len:7} TV Shows\n{msg}",
            )
            .unwrap()
            .progress_chars("##-"),
        );
        let mut url = self.base_url.clone();
        url.path_segments_mut().unwrap().push("series");
        url = self.limit_records(url, "seriesid[]").await;
        let response = self.get_all::<TVShow>(url.clone()).await?;
        let num_series: u64 = response.data.len() as u64;
        if num_series == 0 {
            pb_main.finish_with_message("No tv shows found");
            return Ok(());
        }

        pb_main.set_length(num_series);
        let sub_pb = mp.insert_after(&pb_main, ProgressBar::new(0));
        sub_pb.set_style(
            ProgressStyle::with_template("[{bar:60.cyan/blue}] {pos:>7}/{len:7} Episodes\n{msg}")
                .unwrap()
                .progress_chars("##-"),
        );
        url.path_segments_mut().unwrap().pop().push("episodes");
        for series in response.data {
            pb_main.set_message(format!("Processing tv show {}", series.title,));
            let query_param = format!("seriesid[]={}", series.sonarr_series_id);
            let mut new_url = url.clone();
            new_url.set_query(Some(&query_param));
            let response = self.get_all::<Episode>(new_url).await?;
            let num_episodes: u64 = response.data.len() as u64;
            sub_pb.set_length(num_episodes);
            if num_episodes == 0 {
                sub_pb.finish_with_message("No episodes found");
                continue;
            }
            for episode in response.data {
                self.process_episode_subtitle(&sub_pb, episode).await;
                sub_pb.inc(1);
            }
            pb_main.inc(1);
            pb_main.set_message(format!("Finished processing tv show {}", series.title,));
        }
        pb_main.finish_with_message(format!(
            "Finished performing action {} on all tv shows",
            self.action.to_string(),
        ));
        Ok(())
    }
}