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

+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