tuitube 0.7.0

YouTube in your terminal: a vim-style client with local subscriptions, built on yt-dlp and mpv
mod app;
mod chapters;
mod commands;
mod config;
mod dearrow;
mod demo;
mod feed;
mod icons;
mod ids;
mod images;
mod links;
mod player;
mod proxy;
mod settings;
mod sponsorblock;
mod store;
mod takeout;
mod text;
mod theme;
mod tmux;
mod tools;
mod ui;
mod video;
mod ytdlp;

use std::io::{Write, stdout};

use anyhow::{Context, Result, bail};
use crossterm::event::{DisableBracketedPaste, EnableBracketedPaste};
use crossterm::execute;

fn main() -> Result<()> {
    // Before any thread exists: PATH keeps only its absolute folders, as
    // `tools::on_path` does. tuitube names every program it starts by its
    // full path, but code it uses starts some by name (the image library's
    // `tmux`, crossterm's `tput`), and so does xdg-open: an empty or relative
    // entry would let one planted in the folder tuitube was started from run.
    #[cfg(unix)]
    match tools::absolute_path_entries(std::env::var_os("PATH")) {
        // SAFETY: first thing in main, before the runtime or any thread.
        Some(path) => unsafe { std::env::set_var("PATH", path) },
        // SAFETY: as above.
        None => unsafe { std::env::remove_var("PATH") },
    }
    tokio::runtime::Builder::new_multi_thread()
        .enable_all()
        .build()?
        .block_on(run())
}

async fn run() -> Result<()> {
    config::load_dotenv();
    let mut args = std::env::args().skip(1);
    let mut check = false;
    let import = match args.next().as_deref() {
        None => None,
        Some("--check") => {
            check = true;
            None
        }
        Some("--demo") => return demo::run().await,
        Some("-h" | "--help") => return print(&help()?),
        Some("-V" | "--version") => {
            return print(&format!("tuitube {}\n", env!("CARGO_PKG_VERSION")));
        }
        Some("--import") => Some(
            args.next()
                .context("--import needs the path to subscriptions.csv")?,
        ),
        Some(other) => bail!(
            "unknown argument {:?}; see tuitube --help",
            text::clean(other)
        ),
    };

    // Everything that can fail does so before the terminal is taken over,
    // so errors print as normal text.
    proxy::init()?;
    let config = config::Config::load()?;
    let settings_path = settings::path(&config.data_dir);
    let (settings, unknown_settings) = settings::Settings::load_checked(&settings_path)?;
    let mut store = store::Store::open(&config.data_dir.join("tuitube.db"))?;

    if let Some(path) = import {
        return import_takeout(&mut store, &path, settings.history);
    }
    if check {
        return print_check(&config, &settings).await;
    }

    let tools = tools::Tools::find(
        settings.yt_dlp.as_deref(),
        settings.mpv.as_deref(),
        settings.deno.as_deref(),
    );
    let yt = ytdlp::YtDlp::new(&tools, &config.data_dir)?;
    let image_cache = config.data_dir.join("cache").join("images");
    config::private_dir(&image_cache)?;
    let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
    let http = feed::client();

    need_terminal()?;
    let mut terminal = ratatui::init();
    // A panic anywhere but in an image decoder (caught there) ends the app:
    // the terminal is put back, then the message is printed without control
    // characters, since it can quote a video's title.
    std::panic::set_hook(Box::new(|info| {
        if images::panic_is_contained() {
            return;
        }
        let _ = execute!(stdout(), DisableBracketedPaste, crossterm::cursor::Show);
        ratatui::restore();
        tmux::restore();
        eprintln!("tuitube crashed: {}", text::clean(&info.to_string()));
        std::process::exit(101);
    }));
    // Ask the terminal which image protocol it speaks and its cell size in
    // pixels. Must happen before key reading starts. Inside tmux this turns
    // the pane's allow-passthrough on: its old value is put back at the end.
    tmux::save();
    let picker = images::picker(settings.image_mode());
    // A pasted path arrives as one event instead of keystrokes.
    execute!(stdout(), EnableBracketedPaste)?;

    let images = images::Images::new(picker, tx.clone(), http.clone(), Some(image_cache));
    let mut app = app::App::new(
        store,
        settings,
        Some(settings_path),
        tools,
        yt,
        http,
        tx,
        images,
    );
    if !unknown_settings.is_empty() {
        app.error(format!(
            "settings.toml: tuitube doesn't know {}, so it's ignored",
            unknown_settings.join(", ")
        ));
    }
    let result = app.run(&mut terminal, rx).await;
    drop(app);

    let _ = execute!(stdout(), DisableBracketedPaste);
    ratatui::restore();
    tmux::restore();
    result
}

