feat: filter members-only YouTube videos
This commit is contained in:
+74
-2
@@ -140,6 +140,22 @@ pub struct Video {
|
||||
/// Cached YYYY-MM-DD label; a leading ~ marks an estimated UTC date.
|
||||
#[serde(default, rename = "upload_date")]
|
||||
pub publication_date: Option<String>,
|
||||
// Listing metadata only; offline collections keep their existing format.
|
||||
#[serde(skip_serializing)]
|
||||
pub availability: Option<Availability>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Deserialize)]
|
||||
pub enum Availability {
|
||||
#[serde(rename = "subscriber_only")]
|
||||
MembersOnly,
|
||||
#[serde(other)]
|
||||
Other,
|
||||
}
|
||||
|
||||
fn listed_video(entry: &serde_json::value::RawValue) -> Option<Video> {
|
||||
let video: Video = serde_json::from_str(entry.get()).ok()?;
|
||||
(!matches!(video.availability, Some(Availability::MembersOnly))).then_some(video)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -160,7 +176,7 @@ pub fn search_results(output: &str) -> Result<Page, String> {
|
||||
.entries
|
||||
.into_iter()
|
||||
.filter_map(|entry| {
|
||||
let mut video = serde_json::from_str(entry.get()).ok()?;
|
||||
let mut video = listed_video(entry)?;
|
||||
validate_video(&mut video).ok()?;
|
||||
Some(video)
|
||||
})
|
||||
@@ -254,7 +270,7 @@ pub fn feed_results(output: &str, requests: &[FeedRequest]) -> Result<Page, Stri
|
||||
}
|
||||
feed_progress.push(FeedProgress { request, count });
|
||||
for entry in playlist.entries {
|
||||
let Ok(mut video) = serde_json::from_str::<Video>(entry.get()) else {
|
||||
let Some(mut video) = listed_video(entry) else {
|
||||
continue;
|
||||
};
|
||||
if video.channel_id.is_none() {
|
||||
@@ -1646,6 +1662,62 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn members_only_rows_are_hidden_without_exhausting_search_or_feed_pages() {
|
||||
let members = serde_json::json!({
|
||||
"id": "jNQXAC9IVRw", "title": "Members only", "availability": "subscriber_only"
|
||||
});
|
||||
let mut entries = vec![members; PAGE_SIZE];
|
||||
let output = serde_json::json!({"entries": entries}).to_string();
|
||||
let search = search_results(&output).unwrap();
|
||||
assert!(search.videos.is_empty());
|
||||
assert!(search.has_more);
|
||||
let mut cursor = FeedCursor::default();
|
||||
let requests = cursor.plan(1).unwrap();
|
||||
let feed = feed_results(&output, &requests).unwrap();
|
||||
assert!(feed.videos.is_empty());
|
||||
assert!(feed.has_more);
|
||||
cursor.accept(&feed.feed_progress).unwrap();
|
||||
assert_eq!(cursor.plan(1).unwrap()[0].start, PAGE_SIZE + 1);
|
||||
|
||||
for (index, availability) in [
|
||||
Some("public"),
|
||||
Some("unlisted"),
|
||||
None,
|
||||
Some("new_availability"),
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
entries[index] = serde_json::json!({
|
||||
"id": format!("{index:011}"),
|
||||
"title": "Join my channel membership",
|
||||
"availability": availability,
|
||||
});
|
||||
}
|
||||
entries[4] = serde_json::json!({"id": "rQ_J9WH6CGk", "title": "No availability"});
|
||||
let output = serde_json::json!({"entries": entries}).to_string();
|
||||
for page in [
|
||||
search_results(&output).unwrap(),
|
||||
feed_results(&output, &cursor.plan(1).unwrap()).unwrap(),
|
||||
] {
|
||||
assert_eq!(
|
||||
page.videos
|
||||
.iter()
|
||||
.map(|video| video.id.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
[
|
||||
"00000000000",
|
||||
"00000000001",
|
||||
"00000000002",
|
||||
"00000000003",
|
||||
"rQ_J9WH6CGk"
|
||||
]
|
||||
);
|
||||
assert!(page.has_more);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discarded_raw_entries_advance_the_cursor_only_after_acceptance() {
|
||||
let bad = serde_json::json!({"id":"invalid", "title":"Bad", "timestamp":-500});
|
||||
|
||||
Reference in New Issue
Block a user