Initial commit

Watches district.in showtimes and sends a max-priority phone notification the
moment tickets for a given movie, date, format and cinema go on sale.
This commit is contained in:
Aculix Technologies committed 2026-08-14 14:31:28 +05:30
commit b70968d259
15 files changed
+1164

No files matched your search

+52
View File
@@ -0,0 +1,52 @@
# Copy to .env and fill in. Every value below is read from the environment,
# so you can also set them directly (Docker, systemd, CI) instead of using a file.
# The example values watch IMAX shows of one film at one Ahmedabad cinema.
# ── What to watch ─────────────────────────────────────────────────────────────
# Open the film's page on district.in, then read these off the network request to
# /gw/consumer/movies/v5/movie (see "Finding your IDs" in the README).
MOVIE_CODE=y7EZNLXN5Y # ?frmtid= in the page URL — identifies movie + language
CONTENT_ID=187151 # MV<number> in the page URL
CITY_KEY=ahmedabad # city slug as it appears in the page URL
LAT=23.033938956712916 # city coordinates; the API rejects requests without them
LNG=72.57696301365426
TARGET_DATE=2026-08-10 # the date you are waiting for (YYYY-MM-DD)
# Restrict to one cinema. Leave empty to watch every cinema in the city.
CINEMA_ID=1032362
# Which shows count as a match. A session matches if it carries FORMAT_TAG *or* if its
# screen format contains FORMAT_MATCH (case-insensitive), so "IMAX" also catches
# "IMAX 3D". Set BOTH to empty to match every show regardless of format.
FORMAT_TAG=imax_2d
FORMAT_MATCH=IMAX
FORMAT_LABEL=IMAX # cosmetic: how the format is named in alerts
# Opened when you tap the notification.
BOOKING_URL=https://www.district.in/movies/the-odyssey-movie-tickets-in-ahmedabad-MV187151?frmtid=y7EZNLXN5Y&fromdate=2026-08-10
# ── Notifications (ntfy) ──────────────────────────────────────────────────────
# Subscribe to this topic in the ntfy app. Treat it as a password: anyone who knows
# it can read your alerts and publish to you. Use a long random string.
NTFY_TOPIC=change-me-to-something-random
# Public ntfy.sh works from home connections. It rate-limits shared cloud egress
# IPs with HTTP 429, so point this at your own server when hosting in the cloud.
NTFY_URL=https://ntfy.sh
# NTFY_TOKEN=tk_... # only if your server (or topic) requires auth
# ── Behaviour ─────────────────────────────────────────────────────────────────
CHECK_INTERVAL_SECONDS=60 # how often to poll; don't go lower without good reason
FAILURE_THRESHOLD=6 # consecutive failures before the "monitor broken" alert
TZ_OFFSET_MINUTES=330 # showtimes are rendered in this offset (330 = IST)
HEARTBEAT_HOUR=9 # daily "still alive" push at this local hour; -1 disables
CURRENCY=₹
# EXPIRY_UTC= # auto: local midnight after TARGET_DATE. Override to extend.
# ── Runtime ───────────────────────────────────────────────────────────────────
STATE_PATH=./state.json # Docker image defaults this to /data/state.json
PORT=8080 # status endpoint (--loop mode only)
# Routes only the site checks through a SOCKS5 proxy; notifications stay direct.
# Needed on datacenter hosts, whose IPs the site's WAF is likely to block.
# PROXY_URL=socks5://user:pass@proxy.example.net:1080
+8
View File
@@ -0,0 +1,8 @@
node_modules/
.env
.env.local
state.json
state.local.json
data/
logs/
.DS_Store
+14
View File
@@ -0,0 +1,14 @@
FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY src/ src/
ENV STATE_PATH=/data/state.json
VOLUME /data
EXPOSE 8080
# --env-file-if-exists so the container works with plain env vars when no .env is mounted.
CMD ["node", "--env-file-if-exists=.env", "src/index.js", "--loop"]
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Aculix Technologies
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+180
View File
@@ -0,0 +1,180 @@
# ticket-monitoring
Watches [district.in](https://www.district.in) showtimes and sends a max-priority push to your
phone the second tickets go on sale for the movie, date, format and cinema you care about.
![Example notification: IMAX shows for a target date, with showtimes, seat counts and prices](docs/alert.png)
I built this because IMAX shows kept selling out before I even knew they'd opened. Refreshing the
page a few times a day wasn't working. Now something else checks every 60 seconds and yells at me
when it matters.
## How it works
Every 60 seconds it calls the same showtimes endpoint the District site uses itself. A date that
isn't on sale yet answers with `204 No Content`. The moment it opens, you get the full session
list back. The monitor filters that list down to your cinema and format, then pushes to
[ntfy](https://ntfy.sh), a free open-source notification service with iOS and Android apps.
The part I care most about: silence always means "not open yet". If checks start failing you get a
*Monitor BROKEN* push after six failures in a row, plus an optional daily heartbeat. A monitor
that died at 3am and one that's quietly working should never look the same from the outside.
Alerts are deduplicated by session id in a small state file, so you hear about each show once.
Shows added later still get through.
| Notification | Priority | What it means |
|---|---|---|
| `<FORMAT> <DATE> OPEN - BOOK NOW` | max | The one you're waiting for. Tap it to book. |
| `<DATE> bookings are OPEN (no <FORMAT> yet)` | high | Date opened, your format hasn't shown up yet. |
| `Monitor BROKEN - check manually` | high | Checks are failing. Don't trust the silence. |
| `Monitor recovered` | default | Back to normal. |
| `Still watching <DATE>` | min | Daily heartbeat. |
| `Monitor retired` | default | Target date passed, so it stopped on its own. |
## Requirements
The [ntfy app](https://ntfy.sh/#subscribe-phone) on your phone, and either Docker or Node.js 22+.
## Quick start with Docker
```bash
git clone https://github.com/aculix/ticket-monitoring.git
cd ticket-monitoring
cp .env.example .env # edit it, see "Finding your IDs" below
docker compose up -d --build
docker compose logs -f
```
You want to see `tick: closed failures: 0` scrolling past once a minute.
## Quick start without Docker
```bash
npm install
cp .env.example .env # edit it
npm run check # one check, tells you what it found
npm start # run it for real
```
Run `npm run check` first. It's the fastest way to find out you got an ID wrong, and much less
annoying than discovering it three hours into a run. Needs Node 22+ for the built-in `--env-file`
support.
## Finding your IDs
Everything movie-specific is sitting in one URL. Open the film's District page for your city:
```
https://www.district.in/movies/the-odyssey-movie-tickets-in-ahmedabad-MV187151?frmtid=y7EZNLXN5Y&fromdate=2026-08-10
└── CITY_KEY ──┘ └CONTENT_ID┘ └─MOVIE_CODE─┘
```
- `CONTENT_ID` is the number after `MV`
- `MOVIE_CODE` is the `frmtid` parameter, which pins down the movie *and* the language
- `CITY_KEY` is the city slug in the path
- `LAT` and `LNG` can be any coordinates inside that city. The API refuses to answer without them,
though results are city-wide either way.
- `BOOKING_URL` is the whole URL with `fromdate` set to your target date
`CINEMA_ID` is the fiddly one, since it isn't in the URL. Easiest approach: leave it empty at
first, which watches every cinema in the city, then run `npm run check` against a date that's
already bookable and read the ids out of the response. Or just leave it empty for good and let the
alert tell you where the shows are.
Want any format, not only IMAX? Set both `FORMAT_TAG` and `FORMAT_MATCH` to empty.
## Configuration
Everything is environment variables. `.env` is only a convenient place to put them.
| Variable | Default | Purpose |
|---|---|---|
| `MOVIE_CODE` | *required* | `frmtid`, identifies movie and language |
| `CONTENT_ID` | *required* | Movie id, the digits after `MV` |
| `CITY_KEY` | *required* | City slug, e.g. `ahmedabad` |
| `LAT`, `LNG` | *required* | Coordinates somewhere in that city |
| `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. |
| `CINEMA_ID` | *(any)* | Restrict to one cinema |
| `FORMAT_TAG` | `imax_2d` | Session tag that counts as a match |
| `FORMAT_MATCH` | `IMAX` | Screen-format substring that counts as a match |
| `FORMAT_LABEL` | `IMAX` | What the format is called in the alert text |
| `BOOKING_URL` | | Opens when you tap the notification |
| `NTFY_URL` | `https://ntfy.sh` | Your own ntfy server, if you run one |
| `NTFY_TOKEN` | | Only if your server wants auth |
| `CHECK_INTERVAL_SECONDS` | `60` | Poll interval |
| `FAILURE_THRESHOLD` | `6` | Failures in a row before the BROKEN alert |
| `TZ_OFFSET_MINUTES` | `330` | Timezone for showtimes, 330 is IST |
| `HEARTBEAT_HOUR` | `9` | Local hour for the daily heartbeat, `-1` turns it off |
| `CURRENCY` | `₹` | Price prefix |
| `EXPIRY_UTC` | *auto* | When to retire. Defaults to local midnight after `TARGET_DATE`. |
| `STATE_PATH` | `./state.json` | Where dedupe state lives, `/data/state.json` in Docker |
| `PORT` | `8080` | Status endpoint |
| `PROXY_URL` | | SOCKS5 proxy for the site checks, see below |
## Status endpoint
While running continuously it serves JSON on `PORT`:
- `/` gives you state, `lastTick` and `proxied`
- `/?force=1` adds a live check right now, with no notifications and no state changes
- `/healthz` returns `{"ok":true}`
Watch `lastTick.at`. If it's more than a couple of minutes old, the loop is stuck.
## Troubleshooting
### Every check returns HTTP 403
The site's WAF is blocking your host. This happens reliably on cloud providers. An earlier version
of this ran as a Cloudflare Worker, worked fine for two days, then got blocked permanently, while
a home connection was never touched once. Either run it from a residential connection, or point
`PROXY_URL` at a SOCKS5 proxy. Only the site checks go through the proxy. Notifications stay
direct, so your alerts don't depend on it.
### ntfy returns HTTP 429
Public ntfy.sh rate-limits by IP, and shared cloud egress IPs are usually spent already by other
people. Self-host ntfy and set `NTFY_URL`, or run from home.
### Notifications arrive, but silently
That's your phone, not the monitor. Open the subscription in the ntfy app and let max-priority
messages through Do Not Disturb.
### Alerts repeat, or never arrive again
Dedupe lives in the state file at `STATE_PATH`, or in the `data/` volume under Docker. Delete it to
start fresh. Do this whenever you change `TARGET_DATE`, otherwise it still thinks it has already
told you about those shows.
### Nothing happens at all
Run `npm run check` for a single noisy check. Configuration problems are reported explicitly.
## Development
```bash
npm test # 16 tests, no network needed
```
The tests run the alert state machine and session parsing against a real captured API response in
`test/fixtures/`. `src/monitor.js` has the pure logic and the API client, `src/index.js` is the
runner plus state file and status server, `src/config.js` reads the environment.
If you're forking this for another site, [docs/design-notes.md](docs/design-notes.md) covers what I
learned about the API and why the alert logic is shaped the way it is.
## Notes
This polls a public endpoint once a minute, which is roughly what a person refreshing the page
would do, to buy their own tickets. It doesn't automate purchases, hold seats or skip any queue.
Please keep it that way: don't drop the interval, don't run ten copies, don't use it to scalp.
Not affiliated with District, PVR or IMAX. The API is undocumented and can change whenever they
feel like it.
## License
MIT, see [LICENSE](LICENSE).
+11
View File
@@ -0,0 +1,11 @@
services:
monitor:
build: .
image: ticket-monitoring
container_name: ticket-monitoring
restart: unless-stopped
env_file: .env
ports:
- "8080:8080" # status endpoint; drop this line to keep it private
volumes:
- ./data:/data # state survives restarts, so you aren't re-alerted
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

+73
View File
@@ -0,0 +1,73 @@
# Design notes
Why this is built the way it is, and what broke along the way. Useful if you're forking it,
or writing something similar against a different site.
## The API
District's movie pages are server-rendered Next.js, but showtimes load client-side from:
```
GET /gw/consumer/movies/v5/movie
?version=3&site_id=1&channel=mweb&child_site_id=1&platform=district
&movieCode=<frmtid>&city_key=<city>&content_id=<id>
&date=YYYY-MM-DD&latitude=<lat>&longitude=<lng>&cinemaOrderLogic=3
```
Findings that shaped the design:
- **`x-guest-token` is generated client-side.** It looks like a session token but isn't
server-issued — the browser builds `<epoch_ms>_<18 digits>_<11 random chars>` per request, and
any value of that shape is accepted. No login, no cookies.
- **Latitude and longitude are mandatory.** Without them the API returns `400 Mandatory search
params are missing`, even though results are city-wide.
- **`204` vs `200` is the signal.** A date that isn't open for booking returns `204 No Content`;
an open date returns the full session list. That status flip is the entire trigger, which keeps
the check to a single cheap request.
- **`showTime` is UTC** without a zone suffix. Local time is applied via `TZ_OFFSET_MINUTES`.
- **`meta.showDates` only exists in a `200` body.** Because the target date returns an empty
`204` while closed, the daily heartbeat makes one extra call for *today* purely to report how
far ahead bookings currently run. That probe is best-effort and never counts as a failure.
## Alert state machine
`decide()` in `src/monitor.js` is pure: state in, `{ baseState, alerts }` out. That makes every
edge case testable without network or clock mocking, and it's where the interesting rules live.
- **Publish before persist.** Each alert carries its own `statePatch`, merged only after that
alert's push succeeds. A failed notification retries on the next tick instead of being silently
recorded as delivered.
- **Dedupe by session id, not a boolean.** Shows added after the first alert still notify you.
- **`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.
- **Open-but-empty counts as closed**, defending against a `200` with no sessions.
- **Failures are capped at the threshold**, so a long outage stops writing state entirely.
- **State is written only when it actually changed.** Originally this ran on Cloudflare KV with a
1,000 writes/day free limit against 1,440 daily ticks, which forced the discipline; it's still
the right behaviour on a filesystem.
The guiding rule throughout: **degrade toward duplicate notifications, never toward silence.**
Every failure path — unreadable state, failed push, failed write — errs that way.
## What broke in production
Both failures were about *where the traffic came from*, not the code.
1. **Akamai started returning `403` to the cloud host after ~2 days.** The first version ran as a
Cloudflare Worker on a 1-minute cron. It worked, alerted correctly in a fire drill, then hard-
blocked — while the same requests from a home connection kept working. Datacenter IP ranges
get flagged; residential ones didn't. The monitor's own BROKEN alert is what surfaced this,
which is the strongest argument for having it.
2. **ntfy.sh returned `429` to that same class of IP.** Push delivery failed from cloud egress
even with an account token, because rate limits are per visitor IP and shared cloud IPs are
already spent. Self-hosting ntfy fixed it immediately.
The fix for both: run it from a residential connection, or route only the site checks through a
SOCKS5 proxy (`PROXY_URL`) and use your own ntfy server. Notifications deliberately stay direct
so the alerting path has no dependency on the proxy.
**Cloudflare Workers cannot do this.** `fetch()` there has no proxy option, and the raw-TCP
escape hatch (`connect()` from `cloudflare:sockets`) can't help: `startTls()` validates the
certificate against the host you dialed, with no SNI override, so you can't form a TLS session
for a third-party origin through a proxy. That's why the project ended up as a plain Node
process in Docker rather than a serverless function.
+62
View File
@@ -0,0 +1,62 @@
{
"name": "ticket-monitoring",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ticket-monitoring",
"version": "1.0.0",
"license": "MIT",
"dependencies": {
"fetch-socks": "^1.3.3",
"undici": "^8.10.0"
},
"engines": {
"node": ">=22"
}
},
"node_modules/fetch-socks": {
"version": "1.3.3",
"license": "MIT",
"dependencies": {
"socks": "^2.8.7",
"undici": ">=7"
}
},
"node_modules/ip-address": {
"version": "10.4.0",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/smart-buffer": {
"version": "4.2.0",
"license": "MIT",
"engines": {
"node": ">= 6.0.0",
"npm": ">= 3.0.0"
}
},
"node_modules/socks": {
"version": "2.8.9",
"license": "MIT",
"dependencies": {
"ip-address": "^10.1.1",
"smart-buffer": "^4.2.0"
},
"engines": {
"node": ">= 10.0.0",
"npm": ">= 3.0.0"
}
},
"node_modules/undici": {
"version": "8.10.0",
"license": "MIT",
"engines": {
"node": ">=22.19.0"
}
}
}
}
+19
View File
@@ -0,0 +1,19 @@
{
"name": "ticket-monitoring",
"version": "1.0.0",
"description": "Watches district.in showtimes and sends a phone notification the moment tickets for a specific movie, date, format and cinema go on sale.",
"license": "MIT",
"type": "module",
"engines": {
"node": ">=22"
},
"scripts": {
"start": "node --env-file-if-exists=.env src/index.js --loop",
"check": "node --env-file-if-exists=.env src/index.js",
"test": "node --test"
},
"dependencies": {
"fetch-socks": "^1.3.3",
"undici": "^8.10.0"
}
}
+62
View File
@@ -0,0 +1,62 @@
// Configuration is read entirely from environment variables (see .env.example).
// Load a .env file with Node's built-in flag: node --env-file-if-exists=.env src/index.js
function required(name) {
const v = process.env[name];
if (!v) throw new Error(`${name} is required — copy .env.example to .env and fill it in`);
return v;
}
const num = (name, fallback) => {
const v = process.env[name];
if (v === undefined || v === "") return fallback;
const n = Number(v);
if (Number.isNaN(n)) throw new Error(`${name} must be a number, got "${v}"`);
return n;
};
export function loadConfig() {
const targetDate = required("TARGET_DATE");
if (!/^\d{4}-\d{2}-\d{2}$/.test(targetDate)) {
throw new Error(`TARGET_DATE must be YYYY-MM-DD, got "${targetDate}"`);
}
const tzOffsetMinutes = num("TZ_OFFSET_MINUTES", 330);
// Retire at local midnight following the target date, unless overridden.
const expiryUtc = process.env.EXPIRY_UTC ||
new Date(Date.parse(`${targetDate}T00:00:00Z`) + 86400000 - tzOffsetMinutes * 60000).toISOString();
return {
// what to watch
movieCode: required("MOVIE_CODE"),
contentId: required("CONTENT_ID"),
cityKey: required("CITY_KEY"),
lat: required("LAT"),
lng: required("LNG"),
targetDate,
cinemaId: process.env.CINEMA_ID || "", // empty = any cinema
formatTag: process.env.FORMAT_TAG ?? "imax_2d", // empty + empty match = any format
formatMatch: process.env.FORMAT_MATCH ?? "IMAX",
formatLabel: process.env.FORMAT_LABEL || "IMAX",
bookingUrl: process.env.BOOKING_URL || "",
// notifications
ntfyUrl: (process.env.NTFY_URL || "https://ntfy.sh").replace(/\/+$/, ""),
ntfyTopic: required("NTFY_TOPIC"),
ntfyToken: process.env.NTFY_TOKEN || "",
// behaviour
intervalMs: num("CHECK_INTERVAL_SECONDS", 60) * 1000,
failureThreshold: num("FAILURE_THRESHOLD", 6),
tzOffsetMinutes,
heartbeatHour: num("HEARTBEAT_HOUR", 9), // -1 disables the daily heartbeat
currency: process.env.CURRENCY || "₹",
expiryUtc,
// runtime
statePath: process.env.STATE_PATH || "./state.json",
port: num("PORT", 8080),
proxyUrl: process.env.PROXY_URL || "",
dispatcher: null, // set by attachProxy() when PROXY_URL is present
};
}
+165
View File
@@ -0,0 +1,165 @@
#!/usr/bin/env node
// Entry point. Runs one check (default) or loops forever with a status server (--loop).
import { createServer } from "node:http";
import { readFileSync, writeFileSync, renameSync, mkdirSync } from "node:fs";
import { dirname } from "node:path";
import { loadConfig } from "./config.js";
import {
decide, defaultState, extractSessions, checkDistrict, notify, heartbeatDue, localDate,
} from "./monitor.js";
const ts = () => new Date().toISOString();
// Last tick's outcome, surfaced by the status server. Persisted state records only
// transitions, so "is it ticking right now" has to live in memory.
const lastTick = { at: null, kind: null, reason: null };
function readState(cfg) {
try {
return { ...defaultState(), ...JSON.parse(readFileSync(cfg.statePath, "utf8")) };
} catch {
return defaultState(); // missing or corrupt file: start clean
}
}
function writeState(cfg, state) {
mkdirSync(dirname(cfg.statePath), { recursive: true });
const tmp = cfg.statePath + ".tmp";
writeFileSync(tmp, JSON.stringify(state, null, 1));
renameSync(tmp, cfg.statePath); // atomic: a killed process never leaves a torn file
}
/**
* Routes District requests through a SOCKS5 proxy. Notifications stay direct, so the
* alerting path never depends on the proxy. Needed on datacenter hosts whose IPs the
* site's WAF blocks.
*/
async function attachProxy(cfg) {
if (!cfg.proxyUrl) return;
const u = new URL(cfg.proxyUrl);
const { socksDispatcher } = await import("fetch-socks");
// Node's built-in fetch bundles its own undici and rejects a dispatcher built by the
// npm undici ("invalid onRequestStart method"), so use undici's fetch for both halves.
const { fetch: undiciFetch } = await import("undici");
globalThis.fetch = undiciFetch;
cfg.dispatcher = socksDispatcher({
type: 5,
host: u.hostname,
port: Number(u.port),
...(u.username ? { userId: decodeURIComponent(u.username), password: decodeURIComponent(u.password) } : {}),
});
console.log(ts(), `checks routed via SOCKS5 ${u.hostname}:${u.port}`);
}
export async function tick(cfg) {
const now = new Date();
const state = readState(cfg);
if (state.retired) return "retired";
let input;
if (now.getTime() >= Date.parse(cfg.expiryUtc)) {
input = { kind: "closed", now }; // decide() retires before it looks at kind
} else {
// Best-effort daily probe: showDates only exists in a 200 body, and the target date
// returns an empty 204 while closed. Its failure never counts toward the streak.
let probeMax = null;
if (heartbeatDue(state, now, cfg) && !state.brokenAlerted) {
try {
const probe = await checkDistrict(cfg, localDate(now, cfg.tzOffsetMinutes));
if (probe.kind === "open") {
const dates = probe.data?.meta?.showDates ?? [];
if (dates.length) probeMax = dates.slice().sort().at(-1);
}
} catch (err) {
console.error(ts(), "showDates probe failed (best-effort):", err.message);
}
}
try {
const res = await checkDistrict(cfg, cfg.targetDate);
input = res.kind === "open"
? { kind: "open", extract: extractSessions(res.data, cfg), now, probeMax }
: { kind: "closed", now, probeMax };
} catch (err) {
input = { kind: "error", reason: String(err.message ?? err), now, probeMax };
}
}
const { baseState, alerts } = decide(state, input, cfg);
let merged = baseState;
for (const alert of alerts) {
try {
await notify(cfg, alert);
merged = { ...merged, ...alert.statePatch };
console.log(ts(), "alert sent:", alert.title);
} catch (err) {
console.error(ts(), `notify failed for "${alert.title}" (will retry next tick):`, err.message);
}
}
if (JSON.stringify(merged) !== JSON.stringify(state)) {
try {
writeState(cfg, merged);
} catch (err) {
// Degrades to duplicate pushes, never missed ones — the right direction here.
console.error(ts(), "state write failed (may duplicate pushes):", err.message);
}
}
lastTick.at = ts();
lastTick.kind = input.kind;
lastTick.reason = input.reason ?? null;
console.log(ts(), "tick:", input.kind, input.kind === "error" ? `(${input.reason})` : "", "failures:", merged.failures);
return input.kind;
}
function startStatusServer(cfg) {
createServer(async (req, res) => {
const send = (code, obj) => {
res.writeHead(code, { "content-type": "application/json; charset=utf-8" });
res.end(JSON.stringify(obj, null, 1));
};
try {
const url = new URL(req.url, "http://localhost");
if (url.pathname === "/healthz") return send(200, { ok: true });
const state = readState(cfg);
const out = {
now: ts(),
targetDate: cfg.targetDate,
proxied: Boolean(cfg.dispatcher),
retired: state.retired,
lastTick,
state, // never includes topic, token or proxy credentials
};
if (url.searchParams.get("force") === "1") {
// Side-effect-free: no state writes, no notifications.
try {
const r = await checkDistrict(cfg, cfg.targetDate);
out.live = r.kind === "open"
? { kind: "open", ...extractSessions(r.data, cfg) }
: { kind: "closed" };
} catch (err) {
out.live = { kind: "error", reason: String(err.message ?? err) };
}
}
send(200, out);
} catch (err) {
send(500, { error: String(err.message ?? err) });
}
}).listen(cfg.port, "0.0.0.0", () => console.log(ts(), `status server on :${cfg.port}`));
}
const cfg = loadConfig();
await attachProxy(cfg);
if (process.argv.includes("--loop")) {
startStatusServer(cfg);
console.log(ts(), `watching ${cfg.targetDate} every ${cfg.intervalMs / 1000}s (expires ${cfg.expiryUtc})`);
for (const sig of ["SIGINT", "SIGTERM"]) process.on(sig, () => process.exit(0));
for (;;) {
const kind = await tick(cfg).catch((err) => { console.error(ts(), "tick crashed:", err); return "crash"; });
if (kind === "retired") break;
await new Promise((r) => setTimeout(r, cfg.intervalMs));
}
} else {
await tick(cfg);
}
+282
View File
@@ -0,0 +1,282 @@
// Core logic: District API client, session matching, alert state machine, ntfy publisher.
// Everything here is runtime-agnostic and side-effect-free except checkDistrict/notify.
export class CheckError extends Error {}
const UA = "Mozilla/5.0 (Linux; Android 15; Pixel 9) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36";
/**
* District's web client mints this token locally on every request — it is not
* server-issued, and any value of this shape is accepted.
*/
export function guestToken() {
let digits = "";
for (let i = 0; i < 18; i++) digits += Math.floor(Math.random() * 10);
const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789";
let suffix = "";
for (let i = 0; i < 11; i++) suffix += alphabet[Math.floor(Math.random() * alphabet.length)];
return `${Date.now()}_${digits}_${suffix}`;
}
/** API showTime is UTC without a zone suffix ("2026-08-05T03:30"). */
export function toLocalTime(showTime, tzOffsetMinutes) {
let s = showTime;
if (/T\d{2}:\d{2}$/.test(s)) s += ":00";
if (!s.endsWith("Z")) s += "Z";
const d = new Date(Date.parse(s) + tzOffsetMinutes * 60000);
let h = d.getUTCHours();
const m = d.getUTCMinutes();
const ampm = h >= 12 ? "PM" : "AM";
h = h % 12 || 12;
return `${h}:${String(m).padStart(2, "0")} ${ampm}`;
}
/** YYYY-MM-DD in the configured timezone. */
export function localDate(now, tzOffsetMinutes) {
return new Date(now.getTime() + tzOffsetMinutes * 60000).toISOString().slice(0, 10);
}
function localMinutes(now, tzOffsetMinutes) {
const d = new Date(now.getTime() + tzOffsetMinutes * 60000);
return d.getUTCHours() * 60 + d.getUTCMinutes();
}
/** Single source of truth for "should this tick send today's heartbeat". */
export function heartbeatDue(state, now, cfg) {
if (cfg.heartbeatHour < 0) return false;
return localMinutes(now, cfg.tzOffsetMinutes) >= cfg.heartbeatHour * 60 &&
state.hbDate !== localDate(now, cfg.tzOffsetMinutes);
}
function isMatch(sess, cfg) {
// Deliberately broad: either the format tag or a substring of the screen format,
// so "IMAX" also catches "IMAX 3D" and similar variants.
if (!cfg.formatTag && !cfg.formatMatch) return true;
return (cfg.formatTag && (sess.tags || []).includes(cfg.formatTag)) ||
(cfg.formatMatch && String(sess.scrnFmt || "").toUpperCase().includes(cfg.formatMatch.toUpperCase()));
}
function normalize(sess, cinemaName, cfg) {
return {
sid: String(sess.sid),
cinema: cinemaName,
time: toLocalTime(sess.showTime, cfg.tzOffsetMinutes),
audi: sess.audi || "",
avail: sess.avail,
total: sess.total,
tiers: (sess.areas || [])
.map((a) => `${a.label} ${cfg.currency}${Math.round(a.price)} (${a.sAvail}/${a.sTotal})`)
.join(" · "),
};
}
/**
* Split a District response into matching sessions.
* matched — at cfg.cinemaId (or every cinema when cinemaId is unset)
* other — matching format at a different cinema (reported as secondary lines)
*/
export function extractSessions(data, cfg) {
const cinemas = [
...(data?.pageData?.nearbyCinemas ?? []),
...(data?.pageData?.farCinemas ?? []),
];
const out = {
matched: [], other: [],
cinemaCount: cinemas.length, sessionCount: 0,
showDates: data?.meta?.showDates ?? [],
};
for (const c of cinemas) {
const name = c?.cinemaInfo?.name || `cinema ${c?.id}`;
for (const s of c?.sessions ?? []) {
out.sessionCount++;
if (!isMatch(s, cfg)) continue;
const wanted = !cfg.cinemaId || String(c.id) === String(cfg.cinemaId);
(wanted ? out.matched : out.other).push(normalize(s, name, cfg));
}
}
return out;
}
function apiUrl(cfg, date) {
const p = new URLSearchParams({
version: "3", site_id: "1", channel: "mweb", child_site_id: "1", platform: "district",
movieCode: cfg.movieCode, city_key: cfg.cityKey, content_id: cfg.contentId,
date, latitude: cfg.lat, longitude: cfg.lng, cinemaOrderLogic: "3",
});
return `https://www.district.in/gw/consumer/movies/v5/movie?${p}`;
}
/**
* One API call, classified by HTTP outcome only:
* 204 -> date not open for booking yet
* 200 -> open (open-but-empty is decided later, in decide())
* Anything else throws CheckError, which counts toward the failure streak.
*/
export async function checkDistrict(cfg, date) {
let res;
try {
res = await fetch(apiUrl(cfg, date), {
headers: {
accept: "*/*",
api_source: "district",
"x-app-type": "ed_mweb",
"x-guest-token": guestToken(),
"x-request-id": crypto.randomUUID(),
"user-agent": UA,
...(cfg.bookingUrl ? { referer: cfg.bookingUrl } : {}),
},
signal: AbortSignal.timeout(15000),
// Node-only: routes this call through a SOCKS5 proxy when one is configured.
...(cfg.dispatcher ? { dispatcher: cfg.dispatcher } : {}),
});
} catch (err) {
throw new CheckError(`fetch failed: ${err.message}`);
}
if (res.status === 204) return { kind: "closed" };
if (res.status === 200) {
try {
return { kind: "open", data: await res.json() };
} catch (err) {
throw new CheckError(`unparseable 200 body: ${err.message}`);
}
}
throw new CheckError(`HTTP ${res.status}`);
}
export async function notify(cfg, alert) {
if (!cfg.ntfyTopic) throw new Error("NTFY_TOPIC is not set");
const headers = {
Title: alert.title,
Priority: alert.priority,
Tags: alert.tags,
"Content-Type": "text/plain; charset=utf-8",
};
if (alert.click) headers.Click = alert.click;
if (cfg.ntfyToken) headers.Authorization = `Bearer ${cfg.ntfyToken}`;
const res = await fetch(`${cfg.ntfyUrl}/${cfg.ntfyTopic}`, {
method: "POST",
headers,
body: alert.body,
signal: AbortSignal.timeout(10000),
});
if (!res.ok) throw new Error(`ntfy HTTP ${res.status}`);
}
export function defaultState() {
return {
dateOpenAlerted: false,
sidsAlerted: {},
failures: 0,
brokenAlerted: false,
hbDate: "",
lastGood: null,
retired: false,
};
}
/**
* Pure decision core.
* input: { kind: "closed"|"open"|"error", extract?, reason?, now, probeMax? }
* Returns { baseState, alerts }. Each alert carries its own statePatch, which the
* caller merges ONLY after that alert's push succeeds, so a failed push retries
* next tick instead of being silently marked as delivered.
*/
export function decide(state, input, cfg) {
const base = structuredClone(state);
const alerts = [];
const now = input.now;
if (base.retired) return { baseState: base, alerts };
if (now.getTime() >= Date.parse(cfg.expiryUtc)) {
alerts.push({
title: "Monitor retired",
priority: "3",
tags: "checkered_flag",
click: cfg.bookingUrl,
body: `${cfg.targetDate} has passed, so this monitor is now idle. Stop it when convenient.`,
statePatch: { retired: true },
});
return { baseState: base, alerts };
}
// Only write when it actually changed, so a repeating probe cannot churn state.
if (input.probeMax && base.lastGood?.showDatesMax !== input.probeMax) {
base.lastGood = { ...(base.lastGood ?? {}), showDatesMax: input.probeMax, at: now.toISOString() };
}
if (input.kind === "error") {
base.failures = Math.min((state.failures ?? 0) + 1, cfg.failureThreshold);
if (base.failures >= cfg.failureThreshold && !state.brokenAlerted) {
alerts.push({
title: "Monitor BROKEN - check manually",
priority: "4",
tags: "warning",
click: cfg.bookingUrl,
body: `${cfg.failureThreshold} consecutive check failures (last: ${input.reason}). The site may be blocking this host, or be down. Until this recovers, silence does NOT mean "not open yet" - check the page yourself.`,
statePatch: { brokenAlerted: true },
});
}
return { baseState: base, alerts };
}
// --- success tick ---
base.failures = 0;
if (state.brokenAlerted) {
alerts.push({
title: "Monitor recovered",
priority: "3",
tags: "white_check_mark",
click: cfg.bookingUrl,
body: "Checks are succeeding again. Normal service resumed.",
statePatch: { brokenAlerted: false },
});
}
const ex = input.kind === "open" ? input.extract : null;
const effectiveKind = ex && ex.sessionCount > 0 ? "open" : "closed"; // open-but-empty = closed
if (base.lastGood?.kind !== effectiveKind) {
base.lastGood = { ...(base.lastGood ?? {}), kind: effectiveKind, at: now.toISOString() };
}
if (effectiveKind === "open") {
const fresh = ex.matched.filter((s) => !state.sidsAlerted?.[s.sid]);
if (fresh.length > 0) {
const lines = fresh.map((s) =>
`${s.time} | ${s.cinema}${s.audi ? ` | ${s.audi}` : ""} | seats ${s.avail}/${s.total}\n${s.tiers}`);
for (const s of ex.other) lines.push(`Also at ${s.cinema}: ${s.time}`);
const sidsAlerted = { ...state.sidsAlerted };
for (const s of fresh) sidsAlerted[s.sid] = true;
alerts.push({
title: `${cfg.formatLabel} ${cfg.targetDate} OPEN - BOOK NOW`,
priority: "5",
tags: "rotating_light,movie_camera",
click: cfg.bookingUrl,
body: lines.join("\n\n"),
statePatch: { sidsAlerted, dateOpenAlerted: true },
});
} else if (!state.dateOpenAlerted && ex.matched.length === 0) {
alerts.push({
title: `${cfg.targetDate} bookings are OPEN (no ${cfg.formatLabel} yet)`,
priority: "4",
tags: "eyes",
click: cfg.bookingUrl,
body: `${ex.cinemaCount} cinemas are listing ${cfg.targetDate}, but nothing matching "${cfg.formatLabel}" so far. You'll get a max-priority push the moment it appears. Open dates: ${ex.showDates.join(", ") || "n/a"}.`,
statePatch: { dateOpenAlerted: true },
});
}
}
if (heartbeatDue(state, now, cfg)) {
alerts.push({
title: `Still watching ${cfg.targetDate}`,
priority: "1",
tags: "hourglass",
click: cfg.bookingUrl,
body: `Monitor healthy. Bookings open through: ${base.lastGood?.showDatesMax ?? "unavailable"}. Target date is still ${effectiveKind}.`,
statePatch: { hbDate: localDate(now, cfg.tzOffsetMinutes) },
});
}
return { baseState: base, alerts };
}
+1
View File
@@ -0,0 +1 @@
{"serverTime":1785852192,"meta":{"entityMetaData":{"entity_ids":"171,125,1020680,1032362,1022291","num_show_total":"1,3,1,4,2","max_price_cinema":"150.0,350.0,200.0,870.0,440.0","min_price_cinema":"150.0,200.0,130.0,260.0,180.0","cinema_distance":"2.918495927212754,4.709702768689504,5.0199044666769534,6.312918866307513,6.456346530469135","screen_format_dict":"{\"1020680\":[\"2D\"],\"1032362\":[\"IMAX 2D\"],\"125\":[\"2D\"],\"1022291\":[\"2D\",\"4DX-2D\"],\"171\":[\"2D\"]}","show_language_dict":"{\"1020680\":[\"default_language\"],\"1032362\":[\"default_language\"],\"125\":[\"default_language\"],\"1022291\":[\"default_language\"],\"171\":[\"default_language\"]}","prev_ordered_cinema":"0,0,0,0,0"},"showDates":["2026-08-04","2026-08-05","2026-08-06","2026-08-07","2026-08-08","2026-08-09"],"amenities":{"12":{"id":12,"name":"WIFI","rank":12,"icon_url":"https://cdn.district.in/movies-assets/images/cinema/Wifi-5646f900-95f6-11ef-b954-a1c7e1e4be25.png"},"24":{"id":24,"name":"Silver Screen","rank":24,"icon_url":"https://cdn.district.in/movies-assets/images/cinema/Screen-fef54df0-95f5-11ef-b954-a1c7e1e4be25.png"},"14":{"id":14,"name":"Gaming Zone","rank":14,"icon_url":"https://cdn.district.in/movies-assets/images/cinema/Gaming-d3353df0-95f6-11ef-a253-33f238ee5f34.png"},"15":{"id":15,"name":"2K","rank":15,"icon_url":"https://cdn.district.in/movies-assets/images/cinema/Screen-fef54df0-95f5-11ef-b954-a1c7e1e4be25.png"},"17":{"id":17,"name":"4DX","rank":17,"icon_url":"https://cdn.district.in/movies-assets/images/cinema/Screen-fef54df0-95f5-11ef-b954-a1c7e1e4be25.png"},"28":{"id":28,"name":"Free Parking","rank":28,"icon_url":"https://cdn.district.in/movies-assets/images/cinema/Parking-c79a3400-95f6-11ef-a253-33f238ee5f34.png"},"18":{"id":18,"name":"Dolby 7.1","rank":18,"icon_url":"https://cdn.district.in/movies-assets/images/cinema/Audio-4acee060-95f6-11ef-b954-a1c7e1e4be25.png"},"29":{"id":29,"name":"Baggage Counter","rank":29,"icon_url":"https://cdn.district.in/movies-assets/images/cinema/Baggage%20counter-f4cbae50-95f5-11ef-a253-33f238ee5f34.png"},"19":{"id":19,"name":"Air Conditioning","rank":19,"icon_url":"https://cdn.district.in/movies-assets/images/cinema/AC-d92791f0-95f5-11ef-b954-a1c7e1e4be25.png"},"1":{"id":1,"name":"Mobile Ticket","rank":2,"icon_url":"https://cdn.district.in/movies-assets/images/cinema/Mobile%20Ticket-bc527a30-95f6-11ef-b954-a1c7e1e4be25.png"},"2":{"id":2,"name":"Parking","rank":1,"icon_url":"https://cdn.district.in/movies-assets/images/cinema/Parking-c79a3400-95f6-11ef-a253-33f238ee5f34.png"},"3":{"id":3,"name":"Food & Beverages","rank":3,"icon_url":"https://cdn.district.in/movies-assets/images/cinema/F%26B-938587f0-95f6-11ef-b954-a1c7e1e4be25.png"},"4":{"id":4,"name":"Wheelchair Friendly","rank":6,"icon_url":"https://cdn.district.in/movies-assets/images/cinema/Wheelchair-73f627a0-95f6-11ef-a253-33f238ee5f34.png"},"5":{"id":5,"name":"Recliners","rank":4,"icon_url":"https://cdn.district.in/movies-assets/images/cinema/Seat-13023560-95f6-11ef-b954-a1c7e1e4be25.png"},"6":{"id":6,"name":"Digital Payments","rank":5,"icon_url":"https://cdn.district.in/movies-assets/images/cinema/Payment-eab8cb00-95f5-11ef-a253-33f238ee5f34.png"},"7":{"id":7,"name":"RGB Laser","rank":7,"icon_url":"https://cdn.district.in/movies-assets/images/cinema/Screen-fef54df0-95f5-11ef-b954-a1c7e1e4be25.png"},"8":{"id":8,"name":"Dolby Atmos","rank":8,"icon_url":"https://cdn.district.in/movies-assets/images/cinema/Audio-4acee060-95f6-11ef-b954-a1c7e1e4be25.png"},"9":{"id":9,"name":"IMAX","rank":9,"icon_url":"https://cdn.district.in/movies-assets/images/cinema/Screen-fef54df0-95f5-11ef-b954-a1c7e1e4be25.png"},"30":{"id":30,"name":"Dolby 4 Way 11.1","rank":30,"icon_url":"https://cdn.district.in/movies-assets/images/cinema/Audio-4acee060-95f6-11ef-b954-a1c7e1e4be25.png"},"20":{"id":20,"name":"Metro Nearby","rank":20,"icon_url":"https://cdn.district.in/movies-assets/images/cinema/Metro-08856cb0-95f6-11ef-b954-a1c7e1e4be25.png"},"31":{"id":31,"name":"Barco 4K Ultra HD","rank":31,"icon_url":"https://cdn.district.in/movies-assets/images/cinema/Screen-fef54df0-95f5-11ef-b954-a1c7e1e4be25.png"},"10":{"id":10,"name":"4K","rank":10,"icon_url":"https://cdn.district.in/movies-assets/images/cinema/Screen-fef54df0-95f5-11ef-b954-a1c7e1e4be25.png"},"21":{"id":21,"name":"Sofa","rank":21,"icon_url":"https://cdn.district.in/movies-assets/images/cinema/Seat-13023560-95f6-11ef-b954-a1c7e1e4be25.png"}},"selectedShowDate":"2026-08-05","pageMeta":null,"movie":{"content_id":187151,"name":"The Odyssey","description":"The story follows Odysseus on his epic journey home after the Trojan War, highlighting his encounters with Polyphemus, the Sirens, and Circe, culminating in a heartfelt reunion with his wife, Penelope.","release_date":"2026-07-17","re_release_date":"","upcoming_movie_text":null,"duration":175,"censor":"A","primary_language":"English","languages":["Hindi","English"],"genre":["Epic","Adventure"],"poster":"https://cdn.district.in/movies-assets/iLine truncated
+214
View File
@@ -0,0 +1,214 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { guestToken, toLocalTime, extractSessions, decide, defaultState } from "../src/monitor.js";
const fixture = JSON.parse(readFileSync(
fileURLToPath(new URL("./fixtures/response-open-date.json", import.meta.url)), "utf8"));
// Matches the sample in .env.example: IMAX at one specific cinema, IST.
const CFG = {
targetDate: "2026-08-10",
expiryUtc: "2026-08-10T18:30:00Z",
bookingUrl: "https://example.com/book",
failureThreshold: 6,
tzOffsetMinutes: 330,
heartbeatHour: 9,
formatTag: "imax_2d",
formatMatch: "IMAX",
formatLabel: "IMAX",
cinemaId: "1032362",
currency: "₹",
};
test("guestToken matches District's client-generated shape", () => {
const t = guestToken();
assert.match(t, /^\d{13}_\d{18}_[a-z0-9]{11}$/);
assert.notEqual(guestToken(), t, "fresh token per call");
});
test("toLocalTime converts API UTC showTime to local clock time", () => {
assert.equal(toLocalTime("2026-08-05T03:30", 330), "9:00 AM"); // verified against the site UI
assert.equal(toLocalTime("2026-08-05T06:30", 330), "12:00 PM"); // noon boundary
assert.equal(toLocalTime("2026-08-05T12:00", 330), "5:30 PM");
assert.equal(toLocalTime("2026-08-05T18:45", 330), "12:15 AM"); // past midnight, clock time only
assert.equal(toLocalTime("2026-08-05T03:30:00", 330), "9:00 AM"); // seconds already present
assert.equal(toLocalTime("2026-08-05T03:30", 0), "3:30 AM"); // other timezones
});
test("extractSessions finds the target cinema's sessions in a real capture", () => {
const ex = extractSessions(fixture, CFG);
assert.equal(ex.cinemaCount, 5);
assert.ok(ex.sessionCount > ex.matched.length);
assert.equal(ex.matched.length, 4);
assert.equal(ex.other.length, 0);
const s = ex.matched[0];
assert.equal(s.sid, "36874");
assert.equal(s.cinema, "PVR Palladium Mall, Thaltej, Ahmedabad");
assert.equal(s.time, "9:00 AM");
assert.equal(s.audi, "SCREEN 9");
assert.ok(s.tiers.includes("CLASSIC ₹260"));
assert.ok(s.tiers.includes("RECLINER ₹640"));
assert.equal(ex.showDates.at(-1), "2026-08-09");
});
test("extractSessions: no cinemaId watches every cinema; no format filter matches all", () => {
const anyCinema = extractSessions(fixture, { ...CFG, cinemaId: "" });
assert.equal(anyCinema.matched.length, 4, "still only IMAX sessions");
assert.equal(anyCinema.other.length, 0, "nothing is 'other' when no cinema is targeted");
const anyFormat = extractSessions(fixture, { ...CFG, cinemaId: "", formatTag: "", formatMatch: "" });
assert.equal(anyFormat.matched.length, anyFormat.sessionCount, "every session matches");
const otherCinema = extractSessions(fixture, { ...CFG, cinemaId: "125" });
assert.equal(otherCinema.matched.length, 0);
assert.equal(otherCinema.other.length, 4, "IMAX elsewhere becomes a secondary line");
});
test("extractSessions tolerates empty/malformed payloads", () => {
assert.deepEqual(extractSessions({}, CFG),
{ matched: [], other: [], cinemaCount: 0, sessionCount: 0, showDates: [] });
});
const T0 = new Date("2026-08-06T02:00:00Z"); // before the 03:30 UTC (09:00 IST) heartbeat window
const openInput = (extract, now = T0) => ({ kind: "open", extract, now });
const EX_MATCH = {
matched: [{ sid: "111", cinema: "Test Cinema", time: "9:00 AM", audi: "SCREEN 9", avail: 100, total: 300, tiers: "CLASSIC ₹260 (66/66)" }],
other: [], cinemaCount: 3, sessionCount: 7, showDates: [],
};
const EX_NO_MATCH = { matched: [], other: [], cinemaCount: 3, sessionCount: 7, showDates: [] };
const EX_EMPTY = { matched: [], other: [], cinemaCount: 0, sessionCount: 0, showDates: [] };
/** Mirrors the runner: merge each alert's patch only after its push succeeds. */
function apply(state, decision) {
let merged = decision.baseState;
for (const a of decision.alerts) merged = { ...merged, ...a.statePatch };
return merged;
}
test("closed steady state produces no alerts and no state change", () => {
const s1 = apply(defaultState(), decide(defaultState(), { kind: "closed", now: T0 }, CFG));
const d2 = decide(s1, { kind: "closed", now: new Date(T0.getTime() + 60000) }, CFG);
assert.equal(d2.alerts.length, 0);
assert.equal(JSON.stringify(apply(s1, d2)), JSON.stringify(s1), "no state write when nothing changed");
});
test("a match fires a max-priority alert once, re-firing only for new session ids", () => {
const d1 = decide(defaultState(), openInput(EX_MATCH), CFG);
assert.equal(d1.alerts.length, 1);
assert.equal(d1.alerts[0].priority, "5");
assert.ok(d1.alerts[0].statePatch.dateOpenAlerted, "first open response sets dateOpenAlerted");
assert.ok(d1.alerts[0].statePatch.sidsAlerted["111"]);
const s1 = apply(defaultState(), d1);
assert.equal(decide(s1, openInput(EX_MATCH), CFG).alerts.length, 0, "deduped");
const withNew = { ...EX_MATCH, matched: [...EX_MATCH.matched, { ...EX_MATCH.matched[0], sid: "222", time: "1:15 PM" }] };
const d3 = decide(s1, openInput(withNew), CFG);
assert.equal(d3.alerts.length, 1, "new session id re-alerts");
assert.ok(d3.alerts[0].body.includes("1:15 PM"));
assert.ok(!d3.alerts[0].body.includes("9:00 AM"), "already-alerted session not repeated");
});
test("sessions at other cinemas ride along as secondary lines, untracked", () => {
const withOther = {
...EX_MATCH,
other: [{ sid: "999", cinema: "Other Cinema", time: "6:00 PM", audi: "", avail: 50, total: 100, tiers: "CLASSIC ₹300 (50/100)" }],
};
const d = decide(defaultState(), openInput(withOther), CFG);
assert.equal(d.alerts.length, 1, "no separate push for other venues");
assert.ok(d.alerts[0].body.includes("Also at Other Cinema: 6:00 PM"));
assert.ok(!d.alerts[0].statePatch.sidsAlerted["999"], "other-venue ids are not dedupe-tracked");
});
test("date open without a match fires DATE_OPEN once; a later match still fires", () => {
const d1 = decide(defaultState(), openInput(EX_NO_MATCH), CFG);
assert.equal(d1.alerts.length, 1);
assert.equal(d1.alerts[0].priority, "4");
const s1 = apply(defaultState(), d1);
assert.equal(decide(s1, openInput(EX_NO_MATCH), CFG).alerts.length, 0);
const d3 = decide(s1, openInput(EX_MATCH), CFG);
assert.equal(d3.alerts.length, 1);
assert.equal(d3.alerts[0].priority, "5");
});
test("failed match push then sessions vanish -> DATE_OPEN still reachable", () => {
const d1 = decide(defaultState(), openInput(EX_MATCH), CFG);
const s1 = d1.baseState; // push failed: statePatch not merged
const d2 = decide(s1, openInput(EX_NO_MATCH), CFG);
assert.equal(d2.alerts.length, 1);
assert.equal(d2.alerts[0].priority, "4", "user still learns the date opened");
});
test("open-but-empty counts as closed", () => {
const d = decide(defaultState(), openInput(EX_EMPTY), CFG);
assert.equal(d.alerts.length, 0);
assert.equal(d.baseState.lastGood.kind, "closed");
});
test("BROKEN fires at the threshold, once; RECOVERED on the next success", () => {
let s = defaultState();
for (let i = 1; i <= 5; i++) {
const d = decide(s, { kind: "error", reason: "HTTP 403", now: T0 }, CFG);
assert.equal(d.alerts.length, 0, `no alert at failure ${i}`);
s = apply(s, d);
assert.equal(s.failures, i);
}
const d6 = decide(s, { kind: "error", reason: "HTTP 403", now: T0 }, CFG);
assert.equal(d6.alerts.length, 1);
assert.ok(d6.alerts[0].title.includes("BROKEN"));
s = apply(s, d6);
assert.equal(decide(s, { kind: "error", reason: "HTTP 403", now: T0 }, CFG).alerts.length, 0, "not repeated");
const d8 = decide(s, { kind: "closed", now: T0 }, CFG);
assert.equal(d8.alerts.length, 1);
assert.ok(d8.alerts[0].title.includes("recovered"));
s = apply(s, d8);
assert.equal(s.failures, 0);
assert.equal(s.brokenAlerted, false);
});
test("heartbeat fires once per local day, never on error ticks, and can be disabled", () => {
const hbTime = new Date("2026-08-06T03:31:00Z"); // 09:01 IST
const early = new Date("2026-08-06T03:29:00Z");
assert.equal(decide(defaultState(), { kind: "closed", now: early }, CFG).alerts.length, 0);
const d1 = decide(defaultState(), { kind: "closed", now: hbTime }, CFG);
assert.equal(d1.alerts.length, 1);
assert.equal(d1.alerts[0].priority, "1");
assert.equal(d1.alerts[0].statePatch.hbDate, "2026-08-06");
const s1 = apply(defaultState(), d1);
assert.equal(decide(s1, { kind: "closed", now: new Date("2026-08-06T09:00:00Z") }, CFG).alerts.length, 0, "once per day");
assert.equal(decide(defaultState(), { kind: "error", reason: "x", now: hbTime }, CFG).alerts.length, 0, "not on errors");
assert.equal(decide(defaultState(), { kind: "closed", now: hbTime }, { ...CFG, heartbeatHour: -1 }).alerts.length, 0, "disabled");
});
test("heartbeat reports the probe-refreshed showDates maximum", () => {
const hbTime = new Date("2026-08-06T03:31:00Z");
const d = decide(defaultState(), { kind: "closed", now: hbTime, probeMax: "2026-08-11" }, CFG);
assert.equal(d.baseState.lastGood.showDatesMax, "2026-08-11", "probe result rides baseState");
assert.ok(d.alerts[0].body.includes("2026-08-11"));
});
test("recovery, match and heartbeat can co-fire with disjoint patches", () => {
const hbTime = new Date("2026-08-06T03:31:00Z");
const s0 = { ...defaultState(), brokenAlerted: true, failures: 6 };
const d = decide(s0, openInput(EX_MATCH, hbTime), CFG);
assert.deepEqual(d.alerts.map((a) => a.priority), ["3", "5", "1"]);
assert.deepEqual(d.alerts.map((a) => Object.keys(a.statePatch)),
[["brokenAlerted"], ["sidsAlerted", "dateOpenAlerted"], ["hbDate"]]);
const merged = apply(s0, d);
assert.equal(merged.brokenAlerted, false);
assert.equal(merged.failures, 0);
assert.ok(merged.sidsAlerted["111"]);
assert.equal(merged.hbDate, "2026-08-06");
});
test("retirement: one alert at expiry, then a permanent no-op", () => {
const past = new Date("2026-08-10T18:31:00Z");
const d1 = decide(defaultState(), { kind: "closed", now: past }, CFG);
assert.equal(d1.alerts.length, 1);
assert.ok(d1.alerts[0].statePatch.retired);
const s1 = apply(defaultState(), d1);
assert.equal(decide(s1, { kind: "closed", now: past }, CFG).alerts.length, 0);
});