riva 0.1.0

Provider-agnostic Rust library for extracting normalized media stream metadata from SoundCloud and YouTube via async helpers.
Documentation
use reqwest::Client;
use serde_json::from_slice;
use thiserror::Error;

use super::models::{ClientContext, PlayerRequest, PlayerResponse, StreamInfo, player_js_url};
use super::normalize::normalize_video_id;
use super::signature::{SignatureDecipher, SignatureResolver};
use super::validator::filter_working_streams;

const PLAYER_ENDPOINT: &str = "https://www.youtube.com/youtubei/v1/player";
const ANDROID_API_KEY: &str = "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8";
const ANDROID_CLIENT_VERSION: &str = "19.33.34";
const ANDROID_USER_AGENT: &str = "com.google.android.youtube/19.33.34 (Linux; U; Android 13) gzip";

type Result<T> = std::result::Result<T, RivaError>;

#[derive(Debug, Error)]
pub enum RivaError {
    #[error("invalid or unsupported YouTube URL")]
    InvalidUrl,
    #[error("network request failed: {0}")]
    Network(#[from] reqwest::Error),
    #[error("failed to parse API response: {0}")]
    Json(#[from] serde_json::Error),
    #[error("no playable streams found for this video")]
    NoStreams,
    #[error("YouTube blocked playback: {0}")]
    Playability(String),
}

pub async fn extract_streams(video_url: &str) -> Result<Vec<StreamInfo>> {
    let video_id = normalize_video_id(video_url)?;

    let client = Client::builder().user_agent(ANDROID_USER_AGENT).build()?;

    let request = PlayerRequest::new(&video_id, android_client_context());
    let response = client
        .post(format!("{PLAYER_ENDPOINT}?key={ANDROID_API_KEY}"))
        .json(&request)
        .send()
        .await?
        .error_for_status()?;

    let payload: PlayerResponse = {
        let bytes = response.bytes().await?;
        from_slice(&bytes)?
    };

    if let Some(status) = payload.playability_status.as_ref() {
        let code = status.status.as_deref().unwrap_or("UNKNOWN");
        if code != "OK" {
            let reason = status
                .reason
                .clone()
                .unwrap_or_else(|| "Unknown restriction".to_string());
            return Err(RivaError::Playability(reason));
        }
    }

    let js_player_url = player_js_url(&payload).map(|url| url.to_owned());
    let streaming = payload.streaming_data.ok_or(RivaError::NoStreams)?;
    let signature_decipher = SignatureDecipher;
    let signature_resolver = js_player_url.as_deref().map(|url| SignatureResolver {
        js_url: url,
        decipher: &signature_decipher,
    });

    let mut streams = Vec::new();

    if let Some(formats) = streaming.formats {
        streams.extend(
            formats
                .into_iter()
                .filter_map(|f| f.into_stream_info(false, signature_resolver.as_ref())),
        );
    }

    if let Some(formats) = streaming.adaptive_formats {
        streams.extend(
            formats
                .into_iter()
                .filter_map(|f| f.into_stream_info(true, signature_resolver.as_ref())),
        );
    }

    let streams = filter_working_streams(&client, streams).await;

    if streams.is_empty() {
        return Err(RivaError::NoStreams);
    }

    Ok(streams)
}

fn android_client_context() -> ClientContext<'static> {
    ClientContext {
        client_name: "ANDROID",
        client_version: ANDROID_CLIENT_VERSION,
        android_sdk_version: 33,
        os_version: "13",
        device_make: "Google",
        device_model: "Pixel 7",
        user_agent: ANDROID_USER_AGENT,
        hl: "en",
        gl: "US",
        time_zone: "UTC",
        platform: "MOBILE",
    }
}