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).
63 lines
2.2 KiB
Rust
63 lines
2.2 KiB
Rust
// 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::{from_core as search_from_core, SearchItem};
|
|
|
|
#[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>,
|
|
}
|
|
|
|
#[uniffi::export(async_runtime = "tokio")]
|
|
pub async fn channel_info(input: String) -> Result<ChannelInfo, StrawcoreError> {
|
|
log::info!("strawcore::channel_info input_len={}", input.len());
|
|
crate::runtime::ensure_initialized();
|
|
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 resolve_channel_identifier(
|
|
input: &str,
|
|
) -> Result<core_link::ChannelIdentifier, StrawcoreError> {
|
|
let trimmed = input.trim();
|
|
// Bare channel ID — UC..., 24 chars.
|
|
if trimmed.starts_with("UC") && trimmed.len() == 24 {
|
|
return Ok(core_link::ChannelIdentifier::DirectId(trimmed.into()));
|
|
}
|
|
core_link::parse(trimmed).map_err(|e| StrawcoreError::Unsupported {
|
|
detail: e.to_string(),
|
|
})
|
|
}
|
|
|
|
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: c.subscriber_count,
|
|
description: c.description,
|
|
videos,
|
|
}
|
|
}
|