use crate::streaming::VideoFile;
use crate::tmdb::{Episode, SearchResult as TmdbResult, SeasonSummary, TvDetails};
use crate::torznab::TorrentResult;
use crate::doctor::{CheckResult, CheckStatus};
#[derive(Debug, Clone, PartialEq)]
pub enum View {
Wizard,
Discovery,
Search,
Results,
TvSeasons,
TvEpisodes,
FileSelection,
Streaming,
Doctor,
Settings,
TraktAuth,
}
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub enum WizardStep {
#[default]
Welcome,
Prowlarr,
Tmdb,
Player,
Done,
}
impl WizardStep {
pub fn next(self) -> Self {
match self {
WizardStep::Welcome => WizardStep::Prowlarr,
WizardStep::Prowlarr => WizardStep::Tmdb,
WizardStep::Tmdb => WizardStep::Player,
WizardStep::Player => WizardStep::Done,
WizardStep::Done => WizardStep::Done,
}
}
pub fn prev(self) -> Self {
match self {
WizardStep::Welcome => WizardStep::Welcome,
WizardStep::Prowlarr => WizardStep::Welcome,
WizardStep::Tmdb => WizardStep::Prowlarr,
WizardStep::Player => WizardStep::Tmdb,
WizardStep::Done => WizardStep::Player,
}
}
pub fn index(self) -> usize {
match self {
WizardStep::Welcome => 0,
WizardStep::Prowlarr => 1,
WizardStep::Tmdb => 2,
WizardStep::Player => 3,
WizardStep::Done => 4,
}
}
pub fn total() -> usize {
5
}
}
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub enum SettingsSection {
#[default]
Prowlarr,
Tmdb,
Player,
Streaming,
Subtitles,
Discord,
Trakt,
}
impl SettingsSection {
pub fn next(self) -> Self {
match self {
SettingsSection::Prowlarr => SettingsSection::Tmdb,
SettingsSection::Tmdb => SettingsSection::Player,
SettingsSection::Player => SettingsSection::Streaming,
SettingsSection::Streaming => SettingsSection::Subtitles,
SettingsSection::Subtitles => SettingsSection::Discord,
SettingsSection::Discord => SettingsSection::Trakt,
SettingsSection::Trakt => SettingsSection::Prowlarr,
}
}
pub fn prev(self) -> Self {
match self {
SettingsSection::Prowlarr => SettingsSection::Trakt,
SettingsSection::Tmdb => SettingsSection::Prowlarr,
SettingsSection::Player => SettingsSection::Tmdb,
SettingsSection::Streaming => SettingsSection::Player,
SettingsSection::Subtitles => SettingsSection::Streaming,
SettingsSection::Discord => SettingsSection::Subtitles,
SettingsSection::Trakt => SettingsSection::Discord,
}
}
pub fn label(&self) -> &'static str {
match self {
SettingsSection::Prowlarr => "Prowlarr",
SettingsSection::Tmdb => "TMDB",
SettingsSection::Player => "Player",
SettingsSection::Streaming => "Streaming",
SettingsSection::Subtitles => "Subtitles",
SettingsSection::Discord => "Discord",
SettingsSection::Trakt => "Trakt",
}
}
pub fn field_count(&self) -> usize {
match self {
SettingsSection::Prowlarr => 3, SettingsSection::Tmdb => 1, SettingsSection::Player => 2, SettingsSection::Streaming => 1, SettingsSection::Subtitles => 3, SettingsSection::Discord => 2, SettingsSection::Trakt => 4, }
}
pub const ALL: &'static [SettingsSection] = &[
SettingsSection::Prowlarr,
SettingsSection::Tmdb,
SettingsSection::Player,
SettingsSection::Streaming,
SettingsSection::Subtitles,
SettingsSection::Discord,
SettingsSection::Trakt,
];
}
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub enum SortOrder {
#[default]
SeedersDesc,
SeedersAsc,
SizeDesc,
SizeAsc,
NameAsc,
NameDesc,
}
impl SortOrder {
pub fn next(self) -> Self {
match self {
SortOrder::SeedersDesc => SortOrder::SeedersAsc,
SortOrder::SeedersAsc => SortOrder::SizeDesc,
SortOrder::SizeDesc => SortOrder::SizeAsc,
SortOrder::SizeAsc => SortOrder::NameAsc,
SortOrder::NameAsc => SortOrder::NameDesc,
SortOrder::NameDesc => SortOrder::SeedersDesc,
}
}
pub fn label(&self) -> &'static str {
match self {
SortOrder::SeedersDesc => "Seeders ↓",
SortOrder::SeedersAsc => "Seeders ↑",
SortOrder::SizeDesc => "Size ↓",
SortOrder::SizeAsc => "Size ↑",
SortOrder::NameAsc => "Name A-Z",
SortOrder::NameDesc => "Name Z-A",
}
}
}
#[derive(Debug, Clone, Default)]
pub struct TmdbMetadata {
pub id: Option<u64>,
pub title: String,
pub year: Option<u16>,
pub overview: Option<String>,
pub rating: Option<f64>,
pub media_type: Option<String>,
pub poster_url: Option<String>,
}
#[derive(Debug, Clone)]
pub struct TmdbSuggestion {
pub id: u64,
pub title: String,
pub year: Option<u16>,
pub media_type: String,
}
#[derive(Debug, Clone, PartialEq)]
pub enum StreamingState {
Connecting,
FetchingMetadata,
Ready { stream_url: String },
Playing,
Error(String),
}
#[derive(Debug, Clone, Default)]
pub struct DownloadProgress {
pub downloaded_bytes: u64,
pub total_bytes: u64,
pub download_speed: u64, pub upload_speed: u64, pub peers_connected: u32,
pub progress_percent: f64,
}
pub struct App {
pub view: View,
pub should_quit: bool,
pub search_input: String,
pub is_searching: bool,
pub search_error: Option<String>,
pub search_id: u64,
pub suggestions: Vec<TmdbSuggestion>,
pub selected_suggestion: usize,
pub is_fetching_suggestions: bool,
pub results: Vec<TorrentResult>,
pub selected_index: usize,
pub sort_order: SortOrder,
pub tmdb_info: Option<TmdbMetadata>,
pub available_files: Vec<VideoFile>,
pub selected_file_index: usize,
pub pending_torrent_id: Option<usize>,
pub current_episode_index: usize, pub next_episode_ready: bool, pub auto_play_next: bool,
pub streaming_state: StreamingState,
pub current_title: String,
pub current_file: String,
pub current_tmdb_id: Option<u64>,
pub current_year: Option<u16>,
pub current_media_type: Option<String>,
pub current_poster_url: Option<String>,
pub download_progress: DownloadProgress,
pub is_streaming: bool,
pub doctor_results: Vec<CheckResult>,
pub is_checking: bool,
pub tv_details: Option<TvDetails>,
pub tv_seasons: Vec<SeasonSummary>,
pub selected_season_index: usize,
pub tv_episodes: Vec<Episode>,
pub selected_episode_index: usize,
pub is_fetching_tv_details: bool,
pub settings_section: SettingsSection,
pub settings_field_index: usize,
pub settings_editing: bool,
pub settings_edit_buffer: String,
pub settings_dirty: bool,
pub wizard_step: WizardStep,
pub wizard_field_index: usize, pub wizard_editing: bool,
pub wizard_edit_buffer: String,
pub show_resume_prompt: bool,
pub resume_progress: f64,
pub show_subtitle_warning: bool,
pub show_rating_prompt: bool,
pub selected_rating: Option<u32>,
pub playback_progress: f64,
pub racing_message: Option<String>,
pub discovery_rows: Vec<DiscoveryRow>,
pub selected_row_index: usize,
pub selected_item_index: usize,
pub is_loading_discovery: bool,
pub discovery_error: Option<String>,
pub trakt_auth_url: Option<String>,
pub trakt_auth_code_input: String,
pub trakt_auth_error: Option<String>,
}
#[derive(Debug, Clone)]
pub struct DiscoveryRow {
pub title: String,
pub items: Vec<DiscoveryItem>,
}
#[derive(Debug, Clone)]
pub struct DiscoveryItem {
pub id: u64,
pub title: String,
pub year: Option<u16>,
pub media_type: String, pub poster_url: Option<String>,
pub overview: Option<String>,
pub rating: Option<f64>,
pub is_released: bool,
pub release_date: Option<String>,
}
impl From<TmdbResult> for DiscoveryItem {
fn from(result: TmdbResult) -> Self {
DiscoveryItem {
id: result.id,
title: result.display_title().to_string(),
year: result.year(),
media_type: result.media_type.clone().unwrap_or_else(|| {
if result.name.is_some() {
"tv".to_string()
} else {
"movie".to_string()
}
}),
poster_url: result.poster_url("w300"),
overview: result.overview,
rating: result.vote_average,
is_released: true, release_date: result
.release_date
.clone()
.or(result.first_air_date.clone()),
}
}
}
impl From<crate::providers::DiscoveryItem> for DiscoveryItem {
fn from(item: crate::providers::DiscoveryItem) -> Self {
DiscoveryItem {
id: item.id,
title: item.title,
year: item.year,
media_type: item.media_type,
poster_url: None, overview: item.overview,
rating: item.rating,
is_released: item.is_released,
release_date: item.released,
}
}
}
impl App {
pub fn new() -> Self {
Self {
view: View::Discovery,
should_quit: false,
search_input: String::new(),
is_searching: false,
search_error: None,
search_id: 0,
suggestions: Vec::new(),
selected_suggestion: 0,
is_fetching_suggestions: false,
results: Vec::new(),
selected_index: 0,
sort_order: SortOrder::default(),
tmdb_info: None,
available_files: Vec::new(),
selected_file_index: 0,
pending_torrent_id: None,
current_episode_index: 0,
next_episode_ready: false,
auto_play_next: true, streaming_state: StreamingState::Connecting,
current_title: String::new(),
current_file: String::new(),
current_tmdb_id: None,
current_year: None,
current_media_type: None,
current_poster_url: None,
download_progress: DownloadProgress::default(),
is_streaming: false,
doctor_results: Vec::new(),
is_checking: false,
tv_details: None,
tv_seasons: Vec::new(),
selected_season_index: 0,
tv_episodes: Vec::new(),
selected_episode_index: 0,
is_fetching_tv_details: false,
settings_section: SettingsSection::default(),
settings_field_index: 0,
settings_editing: false,
settings_edit_buffer: String::new(),
settings_dirty: false,
wizard_step: WizardStep::default(),
wizard_field_index: 0,
wizard_editing: false,
wizard_edit_buffer: String::new(),
show_resume_prompt: false,
resume_progress: 0.0,
show_subtitle_warning: false,
show_rating_prompt: false,
selected_rating: None,
playback_progress: 0.0,
racing_message: None,
discovery_rows: Vec::new(),
selected_row_index: 0,
selected_item_index: 0,
is_loading_discovery: false,
discovery_error: None,
trakt_auth_url: None,
trakt_auth_code_input: String::new(),
trakt_auth_error: None,
}
}
pub fn wizard_field_count(&self) -> usize {
match self.wizard_step {
WizardStep::Welcome => 0,
WizardStep::Prowlarr => 2, WizardStep::Tmdb => 1, WizardStep::Player => 1, WizardStep::Done => 0,
}
}
pub fn wizard_next_field(&mut self) {
let max = self.wizard_field_count();
if max > 0 {
self.wizard_field_index = (self.wizard_field_index + 1) % max;
}
}
pub fn wizard_prev_field(&mut self) {
let max = self.wizard_field_count();
if max > 0 {
self.wizard_field_index = if self.wizard_field_index == 0 {
max - 1
} else {
self.wizard_field_index - 1
};
}
}
pub fn settings_next_field(&mut self) {
let max = self.settings_section.field_count();
self.settings_field_index = (self.settings_field_index + 1) % max;
}
pub fn settings_prev_field(&mut self) {
let max = self.settings_section.field_count();
self.settings_field_index = if self.settings_field_index == 0 {
max - 1
} else {
self.settings_field_index - 1
};
}
pub fn select_next(&mut self) {
if !self.results.is_empty() {
self.selected_index = (self.selected_index + 1).min(self.results.len() - 1);
}
}
pub fn select_previous(&mut self) {
if self.selected_index > 0 {
self.selected_index -= 1;
}
}
pub fn selected_result(&self) -> Option<&TorrentResult> {
self.results.get(self.selected_index)
}
pub fn cycle_sort(&mut self) {
self.sort_order = self.sort_order.next();
self.sort_results();
}
pub fn sort_results(&mut self) {
match self.sort_order {
SortOrder::SeedersDesc => {
self.results.sort_by(|a, b| b.seeders.cmp(&a.seeders));
}
SortOrder::SeedersAsc => {
self.results.sort_by(|a, b| a.seeders.cmp(&b.seeders));
}
SortOrder::SizeDesc => {
self.results.sort_by(|a, b| b.size.cmp(&a.size));
}
SortOrder::SizeAsc => {
self.results.sort_by(|a, b| a.size.cmp(&b.size));
}
SortOrder::NameAsc => {
self.results
.sort_by(|a, b| a.title.to_lowercase().cmp(&b.title.to_lowercase()));
}
SortOrder::NameDesc => {
self.results
.sort_by(|a, b| b.title.to_lowercase().cmp(&a.title.to_lowercase()));
}
}
if self.selected_index >= self.results.len() {
self.selected_index = self.results.len().saturating_sub(1);
}
}
pub fn select_next_file(&mut self) {
if !self.available_files.is_empty() {
self.selected_file_index =
(self.selected_file_index + 1).min(self.available_files.len() - 1);
}
}
pub fn select_previous_file(&mut self) {
if self.selected_file_index > 0 {
self.selected_file_index -= 1;
}
}
pub fn selected_video_file(&self) -> Option<&VideoFile> {
self.available_files.get(self.selected_file_index)
}
pub fn has_next_episode(&self) -> bool {
self.current_episode_index + 1 < self.available_files.len()
}
pub fn next_episode(&self) -> Option<&VideoFile> {
self.available_files.get(self.current_episode_index + 1)
}
pub fn advance_to_next_episode(&mut self) -> Option<&VideoFile> {
if self.has_next_episode() {
self.current_episode_index += 1;
self.selected_file_index = self.current_episode_index;
self.next_episode_ready = false;
self.available_files.get(self.current_episode_index)
} else {
None
}
}
pub fn select_next_season(&mut self) {
if !self.tv_seasons.is_empty() {
self.selected_season_index =
(self.selected_season_index + 1).min(self.tv_seasons.len() - 1);
}
}
pub fn select_previous_season(&mut self) {
if self.selected_season_index > 0 {
self.selected_season_index -= 1;
}
}
pub fn selected_season(&self) -> Option<&SeasonSummary> {
self.tv_seasons.get(self.selected_season_index)
}
pub fn select_next_episode(&mut self) {
if !self.tv_episodes.is_empty() {
self.selected_episode_index =
(self.selected_episode_index + 1).min(self.tv_episodes.len() - 1);
}
}
pub fn select_previous_episode(&mut self) {
if self.selected_episode_index > 0 {
self.selected_episode_index -= 1;
}
}
pub fn selected_tv_episode(&self) -> Option<&Episode> {
self.tv_episodes.get(self.selected_episode_index)
}
pub fn select_next_row(&mut self) {
if !self.discovery_rows.is_empty() {
self.selected_row_index =
(self.selected_row_index + 1).min(self.discovery_rows.len() - 1);
self.selected_item_index = 0;
}
}
pub fn select_previous_row(&mut self) {
if self.selected_row_index > 0 {
self.selected_row_index -= 1;
self.selected_item_index = 0;
}
}
pub fn select_next_item(&mut self) {
if let Some(row) = self.discovery_rows.get(self.selected_row_index)
&& !row.items.is_empty()
{
self.selected_item_index = (self.selected_item_index + 1).min(row.items.len() - 1);
}
}
pub fn select_previous_item(&mut self) {
if self.selected_item_index > 0 {
self.selected_item_index -= 1;
}
}
pub fn selected_discovery_item(&self) -> Option<&DiscoveryItem> {
self.discovery_rows
.get(self.selected_row_index)
.and_then(|row| row.items.get(self.selected_item_index))
}
pub fn has_active_prompt(&self) -> bool {
self.show_resume_prompt || self.show_subtitle_warning || self.show_rating_prompt
}
}