feat: add categorized channel subscription feeds
This commit is contained in:
Generated
+57
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
+380
@@ -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());
|
||||
}
|
||||
}
|
||||
+520
-61
@@ -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 {
|
||||
return;
|
||||
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;
|
||||
}
|
||||
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}...");
|
||||
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;
|
||||
}
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
+135
-9
@@ -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,27 +23,83 @@ 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() {
|
||||
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("Search returned an invalid YouTube video ID".into());
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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."}"#;
|
||||
|
||||
Reference in New Issue
Block a user