Hide Shorts in the subscription feed (on by default) #31

Merged
kayos merged 14 commits from feat/hide-shorts-feed into main 2026-10-22 14:39:48 -07:00
19 changed files with 951 additions and 65 deletions

View file

@ -81,6 +81,22 @@ jobs:
|| { echo "::error::versionCode auto-patch failed (sed matched nothing)"; exit 1; }
echo "auto versionCode = $VC (commit count $COUNT)"
# The unit tests run before the APK is built, so that a failing test
# fails the job before anything is built, signed or published. Neither
# step has a secret in its environment. Both use the build step's
# target dir (off the container rootfs) and its low CPU and I/O priority.
- name: Rust unit tests (strawcore)
working-directory: straw/rust
env:
CARGO_TARGET_DIR: ${{ github.workspace }}/cargo-target
run: ionice -c3 nice -n 19 cargo test -p strawcore --lib
- name: JVM unit tests
working-directory: straw
env:
CARGO_TARGET_DIR: ${{ github.workspace }}/cargo-target
run: ionice -c3 nice -n 19 ./gradlew :strawApp:testDebugUnitTest --no-daemon --stacktrace
- name: Assemble debug APK
working-directory: straw
env:

View file

@ -8,10 +8,19 @@
// turns a 50-sub refresh from ~5-8s parallel-12 to ~1s parallel-50.
//
// RSS is intentionally lossy — it returns title/url/published/thumbnail
// only. No duration, no view count, no shorts/age/paid flags. That's the
// right trade for a feed-refresh use case: tap-through still goes through
// the full stream_info path to fetch the rich metadata when actually
// needed.
// only. No duration, no view count, no age/paid flags. That's the right
// trade for a feed-refresh use case: tap-through still goes through the
// full stream_info path to fetch the rich metadata when actually needed.
//
// It does say which entries are Shorts: a Short's
// `<link rel="alternate">` points at `https://www.youtube.com/shorts/<id>`
// where every other upload points at `/watch?v=<id>`. Measured 2026-10-22
// on 7 public channel feeds (91 entries): the 33 entries with a /shorts/
// link were exactly the channel-feed entries also listed in the channel's
// Shorts playlist feed (`playlist_id=UUSH...`), and none of the 58
// /watch links were. parse_rss keeps that signal by giving a Short its
// /shorts/ URL, which is the shape the app's hide-Shorts filter
// (ContentFilter.looksLikeShort) already drops. Costs no extra request.
use std::sync::OnceLock;
use std::time::Duration;
@ -23,6 +32,11 @@ use crate::error::StrawcoreError;
use crate::search::SearchItem;
const RSS_BASE: &str = "https://www.youtube.com/feeds/videos.xml?channel_id=";
const WATCH_URL_PREFIX: &str = "https://www.youtube.com/watch?v=";
/// Prefix of a Short's `<link rel="alternate">` in the channel feed, in the
/// exact form measured (see the header comment). Also the URL a Short is
/// given, so the app's `/shorts/` URL check recognises it.
const SHORTS_URL_PREFIX: &str = "https://www.youtube.com/shorts/";
const MAX_CONCURRENT: usize = 50;
const PER_CHANNEL_TIMEOUT_S: u64 = 8;
/// Cap on the body bytes we'll read for a single RSS fetch. Real YT
@ -269,6 +283,9 @@ fn parse_rss(body: &str, channel_id: String) -> Option<Vec<SearchItem>> {
let mut uploader_url = String::new();
let mut thumbnail: Option<String> = None;
let mut published = String::new();
// hrefs of the entry's <link rel="alternate"> elements, in order;
// together they decide Short or not (see agreed_alternate).
let mut alternate_hrefs: Vec<String> = Vec::new();
// What text-collecting state we're in. Replaced per element open.
let mut text_target: Option<TextTarget> = None;
@ -287,6 +304,7 @@ fn parse_rss(body: &str, channel_id: String) -> Option<Vec<SearchItem>> {
uploader_url.clear();
thumbnail = None;
published.clear();
alternate_hrefs.clear();
}
if !in_entry {
continue;
@ -317,6 +335,22 @@ fn parse_rss(body: &str, channel_id: String) -> Option<Vec<SearchItem>> {
}
}
}
// <link rel="alternate" href="..."/> is self-closing too
// (the only form measured). Other rel values are ignored.
if local == "link" {
let mut is_alternate = false;
let mut href = None;
for attr in e.attributes().flatten() {
match attr.key.as_ref() {
b"rel" => is_alternate = attr.value.as_ref() == b"alternate",
b"href" => href = attr.unescape_value().ok().map(|v| v.into_owned()),
_ => {}
}
}
if let (true, Some(h)) = (is_alternate, href) {
alternate_hrefs.push(h);
}
}
}
Ok(Event::Text(t)) => {
if !in_entry {
@ -346,7 +380,7 @@ fn parse_rss(body: &str, channel_id: String) -> Option<Vec<SearchItem>> {
// recency sort.
if !video_id.is_empty() && !title.is_empty() && !published.is_empty() {
items.push(SearchItem {
url: format!("https://www.youtube.com/watch?v={video_id}"),
url: entry_url(&video_id, agreed_alternate(&alternate_hrefs)),
title: title.clone(),
uploader: uploader.clone(),
uploader_url: if uploader_url.is_empty() {
@ -397,6 +431,35 @@ fn parse_rss(body: &str, channel_id: String) -> Option<Vec<SearchItem>> {
Some(items)
}
/// The alternate link an entry's URL is decided from. In 326 channel feeds
/// captured on 2026-10-22 (4,647 entries) every entry carried exactly one.
/// An entry that carries more decides from them only when they are all the
/// same href; if they disagree it decides from none (""), so it keeps its
/// watch URL. Chosen so that a feed contradicting itself can at worst show
/// a Short, never hide a regular upload: a shown Short is a row the user
/// scrolls past, a hidden upload is one they never learn about.
fn agreed_alternate(hrefs: &[String]) -> &str {
match hrefs.split_first() {
Some((first, rest)) if rest.iter().all(|h| h == first) => first.as_str(),
_ => "",
}
}
/// The URL an RSS entry is given. A Short (its alternate link is exactly
/// `SHORTS_URL_PREFIX` + its own video id) keeps the /shorts/ shape so the
/// app can hide it; anything else, including a /shorts/ link naming a
/// different id, gets the canonical watch URL as before.
fn entry_url(video_id: &str, alternate_href: &str) -> String {
let is_short = alternate_href
.strip_prefix(SHORTS_URL_PREFIX)
.is_some_and(|id| id == video_id);
if is_short {
format!("{SHORTS_URL_PREFIX}{video_id}")
} else {
format!("{WATCH_URL_PREFIX}{video_id}")
}
}
enum TextTarget {
VideoId,
Title,
@ -514,3 +577,244 @@ fn local_name(qualified: &[u8]) -> &str {
None => s,
}
}
#[cfg(test)]
mod tests {
use super::*;
const CHANNEL: &str = "UCuAXFkgsw1L7xaCfnd5JJOw";
/// Header plus one Short and one long upload, in the shape the public
/// channel feed for CHANNEL served on 2026-10-22 (titles shortened,
/// description and statistics trimmed).
const REAL_SHAPE: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns:yt="http://www.youtube.com/xml/schemas/2015" xmlns:media="http://search.yahoo.com/mrss/" xmlns="http://www.w3.org/2005/Atom">
<link rel="self" href="http://www.youtube.com/feeds/videos.xml?channel_id=UCuAXFkgsw1L7xaCfnd5JJOw"/>
<id>yt:channel:uAXFkgsw1L7xaCfnd5JJOw</id>
<yt:channelId>uAXFkgsw1L7xaCfnd5JJOw</yt:channelId>
<title>Rick Astley</title>
<link rel="alternate" href="https://www.youtube.com/channel/UCuAXFkgsw1L7xaCfnd5JJOw"/>
<author>
<name>Rick Astley</name>
<uri>https://www.youtube.com/channel/UCuAXFkgsw1L7xaCfnd5JJOw</uri>
</author>
<published>2015-02-01T16:32:30+00:00</published>
<entry>
<id>yt:video:E_MGy41IYVw</id>
<yt:videoId>E_MGy41IYVw</yt:videoId>
<yt:channelId>UCuAXFkgsw1L7xaCfnd5JJOw</yt:channelId>
<title>38 years on, and the music video still hits</title>
<link rel="alternate" href="https://www.youtube.com/shorts/E_MGy41IYVw"/>
<author>
<name>Rick Astley</name>
<uri>https://www.youtube.com/channel/UCuAXFkgsw1L7xaCfnd5JJOw</uri>
</author>
<published>2026-09-20T08:52:27+00:00</published>
<updated>2026-10-08T20:00:28+00:00</updated>
<media:group>
<media:title>38 years on, and the music video still hits</media:title>
<media:content url="https://www.youtube.com/v/E_MGy41IYVw?version=3" type="application/x-shockwave-flash" width="640" height="390"/>
<media:thumbnail url="https://i2.ytimg.com/vi/E_MGy41IYVw/hqdefault.jpg" width="480" height="360"/>
<media:description></media:description>
</media:group>
</entry>
<entry>
<id>yt:video:PXC_PYeB6F8</id>
<yt:videoId>PXC_PYeB6F8</yt:videoId>
<yt:channelId>UCuAXFkgsw1L7xaCfnd5JJOw</yt:channelId>
<title>Rick Astley - Angels On My Side (Live at The O2, London, April 2026)</title>
<link rel="alternate" href="https://www.youtube.com/watch?v=PXC_PYeB6F8"/>
<author>
<name>Rick Astley</name>
<uri>https://www.youtube.com/channel/UCuAXFkgsw1L7xaCfnd5JJOw</uri>
</author>
<published>2026-06-26T16:00:07+00:00</published>
<updated>2026-09-30T04:12:50+00:00</updated>
<media:group>
<media:title>Rick Astley - Angels On My Side</media:title>
<media:content url="https://www.youtube.com/v/PXC_PYeB6F8?version=3" type="application/x-shockwave-flash" width="640" height="390"/>
<media:thumbnail url="https://i1.ytimg.com/vi/PXC_PYeB6F8/hqdefault.jpg" width="480" height="360"/>
<media:description>Taken from the Reflections Tour.</media:description>
</media:group>
</entry>
</feed>"#;
/// A minimal entry; `link` is inserted verbatim (empty for none).
fn entry(id: &str, link: &str) -> String {
format!(
"<entry><yt:videoId>{id}</yt:videoId><title>t {id}</title>{link}\
<published>2026-10-01T00:00:00+00:00</published></entry>"
)
}
fn feed(entries: &[String]) -> String {
format!(
"<feed xmlns:yt=\"http://www.youtube.com/xml/schemas/2015\" \
xmlns=\"http://www.w3.org/2005/Atom\">{}</feed>",
entries.concat()
)
}
fn urls(body: &str) -> Vec<String> {
parse_rss(body, CHANNEL.to_string())
.expect("parse")
.into_iter()
.map(|i| i.url)
.collect()
}
#[test]
fn a_short_keeps_its_shorts_url_and_a_long_upload_gets_a_watch_url() {
assert_eq!(
urls(REAL_SHAPE),
vec![
"https://www.youtube.com/shorts/E_MGy41IYVw".to_string(),
"https://www.youtube.com/watch?v=PXC_PYeB6F8".to_string(),
]
);
}
#[test]
fn the_rest_of_a_short_entry_parses_as_before() {
let items = parse_rss(REAL_SHAPE, CHANNEL.to_string()).expect("parse");
let short = &items[0];
assert_eq!(short.title, "38 years on, and the music video still hits");
assert_eq!(short.uploader, "Rick Astley");
assert_eq!(
short.thumbnail.as_deref(),
Some("https://i2.ytimg.com/vi/E_MGy41IYVw/hqdefault.jpg")
);
}
#[test]
fn a_shorts_link_naming_another_video_does_not_make_a_short() {
let body = feed(&[entry(
"AAAAAAAAAA1",
r#"<link rel="alternate" href="https://www.youtube.com/shorts/BBBBBBBBBB2"/>"#,
)]);
assert_eq!(
urls(&body),
vec!["https://www.youtube.com/watch?v=AAAAAAAAAA1"]
);
}
#[test]
fn only_the_alternate_link_marks_a_short() {
let body = feed(&[
entry(
"AAAAAAAAAA1",
r#"<link rel="related" href="https://www.youtube.com/shorts/AAAAAAAAAA1"/>"#,
),
entry(
"AAAAAAAAAA2",
r#"<link href="https://www.youtube.com/shorts/AAAAAAAAAA2"/>"#,
),
]);
assert_eq!(
urls(&body),
vec![
"https://www.youtube.com/watch?v=AAAAAAAAAA1",
"https://www.youtube.com/watch?v=AAAAAAAAAA2",
]
);
}
#[test]
fn an_entry_without_a_link_is_not_marked_by_the_entry_before_it() {
// The same id twice is the only case where a link left over from
// the previous entry could change the answer, because the check
// compares the link against the entry's own id.
let body = feed(&[
entry(
"AAAAAAAAAA1",
r#"<link rel="alternate" href="https://www.youtube.com/shorts/AAAAAAAAAA1"/>"#,
),
entry("AAAAAAAAAA1", ""),
]);
assert_eq!(
urls(&body),
vec![
"https://www.youtube.com/shorts/AAAAAAAAAA1",
"https://www.youtube.com/watch?v=AAAAAAAAAA1",
]
);
}
#[test]
fn entry_url_trusts_only_the_measured_shorts_form_for_its_own_id() {
let id = "E_MGy41IYVw";
assert_eq!(
entry_url(id, "https://www.youtube.com/shorts/E_MGy41IYVw"),
"https://www.youtube.com/shorts/E_MGy41IYVw"
);
for other in [
"",
"https://www.youtube.com/watch?v=E_MGy41IYVw",
"https://www.youtube.com/shorts/E_MGy41IYVwX",
"https://www.youtube.com/shorts/E_MGy41IYV",
"https://www.youtube.com/shorts/E_MGy41IYVw/",
"http://www.youtube.com/shorts/E_MGy41IYVw",
"https://evil.example/https://www.youtube.com/shorts/E_MGy41IYVw",
] {
assert_eq!(
entry_url(id, other),
"https://www.youtube.com/watch?v=E_MGy41IYVw",
"href {other:?} must not mark a Short"
);
}
}
fn alternate(href: &str) -> String {
format!(r#"<link rel="alternate" href="{href}"/>"#)
}
fn shorts(id: &str) -> String {
format!("https://www.youtube.com/shorts/{id}")
}
fn watch(id: &str) -> String {
format!("https://www.youtube.com/watch?v={id}")
}
#[test]
fn an_entity_escaped_shorts_href_still_marks_a_short() {
// The href is unescaped as XML before it is compared, so the same
// URL written with character references is the same link.
let body = feed(&[entry(
"AAAAAAAAAA1",
r#"<link rel="alternate" href="https:&#47;&#47;www.youtube.com&#47;shorts&#47;AAAAAAAAAA1"/>"#,
)]);
assert_eq!(urls(&body), vec![shorts("AAAAAAAAAA1")]);
}
#[test]
fn alternate_links_that_disagree_keep_the_watch_url() {
let a1 = "AAAAAAAAAA1";
let a2 = "AAAAAAAAAA2";
let a3 = "AAAAAAAAAA3";
let a4 = "AAAAAAAAAA4";
let body = feed(&[
entry(a1, &(alternate(&shorts(a1)) + &alternate(&watch(a1)))),
entry(a2, &(alternate(&watch(a2)) + &alternate(&shorts(a2)))),
entry(
a3,
&(alternate(&shorts(a3)) + &alternate(&shorts(a3)) + &alternate(&watch(a3))),
),
entry(a4, &(alternate(&shorts(a4)) + &alternate(&shorts(a4)))),
]);
assert_eq!(
urls(&body),
vec![watch(a1), watch(a2), watch(a3), shorts(a4)],
"links that disagree decide nothing; links that all agree decide as one"
);
}
#[test]
fn a_shorts_link_whose_id_differs_only_in_case_is_not_a_short() {
// The id is compared exactly. Measured 2026-10-22: YouTube's oEmbed
// endpoint answered 200 for E_MGy41IYVw, 404 for e_mgy41iyvw and
// 400 for E_MGY41IYVW, so case variants of that id named no video.
let body = feed(&[entry("AAAAAAAAAA1", &alternate(&shorts("aaaaaaaaaa1")))]);
assert_eq!(urls(&body), vec![watch("AAAAAAAAAA1")]);
}
}

View file

@ -204,6 +204,11 @@ dependencies {
// cargoBuild + uniffiBindgen tasks below; phase U-2+ exposes search /
// streamInfo / channelInfo to replace NewPipeExtractor.
implementation("net.java.dev.jna:jna:5.14.0@aar")
// JVM unit tests under src/test, run by `./gradlew :strawApp:testDebugUnitTest`.
// Before this line that source set had no test framework, so it was
// never compiled or run (the note at the top of LogDumpScrubTest).
testImplementation(libs.junit)
}
// =============================================================================

View file

@ -80,6 +80,7 @@ import com.sulkta.straw.data.History
import com.sulkta.straw.data.Subscriptions
import com.sulkta.straw.data.WatchHistoryItem
import com.sulkta.straw.feature.feed.SubscriptionFeedViewModel
import com.sulkta.straw.feature.feed.subsTabRows
import com.sulkta.straw.feature.player.VideoThumbnail
import com.sulkta.straw.feature.playlist.VideoActionTarget
import com.sulkta.straw.feature.playlist.VideoActionsSheet
@ -310,12 +311,15 @@ private fun SubsPane(
watches.map { it.videoId }.filter { it.isNotBlank() }.toSet()
}
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)
val settings = com.sulkta.straw.data.Settings.get()
val hideShorts by settings.hideShorts.collectAsState()
// subsTabRows reads Hide watched and Hide Shorts from settings itself;
// the two collected values are keys here so that flipping either
// switch recomputes the rows.
val subsRows = remember(feed.items, hideWatched, watchedIds, hideShorts) {
subsTabRows(feed.items, watchedIds, settings)
}
val filteredItems = subsRows.rows
// Reset pagination when the underlying list changes so the user
// doesn't end up looking at "no more items" after a refresh.
LaunchedEffect(filteredItems) {
@ -404,7 +408,7 @@ private fun SubsPane(
}
feed.items.isNotEmpty() && filteredItems.isEmpty() -> {
Text(
"All caught up — nothing unwatched.",
subsRows.emptyText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@ -491,17 +495,6 @@ private fun SubsPane(
private const val PAGE_SIZE = 20
/**
* Extract the YouTube video ID from a watch URL so we can cross-check
* against History.watches (which stores videoId, not full URL). Handles
* the common forms: youtube.com/watch?v=XXXXXXXXXXX and youtu.be/X...
* Returns empty string when nothing matches — callers compare against
* watchedIds, so an empty string just won't filter anything out.
*/
private val VIDEO_ID_RE = Regex("(?:v=|/)([A-Za-z0-9_-]{11})(?:[?&#].*)?$")
private fun extractVideoId(url: String): String =
VIDEO_ID_RE.find(url)?.groupValues?.getOrNull(1).orEmpty()
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun FeedRow(

View file

@ -32,18 +32,42 @@ data class FeedCacheEntry(
)
private const val PREFS = "straw_feed_cache"
private const val KEY = "cache_v1"
class FeedCacheStore(context: Context) {
private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
private val json = Json { ignoreUnknownKeys = true }
/**
* v2: from 2026-10-22 strawcore gives a Short in the RSS feed its
* /shorts/<id> URL, which is how Hide Shorts finds it. A v1 cache holds
* Shorts under watch?v= URLs that nothing can tell apart, so it is never
* read (it would paint Shorts until each channel refetched) and is
* removed when the store is built.
*/
internal const val FEED_CACHE_KEY = "cache_v2"
internal const val FEED_CACHE_LEGACY_KEY = "cache_v1"
class FeedCacheStore internal constructor(
private val sp: SharedPreferences,
// Writes serialized off-thread (audit #2 M-2 follow-on). Unlike the StateFlow
// stores, this cache's state is caller-owned (the feed VM's channelCache), so
// there's no live value to re-read — the write captures the encoded snapshot.
// Ordering is safe because the only writer (SubscriptionFeedViewModel) calls
// save()/clear() from a single refresh coroutine (cancels in-flight before
// clear), so enqueue order == intended order and the FIFO dispatcher preserves it.
private val writer = PrefsWriter(sp)
// Passed in so unit tests can give a writer that edits at once: PrefsWriter
// launches on StrawApp.globalScope, which only the running app sets up.
private val writer: (SharedPreferences.Editor.() -> Unit) -> Unit,
) {
constructor(context: Context) : this(context.getSharedPreferences(PREFS, Context.MODE_PRIVATE))
/** The store the app uses: writes go through a [PrefsWriter] on [sp]. */
internal constructor(sp: SharedPreferences) : this(sp, PrefsWriter(sp)::write)
private val json = Json { ignoreUnknownKeys = true }
init {
// Not routed through `writer`: nothing else ever writes the legacy
// key, so there is no ordering to keep, and this keeps the store
// constructible where no app scope exists (unit tests).
if (sp.contains(FEED_CACHE_LEGACY_KEY)) sp.edit().remove(FEED_CACHE_LEGACY_KEY).apply()
}
/**
* Snapshot of the disk cache, filtered by the user-configured TTL.
@ -51,10 +75,13 @@ class FeedCacheStore(context: Context) {
* Settings.cacheTtl.isForever short-circuits the filter; finite TTLs
* drop entries whose fetchedAt is older than (now - ttl).
*/
fun load(): Map<String, FeedCacheEntry> = runCatching {
val s = sp.getString(KEY, null) ?: return emptyMap()
fun load(): Map<String, FeedCacheEntry> =
runCatching { load(Settings.get().cacheTtl.value) }.getOrDefault(emptyMap())
/** [load] with the TTL passed in rather than read from Settings. */
internal fun load(ttl: CacheTtl): Map<String, FeedCacheEntry> = runCatching {
val s = sp.getString(FEED_CACHE_KEY, null) ?: return emptyMap()
val raw = json.decodeFromString<Map<String, FeedCacheEntry>>(s)
val ttl = Settings.get().cacheTtl.value
if (ttl.isForever) return raw
val cutoff = System.currentTimeMillis() - ttl.ms
raw.filterValues { it.fetchedAt >= cutoff }
@ -63,11 +90,11 @@ class FeedCacheStore(context: Context) {
/** Atomic write. Caller is responsible for diffing if needed. */
fun save(map: Map<String, FeedCacheEntry>) {
val s = json.encodeToString(map)
writer.write { putString(KEY, s) }
writer { putString(FEED_CACHE_KEY, s) }
}
fun clear() {
writer.write { remove(KEY) }
writer { remove(FEED_CACHE_KEY) }
}
}

View file

@ -143,6 +143,7 @@ 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"
private const val HIDE_SHORTS_DEFAULT = true
private const val KEY_HIDE_WATCHED = "hide_watched_v1"
private const val KEY_CACHE_HISTORY_WATCHES = "cache_history_watches_v1"
private const val KEY_CACHE_HISTORY_SEARCHES = "cache_history_searches_v1"
@ -152,8 +153,8 @@ private const val KEY_CACHE_TTL = "cache_ttl_v1"
private const val KEY_BG_FEED_REFRESH_ENABLED = "bg_feed_refresh_enabled_v1"
private const val KEY_BG_FEED_REFRESH_INTERVAL = "bg_feed_refresh_interval_v1"
class SettingsStore(context: Context) {
private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
class SettingsStore internal constructor(private val sp: SharedPreferences) {
constructor(context: Context) : this(context.getSharedPreferences(PREFS, Context.MODE_PRIVATE))
// SP writes serialized off the calling thread (audit #2 M-2): the
// importer toggles SponsorBlock categories on Dispatchers.IO while the
@ -255,20 +256,20 @@ 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 a future build plumbs an
* isShort flag through strawcore-core.
* Hide YouTube Shorts. ON by default since 2026-10-22; the switch in
* Settings shows them again.
* What counts as a Short is ContentFilter.looksLikeShort.
*
* The default changed from false to true without a new key, and that
* is enough to move every install that never touched the switch:
* setHideShorts is the only writer of this key, it is called only from
* the Settings switch, and it has skipped the write when the value is
* unchanged since it was added (06bd17d82). So an untouched install has
* no stored value and now reads true, and a stored false can only mean
* the user turned the switch on and then off again, which is kept.
*/
private val _hideShorts = MutableStateFlow(
sp.getBoolean(KEY_HIDE_SHORTS, false),
sp.getBoolean(KEY_HIDE_SHORTS, HIDE_SHORTS_DEFAULT),
)
val hideShorts: StateFlow<Boolean> = _hideShorts.asStateFlow()

View file

@ -0,0 +1,61 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Which subscription-feed items the Subs tab shows. Kept out of the
* composable so the JVM unit tests can run it; the screen only renders
* what this returns.
*/
package com.sulkta.straw.feature.feed
import com.sulkta.straw.data.SettingsStore
import com.sulkta.straw.feature.search.StreamItem
import com.sulkta.straw.util.applyContentFilters
/**
* What the Subs tab shows: [rows], and [shortsHidden], the number of
* items Hide Shorts removed from what Hide watched left.
*/
internal data class SubsTabRows(val rows: List<StreamItem>, val shortsHidden: Int) {
/**
* The line shown when the feed has items but [rows] is empty. If any
* Shorts were hidden, everything Hide watched left was a Short, so
* "nothing unwatched" would not be true.
*/
val emptyText: String
get() = if (shortsHidden > 0) {
"Nothing to show but Shorts, and Hide Shorts is on in Settings."
} else {
"All caught up — nothing unwatched."
}
}
/**
* The Subs tab's rows: with Hide watched on, items whose video id is in
* [watchedIds] are dropped; then the content filters (Hide Shorts) apply
* as set in [settings].
*/
internal fun subsTabRows(
items: List<StreamItem>,
watchedIds: Set<String>,
settings: SettingsStore,
): SubsTabRows {
val unwatched = if (!settings.hideWatched.value) items
else items.filterNot { extractVideoId(it.url) in watchedIds }
val rows = applyContentFilters(unwatched, settings)
// The settings form of applyContentFilters hides only Shorts, so every
// item it dropped is a hidden Short.
return SubsTabRows(rows, shortsHidden = unwatched.size - rows.size)
}
/**
* Extract the YouTube video ID from a watch URL so we can cross-check
* against History.watches (which stores videoId, not full URL). Handles
* the common forms: youtube.com/watch?v=XXXXXXXXXXX and youtu.be/X...
* Returns empty string when nothing matches — callers compare against
* watchedIds, so an empty string just won't filter anything out.
*/
private val VIDEO_ID_RE = Regex("(?:v=|/)([A-Za-z0-9_-]{11})(?:[?&#].*)?$")
private fun extractVideoId(url: String): String =
VIDEO_ID_RE.find(url)?.groupValues?.getOrNull(1).orEmpty()

View file

@ -381,8 +381,11 @@ class SubscriptionFeedViewModel : ViewModel() {
* refresh + clearInMemoryCache can cancel it.
*/
private fun enrichVisibleItems(items: List<StreamItem>, channelsSnapshot: List<ChannelRef>) {
val take = items.take(ENRICH_HEAD_COUNT)
.filter { it.viewCount <= 0L && it.durationSeconds <= 0L }
val take = feedEnrichmentCandidates(
items,
hideShorts = Settings.get().hideShorts.value,
count = ENRICH_HEAD_COUNT,
)
if (take.isEmpty()) return
enrichJob?.cancel()
enrichJob = viewModelScope.launch {
@ -480,6 +483,23 @@ class SubscriptionFeedViewModel : ViewModel() {
}
}
/**
* The feed items the background enrichment should fetch metadata for: the
* first [count] items the Subs tab will actually show that RSS left
* without views and duration. Hidden Shorts are skipped BEFORE the head is
* cut, so with Hide Shorts on the enrichment calls go to visible rows
* instead of being spent on Shorts nobody sees. (Hide watched is not
* applied here; that was already so before Shorts were hidden.)
*/
internal fun feedEnrichmentCandidates(
items: List<StreamItem>,
hideShorts: Boolean,
count: Int,
): List<StreamItem> =
com.sulkta.straw.util.applyContentFilters(items, hideShorts = hideShorts)
.take(count)
.filter { it.viewCount <= 0L && it.durationSeconds <= 0L }
/**
* Convert "2 days ago" / "3 weeks ago" / "Streamed 5 hours ago" style
* strings into approximate seconds-ago. Higher = more recent (so default

View file

@ -169,9 +169,13 @@ fun SearchScreen(
modifier = Modifier.padding(bottom = 4.dp),
)
}
val hideShorts by com.sulkta.straw.data.Settings.get().hideShorts.collectAsState()
val settings = com.sulkta.straw.data.Settings.get()
val hideShorts by settings.hideShorts.collectAsState()
// The filter reads Hide Shorts from settings itself; the
// collected value is a key here so that flipping the switch
// recomputes the list.
val filteredResults = remember(state.results, hideShorts) {
com.sulkta.straw.util.applyContentFilters(state.results, hideShorts = hideShorts)
com.sulkta.straw.util.applyContentFilters(state.results, settings)
}
val listState = rememberLazyListState()
// Infinite scroll: near the end of a live result set with a

View file

@ -358,9 +358,7 @@ fun SettingsScreen() {
fontWeight = FontWeight.SemiBold,
)
Text(
"Drop /shorts/ URLs from search + channel pages " +
"and best-effort filter (\"#shorts\" tag) on the " +
"subs feed.",
"Keep YouTube Shorts out of your subscriptions feed.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)

View file

@ -6,28 +6,38 @@
* Pure functions on StreamItem so any list-rendering site can call them
* with one line at row-emit time.
*
* ships only the shorts heuristic — paid/age require strawcore
* ships only the shorts check — paid/age require strawcore
* flag plumbing landing previously. 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.data.SettingsStore
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).
* A Short is an item whose URL has the `/shorts/<id>` shape.
*
* Where that shape comes from, per surface (measured 2026-10-22):
* - Subscription feed: strawcore's RSS parser gives an entry its
* /shorts/ URL exactly when YouTube's channel feed links it there,
* which matched the channel's own Shorts playlist for 33 of 33 Shorts
* across 7 channels (see rust/strawcore/src/feed.rs).
* - Search: strawcore builds `watch?v=` URLs, but search with the
* Videos filter did not return Shorts (0 of 79 results across 4
* queries), so nothing there needs this check today.
* - Channel pages list the channel's Videos tab, whose items strawcore
* also gives `watch?v=` URLs (read in its source, not measured), so
* this check cannot see a Short there. Whether that tab ever lists a
* Short was not measured.
*
* There used to be a title fallback ("#shorts", "#short", "(shorts)").
* It matched 0 of those 33 Shorts, and with Hide Shorts now on by
* default it would hide any long video whose title merely contains
* "#short" (for example "#shortfilm"), so it is gone.
*/
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
}
fun looksLikeShort(item: StreamItem): Boolean = "/shorts/" in item.url
/**
* Placeholder until adds an isPaid flag via strawcore-core.
@ -58,3 +68,13 @@ fun applyContentFilters(
(hidePaid && looksLikePaid(item)) ||
(hideAgeRestricted && looksLikeAgeRestricted(item))
}
/**
* [applyContentFilters] with the switches as the user set them in
* [settings]. The Subs tab (through subsTabRows) and the search screen
* call this one instead of passing each switch themselves, so which
* setting hides what is decided here, where the unit tests reach it,
* and not in a composable they cannot run.
*/
fun applyContentFilters(items: List<StreamItem>, settings: SettingsStore): List<StreamItem> =
applyContentFilters(items, hideShorts = settings.hideShorts.value)

View file

@ -0,0 +1,42 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta
* SPDX-License-Identifier: GPL-3.0-or-later
*
* The two screens take the Hide Shorts decision from the tested functions (subsTabRows and the
* settings form of applyContentFilters). A refactor that stops calling them would otherwise compile
* and pass every other test.
*/
package com.sulkta.straw
import java.io.File
import org.junit.Assert.assertTrue
import org.junit.Test
class ScreenWiringTest {
private fun src(rel: String): String {
val f = File("src/main/kotlin/com/sulkta/straw/$rel")
assertTrue("source not found: ${f.absolutePath}", f.isFile)
return f.readText()
}
@Test
fun theSubsTabTakesItsRowsAndEmptyTextFromSubsTabRows() {
val s = src("StrawHome.kt")
assertTrue(
"SubsPane must build its rows with subsTabRows",
Regex("""subsTabRows\(\s*feed\.items\s*,\s*watchedIds\s*,\s*settings\s*\)""").containsMatchIn(s),
)
assertTrue("SubsPane must show those rows", s.contains("val filteredItems = subsRows.rows"))
assertTrue("SubsPane must show subsTabRows' empty text", s.contains("subsRows.emptyText"))
}
@Test
fun theSearchScreenUsesTheSettingsFormOfTheFilter() {
assertTrue(
"SearchScreen must filter with the settings form",
src("feature/search/SearchScreen.kt").contains("applyContentFilters(state.results, settings)"),
)
}
}

View file

@ -0,0 +1,76 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta
* SPDX-License-Identifier: GPL-3.0-or-later
*
* In-memory SharedPreferences for JVM unit tests. The stores take a
* SharedPreferences through an internal constructor so they can be built
* here without an Android Context. Edits apply immediately on commit or
* apply; change listeners are not supported (nothing under test uses them).
*/
package com.sulkta.straw.data
import android.content.SharedPreferences
class FakeSharedPreferences(initial: Map<String, Any?> = emptyMap()) : SharedPreferences {
val values: MutableMap<String, Any?> = initial.toMutableMap()
override fun getAll(): MutableMap<String, *> = values.toMutableMap()
override fun getString(key: String, defValue: String?): String? =
if (key in values) values[key] as String? else defValue
@Suppress("UNCHECKED_CAST")
override fun getStringSet(key: String, defValues: MutableSet<String>?): MutableSet<String>? =
if (key in values) values[key] as MutableSet<String>? else defValues
override fun getInt(key: String, defValue: Int): Int = values[key] as Int? ?: defValue
override fun getLong(key: String, defValue: Long): Long = values[key] as Long? ?: defValue
override fun getFloat(key: String, defValue: Float): Float = values[key] as Float? ?: defValue
override fun getBoolean(key: String, defValue: Boolean): Boolean =
values[key] as Boolean? ?: defValue
override fun contains(key: String): Boolean = key in values
override fun edit(): SharedPreferences.Editor = Editor()
override fun registerOnSharedPreferenceChangeListener(
listener: SharedPreferences.OnSharedPreferenceChangeListener,
) {
throw UnsupportedOperationException()
}
override fun unregisterOnSharedPreferenceChangeListener(
listener: SharedPreferences.OnSharedPreferenceChangeListener,
) {
throw UnsupportedOperationException()
}
private inner class Editor : SharedPreferences.Editor {
private val puts = mutableMapOf<String, Any?>()
private val removes = mutableSetOf<String>()
private var clearAll = false
private fun put(key: String, value: Any?): SharedPreferences.Editor {
puts[key] = value
return this
}
override fun putString(key: String, value: String?) = put(key, value)
override fun putStringSet(key: String, values: MutableSet<String>?) = put(key, values)
override fun putInt(key: String, value: Int) = put(key, value)
override fun putLong(key: String, value: Long) = put(key, value)
override fun putFloat(key: String, value: Float) = put(key, value)
override fun putBoolean(key: String, value: Boolean) = put(key, value)
override fun remove(key: String): SharedPreferences.Editor {
removes += key
return this
}
override fun clear(): SharedPreferences.Editor {
clearAll = true
return this
}
override fun commit(): Boolean {
if (clearAll) values.clear()
removes.forEach { values.remove(it) }
values.putAll(puts)
return true
}
override fun apply() {
commit()
}
}
}

View file

@ -0,0 +1,43 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta
* SPDX-License-Identifier: GPL-3.0-or-later
*
* A subscription-feed cache written before Shorts kept their /shorts/ URL
* is never shown, and is removed; the current cache still loads.
*/
package com.sulkta.straw.data
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Test
class FeedCacheLegacyKeyTest {
private fun cacheJson(url: String): String =
"""{"https://www.youtube.com/channel/UCuAXFkgsw1L7xaCfnd5JJOw":""" +
"""{"fetchedAt":1,"items":[{"url":"$url","title":"t","uploader":"u",""" +
""""uploaderUrl":null,"thumbnail":null,"durationSeconds":0,"viewCount":0}]}}"""
@Test
fun theOldCacheIsNotLoadedAndIsRemoved() {
val sp = FakeSharedPreferences(
mapOf("cache_v1" to cacheJson("https://www.youtube.com/watch?v=E_MGy41IYVw")),
)
val store = FeedCacheStore(sp)
assertEquals(emptyMap<String, FeedCacheEntry>(), store.load(CacheTtl.Forever))
assertFalse("the old cache must be removed", sp.contains("cache_v1"))
}
@Test
fun theCurrentCacheLoadsBesideTheOldOne() {
val sp = FakeSharedPreferences(
mapOf(
"cache_v1" to cacheJson("https://www.youtube.com/watch?v=E_MGy41IYVw"),
"cache_v2" to cacheJson("https://www.youtube.com/shorts/E_MGy41IYVw"),
),
)
val urls = FeedCacheStore(sp).load(CacheTtl.Forever).values.flatMap { e -> e.items.map { it.url } }
assertEquals(listOf("https://www.youtube.com/shorts/E_MGy41IYVw"), urls)
}
}

View file

@ -0,0 +1,55 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta
* SPDX-License-Identifier: GPL-3.0-or-later
*
* The subscription-feed cache is saved under, and cleared from, the
* current key only.
*/
package com.sulkta.straw.data
import android.content.SharedPreferences
import com.sulkta.straw.feature.search.StreamItem
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class FeedCacheWriteKeyTest {
/** A store whose writes edit [sp] at once instead of on the app's scope. */
private fun storeWritingAtOnce(sp: SharedPreferences) =
FeedCacheStore(sp) { block ->
val editor = sp.edit()
editor.block()
editor.apply()
}
private val entry = FeedCacheEntry(
fetchedAt = 1,
items = listOf(
StreamItem("https://www.youtube.com/shorts/E_MGy41IYVw", "t", "u", null, null, 0, 0),
),
)
private val channel = "https://www.youtube.com/channel/UCuAXFkgsw1L7xaCfnd5JJOw"
@Test
fun saveWritesTheCurrentKeyAndNotTheOldOne() {
val sp = FakeSharedPreferences()
val store = storeWritingAtOnce(sp)
store.save(mapOf(channel to entry))
assertTrue("the cache must be saved under cache_v2", sp.contains("cache_v2"))
assertFalse("nothing may be saved under cache_v1", sp.contains("cache_v1"))
assertEquals(mapOf(channel to entry), store.load(CacheTtl.Forever))
}
@Test
fun clearRemovesTheCurrentKey() {
val sp = FakeSharedPreferences()
val store = storeWritingAtOnce(sp)
store.save(mapOf(channel to entry))
store.clear()
assertFalse("clear must remove cache_v2", sp.contains("cache_v2"))
assertEquals(emptyMap<String, FeedCacheEntry>(), store.load(CacheTtl.Forever))
}
}

View file

@ -0,0 +1,32 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Hide Shorts is on by default, and an install's own choice is kept.
*/
package com.sulkta.straw.data
import org.junit.Assert.assertEquals
import org.junit.Test
class HideShortsDefaultTest {
private fun hideShortsWith(stored: Map<String, Any?>): Boolean =
SettingsStore(FakeSharedPreferences(stored)).hideShorts.value
@Test
fun anInstallThatNeverTouchedTheSwitchHidesShorts() {
assertEquals(true, hideShortsWith(emptyMap()))
}
@Test
fun anInstallThatTurnedTheSwitchOffKeepsShowingShorts() {
assertEquals(false, hideShortsWith(mapOf("hide_shorts_v1" to false)))
}
@Test
fun anInstallThatTurnedTheSwitchOnKeepsHidingShorts() {
assertEquals(true, hideShortsWith(mapOf("hide_shorts_v1" to true)))
}
}

View file

@ -0,0 +1,46 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta
* SPDX-License-Identifier: GPL-3.0-or-later
*
* The feed's background enrichment spends its calls on rows that are shown.
*/
package com.sulkta.straw.feature.feed
import com.sulkta.straw.feature.search.StreamItem
import org.junit.Assert.assertEquals
import org.junit.Test
class FeedEnrichmentCandidatesTest {
private fun item(url: String, views: Long = 0, duration: Long = 0) =
StreamItem(url, "t", "u", null, null, duration, views)
private val s1 = item("https://www.youtube.com/shorts/AAAAAAAAAS1")
private val s2 = item("https://www.youtube.com/shorts/AAAAAAAAAS2")
private val v1 = item("https://www.youtube.com/watch?v=AAAAAAAAAV1")
private val v2 = item("https://www.youtube.com/watch?v=AAAAAAAAAV2")
private val v3 = item("https://www.youtube.com/watch?v=AAAAAAAAAV3")
@Test
fun withHideShortsOnTheHeadIsCountedOverVisibleRows() {
assertEquals(
listOf(v1, v2),
feedEnrichmentCandidates(listOf(s1, v1, s2, v2, v3), hideShorts = true, count = 2),
)
}
@Test
fun withHideShortsOffShortsAreEnrichedLikeAnyRow() {
assertEquals(
listOf(s1, v1),
feedEnrichmentCandidates(listOf(s1, v1, s2, v2, v3), hideShorts = false, count = 2),
)
}
@Test
fun rowsThatAlreadyHaveMetadataAreSkipped() {
val done = item("https://www.youtube.com/watch?v=AAAAAAAAAV4", views = 10, duration = 60)
assertEquals(listOf(v1), feedEnrichmentCandidates(listOf(done, v1), hideShorts = true, count = 2))
}
}

View file

@ -0,0 +1,85 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta
* SPDX-License-Identifier: GPL-3.0-or-later
*
* What the Subs tab shows, with the switches read from a real SettingsStore.
*/
package com.sulkta.straw.feature.feed
import com.sulkta.straw.data.FakeSharedPreferences
import com.sulkta.straw.data.SettingsStore
import com.sulkta.straw.feature.search.StreamItem
import org.junit.Assert.assertEquals
import org.junit.Test
class SubsTabRowsTest {
private fun item(url: String) = StreamItem(url, "t", "u", null, null, 0, 0)
private fun settings(vararg stored: Pair<String, Any?>) =
SettingsStore(FakeSharedPreferences(mapOf(*stored)))
private val short = item("https://www.youtube.com/shorts/AAAAAAAAAS1")
private val watchedShort = item("https://www.youtube.com/shorts/AAAAAAAAAS2")
private val long = item("https://www.youtube.com/watch?v=AAAAAAAAAV1")
private val watchedLong = item("https://www.youtube.com/watch?v=AAAAAAAAAV2")
@Test
fun anInstallThatNeverTouchedTheSwitchSeesNoShortsOnTheSubsTab() {
assertEquals(listOf(long), subsTabRows(listOf(short, long), emptySet(), settings()).rows)
}
@Test
fun withHideShortsTurnedOffTheSubsTabShowsShorts() {
assertEquals(
listOf(short, long),
subsTabRows(listOf(short, long), emptySet(), settings("hide_shorts_v1" to false)).rows,
)
}
@Test
fun hideWatchedDropsWatchedRowsAndLeavesShortsToTheirOwnSwitch() {
val s = settings("hide_watched_v1" to true, "hide_shorts_v1" to false)
assertEquals(
listOf(short, long),
subsTabRows(
listOf(short, watchedShort, long, watchedLong),
setOf("AAAAAAAAAS2", "AAAAAAAAAV2"),
s,
).rows,
)
}
@Test
fun aFeedOfOnlyShortsSaysSoInsteadOfAllCaughtUp() {
val r = subsTabRows(listOf(short, watchedShort), emptySet(), settings())
assertEquals(emptyList<StreamItem>(), r.rows)
assertEquals(2, r.shortsHidden)
assertEquals("Nothing to show but Shorts, and Hide Shorts is on in Settings.", r.emptyText)
}
@Test
fun whenWatchedRowsAreHiddenAndOnlyShortsAreLeftItSaysShorts() {
val r = subsTabRows(
listOf(watchedLong, short),
setOf("AAAAAAAAAV2"),
settings("hide_watched_v1" to true),
)
assertEquals(emptyList<StreamItem>(), r.rows)
assertEquals(1, r.shortsHidden)
assertEquals("Nothing to show but Shorts, and Hide Shorts is on in Settings.", r.emptyText)
}
@Test
fun aFeedThatIsAllWatchedStillSaysAllCaughtUp() {
val r = subsTabRows(
listOf(long, watchedLong),
setOf("AAAAAAAAAV1", "AAAAAAAAAV2"),
settings("hide_watched_v1" to true),
)
assertEquals(emptyList<StreamItem>(), r.rows)
assertEquals(0, r.shortsHidden)
assertEquals("All caught up — nothing unwatched.", r.emptyText)
}
}

View file

@ -0,0 +1,58 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta
* SPDX-License-Identifier: GPL-3.0-or-later
*
* What Hide Shorts drops: items with a /shorts/ URL, and nothing else.
*/
package com.sulkta.straw.util
import com.sulkta.straw.data.FakeSharedPreferences
import com.sulkta.straw.data.SettingsStore
import com.sulkta.straw.feature.search.StreamItem
import org.junit.Assert.assertEquals
import org.junit.Test
class ContentFilterTest {
private fun item(url: String, title: String = "t") =
StreamItem(url, title, "u", null, null, 0, 0)
private val short = item("https://www.youtube.com/shorts/E_MGy41IYVw")
private val long = item("https://www.youtube.com/watch?v=PXC_PYeB6F8")
@Test
fun hideShortsOnDropsShortsAndKeepsTheRest() {
assertEquals(listOf(long), applyContentFilters(listOf(short, long), hideShorts = true))
}
@Test
fun hideShortsOffKeepsEverything() {
assertEquals(listOf(short, long), applyContentFilters(listOf(short, long), hideShorts = false))
}
@Test
fun aShortsTagInTheTitleDoesNotHideALongVideo() {
val tagged = listOf(
item("https://www.youtube.com/watch?v=AAAAAAAAAA1", "My #shortfilm"),
item("https://www.youtube.com/watch?v=AAAAAAAAAA2", "Best of #Shorts (shorts)"),
)
assertEquals(tagged, applyContentFilters(tagged, hideShorts = true))
}
@Test
fun withSettingsShortsFollowTheHideShortsSwitch() {
val untouched = SettingsStore(FakeSharedPreferences())
val switchedOff = SettingsStore(FakeSharedPreferences(mapOf("hide_shorts_v1" to false)))
assertEquals(listOf(long), applyContentFilters(listOf(short, long), untouched))
assertEquals(listOf(short, long), applyContentFilters(listOf(short, long), switchedOff))
}
@Test
fun withSettingsHideWatchedDoesNotHideShorts() {
val s = SettingsStore(
FakeSharedPreferences(mapOf("hide_watched_v1" to true, "hide_shorts_v1" to false)),
)
assertEquals(listOf(short, long), applyContentFilters(listOf(short, long), s))
}
}