Straw full-audit fix batch (2026-07-04). Confirmed HIGH/MED findings: - Updater self-brick: drop the fdroid.sulkta.com leaf+E7 SPKI pins (LE rotates the leaf every ~90d + intermediates from a pool → a routine renewal was guaranteed to miss both pins and silently kill the only in-app update path). System-CA validation + the install-time APK signature gate remain. Also distinguish "index unreachable" from "up to date", add a 30s callTimeout + bounded index read, guard the API-26 NotificationChannel, and setOnlyAlertOnce. - Request POST_NOTIFICATIONS at runtime so A13+ update/media notifications actually post (was declared but never requested → silent no-op). - isDebuggable=false on the shipped debug variant (closes ADB run-as dump of watch/search history + subs; no package-id cutover). - Crash fixes: distinctBy(url) on moreFromChannel + related (duplicate LazyColumn key); back-handling driven by live nav depth so it survives moveTaskToBack on A12+; PlaylistsStore per-store caps + off-main hydration (hostile import ANR); SponsorBlock staleness fence via NowPlaying.refreshIfCurrent. - CacheCap.nearest() snaps up to the nearest finite cap (defaults no longer resolve to Unlimited/100k on fresh installs). - RYD/SB FFI shims wrap the uniffi call (swallow a core panic per contract). - Rust wrapper: release panic="unwind" (was "abort", which defeated UniFFI catch_unwind → any core panic = whole-app SIGABRT) + a 60s wall-clock timeout on every blocking extractor call (unblocks the caller, bounds thread pileup). Pairs with the strawcore-core reliability fix.
257 lines
8.9 KiB
Rust
257 lines
8.9 KiB
Rust
// Phase 7 — `stream_info(url)` via Sulkta-OSS/strawcore-core.
|
|
// Exposed as a suspend fun.
|
|
//
|
|
// 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 strawcore_core::youtube::stream_extractor::stream_metadata as core_stream_metadata;
|
|
|
|
use crate::error::StrawcoreError;
|
|
use crate::search::SearchItem;
|
|
|
|
#[derive(Debug, Clone, uniffi::Record)]
|
|
pub struct StreamInfo {
|
|
pub id: String,
|
|
pub title: String,
|
|
pub uploader: String,
|
|
pub uploader_url: Option<String>,
|
|
pub description: String,
|
|
pub thumbnail: Option<String>,
|
|
pub view_count: i64,
|
|
pub like_count: i64,
|
|
/// Duration in seconds. 0 = live/unknown.
|
|
pub duration_seconds: i64,
|
|
|
|
/// Progressive (audio+video combined). Empty when YT only serves DASH.
|
|
pub combined: Vec<VideoStreamItem>,
|
|
/// DASH/adaptive video-only streams. Pair with `audio_only` via MergingMediaSource.
|
|
pub video_only: Vec<VideoStreamItem>,
|
|
/// DASH/adaptive audio-only streams. Sort by bitrate desc for "best audio".
|
|
pub audio_only: Vec<AudioStreamItem>,
|
|
/// Optional DASH MPD URL. ExoPlayer's DashMediaSource accepts this directly.
|
|
pub dash_mpd_url: Option<String>,
|
|
/// Optional HLS playlist URL. ExoPlayer's HlsMediaSource accepts this directly.
|
|
pub hls_url: Option<String>,
|
|
|
|
/// "Up next" list. Empty for now — populated when we port /next response.
|
|
pub related: Vec<SearchItem>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, uniffi::Record)]
|
|
pub struct VideoStreamItem {
|
|
pub url: String,
|
|
/// e.g. 1080, 720, 480.
|
|
pub height: i32,
|
|
pub bitrate: i64,
|
|
pub mime_type: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, uniffi::Record)]
|
|
pub struct AudioStreamItem {
|
|
pub url: String,
|
|
pub bitrate: i64,
|
|
pub mime_type: String,
|
|
}
|
|
|
|
/// The player-facing URLs picked from a [`StreamInfo`]'s stream lists under
|
|
/// a resolution ceiling. This is the stream-selection policy that used to
|
|
/// live in the Kotlin `resolveStreamPlayback`; the app now keeps only a thin
|
|
/// shim that supplies the ceiling (its `Settings.maxResolution`) and attaches
|
|
/// SponsorBlock segments on top. SponsorBlock is deliberately NOT here — it's
|
|
/// a separate concern the app layers over this.
|
|
#[derive(Debug, Clone, uniffi::Record)]
|
|
pub struct ResolvedStreams {
|
|
pub title: String,
|
|
pub video_url: Option<String>,
|
|
pub audio_url: Option<String>,
|
|
pub combined_url: Option<String>,
|
|
pub dash_mpd_url: Option<String>,
|
|
pub hls_url: Option<String>,
|
|
}
|
|
|
|
#[uniffi::export(async_runtime = "tokio")]
|
|
pub async fn stream_info(input: String) -> Result<StreamInfo, StrawcoreError> {
|
|
log::info!("strawcore::stream_info input_len={}", input.len());
|
|
crate::runtime::ensure_initialized();
|
|
let video_id = resolve_video_id(&input)?;
|
|
let video_id_for_call = video_id.clone();
|
|
let core = crate::runtime::run_extract("stream_info", move || {
|
|
core_stream_info(&video_id_for_call)
|
|
})
|
|
.await?;
|
|
Ok(map_stream_info(video_id, core))
|
|
}
|
|
|
|
/// Metadata-only fetch — returns `(view_count, duration_seconds)` from the
|
|
/// Android `videoDetails`, skipping the JS-deobf / streams / extra WEB
|
|
/// round-trip the full `stream_info` pays. Backs `enrich_feed_item`, which
|
|
/// only needs those two fields. Non-negative clamping mirrors
|
|
/// `map_stream_info` so the values are identical to the full path's.
|
|
pub async fn stream_metadata(input: String) -> Result<(i64, i64), StrawcoreError> {
|
|
log::info!("strawcore::stream_metadata input_len={}", input.len());
|
|
crate::runtime::ensure_initialized();
|
|
let video_id = resolve_video_id(&input)?;
|
|
let core = crate::runtime::run_extract("stream_metadata", move || {
|
|
core_stream_metadata(&video_id)
|
|
})
|
|
.await?;
|
|
Ok((clamp_nonneg(core.view_count), core.duration_seconds.max(0)))
|
|
}
|
|
|
|
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 == '-')
|
|
{
|
|
return Ok(trimmed.to_string());
|
|
}
|
|
extract_video_id(trimmed).map_err(|e| StrawcoreError::Unsupported {
|
|
detail: e.to_string(),
|
|
})
|
|
}
|
|
|
|
fn map_stream_info(
|
|
video_id: String,
|
|
s: strawcore_core::stream::StreamInfo,
|
|
) -> StreamInfo {
|
|
let combined = s
|
|
.video_streams
|
|
.into_iter()
|
|
.map(video_to_dto)
|
|
.collect();
|
|
let video_only = s
|
|
.video_only_streams
|
|
.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());
|
|
|
|
StreamInfo {
|
|
id: video_id,
|
|
title: s.name,
|
|
uploader: s.uploader_name,
|
|
uploader_url,
|
|
description: s.description,
|
|
thumbnail,
|
|
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: 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(),
|
|
}
|
|
}
|
|
|
|
/// Pick the player URLs from an already-fetched [`StreamInfo`]. Pure +
|
|
/// synchronous (no network). `max_height` is the app's
|
|
/// `Settings.maxResolution` ceiling — `Int.MAX_VALUE` means "no cap". This
|
|
/// is a byte-for-byte port of the prior Kotlin `resolveStreamPlayback`:
|
|
/// the app keeps a thin shim that passes the ceiling and tacks on
|
|
/// SponsorBlock segments.
|
|
#[uniffi::export]
|
|
pub fn resolve_playback(info: StreamInfo, max_height: i32) -> ResolvedStreams {
|
|
// Borrow the stream lists for selection before moving the owned String
|
|
// fields into the result struct (disjoint fields, so this is fine).
|
|
let video_url = pick_video(&info.video_only, max_height);
|
|
let audio_url = pick_audio(&info.audio_only);
|
|
let combined_url = pick_video(&info.combined, max_height);
|
|
ResolvedStreams {
|
|
title: info.title,
|
|
video_url,
|
|
audio_url,
|
|
combined_url,
|
|
// Kotlin used `takeIf { it.isNotBlank() }` — empty OR all-whitespace
|
|
// collapses to None.
|
|
dash_mpd_url: info.dash_mpd_url.filter(|s| !s.trim().is_empty()),
|
|
hls_url: info.hls_url.filter(|s| !s.trim().is_empty()),
|
|
}
|
|
}
|
|
|
|
/// Highest-bitrate video stream at or below `max_height`; if none fit, the
|
|
/// lowest-height stream (so a too-high-only set still plays without blowing
|
|
/// a data plan). The FIRST element wins ties — matching Kotlin's
|
|
/// `maxByOrNull`/`minByOrNull`, which keep the first max/min, unlike Rust's
|
|
/// `Iterator::max_by_key` (last on ties). Preserving that keeps the picked
|
|
/// URL identical to the previous Kotlin picker on same-bitrate/-height ties.
|
|
fn pick_video(streams: &[VideoStreamItem], max_height: i32) -> Option<String> {
|
|
if streams.is_empty() {
|
|
return None;
|
|
}
|
|
let mut best_capped: Option<&VideoStreamItem> = None;
|
|
for s in streams.iter().filter(|s| s.height <= max_height) {
|
|
match best_capped {
|
|
// Keep the existing pick unless this one is strictly higher
|
|
// bitrate — first-on-ties, matching `maxByOrNull`.
|
|
Some(b) if b.bitrate >= s.bitrate => {}
|
|
_ => best_capped = Some(s),
|
|
}
|
|
}
|
|
if let Some(s) = best_capped {
|
|
return Some(s.url.clone());
|
|
}
|
|
let mut lowest: Option<&VideoStreamItem> = None;
|
|
for s in streams {
|
|
match lowest {
|
|
// First-on-ties, matching `minByOrNull`.
|
|
Some(b) if b.height <= s.height => {}
|
|
_ => lowest = Some(s),
|
|
}
|
|
}
|
|
lowest.map(|s| s.url.clone())
|
|
}
|
|
|
|
/// Highest-bitrate audio stream, first-on-ties (matching Kotlin
|
|
/// `maxByOrNull { it.bitrate }`).
|
|
fn pick_audio(streams: &[AudioStreamItem]) -> Option<String> {
|
|
let mut best: Option<&AudioStreamItem> = None;
|
|
for s in streams {
|
|
match best {
|
|
Some(b) if b.bitrate >= s.bitrate => {}
|
|
_ => best = Some(s),
|
|
}
|
|
}
|
|
best.map(|s| s.url.clone())
|
|
}
|