feat: watch BookMyShow alongside District in one process

BookMyShow sits behind Cloudflare bot management that rejects curl and Node fetch
with 403 on the TLS fingerprint alone, so no header or cookie copying helps and a
proxy makes no difference. impit performs the request with a real Chrome
fingerprint; it ships musl and arm64 binaries, so the Alpine multi-arch image is
unaffected. BMS has no 204 either: a date that is not on sale returns 200 with no
showtimeWidgets key, so 'closed' is read from the payload.

Sites now live in src/providers/, each exposing check() and extract() and returning
one normalized session shape. State is namespaced per site, so each keeps its own
dedupe and failure tracking and neither can silence the other. Alert titles name the
site. Sessions without exact seat counts render without them, which BMS needs.

Old flat District variable names still work. CI now starts the built image and runs
a real check through both providers before publishing, since a broken native binary
only fails at runtime.
This commit is contained in:
Aculix Technologies committed 2026-08-26 17:50:56 +05:30
1 parent 00e6852863
commit 608221fe18
17 files changed
+1188 -539

No files matched your search

+42 -28
View File
@@ -1,31 +1,13 @@
# 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.
# Copy to .env and fill in. Every value is read from the environment, so you can set
# these directly (Docker, systemd, CI) instead of using a file.
#
# Configure District, BookMyShow, or both. Both are watched in one process, each with
# its own dedupe and failure tracking, so one site breaking never silences the other.
# ── 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
# ── Shared ────────────────────────────────────────────────────────────────────
TARGET_DATE=2026-09-25 # the date you are waiting for (YYYY-MM-DD)
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
@@ -35,10 +17,41 @@ NTFY_TOPIC=change-me-to-something-random
NTFY_URL=https://ntfy.sh
# NTFY_TOKEN=tk_... # only if your server (or topic) requires auth
# ── District (district.in) ────────────────────────────────────────────────────
# Leave DISTRICT_MOVIE_CODE unset to skip District entirely.
# Read these off the movie's page URL, e.g.
# district.in/movies/the-odyssey-movie-tickets-in-ahmedabad-MV187151?frmtid=y7EZNLXN5Y
DISTRICT_MOVIE_CODE=y7EZNLXN5Y # ?frmtid= — identifies movie + language
DISTRICT_CONTENT_ID=187151 # digits after MV
DISTRICT_CITY_KEY=ahmedabad # city slug in the path
DISTRICT_LAT=23.033938956712916 # any coordinates in that city; the API needs them
DISTRICT_LNG=72.57696301365426
DISTRICT_CINEMA_ID=1032362 # empty = watch every cinema in the city
DISTRICT_FORMAT_TAG=imax_2d # session tag that counts as a match
DISTRICT_FORMAT_MATCH=IMAX # or a screen-format substring; both empty = any show
DISTRICT_BOOKING_URL=https://www.district.in/movies/the-odyssey-movie-tickets-in-ahmedabad-MV187151?frmtid=y7EZNLXN5Y&fromdate=2026-09-25
# ── BookMyShow (in.bookmyshow.com) ────────────────────────────────────────────
# Leave BMS_EVENT_CODE unset to skip BookMyShow entirely.
# From the movie URL, e.g. in.bookmyshow.com/movies/ahmedabad/avengers-endgame-encore/ET00514163
BMS_EVENT_CODE=ET00514163 # the ET... code
# BMS_REF_EVENT_CODE= # only if the URL's refEventCode differs from above
BMS_REGION_CODE=AHD # BMS region code (AHD, BANG, MUMBAI, NCR ...)
BMS_REGION_SLUG=ahmedabad # city slug in the path
BMS_LAT=23.0395677
BMS_LNG=72.5660045
BMS_VENUE_CODE=PPAM # empty = watch every cinema in the region
# BMS_LANGUAGE=hindi # only if you want one language's shows
BMS_FORMAT_MATCH=IMAX # matched against the screen format; empty = any show
BMS_BOOKING_URL=https://in.bookmyshow.com/movies/ahmedabad/avengers-endgame-encore/ET00514163
# Watch only some of the configured providers. Unset = every provider you configured.
# PROVIDERS=district,bookmyshow
# ── 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)
TZ_OFFSET_MINUTES=330 # timezone used for showtimes and dates (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.
@@ -47,6 +60,7 @@ CURRENCY=₹
STATE_PATH=./state.json # Docker image defaults this to /data/state.json
PORT=4733 # 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.
# Routes District checks through a SOCKS5 proxy; notifications stay direct.
# Needed on datacenter hosts, whose IPs District's WAF blocks. It does nothing for
# BookMyShow, which blocks on TLS fingerprint rather than IP (impit handles that).
# PROXY_URL=socks5://user:pass@proxy.example.net:1080
+20
View File
@@ -39,6 +39,26 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Build amd64 first and actually start it. The native TLS-impersonation binary
# only fails at runtime, so a plain build would happily publish a broken image.
- uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64
load: true
tags: ticket-monitoring:smoke
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Smoke test the image
run: |
docker run --rm ticket-monitoring:smoke \
node -e "import('impit').then(m => { if (!m.Impit) { throw new Error('impit missing'); } console.log('impit loaded'); })"
docker run --rm --env-file smoke.env ticket-monitoring:smoke \
node src/index.js 2>&1 | tee /tmp/smoke.log
grep -q "\[district\]" /tmp/smoke.log
grep -q "\[bookmyshow\]" /tmp/smoke.log
- uses: docker/build-push-action@v6
with:
context: .
+121 -70
View File
@@ -2,36 +2,41 @@
[![docker](https://github.com/aculix/ticket-monitoring/actions/workflows/docker.yml/badge.svg)](https://github.com/aculix/ticket-monitoring/actions/workflows/docker.yml)
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.
Watches [District](https://www.district.in) and [BookMyShow](https://in.bookmyshow.com)
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.
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
Every 60 seconds it calls the same showtime APIs the two sites use themselves, and filters the
result down to your cinema and format. When something matches, it 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.
Both sites can be watched at once, in one process. Each keeps its own dedupe and failure state,
so tickets appearing on one site never suppress the alert for the other, and one site going down
doesn't stop the other from being checked.
Alerts are deduplicated by session id in a small state file, so you hear about each show once.
Shows added later still get through.
The part I care most about: silence always means "not open yet". If checks start failing you get
a *BROKEN* push after six failures in a row, naming the site that's failing, 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, 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. |
| `<FORMAT> <DATE> OPEN on <SITE> - BOOK NOW` | max | The one you're waiting for. Tap it to book. |
| `<DATE> is OPEN on <SITE> (no <FORMAT> yet)` | high | Date opened, your format hasn't shown up yet. |
| `<SITE> checks BROKEN - check manually` | high | That site's checks are failing. Don't trust the silence. |
| `<SITE> checks recovered` | default | Back to normal. |
| `Still watching <DATE>` | min | Daily heartbeat, with a line per site. |
| `Monitor retired` | default | Target date passed, so it stopped on its own. |
## Requirements
@@ -44,8 +49,8 @@ A prebuilt image is published to GitHub Container Registry on every push to `mai
`linux/amd64` and `linux/arm64`. It runs as-is on a normal server, an Apple Silicon Mac or a
Raspberry Pi, with nothing to clone and nothing to build.
Grab [`.env.example`](.env.example), save it as `.env`, fill it in using "Finding your IDs" below,
then:
Grab [`.env.example`](.env.example), save it as `.env`, fill it in using "Finding your IDs"
below, then:
```bash
docker run -d --name ticket-monitoring \
@@ -60,11 +65,11 @@ docker run -d --name ticket-monitoring \
docker logs -f ticket-monitoring
```
You want to see `tick: closed failures: 0` scrolling past once a minute.
You want to see a line per site once a minute, like `[district] closed | matched: 0 | failures: 0`.
The volume is what stops it re-alerting you about shows it already found, so keep it around across
restarts. Drop the `-p` flag if you don't want the status endpoint reachable. To update, pull the
image again and recreate the container:
The volume is what stops it re-alerting you about shows it already found, so keep it around
across restarts. Drop the `-p` flag if you don't want the status endpoint reachable. To update,
pull the image again and recreate the container:
```bash
docker pull ghcr.io/aculix/ticket-monitoring:latest
@@ -90,7 +95,7 @@ docker compose logs -f
```bash
npm install
cp .env.example .env # edit it
npm run check # one check, tells you what it found
npm run check # one check against every configured site
npm start # run it for real
```
@@ -100,79 +105,118 @@ support.
## Finding your IDs
Everything movie-specific is sitting in one URL. Open the film's District page for your city:
Configure either site, or both. Skip a site by leaving its main ID unset: no
`DISTRICT_MOVIE_CODE` means District isn't watched, no `BMS_EVENT_CODE` means BookMyShow isn't.
### District
Everything is sitting in the movie's page URL for your city:
```
https://www.district.in/movies/the-odyssey-movie-tickets-in-ahmedabad-MV187151?frmtid=y7EZNLXN5Y&fromdate=2026-08-10
https://www.district.in/movies/the-odyssey-movie-tickets-in-ahmedabad-MV187151?frmtid=y7EZNLXN5Y&fromdate=2026-09-25
└── 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
- `DISTRICT_CONTENT_ID` is the number after `MV`
- `DISTRICT_MOVIE_CODE` is the `frmtid` parameter, which pins down the movie *and* the language
- `DISTRICT_CITY_KEY` is the city slug in the path
- `DISTRICT_LAT` and `DISTRICT_LNG` can be any coordinates inside that city. The API refuses to
answer without them, though results are city-wide either way.
- `DISTRICT_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.
### BookMyShow
Want any format, not only IMAX? Set both `FORMAT_TAG` and `FORMAT_MATCH` to empty.
From the movie's page URL:
```
https://in.bookmyshow.com/movies/ahmedabad/avengers-endgame-encore/ET00514163
└REGION_SLUG┘ └EVENT_CODE┘
```
- `BMS_EVENT_CODE` is the `ET...` code
- `BMS_REGION_SLUG` is the city slug, and `BMS_REGION_CODE` is the short code BMS uses internally
(`AHD` for Ahmedabad). If you don't know yours, open a showtimes page for your city and look at
the `regionCode` parameter in the network request, or try the obvious abbreviation.
- `BMS_REF_EVENT_CODE` only matters when a URL carries a `refEventCode` that differs from the
event code, which happens for format-specific listings. Leave it unset otherwise.
### Both
`DISTRICT_CINEMA_ID` and `BMS_VENUE_CODE` restrict alerts to one cinema. The fiddly part is that
neither is in the URL. Easiest approach: leave them empty at first, which watches every cinema in
the city, run `npm run check`, and read the ids out of the output. Or leave them empty for good
and let the alert tell you where the shows are.
Want any format, not only IMAX? Clear `DISTRICT_FORMAT_TAG`, `DISTRICT_FORMAT_MATCH` and
`BMS_FORMAT_MATCH`.
## Configuration
Everything is environment variables. `.env` is only a convenient place to put them.
Everything is environment variables. `.env` is only a convenient place to put them. Full list
with comments in [`.env.example`](.env.example).
| 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 |
| `DISTRICT_MOVIE_CODE` | | Set to watch District. Plus `DISTRICT_CONTENT_ID`, `DISTRICT_CITY_KEY`, `DISTRICT_LAT`, `DISTRICT_LNG` |
| `DISTRICT_CINEMA_ID` | *(any)* | Restrict District to one cinema |
| `DISTRICT_FORMAT_TAG` | `imax_2d` | Session tag that counts as a match |
| `DISTRICT_FORMAT_MATCH` | `IMAX` | Screen-format substring that counts as a match |
| `DISTRICT_BOOKING_URL` | | Opens when you tap a District notification |
| `BMS_EVENT_CODE` | | Set to watch BookMyShow. Plus `BMS_REGION_CODE`, `BMS_REGION_SLUG` |
| `BMS_VENUE_CODE` | *(any)* | Restrict BookMyShow to one cinema |
| `BMS_FORMAT_MATCH` | `IMAX` | Screen-format substring that counts as a match |
| `BMS_LANGUAGE` | *(all)* | Restrict to one language's shows |
| `BMS_BOOKING_URL` | | Opens when you tap a BookMyShow notification |
| `PROVIDERS` | *(all configured)* | Allow-list, e.g. `district` to temporarily watch only one |
| `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 |
| `TZ_OFFSET_MINUTES` | `330` | Timezone for showtimes and dates, 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` | `4733` | Status endpoint |
| `PROXY_URL` | | SOCKS5 proxy for the site checks, see below |
| `PROXY_URL` | | SOCKS5 proxy for District checks, see below |
v1 used flat names (`MOVIE_CODE`, `CINEMA_ID` and friends) for District. Those still work, so an
existing `.env` keeps running unchanged.
## Status endpoint
While running continuously it serves JSON on `PORT`, which defaults to 4733:
- `/` gives you state, `lastTick` and `proxied`
- `/?force=1` adds a live check right now, with no notifications and no state changes
- `/` gives you state and `lastTick` per site, plus `proxied`
- `/?force=1` adds a live check of every site right now, with no notifications and no state changes
- `/healthz` returns `{"ok":true}`
```bash
curl -s localhost:4733/ | jq
```
Watch `lastTick.at`. If it's more than a couple of minutes old, the loop is stuck.
Watch `lastTick`. If a site's entry is more than a couple of minutes old, the loop is stuck.
## Troubleshooting
### Every check returns HTTP 403
### District 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.
District'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.
### BookMyShow returns HTTP 403
BookMyShow blocks on the TLS fingerprint of the client, not on your IP, which is why plain
`curl` and Node's `fetch` both get 403 no matter which headers or cookies you send. A proxy
won't help either. This is handled with [impit](https://github.com/apify/impit), which performs
the request with a real Chrome fingerprint. If you see 403s here, the impersonation has probably
stopped matching, so try updating the image or `npm update impit`.
### ntfy returns HTTP 429
@@ -186,9 +230,9 @@ 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.
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
@@ -197,24 +241,31 @@ Run `npm run check` for a single noisy check. Configuration problems are reporte
## Development
```bash
npm test # 16 tests, no network needed
npm test # 24 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.
The tests run the alert state machine and both site parsers against real captured API responses
in `test/fixtures/`. Layout:
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.
- `src/providers/district.js` and `src/providers/bookmyshow.js` fetch and normalize showtimes.
Each exports `check()` and `extract()`, and nothing else knows which site a session came from.
- `src/monitor.js` decides what to alert about and when to stay quiet
- `src/index.js` is the runner, state file and status server
- `src/config.js` reads the environment
- `src/lib.js` has the shared time helpers
Adding a third site means writing one file in `src/providers/` and registering it in
`config.js`. [docs/design-notes.md](docs/design-notes.md) covers what I learned about both APIs
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
This polls public endpoints 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.
Not affiliated with District, BookMyShow, PVR or IMAX. Both APIs are undocumented and can change
whenever they feel like it.
## License
+44 -3
View File
@@ -3,7 +3,11 @@
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
Two sites are supported. Each lives in `src/providers/` and exposes the same two functions,
`check()` and `extract()`, returning sessions in one normalized shape. Everything above that
layer, including the alert state machine, has no idea which site a session came from.
## District: the API
District's movie pages are server-rendered Next.js, but showtimes load client-side from:
@@ -29,9 +33,42 @@ Findings that shaped the design:
`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.
## BookMyShow: the API
```
GET /api/movies-data/v5/showtimes-by-event/primary-dynamic
?etCodes=<ET code>&dateCode=YYYYMMDD&regionCode=AHD
&isDesktop=true&xLocationShared=false&memberId=&lsId=&subCode=
&appCode=WEB&refEventCode=<ET code>[&language=hindi]
```
Sent with `x-region-code`, `x-region-slug`, `x-latitude`, `x-longitude`, `x-app-code: WEB` and
an `x-bms-id` that, like District's guest token, the web client makes up locally.
- **Cloudflare blocks on TLS fingerprint, not IP or headers.** curl and Node's `fetch` both get
403 with a byte-perfect copy of the browser's headers, and no `cf_clearance` cookie is involved,
so there is nothing to copy across. A proxy does not help either, since the block happens during
the TLS handshake. [impit](https://github.com/apify/impit) performs the request with a real
Chrome fingerprint and returns 200. It ships prebuilt binaries for linux x64 and arm64 in both
gnu and musl flavours, which is what keeps the Alpine image and the arm64 build working.
- **There is no 204.** A date that is not on sale answers 200 with a short body containing only
`header`, `additionalData` and `addOnWidgets`. The tell is that `data.showtimeWidgets` is
absent, so "closed" is a property of the payload rather than the status code.
- **A past or invalid date returns 400**, which counts as a failure rather than "closed". That is
deliberate: it means something is wrong with the configuration, and you should hear about it.
- **Sessions are buried in generic UI widgets.** Venue cards sit several layers down inside
`showtimeWidgets`, so the parser walks the tree looking for `type: "venue-card"` instead of
indexing through fixed positions, which would break the moment BMS reorders its layout.
- **Seat detail lives in a bottom sheet.** Each showtime carries a `customGestureCTA` describing
the panel shown on double-tap, and that is the only place per-tier prices appear. BMS reports
availability as words (`AVAILABLE`, `FILLING FAST`, `ALMOST FULL`) and never as exact counts,
unlike District, so the normalized session leaves `avail` and `total` null and alert lines omit
the seat figures for this site.
- **Times are already local**, so unlike District's UTC timestamps they are passed through as-is.
## Alert state machine
`decide()` in `src/monitor.js` is pure: state in, `{ baseState, alerts }` out. That makes every
`decideProvider()` 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
@@ -45,9 +82,13 @@ edge case testable without network or clock mocking, and it's where the interest
- **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.
- **State is namespaced per site.** Each provider gets its own dedupe map, failure counter and
BROKEN flag under `state.providers.<key>`, so the same session id can alert once per site and a
site going down never marks the other as broken. Retirement and the daily heartbeat are the only
tick-level decisions, and they live in `decideGlobal()`.
The guiding rule throughout: **degrade toward duplicate notifications, never toward silence.**
Every failure path — unreadable state, failed push, failed write — errs that way.
Every failure path, whether it is unreadable state, a failed push or a failed write, errs that way.
## What broke in production
+148
View File
@@ -10,6 +10,7 @@
"license": "MIT",
"dependencies": {
"fetch-socks": "^1.3.3",
"impit": "^0.14.4",
"undici": "^8.10.0"
},
"engines": {
@@ -24,6 +25,153 @@
"undici": ">=7"
}
},
"node_modules/impit": {
"version": "0.14.4",
"resolved": "https://registry.npmjs.org/impit/-/impit-0.14.4.tgz",
"integrity": "sha512-XXAafVQpWIYIchSxT6UufZgVNLewKVg9BJSwQYom7b1VWt/l0m+GPMhc4agK9O7QlAnEiFm1QQcX2RpsmLV6gQ==",
"license": "Apache-2.0",
"engines": {
"node": ">= 20"
},
"optionalDependencies": {
"impit-darwin-arm64": "0.14.4",
"impit-darwin-x64": "0.14.4",
"impit-linux-arm64-gnu": "0.14.4",
"impit-linux-arm64-musl": "0.14.4",
"impit-linux-x64-gnu": "0.14.4",
"impit-linux-x64-musl": "0.14.4",
"impit-win32-arm64-msvc": "0.14.4",
"impit-win32-x64-msvc": "0.14.4"
}
},
"node_modules/impit-darwin-arm64": {
"version": "0.14.4",
"resolved": "https://registry.npmjs.org/impit-darwin-arm64/-/impit-darwin-arm64-0.14.4.tgz",
"integrity": "sha512-i9fkPXXCB/1qBTGIlS26Z2krmE0wk/1UUlDUFeE1bMtihoUIe8TbeslSnx6pgaSY5ai9H0seUAgS9D4i66Kscg==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/impit-darwin-x64": {
"version": "0.14.4",
"resolved": "https://registry.npmjs.org/impit-darwin-x64/-/impit-darwin-x64-0.14.4.tgz",
"integrity": "sha512-p+W0enCZDCsnSwEWOMImpHq71jmVR7J9CjYvqgiFtQD3W+rQb7ilFLYYRalQ0rmrrH8imLRm2skG6V7YQkPwOg==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/impit-linux-arm64-gnu": {
"version": "0.14.4",
"resolved": "https://registry.npmjs.org/impit-linux-arm64-gnu/-/impit-linux-arm64-gnu-0.14.4.tgz",
"integrity": "sha512-9VVb905sNSPh0dF2sDFbbjSz5JqXCoMPxTery77PyqMCEuxybtW+Yv3U1tCM+CFKSuC39wXmueW+FLwOCS+zIw==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/impit-linux-arm64-musl": {
"version": "0.14.4",
"resolved": "https://registry.npmjs.org/impit-linux-arm64-musl/-/impit-linux-arm64-musl-0.14.4.tgz",
"integrity": "sha512-PajwrCPCWPey4wjLSWSVFS/mRQXbh8WVrxXdgo6Ej530dyplxIiqcDh0Gr9fWYPriOC8pkcOpRqQ4O+1mH1xfQ==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/impit-linux-x64-gnu": {
"version": "0.14.4",
"resolved": "https://registry.npmjs.org/impit-linux-x64-gnu/-/impit-linux-x64-gnu-0.14.4.tgz",
"integrity": "sha512-Ly8CweOZ9Qj6BZNhvglJMo56i7tCF7Nw33OdXLZLZRxnBzxd2kI/LXpFsd1LiMkTDaiuLvqLGo7ZpxVt08LdwA==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/impit-linux-x64-musl": {
"version": "0.14.4",
"resolved": "https://registry.npmjs.org/impit-linux-x64-musl/-/impit-linux-x64-musl-0.14.4.tgz",
"integrity": "sha512-dS9wj2phkB+o8OFdWBIFYx23ExH+pcsFMiXmoOwRVdDfUX9reZTmZL4KaiPuhSEBysN/wd46dxB/OzNbnn5hnQ==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/impit-win32-arm64-msvc": {
"version": "0.14.4",
"resolved": "https://registry.npmjs.org/impit-win32-arm64-msvc/-/impit-win32-arm64-msvc-0.14.4.tgz",
"integrity": "sha512-Nga0z+Hmp/HmxEKX0jv1wy2vaT4J44nE6+Z+CNZvvyVWiodWCG9+FvVn4McivgXix7UerspeyPvc174QBHwXEw==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/impit-win32-x64-msvc": {
"version": "0.14.4",
"resolved": "https://registry.npmjs.org/impit-win32-x64-msvc/-/impit-win32-x64-msvc-0.14.4.tgz",
"integrity": "sha512-v15fn/kcjLEzALB4z+VWTeG1UrtqZQq9n2HNsRUfUgfcgzM1i+KKtKP2RCJ1Fg1YeojQ7B6XGhRtpVVTjh8u+A==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/ip-address": {
"version": "10.4.0",
"license": "MIT",
+3 -2
View File
@@ -1,7 +1,7 @@
{
"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.",
"version": "2.0.0",
"description": "Watches district.in and BookMyShow showtimes and sends a phone notification the moment tickets for a specific movie, date, format and cinema go on sale.",
"license": "MIT",
"type": "module",
"engines": {
@@ -14,6 +14,7 @@
},
"dependencies": {
"fetch-socks": "^1.3.3",
"impit": "^0.14.4",
"undici": "^8.10.0"
}
}
+17
View File
@@ -0,0 +1,17 @@
# Config used only by the CI smoke test: proves both providers load, parse config and
# complete a real check inside the image. Alerts go nowhere real.
TARGET_DATE=2030-01-01
NTFY_TOPIC=ci-smoke-test-does-not-exist-000
NTFY_URL=http://127.0.0.1:9
HEARTBEAT_HOUR=-1
STATE_PATH=/tmp/smoke-state.json
DISTRICT_MOVIE_CODE=y7EZNLXN5Y
DISTRICT_CONTENT_ID=187151
DISTRICT_CITY_KEY=ahmedabad
DISTRICT_LAT=23.033938956712916
DISTRICT_LNG=72.57696301365426
BMS_EVENT_CODE=ET00514163
BMS_REGION_CODE=AHD
BMS_REGION_SLUG=ahmedabad
BMS_LAT=23.0395677
BMS_LNG=72.5660045
+88 -21
View File
@@ -1,5 +1,12 @@
// 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
// Configuration comes 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
import * as district from "./providers/district.js";
import * as bookmyshow from "./providers/bookmyshow.js";
const MODULES = { district, bookmyshow };
const env = (name, fallback = "") => process.env[name] ?? fallback;
function required(name) {
const v = process.env[name];
@@ -15,6 +22,55 @@ const num = (name, fallback) => {
return n;
};
/** Old single-provider variable names, still honoured so v1 .env files keep working. */
function legacyDistrict() {
if (!process.env.MOVIE_CODE || process.env.DISTRICT_MOVIE_CODE) return null;
return {
movieCode: env("MOVIE_CODE"),
contentId: env("CONTENT_ID"),
cityKey: env("CITY_KEY"),
lat: env("LAT"),
lng: env("LNG"),
venueId: env("CINEMA_ID"),
formatTag: process.env.FORMAT_TAG ?? "imax_2d",
formatMatch: process.env.FORMAT_MATCH ?? "IMAX",
bookingUrl: env("BOOKING_URL"),
};
}
function districtConfig() {
const legacy = legacyDistrict();
if (legacy) return legacy;
if (!process.env.DISTRICT_MOVIE_CODE) return null;
return {
movieCode: required("DISTRICT_MOVIE_CODE"),
contentId: required("DISTRICT_CONTENT_ID"),
cityKey: required("DISTRICT_CITY_KEY"),
lat: required("DISTRICT_LAT"),
lng: required("DISTRICT_LNG"),
venueId: env("DISTRICT_CINEMA_ID"),
formatTag: process.env.DISTRICT_FORMAT_TAG ?? "imax_2d",
formatMatch: process.env.DISTRICT_FORMAT_MATCH ?? "IMAX",
bookingUrl: env("DISTRICT_BOOKING_URL"),
};
}
function bookmyshowConfig() {
if (!process.env.BMS_EVENT_CODE) return null;
return {
eventCode: required("BMS_EVENT_CODE"),
refEventCode: env("BMS_REF_EVENT_CODE"),
regionCode: required("BMS_REGION_CODE"),
regionSlug: env("BMS_REGION_SLUG", "").toLowerCase(),
lat: env("BMS_LAT", "0"),
lng: env("BMS_LNG", "0"),
venueId: env("BMS_VENUE_CODE"),
language: env("BMS_LANGUAGE"),
formatMatch: process.env.BMS_FORMAT_MATCH ?? "IMAX",
bookingUrl: env("BMS_BOOKING_URL"),
};
}
export function loadConfig() {
const targetDate = required("TARGET_DATE");
if (!/^\d{4}-\d{2}-\d{2}$/.test(targetDate)) {
@@ -26,37 +82,48 @@ export function loadConfig() {
const expiryUtc = process.env.EXPIRY_UTC ||
new Date(Date.parse(`${targetDate}T00:00:00Z`) + 86400000 - tzOffsetMinutes * 60000).toISOString();
const built = { district: districtConfig(), bookmyshow: bookmyshowConfig() };
// PROVIDERS is an explicit allow-list; without it, every configured provider runs.
const wanted = env("PROVIDERS").split(",").map((s) => s.trim()).filter(Boolean);
for (const w of wanted) {
if (!MODULES[w]) throw new Error(`Unknown provider "${w}" in PROVIDERS. Valid: ${Object.keys(MODULES).join(", ")}`);
}
const keys = (wanted.length ? wanted : Object.keys(built)).filter((k) => built[k]);
if (!keys.length) {
throw new Error("No provider configured. Set DISTRICT_MOVIE_CODE and/or BMS_EVENT_CODE (see .env.example)");
}
for (const w of wanted) {
if (!built[w]) throw new Error(`PROVIDERS lists "${w}" but its settings are missing (see .env.example)`);
}
const providers = keys.map((k) => ({
key: k,
label: MODULES[k].label,
module: MODULES[k],
...built[k],
}));
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 || "",
providers,
formatLabel: env("FORMAT_LABEL", "IMAX"),
// Used by alerts that are not tied to one provider (heartbeat, retirement).
bookingUrl: providers[0].bookingUrl || "",
// notifications
ntfyUrl: (process.env.NTFY_URL || "https://ntfy.sh").replace(/\/+$/, ""),
ntfyUrl: env("NTFY_URL", "https://ntfy.sh").replace(/\/+$/, ""),
ntfyTopic: required("NTFY_TOPIC"),
ntfyToken: process.env.NTFY_TOKEN || "",
ntfyToken: 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 || "₹",
currency: env("CURRENCY", "₹"),
expiryUtc,
// runtime
statePath: process.env.STATE_PATH || "./state.json",
statePath: env("STATE_PATH", "./state.json"),
port: num("PORT", 4733),
proxyUrl: process.env.PROXY_URL || "",
proxyUrl: env("PROXY_URL"),
dispatcher: null, // set by attachProxy() when PROXY_URL is present
};
}
+92 -57
View File
@@ -1,18 +1,20 @@
#!/usr/bin/env node
// Entry point. Runs one check (default) or loops forever with a status server (--loop).
// Entry point. Runs one check across every configured provider (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 { localDate } from "./lib.js";
import {
decide, defaultState, extractSessions, checkDistrict, notify, heartbeatDue, localDate,
decideProvider, decideGlobal, defaultState, providerState, heartbeatDue, notify,
} from "./monitor.js";
const ts = () => new Date().toISOString();
// Last tick's outcome, surfaced by the status server. Persisted state records only
// Last tick per provider, 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 };
const lastTick = {};
function readState(cfg) {
try {
@@ -30,9 +32,10 @@ function writeState(cfg, state) {
}
/**
* 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.
* Routes provider requests through a SOCKS5 proxy. Notifications stay direct, so the
* alerting path never depends on the proxy. District needs this on datacenter hosts whose
* IP the WAF blocks. It does nothing for BookMyShow, which blocks on TLS fingerprint
* rather than IP; impit handles that instead.
*/
async function attachProxy(cfg) {
if (!cfg.proxyUrl) return;
@@ -51,49 +54,81 @@ async function attachProxy(cfg) {
console.log(ts(), `checks routed via SOCKS5 ${u.hostname}:${u.port}`);
}
/** Check one provider and return its alerts plus the next sub-state. */
async function tickProvider(cfg, p, state, now) {
const pstate = providerState(state, p.key);
// Best-effort daily probe, only from providers that support it and only on the
// heartbeat tick. Its failure never counts toward the failure streak.
let probeMax = null;
if (p.module.probeShowDates && heartbeatDue(state, now, cfg) && !pstate.brokenAlerted) {
try {
probeMax = await p.module.probeShowDates(cfg, p, localDate(now, cfg.tzOffsetMinutes));
} catch (err) {
console.error(ts(), `[${p.key}] showDates probe failed (best-effort):`, err.message);
}
}
let input;
try {
const res = await p.module.check(cfg, p, cfg.targetDate);
input = res.kind === "open"
? { kind: "open", extract: p.module.extract(res.data, cfg, p), now, probeMax }
: { kind: "closed", now, probeMax };
} catch (err) {
input = { kind: "error", reason: String(err.message ?? err), now, probeMax };
}
const { baseState, alerts } = decideProvider(pstate, input, cfg, p);
lastTick[p.key] = { at: ts(), kind: input.kind, reason: input.reason ?? null };
const matched = input.extract?.matched?.length ?? 0;
console.log(ts(), `[${p.key}] ${input.kind}${input.reason ? ` (${input.reason})` : ""}`,
`| matched: ${matched} | failures: ${baseState.failures}`);
return { pstate: baseState, alerts, input };
}
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);
}
}
const merged = structuredClone(state);
merged.providers ??= {};
const summaries = [];
const expired = now.getTime() >= Date.parse(cfg.expiryUtc);
const send = async (alert, apply) => {
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 };
await notify(cfg, alert);
apply();
console.log(ts(), "alert sent:", alert.title);
} catch (err) {
input = { kind: "error", reason: String(err.message ?? err), now, probeMax };
// Patch not applied, so the push is retried on the next tick.
console.error(ts(), `notify failed for "${alert.title}" (will retry):`, err.message);
}
};
if (!expired) {
for (const p of cfg.providers) {
const { pstate, alerts, input } = await tickProvider(cfg, p, state, now);
merged.providers[p.key] = pstate;
for (const alert of alerts) {
await send(alert, () => {
merged.providers[p.key] = { ...merged.providers[p.key], ...alert.statePatch };
});
}
summaries.push({
label: p.label,
kind: input.kind === "error" ? "checks failing" : merged.providers[p.key].lastGood?.kind,
showDatesMax: merged.providers[p.key].lastGood?.showDatesMax,
});
}
}
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);
}
const global = decideGlobal(state, cfg, now, summaries);
Object.assign(merged, global.baseState, { providers: merged.providers });
for (const alert of global.alerts) {
await send(alert, () => Object.assign(merged, alert.statePatch));
}
if (JSON.stringify(merged) !== JSON.stringify(state)) {
@@ -104,12 +139,7 @@ export async function tick(cfg) {
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;
return merged.retired ? "retired" : "ok";
}
function startStatusServer(cfg) {
@@ -125,6 +155,7 @@ function startStatusServer(cfg) {
const out = {
now: ts(),
targetDate: cfg.targetDate,
providers: cfg.providers.map((p) => p.key),
proxied: Boolean(cfg.dispatcher),
retired: state.retired,
lastTick,
@@ -132,13 +163,16 @@ function startStatusServer(cfg) {
};
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) };
out.live = {};
for (const p of cfg.providers) {
try {
const r = await p.module.check(cfg, p, cfg.targetDate);
out.live[p.key] = r.kind === "open"
? { kind: "open", ...p.module.extract(r.data, cfg, p) }
: { kind: "closed" };
} catch (err) {
out.live[p.key] = { kind: "error", reason: String(err.message ?? err) };
}
}
}
send(200, out);
@@ -153,12 +187,13 @@ await attachProxy(cfg);
if (process.argv.includes("--loop")) {
startStatusServer(cfg);
console.log(ts(), `watching ${cfg.targetDate} every ${cfg.intervalMs / 1000}s (expires ${cfg.expiryUtc})`);
console.log(ts(), `watching ${cfg.targetDate} on ${cfg.providers.map((p) => p.label).join(" + ")}`,
`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));
const r = await tick(cfg).catch((err) => { console.error(ts(), "tick crashed:", err); return "crash"; });
if (r === "retired") break;
await new Promise((r2) => setTimeout(r2, cfg.intervalMs));
}
} else {
await tick(cfg);
+29
View File
@@ -0,0 +1,29 @@
// Small shared helpers used by providers and the alert logic.
export class CheckError extends Error {}
/**
* Providers report showtimes in local wall-clock terms. District returns UTC
* timestamps, so they get shifted; BookMyShow already returns local strings.
*/
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);
}
export function localMinutes(now, tzOffsetMinutes) {
const d = new Date(now.getTime() + tzOffsetMinutes * 60000);
return d.getUTCHours() * 60 + d.getUTCMinutes();
}
+118 -235
View File
@@ -1,146 +1,148 @@
// Core logic: District API client, session matching, alert state machine, ntfy publisher.
// Everything here is runtime-agnostic and side-effect-free except checkDistrict/notify.
// Provider-agnostic alert logic: what to notify about, once, and when to stay quiet.
// Providers supply normalized sessions; nothing here knows which site they came from.
import { localDate, localMinutes } from "./lib.js";
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}`;
export function defaultProviderState() {
return { dateOpenAlerted: false, sidsAlerted: {}, failures: 0, brokenAlerted: false, lastGood: null };
}
/** 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}`;
export function defaultState() {
return { hbDate: "", retired: false, providers: {} };
}
/** YYYY-MM-DD in the configured timezone. */
export function localDate(now, tzOffsetMinutes) {
return new Date(now.getTime() + tzOffsetMinutes * 60000).toISOString().slice(0, 10);
/** Provider sub-state, defaulted, so older state files keep working. */
export function providerState(state, key) {
return { ...defaultProviderState(), ...((state.providers ?? {})[key] ?? {}) };
}
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(" · "),
};
function sessionLine(s) {
const head = [s.time, s.cinema, s.audi || s.format, s.avail != null ? `seats ${s.avail}/${s.total}` : null]
.filter(Boolean).join(" | ");
return s.tiers ? `${head}\n${s.tiers}` : head;
}
/**
* 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)
* Decide what to alert for ONE provider on one tick.
* input: { kind: "closed"|"open"|"error", extract?, reason?, now, probeMax? }
* Returns { baseState, alerts } for that provider's sub-state. Each alert carries its own
* statePatch, which the caller merges only after that alert's push succeeds, so a failed
* notification retries next tick instead of being recorded as delivered.
*/
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));
export function decideProvider(pstate, input, cfg, p) {
const base = structuredClone(pstate);
const alerts = [];
const now = input.now;
const tag = (a) => ({ ...a, provider: p.key });
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((pstate.failures ?? 0) + 1, cfg.failureThreshold);
if (base.failures >= cfg.failureThreshold && !pstate.brokenAlerted) {
alerts.push(tag({
title: `${p.label} checks BROKEN - check manually`,
priority: "4",
tags: "warning",
click: p.bookingUrl,
body: `${cfg.failureThreshold} consecutive failures against ${p.label} (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 };
}
base.failures = 0;
if (pstate.brokenAlerted) {
alerts.push(tag({
title: `${p.label} checks recovered`,
priority: "3",
tags: "white_check_mark",
click: p.bookingUrl,
body: `${p.label} 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) => !pstate.sidsAlerted?.[s.sid]);
if (fresh.length > 0) {
const lines = fresh.map(sessionLine);
for (const s of ex.other) lines.push(`Also at ${s.cinema}: ${s.time}`);
const sidsAlerted = { ...pstate.sidsAlerted };
for (const s of fresh) sidsAlerted[s.sid] = true;
alerts.push(tag({
title: `${cfg.formatLabel} ${cfg.targetDate} OPEN on ${p.label} - BOOK NOW`,
priority: "5",
tags: "rotating_light,movie_camera",
click: p.bookingUrl,
body: lines.join("\n\n"),
statePatch: { sidsAlerted, dateOpenAlerted: true },
}));
} else if (!pstate.dateOpenAlerted && ex.matched.length === 0) {
alerts.push(tag({
title: `${cfg.targetDate} is OPEN on ${p.label} (no ${cfg.formatLabel} yet)`,
priority: "4",
tags: "eyes",
click: p.bookingUrl,
body: `${p.label} is listing ${cfg.targetDate} at ${ex.cinemaCount} cinemas, but nothing matching "${cfg.formatLabel}" so far. You'll get a max-priority push the moment it appears.${ex.showDates.length ? ` Open dates: ${ex.showDates.join(", ")}.` : ""}`,
statePatch: { dateOpenAlerted: true },
}));
}
}
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}`;
return { baseState: base, alerts };
}
/**
* 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.
* Tick-level decisions that are not per-provider: retirement and the daily heartbeat.
* summaries: [{ label, kind, showDatesMax }] describing each provider this tick.
*/
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 } : {}),
export function decideGlobal(state, cfg, now, summaries) {
const base = structuredClone(state);
const alerts = [];
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 },
});
} catch (err) {
throw new CheckError(`fetch failed: ${err.message}`);
return { baseState: base, alerts };
}
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}`);
}
if (heartbeatDue(state, now, cfg)) {
const lines = summaries.map((s) =>
`${s.label}: ${s.kind ?? "unknown"}${s.showDatesMax ? ` (open through ${s.showDatesMax})` : ""}`);
alerts.push({
title: `Still watching ${cfg.targetDate}`,
priority: "1",
tags: "hourglass",
click: cfg.bookingUrl,
body: `Monitor healthy.\n${lines.join("\n")}`,
statePatch: { hbDate: localDate(now, cfg.tzOffsetMinutes) },
});
}
throw new CheckError(`HTTP ${res.status}`);
return { baseState: base, alerts };
}
export async function notify(cfg, alert) {
@@ -161,122 +163,3 @@ export async function notify(cfg, alert) {
});
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 };
}
+145
View File
@@ -0,0 +1,145 @@
// BookMyShow (in.bookmyshow.com) provider.
//
// BMS sits behind Cloudflare bot management that rejects Node's fetch and curl with 403
// regardless of headers or cookies: the block is on the TLS/HTTP2 fingerprint. `impit`
// performs the request with a real Chrome fingerprint, which is why it is a dependency.
//
// Unlike District there is no 204. A date that is not on sale yet answers 200 with a
// short body that simply has no `showtimeWidgets` key, so "closed" is inferred from the
// payload rather than the status code.
import { CheckError } from "../lib.js";
export const key = "bookmyshow";
export const label = "BookMyShow";
let Impit = null;
let client = null;
async function getClient(cfg) {
if (client) return client;
if (!Impit) ({ Impit } = await import("impit"));
client = new Impit({
browser: "chrome",
...(cfg.proxyUrl ? { proxyUrl: cfg.proxyUrl } : {}),
});
return client;
}
/** BMS dates are YYYYMMDD; the rest of the app speaks YYYY-MM-DD. */
export const toDateCode = (isoDate) => isoDate.replaceAll("-", "");
function apiUrl(p, date) {
const q = new URLSearchParams({
etCodes: p.eventCode,
dateCode: toDateCode(date),
isDesktop: "true",
regionCode: p.regionCode,
xLocationShared: "false",
memberId: "", lsId: "", subCode: "",
appCode: "WEB",
refEventCode: p.refEventCode || p.eventCode,
...(p.language ? { language: p.language } : {}),
});
return `https://in.bookmyshow.com/api/movies-data/v5/showtimes-by-event/primary-dynamic?${q}`;
}
/** Generated by the BMS web client itself, not issued by the server. */
const bmsId = () => `1.${Math.floor(Math.random() * 9e8 + 1e8)}.${Date.now()}`;
export async function check(cfg, p, date) {
const impit = await getClient(cfg);
let res, text;
try {
res = await impit.fetch(apiUrl(p, date), {
headers: {
accept: "application/json, text/plain, */*",
"x-region-code": p.regionCode,
"x-region-slug": p.regionSlug,
"x-latitude": p.lat,
"x-longitude": p.lng,
"x-bms-id": bmsId(),
"x-app-code": "WEB",
"x-platform-code": "WEB",
"x-platform": "WEB",
},
});
text = await res.text();
} catch (err) {
throw new CheckError(`fetch failed: ${err.message}`);
}
if (res.status !== 200) throw new CheckError(`HTTP ${res.status}`);
let data;
try {
data = JSON.parse(text);
} catch (err) {
throw new CheckError(`unparseable body: ${err.message}`);
}
// No showtime widgets at all means the date has not gone on sale.
if (!data?.data?.showtimeWidgets) return { kind: "closed" };
return { kind: "open", data };
}
/** Venue cards sit several widget layers deep, so walk rather than index blindly. */
function collectVenueCards(node, out = []) {
if (Array.isArray(node)) {
for (const n of node) collectVenueCards(n, out);
} else if (node && typeof node === "object") {
if (node.type === "venue-card") out.push(node);
for (const v of Object.values(node)) collectVenueCards(v, out);
}
return out;
}
/** Seat tiers live in the double-tap bottom sheet: type, price and an availability word. */
function tiersOf(showtime, cfg) {
const widgets = showtime?.customGestureCTA?.additionalData?.bottomSheetData?.widgets ?? [];
return widgets
.filter((w) => w?.variableData?.seatType)
.map((w) => {
const v = w.variableData;
const cost = String(v.seatCost || "").replace(/[^\d.]/g, "");
const price = cost ? `${cfg.currency}${Math.round(Number(cost))}` : "";
return `${v.seatType} ${price} (${v.seatAvalibility || ""})`.replace(/\s+/g, " ").trim();
})
.join(" · ");
}
function formatOf(showtime) {
return String(showtime.screenAttr || showtime?.additionalData?.attributes || "");
}
function isMatch(showtime, p) {
if (!p.formatMatch) return true;
return formatOf(showtime).toUpperCase().includes(p.formatMatch.toUpperCase());
}
export function extract(data, cfg, p) {
const cards = collectVenueCards(data?.data?.showtimeWidgets ?? []);
const out = { matched: [], other: [], cinemaCount: cards.length, sessionCount: 0, showDates: [] };
for (const card of cards) {
const info = card.additionalData ?? {};
const name = info.venueName || info.venueCode || "unknown venue";
for (const section of card.showtimesSections ?? []) {
for (const st of section.showtimes ?? []) {
out.sessionCount++;
if (!isMatch(st, p)) continue;
const ad = st.additionalData ?? {};
const session = {
sid: String(ad.sessionId || `${info.venueCode}-${ad.showTimeCode || st.title}`),
cinema: name,
// BMS already returns local wall-clock time, so no timezone shift is applied.
time: ad.showTime || st.title || "",
audi: "",
format: formatOf(st),
// BMS exposes availability words per tier, never exact seat counts.
avail: null,
total: null,
tiers: tiersOf(st, cfg),
};
const wanted = !p.venueId || String(info.venueCode) === String(p.venueId);
(wanted ? out.matched : out.other).push(session);
}
}
}
return out;
}
+112
View File
@@ -0,0 +1,112 @@
// District (district.in) provider.
// The showtimes endpoint answers 204 while a date is not on sale yet, and 200 with the
// full session list once it opens. Plain fetch works, so this provider needs no special
// HTTP client, and it honours a SOCKS5 dispatcher when one is configured.
import { CheckError, toLocalTime } from "../lib.js";
export const key = "district";
export const label = "District";
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";
/** Generated client-side by District's own web app, not issued by the server. */
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}`;
}
function apiUrl(p, date) {
const q = new URLSearchParams({
version: "3", site_id: "1", channel: "mweb", child_site_id: "1", platform: "district",
movieCode: p.movieCode, city_key: p.cityKey, content_id: p.contentId,
date, latitude: p.lat, longitude: p.lng, cinemaOrderLogic: "3",
});
return `https://www.district.in/gw/consumer/movies/v5/movie?${q}`;
}
/** @returns {{kind:"closed"}|{kind:"open",data:object}} */
export async function check(cfg, p, date) {
let res;
try {
res = await fetch(apiUrl(p, date), {
headers: {
accept: "*/*",
api_source: "district",
"x-app-type": "ed_mweb",
"x-guest-token": guestToken(),
"x-request-id": crypto.randomUUID(),
"user-agent": UA,
...(p.bookingUrl ? { referer: p.bookingUrl } : {}),
},
signal: AbortSignal.timeout(15000),
...(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}`);
}
function isMatch(sess, p) {
if (!p.formatTag && !p.formatMatch) return true;
return (p.formatTag && (sess.tags || []).includes(p.formatTag)) ||
(p.formatMatch && String(sess.scrnFmt || "").toUpperCase().includes(p.formatMatch.toUpperCase()));
}
export function extract(data, cfg, p) {
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, p)) continue;
const session = {
sid: String(s.sid),
cinema: name,
time: toLocalTime(s.showTime, cfg.tzOffsetMinutes),
audi: s.audi || "",
format: s.scrnFmt || "",
avail: s.avail,
total: s.total,
tiers: (s.areas || [])
.map((a) => `${a.label} ${cfg.currency}${Math.round(a.price)} (${a.sAvail}/${a.sTotal})`)
.join(" · "),
};
const wanted = !p.venueId || String(c.id) === String(p.venueId);
(wanted ? out.matched : out.other).push(session);
}
}
return out;
}
/**
* Optional capability: how far ahead bookings currently run. `meta.showDates` only
* appears in a 200 body, and the target date returns an empty 204 while closed, so this
* asks about today instead. Best effort; the caller ignores failures.
*/
export async function probeShowDates(cfg, p, todayIso) {
const res = await check(cfg, p, todayIso);
if (res.kind !== "open") return null;
const dates = res.data?.meta?.showDates ?? [];
return dates.length ? dates.slice().sort().at(-1) : null;
}
+1
View File
@@ -0,0 +1 @@
{"data":{"header":{"title":{"styleId":"header-title-v2","text":"Avengers Endgame: Encore"},"subtitle":{"styleId":"screen-subtitle","text":"Movie runtime: 3h 5m"},"startActions":[{"iconUrl":"https://assets-in.bmscdn.com/moviesmaster/movies-showtimes/v4/common/left-chevron.png","cta":{"type":"closeScreen","analytics":{"event_action":"back","event_name":"showtime_actions","event_type":"click"}}}],"endActions":[{"iconUrl":"https://assets-in.bmscdn.com/moviesmaster/movies-showtimes/v4/common/search.png","cta":{"type":"searchView","analytics":{"event_action":"search","event_name":"showtime_actions","event_type":"click"}}}],"backgroundColor":"#FFFFFF","searchBackgroundColor":"#FFFFFF","searchForegroundColor":"#000000","additionalData":{"genre":"Action|Adventure|Fantasy"}},"additionalData":{},"addOnWidgets":{}},"metadata":{"error":{"title":"Uh-oh! We couldn't find anything.","message":"There are no shows available in this region.","errorCode":"1a.1b.2001","imageUrl":"https://assets-in.bmscdn.com/moviesmaster/movies-showtimes/v4/errors/no-showtimes-found-eventcode.png","action":{"label":"Change Location","backgroundColor":"#EB4E62","textColor":"#FFFFFF","cta":{"type":"changeLocation"}}}}}
+1
View File
@@ -0,0 +1 @@
{"data":{"meta":{"version":23},"header":{"title":{"styleId":"header-title-v2","text":"Toxic: A Fairy Tale for Grown-ups"},"subtitle":{"styleId":"screen-subtitle","text":"Movie runtime: 3h 14m"},"startActions":[{"iconUrl":"https://assets-in.bmscdn.com/moviesmaster/movies-showtimes/v4/common/left-chevron.png","cta":{"type":"closeScreen","analytics":{"event_action":"back","event_name":"showtime_actions","event_type":"click"}}}],"endActions":[{"iconUrl":"https://assets-in.bmscdn.com/moviesmaster/movies-showtimes/v4/common/search.png","cta":{"type":"searchView","analytics":{"event_action":"search","event_name":"showtime_actions","event_type":"click"}}},{"iconUrl":"https://assets-in.bmscdn.com/moviesmaster/movies-showtimes/v4/common/filter.png","cta":{"type":"bottomsheet","id":"filter","analytics":{"event_action":"filter_icon","event_name":"showtime_actions","event_type":"click"}}}],"backgroundColor":"#FFFFFF","searchBackgroundColor":"#FFFFFF","searchForegroundColor":"#000000","additionalData":{"eventCensor":"A","genre":"Action|Crime|Period|Thriller"}},"bottomSheetData":{"filter":{"type":"filter","widgets":[{"type":"vertical-text-list","data":[{"text":"Filters","styleId":"bottomsheet-title"}]},{"type":"chip-list","text":"Price Range","data":[{"type":"chip","title":"₹0 - ₹500","cta":{"type":"selectFilter","analytics":{"category":"price","event_action":"bottom_filter","event_name":"showtime_actions","event_type":"click","label":"0-500"},"additionalData":{"filterID":"pf5","filterLevel":"both","categoryId":"price"}}},{"type":"chip","title":"₹501 - ₹600","cta":{"type":"selectFilter","analytics":{"category":"price","event_action":"bottom_filter","event_name":"showtime_actions","event_type":"click","label":"501-600"},"additionalData":{"filterID":"pf6","filterLevel":"both","categoryId":"price"}}},{"type":"chip","title":"₹601 - ₹700","cta":{"type":"selectFilter","analytics":{"category":"price","event_action":"bottom_filter","event_name":"showtime_actions","event_type":"click","label":"601-700"},"additionalData":{"filterID":"pf7","filterLevel":"both","categoryId":"price"}}},{"type":"chip","title":"₹701 - ₹800","cta":{"type":"selectFilter","analytics":{"category":"price","event_action":"bottom_filter","event_name":"showtime_actions","event_type":"click","label":"701-800"},"additionalData":{"filterID":"pf8","filterLevel":"both","categoryId":"price"}}},{"type":"chip","title":"₹801 - ₹900","cta":{"type":"selectFilter","analytics":{"category":"price","event_action":"bottom_filter","event_name":"showtime_actions","event_type":"click","label":"801-900"},"additionalData":{"filterID":"pf9","filterLevel":"both","categoryId":"price"}}},{"type":"chip","title":"₹901 - ₹1000","cta":{"type":"selectFilter","analytics":{"category":"price","event_action":"bottom_filter","event_name":"showtime_actions","event_type":"click","label":"901-1000"},"additionalData":{"filterID":"pf91","filterLevel":"both","categoryId":"price"}}},{"type":"chip","title":"₹1001 - ₹1200","cta":{"type":"selectFilter","analytics":{"category":"price","event_action":"bottom_filter","event_name":"showtime_actions","event_type":"click","label":"1001-1200"},"additionalData":{"filterID":"pf93","filterLevel":"both","categoryId":"price"}}}]},{"type":"chip-list","text":"Special Formats","secondaryText":"No special formats available in your region"},{"type":"chip-list","text":"Other Filters","data":[{"type":"chip","title":"Cancellable","cta":{"type":"selectFilter","analytics":{"category":"cancel","event_action":"bottom_filter","event_name":"showtime_actions","event_type":"click","label":"Cancellable"},"additionalData":{"filterID":"cancellation","filterLevel":"parent","categoryId":"cancellation"}}}]},{"type":"multi-selector","text":"Preferred Time","data":[{"title":{"styleId":"filter-title","text":"Morning"},"subtitle":{"styleId":"filter-subtitle","text":"12:00 AM - 11:59 AM"},"iconUrl":"https://assets-in.bmscdn.com/moviesmaster/movies-showtimes/v4/common/morning.png","cta":{"type":"selectFilter","analytics":{"category":"time","event_action":"bottom_filter","event_name":"showtime_actions","event_type":"click","label":"morning"},"additionalData":{"filterID":"tf1","filterLevel":"both","categoryId":"time"}}},{"title":{"styleId":"filter-title","text":"Afternoon"},"subtitle":{"styleId":"filter-subtitle","text":"12:00 PM - 3:59 PM"},"iconUrl":"https://assets-in.bmscdn.com/moviesmaster/movies-showtimes/v4/common/afternoon.png","cta":{"type":"selectFilter","analytics":{"category":"time","event_action":"bottom_filter","event_name":"showtime_actions","event_type":"click","label":"afternoon"},"additionalData":{"filterID":"tf2","filterLevel":"both","categoryId":"time"}}},{"title":{"styleId":"filter-title-disabled","text":"Evening"},"subtitle":{"styleId":"filter-subtitle-disabled","text":"4:00 PM - 6:59 PM"},"iconUrl":"https://assets-in.bmscdn.com/moviesmaster/movies-showtimes/v4/common/evening-disabled.png"},{"title":{"styleId":"filter-title","text":"Night"},"subtiLine truncated
+101 -123
View File
@@ -1,165 +1,129 @@
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";
import {
decideProvider, decideGlobal, defaultProviderState, defaultState, providerState, heartbeatDue,
} 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",
targetDate: "2026-09-25",
expiryUtc: "2026-09-25T18:30:00Z",
bookingUrl: "https://example.com/book",
failureThreshold: 6,
tzOffsetMinutes: 330,
heartbeatHour: 9,
formatTag: "imax_2d",
formatMatch: "IMAX",
formatLabel: "IMAX",
cinemaId: "1032362",
currency: "₹",
};
const P = { key: "district", label: "District", bookingUrl: "https://example.com/d" };
const P2 = { key: "bookmyshow", label: "BookMyShow", bookingUrl: "https://example.com/b" };
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 T0 = new Date("2026-09-20T02:00:00Z"); // before the 03:30 UTC (09:00 IST) heartbeat window
const HB = new Date("2026-09-20T03:31:00Z"); // inside it
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)" }],
matched: [{ sid: "111", cinema: "Test Cinema", time: "9:00 AM", audi: "SCREEN 9", format: "IMAX", 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) {
function apply(pstate, 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);
const s1 = apply(defaultProviderState(), decideProvider(defaultProviderState(), { kind: "closed", now: T0 }, CFG, P));
const d2 = decideProvider(s1, { kind: "closed", now: new Date(T0.getTime() + 60000) }, CFG, P);
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);
const d1 = decideProvider(defaultProviderState(), openInput(EX_MATCH), CFG, P);
assert.equal(d1.alerts.length, 1);
assert.equal(d1.alerts[0].priority, "5");
assert.equal(d1.alerts[0].provider, "district", "alerts carry their provider");
assert.ok(d1.alerts[0].title.includes("District"), "the site is named in the title");
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 s1 = apply(defaultProviderState(), d1);
assert.equal(decideProvider(s1, openInput(EX_MATCH), CFG, P).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);
const d3 = decideProvider(s1, openInput(withNew), CFG, P);
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", () => {
test("sessions without seat counts still render (BookMyShow shape)", () => {
const bmsLike = {
matched: [{ sid: "37929", cinema: "PVR: Palladium", time: "08:00 AM", audi: "", format: "IMAX", avail: null, total: null, tiers: "CLASSIC ₹470 (AVAILABLE)" }],
other: [], cinemaCount: 1, sessionCount: 5, showDates: [],
};
const d = decideProvider(defaultProviderState(), openInput(bmsLike), CFG, P2);
const body = d.alerts[0].body;
assert.ok(body.includes("08:00 AM | PVR: Palladium | IMAX"), body);
assert.ok(!body.includes("seats"), "no seat counts when the provider has none");
assert.ok(d.alerts[0].title.includes("BookMyShow"));
});
test("sessions at other venues 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)" }],
other: [{ sid: "999", cinema: "Other Cinema", time: "6:00 PM", audi: "", format: "IMAX", avail: null, total: null, tiers: "" }],
};
const d = decide(defaultState(), openInput(withOther), CFG);
const d = decideProvider(defaultProviderState(), openInput(withOther), CFG, P);
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);
test("date open without a match fires once; a later match still fires", () => {
const d1 = decideProvider(defaultProviderState(), openInput(EX_NO_MATCH), CFG, P);
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);
const s1 = apply(defaultProviderState(), d1);
assert.equal(decideProvider(s1, openInput(EX_NO_MATCH), CFG, P).alerts.length, 0);
const d3 = decideProvider(s1, openInput(EX_MATCH), CFG, P);
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);
test("failed match push then sessions vanish -> date-open alert still reachable", () => {
const d1 = decideProvider(defaultProviderState(), openInput(EX_MATCH), CFG, P);
const s1 = d1.baseState; // push failed: statePatch not merged
const d2 = decide(s1, openInput(EX_NO_MATCH), CFG);
const d2 = decideProvider(s1, openInput(EX_NO_MATCH), CFG, P);
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);
const d = decideProvider(defaultProviderState(), openInput(EX_EMPTY), CFG, P);
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();
test("BROKEN fires at the threshold, once, and names the site; RECOVERED on next success", () => {
let s = defaultProviderState();
for (let i = 1; i <= 5; i++) {
const d = decide(s, { kind: "error", reason: "HTTP 403", now: T0 }, CFG);
const d = decideProvider(s, { kind: "error", reason: "HTTP 403", now: T0 }, CFG, P2);
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);
const d6 = decideProvider(s, { kind: "error", reason: "HTTP 403", now: T0 }, CFG, P2);
assert.equal(d6.alerts.length, 1);
assert.ok(d6.alerts[0].title.includes("BookMyShow"));
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(decideProvider(s, { kind: "error", reason: "HTTP 403", now: T0 }, CFG, P2).alerts.length, 0, "not repeated");
const d8 = decideProvider(s, { kind: "closed", now: T0 }, CFG, P2);
assert.equal(d8.alerts.length, 1);
assert.ok(d8.alerts[0].title.includes("recovered"));
s = apply(s, d8);
@@ -167,48 +131,62 @@ test("BROKEN fires at the threshold, once; RECOVERED on the next success", () =>
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("providers keep independent dedupe state", () => {
const state = defaultState();
const dA = decideProvider(providerState(state, "district"), openInput(EX_MATCH), CFG, P);
state.providers.district = apply(providerState(state, "district"), dA);
// BookMyShow has never alerted, so the same session id must still notify there.
const dB = decideProvider(providerState(state, "bookmyshow"), openInput(EX_MATCH), CFG, P2);
assert.equal(dB.alerts.length, 1, "one site alerting must not silence the other");
assert.ok(dB.alerts[0].title.includes("BookMyShow"));
assert.equal(decideProvider(providerState(state, "district"), openInput(EX_MATCH), CFG, P).alerts.length, 0);
});
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("providerState defaults unknown providers and preserves stored ones", () => {
const state = { ...defaultState(), providers: { district: { failures: 3 } } };
assert.equal(providerState(state, "district").failures, 3);
assert.equal(providerState(state, "district").brokenAlerted, false, "missing fields are defaulted");
assert.deepEqual(providerState(state, "bookmyshow"), defaultProviderState());
});
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");
// ---------------------------------------------------------- global alerts
test("heartbeat fires once per local day, summarises every provider, and can be disabled", () => {
const summaries = [
{ label: "District", kind: "closed", showDatesMax: "2026-09-20" },
{ label: "BookMyShow", kind: "closed" },
];
assert.equal(decideGlobal(defaultState(), CFG, new Date("2026-09-20T03:29:00Z"), summaries).alerts.length, 0, "before the window");
const d = decideGlobal(defaultState(), CFG, HB, summaries);
assert.equal(d.alerts.length, 1);
assert.equal(d.alerts[0].priority, "1");
assert.ok(d.alerts[0].body.includes("District: closed (open through 2026-09-20)"));
assert.ok(d.alerts[0].body.includes("BookMyShow: closed"));
assert.equal(d.alerts[0].statePatch.hbDate, "2026-09-20");
const after = { ...defaultState(), hbDate: "2026-09-20" };
assert.equal(decideGlobal(after, CFG, new Date("2026-09-20T09:00:00Z"), summaries).alerts.length, 0, "once per day");
assert.equal(decideGlobal(defaultState(), { ...CFG, heartbeatHour: -1 }, HB, summaries).alerts.length, 0, "disabled");
});
test("heartbeatDue tracks the local day, so it is not fooled by UTC rollover", () => {
assert.equal(heartbeatDue({ hbDate: "" }, HB, CFG), true);
assert.equal(heartbeatDue({ hbDate: "2026-09-20" }, HB, CFG), false);
});
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);
const past = new Date("2026-09-25T18:31:00Z");
const d1 = decideGlobal(defaultState(), CFG, past, []);
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);
const s1 = { ...defaultState(), ...d1.alerts[0].statePatch };
assert.equal(decideGlobal(s1, CFG, past, []).alerts.length, 0);
});
test("retirement takes precedence over the heartbeat", () => {
const past = new Date("2026-09-26T03:31:00Z"); // after expiry and inside the heartbeat window
const d = decideGlobal(defaultState(), CFG, past, []);
assert.equal(d.alerts.length, 1);
assert.ok(d.alerts[0].title.includes("retired"));
});
+106
View File
@@ -0,0 +1,106 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { toLocalTime, localDate } from "../src/lib.js";
import * as district from "../src/providers/district.js";
import * as bms from "../src/providers/bookmyshow.js";
const load = (f) => JSON.parse(readFileSync(fileURLToPath(new URL(`./fixtures/${f}`, import.meta.url)), "utf8"));
const CFG = { tzOffsetMinutes: 330, currency: "₹" };
test("toLocalTime converts API UTC timestamps 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("localDate rolls over at local midnight, not UTC midnight", () => {
assert.equal(localDate(new Date("2026-08-05T19:00:00Z"), 330), "2026-08-06");
assert.equal(localDate(new Date("2026-08-05T17:00:00Z"), 330), "2026-08-05");
});
// ---------------------------------------------------------------- District
const D_PARAMS = { venueId: "1032362", formatTag: "imax_2d", formatMatch: "IMAX" };
test("district: extracts the target cinema's sessions from a real capture", () => {
const ex = district.extract(load("response-open-date.json"), CFG, D_PARAMS);
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.equal(s.avail, 103); // District reports exact seat counts
assert.ok(s.tiers.includes("CLASSIC ₹260"));
assert.ok(s.tiers.includes("RECLINER ₹640"));
assert.equal(ex.showDates.at(-1), "2026-08-09");
});
test("district: no venue watches every cinema, no format filter matches everything", () => {
const data = load("response-open-date.json");
const anyVenue = district.extract(data, CFG, { ...D_PARAMS, venueId: "" });
assert.equal(anyVenue.matched.length, 4, "still only IMAX");
assert.equal(anyVenue.other.length, 0);
const anyFormat = district.extract(data, CFG, { venueId: "", formatTag: "", formatMatch: "" });
assert.equal(anyFormat.matched.length, anyFormat.sessionCount);
const elsewhere = district.extract(data, CFG, { ...D_PARAMS, venueId: "125" });
assert.equal(elsewhere.matched.length, 0);
assert.equal(elsewhere.other.length, 4, "IMAX at another venue becomes a secondary line");
});
test("district: tolerates empty payloads", () => {
assert.deepEqual(district.extract({}, CFG, D_PARAMS),
{ matched: [], other: [], cinemaCount: 0, sessionCount: 0, showDates: [] });
});
// ------------------------------------------------------------- BookMyShow
const B_PARAMS = { venueId: "PPAM", formatMatch: "IMAX" };
test("bookmyshow: date codes drop the dashes", () => {
assert.equal(bms.toDateCode("2026-09-25"), "20260925");
});
test("bookmyshow: extracts venue sessions and per-tier availability from a real capture", () => {
const ex = bms.extract(load("bms-open-date.json"), CFG, B_PARAMS);
assert.equal(ex.cinemaCount, 1);
assert.equal(ex.sessionCount, 5);
assert.equal(ex.matched.length, 5);
const s = ex.matched[0];
assert.equal(s.sid, "37929");
assert.equal(s.cinema, "PVR: Palladium Mall, Ahmedabad");
assert.equal(s.time, "08:00 AM"); // already local, so it is passed through untouched
assert.equal(s.format, "IMAX");
assert.equal(s.avail, null, "BMS never exposes exact seat counts");
assert.ok(s.tiers.includes("RECLINER ₹960 (ALMOST FULL)"));
assert.ok(s.tiers.includes("CLASSIC ₹470 (AVAILABLE)"));
});
test("bookmyshow: a different venue code moves sessions to the secondary bucket", () => {
const ex = bms.extract(load("bms-open-date.json"), CFG, { ...B_PARAMS, venueId: "XXXX" });
assert.equal(ex.matched.length, 0);
assert.equal(ex.other.length, 5);
});
test("bookmyshow: a non-matching format filter yields nothing", () => {
const ex = bms.extract(load("bms-open-date.json"), CFG, { ...B_PARAMS, formatMatch: "4DX" });
assert.equal(ex.matched.length, 0);
assert.equal(ex.sessionCount, 5, "sessions are still counted, just not matched");
});
test("bookmyshow: a not-yet-on-sale response has no widgets and extracts to nothing", () => {
const closed = load("bms-closed-date.json");
assert.equal(closed.data.showtimeWidgets, undefined, "this is how 'not open yet' looks");
assert.deepEqual(bms.extract(closed, CFG, B_PARAMS),
{ matched: [], other: [], cinemaCount: 0, sessionCount: 0, showDates: [] });
});