feat: add categorized channel subscription feeds

This commit is contained in:
2026-10-09 21:33:31 +02:00
parent 3954de092e
commit 656474df5b
5 changed files with 1107 additions and 84 deletions
+380
View File
@@ -0,0 +1,380 @@
use std::{
collections::BTreeMap,
ffi::OsString,
fs::{self, OpenOptions},
io::{self, Write},
path::{Path, PathBuf},
sync::atomic::{AtomicU64, Ordering},
};
use toml_edit::{Array, DocumentMut, Item, Table, value};
#[derive(Default, Debug)]
pub struct Config {
pub categories: BTreeMap<String, Vec<String>>,
}
pub fn config_path() -> io::Result<PathBuf> {
resolve_path(
std::env::var_os("XDG_CONFIG_HOME"),
std::env::var_os("HOME"),
)
}
fn resolve_path(xdg: Option<OsString>, home: Option<OsString>) -> io::Result<PathBuf> {
let root = match xdg.filter(|path| !path.is_empty() && Path::new(path).is_absolute()) {
Some(path) => PathBuf::from(path),
None => PathBuf::from(
home.filter(|path| !path.is_empty())
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "HOME is unavailable"))?,
)
.join(".config"),
};
Ok(root.join("tubular/config.toml"))
}
pub fn load(path: &Path) -> io::Result<Config> {
let (_, document) = read_document(path)?;
parse_categories(&document)
}
pub fn add_channel(path: &Path, category: &str, channel: &str) -> io::Result<Config> {
let category = category_name(category)?;
let channel = normalize_channel(channel)?;
let (original, mut document) = read_document(path)?;
// Validate every category before touching the document or creating directories.
let mut config = parse_categories(&document)?;
if !document.contains_key("categories") {
document.insert("categories", Item::Table(Table::new()));
}
let categories = document["categories"]
.as_table_like_mut()
.expect("categories were validated");
// Keep the manually written key and its formatting if it has surrounding spaces.
let key = categories
.iter()
.find(|(key, _)| key.trim() == category)
.map(|(key, _)| key.to_owned())
.unwrap_or_else(|| category.to_owned());
if !categories.contains_key(&key) {
categories.insert(&key, value(Array::new()));
}
let channels = categories
.get_mut(&key)
.and_then(Item::as_array_mut)
.expect("category was validated");
let saved_channels = config.categories.entry(category.to_owned()).or_default();
// Preserve manually written entries and their comments; do not append an equivalent URL.
if !saved_channels.contains(&channel) {
channels.push(channel.clone());
saved_channels.push(channel);
}
save(path, original.as_deref(), document.to_string().as_bytes())?;
Ok(config)
}
fn invalid(message: impl Into<String>) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, message.into())
}
fn category_name(name: &str) -> io::Result<&str> {
if name.chars().any(char::is_control) || name.trim().is_empty() {
return Err(invalid(
"Category names must be nonempty and contain no control characters",
));
}
Ok(name.trim())
}
fn normalize_channel(input: &str) -> io::Result<String> {
if input.chars().any(char::is_control) {
return Err(invalid(
"Channel addresses must contain no control characters",
));
}
let input = input.trim();
let url_address = input
.strip_prefix("https://www.youtube.com/")
.or_else(|| input.strip_prefix("https://youtube.com/"));
let address = url_address.unwrap_or(input);
if let Some(handle) = address.strip_prefix('@') {
let length = handle.chars().count();
if length == 0
|| length > 30
|| !handle
.chars()
.all(|c| c.is_alphanumeric() || matches!(c, '_' | '-' | '.'))
|| !handle.starts_with(char::is_alphanumeric)
|| !handle.ends_with(char::is_alphanumeric)
{
return Err(invalid("Invalid YouTube channel handle"));
}
return Ok(format!("https://www.youtube.com/@{handle}"));
}
let id = match url_address {
Some(address) => address
.strip_prefix("channel/")
.ok_or_else(|| invalid("Expected a YouTube channel URL"))?,
None => address,
};
if id.len() != 24
|| !id.starts_with("UC")
|| !id
.bytes()
.all(|c| c.is_ascii_alphanumeric() || c == b'_' || c == b'-')
{
return Err(invalid(
"Expected a YouTube @handle or canonical UC channel ID",
));
}
Ok(format!("https://www.youtube.com/channel/{id}"))
}
fn read_bytes(path: &Path) -> io::Result<Option<Vec<u8>>> {
match fs::read(path) {
Ok(bytes) => Ok(Some(bytes)),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(error),
}
}
fn read_document(path: &Path) -> io::Result<(Option<Vec<u8>>, DocumentMut)> {
let bytes = read_bytes(path)?;
let document = match &bytes {
Some(bytes) => std::str::from_utf8(bytes)
.map_err(|error| invalid(format!("Config is not UTF-8: {error}")))?
.parse::<DocumentMut>()
.map_err(|error| invalid(format!("Invalid config TOML: {error}")))?,
None => DocumentMut::new(),
};
Ok((bytes, document))
}
fn parse_categories(document: &DocumentMut) -> io::Result<Config> {
let mut config = Config::default();
let Some(item) = document.get("categories") else {
return Ok(config);
};
let categories = item
.as_table_like()
.ok_or_else(|| invalid("categories must be a TOML table"))?;
for (name, channels) in categories.iter() {
let name = category_name(name)?;
let channels = channels
.as_array()
.ok_or_else(|| invalid(format!("Category {name:?} must be an array of channels")))?;
let mut normalized = Vec::with_capacity(channels.len());
for channel in channels.iter() {
let channel = normalize_channel(
channel
.as_str()
.ok_or_else(|| invalid("Channels must be strings"))?,
)?;
if !normalized.contains(&channel) {
normalized.push(channel);
}
}
if config
.categories
.insert(name.to_owned(), normalized)
.is_some()
{
return Err(invalid(format!(
"Duplicate category name after trimming: {name:?}"
)));
}
}
Ok(config)
}
static NEXT_TEMP: AtomicU64 = AtomicU64::new(0);
fn save(path: &Path, original: Option<&[u8]>, contents: &[u8]) -> io::Result<()> {
let parent = path
.parent()
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or(Path::new("."));
let filename = path
.file_name()
.ok_or_else(|| invalid("Config path has no filename"))?;
fs::create_dir_all(parent)?;
let (temporary, mut file) = loop {
let mut name = OsString::from(".");
name.push(filename);
name.push(format!(
".{}.{}.tmp",
std::process::id(),
NEXT_TEMP.fetch_add(1, Ordering::Relaxed)
));
let temporary = parent.join(name);
let mut options = OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
match options.open(&temporary) {
Ok(file) => break (temporary, file),
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
Err(error) => return Err(error),
}
};
let result = (|| {
if original.is_some() {
file.set_permissions(fs::metadata(path)?.permissions())?;
}
file.write_all(contents)?;
file.sync_all()?;
if read_bytes(path)?.as_deref() != original {
return Err(io::Error::other(
"Config changed while saving; please retry",
));
}
if original.is_none() {
// Unlike rename, hard_link cannot overwrite a file created by a racing editor.
fs::hard_link(&temporary, path)?;
} else {
// std has no compare-and-swap rename; check immediately before replacement.
fs::rename(&temporary, path)?;
}
Ok(())
})();
drop(file);
let _ = fs::remove_file(&temporary);
result
}
#[cfg(test)]
mod tests {
use super::*;
struct TestDirectory(PathBuf);
impl TestDirectory {
fn new() -> Self {
loop {
let path = std::env::temp_dir().join(format!(
"tubular-config-test-{}-{}",
std::process::id(),
NEXT_TEMP.fetch_add(1, Ordering::Relaxed)
));
match fs::create_dir(&path) {
Ok(()) => return Self(path),
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
Err(error) => panic!("Cannot create test directory: {error}"),
}
}
}
}
impl Drop for TestDirectory {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
#[test]
fn edits_preserve_manual_config_and_refuse_invalid_or_changed_files() {
let directory = TestDirectory::new();
let path = directory.0.join("nested/config.toml");
assert!(load(&path).unwrap().categories.is_empty());
assert!(!path.parent().unwrap().exists());
let config = add_channel(&path, " Music ", "@example").unwrap();
assert_eq!(
config.categories["Music"],
["https://www.youtube.com/@example"]
);
let manual = "# manual preferences\nplayer = 'mpv' # keep me\n[other]\nvalue = 42\n[categories]\n# favorites\n' Music ' = ['@example', 'https://www.youtube.com/@example'] # channel note\nEmpty = []\n";
fs::write(&path, manual).unwrap();
let config = add_channel(&path, "Music", "https://youtube.com/@example").unwrap();
assert_eq!(config.categories["Music"].len(), 1);
assert!(config.categories["Empty"].is_empty());
assert_eq!(fs::read_to_string(&path).unwrap(), manual);
let id = "UCabcdefghijklmnopqrstuv";
let config = add_channel(&path, "Music", id).unwrap();
assert_eq!(
config.categories["Music"][1],
format!("https://www.youtube.com/channel/{id}")
);
let saved = fs::read_to_string(&path).unwrap();
for comment in [
"# manual preferences",
"# keep me",
"# favorites",
"# channel note",
] {
assert!(saved.contains(comment));
}
assert_eq!(
saved.parse::<DocumentMut>().unwrap()["other"]["value"].as_integer(),
Some(42)
);
for malformed in [
"[categories\n",
"categories = 3\n",
"[categories]\nMusic = [3]\n",
"[categories]\nMusic = ['https://example.com/@example']\n",
"[categories]\nMusic = []\n' Music ' = []\n",
] {
fs::write(&path, malformed).unwrap();
assert!(load(&path).is_err());
assert!(add_channel(&path, "Music", "@example").is_err());
assert_eq!(fs::read_to_string(&path).unwrap(), malformed);
}
fs::write(&path, manual).unwrap();
assert!(add_channel(&path, "Music", "https://www.youtube.com/@example/videos").is_err());
assert_eq!(fs::read_to_string(&path).unwrap(), manual);
assert!(save(&path, Some(b"previous contents"), b"replacement").is_err());
assert_eq!(fs::read_to_string(&path).unwrap(), manual);
assert_eq!(fs::read_dir(path.parent().unwrap()).unwrap().count(), 1);
}
#[test]
fn paths_and_channel_addresses_are_strict() {
let home = Some(OsString::from("/home/test"));
let fallback = PathBuf::from("/home/test/.config/tubular/config.toml");
for xdg in [
None,
Some(OsString::new()),
Some(OsString::from("relative")),
] {
assert_eq!(resolve_path(xdg, home.clone()).unwrap(), fallback);
}
assert_eq!(
resolve_path(Some("/config".into()), None).unwrap(),
PathBuf::from("/config/tubular/config.toml")
);
assert!(resolve_path(None, None).is_err());
assert!(resolve_path(None, Some(OsString::new())).is_err());
for channel in [
"",
"@",
"@bad/name",
"@bad?query",
"@bad#fragment",
"@bad\n",
"https://www.youtube.com.evil/@example",
"http://www.youtube.com/@example",
"https://www.youtube.com/channel/UCshort",
"channel/UCshort",
"https://www.youtube.com/@example/videos",
"https://www.youtube.com/@example?x=1",
"https://www.youtube.com/@example/../other",
"https://www.youtube.com/@example//",
] {
assert!(normalize_channel(channel).is_err(), "accepted {channel:?}");
}
assert_eq!(
normalize_channel("@example").unwrap(),
"https://www.youtube.com/@example"
);
assert_eq!(
normalize_channel("https://youtube.com/channel/UCabcdefghijklmnopqrstuv").unwrap(),
"https://www.youtube.com/channel/UCabcdefghijklmnopqrstuv"
);
assert!(category_name(" \t ").is_err());
}
}
+521 -62
View File
@@ -7,13 +7,19 @@
//! Normal-mode d opens the full description; Up/Down scrolls lines and PgUp/PgDn scrolls pages.
//! The centered popup uses up to 70% width and 60% height, capped at 96 by 20 cells.
//! Esc closes the popup; j/k loads the selected video's description and i returns to editing.
//! Channel categories live in `$XDG_CONFIG_HOME/tubular/config.toml`, or `~/.config/tubular/config.toml`.
//! Under `[categories]`, write `Rust = ["@RustVideos"]`; channel URLs and UC IDs also work.
//! Normal-mode c opens the category picker; n there prompts for a new name, Enter saves, Esc cancels.
//! Tab/Shift-Tab switch the independent Search/category views; r reloads config and refreshes.
//! Feeds merge up to 20 videos per channel's /videos tab, ordered by approximate upload dates.
//! IINA receives `--stdin`; other players must read standard input when passed `-`.
//! Audio and video are streamed through a pipe, never saved to a video file.
//! See `--help` for controls; replacing playback or quitting cancels its processes.
mod config;
mod youtube;
use std::{ffi::OsString, io, time::Duration};
use std::{ffi::OsString, io, path::PathBuf, time::Duration};
use clap::Parser;
use ratatui::{
@@ -22,7 +28,7 @@ use ratatui::{
layout::{Constraint, Layout, Rect},
style::{Color, Style, Stylize},
text::Line,
widgets::{Block, Clear, Paragraph, Row, Table, TableState, Wrap},
widgets::{Block, Clear, List, ListState, Paragraph, Row, Table, TableState, Tabs, Wrap},
};
use youtube::{Job, Playback, Video};
@@ -30,7 +36,7 @@ use youtube::{Job, Playback, Video};
#[command(
version,
about = "Search YouTube and stream videos to your chosen player",
after_help = "Requires yt-dlp, FFmpeg, and a player on PATH.\nDefaults to IINA on macOS and mpv elsewhere; --player overrides the default.\nIINA receives '--stdin'; other players receive '-' to read audio/video from standard input.\nWithout a CLI query, start in INSERT; a CLI query is searched automatically in NORMAL.\nINSERT: type to edit; Enter submits if changed and returns to NORMAL; Esc returns without submitting.\nNORMAL: j/k or arrows select; Enter streams; i edits the query; d toggles description; q quits.\nOpening the description fetches full video metadata, preserving paragraphs and caching it for these results.\nThe centered description window uses up to 70% width and 60% height, capped at 96 by 20 cells.\nWhile it is open, Up/Down scroll lines and PgUp/PgDn scroll pages; j/k still selects videos.\nEsc closes the description; i closes it to edit the query. Scrolling stays within the text.\nCtrl-U clears in INSERT; Ctrl-C quits in either mode.\nSelecting another video replaces playback. Quitting stops playback.\nExample: tubular --player mpv --player-arg=--volume=50 \"rust programming\""
after_help = "Requires yt-dlp, FFmpeg, and a player on PATH.\nDefaults to IINA on macOS and mpv elsewhere; --player overrides the default.\nIINA receives '--stdin'; other players receive '-' to read audio/video from standard input.\nWithout a CLI query, start in INSERT; a CLI query is searched automatically in NORMAL.\nINSERT: type to edit; Enter submits if changed and returns to NORMAL; Esc returns without submitting.\nNORMAL: j/k or arrows select; Enter streams; i edits the query; d toggles description; c saves a channel; q quits.\nTab/Shift-Tab switch Search/category views; r reloads config and refreshes the current view.\nSubscriptions: $XDG_CONFIG_HOME/tubular/config.toml, or ~/.config/tubular/config.toml when XDG_CONFIG_HOME is unset/empty/relative.\nExample TOML:\n[categories]\nRust = [\"@RustVideos\"]\nChannel entries accept @handles, HTTPS YouTube channel URLs, or canonical UC channel IDs.\nFeeds combine up to 20 videos per channel's /videos tab, newest-first using approximate upload dates.\nCATEGORY PICKER: j/k or arrows choose; Enter saves; n prompts for a new category; Esc cancels.\nNEW CATEGORY: type a name; Enter creates it and saves the channel; Esc returns to the picker; Ctrl-U clears.\nOpening the description fetches full video metadata, preserving paragraphs and caching it for these results.\nThe centered description window uses up to 70% width and 60% height, capped at 96 by 20 cells.\nWhile it is open, Up/Down scroll lines and PgUp/PgDn scroll pages; j/k still selects videos.\nEsc closes the description; i closes it to edit the query. Scrolling stays within the text.\nCtrl-U clears in INSERT; Ctrl-C quits in any mode or popup.\nSelecting another video replaces playback. Quitting stops playback.\nExample: tubular --player mpv --player-arg=--volume=50 \"rust programming\""
)]
struct Options {
/// Player executable (IINA or a player supporting '-' for standard input).
@@ -51,13 +57,44 @@ enum Mode {
Normal,
}
struct View {
name: String,
title: String,
videos: Vec<Video>,
selection: TableState,
loaded: bool,
}
impl View {
fn new(name: String) -> Self {
Self {
title: format!(" {name} "),
name,
videos: Vec::new(),
selection: TableState::default(),
loaded: false,
}
}
}
struct CategoryDialog {
channel: String,
label: String,
selection: ListState,
name: Option<String>,
error: Option<String>,
}
struct App {
options: Options,
query: String,
mode: Mode,
submitted_query: String,
videos: Vec<Video>,
selection: TableState,
config_path: PathBuf,
config: config::Config,
views: Vec<View>,
view_index: usize,
category_dialog: Option<CategoryDialog>,
description_open: bool,
description_job: Option<(usize, Job)>,
description_error: Option<String>,
@@ -70,20 +107,25 @@ struct App {
}
impl App {
fn new(options: Options) -> Self {
fn new(options: Options, config_path: PathBuf, config: config::Config) -> Self {
let query = options.query.clone().unwrap_or_default();
let mode = if query.trim().is_empty() {
Mode::Insert
} else {
Mode::Normal
};
let mut views = vec![View::new("Search".into())];
views.extend(config.categories.keys().cloned().map(View::new));
Self {
options,
query,
mode,
submitted_query: String::new(),
videos: Vec::new(),
selection: TableState::default(),
config_path,
config,
views,
view_index: 0,
category_dialog: None,
description_open: false,
description_job: None,
description_error: None,
@@ -102,36 +144,244 @@ impl App {
self.status = "Enter a search first.".into();
return;
}
if query == self.submitted_query {
if query != self.submitted_query {
self.submitted_query = query.to_owned();
self.views[0].loaded = false;
self.job = None;
}
self.switch_view(0);
}
fn switch_view(&mut self, index: usize) {
if index != self.view_index {
self.job = None;
self.view_index = index;
}
self.description_open = false;
self.description_job = None;
self.description_viewport = None;
if !self.views[index].loaded {
if self.job.is_none() {
self.load_view();
}
} else {
self.status = format!(
"{} videos in {}. Select a video to stream.",
self.views[index].videos.len(),
self.views[index].name
);
}
}
fn load_view(&mut self) {
self.job = None;
self.description_open = false;
self.description_job = None;
let view = &mut self.views[self.view_index];
view.videos.clear();
view.selection = TableState::default();
view.loaded = false;
let result = if self.view_index == 0 {
if self.submitted_query.is_empty() {
self.status = "Type a query, then press Enter.".into();
return;
}
self.status = format!("Searching for {}...", self.submitted_query);
Job::search(&self.submitted_query)
} else {
let channels = &self.config.categories[&view.name];
if channels.is_empty() {
view.loaded = true;
self.status = format!(
"{} has no channels. Press c on a search result to add one.",
view.name
);
return;
}
self.status = format!("Loading {}...", view.name);
Job::feed(channels)
};
match result {
Ok(job) => self.job = Some(job),
Err(error) => self.status = error.to_string(),
}
}
fn apply_config(&mut self, config: config::Config) {
if config.categories == self.config.categories {
return;
}
match Job::search(query) {
Ok(job) => {
self.job = Some(job);
self.description_open = false;
self.description_job = None;
self.submitted_query = query.to_owned();
self.videos.clear();
self.selection = TableState::default();
self.status = format!("Searching for {query}...");
let active_name = (self.view_index != 0).then(|| self.views[self.view_index].name.clone());
let mut old = std::mem::take(&mut self.views).into_iter();
self.views.push(old.next().expect("search view exists"));
let mut old: Vec<_> = old.collect();
// ponytail: category lookup is quadratic; use a keyed map if hundreds of categories matter.
for name in config.categories.keys() {
let mut view = if let Some(index) = old.iter().position(|view| &view.name == name) {
old.swap_remove(index)
} else {
View::new(name.clone())
};
if config.categories.get(name) != self.config.categories.get(name) {
view.loaded = false;
view.videos.clear();
view.selection = TableState::default();
}
self.views.push(view);
}
self.view_index = active_name
.and_then(|name| self.views.iter().skip(1).position(|view| view.name == name))
.map(|index| index + 1)
.unwrap_or(0);
self.config = config;
self.job = None;
self.description_open = false;
self.description_job = None;
}
fn refresh(&mut self) {
match config::load(&self.config_path) {
Ok(config) => {
self.apply_config(config);
self.load_view();
}
Err(error) => self.status = error.to_string(),
}
}
fn open_category_picker(&mut self) {
let view = &self.views[self.view_index];
let Some(video) = view
.selection
.selected()
.and_then(|index| view.videos.get(index))
else {
self.status = "Select a video first.".into();
return;
};
let Some(id) = video.channel_id.as_deref() else {
self.status = "This video has no available channel identity.".into();
return;
};
let channel = format!("https://www.youtube.com/channel/{id}");
let label = format!(" {} ", video.channel.as_deref().unwrap_or(id));
match config::load(&self.config_path) {
Ok(config) => self.apply_config(config),
Err(error) => {
self.status = error.to_string();
return;
}
}
let mut selection = ListState::default();
selection.select((!self.config.categories.is_empty()).then_some(0));
self.description_open = false;
self.description_job = None;
self.category_dialog = Some(CategoryDialog {
channel,
label,
selection,
name: None,
error: None,
});
}
fn category_key(&mut self, key: KeyEvent) {
let mut dialog = self
.category_dialog
.take()
.expect("category dialog is open");
if let Some(name) = &mut dialog.name {
match key.code {
KeyCode::Esc => {
dialog.name = None;
dialog.error = None;
}
KeyCode::Backspace => {
name.pop();
}
KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => name.clear(),
KeyCode::Char(c)
if !key
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
{
name.push(c);
}
_ => {}
}
} else {
let count = self.config.categories.len();
match key.code {
KeyCode::Esc => {
self.switch_view(self.view_index);
return;
}
KeyCode::Char('n') => {
dialog.name = Some(String::new());
dialog.error = None;
}
KeyCode::Down | KeyCode::Char('j') if count != 0 => {
dialog.selection.select(Some(
dialog
.selection
.selected()
.unwrap_or(0)
.saturating_add(1)
.min(count - 1),
));
}
KeyCode::Up | KeyCode::Char('k') if count != 0 => {
dialog.selection.select(Some(
dialog.selection.selected().unwrap_or(0).saturating_sub(1),
));
}
_ => {}
}
}
if key.code == KeyCode::Enter {
let category = dialog
.name
.as_deref()
.map(str::trim)
.map(str::to_owned)
.or_else(|| {
dialog
.selection
.selected()
.and_then(|index| self.config.categories.keys().nth(index))
.cloned()
});
if let Some(category) = category {
match config::add_channel(&self.config_path, &category, &dialog.channel) {
Ok(config) => {
self.apply_config(config);
self.switch_view(self.view_index);
self.status = format!("Saved channel to {category}.");
return;
}
Err(error) => dialog.error = Some(error.to_string()),
}
} else {
dialog.error = Some("Press n to create a category.".into());
}
}
self.category_dialog = Some(dialog);
}
fn load_description(&mut self) {
self.description_job = None;
self.description_error = None;
self.description_scroll = 0;
self.description_viewport = None;
let Some(index) = self
let view = &self.views[self.view_index];
let Some(index) = view
.selection
.selected()
.filter(|&index| index < self.videos.len())
.filter(|&index| index < view.videos.len())
else {
return;
};
let video = &self.videos[index];
let video = &view.videos[index];
if video.description.is_some() {
return;
}
@@ -145,10 +395,11 @@ impl App {
if self.job.is_some() {
return;
}
let Some(video) = self
let view = &self.views[self.view_index];
let Some(video) = view
.selection
.selected()
.and_then(|index| self.videos.get(index))
.and_then(|index| view.videos.get(index))
else {
return;
};
@@ -168,13 +419,30 @@ impl App {
fn update(&mut self) {
if let Some(result) = self.job.as_mut().and_then(Job::poll) {
self.job = None;
self.status = match result.and_then(|output| youtube::search_results(&output)) {
let parsed = result.and_then(|output| {
if self.view_index == 0 {
youtube::search_results(&output)
} else {
youtube::feed_results(&output)
}
});
self.status = match parsed {
Ok(videos) => {
self.videos = videos;
self.selection = TableState::default();
self.selection
.select((!self.videos.is_empty()).then_some(0));
format!("{} results. Select a video to stream.", self.videos.len())
let view = &mut self.views[self.view_index];
view.videos = videos;
view.loaded = true;
view.selection = TableState::default();
view.selection
.select((!view.videos.is_empty()).then_some(0));
if self.view_index == 0 {
format!("{} results. Select a video to stream.", view.videos.len())
} else {
format!(
"{} videos in {}. Select a video to stream.",
view.videos.len(),
view.name
)
}
}
Err(error) => error,
};
@@ -188,7 +456,7 @@ impl App {
.description_job
.take()
.expect("completed description job exists");
if let Some(video) = self.videos.get_mut(index) {
if let Some(video) = self.views[self.view_index].videos.get_mut(index) {
match result.and_then(|output| youtube::full_description(&output, &video.id)) {
Ok(description) => video.description = Some(description),
Err(error) => self.description_error = Some(error),
@@ -209,7 +477,11 @@ impl App {
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
return true;
}
let selected_before = self.selection.selected();
if self.category_dialog.is_some() {
self.category_key(key);
return false;
}
let selected_before = self.views[self.view_index].selection.selected();
let description_was_open = self.description_open;
if self.mode == Mode::Insert {
match key.code {
@@ -242,10 +514,10 @@ impl App {
}
KeyCode::Char('d') => {
self.description_open = !self.description_open
&& self
&& self.views[self.view_index]
.selection
.selected()
.is_some_and(|index| index < self.videos.len());
.is_some_and(|index| index < self.views[self.view_index].videos.len());
}
KeyCode::Esc => self.description_open = false,
KeyCode::Down if self.description_open => {
@@ -276,38 +548,62 @@ impl App {
.max(1);
self.description_scroll = self.description_scroll.saturating_sub(page);
}
KeyCode::Char('c') => self.open_category_picker(),
KeyCode::Char('r') => self.refresh(),
KeyCode::Tab => self.switch_view((self.view_index + 1) % self.views.len()),
KeyCode::BackTab => {
self.switch_view((self.view_index + self.views.len() - 1) % self.views.len())
}
KeyCode::Enter => self.play(),
KeyCode::Down | KeyCode::Char('j') => self.selection.select_next(),
KeyCode::Up | KeyCode::Char('k') => self.selection.select_previous(),
KeyCode::Home | KeyCode::Char('g') => self.selection.select_first(),
KeyCode::End | KeyCode::Char('G') => self.selection.select_last(),
KeyCode::Down | KeyCode::Char('j') => {
self.views[self.view_index].selection.select_next()
}
KeyCode::Up | KeyCode::Char('k') => {
self.views[self.view_index].selection.select_previous()
}
KeyCode::Home | KeyCode::Char('g') => {
self.views[self.view_index].selection.select_first()
}
KeyCode::End | KeyCode::Char('G') => {
self.views[self.view_index].selection.select_last()
}
_ => {}
}
}
if let Some(index) = self
let view = &mut self.views[self.view_index];
if let Some(index) = view
.selection
.selected()
.filter(|_| !self.videos.is_empty())
.filter(|_| !view.videos.is_empty())
{
self.selection
.select(Some(index.min(self.videos.len() - 1)));
view.selection
.select(Some(index.min(view.videos.len() - 1)));
}
if !self.description_open {
self.description_job = None;
} else if !description_was_open || self.selection.selected() != selected_before {
} else if !description_was_open
|| self.views[self.view_index].selection.selected() != selected_before
{
self.load_description();
}
false
}
fn draw(&mut self, frame: &mut Frame) {
let [search, results, status, help] = Layout::vertical([
let [tabs, search, results, status, help] = Layout::vertical([
Constraint::Length(1),
Constraint::Length(3),
Constraint::Min(3),
Constraint::Length(3),
Constraint::Length(1),
])
.areas(frame.area());
frame.render_widget(
Tabs::new(self.views.iter().map(|view| view.name.as_str()))
.select(self.view_index)
.highlight_style(Style::new().fg(Color::Cyan).bold()),
tabs,
);
let search_style = if self.mode == Mode::Insert {
Style::new().fg(Color::Cyan)
} else {
@@ -328,8 +624,9 @@ impl App {
),
search,
);
let view = &mut self.views[self.view_index];
let table = Table::new(
self.videos.iter().map(|video| {
view.videos.iter().map(|video| {
Row::new([
video.title.as_str(),
video.channel.as_deref().unwrap_or("Unknown"),
@@ -338,10 +635,14 @@ impl App {
[Constraint::Percentage(75), Constraint::Percentage(25)],
)
.header(Row::new(["Video", "Channel"]).bold())
.block(Block::bordered().title(" Results "))
.block(Block::bordered().title(if self.view_index == 0 {
" Results "
} else {
view.title.as_str()
}))
.row_highlight_style(Style::new().fg(Color::Black).bg(Color::Cyan))
.highlight_symbol("> ");
frame.render_stateful_widget(table, results, &mut self.selection);
frame.render_stateful_widget(table, results, &mut view.selection);
frame.render_widget(
Paragraph::new(self.status.as_str())
.wrap(Wrap { trim: true })
@@ -356,15 +657,15 @@ impl App {
"NORMAL · ↑/↓ PgUp/PgDn scroll · j/k video · d/Esc close · i insert · q quit"
}
Mode::Normal => {
"NORMAL · j/k select · Enter stream · i insert · d description · q quit"
"NORMAL · j/k select · Enter play · i search · d info · c save · Tab views · r refresh · q quit"
}
};
frame.render_widget(Paragraph::new(help_text).dim(), help);
if self.description_open
&& let Some(video) = self
&& let Some(video) = self.views[self.view_index]
.selection
.selected()
.and_then(|index| self.videos.get(index))
.and_then(|index| self.views[self.view_index].videos.get(index))
{
let area = frame.area();
let width = area
@@ -420,6 +721,86 @@ impl App {
popup,
);
}
self.draw_category_dialog(frame);
}
fn draw_category_dialog(&mut self, frame: &mut Frame) {
let Some(dialog) = &mut self.category_dialog else {
return;
};
let area = frame.area();
let width = area.width.saturating_sub(4).min(60);
let height = area.height.saturating_sub(4).min(if dialog.name.is_some() {
7
} else {
self.config.categories.len().saturating_add(5).clamp(7, 16) as u16
});
let popup = Rect::new(
area.x + (area.width - width) / 2,
area.y + (area.height - height) / 2,
width,
height,
);
frame.render_widget(Clear, popup);
let block = Block::bordered()
.title(if dialog.name.is_some() {
" New category "
} else {
" Save channel "
})
.title_bottom(dialog.label.as_str())
.border_style(Style::new().fg(Color::Cyan));
let [body, error, help] = Layout::vertical([
Constraint::Min(1),
Constraint::Length(if dialog.error.is_some() { 2 } else { 0 }),
Constraint::Length(1),
])
.areas(block.inner(popup));
frame.render_widget(block, popup);
if let Some(name) = &dialog.name {
let text = Line::raw(name.as_str());
let scroll = text
.width()
.saturating_sub(body.width.saturating_sub(1) as usize);
let cursor = text.width().saturating_sub(scroll) as u16;
frame.render_widget(
Paragraph::new(text).scroll((0, scroll.min(u16::MAX as usize) as u16)),
body,
);
if body.width != 0 && body.height != 0 {
frame.set_cursor_position((body.x + cursor.min(body.width - 1), body.y));
}
} else if self.config.categories.is_empty() {
frame.render_widget(
Paragraph::new("No categories. Press n to create one."),
body,
);
} else {
frame.render_stateful_widget(
List::new(self.config.categories.keys().map(String::as_str))
.highlight_symbol("> ")
.highlight_style(Style::new().fg(Color::Black).bg(Color::Cyan)),
body,
&mut dialog.selection,
);
}
if let Some(message) = &dialog.error {
frame.render_widget(
Paragraph::new(message.as_str())
.wrap(Wrap { trim: true })
.fg(Color::Red),
error,
);
}
frame.render_widget(
Paragraph::new(if dialog.name.is_some() {
"Enter create & save · Esc back · Ctrl-U clear"
} else {
"j/k select · Enter save · n new · Esc cancel"
})
.dim(),
help,
);
}
fn run(mut self, terminal: &mut DefaultTerminal) -> io::Result<()> {
@@ -441,7 +822,10 @@ impl App {
}
fn main() -> io::Result<()> {
let app = App::new(Options::parse());
let options = Options::parse();
let config_path = config::config_path()?;
let config = config::load(&config_path)?;
let app = App::new(options, config_path, config);
ratatui::run(|terminal| app.run(terminal))
}
@@ -449,41 +833,53 @@ fn main() -> io::Result<()> {
mod tests {
use super::*;
fn app(args: &[&str]) -> App {
App::new(
Options::parse_from(args),
PathBuf::new(),
config::Config::default(),
)
}
#[test]
fn modal_keys_edit_or_navigate_without_submitting_on_escape() {
let mut app = App::new(Options::parse_from(["tubular"]));
let mut app = app(&["tubular"]);
assert_eq!(app.mode, Mode::Insert);
app.videos = vec![
app.views[0].videos = vec![
Video {
id: "jNQXAC9IVRw".into(),
title: "Me at the zoo".into(),
channel: None,
channel_id: None,
timestamp: None,
description: None,
},
Video {
id: "rQ_J9WH6CGk".into(),
title: "Rust course".into(),
channel: None,
channel_id: None,
timestamp: None,
description: None,
},
];
app.selection.select(Some(0));
app.views[0].selection.select(Some(0));
for c in ['j', 'k', 'q'] {
assert!(!app.key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)));
}
app.update();
assert_eq!(app.query, "jkq");
assert_eq!(app.selection.selected(), Some(0));
assert_eq!(app.views[0].selection.selected(), Some(0));
assert!(app.job.is_none());
app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert_eq!(app.mode, Mode::Normal);
assert!(app.job.is_none());
app.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE));
assert_eq!(app.selection.selected(), Some(1));
assert_eq!(app.views[0].selection.selected(), Some(1));
app.key(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE));
assert_eq!(app.selection.selected(), Some(0));
assert_eq!(app.views[0].selection.selected(), Some(0));
app.key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
assert_eq!(app.mode, Mode::Insert);
app.key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL));
@@ -492,13 +888,76 @@ mod tests {
assert!(app.query.is_empty());
assert!(app.job.is_none());
assert_eq!(self::app(&["tubular", "rust"]).mode, Mode::Normal);
assert_eq!(self::app(&["tubular", " "]).mode, Mode::Insert);
}
#[test]
fn category_dialog_is_modal_and_views_keep_independent_selections() {
let mut app = app(&["tubular", "rust"]);
let videos = r#"{"entries":[{"id":"l-GaaivVJCE","title":"Rust video","channel":"Rust","channel_id":"UCaYhcUwRBNscFNUKTjgPFiA"},{"id":"rQ_J9WH6CGk","title":"Another video"}]}"#;
app.views[0].videos = youtube::search_results(videos).unwrap();
app.views[0].loaded = true;
app.views[0].selection.select(Some(1));
// The selected channel is unavailable; do not save a guessed display-name identity.
app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE));
assert!(app.category_dialog.is_none());
app.views[0].selection.select(Some(0));
app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE));
app.key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE));
app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(app.category_dialog.as_ref().unwrap().error.is_some());
for c in ['j', 'k', 'q', 'i'] {
assert!(!app.key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)));
}
assert_eq!(
App::new(Options::parse_from(["tubular", "rust"])).mode,
Mode::Normal
);
assert_eq!(
App::new(Options::parse_from(["tubular", " "])).mode,
Mode::Insert
app.category_dialog.as_ref().unwrap().name.as_deref(),
Some("jkqi")
);
assert_eq!(app.query, "rust");
assert_eq!(app.views[0].selection.selected(), Some(0));
app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(app.category_dialog.as_ref().unwrap().name.is_none());
app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(app.category_dialog.is_none());
assert_eq!(app.mode, Mode::Normal);
let rust = vec!["https://www.youtube.com/@RustVideos".into()];
app.apply_config(config::Config {
categories: [("Rust".into(), rust.clone())].into(),
});
app.views[0].selection.select(Some(1));
app.views[1].videos = youtube::search_results(videos).unwrap();
app.views[1].loaded = true;
app.views[1].selection.select(Some(0));
app.key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
app.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE));
assert_eq!(app.views[1].selection.selected(), Some(1));
app.key(KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT));
assert_eq!(app.view_index, 0);
assert_eq!(app.views[0].selection.selected(), Some(1));
app.key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
app.apply_config(config::Config {
categories: [
(
"Books".into(),
vec!["https://www.youtube.com/@example".into()],
),
("Rust".into(), rust),
]
.into(),
});
assert_eq!(app.view_index, 2);
assert_eq!(app.views[2].name, "Rust");
assert_eq!(app.views[2].selection.selected(), Some(1));
assert!(app.views[2].loaded);
app.apply_config(config::Config {
categories: [("Rust".into(), vec![])].into(),
});
assert_eq!(app.views[app.view_index].name, "Rust");
assert!(app.views[app.view_index].videos.is_empty());
assert!(app.views[app.view_index].selection.selected().is_none());
assert!(!app.views[app.view_index].loaded);
assert!(app.job.is_none());
}
}
+148 -22
View File
@@ -13,6 +13,8 @@ pub struct Video {
pub id: String,
pub title: String,
pub channel: Option<String>,
pub channel_id: Option<String>,
pub timestamp: Option<i64>,
// Search descriptions are truncated; only full video metadata populates this cache.
#[serde(skip)]
pub description: Option<String>,
@@ -21,30 +23,86 @@ pub struct Video {
#[derive(Deserialize)]
struct SearchResults {
entries: Vec<Option<Video>>,
channel: Option<String>,
channel_id: Option<String>,
}
pub fn search_results(output: &str) -> Result<Vec<Video>, String> {
let results: SearchResults = serde_json::from_str(output)
.map_err(|error| format!("Invalid search response: {error}"))?;
let mut videos = Vec::new();
for mut video in results.entries.into_iter().flatten() {
if video.id.len() != 11
|| !video
.id
.bytes()
.all(|c| c.is_ascii_alphanumeric() || c == b'-' || c == b'_')
{
return Err("Search returned an invalid YouTube video ID".into());
}
video.title.retain(|c| !c.is_control());
if let Some(channel) = &mut video.channel {
channel.retain(|c| !c.is_control());
}
videos.push(video);
let mut videos: Vec<Video> = results.entries.into_iter().flatten().collect();
for video in &mut videos {
validate_video(video).map_err(|error| format!("Search returned {error}"))?;
}
Ok(videos)
}
fn valid_channel_id(id: &str) -> bool {
id.len() == 24
&& id.starts_with("UC")
&& id
.bytes()
.all(|c| c.is_ascii_alphanumeric() || c == b'-' || c == b'_')
}
fn validate_video(video: &mut Video) -> Result<(), String> {
if video.id.len() != 11
|| !video
.id
.bytes()
.all(|c| c.is_ascii_alphanumeric() || c == b'-' || c == b'_')
{
return Err("an invalid YouTube video ID".into());
}
if video
.channel_id
.as_deref()
.is_some_and(|id| !valid_channel_id(id))
{
return Err("an invalid YouTube channel ID".into());
}
video.title.retain(|c| !c.is_control());
if let Some(channel) = &mut video.channel {
channel.retain(|c| !c.is_control());
}
Ok(())
}
pub fn feed_results(output: &str) -> Result<Vec<Video>, String> {
let mut videos = Vec::new();
let mut playlists = 0;
// Multiple yt-dlp URLs produce one playlist object per line, not one combined object.
for result in serde_json::Deserializer::from_str(output).into_iter::<SearchResults>() {
let playlist = result.map_err(|error| format!("Invalid feed response: {error}"))?;
playlists += 1;
if playlist
.channel_id
.as_deref()
.is_some_and(|id| !valid_channel_id(id))
{
return Err("Feed returned an invalid YouTube channel ID".into());
}
for mut video in playlist.entries.into_iter().flatten() {
if video.channel_id.is_none() {
video.channel_id = playlist.channel_id.clone();
}
if video.channel.is_none() {
video.channel = playlist.channel.clone();
}
validate_video(&mut video).map_err(|error| format!("Feed returned {error}"))?;
videos.push(video);
}
}
if playlists == 0 {
return Err("Feed returned no playlist data".into());
}
// Keep the newest occurrence of each ID without copying IDs into a second collection.
videos.sort_unstable_by(|a, b| a.id.cmp(&b.id).then_with(|| b.timestamp.cmp(&a.timestamp)));
videos.dedup_by(|a, b| a.id == b.id);
videos.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
Ok(videos)
}
pub fn full_description(output: &str, expected_id: &str) -> Result<String, String> {
#[derive(Deserialize)]
struct Details {
@@ -69,25 +127,46 @@ pub struct Job {
impl Job {
pub fn search(query: &str) -> io::Result<Self> {
Self::spawn(&[
Self::spawn(extractor().args([
"--flat-playlist",
"--dump-single-json",
&format!("ytsearch20:{query}"),
])
]))
}
pub fn feed(channels: &[String]) -> io::Result<Self> {
if channels.is_empty() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"No channels in this category",
));
}
// ponytail: 20 entries per channel's /videos tab; add pagination if older uploads are needed.
Self::spawn(
extractor()
.args([
"--flat-playlist",
"--playlist-end",
"20",
"--extractor-args",
"youtubetab:approximate_date",
"--dump-single-json",
])
.args(channels.iter().map(|channel| format!("{channel}/videos"))),
)
}
pub fn description(id: &str) -> io::Result<Self> {
Self::spawn(&[
Self::spawn(extractor().args([
"--no-playlist",
"--skip-download",
"--dump-single-json",
&format!("https://www.youtube.com/watch?v={id}"),
])
]))
}
fn spawn(args: &[&str]) -> io::Result<Self> {
let mut child = extractor()
.args(args)
fn spawn(command: &mut Command) -> io::Result<Self> {
let mut child = command
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
@@ -300,10 +379,57 @@ mod tests {
.unwrap();
assert_eq!(videos[0].title, "Rust");
assert_eq!(videos[0].channel, None);
assert_eq!(videos[0].channel_id, None);
assert!(
search_results(
r#"{"entries":[{"id":"rQ_J9WH6CGk","title":"Bad","channel_id":"not-a-channel"}]}"#
)
.is_err()
);
assert!(search_results(r#"{"entries":[{"id":"--bad","title":"Bad"}]}"#).is_err());
assert!(search_results("not JSON").is_err());
}
#[test]
fn feeds_merge_newest_first_deduplicate_and_inherit_channel_identity() {
let output = r#"{"channel_id":"UCaYhcUwRBNscFNUKTjgPFiA","channel":"Rust\u001b","entries":[null,{"id":"l-GaaivVJCE","title":"Older","timestamp":100},{"id":"jNQXAC9IVRw","title":"Unknown","timestamp":null}]}
{"channel_id":"UCaYhcUwRBNscFNUKTjgPFiA","channel":"Rust","entries":[{"id":"SLV9AJxnb1U","title":"Newest","timestamp":300},{"id":"l-GaaivVJCE","title":"Newer","timestamp":200},{"id":"rQ_J9WH6CGk","title":"No metadata","channel_id":null}]}"#;
let videos = feed_results(output).unwrap();
assert_eq!(
videos
.iter()
.map(|video| video.id.as_str())
.collect::<Vec<_>>(),
["SLV9AJxnb1U", "l-GaaivVJCE", "jNQXAC9IVRw", "rQ_J9WH6CGk"]
);
assert_eq!(videos[1].title, "Newer");
assert_eq!(videos[1].timestamp, Some(200));
assert_eq!(videos[2].timestamp, None);
assert_eq!(videos[2].channel.as_deref(), Some("Rust"));
assert!(
videos
.iter()
.all(|video| video.channel_id.as_deref() == Some("UCaYhcUwRBNscFNUKTjgPFiA"))
);
let unavailable = feed_results(
r#"{"entries":[null,{"id":"jNQXAC9IVRw","title":"Unknown","channel_id":null}]}"#,
)
.unwrap();
assert_eq!(unavailable[0].channel_id, None);
assert!(feed_results(r#"{"channel_id":"malformed","entries":[]}"#).is_err());
assert!(
feed_results(
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Bad","channel_id":"malformed"}]}"#
)
.is_err()
);
assert!(feed_results(r#"{"entries":[{"id":"bad","title":"Bad"}]}"#).is_err());
assert!(feed_results("").is_err());
assert!(feed_results("{}").is_err());
assert!(feed_results("{not JSON}").is_err());
assert!(feed_results(r#"{"entries":[]}"#).unwrap().is_empty());
}
#[test]
fn full_descriptions_preserve_paragraphs_and_reject_mismatched_videos() {
let metadata = r#"{"id":"jNQXAC9IVRw","description":"First paragraph.\n\n日本語\tsecond paragraph.\u001b\nLast line."}"#;