riva 0.1.0

Provider-agnostic Rust library for extracting normalized media stream metadata from SoundCloud and YouTube via async helpers.
Documentation
use std::collections::HashMap;

use serde::{Deserialize, Serialize};
use url::{Url, form_urlencoded};

use super::signature::SignatureResolver;

#[derive(Debug, Clone)]
pub struct StreamInfo {
    pub itag: u32,
    pub url: String,
    pub mime_type: Option<String>,
    pub bitrate: Option<u64>,
    pub quality_label: Option<String>,
    pub audio_quality: Option<String>,
    pub fps: Option<u32>,
    pub width: Option<u32>,
    pub height: Option<u32>,
    pub content_length: Option<u64>,
    pub approx_duration_ms: Option<u64>,
    pub audio_sample_rate: Option<u32>,
    pub audio_channels: Option<u32>,
    pub has_audio: bool,
    pub has_video: bool,
    pub is_adaptive: bool,
}

#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PlayerRequest<'a> {
    pub context: RequestContext<'a>,
    pub video_id: &'a str,
    pub content_check_ok: bool,
    pub racy_check_ok: bool,
    pub playback_context: PlaybackContext,
}

impl<'a> PlayerRequest<'a> {
    pub fn new(video_id: &'a str, client_context: ClientContext<'a>) -> Self {
        Self {
            context: RequestContext {
                client: client_context,
            },
            video_id,
            content_check_ok: true,
            racy_check_ok: true,
            playback_context: PlaybackContext {
                content_playback_context: ContentPlaybackContext {
                    html5_preference: "HTML5_PREF_WANTS",
                },
            },
        }
    }
}

#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RequestContext<'a> {
    pub client: ClientContext<'a>,
}

#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ClientContext<'a> {
    pub client_name: &'a str,
    pub client_version: &'a str,
    pub android_sdk_version: u32,
    pub os_version: &'a str,
    pub device_make: &'a str,
    pub device_model: &'a str,
    pub user_agent: &'a str,
    pub hl: &'a str,
    pub gl: &'a str,
    pub time_zone: &'a str,
    pub platform: &'a str,
}

#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PlaybackContext {
    pub content_playback_context: ContentPlaybackContext,
}

#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ContentPlaybackContext {
    pub html5_preference: &'static str,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PlayerResponse {
    pub streaming_data: Option<StreamingData>,
    pub playability_status: Option<PlayabilityStatus>,
    pub player_config: Option<PlayerConfig>,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PlayerConfig {
    pub assets: Option<PlayerAssets>,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PlayerAssets {
    pub js: Option<String>,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StreamingData {
    pub formats: Option<Vec<Format>>,
    pub adaptive_formats: Option<Vec<Format>>,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PlayabilityStatus {
    pub status: Option<String>,
    pub reason: Option<String>,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Format {
    pub itag: Option<u32>,
    pub url: Option<String>,
    pub signature_cipher: Option<String>,
    pub cipher: Option<String>,
    pub mime_type: Option<String>,
    pub bitrate: Option<u64>,
    pub average_bitrate: Option<u64>,
    pub width: Option<u32>,
    pub height: Option<u32>,
    pub fps: Option<u32>,
    pub quality_label: Option<String>,
    pub quality: Option<String>,
    pub audio_quality: Option<String>,
    pub audio_sample_rate: Option<String>,
    pub audio_channels: Option<u32>,
    pub content_length: Option<String>,
    pub approx_duration_ms: Option<String>,
}

impl Format {
    pub fn into_stream_info(
        self,
        is_adaptive: bool,
        resolver: Option<&SignatureResolver>,
    ) -> Option<StreamInfo> {
        let itag = self.itag?;
        let url = self.resolved_url(resolver)?;

        let mime_type = self.mime_type.clone();
        let (has_audio, has_video) = classify_mime(mime_type.as_deref());

        Some(StreamInfo {
            itag,
            url,
            mime_type,
            bitrate: self.average_bitrate.or(self.bitrate),
            quality_label: self.quality_label.or(self.quality),
            audio_quality: self.audio_quality,
            fps: self.fps,
            width: self.width,
            height: self.height,
            content_length: self
                .content_length
                .and_then(|value| value.parse::<u64>().ok()),
            approx_duration_ms: self
                .approx_duration_ms
                .and_then(|value| value.parse::<u64>().ok()),
            audio_sample_rate: self
                .audio_sample_rate
                .and_then(|value| value.parse::<u32>().ok()),
            audio_channels: self.audio_channels,
            has_audio,
            has_video,
            is_adaptive,
        })
    }

    fn resolved_url(&self, resolver: Option<&SignatureResolver>) -> Option<String> {
        if let Some(url) = &self.url {
            return Some(decode_js_url(url));
        }

        let cipher = self.signature_cipher.as_ref().or(self.cipher.as_ref())?;
        let params: HashMap<_, _> = form_urlencoded::parse(cipher.as_bytes())
            .into_owned()
            .collect();
        let base_url = params.get("url").map(|value| decode_js_url(value))?;
        let target_param = params.get("sp").map(String::as_str).unwrap_or("signature");

        if let Some(sig) = params.get("sig").or_else(|| params.get("signature")) {
            return append_signature(&base_url, target_param, sig);
        }

        let scrambled = params.get("s")?;
        let resolver = resolver?;
        let deciphered = resolver.apply(scrambled).ok()?;
        append_signature(&base_url, target_param, &deciphered)
    }
}

pub fn player_js_url(payload: &PlayerResponse) -> Option<&str> {
    payload
        .player_config
        .as_ref()
        .and_then(|config| config.assets.as_ref())
        .and_then(|assets| assets.js.as_deref())
}

fn classify_mime(mime: Option<&str>) -> (bool, bool) {
    if let Some(mime) = mime {
        if mime.starts_with("audio/") {
            return (true, false);
        }

        if mime.starts_with("video/") {
            let lower = mime.to_ascii_lowercase();
            let has_audio =
                lower.contains("mp4a") || lower.contains("opus") || lower.contains("vorbis");
            return (has_audio, true);
        }
    }

    (false, false)
}

fn decode_js_url(value: &str) -> String {
    value.replace("\\u0026", "&")
}

fn append_signature(base: &str, param: &str, signature: &str) -> Option<String> {
    let mut url = Url::parse(base).ok()?;
    {
        let mut pairs = url.query_pairs_mut();
        pairs.append_pair(param, signature);
    }
    Some(url.into())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn classify_mime_detects_stream_types() {
        assert_eq!(classify_mime(Some("audio/mp4")), (true, false));
        assert_eq!(
            classify_mime(Some("video/mp4; codecs=\"avc1.4d401f, mp4a.40.2\"")),
            (true, true)
        );
        assert_eq!(classify_mime(Some("video/webm")), (false, true));
        assert_eq!(classify_mime(None), (false, false));
    }

    #[test]
    fn decode_js_url_unescapes_ampersands() {
        let source = "https://example.com/watch?foo=bar\\u0026baz=qux";
        assert_eq!(
            decode_js_url(source),
            "https://example.com/watch?foo=bar&baz=qux"
        );
    }

    #[test]
    fn append_signature_adds_param() {
        let base = "https://example.com/video?foo=bar";
        let result = append_signature(base, "sig", "12345").unwrap();
        assert!(result.contains("sig=12345"));
        assert!(result.starts_with("https://example.com/video"));
    }
}