Phase 7 — strawcore wrapper now bridges to Sulkta-OSS/strawcore-core
Replaces the rustypipe-backed extraction with calls into the new
NPE-port crate. The UniFFI surface Kotlin sees is unchanged:
suspend fun search(query: String): List<SearchItem>
suspend fun streamInfo(input: String): StreamInfo
suspend fun channelInfo(input: String): ChannelInfo
fun initLogging() // also wires the strawcore-core Downloader
fun helloFromRust(name: String): String
rust/strawcore/
* Cargo.toml — dropped rustypipe + rquickjs-sys direct dep;
added strawcore-core path dep (../../../strawcore)
* src/error.rs — From<strawcore_core::ExtractionError>, mapping
ContentUnavailable variants to typed
StrawcoreError cases (AgeRestricted, GeoRestricted,
Private, RequiresLogin) instead of bucketing all
to Extractor
* src/runtime.rs — Once-guarded ReqwestDownloader init via
NewPipe::init_full
* src/search.rs — search() spawn_blocks core search_extractor::search
against SearchFilter::Videos
* src/stream.rs — stream_info() resolves URL → video_id via
strawcore_core::linkhandler::stream, then
spawn_blocks core stream_extractor::stream_info,
then maps StreamInfo → wrapper DTOs (combined/
video_only/audio_only/dash/hls)
* src/channel.rs — channel_info() parses input via
strawcore_core::linkhandler::channel (handle /
custom-url / legacy-user resolution lives in
core), then spawn_blocks core channel::channel_info
Build verified: wrapper compiles linking strawcore-core, uniffi-bindgen
generates Kotlin bindings with the same suspend fun + data class
surface Kotlin already consumes. Android NDK cross-compile + APK + on-
device smoke pending (needs build-host container).
This commits onto rollback/vc18-back-to-NPE — the existing Kotlin code
still calls NewPipeExtractor directly. Switching the Kotlin side to
consume the rust wrapper is a separate cutover.
This commit is contained in:
parent
c1d7fffb1f
commit
fb89b22685
7 changed files with 250 additions and 309 deletions
|
|
@ -17,22 +17,19 @@ crate-type = ["cdylib", "staticlib"]
|
||||||
# `tokio` feature wires `#[uniffi::export(async_runtime = "tokio")]` so async
|
# `tokio` feature wires `#[uniffi::export(async_runtime = "tokio")]` so async
|
||||||
# fns surface as suspend fun on the Kotlin side.
|
# fns surface as suspend fun on the Kotlin side.
|
||||||
uniffi = { version = "0.28", features = ["cli", "tokio"] }
|
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"] }
|
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync"] }
|
||||||
# rustypipe — the actual YouTube Innertube client. Phase U-2 wires search.
|
# The actual YT extractor lives in Sulkta-OSS/strawcore. Renamed locally
|
||||||
# Force rustls + webpki-roots so we don't pull openssl-sys (cross-compiling
|
# to `strawcore_core` to avoid collision with this wrapper crate's name
|
||||||
# system OpenSSL to four Android ABIs is a tarpit; rustls is pure-Rust).
|
# (which has to stay `strawcore` so the .so name + Kotlin loadLibrary
|
||||||
rustypipe = { version = "0.11", default-features = false, features = ["rustls-tls-webpki-roots"] }
|
# call keep working).
|
||||||
# rquickjs-sys (transitive dep of rustypipe for YT signature decryption JS)
|
strawcore-core = { path = "../../../strawcore" }
|
||||||
# doesn't ship prebuilt Android bindings. Direct-depend with `bindgen` feature
|
|
||||||
# so it generates them at build time. The build host has libclang preinstalled.
|
|
||||||
rquickjs-sys = { version = "0.9", default-features = false, features = ["bindgen"] }
|
|
||||||
# Error glue.
|
# Error glue.
|
||||||
thiserror = "1"
|
thiserror = "1"
|
||||||
# Single-threaded init for the runtime + extractor singletons.
|
# Single-threaded init for the runtime + extractor singletons.
|
||||||
once_cell = "1"
|
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`.
|
# Android log integration — `log::info!()` ends up in `adb logcat -s strawcore`.
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
android_logger = { version = "0.14", default-features = false }
|
android_logger = { version = "0.14", default-features = false }
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
// Phase U-4 — `channel_info(channel_url)` via rustypipe.
|
// Phase 7 — `channel_info(channel_url)` via the new strawcore.
|
||||||
//
|
|
||||||
// Returns channel metadata + the channel's latest videos (the "Videos" tab).
|
|
||||||
// Used by ChannelScreen (single-channel view) AND
|
// Used by ChannelScreen (single-channel view) AND
|
||||||
// SubscriptionFeedViewModel (which fans out across all subscriptions).
|
// 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::error::StrawcoreError;
|
||||||
use crate::search::SearchItem;
|
use crate::search::{from_core as search_from_core, SearchItem};
|
||||||
use rustypipe::client::RustyPipe;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, uniffi::Record)]
|
#[derive(Debug, Clone, uniffi::Record)]
|
||||||
pub struct ChannelInfo {
|
pub struct ChannelInfo {
|
||||||
|
|
@ -21,93 +21,42 @@ pub struct ChannelInfo {
|
||||||
pub videos: Vec<SearchItem>,
|
pub videos: Vec<SearchItem>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn yt_video_url(id: &str) -> String {
|
#[uniffi::export(async_runtime = "tokio")]
|
||||||
format!("https://www.youtube.com/watch?v={}", id)
|
pub async fn channel_info(input: String) -> Result<ChannelInfo, StrawcoreError> {
|
||||||
|
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 {
|
fn resolve_channel_identifier(
|
||||||
format!("https://www.youtube.com/channel/{}", id)
|
input: &str,
|
||||||
}
|
) -> Result<core_link::ChannelIdentifier, StrawcoreError> {
|
||||||
|
|
||||||
/// 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<String, StrawcoreError> {
|
|
||||||
let trimmed = input.trim();
|
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 {
|
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 {
|
core_link::parse(trimmed).map_err(|e| StrawcoreError::Unsupported {
|
||||||
detail: format!("bad URL: {}", e),
|
detail: e.to_string(),
|
||||||
})?;
|
|
||||||
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),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[uniffi::export(async_runtime = "tokio")]
|
fn map_channel(c: CoreInfo) -> ChannelInfo {
|
||||||
pub async fn channel_info(channel_url: String) -> Result<ChannelInfo, StrawcoreError> {
|
let avatar = c.avatars.last().map(|i| i.url().to_string());
|
||||||
let key = extract_channel_input(&channel_url)?;
|
let banner = c.banners.last().map(|i| i.url().to_string());
|
||||||
log::info!("strawcore::channel_info key={}", key);
|
let videos = c.recent_videos.into_iter().map(search_from_core).collect();
|
||||||
let rp = RustyPipe::new();
|
ChannelInfo {
|
||||||
|
id: c.channel_id,
|
||||||
// channel_videos(id) returns Channel<Paginator<VideoItem>> — the
|
name: c.name,
|
||||||
// Channel<T> 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<SearchItem> = 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,
|
|
||||||
avatar,
|
avatar,
|
||||||
banner,
|
banner,
|
||||||
subscriber_count: channel.subscriber_count.map(|n| n as i64).unwrap_or(-1),
|
subscriber_count: c.subscriber_count,
|
||||||
description: channel.description,
|
description: c.description,
|
||||||
videos,
|
videos,
|
||||||
})
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,13 +17,51 @@ pub enum StrawcoreError {
|
||||||
|
|
||||||
#[error("unsupported: {detail}")]
|
#[error("unsupported: {detail}")]
|
||||||
Unsupported { detail: String },
|
Unsupported { detail: String },
|
||||||
|
|
||||||
|
#[error("age restricted")]
|
||||||
|
AgeRestricted,
|
||||||
|
|
||||||
|
#[error("geo restricted")]
|
||||||
|
GeoRestricted,
|
||||||
|
|
||||||
|
#[error("private content")]
|
||||||
|
Private,
|
||||||
|
|
||||||
|
#[error("requires login: {detail}")]
|
||||||
|
RequiresLogin { detail: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<rustypipe::error::Error> for StrawcoreError {
|
impl From<strawcore_core::exceptions::ExtractionError> for StrawcoreError {
|
||||||
fn from(e: rustypipe::error::Error) -> Self {
|
fn from(e: strawcore_core::exceptions::ExtractionError) -> Self {
|
||||||
// Bucket every rustypipe error into Extractor for now. Phase U-3+
|
use strawcore_core::exceptions::{ContentUnavailable, ExtractionError, NetworkError};
|
||||||
// can pull apart specific cases (e.g. ageRestricted → NotFound,
|
match e {
|
||||||
// network timeouts → Network) when we have a tighter UI for it.
|
ExtractionError::Network(NetworkError::Recaptcha { url }) => {
|
||||||
StrawcoreError::Extractor { msg: e.to_string() }
|
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 },
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
// Thin layer over the new Sulkta-OSS/strawcore-core crate. All extractor
|
||||||
// String so we know JNI + UniFFI marshalling works end-to-end before we
|
// logic (InnerTube, JS deobf, stream parsing, search, channel, playlist)
|
||||||
// pull in rustypipe.
|
// lives in core. This file:
|
||||||
//
|
// * re-exports the DTOs Kotlin expects under their familiar names
|
||||||
// Phase U-2+ adds real APIs (search, stream_info, channel_info).
|
// * 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;
|
use std::sync::Once;
|
||||||
|
|
||||||
|
|
@ -14,18 +16,14 @@ mod runtime;
|
||||||
mod search;
|
mod search;
|
||||||
mod stream;
|
mod stream;
|
||||||
|
|
||||||
#[allow(unused_imports)]
|
|
||||||
use runtime::block_on;
|
|
||||||
|
|
||||||
// Re-exports so UniFFI sees the types at the crate root for macro discovery.
|
// Re-exports so UniFFI sees the types at the crate root for macro discovery.
|
||||||
pub use channel::ChannelInfo;
|
pub use channel::ChannelInfo;
|
||||||
pub use error::StrawcoreError;
|
pub use error::StrawcoreError;
|
||||||
pub use search::SearchItem;
|
pub use search::SearchItem;
|
||||||
pub use stream::{AudioStreamItem, StreamInfo, VideoStreamItem};
|
pub use stream::{AudioStreamItem, StreamInfo, VideoStreamItem};
|
||||||
|
|
||||||
/// Initialize Android logging once. The Kotlin side calls this from
|
/// Initialize Android logging + the strawcore-core HTTP downloader.
|
||||||
/// StrawApp.onCreate() so anything emitted via `log::info!()` shows up in
|
/// Kotlin calls this from StrawApp.onCreate(). Idempotent.
|
||||||
/// `adb logcat -s strawcore`.
|
|
||||||
#[uniffi::export]
|
#[uniffi::export]
|
||||||
pub fn init_logging() {
|
pub fn init_logging() {
|
||||||
static ONCE: Once = Once::new();
|
static ONCE: Once = Once::new();
|
||||||
|
|
@ -37,14 +35,18 @@ pub fn init_logging() {
|
||||||
);
|
);
|
||||||
log::info!("strawcore initialized");
|
log::info!("strawcore initialized");
|
||||||
});
|
});
|
||||||
|
runtime::ensure_initialized();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Smoke-test entry point — round-trip a string through JNI so we know
|
/// Smoke-test entry point — round-trip a string through JNI.
|
||||||
/// the bridge is working before pulling in rustypipe.
|
|
||||||
#[uniffi::export]
|
#[uniffi::export]
|
||||||
pub fn hello_from_rust(name: String) -> String {
|
pub fn hello_from_rust(name: String) -> String {
|
||||||
log::info!("hello_from_rust called with name={}", name);
|
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!();
|
uniffi::setup_scaffolding!();
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,29 @@
|
||||||
// Tokio runtime singleton — rustypipe is async-first, so every exported
|
// Runtime bootstrap. Called once from Kotlin's StrawApp.onCreate via
|
||||||
// function that touches the network needs to drive a runtime. Building one
|
// init_logging(). Wires the strawcore-core Downloader + Localization
|
||||||
// per call is wasteful; we build one shared multi-thread runtime at first
|
// singleton so the extractor calls have an HTTP client to use.
|
||||||
// use and `block_on` against it.
|
|
||||||
|
|
||||||
use once_cell::sync::Lazy;
|
use std::sync::{Arc, Once};
|
||||||
use tokio::runtime::Runtime;
|
|
||||||
|
|
||||||
static RT: Lazy<Runtime> = Lazy::new(|| {
|
use strawcore_core::downloader::ReqwestDownloader;
|
||||||
tokio::runtime::Builder::new_multi_thread()
|
use strawcore_core::localization::{ContentCountry, Localization};
|
||||||
.worker_threads(2) // Mobile — we don't need many. Most blocks are I/O.
|
use strawcore_core::newpipe::NewPipe;
|
||||||
.enable_all()
|
|
||||||
.thread_name("strawcore-tokio")
|
|
||||||
.build()
|
|
||||||
.expect("strawcore: failed to build tokio runtime")
|
|
||||||
});
|
|
||||||
|
|
||||||
#[allow(dead_code)]
|
static INIT: Once = Once::new();
|
||||||
pub fn block_on<F: std::future::Future>(fut: F) -> F::Output {
|
|
||||||
RT.block_on(fut)
|
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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
// Phase U-2 — search via rustypipe, exposed to Kotlin as a suspend fun.
|
// Phase 7 — search via Sulkta-OSS/strawcore-core. Exposed to Kotlin
|
||||||
//
|
// as a suspend fun. SearchItem field shape is unchanged from Phase U-2
|
||||||
// `SearchItem` mirrors the existing Kotlin `StreamItem` field for field so
|
// so Kotlin callers (SearchViewModel) keep working with no code
|
||||||
// `SearchViewModel` can swap NewPipeExtractor for this with a one-line
|
// changes.
|
||||||
// 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).
|
use strawcore_core::stream::StreamInfoItem;
|
||||||
|
use strawcore_core::youtube::linkhandler::search::SearchFilter;
|
||||||
|
use strawcore_core::youtube::search_extractor;
|
||||||
|
|
||||||
use crate::error::StrawcoreError;
|
use crate::error::StrawcoreError;
|
||||||
use rustypipe::client::RustyPipe;
|
|
||||||
use rustypipe::model::YouTubeItem;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, uniffi::Record)]
|
#[derive(Debug, Clone, uniffi::Record)]
|
||||||
pub struct SearchItem {
|
pub struct SearchItem {
|
||||||
|
|
@ -22,44 +22,40 @@ pub struct SearchItem {
|
||||||
pub view_count: i64,
|
pub view_count: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn yt_video_url(id: &str) -> String {
|
pub(crate) fn from_core(item: StreamInfoItem) -> SearchItem {
|
||||||
format!("https://www.youtube.com/watch?v={}", id)
|
let uploader_url = if item.uploader_url.is_empty() {
|
||||||
}
|
None
|
||||||
|
} else {
|
||||||
fn yt_channel_url(id: &str) -> String {
|
Some(item.uploader_url)
|
||||||
format!("https://www.youtube.com/channel/{}", id)
|
};
|
||||||
|
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")]
|
#[uniffi::export(async_runtime = "tokio")]
|
||||||
pub async fn search(query: String) -> Result<Vec<SearchItem>, StrawcoreError> {
|
pub async fn search(query: String) -> Result<Vec<SearchItem>, StrawcoreError> {
|
||||||
log::info!("strawcore::search query={}", query);
|
log::info!("strawcore::search query={}", query);
|
||||||
let rp = RustyPipe::new();
|
let result = tokio::task::spawn_blocking(move || {
|
||||||
let results = rp.query().search(query).await?;
|
search_extractor::search(&query, SearchFilter::Videos)
|
||||||
|
})
|
||||||
let items: Vec<SearchItem> = results
|
.await
|
||||||
.items
|
.map_err(|e| StrawcoreError::Extractor {
|
||||||
.items
|
msg: format!("join: {e}"),
|
||||||
.into_iter()
|
})??;
|
||||||
.filter_map(|item| match item {
|
Ok(result.videos.into_iter().map(from_core).collect())
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,15 @@
|
||||||
// Phase U-3 — `stream_info(url)` via rustypipe, exposed as a suspend fun.
|
// Phase 7 — `stream_info(url)` via Sulkta-OSS/strawcore-core.
|
||||||
|
// Exposed as a suspend fun.
|
||||||
//
|
//
|
||||||
// Drives both VideoDetailScreen (title/uploader/description/thumbnail) and
|
// StreamInfo/AudioStreamItem/VideoStreamItem field shapes are unchanged
|
||||||
// PlayerScreen (audio/video stream URLs that ExoPlayer loads from). One
|
// from Phase U-3 so Kotlin VideoDetailScreen + PlayerScreen +
|
||||||
// Rust call replaces two NewPipeExtractor StreamInfo.getInfo() round-trips.
|
// ResolvedPlayback consume them with zero code changes.
|
||||||
//
|
|
||||||
// `StreamInfo` keeps field names parallel to the Kotlin-side VideoDetail
|
use strawcore_core::youtube::linkhandler::stream::extract_video_id;
|
||||||
// + ResolvedPlayback so the ViewModels swap one-to-one.
|
use strawcore_core::youtube::stream_extractor::stream_info as core_stream_info;
|
||||||
//
|
|
||||||
// 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::error::StrawcoreError;
|
||||||
use crate::search::SearchItem;
|
use crate::search::SearchItem;
|
||||||
use rustypipe::client::{ClientType, RustyPipe};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, uniffi::Record)]
|
#[derive(Debug, Clone, uniffi::Record)]
|
||||||
pub struct StreamInfo {
|
pub struct StreamInfo {
|
||||||
|
|
@ -43,7 +35,7 @@ pub struct StreamInfo {
|
||||||
/// Optional HLS playlist URL. ExoPlayer's HlsMediaSource accepts this directly.
|
/// Optional HLS playlist URL. ExoPlayer's HlsMediaSource accepts this directly.
|
||||||
pub hls_url: Option<String>,
|
pub hls_url: Option<String>,
|
||||||
|
|
||||||
/// "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<SearchItem>,
|
pub related: Vec<SearchItem>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -63,140 +55,99 @@ pub struct AudioStreamItem {
|
||||||
pub mime_type: String,
|
pub mime_type: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn yt_channel_url(id: &str) -> String {
|
#[uniffi::export(async_runtime = "tokio")]
|
||||||
format!("https://www.youtube.com/channel/{}", id)
|
pub async fn stream_info(input: String) -> Result<StreamInfo, StrawcoreError> {
|
||||||
|
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 resolve_video_id(input: &str) -> Result<String, StrawcoreError> {
|
||||||
fn extract_video_id(input: &str) -> Result<String, StrawcoreError> {
|
|
||||||
let trimmed = input.trim();
|
let trimmed = input.trim();
|
||||||
|
// Bare 11-char id?
|
||||||
if trimmed.len() == 11
|
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());
|
return Ok(trimmed.to_string());
|
||||||
}
|
}
|
||||||
let url = url::Url::parse(trimmed).map_err(|e| StrawcoreError::Unsupported {
|
extract_video_id(trimmed).map_err(|e| StrawcoreError::Unsupported {
|
||||||
detail: format!("bad URL: {}", e),
|
detail: e.to_string(),
|
||||||
})?;
|
})
|
||||||
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")]
|
fn map_stream_info(
|
||||||
pub async fn stream_info(url: String) -> Result<StreamInfo, StrawcoreError> {
|
video_id: String,
|
||||||
let id = extract_video_id(&url)?;
|
s: strawcore_core::stream::StreamInfo,
|
||||||
log::info!("strawcore::stream_info id={}", id);
|
) -> StreamInfo {
|
||||||
let rp = RustyPipe::new();
|
let combined = s
|
||||||
|
|
||||||
// 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<VideoStreamItem> = player
|
|
||||||
.video_streams
|
.video_streams
|
||||||
.iter()
|
.into_iter()
|
||||||
.map(|s| VideoStreamItem {
|
.map(video_to_dto)
|
||||||
url: s.url.clone(),
|
|
||||||
height: s.height as i32,
|
|
||||||
bitrate: s.bitrate as i64,
|
|
||||||
mime_type: format!("{:?}/{:?}", s.format, s.codec),
|
|
||||||
})
|
|
||||||
.collect();
|
.collect();
|
||||||
let video_only: Vec<VideoStreamItem> = player
|
let video_only = s
|
||||||
.video_only_streams
|
.video_only_streams
|
||||||
.iter()
|
.into_iter()
|
||||||
.map(|s| VideoStreamItem {
|
.map(video_to_dto)
|
||||||
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();
|
.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());
|
StreamInfo {
|
||||||
|
id: video_id,
|
||||||
Ok(StreamInfo {
|
title: s.name,
|
||||||
id: details.id.clone(),
|
uploader: s.uploader_name,
|
||||||
title: details.name.clone().unwrap_or_default(),
|
uploader_url,
|
||||||
uploader: details.channel_name.clone().unwrap_or_default(),
|
description: s.description,
|
||||||
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,
|
thumbnail,
|
||||||
view_count: details.view_count.unwrap_or(0) as i64,
|
view_count: clamp_nonneg(s.view_count),
|
||||||
like_count: 0,
|
like_count: clamp_nonneg(s.like_count),
|
||||||
duration_seconds: details.duration as i64,
|
duration_seconds: s.duration_seconds.max(0),
|
||||||
combined,
|
combined,
|
||||||
video_only,
|
video_only,
|
||||||
audio_only,
|
audio_only,
|
||||||
dash_mpd_url: player.dash_manifest_url.clone(),
|
dash_mpd_url: s.dash_manifest_url,
|
||||||
hls_url: player.hls_manifest_url.clone(),
|
hls_url: s.hls_manifest_url,
|
||||||
related: Vec::new(),
|
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(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue