fix: resolve omitted search handles before saving channels
This commit is contained in:
@@ -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)
|
### 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.
|
- **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.
|
- **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.
|
- 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.
|
||||||
|
|||||||
+88
-14
@@ -39,7 +39,8 @@
|
|||||||
//! Checked tags save the creator's @handle; `[channels]` retains its canonical URL.
|
//! Checked tags save the creator's @handle; `[channels]` retains its canonical URL.
|
||||||
//! Mapped handles resolve to stable channel IDs; unmapped handles still work directly.
|
//! 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.
|
//! 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.
|
//! 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.
|
//! 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.
|
//! Enter/Esc leaves filter editing without saving; hidden staged memberships remain unchanged.
|
||||||
@@ -881,6 +882,7 @@ struct App {
|
|||||||
view_history: Vec<ViewSource>,
|
view_history: Vec<ViewSource>,
|
||||||
forward_history: Vec<ViewSource>,
|
forward_history: Vec<ViewSource>,
|
||||||
category_dialog: Option<CategoryDialog>,
|
category_dialog: Option<CategoryDialog>,
|
||||||
|
channel_job: Option<(String, String, Job)>,
|
||||||
category_name_input: Option<CategoryNamePrompt>,
|
category_name_input: Option<CategoryNamePrompt>,
|
||||||
browser: Option<Browser>,
|
browser: Option<Browser>,
|
||||||
temporary_view: Option<ViewSource>,
|
temporary_view: Option<ViewSource>,
|
||||||
@@ -953,6 +955,7 @@ impl App {
|
|||||||
view_history: Vec::new(),
|
view_history: Vec::new(),
|
||||||
forward_history: Vec::new(),
|
forward_history: Vec::new(),
|
||||||
category_dialog: None,
|
category_dialog: None,
|
||||||
|
channel_job: None,
|
||||||
category_name_input: None,
|
category_name_input: None,
|
||||||
browser: None,
|
browser: None,
|
||||||
temporary_view: None,
|
temporary_view: None,
|
||||||
@@ -2118,18 +2121,35 @@ impl App {
|
|||||||
self.status = "This video has no available channel identity.".into();
|
self.status = "This video has no available channel identity.".into();
|
||||||
return;
|
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
|
.uploader_id
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.filter(|handle| handle.starts_with('@'))
|
.filter(|handle| handle.starts_with('@'))
|
||||||
else {
|
.map(str::to_owned)
|
||||||
self.status =
|
.or_else(|| {
|
||||||
"This video's channel handle is unavailable; no channel was saved.".into();
|
self.config
|
||||||
return;
|
.channels
|
||||||
};
|
.iter()
|
||||||
let handle = handle.to_owned();
|
.find_map(|(handle, identity)| (identity == &channel).then(|| handle.clone()))
|
||||||
let channel = format!("https://www.youtube.com/channel/{id}");
|
});
|
||||||
let label = format!(" {} ({handle}) ", video.channel.as_deref().unwrap_or(id));
|
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) {
|
match config::load(&self.config_path) {
|
||||||
Ok(config) => self.apply_config(config),
|
Ok(config) => self.apply_config(config),
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
@@ -2417,6 +2437,24 @@ impl App {
|
|||||||
if let Some(result) = self.job.as_mut().and_then(Job::poll) {
|
if let Some(result) = self.job.as_mut().and_then(Job::poll) {
|
||||||
self.finish_page(result);
|
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
|
if let Some(result) = self
|
||||||
.description_job
|
.description_job
|
||||||
.as_mut()
|
.as_mut()
|
||||||
@@ -2464,6 +2502,8 @@ impl App {
|
|||||||
Context::Help
|
Context::Help
|
||||||
} else if self.category_name_input.is_some() {
|
} else if self.category_name_input.is_some() {
|
||||||
Context::CategoryNameInput
|
Context::CategoryNameInput
|
||||||
|
} else if self.channel_job.is_some() {
|
||||||
|
Context::TagList
|
||||||
} else if let Some(dialog) = &self.category_dialog {
|
} else if let Some(dialog) = &self.category_dialog {
|
||||||
if dialog.name.is_some() {
|
if dialog.name.is_some() {
|
||||||
Context::TagInput
|
Context::TagInput
|
||||||
@@ -2535,6 +2575,13 @@ impl App {
|
|||||||
if action == Some(Action::Interrupt) {
|
if action == Some(Action::Interrupt) {
|
||||||
return true;
|
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() {
|
if self.collection_removal.is_some() {
|
||||||
match key.code {
|
match key.code {
|
||||||
KeyCode::Char('y') if key.modifiers.is_empty() => {
|
KeyCode::Char('y') if key.modifiers.is_empty() => {
|
||||||
@@ -7832,10 +7879,6 @@ tags.cancel = "z"
|
|||||||
app.key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE));
|
app.key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE));
|
||||||
assert!(app.category_dialog.is_none());
|
assert!(app.category_dialog.is_none());
|
||||||
app.views[0].selection.select(Some(0));
|
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('s'), KeyModifiers::NONE));
|
||||||
app.key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE));
|
app.key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE));
|
||||||
app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||||
@@ -8451,4 +8494,35 @@ tags.cancel = "z"
|
|||||||
.collect();
|
.collect();
|
||||||
assert!(tail.contains("099"), "{tail}");
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -196,6 +196,25 @@ pub fn full_description(output: &str, expected_id: &str) -> Result<String, Strin
|
|||||||
Ok(description)
|
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> {
|
fn page_end(start: usize) -> io::Result<usize> {
|
||||||
start
|
start
|
||||||
.checked_add(PAGE_SIZE - 1)
|
.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> {
|
pub fn description(id: &str) -> io::Result<Self> {
|
||||||
Self::spawn(extractor().args([
|
Self::spawn(extractor().args([
|
||||||
"--no-playlist",
|
"--no-playlist",
|
||||||
@@ -839,6 +874,26 @@ mod tests {
|
|||||||
assert_eq!(exhausted.videos.len(), 2);
|
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]
|
#[test]
|
||||||
fn full_descriptions_preserve_paragraphs_and_reject_mismatched_videos() {
|
fn full_descriptions_preserve_paragraphs_and_reject_mismatched_videos() {
|
||||||
let metadata = r#"{"id":"jNQXAC9IVRw","description":"First paragraph.\n\n日本語\tsecond paragraph.\u001b\nLast line."}"#;
|
let metadata = r#"{"id":"jNQXAC9IVRw","description":"First paragraph.\n\n日本語\tsecond paragraph.\u001b\nLast line."}"#;
|
||||||
|
|||||||
Reference in New Issue
Block a user