feat: save readable channel handles with stable URL mappings
This commit is contained in:
+32
-5
@@ -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<String>,
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user