// Small third-party HTTP/JSON clients that used to live in Kotlin // (`net/RydClient.kt` + `net/SponsorBlockClient.kt`). Ported to Rust as // part of the "all backend logic -> Rust" migration: these are network + // parse, which belongs behind the FFI, not in the Android UI layer. Kotlin // keeps thin shims that map these Records onto its existing domain types. // // Both endpoints are best-effort enrichment: any failure (transport, // non-2xx, oversized body, malformed JSON) collapses to "no data" // (None / empty Vec), exactly as the Kotlin originals did via runCatching. // We never surface a StrawcoreError here — a dead RYD/SB host must not // break video playback. use std::sync::OnceLock; use std::time::Duration; use reqwest::Client; use serde::Deserialize; use sha2::{Digest, Sha256}; /// Matches the UA the Kotlin clients sent (some of these public APIs /// rate-limit or shape responses by UA). Kept byte-identical to the old /// `STRAW_USER_AGENT` in `net/Http.kt`. const STRAW_USER_AGENT: &str = "Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 (KHTML, like Gecko) \ Chrome/124.0.0.0 Mobile Safari/537.36 Straw/1.0"; /// Body caps carried over 1:1 from `net/Http.kt` (RYD_MAX_BYTES / /// SB_MAX_BYTES). A hostile or compromised host must not be able to /// stream a GB-scale body into memory. const RYD_MAX_BYTES: usize = 256 * 1024; const SB_MAX_BYTES: usize = 1024 * 1024; const RYD_VOTES_URL: &str = "https://returnyoutubedislikeapi.com/votes"; const SB_SKIP_BASE: &str = "https://sponsor.ajay.app/api/skipSegments/"; /// Shared reqwest client for the small enrichment endpoints. Mirrors the /// old OkHttp config (`connectTimeout 15s`, `readTimeout 30s`). One pool /// for RYD + SB — they're low-volume and the old code shared one OkHttp /// client too. static CLIENT: OnceLock = OnceLock::new(); fn client() -> Option<&'static Client> { if let Some(c) = CLIENT.get() { return Some(c); } let built = Client::builder() .connect_timeout(Duration::from_secs(15)) .timeout(Duration::from_secs(30)) .user_agent(STRAW_USER_AGENT) .redirect(reqwest::redirect::Policy::limited(3)) .build() .ok()?; Some(CLIENT.get_or_init(|| built)) } /// Drain a reqwest Response into a String, returning None if the body /// exceeds `cap`. Shared with the RSS feed path (`feed.rs`). Per-chunk /// guard first (HTTP allows multi-GiB chunks; hyper may have already /// allocated one before we see it), then the running total. /// /// The cap bounds gzip-DECOMPRESSED bytes (reqwest is built with `gzip`), /// not wire bytes — same as the old OkHttp `cappedString`, and fine for the /// few-hundred-byte-to-low-KB RYD / SponsorBlock / RSS payloads. A hostile /// host could force up-to-`cap` decompression, but never enough to OOM. pub(crate) async fn read_capped_body(resp: reqwest::Response, cap: usize) -> Option { use futures::StreamExt; let mut total = 0usize; let mut buf: Vec = Vec::with_capacity(32 * 1024); let mut stream = resp.bytes_stream(); while let Some(chunk_result) = stream.next().await { let chunk = chunk_result.ok()?; if chunk.len() > cap { log::warn!("strawcore::net single chunk {} exceeds cap; aborting", chunk.len()); return None; } total = total.saturating_add(chunk.len()); if total > cap { log::warn!("strawcore::net body exceeded {cap} bytes; aborting"); return None; } buf.extend_from_slice(&chunk); } // Lossy decode: a strict from_utf8 would drop the whole response on a // single mojibake byte; serde_json tolerates U+FFFD in string values. Some(String::from_utf8_lossy(&buf).into_owned()) } // --------------------------------------------------------------------------- // Return YouTube Dislike // --------------------------------------------------------------------------- /// Vote counts from the Return-YouTube-Dislike API. Kotlin maps this onto /// its own `net.RydVotes` data class (the detail-screen overlay model). /// /// We carry only the fields the UI actually renders (likes/dislikes). RYD's /// own `rating` (a 0-5 restatement of the like/dislike ratio) and `viewCount` /// (RYD's stale aggregate — the real YouTube count from `videoDetails` is what /// the detail screen already shows) are intentionally NOT surfaced: rendering /// either would be redundant or actively misleading, so they don't cross the /// FFI (audit #2 L-9 — slimmed the dead carry-through). #[derive(Debug, Clone, uniffi::Record)] pub struct RydVotes { pub id: String, pub likes: i64, pub dislikes: i64, } #[derive(Deserialize)] struct RydVotesWire { // `id` is required (no default) to match the Kotlin original, whose // non-nullable `id: String` made a response missing `id` fail to parse // and return null. likes/dislikes keep defaults — the Kotlin data class // defaulted those. The JSON's `rating`/`viewCount` are simply not read // (serde ignores unknown fields here) — see RydVotes above. id: String, #[serde(default)] likes: i64, #[serde(default)] dislikes: i64, } /// GET https://returnyoutubedislikeapi.com/votes?videoId= /// Returns None on any failure (transport / non-2xx / oversize / bad JSON), /// matching the Kotlin client's runCatching-to-null contract. #[uniffi::export(async_runtime = "tokio")] pub async fn fetch_ryd_votes(video_id: String) -> Option { log::info!("strawcore::ryd fetch id_len={}", video_id.len()); let client = client()?; let resp = client .get(RYD_VOTES_URL) .query(&[("videoId", video_id.as_str())]) .header("Accept", "application/json") .send() .await .ok()?; if !resp.status().is_success() { return None; } let body = read_capped_body(resp, RYD_MAX_BYTES).await?; let wire: RydVotesWire = serde_json::from_str(&body) .map_err(|e| log::warn!("strawcore::ryd json decode failed: {e}")) .ok()?; Some(RydVotes { id: wire.id, likes: wire.likes, dislikes: wire.dislikes, }) } // --------------------------------------------------------------------------- // SponsorBlock // --------------------------------------------------------------------------- /// One SponsorBlock segment. Kotlin maps this onto its `net.SbSegment` /// (reconstructing the `[start, end]` list its serializer expects). #[derive(Debug, Clone, uniffi::Record)] pub struct SponsorSegment { pub category: String, pub start_sec: f64, pub end_sec: f64, pub uuid: Option, pub action_type: Option, } #[derive(Deserialize)] struct SbVideoWire { #[serde(rename = "videoID")] video_id: String, #[serde(default)] segments: Vec, } #[derive(Deserialize)] struct SbSegmentWire { #[serde(rename = "UUID")] uuid: Option, category: String, #[serde(default)] segment: Vec, #[serde(rename = "actionType")] action_type: Option, } /// SponsorBlock skip-segment lookup via the privacy-preserving SHA-256 /// hash-prefix endpoint (k-anonymity): we send only the first 4 hex chars /// of sha256(videoId), the server returns segments for every video whose /// hash shares that prefix, and we filter to the exact match locally — so /// the server never learns which video the user is watching. /// /// GET https://sponsor.ajay.app/api/skipSegments/?categories=[...] /// Returns an empty Vec on any failure, matching the Kotlin contract. #[uniffi::export(async_runtime = "tokio")] pub async fn fetch_sponsor_segments( video_id: String, categories: Vec, ) -> Vec { log::info!( "strawcore::sb fetch id_len={} categories={}", video_id.len(), categories.len() ); match fetch_sponsor_segments_inner(&video_id, &categories).await { Some(v) => v, None => Vec::new(), } } async fn fetch_sponsor_segments_inner( video_id: &str, categories: &[String], ) -> Option> { let client = client()?; let prefix = sha256_prefix4(video_id); let url = format!("{SB_SKIP_BASE}{prefix}"); // Encode the category list as a JSON array, the form the SB API // expects (`?categories=["sponsor","selfpromo"]`). reqwest's `.query` // percent-encodes the value for us. let categories_json = serde_json::to_string(categories).ok()?; let resp = client .get(&url) .query(&[("categories", categories_json.as_str())]) .header("Accept", "application/json") .send() .await .ok()?; if !resp.status().is_success() { return None; } let body = read_capped_body(resp, SB_MAX_BYTES).await?; let videos: Vec = serde_json::from_str(&body) .map_err(|e| log::warn!("strawcore::sb json decode failed: {e}")) .ok()?; // The prefix lookup returns many videos; keep only ours. let mine = videos.into_iter().find(|v| v.video_id == video_id)?; let out: Vec = mine .segments .into_iter() .map(|s| SponsorSegment { category: s.category, // Kotlin read segment[0]/segment[1] with a 0.0 fallback; match // that so a malformed 1-element segment doesn't drop the row. start_sec: s.segment.first().copied().unwrap_or(0.0), end_sec: s.segment.get(1).copied().unwrap_or(0.0), uuid: s.uuid, action_type: s.action_type, }) .collect(); Some(out) } /// First 4 lowercase-hex chars of sha256(input) — i.e. the first two /// bytes of the digest. Matches Kotlin's /// `sha256Hex(videoId).substring(0, 4)`. fn sha256_prefix4(input: &str) -> String { let digest = Sha256::digest(input.as_bytes()); format!("{:02x}{:02x}", digest[0], digest[1]) } #[cfg(test)] mod tests { use super::*; #[test] fn prefix_matches_known_sha256() { // sha256("dQw4w9WgXcQ") starts 5f6b — this is the 4-char prefix // SponsorBlock would receive for that video id. assert_eq!(sha256_prefix4("dQw4w9WgXcQ"), "5f6b"); // Empty string -> the well-known empty-sha256 digest starts e3b0. assert_eq!(sha256_prefix4(""), "e3b0"); } #[test] fn segment_fallback_on_short_array() { // Mirror the unwrap_or(0.0) guard for a malformed 1-element segment. let seg = vec![12.5f64]; assert_eq!(seg.first().copied().unwrap_or(0.0), 12.5); assert_eq!(seg.get(1).copied().unwrap_or(0.0), 0.0); } }