From 65200626d2070963ae4c52c110938843fbcefca4 Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Sun, 25 Oct 2026 21:53:30 +0100 Subject: [PATCH] feat: browse channel playlists --- README.md | 7 +++ src/keys.rs | 17 ++++-- src/main.rs | 126 ++++++++++++++++++++++++++++++++++------- src/youtube.rs | 127 ++++++++++++++++++++++++++++++++++++++++++ tests/unit/app.rs | 72 ++++++++++++++++++++++++ tests/unit/keys.rs | 47 +++++++++++++++- tests/unit/youtube.rs | 114 +++++++++++++++++++++++++++++++++++++ 7 files changed, 483 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 3110cc9..231f90c 100644 --- a/README.md +++ b/README.md @@ -178,6 +178,7 @@ These are the default bindings. Overrides in `[keys]` replace an action's bindin | `c` | Browse saved categories | | `u` | Browse an individual saved channel | | `U` (Shift-U) | Open the selected video's channel | +| `L` (Shift-L, in a channel view) | Browse that channel's public playlists | | `s` | Edit the selected channel's tags | | `S` | Edit tags for all saved channels in the current category | | `R` (Shift-R) | Rename the current category, including category-scoped collection views | @@ -261,6 +262,12 @@ Combined feeds are not individual categories: `S`, `R`, and `Alt-w` / `Alt-v` re Press **`U` (Shift-U)** on a video to browse its creator directly, including channels you have not saved. This replaces the current temporary browsing tab without adding a subscription. Channel IDs are preferred; a valid handle is used when an ID is unavailable. Missing channel metadata leaves the current view unchanged. Unsaved channel views survive configuration changes for the session; explicitly removing a saved subscription still removes its channel view. +### Channel playlists (`L`) + +Open a channel with `u` or `U`, then press **`L` (Shift-L)** in normal mode. Its public playlists load into a searchable selector: `j` / `k` or arrows move, `i` filters by title, and Enter opens the selected playlist. In filter mode, arrows or Ctrl-J / Ctrl-K move; Esc returns to normal mode, then closes. Closing cancels an in-flight playlist lookup; close and reopen to retry a failed lookup. Configure the shortcut with `channel.playlists`. + +Playlist videos use the ordinary video table, 60-row pagination, playback checks, previews, and collection controls. They retain playlist order rather than sorting newest-first; repeated video IDs appear once, like other video views. Playlist views share the temporary tab without changing subscriptions. `-` returns to the channel and `+` restores the playlist's cached rows and selection. `r` refreshes the playlist from page one. + ### 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. diff --git a/src/keys.rs b/src/keys.rs index ee04bfd..7a03d70 100644 --- a/src/keys.rs +++ b/src/keys.rs @@ -135,6 +135,7 @@ actions! { CategoryBrowse, "category.browse", "Browse categories", VIEW, ["c"]; ChannelBrowse, "channel.browse", "Browse subscribed channels", VIEW, ["u"]; VideoChannel, "video.channel", "Open the selected video's channel", VIEW, ["U"]; + ChannelPlaylists, "channel.playlists", "Browse the current channel's playlists", &[Context::Normal], ["L"]; ChannelTags, "channel.tags", "Edit the selected YouTube channel's or anime show's category tags", VIEW, ["s"]; CategoryTags, "category.tags", "Edit category tags for all channels in this category", VIEW, ["S"]; CategoryRename, "category.rename", "Rename the current or selected category", &[Context::Normal, Context::CollectionNormal, Context::SelectorNormal, Context::TagList], ["R"]; @@ -151,15 +152,15 @@ actions! { WatchingBrowse, "view.watching.browse", "Browse started, unfinished videos", VIEW, ["C"]; WatchStatusWatching, "video.status.watching", "Mark the selected video as watching", VIEW, ["m"]; WatchStatusWatched, "video.status.watched", "Mark the selected video as watched", VIEW, ["M"]; - SelectorNext, "selector.select.next", "Select the next category or channel", &[Context::SelectorNormal], ["j", "down"]; - SelectorPrevious, "selector.select.previous", "Select the previous category or channel", &[Context::SelectorNormal], ["k", "up"]; + SelectorNext, "selector.select.next", "Select the next category, channel, or playlist", &[Context::SelectorNormal], ["j", "down"]; + SelectorPrevious, "selector.select.previous", "Select the previous category, channel, or playlist", &[Context::SelectorNormal], ["k", "up"]; SelectorInputNext, "selector.input.next", "Select the next match while editing the filter", &[Context::SelectorInput], ["down", "ctrl+j"]; SelectorInputPrevious, "selector.input.previous", "Select the previous match while editing the filter", &[Context::SelectorInput], ["up", "ctrl+k"]; - SelectorEdit, "selector.edit", "Edit the category, channel, or collection filter", &[Context::SelectorNormal], ["i"]; - SelectorOpen, "selector.open", "Open the selected category, channel, or collection", &[Context::SelectorNormal], ["enter"]; + SelectorEdit, "selector.edit", "Edit the category, channel, playlist, or collection filter", &[Context::SelectorNormal], ["i"]; + SelectorOpen, "selector.open", "Open the selected category, channel, playlist, or collection", &[Context::SelectorNormal], ["enter"]; SelectorPin, "selector.pin", "Toggle category pinning (category picker only)", &[Context::SelectorNormal], ["p"]; SelectorToggle, "selector.toggle", "Check a category for combined browsing (category picker only)", &[Context::SelectorNormal], ["space"]; - SelectorClose, "selector.close", "Close the category, channel, or collection picker", &[Context::SelectorNormal], ["escape"]; + SelectorClose, "selector.close", "Close the category, channel, playlist, or collection picker", &[Context::SelectorNormal], ["escape"]; TagNext, "tags.select.next", "Select the next category tag", &[Context::TagList], ["j", "down"]; TagPrevious, "tags.select.previous", "Select the previous category tag", &[Context::TagList], ["k", "up"]; TagEdit, "tags.edit", "Edit the category tag filter", &[Context::TagList], ["i"]; @@ -519,6 +520,10 @@ impl Bindings { ), (&[VideoChannel], "Browse the selected video's channel"), (&[ChannelBrowse], "Choose a saved channel"), + ( + &[ChannelPlaylists], + "Browse the current channel's playlists", + ), (&[CategoryBrowse], "Choose a saved category"), (&[ChannelTags], "Edit the selected channel's tags"), (&[CategoryTags], "Edit all channels in a category"), @@ -615,7 +620,7 @@ impl Bindings { ], ), ( - "CHANNEL / CATEGORY / COLLECTION SELECTOR", + "CHANNEL / CATEGORY / PLAYLIST / COLLECTION SELECTOR", &[ (&[SelectorNext, SelectorPrevious], "Move in NORMAL mode"), (&[SelectorEdit], "Enter filter INSERT mode"), diff --git a/src/main.rs b/src/main.rs index 1d6d043..b871541 100644 --- a/src/main.rs +++ b/src/main.rs @@ -83,8 +83,10 @@ //! Help scrolls with j/k, arrows, Ctrl-J/K and page keys; ? or Esc closes it. Text inputs keep literal ?. //! Normal-mode u opens a searchable saved-channel selector with the category browser's modal controls. //! Normal-mode U opens the selected video's creator directly, without saving a subscription. +//! Normal-mode L in a channel opens its public playlists in the searchable selector. +//! Enter browses playlist videos in provider order with normal pagination, playback and view history. //! Choices include untagged registrations and category-only channels, deduplicated with readable handle labels. -//! Channels, combined feeds and unpinned categories share one temporary tab; hidden views retain history. +//! Channels, playlists, combined feeds and unpinned categories share one temporary tab; hidden views retain history. //! Channel views use the same pagination but extract only their stored channel identity; S stays category-only. //! Unfiltered channel/feed/tag catalogs traverse uploads, then flatten Releases album tracks. //! Release tracks share the raw-row budget; absent Releases tabs are normal, other failures are page errors. @@ -212,6 +214,7 @@ enum ViewSource { Category(String), CategoryGroup(Vec), Channel(String), + Playlist(String), WatchLater(Option), SavedVideos(Option), Watching, @@ -225,7 +228,11 @@ impl ViewSource { group: &'a [String], ) -> &'a [String] { match self { - Self::Search | Self::WatchLater(_) | Self::SavedVideos(_) | Self::Watching => &[], + Self::Search + | Self::Playlist(_) + | Self::WatchLater(_) + | Self::SavedVideos(_) + | Self::Watching => &[], Self::Feed => feed, Self::Category(name) => &config.categories[name], Self::CategoryGroup(_) => group, @@ -581,6 +588,7 @@ fn filter_choices( enum BrowserKind { Categories, Channels, + Playlists, WatchLater, SavedVideos, } @@ -595,6 +603,7 @@ struct Browser { matches: Vec, selection: ListState, error: Option, + job: Option, } impl Browser { @@ -605,7 +614,9 @@ impl Browser { &mut self.matches, &mut self.selection, ); - self.error = None; + if self.kind != BrowserKind::Playlists { + self.error = None; + } } fn selected_choice(&self) -> Option<&(String, ViewSource)> { @@ -645,6 +656,8 @@ struct KeyHints { saved_videos: String, watching: String, group_save: String, + channel_playlists: String, + playlists: String, } impl KeyHints { @@ -872,6 +885,12 @@ impl KeyHints { key(Action::InfoHide) ), ], + channel_playlists: format!("· {} playlists ", key(Action::ChannelPlaylists)), + playlists: format!( + " Playlists · {} filter · {} browse ", + key(Action::SelectorEdit), + key(Action::SelectorOpen) + ), browser: [ format!( " Categories · {} filter · {} check · {} browse · {} pin · {} rename ", @@ -1079,7 +1098,7 @@ impl App { .filter(|(_, view)| match &view.source { ViewSource::Search | ViewSource::Feed => true, ViewSource::Category(name) => self.config.pinned_categories.contains(name), - ViewSource::CategoryGroup(_) | ViewSource::Channel(_) + ViewSource::CategoryGroup(_) | ViewSource::Channel(_) | ViewSource::Playlist(_) | ViewSource::WatchLater(_) | ViewSource::SavedVideos(_) | ViewSource::Watching => false, }) .chain(self.temporary_view.as_ref().and_then(|source| { @@ -1109,8 +1128,15 @@ impl App { } fn open_browser(&mut self, kind: BrowserKind) { + if kind == BrowserKind::Playlists + && !matches!(self.views[self.view_index].source, ViewSource::Channel(_)) + { + self.status = "Open a channel first to browse its playlists.".into(); + return; + } self.close_preview(); let choices: Vec<_> = match kind { + BrowserKind::Playlists => Vec::new(), BrowserKind::Categories => self .config .categories @@ -1168,12 +1194,43 @@ impl App { choices, selection: ListState::default(), error: None, + job: None, }; browser.refilter(); browser.selection.select(selected); + if kind == BrowserKind::Playlists + && let ViewSource::Channel(channel) = &self.views[self.view_index].source + { + match Job::channel_playlists(channel) { + Ok(job) => browser.job = Some(job), + Err(error) => browser.error = Some(error.to_string()), + } + } self.browser = Some(browser); } + fn finish_playlists(&mut self, result: Result) { + let Some(browser) = &mut self.browser else { + return; + }; + browser.job = None; + match result.and_then(|output| youtube::channel_playlists(&output)) { + Ok(playlists) => { + browser.choices = playlists + .into_iter() + .map(|playlist| (playlist.title, ViewSource::Playlist(playlist.id))) + .collect(); + browser.search_names = browser + .choices + .iter() + .map(|(title, _)| title.to_lowercase()) + .collect(); + browser.refilter(); + } + Err(error) => browser.error = Some(error), + } + } + fn browse_key(&mut self, key: KeyEvent, action: Option) { let mut browser = self.browser.take().expect("browser is open"); let action = if browser.mode == Mode::Insert { @@ -1457,6 +1514,7 @@ impl App { source, ViewSource::CategoryGroup(_) | ViewSource::Channel(_) + | ViewSource::Playlist(_) | ViewSource::WatchLater(_) | ViewSource::SavedVideos(_) | ViewSource::Watching @@ -1515,6 +1573,9 @@ impl App { } self.status = format!("Searching for {} (page {page})...", view.submitted_query); Job::search(&view.submitted_query, view.next_start) + } else if let ViewSource::Playlist(id) = &view.source { + self.status = format!("Loading {} page {page}...", view.name); + Job::playlist(id, view.next_start) } else { let channels = view.source @@ -1586,7 +1647,7 @@ impl App { (view.next_start - 1) / PAGE_SIZE + 1 ); } - if view.source != ViewSource::Search + if !matches!(view.source, ViewSource::Search | ViewSource::Playlist(_)) && view .source .channels(&self.config, &self.feed_channels, &view.group_channels) @@ -1612,14 +1673,15 @@ impl App { fn finish_page(&mut self, result: Result) { self.job = None; - let parsed = result.and_then(|output| { - if self.views[self.view_index].source == ViewSource::Search { - youtube::search_results(&output) - } else { - youtube::feed_results(&output, &self.views[self.view_index].feed_requests) - } + let parsed = result.and_then(|output| match &self.views[self.view_index].source { + ViewSource::Search => youtube::search_results(&output), + ViewSource::Playlist(_) => youtube::playlist_results(&output), + _ => youtube::feed_results(&output, &self.views[self.view_index].feed_requests), }); - let feed = self.views[self.view_index].source != ViewSource::Search; + let feed = !matches!( + self.views[self.view_index].source, + ViewSource::Search | ViewSource::Playlist(_) + ); let replacing = !self.views[self.view_index].loaded; match parsed.and_then(|page| self.views[self.view_index].accept_page(page, feed)) { Ok(()) if replacing => self.close_preview(), @@ -1952,6 +2014,7 @@ impl App { } (matches!(&view.source, ViewSource::Channel(identity) if !self.feed_channels.contains(identity)) || matches!(&view.source, ViewSource::Watching) + || matches!(&view.source, ViewSource::Playlist(_)) || matches!(&view.source, ViewSource::WatchLater(category) | ViewSource::SavedVideos(category) if category.as_ref().is_none_or(|name| config.categories.contains_key(name)))) .then_some(view) })); @@ -1972,6 +2035,7 @@ impl App { if matches!( &self.views[self.view_index].source, ViewSource::CategoryGroup(_) + | ViewSource::Playlist(_) | ViewSource::WatchLater(_) | ViewSource::SavedVideos(_) | ViewSource::Watching @@ -2259,6 +2323,7 @@ impl App { view.source, ViewSource::CategoryGroup(_) | ViewSource::Channel(_) + | ViewSource::Playlist(_) | ViewSource::WatchLater(_) | ViewSource::SavedVideos(_) | ViewSource::Watching @@ -2633,6 +2698,14 @@ impl App { } fn update(&mut self) { + if let Some(result) = self + .browser + .as_mut() + .and_then(|browser| browser.job.as_mut()) + .and_then(Job::poll) + { + self.finish_playlists(result); + } if let Some(result) = self.job.as_mut().and_then(Job::poll) { self.finish_page(result); } @@ -2927,6 +3000,7 @@ impl App { } Some(Action::CategoryBrowse) => self.open_browser(BrowserKind::Categories), Some(Action::ChannelBrowse) => self.open_browser(BrowserKind::Channels), + Some(Action::ChannelPlaylists) => self.open_browser(BrowserKind::Playlists), Some(Action::VideoChannel) => self.open_video_channel(), Some(Action::ChannelTags) => self.open_category_picker(), Some(Action::CategoryTags) => self.open_bulk_category_picker(), @@ -3166,6 +3240,7 @@ impl App { ViewSource::SavedVideos(_) => self.key_hints.saved_videos.as_str(), ViewSource::Watching => self.key_hints.watching.as_str(), ViewSource::CategoryGroup(_) => self.key_hints.group_save.as_str(), + ViewSource::Channel(_) => self.key_hints.channel_playlists.as_str(), _ => "", }, self.job.is_some().then_some(spinner), @@ -3279,7 +3354,7 @@ impl App { ); } self.draw_category_dialog(frame); - self.draw_browser(frame); + self.draw_browser(frame, elapsed); self.draw_category_name_input(frame); self.draw_help(frame); self.draw_collection_removal(frame); @@ -3331,19 +3406,24 @@ impl App { frame.render_widget(block, popup); } - fn draw_browser(&mut self, frame: &mut Frame) { + fn draw_browser(&mut self, frame: &mut Frame, elapsed: Duration) { let Some(browser) = &mut self.browser else { return; }; let popup = preview_area(frame.area()); frame.render_widget(Clear, popup); let block = Block::bordered() - .title(match browser.kind { - BrowserKind::Categories => self.key_hints.browser[0].as_str(), - BrowserKind::Channels => self.key_hints.browser[1].as_str(), - BrowserKind::WatchLater => self.key_hints.browser[2].as_str(), - BrowserKind::SavedVideos => self.key_hints.browser[3].as_str(), - }) + .title(loading_title( + match browser.kind { + BrowserKind::Categories => self.key_hints.browser[0].as_str(), + BrowserKind::Channels => self.key_hints.browser[1].as_str(), + BrowserKind::WatchLater => self.key_hints.browser[2].as_str(), + BrowserKind::SavedVideos => self.key_hints.browser[3].as_str(), + BrowserKind::Playlists => self.key_hints.playlists.as_str(), + }, + "", + browser.job.is_some().then_some(ui::spinner(elapsed)), + )) .border_style(ui::ACCENT); let [input, list, error] = Layout::vertical([ Constraint::Length(3), @@ -3375,6 +3455,11 @@ impl App { if browser.choices.is_empty() { frame.render_widget( Paragraph::new(match browser.kind { + BrowserKind::Playlists if browser.job.is_some() => "Loading playlists...", + BrowserKind::Playlists if browser.error.is_some() => { + "Could not load playlists. Close and reopen to retry." + } + BrowserKind::Playlists => "This channel has no public playlists.", BrowserKind::Categories => { "No categories yet. Use s on a video to save its channel." } @@ -3394,6 +3479,7 @@ impl App { Paragraph::new(match browser.kind { BrowserKind::Categories => "No matching categories.", BrowserKind::Channels => "No matching channels.", + BrowserKind::Playlists => "No matching playlists.", BrowserKind::WatchLater | BrowserKind::SavedVideos => { "No matching categories or combined collection." } diff --git a/src/youtube.rs b/src/youtube.rs index e66767c..f7d56e7 100644 --- a/src/youtube.rs +++ b/src/youtube.rs @@ -132,6 +132,12 @@ pub struct Page { pub feed_progress: Vec, } +#[derive(Deserialize)] +pub struct Playlist { + pub id: String, + pub title: String, +} + #[derive(Clone, Deserialize, Serialize)] pub struct Video { pub id: String, @@ -192,6 +198,97 @@ pub fn search_results(output: &str) -> Result { }) } +pub fn channel_playlists(output: &str) -> Result, String> { + let results: SearchResults = serde_json::from_str(output) + .map_err(|error| format!("Invalid channel playlists response: {error}"))?; + let mut ids = BTreeSet::new(); + Ok(results + .entries + .into_iter() + .filter_map(|entry| { + let mut playlist: Playlist = serde_json::from_str(entry.get()).ok()?; + if !valid_playlist_id(&playlist.id) { + return None; + } + playlist.title.retain(|c| !c.is_control()); + if playlist.title.trim().is_empty() || !ids.insert(playlist.id.clone()) { + return None; + } + Some(playlist) + }) + .collect()) +} + +pub fn playlist_results(output: &str) -> Result { + let results: SearchResults = serde_json::from_str(output) + .map_err(|error| format!("Invalid playlist response: {error}"))?; + if results + .channel_id + .as_deref() + .is_some_and(|id| !valid_channel_id(id)) + || results + .uploader_id + .as_deref() + .is_some_and(|handle| handle.chars().any(char::is_control)) + { + return Err("Playlist returned invalid YouTube creator metadata".into()); + } + let has_more = results.entries.len() >= PAGE_SIZE; + let videos = results + .entries + .into_iter() + .filter_map(|entry| { + let mut video = listed_video(entry)?; + // A playlist owner is not necessarily the creator of each video. + let same_creator = video + .channel_id + .as_ref() + .is_none_or(|id| Some(id) == results.channel_id.as_ref()) + && video + .uploader_id + .as_ref() + .is_none_or(|id| Some(id) == results.uploader_id.as_ref()); + if same_creator && (video.channel_id.is_some() || video.uploader_id.is_some()) { + if video.channel_id.is_none() { + video.channel_id = results.channel_id.clone(); + } + if video.uploader_id.is_none() { + video.uploader_id = results.uploader_id.clone(); + } + if video.channel.is_none() { + video.channel = results.channel.clone(); + } + } + validate_video(&mut video).ok()?; + Some(video) + }) + .collect(); + Ok(Page { + videos, + has_more, + feed_progress: Vec::new(), + }) +} + +fn valid_playlist_id(id: &str) -> bool { + if !id + .bytes() + .all(|c| c.is_ascii_alphanumeric() || c == b'-' || c == b'_') + { + return false; + } + // Match yt-dlp's YouTube playlist families, including music releases. + matches!(id, "RDMM" | "WL" | "LL" | "LM") + || [ + "PL", "LL", "EC", "UU", "FL", "RD", "UL", "TL", "PU", "OLAK5uy_", + ] + .iter() + .any(|prefix| { + id.strip_prefix(*prefix) + .is_some_and(|suffix| suffix.len() >= 10) + }) +} + fn valid_channel_id(id: &str) -> bool { id.len() == 24 && id.starts_with("UC") @@ -489,6 +586,36 @@ impl Job { ])) } + pub fn channel_playlists(channel: &str) -> io::Result { + let channel = crate::config::normalize_channel(channel)?; + Self::spawn(extractor().args([ + "--flat-playlist", + "--dump-single-json", + &format!("{channel}/playlists"), + ])) + } + + pub fn playlist(id: &str, start: usize) -> io::Result { + if !valid_playlist_id(id) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Invalid YouTube playlist ID", + )); + } + let end = page_end(start)?; + Self::spawn(extractor().args([ + "--flat-playlist", + "--playlist-start", + &start.to_string(), + "--playlist-end", + &end.to_string(), + "--extractor-args", + "youtubetab:approximate_date", + "--dump-single-json", + &format!("https://www.youtube.com/playlist?list={id}"), + ])) + } + pub fn feed(channels: &[String], requests: &[FeedRequest], query: &str) -> io::Result { let mut job = Self::spawn_all(feed_commands(channels, requests, query)?)?; if query.is_empty() { diff --git a/tests/unit/app.rs b/tests/unit/app.rs index 5453bd6..43adc11 100644 --- a/tests/unit/app.rs +++ b/tests/unit/app.rs @@ -3749,6 +3749,78 @@ fn back_history_uses_source_identity_after_config_reorders_or_removes_views() { assert!(app.job.is_none()); } +#[test] +fn channel_playlists_filter_open_in_provider_order_and_retain_history_and_config() { + let mut app = app(&["tubular"]); + let press = |app: &mut App, code| { + app.key(KeyEvent::new(code, KeyModifiers::NONE)); + }; + press(&mut app, KeyCode::Char('L')); + assert!(app.browser.is_none()); + let channel = ViewSource::Channel("https://www.youtube.com/@RustVideos".into()); + let mut view = View::new("@RustVideos".into(), channel.clone()); + view.loaded = true; + app.views.push(view); + app.switch_view(app.views.len() - 1); + press(&mut app, KeyCode::Char('L')); + assert_eq!(app.browser.as_ref().unwrap().kind, BrowserKind::Playlists); + app.finish_playlists(Ok(r#"{"entries":[{"id":"PLJ5MFiMfElFA","title":"Conference"},{"id":"PLJ5MFiMfElFB","title":"Tutorials"}]}"#.into())); + press(&mut app, KeyCode::Char('i')); + app.paste("tutor"); + assert_eq!( + app.browser.as_ref().unwrap().selected_choice().unwrap().0, + "Tutorials" + ); + press(&mut app, KeyCode::Enter); + assert!(app.browser.is_none()); + let playlist = ViewSource::Playlist("PLJ5MFiMfElFB".into()); + assert_eq!(app.views[app.view_index].source, playlist); + app.finish_page(Ok(r#"{"entries":[{"id":"jNQXAC9IVRw","title":"First","timestamp":100},{"id":"rQ_J9WH6CGk","title":"Second","timestamp":200}]}"#.into())); + let view = &app.views[app.view_index]; + assert_eq!( + view.videos + .iter() + .map(|video| video.title.as_str()) + .collect::>(), + ["First", "Second"] + ); + assert!(!view.has_more); + press(&mut app, KeyCode::Char('j')); + press(&mut app, KeyCode::Char(' ')); + assert_eq!( + app.views[app.view_index].playback_videos()[0].id, + "rQ_J9WH6CGk" + ); + press(&mut app, KeyCode::Char('-')); + assert_eq!(app.views[app.view_index].source, channel); + press(&mut app, KeyCode::Char('+')); + assert_eq!(app.views[app.view_index].source, playlist); + assert_eq!(app.views[app.view_index].selection.selected(), Some(1)); + app.apply_config(config::Config { + categories: [("Unrelated".into(), Vec::new())].into(), + ..Default::default() + }); + assert_eq!(app.views[app.view_index].source, playlist); + assert_eq!(app.temporary_view, Some(playlist)); + assert_eq!(app.views[app.view_index].selection.selected(), Some(1)); + assert!(app.config.subscriptions().is_empty()); + press(&mut app, KeyCode::Char('-')); + press(&mut app, KeyCode::Char('L')); + app.finish_playlists(Err("Extractor failed".into())); + assert_eq!( + app.browser.as_ref().unwrap().error.as_deref(), + Some("Extractor failed") + ); + press(&mut app, KeyCode::Esc); + assert!(app.browser.is_none()); + press(&mut app, KeyCode::Char('L')); + app.finish_playlists(Ok(r#"{"entries":[]}"#.into())); + assert!(app.browser.as_ref().unwrap().choices.is_empty()); + assert!(app.browser.as_ref().unwrap().error.is_none()); + press(&mut app, KeyCode::Esc); + assert_eq!(app.views[app.view_index].source, channel); +} + #[test] fn video_channel_shortcut_reuses_saved_views_and_requires_valid_identity() { let mut app = app(&["tubular"]); diff --git a/tests/unit/keys.rs b/tests/unit/keys.rs index f745352..0b47868 100644 --- a/tests/unit/keys.rs +++ b/tests/unit/keys.rs @@ -120,6 +120,47 @@ fn provider_choice_is_configurable_and_normal_only() { } } +#[test] +fn channel_playlists_are_configurable_and_normal_only() { + let defaults = Bindings::default(); + let overrides = configured("[keys.channel]\nplaylists = 'alt+l'\n").unwrap(); + for &context in ALL { + let expected = (context == Context::Normal).then_some(Action::ChannelPlaylists); + for (code, modifiers) in [ + (KeyCode::Char('L'), KeyModifiers::NONE), + (KeyCode::Char('L'), KeyModifiers::SHIFT), + (KeyCode::Char('l'), KeyModifiers::SHIFT), + ] { + assert_eq!(press(&defaults, context, code, modifiers), expected); + assert_ne!( + press(&overrides, context, code, modifiers), + Some(Action::ChannelPlaylists), + ); + } + assert_eq!( + press(&defaults, context, KeyCode::Char('l'), KeyModifiers::NONE), + None, + ); + assert_eq!( + press(&overrides, context, KeyCode::Char('l'), KeyModifiers::ALT), + expected, + ); + } + assert!(default_config().contains("channel.playlists = [\"L\"]")); + assert!(defaults.help_text().to_string().lines().any(|line| { + line.trim_start().starts_with("L ") + && line.contains("Browse the current channel's playlists") + })); + assert!(overrides.help_text().to_string().lines().any(|line| { + line.trim_start().starts_with("Alt+l ") + && line.contains("Browse the current channel's playlists") + })); + let collision = configured("[keys.channel]\nplaylists = 'u'\n") + .unwrap_err() + .to_string(); + assert!(collision.contains("channel.playlists") && collision.contains("channel.browse")); +} + #[test] fn only_section_headers_are_bold_in_the_grouped_overview() { use ratatui::{ @@ -426,6 +467,9 @@ fn collection_removal_defaults_are_scoped_and_inherit_view_actions() { (Context::Description, Context::CollectionDescription), ] { for &(key, action) in &bindings.contexts[ordinary as usize] { + if action == Action::ChannelPlaylists { + continue; + } assert_eq!( bindings.resolve(collection, KeyEvent::new(key.code, key.modifiers)), Some(action), @@ -434,7 +478,8 @@ fn collection_removal_defaults_are_scoped_and_inherit_view_actions() { } assert_eq!( bindings.contexts[collection as usize].len(), - bindings.contexts[ordinary as usize].len() + 2, + bindings.contexts[ordinary as usize].len() + 2 + - usize::from(ordinary == Context::Normal), ); } } diff --git a/tests/unit/youtube.rs b/tests/unit/youtube.rs index 3adb69e..285ea37 100644 --- a/tests/unit/youtube.rs +++ b/tests/unit/youtube.rs @@ -787,6 +787,120 @@ fn search_sanitizes_display_metadata_and_rejects_invalid_envelopes() { } } +#[test] +fn channel_playlist_listing_uses_flat_ids_sanitizes_and_deduplicates() { + // yt-dlp's /playlists tab returns URL rows, not nested playlist contents. + let playlists = channel_playlists( + r#"{"_type":"playlist","entries":[ + {"_type":"url","ie_key":"YoutubeTab","id":"PLJ5MFiMfElFA","title":"Rust\u001b"}, + null,42,{},{"id":"PLJ5MFiMfElFA","title":"Duplicate"}, + {"id":"PLabcdefghij&list=other","title":"Unsafe"}, + {"id":"rQ_J9WH6CGk","title":"Video"}, + {"id":"PLabcdefghij","title":null}, + {"id":"PLabcdefghij","title":"\u001b\n"}, + {"id":"OLAK5uy_abcdefghij","title":"Release"} + ]}"#, + ) + .unwrap(); + assert_eq!( + playlists + .iter() + .map(|playlist| (playlist.id.as_str(), playlist.title.as_str())) + .collect::>(), + [("PLJ5MFiMfElFA", "Rust"), ("OLAK5uy_abcdefghij", "Release")] + ); + assert!(channel_playlists(r#"{"entries":[]}"#).unwrap().is_empty()); + for output in ["not JSON", "{}", r#"{"entries":null}"#, r#"{"entries":{}}"#] { + assert!(channel_playlists(output).is_err()); + assert!(playlist_results(output).is_err()); + } +} + +#[test] +fn playlist_ids_are_safe_and_support_music_and_legacy_families() { + for id in [ + "PLJ5MFiMfElFA", + "OLAK5uy_abcdefghij-_", + "UUabcdefghij", + "RDabcdefghij", + "RDMM", + "WL", + "LL", + "LM", + ] { + assert!(valid_playlist_id(id), "{id}"); + assert!(matches!( + Job::playlist(id, 0), + Err(error) if error.kind() == io::ErrorKind::InvalidInput + )); + } + for id in [ + "", + "--bad", + "PLshort", + "PLabcdefghij&x=1", + "PLabcdefghij#x", + "PLabcdefghij/", + ] { + assert!(!valid_playlist_id(id), "{id}"); + assert!(matches!( + Job::playlist(id, 1), + Err(error) if error.kind() == io::ErrorKind::InvalidInput + )); + } + assert!(Job::channel_playlists("https://example.com/@channel").is_err()); +} + +#[test] +fn playlists_preserve_order_repeats_and_creator_identity_with_raw_pagination() { + let mut entries = vec![serde_json::Value::Null; PAGE_SIZE]; + entries[0] = serde_json::json!({ + "id":"l-GaaivVJCE","title":"First\u{1b}","timestamp":100 + }); + entries[1] = serde_json::json!({ + "id":"SLV9AJxnb1U","title":"Second","timestamp":300, + "channel":"Another creator","channel_id":"UC4QobU6STFB0P71PMvOGN5A" + }); + entries[2] = serde_json::json!({"id":"bad","title":"Invalid"}); + entries[3] = serde_json::json!({"id":"jNQXAC9IVRw","title":null}); + entries[4] = serde_json::json!({ + "id":"jNQXAC9IVRw","title":"Members only","availability":"subscriber_only" + }); + entries[5] = entries[0].clone(); + entries[6] = serde_json::json!({ + "id":"rQ_J9WH6CGk","title":"Owner upload","channel_id":"UCaYhcUwRBNscFNUKTjgPFiA" + }); + let output = serde_json::json!({ + "channel_id":"UCaYhcUwRBNscFNUKTjgPFiA","uploader_id":"@RustVideos", + "channel":"Rust\u{1b}","entries":entries + }); + let page = playlist_results(&output.to_string()).unwrap(); + assert!(page.has_more); + assert!(page.feed_progress.is_empty()); + assert_eq!( + page.videos + .iter() + .map(|video| video.id.as_str()) + .collect::>(), + ["l-GaaivVJCE", "SLV9AJxnb1U", "l-GaaivVJCE", "rQ_J9WH6CGk"] + ); + assert_eq!(page.videos[0].title, "First"); + assert_eq!(page.videos[0].channel, None); + assert_eq!(page.videos[0].channel_id, None); + assert_eq!(page.videos[0].uploader_id, None); + assert_eq!(page.videos[3].channel.as_deref(), Some("Rust")); + assert_eq!(page.videos[3].uploader_id.as_deref(), Some("@RustVideos")); + assert_eq!(page.videos[1].channel.as_deref(), Some("Another creator")); + assert_eq!( + page.videos[1].channel_id.as_deref(), + Some("UC4QobU6STFB0P71PMvOGN5A") + ); + assert_eq!(page.videos[1].uploader_id, None); + assert!(!playlist_results(r#"{"entries":[]}"#).unwrap().has_more); + assert!(playlist_results(r#"{"channel_id":"invalid","entries":[]}"#).is_err()); + assert!(playlist_results(r#"{"uploader_id":"@bad\u001b","entries":[]}"#).is_err()); +} + #[test] fn feeds_merge_newest_first_deduplicate_and_inherit_channel_identity() { let output = r#"{"channel_id":"UCaYhcUwRBNscFNUKTjgPFiA","uploader_id":"@RustVideos","channel":"Rust\u001b","entries":[null,{"id":"l-GaaivVJCE","title":"Older","timestamp":100},{"id":"jNQXAC9IVRw","title":"Unknown","timestamp":null}]}