use anyhow::{Result, bail};
use serde::Deserialize;
use crate::ids::VideoId;
const API: &str = "https://sponsor.ajay.app/api/skipSegments/";
const PREFIX: usize = 4;
const MAX_BYTES: usize = 1024 * 1024;
const MAX_SEGMENTS: usize = 64;
const MIN_LENGTH: f64 = 0.5;
const MAX_END: f64 = 24.0 * 3600.0;
const LENGTH_SLACK: f64 = 2.0;
const JOIN_WITHIN: f64 = 0.5;
pub const CATEGORIES: [(&str, &str); 9] = [
("sponsor", "Sponsor"),
("selfpromo", "Self-promotion"),
("interaction", "Interaction reminder"),
("intro", "Intro"),
("outro", "Outro"),
("preview", "Preview"),
("hook", "Hook"),
("music_offtopic", "Non-music"),
("filler", "Filler"),
];
pub const NOT_MUSIC: &str = "music_offtopic";
pub fn category(name: &str) -> Option<&'static str> {
CATEGORIES.iter().find(|(c, _)| *c == name).map(|(c, _)| *c)
}
pub fn shown(category: &str) -> &'static str {
CATEGORIES
.iter()
.find(|(c, _)| *c == category)
.map_or("Segment", |(_, name)| name)
}
#[derive(Clone, Debug, PartialEq)]
pub struct Segment {
pub start: f64,
pub end: f64,
pub category: &'static str,
video_length: Option<f64>,
}
impl Segment {
#[cfg(test)]
pub fn new(start: f64, end: f64, category: &'static str) -> Self {
Self {
start,
end,
category,
video_length: None,
}
}
fn fits(&self, length: Option<f64>) -> bool {
match (self.video_length, length) {
(Some(marked), Some(length)) => (marked - length).abs() <= LENGTH_SLACK,
_ => true,
}
}
}
pub(crate) fn hash_prefix(id: &VideoId) -> String {
let digest = ring::digest::digest(&ring::digest::SHA256, id.as_str().as_bytes());
let hex: String = digest.as_ref().iter().map(|b| format!("{b:02x}")).collect();
hex[..PREFIX].to_string()
}
fn url(id: &VideoId, categories: &[&'static str]) -> String {
let mut url = format!("{API}{}?actionType=skip", hash_prefix(id));
for category in categories {
url.push_str("&category=");
url.push_str(category);
}
url
}
pub async fn segments(
client: &reqwest::Client,
id: &VideoId,
categories: &[&'static str],
) -> Result<Vec<Segment>> {
if categories.is_empty() {
return Ok(Vec::new());
}
match fetch(client, &url(id, categories)).await? {
Some(body) => parse(&body, id, categories),
None => Ok(Vec::new()),
}
}
pub(crate) async fn fetch(client: &reqwest::Client, url: &str) -> Result<Option<Vec<u8>>> {
let mut response = client
.get(url)
.send()
.await
.map_err(reqwest::Error::without_url)?;
if response.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(None);
}
if !response.status().is_success() {
bail!("HTTP {}", response.status().as_u16());
}
let mut body = Vec::new();
while let Some(chunk) = response
.chunk()
.await
.map_err(reqwest::Error::without_url)?
{
if body.len() + chunk.len() > MAX_BYTES {
bail!("the answer is too big");
}
body.extend_from_slice(&chunk);
}
Ok(Some(body))
}
#[derive(Deserialize, Default)]
#[serde(default)]
struct Video {
#[serde(rename = "videoID")]
video_id: String,
#[serde(deserialize_with = "crate::ytdlp::lenient")]
segments: Vec<Marked>,
}
#[derive(Deserialize, Default)]
#[serde(default)]
struct Marked {
category: String,
#[serde(rename = "actionType")]
action_type: Option<String>,
segment: Vec<f64>,
#[serde(rename = "videoDuration")]
video_duration: Option<f64>,
}
fn parse(body: &[u8], id: &VideoId, categories: &[&'static str]) -> Result<Vec<Segment>> {
let videos: Vec<serde_json::Value> = serde_json::from_slice(body)?;
let mut segments: Vec<Segment> = videos
.into_iter()
.filter_map(|v| serde_json::from_value::<Video>(v).ok())
.filter(|v| v.video_id == id.as_str())
.flat_map(|v| v.segments)
.filter(|m| m.action_type.as_deref().is_none_or(|a| a == "skip"))
.filter_map(|m| {
let category = category(&m.category).filter(|c| categories.contains(c))?;
let [start, end] = m.segment[..] else {
return None;
};
let sane = start.is_finite()
&& end.is_finite()
&& start >= 0.0
&& end <= MAX_END
&& end - start >= MIN_LENGTH;
sane.then(|| Segment {
start,
end,
category,
video_length: m
.video_duration
.filter(|d| d.is_finite() && *d > 0.0 && *d <= MAX_END),
})
})
.take(MAX_SEGMENTS)
.collect();
segments.sort_by(|a, b| a.start.total_cmp(&b.start));
Ok(segments)
}
#[derive(Debug, Default)]
pub struct Skipper {
segments: Vec<(Segment, bool)>,
}
impl Skipper {
pub fn new(segments: Vec<Segment>) -> Self {
Self {
segments: segments.into_iter().map(|s| (s, false)).collect(),
}
}
pub fn due(&mut self, at: f64, length: Option<f64>) -> Option<Segment> {
let first = self.segments.iter().position(|(s, skipped)| {
!skipped && at >= s.start && at < s.end - MIN_LENGTH / 2.0 && s.fits(length)
})?;
self.segments[first].1 = true;
let mut skip = self.segments[first].0.clone();
while let Some(next) = self.segments.iter().position(|(s, skipped)| {
!skipped && s.start <= skip.end + JOIN_WITHIN && s.end > skip.end && s.fits(length)
}) {
self.segments[next].1 = true;
skip.end = self.segments[next].0.end;
}
Some(skip)
}
pub fn again(&mut self) {
for (_, skipped) in &mut self.segments {
*skipped = false;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn id() -> VideoId {
VideoId::parse("dQw4w9WgXcQ").unwrap()
}
#[test]
fn only_a_hash_prefix_of_the_id_is_sent() {
assert_eq!(hash_prefix(&id()), "5f6b");
let url = url(&id(), &["sponsor", "intro"]);
assert_eq!(
url,
"https://sponsor.ajay.app/api/skipSegments/5f6b?actionType=skip&category=sponsor&category=intro"
);
assert!(!url.contains("dQw4w9WgXcQ"));
assert!(
category("sponsor&x=1").is_none(),
"only names tuitube knows"
);
}
#[test]
fn only_this_videos_skips_in_the_asked_categories_are_kept() {
let body = br#"[
{"videoID":"eXjGWlJOhWg","segments":[{"category":"sponsor","actionType":"skip","segment":[1,20]}]},
{"videoID":"dQw4w9WgXcQ","segments":[
{"category":"outro","actionType":"skip","segment":[200,212.5],"videoDuration":212.9},
{"category":"sponsor","actionType":"skip","segment":[30,60],"UUID":"x","votes":3},
{"category":"sponsor","actionType":"mute","segment":[70,80]},
{"category":"poi_highlight","actionType":"poi","segment":[90,90]},
{"category":"selfpromo","actionType":"skip","segment":[100,110]},
{"category":"intro","segment":[0,"x"]},
{"category":"intro","segment":[0,5,6]},
{"category":"intro","segment":[-5,5]},
{"category":"intro","segment":[10,10.2]},
{"category":"intro","segment":[0,1e300]},
"not a segment",
{"category":"intro","segment":[0,8.5]}
]}
]"#;
let got = parse(body, &id(), &["sponsor", "intro", "outro"]).unwrap();
let times: Vec<_> = got.iter().map(|s| (s.start, s.end, s.category)).collect();
assert_eq!(
times,
[
(0.0, 8.5, "intro"),
(30.0, 60.0, "sponsor"),
(200.0, 212.5, "outro")
]
);
assert_eq!(got[2].video_length, Some(212.9));
assert!(parse(b"{\"error\":1}", &id(), &["sponsor"]).is_err());
assert!(parse(b"[]", &id(), &["sponsor"]).unwrap().is_empty());
}
#[test]
fn touching_segments_are_skipped_as_one_if_each_fits() {
let body = br#"[{"videoID":"dQw4w9WgXcQ","segments":[
{"category":"sponsor","segment":[20,40]},
{"category":"intro","segment":[0,20.3]},
{"category":"sponsor","segment":[25,30]},
{"category":"outro","segment":[50,60]}
]}]"#;
let segments = parse(body, &id(), &["sponsor", "intro", "outro"]).unwrap();
let starts: Vec<_> = segments.iter().map(|s| s.start).collect();
assert_eq!(starts, [0.0, 20.0, 25.0, 50.0], "in order, not joined yet");
let mut skipper = Skipper::new(segments);
let skip = skipper.due(1.0, Some(600.0)).unwrap();
assert_eq!((skip.start, skip.end, skip.category), (0.0, 40.0, "intro"));
assert_eq!(
skipper.due(30.0, Some(600.0)),
None,
"skipped with the first"
);
assert_eq!(skipper.due(50.5, Some(600.0)).unwrap().end, 60.0);
let body = br#"[{"videoID":"dQw4w9WgXcQ","segments":[
{"category":"intro","segment":[0,20],"videoDuration":600},
{"category":"sponsor","segment":[20.3,590],"videoDuration":900}
]}]"#;
let mut skipper = Skipper::new(parse(body, &id(), &["sponsor", "intro"]).unwrap());
assert_eq!(skipper.due(1.0, Some(600.0)).unwrap().end, 20.0);
assert_eq!(skipper.due(25.0, Some(600.0)), None);
}
#[test]
fn a_hostile_answer_is_cut_short() {
let one = r#"{"category":"sponsor","segment":[1,2]}"#;
let many: Vec<String> = (0..1000)
.map(|i| one.replace("[1,2]", &format!("[{},{}]", i * 10, i * 10 + 5)))
.collect();
let body = format!(
r#"[{{"videoID":"dQw4w9WgXcQ","segments":[{}]}}]"#,
many.join(",")
);
let got = parse(body.as_bytes(), &id(), &["sponsor"]).unwrap();
assert_eq!(got.len(), MAX_SEGMENTS);
}
#[test]
fn each_segment_is_skipped_once_when_playback_is_inside_it() {
let segment = |start, end, video_length| Segment {
start,
end,
category: "sponsor",
video_length,
};
let mut skipper = Skipper::new(vec![
segment(10.0, 20.0, None),
segment(30.0, 40.0, Some(100.0)),
]);
assert_eq!(skipper.due(5.0, Some(100.0)), None);
assert_eq!(skipper.due(10.1, Some(100.0)).unwrap().end, 20.0);
assert_eq!(skipper.due(15.0, Some(100.0)), None, "skipped already");
assert_eq!(skipper.due(39.9, Some(100.0)), None, "where a seek lands");
assert_eq!(
skipper.due(35.0, Some(130.0)),
None,
"marked on another cut"
);
assert_eq!(skipper.due(35.0, Some(101.0)).unwrap().start, 30.0);
assert_eq!(skipper.due(35.0, Some(101.0)), None);
}
}
#[cfg(test)]
mod live {
use super::*;
#[tokio::test]
#[ignore = "talks to sponsor.ajay.app"]
async fn live_sponsorblock_answers_for_a_bucket() {
let client = crate::feed::client();
let all: Vec<&'static str> = CATEGORIES.iter().map(|(c, _)| *c).collect();
let id = VideoId::parse("dQw4w9WgXcQ").unwrap();
let response = client.get(url(&id, &all)).send().await.unwrap();
assert!(response.status().is_success(), "{}", response.status());
let videos: Vec<Video> = serde_json::from_slice(&response.bytes().await.unwrap()).unwrap();
let other = videos
.iter()
.find_map(|v| VideoId::parse(&v.video_id).filter(|_| !v.segments.is_empty()))
.expect("a bucket has videos with segments");
let segments = segments(&client, &other, &all).await.unwrap();
assert!(!segments.is_empty(), "{other}");
for s in &segments {
assert!(s.start >= 0.0 && s.end > s.start, "{s:?}");
}
}
}