/// `tuitube --check`: what tuitube found, to see why something is missing.
async fn print_check(config: &config::Config, settings: &settings::Settings) -> Result<()> {
    let tools = tools::Tools::find(
        settings.yt_dlp.as_deref(),
        settings.mpv.as_deref(),
        settings.deno.as_deref(),
    );
    let found = |path: &Option<std::path::PathBuf>| {
        path.as_deref()
            .map_or("not found".to_string(), config::shown)
    };
    // yt-dlp's version, and whether it's old enough to update.
    let mut yt = found(&tools.yt_dlp);
    if let (Some(path), Some(yt_dlp)) =
        (&tools.yt_dlp, ytdlp::YtDlp::new(&tools, &config.data_dir)?)
    {
        match yt_dlp.version().await {
            Ok(version) => {
                yt = format!("{yt} ({version})");
                let today = chrono::Local::now().date_naive();
                if let Some(notice) = ytdlp::old_notice(&version, path, today) {
                    yt = format!("{yt}\n             {notice}");
                }
            }
            Err(e) => yt = format!("{yt} (doesn't run: {})", text::clean(&format!("{e:#}"))),
        }
    }
    let env = |name: &str| text::clean(&std::env::var(name).unwrap_or_default());
    // The image query needs the terminal: it's skipped when stdout isn't one.
    let images = if std::io::IsTerminal::is_terminal(&stdout()) {
        tmux::save();
        let answer = images::check(settings.image_mode());
        tmux::restore();
        answer
    } else {
        "not a terminal".into()
    };
    print(&format!(
        "tuitube {version}
data folder  {data}
yt-dlp       {yt}
deno         {deno}
mpv          {mpv}
proxy        {proxy}
terminal     TERM={term} TERM_PROGRAM={program} {tmux}
images       {images}
",
        version = env!("CARGO_PKG_VERSION"),
        data = config::shown(&config.data_dir),
        deno = found(&tools.deno),
        mpv = found(&tools.mpv),
        proxy = proxy::shown(),
        term = env("TERM"),
        program = env("TERM_PROGRAM"),
        tmux = if env("TMUX").is_empty() {
            ""
        } else {
            "(in tmux)"
        },
    ))
}

/// A plain error, before the screen is taken over, when tuitube isn't run in
/// a terminal (piped, or from a script): there'd be nothing to draw on.
pub fn need_terminal() -> Result<()> {
    use std::io::IsTerminal;
    if !std::io::stdin().is_terminal() || !stdout().is_terminal() {
        bail!("tuitube needs a terminal to run in (tuitube --help says more)");
    }
    Ok(())
}

fn import_takeout(store: &mut store::Store, path: &str, history: bool) -> Result<()> {
    let found = takeout::find(std::path::Path::new(path))?;
    let imported = takeout::save(store, found, history)?;
    print(&format!(
        "{}. Start tuitube to see it.\n",
        imported.summary()
    ))
}

/// Writes to stdout. A reader that stops early (`tuitube --help | head -1`)
/// is fine, where `println!` would panic on the closed pipe.
fn print(text: &str) -> Result<()> {
    match stdout().lock().write_all(text.as_bytes()) {
        Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => Ok(()),
        result => Ok(result?),
    }
}

fn help() -> Result<String> {
    Ok(format!(
        "tuitube {version}
YouTube in your terminal: your subscriptions, search and playback, with
arrow keys or vim keys. No Google account, no login.

Usage: tuitube [-h | --help] [-V | --version] [--check] [--demo]
               [--import subscriptions.csv]

Inside the app, ? lists the keys and q quits. --check says what tuitube
found: yt-dlp, deno, mpv, and how your terminal draws images.
--demo shows made-up channels and videos, fetching and playing nothing.

Subscriptions come from Google Takeout (takeout.google.com → YouTube and
YouTube Music → subscriptions): press I in the app, or run
  tuitube --import path/to/subscriptions.csv

tuitube needs three programs, found on PATH:
  yt-dlp  talks to YouTube (searches, channels, what to play)
  deno    runs the JavaScript yt-dlp needs for YouTube
  mpv     plays videos
On macOS: brew install yt-dlp mpv (Homebrew's yt-dlp brings Deno)

Your subscriptions, Watch later, history and settings are kept only on
this computer, in:
  {data}

Environment:
  TT_DATA_DIR            keep them somewhere else
  TT_YT_DLP, TT_MPV,     where those programs are, if not on PATH
  TT_DENO
  TT_IMAGES              how images are drawn: auto, kitty, sixel, iterm2
                         or blocks (also `images` in settings.toml)
  TT_ICONS               which icons: auto, nerd (Nerd Font) or plain
                         (also `icons` in settings.toml)
  https_proxy, ...       one http:// proxy for tuitube, yt-dlp and mpv alike:
                         the first set of https_proxy, HTTPS_PROXY, all_proxy,
                         ALL_PROXY, http_proxy, HTTP_PROXY

tuitube isn't made or endorsed by YouTube or Google.
",
        version = env!("CARGO_PKG_VERSION"),
        data = config::shown(&config::data_dir()?),
    ))
}