feat: browse channel playlists
This commit is contained in:
@@ -178,6 +178,7 @@ These are the default bindings. Overrides in `[keys]` replace an action's bindin
|
|||||||
| `c` | Browse saved categories |
|
| `c` | Browse saved categories |
|
||||||
| `u` | Browse an individual saved channel |
|
| `u` | Browse an individual saved channel |
|
||||||
| `U` (Shift-U) | Open the selected video's 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 the selected channel's tags |
|
||||||
| `S` | Edit tags for all saved channels in the current category |
|
| `S` | Edit tags for all saved channels in the current category |
|
||||||
| `R` (Shift-R) | Rename the current category, including category-scoped collection views |
|
| `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.
|
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)
|
### 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.
|
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.
|
||||||
|
|||||||
+11
-6
@@ -135,6 +135,7 @@ actions! {
|
|||||||
CategoryBrowse, "category.browse", "Browse categories", VIEW, ["c"];
|
CategoryBrowse, "category.browse", "Browse categories", VIEW, ["c"];
|
||||||
ChannelBrowse, "channel.browse", "Browse subscribed channels", VIEW, ["u"];
|
ChannelBrowse, "channel.browse", "Browse subscribed channels", VIEW, ["u"];
|
||||||
VideoChannel, "video.channel", "Open the selected video's channel", 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"];
|
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"];
|
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"];
|
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"];
|
WatchingBrowse, "view.watching.browse", "Browse started, unfinished videos", VIEW, ["C"];
|
||||||
WatchStatusWatching, "video.status.watching", "Mark the selected video as watching", VIEW, ["m"];
|
WatchStatusWatching, "video.status.watching", "Mark the selected video as watching", VIEW, ["m"];
|
||||||
WatchStatusWatched, "video.status.watched", "Mark the selected video as watched", 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"];
|
SelectorNext, "selector.select.next", "Select the next category, channel, or playlist", &[Context::SelectorNormal], ["j", "down"];
|
||||||
SelectorPrevious, "selector.select.previous", "Select the previous category or channel", &[Context::SelectorNormal], ["k", "up"];
|
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"];
|
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"];
|
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"];
|
SelectorEdit, "selector.edit", "Edit the category, channel, playlist, or collection filter", &[Context::SelectorNormal], ["i"];
|
||||||
SelectorOpen, "selector.open", "Open the selected category, channel, or collection", &[Context::SelectorNormal], ["enter"];
|
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"];
|
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"];
|
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"];
|
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"];
|
TagPrevious, "tags.select.previous", "Select the previous category tag", &[Context::TagList], ["k", "up"];
|
||||||
TagEdit, "tags.edit", "Edit the category tag filter", &[Context::TagList], ["i"];
|
TagEdit, "tags.edit", "Edit the category tag filter", &[Context::TagList], ["i"];
|
||||||
@@ -519,6 +520,10 @@ impl Bindings {
|
|||||||
),
|
),
|
||||||
(&[VideoChannel], "Browse the selected video's channel"),
|
(&[VideoChannel], "Browse the selected video's channel"),
|
||||||
(&[ChannelBrowse], "Choose a saved channel"),
|
(&[ChannelBrowse], "Choose a saved channel"),
|
||||||
|
(
|
||||||
|
&[ChannelPlaylists],
|
||||||
|
"Browse the current channel's playlists",
|
||||||
|
),
|
||||||
(&[CategoryBrowse], "Choose a saved category"),
|
(&[CategoryBrowse], "Choose a saved category"),
|
||||||
(&[ChannelTags], "Edit the selected channel's tags"),
|
(&[ChannelTags], "Edit the selected channel's tags"),
|
||||||
(&[CategoryTags], "Edit all channels in a category"),
|
(&[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"),
|
(&[SelectorNext, SelectorPrevious], "Move in NORMAL mode"),
|
||||||
(&[SelectorEdit], "Enter filter INSERT mode"),
|
(&[SelectorEdit], "Enter filter INSERT mode"),
|
||||||
|
|||||||
+101
-15
@@ -83,8 +83,10 @@
|
|||||||
//! Help scrolls with j/k, arrows, Ctrl-J/K and page keys; ? or Esc closes it. Text inputs keep literal ?.
|
//! 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 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 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.
|
//! 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.
|
//! 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.
|
//! 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.
|
//! 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),
|
Category(String),
|
||||||
CategoryGroup(Vec<String>),
|
CategoryGroup(Vec<String>),
|
||||||
Channel(String),
|
Channel(String),
|
||||||
|
Playlist(String),
|
||||||
WatchLater(Option<String>),
|
WatchLater(Option<String>),
|
||||||
SavedVideos(Option<String>),
|
SavedVideos(Option<String>),
|
||||||
Watching,
|
Watching,
|
||||||
@@ -225,7 +228,11 @@ impl ViewSource {
|
|||||||
group: &'a [String],
|
group: &'a [String],
|
||||||
) -> &'a [String] {
|
) -> &'a [String] {
|
||||||
match self {
|
match self {
|
||||||
Self::Search | Self::WatchLater(_) | Self::SavedVideos(_) | Self::Watching => &[],
|
Self::Search
|
||||||
|
| Self::Playlist(_)
|
||||||
|
| Self::WatchLater(_)
|
||||||
|
| Self::SavedVideos(_)
|
||||||
|
| Self::Watching => &[],
|
||||||
Self::Feed => feed,
|
Self::Feed => feed,
|
||||||
Self::Category(name) => &config.categories[name],
|
Self::Category(name) => &config.categories[name],
|
||||||
Self::CategoryGroup(_) => group,
|
Self::CategoryGroup(_) => group,
|
||||||
@@ -581,6 +588,7 @@ fn filter_choices(
|
|||||||
enum BrowserKind {
|
enum BrowserKind {
|
||||||
Categories,
|
Categories,
|
||||||
Channels,
|
Channels,
|
||||||
|
Playlists,
|
||||||
WatchLater,
|
WatchLater,
|
||||||
SavedVideos,
|
SavedVideos,
|
||||||
}
|
}
|
||||||
@@ -595,6 +603,7 @@ struct Browser {
|
|||||||
matches: Vec<usize>,
|
matches: Vec<usize>,
|
||||||
selection: ListState,
|
selection: ListState,
|
||||||
error: Option<String>,
|
error: Option<String>,
|
||||||
|
job: Option<Job>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Browser {
|
impl Browser {
|
||||||
@@ -605,8 +614,10 @@ impl Browser {
|
|||||||
&mut self.matches,
|
&mut self.matches,
|
||||||
&mut self.selection,
|
&mut self.selection,
|
||||||
);
|
);
|
||||||
|
if self.kind != BrowserKind::Playlists {
|
||||||
self.error = None;
|
self.error = None;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn selected_choice(&self) -> Option<&(String, ViewSource)> {
|
fn selected_choice(&self) -> Option<&(String, ViewSource)> {
|
||||||
self.selection
|
self.selection
|
||||||
@@ -645,6 +656,8 @@ struct KeyHints {
|
|||||||
saved_videos: String,
|
saved_videos: String,
|
||||||
watching: String,
|
watching: String,
|
||||||
group_save: String,
|
group_save: String,
|
||||||
|
channel_playlists: String,
|
||||||
|
playlists: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl KeyHints {
|
impl KeyHints {
|
||||||
@@ -872,6 +885,12 @@ impl KeyHints {
|
|||||||
key(Action::InfoHide)
|
key(Action::InfoHide)
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
channel_playlists: format!("· {} playlists ", key(Action::ChannelPlaylists)),
|
||||||
|
playlists: format!(
|
||||||
|
" Playlists · {} filter · {} browse ",
|
||||||
|
key(Action::SelectorEdit),
|
||||||
|
key(Action::SelectorOpen)
|
||||||
|
),
|
||||||
browser: [
|
browser: [
|
||||||
format!(
|
format!(
|
||||||
" Categories · {} filter · {} check · {} browse · {} pin · {} rename ",
|
" Categories · {} filter · {} check · {} browse · {} pin · {} rename ",
|
||||||
@@ -1079,7 +1098,7 @@ impl App {
|
|||||||
.filter(|(_, view)| match &view.source {
|
.filter(|(_, view)| match &view.source {
|
||||||
ViewSource::Search | ViewSource::Feed => true,
|
ViewSource::Search | ViewSource::Feed => true,
|
||||||
ViewSource::Category(name) => self.config.pinned_categories.contains(name),
|
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,
|
| ViewSource::WatchLater(_) | ViewSource::SavedVideos(_) | ViewSource::Watching => false,
|
||||||
})
|
})
|
||||||
.chain(self.temporary_view.as_ref().and_then(|source| {
|
.chain(self.temporary_view.as_ref().and_then(|source| {
|
||||||
@@ -1109,8 +1128,15 @@ impl App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn open_browser(&mut self, kind: BrowserKind) {
|
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();
|
self.close_preview();
|
||||||
let choices: Vec<_> = match kind {
|
let choices: Vec<_> = match kind {
|
||||||
|
BrowserKind::Playlists => Vec::new(),
|
||||||
BrowserKind::Categories => self
|
BrowserKind::Categories => self
|
||||||
.config
|
.config
|
||||||
.categories
|
.categories
|
||||||
@@ -1168,12 +1194,43 @@ impl App {
|
|||||||
choices,
|
choices,
|
||||||
selection: ListState::default(),
|
selection: ListState::default(),
|
||||||
error: None,
|
error: None,
|
||||||
|
job: None,
|
||||||
};
|
};
|
||||||
browser.refilter();
|
browser.refilter();
|
||||||
browser.selection.select(selected);
|
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);
|
self.browser = Some(browser);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn finish_playlists(&mut self, result: Result<String, String>) {
|
||||||
|
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<Action>) {
|
fn browse_key(&mut self, key: KeyEvent, action: Option<Action>) {
|
||||||
let mut browser = self.browser.take().expect("browser is open");
|
let mut browser = self.browser.take().expect("browser is open");
|
||||||
let action = if browser.mode == Mode::Insert {
|
let action = if browser.mode == Mode::Insert {
|
||||||
@@ -1457,6 +1514,7 @@ impl App {
|
|||||||
source,
|
source,
|
||||||
ViewSource::CategoryGroup(_)
|
ViewSource::CategoryGroup(_)
|
||||||
| ViewSource::Channel(_)
|
| ViewSource::Channel(_)
|
||||||
|
| ViewSource::Playlist(_)
|
||||||
| ViewSource::WatchLater(_)
|
| ViewSource::WatchLater(_)
|
||||||
| ViewSource::SavedVideos(_)
|
| ViewSource::SavedVideos(_)
|
||||||
| ViewSource::Watching
|
| ViewSource::Watching
|
||||||
@@ -1515,6 +1573,9 @@ impl App {
|
|||||||
}
|
}
|
||||||
self.status = format!("Searching for {} (page {page})...", view.submitted_query);
|
self.status = format!("Searching for {} (page {page})...", view.submitted_query);
|
||||||
Job::search(&view.submitted_query, view.next_start)
|
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 {
|
} else {
|
||||||
let channels =
|
let channels =
|
||||||
view.source
|
view.source
|
||||||
@@ -1586,7 +1647,7 @@ impl App {
|
|||||||
(view.next_start - 1) / PAGE_SIZE + 1
|
(view.next_start - 1) / PAGE_SIZE + 1
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if view.source != ViewSource::Search
|
if !matches!(view.source, ViewSource::Search | ViewSource::Playlist(_))
|
||||||
&& view
|
&& view
|
||||||
.source
|
.source
|
||||||
.channels(&self.config, &self.feed_channels, &view.group_channels)
|
.channels(&self.config, &self.feed_channels, &view.group_channels)
|
||||||
@@ -1612,14 +1673,15 @@ impl App {
|
|||||||
|
|
||||||
fn finish_page(&mut self, result: Result<String, String>) {
|
fn finish_page(&mut self, result: Result<String, String>) {
|
||||||
self.job = None;
|
self.job = None;
|
||||||
let parsed = result.and_then(|output| {
|
let parsed = result.and_then(|output| match &self.views[self.view_index].source {
|
||||||
if self.views[self.view_index].source == ViewSource::Search {
|
ViewSource::Search => youtube::search_results(&output),
|
||||||
youtube::search_results(&output)
|
ViewSource::Playlist(_) => youtube::playlist_results(&output),
|
||||||
} else {
|
_ => youtube::feed_results(&output, &self.views[self.view_index].feed_requests),
|
||||||
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;
|
let replacing = !self.views[self.view_index].loaded;
|
||||||
match parsed.and_then(|page| self.views[self.view_index].accept_page(page, feed)) {
|
match parsed.and_then(|page| self.views[self.view_index].accept_page(page, feed)) {
|
||||||
Ok(()) if replacing => self.close_preview(),
|
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::Channel(identity) if !self.feed_channels.contains(identity))
|
||||||
|| matches!(&view.source, ViewSource::Watching)
|
|| 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))))
|
|| 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)
|
.then_some(view)
|
||||||
}));
|
}));
|
||||||
@@ -1972,6 +2035,7 @@ impl App {
|
|||||||
if matches!(
|
if matches!(
|
||||||
&self.views[self.view_index].source,
|
&self.views[self.view_index].source,
|
||||||
ViewSource::CategoryGroup(_)
|
ViewSource::CategoryGroup(_)
|
||||||
|
| ViewSource::Playlist(_)
|
||||||
| ViewSource::WatchLater(_)
|
| ViewSource::WatchLater(_)
|
||||||
| ViewSource::SavedVideos(_)
|
| ViewSource::SavedVideos(_)
|
||||||
| ViewSource::Watching
|
| ViewSource::Watching
|
||||||
@@ -2259,6 +2323,7 @@ impl App {
|
|||||||
view.source,
|
view.source,
|
||||||
ViewSource::CategoryGroup(_)
|
ViewSource::CategoryGroup(_)
|
||||||
| ViewSource::Channel(_)
|
| ViewSource::Channel(_)
|
||||||
|
| ViewSource::Playlist(_)
|
||||||
| ViewSource::WatchLater(_)
|
| ViewSource::WatchLater(_)
|
||||||
| ViewSource::SavedVideos(_)
|
| ViewSource::SavedVideos(_)
|
||||||
| ViewSource::Watching
|
| ViewSource::Watching
|
||||||
@@ -2633,6 +2698,14 @@ impl App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn update(&mut self) {
|
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) {
|
if let Some(result) = self.job.as_mut().and_then(Job::poll) {
|
||||||
self.finish_page(result);
|
self.finish_page(result);
|
||||||
}
|
}
|
||||||
@@ -2927,6 +3000,7 @@ impl App {
|
|||||||
}
|
}
|
||||||
Some(Action::CategoryBrowse) => self.open_browser(BrowserKind::Categories),
|
Some(Action::CategoryBrowse) => self.open_browser(BrowserKind::Categories),
|
||||||
Some(Action::ChannelBrowse) => self.open_browser(BrowserKind::Channels),
|
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::VideoChannel) => self.open_video_channel(),
|
||||||
Some(Action::ChannelTags) => self.open_category_picker(),
|
Some(Action::ChannelTags) => self.open_category_picker(),
|
||||||
Some(Action::CategoryTags) => self.open_bulk_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::SavedVideos(_) => self.key_hints.saved_videos.as_str(),
|
||||||
ViewSource::Watching => self.key_hints.watching.as_str(),
|
ViewSource::Watching => self.key_hints.watching.as_str(),
|
||||||
ViewSource::CategoryGroup(_) => self.key_hints.group_save.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),
|
self.job.is_some().then_some(spinner),
|
||||||
@@ -3279,7 +3354,7 @@ impl App {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
self.draw_category_dialog(frame);
|
self.draw_category_dialog(frame);
|
||||||
self.draw_browser(frame);
|
self.draw_browser(frame, elapsed);
|
||||||
self.draw_category_name_input(frame);
|
self.draw_category_name_input(frame);
|
||||||
self.draw_help(frame);
|
self.draw_help(frame);
|
||||||
self.draw_collection_removal(frame);
|
self.draw_collection_removal(frame);
|
||||||
@@ -3331,19 +3406,24 @@ impl App {
|
|||||||
frame.render_widget(block, popup);
|
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 {
|
let Some(browser) = &mut self.browser else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let popup = preview_area(frame.area());
|
let popup = preview_area(frame.area());
|
||||||
frame.render_widget(Clear, popup);
|
frame.render_widget(Clear, popup);
|
||||||
let block = Block::bordered()
|
let block = Block::bordered()
|
||||||
.title(match browser.kind {
|
.title(loading_title(
|
||||||
|
match browser.kind {
|
||||||
BrowserKind::Categories => self.key_hints.browser[0].as_str(),
|
BrowserKind::Categories => self.key_hints.browser[0].as_str(),
|
||||||
BrowserKind::Channels => self.key_hints.browser[1].as_str(),
|
BrowserKind::Channels => self.key_hints.browser[1].as_str(),
|
||||||
BrowserKind::WatchLater => self.key_hints.browser[2].as_str(),
|
BrowserKind::WatchLater => self.key_hints.browser[2].as_str(),
|
||||||
BrowserKind::SavedVideos => self.key_hints.browser[3].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);
|
.border_style(ui::ACCENT);
|
||||||
let [input, list, error] = Layout::vertical([
|
let [input, list, error] = Layout::vertical([
|
||||||
Constraint::Length(3),
|
Constraint::Length(3),
|
||||||
@@ -3375,6 +3455,11 @@ impl App {
|
|||||||
if browser.choices.is_empty() {
|
if browser.choices.is_empty() {
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
Paragraph::new(match browser.kind {
|
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 => {
|
BrowserKind::Categories => {
|
||||||
"No categories yet. Use s on a video to save its channel."
|
"No categories yet. Use s on a video to save its channel."
|
||||||
}
|
}
|
||||||
@@ -3394,6 +3479,7 @@ impl App {
|
|||||||
Paragraph::new(match browser.kind {
|
Paragraph::new(match browser.kind {
|
||||||
BrowserKind::Categories => "No matching categories.",
|
BrowserKind::Categories => "No matching categories.",
|
||||||
BrowserKind::Channels => "No matching channels.",
|
BrowserKind::Channels => "No matching channels.",
|
||||||
|
BrowserKind::Playlists => "No matching playlists.",
|
||||||
BrowserKind::WatchLater | BrowserKind::SavedVideos => {
|
BrowserKind::WatchLater | BrowserKind::SavedVideos => {
|
||||||
"No matching categories or combined collection."
|
"No matching categories or combined collection."
|
||||||
}
|
}
|
||||||
|
|||||||
+127
@@ -132,6 +132,12 @@ pub struct Page {
|
|||||||
pub feed_progress: Vec<FeedProgress>,
|
pub feed_progress: Vec<FeedProgress>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct Playlist {
|
||||||
|
pub id: String,
|
||||||
|
pub title: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Deserialize, Serialize)]
|
#[derive(Clone, Deserialize, Serialize)]
|
||||||
pub struct Video {
|
pub struct Video {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -192,6 +198,97 @@ pub fn search_results(output: &str) -> Result<Page, String> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn channel_playlists(output: &str) -> Result<Vec<Playlist>, 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<Page, String> {
|
||||||
|
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 {
|
fn valid_channel_id(id: &str) -> bool {
|
||||||
id.len() == 24
|
id.len() == 24
|
||||||
&& id.starts_with("UC")
|
&& id.starts_with("UC")
|
||||||
@@ -489,6 +586,36 @@ impl Job {
|
|||||||
]))
|
]))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn channel_playlists(channel: &str) -> io::Result<Self> {
|
||||||
|
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<Self> {
|
||||||
|
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<Self> {
|
pub fn feed(channels: &[String], requests: &[FeedRequest], query: &str) -> io::Result<Self> {
|
||||||
let mut job = Self::spawn_all(feed_commands(channels, requests, query)?)?;
|
let mut job = Self::spawn_all(feed_commands(channels, requests, query)?)?;
|
||||||
if query.is_empty() {
|
if query.is_empty() {
|
||||||
|
|||||||
@@ -3749,6 +3749,78 @@ fn back_history_uses_source_identity_after_config_reorders_or_removes_views() {
|
|||||||
assert!(app.job.is_none());
|
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::<Vec<_>>(),
|
||||||
|
["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]
|
#[test]
|
||||||
fn video_channel_shortcut_reuses_saved_views_and_requires_valid_identity() {
|
fn video_channel_shortcut_reuses_saved_views_and_requires_valid_identity() {
|
||||||
let mut app = app(&["tubular"]);
|
let mut app = app(&["tubular"]);
|
||||||
|
|||||||
+46
-1
@@ -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]
|
#[test]
|
||||||
fn only_section_headers_are_bold_in_the_grouped_overview() {
|
fn only_section_headers_are_bold_in_the_grouped_overview() {
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
@@ -426,6 +467,9 @@ fn collection_removal_defaults_are_scoped_and_inherit_view_actions() {
|
|||||||
(Context::Description, Context::CollectionDescription),
|
(Context::Description, Context::CollectionDescription),
|
||||||
] {
|
] {
|
||||||
for &(key, action) in &bindings.contexts[ordinary as usize] {
|
for &(key, action) in &bindings.contexts[ordinary as usize] {
|
||||||
|
if action == Action::ChannelPlaylists {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
bindings.resolve(collection, KeyEvent::new(key.code, key.modifiers)),
|
bindings.resolve(collection, KeyEvent::new(key.code, key.modifiers)),
|
||||||
Some(action),
|
Some(action),
|
||||||
@@ -434,7 +478,8 @@ fn collection_removal_defaults_are_scoped_and_inherit_view_actions() {
|
|||||||
}
|
}
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
bindings.contexts[collection as usize].len(),
|
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),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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::<Vec<_>>(),
|
||||||
|
[("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::<Vec<_>>(),
|
||||||
|
["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]
|
#[test]
|
||||||
fn feeds_merge_newest_first_deduplicate_and_inherit_channel_identity() {
|
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}]}
|
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}]}
|
||||||
|
|||||||
Reference in New Issue
Block a user