mirror of
https://github.com/aculix/ticket-monitoring.git
synced 2026-10-27 07:15:51 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a82c2b29af | ||
|
|
474415bb55 | ||
|
|
ef23f25ee5 | ||
|
|
6607a469a2 | ||
|
|
67e97f5a6a | ||
|
|
9442bca696 | ||
|
|
5f86252df1 | ||
|
|
608221fe18 |
No files matched your search
+45
-28
@@ -1,31 +1,13 @@
|
|||||||
# Copy to .env and fill in. Every value below is read from the environment,
|
# Copy to .env and fill in. Every value is read from the environment, so you can set
|
||||||
# so you can also set them directly (Docker, systemd, CI) instead of using a file.
|
# these directly (Docker, systemd, CI) instead of using a file.
|
||||||
# The example values watch IMAX shows of one film at one Ahmedabad cinema.
|
#
|
||||||
|
# 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 ─────────────────────────────────────────────────────────────
|
# ── Shared ────────────────────────────────────────────────────────────────────
|
||||||
# Open the film's page on district.in, then read these off the network request to
|
TARGET_DATE=2026-09-25 # the date you are waiting for (YYYY-MM-DD)
|
||||||
# /gw/consumer/movies/v5/movie (see "Finding your IDs" in the README).
|
|
||||||
MOVIE_CODE=y7EZNLXN5Y # ?frmtid= in the page URL — identifies movie + language
|
|
||||||
CONTENT_ID=187151 # MV<number> in the page URL
|
|
||||||
CITY_KEY=ahmedabad # city slug as it appears in the page URL
|
|
||||||
LAT=23.033938956712916 # city coordinates; the API rejects requests without them
|
|
||||||
LNG=72.57696301365426
|
|
||||||
TARGET_DATE=2026-08-10 # the date you are waiting for (YYYY-MM-DD)
|
|
||||||
|
|
||||||
# Restrict to one cinema. Leave empty to watch every cinema in the city.
|
|
||||||
CINEMA_ID=1032362
|
|
||||||
|
|
||||||
# Which shows count as a match. A session matches if it carries FORMAT_TAG *or* if its
|
|
||||||
# screen format contains FORMAT_MATCH (case-insensitive), so "IMAX" also catches
|
|
||||||
# "IMAX 3D". Set BOTH to empty to match every show regardless of format.
|
|
||||||
FORMAT_TAG=imax_2d
|
|
||||||
FORMAT_MATCH=IMAX
|
|
||||||
FORMAT_LABEL=IMAX # cosmetic: how the format is named in alerts
|
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
|
# 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.
|
# it can read your alerts and publish to you. Use a long random string.
|
||||||
NTFY_TOPIC=change-me-to-something-random
|
NTFY_TOPIC=change-me-to-something-random
|
||||||
@@ -35,10 +17,43 @@ NTFY_TOPIC=change-me-to-something-random
|
|||||||
NTFY_URL=https://ntfy.sh
|
NTFY_URL=https://ntfy.sh
|
||||||
# NTFY_TOKEN=tk_... # only if your server (or topic) requires auth
|
# NTFY_TOKEN=tk_... # only if your server (or topic) requires auth
|
||||||
|
|
||||||
|
# ── District (district.in) ────────────────────────────────────────────────────
|
||||||
|
# Leave DISTRICT_CONTENT_ID 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_CONTENT_ID=187151 # digits after MV; this is what enables District
|
||||||
|
DISTRICT_MOVIE_CODE=y7EZNLXN5Y # optional ?frmtid=, pins one language/format variant.
|
||||||
|
# Leave empty for a film with no showtimes yet, since
|
||||||
|
# District only publishes the code once shows exist.
|
||||||
|
DISTRICT_CITY_KEY=ahmedabad # city slug in the path
|
||||||
|
DISTRICT_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 ─────────────────────────────────────────────────────────────────
|
# ── Behaviour ─────────────────────────────────────────────────────────────────
|
||||||
CHECK_INTERVAL_SECONDS=60 # how often to poll; don't go lower without good reason
|
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
|
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
|
HEARTBEAT_HOUR=9 # daily "still alive" push at this local hour; -1 disables
|
||||||
CURRENCY=₹
|
CURRENCY=₹
|
||||||
# EXPIRY_UTC= # auto: local midnight after TARGET_DATE. Override to extend.
|
# EXPIRY_UTC= # auto: local midnight after TARGET_DATE. Override to extend.
|
||||||
@@ -47,6 +62,8 @@ CURRENCY=₹
|
|||||||
STATE_PATH=./state.json # Docker image defaults this to /data/state.json
|
STATE_PATH=./state.json # Docker image defaults this to /data/state.json
|
||||||
PORT=4733 # status endpoint (--loop mode only)
|
PORT=4733 # status endpoint (--loop mode only)
|
||||||
|
|
||||||
# Routes only the site checks through a SOCKS5 proxy; notifications stay direct.
|
# Routes both sites' checks through a SOCKS5 proxy; notifications stay direct.
|
||||||
# Needed on datacenter hosts, whose IPs the site's WAF is likely to block.
|
# Both District and BookMyShow block datacenter IPs, so a cloud host usually needs this.
|
||||||
|
# socks5:// is rewritten to socks5h:// internally, so the proxy resolves DNS. That is
|
||||||
|
# required for IPv6-only exits and harmless otherwise.
|
||||||
# PROXY_URL=socks5://user:pass@proxy.example.net:1080
|
# PROXY_URL=socks5://user:pass@proxy.example.net:1080
|
||||||
@@ -39,6 +39,26 @@ jobs:
|
|||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
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
|
- uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
|
|||||||
@@ -2,36 +2,43 @@
|
|||||||
|
|
||||||
[](https://github.com/aculix/ticket-monitoring/actions/workflows/docker.yml)
|
[](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
|
Watches [District](https://www.district.in) and [BookMyShow](https://in.bookmyshow.com)
|
||||||
phone the second tickets go on sale for the movie, date, format and cinema you care about.
|
showtimes and sends a max-priority push to your phone the second tickets go on sale for the
|
||||||
|
movie, date, format and cinema you care about.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
I built this because IMAX shows kept selling out before I even knew they'd opened. Refreshing the
|
I built this because IMAX shows kept selling out before I even knew they'd opened. Refreshing
|
||||||
page a few times a day wasn't working. Now something else checks every 60 seconds and yells at me
|
the page a few times a day wasn't working. Now something else checks every 60 seconds and yells
|
||||||
when it matters.
|
at me when it matters.
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
Every 60 seconds it calls the same showtimes endpoint the District site uses itself. A date that
|
Every 60 seconds it calls the same showtime APIs the two sites use themselves, and filters the
|
||||||
isn't on sale yet answers with `204 No Content`. The moment it opens, you get the full session
|
result down to your cinema and format. When something matches, it pushes to
|
||||||
list back. The monitor filters that list down to your cinema and format, then pushes to
|
|
||||||
[ntfy](https://ntfy.sh), a free open-source notification service with iOS and Android apps.
|
[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
|
Both sites can be watched at once, in one process. Each keeps its own dedupe and failure state,
|
||||||
*Monitor BROKEN* push after six failures in a row, plus an optional daily heartbeat. A monitor
|
so tickets appearing on one site never suppress the alert for the other, and one site going down
|
||||||
that died at 3am and one that's quietly working should never look the same from the outside.
|
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.
|
The part I care most about: silence always means "not open yet". If checks start failing you get
|
||||||
Shows added later still get through.
|
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, and so do shows that were pulled and re-listed: a show missing for three checks in a
|
||||||
|
row is forgotten, so its return alerts you again.
|
||||||
|
|
||||||
| Notification | Priority | What it means |
|
| Notification | Priority | What it means |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `<FORMAT> <DATE> OPEN - BOOK NOW` | max | The one you're waiting for. Tap it to book. |
|
| `<FORMAT> <DATE> OPEN on <SITE> - BOOK NOW` | max | The one you're waiting for. Tap it to book. |
|
||||||
| `<DATE> bookings are OPEN (no <FORMAT> yet)` | high | Date opened, your format hasn't shown up yet. |
|
| `<DATE> is OPEN on <SITE> (no <FORMAT> yet)` | high | Date opened, your format hasn't shown up yet. |
|
||||||
| `Monitor BROKEN - check manually` | high | Checks are failing. Don't trust the silence. |
|
| `<DATE> shows withdrawn on <SITE>` | default | Shows you were alerted about were pulled. Re-armed for their return. |
|
||||||
| `Monitor recovered` | default | Back to normal. |
|
| `<SITE> checks BROKEN - check manually` | high | That site's checks are failing. Don't trust the silence. |
|
||||||
| `Still watching <DATE>` | min | Daily heartbeat. |
|
| `<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. |
|
| `Monitor retired` | default | Target date passed, so it stopped on its own. |
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
@@ -44,8 +51,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
|
`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.
|
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,
|
Grab [`.env.example`](.env.example), save it as `.env`, fill it in using "Finding your IDs"
|
||||||
then:
|
below, then:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run -d --name ticket-monitoring \
|
docker run -d --name ticket-monitoring \
|
||||||
@@ -60,11 +67,11 @@ docker run -d --name ticket-monitoring \
|
|||||||
docker logs -f 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
|
The volume is what stops it re-alerting you about shows it already found, so keep it around
|
||||||
restarts. Drop the `-p` flag if you don't want the status endpoint reachable. To update, pull the
|
across restarts. Drop the `-p` flag if you don't want the status endpoint reachable. To update,
|
||||||
image again and recreate the container:
|
pull the image again and recreate the container:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker pull ghcr.io/aculix/ticket-monitoring:latest
|
docker pull ghcr.io/aculix/ticket-monitoring:latest
|
||||||
@@ -90,7 +97,7 @@ docker compose logs -f
|
|||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
cp .env.example .env # edit it
|
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
|
npm start # run it for real
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -100,79 +107,130 @@ support.
|
|||||||
|
|
||||||
## Finding your IDs
|
## 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_CONTENT_ID` 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─┘
|
└── CITY_KEY ──┘ └CONTENT_ID┘ └─MOVIE_CODE─┘
|
||||||
```
|
```
|
||||||
|
|
||||||
- `CONTENT_ID` is the number after `MV`
|
- `DISTRICT_CONTENT_ID` is the number after `MV`
|
||||||
- `MOVIE_CODE` is the `frmtid` parameter, which pins down the movie *and* the language
|
- `DISTRICT_MOVIE_CODE` is the `frmtid` parameter, which pins down the movie *and* the language.
|
||||||
- `CITY_KEY` is the city slug in the path
|
It's optional, and a film that hasn't been given showtimes yet doesn't have one, so leave it
|
||||||
- `LAT` and `LNG` can be any coordinates inside that city. The API refuses to answer without them,
|
empty when you're waiting on an unreleased title.
|
||||||
though results are city-wide either way.
|
- `DISTRICT_CITY_KEY` is the city slug in the path
|
||||||
- `BOOKING_URL` is the whole URL with `fromdate` set to your target date
|
- `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
|
### BookMyShow
|
||||||
first, which watches every cinema in the city, then run `npm run check` against a date that's
|
|
||||||
already bookable and read the ids out of the response. Or just leave it empty for good and let the
|
|
||||||
alert tell you where the shows are.
|
|
||||||
|
|
||||||
Want any format, not only IMAX? Set both `FORMAT_TAG` and `FORMAT_MATCH` to empty.
|
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
|
## 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 |
|
| 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` |
|
| `TARGET_DATE` | *required* | The date you're waiting for, `YYYY-MM-DD` |
|
||||||
| `NTFY_TOPIC` | *required* | The topic you subscribe to. Treat it like a password. |
|
| `NTFY_TOPIC` | *required* | The topic you subscribe to. Treat it like a password. |
|
||||||
| `CINEMA_ID` | *(any)* | Restrict to one cinema |
|
| `DISTRICT_CONTENT_ID` | | Set to watch District. Plus `DISTRICT_CITY_KEY`, `DISTRICT_LAT`, `DISTRICT_LNG` |
|
||||||
| `FORMAT_TAG` | `imax_2d` | Session tag that counts as a match |
|
| `DISTRICT_MOVIE_CODE` | *(any variant)* | Optional `frmtid` to pin one language/format |
|
||||||
| `FORMAT_MATCH` | `IMAX` | Screen-format substring that counts as a match |
|
| `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 |
|
| `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_URL` | `https://ntfy.sh` | Your own ntfy server, if you run one |
|
||||||
| `NTFY_TOKEN` | | Only if your server wants auth |
|
| `NTFY_TOKEN` | | Only if your server wants auth |
|
||||||
| `CHECK_INTERVAL_SECONDS` | `60` | Poll interval |
|
| `CHECK_INTERVAL_SECONDS` | `60` | Poll interval |
|
||||||
| `FAILURE_THRESHOLD` | `6` | Failures in a row before the BROKEN alert |
|
| `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 |
|
| `HEARTBEAT_HOUR` | `9` | Local hour for the daily heartbeat, `-1` turns it off |
|
||||||
| `CURRENCY` | `₹` | Price prefix |
|
| `CURRENCY` | `₹` | Price prefix |
|
||||||
| `EXPIRY_UTC` | *auto* | When to retire. Defaults to local midnight after `TARGET_DATE`. |
|
| `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 |
|
| `STATE_PATH` | `./state.json` | Where dedupe state lives, `/data/state.json` in Docker |
|
||||||
| `PORT` | `4733` | Status endpoint |
|
| `PORT` | `4733` | Status endpoint |
|
||||||
| `PROXY_URL` | | SOCKS5 proxy for the site checks, see below |
|
| `PROXY_URL` | | SOCKS5 proxy for both sites' checks, see below |
|
||||||
|
|
||||||
|
v1 used flat names (`MOVIE_CODE`, `CINEMA_ID` and friends) for District. Those still work, so an
|
||||||
|
existing `.env` keeps running unchanged. They're read per field, so a `DISTRICT_*` value you set
|
||||||
|
deliberately always wins over a leftover flat one, and the log warns when any flat name is in use.
|
||||||
|
|
||||||
## Status endpoint
|
## Status endpoint
|
||||||
|
|
||||||
While running continuously it serves JSON on `PORT`, which defaults to 4733:
|
While running continuously it serves JSON on `PORT`, which defaults to 4733:
|
||||||
|
|
||||||
- `/` gives you state, `lastTick` and `proxied`
|
- `/` gives you state and `lastTick` per site, plus `proxied`
|
||||||
- `/?force=1` adds a live check right now, with no notifications and no state changes
|
- `/?force=1` adds a live check of every site right now, with no notifications and no state changes
|
||||||
- `/healthz` returns `{"ok":true}`
|
- `/healthz` returns `{"ok":true}`
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -s localhost:4733/ | jq
|
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
|
## 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
|
District's WAF is blocking your host. This happens reliably on cloud providers. An earlier
|
||||||
of this ran as a Cloudflare Worker, worked fine for two days, then got blocked permanently, while
|
version of this ran as a Cloudflare Worker, worked fine for two days, then got blocked
|
||||||
a home connection was never touched once. Either run it from a residential connection, or point
|
permanently, while a home connection was never touched once. Either run it from a residential
|
||||||
`PROXY_URL` at a SOCKS5 proxy. Only the site checks go through the proxy. Notifications stay
|
connection, or point `PROXY_URL` at a SOCKS5 proxy. Only the site checks go through the proxy.
|
||||||
direct, so your alerts don't depend on it.
|
Notifications stay direct, so your alerts don't depend on it.
|
||||||
|
|
||||||
|
One gotcha if your proxy is IPv6-only: a `socks5://` URL makes some clients resolve DNS locally
|
||||||
|
and hand the proxy an IPv4 address it cannot reach. The monitor rewrites the scheme to `socks5h://`
|
||||||
|
so the proxy does the resolving, which fixes that and changes nothing for other proxies.
|
||||||
|
|
||||||
|
### BookMyShow returns HTTP 403
|
||||||
|
|
||||||
|
BookMyShow blocks on two things at once. First the TLS fingerprint of the client, which is why
|
||||||
|
plain `curl` and Node's `fetch` get 403 no matter which headers or cookies you send. That part is
|
||||||
|
handled by [impit](https://github.com/apify/impit), which performs the request with a real Chrome
|
||||||
|
fingerprint. Second your IP, if it belongs to a datacenter: BookMyShow 403s cloud hosts even with
|
||||||
|
a perfect fingerprint, which I confirmed from GitHub Actions runners.
|
||||||
|
|
||||||
|
So on a cloud host you need both, and setting `PROXY_URL` covers the second half. If you are still
|
||||||
|
getting 403s from a residential connection, the impersonation has probably stopped matching the
|
||||||
|
current Chrome, so update the image or run `npm update impit`.
|
||||||
|
|
||||||
### ntfy returns HTTP 429
|
### ntfy returns HTTP 429
|
||||||
|
|
||||||
@@ -186,9 +244,9 @@ messages through Do Not Disturb.
|
|||||||
|
|
||||||
### Alerts repeat, or never arrive again
|
### 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
|
Dedupe lives in the state file at `STATE_PATH`, or in the `data/` volume under Docker. Delete it
|
||||||
start fresh. Do this whenever you change `TARGET_DATE`, otherwise it still thinks it has already
|
to start fresh. Do this whenever you change `TARGET_DATE`, otherwise it still thinks it has
|
||||||
told you about those shows.
|
already told you about those shows.
|
||||||
|
|
||||||
### Nothing happens at all
|
### Nothing happens at all
|
||||||
|
|
||||||
@@ -197,24 +255,31 @@ Run `npm run check` for a single noisy check. Configuration problems are reporte
|
|||||||
## Development
|
## Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm test # 16 tests, no network needed
|
npm test # 35 tests, no network needed
|
||||||
```
|
```
|
||||||
|
|
||||||
The tests run the alert state machine and session parsing against a real captured API response in
|
The tests run the alert state machine and both site parsers against real captured API responses
|
||||||
`test/fixtures/`. `src/monitor.js` has the pure logic and the API client, `src/index.js` is the
|
in `test/fixtures/`. Layout:
|
||||||
runner plus state file and status server, `src/config.js` reads the environment.
|
|
||||||
|
|
||||||
If you're forking this for another site, [docs/design-notes.md](docs/design-notes.md) covers what I
|
- `src/providers/district.js` and `src/providers/bookmyshow.js` fetch and normalize showtimes.
|
||||||
learned about the API and why the alert logic is shaped the way it is.
|
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
|
## 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.
|
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.
|
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
|
Not affiliated with District, BookMyShow, PVR or IMAX. Both APIs are undocumented and can change
|
||||||
feel like it.
|
whenever they feel like it.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
+52
-3
@@ -3,7 +3,11 @@
|
|||||||
Why this is built the way it is, and what broke along the way. Useful if you're forking it,
|
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.
|
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:
|
District's movie pages are server-rendered Next.js, but showtimes load client-side from:
|
||||||
|
|
||||||
@@ -29,15 +33,56 @@ Findings that shaped the design:
|
|||||||
`204` while closed, the daily heartbeat makes one extra call for *today* purely to report how
|
`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.
|
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®ionCode=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
|
## 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.
|
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
|
- **Publish before persist.** Each alert carries its own `statePatch`, merged only after that
|
||||||
alert's push succeeds. A failed notification retries on the next tick instead of being silently
|
alert's push succeeds. A failed notification retries on the next tick instead of being silently
|
||||||
recorded as delivered.
|
recorded as delivered.
|
||||||
- **Dedupe by session id, not a boolean.** Shows added after the first alert still notify you.
|
- **Dedupe by session id, not a boolean.** Shows added after the first alert still notify you.
|
||||||
|
- **Dedupe does not outlive the listing.** Cinemas publish early, pull the shows and re-list
|
||||||
|
them, often under the same session ids, and this really happened with the Endgame release. A
|
||||||
|
show missing for three consecutive successful checks is forgotten, so its return alerts again.
|
||||||
|
Three rather than one, so a single flaky response can't cause a duplicate max-priority push,
|
||||||
|
and failed checks don't count, since an outage says nothing about whether a show exists. If
|
||||||
|
the whole date goes dark after opening, a separate notice says the shows were withdrawn. The
|
||||||
|
re-arm itself lives in the base state, not in that notice's patch, so it happens even if the
|
||||||
|
notice fails to deliver.
|
||||||
- **`dateOpenAlerted` rides an alert patch, never the base state.** Otherwise a failed push
|
- **`dateOpenAlerted` rides an alert patch, never the base state.** Otherwise a failed push
|
||||||
followed by sessions disappearing could swallow the only notification you'd ever get.
|
followed by sessions disappearing could swallow the only notification you'd ever get.
|
||||||
- **Open-but-empty counts as closed**, defending against a `200` with no sessions.
|
- **Open-but-empty counts as closed**, defending against a `200` with no sessions.
|
||||||
@@ -45,9 +90,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
|
- **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
|
1,000 writes/day free limit against 1,440 daily ticks, which forced the discipline; it's still
|
||||||
the right behaviour on a filesystem.
|
the 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.**
|
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
|
## What broke in production
|
||||||
|
|
||||||
|
|||||||
Generated
+148
@@ -10,6 +10,7 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fetch-socks": "^1.3.3",
|
"fetch-socks": "^1.3.3",
|
||||||
|
"impit": "^0.14.4",
|
||||||
"undici": "^8.10.0"
|
"undici": "^8.10.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -24,6 +25,153 @@
|
|||||||
"undici": ">=7"
|
"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": {
|
"node_modules/ip-address": {
|
||||||
"version": "10.4.0",
|
"version": "10.4.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
+3
-2
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "ticket-monitoring",
|
"name": "ticket-monitoring",
|
||||||
"version": "1.0.0",
|
"version": "1.2.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.",
|
"description": "Watches district.in and BookMyShow showtimes and sends a phone notification the moment tickets for a specific movie, date, format and cinema go on sale.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fetch-socks": "^1.3.3",
|
"fetch-socks": "^1.3.3",
|
||||||
|
"impit": "^0.14.4",
|
||||||
"undici": "^8.10.0"
|
"undici": "^8.10.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
+106
-21
@@ -1,5 +1,12 @@
|
|||||||
// Configuration is read entirely from environment variables (see .env.example).
|
// 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
|
// 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) {
|
function required(name) {
|
||||||
const v = process.env[name];
|
const v = process.env[name];
|
||||||
@@ -15,6 +22,69 @@ const num = (name, fallback) => {
|
|||||||
return n;
|
return n;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* District settings. v1 used flat names (MOVIE_CODE, CINEMA_ID and so on) and those are
|
||||||
|
* still honoured, but per field, so a stale leftover cannot override a DISTRICT_* value
|
||||||
|
* that was set deliberately. Enabled by the content id, since the movie code is optional.
|
||||||
|
*/
|
||||||
|
const LEGACY = {
|
||||||
|
DISTRICT_MOVIE_CODE: "MOVIE_CODE",
|
||||||
|
DISTRICT_CONTENT_ID: "CONTENT_ID",
|
||||||
|
DISTRICT_CITY_KEY: "CITY_KEY",
|
||||||
|
DISTRICT_LAT: "LAT",
|
||||||
|
DISTRICT_LNG: "LNG",
|
||||||
|
DISTRICT_CINEMA_ID: "CINEMA_ID",
|
||||||
|
DISTRICT_FORMAT_TAG: "FORMAT_TAG",
|
||||||
|
DISTRICT_FORMAT_MATCH: "FORMAT_MATCH",
|
||||||
|
DISTRICT_BOOKING_URL: "BOOKING_URL",
|
||||||
|
};
|
||||||
|
|
||||||
|
const pick = (name) => process.env[name] ?? process.env[LEGACY[name]];
|
||||||
|
|
||||||
|
export function legacyNamesInUse() {
|
||||||
|
return Object.entries(LEGACY)
|
||||||
|
.filter(([nu, old]) => process.env[nu] === undefined && process.env[old] !== undefined)
|
||||||
|
.map(([, old]) => old);
|
||||||
|
}
|
||||||
|
|
||||||
|
function districtConfig() {
|
||||||
|
const contentId = pick("DISTRICT_CONTENT_ID");
|
||||||
|
if (!contentId) return null; // District not configured
|
||||||
|
const need = (name) => {
|
||||||
|
const v = pick(name);
|
||||||
|
if (!v) throw new Error(`${name} is required when District is configured (see .env.example)`);
|
||||||
|
return v;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
// Optional: District answers without it, and an unreleased film has no format code yet.
|
||||||
|
movieCode: pick("DISTRICT_MOVIE_CODE") ?? "",
|
||||||
|
contentId,
|
||||||
|
cityKey: need("DISTRICT_CITY_KEY"),
|
||||||
|
lat: need("DISTRICT_LAT"),
|
||||||
|
lng: need("DISTRICT_LNG"),
|
||||||
|
venueId: pick("DISTRICT_CINEMA_ID") ?? "",
|
||||||
|
formatTag: pick("DISTRICT_FORMAT_TAG") ?? "imax_2d",
|
||||||
|
formatMatch: pick("DISTRICT_FORMAT_MATCH") ?? "IMAX",
|
||||||
|
bookingUrl: pick("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() {
|
export function loadConfig() {
|
||||||
const targetDate = required("TARGET_DATE");
|
const targetDate = required("TARGET_DATE");
|
||||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(targetDate)) {
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(targetDate)) {
|
||||||
@@ -26,37 +96,52 @@ export function loadConfig() {
|
|||||||
const expiryUtc = process.env.EXPIRY_UTC ||
|
const expiryUtc = process.env.EXPIRY_UTC ||
|
||||||
new Date(Date.parse(`${targetDate}T00:00:00Z`) + 86400000 - tzOffsetMinutes * 60000).toISOString();
|
new Date(Date.parse(`${targetDate}T00:00:00Z`) + 86400000 - tzOffsetMinutes * 60000).toISOString();
|
||||||
|
|
||||||
|
const built = { district: districtConfig(), bookmyshow: bookmyshowConfig() };
|
||||||
|
const stale = legacyNamesInUse();
|
||||||
|
if (stale.length) {
|
||||||
|
console.warn(`warning: using v1 variable names ${stale.join(", ")}. Rename them to DISTRICT_* to avoid confusion.`);
|
||||||
|
}
|
||||||
|
// PROVIDERS is an explicit allow-list; without it, every configured provider runs.
|
||||||
|
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_CONTENT_ID 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 {
|
return {
|
||||||
// what to watch
|
|
||||||
movieCode: required("MOVIE_CODE"),
|
|
||||||
contentId: required("CONTENT_ID"),
|
|
||||||
cityKey: required("CITY_KEY"),
|
|
||||||
lat: required("LAT"),
|
|
||||||
lng: required("LNG"),
|
|
||||||
targetDate,
|
targetDate,
|
||||||
cinemaId: process.env.CINEMA_ID || "", // empty = any cinema
|
providers,
|
||||||
formatTag: process.env.FORMAT_TAG ?? "imax_2d", // empty + empty match = any format
|
formatLabel: env("FORMAT_LABEL", "IMAX"),
|
||||||
formatMatch: process.env.FORMAT_MATCH ?? "IMAX",
|
// Used by alerts that are not tied to one provider (heartbeat, retirement).
|
||||||
formatLabel: process.env.FORMAT_LABEL || "IMAX",
|
bookingUrl: providers[0].bookingUrl || "",
|
||||||
bookingUrl: process.env.BOOKING_URL || "",
|
|
||||||
|
|
||||||
// notifications
|
ntfyUrl: env("NTFY_URL", "https://ntfy.sh").replace(/\/+$/, ""),
|
||||||
ntfyUrl: (process.env.NTFY_URL || "https://ntfy.sh").replace(/\/+$/, ""),
|
|
||||||
ntfyTopic: required("NTFY_TOPIC"),
|
ntfyTopic: required("NTFY_TOPIC"),
|
||||||
ntfyToken: process.env.NTFY_TOKEN || "",
|
ntfyToken: env("NTFY_TOKEN"),
|
||||||
|
|
||||||
// behaviour
|
|
||||||
intervalMs: num("CHECK_INTERVAL_SECONDS", 60) * 1000,
|
intervalMs: num("CHECK_INTERVAL_SECONDS", 60) * 1000,
|
||||||
failureThreshold: num("FAILURE_THRESHOLD", 6),
|
failureThreshold: num("FAILURE_THRESHOLD", 6),
|
||||||
tzOffsetMinutes,
|
tzOffsetMinutes,
|
||||||
heartbeatHour: num("HEARTBEAT_HOUR", 9), // -1 disables the daily heartbeat
|
heartbeatHour: num("HEARTBEAT_HOUR", 9), // -1 disables the daily heartbeat
|
||||||
currency: process.env.CURRENCY || "₹",
|
currency: env("CURRENCY", "₹"),
|
||||||
expiryUtc,
|
expiryUtc,
|
||||||
|
|
||||||
// runtime
|
statePath: env("STATE_PATH", "./state.json"),
|
||||||
statePath: process.env.STATE_PATH || "./state.json",
|
|
||||||
port: num("PORT", 4733),
|
port: num("PORT", 4733),
|
||||||
proxyUrl: process.env.PROXY_URL || "",
|
proxyUrl: env("PROXY_URL"),
|
||||||
dispatcher: null, // set by attachProxy() when PROXY_URL is present
|
dispatcher: null, // set by attachProxy() when PROXY_URL is present
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
+97
-52
@@ -1,18 +1,20 @@
|
|||||||
#!/usr/bin/env node
|
#!/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 { createServer } from "node:http";
|
||||||
import { readFileSync, writeFileSync, renameSync, mkdirSync } from "node:fs";
|
import { readFileSync, writeFileSync, renameSync, mkdirSync } from "node:fs";
|
||||||
import { dirname } from "node:path";
|
import { dirname } from "node:path";
|
||||||
import { loadConfig } from "./config.js";
|
import { loadConfig } from "./config.js";
|
||||||
|
import { localDate } from "./lib.js";
|
||||||
import {
|
import {
|
||||||
decide, defaultState, extractSessions, checkDistrict, notify, heartbeatDue, localDate,
|
decideProvider, decideGlobal, defaultState, providerState, heartbeatDue, notify,
|
||||||
} from "./monitor.js";
|
} from "./monitor.js";
|
||||||
|
|
||||||
const ts = () => new Date().toISOString();
|
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.
|
// 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) {
|
function readState(cfg) {
|
||||||
try {
|
try {
|
||||||
@@ -30,9 +32,12 @@ function writeState(cfg, state) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Routes District requests through a SOCKS5 proxy. Notifications stay direct, so the
|
* Routes provider requests through a SOCKS5 proxy. Notifications stay direct, so the
|
||||||
* alerting path never depends on the proxy. Needed on datacenter hosts whose IPs the
|
* alerting path never depends on the proxy.
|
||||||
* site's WAF blocks.
|
*
|
||||||
|
* Both sites block datacenter IPs, so a cloud host generally needs this for both. It is
|
||||||
|
* only half the story for BookMyShow, which also blocks on TLS fingerprint: impit deals
|
||||||
|
* with the fingerprint, the proxy deals with the IP, and BMS needs both.
|
||||||
*/
|
*/
|
||||||
async function attachProxy(cfg) {
|
async function attachProxy(cfg) {
|
||||||
if (!cfg.proxyUrl) return;
|
if (!cfg.proxyUrl) return;
|
||||||
@@ -51,49 +56,82 @@ async function attachProxy(cfg) {
|
|||||||
console.log(ts(), `checks routed via SOCKS5 ${u.hostname}:${u.port}`);
|
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, note: res.note ?? null };
|
||||||
|
} 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 ?? input.note ?? null };
|
||||||
|
const matched = input.extract?.matched?.length ?? 0;
|
||||||
|
console.log(ts(), `[${p.key}] ${input.kind}${input.reason || input.note ? ` (${input.reason ?? input.note})` : ""}`,
|
||||||
|
`| matched: ${matched} | failures: ${baseState.failures}`);
|
||||||
|
return { pstate: baseState, alerts, input };
|
||||||
|
}
|
||||||
|
|
||||||
export async function tick(cfg) {
|
export async function tick(cfg) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const state = readState(cfg);
|
const state = readState(cfg);
|
||||||
if (state.retired) return "retired";
|
if (state.retired) return "retired";
|
||||||
|
|
||||||
let input;
|
const merged = structuredClone(state);
|
||||||
if (now.getTime() >= Date.parse(cfg.expiryUtc)) {
|
merged.providers ??= {};
|
||||||
input = { kind: "closed", now }; // decide() retires before it looks at kind
|
const summaries = [];
|
||||||
} else {
|
const expired = now.getTime() >= Date.parse(cfg.expiryUtc);
|
||||||
// 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.
|
const send = async (alert, apply) => {
|
||||||
let probeMax = null;
|
|
||||||
if (heartbeatDue(state, now, cfg) && !state.brokenAlerted) {
|
|
||||||
try {
|
try {
|
||||||
const probe = await checkDistrict(cfg, localDate(now, cfg.tzOffsetMinutes));
|
await notify(cfg, alert);
|
||||||
if (probe.kind === "open") {
|
apply();
|
||||||
const dates = probe.data?.meta?.showDates ?? [];
|
console.log(ts(), "alert sent:", alert.title);
|
||||||
if (dates.length) probeMax = dates.slice().sort().at(-1);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(ts(), "showDates probe failed (best-effort):", err.message);
|
// 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 };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
try {
|
summaries.push({
|
||||||
const res = await checkDistrict(cfg, cfg.targetDate);
|
label: p.label,
|
||||||
input = res.kind === "open"
|
kind: input.kind === "error" ? "checks failing" : merged.providers[p.key].lastGood?.kind,
|
||||||
? { kind: "open", extract: extractSessions(res.data, cfg), now, probeMax }
|
showDatesMax: merged.providers[p.key].lastGood?.showDatesMax,
|
||||||
: { kind: "closed", now, probeMax };
|
note: merged.providers[p.key].lastGood?.note,
|
||||||
} catch (err) {
|
});
|
||||||
input = { kind: "error", reason: String(err.message ?? err), now, probeMax };
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { baseState, alerts } = decide(state, input, cfg);
|
const global = decideGlobal(state, cfg, now, summaries);
|
||||||
let merged = baseState;
|
Object.assign(merged, global.baseState, { providers: merged.providers });
|
||||||
for (const alert of alerts) {
|
for (const alert of global.alerts) {
|
||||||
try {
|
await send(alert, () => Object.assign(merged, alert.statePatch));
|
||||||
await notify(cfg, alert);
|
|
||||||
merged = { ...merged, ...alert.statePatch };
|
|
||||||
console.log(ts(), "alert sent:", alert.title);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(ts(), `notify failed for "${alert.title}" (will retry next tick):`, err.message);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (JSON.stringify(merged) !== JSON.stringify(state)) {
|
if (JSON.stringify(merged) !== JSON.stringify(state)) {
|
||||||
@@ -104,12 +142,7 @@ export async function tick(cfg) {
|
|||||||
console.error(ts(), "state write failed (may duplicate pushes):", err.message);
|
console.error(ts(), "state write failed (may duplicate pushes):", err.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return merged.retired ? "retired" : "ok";
|
||||||
lastTick.at = ts();
|
|
||||||
lastTick.kind = input.kind;
|
|
||||||
lastTick.reason = input.reason ?? null;
|
|
||||||
console.log(ts(), "tick:", input.kind, input.kind === "error" ? `(${input.reason})` : "", "failures:", merged.failures);
|
|
||||||
return input.kind;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function startStatusServer(cfg) {
|
function startStatusServer(cfg) {
|
||||||
@@ -125,6 +158,14 @@ function startStatusServer(cfg) {
|
|||||||
const out = {
|
const out = {
|
||||||
now: ts(),
|
now: ts(),
|
||||||
targetDate: cfg.targetDate,
|
targetDate: cfg.targetDate,
|
||||||
|
// What each site is actually watching. Without this, a misconfigured id or an
|
||||||
|
// unexpectedly narrow filter is indistinguishable from "nothing on sale yet".
|
||||||
|
watching: Object.fromEntries(cfg.providers.map((p) => [p.key, {
|
||||||
|
movie: p.contentId ?? p.eventCode,
|
||||||
|
variant: p.movieCode || p.language || "(any language/format variant)",
|
||||||
|
venue: p.venueId || "(any venue)",
|
||||||
|
format: [p.formatTag, p.formatMatch].filter(Boolean).join(" or ") || "(any format)",
|
||||||
|
}])),
|
||||||
proxied: Boolean(cfg.dispatcher),
|
proxied: Boolean(cfg.dispatcher),
|
||||||
retired: state.retired,
|
retired: state.retired,
|
||||||
lastTick,
|
lastTick,
|
||||||
@@ -132,13 +173,16 @@ function startStatusServer(cfg) {
|
|||||||
};
|
};
|
||||||
if (url.searchParams.get("force") === "1") {
|
if (url.searchParams.get("force") === "1") {
|
||||||
// Side-effect-free: no state writes, no notifications.
|
// Side-effect-free: no state writes, no notifications.
|
||||||
|
out.live = {};
|
||||||
|
for (const p of cfg.providers) {
|
||||||
try {
|
try {
|
||||||
const r = await checkDistrict(cfg, cfg.targetDate);
|
const r = await p.module.check(cfg, p, cfg.targetDate);
|
||||||
out.live = r.kind === "open"
|
out.live[p.key] = r.kind === "open"
|
||||||
? { kind: "open", ...extractSessions(r.data, cfg) }
|
? { kind: "open", ...p.module.extract(r.data, cfg, p) }
|
||||||
: { kind: "closed" };
|
: { kind: "closed" };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
out.live = { kind: "error", reason: String(err.message ?? err) };
|
out.live[p.key] = { kind: "error", reason: String(err.message ?? err) };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
send(200, out);
|
send(200, out);
|
||||||
@@ -153,12 +197,13 @@ await attachProxy(cfg);
|
|||||||
|
|
||||||
if (process.argv.includes("--loop")) {
|
if (process.argv.includes("--loop")) {
|
||||||
startStatusServer(cfg);
|
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 sig of ["SIGINT", "SIGTERM"]) process.on(sig, () => process.exit(0));
|
||||||
for (;;) {
|
for (;;) {
|
||||||
const kind = await tick(cfg).catch((err) => { console.error(ts(), "tick crashed:", err); return "crash"; });
|
const r = await tick(cfg).catch((err) => { console.error(ts(), "tick crashed:", err); return "crash"; });
|
||||||
if (kind === "retired") break;
|
if (r === "retired") break;
|
||||||
await new Promise((r) => setTimeout(r, cfg.intervalMs));
|
await new Promise((r2) => setTimeout(r2, cfg.intervalMs));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
await tick(cfg);
|
await tick(cfg);
|
||||||
|
|||||||
+29
@@ -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();
|
||||||
|
}
|
||||||
+171
-235
@@ -1,146 +1,201 @@
|
|||||||
// Core logic: District API client, session matching, alert state machine, ntfy publisher.
|
// Provider-agnostic alert logic: what to notify about, once, and when to stay quiet.
|
||||||
// Everything here is runtime-agnostic and side-effect-free except checkDistrict/notify.
|
// 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
|
* Consecutive successful checks a previously announced show must be absent before it is
|
||||||
* server-issued, and any value of this shape is accepted.
|
* forgotten. Counting checks, rather than reacting to one miss, stops a single flaky
|
||||||
|
* response from turning into a duplicate max-priority push.
|
||||||
*/
|
*/
|
||||||
export function guestToken() {
|
export const REARM_AFTER = 3;
|
||||||
let digits = "";
|
|
||||||
for (let i = 0; i < 18; i++) digits += Math.floor(Math.random() * 10);
|
export function defaultProviderState() {
|
||||||
const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789";
|
return {
|
||||||
let suffix = "";
|
dateOpenAlerted: false, sidsAlerted: {}, missing: {}, closedTicks: 0,
|
||||||
for (let i = 0; i < 11; i++) suffix += alphabet[Math.floor(Math.random() * alphabet.length)];
|
failures: 0, brokenAlerted: false, lastGood: null,
|
||||||
return `${Date.now()}_${digits}_${suffix}`;
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** API showTime is UTC without a zone suffix ("2026-08-05T03:30"). */
|
export function defaultState() {
|
||||||
export function toLocalTime(showTime, tzOffsetMinutes) {
|
return { hbDate: "", retired: false, providers: {} };
|
||||||
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. */
|
/** Provider sub-state, defaulted, so older state files keep working. */
|
||||||
export function localDate(now, tzOffsetMinutes) {
|
export function providerState(state, key) {
|
||||||
return new Date(now.getTime() + tzOffsetMinutes * 60000).toISOString().slice(0, 10);
|
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) {
|
export function heartbeatDue(state, now, cfg) {
|
||||||
if (cfg.heartbeatHour < 0) return false;
|
if (cfg.heartbeatHour < 0) return false;
|
||||||
return localMinutes(now, cfg.tzOffsetMinutes) >= cfg.heartbeatHour * 60 &&
|
return localMinutes(now, cfg.tzOffsetMinutes) >= cfg.heartbeatHour * 60 &&
|
||||||
state.hbDate !== localDate(now, cfg.tzOffsetMinutes);
|
state.hbDate !== localDate(now, cfg.tzOffsetMinutes);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isMatch(sess, cfg) {
|
function sessionLine(s) {
|
||||||
// Deliberately broad: either the format tag or a substring of the screen format,
|
const head = [s.time, s.cinema, s.audi || s.format, s.avail != null ? `seats ${s.avail}/${s.total}` : null]
|
||||||
// so "IMAX" also catches "IMAX 3D" and similar variants.
|
.filter(Boolean).join(" | ");
|
||||||
if (!cfg.formatTag && !cfg.formatMatch) return true;
|
return s.tiers ? `${head}\n${s.tiers}` : head;
|
||||||
return (cfg.formatTag && (sess.tags || []).includes(cfg.formatTag)) ||
|
|
||||||
(cfg.formatMatch && String(sess.scrnFmt || "").toUpperCase().includes(cfg.formatMatch.toUpperCase()));
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalize(sess, cinemaName, cfg) {
|
|
||||||
return {
|
|
||||||
sid: String(sess.sid),
|
|
||||||
cinema: cinemaName,
|
|
||||||
time: toLocalTime(sess.showTime, cfg.tzOffsetMinutes),
|
|
||||||
audi: sess.audi || "",
|
|
||||||
avail: sess.avail,
|
|
||||||
total: sess.total,
|
|
||||||
tiers: (sess.areas || [])
|
|
||||||
.map((a) => `${a.label} ${cfg.currency}${Math.round(a.price)} (${a.sAvail}/${a.sTotal})`)
|
|
||||||
.join(" · "),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Split a District response into matching sessions.
|
* Decide what to alert for ONE provider on one tick.
|
||||||
* matched — at cfg.cinemaId (or every cinema when cinemaId is unset)
|
* input: { kind: "closed"|"open"|"error", extract?, reason?, now, probeMax? }
|
||||||
* other — matching format at a different cinema (reported as secondary lines)
|
* 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) {
|
export function decideProvider(pstate, input, cfg, p) {
|
||||||
const cinemas = [
|
const base = structuredClone(pstate);
|
||||||
...(data?.pageData?.nearbyCinemas ?? []),
|
const alerts = [];
|
||||||
...(data?.pageData?.farCinemas ?? []),
|
const now = input.now;
|
||||||
];
|
const tag = (a) => ({ ...a, provider: p.key });
|
||||||
const out = {
|
|
||||||
matched: [], other: [],
|
|
||||||
cinemaCount: cinemas.length, sessionCount: 0,
|
|
||||||
showDates: data?.meta?.showDates ?? [],
|
|
||||||
};
|
|
||||||
for (const c of cinemas) {
|
|
||||||
const name = c?.cinemaInfo?.name || `cinema ${c?.id}`;
|
|
||||||
for (const s of c?.sessions ?? []) {
|
|
||||||
out.sessionCount++;
|
|
||||||
if (!isMatch(s, cfg)) continue;
|
|
||||||
const wanted = !cfg.cinemaId || String(c.id) === String(cfg.cinemaId);
|
|
||||||
(wanted ? out.matched : out.other).push(normalize(s, name, cfg));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function apiUrl(cfg, date) {
|
if (input.probeMax && base.lastGood?.showDatesMax !== input.probeMax) {
|
||||||
const p = new URLSearchParams({
|
base.lastGood = { ...(base.lastGood ?? {}), showDatesMax: input.probeMax, at: now.toISOString() };
|
||||||
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",
|
if (input.kind === "error") {
|
||||||
});
|
base.failures = Math.min((pstate.failures ?? 0) + 1, cfg.failureThreshold);
|
||||||
return `https://www.district.in/gw/consumer/movies/v5/movie?${p}`;
|
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
|
||||||
|
const note = input.note ?? null;
|
||||||
|
if (base.lastGood?.kind !== effectiveKind || base.lastGood?.note !== note) {
|
||||||
|
base.lastGood = { ...(base.lastGood ?? {}), kind: effectiveKind, note, at: now.toISOString() };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cinemas publish early, pull the shows and re-list them, often under the same session
|
||||||
|
// ids. Dedupe must not outlive the listing, or the re-listing is silent. So a show that
|
||||||
|
// stays missing for REARM_AFTER checks is forgotten, and its return alerts again. This
|
||||||
|
// lives in the base state rather than an alert patch: re-arming must not depend on a
|
||||||
|
// notification getting through.
|
||||||
|
const present = new Set(effectiveKind === "open" ? ex.matched.map((s) => s.sid) : []);
|
||||||
|
const alerted = { ...(pstate.sidsAlerted ?? {}) };
|
||||||
|
const missing = { ...(pstate.missing ?? {}) };
|
||||||
|
for (const sid of Object.keys(alerted)) {
|
||||||
|
if (present.has(sid)) {
|
||||||
|
delete missing[sid];
|
||||||
|
} else if ((missing[sid] = (missing[sid] ?? 0) + 1) >= REARM_AFTER) {
|
||||||
|
delete alerted[sid];
|
||||||
|
delete missing[sid];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const sid of Object.keys(missing)) if (!(sid in alerted)) delete missing[sid];
|
||||||
|
base.sidsAlerted = alerted;
|
||||||
|
base.missing = missing;
|
||||||
|
|
||||||
|
// The whole date going dark after it opened is worth saying out loud: the user may be
|
||||||
|
// mid-booking and wondering where the shows went. Counted the same way, then re-armed.
|
||||||
|
if (effectiveKind === "closed" && pstate.dateOpenAlerted) {
|
||||||
|
base.closedTicks = (pstate.closedTicks ?? 0) + 1;
|
||||||
|
if (base.closedTicks >= REARM_AFTER) {
|
||||||
|
base.dateOpenAlerted = false;
|
||||||
|
base.closedTicks = 0;
|
||||||
|
alerts.push(tag({
|
||||||
|
title: `${cfg.targetDate} shows withdrawn on ${p.label}`,
|
||||||
|
priority: "3",
|
||||||
|
tags: "arrows_counterclockwise",
|
||||||
|
click: p.bookingUrl,
|
||||||
|
body: `${p.label} is no longer listing shows for ${cfg.targetDate}. The monitor has re-armed, so you'll get a max-priority push again the moment they come back.`,
|
||||||
|
statePatch: {},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} else if (pstate.closedTicks) {
|
||||||
|
base.closedTicks = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (effectiveKind === "open") {
|
||||||
|
const fresh = ex.matched.filter((s) => !alerted[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}`);
|
||||||
|
// Built from the post-forget map, so the patch can't resurrect a forgotten id.
|
||||||
|
const sidsAlerted = { ...alerted };
|
||||||
|
for (const s of fresh) sidsAlerted[s.sid] = true;
|
||||||
|
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 (!base.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 { baseState: base, alerts };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One API call, classified by HTTP outcome only:
|
* Tick-level decisions that are not per-provider: retirement and the daily heartbeat.
|
||||||
* 204 -> date not open for booking yet
|
* summaries: [{ label, kind, showDatesMax }] describing each provider this tick.
|
||||||
* 200 -> open (open-but-empty is decided later, in decide())
|
|
||||||
* Anything else throws CheckError, which counts toward the failure streak.
|
|
||||||
*/
|
*/
|
||||||
export async function checkDistrict(cfg, date) {
|
export function decideGlobal(state, cfg, now, summaries) {
|
||||||
let res;
|
const base = structuredClone(state);
|
||||||
try {
|
const alerts = [];
|
||||||
res = await fetch(apiUrl(cfg, date), {
|
|
||||||
headers: {
|
if (base.retired) return { baseState: base, alerts };
|
||||||
accept: "*/*",
|
|
||||||
api_source: "district",
|
if (now.getTime() >= Date.parse(cfg.expiryUtc)) {
|
||||||
"x-app-type": "ed_mweb",
|
alerts.push({
|
||||||
"x-guest-token": guestToken(),
|
title: "Monitor retired",
|
||||||
"x-request-id": crypto.randomUUID(),
|
priority: "3",
|
||||||
"user-agent": UA,
|
tags: "checkered_flag",
|
||||||
...(cfg.bookingUrl ? { referer: cfg.bookingUrl } : {}),
|
click: cfg.bookingUrl,
|
||||||
},
|
body: `${cfg.targetDate} has passed, so this monitor is now idle. Stop it when convenient.`,
|
||||||
signal: AbortSignal.timeout(15000),
|
statePatch: { retired: true },
|
||||||
// Node-only: routes this call through a SOCKS5 proxy when one is configured.
|
|
||||||
...(cfg.dispatcher ? { dispatcher: cfg.dispatcher } : {}),
|
|
||||||
});
|
});
|
||||||
} catch (err) {
|
return { baseState: base, alerts };
|
||||||
throw new CheckError(`fetch failed: ${err.message}`);
|
|
||||||
}
|
}
|
||||||
if (res.status === 204) return { kind: "closed" };
|
|
||||||
if (res.status === 200) {
|
if (heartbeatDue(state, now, cfg)) {
|
||||||
try {
|
const lines = summaries.map((s) =>
|
||||||
return { kind: "open", data: await res.json() };
|
`${s.label}: ${s.kind ?? "unknown"}${s.note ? `, ${s.note}` : ""}` +
|
||||||
} catch (err) {
|
`${s.showDatesMax ? ` (open through ${s.showDatesMax})` : ""}`);
|
||||||
throw new CheckError(`unparseable 200 body: ${err.message}`);
|
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) {
|
export async function notify(cfg, alert) {
|
||||||
@@ -161,122 +216,3 @@ export async function notify(cfg, alert) {
|
|||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(`ntfy HTTP ${res.status}`);
|
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 };
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
// 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"));
|
||||||
|
// With socks5:// impit resolves DNS locally and asks the proxy for that address,
|
||||||
|
// which fails outright on an IPv6-only exit that cannot reach the IPv4 we picked.
|
||||||
|
// socks5h:// leaves resolution to the proxy, so normalize to it.
|
||||||
|
const proxyUrl = cfg.proxyUrl ? cfg.proxyUrl.replace(/^socks5:\/\//i, "socks5h://") : "";
|
||||||
|
client = new Impit({
|
||||||
|
browser: "chrome",
|
||||||
|
...(proxyUrl ? { 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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
// 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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A film announced but not yet given showtimes answers 400 "Content not found"
|
||||||
|
// (code 32). That is a waiting state, not a fault, so it must not raise the failure
|
||||||
|
// streak. A typo'd content id looks identical, so the reason is carried through to
|
||||||
|
// the status endpoint and the daily heartbeat rather than being swallowed.
|
||||||
|
if (res.status === 400) {
|
||||||
|
let body = null;
|
||||||
|
try {
|
||||||
|
body = await res.json();
|
||||||
|
} catch {
|
||||||
|
// fall through to the generic error below
|
||||||
|
}
|
||||||
|
if (body?.code === 32) {
|
||||||
|
return { kind: "closed", note: "not listed yet (District: content not found)" };
|
||||||
|
}
|
||||||
|
if (body?.msg) throw new CheckError(`HTTP 400: ${body.msg}`);
|
||||||
|
}
|
||||||
|
throw new CheckError(`HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
Vendored
+1
@@ -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"}}}}}
|
||||||
Vendored
+1
@@ -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
|
||||||
+213
-123
@@ -1,165 +1,129 @@
|
|||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { readFileSync } from "node:fs";
|
import {
|
||||||
import { fileURLToPath } from "node:url";
|
decideProvider, decideGlobal, defaultProviderState, defaultState, providerState, heartbeatDue,
|
||||||
import { guestToken, toLocalTime, extractSessions, decide, defaultState } from "../src/monitor.js";
|
} 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 = {
|
const CFG = {
|
||||||
targetDate: "2026-08-10",
|
targetDate: "2026-09-25",
|
||||||
expiryUtc: "2026-08-10T18:30:00Z",
|
expiryUtc: "2026-09-25T18:30:00Z",
|
||||||
bookingUrl: "https://example.com/book",
|
bookingUrl: "https://example.com/book",
|
||||||
failureThreshold: 6,
|
failureThreshold: 6,
|
||||||
tzOffsetMinutes: 330,
|
tzOffsetMinutes: 330,
|
||||||
heartbeatHour: 9,
|
heartbeatHour: 9,
|
||||||
formatTag: "imax_2d",
|
|
||||||
formatMatch: "IMAX",
|
|
||||||
formatLabel: "IMAX",
|
formatLabel: "IMAX",
|
||||||
cinemaId: "1032362",
|
|
||||||
currency: "₹",
|
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 T0 = new Date("2026-09-20T02:00:00Z"); // before the 03:30 UTC (09:00 IST) heartbeat window
|
||||||
const t = guestToken();
|
const HB = new Date("2026-09-20T03:31:00Z"); // inside it
|
||||||
assert.match(t, /^\d{13}_\d{18}_[a-z0-9]{11}$/);
|
|
||||||
assert.notEqual(guestToken(), t, "fresh token per call");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("toLocalTime converts API UTC showTime to local clock time", () => {
|
|
||||||
assert.equal(toLocalTime("2026-08-05T03:30", 330), "9:00 AM"); // verified against the site UI
|
|
||||||
assert.equal(toLocalTime("2026-08-05T06:30", 330), "12:00 PM"); // noon boundary
|
|
||||||
assert.equal(toLocalTime("2026-08-05T12:00", 330), "5:30 PM");
|
|
||||||
assert.equal(toLocalTime("2026-08-05T18:45", 330), "12:15 AM"); // past midnight, clock time only
|
|
||||||
assert.equal(toLocalTime("2026-08-05T03:30:00", 330), "9:00 AM"); // seconds already present
|
|
||||||
assert.equal(toLocalTime("2026-08-05T03:30", 0), "3:30 AM"); // other timezones
|
|
||||||
});
|
|
||||||
|
|
||||||
test("extractSessions finds the target cinema's sessions in a real capture", () => {
|
|
||||||
const ex = extractSessions(fixture, CFG);
|
|
||||||
assert.equal(ex.cinemaCount, 5);
|
|
||||||
assert.ok(ex.sessionCount > ex.matched.length);
|
|
||||||
assert.equal(ex.matched.length, 4);
|
|
||||||
assert.equal(ex.other.length, 0);
|
|
||||||
const s = ex.matched[0];
|
|
||||||
assert.equal(s.sid, "36874");
|
|
||||||
assert.equal(s.cinema, "PVR Palladium Mall, Thaltej, Ahmedabad");
|
|
||||||
assert.equal(s.time, "9:00 AM");
|
|
||||||
assert.equal(s.audi, "SCREEN 9");
|
|
||||||
assert.ok(s.tiers.includes("CLASSIC ₹260"));
|
|
||||||
assert.ok(s.tiers.includes("RECLINER ₹640"));
|
|
||||||
assert.equal(ex.showDates.at(-1), "2026-08-09");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("extractSessions: no cinemaId watches every cinema; no format filter matches all", () => {
|
|
||||||
const anyCinema = extractSessions(fixture, { ...CFG, cinemaId: "" });
|
|
||||||
assert.equal(anyCinema.matched.length, 4, "still only IMAX sessions");
|
|
||||||
assert.equal(anyCinema.other.length, 0, "nothing is 'other' when no cinema is targeted");
|
|
||||||
|
|
||||||
const anyFormat = extractSessions(fixture, { ...CFG, cinemaId: "", formatTag: "", formatMatch: "" });
|
|
||||||
assert.equal(anyFormat.matched.length, anyFormat.sessionCount, "every session matches");
|
|
||||||
|
|
||||||
const otherCinema = extractSessions(fixture, { ...CFG, cinemaId: "125" });
|
|
||||||
assert.equal(otherCinema.matched.length, 0);
|
|
||||||
assert.equal(otherCinema.other.length, 4, "IMAX elsewhere becomes a secondary line");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("extractSessions tolerates empty/malformed payloads", () => {
|
|
||||||
assert.deepEqual(extractSessions({}, CFG),
|
|
||||||
{ matched: [], other: [], cinemaCount: 0, sessionCount: 0, showDates: [] });
|
|
||||||
});
|
|
||||||
|
|
||||||
const T0 = new Date("2026-08-06T02:00:00Z"); // before the 03:30 UTC (09:00 IST) heartbeat window
|
|
||||||
const openInput = (extract, now = T0) => ({ kind: "open", extract, now });
|
const openInput = (extract, now = T0) => ({ kind: "open", extract, now });
|
||||||
|
|
||||||
const EX_MATCH = {
|
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: [],
|
other: [], cinemaCount: 3, sessionCount: 7, showDates: [],
|
||||||
};
|
};
|
||||||
const EX_NO_MATCH = { matched: [], 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: [] };
|
const EX_EMPTY = { matched: [], other: [], cinemaCount: 0, sessionCount: 0, showDates: [] };
|
||||||
|
|
||||||
/** Mirrors the runner: merge each alert's patch only after its push succeeds. */
|
/** 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;
|
let merged = decision.baseState;
|
||||||
for (const a of decision.alerts) merged = { ...merged, ...a.statePatch };
|
for (const a of decision.alerts) merged = { ...merged, ...a.statePatch };
|
||||||
return merged;
|
return merged;
|
||||||
}
|
}
|
||||||
|
|
||||||
test("closed steady state produces no alerts and no state change", () => {
|
test("closed steady state produces no alerts and no state change", () => {
|
||||||
const s1 = apply(defaultState(), decide(defaultState(), { kind: "closed", now: T0 }, CFG));
|
const s1 = apply(defaultProviderState(), decideProvider(defaultProviderState(), { kind: "closed", now: T0 }, CFG, P));
|
||||||
const d2 = decide(s1, { kind: "closed", now: new Date(T0.getTime() + 60000) }, CFG);
|
const d2 = decideProvider(s1, { kind: "closed", now: new Date(T0.getTime() + 60000) }, CFG, P);
|
||||||
assert.equal(d2.alerts.length, 0);
|
assert.equal(d2.alerts.length, 0);
|
||||||
assert.equal(JSON.stringify(apply(s1, d2)), JSON.stringify(s1), "no state write when nothing changed");
|
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", () => {
|
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.length, 1);
|
||||||
assert.equal(d1.alerts[0].priority, "5");
|
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.dateOpenAlerted, "first open response sets dateOpenAlerted");
|
||||||
assert.ok(d1.alerts[0].statePatch.sidsAlerted["111"]);
|
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 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.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("1:15 PM"));
|
||||||
assert.ok(!d3.alerts[0].body.includes("9:00 AM"), "already-alerted session not repeated");
|
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 = {
|
const withOther = {
|
||||||
...EX_MATCH,
|
...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.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].body.includes("Also at Other Cinema: 6:00 PM"));
|
||||||
assert.ok(!d.alerts[0].statePatch.sidsAlerted["999"], "other-venue ids are not dedupe-tracked");
|
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", () => {
|
test("date open without a match fires once; a later match still fires", () => {
|
||||||
const d1 = decide(defaultState(), openInput(EX_NO_MATCH), CFG);
|
const d1 = decideProvider(defaultProviderState(), openInput(EX_NO_MATCH), CFG, P);
|
||||||
assert.equal(d1.alerts.length, 1);
|
assert.equal(d1.alerts.length, 1);
|
||||||
assert.equal(d1.alerts[0].priority, "4");
|
assert.equal(d1.alerts[0].priority, "4");
|
||||||
const s1 = apply(defaultState(), d1);
|
const s1 = apply(defaultProviderState(), d1);
|
||||||
assert.equal(decide(s1, openInput(EX_NO_MATCH), CFG).alerts.length, 0);
|
assert.equal(decideProvider(s1, openInput(EX_NO_MATCH), CFG, P).alerts.length, 0);
|
||||||
const d3 = decide(s1, openInput(EX_MATCH), CFG);
|
const d3 = decideProvider(s1, openInput(EX_MATCH), CFG, P);
|
||||||
assert.equal(d3.alerts.length, 1);
|
assert.equal(d3.alerts.length, 1);
|
||||||
assert.equal(d3.alerts[0].priority, "5");
|
assert.equal(d3.alerts[0].priority, "5");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("failed match push then sessions vanish -> DATE_OPEN still reachable", () => {
|
test("failed match push then sessions vanish -> date-open alert still reachable", () => {
|
||||||
const d1 = decide(defaultState(), openInput(EX_MATCH), CFG);
|
const d1 = decideProvider(defaultProviderState(), openInput(EX_MATCH), CFG, P);
|
||||||
const s1 = d1.baseState; // push failed: statePatch not merged
|
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.length, 1);
|
||||||
assert.equal(d2.alerts[0].priority, "4", "user still learns the date opened");
|
assert.equal(d2.alerts[0].priority, "4", "user still learns the date opened");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("open-but-empty counts as closed", () => {
|
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.alerts.length, 0);
|
||||||
assert.equal(d.baseState.lastGood.kind, "closed");
|
assert.equal(d.baseState.lastGood.kind, "closed");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("BROKEN fires at the threshold, once; RECOVERED on the next success", () => {
|
test("BROKEN fires at the threshold, once, and names the site; RECOVERED on next success", () => {
|
||||||
let s = defaultState();
|
let s = defaultProviderState();
|
||||||
for (let i = 1; i <= 5; i++) {
|
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}`);
|
assert.equal(d.alerts.length, 0, `no alert at failure ${i}`);
|
||||||
s = apply(s, d);
|
s = apply(s, d);
|
||||||
assert.equal(s.failures, i);
|
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.equal(d6.alerts.length, 1);
|
||||||
|
assert.ok(d6.alerts[0].title.includes("BookMyShow"));
|
||||||
assert.ok(d6.alerts[0].title.includes("BROKEN"));
|
assert.ok(d6.alerts[0].title.includes("BROKEN"));
|
||||||
s = apply(s, d6);
|
s = apply(s, d6);
|
||||||
assert.equal(decide(s, { kind: "error", reason: "HTTP 403", now: T0 }, CFG).alerts.length, 0, "not repeated");
|
assert.equal(decideProvider(s, { kind: "error", reason: "HTTP 403", now: T0 }, CFG, P2).alerts.length, 0, "not repeated");
|
||||||
const d8 = decide(s, { kind: "closed", now: T0 }, CFG);
|
const d8 = decideProvider(s, { kind: "closed", now: T0 }, CFG, P2);
|
||||||
assert.equal(d8.alerts.length, 1);
|
assert.equal(d8.alerts.length, 1);
|
||||||
assert.ok(d8.alerts[0].title.includes("recovered"));
|
assert.ok(d8.alerts[0].title.includes("recovered"));
|
||||||
s = apply(s, d8);
|
s = apply(s, d8);
|
||||||
@@ -167,48 +131,174 @@ test("BROKEN fires at the threshold, once; RECOVERED on the next success", () =>
|
|||||||
assert.equal(s.brokenAlerted, false);
|
assert.equal(s.brokenAlerted, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("heartbeat fires once per local day, never on error ticks, and can be disabled", () => {
|
test("providers keep independent dedupe state", () => {
|
||||||
const hbTime = new Date("2026-08-06T03:31:00Z"); // 09:01 IST
|
const state = defaultState();
|
||||||
const early = new Date("2026-08-06T03:29:00Z");
|
const dA = decideProvider(providerState(state, "district"), openInput(EX_MATCH), CFG, P);
|
||||||
assert.equal(decide(defaultState(), { kind: "closed", now: early }, CFG).alerts.length, 0);
|
state.providers.district = apply(providerState(state, "district"), dA);
|
||||||
|
// BookMyShow has never alerted, so the same session id must still notify there.
|
||||||
const d1 = decide(defaultState(), { kind: "closed", now: hbTime }, CFG);
|
const dB = decideProvider(providerState(state, "bookmyshow"), openInput(EX_MATCH), CFG, P2);
|
||||||
assert.equal(d1.alerts.length, 1);
|
assert.equal(dB.alerts.length, 1, "one site alerting must not silence the other");
|
||||||
assert.equal(d1.alerts[0].priority, "1");
|
assert.ok(dB.alerts[0].title.includes("BookMyShow"));
|
||||||
assert.equal(d1.alerts[0].statePatch.hbDate, "2026-08-06");
|
assert.equal(decideProvider(providerState(state, "district"), openInput(EX_MATCH), CFG, P).alerts.length, 0);
|
||||||
|
|
||||||
const s1 = apply(defaultState(), d1);
|
|
||||||
assert.equal(decide(s1, { kind: "closed", now: new Date("2026-08-06T09:00:00Z") }, CFG).alerts.length, 0, "once per day");
|
|
||||||
assert.equal(decide(defaultState(), { kind: "error", reason: "x", now: hbTime }, CFG).alerts.length, 0, "not on errors");
|
|
||||||
assert.equal(decide(defaultState(), { kind: "closed", now: hbTime }, { ...CFG, heartbeatHour: -1 }).alerts.length, 0, "disabled");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("heartbeat reports the probe-refreshed showDates maximum", () => {
|
test("providerState defaults unknown providers and preserves stored ones", () => {
|
||||||
const hbTime = new Date("2026-08-06T03:31:00Z");
|
const state = { ...defaultState(), providers: { district: { failures: 3 } } };
|
||||||
const d = decide(defaultState(), { kind: "closed", now: hbTime, probeMax: "2026-08-11" }, CFG);
|
assert.equal(providerState(state, "district").failures, 3);
|
||||||
assert.equal(d.baseState.lastGood.showDatesMax, "2026-08-11", "probe result rides baseState");
|
assert.equal(providerState(state, "district").brokenAlerted, false, "missing fields are defaulted");
|
||||||
assert.ok(d.alerts[0].body.includes("2026-08-11"));
|
assert.deepEqual(providerState(state, "bookmyshow"), defaultProviderState());
|
||||||
});
|
});
|
||||||
|
|
||||||
test("recovery, match and heartbeat can co-fire with disjoint patches", () => {
|
// ---------------------------------------------------------- global alerts
|
||||||
const hbTime = new Date("2026-08-06T03:31:00Z");
|
|
||||||
const s0 = { ...defaultState(), brokenAlerted: true, failures: 6 };
|
test("heartbeat fires once per local day, summarises every provider, and can be disabled", () => {
|
||||||
const d = decide(s0, openInput(EX_MATCH, hbTime), CFG);
|
const summaries = [
|
||||||
assert.deepEqual(d.alerts.map((a) => a.priority), ["3", "5", "1"]);
|
{ label: "District", kind: "closed", showDatesMax: "2026-09-20" },
|
||||||
assert.deepEqual(d.alerts.map((a) => Object.keys(a.statePatch)),
|
{ label: "BookMyShow", kind: "closed" },
|
||||||
[["brokenAlerted"], ["sidsAlerted", "dateOpenAlerted"], ["hbDate"]]);
|
];
|
||||||
const merged = apply(s0, d);
|
assert.equal(decideGlobal(defaultState(), CFG, new Date("2026-09-20T03:29:00Z"), summaries).alerts.length, 0, "before the window");
|
||||||
assert.equal(merged.brokenAlerted, false);
|
|
||||||
assert.equal(merged.failures, 0);
|
const d = decideGlobal(defaultState(), CFG, HB, summaries);
|
||||||
assert.ok(merged.sidsAlerted["111"]);
|
assert.equal(d.alerts.length, 1);
|
||||||
assert.equal(merged.hbDate, "2026-08-06");
|
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", () => {
|
test("retirement: one alert at expiry, then a permanent no-op", () => {
|
||||||
const past = new Date("2026-08-10T18:31:00Z");
|
const past = new Date("2026-09-25T18:31:00Z");
|
||||||
const d1 = decide(defaultState(), { kind: "closed", now: past }, CFG);
|
const d1 = decideGlobal(defaultState(), CFG, past, []);
|
||||||
assert.equal(d1.alerts.length, 1);
|
assert.equal(d1.alerts.length, 1);
|
||||||
assert.ok(d1.alerts[0].statePatch.retired);
|
assert.ok(d1.alerts[0].statePatch.retired);
|
||||||
const s1 = apply(defaultState(), d1);
|
const s1 = { ...defaultState(), ...d1.alerts[0].statePatch };
|
||||||
assert.equal(decide(s1, { kind: "closed", now: past }, CFG).alerts.length, 0);
|
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"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a 'not listed yet' note rides through to lastGood and the heartbeat", () => {
|
||||||
|
const d = decideProvider(defaultProviderState(),
|
||||||
|
{ kind: "closed", now: T0, note: "not listed yet (District: content not found)" }, CFG, P);
|
||||||
|
assert.equal(d.alerts.length, 0, "waiting for a listing is not an alert");
|
||||||
|
assert.equal(d.baseState.lastGood.kind, "closed");
|
||||||
|
assert.match(d.baseState.lastGood.note, /not listed yet/);
|
||||||
|
|
||||||
|
const hb = decideGlobal(defaultState(), CFG, HB, [
|
||||||
|
{ label: "District", kind: "closed", note: "not listed yet (District: content not found)" },
|
||||||
|
{ label: "BookMyShow", kind: "closed" },
|
||||||
|
]);
|
||||||
|
assert.match(hb.alerts[0].body, /District: closed, not listed yet/,
|
||||||
|
"the daily heartbeat must say why District sees nothing, or silence is ambiguous");
|
||||||
|
assert.match(hb.alerts[0].body, /BookMyShow: closed$/m);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clearing the note is itself a state change, so the status endpoint stops lying", () => {
|
||||||
|
const withNote = decideProvider(defaultProviderState(), { kind: "closed", now: T0, note: "not listed yet" }, CFG, P).baseState;
|
||||||
|
const after = decideProvider(withNote, { kind: "closed", now: T0 }, CFG, P);
|
||||||
|
assert.equal(after.baseState.lastGood.note, null, "note is dropped once the listing appears");
|
||||||
|
});
|
||||||
|
|
||||||
|
// ------------------------------------------------ withdrawn and re-listed shows
|
||||||
|
//
|
||||||
|
// Cinemas publish early, pull the shows, then re-list them, often under the same
|
||||||
|
// session ids. If dedupe outlives the listing, the re-listing (the moment bookings
|
||||||
|
// really open) is silent. These pin down when a vanished session is forgotten.
|
||||||
|
|
||||||
|
const closed = (now = T0) => ({ kind: "closed", now });
|
||||||
|
const tick = (state, input, p = P) => apply(state, decideProvider(state, input, CFG, p));
|
||||||
|
|
||||||
|
test("a withdrawn session that comes back after 3 missed checks alerts again", () => {
|
||||||
|
let s = tick(defaultProviderState(), openInput(EX_MATCH));
|
||||||
|
assert.ok(s.sidsAlerted["111"], "alerted once");
|
||||||
|
for (let i = 0; i < 3; i++) s = tick(s, closed());
|
||||||
|
assert.equal(s.sidsAlerted["111"], undefined, "forgotten after 3 checks without it");
|
||||||
|
|
||||||
|
const back = decideProvider(s, openInput(EX_MATCH), CFG, P);
|
||||||
|
assert.equal(back.alerts.length, 1, "the re-listing must alert");
|
||||||
|
assert.equal(back.alerts[0].priority, "5");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a session missing for fewer than 3 checks is not re-alerted", () => {
|
||||||
|
let s = tick(defaultProviderState(), openInput(EX_MATCH));
|
||||||
|
s = tick(s, closed());
|
||||||
|
s = tick(s, closed());
|
||||||
|
const back = decideProvider(s, openInput(EX_MATCH), CFG, P);
|
||||||
|
assert.equal(back.alerts.length, 0, "a brief flicker in the response is not a new listing");
|
||||||
|
assert.deepEqual(apply(s, back).missing, {}, "and the miss counter resets once it is seen again");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a fully withdrawn date re-arms after 3 closed checks and says so, once", () => {
|
||||||
|
let s = tick(defaultProviderState(), openInput(EX_MATCH));
|
||||||
|
assert.equal(s.dateOpenAlerted, true);
|
||||||
|
s = tick(s, closed());
|
||||||
|
s = tick(s, closed());
|
||||||
|
const third = decideProvider(s, closed(), CFG, P);
|
||||||
|
const notice = third.alerts.find((a) => /withdrawn/i.test(a.title));
|
||||||
|
assert.ok(notice, "the user should hear that the shows were pulled");
|
||||||
|
assert.ok(notice.title.includes("District"));
|
||||||
|
assert.ok(Number(notice.priority) < 5, "informational, not a booking alert");
|
||||||
|
s = apply(s, third);
|
||||||
|
assert.equal(s.dateOpenAlerted, false, "re-armed");
|
||||||
|
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
assert.equal(decideProvider(s, closed(), CFG, P).alerts.length, 0, "the notice is not repeated");
|
||||||
|
s = tick(s, closed());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the re-arm does not depend on the withdrawal notice being delivered", () => {
|
||||||
|
let s = tick(defaultProviderState(), openInput(EX_MATCH));
|
||||||
|
s = tick(s, closed());
|
||||||
|
s = tick(s, closed());
|
||||||
|
const third = decideProvider(s, closed(), CFG, P);
|
||||||
|
s = third.baseState; // ntfy was down: no alert patches merged
|
||||||
|
assert.equal(s.dateOpenAlerted, false, "re-arming is not gated on the push");
|
||||||
|
assert.equal(s.sidsAlerted["111"], undefined);
|
||||||
|
assert.equal(decideProvider(s, openInput(EX_MATCH), CFG, P).alerts[0].priority, "5",
|
||||||
|
"so a re-listing still gets through even if the notice was lost");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("partial withdrawal forgets only the missing session, with no withdrawal notice", () => {
|
||||||
|
const two = { ...EX_MATCH, matched: [EX_MATCH.matched[0], { ...EX_MATCH.matched[0], sid: "222", time: "1:15 PM" }] };
|
||||||
|
let s = tick(defaultProviderState(), openInput(two));
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const d = decideProvider(s, openInput(EX_MATCH), CFG, P); // 222 gone, date still open
|
||||||
|
assert.equal(d.alerts.filter((a) => /withdrawn/i.test(a.title)).length, 0);
|
||||||
|
s = apply(s, d);
|
||||||
|
}
|
||||||
|
assert.ok(s.sidsAlerted["111"], "the session still listed stays deduped");
|
||||||
|
assert.equal(s.sidsAlerted["222"], undefined, "the pulled one is forgotten");
|
||||||
|
|
||||||
|
const back = decideProvider(s, openInput(two), CFG, P);
|
||||||
|
assert.equal(back.alerts.length, 1);
|
||||||
|
assert.ok(back.alerts[0].body.includes("1:15 PM"), "only the returning session is announced");
|
||||||
|
assert.ok(!back.alerts[0].body.includes("9:00 AM"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("failed checks do not count as a session being missing", () => {
|
||||||
|
let s = tick(defaultProviderState(), openInput(EX_MATCH));
|
||||||
|
for (let i = 0; i < 5; i++) s = tick(s, { kind: "error", reason: "HTTP 403", now: T0 });
|
||||||
|
assert.ok(s.sidsAlerted["111"], "an outage says nothing about whether the show still exists");
|
||||||
|
assert.equal(decideProvider(s, openInput(EX_MATCH), CFG, P).alerts.filter((a) => a.priority === "5").length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("waiting on a date that never opened writes no state", () => {
|
||||||
|
let s = tick(defaultProviderState(), closed());
|
||||||
|
const next = decideProvider(s, closed(), CFG, P);
|
||||||
|
assert.equal(JSON.stringify(apply(s, next)), JSON.stringify(s), "no counters ticking while nothing was ever alerted");
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
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: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
// The 400 branch decides between "waiting for a listing" and "something is wrong",
|
||||||
|
// so it is worth pinning without a network.
|
||||||
|
test("district: 400 code 32 is a waiting state, other 400s are failures", async () => {
|
||||||
|
const realFetch = globalThis.fetch;
|
||||||
|
const stub = (status, body) => {
|
||||||
|
globalThis.fetch = async () => ({ status, json: async () => body });
|
||||||
|
};
|
||||||
|
const p = { movieCode: "X", contentId: "1", cityKey: "c", lat: "1", lng: "2" };
|
||||||
|
try {
|
||||||
|
stub(400, { errors: [], msg: "Content not found", code: 32 });
|
||||||
|
const waiting = await district.check({}, p, "2026-09-25");
|
||||||
|
assert.equal(waiting.kind, "closed");
|
||||||
|
assert.match(waiting.note, /not listed yet/, "the reason must survive for the heartbeat");
|
||||||
|
|
||||||
|
stub(400, { errors: [], msg: "Mandatory search params are missing", code: 3 });
|
||||||
|
await assert.rejects(() => district.check({}, p, "2026-09-25"), /Mandatory search params/,
|
||||||
|
"a real config error must still raise the failure streak");
|
||||||
|
|
||||||
|
stub(500, {});
|
||||||
|
await assert.rejects(() => district.check({}, p, "2026-09-25"), /HTTP 500/);
|
||||||
|
|
||||||
|
stub(204, {});
|
||||||
|
assert.equal((await district.check({}, p, "2026-09-25")).kind, "closed");
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = realFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// A leftover v1 name silently overriding a DISTRICT_* value that was set on purpose is
|
||||||
|
// the worst kind of config bug: the monitor watches the wrong film and still says "closed".
|
||||||
|
test("config: DISTRICT_* wins per field over leftover v1 names", async () => {
|
||||||
|
const { loadConfig, legacyNamesInUse } = await import("../src/config.js");
|
||||||
|
const saved = { ...process.env };
|
||||||
|
try {
|
||||||
|
Object.assign(process.env, {
|
||||||
|
TARGET_DATE: "2026-09-25", NTFY_TOPIC: "t",
|
||||||
|
MOVIE_CODE: "OLDCODE", CONTENT_ID: "187151", CITY_KEY: "ahmedabad",
|
||||||
|
LAT: "23.03", LNG: "72.57", CINEMA_ID: "1032362",
|
||||||
|
DISTRICT_CONTENT_ID: "227928", DISTRICT_CINEMA_ID: "",
|
||||||
|
BMS_EVENT_CODE: "ET1", BMS_REGION_CODE: "AHD",
|
||||||
|
});
|
||||||
|
const d = loadConfig().providers.find((p) => p.key === "district");
|
||||||
|
assert.equal(d.contentId, "227928", "the explicitly set new name must win");
|
||||||
|
assert.equal(d.venueId, "", "an explicitly emptied new name must not fall back to the old one");
|
||||||
|
assert.equal(d.movieCode, "OLDCODE", "fields with no new value still inherit");
|
||||||
|
assert.ok(legacyNamesInUse().includes("MOVIE_CODE"), "and the leftovers are reported");
|
||||||
|
|
||||||
|
delete process.env.DISTRICT_CONTENT_ID;
|
||||||
|
delete process.env.CONTENT_ID;
|
||||||
|
const only = loadConfig().providers;
|
||||||
|
assert.equal(only.find((p) => p.key === "district"), undefined,
|
||||||
|
"no content id means District is simply not watched");
|
||||||
|
assert.equal(only.length, 1, "and the other site carries on alone");
|
||||||
|
} finally {
|
||||||
|
for (const k of Object.keys(process.env)) if (!(k in saved)) delete process.env[k];
|
||||||
|
Object.assign(process.env, saved);
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in new issue
Block a user