diff --git a/README.md b/README.md index d2bb95e..ee15e1d 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,8 @@ Press **`U` (Shift-U)** on a video to browse its creator directly, including cha ### Channel tags (`s`, or `S` for bulk editing) +When search results omit a channel handle, `s` reuses its saved mapping or looks up the handle from the channel's canonical ID before opening the editor. Esc cancels a pending lookup without saving. Failed lookups or mismatched channel metadata leave the config unchanged. + - **Normal:** `j` / `k` or arrow keys navigate; Space toggles a tag; `i` edits the filter; `R` renames the selected tag. - **Insert:** type a case-insensitive substring to filter live; arrow keys or Ctrl-J / Ctrl-K navigate matches. Enter or Esc returns to normal mode without saving or clearing the filter. - Filtering only hides rows: checked and unchecked staged memberships outside the filter are retained. With no matches, navigation and toggling do nothing. Reopening the dialog clears its filter. diff --git a/src/main.rs b/src/main.rs index a030d04..b68523b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -39,7 +39,8 @@ //! Checked tags save the creator's @handle; `[channels]` retains its canonical URL. //! Mapped handles resolve to stable channel IDs; unmapped handles still work directly. //! Deselecting removes the channel from that tag, preserving other creators and empty tags. -//! Missing handle/ID metadata or a conflicting mapping prevents saving rather than guessing. +//! Missing search handles use a saved mapping or an asynchronous canonical-channel lookup. +//! Lookup cancellation, missing ID/handle metadata, or conflicting mappings never save a guessed identity. //! Normal-mode s edits memberships: j/k moves, Space toggles, Enter commits, Esc cancels. //! i filters tags case-insensitively; arrows or Ctrl-J/K move through matches while typing. //! Enter/Esc leaves filter editing without saving; hidden staged memberships remain unchanged. @@ -881,6 +882,7 @@ struct App { view_history: Vec, forward_history: Vec, category_dialog: Option, + channel_job: Option<(String, String, Job)>, category_name_input: Option, browser: Option, temporary_view: Option, @@ -953,6 +955,7 @@ impl App { view_history: Vec::new(), forward_history: Vec::new(), category_dialog: None, + channel_job: None, category_name_input: None, browser: None, temporary_view: None, @@ -2118,18 +2121,35 @@ impl App { self.status = "This video has no available channel identity.".into(); return; }; - let Some(handle) = video + let id = id.to_owned(); + let name = video.channel.as_deref().unwrap_or(&id).to_owned(); + let channel = format!("https://www.youtube.com/channel/{id}"); + let 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!(" {} ({handle}) ", video.channel.as_deref().unwrap_or(id)); + .map(str::to_owned) + .or_else(|| { + self.config + .channels + .iter() + .find_map(|(handle, identity)| (identity == &channel).then(|| handle.clone())) + }); + if let Some(handle) = handle { + self.show_channel_tags(channel, handle, name); + } else { + match Job::channel(&id) { + Ok(job) => { + self.channel_job = Some((id, name, job)); + self.status = "Looking up channel handle...".into(); + } + Err(error) => self.status = error.to_string(), + } + } + } + + fn show_channel_tags(&mut self, channel: String, handle: String, name: String) { + let label = format!(" {name} ({handle}) "); match config::load(&self.config_path) { Ok(config) => self.apply_config(config), Err(error) => { @@ -2417,6 +2437,24 @@ impl App { if let Some(result) = self.job.as_mut().and_then(Job::poll) { self.finish_page(result); } + if let Some(result) = self.channel_job.as_mut().and_then(|(_, _, job)| job.poll()) { + let (id, name, _) = self.channel_job.take().expect("channel lookup is active"); + match result.and_then(|output| youtube::channel_handle(&output, &id)) { + Ok(handle) => { + for video in self.views.iter_mut().flat_map(|view| &mut view.videos) { + if video.channel_id.as_deref() == Some(&id) { + video.uploader_id = Some(handle.clone()); + } + } + self.show_channel_tags( + format!("https://www.youtube.com/channel/{id}"), + handle, + name, + ); + } + Err(error) => self.status = error, + } + } if let Some(result) = self .description_job .as_mut() @@ -2464,6 +2502,8 @@ impl App { Context::Help } else if self.category_name_input.is_some() { Context::CategoryNameInput + } else if self.channel_job.is_some() { + Context::TagList } else if let Some(dialog) = &self.category_dialog { if dialog.name.is_some() { Context::TagInput @@ -2535,6 +2575,13 @@ impl App { if action == Some(Action::Interrupt) { return true; } + if self.channel_job.is_some() { + if action == Some(Action::TagCancel) { + self.channel_job = None; + self.status = "Channel lookup cancelled.".into(); + } + return false; + } if self.collection_removal.is_some() { match key.code { KeyCode::Char('y') if key.modifiers.is_empty() => { @@ -7832,10 +7879,6 @@ tags.cancel = "z" app.key(KeyEvent::new(KeyCode::Char('s'), 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('s'), KeyModifiers::NONE)); - assert!(app.category_dialog.is_none()); - app.views[0].videos[0].uploader_id = handle; app.key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE)); app.key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)); app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); @@ -8451,4 +8494,35 @@ tags.cancel = "z" .collect(); assert!(tail.contains("099"), "{tail}"); } + + #[test] + fn search_without_a_handle_can_tag_a_registered_channel() { + let path = std::env::temp_dir().join(format!( + "tubular-missing-handle-{}.toml", + std::process::id() + )); + let original = + "[channels]\n'@ch.1676' = 'UChAQQ9at2MDlXjvQX3vL3Yw'\n[categories]\nJapanese = []\n"; + std::fs::write(&path, original).unwrap(); + let mut app = App::new( + Options::parse_from(["tubular"]), + path.clone(), + config::load(&path).unwrap(), + ); + app.views[0].videos = youtube::search_results( + r#"{"entries":[{"id":"nwxkg84mADI","title":"Hokkaido Vlog","channel":"敷嶋てとらch.","channel_id":"UChAQQ9at2MDlXjvQX3vL3Yw","uploader_id":null}]}"#, + ).unwrap().videos; + app.views[0].loaded = true; + app.views[0].selection.select(Some(0)); + app.key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE)); + assert!(app.category_dialog.is_some(), "{}", app.status); + app.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); + app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + let saved = config::load(&path).unwrap(); + assert_eq!( + saved.categories["Japanese"], + ["https://www.youtube.com/channel/UChAQQ9at2MDlXjvQX3vL3Yw"] + ); + std::fs::remove_file(path).unwrap(); + } } diff --git a/src/youtube.rs b/src/youtube.rs index cc1ea46..8d307f2 100644 --- a/src/youtube.rs +++ b/src/youtube.rs @@ -196,6 +196,25 @@ pub fn full_description(output: &str, expected_id: &str) -> Result Result { + #[derive(Deserialize)] + struct Channel { + channel_id: String, + uploader_id: Option, + } + let channel: Channel = serde_json::from_str(output) + .map_err(|error| format!("Invalid channel metadata: {error}"))?; + if channel.channel_id != expected_id { + return Err("Channel metadata did not match the requested channel".into()); + } + let handle = channel + .uploader_id + .filter(|handle| handle.starts_with('@')) + .ok_or_else(|| "This channel has no available handle; no channel was saved.".to_owned())?; + crate::config::normalize_channel(&handle).map_err(|error| error.to_string())?; + Ok(handle) +} + fn page_end(start: usize) -> io::Result { start .checked_add(PAGE_SIZE - 1) @@ -251,6 +270,22 @@ impl Job { ) } + pub fn channel(id: &str) -> io::Result { + if !valid_channel_id(id) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Invalid YouTube channel ID", + )); + } + Self::spawn(extractor().args([ + "--flat-playlist", + "--playlist-end", + "1", + "--dump-single-json", + &format!("https://www.youtube.com/channel/{id}/videos"), + ])) + } + pub fn description(id: &str) -> io::Result { Self::spawn(extractor().args([ "--no-playlist", @@ -839,6 +874,26 @@ mod tests { assert_eq!(exhausted.videos.len(), 2); } + #[test] + fn channel_handles_require_the_requested_identity_and_a_valid_handle() { + let id = "UChAQQ9at2MDlXjvQX3vL3Yw"; + for handle in ["@ch.1676", "@敷嶋てとら"] { + let metadata = serde_json::json!({"channel_id": id, "uploader_id": handle}).to_string(); + assert_eq!(channel_handle(&metadata, id).unwrap(), handle); + assert!(channel_handle(&metadata, "UCaYhcUwRBNscFNUKTjgPFiA").is_err()); + } + for handle in [ + None, + Some("ch.1676"), + Some("@bad/handle"), + Some("@bad\u{1b}"), + ] { + let metadata = serde_json::json!({"channel_id": id, "uploader_id": handle}).to_string(); + assert!(channel_handle(&metadata, id).is_err()); + } + assert!(channel_handle("not JSON", id).is_err()); + } + #[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."}"#;