use std::sync::mpsc::{self, Receiver, Sender};
use std::time::{Duration, Instant};
use super::innertube;
const PERIOD: Duration = Duration::from_secs(10);
const PARAMS: &str = "&referrer=https%3A%2F%2Faccounts.google.com%2F&ns=yt&el=detailpage&ver=2&fs=0&volume=100&muted=0";
pub fn enabled() -> bool {
std::env::var("MEDIAS_YT_SYNC").is_ok_and(|v| !v.is_empty() && v != "0")
}
enum Msg {
Start { video_id: String, token: String },
Watch { token: String, cmt: f64, st: f64, et: f64, state: &'static str },
}
pub struct Reporter {
tx: Sender<Msg>,
done: Option<Receiver<()>>, video: Option<String>,
active: bool, segment_start: f64,
report_pos: f64,
last_pos: f64,
state: &'static str,
last_report: Instant,
}
impl Reporter {
pub fn new() -> Reporter {
let (tx, rx) = mpsc::channel::<Msg>();
let (done_tx, done) = mpsc::channel();
let spawned = std::thread::Builder::new().name("yt-sync".into()).spawn(move || {
let mut current: Option<(String, String, String)> = None;
for msg in rx {
match msg {
Msg::Start { video_id, token } => {
current = None;
match innertube::tracking_urls(&token, &video_id, &cpn()) {
Ok((playback, watchtime)) => {
report(&video_id, &format!("{playback}{PARAMS}"), &token);
current = Some((video_id, playback, watchtime));
}
Err(error) => eprintln!("yt-sync: {video_id}: {error}"),
}
}
Msg::Watch { token, cmt, st, et, state } => {
if let Some((id, _, watchtime)) = ¤t {
let url = format!("{watchtime}{PARAMS}&cmt={cmt:.3}&st={st:.3}&et={et:.3}&state={state}");
report(id, &url, &token);
}
}
}
}
let _ = done_tx.send(());
});
if let Err(error) = spawned {
eprintln!("yt-sync: cannot start worker: {error}");
}
Reporter {
tx,
done: Some(done),
video: None,
active: false,
segment_start: 0.0,
report_pos: -1.0,
last_pos: 0.0,
state: "playing",
last_report: Instant::now(),
}
}
pub fn update(&mut self, video: Option<&str>, pos: Option<f64>, mut token: impl FnMut() -> Option<String>) {
if self.video.as_deref() != video {
let token = token();
if self.active
&& let Some(token) = token.clone()
{
let end = self.last_pos;
let _ = self.tx.send(Msg::Watch { token, cmt: end, st: end, et: end, state: "stopped" });
}
self.video = video.map(str::to_string);
self.active = false;
if let (Some(id), Some(token)) = (video, token) {
let _ = self.tx.send(Msg::Start { video_id: id.to_string(), token });
self.active = true;
self.segment_start = pos.unwrap_or(0.0);
self.report_pos = -1.0;
self.state = "playing";
self.last_report = Instant::now();
}
}
let Some(pos) = pos else { return };
self.last_pos = pos;
if !self.active || self.last_report.elapsed() < PERIOD {
return;
}
self.last_report = Instant::now();
let last_state = self.state;
self.state = if pos == self.report_pos { "paused" } else { "playing" };
self.report_pos = pos;
let mut segment_end = if self.state == "playing" { pos } else { self.segment_start };
if self.segment_start > segment_end {
segment_end = self.segment_start + PERIOD.as_secs_f64(); }
if self.state == "playing" || last_state == "playing" {
let Some(token) = token() else { return };
let st = self.segment_start;
let _ = self.tx.send(Msg::Watch { token, cmt: pos, st, et: segment_end, state: self.state });
}
self.segment_start = segment_end;
}
}
impl Reporter {
pub fn finish(mut self, token: impl FnMut() -> Option<String>) {
self.update(None, None, token);
let done = self.done.take();
drop(self);
if let Some(done) = done {
let _ = done.recv_timeout(Duration::from_secs(2));
}
}
}
fn report(video_id: &str, url: &str, token: &str) {
let referer = format!("https://www.youtube.com/watch?v={video_id}");
match innertube::ping(url, token, &referer) {
Ok(status) if (200..300).contains(&status) => {}
Ok(status) => eprintln!("yt-sync: {video_id}: HTTP {status}"),
Err(error) => eprintln!("yt-sync: {video_id}: {error}"),
}
}
fn cpn() -> String {
use std::hash::{BuildHasher, Hasher};
const ALPHABET: &[u8; 64] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_";
let mut h = std::collections::hash_map::RandomState::new().build_hasher();
h.write_u128(std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0));
let mut bits = h.finish();
(0..16)
.map(|i| {
if i == 10 {
h.write_u64(bits);
bits = h.finish();
}
let c = ALPHABET[(bits & 63) as usize] as char;
bits >>= 6;
c
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cpn_is_16_url_safe_chars() {
let c = cpn();
assert_eq!(c.len(), 16);
assert!(c.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_'));
}
#[test]
fn reports_start_watch_segments_and_stop() {
let (tx, rx) = mpsc::channel();
let mut r = Reporter {
tx, done: None, video: None, active: false, segment_start: 0.0, report_pos: -1.0, last_pos: 0.0, state: "playing",
last_report: Instant::now(),
};
let tok = || Some("t".to_string());
r.update(Some("vid"), Some(0.0), tok);
assert!(matches!(rx.try_recv(), Ok(Msg::Start { ref video_id, .. }) if video_id == "vid"));
r.update(Some("vid"), Some(5.0), tok);
assert!(rx.try_recv().is_err()); r.last_report -= PERIOD;
r.update(Some("vid"), Some(10.0), tok);
assert!(matches!(rx.try_recv(), Ok(Msg::Watch { cmt: 10.0, st: 0.0, et: 10.0, state: "playing", .. })));
r.last_report -= PERIOD;
r.update(Some("vid"), Some(10.0), tok); assert!(matches!(rx.try_recv(), Ok(Msg::Watch { state: "paused", st: 10.0, et: 10.0, .. })));
r.last_report -= PERIOD;
r.update(Some("vid"), Some(10.0), tok);
assert!(rx.try_recv().is_err());
r.update(None, None, tok);
assert!(matches!(rx.try_recv(), Ok(Msg::Watch { state: "stopped", cmt: 10.0, .. })));
}
}