From 6dffc90e9265c215091ece78c5caecb94fe899ff Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 15 Nov 2024 14:57:19 -0700 Subject: [PATCH] feat(sonarr_network): Added support for fetching episodes for a specified series to the network events --- Cargo.lock | 2 +- src/models/sonarr_models.rs | 4 +- src/models/stateful_tree.rs | 4 +- src/network/sonarr_network.rs | 75 +++++++- src/network/sonarr_network_tests.rs | 270 +++++++++++++++++++++++++++- 5 files changed, 348 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7f464e2..93eb84d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1314,7 +1314,7 @@ dependencies = [ [[package]] name = "managarr-tree-widget" version = "0.24.0" -source = "git+https://github.com/Dark-Alex-17/managarr-tree-widget.git#6baef469c8d20f7f1e567edba9886401ea9baa7c" +source = "git+https://github.com/Dark-Alex-17/managarr-tree-widget.git#b0d8d9f0bfdbb4f4a43f9a59806f8e84bbaf4e30" dependencies = [ "ratatui", "unicode-width 0.2.0", diff --git a/src/models/sonarr_models.rs b/src/models/sonarr_models.rs index b11fe77..afdebaa 100644 --- a/src/models/sonarr_models.rs +++ b/src/models/sonarr_models.rs @@ -279,11 +279,11 @@ impl From<()> for SonarrSerdeable { serde_enum_from!( SonarrSerdeable { Value(Value), - Episodes(Vec), + Episodes(Vec), SeriesVec(Vec), SystemStatus(SystemStatus), BlocklistResponse(BlocklistResponse), - LogResponse(LogResponse), + LogResponse(LogResponse), } ); diff --git a/src/models/stateful_tree.rs b/src/models/stateful_tree.rs index 6837777..e33b516 100644 --- a/src/models/stateful_tree.rs +++ b/src/models/stateful_tree.rs @@ -12,7 +12,7 @@ mod stateful_tree_tests; #[derive(Default)] pub struct StatefulTree where - T: ToText + Hash + Clone + PartialEq + Eq + Debug + Default + Display, + T: ToText + Hash + Clone + PartialEq + Eq + Debug + Default + Display + PartialEq + Eq, { pub state: TreeState, pub items: Vec>, @@ -20,7 +20,7 @@ where impl StatefulTree where - T: ToText + Hash + Clone + PartialEq + Eq + Debug + Default + Display, + T: ToText + Hash + Clone + PartialEq + Eq + Debug + Default + Display + PartialEq + Eq, { pub fn set_items(&mut self, items: Vec>) { self.items = items; diff --git a/src/network/sonarr_network.rs b/src/network/sonarr_network.rs index 2e87e22..252ab08 100644 --- a/src/network/sonarr_network.rs +++ b/src/network/sonarr_network.rs @@ -1,11 +1,16 @@ +use std::collections::BTreeMap; + use anyhow::Result; use log::info; +use managarr_tree_widget::TreeItem; use serde_json::{json, Value}; use crate::{ models::{ servarr_data::sonarr::sonarr_data::ActiveSonarrBlock, - sonarr_models::{BlocklistResponse, LogResponse, Series, SonarrSerdeable, SystemStatus}, + sonarr_models::{ + BlocklistResponse, Episode, LogResponse, Series, SonarrSerdeable, SystemStatus, + }, HorizontallyScrollableText, Route, Scrollable, }, network::RequestMethod, @@ -21,6 +26,7 @@ pub enum SonarrEvent { ClearBlocklist, DeleteBlocklistItem(Option), GetBlocklist, + GetEpisodes(Option), GetLogs(Option), GetStatus, HealthCheck, @@ -33,6 +39,7 @@ impl NetworkResource for SonarrEvent { SonarrEvent::ClearBlocklist => "/blocklist/bulk", SonarrEvent::DeleteBlocklistItem(_) => "/blocklist", SonarrEvent::GetBlocklist => "/blocklist?page=1&pageSize=10000", + SonarrEvent::GetEpisodes(_) => "/episode", SonarrEvent::GetLogs(_) => "/log", SonarrEvent::GetStatus => "/system/status", SonarrEvent::HealthCheck => "/health", @@ -62,6 +69,10 @@ impl<'a, 'b> Network<'a, 'b> { .await .map(SonarrSerdeable::from), SonarrEvent::GetBlocklist => self.get_sonarr_blocklist().await.map(SonarrSerdeable::from), + SonarrEvent::GetEpisodes(series_id) => self + .get_episodes(series_id) + .await + .map(SonarrSerdeable::from), SonarrEvent::GetLogs(events) => self .get_sonarr_logs(events) .await @@ -175,6 +186,51 @@ impl<'a, 'b> Network<'a, 'b> { .await } + async fn get_episodes(&mut self, series_id: Option) -> Result> { + let event = SonarrEvent::GetEpisodes(series_id); + let (id, series_id_param) = self.extract_series_id(series_id).await; + info!("Fetching episodes for Sonarr series with ID: {id}"); + + let request_props = self + .request_props_from( + event, + RequestMethod::Get, + None::<()>, + None, + Some(series_id_param), + ) + .await; + + self + .handle_request::<(), Vec>(request_props, |mut episode_vec, mut app| { + episode_vec.sort_by(|a, b| a.id.cmp(&b.id)); + let mut seasons = BTreeMap::new(); + + for episode in episode_vec { + seasons + .entry(episode.season_number) + .or_insert_with(Vec::new) + .push(episode); + } + + let tree = seasons + .into_iter() + .map(|(season, episodes_vec)| { + let marker_episode = Episode { + title: Some(format!("Season {season}")), + ..Episode::default() + }; + let children = episodes_vec.into_iter().map(TreeItem::new_leaf).collect(); + + TreeItem::new(marker_episode, children).expect("All item identifiers must be unique") + }) + .collect(); + + app.data.sonarr_data.episodes.set_items(tree); + }) + .await + } + async fn get_sonarr_logs(&mut self, events: Option) -> Result { info!("Fetching Sonarr logs"); let event = SonarrEvent::GetLogs(events); @@ -259,4 +315,21 @@ impl<'a, 'b> Network<'a, 'b> { }) .await } + + async fn extract_series_id(&mut self, series_id: Option) -> (i64, String) { + let series_id = if let Some(id) = series_id { + id + } else { + self + .app + .lock() + .await + .data + .sonarr_data + .series + .current_selection() + .id + }; + (series_id, format!("seriesId={series_id}")) + } } diff --git a/src/network/sonarr_network_tests.rs b/src/network/sonarr_network_tests.rs index 84159ff..6b8c0d3 100644 --- a/src/network/sonarr_network_tests.rs +++ b/src/network/sonarr_network_tests.rs @@ -1,17 +1,23 @@ #[cfg(test)] mod test { + use std::sync::Arc; + use chrono::{DateTime, Utc}; + use managarr_tree_widget::TreeItem; use pretty_assertions::{assert_eq, assert_str_eq}; use reqwest::Client; use rstest::rstest; use serde_json::json; use serde_json::{Number, Value}; + use tokio::sync::Mutex; use tokio_util::sync::CancellationToken; + use crate::app::App; use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock; - use crate::models::sonarr_models::{BlocklistItem, Language, LogResponse}; + use crate::models::sonarr_models::{BlocklistItem, Episode, Language, LogResponse}; use crate::models::sonarr_models::{BlocklistResponse, Quality}; use crate::models::sonarr_models::{QualityWrapper, SystemStatus}; + use crate::models::stateful_table::StatefulTable; use crate::models::HorizontallyScrollableText; use crate::models::{sonarr_models::SonarrSerdeable, stateful_table::SortOption}; @@ -80,6 +86,7 @@ mod test { #[case(SonarrEvent::DeleteBlocklistItem(None), "/blocklist")] #[case(SonarrEvent::HealthCheck, "/health")] #[case(SonarrEvent::GetBlocklist, "/blocklist?page=1&pageSize=10000")] + #[case(SonarrEvent::GetEpisodes(None), "/episode")] #[case(SonarrEvent::GetLogs(Some(500)), "/log")] #[case(SonarrEvent::GetStatus, "/system/status")] fn test_resource(#[case] event: SonarrEvent, #[case] expected_uri: String) { @@ -278,6 +285,190 @@ mod test { async_server.assert_async().await; } + #[tokio::test] + async fn test_handle_get_episodes_event() { + let episodes_json = json!([ + { + "id": 2, + "seriesId": 1, + "tvdbId": 1234, + "episodeFileId": 2, + "seasonNumber": 2, + "episodeNumber": 2, + "title": "Something cool", + "airDateUtc": "2024-02-10T07:28:45Z", + "overview": "Okay so this one time at band camp...", + "hasFile": true, + "monitored": true + }, + { + "id": 1, + "seriesId": 1, + "tvdbId": 1234, + "episodeFileId": 1, + "seasonNumber": 1, + "episodeNumber": 1, + "title": "Something cool", + "airDateUtc": "2024-02-10T07:28:45Z", + "overview": "Okay so this one time at band camp...", + "hasFile": true, + "monitored": true + } + ]); + let marker_episode_1 = Episode { + title: Some("Season 1".to_owned()), + ..Episode::default() + }; + let marker_episode_2 = Episode { + title: Some("Season 2".to_owned()), + ..Episode::default() + }; + let episode_1 = episode(); + let episode_2 = Episode { + id: 2, + episode_file_id: 2, + season_number: 2, + episode_number: 2, + ..episode() + }; + let expected_episodes = vec![episode_2.clone(), episode_1.clone()]; + let expected_tree = vec![ + TreeItem::new( + marker_episode_1, + vec![TreeItem::new_leaf(episode_1.clone())], + ) + .unwrap(), + TreeItem::new( + marker_episode_2, + vec![TreeItem::new_leaf(episode_2.clone())], + ) + .unwrap(), + ]; + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(episodes_json), + None, + SonarrEvent::GetEpisodes(None), + None, + Some("seriesId=1"), + ) + .await; + app_arc + .lock() + .await + .data + .sonarr_data + .series + .set_items(vec![Series { + id: 1, + ..Series::default() + }]); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::Episodes(episodes) = network + .handle_sonarr_event(SonarrEvent::GetEpisodes(None)) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc.lock().await.data.sonarr_data.episodes.items, + expected_tree + ); + assert_eq!(episodes, expected_episodes); + } + } + + #[tokio::test] + async fn test_handle_get_episodes_event_uses_provided_series_id() { + let episodes_json = json!([ + { + "id": 2, + "seriesId": 2, + "tvdbId": 1234, + "episodeFileId": 2, + "seasonNumber": 2, + "episodeNumber": 2, + "title": "Something cool", + "airDateUtc": "2024-02-10T07:28:45Z", + "overview": "Okay so this one time at band camp...", + "hasFile": true, + "monitored": true + }, + { + "id": 1, + "seriesId": 2, + "tvdbId": 1234, + "episodeFileId": 1, + "seasonNumber": 1, + "episodeNumber": 1, + "title": "Something cool", + "airDateUtc": "2024-02-10T07:28:45Z", + "overview": "Okay so this one time at band camp...", + "hasFile": true, + "monitored": true + } + ]); + let marker_episode_1 = Episode { + title: Some("Season 1".to_owned()), + ..Episode::default() + }; + let marker_episode_2 = Episode { + title: Some("Season 2".to_owned()), + ..Episode::default() + }; + let episode_1 = Episode { + series_id: 2, + ..episode() + }; + let episode_2 = Episode { + id: 2, + episode_file_id: 2, + season_number: 2, + episode_number: 2, + series_id: 2, + ..episode() + }; + let expected_episodes = vec![episode_2.clone(), episode_1.clone()]; + let expected_tree = vec![ + TreeItem::new( + marker_episode_1, + vec![TreeItem::new_leaf(episode_1.clone())], + ) + .unwrap(), + TreeItem::new( + marker_episode_2, + vec![TreeItem::new_leaf(episode_2.clone())], + ) + .unwrap(), + ]; + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(episodes_json), + None, + SonarrEvent::GetEpisodes(None), + None, + Some("seriesId=2"), + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::Episodes(episodes) = network + .handle_sonarr_event(SonarrEvent::GetEpisodes(Some(2))) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc.lock().await.data.sonarr_data.episodes.items, + expected_tree + ); + assert_eq!(episodes, expected_episodes); + } + } + #[tokio::test] async fn test_handle_get_sonarr_logs_event() { let expected_logs = vec![ @@ -591,6 +782,65 @@ mod test { } } + #[tokio::test] + async fn test_extract_series_id() { + let app_arc = Arc::new(Mutex::new(App::default())); + app_arc + .lock() + .await + .data + .sonarr_data + .series + .set_items(vec![Series { + id: 1, + ..Series::default() + }]); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + let (id, series_id_param) = network.extract_series_id(None).await; + + assert_eq!(id, 1); + assert_str_eq!(series_id_param, "seriesId=1"); + } + + #[tokio::test] + async fn test_extract_series_id_uses_provided_id() { + let app_arc = Arc::new(Mutex::new(App::default())); + app_arc + .lock() + .await + .data + .sonarr_data + .series + .set_items(vec![Series { + id: 1, + ..Series::default() + }]); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + let (id, series_id_param) = network.extract_series_id(Some(2)).await; + + assert_eq!(id, 2); + assert_str_eq!(series_id_param, "seriesId=2"); + } + + #[tokio::test] + async fn test_extract_series_id_filtered_series() { + let app_arc = Arc::new(Mutex::new(App::default())); + let mut filtered_series = StatefulTable::default(); + filtered_series.set_filtered_items(vec![Series { + id: 1, + ..Series::default() + }]); + app_arc.lock().await.data.sonarr_data.series = filtered_series; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + let (id, series_id_param) = network.extract_series_id(None).await; + + assert_eq!(id, 1); + assert_str_eq!(series_id_param, "seriesId=1"); + } + fn blocklist_item() -> BlocklistItem { BlocklistItem { id: 1, @@ -606,6 +856,24 @@ mod test { } } + fn episode() -> Episode { + Episode { + id: 1, + series_id: 1, + tvdb_id: 1234, + episode_file_id: 1, + season_number: 1, + episode_number: 1, + title: Some("Something cool".to_owned()), + air_date_utc: Some(DateTime::from( + DateTime::parse_from_rfc3339("2024-02-10T07:28:45Z").unwrap(), + )), + overview: Some("Okay so this one time at band camp...".to_owned()), + has_file: true, + monitored: true, + } + } + fn language() -> Language { Language { name: "English".to_owned(),