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
@@ -361,6 +361,8 @@ In normal mode, forward video navigation authorizes prefetch when 20 loaded rows
Malformed individual entries—including invalid video IDs, missing required fields, and wrong field types—are discarded without losing valid videos from the same page. Raw entries, including skipped and duplicate rows, consume the request budget and determine each channel's exhaustion; a full unavailable batch therefore does not hide later results. Invalid JSON, malformed playlist-level metadata, missing/extra playlists, oversized responses, and extractor failures still fail the page without advancing channel offsets. Malformed individual entries—including invalid video IDs, missing required fields, and wrong field types—are discarded without losing valid videos from the same page. Raw entries, including skipped and duplicate rows, consume the request budget and determine each channel's exhaustion; a full unavailable batch therefore does not hide later results. Invalid JSON, malformed playlist-level metadata, missing/extra playlists, oversized responses, and extractor failures still fail the page without advancing channel offsets.
YouTube Search, feeds, category searches, and channel browsing hide videos that yt-dlp marks `subscriber_only` (members-only). Public, unlisted, and unknown-availability videos remain visible; titles or channel membership promotions are not used to infer restrictions. Filtered rows still count toward pagination, and offline saved collections are unchanged.
Feed views sort all loaded videos newest-first using available upload dates, with undated videos last. The budget limits what has been inspected: later loads may discover newer videos and insert them above older rows, while selection and pending description requests stay attached to the same video ID. Publication labels use `YYYY-MM-DD`; `~` marks an estimated UTC date, and `Unknown` means unavailable metadata. Feed views sort all loaded videos newest-first using available upload dates, with undated videos last. The budget limits what has been inspected: later loads may discover newer videos and insert them above older rows, while selection and pending description requests stay attached to the same video ID. Publication labels use `YYYY-MM-DD`; `~` marks an estimated UTC date, and `Unknown` means unavailable metadata.
Pane titles show animated spinners while content loads. Refresh keeps the current rows and selection visible until a valid first page replaces them, including when the new result is empty. Errors retain loaded rows and stop automatic retries; `r` refreshes the current view from page one. Pane titles show animated spinners while content loads. Refresh keeps the current rows and selection visible until a valid first page replaces them, including when the new result is empty. Errors retain loaded rows and stop automatic retries; `r` refreshes the current view from page one.
+2
View File
@@ -8875,6 +8875,7 @@ tags.cancel = "z"
uploader_id: None, uploader_id: None,
timestamp: None, timestamp: None,
publication_date: None, publication_date: None,
availability: None,
}, },
Video { Video {
id: "rQ_J9WH6CGk".into(), id: "rQ_J9WH6CGk".into(),
@@ -8884,6 +8885,7 @@ tags.cancel = "z"
uploader_id: None, uploader_id: None,
timestamp: None, timestamp: None,
publication_date: None, publication_date: None,
availability: None,
}, },
]; ];
app.views[0].selection.select(Some(0)); 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, uploader_id: None,
timestamp: None, timestamp: None,
publication_date: date, publication_date: date,
availability: None,
} }
} }
fn media(metadata: Value) -> io::Result<Media> { fn media(metadata: Value) -> io::Result<Media> {
+2
View File
@@ -1292,6 +1292,7 @@ mod tests {
uploader_id: Some("@jawed".into()), uploader_id: Some("@jawed".into()),
timestamp: Some(1_114_300_000), timestamp: Some(1_114_300_000),
publication_date: Some("2005-04-23".into()), publication_date: Some("2005-04-23".into()),
availability: None,
} }
} }
@@ -1323,6 +1324,7 @@ mod tests {
uploader_id: None, uploader_id: None,
timestamp: Some(1_114_300_000), timestamp: Some(1_114_300_000),
publication_date: Some("2005-04-23".into()), 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. /// Cached YYYY-MM-DD label; a leading ~ marks an estimated UTC date.
#[serde(default, rename = "upload_date")] #[serde(default, rename = "upload_date")]
pub publication_date: Option<String>, 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)] #[derive(Deserialize)]
@@ -160,7 +176,7 @@ pub fn search_results(output: &str) -> Result<Page, String> {
.entries .entries
.into_iter() .into_iter()
.filter_map(|entry| { .filter_map(|entry| {
let mut video = serde_json::from_str(entry.get()).ok()?; let mut video = listed_video(entry)?;
validate_video(&mut video).ok()?; validate_video(&mut video).ok()?;
Some(video) Some(video)
}) })
@@ -254,7 +270,7 @@ pub fn feed_results(output: &str, requests: &[FeedRequest]) -> Result<Page, Stri
} }
feed_progress.push(FeedProgress { request, count }); feed_progress.push(FeedProgress { request, count });
for entry in playlist.entries { 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; continue;
}; };
if video.channel_id.is_none() { 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] #[test]
fn discarded_raw_entries_advance_the_cursor_only_after_acceptance() { fn discarded_raw_entries_advance_the_cursor_only_after_acceptance() {
let bad = serde_json::json!({"id":"invalid", "title":"Bad", "timestamp":-500}); let bad = serde_json::json!({"id":"invalid", "title":"Bad", "timestamp":-500});