3 Commits
Author SHA1 Message Date
Manielle 98b1298df0 Merge origin/main into airing-row
Publish image / publish (push) Failing after 14s
2026-10-04 21:00:15 +02:00
Manielle 4e6edf4c5c card: exclude the daily-broadcast genres from the airing rows
Talk, news, soap and reality air every day; a row bounded by "aired today" is
otherwise their schedule. Excluded on the airing path only — a popular row has
no reason to hide a talk show.
2026-09-29 20:27:53 +02:00
Manielle 400f1a3a18 card: add the airing mode, a date-bounded discovery row
TMDB knows what airs today; asking the indexers instead would mean one query
per show per day, which is the load that wedges a Prowlarr. So the row is
bounded by air date: TV to the single day, film to a fortnight (a day of
cinema releases is usually empty). The vote floor is lifted on this row only —
a season airing now has not collected 50 votes yet, and the shared floor would
empty exactly the row it is meant to fill. The bound is computed per request,
so the row rolls over at midnight with nothing scheduled.
2026-09-28 22:16:27 +02:00
2 changed files with 163 additions and 3 deletions
+14
View File
@@ -47,6 +47,20 @@ pub(crate) const TMDB_DISCOVERY_WAIT_DEADLINE_SECS: u64 = 5;
/// asking for 500) to a predictable per-row TMDB cost.
pub(crate) const DISCOVERY_MAX_PAGES: u32 = 5;
/// TMDB genre ids kept OUT of the date-bounded `airing` rows.
///
/// A row bounded by "aired today" is otherwise dominated by what airs EVERY
/// day: late-night talk (`The Tonight Show`, `Watch What Happens Live`), the
/// news-desk shows (`The Daily Show`), long-running soaps (`Goede Tijden,
/// Slechte Tijden`, `Demain nous appartient`) and reality. Measured on the live
/// row: 6 of the first 6 titles. They are legitimately airing — they are simply
/// not what "what came out today" is asked to answer, and they crowd out the
/// drama that airs weekly.
///
/// 10764 Reality · 10767 Talk · 10763 News · 10766 Soap.
/// Drop an id from this list to let that genre back into the row.
pub(crate) const AIRING_EXCLUDED_GENRES: &str = "10764,10767,10763,10766";
/// TMDB keyword ids excluded from an `anime:true` discovery row, as
/// `without_keywords`: `hentai`, `softcore`, `erotica`, `pornography`,
/// `adult animation`.
+149 -3
View File
@@ -58,7 +58,9 @@ use meta_feeder_sdk::plugin::GatewayQuery;
use tracing::debug;
use crate::card::{Card, CardSource};
use crate::consts::{DISCOVERY_EXCLUDED_KEYWORDS, DISCOVERY_MAX_PAGES, DISCOVERY_MIN_VOTES};
use crate::consts::{
AIRING_EXCLUDED_GENRES, DISCOVERY_EXCLUDED_KEYWORDS, DISCOVERY_MAX_PAGES, DISCOVERY_MIN_VOTES,
};
use crate::resolve::{dedup_titles, Resolver};
use crate::tmdb_client::{title_names, TmdbHit, TmdbKind};
@@ -77,6 +79,15 @@ pub(crate) enum DiscoveryMode {
Popular,
Trending,
TopRated,
/// What came out **today** — the day-by-day row. TV: shows with an episode
/// airing today; film: releases from the last fortnight.
///
/// Date-bounded rather than availability-bounded on purpose. "Is a file out
/// yet" is an indexer question, and asking it per show per day is exactly
/// the load that wedges a Prowlarr; an air date is a fact TMDB already
/// holds, costs the same single call as every other row, and makes the row
/// roll over by itself at midnight with nothing scheduled.
Airing,
}
impl DiscoveryMode {
@@ -94,6 +105,7 @@ impl DiscoveryMode {
DiscoveryMode::Popular => "popular",
DiscoveryMode::Trending => "trending",
DiscoveryMode::TopRated => "top_rated",
DiscoveryMode::Airing => "airing",
}
}
@@ -103,7 +115,11 @@ impl DiscoveryMode {
fn discover_sort(self) -> &'static str {
match self {
DiscoveryMode::TopRated => "vote_average.desc",
DiscoveryMode::Popular | DiscoveryMode::Trending => "popularity.desc",
// Airing sorts by popularity too: the date bound is what selects the
// row, so the sort only decides who leads it.
DiscoveryMode::Popular | DiscoveryMode::Trending | DiscoveryMode::Airing => {
"popularity.desc"
}
}
}
}
@@ -124,6 +140,10 @@ pub(crate) fn is_discovery_query(query: &GatewayQuery) -> bool {
/// First truthy mode marker on the query, in precedence order.
fn discovery_mode(query: &GatewayQuery) -> Option<DiscoveryMode> {
for mode in [
// Airing leads: it is the most specific claim a row can make, and a
// caller that sends `airing:true popular:true` means "today's, by
// popularity", not "popular, ever".
DiscoveryMode::Airing,
DiscoveryMode::Trending,
DiscoveryMode::Popular,
DiscoveryMode::TopRated,
@@ -203,6 +223,70 @@ fn with_genres_ids(wanted: &[String], names: &HashMap<u32, String>) -> Vec<u32>
ids
}
/// How far back the film side of [`DiscoveryMode::Airing`] reaches, in days.
///
/// A series airs an episode on a given day, so "today" is a real set. Films do
/// not: a single day's releases are usually empty, and a viewer opening the app
/// on a Tuesday still means "what's new". A fortnight is the smallest window
/// that keeps the row populated without turning it into "this year".
const AIRING_MOVIE_WINDOW_DAYS: i64 = 13;
/// Today, UTC, as `YYYY-MM-DD` — the bound [`DiscoveryMode::Airing`] is built
/// from. UTC rather than a local zone: the feeder serves peers in every zone,
/// and TMDB's air dates are themselves zone-less calendar dates.
fn today_utc_days() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| (d.as_secs() / 86_400) as i64)
.unwrap_or(0)
}
/// `YYYY-MM-DD` for a count of days since 1970-01-01.
///
/// Hand-rolled (Howard Hinnant's civil-from-days) rather than pulling in a date
/// crate for one format call — and split from [`today_utc_days`] so the whole
/// thing is testable against known dates instead of "whatever today is".
fn fmt_days(days: i64) -> String {
let z = days + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = (z - era * 146_097) as i64; // [0, 146096]
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
let mp = (5 * doy + 2) / 153; // [0, 11]
let d = doy - (153 * mp + 2) / 5 + 1; // [1, 31]
let m = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12]
let y = if m <= 2 { y + 1 } else { y };
format!("{y:04}-{m:02}-{d:02}")
}
/// The `/discover` date bounds for [`DiscoveryMode::Airing`], given today.
///
/// TV bounds `air_date` to the single day, which is what makes the row
/// "today's episodes" and what makes it roll over on its own. Film bounds
/// `primary_release_date` to [`AIRING_MOVIE_WINDOW_DAYS`] back — see that
/// constant for why the two kinds cannot share a window.
fn airing_bounds(kind: TmdbKind, today: i64) -> String {
match kind {
TmdbKind::Tv => {
let d = fmt_days(today);
// ⚠ `without_genres` rides with the date bound, and only here. A row
// of "aired today" is otherwise the daily-broadcast schedule — talk,
// news, soap, reality — which is exactly what the viewer did not
// mean. See [`AIRING_EXCLUDED_GENRES`]. The other modes keep every
// genre: a *popular* row has no reason to hide a talk show.
format!(
"air_date.gte={d}&air_date.lte={d}&without_genres={AIRING_EXCLUDED_GENRES}&"
)
}
TmdbKind::Movie => format!(
"primary_release_date.gte={}&primary_release_date.lte={}&",
fmt_days(today - AIRING_MOVIE_WINDOW_DAYS),
fmt_days(today),
),
}
}
/// Build the `path_and_query` for `TmdbClient::discovery_list` from the mode,
/// kind, anime flag, requested genre ids, and 1-based `page`.
fn build_path_and_query(
@@ -244,8 +328,18 @@ fn build_path_and_query(
// single 10.0 vote on top, which a 50-vote gate doesn't stop.
let votes = match mode {
DiscoveryMode::TopRated => 200,
// ⚠ No vote floor on the airing row, and that is the point. The shared
// gate exists to keep unrated residue off a popularity ranking; here the
// date bound already decides membership, and a floor would filter out
// exactly what the row is for — a series whose first season is airing
// now has not had time to collect 50 votes.
DiscoveryMode::Airing => 0,
_ => DISCOVERY_MIN_VOTES,
};
let dates = match mode {
DiscoveryMode::Airing => airing_bounds(kind, today_utc_days()),
_ => String::new(),
};
let keyword = if anime {
format!("with_keywords={TMDB_ANIME_KEYWORD}&")
} else {
@@ -266,7 +360,7 @@ fn build_path_and_query(
format!("with_genres={joined}&")
};
format!(
"discover/{seg}?{keyword}{genres}sort_by={}&include_adult=false\
"discover/{seg}?{keyword}{genres}{dates}sort_by={}&include_adult=false\
&without_keywords={DISCOVERY_EXCLUDED_KEYWORDS}&vote_count.gte={votes}&page={page}",
mode.discover_sort(),
)
@@ -719,4 +813,56 @@ mod tests {
let movie = card_from_hit(&hit(), TmdbKind::Movie, &genre_table());
assert_eq!(movie.content_kind(), "movie");
}
/// The day-by-day row: what selects it, and what it asks TMDB for.
///
/// Bounding by air date rather than by availability is the whole design —
/// see [`DiscoveryMode::Airing`]. These pin the three parts that make the
/// row roll over on its own: the date maths, the bounds per kind, and the
/// fact that the vote floor is lifted (a season airing now has no votes
/// yet, and the shared floor would empty the row it is meant to fill).
#[test]
fn airing_row_is_bounded_by_date_not_by_votes() {
// Date maths, against known days-since-epoch.
assert_eq!(fmt_days(0), "1970-01-01");
assert_eq!(fmt_days(19_723), "2024-01-01"); // a leap year's first day
assert_eq!(fmt_days(19_782), "2024-02-29"); // the leap day itself
assert_eq!(fmt_days(20_453), "2025-12-31");
// TV: exactly one day, so "today's episodes" is literal.
let tv = airing_bounds(TmdbKind::Tv, 19_723);
assert!(tv.starts_with("air_date.gte=2024-01-01&air_date.lte=2024-01-01&"), "{tv}");
// The daily-broadcast genres are excluded from this row, and only this
// row — a talk show airs every day and would otherwise fill it.
assert!(tv.contains(&format!("without_genres={AIRING_EXCLUDED_GENRES}")), "{tv}");
assert!(
!build_path_and_query(DiscoveryMode::Popular, TmdbKind::Tv, false, &[], 1)
.contains("without_genres"),
"a popular row must keep every genre"
);
// Film: a fortnight back, because a single day of releases is empty.
let movie = airing_bounds(TmdbKind::Movie, 19_723);
assert!(movie.contains("primary_release_date.gte=2023-12-19"), "{movie}");
assert!(movie.contains("primary_release_date.lte=2024-01-01"), "{movie}");
// The marker selects the mode, and outranks a co-sent `popular`.
assert_eq!(
discovery_mode(&query(&[("airing", "true"), ("popular", "true")])),
Some(DiscoveryMode::Airing)
);
// The built query carries the date bound and lifts the vote floor.
let url = build_path_and_query(DiscoveryMode::Airing, TmdbKind::Tv, false, &[], 1);
assert!(url.starts_with("discover/tv?"), "{url}");
assert!(url.contains("air_date.gte="), "{url}");
assert!(url.contains("vote_count.gte=0"), "{url}");
// Still gated for adult residue like every other /discover row.
assert!(url.contains("without_keywords="), "{url}");
// A popular row is unchanged: no date bound, floor intact.
let pop = build_path_and_query(DiscoveryMode::Popular, TmdbKind::Tv, false, &[], 1);
assert!(!pop.contains("air_date."), "{pop}");
assert!(pop.contains(&format!("vote_count.gte={DISCOVERY_MIN_VOTES}")), "{pop}");
}
}