// Phase 7 — `stream_info(url)` via Sulkta-Coop/strawcore-core. // Exposed as a suspend fun. // // StreamInfo/AudioStreamItem/VideoStreamItem field shapes are unchanged // from Phase U-3 so Kotlin VideoDetailScreen + PlayerScreen + // ResolvedPlayback consume them with zero code changes. use strawcore_core::youtube::linkhandler::stream::extract_video_id; use strawcore_core::youtube::stream_extractor::stream_info as core_stream_info; use crate::error::StrawcoreError; use crate::search::SearchItem; #[derive(Debug, Clone, uniffi::Record)] pub struct StreamInfo { pub id: String, pub title: String, pub uploader: String, pub uploader_url: Option, pub description: String, pub thumbnail: Option, 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, /// DASH/adaptive video-only streams. Pair with `audio_only` via MergingMediaSource. pub video_only: Vec, /// DASH/adaptive audio-only streams. Sort by bitrate desc for "best audio". pub audio_only: Vec, /// Optional DASH MPD URL. ExoPlayer's DashMediaSource accepts this directly. pub dash_mpd_url: Option, /// Optional HLS playlist URL. ExoPlayer's HlsMediaSource accepts this directly. pub hls_url: Option, /// "Up next" list. Empty for now — populated when we port /next response. pub related: Vec, } #[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, } #[uniffi::export(async_runtime = "tokio")] pub async fn stream_info(input: String) -> Result { log::info!("strawcore::stream_info input_len={}", input.len()); crate::runtime::ensure_initialized(); let video_id = resolve_video_id(&input)?; let video_id_for_call = video_id.clone(); let core = tokio::task::spawn_blocking(move || core_stream_info(&video_id_for_call)) .await .map_err(|e| StrawcoreError::Extractor { msg: format!("join: {e}"), })??; Ok(map_stream_info(video_id, core)) } fn resolve_video_id(input: &str) -> Result { let trimmed = input.trim(); // Bare 11-char id? if trimmed.len() == 11 && trimmed .chars() .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') { return Ok(trimmed.to_string()); } extract_video_id(trimmed).map_err(|e| StrawcoreError::Unsupported { detail: e.to_string(), }) } fn map_stream_info( video_id: String, s: strawcore_core::stream::StreamInfo, ) -> StreamInfo { let combined = s .video_streams .into_iter() .map(video_to_dto) .collect(); let video_only = s .video_only_streams .into_iter() .map(video_to_dto) .collect(); let audio_only = s.audio_streams.into_iter().map(audio_to_dto).collect(); let uploader_url = if s.uploader_url.is_empty() { None } else { Some(s.uploader_url) }; let thumbnail = s.thumbnails.last().map(|i| i.url().to_string()); StreamInfo { id: video_id, title: s.name, uploader: s.uploader_name, uploader_url, description: s.description, thumbnail, view_count: clamp_nonneg(s.view_count), like_count: clamp_nonneg(s.like_count), duration_seconds: s.duration_seconds.max(0), combined, video_only, audio_only, dash_mpd_url: s.dash_manifest_url, hls_url: s.hls_manifest_url, related: Vec::new(), } } fn clamp_nonneg(n: i64) -> i64 { if n < 0 { 0 } else { n } } fn video_to_dto(v: strawcore_core::stream::VideoStream) -> VideoStreamItem { VideoStreamItem { url: v.url, height: v.height.map(|h| h as i32).unwrap_or(0), bitrate: v.bandwidth.map(|b| b as i64).unwrap_or(0), mime_type: v.format.mime().to_string(), } } fn audio_to_dto(a: strawcore_core::stream::AudioStream) -> AudioStreamItem { AudioStreamItem { url: a.url, bitrate: a .average_bitrate_kbps .map(|b| (b as i64) * 1000) .unwrap_or(0), mime_type: a.format.mime().to_string(), } }