// Phase 7 — `stream_info(url)` via Sulkta-OSS/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 strawcore_core::youtube::stream_extractor::stream_metadata as core_stream_metadata; 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, } /// The player-facing URLs picked from a [`StreamInfo`]'s stream lists under /// a resolution ceiling. This is the stream-selection policy that used to /// live in the Kotlin `resolveStreamPlayback`; the app now keeps only a thin /// shim that supplies the ceiling (its `Settings.maxResolution`) and attaches /// SponsorBlock segments on top. SponsorBlock is deliberately NOT here — it's /// a separate concern the app layers over this. #[derive(Debug, Clone, uniffi::Record)] pub struct ResolvedStreams { pub title: String, pub video_url: Option, pub audio_url: Option, pub combined_url: Option, pub dash_mpd_url: Option, pub hls_url: Option, } #[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 = crate::runtime::run_extract("stream_info", move || { core_stream_info(&video_id_for_call) }) .await?; Ok(map_stream_info(video_id, core)) } /// Metadata-only fetch — returns `(view_count, duration_seconds)` from the /// Android `videoDetails`, skipping the JS-deobf / streams / extra WEB /// round-trip the full `stream_info` pays. Backs `enrich_feed_item`, which /// only needs those two fields. Non-negative clamping mirrors /// `map_stream_info` so the values are identical to the full path's. pub async fn stream_metadata(input: String) -> Result<(i64, i64), StrawcoreError> { log::info!("strawcore::stream_metadata input_len={}", input.len()); crate::runtime::ensure_initialized(); let video_id = resolve_video_id(&input)?; let core = crate::runtime::run_extract("stream_metadata", move || { core_stream_metadata(&video_id) }) .await?; Ok((clamp_nonneg(core.view_count), core.duration_seconds.max(0))) } 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(), } } /// Pick the player URLs from an already-fetched [`StreamInfo`]. Pure + /// synchronous (no network). `max_height` is the app's /// `Settings.maxResolution` ceiling — `Int.MAX_VALUE` means "no cap". This /// is a byte-for-byte port of the prior Kotlin `resolveStreamPlayback`: /// the app keeps a thin shim that passes the ceiling and tacks on /// SponsorBlock segments. #[uniffi::export] pub fn resolve_playback(info: StreamInfo, max_height: i32) -> ResolvedStreams { // Borrow the stream lists for selection before moving the owned String // fields into the result struct (disjoint fields, so this is fine). let video_url = pick_video(&info.video_only, max_height); let audio_url = pick_audio(&info.audio_only); let combined_url = pick_video(&info.combined, max_height); ResolvedStreams { title: info.title, video_url, audio_url, combined_url, // Kotlin used `takeIf { it.isNotBlank() }` — empty OR all-whitespace // collapses to None. dash_mpd_url: info.dash_mpd_url.filter(|s| !s.trim().is_empty()), hls_url: info.hls_url.filter(|s| !s.trim().is_empty()), } } /// Highest-bitrate video stream at or below `max_height`; if none fit, the /// lowest-height stream (so a too-high-only set still plays without blowing /// a data plan). The FIRST element wins ties — matching Kotlin's /// `maxByOrNull`/`minByOrNull`, which keep the first max/min, unlike Rust's /// `Iterator::max_by_key` (last on ties). Preserving that keeps the picked /// URL identical to the previous Kotlin picker on same-bitrate/-height ties. fn pick_video(streams: &[VideoStreamItem], max_height: i32) -> Option { if streams.is_empty() { return None; } let mut best_capped: Option<&VideoStreamItem> = None; for s in streams.iter().filter(|s| s.height <= max_height) { match best_capped { // Keep the existing pick unless this one is strictly higher // bitrate — first-on-ties, matching `maxByOrNull`. Some(b) if b.bitrate >= s.bitrate => {} _ => best_capped = Some(s), } } if let Some(s) = best_capped { return Some(s.url.clone()); } let mut lowest: Option<&VideoStreamItem> = None; for s in streams { match lowest { // First-on-ties, matching `minByOrNull`. Some(b) if b.height <= s.height => {} _ => lowest = Some(s), } } lowest.map(|s| s.url.clone()) } /// Highest-bitrate audio stream, first-on-ties (matching Kotlin /// `maxByOrNull { it.bitrate }`). fn pick_audio(streams: &[AudioStreamItem]) -> Option { let mut best: Option<&AudioStreamItem> = None; for s in streams { match best { Some(b) if b.bitrate >= s.bitrate => {} _ => best = Some(s), } } best.map(|s| s.url.clone()) }