Caught on first emulator smoke: stream_info hardcoded `player_from_clients(&[Android, Ios])` from U-3 era. Android first trips YT's "Precondition check failed" because needs_po_token doesn't flag Android — request fires unsigned and YT rejects it. The fork's audit-fixed player_client_order is [Ios, Tv] without botguard (HIGH-3 in the audit). Use rp.query().player(id) directly so we inherit that order and pick up future tweaks automatically.
198 lines
7 KiB
Rust
198 lines
7 KiB
Rust
// Phase U-3 — `stream_info(url)` via rustypipe, exposed as a suspend fun.
|
|
//
|
|
// Drives both VideoDetailScreen (title/uploader/description/thumbnail) and
|
|
// PlayerScreen (audio/video stream URLs that ExoPlayer loads from). One
|
|
// Rust call replaces two NewPipeExtractor StreamInfo.getInfo() round-trips.
|
|
//
|
|
// `StreamInfo` keeps field names parallel to the Kotlin-side VideoDetail
|
|
// + ResolvedPlayback so the ViewModels swap one-to-one.
|
|
//
|
|
// Not yet wired here (rustypipe doesn't surface these from `player()` alone
|
|
// and they need a separate fetch):
|
|
// - like_count
|
|
// - related videos
|
|
// Both will land in U-3.5 via `rp.query().video_details(id)` if we want
|
|
// the like count, and via a separate "related" call. For now Kotlin gets
|
|
// 0 / empty list and the UI handles it (already does).
|
|
|
|
use crate::error::StrawcoreError;
|
|
use crate::search::SearchItem;
|
|
use rustypipe::client::{ClientType, RustyPipe};
|
|
|
|
#[derive(Debug, Clone, uniffi::Record)]
|
|
pub struct StreamInfo {
|
|
pub id: String,
|
|
pub title: String,
|
|
pub uploader: String,
|
|
pub uploader_url: Option<String>,
|
|
pub description: String,
|
|
pub thumbnail: Option<String>,
|
|
pub view_count: i64,
|
|
pub like_count: i64,
|
|
/// Duration in seconds. 0 = live/unknown.
|
|
pub duration_seconds: i64,
|
|
|
|
/// Progressive (audio+video combined). Empty when YT only serves DASH.
|
|
pub combined: Vec<VideoStreamItem>,
|
|
/// DASH/adaptive video-only streams. Pair with `audio_only` via MergingMediaSource.
|
|
pub video_only: Vec<VideoStreamItem>,
|
|
/// DASH/adaptive audio-only streams. Sort by bitrate desc for "best audio".
|
|
pub audio_only: Vec<AudioStreamItem>,
|
|
/// Optional DASH MPD URL. ExoPlayer's DashMediaSource accepts this directly.
|
|
pub dash_mpd_url: Option<String>,
|
|
/// Optional HLS playlist URL. ExoPlayer's HlsMediaSource accepts this directly.
|
|
pub hls_url: Option<String>,
|
|
|
|
/// "Up next" list. Empty for now — populated in U-3.5.
|
|
pub related: Vec<SearchItem>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, uniffi::Record)]
|
|
pub struct VideoStreamItem {
|
|
pub url: String,
|
|
/// e.g. 1080, 720, 480.
|
|
pub height: i32,
|
|
pub bitrate: i64,
|
|
pub mime_type: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, uniffi::Record)]
|
|
pub struct AudioStreamItem {
|
|
pub url: String,
|
|
pub bitrate: i64,
|
|
pub mime_type: String,
|
|
}
|
|
|
|
fn yt_channel_url(id: &str) -> String {
|
|
format!("https://www.youtube.com/channel/{}", id)
|
|
}
|
|
|
|
/// Best-effort YouTube video-id extraction.
|
|
fn extract_video_id(input: &str) -> Result<String, StrawcoreError> {
|
|
let trimmed = input.trim();
|
|
if trimmed.len() == 11
|
|
&& trimmed.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
|
|
{
|
|
return Ok(trimmed.to_string());
|
|
}
|
|
let url = url::Url::parse(trimmed).map_err(|e| StrawcoreError::Unsupported {
|
|
detail: format!("bad URL: {}", e),
|
|
})?;
|
|
let host = url.host_str().unwrap_or("").to_ascii_lowercase();
|
|
let host = host
|
|
.trim_start_matches("www.")
|
|
.trim_start_matches("m.")
|
|
.trim_start_matches("music.");
|
|
match host {
|
|
"youtube.com" | "youtube-nocookie.com" => {
|
|
if let Some(v) = url
|
|
.query_pairs()
|
|
.find(|(k, _)| k == "v")
|
|
.map(|(_, v)| v.into_owned())
|
|
{
|
|
if !v.is_empty() {
|
|
return Ok(v);
|
|
}
|
|
}
|
|
let path = url.path().trim_start_matches('/');
|
|
for prefix in ["embed/", "v/", "shorts/"] {
|
|
if let Some(rest) = path.strip_prefix(prefix) {
|
|
let id = rest.split('/').next().unwrap_or("");
|
|
if !id.is_empty() {
|
|
return Ok(id.to_string());
|
|
}
|
|
}
|
|
}
|
|
Err(StrawcoreError::Unsupported {
|
|
detail: "no video id in URL".into(),
|
|
})
|
|
}
|
|
"youtu.be" => {
|
|
let id = url.path().trim_start_matches('/').split('/').next().unwrap_or("");
|
|
if id.is_empty() {
|
|
Err(StrawcoreError::Unsupported {
|
|
detail: "no video id in youtu.be URL".into(),
|
|
})
|
|
} else {
|
|
Ok(id.to_string())
|
|
}
|
|
}
|
|
_ => Err(StrawcoreError::Unsupported {
|
|
detail: format!("unsupported host: {}", host),
|
|
}),
|
|
}
|
|
}
|
|
|
|
#[uniffi::export(async_runtime = "tokio")]
|
|
pub async fn stream_info(url: String) -> Result<StreamInfo, StrawcoreError> {
|
|
let id = extract_video_id(&url)?;
|
|
log::info!("strawcore::stream_info id={}", id);
|
|
let rp = RustyPipe::new();
|
|
|
|
// Use the fork's audit-fixed default client order. As of
|
|
// v0.11.5-sulkta.2 that's [Ios, Tv] without botguard — iOS first
|
|
// because it skips player.js deobfuscation AND doesn't require
|
|
// device attestation. Android is intentionally NOT in the default
|
|
// order: needs_po_token doesn't flag Android, so unsigned requests
|
|
// get YT's "Precondition check failed" / "Sign in to confirm
|
|
// you're not a bot" rejection, which is environmental-non-switchable.
|
|
// Re-add Android when a real po_token strategy lands.
|
|
let player = rp.query().player(&id).await?;
|
|
let details = &player.details;
|
|
|
|
// Progressive (combined audio+video) goes through video_streams; the
|
|
// audio+video split path is video_only_streams + audio_streams.
|
|
let combined: Vec<VideoStreamItem> = player
|
|
.video_streams
|
|
.iter()
|
|
.map(|s| VideoStreamItem {
|
|
url: s.url.clone(),
|
|
height: s.height as i32,
|
|
bitrate: s.bitrate as i64,
|
|
mime_type: format!("{:?}/{:?}", s.format, s.codec),
|
|
})
|
|
.collect();
|
|
let video_only: Vec<VideoStreamItem> = player
|
|
.video_only_streams
|
|
.iter()
|
|
.map(|s| VideoStreamItem {
|
|
url: s.url.clone(),
|
|
height: s.height as i32,
|
|
bitrate: s.bitrate as i64,
|
|
mime_type: format!("{:?}/{:?}", s.format, s.codec),
|
|
})
|
|
.collect();
|
|
let audio_only: Vec<AudioStreamItem> = player
|
|
.audio_streams
|
|
.iter()
|
|
.map(|s| AudioStreamItem {
|
|
url: s.url.clone(),
|
|
bitrate: s.bitrate as i64,
|
|
mime_type: format!("{:?}/{:?}", s.format, s.codec),
|
|
})
|
|
.collect();
|
|
|
|
let thumbnail = details.thumbnail.last().map(|t| t.url.clone());
|
|
|
|
Ok(StreamInfo {
|
|
id: details.id.clone(),
|
|
title: details.name.clone().unwrap_or_default(),
|
|
uploader: details.channel_name.clone().unwrap_or_default(),
|
|
uploader_url: if details.channel_id.is_empty() {
|
|
None
|
|
} else {
|
|
Some(yt_channel_url(&details.channel_id))
|
|
},
|
|
description: details.description.clone().unwrap_or_default(),
|
|
thumbnail,
|
|
view_count: details.view_count.unwrap_or(0) as i64,
|
|
like_count: 0,
|
|
duration_seconds: details.duration as i64,
|
|
combined,
|
|
video_only,
|
|
audio_only,
|
|
dash_mpd_url: player.dash_manifest_url.clone(),
|
|
hls_url: player.hls_manifest_url.clone(),
|
|
related: Vec::new(),
|
|
})
|
|
}
|