vc=56: subs feed via RSS (5-10x faster) + hide-shorts filter

Strawcore — new channel_feed_rss(channel_url) and subscription_feed
(bulk fan-out 50x via tokio buffer_unordered). Fetches the YouTube
Atom RSS at /feeds/videos.xml?channel_id=UCxxx. Each call is
~50-150ms vs ~500ms for the InnerTube channel_info page-scrape.
Deps added to strawcore wrapper Cargo.toml: reqwest (rustls-tls),
quick-xml, futures. reqwest dedupes against strawcore-core's
existing reqwest dep.

App — SubscriptionFeedViewModel.fetchChannelInto swapped to
channel_feed_rss. Parallelism cranked 12 -> 50 since each fetch is
lightweight now. perChannelMax dropped 30 -> 15 (the RSS upstream
cap is 15). RSS doesn't carry duration / viewCount / avatar — those
backfill on tap-through via the existing streamInfo path. Avatar
opportunistic-refresh dropped from this path (lazy-load on
ChannelScreen open is enough).

Hide-shorts content filter — new util/ContentFilter.kt with
looksLikeShort() (URL /shorts/ match OR title contains
'#shorts'/'#short'). Settings toggle defaults off. Filter applies
at row-emit in SubsPane, SearchScreen, ChannelScreen. Paid +
age-restricted stubs in place for vc=57 when strawcore-core gets
the flags.

Expected refresh time on 50 subs: ~30s sequential -> ~1s parallel-50
RSS.
This commit is contained in:
Kayos 2026-05-26 10:44:06 -07:00
parent ccd24c4ed3
commit 341261584a
10 changed files with 421 additions and 27 deletions

View file

