feat: browse channel playlists

This commit is contained in:
2026-10-25 21:53:30 +01:00
parent 6cc4e18f72
commit 65200626d2
7 changed files with 483 additions and 27 deletions
+114
View File
@@ -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}]}