8 Commits
Author SHA1 Message Date
Worph b2bc27f99c CI: push to ipcr-push-85-17-246-67.ip.nsl.sh (the box's cert no longer covers nip.io) [skip ci] 2026-11-04 14:31:38 +00:00
Manielle 02ed69d275 CI accepts four-part tags (v1.2.3.1) for fixes
Publish image / publish (push) Failing after 55s
2026-10-25 14:22:08 +01:00
Worph 12c73bfa9a release (first build on gitea-watch / IPCR)
Publish image / publish (push) Successful in 2m26s
2026-10-13 11:03:25 +00:00
Worph 01769b36f7 gitea workflow: push to IPCR's direct host (Cloudflare 100 MB cap) [skip ci] 2026-10-13 10:02:48 +00:00
Worph 2a2d97a6b1 gitea workflow: publish to IPCR (metadec.eth) instead of ghcr
Publish image / publish (push) Successful in 1m49s
2026-10-12 18:31:04 +00:00
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
4 changed files with 254 additions and 3 deletions
+88
View File
@@ -0,0 +1,88 @@
# Gitea twin of .github/workflows/publish.yml. Gitea runs only .gitea/workflows/ once this
# folder exists, and GitHub never reads it.
#
# Publishes to IPCR on watch.nsl.sh instead of ghcr.io:
# push ipcr-push-85-17-246-67.ip.nsl.sh/metadec.eth/<repo>:<tag> docker login as metadec.eth with the
# IPCR_PUSH_TOKEN secret (the metadec.eth namespace token from the IPCR dashboard)
# pull ipcr-watch.nsl.sh/ipns/metadec.eth/metadec.eth/<repo>:<tag> anonymous
# The push goes to ipcr-push-85-17-246-67.ip.nsl.sh, the box's own address, not ipcr-push-watch.nsl.sh: Cloudflare fronts
# *.watch.nsl.sh and refuses any request over 100 MB (413), which a large image layer is.
# That host's certificate is signed by the box's private Mesh Router CA, given to buildkit from
# the IPCR_CA_PEM repo variable. docker login would make the job's Docker daemon check the
# certificate without that CA, so the login is written straight into ~/.docker/config.json.
# The runner's job image (node:22-bookworm) has no docker CLI, so the static one is
# installed first; buildx installs itself. No `type=gha` cache: the runner has none.
name: Publish image
# Build and push the feeder image to the IPCR (ipcr-push-watch.nsl.sh):
# * every push to main -> the moving `main` tag
# * every `v*` tag -> semver tags (e.g. v1.0.0 -> 1.0.0, 1.0, 1)
# The build context is the repo root (the workspace; `meta-feeder-sdk` is a
# git dependency pinned by tag); the Dockerfile lives under
# feeder-plugin/card-feeder/.
on:
push:
branches: [main]
tags: ['v*']
env:
REGISTRY: ipcr-push-85-17-246-67.ip.nsl.sh
IMAGE_NAME: metadec.eth/${{ github.event.repository.name }}
jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Docker CLI
run: curl -fsSL https://download.docker.com/linux/static/stable/x86_64/docker-28.5.1.tgz | tar -xz -C /usr/local/bin --strip-components=1 docker/docker
- name: Trust the Mesh Router CA
run: printf '%s\n' "$IPCR_CA_PEM" > /tmp/ipcr-ca.pem
env:
IPCR_CA_PEM: ${{ vars.IPCR_CA_PEM }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
version: latest
buildkitd-config-inline: |
[registry."${{ env.REGISTRY }}"]
ca = ["/tmp/ipcr-ca.pem"]
- name: Log in to IPCR
env:
IPCR_PUSH_TOKEN: ${{ secrets.IPCR_PUSH_TOKEN }}
run: |
mkdir -p ~/.docker
printf '{"auths":{"%s":{"auth":"%s"}}}\n' "$REGISTRY" "$(printf 'metadec.eth:%s' "$IPCR_PUSH_TOKEN" | base64 -w0)" > ~/.docker/config.json
- name: Extract image metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=semver,pattern={{version}}
# A fix to release 1.2.3 is tagged v1.2.3.1: four numbers are not semver, so
# the pattern above makes no tag for it; this one does (image tag 1.2.3.1).
type=match,pattern=v(\d+\.\d+\.\d+\.\d+),group=1
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=sha,format=short
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: ./feeder-plugin/card-feeder/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+3
View File
@@ -43,6 +43,9 @@ jobs:
tags: | tags: |
type=ref,event=branch type=ref,event=branch
type=semver,pattern={{version}} type=semver,pattern={{version}}
# A fix to release 1.2.3 is tagged v1.2.3.1: four numbers are not semver, so
# the pattern above makes no tag for it; this one does (image tag 1.2.3.1).
type=match,pattern=v(\d+\.\d+\.\d+\.\d+),group=1
type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}} type=semver,pattern={{major}}
type=sha,format=short type=sha,format=short
+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. /// asking for 500) to a predictable per-row TMDB cost.
pub(crate) const DISCOVERY_MAX_PAGES: u32 = 5; 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 /// TMDB keyword ids excluded from an `anime:true` discovery row, as
/// `without_keywords`: `hentai`, `softcore`, `erotica`, `pornography`, /// `without_keywords`: `hentai`, `softcore`, `erotica`, `pornography`,
/// `adult animation`. /// `adult animation`.
+149 -3
View File
@@ -58,7 +58,9 @@ use meta_feeder_sdk::plugin::GatewayQuery;
use tracing::debug; use tracing::debug;
use crate::card::{Card, CardSource}; 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::resolve::{dedup_titles, Resolver};
use crate::tmdb_client::{title_names, TmdbHit, TmdbKind}; use crate::tmdb_client::{title_names, TmdbHit, TmdbKind};
@@ -77,6 +79,15 @@ pub(crate) enum DiscoveryMode {
Popular, Popular,
Trending, Trending,
TopRated, 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 { impl DiscoveryMode {
@@ -94,6 +105,7 @@ impl DiscoveryMode {
DiscoveryMode::Popular => "popular", DiscoveryMode::Popular => "popular",
DiscoveryMode::Trending => "trending", DiscoveryMode::Trending => "trending",
DiscoveryMode::TopRated => "top_rated", DiscoveryMode::TopRated => "top_rated",
DiscoveryMode::Airing => "airing",
} }
} }
@@ -103,7 +115,11 @@ impl DiscoveryMode {
fn discover_sort(self) -> &'static str { fn discover_sort(self) -> &'static str {
match self { match self {
DiscoveryMode::TopRated => "vote_average.desc", 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. /// First truthy mode marker on the query, in precedence order.
fn discovery_mode(query: &GatewayQuery) -> Option<DiscoveryMode> { fn discovery_mode(query: &GatewayQuery) -> Option<DiscoveryMode> {
for mode in [ 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::Trending,
DiscoveryMode::Popular, DiscoveryMode::Popular,
DiscoveryMode::TopRated, DiscoveryMode::TopRated,
@@ -203,6 +223,70 @@ fn with_genres_ids(wanted: &[String], names: &HashMap<u32, String>) -> Vec<u32>
ids 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, /// Build the `path_and_query` for `TmdbClient::discovery_list` from the mode,
/// kind, anime flag, requested genre ids, and 1-based `page`. /// kind, anime flag, requested genre ids, and 1-based `page`.
fn build_path_and_query( 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. // single 10.0 vote on top, which a 50-vote gate doesn't stop.
let votes = match mode { let votes = match mode {
DiscoveryMode::TopRated => 200, 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, _ => DISCOVERY_MIN_VOTES,
}; };
let dates = match mode {
DiscoveryMode::Airing => airing_bounds(kind, today_utc_days()),
_ => String::new(),
};
let keyword = if anime { let keyword = if anime {
format!("with_keywords={TMDB_ANIME_KEYWORD}&") format!("with_keywords={TMDB_ANIME_KEYWORD}&")
} else { } else {
@@ -266,7 +360,7 @@ fn build_path_and_query(
format!("with_genres={joined}&") format!("with_genres={joined}&")
}; };
format!( 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}", &without_keywords={DISCOVERY_EXCLUDED_KEYWORDS}&vote_count.gte={votes}&page={page}",
mode.discover_sort(), mode.discover_sort(),
) )
@@ -719,4 +813,56 @@ mod tests {
let movie = card_from_hit(&hit(), TmdbKind::Movie, &genre_table()); let movie = card_from_hit(&hit(), TmdbKind::Movie, &genre_table());
assert_eq!(movie.content_kind(), "movie"); 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}");
}
} }