vc=61: fix subs feed sort + date display

Cobb caught the regression on vc=60: subs feed only showed LTT +
WTYP because vc=56's RSS path emitted raw ISO timestamps in
upload_date_relative, but Kotlin's recencyScore() parser only
understands 'N units ago' format. Every item tied at MIN_VALUE,
sort order went to whichever channel resolved first in the
50-concurrent fan-out — LTT + WTYP just happened to win the race.

Fix in feed.rs: parse the RFC3339 published timestamp, compute
delta from now, format as 'N second/minute/hour/day/week/month/year
ago'. Matches recencyScore's regex exactly. RSS still gives ISO;
we convert at the Rust boundary.

Standalone RFC3339 parser (no chrono dep) — Howard Hinnant's
civil-to-days algo, 30 lines, handles negative years correctly.

Display ALSO benefits — UI was showing the raw ISO string
('2026-05-19T13:00:31+00:00') in the channel row. Now reads
'7 days ago' like every other YT client.
This commit is contained in:
Kayos 2026-05-26 12:24:33 -07:00
parent 26c9483b94
commit 6cc789a8a0
2 changed files with 89 additions and 6 deletions

View file

@ -55,6 +55,6 @@ const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app"
// vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via // vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via
// strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no // strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no
// NewPipeExtractor in the runtime path. // NewPipeExtractor in the runtime path.
const val STRAW_VERSION_CODE = 60 const val STRAW_VERSION_CODE = 61
const val STRAW_VERSION_NAME = "0.1.0-BT" const val STRAW_VERSION_NAME = "0.1.0-BU"
const val STRAW_APPLICATION_ID = "com.sulkta.straw" const val STRAW_APPLICATION_ID = "com.sulkta.straw"

View file

@ -225,10 +225,16 @@ fn parse_rss(body: &str, channel_id: String) -> Option<Vec<SearchItem>> {
thumbnail: thumbnail.clone(), thumbnail: thumbnail.clone(),
duration_seconds: 0, duration_seconds: 0,
view_count: 0, view_count: 0,
// RSS gives ISO-8601 timestamps. We pass them // RSS gives RFC3339 timestamps. Convert to
// through unchanged — newer-first sorting on // the human-relative format Kotlin's
// raw ISO strings is correct. // recencyScore parser expects ("N units
upload_date_relative: published.clone(), // ago"). vc=56 was passing the raw ISO
// through, which broke the sort comparator
// — every item tied at MIN_VALUE so the
// feed order was effectively random; LTT +
// WTYP landed at top because they resolved
// first in the fan-out. Caught 2026-05-26.
upload_date_relative: iso_to_relative(&published),
}); });
} }
in_entry = false; in_entry = false;
@ -255,6 +261,83 @@ enum TextTarget {
Published, Published,
} }
/// Parse an RFC3339 timestamp (`2026-05-25T15:00:00+00:00`) into "N
/// units ago". Drops the timezone offset — YT RSS always serves UTC
/// and the granularity is days at most, so a ±14h skew doesn't matter
/// for the relative display.
///
/// Falls back to the raw string if parsing fails. That keeps the UI
/// readable even on a malformed feed (rare).
fn iso_to_relative(iso: &str) -> String {
let secs = match parse_rfc3339_secs(iso) {
Some(s) => s,
None => return iso.to_string(),
};
let now_secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
format_relative(now_secs.saturating_sub(secs))
}
fn parse_rfc3339_secs(s: &str) -> Option<i64> {
if s.len() < 19 {
return None;
}
let date = s.get(..10)?;
let time = s.get(11..19)?;
if !s.is_char_boundary(10) || s.as_bytes().get(10) != Some(&b'T') {
return None;
}
let mut date_parts = date.split('-');
let y: i32 = date_parts.next()?.parse().ok()?;
let m: u32 = date_parts.next()?.parse().ok()?;
let d: u32 = date_parts.next()?.parse().ok()?;
let mut time_parts = time.split(':');
let hh: u32 = time_parts.next()?.parse().ok()?;
let mm: u32 = time_parts.next()?.parse().ok()?;
let ss: u32 = time_parts.next()?.parse().ok()?;
if !(1..=12).contains(&m) || !(1..=31).contains(&d) || hh > 23 || mm > 59 || ss > 60 {
return None;
}
let days = civil_to_days(y, m, d);
Some(days * 86_400 + hh as i64 * 3_600 + mm as i64 * 60 + ss as i64)
}
/// Howard Hinnant's days-since-1970-01-01 algorithm. Standard,
/// branch-free, handles negative years correctly. Source: chrono
/// proposal for C++20.
fn civil_to_days(y: i32, m: u32, d: u32) -> i64 {
let y = if m <= 2 { y - 1 } else { y };
let era = if y >= 0 { y / 400 } else { (y - 399) / 400 };
let yoe = (y - era * 400) as u32;
let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era as i64 * 146_097 + doe as i64 - 719_468
}
fn format_relative(age_secs: i64) -> String {
let s = age_secs.max(0);
fn unit(n: i64, name: &str) -> String {
format!("{} {}{} ago", n, name, if n == 1 { "" } else { "s" })
}
if s < 60 {
unit(s, "second")
} else if s < 3_600 {
unit(s / 60, "minute")
} else if s < 86_400 {
unit(s / 3_600, "hour")
} else if s < 604_800 {
unit(s / 86_400, "day")
} else if s < 2_592_000 {
unit(s / 604_800, "week")
} else if s < 31_536_000 {
unit(s / 2_592_000, "month")
} else {
unit(s / 31_536_000, "year")
}
}
/// Strip the namespace prefix off an XML element name. YouTube's feed /// Strip the namespace prefix off an XML element name. YouTube's feed
/// is heavily namespaced (`yt:videoId`, `media:thumbnail`) but we only /// is heavily namespaced (`yt:videoId`, `media:thumbnail`) but we only
/// care about the local part — namespace-vs-local distinguishing /// care about the local part — namespace-vs-local distinguishing