feat(network): Added get quality profiles and get episode details events for Sonarr
This commit is contained in:
@@ -21,7 +21,6 @@ use crossterm::execute;
|
|||||||
use crossterm::terminal::{
|
use crossterm::terminal::{
|
||||||
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
|
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
|
||||||
};
|
};
|
||||||
use human_panic::metadata;
|
|
||||||
use log::{error, warn};
|
use log::{error, warn};
|
||||||
use network::NetworkTrait;
|
use network::NetworkTrait;
|
||||||
use ratatui::backend::CrosstermBackend;
|
use ratatui::backend::CrosstermBackend;
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
|
pub mod modals;
|
||||||
pub mod sonarr_data;
|
pub mod sonarr_data;
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
use crate::models::ScrollableText;
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct EpisodeDetailsModal {
|
||||||
|
pub episode_details: ScrollableText,
|
||||||
|
pub file_details: String,
|
||||||
|
pub audio_details: String,
|
||||||
|
pub video_details: String,
|
||||||
|
// pub episode_history: StatefulTable<MovieHistoryItem>,
|
||||||
|
// pub episode_cast: StatefulTable<Credit>,
|
||||||
|
// pub episode_crew: StatefulTable<Credit>,
|
||||||
|
// pub episode_releases: StatefulTable<Release>,
|
||||||
|
}
|
||||||
@@ -1,14 +1,17 @@
|
|||||||
|
use bimap::BiMap;
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use strum::EnumIter;
|
use strum::EnumIter;
|
||||||
|
|
||||||
use crate::models::{
|
use crate::models::{
|
||||||
sonarr_models::{BlocklistItem, Episode, Series},
|
sonarr_models::{BlocklistItem, DownloadRecord, Episode, Series},
|
||||||
stateful_list::StatefulList,
|
stateful_list::StatefulList,
|
||||||
stateful_table::StatefulTable,
|
stateful_table::StatefulTable,
|
||||||
stateful_tree::StatefulTree,
|
stateful_tree::StatefulTree,
|
||||||
HorizontallyScrollableText, Route,
|
HorizontallyScrollableText, Route,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use super::modals::EpisodeDetailsModal;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
#[path = "sonarr_data_tests.rs"]
|
#[path = "sonarr_data_tests.rs"]
|
||||||
mod sonarr_data_tests;
|
mod sonarr_data_tests;
|
||||||
@@ -19,7 +22,11 @@ pub struct SonarrData {
|
|||||||
pub series: StatefulTable<Series>,
|
pub series: StatefulTable<Series>,
|
||||||
pub blocklist: StatefulTable<BlocklistItem>,
|
pub blocklist: StatefulTable<BlocklistItem>,
|
||||||
pub logs: StatefulList<HorizontallyScrollableText>,
|
pub logs: StatefulList<HorizontallyScrollableText>,
|
||||||
pub episodes: StatefulTree<Episode>,
|
pub episodes_tree: StatefulTree<Episode>,
|
||||||
|
pub episodes_table: StatefulTable<Episode>,
|
||||||
|
pub downloads: StatefulTable<DownloadRecord>,
|
||||||
|
pub episode_details_modal: Option<EpisodeDetailsModal>,
|
||||||
|
pub quality_profile_map: BiMap<i64, String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for SonarrData {
|
impl Default for SonarrData {
|
||||||
@@ -30,7 +37,11 @@ impl Default for SonarrData {
|
|||||||
series: StatefulTable::default(),
|
series: StatefulTable::default(),
|
||||||
blocklist: StatefulTable::default(),
|
blocklist: StatefulTable::default(),
|
||||||
logs: StatefulList::default(),
|
logs: StatefulList::default(),
|
||||||
episodes: StatefulTree::default(),
|
episodes_tree: StatefulTree::default(),
|
||||||
|
episodes_table: StatefulTable::default(),
|
||||||
|
downloads: StatefulTable::default(),
|
||||||
|
episode_details_modal: None,
|
||||||
|
quality_profile_map: BiMap::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -39,6 +50,9 @@ impl Default for SonarrData {
|
|||||||
pub enum ActiveSonarrBlock {
|
pub enum ActiveSonarrBlock {
|
||||||
Blocklist,
|
Blocklist,
|
||||||
BlocklistSortPrompt,
|
BlocklistSortPrompt,
|
||||||
|
EpisodesExplorer,
|
||||||
|
EpisodesTable,
|
||||||
|
EpisodesTableSortPrompt,
|
||||||
#[default]
|
#[default]
|
||||||
Series,
|
Series,
|
||||||
SeriesSortPrompt,
|
SeriesSortPrompt,
|
||||||
|
|||||||
@@ -39,6 +39,11 @@ mod tests {
|
|||||||
assert!(sonarr_data.series.is_empty());
|
assert!(sonarr_data.series.is_empty());
|
||||||
assert!(sonarr_data.blocklist.is_empty());
|
assert!(sonarr_data.blocklist.is_empty());
|
||||||
assert!(sonarr_data.logs.is_empty());
|
assert!(sonarr_data.logs.is_empty());
|
||||||
|
assert!(sonarr_data.episodes_tree.is_empty());
|
||||||
|
assert!(sonarr_data.episodes_table.is_empty());
|
||||||
|
assert!(sonarr_data.downloads.is_empty());
|
||||||
|
assert!(sonarr_data.episode_details_modal.is_none());
|
||||||
|
assert!(sonarr_data.quality_profile_map.is_empty());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,25 @@ pub struct BlocklistResponse {
|
|||||||
pub records: Vec<BlocklistItem>,
|
pub records: Vec<BlocklistItem>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Derivative, Serialize, Deserialize, Debug, Default, Clone, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct DownloadRecord {
|
||||||
|
pub title: String,
|
||||||
|
pub status: String,
|
||||||
|
#[serde(deserialize_with = "super::from_i64")]
|
||||||
|
pub id: i64,
|
||||||
|
#[serde(deserialize_with = "super::from_i64")]
|
||||||
|
pub episode_id: i64,
|
||||||
|
#[serde(deserialize_with = "super::from_i64")]
|
||||||
|
pub size: i64,
|
||||||
|
#[serde(deserialize_with = "super::from_i64")]
|
||||||
|
pub sizeleft: i64,
|
||||||
|
pub output_path: Option<HorizontallyScrollableText>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub indexer: String,
|
||||||
|
pub download_client: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Default, Serialize, Deserialize, Hash, Debug, Clone, PartialEq, Eq)]
|
#[derive(Default, Serialize, Deserialize, Hash, Debug, Clone, PartialEq, Eq)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct Episode {
|
pub struct Episode {
|
||||||
@@ -57,6 +76,7 @@ pub struct Episode {
|
|||||||
pub overview: Option<String>,
|
pub overview: Option<String>,
|
||||||
pub has_file: bool,
|
pub has_file: bool,
|
||||||
pub monitored: bool,
|
pub monitored: bool,
|
||||||
|
pub episode_file: Option<EpisodeFile>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Display for Episode {
|
impl Display for Episode {
|
||||||
@@ -65,7 +85,19 @@ impl Display for Episode {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq, Ord, PartialOrd)]
|
#[derive(Default, Serialize, Deserialize, Hash, Debug, Clone, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct EpisodeFile {
|
||||||
|
pub relative_path: String,
|
||||||
|
pub path: String,
|
||||||
|
#[serde(deserialize_with = "super::from_i64")]
|
||||||
|
pub size: i64,
|
||||||
|
pub language: Language,
|
||||||
|
pub date_added: DateTime<Utc>,
|
||||||
|
pub media_info: Option<MediaInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Default, Debug, Hash, Clone, PartialEq, Eq, Ord, PartialOrd)]
|
||||||
pub struct Language {
|
pub struct Language {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
}
|
}
|
||||||
@@ -87,16 +119,48 @@ pub struct LogResponse {
|
|||||||
pub records: Vec<Log>,
|
pub records: Vec<Log>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq, Ord, PartialOrd)]
|
#[derive(Serialize, Deserialize, Derivative, Hash, Debug, Clone, PartialEq, Eq)]
|
||||||
|
#[derivative(Default)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct MediaInfo {
|
||||||
|
#[serde(deserialize_with = "super::from_i64")]
|
||||||
|
pub audio_bitrate: i64,
|
||||||
|
#[derivative(Default(value = "Number::from(0)"))]
|
||||||
|
pub audio_channels: Number,
|
||||||
|
pub audio_codec: Option<String>,
|
||||||
|
pub audio_languages: Option<String>,
|
||||||
|
#[serde(deserialize_with = "super::from_i64")]
|
||||||
|
pub audio_stream_count: i64,
|
||||||
|
#[serde(deserialize_with = "super::from_i64")]
|
||||||
|
pub video_bit_depth: i64,
|
||||||
|
#[serde(deserialize_with = "super::from_i64")]
|
||||||
|
pub video_bitrate: i64,
|
||||||
|
pub video_codec: String,
|
||||||
|
#[derivative(Default(value = "Number::from(0)"))]
|
||||||
|
pub video_fps: Number,
|
||||||
|
pub resolution: String,
|
||||||
|
pub run_time: String,
|
||||||
|
pub scan_type: String,
|
||||||
|
pub subtitles: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Default, Debug, Hash, Clone, PartialEq, Eq, Ord, PartialOrd)]
|
||||||
pub struct Quality {
|
pub struct Quality {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq, Ord, PartialOrd)]
|
#[derive(Serialize, Deserialize, Default, Debug, Hash, Clone, PartialEq, Eq, Ord, PartialOrd)]
|
||||||
pub struct QualityWrapper {
|
pub struct QualityWrapper {
|
||||||
pub quality: Quality,
|
pub quality: Quality,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Default, Clone, Serialize, Deserialize, Debug, PartialEq, Eq)]
|
||||||
|
pub struct QualityProfile {
|
||||||
|
#[serde(deserialize_with = "super::from_i64")]
|
||||||
|
pub id: i64,
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Derivative, Serialize, Deserialize, Debug, Clone, PartialEq)]
|
#[derive(Derivative, Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||||
#[derivative(Default)]
|
#[derivative(Default)]
|
||||||
pub struct Rating {
|
pub struct Rating {
|
||||||
@@ -257,7 +321,9 @@ impl SeriesStatus {
|
|||||||
#[allow(clippy::large_enum_variant)]
|
#[allow(clippy::large_enum_variant)]
|
||||||
pub enum SonarrSerdeable {
|
pub enum SonarrSerdeable {
|
||||||
Value(Value),
|
Value(Value),
|
||||||
|
Episode(Episode),
|
||||||
Episodes(Vec<Episode>),
|
Episodes(Vec<Episode>),
|
||||||
|
QualityProfiles(Vec<QualityProfile>),
|
||||||
SeriesVec(Vec<Series>),
|
SeriesVec(Vec<Series>),
|
||||||
SystemStatus(SystemStatus),
|
SystemStatus(SystemStatus),
|
||||||
BlocklistResponse(BlocklistResponse),
|
BlocklistResponse(BlocklistResponse),
|
||||||
@@ -279,7 +345,9 @@ impl From<()> for SonarrSerdeable {
|
|||||||
serde_enum_from!(
|
serde_enum_from!(
|
||||||
SonarrSerdeable {
|
SonarrSerdeable {
|
||||||
Value(Value),
|
Value(Value),
|
||||||
|
Episode(Episode),
|
||||||
Episodes(Vec<Episode>),
|
Episodes(Vec<Episode>),
|
||||||
|
QualityProfiles(Vec<QualityProfile>),
|
||||||
SeriesVec(Vec<Series>),
|
SeriesVec(Vec<Series>),
|
||||||
SystemStatus(SystemStatus),
|
SystemStatus(SystemStatus),
|
||||||
BlocklistResponse(BlocklistResponse),
|
BlocklistResponse(BlocklistResponse),
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ mod tests {
|
|||||||
|
|
||||||
use crate::models::{
|
use crate::models::{
|
||||||
sonarr_models::{
|
sonarr_models::{
|
||||||
BlocklistItem, BlocklistResponse, Episode, Log, LogResponse, Series, SeriesStatus,
|
BlocklistItem, BlocklistResponse, Episode, Log, LogResponse, QualityProfile, Series,
|
||||||
SeriesType, SonarrSerdeable, SystemStatus,
|
SeriesStatus, SeriesType, SonarrSerdeable, SystemStatus,
|
||||||
},
|
},
|
||||||
Serdeable,
|
Serdeable,
|
||||||
};
|
};
|
||||||
@@ -77,6 +77,18 @@ mod tests {
|
|||||||
assert_eq!(sonarr_serdeable, SonarrSerdeable::Value(value));
|
assert_eq!(sonarr_serdeable, SonarrSerdeable::Value(value));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sonarr_serdeable_from_episode() {
|
||||||
|
let episode = Episode {
|
||||||
|
id: 1,
|
||||||
|
..Episode::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let sonarr_serdeable: SonarrSerdeable = episode.clone().into();
|
||||||
|
|
||||||
|
assert_eq!(sonarr_serdeable, SonarrSerdeable::Episode(episode));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_sonarr_serdeable_from_episodes() {
|
fn test_sonarr_serdeable_from_episodes() {
|
||||||
let episodes = vec![Episode {
|
let episodes = vec![Episode {
|
||||||
@@ -146,4 +158,19 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(sonarr_serdeable, SonarrSerdeable::LogResponse(log_response));
|
assert_eq!(sonarr_serdeable, SonarrSerdeable::LogResponse(log_response));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sonarr_serdeable_from_quality_profiles() {
|
||||||
|
let quality_profiles = vec![QualityProfile {
|
||||||
|
name: "Test Profile".to_owned(),
|
||||||
|
id: 1,
|
||||||
|
}];
|
||||||
|
|
||||||
|
let sonarr_serdeable: SonarrSerdeable = quality_profiles.clone().into();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
sonarr_serdeable,
|
||||||
|
SonarrSerdeable::QualityProfiles(quality_profiles)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -211,9 +211,10 @@ impl<'a, 'b> Network<'a, 'b> {
|
|||||||
.map(RadarrSerdeable::from),
|
.map(RadarrSerdeable::from),
|
||||||
RadarrEvent::GetMovies => self.get_movies().await.map(RadarrSerdeable::from),
|
RadarrEvent::GetMovies => self.get_movies().await.map(RadarrSerdeable::from),
|
||||||
RadarrEvent::GetOverview => self.get_diskspace().await.map(RadarrSerdeable::from),
|
RadarrEvent::GetOverview => self.get_diskspace().await.map(RadarrSerdeable::from),
|
||||||
RadarrEvent::GetQualityProfiles => {
|
RadarrEvent::GetQualityProfiles => self
|
||||||
self.get_quality_profiles().await.map(RadarrSerdeable::from)
|
.get_radarr_quality_profiles()
|
||||||
}
|
.await
|
||||||
|
.map(RadarrSerdeable::from),
|
||||||
RadarrEvent::GetQueuedEvents => self.get_queued_events().await.map(RadarrSerdeable::from),
|
RadarrEvent::GetQueuedEvents => self.get_queued_events().await.map(RadarrSerdeable::from),
|
||||||
RadarrEvent::GetReleases(movie_id) => {
|
RadarrEvent::GetReleases(movie_id) => {
|
||||||
self.get_releases(movie_id).await.map(RadarrSerdeable::from)
|
self.get_releases(movie_id).await.map(RadarrSerdeable::from)
|
||||||
@@ -1702,7 +1703,7 @@ impl<'a, 'b> Network<'a, 'b> {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_quality_profiles(&mut self) -> Result<Vec<QualityProfile>> {
|
async fn get_radarr_quality_profiles(&mut self) -> Result<Vec<QualityProfile>> {
|
||||||
info!("Fetching Radarr quality profiles");
|
info!("Fetching Radarr quality profiles");
|
||||||
let event = RadarrEvent::GetQualityProfiles;
|
let event = RadarrEvent::GetQualityProfiles;
|
||||||
|
|
||||||
|
|||||||
@@ -2586,7 +2586,7 @@ mod test {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_handle_get_quality_profiles_event() {
|
async fn test_handle_get_radarr_quality_profiles_event() {
|
||||||
let quality_profile_json = json!([{
|
let quality_profile_json = json!([{
|
||||||
"id": 2222,
|
"id": 2222,
|
||||||
"name": "HD - 1080p"
|
"name": "HD - 1080p"
|
||||||
|
|||||||
@@ -1,19 +1,22 @@
|
|||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
use indoc::formatdoc;
|
||||||
use log::info;
|
use log::info;
|
||||||
use managarr_tree_widget::TreeItem;
|
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::{modals::EpisodeDetailsModal, sonarr_data::ActiveSonarrBlock},
|
||||||
sonarr_models::{
|
sonarr_models::{
|
||||||
BlocklistResponse, Episode, LogResponse, Series, SonarrSerdeable, SystemStatus,
|
BlocklistResponse, DownloadRecord, Episode, LogResponse, QualityProfile, Series,
|
||||||
|
SonarrSerdeable, SystemStatus,
|
||||||
},
|
},
|
||||||
HorizontallyScrollableText, Route, Scrollable,
|
HorizontallyScrollableText, Route, Scrollable, ScrollableText,
|
||||||
},
|
},
|
||||||
network::RequestMethod,
|
network::RequestMethod,
|
||||||
|
utils::convert_to_gb,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::{Network, NetworkEvent, NetworkResource};
|
use super::{Network, NetworkEvent, NetworkResource};
|
||||||
@@ -26,8 +29,10 @@ pub enum SonarrEvent {
|
|||||||
ClearBlocklist,
|
ClearBlocklist,
|
||||||
DeleteBlocklistItem(Option<i64>),
|
DeleteBlocklistItem(Option<i64>),
|
||||||
GetBlocklist,
|
GetBlocklist,
|
||||||
|
GetEpisodeDetails(Option<i64>),
|
||||||
GetEpisodes(Option<i64>),
|
GetEpisodes(Option<i64>),
|
||||||
GetLogs(Option<u64>),
|
GetLogs(Option<u64>),
|
||||||
|
GetQualityProfiles,
|
||||||
GetStatus,
|
GetStatus,
|
||||||
HealthCheck,
|
HealthCheck,
|
||||||
ListSeries,
|
ListSeries,
|
||||||
@@ -39,8 +44,9 @@ 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::GetEpisodes(_) | SonarrEvent::GetEpisodeDetails(_) => "/episode",
|
||||||
SonarrEvent::GetLogs(_) => "/log",
|
SonarrEvent::GetLogs(_) => "/log",
|
||||||
|
SonarrEvent::GetQualityProfiles => "/qualityprofile",
|
||||||
SonarrEvent::GetStatus => "/system/status",
|
SonarrEvent::GetStatus => "/system/status",
|
||||||
SonarrEvent::HealthCheck => "/health",
|
SonarrEvent::HealthCheck => "/health",
|
||||||
SonarrEvent::ListSeries => "/series",
|
SonarrEvent::ListSeries => "/series",
|
||||||
@@ -73,6 +79,14 @@ impl<'a, 'b> Network<'a, 'b> {
|
|||||||
.get_episodes(series_id)
|
.get_episodes(series_id)
|
||||||
.await
|
.await
|
||||||
.map(SonarrSerdeable::from),
|
.map(SonarrSerdeable::from),
|
||||||
|
SonarrEvent::GetEpisodeDetails(episode_id) => self
|
||||||
|
.get_episode_details(episode_id)
|
||||||
|
.await
|
||||||
|
.map(SonarrSerdeable::from),
|
||||||
|
SonarrEvent::GetQualityProfiles => self
|
||||||
|
.get_sonarr_quality_profiles()
|
||||||
|
.await
|
||||||
|
.map(SonarrSerdeable::from),
|
||||||
SonarrEvent::GetLogs(events) => self
|
SonarrEvent::GetLogs(events) => self
|
||||||
.get_sonarr_logs(events)
|
.get_sonarr_logs(events)
|
||||||
.await
|
.await
|
||||||
@@ -204,6 +218,22 @@ impl<'a, 'b> Network<'a, 'b> {
|
|||||||
self
|
self
|
||||||
.handle_request::<(), Vec<Episode>>(request_props, |mut episode_vec, mut app| {
|
.handle_request::<(), Vec<Episode>>(request_props, |mut episode_vec, mut app| {
|
||||||
episode_vec.sort_by(|a, b| a.id.cmp(&b.id));
|
episode_vec.sort_by(|a, b| a.id.cmp(&b.id));
|
||||||
|
if !matches!(
|
||||||
|
app.get_current_route(),
|
||||||
|
Route::Sonarr(ActiveSonarrBlock::EpisodesTableSortPrompt, _)
|
||||||
|
) {
|
||||||
|
app
|
||||||
|
.data
|
||||||
|
.sonarr_data
|
||||||
|
.episodes_table
|
||||||
|
.set_items(episode_vec.clone());
|
||||||
|
app
|
||||||
|
.data
|
||||||
|
.sonarr_data
|
||||||
|
.episodes_table
|
||||||
|
.apply_sorting_toggle(false);
|
||||||
|
}
|
||||||
|
|
||||||
let mut seasons = BTreeMap::new();
|
let mut seasons = BTreeMap::new();
|
||||||
|
|
||||||
for episode in episode_vec {
|
for episode in episode_vec {
|
||||||
@@ -226,7 +256,114 @@ impl<'a, 'b> Network<'a, 'b> {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
app.data.sonarr_data.episodes.set_items(tree);
|
app.data.sonarr_data.episodes_tree.set_items(tree);
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_episode_details(&mut self, episode_id: Option<i64>) -> Result<Episode> {
|
||||||
|
info!("Fetching Sonarr episode details");
|
||||||
|
let event = SonarrEvent::GetEpisodeDetails(None);
|
||||||
|
let id = self.extract_episode_id(episode_id).await;
|
||||||
|
|
||||||
|
info!("Fetching episode details for episode with ID: {id}");
|
||||||
|
|
||||||
|
let request_props = self
|
||||||
|
.request_props_from(
|
||||||
|
event,
|
||||||
|
RequestMethod::Get,
|
||||||
|
None::<()>,
|
||||||
|
Some(format!("/{id}")),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
self
|
||||||
|
.handle_request::<(), Episode>(request_props, |episode_response, mut app| {
|
||||||
|
let Episode {
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
air_date_utc,
|
||||||
|
overview,
|
||||||
|
has_file,
|
||||||
|
season_number,
|
||||||
|
episode_number,
|
||||||
|
episode_file,
|
||||||
|
..
|
||||||
|
} = episode_response;
|
||||||
|
let status = get_episode_status(has_file, &app.data.sonarr_data.downloads.items, id);
|
||||||
|
let air_date = if let Some(air_date) = air_date_utc {
|
||||||
|
format!("{air_date}")
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
let mut episode_details_modal = EpisodeDetailsModal {
|
||||||
|
episode_details: ScrollableText::with_string(formatdoc!(
|
||||||
|
"
|
||||||
|
Title: {}
|
||||||
|
Season: {season_number}
|
||||||
|
Episode Number: {episode_number}
|
||||||
|
Air Date: {air_date}
|
||||||
|
Status: {status}
|
||||||
|
Description: {}",
|
||||||
|
title.unwrap_or_default(),
|
||||||
|
overview.unwrap_or_default(),
|
||||||
|
)),
|
||||||
|
..EpisodeDetailsModal::default()
|
||||||
|
};
|
||||||
|
if let Some(file) = episode_file {
|
||||||
|
let size = convert_to_gb(file.size);
|
||||||
|
episode_details_modal.file_details = formatdoc!(
|
||||||
|
"
|
||||||
|
Relative Path: {}
|
||||||
|
Absolute Path: {}
|
||||||
|
Size: {size:.2} GB
|
||||||
|
Language: {}
|
||||||
|
Date Added: {}",
|
||||||
|
file.relative_path,
|
||||||
|
file.path,
|
||||||
|
file.language.name,
|
||||||
|
file.date_added,
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Some(media_info) = file.media_info {
|
||||||
|
episode_details_modal.audio_details = formatdoc!(
|
||||||
|
"
|
||||||
|
Bitrate: {}
|
||||||
|
Channels: {:.1}
|
||||||
|
Codec: {}
|
||||||
|
Languages: {}
|
||||||
|
Stream Count: {}",
|
||||||
|
media_info.audio_bitrate,
|
||||||
|
media_info.audio_channels.as_f64().unwrap(),
|
||||||
|
media_info.audio_codec.unwrap_or_default(),
|
||||||
|
media_info.audio_languages.unwrap_or_default(),
|
||||||
|
media_info.audio_stream_count
|
||||||
|
);
|
||||||
|
|
||||||
|
episode_details_modal.video_details = formatdoc!(
|
||||||
|
"
|
||||||
|
Bit Depth: {}
|
||||||
|
Bitrate: {}
|
||||||
|
Codec: {}
|
||||||
|
FPS: {}
|
||||||
|
Resolution: {}
|
||||||
|
Scan Type: {}
|
||||||
|
Runtime: {}
|
||||||
|
Subtitles: {}",
|
||||||
|
media_info.video_bit_depth,
|
||||||
|
media_info.video_bitrate,
|
||||||
|
media_info.video_codec,
|
||||||
|
media_info.video_fps.as_f64().unwrap(),
|
||||||
|
media_info.resolution,
|
||||||
|
media_info.scan_type,
|
||||||
|
media_info.run_time,
|
||||||
|
media_info.subtitles.unwrap_or_default()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
app.data.sonarr_data.episode_details_modal = Some(episode_details_modal);
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
@@ -278,6 +415,24 @@ impl<'a, 'b> Network<'a, 'b> {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_sonarr_quality_profiles(&mut self) -> Result<Vec<QualityProfile>> {
|
||||||
|
info!("Fetching Sonarr quality profiles");
|
||||||
|
let event = SonarrEvent::GetQualityProfiles;
|
||||||
|
|
||||||
|
let request_props = self
|
||||||
|
.request_props_from(event, RequestMethod::Get, None::<()>, None, None)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
self
|
||||||
|
.handle_request::<(), Vec<QualityProfile>>(request_props, |quality_profiles, mut app| {
|
||||||
|
app.data.sonarr_data.quality_profile_map = quality_profiles
|
||||||
|
.into_iter()
|
||||||
|
.map(|profile| (profile.id, profile.name))
|
||||||
|
.collect();
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
async fn list_series(&mut self) -> Result<Vec<Series>> {
|
async fn list_series(&mut self) -> Result<Vec<Series>> {
|
||||||
info!("Fetching Sonarr library");
|
info!("Fetching Sonarr library");
|
||||||
let event = SonarrEvent::ListSeries;
|
let event = SonarrEvent::ListSeries;
|
||||||
@@ -332,4 +487,49 @@ impl<'a, 'b> Network<'a, 'b> {
|
|||||||
};
|
};
|
||||||
(series_id, format!("seriesId={series_id}"))
|
(series_id, format!("seriesId={series_id}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn extract_episode_id(&mut self, episode_id: Option<i64>) -> i64 {
|
||||||
|
let app = self.app.lock().await;
|
||||||
|
|
||||||
|
let episode_id = if let Some(id) = episode_id {
|
||||||
|
id
|
||||||
|
} else if matches!(
|
||||||
|
app.get_current_route(),
|
||||||
|
Route::Sonarr(ActiveSonarrBlock::EpisodesTable, _)
|
||||||
|
) {
|
||||||
|
app.data.sonarr_data.episodes_table.current_selection().id
|
||||||
|
} else {
|
||||||
|
app
|
||||||
|
.data
|
||||||
|
.sonarr_data
|
||||||
|
.episodes_tree
|
||||||
|
.current_selection()
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.id
|
||||||
|
};
|
||||||
|
|
||||||
|
episode_id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_episode_status(has_file: bool, downloads_vec: &[DownloadRecord], episode_id: i64) -> String {
|
||||||
|
if !has_file {
|
||||||
|
if let Some(download) = downloads_vec
|
||||||
|
.iter()
|
||||||
|
.find(|&download| download.episode_id == episode_id)
|
||||||
|
{
|
||||||
|
if download.status == "downloading" {
|
||||||
|
return "Downloading".to_owned();
|
||||||
|
}
|
||||||
|
|
||||||
|
if download.status == "completed" {
|
||||||
|
return "Awaiting Import".to_owned();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Missing".to_owned();
|
||||||
|
}
|
||||||
|
|
||||||
|
"Downloaded".to_owned()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod test {
|
mod test {
|
||||||
|
use std::fmt::Display;
|
||||||
|
use std::hash::Hash;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use bimap::BiMap;
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use managarr_tree_widget::TreeItem;
|
use indoc::formatdoc;
|
||||||
|
use managarr_tree_widget::{Tree, TreeItem, TreeState};
|
||||||
use pretty_assertions::{assert_eq, assert_str_eq};
|
use pretty_assertions::{assert_eq, assert_str_eq};
|
||||||
|
use ratatui::buffer::Buffer;
|
||||||
|
use ratatui::layout::Rect;
|
||||||
|
use ratatui::text::ToText;
|
||||||
|
use ratatui::widgets::StatefulWidget;
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
use rstest::rstest;
|
use rstest::rstest;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
@@ -14,13 +22,17 @@ mod test {
|
|||||||
|
|
||||||
use crate::app::App;
|
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, Episode, Language, LogResponse};
|
use crate::models::sonarr_models::{
|
||||||
|
BlocklistItem, DownloadRecord, Episode, EpisodeFile, Language, LogResponse, MediaInfo,
|
||||||
|
QualityProfile,
|
||||||
|
};
|
||||||
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::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};
|
||||||
|
|
||||||
|
use crate::network::sonarr_network::get_episode_status;
|
||||||
use crate::{
|
use crate::{
|
||||||
models::sonarr_models::{
|
models::sonarr_models::{
|
||||||
Rating, Season, SeasonStatistics, Series, SeriesStatistics, SeriesStatus, SeriesType,
|
Rating, Season, SeasonStatistics, Series, SeriesStatistics, SeriesStatus, SeriesType,
|
||||||
@@ -75,6 +87,50 @@ mod test {
|
|||||||
"id": 1
|
"id": 1
|
||||||
}
|
}
|
||||||
"#;
|
"#;
|
||||||
|
const EPISODE_JSON: &str = r#"{
|
||||||
|
"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...",
|
||||||
|
"episodeFile": {
|
||||||
|
"relativePath": "/season 1/episode 1.mkv",
|
||||||
|
"path": "/nfs/tv/series/season 1/episode 1.mkv",
|
||||||
|
"size": 3543348019,
|
||||||
|
"dateAdded": "2024-02-10T07:28:45Z",
|
||||||
|
"language": { "name": "English" },
|
||||||
|
"quality": { "quality": { "name": "Bluray-1080p" } },
|
||||||
|
"mediaInfo": {
|
||||||
|
"audioBitrate": 0,
|
||||||
|
"audioChannels": 7.1,
|
||||||
|
"audioCodec": "AAC",
|
||||||
|
"audioLanguages": "eng",
|
||||||
|
"audioStreamCount": 1,
|
||||||
|
"videoBitDepth": 10,
|
||||||
|
"videoBitrate": 0,
|
||||||
|
"videoCodec": "x265",
|
||||||
|
"videoFps": 23.976,
|
||||||
|
"resolution": "1920x1080",
|
||||||
|
"runTime": "23:51",
|
||||||
|
"scanType": "Progressive",
|
||||||
|
"subtitles": "English"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"hasFile": true,
|
||||||
|
"monitored": true,
|
||||||
|
"id": 1
|
||||||
|
}"#;
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
fn test_resource_episode(
|
||||||
|
#[values(SonarrEvent::GetEpisodes(None), SonarrEvent::GetEpisodeDetails(None))]
|
||||||
|
event: SonarrEvent,
|
||||||
|
) {
|
||||||
|
assert_str_eq!(event.resource(), "/episode");
|
||||||
|
}
|
||||||
|
|
||||||
#[rstest]
|
#[rstest]
|
||||||
fn test_resource_series(#[values(SonarrEvent::ListSeries)] event: SonarrEvent) {
|
fn test_resource_series(#[values(SonarrEvent::ListSeries)] event: SonarrEvent) {
|
||||||
@@ -86,8 +142,8 @@ 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::GetQualityProfiles, "/qualityprofile")]
|
||||||
#[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) {
|
||||||
assert_str_eq!(event.resource(), expected_uri);
|
assert_str_eq!(event.resource(), expected_uri);
|
||||||
@@ -285,8 +341,124 @@ mod test {
|
|||||||
async_server.assert_async().await;
|
async_server.assert_async().await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_handle_get_episodes_event() {
|
async fn test_handle_get_episodes_event(#[values(true, false)] use_custom_sorting: bool) {
|
||||||
|
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 {
|
||||||
|
title: Some("z test".to_owned()),
|
||||||
|
episode_file: None,
|
||||||
|
..episode()
|
||||||
|
};
|
||||||
|
let episode_2 = Episode {
|
||||||
|
id: 2,
|
||||||
|
title: Some("A test".to_owned()),
|
||||||
|
episode_file_id: 2,
|
||||||
|
season_number: 2,
|
||||||
|
episode_number: 2,
|
||||||
|
episode_file: None,
|
||||||
|
..episode()
|
||||||
|
};
|
||||||
|
let expected_episodes = vec![episode_1.clone(), episode_2.clone()];
|
||||||
|
let mut expected_sorted_episodes = vec![episode_1.clone(), episode_2.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(json!([episode_1, episode_2])),
|
||||||
|
None,
|
||||||
|
SonarrEvent::GetEpisodes(None),
|
||||||
|
None,
|
||||||
|
Some("seriesId=1"),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
app_arc
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.data
|
||||||
|
.sonarr_data
|
||||||
|
.episodes_table
|
||||||
|
.sort_asc = true;
|
||||||
|
if use_custom_sorting {
|
||||||
|
let cmp_fn = |a: &Episode, b: &Episode| {
|
||||||
|
a.title
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.to_lowercase()
|
||||||
|
.cmp(&b.title.as_ref().unwrap().to_lowercase())
|
||||||
|
};
|
||||||
|
expected_sorted_episodes.sort_by(cmp_fn);
|
||||||
|
let title_sort_option = SortOption {
|
||||||
|
name: "Title",
|
||||||
|
cmp_fn: Some(cmp_fn),
|
||||||
|
};
|
||||||
|
app_arc
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.data
|
||||||
|
.sonarr_data
|
||||||
|
.episodes_table
|
||||||
|
.sorting(vec![title_sort_option]);
|
||||||
|
}
|
||||||
|
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_table.items,
|
||||||
|
expected_sorted_episodes
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
app_arc
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.data
|
||||||
|
.sonarr_data
|
||||||
|
.episodes_table
|
||||||
|
.sort_asc
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
app_arc.lock().await.data.sonarr_data.episodes_tree.items,
|
||||||
|
expected_tree
|
||||||
|
);
|
||||||
|
assert_eq!(episodes, expected_episodes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_handle_get_episodes_event_no_op_while_user_is_selecting_sort_options_on_table() {
|
||||||
let episodes_json = json!([
|
let episodes_json = json!([
|
||||||
{
|
{
|
||||||
"id": 2,
|
"id": 2,
|
||||||
@@ -323,15 +495,19 @@ mod test {
|
|||||||
title: Some("Season 2".to_owned()),
|
title: Some("Season 2".to_owned()),
|
||||||
..Episode::default()
|
..Episode::default()
|
||||||
};
|
};
|
||||||
let episode_1 = episode();
|
let episode_1 = Episode {
|
||||||
|
episode_file: None,
|
||||||
|
..episode()
|
||||||
|
};
|
||||||
let episode_2 = Episode {
|
let episode_2 = Episode {
|
||||||
id: 2,
|
id: 2,
|
||||||
episode_file_id: 2,
|
episode_file_id: 2,
|
||||||
season_number: 2,
|
season_number: 2,
|
||||||
episode_number: 2,
|
episode_number: 2,
|
||||||
|
episode_file: None,
|
||||||
..episode()
|
..episode()
|
||||||
};
|
};
|
||||||
let expected_episodes = vec![episode_2.clone(), episode_1.clone()];
|
let mut expected_episodes = vec![episode_2.clone(), episode_1.clone()];
|
||||||
let expected_tree = vec![
|
let expected_tree = vec![
|
||||||
TreeItem::new(
|
TreeItem::new(
|
||||||
marker_episode_1,
|
marker_episode_1,
|
||||||
@@ -354,6 +530,36 @@ mod test {
|
|||||||
Some("seriesId=1"),
|
Some("seriesId=1"),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
app_arc
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.push_navigation_stack(ActiveSonarrBlock::EpisodesTableSortPrompt.into());
|
||||||
|
app_arc
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.data
|
||||||
|
.sonarr_data
|
||||||
|
.episodes_table
|
||||||
|
.sort_asc = true;
|
||||||
|
let cmp_fn = |a: &Episode, b: &Episode| {
|
||||||
|
a.title
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.to_lowercase()
|
||||||
|
.cmp(&b.title.as_ref().unwrap().to_lowercase())
|
||||||
|
};
|
||||||
|
expected_episodes.sort_by(cmp_fn);
|
||||||
|
let title_sort_option = SortOption {
|
||||||
|
name: "Title",
|
||||||
|
cmp_fn: Some(cmp_fn),
|
||||||
|
};
|
||||||
|
app_arc
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.data
|
||||||
|
.sonarr_data
|
||||||
|
.episodes_table
|
||||||
|
.sorting(vec![title_sort_option]);
|
||||||
app_arc
|
app_arc
|
||||||
.lock()
|
.lock()
|
||||||
.await
|
.await
|
||||||
@@ -372,8 +578,24 @@ mod test {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
{
|
{
|
||||||
async_server.assert_async().await;
|
async_server.assert_async().await;
|
||||||
|
assert!(app_arc
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.data
|
||||||
|
.sonarr_data
|
||||||
|
.episodes_table
|
||||||
|
.is_empty());
|
||||||
|
assert!(
|
||||||
|
app_arc
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.data
|
||||||
|
.sonarr_data
|
||||||
|
.episodes_table
|
||||||
|
.sort_asc
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
app_arc.lock().await.data.sonarr_data.episodes.items,
|
app_arc.lock().await.data.sonarr_data.episodes_tree.items,
|
||||||
expected_tree
|
expected_tree
|
||||||
);
|
);
|
||||||
assert_eq!(episodes, expected_episodes);
|
assert_eq!(episodes, expected_episodes);
|
||||||
@@ -420,6 +642,7 @@ mod test {
|
|||||||
};
|
};
|
||||||
let episode_1 = Episode {
|
let episode_1 = Episode {
|
||||||
series_id: 2,
|
series_id: 2,
|
||||||
|
episode_file: None,
|
||||||
..episode()
|
..episode()
|
||||||
};
|
};
|
||||||
let episode_2 = Episode {
|
let episode_2 = Episode {
|
||||||
@@ -428,6 +651,7 @@ mod test {
|
|||||||
season_number: 2,
|
season_number: 2,
|
||||||
episode_number: 2,
|
episode_number: 2,
|
||||||
series_id: 2,
|
series_id: 2,
|
||||||
|
episode_file: None,
|
||||||
..episode()
|
..episode()
|
||||||
};
|
};
|
||||||
let expected_episodes = vec![episode_2.clone(), episode_1.clone()];
|
let expected_episodes = vec![episode_2.clone(), episode_1.clone()];
|
||||||
@@ -462,13 +686,128 @@ mod test {
|
|||||||
{
|
{
|
||||||
async_server.assert_async().await;
|
async_server.assert_async().await;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
app_arc.lock().await.data.sonarr_data.episodes.items,
|
app_arc.lock().await.data.sonarr_data.episodes_tree.items,
|
||||||
expected_tree
|
expected_tree
|
||||||
);
|
);
|
||||||
assert_eq!(episodes, expected_episodes);
|
assert_eq!(episodes, expected_episodes);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_handle_get_episode_details_event() {
|
||||||
|
let response: Episode = serde_json::from_str(EPISODE_JSON).unwrap();
|
||||||
|
let (async_server, app_arc, _server) = mock_servarr_api(
|
||||||
|
RequestMethod::Get,
|
||||||
|
None,
|
||||||
|
Some(serde_json::from_str(EPISODE_JSON).unwrap()),
|
||||||
|
None,
|
||||||
|
SonarrEvent::GetEpisodeDetails(None),
|
||||||
|
Some("/1"),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
app_arc
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.data
|
||||||
|
.sonarr_data
|
||||||
|
.episodes_table
|
||||||
|
.set_items(vec![episode()]);
|
||||||
|
app_arc
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.push_navigation_stack(ActiveSonarrBlock::EpisodesTable.into());
|
||||||
|
let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new());
|
||||||
|
|
||||||
|
if let SonarrSerdeable::Episode(episode) = network
|
||||||
|
.handle_sonarr_event(SonarrEvent::GetEpisodeDetails(None))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
{
|
||||||
|
async_server.assert_async().await;
|
||||||
|
assert!(app_arc
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.data
|
||||||
|
.sonarr_data
|
||||||
|
.episode_details_modal
|
||||||
|
.is_some());
|
||||||
|
assert_eq!(episode, response);
|
||||||
|
|
||||||
|
let app = app_arc.lock().await;
|
||||||
|
let episode_details_modal = app.data.sonarr_data.episode_details_modal.as_ref().unwrap();
|
||||||
|
assert_str_eq!(
|
||||||
|
episode_details_modal.episode_details.get_text(),
|
||||||
|
formatdoc!(
|
||||||
|
"Title: Something cool
|
||||||
|
Season: 1
|
||||||
|
Episode Number: 1
|
||||||
|
Air Date: 2024-02-10 07:28:45 UTC
|
||||||
|
Status: Downloaded
|
||||||
|
Description: Okay so this one time at band camp..."
|
||||||
|
)
|
||||||
|
);
|
||||||
|
assert_str_eq!(
|
||||||
|
episode_details_modal.file_details,
|
||||||
|
formatdoc!(
|
||||||
|
"Relative Path: /season 1/episode 1.mkv
|
||||||
|
Absolute Path: /nfs/tv/series/season 1/episode 1.mkv
|
||||||
|
Size: 3.30 GB
|
||||||
|
Language: English
|
||||||
|
Date Added: 2024-02-10 07:28:45 UTC"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
assert_str_eq!(
|
||||||
|
episode_details_modal.audio_details,
|
||||||
|
formatdoc!(
|
||||||
|
"Bitrate: 0
|
||||||
|
Channels: 7.1
|
||||||
|
Codec: AAC
|
||||||
|
Languages: eng
|
||||||
|
Stream Count: 1"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
assert_str_eq!(
|
||||||
|
episode_details_modal.video_details,
|
||||||
|
formatdoc!(
|
||||||
|
"Bit Depth: 10
|
||||||
|
Bitrate: 0
|
||||||
|
Codec: x265
|
||||||
|
FPS: 23.976
|
||||||
|
Resolution: 1920x1080
|
||||||
|
Scan Type: Progressive
|
||||||
|
Runtime: 23:51
|
||||||
|
Subtitles: English"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_handle_get_episode_details_event_uses_provided_id() {
|
||||||
|
let response: Episode = serde_json::from_str(EPISODE_JSON).unwrap();
|
||||||
|
let (async_server, app_arc, _server) = mock_servarr_api(
|
||||||
|
RequestMethod::Get,
|
||||||
|
None,
|
||||||
|
Some(serde_json::from_str(EPISODE_JSON).unwrap()),
|
||||||
|
None,
|
||||||
|
SonarrEvent::GetEpisodeDetails(None),
|
||||||
|
Some("/1"),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new());
|
||||||
|
|
||||||
|
if let SonarrSerdeable::Episode(episode) = network
|
||||||
|
.handle_sonarr_event(SonarrEvent::GetEpisodeDetails(Some(1)))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
{
|
||||||
|
async_server.assert_async().await;
|
||||||
|
assert_eq!(episode, response);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[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![
|
||||||
@@ -605,6 +944,40 @@ mod test {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_handle_get_sonarr_quality_profiles_event() {
|
||||||
|
let quality_profile_json = json!([{
|
||||||
|
"id": 2222,
|
||||||
|
"name": "HD - 1080p"
|
||||||
|
}]);
|
||||||
|
let response: Vec<QualityProfile> =
|
||||||
|
serde_json::from_value(quality_profile_json.clone()).unwrap();
|
||||||
|
let (async_server, app_arc, _server) = mock_servarr_api(
|
||||||
|
RequestMethod::Get,
|
||||||
|
None,
|
||||||
|
Some(quality_profile_json),
|
||||||
|
None,
|
||||||
|
SonarrEvent::GetQualityProfiles,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new());
|
||||||
|
|
||||||
|
if let SonarrSerdeable::QualityProfiles(quality_profiles) = network
|
||||||
|
.handle_sonarr_event(SonarrEvent::GetQualityProfiles)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
{
|
||||||
|
async_server.assert_async().await;
|
||||||
|
assert_eq!(
|
||||||
|
app_arc.lock().await.data.sonarr_data.quality_profile_map,
|
||||||
|
BiMap::from_iter([(2222i64, "HD - 1080p".to_owned())])
|
||||||
|
);
|
||||||
|
assert_eq!(quality_profiles, response);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[rstest]
|
#[rstest]
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_handle_get_series_event(#[values(true, false)] use_custom_sorting: bool) {
|
async fn test_handle_get_series_event(#[values(true, false)] use_custom_sorting: bool) {
|
||||||
@@ -841,6 +1214,180 @@ mod test {
|
|||||||
assert_str_eq!(series_id_param, "seriesId=1");
|
assert_str_eq!(series_id_param, "seriesId=1");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_extract_episode_id() {
|
||||||
|
let app_arc = Arc::new(Mutex::new(App::default()));
|
||||||
|
app_arc
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.data
|
||||||
|
.sonarr_data
|
||||||
|
.episodes_table
|
||||||
|
.set_items(vec![Episode {
|
||||||
|
id: 1,
|
||||||
|
..Episode::default()
|
||||||
|
}]);
|
||||||
|
app_arc
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.push_navigation_stack(ActiveSonarrBlock::EpisodesTable.into());
|
||||||
|
let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new());
|
||||||
|
|
||||||
|
let id = network.extract_episode_id(None).await;
|
||||||
|
|
||||||
|
assert_eq!(id, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_extract_episode_id_uses_provided_id() {
|
||||||
|
let app_arc = Arc::new(Mutex::new(App::default()));
|
||||||
|
app_arc
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.data
|
||||||
|
.sonarr_data
|
||||||
|
.episodes_table
|
||||||
|
.set_items(vec![Episode {
|
||||||
|
id: 1,
|
||||||
|
..Episode::default()
|
||||||
|
}]);
|
||||||
|
app_arc
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.push_navigation_stack(ActiveSonarrBlock::EpisodesTable.into());
|
||||||
|
let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new());
|
||||||
|
|
||||||
|
let id = network.extract_episode_id(Some(2)).await;
|
||||||
|
|
||||||
|
assert_eq!(id, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_extract_episode_id_filtered_series() {
|
||||||
|
let app_arc = Arc::new(Mutex::new(App::default()));
|
||||||
|
let mut filtered_episodes = StatefulTable::default();
|
||||||
|
filtered_episodes.set_filtered_items(vec![Episode {
|
||||||
|
id: 1,
|
||||||
|
..Episode::default()
|
||||||
|
}]);
|
||||||
|
app_arc.lock().await.data.sonarr_data.episodes_table = filtered_episodes;
|
||||||
|
app_arc
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.push_navigation_stack(ActiveSonarrBlock::EpisodesTable.into());
|
||||||
|
let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new());
|
||||||
|
|
||||||
|
let id = network.extract_episode_id(None).await;
|
||||||
|
|
||||||
|
assert_eq!(id, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_extract_episode_id_from_tree() {
|
||||||
|
let app_arc = Arc::new(Mutex::new(App::default()));
|
||||||
|
{
|
||||||
|
let mut app = app_arc.lock().await;
|
||||||
|
let items = vec![TreeItem::new_leaf(Episode {
|
||||||
|
id: 1,
|
||||||
|
..Episode::default()
|
||||||
|
})];
|
||||||
|
app.data.sonarr_data.episodes_tree.set_items(items.clone());
|
||||||
|
render(
|
||||||
|
&mut app.data.sonarr_data.episodes_tree.state,
|
||||||
|
&items.clone(),
|
||||||
|
);
|
||||||
|
app.data.sonarr_data.episodes_tree.state.key_down();
|
||||||
|
render(
|
||||||
|
&mut app.data.sonarr_data.episodes_tree.state,
|
||||||
|
&items.clone(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new());
|
||||||
|
|
||||||
|
let id = network.extract_episode_id(None).await;
|
||||||
|
|
||||||
|
assert_eq!(id, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_extract_episode_id_uses_provided_id_over_tree() {
|
||||||
|
let app_arc = Arc::new(Mutex::new(App::default()));
|
||||||
|
{
|
||||||
|
let mut app = app_arc.lock().await;
|
||||||
|
let items = vec![TreeItem::new_leaf(Episode {
|
||||||
|
id: 1,
|
||||||
|
..Episode::default()
|
||||||
|
})];
|
||||||
|
app.data.sonarr_data.episodes_tree.set_items(items.clone());
|
||||||
|
render(
|
||||||
|
&mut app.data.sonarr_data.episodes_tree.state,
|
||||||
|
&items.clone(),
|
||||||
|
);
|
||||||
|
app.data.sonarr_data.episodes_tree.state.key_down();
|
||||||
|
render(
|
||||||
|
&mut app.data.sonarr_data.episodes_tree.state,
|
||||||
|
&items.clone(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new());
|
||||||
|
|
||||||
|
let id = network.extract_episode_id(Some(2)).await;
|
||||||
|
|
||||||
|
assert_eq!(id, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_get_episode_status_downloaded() {
|
||||||
|
assert_str_eq!(get_episode_status(true, &[], 0), "Downloaded");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_get_episode_status_missing() {
|
||||||
|
let download_record = DownloadRecord {
|
||||||
|
episode_id: 1,
|
||||||
|
..DownloadRecord::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_str_eq!(
|
||||||
|
get_episode_status(false, &[download_record.clone()], 0),
|
||||||
|
"Missing"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_str_eq!(get_episode_status(false, &[download_record], 1), "Missing");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_get_episode_status_downloading() {
|
||||||
|
assert_str_eq!(
|
||||||
|
get_episode_status(
|
||||||
|
false,
|
||||||
|
&[DownloadRecord {
|
||||||
|
episode_id: 1,
|
||||||
|
status: "downloading".to_owned(),
|
||||||
|
..DownloadRecord::default()
|
||||||
|
}],
|
||||||
|
1
|
||||||
|
),
|
||||||
|
"Downloading"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_get_episode_status_awaiting_import() {
|
||||||
|
assert_str_eq!(
|
||||||
|
get_episode_status(
|
||||||
|
false,
|
||||||
|
&[DownloadRecord {
|
||||||
|
episode_id: 1,
|
||||||
|
status: "completed".to_owned(),
|
||||||
|
..DownloadRecord::default()
|
||||||
|
}],
|
||||||
|
1
|
||||||
|
),
|
||||||
|
"Awaiting Import"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
fn blocklist_item() -> BlocklistItem {
|
fn blocklist_item() -> BlocklistItem {
|
||||||
BlocklistItem {
|
BlocklistItem {
|
||||||
id: 1,
|
id: 1,
|
||||||
@@ -871,6 +1418,18 @@ mod test {
|
|||||||
overview: Some("Okay so this one time at band camp...".to_owned()),
|
overview: Some("Okay so this one time at band camp...".to_owned()),
|
||||||
has_file: true,
|
has_file: true,
|
||||||
monitored: true,
|
monitored: true,
|
||||||
|
episode_file: Some(episode_file()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn episode_file() -> EpisodeFile {
|
||||||
|
EpisodeFile {
|
||||||
|
relative_path: "/season 1/episode 1.mkv".to_owned(),
|
||||||
|
path: "/nfs/tv/series/season 1/episode 1.mkv".to_owned(),
|
||||||
|
size: 3543348019,
|
||||||
|
language: language(),
|
||||||
|
date_added: DateTime::from(DateTime::parse_from_rfc3339("2024-02-10T07:28:45Z").unwrap()),
|
||||||
|
media_info: Some(media_info()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -880,6 +1439,23 @@ mod test {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn media_info() -> MediaInfo {
|
||||||
|
MediaInfo {
|
||||||
|
audio_bitrate: 0,
|
||||||
|
audio_channels: Number::from_f64(7.1).unwrap(),
|
||||||
|
audio_codec: Some("AAC".to_owned()),
|
||||||
|
audio_languages: Some("eng".to_owned()),
|
||||||
|
audio_stream_count: 1,
|
||||||
|
video_bit_depth: 10,
|
||||||
|
video_bitrate: 0,
|
||||||
|
video_codec: "x265".to_owned(),
|
||||||
|
video_fps: Number::from_f64(23.976).unwrap(),
|
||||||
|
resolution: "1920x1080".to_owned(),
|
||||||
|
run_time: "23:51".to_owned(),
|
||||||
|
scan_type: "Progressive".to_owned(),
|
||||||
|
subtitles: Some("English".to_owned()),
|
||||||
|
}
|
||||||
|
}
|
||||||
fn quality() -> Quality {
|
fn quality() -> Quality {
|
||||||
Quality {
|
Quality {
|
||||||
name: "Bluray-1080p".to_owned(),
|
name: "Bluray-1080p".to_owned(),
|
||||||
@@ -955,4 +1531,14 @@ mod test {
|
|||||||
percent_of_episodes: 100.0,
|
percent_of_episodes: 100.0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn render<T>(state: &mut TreeState, items: &[TreeItem<T>])
|
||||||
|
where
|
||||||
|
T: ToText + Clone + Default + Display + Hash + PartialEq + Eq,
|
||||||
|
{
|
||||||
|
let tree = Tree::new(items).unwrap();
|
||||||
|
let area = Rect::new(0, 0, 10, 4);
|
||||||
|
let mut buffer = Buffer::empty(area);
|
||||||
|
StatefulWidget::render(tree, area, &mut buffer, state);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user