straw/rust/strawcore/src/stream.rs
Kayos da48109a4d vc=40: loop round 2/5 — round-1 misses + new HIGHs from round-5 audits
Three parallel Opus round-5 audits ran on vc=39. The big finds were
regressions on vc=39's own fixes — the retry-init wiring was incomplete,
the URL-fence pattern missed CancellationException swallowing, and the
SettingsStore idempotency branch was dead code. Real round-5 work.

HIGH
  R5-1  Rust `ensure_initialized` was only called from `init_logging`
        — extractor entry points never invoked it, so the 5s-backoff
        retry from vc=39 was unreachable. A cold-boot init failure
        still bricked extraction for the whole process. Now every
        `search()` / `stream_info()` / `channel_info()` calls
        ensure_initialized at entry; cheap when INITIALIZED is true.
  R5-2  runtime.rs in-progress race: prior shape stamped
        LAST_ATTEMPT_MS at the *start* of an attempt, then let
        concurrent callers short-circuit via the backoff check
        and proceed to call into the extractor before init
        completed. New: lock FIRST (mutex IS the in-progress
        queue), re-check INITIALIZED under lock, only THEN check
        backoff (and only stamp it on failure).
  R5-3  `runCatching` in VideoDetailViewModel + SubscriptionFeed
        swallows CancellationException — when a job was cancelled
        mid-suspend inside channelInfo/RYD/SB, the inner cancellation
        was eaten, the lambda returned its default, the job carried
        past the runCatching to its terminal write, and the URL fence
        let it through because same-URL races can't be distinguished
        by string equality. New util.runCatchingCancellable
        re-throws CancellationException; all coroutine-body
        runCatching sites in the affected VMs migrated.
  R5-4  SearchViewModel.submit post-network fence only guarded
        `_ui.update`. SearchCache.record + pool rebuild proceeded
        for a cancelled query → ghost cache entries for queries
        the user abandoned mid-stroke. Now: re-check the query
        AFTER the IO suspend and before the cache write.
  R5-5  ChannelViewModel.load / VideoDetailViewModel.load now gate
        the extractor entry on isAllowedYtUrl(channelUrl/streamUrl).
        Prior shape only gated recordWatch persistence — extractor
        invocation for poisoned uploaderUrl still happened.

MED
  R5-6  SettingsStore.set{MaxResolution,ThemeMode}: vc=39 used
        `updateAndGet { r } == r` which is unconditionally true
        (lambda ignores prior) — the in-memory equality check was
        dead code. SP-side check still gated the disk write so the
        feature worked, but the dead branch was a comment-vs-code
        liar. Rewrote with explicit before-capture + equality
        gate.
  R5-7  SponsorBlockSkipLoop polled `controller.currentPosition`
        every 150ms even when paused — paused-overnight playback
        ate ~24k binder calls/hour. Now: when `!isPlaying`, sleep
        1s and continue.
  R5-8  StrawApp.appScope had no CoroutineExceptionHandler — an
        uncaught throwable in a top-level launch could crash on
        cold start even with SupervisorJob (top-level failure
        still propagates to default handler). Added handler that
        logs via strawLogW.
  R5-9  YtUrl.isAllowedYtUrl now requires http/https scheme
        (schemeless `//host/...` URLs no longer pass) and strips
        a single trailing dot from host (RFC FQDN form). Defense
        in depth.

LOW
  R5-10 NowPlaying.set() removed — non-CAS setter footgun
        alongside the race-free claim()/CAS path. No external
        callers (grep clean). Doc-comment updated.

Deferred (later round / different scope):
  - PlaylistsStore URL canonicalization (round-5 MED-1 — needs a
    shared YT-id-extract util; not blocking).
  - Release R8 + Nav rememberSaveable (still deferred).
  - LazyColumn keys + collectAsStateWithLifecycle (cosmetic).
  - SponsorBlockSkipLoop currentMediaItem binding (round-5
    deferred).
2026-05-25 15:12:30 -07:00

154 lines
4.6 KiB
Rust

// Phase 7 — `stream_info(url)` via Sulkta-Coop/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 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,
}
#[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 = 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))
}
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(),
}
}