feat: browse channel playlists
This commit is contained in:
@@ -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::<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]
|
||||
fn video_channel_shortcut_reuses_saved_views_and_requires_valid_identity() {
|
||||
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]
|
||||
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
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}]}
|
||||
|
||||
Reference in New Issue
Block a user