v0.1.0-W (vc=10): U-4 + U-5 — channels via rustypipe + rip NewPipeExtractor

channel_info(url) UniFFI suspend fn. ChannelViewModel +
SubscriptionFeedViewModel both swap. NewPipeExtractor (Java) is OUT —
zero org.schabi.newpipe classes in the APK now.

Cleanup:
- NewPipeDownloader.kt deleted (was the OkHttp adapter)
- Thumbnails.kt deleted (rustypipe returns full URLs)
- NewPipe.init() dropped from StrawApp.onCreate
- libs.newpipe.extractor removed from build.gradle.kts
- STRAW_USER_AGENT + strawHttpClient() now live in net/Http.kt
- RydClient + SponsorBlockClient + PlayerScreen + PlaybackService all
  read from net/Http.kt instead of the extractor package

rustypipe API quirks beat:
- channel_videos(id) is the right method (channel() doesn't exist)
- ChannelInfo struct = basic metadata; Channel<T> wrapper carries
  name/avatar/banner + .content is the paginator of videos
- description is String (not Option), subscriber_count is Option<u64>

End state: strawApp Kotlin is ~UI + thin glue to strawcore. The Rust
core handles search / streamInfo / channel / channel_videos via UniFFI
suspend fns. Tokio + reqwest + rustls + rquickjs all packed in
libstrawcore.so (~6MB per ABI). APK 40MB total.
This commit is contained in:
Kayos 2026-05-24 09:11:14 -07:00
parent 7327de2843
commit a13896f5e9
14 changed files with 213 additions and 257 deletions

View file

@ -0,0 +1,113 @@
// Phase U-4 — `channel_info(channel_url)` via rustypipe.
//
// Returns channel metadata + the channel's latest videos (the "Videos" tab).
// Used by ChannelScreen (single-channel view) AND
// SubscriptionFeedViewModel (which fans out across all subscriptions).
use crate::error::StrawcoreError;
use crate::search::SearchItem;
use rustypipe::client::RustyPipe;
#[derive(Debug, Clone, uniffi::Record)]
pub struct ChannelInfo {
pub id: String,
pub name: String,
pub avatar: Option<String>,
pub banner: Option<String>,
/// -1 = unknown / hidden by the channel.
pub subscriber_count: i64,
pub description: String,
/// Latest videos from the channel (Videos tab, newest first).
pub videos: Vec<SearchItem>,
}
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)
}
/// 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();
// Bare channel ID — usually 24 chars starting with UC.
if trimmed.starts_with("UC") && trimmed.len() == 24 {
return Ok(trimmed.to_string());
}
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),
})
}
#[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,
avatar,
banner,
subscriber_count: channel.subscriber_count.map(|n| n as i64).unwrap_or(-1),
description: channel.description,
videos,
})
}

View file

@ -8,6 +8,7 @@
use std::sync::Once;
mod channel;
mod error;
mod runtime;
mod search;
@ -17,6 +18,7 @@ mod stream;
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};