mirror of
https://github.com/aculix/ticket-monitoring.git
synced 2026-10-27 07:15:51 +00:00
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:
commit
b70968d259
15 files changed
+1164
No files matched your search
@@ -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
|
||||
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
.env
|
||||
.env.local
|
||||
state.json
|
||||
state.local.json
|
||||
data/
|
||||
logs/
|
||||
.DS_Store
|
||||
+14
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
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).
|
||||
@@ -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
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 232 KiB |
@@ -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.
|
||||
Generated
+62
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user