diff --git a/rust/strawcore/Cargo.toml b/rust/strawcore/Cargo.toml index 590a12e12..00d6ebc2f 100644 --- a/rust/strawcore/Cargo.toml +++ b/rust/strawcore/Cargo.toml @@ -17,22 +17,19 @@ crate-type = ["cdylib", "staticlib"] # `tokio` feature wires `#[uniffi::export(async_runtime = "tokio")]` so async # fns surface as suspend fun on the Kotlin side. uniffi = { version = "0.28", features = ["cli", "tokio"] } -# Tokio multi-thread runtime — rustypipe is async-first. +# Tokio multi-thread runtime — needed to host spawn_blocking around the +# blocking strawcore_core HTTP calls so Kotlin `suspend fun` semantics +# survive. tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync"] } -# rustypipe — the actual YouTube Innertube client. Phase U-2 wires search. -# Force rustls + webpki-roots so we don't pull openssl-sys (cross-compiling -# system OpenSSL to four Android ABIs is a tarpit; rustls is pure-Rust). -rustypipe = { version = "0.11", default-features = false, features = ["rustls-tls-webpki-roots"] } -# rquickjs-sys (transitive dep of rustypipe for YT signature decryption JS) -# doesn't ship prebuilt Android bindings. Direct-depend with `bindgen` feature -# so it generates them at build time. Crafting-table has libclang preinstalled. -rquickjs-sys = { version = "0.9", default-features = false, features = ["bindgen"] } +# The actual YT extractor lives in Sulkta-Coop/strawcore. Renamed locally +# to `strawcore_core` to avoid collision with this wrapper crate's name +# (which has to stay `strawcore` so the .so name + Kotlin loadLibrary +# call keep working). +strawcore-core = { path = "../../../strawcore" } # Error glue. 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 } diff --git a/rust/strawcore/src/channel.rs b/rust/strawcore/src/channel.rs index e52ee438d..67d48b8a9 100644 --- a/rust/strawcore/src/channel.rs +++ b/rust/strawcore/src/channel.rs @@ -1,12 +1,12 @@ -// Phase U-4 — `channel_info(channel_url)` via rustypipe. -// -// Returns channel metadata + the channel's latest videos (the "Videos" tab). +// Phase 7 — `channel_info(channel_url)` via the new strawcore. // Used by ChannelScreen (single-channel view) AND // SubscriptionFeedViewModel (which fans out across all subscriptions). +use strawcore_core::youtube::channel::{channel_info as core_channel_info, ChannelInfo as CoreInfo}; +use strawcore_core::youtube::linkhandler::channel as core_link; + use crate::error::StrawcoreError; -use crate::search::SearchItem; -use rustypipe::client::RustyPipe; +use crate::search::{from_core as search_from_core, SearchItem}; #[derive(Debug, Clone, uniffi::Record)] pub struct ChannelInfo { @@ -21,93 +21,42 @@ pub struct ChannelInfo { pub videos: Vec, } -fn yt_video_url(id: &str) -> String { - format!("https://www.youtube.com/watch?v={}", id) +#[uniffi::export(async_runtime = "tokio")] +pub async fn channel_info(input: String) -> Result { + log::info!("strawcore::channel_info input={}", input); + let identifier = resolve_channel_identifier(&input)?; + let core = tokio::task::spawn_blocking(move || core_channel_info(identifier)) + .await + .map_err(|e| StrawcoreError::Extractor { + msg: format!("join: {e}"), + })??; + Ok(map_channel(core)) } -fn yt_channel_url(id: &str) -> String { - format!("https://www.youtube.com/channel/{}", id) -} - -/// Channel-id extraction. Accepts: -/// https://www.youtube.com/channel/UC... -/// https://www.youtube.com/@handle -/// https://www.youtube.com/c/handle -/// https://www.youtube.com/user/handle -/// bare channel id (UC..., 24 chars) -fn extract_channel_input(input: &str) -> Result { +fn resolve_channel_identifier( + input: &str, +) -> Result { let trimmed = input.trim(); - // Bare channel ID — usually 24 chars starting with UC. + // Bare channel ID — UC..., 24 chars. if trimmed.starts_with("UC") && trimmed.len() == 24 { - return Ok(trimmed.to_string()); + return Ok(core_link::ChannelIdentifier::DirectId(trimmed.into())); } - let url = url::Url::parse(trimmed).map_err(|e| StrawcoreError::Unsupported { - detail: format!("bad URL: {}", e), - })?; - let path = url.path().trim_start_matches('/').trim_end_matches('/'); - // /channel/UCxxx — canonical - if let Some(rest) = path.strip_prefix("channel/") { - let id = rest.split('/').next().unwrap_or(""); - if !id.is_empty() { - return Ok(id.to_string()); - } - } - // /@handle — rustypipe takes the handle (with @) - if path.starts_with('@') { - return Ok(path.split('/').next().unwrap_or(path).to_string()); - } - // /c/name or /user/name - for prefix in ["c/", "user/"] { - if let Some(rest) = path.strip_prefix(prefix) { - let name = rest.split('/').next().unwrap_or(""); - if !name.is_empty() { - // Rustypipe channel() takes the channel id or @handle. For - // legacy /c/ and /user/ URLs we prepend @ as a best-effort. - return Ok(format!("@{}", name)); - } - } - } - Err(StrawcoreError::Unsupported { - detail: format!("unsupported channel URL: {}", input), + core_link::parse(trimmed).map_err(|e| StrawcoreError::Unsupported { + detail: e.to_string(), }) } -#[uniffi::export(async_runtime = "tokio")] -pub async fn channel_info(channel_url: String) -> Result { - let key = extract_channel_input(&channel_url)?; - log::info!("strawcore::channel_info key={}", key); - let rp = RustyPipe::new(); - - // channel_videos(id) returns Channel> — the - // Channel wrapper carries name/avatar/banner/etc and `.content` - // is the paginator of videos. One round-trip gets us everything. - let channel = rp.query().channel_videos(&key).await?; - - let videos: Vec = channel - .content - .items - .into_iter() - .map(|v| SearchItem { - url: yt_video_url(&v.id), - title: v.name.clone(), - uploader: channel.name.clone(), - uploader_url: Some(yt_channel_url(&channel.id)), - thumbnail: v.thumbnail.last().map(|t| t.url.clone()), - duration_seconds: v.duration.unwrap_or(0) as i64, - view_count: v.view_count.unwrap_or(0) as i64, - }) - .collect(); - - let avatar = channel.avatar.last().map(|t| t.url.clone()); - let banner = channel.banner.last().map(|t| t.url.clone()); - - Ok(ChannelInfo { - id: channel.id, - name: channel.name, +fn map_channel(c: CoreInfo) -> ChannelInfo { + let avatar = c.avatars.last().map(|i| i.url().to_string()); + let banner = c.banners.last().map(|i| i.url().to_string()); + let videos = c.recent_videos.into_iter().map(search_from_core).collect(); + ChannelInfo { + id: c.channel_id, + name: c.name, avatar, banner, - subscriber_count: channel.subscriber_count.map(|n| n as i64).unwrap_or(-1), - description: channel.description, + subscriber_count: c.subscriber_count, + description: c.description, videos, - }) + } } diff --git a/rust/strawcore/src/error.rs b/rust/strawcore/src/error.rs index 110b69092..34069bb81 100644 --- a/rust/strawcore/src/error.rs +++ b/rust/strawcore/src/error.rs @@ -17,13 +17,51 @@ pub enum StrawcoreError { #[error("unsupported: {detail}")] Unsupported { detail: String }, + + #[error("age restricted")] + AgeRestricted, + + #[error("geo restricted")] + GeoRestricted, + + #[error("private content")] + Private, + + #[error("requires login: {detail}")] + RequiresLogin { detail: String }, } -impl From for StrawcoreError { - fn from(e: rustypipe::error::Error) -> Self { - // Bucket every rustypipe error into Extractor for now. Phase U-3+ - // can pull apart specific cases (e.g. ageRestricted → NotFound, - // network timeouts → Network) when we have a tighter UI for it. - StrawcoreError::Extractor { msg: e.to_string() } +impl From for StrawcoreError { + fn from(e: strawcore_core::exceptions::ExtractionError) -> Self { + use strawcore_core::exceptions::{ContentUnavailable, ExtractionError, NetworkError}; + match e { + ExtractionError::Network(NetworkError::Recaptcha { url }) => { + StrawcoreError::RequiresLogin { + detail: format!("reCAPTCHA at {url}"), + } + } + ExtractionError::Network(NetworkError::Transport(msg)) => { + StrawcoreError::Network { msg } + } + ExtractionError::Parsing(p) => StrawcoreError::Extractor { + msg: p.to_string(), + }, + ExtractionError::ContentUnavailable(ContentUnavailable::AgeRestricted) => { + StrawcoreError::AgeRestricted + } + ExtractionError::ContentUnavailable(ContentUnavailable::GeoRestricted) => { + StrawcoreError::GeoRestricted + } + ExtractionError::ContentUnavailable(ContentUnavailable::Private) => { + StrawcoreError::Private + } + ExtractionError::ContentUnavailable(other) => StrawcoreError::Extractor { + msg: other.to_string(), + }, + ExtractionError::DownloaderMissing => StrawcoreError::Extractor { + msg: "downloader not initialized".into(), + }, + ExtractionError::Other(msg) => StrawcoreError::Extractor { msg }, + } } } diff --git a/rust/strawcore/src/lib.rs b/rust/strawcore/src/lib.rs index ea175b8eb..1ca13d06c 100644 --- a/rust/strawcore/src/lib.rs +++ b/rust/strawcore/src/lib.rs @@ -1,10 +1,12 @@ -// strawcore — Rust core for the Straw Android app. +// strawcore (wrapper) — UniFFI surface for the Straw Android app. // -// Phase U-1: hello-world smoke test. One exported function, returns a -// String so we know JNI + UniFFI marshalling works end-to-end before we -// pull in rustypipe. -// -// Phase U-2+ adds real APIs (search, stream_info, channel_info). +// Thin layer over the new Sulkta-Coop/strawcore-core crate. All extractor +// logic (InnerTube, JS deobf, stream parsing, search, channel, playlist) +// lives in core. This file: +// * re-exports the DTOs Kotlin expects under their familiar names +// * exposes #[uniffi::export] async fns that bridge Kotlin suspend funs +// to the core's blocking calls via tokio::task::spawn_blocking +// * owns init_logging() — also initializes the core Downloader use std::sync::Once; @@ -14,18 +16,14 @@ mod runtime; mod search; mod stream; -#[allow(unused_imports)] -use runtime::block_on; - // Re-exports so UniFFI sees the types at the crate root for macro discovery. pub use channel::ChannelInfo; 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 -/// `adb logcat -s strawcore`. +/// Initialize Android logging + the strawcore-core HTTP downloader. +/// Kotlin calls this from StrawApp.onCreate(). Idempotent. #[uniffi::export] pub fn init_logging() { static ONCE: Once = Once::new(); @@ -37,14 +35,18 @@ pub fn init_logging() { ); log::info!("strawcore initialized"); }); + runtime::ensure_initialized(); } -/// Smoke-test entry point — round-trip a string through JNI so we know -/// the bridge is working before pulling in rustypipe. +/// Smoke-test entry point — round-trip a string through JNI. #[uniffi::export] pub fn hello_from_rust(name: String) -> String { log::info!("hello_from_rust called with name={}", name); - format!("hello {} from rust 🦀 (strawcore v{})", name, env!("CARGO_PKG_VERSION")) + format!( + "hello {} from rust 🦀 (strawcore v{})", + name, + env!("CARGO_PKG_VERSION") + ) } uniffi::setup_scaffolding!(); diff --git a/rust/strawcore/src/runtime.rs b/rust/strawcore/src/runtime.rs index 52100b64c..6e1b3fbc0 100644 --- a/rust/strawcore/src/runtime.rs +++ b/rust/strawcore/src/runtime.rs @@ -1,21 +1,29 @@ -// Tokio runtime singleton — rustypipe is async-first, so every exported -// function that touches the network needs to drive a runtime. Building one -// per call is wasteful; we build one shared multi-thread runtime at first -// use and `block_on` against it. +// Runtime bootstrap. Called once from Kotlin's StrawApp.onCreate via +// init_logging(). Wires the strawcore-core Downloader + Localization +// singleton so the extractor calls have an HTTP client to use. -use once_cell::sync::Lazy; -use tokio::runtime::Runtime; +use std::sync::{Arc, Once}; -static RT: Lazy = Lazy::new(|| { - tokio::runtime::Builder::new_multi_thread() - .worker_threads(2) // Mobile — we don't need many. Most blocks are I/O. - .enable_all() - .thread_name("strawcore-tokio") - .build() - .expect("strawcore: failed to build tokio runtime") -}); +use strawcore_core::downloader::ReqwestDownloader; +use strawcore_core::localization::{ContentCountry, Localization}; +use strawcore_core::newpipe::NewPipe; -#[allow(dead_code)] -pub fn block_on(fut: F) -> F::Output { - RT.block_on(fut) +static INIT: Once = Once::new(); + +pub fn ensure_initialized() { + INIT.call_once(|| { + match ReqwestDownloader::new() { + Ok(dl) => { + NewPipe::init_full( + Arc::new(dl), + Localization::default(), + ContentCountry::default(), + ); + log::info!("strawcore-core: downloader + localization initialized"); + } + Err(e) => { + log::error!("strawcore-core: failed to build downloader: {e}"); + } + } + }); } diff --git a/rust/strawcore/src/search.rs b/rust/strawcore/src/search.rs index 82d6feef4..b4f96395e 100644 --- a/rust/strawcore/src/search.rs +++ b/rust/strawcore/src/search.rs @@ -1,13 +1,13 @@ -// Phase U-2 — search via rustypipe, exposed to Kotlin as a suspend fun. -// -// `SearchItem` mirrors the existing Kotlin `StreamItem` field for field so -// `SearchViewModel` can swap NewPipeExtractor for this with a one-line -// change. We map only Video items from rustypipe's result (it also returns -// channels and playlists which we don't need for the search list yet). +// Phase 7 — search via Sulkta-Coop/strawcore-core. Exposed to Kotlin +// as a suspend fun. SearchItem field shape is unchanged from Phase U-2 +// so Kotlin callers (SearchViewModel) keep working with no code +// changes. + +use strawcore_core::stream::StreamInfoItem; +use strawcore_core::youtube::linkhandler::search::SearchFilter; +use strawcore_core::youtube::search_extractor; use crate::error::StrawcoreError; -use rustypipe::client::RustyPipe; -use rustypipe::model::YouTubeItem; #[derive(Debug, Clone, uniffi::Record)] pub struct SearchItem { @@ -22,44 +22,40 @@ pub struct SearchItem { pub view_count: i64, } -fn yt_video_url(id: &str) -> String { - format!("https://www.youtube.com/watch?v={}", id) -} - -fn yt_channel_url(id: &str) -> String { - format!("https://www.youtube.com/channel/{}", id) +pub(crate) fn from_core(item: StreamInfoItem) -> SearchItem { + let uploader_url = if item.uploader_url.is_empty() { + None + } else { + Some(item.uploader_url) + }; + let thumbnail = item + .thumbnails + .last() + .map(|i| i.url().to_string()); + SearchItem { + url: item.url, + title: item.name, + uploader: item.uploader_name, + uploader_url, + thumbnail, + duration_seconds: item.duration_seconds, + view_count: if item.view_count < 0 { + 0 + } else { + item.view_count + }, + } } #[uniffi::export(async_runtime = "tokio")] pub async fn search(query: String) -> Result, StrawcoreError> { log::info!("strawcore::search query={}", query); - let rp = RustyPipe::new(); - let results = rp.query().search(query).await?; - - let items: Vec = results - .items - .items - .into_iter() - .filter_map(|item| match item { - YouTubeItem::Video(v) => Some(SearchItem { - url: yt_video_url(&v.id), - title: v.name, - uploader: v - .channel - .as_ref() - .map(|c| c.name.clone()) - .unwrap_or_default(), - uploader_url: v.channel.as_ref().map(|c| yt_channel_url(&c.id)), - thumbnail: v.thumbnail.first().map(|t| t.url.clone()), - duration_seconds: v.duration.unwrap_or(0) as i64, - view_count: v.view_count.unwrap_or(0) as i64, - }), - // Channels + playlists are dropped at U-2; future phases can - // surface them as a "channels you might like" row. - _ => None, - }) - .collect(); - - log::info!("strawcore::search returned {} videos", items.len()); - Ok(items) + let result = tokio::task::spawn_blocking(move || { + search_extractor::search(&query, SearchFilter::Videos) + }) + .await + .map_err(|e| StrawcoreError::Extractor { + msg: format!("join: {e}"), + })??; + Ok(result.videos.into_iter().map(from_core).collect()) } diff --git a/rust/strawcore/src/stream.rs b/rust/strawcore/src/stream.rs index 8f550b7f3..7a4ce6839 100644 --- a/rust/strawcore/src/stream.rs +++ b/rust/strawcore/src/stream.rs @@ -1,23 +1,15 @@ -// Phase U-3 — `stream_info(url)` via rustypipe, exposed as a suspend fun. +// Phase 7 — `stream_info(url)` via Sulkta-Coop/strawcore-core. +// 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). +// 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; -use rustypipe::client::{ClientType, RustyPipe}; #[derive(Debug, Clone, uniffi::Record)] pub struct StreamInfo { @@ -43,7 +35,7 @@ pub struct StreamInfo { /// Optional HLS playlist URL. ExoPlayer's HlsMediaSource accepts this directly. pub hls_url: Option, - /// "Up next" list. Empty for now — populated in U-3.5. + /// "Up next" list. Empty for now — populated when we port /next response. pub related: Vec, } @@ -63,140 +55,99 @@ pub struct AudioStreamItem { pub mime_type: String, } -fn yt_channel_url(id: &str) -> String { - format!("https://www.youtube.com/channel/{}", id) +#[uniffi::export(async_runtime = "tokio")] +pub async fn stream_info(input: String) -> Result { + log::info!("strawcore::stream_info input={}", input); + 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)) } -/// Best-effort YouTube video-id extraction. -fn extract_video_id(input: &str) -> Result { +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 == '-') + && 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), - }), - } + extract_video_id(trimmed).map_err(|e| StrawcoreError::Unsupported { + detail: e.to_string(), + }) } -#[uniffi::export(async_runtime = "tokio")] -pub async fn stream_info(url: String) -> Result { - let id = extract_video_id(&url)?; - log::info!("strawcore::stream_info id={}", id); - let rp = RustyPipe::new(); - - // rustypipe's default `player()` uses the Web client first, which - // returns signed URLs that need JS deobfuscation. Even the TV (TVHTML5) - // client signs URLs nowadays, so deobfuscation runs and currently - // fails ("could not extract sig fn name") because YT changed the - // obfuscation pattern after rustypipe 0.11.4's last cut. - // - // Android and iOS YT-app clients serve URLs UNSIGNED — no sig - // decryption needed, ExoPlayer plays them directly. This is the same - // path NewPipe uses for its mobile + iOS-embed strategies. - let player = rp - .query() - .player_from_clients(&id, &[ClientType::Android, ClientType::Ios]) - .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 = player +fn map_stream_info( + video_id: String, + s: strawcore_core::stream::StreamInfo, +) -> StreamInfo { + let combined = s .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), - }) + .into_iter() + .map(video_to_dto) .collect(); - let video_only: Vec = player + let video_only = s .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 = player - .audio_streams - .iter() - .map(|s| AudioStreamItem { - url: s.url.clone(), - bitrate: s.bitrate as i64, - mime_type: format!("{:?}/{:?}", s.format, s.codec), - }) + .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()); - 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(), + StreamInfo { + id: video_id, + title: s.name, + uploader: s.uploader_name, + uploader_url, + description: s.description, thumbnail, - view_count: details.view_count.unwrap_or(0) as i64, - like_count: 0, - duration_seconds: details.duration as i64, + 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: player.dash_manifest_url.clone(), - hls_url: player.hls_manifest_url.clone(), + 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(), + } }