From 656474df5b871417299335d2f01b7aa2e34370d9 Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Fri, 9 Oct 2026 21:33:31 +0200 Subject: [PATCH] feat: add categorized channel subscription feeds --- Cargo.lock | 57 +++++ Cargo.toml | 1 + src/config.rs | 380 ++++++++++++++++++++++++++++++++ src/main.rs | 583 +++++++++++++++++++++++++++++++++++++++++++------ src/youtube.rs | 170 ++++++++++++-- 5 files changed, 1107 insertions(+), 84 deletions(-) create mode 100644 src/config.rs diff --git a/Cargo.lock b/Cargo.lock index abf4387..ccf1c56 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -329,6 +329,16 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + [[package]] name = "indoc" version = "2.0.8" @@ -871,6 +881,43 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" +[[package]] +name = "toml_datetime" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.17+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" + [[package]] name = "tubular" version = "0.1.0" @@ -880,6 +927,7 @@ dependencies = [ "ratatui", "serde", "serde_json", + "toml_edit", ] [[package]] @@ -960,6 +1008,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + [[package]] name = "zmij" version = "1.0.23" diff --git a/Cargo.toml b/Cargo.toml index 4123f10..e010c38 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ clap = { version = "4.6.7", features = ["derive"] } ratatui = { version = "0.30.2", default-features = false, features = ["crossterm", "unstable-rendered-line-info"] } serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.151" +toml_edit = "0.25.17" [target."cfg(unix)".dependencies] libc = "0.2.190" diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..cc1523f --- /dev/null +++ b/src/config.rs @@ -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>, +} + +pub fn config_path() -> io::Result { + resolve_path( + std::env::var_os("XDG_CONFIG_HOME"), + std::env::var_os("HOME"), + ) +} + +fn resolve_path(xdg: Option, home: Option) -> io::Result { + 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 { + let (_, document) = read_document(path)?; + parse_categories(&document) +} + +pub fn add_channel(path: &Path, category: &str, channel: &str) -> io::Result { + 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) -> 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 { + 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>> { + 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>, 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::() + .map_err(|error| invalid(format!("Invalid config TOML: {error}")))?, + None => DocumentMut::new(), + }; + Ok((bytes, document)) +} + +fn parse_categories(document: &DocumentMut) -> io::Result { + 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::().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()); + } +} diff --git a/src/main.rs b/src/main.rs index a0ceb26..a134121 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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