medias 0.3.9

TV media player on bare DRM/KMS: Vulkan UI plane over libmpv video, for local files, UPnP/DLNA servers and YouTube, controlled by HDMI-CEC remote, evdev or terminal
//! Opt-in (MEDIAS_YT_SYNC=1) watch-history reporting to the YouTube TV account.
//! Port of the Kodi plugin's remote history (player_monitor.py): ping
//! videostatsPlaybackUrl at start, videostatsWatchtimeUrl every 10 s with
//! cmt/st/et/state, and once with state=stopped. Network I/O runs on a worker thread.

use std::sync::mpsc::{self, Receiver, Sender};
use std::time::{Duration, Instant};

use super::innertube;

const PERIOD: Duration = Duration::from_secs(10);
/// Fixed query parameters of a watch-history ping (Kodi `watch_history` client).
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 },
}

/// Segment bookkeeping for the playing video; see `update`.
pub struct Reporter {
    tx: Sender<Msg>,
    done: Option<Receiver<()>>, // signalled by the worker after the channel closes
    video: Option<String>,
    active: bool, // Start was sent for `video` (a token was available)
    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 || {
            // (video id, playback url, watchtime url) of the current video.
            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)) = &current {
                            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(),
        }
    }

    /// Call once per main-loop iteration. `video`: id of the playing YouTube
    /// video, None otherwise; `pos`: playback position; `token` is called only
    /// when a report is due. Without a token the video is not reported.
    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(); // Kodi: after a backward seek
        }
        // `paused` is reported once.
        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 {
    /// Report `stopped` for the current video and wait up to 2 s for pending pings.
    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}"),
    }
}

/// Client playback nonce: 16 characters of [A-Za-z0-9-_]. YouTube accepts any value (Kodi, youtube-dl).
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()); // within the period
        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); // position unchanged: paused, reported once
        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, .. })));
    }
}