Hide Shorts in the subscription feed (on by default) #31
19 changed files with 951 additions and 65 deletions
|
|
@ -81,6 +81,22 @@ jobs:
|
||||||
|| { echo "::error::versionCode auto-patch failed (sed matched nothing)"; exit 1; }
|
|| { echo "::error::versionCode auto-patch failed (sed matched nothing)"; exit 1; }
|
||||||
echo "auto versionCode = $VC (commit count $COUNT)"
|
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
|
- name: Assemble debug APK
|
||||||
working-directory: straw
|
working-directory: straw
|
||||||
env:
|
env:
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,19 @@
|
||||||
// turns a 50-sub refresh from ~5-8s parallel-12 to ~1s parallel-50.
|
// turns a 50-sub refresh from ~5-8s parallel-12 to ~1s parallel-50.
|
||||||
//
|
//
|
||||||
// RSS is intentionally lossy — it returns title/url/published/thumbnail
|
// RSS is intentionally lossy — it returns title/url/published/thumbnail
|
||||||
// only. No duration, no view count, no shorts/age/paid flags. That's the
|
// only. No duration, no view count, no age/paid flags. That's the right
|
||||||
// right trade for a feed-refresh use case: tap-through still goes through
|
// trade for a feed-refresh use case: tap-through still goes through the
|
||||||
// the full stream_info path to fetch the rich metadata when actually
|
// full stream_info path to fetch the rich metadata when actually needed.
|
||||||
// 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::sync::OnceLock;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
@ -23,6 +32,11 @@ use crate::error::StrawcoreError;
|
||||||
use crate::search::SearchItem;
|
use crate::search::SearchItem;
|
||||||
|
|
||||||
const RSS_BASE: &str = "https://www.youtube.com/feeds/videos.xml?channel_id=";
|
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 MAX_CONCURRENT: usize = 50;
|
||||||
const PER_CHANNEL_TIMEOUT_S: u64 = 8;
|
const PER_CHANNEL_TIMEOUT_S: u64 = 8;
|
||||||
/// Cap on the body bytes we'll read for a single RSS fetch. Real YT
|
/// 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 uploader_url = String::new();
|
||||||
let mut thumbnail: Option<String> = None;
|
let mut thumbnail: Option<String> = None;
|
||||||
let mut published = String::new();
|
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.
|
// What text-collecting state we're in. Replaced per element open.
|
||||||
let mut text_target: Option<TextTarget> = None;
|
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();
|
uploader_url.clear();
|
||||||
thumbnail = None;
|
thumbnail = None;
|
||||||
published.clear();
|
published.clear();
|
||||||
|
alternate_hrefs.clear();
|
||||||
}
|
}
|
||||||
if !in_entry {
|
if !in_entry {
|
||||||
continue;
|
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)) => {
|
Ok(Event::Text(t)) => {
|
||||||
if !in_entry {
|
if !in_entry {
|
||||||
|
|
@ -346,7 +380,7 @@ fn parse_rss(body: &str, channel_id: String) -> Option<Vec<SearchItem>> {
|
||||||
// recency sort.
|
// recency sort.
|
||||||
if !video_id.is_empty() && !title.is_empty() && !published.is_empty() {
|
if !video_id.is_empty() && !title.is_empty() && !published.is_empty() {
|
||||||
items.push(SearchItem {
|
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(),
|
title: title.clone(),
|
||||||
uploader: uploader.clone(),
|
uploader: uploader.clone(),
|
||||||
uploader_url: if uploader_url.is_empty() {
|
uploader_url: if uploader_url.is_empty() {
|
||||||
|
|
@ -397,6 +431,35 @@ fn parse_rss(body: &str, channel_id: String) -> Option<Vec<SearchItem>> {
|
||||||
Some(items)
|
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 {
|
enum TextTarget {
|
||||||
VideoId,
|
VideoId,
|
||||||
Title,
|
Title,
|
||||||
|
|
@ -514,3 +577,244 @@ fn local_name(qualified: &[u8]) -> &str {
|
||||||
None => s,
|
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://www.youtube.com/shorts/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")]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -204,6 +204,11 @@ dependencies {
|
||||||
// cargoBuild + uniffiBindgen tasks below; phase U-2+ exposes search /
|
// cargoBuild + uniffiBindgen tasks below; phase U-2+ exposes search /
|
||||||
// streamInfo / channelInfo to replace NewPipeExtractor.
|
// streamInfo / channelInfo to replace NewPipeExtractor.
|
||||||
implementation("net.java.dev.jna:jna:5.14.0@aar")
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,7 @@ import com.sulkta.straw.data.History
|
||||||
import com.sulkta.straw.data.Subscriptions
|
import com.sulkta.straw.data.Subscriptions
|
||||||
import com.sulkta.straw.data.WatchHistoryItem
|
import com.sulkta.straw.data.WatchHistoryItem
|
||||||
import com.sulkta.straw.feature.feed.SubscriptionFeedViewModel
|
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.player.VideoThumbnail
|
||||||
import com.sulkta.straw.feature.playlist.VideoActionTarget
|
import com.sulkta.straw.feature.playlist.VideoActionTarget
|
||||||
import com.sulkta.straw.feature.playlist.VideoActionsSheet
|
import com.sulkta.straw.feature.playlist.VideoActionsSheet
|
||||||
|
|
@ -310,12 +311,15 @@ private fun SubsPane(
|
||||||
watches.map { it.videoId }.filter { it.isNotBlank() }.toSet()
|
watches.map { it.videoId }.filter { it.isNotBlank() }.toSet()
|
||||||
}
|
}
|
||||||
|
|
||||||
val hideShorts by com.sulkta.straw.data.Settings.get().hideShorts.collectAsState()
|
val settings = com.sulkta.straw.data.Settings.get()
|
||||||
val filteredItems = remember(feed.items, hideWatched, watchedIds, hideShorts) {
|
val hideShorts by settings.hideShorts.collectAsState()
|
||||||
val watchFiltered = if (!hideWatched) feed.items
|
// subsTabRows reads Hide watched and Hide Shorts from settings itself;
|
||||||
else feed.items.filterNot { extractVideoId(it.url) in watchedIds }
|
// the two collected values are keys here so that flipping either
|
||||||
com.sulkta.straw.util.applyContentFilters(watchFiltered, hideShorts = hideShorts)
|
// 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
|
// Reset pagination when the underlying list changes so the user
|
||||||
// doesn't end up looking at "no more items" after a refresh.
|
// doesn't end up looking at "no more items" after a refresh.
|
||||||
LaunchedEffect(filteredItems) {
|
LaunchedEffect(filteredItems) {
|
||||||
|
|
@ -404,7 +408,7 @@ private fun SubsPane(
|
||||||
}
|
}
|
||||||
feed.items.isNotEmpty() && filteredItems.isEmpty() -> {
|
feed.items.isNotEmpty() && filteredItems.isEmpty() -> {
|
||||||
Text(
|
Text(
|
||||||
"All caught up — nothing unwatched.",
|
subsRows.emptyText,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
|
@ -491,17 +495,6 @@ private fun SubsPane(
|
||||||
|
|
||||||
private const val PAGE_SIZE = 20
|
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)
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun FeedRow(
|
private fun FeedRow(
|
||||||
|
|
|
||||||
|
|
@ -32,18 +32,42 @@ data class FeedCacheEntry(
|
||||||
)
|
)
|
||||||
|
|
||||||
private const val PREFS = "straw_feed_cache"
|
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)
|
* v2: from 2026-10-22 strawcore gives a Short in the RSS feed its
|
||||||
private val json = Json { ignoreUnknownKeys = true }
|
* /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
|
// 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
|
// 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.
|
// there's no live value to re-read — the write captures the encoded snapshot.
|
||||||
// Ordering is safe because the only writer (SubscriptionFeedViewModel) calls
|
// Ordering is safe because the only writer (SubscriptionFeedViewModel) calls
|
||||||
// save()/clear() from a single refresh coroutine (cancels in-flight before
|
// save()/clear() from a single refresh coroutine (cancels in-flight before
|
||||||
// clear), so enqueue order == intended order and the FIFO dispatcher preserves it.
|
// 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.
|
* 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
|
* Settings.cacheTtl.isForever short-circuits the filter; finite TTLs
|
||||||
* drop entries whose fetchedAt is older than (now - ttl).
|
* drop entries whose fetchedAt is older than (now - ttl).
|
||||||
*/
|
*/
|
||||||
fun load(): Map<String, FeedCacheEntry> = runCatching {
|
fun load(): Map<String, FeedCacheEntry> =
|
||||||
val s = sp.getString(KEY, null) ?: return emptyMap()
|
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 raw = json.decodeFromString<Map<String, FeedCacheEntry>>(s)
|
||||||
val ttl = Settings.get().cacheTtl.value
|
|
||||||
if (ttl.isForever) return raw
|
if (ttl.isForever) return raw
|
||||||
val cutoff = System.currentTimeMillis() - ttl.ms
|
val cutoff = System.currentTimeMillis() - ttl.ms
|
||||||
raw.filterValues { it.fetchedAt >= cutoff }
|
raw.filterValues { it.fetchedAt >= cutoff }
|
||||||
|
|
@ -63,11 +90,11 @@ class FeedCacheStore(context: Context) {
|
||||||
/** Atomic write. Caller is responsible for diffing if needed. */
|
/** Atomic write. Caller is responsible for diffing if needed. */
|
||||||
fun save(map: Map<String, FeedCacheEntry>) {
|
fun save(map: Map<String, FeedCacheEntry>) {
|
||||||
val s = json.encodeToString(map)
|
val s = json.encodeToString(map)
|
||||||
writer.write { putString(KEY, s) }
|
writer { putString(FEED_CACHE_KEY, s) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clear() {
|
fun clear() {
|
||||||
writer.write { remove(KEY) }
|
writer { remove(FEED_CACHE_KEY) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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_VC = "latest_known_vc_v1"
|
||||||
private const val KEY_LATEST_KNOWN_VNAME = "latest_known_vname_v1"
|
private const val KEY_LATEST_KNOWN_VNAME = "latest_known_vname_v1"
|
||||||
private const val KEY_HIDE_SHORTS = "hide_shorts_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_HIDE_WATCHED = "hide_watched_v1"
|
||||||
private const val KEY_CACHE_HISTORY_WATCHES = "cache_history_watches_v1"
|
private const val KEY_CACHE_HISTORY_WATCHES = "cache_history_watches_v1"
|
||||||
private const val KEY_CACHE_HISTORY_SEARCHES = "cache_history_searches_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_ENABLED = "bg_feed_refresh_enabled_v1"
|
||||||
private const val KEY_BG_FEED_REFRESH_INTERVAL = "bg_feed_refresh_interval_v1"
|
private const val KEY_BG_FEED_REFRESH_INTERVAL = "bg_feed_refresh_interval_v1"
|
||||||
|
|
||||||
class SettingsStore(context: Context) {
|
class SettingsStore internal constructor(private val sp: SharedPreferences) {
|
||||||
private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
constructor(context: Context) : this(context.getSharedPreferences(PREFS, Context.MODE_PRIVATE))
|
||||||
|
|
||||||
// SP writes serialized off the calling thread (audit #2 M-2): the
|
// SP writes serialized off the calling thread (audit #2 M-2): the
|
||||||
// importer toggles SponsorBlock categories on Dispatchers.IO while the
|
// importer toggles SponsorBlock categories on Dispatchers.IO while the
|
||||||
|
|
@ -255,20 +256,20 @@ class SettingsStore(context: Context) {
|
||||||
val latestKnownVname: StateFlow<String> = _latestKnownVname.asStateFlow()
|
val latestKnownVname: StateFlow<String> = _latestKnownVname.asStateFlow()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hide YouTube Shorts everywhere. Detection is multi-signal because
|
* Hide YouTube Shorts. ON by default since 2026-10-22; the switch in
|
||||||
* each surface gives different hints:
|
* Settings shows them again.
|
||||||
* - Search + ChannelScreen results: URL pattern `/shorts/<id>` is
|
* What counts as a Short is ContentFilter.looksLikeShort.
|
||||||
* reliable (strawcore preserves it).
|
*
|
||||||
* - Subscription RSS feed: URLs come back as canonical `watch?v=`
|
* The default changed from false to true without a new key, and that
|
||||||
* so URL alone won't trip; fall back to title containing
|
* is enough to move every install that never touched the switch:
|
||||||
* "#shorts" / "#Shorts" / "(shorts)" which most short uploaders
|
* setHideShorts is the only writer of this key, it is called only from
|
||||||
* include.
|
* the Settings switch, and it has skipped the write when the value is
|
||||||
* Filter is best-effort — a hand-tagged short with a clean title
|
* unchanged since it was added (06bd17d82). So an untouched install has
|
||||||
* in the subs feed will slip through until a future build plumbs an
|
* no stored value and now reads true, and a stored false can only mean
|
||||||
* isShort flag through strawcore-core.
|
* the user turned the switch on and then off again, which is kept.
|
||||||
*/
|
*/
|
||||||
private val _hideShorts = MutableStateFlow(
|
private val _hideShorts = MutableStateFlow(
|
||||||
sp.getBoolean(KEY_HIDE_SHORTS, false),
|
sp.getBoolean(KEY_HIDE_SHORTS, HIDE_SHORTS_DEFAULT),
|
||||||
)
|
)
|
||||||
val hideShorts: StateFlow<Boolean> = _hideShorts.asStateFlow()
|
val hideShorts: StateFlow<Boolean> = _hideShorts.asStateFlow()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -381,8 +381,11 @@ class SubscriptionFeedViewModel : ViewModel() {
|
||||||
* refresh + clearInMemoryCache can cancel it.
|
* refresh + clearInMemoryCache can cancel it.
|
||||||
*/
|
*/
|
||||||
private fun enrichVisibleItems(items: List<StreamItem>, channelsSnapshot: List<ChannelRef>) {
|
private fun enrichVisibleItems(items: List<StreamItem>, channelsSnapshot: List<ChannelRef>) {
|
||||||
val take = items.take(ENRICH_HEAD_COUNT)
|
val take = feedEnrichmentCandidates(
|
||||||
.filter { it.viewCount <= 0L && it.durationSeconds <= 0L }
|
items,
|
||||||
|
hideShorts = Settings.get().hideShorts.value,
|
||||||
|
count = ENRICH_HEAD_COUNT,
|
||||||
|
)
|
||||||
if (take.isEmpty()) return
|
if (take.isEmpty()) return
|
||||||
enrichJob?.cancel()
|
enrichJob?.cancel()
|
||||||
enrichJob = viewModelScope.launch {
|
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
|
* Convert "2 days ago" / "3 weeks ago" / "Streamed 5 hours ago" style
|
||||||
* strings into approximate seconds-ago. Higher = more recent (so default
|
* strings into approximate seconds-ago. Higher = more recent (so default
|
||||||
|
|
|
||||||
|
|
@ -169,9 +169,13 @@ fun SearchScreen(
|
||||||
modifier = Modifier.padding(bottom = 4.dp),
|
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) {
|
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()
|
val listState = rememberLazyListState()
|
||||||
// Infinite scroll: near the end of a live result set with a
|
// Infinite scroll: near the end of a live result set with a
|
||||||
|
|
|
||||||
|
|
@ -358,9 +358,7 @@ fun SettingsScreen() {
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
"Drop /shorts/ URLs from search + channel pages " +
|
"Keep YouTube Shorts out of your subscriptions feed.",
|
||||||
"and best-effort filter (\"#shorts\" tag) on the " +
|
|
||||||
"subs feed.",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -6,28 +6,38 @@
|
||||||
* Pure functions on StreamItem so any list-rendering site can call them
|
* Pure functions on StreamItem so any list-rendering site can call them
|
||||||
* with one line at row-emit time.
|
* 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
|
* 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.
|
* call sites we add now don't need to change when the flags arrive.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package com.sulkta.straw.util
|
package com.sulkta.straw.util
|
||||||
|
|
||||||
|
import com.sulkta.straw.data.SettingsStore
|
||||||
import com.sulkta.straw.feature.search.StreamItem
|
import com.sulkta.straw.feature.search.StreamItem
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Best-effort short-video detector:
|
* A Short is an item whose URL has the `/shorts/<id>` shape.
|
||||||
* - URL pattern `/shorts/<id>` — reliable signal from search +
|
*
|
||||||
* channel pages (strawcore preserves the original URL shape).
|
* Where that shape comes from, per surface (measured 2026-10-22):
|
||||||
* - Title contains `#shorts` / `#short` / "(shorts)" — fallback for
|
* - Subscription feed: strawcore's RSS parser gives an entry its
|
||||||
* items where the URL is the canonical `watch?v=` form (RSS feed
|
* /shorts/ URL exactly when YouTube's channel feed links it there,
|
||||||
* items always come through this way).
|
* 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 {
|
fun looksLikeShort(item: StreamItem): Boolean = "/shorts/" in item.url
|
||||||
if ("/shorts/" in item.url) return true
|
|
||||||
val t = item.title.lowercase()
|
|
||||||
return "#shorts" in t || "#short" in t || "(shorts)" in t
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Placeholder until adds an isPaid flag via strawcore-core.
|
* Placeholder until adds an isPaid flag via strawcore-core.
|
||||||
|
|
@ -58,3 +68,13 @@ fun applyContentFilters(
|
||||||
(hidePaid && looksLikePaid(item)) ||
|
(hidePaid && looksLikePaid(item)) ||
|
||||||
(hideAgeRestricted && looksLikeAgeRestricted(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)
|
||||||
|
|
|
||||||
|
|
@ -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)"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue