From 20cdd8afb2007dc0a2684c16f247f399529153f0 Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Fri, 9 Oct 2026 23:07:32 +0200 Subject: [PATCH] feat: save readable channel handles with stable URL mappings --- src/config.rs | 169 +++++++++++++++++++++++++++++++++++++++++++------ src/main.rs | 37 +++++++++-- src/youtube.rs | 27 +++++++- 3 files changed, 207 insertions(+), 26 deletions(-) diff --git a/src/config.rs b/src/config.rs index cc1523f..3e6b21d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -35,15 +35,40 @@ fn resolve_path(xdg: Option, home: Option) -> io::Result io::Result { let (_, document) = read_document(path)?; - parse_categories(&document) + parse_categories(&document, &parse_channels(&document)?) } -pub fn add_channel(path: &Path, category: &str, channel: &str) -> io::Result { +pub fn add_channel(path: &Path, category: &str, handle: &str, channel: &str) -> io::Result { let category = category_name(category)?; + let handle = normalize_handle(handle)?; let channel = normalize_channel(channel)?; + if !channel.starts_with("https://www.youtube.com/channel/") { + return Err(invalid( + "Channel mappings require a canonical UC channel ID", + )); + } let (original, mut document) = read_document(path)?; - // Validate every category before touching the document or creating directories. - let mut config = parse_categories(&document)?; + let mut mappings = parse_channels(&document)?; + // Validate the whole file before changing it or creating directories. + parse_categories(&document, &mappings)?; + match mappings.get(&handle) { + Some(existing) if existing != &channel => { + return Err(invalid(format!( + "Handle {handle:?} already maps to another channel" + ))); + } + Some(_) => {} + None => { + if !document.contains_key("channels") { + document.insert("channels", Item::Table(Table::new())); + } + document["channels"] + .as_table_like_mut() + .expect("channel mappings were validated") + .insert(&handle, value(&channel)); + mappings.insert(handle.clone(), channel.clone()); + } + } if !document.contains_key("categories") { document.insert("categories", Item::Table(Table::new())); } @@ -63,12 +88,22 @@ pub fn add_channel(path: &Path, category: &str, channel: &str) -> io::Result io::Result { Ok(format!("https://www.youtube.com/channel/{id}")) } +fn normalize_handle(input: &str) -> io::Result { + let mut address = normalize_channel(input)?; + let prefix = "https://www.youtube.com/"; + if !address[prefix.len()..].starts_with('@') { + return Err(invalid("Expected a YouTube @handle")); + } + address.drain(..prefix.len()); + Ok(address) +} + +fn parse_channels(document: &DocumentMut) -> io::Result> { + let mut mappings = BTreeMap::new(); + let Some(item) = document.get("channels") else { + return Ok(mappings); + }; + let channels = item + .as_table_like() + .ok_or_else(|| invalid("channels must be a TOML table"))?; + for (handle, address) in channels.iter() { + let handle = normalize_handle(handle)?; + let address = normalize_channel( + address + .as_str() + .ok_or_else(|| invalid("Channel mappings must be strings"))?, + )?; + if !address.starts_with("https://www.youtube.com/channel/") { + return Err(invalid( + "Channel mappings require a canonical UC channel ID", + )); + } + if mappings.insert(handle, address).is_some() { + return Err(invalid("Duplicate channel handle after normalization")); + } + } + Ok(mappings) +} + +fn resolve_channel(input: &str, mappings: &BTreeMap) -> io::Result { + let address = normalize_channel(input)?; + match address + .strip_prefix("https://www.youtube.com/") + .and_then(|handle| mappings.get(handle)) + { + Some(mapped) => Ok(mapped.clone()), + None => Ok(address), + } +} + fn read_bytes(path: &Path) -> io::Result>> { match fs::read(path) { Ok(bytes) => Ok(Some(bytes)), @@ -150,7 +233,10 @@ fn read_document(path: &Path) -> io::Result<(Option>, DocumentMut)> { Ok((bytes, document)) } -fn parse_categories(document: &DocumentMut) -> io::Result { +fn parse_categories( + document: &DocumentMut, + mappings: &BTreeMap, +) -> io::Result { let mut config = Config::default(); let Some(item) = document.get("categories") else { return Ok(config); @@ -165,10 +251,11 @@ fn parse_categories(document: &DocumentMut) -> io::Result { .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( + let channel = resolve_channel( channel .as_str() .ok_or_else(|| invalid("Channels must be strings"))?, + mappings, )?; if !normalized.contains(&channel) { normalized.push(channel); @@ -278,22 +365,22 @@ mod tests { fn edits_preserve_manual_config_and_refuse_invalid_or_changed_files() { let directory = TestDirectory::new(); let path = directory.0.join("nested/config.toml"); + let id = "UC4QobU6STFB0P71PMvOGN5A"; assert!(load(&path).unwrap().categories.is_empty()); assert!(!path.parent().unwrap().exists()); - let config = add_channel(&path, " Music ", "@example").unwrap(); + let config = add_channel(&path, " Music ", "@example", id).unwrap(); assert_eq!( config.categories["Music"], - ["https://www.youtube.com/@example"] + [format!("https://www.youtube.com/channel/{id}")] ); 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(); + let config = add_channel(&path, "Music", "https://youtube.com/@example", id).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(); + let config = add_channel(&path, "Music", "@another", id).unwrap(); assert_eq!( config.categories["Music"][1], format!("https://www.youtube.com/channel/{id}") @@ -318,20 +405,66 @@ mod tests { "[categories]\nMusic = [3]\n", "[categories]\nMusic = ['https://example.com/@example']\n", "[categories]\nMusic = []\n' Music ' = []\n", + "channels = 3\n", + "[channels]\n'@example' = 3\n", + "[channels]\n'@example' = 'https://example.com/channel/UCabcdefghijklmnopqrstuv'\n", + "[channels]\n'@example' = '@another'\n", + "[channels]\n'not-a-handle' = 'UCabcdefghijklmnopqrstuv'\n", ] { fs::write(&path, malformed).unwrap(); assert!(load(&path).is_err()); - assert!(add_channel(&path, "Music", "@example").is_err()); + assert!(add_channel(&path, "Music", "@example", id).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!( + add_channel( + &path, + "Music", + "https://www.youtube.com/@example/videos", + id + ) + .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 handles_resolve_to_stable_ids_and_resaving_replaces_opaque_entries() { + let directory = TestDirectory::new(); + let path = directory.0.join("config.toml"); + let id = "UC4QobU6STFB0P71PMvOGN5A"; + let url = format!("https://www.youtube.com/channel/{id}"); + let manual = format!( + "[channels]\n'@old' = '{url}'\n[categories]\nJapanese = [\n '{url}', # keep this entry comment\n '@old',\n]\nOther = ['@old']\nDirect = ['@RustVideos']\n" + ); + fs::write(&path, manual).unwrap(); + let config = load(&path).unwrap(); + assert_eq!(config.categories["Japanese"], std::slice::from_ref(&url)); + assert_eq!( + config.categories["Direct"], + ["https://www.youtube.com/@RustVideos"] + ); + let config = add_channel(&path, "Japanese", "@jawed", id).unwrap(); + assert_eq!(config.categories["Japanese"], std::slice::from_ref(&url)); + assert_eq!(config.categories["Other"], std::slice::from_ref(&url)); + let saved = fs::read_to_string(&path).unwrap(); + let document = saved.parse::().unwrap(); + let entries = document["categories"]["Japanese"].as_array().unwrap(); + assert!(entries.iter().all(|entry| entry.as_str() == Some("@jawed"))); + assert_eq!(document["channels"]["@jawed"].as_str(), Some(url.as_str())); + assert_eq!(document["channels"]["@old"].as_str(), Some(url.as_str())); + assert!(saved.contains("# keep this entry comment")); + add_channel(&path, "Japanese", "@jawed", id).unwrap(); + assert_eq!(fs::read_to_string(&path).unwrap(), saved); + assert!(add_channel(&path, "Japanese", "@jawed", "UCabcdefghijklmnopqrstuv").is_err()); + assert_eq!(fs::read_to_string(&path).unwrap(), saved); + assert_eq!(load(&path).unwrap().categories["Japanese"], [url]); + } + #[test] fn paths_and_channel_addresses_are_strict() { let home = Some(OsString::from("/home/test")); diff --git a/src/main.rs b/src/main.rs index 93027e3..7ace5cd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,6 +13,10 @@ //! curl fetches JPEGs lazily into memory; decoding and image resizing/encoding run off-thread. //! 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. +//! Saving puts the creator's @handle in the category and its canonical URL in `[channels]`. +//! Mapped handles resolve to stable channel IDs; unmapped handles still work directly. +//! Re-saving an existing channel replaces its opaque URL in that category and preserves comments. +//! Missing handle/ID metadata or a conflicting mapping prevents saving rather than guessing. //! 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. @@ -41,7 +45,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; thumbnails also use curl.\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; t toggles thumbnail; 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.\nPreview windows use up to 70% width and 60% height, capped at 96 by 20 cells.\nIn descriptions, 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.\nTHUMBNAIL: t or Esc closes; j/k or arrows load the selected video's image; d switches to description.\nThumbnails are fetched lazily into memory and fit proportionally inside the popup on resize.\nTerminal graphics are detected at startup, with colored half-block rendering when image protocols are unavailable.\nDownloading, JPEG decoding, and image resizing/encoding do not block navigation.\ni, c, view changes, and refresh dismiss the thumbnail; typing t in INSERT or a category name edits 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\"" + after_help = "Requires yt-dlp, FFmpeg, and a player on PATH; thumbnails also use curl.\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; t toggles thumbnail; 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]\nCreators = [\"@jawed\"]\n[channels]\n\"@jawed\" = \"https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A\"\nCategory entries accept @handles, HTTPS YouTube channel URLs, or canonical UC channel IDs.\n[channels] optionally maps handles to canonical channel URLs/IDs; feeds use these stable identities.\nSaving writes a handle plus its mapping; re-saving replaces matching URLs/aliases in that category, preserving comments.\nUnmapped handles work directly. Existing URL entries still load; they are not changed until re-saved.\nSaving requires a handle and canonical ID; unavailable metadata or conflicting mappings are reported without changing the file.\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.\nPreview windows use up to 70% width and 60% height, capped at 96 by 20 cells.\nIn descriptions, 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.\nTHUMBNAIL: t or Esc closes; j/k or arrows load the selected video's image; d switches to description.\nThumbnails are fetched lazily into memory and fit proportionally inside the popup on resize.\nTerminal graphics are detected at startup, with colored half-block rendering when image protocols are unavailable.\nDownloading, JPEG decoding, and image resizing/encoding do not block navigation.\ni, c, view changes, and refresh dismiss the thumbnail; typing t in INSERT or a category name edits 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). @@ -84,6 +88,7 @@ impl View { struct CategoryDialog { channel: String, + handle: String, label: String, selection: ListState, name: Option, @@ -277,8 +282,18 @@ impl App { self.status = "This video has no available channel identity.".into(); return; }; + let Some(handle) = video + .uploader_id + .as_deref() + .filter(|handle| handle.starts_with('@')) + else { + self.status = + "This video's channel handle is unavailable; no channel was saved.".into(); + return; + }; + let handle = handle.to_owned(); let channel = format!("https://www.youtube.com/channel/{id}"); - let label = format!(" {} ", video.channel.as_deref().unwrap_or(id)); + let label = format!(" {} ({handle}) ", video.channel.as_deref().unwrap_or(id)); match config::load(&self.config_path) { Ok(config) => self.apply_config(config), Err(error) => { @@ -292,6 +307,7 @@ impl App { self.description_job = None; self.category_dialog = Some(CategoryDialog { channel, + handle, label, selection, name: None, @@ -366,7 +382,12 @@ impl App { .cloned() }); if let Some(category) = category { - match config::add_channel(&self.config_path, &category, &dialog.channel) { + match config::add_channel( + &self.config_path, + &category, + &dialog.handle, + &dialog.channel, + ) { Ok(config) => { self.apply_config(config); self.switch_view(self.view_index); @@ -929,6 +950,7 @@ mod tests { title: "Me at the zoo".into(), channel: None, channel_id: None, + uploader_id: None, timestamp: None, description: None, }, @@ -937,6 +959,7 @@ mod tests { title: "Rust course".into(), channel: None, channel_id: None, + uploader_id: None, timestamp: None, description: None, }, @@ -973,7 +996,7 @@ mod tests { #[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"}]}"#; + let videos = r#"{"entries":[{"id":"l-GaaivVJCE","title":"Rust video","channel":"Rust","channel_id":"UCaYhcUwRBNscFNUKTjgPFiA","uploader_id":"@RustVideos"},{"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)); @@ -981,6 +1004,10 @@ mod tests { app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)); assert!(app.category_dialog.is_none()); app.views[0].selection.select(Some(0)); + let handle = app.views[0].videos[0].uploader_id.take(); + app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)); + assert!(app.category_dialog.is_none()); + app.views[0].videos[0].uploader_id = handle; 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)); @@ -1050,7 +1077,7 @@ mod tests { assert!(!app.thumbnail_open); app.views[0].videos = youtube::search_results( - r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Me at the zoo","channel_id":"UC4QobU6STFB0P71PMvOGN5A"}]}"#, + r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Me at the zoo","channel_id":"UC4QobU6STFB0P71PMvOGN5A","uploader_id":"@jawed"}]}"#, ).unwrap(); app.views[0].videos[0].description = Some("A cached full description.".into()); app.views[0].selection.select(Some(0)); diff --git a/src/youtube.rs b/src/youtube.rs index baef009..395a753 100644 --- a/src/youtube.rs +++ b/src/youtube.rs @@ -14,6 +14,7 @@ pub struct Video { pub title: String, pub channel: Option, pub channel_id: Option, + pub uploader_id: Option, pub timestamp: Option, // Search descriptions are truncated; only full video metadata populates this cache. #[serde(skip)] @@ -25,6 +26,7 @@ struct SearchResults { entries: Vec>, channel: Option, channel_id: Option, + uploader_id: Option, } pub fn search_results(output: &str) -> Result, String> { @@ -63,6 +65,13 @@ fn validate_video(video: &mut Video) -> Result<(), String> { { return Err("an invalid YouTube channel ID".into()); } + if video + .uploader_id + .as_deref() + .is_some_and(|handle| handle.chars().any(char::is_control)) + { + return Err("an invalid YouTube channel handle".into()); + } video.title.retain(|c| !c.is_control()); if let Some(channel) = &mut video.channel { channel.retain(|c| !c.is_control()); @@ -88,6 +97,9 @@ pub fn feed_results(output: &str) -> Result, String> { if video.channel_id.is_none() { video.channel_id = playlist.channel_id.clone(); } + if video.uploader_id.is_none() { + video.uploader_id = playlist.uploader_id.clone(); + } if video.channel.is_none() { video.channel = playlist.channel.clone(); } @@ -485,26 +497,30 @@ mod tests { #[test] fn search_handles_unavailable_entries_and_rejects_invalid_ids() { let videos = search_results( - r#"{"entries":[null,{"id":"rQ_J9WH6CGk","title":"Rust\u001b","channel":null}]}"#, + r#"{"entries":[null,{"id":"rQ_J9WH6CGk","title":"Rust\u001b","channel":null,"uploader_id":"@RustVideos"}]}"#, ) .unwrap(); assert_eq!(videos[0].title, "Rust"); assert_eq!(videos[0].channel, None); assert_eq!(videos[0].channel_id, None); + assert_eq!(videos[0].uploader_id.as_deref(), Some("@RustVideos")); assert!( search_results( r#"{"entries":[{"id":"rQ_J9WH6CGk","title":"Bad","channel_id":"not-a-channel"}]}"# ) .is_err() ); + assert!(search_results( + r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Unsafe","uploader_id":"@jawed\u001b"}]}"# + ).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 output = r#"{"channel_id":"UCaYhcUwRBNscFNUKTjgPFiA","uploader_id":"@RustVideos","channel":"Rust\u001b","entries":[null,{"id":"l-GaaivVJCE","title":"Older","timestamp":100},{"id":"jNQXAC9IVRw","title":"Unknown","timestamp":null}]} +{"channel_id":"UCaYhcUwRBNscFNUKTjgPFiA","uploader_id":"@RustVideos","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 @@ -522,6 +538,11 @@ mod tests { .iter() .all(|video| video.channel_id.as_deref() == Some("UCaYhcUwRBNscFNUKTjgPFiA")) ); + assert!( + videos + .iter() + .all(|video| video.uploader_id.as_deref() == Some("@RustVideos")) + ); let unavailable = feed_results( r#"{"entries":[null,{"id":"jNQXAC9IVRw","title":"Unknown","channel_id":null}]}"#, )