anitrack 0.1.7

CLI/TUI companion for ani-cli with watch-progress tracking
mod actions;
mod render;
mod session;

use std::collections::HashMap;
use std::io;
use std::sync::mpsc;
use std::time::Duration;

use anyhow::{Context, Result};
use crossterm::event::{self, Event, KeyCode, KeyEventKind};
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::widgets::TableState;

use crate::db::Database;

use super::episode::{has_next_episode, has_previous_episode, parse_title_and_total_eps, truncate};
use super::tracking::run_ani_cli_search;

use self::actions::{
    drain_episode_fetch_results, ensure_selected_episode_list, refresh_items, run_selected_action,
    status_error, status_info,
};
use self::render::draw_tui;
use self::session::TuiSession;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum TuiAction {
    Next,
    Replay,
    Previous,
    Select,
}

impl TuiAction {
    pub(crate) fn label(self) -> &'static str {
        match self {
            Self::Next => "NEXT",
            Self::Replay => "REPLAY",
            Self::Previous => "PREVIOUS",
            Self::Select => "SELECT",
        }
    }

    pub(crate) fn move_left(self) -> Self {
        match self {
            Self::Next => Self::Next,
            Self::Replay => Self::Next,
            Self::Previous => Self::Replay,
            Self::Select => Self::Previous,
        }
    }

    pub(crate) fn move_right(self) -> Self {
        match self {
            Self::Next => Self::Replay,
            Self::Replay => Self::Previous,
            Self::Previous => Self::Select,
            Self::Select => Self::Select,
        }
    }
}

#[derive(Debug, Clone)]
pub(super) struct PendingDelete {
    pub(super) ani_id: String,
    pub(super) title: String,
}

#[derive(Debug, Clone)]
pub(super) struct PendingNotice {
    pub(super) message: String,
}

#[derive(Debug, Clone)]
pub(super) struct EpisodeListFetchResult {
    pub(super) ani_id: String,
    pub(super) episode_list: Option<Vec<String>>,
    pub(super) warning: Option<String>,
}

#[derive(Debug, Clone)]
pub(super) enum EpisodeListState {
    Loading,
    Ready {
        episode_list: Option<Vec<String>>,
        warning: Option<String>,
    },
}

impl EpisodeListState {
    pub(super) fn episode_list(&self) -> Option<&[String]> {
        match self {
            Self::Ready {
                episode_list: Some(episodes),
                ..
            } => Some(episodes.as_slice()),
            Self::Loading
            | Self::Ready {
                episode_list: None, ..
            } => None,
        }
    }

    pub(super) fn is_loading(&self) -> bool {
        matches!(self, Self::Loading)
    }

    pub(super) fn warning(&self) -> Option<&str> {
        match self {
            Self::Ready {
                warning: Some(warning),
                ..
            } => Some(warning.as_str()),
            _ => None,
        }
    }
}

