5 Commits
Author SHA1 Message Date
Aculix Technologies a82c2b29af chore: release 1.2.0 2026-09-10 23:28:19 +05:30
Aculix Technologies 474415bb55 fix: re-alert when withdrawn shows are re-listed
District published Endgame showtimes, the monitor alerted, and then the shows were
pulled. Dedupe remembered every session id it had announced, and re-listings often
reuse the same ids, so the moment bookings actually opened would have been silent.
It only re-armed this time because a redeploy happened to wipe the state file.

A session missing for three consecutive successful checks is now forgotten, so its
return alerts again. Three rather than one, so a flaky response can't produce a
duplicate max-priority push; failed checks don't count. When the whole date goes
dark after opening, a separate notice says the shows were withdrawn. The re-arm
lives in the base state rather than that notice's patch, so a lost notification
can't leave the monitor disarmed.
2026-09-10 21:06:19 +05:30
Aculix Technologies ef23f25ee5 fix: District settings read per field, so DISTRICT_* always wins
A leftover v1 MOVIE_CODE used to switch the whole District block to the old names,
silently discarding DISTRICT_CONTENT_ID even when it was set deliberately. The
monitor then watched the wrong film and still reported 'closed', which is the
failure mode this project exists to avoid.

Each field now prefers the DISTRICT_* value and falls back to its v1 name, an
explicitly emptied value is respected, and any v1 name still in use is warned
about at startup. District is enabled by DISTRICT_CONTENT_ID rather than the
movie code, which is optional: District answers without it, and a film with no
showtimes yet has none.
2026-09-08 15:14:51 +05:30
Aculix Technologies 6607a469a2 feat: status endpoint reports what each site is actually watching
Adds a 'watching' block with the movie id, variant, venue and format filter per
provider. A wrong id or an unexpectedly narrow filter was previously impossible
to tell apart from 'nothing on sale yet', since both just report closed.
2026-09-08 15:07:14 +05:30
Aculix Technologies 67e97f5a6a fix: treat District 'content not found' as waiting, not as a failure
A film that is announced but has no showtimes yet answers 400 with code 32, which
is exactly the state you are monitoring for before a release. Counting that as a
check failure meant pointing District at an upcoming film produced a false BROKEN
alert every six minutes until the listing appeared.

Code 32 now reports closed and carries a note through to the status endpoint and
the daily heartbeat, so a typo'd content id still surfaces rather than looking
like a quiet 'not open yet' forever. Other 400s, including the missing-parameter
error, still fail loudly.
2026-09-08 14:57:31 +05:30
10 changed files with 330 additions and 48 deletions

No files matched your search

