fix: resolve omitted search handles before saving channels

This commit is contained in:
2026-10-11 23:32:00 +02:00
parent b53b57ee17
commit 6563a3d38a
3 changed files with 145 additions and 14 deletions
+88 -14
View File
@@ -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<ViewSource>,
forward_history: Vec<ViewSource>,
category_dialog: Option<CategoryDialog>,
channel_job: Option<(String, String, Job)>,
category_name_input: Option<CategoryNamePrompt>,
browser: Option<Browser>,
temporary_view: Option<ViewSource>,
@@ -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();
}
}
+55
View File
@@ -196,6 +196,25 @@ pub fn full_description(output: &str, expected_id: &str) -> Result<String, Strin
Ok(description)
}
pub fn channel_handle(output: &str, expected_id: &str) -> Result<String, String> {
#[derive(Deserialize)]
struct Channel {
channel_id: String,
uploader_id: Option<String>,
}
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<usize> {
start
.checked_add(PAGE_SIZE - 1)
@@ -251,6 +270,22 @@ impl Job {
)
}
pub fn channel(id: &str) -> io::Result<Self> {
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> {
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."}"#;