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,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<SearchItem>,
|
||||
}
|
||||
|
||||
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<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 {
|
||||
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<String, StrawcoreError> {
|
||||
fn resolve_channel_identifier(
|
||||
input: &str,
|
||||
) -> Result<core_link::ChannelIdentifier, StrawcoreError> {
|
||||
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<ChannelInfo, StrawcoreError> {
|
||||
let key = extract_channel_input(&channel_url)?;
|
||||
log::info!("strawcore::channel_info key={}", key);
|
||||
let rp = RustyPipe::new();
|
||||
|
||||
// channel_videos(id) returns Channel<Paginator<VideoItem>> — the
|
||||
// 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,
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue