feat(sonarr_network): Added support for fetching episodes for a specified series to the network events

This commit is contained in:
2024-11-15 14:57:19 -07:00
parent 295cd56a1f
commit 6dffc90e92
5 changed files with 348 additions and 7 deletions
Generated
+1 -1
View File
@@ -1314,7 +1314,7 @@ dependencies = [
[[package]] [[package]]
name = "managarr-tree-widget" name = "managarr-tree-widget"
version = "0.24.0" 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 = [ dependencies = [
"ratatui", "ratatui",
"unicode-width 0.2.0", "unicode-width 0.2.0",
+2 -2
View File
@@ -279,11 +279,11 @@ impl From<()> for SonarrSerdeable {
serde_enum_from!( serde_enum_from!(
SonarrSerdeable { SonarrSerdeable {
Value(Value), Value(Value),
Episodes(Vec<Episode>), Episodes(Vec<Episode>),
SeriesVec(Vec<Series>), SeriesVec(Vec<Series>),
SystemStatus(SystemStatus), SystemStatus(SystemStatus),
BlocklistResponse(BlocklistResponse), BlocklistResponse(BlocklistResponse),
LogResponse(LogResponse), LogResponse(LogResponse),
} }
); );
+2 -2
View File
@@ -12,7 +12,7 @@ mod stateful_tree_tests;
#[derive(Default)] #[derive(Default)]
pub struct StatefulTree<T> pub struct StatefulTree<T>
where 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 state: TreeState,
pub items: Vec<TreeItem<T>>, pub items: Vec<TreeItem<T>>,
@@ -20,7 +20,7 @@ where
impl<T> StatefulTree<T> impl<T> StatefulTree<T>
where 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<TreeItem<T>>) { pub fn set_items(&mut self, items: Vec<TreeItem<T>>) {
self.items = items; self.items = items;
+74 -1
View File
@@ -1,11 +1,16 @@
use std::collections::BTreeMap;
use anyhow::Result; use anyhow::Result;
use log::info; use log::info;
use managarr_tree_widget::TreeItem;
use serde_json::{json, Value}; use serde_json::{json, Value};
use crate::{ use crate::{
models::{ models::{
servarr_data::sonarr::sonarr_data::ActiveSonarrBlock, servarr_data::sonarr::sonarr_data::ActiveSonarrBlock,
sonarr_models::{BlocklistResponse, LogResponse, Series, SonarrSerdeable, SystemStatus}, sonarr_models::{
BlocklistResponse, Episode, LogResponse, Series, SonarrSerdeable, SystemStatus,
},
HorizontallyScrollableText, Route, Scrollable, HorizontallyScrollableText, Route, Scrollable,
}, },
network::RequestMethod, network::RequestMethod,
@@ -21,6 +26,7 @@ pub enum SonarrEvent {
ClearBlocklist, ClearBlocklist,
DeleteBlocklistItem(Option<i64>), DeleteBlocklistItem(Option<i64>),
GetBlocklist, GetBlocklist,
GetEpisodes(Option<i64>),
GetLogs(Option<u64>), GetLogs(Option<u64>),
GetStatus, GetStatus,
HealthCheck, HealthCheck,
@@ -33,6 +39,7 @@ impl NetworkResource for SonarrEvent {
SonarrEvent::ClearBlocklist => "/blocklist/bulk", SonarrEvent::ClearBlocklist => "/blocklist/bulk",
SonarrEvent::DeleteBlocklistItem(_) => "/blocklist", SonarrEvent::DeleteBlocklistItem(_) => "/blocklist",
SonarrEvent::GetBlocklist => "/blocklist?page=1&pageSize=10000", SonarrEvent::GetBlocklist => "/blocklist?page=1&pageSize=10000",
SonarrEvent::GetEpisodes(_) => "/episode",
SonarrEvent::GetLogs(_) => "/log", SonarrEvent::GetLogs(_) => "/log",
SonarrEvent::GetStatus => "/system/status", SonarrEvent::GetStatus => "/system/status",
SonarrEvent::HealthCheck => "/health", SonarrEvent::HealthCheck => "/health",
@@ -62,6 +69,10 @@ impl<'a, 'b> Network<'a, 'b> {
.await .await
.map(SonarrSerdeable::from), .map(SonarrSerdeable::from),
SonarrEvent::GetBlocklist => self.get_sonarr_blocklist().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 SonarrEvent::GetLogs(events) => self
.get_sonarr_logs(events) .get_sonarr_logs(events)
.await .await
@@ -175,6 +186,51 @@ impl<'a, 'b> Network<'a, 'b> {
.await .await
} }
async fn get_episodes(&mut self, series_id: Option<i64>) -> Result<Vec<Episode>> {
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<Episode>>(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<u64>) -> Result<LogResponse> { async fn get_sonarr_logs(&mut self, events: Option<u64>) -> Result<LogResponse> {
info!("Fetching Sonarr logs"); info!("Fetching Sonarr logs");
let event = SonarrEvent::GetLogs(events); let event = SonarrEvent::GetLogs(events);
@@ -259,4 +315,21 @@ impl<'a, 'b> Network<'a, 'b> {
}) })
.await .await
} }
async fn extract_series_id(&mut self, series_id: Option<i64>) -> (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}"))
}
} }
+269 -1
View File
@@ -1,17 +1,23 @@
#[cfg(test)] #[cfg(test)]
mod test { mod test {
use std::sync::Arc;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use managarr_tree_widget::TreeItem;
use pretty_assertions::{assert_eq, assert_str_eq}; use pretty_assertions::{assert_eq, assert_str_eq};
use reqwest::Client; use reqwest::Client;
use rstest::rstest; use rstest::rstest;
use serde_json::json; use serde_json::json;
use serde_json::{Number, Value}; use serde_json::{Number, Value};
use tokio::sync::Mutex;
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
use crate::app::App;
use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock; 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::{BlocklistResponse, Quality};
use crate::models::sonarr_models::{QualityWrapper, SystemStatus}; use crate::models::sonarr_models::{QualityWrapper, SystemStatus};
use crate::models::stateful_table::StatefulTable;
use crate::models::HorizontallyScrollableText; use crate::models::HorizontallyScrollableText;
use crate::models::{sonarr_models::SonarrSerdeable, stateful_table::SortOption}; use crate::models::{sonarr_models::SonarrSerdeable, stateful_table::SortOption};
@@ -80,6 +86,7 @@ mod test {
#[case(SonarrEvent::DeleteBlocklistItem(None), "/blocklist")] #[case(SonarrEvent::DeleteBlocklistItem(None), "/blocklist")]
#[case(SonarrEvent::HealthCheck, "/health")] #[case(SonarrEvent::HealthCheck, "/health")]
#[case(SonarrEvent::GetBlocklist, "/blocklist?page=1&pageSize=10000")] #[case(SonarrEvent::GetBlocklist, "/blocklist?page=1&pageSize=10000")]
#[case(SonarrEvent::GetEpisodes(None), "/episode")]
#[case(SonarrEvent::GetLogs(Some(500)), "/log")] #[case(SonarrEvent::GetLogs(Some(500)), "/log")]
#[case(SonarrEvent::GetStatus, "/system/status")] #[case(SonarrEvent::GetStatus, "/system/status")]
fn test_resource(#[case] event: SonarrEvent, #[case] expected_uri: String) { fn test_resource(#[case] event: SonarrEvent, #[case] expected_uri: String) {
@@ -278,6 +285,190 @@ mod test {
async_server.assert_async().await; 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] #[tokio::test]
async fn test_handle_get_sonarr_logs_event() { async fn test_handle_get_sonarr_logs_event() {
let expected_logs = vec![ 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 { fn blocklist_item() -> BlocklistItem {
BlocklistItem { BlocklistItem {
id: 1, 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 { fn language() -> Language {
Language { Language {
name: "English".to_owned(), name: "English".to_owned(),