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
|
|
@ -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
|
||||
// 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<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>,
|
||||
}
|
||||
|
||||
|
|
@ -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<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 extract_video_id(input: &str) -> Result<String, StrawcoreError> {
|
||||
fn resolve_video_id(input: &str) -> Result<String, StrawcoreError> {
|
||||
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<StreamInfo, StrawcoreError> {
|
||||
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<VideoStreamItem> = 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<VideoStreamItem> = 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<AudioStreamItem> = 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(),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue