vc=66: hybrid feed backfill — RSS-fast + streamInfo-complete

Cobb asked for views + durations back in the subs feed without
giving up the 5-10× RSS speedup vc=56 bought. Hybrid path:

1. Rust wrapper — new enrich_feed_item(video_url) ->
   EnrichedFeedMetadata { view_count, duration_seconds }. Thin
   wrapper around stream_info that discards the heavy play-URL
   payload. Future opt: parse watch-page HTML JSON state directly
   to skip JS deobf entirely. ~150 lines of pluck logic, punted.

2. EnrichmentStore — new SharedPreferences-lite store keyed by
   videoId, value Enrichment(viewCount, durationSeconds,
   fetchedAt). Bound to Settings.cacheTtl for staleness. Hard cap
   5000 entries with oldest-eviction.

3. SubscriptionFeedViewModel — after the RSS refresh paints,
   enrichVisibleItems() fans out enrichFeedItem for the first 30
   items (skipping any already enriched fresh). Bounded at 8 wide
   so we don't hammer YT; each call ~500ms full streamInfo so
   30 items in ~2s. Runs on StrawApp.globalScope so a
   refresh-cancel doesn't kill the in-flight enrichment.
   mergeFromCache overlays the enrichment via .withEnrichment()
   so RSS rows pick up viewCount + durationSeconds the moment
   they land. The Enrichment store's StateFlow.value is read on
   every merge call; the enrichment-complete handler triggers a
   _ui.update that re-merges.

Net behavior: feed paints instantly from RSS (no view/duration),
~2s later the visible top-N populate with full metadata. Cached
forever (or until TTL/cap). Subsequent opens read straight from
EnrichmentStore.

StrawApp.onCreate inits the new store alongside the existing
SP-backed ones.
This commit is contained in:
Kayos 2026-05-26 13:40:26 -07:00
parent 7156208c3c
commit dd151e322d
5 changed files with 236 additions and 3 deletions

View file

@ -25,6 +25,36 @@ const RSS_BASE: &str = "https://www.youtube.com/feeds/videos.xml?channel_id=";
const MAX_CONCURRENT: usize = 50;
const PER_CHANNEL_TIMEOUT_S: u64 = 8;
/// Hybrid-backfill metadata: just the two fields RSS doesn't return
/// (view count + duration). Kotlin calls this lazily for visible feed
/// items after the RSS-fed paint to fill in the gaps that
/// channel_feed_rss leaves empty.
///
/// vc=66 — built specifically so the subs feed can show 'N views ·
/// X duration' the way YT does, without paying the full channel_info
/// page-scrape cost on initial paint. The underlying stream_info IS
/// heavier than we'd like (~500ms each, runs JS deobf for play URLs
/// we'll discard) — future opt would be to parse the watch-page HTML
/// JSON state directly for just these two fields. ~100ms savings per
/// call but ~150 lines of HTML/JSON pluck logic. Punted until needed.
#[derive(Debug, Clone, uniffi::Record)]
pub struct EnrichedFeedMetadata {
pub view_count: i64,
pub duration_seconds: i64,
}
#[uniffi::export(async_runtime = "tokio")]
pub async fn enrich_feed_item(
video_url: String,
) -> Result<EnrichedFeedMetadata, StrawcoreError> {
crate::runtime::ensure_initialized();
let info = crate::stream::stream_info(video_url).await?;
Ok(EnrichedFeedMetadata {
view_count: info.view_count,
duration_seconds: info.duration_seconds,
})
}
/// Single-channel RSS — Kotlin keeps its per-channel cache + fan-out
/// (parallelism cranked to 50 in the wrapper). Each call is ~50-150ms
/// instead of the ~500ms channelInfo page-scrape, so a 50-sub refresh