feat: filter members-only YouTube videos

This commit is contained in:
2026-10-17 17:09:03 +02:00
parent 9743d1993d
commit d9c740c506
5 changed files with 81 additions and 2 deletions
+2
View File
@@ -8875,6 +8875,7 @@ tags.cancel = "z"
uploader_id: None,
timestamp: None,
publication_date: None,
availability: None,
},
Video {
id: "rQ_J9WH6CGk".into(),
@@ -8884,6 +8885,7 @@ tags.cancel = "z"
uploader_id: None,
timestamp: None,
publication_date: None,
availability: None,
},
];
app.views[0].selection.select(Some(0));
+1
View File
@@ -200,6 +200,7 @@ fn video(id: String, title: String, date: Option<String>) -> Video {
uploader_id: None,
timestamp: None,
publication_date: date,
availability: None,
}
}
fn media(metadata: Value) -> io::Result<Media> {
+2
View File
@@ -1292,6 +1292,7 @@ mod tests {
uploader_id: Some("@jawed".into()),
timestamp: Some(1_114_300_000),
publication_date: Some("2005-04-23".into()),
availability: None,
}
}
@@ -1323,6 +1324,7 @@ mod tests {
uploader_id: None,
timestamp: Some(1_114_300_000),
publication_date: Some("2005-04-23".into()),
availability: None,
}
}
+74 -2
View File
@@ -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});