pub(crate) fn run_tui(db: &Database) -> Result<()> {
    let mut session = TuiSession::enter()?;
    let mut terminal = Terminal::new(CrosstermBackend::new(io::stdout()))
        .context("failed to initialize terminal backend")?;
    terminal.clear()?;

    let mut items = db.list_seen()?;
    let mut table_state = TableState::default();
    table_state.select((!items.is_empty()).then_some(0));
    let mut action = TuiAction::Next;
    let mut pending_delete = None::<PendingDelete>;
    let mut pending_notice = None::<PendingNotice>;
    let mut episode_lists_by_id: HashMap<String, EpisodeListState> = HashMap::new();
    let (episode_fetch_tx, episode_fetch_rx) = mpsc::channel::<EpisodeListFetchResult>();
    let mut status = if items.is_empty() {
        status_info("No tracked entries yet. Press `s` to search or run `anitrack start`.")
    } else {
        status_info("Ready.")
    };

    loop {
        drain_episode_fetch_results(&episode_fetch_rx, &mut episode_lists_by_id);
        ensure_selected_episode_list(
            &items,
            &table_state,
            &mut episode_lists_by_id,
            &episode_fetch_tx,
        );
        terminal.draw(|frame| {
            draw_tui(
                frame,
                &items,
                &mut table_state,
                action,
                &status,
                pending_delete.as_ref(),
                pending_notice.as_ref(),
                &episode_lists_by_id,
            )
        })?;

        if !event::poll(Duration::from_millis(200))? {
            continue;
        }

        let Event::Key(key) = event::read()? else {
            continue;
        };
        if key.kind != KeyEventKind::Press {
            continue;
        }

        if pending_notice.is_some() {
            pending_notice = None;
            continue;
        }

        if let Some(dialog) = pending_delete.as_ref() {
            match key.code {
                KeyCode::Char('y') | KeyCode::Enter => {
                    let deleting_id = dialog.ani_id.clone();
                    let deleting_title = dialog.title.clone();
                    pending_delete = None;
                    match db.delete_seen(&deleting_id) {
                        Ok(true) => {
                            status =
                                status_info(&format!("Deleted tracked entry: {deleting_title}"));
                            refresh_items(db, &mut items, &mut table_state, None)?;
                        }
                        Ok(false) => {
                            status = status_error("Delete failed: entry no longer exists.");
                            refresh_items(db, &mut items, &mut table_state, None)?;
                        }
                        Err(err) => status = status_error(&format!("Delete failed: {err}")),
                    }
                }
                KeyCode::Esc | KeyCode::Char('n') => {
                    pending_delete = None;
                    status = status_info("Delete canceled.");
                }
                _ => {}
            }
            continue;
        }

        match key.code {
            KeyCode::Char('q') => break,
            KeyCode::Char('s') => {
                session.suspend()?;
                let result = run_ani_cli_search(db);
                session.resume()?;
                terminal.clear()?;

                match result {
                    Ok((msg, changed_id)) => {
                        status = status_info(&msg);
                        refresh_items(db, &mut items, &mut table_state, changed_id.as_deref())?;
                    }
                    Err(err) => status = status_error(&format!("Search failed: {err}")),
                }
            }
            KeyCode::Up => {
                if let Some(selected) = table_state.selected() {
                    table_state.select(Some(selected.saturating_sub(1)));
                }
            }
            KeyCode::Down => {
                if let Some(selected) = table_state.selected()
                    && !items.is_empty()
                {
                    let next = (selected + 1).min(items.len().saturating_sub(1));
                    table_state.select(Some(next));
                }
            }
            KeyCode::Left => action = action.move_left(),
            KeyCode::Right => action = action.move_right(),
            KeyCode::Char('d') => {
                let Some(selected) = table_state.selected() else {
                    status = status_error("Delete failed: no entry selected.");
                    continue;
                };
                if selected >= items.len() {
                    status = status_error("Delete failed: invalid selection.");
                    continue;
                }
                let selected_item = &items[selected];
                pending_delete = Some(PendingDelete {
                    ani_id: selected_item.ani_id.clone(),
                    title: selected_item.title.clone(),
                });
                status = status_info("Confirm delete: y/Enter to delete, n/Esc to cancel.");
            }
            KeyCode::Enter => {
                let Some(selected) = table_state.selected() else {
                    continue;
                };
                if selected >= items.len() {
                    continue;
                }
                let selected_item = &items[selected];
                let episode_list = episode_lists_by_id
                    .get(&selected_item.ani_id)
                    .and_then(EpisodeListState::episode_list);

                if matches!(action, TuiAction::Next) {
                    let total_eps = parse_title_and_total_eps(&selected_item.title).1;
                    if !has_next_episode(&selected_item.last_episode, total_eps, episode_list) {
                        pending_notice = Some(PendingNotice {
                            message: format!(
                                "No more episodes available.\n\n{}\n\nPress any key to continue.",
                                truncate(&selected_item.title, 50)
                            ),
                        });
                        status = status_info("No next episode available.");
                        continue;
                    }
                }

                if matches!(action, TuiAction::Previous)
                    && !has_previous_episode(&selected_item.last_episode, episode_list)
                {
                    pending_notice = Some(PendingNotice {
                        message: format!(
                            "No previous episode available.\n\n{}\n\nPress any key to continue.",
                            truncate(&selected_item.title, 50)
                        ),
                    });
                    status = status_info("No previous episode available.");
                    continue;
                }

                let selected_id = items[selected].ani_id.clone();
                let selected_title = items[selected].title.clone();

                session.suspend()?;
                let result = run_selected_action(db, &items[selected], action, episode_list);
                session.resume()?;
                terminal.clear()?;

                match result {
                    Ok(msg) => status = status_info(&msg),
                    Err(err) => {
                        let no_previous = matches!(action, TuiAction::Previous)
                            && err.chain().any(|cause| {
                                cause.to_string().contains("no previous episode available")
                            });
                        if no_previous {
                            pending_notice = Some(PendingNotice {
                                message: format!(
                                    "No previous episode available.\n\n{}\n\nPress any key to continue.",
                                    truncate(&selected_title, 50)
                                ),
                            });
                            status = status_info("No previous episode available.");
                        } else {
                            status =
                                status_error(&format!("Action failed for {selected_title}: {err}"));
                        }
                    }
                }

                refresh_items(db, &mut items, &mut table_state, Some(&selected_id))?;
            }
            _ => {}
        }
    }

    terminal.show_cursor()?;
    session.leave()?;
    Ok(())
}