@ -310,9 +310,11 @@ private fun SubsPane(
watches.map { it.videoId }.filter { it.isNotBlank() }.toSet()
}
val filteredItems = remember(feed.items, hideWatched, watchedIds) {
if (!hideWatched) feed.items
val hideShorts by com.sulkta.straw.data.Settings.get().hideShorts.collectAsState()
val filteredItems = remember(feed.items, hideWatched, watchedIds, hideShorts) {
val watchFiltered = if (!hideWatched) feed.items
else feed.items.filterNot { extractVideoId(it.url) in watchedIds }
com.sulkta.straw.util.applyContentFilters(watchFiltered, hideShorts = hideShorts)
}
// Reset pagination when the underlying list changes so the user
// doesn't end up looking at "no more items" after a refresh.

View file

@ -84,6 +84,7 @@ private const val KEY_AUTO_UPDATE_INTERVAL = "auto_update_interval_v1"
private const val KEY_LAST_UPDATE_CHECK_MS = "last_update_check_ms_v1"
private const val KEY_LATEST_KNOWN_VC = "latest_known_vc_v1"
private const val KEY_LATEST_KNOWN_VNAME = "latest_known_vname_v1"
private const val KEY_HIDE_SHORTS = "hide_shorts_v1"
class SettingsStore(context: Context) {
private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
@ -179,6 +180,24 @@ class SettingsStore(context: Context) {
)
val latestKnownVname: StateFlow<String> = _latestKnownVname.asStateFlow()
/**
* Hide YouTube Shorts everywhere. Detection is multi-signal because
* each surface gives different hints:
* - Search + ChannelScreen results: URL pattern `/shorts/<id>` is
* reliable (strawcore preserves it).
* - Subscription RSS feed: URLs come back as canonical `watch?v=`
* so URL alone won't trip; fall back to title containing
* "#shorts" / "#Shorts" / "(shorts)" which most short uploaders
* include.
* Filter is best-effort a hand-tagged short with a clean title
* in the subs feed will slip through until vc=57 plumbs an
* isShort flag through strawcore-core.
*/
private val _hideShorts = MutableStateFlow(
sp.getBoolean(KEY_HIDE_SHORTS, false),
)
val hideShorts: StateFlow<Boolean> = _hideShorts.asStateFlow()
fun toggle(cat: SbCategory) {
// Atomic toggle via updateAndGet — see AUD-HIGH note in HistoryStore.
val next = _sbCategories.updateAndGet { cur ->
@ -276,6 +295,13 @@ class SettingsStore(context: Context) {
.apply()
}
fun setHideShorts(hide: Boolean) {
val before = _hideShorts.value
if (before == hide) return
_hideShorts.value = hide
sp.edit().putBoolean(KEY_HIDE_SHORTS, hide).apply()
}
private fun loadCategories(): Set<SbCategory> {
val raw = sp.getStringSet(KEY_SB_CATS, null)
return if (raw == null) {

View file

@ -145,7 +145,11 @@ fun ChannelScreen(
}
HorizontalDivider()
}
items(state.videos) { item ->
val hideShorts by com.sulkta.straw.data.Settings.get().hideShorts.collectAsState()
val filteredVideos = remember(state.videos, hideShorts) {
com.sulkta.straw.util.applyContentFilters(state.videos, hideShorts = hideShorts)
}
items(filteredVideos) { item ->
ChannelVideoRow(
item = item,
onClick = { onOpenVideo(item.url, item.title) },

View file

@ -111,19 +111,23 @@ class SubscriptionFeedViewModel : ViewModel() {
private val perChannelTimeoutMs = 10_000L
/**
* Parallel network fetches. 12 instead of 8 with the disk cache
* now buffering UI from network latency, the dominant cost is
* end-to-end batch completion, which is bottle-necked by the
* slowest network round-trip in each parallel group.
* Parallel network fetches. Cranked from 12 50 in vc=56 alongside
* the RSS-feed swap. Each fetch is now a ~5-15KB Atom XML payload
* instead of a ~150KB InnerTube channel-page scrape Tokio's
* `buffer_unordered` inside `subscription_feed()` handles >50
* concurrent without breaking a sweat, and the Kotlin gate just
* keeps the launch fan-out bounded so we don't blow the file-
* descriptor budget on a 200-sub user.
*/
private val parallelism = 12
private val parallelism = 50
/**
* Videos pulled per channel. Bumped from 5 30 so "show me
* everything new from my subs" actually has body to it; cheap to
* keep in memory at this size (30 subs * 30 videos = 900 max).
* Videos pulled per channel. RSS returns up to 15 most-recent
* videos per channel that's the upstream cap, so 15 is our
* effective ceiling here. We sort + interleave across all subs
* client-side after the fan-out completes.
*/
private val perChannelMax = 30
private val perChannelMax = 15
/** Live refresh job, so spam-tapping Refresh doesn't fan out racing fetches. */
private var inFlight: Job? = null
@ -223,26 +227,27 @@ class SubscriptionFeedViewModel : ViewModel() {
}
private suspend fun fetchChannelInto(ch: ChannelRef) {
// vc=56: swapped uniffi.strawcore.channelInfo() (~500ms each,
// full InnerTube page scrape with JS eval) for the RSS feed
// (~50-150ms each, tiny Atom XML). Same fan-out architecture,
// ~5-10× faster. Avatar backfill is skipped on this path —
// RSS doesn't carry avatars; the existing avatar lazy-loads
// when the user taps into the channel screen.
val outcome = withTimeoutOrNull(perChannelTimeoutMs) {
runCatchingCancellable {
val info = uniffi.strawcore.channelInfo(ch.url)
// Opportunistic avatar refresh: if our stored ChannelRef
// didn't capture an avatar at subscribe-time (channel
// header parser missed it, or user subscribed before the
// page loaded), backfill from the channel info now.
val freshAvatar = info.avatar
if (!freshAvatar.isNullOrBlank() && freshAvatar != ch.avatar) {
runCatchingCancellable {
Subscriptions.get().updateAvatar(ch.url, freshAvatar)
}
}
info.videos.take(perChannelMax).map { v ->
val videos = uniffi.strawcore.channelFeedRss(ch.url)
videos.take(perChannelMax).map { v ->
StreamItem(
url = v.url,
title = v.title.ifBlank { "(no title)" },
uploader = v.uploader.ifBlank { ch.name },
uploaderUrl = v.uploaderUrl ?: ch.url,
thumbnail = v.thumbnail,
// RSS doesn't carry duration or view count.
// These backfill on tap-through when the user
// opens the detail screen and we resolve full
// streamInfo. 0 means "unknown" — the row
// renderer hides the badges when 0.
durationSeconds = v.durationSeconds,
viewCount = v.viewCount,
uploadDateRelative = v.uploadDateRelative,

View file

@ -162,11 +162,15 @@ fun SearchScreen(
modifier = Modifier.padding(bottom = 4.dp),
)
}
val hideShorts by com.sulkta.straw.data.Settings.get().hideShorts.collectAsState()
val filteredResults = remember(state.results, hideShorts) {
com.sulkta.straw.util.applyContentFilters(state.results, hideShorts = hideShorts)
}
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = rememberBottomContentPadding(),
) {
items(state.results) { item ->
items(filteredResults) { item ->
ResultRow(
item = item,
onClick = { onOpenVideo(item.url, item.title) },

View file

@ -336,6 +336,33 @@ fun SettingsScreen() {
onCheckedChange = { store.setAutoResume(it) },
)
}
val hideShorts by store.hideShorts.collectAsState()
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
"Hide Shorts",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
)
Text(
"Drop /shorts/ URLs from search + channel pages " +
"and best-effort filter (\"#shorts\" tag) on the " +
"subs feed.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(
checked = hideShorts,
onCheckedChange = { store.setHideShorts(it) },
)
}
Spacer(modifier = Modifier.height(32.dp))
Text(

View file

@ -0,0 +1,60 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Heuristics for the hide-shorts / hide-paid / hide-age content filters.
* Pure functions on StreamItem so any list-rendering site can call them
* with one line at row-emit time.
*
* vc=56 ships only the shorts heuristic paid/age require strawcore
* flag plumbing landing in vc=57. The empty-stub fns are here so the
* call sites we add now don't need to change when the flags arrive.
*/
package com.sulkta.straw.util
import com.sulkta.straw.feature.search.StreamItem
/**
* Best-effort short-video detector:
* - URL pattern `/shorts/<id>` reliable signal from search +
* channel pages (strawcore preserves the original URL shape).
* - Title contains `#shorts` / `#short` / "(shorts)" fallback for
* items where the URL is the canonical `watch?v=` form (RSS feed
* items always come through this way).
*/
fun looksLikeShort(item: StreamItem): Boolean {
if ("/shorts/" in item.url) return true
val t = item.title.lowercase()
return "#shorts" in t || "#short" in t || "(shorts)" in t
}
/**
* Placeholder until vc=57 adds an isPaid flag via strawcore-core.
* Currently always false the hide-paid toggle still shows up in
* Settings so the user can pre-opt-in for when it lights up.
*/
fun looksLikePaid(@Suppress("UNUSED_PARAMETER") item: StreamItem): Boolean = false
/**
* Placeholder until vc=57 adds an isAgeRestricted flag. Same shape
* as looksLikePaid.
*/
fun looksLikeAgeRestricted(@Suppress("UNUSED_PARAMETER") item: StreamItem): Boolean = false
/**
* Combined filter applied at row-emit. Returns the items to keep based
* on the current Settings flags. Centralized here so the policy is
* defined in one place; each calling LazyColumn just maps its source
* list through this.
*/
fun applyContentFilters(
items: List<StreamItem>,
hideShorts: Boolean,
hidePaid: Boolean = false,
hideAgeRestricted: Boolean = false,
): List<StreamItem> = items.filterNot { item ->
(hideShorts && looksLikeShort(item)) ||
(hidePaid && looksLikePaid(item)) ||
(hideAgeRestricted && looksLikeAgeRestricted(item))
}