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();
}
}