+5 -3
View File
@@ -18,11 +18,13 @@ NTFY_URL=https://ntfy.sh
# NTFY_TOKEN=tk_... # only if your server (or topic) requires auth # NTFY_TOKEN=tk_... # only if your server (or topic) requires auth
# ── District (district.in) ──────────────────────────────────────────────────── # ── District (district.in) ────────────────────────────────────────────────────
# Leave DISTRICT_MOVIE_CODE unset to skip District entirely. # Leave DISTRICT_CONTENT_ID unset to skip District entirely.
# Read these off the movie's page URL, e.g. # Read these off the movie's page URL, e.g.
# district.in/movies/the-odyssey-movie-tickets-in-ahmedabad-MV187151?frmtid=y7EZNLXN5Y # district.in/movies/the-odyssey-movie-tickets-in-ahmedabad-MV187151?frmtid=y7EZNLXN5Y
DISTRICT_MOVIE_CODE=y7EZNLXN5Y # ?frmtid= — identifies movie + language DISTRICT_CONTENT_ID=187151 # digits after MV; this is what enables District
DISTRICT_CONTENT_ID=187151 # digits after MV DISTRICT_MOVIE_CODE=y7EZNLXN5Y # optional ?frmtid=, pins one language/format variant.
# Leave empty for a film with no showtimes yet, since
# District only publishes the code once shows exist.
DISTRICT_CITY_KEY=ahmedabad # city slug in the path DISTRICT_CITY_KEY=ahmedabad # city slug in the path
DISTRICT_LAT=23.033938956712916 # any coordinates in that city; the API needs them DISTRICT_LAT=23.033938956712916 # any coordinates in that city; the API needs them
DISTRICT_LNG=72.57696301365426 DISTRICT_LNG=72.57696301365426
+12 -6
View File
@@ -28,12 +28,14 @@ daily heartbeat. A monitor that died at 3am and one that's quietly working shoul
same from the outside. same from the outside.
Alerts are deduplicated by session id, so you hear about each show once. Shows added later still Alerts are deduplicated by session id, so you hear about each show once. Shows added later still
get through. get through, and so do shows that were pulled and re-listed: a show missing for three checks in a
row is forgotten, so its return alerts you again.
| Notification | Priority | What it means | | Notification | Priority | What it means |
|---|---|---| |---|---|---|
| `<FORMAT> <DATE> OPEN on <SITE> - BOOK NOW` | max | The one you're waiting for. Tap it to book. | | `<FORMAT> <DATE> OPEN on <SITE> - BOOK NOW` | max | The one you're waiting for. Tap it to book. |
| `<DATE> is OPEN on <SITE> (no <FORMAT> yet)` | high | Date opened, your format hasn't shown up yet. | | `<DATE> is OPEN on <SITE> (no <FORMAT> yet)` | high | Date opened, your format hasn't shown up yet. |
| `<DATE> shows withdrawn on <SITE>` | default | Shows you were alerted about were pulled. Re-armed for their return. |
| `<SITE> checks BROKEN - check manually` | high | That site's checks are failing. Don't trust the silence. | | `<SITE> checks BROKEN - check manually` | high | That site's checks are failing. Don't trust the silence. |
| `<SITE> checks recovered` | default | Back to normal. | | `<SITE> checks recovered` | default | Back to normal. |
| `Still watching <DATE>` | min | Daily heartbeat, with a line per site. | | `Still watching <DATE>` | min | Daily heartbeat, with a line per site. |
@@ -106,7 +108,7 @@ support.
## Finding your IDs ## Finding your IDs
Configure either site, or both. Skip a site by leaving its main ID unset: no Configure either site, or both. Skip a site by leaving its main ID unset: no
`DISTRICT_MOVIE_CODE` means District isn't watched, no `BMS_EVENT_CODE` means BookMyShow isn't. `DISTRICT_CONTENT_ID` means District isn't watched, no `BMS_EVENT_CODE` means BookMyShow isn't.
### District ### District
@@ -118,7 +120,9 @@ https://www.district.in/movies/the-odyssey-movie-tickets-in-ahmedabad-MV187151?f
``` ```
- `DISTRICT_CONTENT_ID` is the number after `MV` - `DISTRICT_CONTENT_ID` is the number after `MV`
- `DISTRICT_MOVIE_CODE` is the `frmtid` parameter, which pins down the movie *and* the language - `DISTRICT_MOVIE_CODE` is the `frmtid` parameter, which pins down the movie *and* the language.
It's optional, and a film that hasn't been given showtimes yet doesn't have one, so leave it
empty when you're waiting on an unreleased title.
- `DISTRICT_CITY_KEY` is the city slug in the path - `DISTRICT_CITY_KEY` is the city slug in the path
- `DISTRICT_LAT` and `DISTRICT_LNG` can be any coordinates inside that city. The API refuses to - `DISTRICT_LAT` and `DISTRICT_LNG` can be any coordinates inside that city. The API refuses to
answer without them, though results are city-wide either way. answer without them, though results are city-wide either way.
@@ -159,7 +163,8 @@ with comments in [`.env.example`](.env.example).
|---|---|---| |---|---|---|
| `TARGET_DATE` | *required* | The date you're waiting for, `YYYY-MM-DD` | | `TARGET_DATE` | *required* | The date you're waiting for, `YYYY-MM-DD` |
| `NTFY_TOPIC` | *required* | The topic you subscribe to. Treat it like a password. | | `NTFY_TOPIC` | *required* | The topic you subscribe to. Treat it like a password. |
| `DISTRICT_MOVIE_CODE` | | Set to watch District. Plus `DISTRICT_CONTENT_ID`, `DISTRICT_CITY_KEY`, `DISTRICT_LAT`, `DISTRICT_LNG` | | `DISTRICT_CONTENT_ID` | | Set to watch District. Plus `DISTRICT_CITY_KEY`, `DISTRICT_LAT`, `DISTRICT_LNG` |
| `DISTRICT_MOVIE_CODE` | *(any variant)* | Optional `frmtid` to pin one language/format |
| `DISTRICT_CINEMA_ID` | *(any)* | Restrict District to one cinema | | `DISTRICT_CINEMA_ID` | *(any)* | Restrict District to one cinema |
| `DISTRICT_FORMAT_TAG` | `imax_2d` | Session tag that counts as a match | | `DISTRICT_FORMAT_TAG` | `imax_2d` | Session tag that counts as a match |
| `DISTRICT_FORMAT_MATCH` | `IMAX` | Screen-format substring that counts as a match | | `DISTRICT_FORMAT_MATCH` | `IMAX` | Screen-format substring that counts as a match |
@@ -184,7 +189,8 @@ with comments in [`.env.example`](.env.example).
| `PROXY_URL` | | SOCKS5 proxy for both sites' checks, see below | | `PROXY_URL` | | SOCKS5 proxy for both sites' checks, see below |
v1 used flat names (`MOVIE_CODE`, `CINEMA_ID` and friends) for District. Those still work, so an v1 used flat names (`MOVIE_CODE`, `CINEMA_ID` and friends) for District. Those still work, so an
existing `.env` keeps running unchanged. existing `.env` keeps running unchanged. They're read per field, so a `DISTRICT_*` value you set
deliberately always wins over a leftover flat one, and the log warns when any flat name is in use.
## Status endpoint ## Status endpoint
@@ -249,7 +255,7 @@ Run `npm run check` for a single noisy check. Configuration problems are reporte
## Development ## Development
```bash ```bash
npm test # 24 tests, no network needed npm test # 35 tests, no network needed
``` ```
The tests run the alert state machine and both site parsers against real captured API responses The tests run the alert state machine and both site parsers against real captured API responses
+8
View File
@@ -75,6 +75,14 @@ edge case testable without network or clock mocking, and it's where the interest
alert's push succeeds. A failed notification retries on the next tick instead of being silently alert's push succeeds. A failed notification retries on the next tick instead of being silently
recorded as delivered. recorded as delivered.
- **Dedupe by session id, not a boolean.** Shows added after the first alert still notify you. - **Dedupe by session id, not a boolean.** Shows added after the first alert still notify you.
- **Dedupe does not outlive the listing.** Cinemas publish early, pull the shows and re-list
them, often under the same session ids, and this really happened with the Endgame release. A
show missing for three consecutive successful checks is forgotten, so its return alerts again.
Three rather than one, so a single flaky response can't cause a duplicate max-priority push,
and failed checks don't count, since an outage says nothing about whether a show exists. If
the whole date goes dark after opening, a separate notice says the shows were withdrawn. The
re-arm itself lives in the base state, not in that notice's patch, so it happens even if the
notice fails to deliver.
- **`dateOpenAlerted` rides an alert patch, never the base state.** Otherwise a failed push - **`dateOpenAlerted` rides an alert patch, never the base state.** Otherwise a failed push
followed by sessions disappearing could swallow the only notification you'd ever get. followed by sessions disappearing could swallow the only notification you'd ever get.
- **Open-but-empty counts as closed**, defending against a `200` with no sessions. - **Open-but-empty counts as closed**, defending against a `200` with no sessions.
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "ticket-monitoring", "name": "ticket-monitoring",
"version": "1.1.0", "version": "1.2.0",
"description": "Watches district.in and BookMyShow showtimes and sends a phone notification the moment tickets for a specific movie, date, format and cinema go on sale.", "description": "Watches district.in and BookMyShow showtimes and sends a phone notification the moment tickets for a specific movie, date, format and cinema go on sale.",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
+45 -27
View File
@@ -22,36 +22,50 @@ const num = (name, fallback) => {
return n; return n;
}; };
/** Old single-provider variable names, still honoured so v1 .env files keep working. */ /**
function legacyDistrict() { * District settings. v1 used flat names (MOVIE_CODE, CINEMA_ID and so on) and those are
if (!process.env.MOVIE_CODE || process.env.DISTRICT_MOVIE_CODE) return null; * still honoured, but per field, so a stale leftover cannot override a DISTRICT_* value
return { * that was set deliberately. Enabled by the content id, since the movie code is optional.
movieCode: env("MOVIE_CODE"), */
contentId: env("CONTENT_ID"), const LEGACY = {
cityKey: env("CITY_KEY"), DISTRICT_MOVIE_CODE: "MOVIE_CODE",
lat: env("LAT"), DISTRICT_CONTENT_ID: "CONTENT_ID",
lng: env("LNG"), DISTRICT_CITY_KEY: "CITY_KEY",
venueId: env("CINEMA_ID"), DISTRICT_LAT: "LAT",
formatTag: process.env.FORMAT_TAG ?? "imax_2d", DISTRICT_LNG: "LNG",
formatMatch: process.env.FORMAT_MATCH ?? "IMAX", DISTRICT_CINEMA_ID: "CINEMA_ID",
bookingUrl: env("BOOKING_URL"), DISTRICT_FORMAT_TAG: "FORMAT_TAG",
}; DISTRICT_FORMAT_MATCH: "FORMAT_MATCH",
DISTRICT_BOOKING_URL: "BOOKING_URL",
};
const pick = (name) => process.env[name] ?? process.env[LEGACY[name]];
export function legacyNamesInUse() {
return Object.entries(LEGACY)
.filter(([nu, old]) => process.env[nu] === undefined && process.env[old] !== undefined)
.map(([, old]) => old);
} }
function districtConfig() { function districtConfig() {
const legacy = legacyDistrict(); const contentId = pick("DISTRICT_CONTENT_ID");
if (legacy) return legacy; if (!contentId) return null; // District not configured
if (!process.env.DISTRICT_MOVIE_CODE) return null; const need = (name) => {
const v = pick(name);
if (!v) throw new Error(`${name} is required when District is configured (see .env.example)`);
return v;
};
return { return {
movieCode: required("DISTRICT_MOVIE_CODE"), // Optional: District answers without it, and an unreleased film has no format code yet.
contentId: required("DISTRICT_CONTENT_ID"), movieCode: pick("DISTRICT_MOVIE_CODE") ?? "",
cityKey: required("DISTRICT_CITY_KEY"), contentId,
lat: required("DISTRICT_LAT"), cityKey: need("DISTRICT_CITY_KEY"),
lng: required("DISTRICT_LNG"), lat: need("DISTRICT_LAT"),
venueId: env("DISTRICT_CINEMA_ID"), lng: need("DISTRICT_LNG"),
formatTag: process.env.DISTRICT_FORMAT_TAG ?? "imax_2d", venueId: pick("DISTRICT_CINEMA_ID") ?? "",
formatMatch: process.env.DISTRICT_FORMAT_MATCH ?? "IMAX", formatTag: pick("DISTRICT_FORMAT_TAG") ?? "imax_2d",
bookingUrl: env("DISTRICT_BOOKING_URL"), formatMatch: pick("DISTRICT_FORMAT_MATCH") ?? "IMAX",
bookingUrl: pick("DISTRICT_BOOKING_URL") ?? "",
}; };
} }
@@ -83,6 +97,10 @@ export function loadConfig() {
new Date(Date.parse(`${targetDate}T00:00:00Z`) + 86400000 - tzOffsetMinutes * 60000).toISOString(); new Date(Date.parse(`${targetDate}T00:00:00Z`) + 86400000 - tzOffsetMinutes * 60000).toISOString();
const built = { district: districtConfig(), bookmyshow: bookmyshowConfig() }; const built = { district: districtConfig(), bookmyshow: bookmyshowConfig() };
const stale = legacyNamesInUse();
if (stale.length) {
console.warn(`warning: using v1 variable names ${stale.join(", ")}. Rename them to DISTRICT_* to avoid confusion.`);
}
// PROVIDERS is an explicit allow-list; without it, every configured provider runs. // PROVIDERS is an explicit allow-list; without it, every configured provider runs.
const wanted = env("PROVIDERS").split(",").map((s) => s.trim()).filter(Boolean); const wanted = env("PROVIDERS").split(",").map((s) => s.trim()).filter(Boolean);
for (const w of wanted) { for (const w of wanted) {
@@ -90,7 +108,7 @@ export function loadConfig() {
} }
const keys = (wanted.length ? wanted : Object.keys(built)).filter((k) => built[k]); const keys = (wanted.length ? wanted : Object.keys(built)).filter((k) => built[k]);
if (!keys.length) { if (!keys.length) {
throw new Error("No provider configured. Set DISTRICT_MOVIE_CODE and/or BMS_EVENT_CODE (see .env.example)"); throw new Error("No provider configured. Set DISTRICT_CONTENT_ID and/or BMS_EVENT_CODE (see .env.example)");
} }
for (const w of wanted) { for (const w of wanted) {
if (!built[w]) throw new Error(`PROVIDERS lists "${w}" but its settings are missing (see .env.example)`); if (!built[w]) throw new Error(`PROVIDERS lists "${w}" but its settings are missing (see .env.example)`);
+12 -4
View File
@@ -76,15 +76,15 @@ async function tickProvider(cfg, p, state, now) {
const res = await p.module.check(cfg, p, cfg.targetDate); const res = await p.module.check(cfg, p, cfg.targetDate);
input = res.kind === "open" input = res.kind === "open"
? { kind: "open", extract: p.module.extract(res.data, cfg, p), now, probeMax } ? { kind: "open", extract: p.module.extract(res.data, cfg, p), now, probeMax }
: { kind: "closed", now, probeMax }; : { kind: "closed", now, probeMax, note: res.note ?? null };
} catch (err) { } catch (err) {
input = { kind: "error", reason: String(err.message ?? err), now, probeMax }; input = { kind: "error", reason: String(err.message ?? err), now, probeMax };
} }
const { baseState, alerts } = decideProvider(pstate, input, cfg, p); const { baseState, alerts } = decideProvider(pstate, input, cfg, p);
lastTick[p.key] = { at: ts(), kind: input.kind, reason: input.reason ?? null }; lastTick[p.key] = { at: ts(), kind: input.kind, reason: input.reason ?? input.note ?? null };
const matched = input.extract?.matched?.length ?? 0; const matched = input.extract?.matched?.length ?? 0;
console.log(ts(), `[${p.key}] ${input.kind}${input.reason ? ` (${input.reason})` : ""}`, console.log(ts(), `[${p.key}] ${input.kind}${input.reason || input.note ? ` (${input.reason ?? input.note})` : ""}`,
`| matched: ${matched} | failures: ${baseState.failures}`); `| matched: ${matched} | failures: ${baseState.failures}`);
return { pstate: baseState, alerts, input }; return { pstate: baseState, alerts, input };
} }
@@ -123,6 +123,7 @@ export async function tick(cfg) {
label: p.label, label: p.label,
kind: input.kind === "error" ? "checks failing" : merged.providers[p.key].lastGood?.kind, kind: input.kind === "error" ? "checks failing" : merged.providers[p.key].lastGood?.kind,
showDatesMax: merged.providers[p.key].lastGood?.showDatesMax, showDatesMax: merged.providers[p.key].lastGood?.showDatesMax,
note: merged.providers[p.key].lastGood?.note,
}); });
} }
} }
@@ -157,7 +158,14 @@ function startStatusServer(cfg) {
const out = { const out = {
now: ts(), now: ts(),
targetDate: cfg.targetDate, targetDate: cfg.targetDate,
providers: cfg.providers.map((p) => p.key), // What each site is actually watching. Without this, a misconfigured id or an
// unexpectedly narrow filter is indistinguishable from "nothing on sale yet".
watching: Object.fromEntries(cfg.providers.map((p) => [p.key, {
movie: p.contentId ?? p.eventCode,
variant: p.movieCode || p.language || "(any language/format variant)",
venue: p.venueId || "(any venue)",
format: [p.formatTag, p.formatMatch].filter(Boolean).join(" or ") || "(any format)",
}])),
proxied: Boolean(cfg.dispatcher), proxied: Boolean(cfg.dispatcher),
retired: state.retired, retired: state.retired,
lastTick, lastTick,
+60 -7
View File
@@ -2,8 +2,18 @@
// Providers supply normalized sessions; nothing here knows which site they came from. // Providers supply normalized sessions; nothing here knows which site they came from.
import { localDate, localMinutes } from "./lib.js"; import { localDate, localMinutes } from "./lib.js";
/**
* Consecutive successful checks a previously announced show must be absent before it is
* forgotten. Counting checks, rather than reacting to one miss, stops a single flaky
* response from turning into a duplicate max-priority push.
*/
export const REARM_AFTER = 3;
export function defaultProviderState() { export function defaultProviderState() {
return { dateOpenAlerted: false, sidsAlerted: {}, failures: 0, brokenAlerted: false, lastGood: null }; return {
dateOpenAlerted: false, sidsAlerted: {}, missing: {}, closedTicks: 0,
failures: 0, brokenAlerted: false, lastGood: null,
};
} }
export function defaultState() { export function defaultState() {
@@ -73,16 +83,58 @@ export function decideProvider(pstate, input, cfg, p) {
const ex = input.kind === "open" ? input.extract : null; const ex = input.kind === "open" ? input.extract : null;
const effectiveKind = ex && ex.sessionCount > 0 ? "open" : "closed"; // open-but-empty = closed const effectiveKind = ex && ex.sessionCount > 0 ? "open" : "closed"; // open-but-empty = closed
if (base.lastGood?.kind !== effectiveKind) { const note = input.note ?? null;
base.lastGood = { ...(base.lastGood ?? {}), kind: effectiveKind, at: now.toISOString() }; if (base.lastGood?.kind !== effectiveKind || base.lastGood?.note !== note) {
base.lastGood = { ...(base.lastGood ?? {}), kind: effectiveKind, note, at: now.toISOString() };
}
// Cinemas publish early, pull the shows and re-list them, often under the same session
// ids. Dedupe must not outlive the listing, or the re-listing is silent. So a show that
// stays missing for REARM_AFTER checks is forgotten, and its return alerts again. This
// lives in the base state rather than an alert patch: re-arming must not depend on a
// notification getting through.
const present = new Set(effectiveKind === "open" ? ex.matched.map((s) => s.sid) : []);
const alerted = { ...(pstate.sidsAlerted ?? {}) };
const missing = { ...(pstate.missing ?? {}) };
for (const sid of Object.keys(alerted)) {
if (present.has(sid)) {
delete missing[sid];
} else if ((missing[sid] = (missing[sid] ?? 0) + 1) >= REARM_AFTER) {
delete alerted[sid];
delete missing[sid];
}
}
for (const sid of Object.keys(missing)) if (!(sid in alerted)) delete missing[sid];
base.sidsAlerted = alerted;
base.missing = missing;
// The whole date going dark after it opened is worth saying out loud: the user may be
// mid-booking and wondering where the shows went. Counted the same way, then re-armed.
if (effectiveKind === "closed" && pstate.dateOpenAlerted) {
base.closedTicks = (pstate.closedTicks ?? 0) + 1;
if (base.closedTicks >= REARM_AFTER) {
base.dateOpenAlerted = false;
base.closedTicks = 0;
alerts.push(tag({
title: `${cfg.targetDate} shows withdrawn on ${p.label}`,
priority: "3",
tags: "arrows_counterclockwise",
click: p.bookingUrl,
body: `${p.label} is no longer listing shows for ${cfg.targetDate}. The monitor has re-armed, so you'll get a max-priority push again the moment they come back.`,
statePatch: {},
}));
}
} else if (pstate.closedTicks) {
base.closedTicks = 0;
} }
if (effectiveKind === "open") { if (effectiveKind === "open") {
const fresh = ex.matched.filter((s) => !pstate.sidsAlerted?.[s.sid]); const fresh = ex.matched.filter((s) => !alerted[s.sid]);
if (fresh.length > 0) { if (fresh.length > 0) {
const lines = fresh.map(sessionLine); const lines = fresh.map(sessionLine);
for (const s of ex.other) lines.push(`Also at ${s.cinema}: ${s.time}`); for (const s of ex.other) lines.push(`Also at ${s.cinema}: ${s.time}`);
const sidsAlerted = { ...pstate.sidsAlerted }; // Built from the post-forget map, so the patch can't resurrect a forgotten id.
const sidsAlerted = { ...alerted };
for (const s of fresh) sidsAlerted[s.sid] = true; for (const s of fresh) sidsAlerted[s.sid] = true;
alerts.push(tag({ alerts.push(tag({
title: `${cfg.formatLabel} ${cfg.targetDate} OPEN on ${p.label} - BOOK NOW`, title: `${cfg.formatLabel} ${cfg.targetDate} OPEN on ${p.label} - BOOK NOW`,
@@ -92,7 +144,7 @@ export function decideProvider(pstate, input, cfg, p) {
body: lines.join("\n\n"), body: lines.join("\n\n"),
statePatch: { sidsAlerted, dateOpenAlerted: true }, statePatch: { sidsAlerted, dateOpenAlerted: true },
})); }));
} else if (!pstate.dateOpenAlerted && ex.matched.length === 0) { } else if (!base.dateOpenAlerted && ex.matched.length === 0) {
alerts.push(tag({ alerts.push(tag({
title: `${cfg.targetDate} is OPEN on ${p.label} (no ${cfg.formatLabel} yet)`, title: `${cfg.targetDate} is OPEN on ${p.label} (no ${cfg.formatLabel} yet)`,
priority: "4", priority: "4",
@@ -131,7 +183,8 @@ export function decideGlobal(state, cfg, now, summaries) {
if (heartbeatDue(state, now, cfg)) { if (heartbeatDue(state, now, cfg)) {
const lines = summaries.map((s) => const lines = summaries.map((s) =>
`${s.label}: ${s.kind ?? "unknown"}${s.showDatesMax ? ` (open through ${s.showDatesMax})` : ""}`); `${s.label}: ${s.kind ?? "unknown"}${s.note ? `, ${s.note}` : ""}` +
`${s.showDatesMax ? ` (open through ${s.showDatesMax})` : ""}`);
alerts.push({ alerts.push({
title: `Still watching ${cfg.targetDate}`, title: `Still watching ${cfg.targetDate}`,
priority: "1", priority: "1",
+16
View File
@@ -56,6 +56,22 @@ export async function check(cfg, p, date) {
throw new CheckError(`unparseable 200 body: ${err.message}`); throw new CheckError(`unparseable 200 body: ${err.message}`);
} }
} }
// A film announced but not yet given showtimes answers 400 "Content not found"
// (code 32). That is a waiting state, not a fault, so it must not raise the failure
// streak. A typo'd content id looks identical, so the reason is carried through to
// the status endpoint and the daily heartbeat rather than being swallowed.
if (res.status === 400) {
let body = null;
try {
body = await res.json();
} catch {
// fall through to the generic error below
}
if (body?.code === 32) {
return { kind: "closed", note: "not listed yet (District: content not found)" };
}
if (body?.msg) throw new CheckError(`HTTP 400: ${body.msg}`);
}
throw new CheckError(`HTTP ${res.status}`); throw new CheckError(`HTTP ${res.status}`);
} }
+112
View File
@@ -190,3 +190,115 @@ test("retirement takes precedence over the heartbeat", () => {
assert.equal(d.alerts.length, 1); assert.equal(d.alerts.length, 1);
assert.ok(d.alerts[0].title.includes("retired")); assert.ok(d.alerts[0].title.includes("retired"));
}); });
test("a 'not listed yet' note rides through to lastGood and the heartbeat", () => {
const d = decideProvider(defaultProviderState(),
{ kind: "closed", now: T0, note: "not listed yet (District: content not found)" }, CFG, P);
assert.equal(d.alerts.length, 0, "waiting for a listing is not an alert");
assert.equal(d.baseState.lastGood.kind, "closed");
assert.match(d.baseState.lastGood.note, /not listed yet/);
const hb = decideGlobal(defaultState(), CFG, HB, [
{ label: "District", kind: "closed", note: "not listed yet (District: content not found)" },
{ label: "BookMyShow", kind: "closed" },
]);
assert.match(hb.alerts[0].body, /District: closed, not listed yet/,
"the daily heartbeat must say why District sees nothing, or silence is ambiguous");
assert.match(hb.alerts[0].body, /BookMyShow: closed$/m);
});
test("clearing the note is itself a state change, so the status endpoint stops lying", () => {
const withNote = decideProvider(defaultProviderState(), { kind: "closed", now: T0, note: "not listed yet" }, CFG, P).baseState;
const after = decideProvider(withNote, { kind: "closed", now: T0 }, CFG, P);
assert.equal(after.baseState.lastGood.note, null, "note is dropped once the listing appears");
});
// ------------------------------------------------ withdrawn and re-listed shows
//
// Cinemas publish early, pull the shows, then re-list them, often under the same
// session ids. If dedupe outlives the listing, the re-listing (the moment bookings
// really open) is silent. These pin down when a vanished session is forgotten.
const closed = (now = T0) => ({ kind: "closed", now });
const tick = (state, input, p = P) => apply(state, decideProvider(state, input, CFG, p));
test("a withdrawn session that comes back after 3 missed checks alerts again", () => {
let s = tick(defaultProviderState(), openInput(EX_MATCH));
assert.ok(s.sidsAlerted["111"], "alerted once");
for (let i = 0; i < 3; i++) s = tick(s, closed());
assert.equal(s.sidsAlerted["111"], undefined, "forgotten after 3 checks without it");
const back = decideProvider(s, openInput(EX_MATCH), CFG, P);
assert.equal(back.alerts.length, 1, "the re-listing must alert");
assert.equal(back.alerts[0].priority, "5");
});
test("a session missing for fewer than 3 checks is not re-alerted", () => {
let s = tick(defaultProviderState(), openInput(EX_MATCH));
s = tick(s, closed());
s = tick(s, closed());
const back = decideProvider(s, openInput(EX_MATCH), CFG, P);
assert.equal(back.alerts.length, 0, "a brief flicker in the response is not a new listing");
assert.deepEqual(apply(s, back).missing, {}, "and the miss counter resets once it is seen again");
});
test("a fully withdrawn date re-arms after 3 closed checks and says so, once", () => {
let s = tick(defaultProviderState(), openInput(EX_MATCH));
assert.equal(s.dateOpenAlerted, true);
s = tick(s, closed());
s = tick(s, closed());
const third = decideProvider(s, closed(), CFG, P);
const notice = third.alerts.find((a) => /withdrawn/i.test(a.title));
assert.ok(notice, "the user should hear that the shows were pulled");
assert.ok(notice.title.includes("District"));
assert.ok(Number(notice.priority) < 5, "informational, not a booking alert");
s = apply(s, third);
assert.equal(s.dateOpenAlerted, false, "re-armed");
for (let i = 0; i < 5; i++) {
assert.equal(decideProvider(s, closed(), CFG, P).alerts.length, 0, "the notice is not repeated");
s = tick(s, closed());
}
});
test("the re-arm does not depend on the withdrawal notice being delivered", () => {
let s = tick(defaultProviderState(), openInput(EX_MATCH));
s = tick(s, closed());
s = tick(s, closed());
const third = decideProvider(s, closed(), CFG, P);
s = third.baseState; // ntfy was down: no alert patches merged
assert.equal(s.dateOpenAlerted, false, "re-arming is not gated on the push");
assert.equal(s.sidsAlerted["111"], undefined);
assert.equal(decideProvider(s, openInput(EX_MATCH), CFG, P).alerts[0].priority, "5",
"so a re-listing still gets through even if the notice was lost");
});
test("partial withdrawal forgets only the missing session, with no withdrawal notice", () => {
const two = { ...EX_MATCH, matched: [EX_MATCH.matched[0], { ...EX_MATCH.matched[0], sid: "222", time: "1:15 PM" }] };
let s = tick(defaultProviderState(), openInput(two));
for (let i = 0; i < 3; i++) {
const d = decideProvider(s, openInput(EX_MATCH), CFG, P); // 222 gone, date still open
assert.equal(d.alerts.filter((a) => /withdrawn/i.test(a.title)).length, 0);
s = apply(s, d);
}
assert.ok(s.sidsAlerted["111"], "the session still listed stays deduped");
assert.equal(s.sidsAlerted["222"], undefined, "the pulled one is forgotten");
const back = decideProvider(s, openInput(two), CFG, P);
assert.equal(back.alerts.length, 1);
assert.ok(back.alerts[0].body.includes("1:15 PM"), "only the returning session is announced");
assert.ok(!back.alerts[0].body.includes("9:00 AM"));
});
test("failed checks do not count as a session being missing", () => {
let s = tick(defaultProviderState(), openInput(EX_MATCH));
for (let i = 0; i < 5; i++) s = tick(s, { kind: "error", reason: "HTTP 403", now: T0 });
assert.ok(s.sidsAlerted["111"], "an outage says nothing about whether the show still exists");
assert.equal(decideProvider(s, openInput(EX_MATCH), CFG, P).alerts.filter((a) => a.priority === "5").length, 0);
});
test("waiting on a date that never opened writes no state", () => {
let s = tick(defaultProviderState(), closed());
const next = decideProvider(s, closed(), CFG, P);
assert.equal(JSON.stringify(apply(s, next)), JSON.stringify(s), "no counters ticking while nothing was ever alerted");
});
+59
View File
@@ -104,3 +104,62 @@ test("bookmyshow: a not-yet-on-sale response has no widgets and extracts to noth
assert.deepEqual(bms.extract(closed, CFG, B_PARAMS), assert.deepEqual(bms.extract(closed, CFG, B_PARAMS),
{ matched: [], other: [], cinemaCount: 0, sessionCount: 0, showDates: [] }); { matched: [], other: [], cinemaCount: 0, sessionCount: 0, showDates: [] });
}); });
// The 400 branch decides between "waiting for a listing" and "something is wrong",
// so it is worth pinning without a network.
test("district: 400 code 32 is a waiting state, other 400s are failures", async () => {
const realFetch = globalThis.fetch;
const stub = (status, body) => {
globalThis.fetch = async () => ({ status, json: async () => body });
};
const p = { movieCode: "X", contentId: "1", cityKey: "c", lat: "1", lng: "2" };
try {
stub(400, { errors: [], msg: "Content not found", code: 32 });
const waiting = await district.check({}, p, "2026-09-25");
assert.equal(waiting.kind, "closed");
assert.match(waiting.note, /not listed yet/, "the reason must survive for the heartbeat");
stub(400, { errors: [], msg: "Mandatory search params are missing", code: 3 });
await assert.rejects(() => district.check({}, p, "2026-09-25"), /Mandatory search params/,
"a real config error must still raise the failure streak");
stub(500, {});
await assert.rejects(() => district.check({}, p, "2026-09-25"), /HTTP 500/);
stub(204, {});
assert.equal((await district.check({}, p, "2026-09-25")).kind, "closed");
} finally {
globalThis.fetch = realFetch;
}
});
// A leftover v1 name silently overriding a DISTRICT_* value that was set on purpose is
// the worst kind of config bug: the monitor watches the wrong film and still says "closed".
test("config: DISTRICT_* wins per field over leftover v1 names", async () => {
const { loadConfig, legacyNamesInUse } = await import("../src/config.js");
const saved = { ...process.env };
try {
Object.assign(process.env, {
TARGET_DATE: "2026-09-25", NTFY_TOPIC: "t",
MOVIE_CODE: "OLDCODE", CONTENT_ID: "187151", CITY_KEY: "ahmedabad",
LAT: "23.03", LNG: "72.57", CINEMA_ID: "1032362",
DISTRICT_CONTENT_ID: "227928", DISTRICT_CINEMA_ID: "",
BMS_EVENT_CODE: "ET1", BMS_REGION_CODE: "AHD",
});
const d = loadConfig().providers.find((p) => p.key === "district");
assert.equal(d.contentId, "227928", "the explicitly set new name must win");
assert.equal(d.venueId, "", "an explicitly emptied new name must not fall back to the old one");
assert.equal(d.movieCode, "OLDCODE", "fields with no new value still inherit");
assert.ok(legacyNamesInUse().includes("MOVIE_CODE"), "and the leftovers are reported");
delete process.env.DISTRICT_CONTENT_ID;
delete process.env.CONTENT_ID;
const only = loadConfig().providers;
assert.equal(only.find((p) => p.key === "district"), undefined,
"no content id means District is simply not watched");
assert.equal(only.length, 1, "and the other site carries on alone");
} finally {
for (const k of Object.keys(process.env)) if (!(k in saved)) delete process.env[k];
Object.assign(process.env, saved);
}
});