v0.1.0-V (vc=9): U-3 — streamInfo via rustypipe drives VideoDetail+Player

stream_info(url) UniFFI suspend fn replaces NewPipeExtractor's
StreamInfo.getInfo() for both VideoDetailViewModel and PlayerViewModel.
One Rust round-trip drives the detail screen render AND the player's
resolve(). The VideoDetailUiState.info field cached on detail load is
reused by the Download dialog so we don't refetch.

Deferred to U-3.5:
- like_count (rustypipe's player() doesn't surface engagement data;
  a separate query is needed)
- related (player() doesn't include 'up next'; comes from a separate
  endpoint). Kotlin gets empty list for now — RelatedRow handles it.

Type quirks vs my initial guesses (caught by cargo check):
- details.duration is u32, not Option<u32>
- channel is split into channel_id + channel_name, not a struct
- like_count doesn't exist at this query depth
- VideoFormat::Webm (lowercase mb), VideoCodec::Avc1 (not H264)
- video_only is a separate vec (video_only_streams), not a bool flag
This commit is contained in:
Sulkta 2026-05-24 08:52:43 -07:00
parent c2c5536729
commit 89b9837758
8 changed files with 258 additions and 64 deletions

View file

@ -24,6 +24,10 @@ codegen-units = 1
panic = "abort"
opt-level = "z"
# `url` crate for video-id extraction in stream.rs.
[workspace.dependencies]
url = "2"
[profile.dev]
# Keep debug builds fast — we're rebuilding constantly during U-1..U-5.
opt-level = 0

View file

@ -31,6 +31,8 @@ rquickjs-sys = { version = "0.9", default-features = false, features = ["bindgen
thiserror = "1"
# Single-threaded init for the runtime + extractor singletons.
once_cell = "1"
# URL parsing for the video-id extractor in stream.rs.
url = { workspace = true }
# Android log integration — `log::info!()` ends up in `adb logcat -s strawcore`.
log = "0.4"
android_logger = { version = "0.14", default-features = false }

View file

@ -11,6 +11,7 @@ use std::sync::Once;
mod error;
mod runtime;
mod search;
mod stream;
#[allow(unused_imports)]
use runtime::block_on;
@ -18,6 +19,7 @@ use runtime::block_on;
// Re-exports so UniFFI sees the types at the crate root for macro discovery.
pub use error::StrawcoreError;
pub use search::SearchItem;
pub use stream::{AudioStreamItem, StreamInfo, VideoStreamItem};
/// Initialize Android logging once. The Kotlin side calls this from
/// StrawApp.onCreate() so anything emitted via `log::info!()` shows up in

View file

@ -0,0 +1,190 @@
// 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::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();
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(),
})
}