mod app;
mod chapters;
mod commands;
mod config;
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<()> {
#[cfg(unix)]
match tools::absolute_path_entries(std::env::var_os("PATH")) {
Some(path) => unsafe { std::env::set_var("PATH", path) },
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)
),
};
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();
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);
}));
tmux::save();
let picker = images::picker(settings.image_mode());
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
}
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)
};
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());
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)"
},
))
}
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()
))
}
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()?),
))
}