use std::collections::HashMap;
use anyhow::Result;
use serde::Deserialize;
use crate::ids::VideoId;
use crate::sponsorblock;
use crate::video;
const API: &str = "https://sponsor.ajay.app/api/branding/";
const AT_ONCE: usize = 4;
const MAX_ASKS: usize = 3000;
const MAX_FAILURES: usize = 3;
pub async fn title(client: &reqwest::Client, id: &VideoId) -> Result<Option<String>> {
let url = format!("{API}{}", sponsorblock::hash_prefix(id));
match sponsorblock::fetch(client, &url).await? {
Some(body) => parse(&body, id),
None => Ok(None),
}
}
#[derive(Deserialize, Default)]
#[serde(default)]
struct Branding {
#[serde(deserialize_with = "crate::ytdlp::lenient")]
titles: Vec<Title>,
}
#[derive(Deserialize)]
struct Title {
title: String,
#[serde(default)]
original: bool,
#[serde(default)]
votes: f64,
#[serde(default)]
locked: bool,
}
fn parse(body: &[u8], id: &VideoId) -> Result<Option<String>> {
let mut videos: HashMap<String, serde_json::Value> = serde_json::from_slice(body)?;
let Some(branding) = videos.remove(id.as_str()) else {
return Ok(None);
};
let branding: Branding = serde_json::from_value(branding).unwrap_or_default();
let best = branding
.titles
.into_iter()
.filter(|t| t.votes.is_finite())
.reduce(|best, t| {
if (t.locked, t.votes) > (best.locked, best.votes) {
t
} else {
best
}
});
Ok(best
.filter(|t| !t.original && (t.locked || t.votes >= 0.0))
.map(|t| tidy(&t.title))
.filter(|t| !t.is_empty()))
}
fn tidy(title: &str) -> String {
let title = title.replace('‹', "<");
let mut kept = String::with_capacity(title.len());
let mut after_space = true;
let mut chars = title.chars().peekable();
while let Some(c) = chars.next() {
let marks_word =
c == '>' && after_space && chars.peek().is_some_and(|n| !n.is_whitespace());
if !marks_word {
kept.push(c);
}
after_space = c.is_whitespace();
}
video::one_line(&kept, video::MAX_TITLE)
}
#[derive(Debug, Default)]
pub struct Titles {
known: HashMap<VideoId, Option<String>>,
wanted: Vec<VideoId>,
asking: usize,
asked: usize,
failures: usize,
}
impl Titles {
pub fn get(&self, id: &VideoId) -> Option<&str> {
self.known.get(id)?.as_deref()
}
pub fn want(&mut self, id: &VideoId) {
if !self.known.contains_key(id) && !self.wanted.contains(id) {
self.wanted.push(id.clone());
}
}
pub fn take_wanted(&mut self) -> Vec<VideoId> {
let wanted = std::mem::take(&mut self.wanted);
if self.failures >= MAX_FAILURES {
return Vec::new();
}
let room = AT_ONCE
.saturating_sub(self.asking)
.min(MAX_ASKS.saturating_sub(self.asked));
let ids: Vec<VideoId> = wanted.into_iter().take(room).collect();
for id in &ids {
self.known.insert(id.clone(), None);
}
self.asking += ids.len();
self.asked += ids.len();
ids
}
pub fn answered(&mut self, id: VideoId, result: &Result<Option<String>>) -> bool {
self.asking = self.asking.saturating_sub(1);
match result {
Ok(title) => {
self.failures = 0;
self.known.insert(id, title.clone());
false
}
Err(_) => {
self.known.remove(&id);
self.failures += 1;
self.failures == MAX_FAILURES
}
}
}
pub fn retry(&mut self) {
self.failures = 0;
}
}
#[cfg(test)]
mod tests {
use super::*;
fn id(s: &str) -> VideoId {
VideoId::parse(s).unwrap()
}
#[test]
fn the_best_title_of_the_video_asked_about_is_kept() {
let body = br#"{
"dQw4w9WgXcQ": {"titles": [
{"title": "Second", "original": false, "votes": 5, "locked": false},
{"title": "Rick Astley - >Never gonna give you up (a > b)", "original": false, "votes": 1, "locked": true},
"not a title"
], "thumbnails": []},
"q4FQa7N4kBg": {"titles": [{"title": "Someone else's", "votes": 9, "original": false, "locked": true}]}
}"#;
assert_eq!(
parse(body, &id("dQw4w9WgXcQ")).unwrap().as_deref(),
Some("Rick Astley - Never gonna give you up (a > b)"),
"locked first; the formatter's > dropped"
);
assert_eq!(
parse(body, &id("aaaaaaaaaaa")).unwrap(),
None,
"not in the bucket"
);
}
#[test]
fn youtubes_own_or_a_voted_down_title_isnt_shown() {
let one = |title: &str| {
let body = format!(r#"{{"dQw4w9WgXcQ": {{"titles": [{title}]}}}}"#);
parse(body.as_bytes(), &id("dQw4w9WgXcQ")).unwrap()
};
assert_eq!(
one(r#"{"title": "Original", "original": true, "votes": 3, "locked": false}"#),
None,
"the crowd picked YouTube's"
);
assert_eq!(
one(r#"{"title": "Bad", "original": false, "votes": -1, "locked": false}"#),
None
);
assert_eq!(
one(r#"{"title": "New", "original": false, "votes": 0, "locked": false}"#).as_deref(),
Some("New"),
"one submitter's, as the extension shows"
);
assert_eq!(
one("{\"title\": \"\\u202eevil\\u0007 ‹b› line\\nbreak\", \"votes\": 0}").as_deref(),
Some("evil <b› line break"),
"cleaned as YouTube's titles are"
);
assert_eq!(one(r#"{"title": " ", "votes": 2}"#), None);
assert!(parse(b"[]", &id("dQw4w9WgXcQ")).is_err());
let body = br#"{"dQw4w9WgXcQ": {"titles": {"no": "list"}}}"#;
assert_eq!(parse(body, &id("dQw4w9WgXcQ")).unwrap(), None);
}
#[test]
fn titles_are_asked_a_few_at_a_time_once_each() {
let mut titles = Titles::default();
let ids: Vec<VideoId> = (0..6).map(|i| id(&format!("video{i:06}"))).collect();
for id in &ids {
titles.want(id);
titles.want(id);
}
let first = titles.take_wanted();
assert_eq!(first, ids[..AT_ONCE], "as many as go at once");
assert!(titles.take_wanted().is_empty(), "the rest wait their turn");
assert!(!titles.answered(ids[0].clone(), &Ok(Some("Better".into()))));
assert!(!titles.answered(ids[1].clone(), &Ok(None)));
assert_eq!(titles.get(&ids[0]), Some("Better"));
assert_eq!(titles.get(&ids[1]), None);
for id in &ids {
titles.want(id);
}
assert_eq!(titles.take_wanted(), ids[4..], "asked about once a session");
}
#[test]
fn a_server_that_keeps_failing_is_left_alone_until_turned_on_again() {
let mut titles = Titles::default();
let ids: Vec<VideoId> = (0..4).map(|i| id(&format!("video{i:06}"))).collect();
for id in &ids {
titles.want(id);
}
let asked = titles.take_wanted();
let failed = |titles: &mut Titles, id: &VideoId| {
titles.answered(id.clone(), &Err(anyhow::anyhow!("HTTP 502")))
};
assert!(!failed(&mut titles, &asked[0]));
assert!(!failed(&mut titles, &asked[1]));
assert!(failed(&mut titles, &asked[2]), "said once");
assert!(!failed(&mut titles, &asked[3]));
titles.want(&ids[0]);
assert!(titles.take_wanted().is_empty());
titles.retry();
titles.want(&ids[0]);
assert_eq!(
titles.take_wanted(),
[ids[0].clone()],
"a failed one is asked again"
);
}
}
#[cfg(test)]
mod live {
use super::*;
#[tokio::test]
#[ignore = "talks to sponsor.ajay.app"]
async fn live_dearrow_answers_for_a_bucket() {
let client = crate::feed::client_without_redirects();
let id = VideoId::parse("dQw4w9WgXcQ").unwrap();
let title = title(&client, &id).await.unwrap();
assert!(title.is_some_and(|t| t.contains("Rick Astley")));
}
}