diff --git a/.env.example b/.env.example index 3a43a34..ffa776e 100644 --- a/.env.example +++ b/.env.example @@ -1,31 +1,13 @@ -# Copy to .env and fill in. Every value below is read from the environment, -# so you can also set them directly (Docker, systemd, CI) instead of using a file. -# The example values watch IMAX shows of one film at one Ahmedabad cinema. +# Copy to .env and fill in. Every value is read from the environment, so you can set +# these directly (Docker, systemd, CI) instead of using a file. +# +# Configure District, BookMyShow, or both. Both are watched in one process, each with +# its own dedupe and failure tracking, so one site breaking never silences the other. -# ── What to watch ───────────────────────────────────────────────────────────── -# Open the film's page on district.in, then read these off the network request to -# /gw/consumer/movies/v5/movie (see "Finding your IDs" in the README). -MOVIE_CODE=y7EZNLXN5Y # ?frmtid= in the page URL — identifies movie + language -CONTENT_ID=187151 # MV in the page URL -CITY_KEY=ahmedabad # city slug as it appears in the page URL -LAT=23.033938956712916 # city coordinates; the API rejects requests without them -LNG=72.57696301365426 -TARGET_DATE=2026-08-10 # the date you are waiting for (YYYY-MM-DD) - -# Restrict to one cinema. Leave empty to watch every cinema in the city. -CINEMA_ID=1032362 - -# Which shows count as a match. A session matches if it carries FORMAT_TAG *or* if its -# screen format contains FORMAT_MATCH (case-insensitive), so "IMAX" also catches -# "IMAX 3D". Set BOTH to empty to match every show regardless of format. -FORMAT_TAG=imax_2d -FORMAT_MATCH=IMAX +# ── Shared ──────────────────────────────────────────────────────────────────── +TARGET_DATE=2026-09-25 # the date you are waiting for (YYYY-MM-DD) FORMAT_LABEL=IMAX # cosmetic: how the format is named in alerts -# Opened when you tap the notification. -BOOKING_URL=https://www.district.in/movies/the-odyssey-movie-tickets-in-ahmedabad-MV187151?frmtid=y7EZNLXN5Y&fromdate=2026-08-10 - -# ── Notifications (ntfy) ────────────────────────────────────────────────────── # Subscribe to this topic in the ntfy app. Treat it as a password: anyone who knows # it can read your alerts and publish to you. Use a long random string. NTFY_TOPIC=change-me-to-something-random @@ -35,10 +17,41 @@ NTFY_TOPIC=change-me-to-something-random NTFY_URL=https://ntfy.sh # NTFY_TOKEN=tk_... # only if your server (or topic) requires auth +# ── District (district.in) ──────────────────────────────────────────────────── +# Leave DISTRICT_MOVIE_CODE unset to skip District entirely. +# Read these off the movie's page URL, e.g. +# district.in/movies/the-odyssey-movie-tickets-in-ahmedabad-MV187151?frmtid=y7EZNLXN5Y +DISTRICT_MOVIE_CODE=y7EZNLXN5Y # ?frmtid= — identifies movie + language +DISTRICT_CONTENT_ID=187151 # digits after MV +DISTRICT_CITY_KEY=ahmedabad # city slug in the path +DISTRICT_LAT=23.033938956712916 # any coordinates in that city; the API needs them +DISTRICT_LNG=72.57696301365426 +DISTRICT_CINEMA_ID=1032362 # empty = watch every cinema in the city +DISTRICT_FORMAT_TAG=imax_2d # session tag that counts as a match +DISTRICT_FORMAT_MATCH=IMAX # or a screen-format substring; both empty = any show +DISTRICT_BOOKING_URL=https://www.district.in/movies/the-odyssey-movie-tickets-in-ahmedabad-MV187151?frmtid=y7EZNLXN5Y&fromdate=2026-09-25 + +# ── BookMyShow (in.bookmyshow.com) ──────────────────────────────────────────── +# Leave BMS_EVENT_CODE unset to skip BookMyShow entirely. +# From the movie URL, e.g. in.bookmyshow.com/movies/ahmedabad/avengers-endgame-encore/ET00514163 +BMS_EVENT_CODE=ET00514163 # the ET... code +# BMS_REF_EVENT_CODE= # only if the URL's refEventCode differs from above +BMS_REGION_CODE=AHD # BMS region code (AHD, BANG, MUMBAI, NCR ...) +BMS_REGION_SLUG=ahmedabad # city slug in the path +BMS_LAT=23.0395677 +BMS_LNG=72.5660045 +BMS_VENUE_CODE=PPAM # empty = watch every cinema in the region +# BMS_LANGUAGE=hindi # only if you want one language's shows +BMS_FORMAT_MATCH=IMAX # matched against the screen format; empty = any show +BMS_BOOKING_URL=https://in.bookmyshow.com/movies/ahmedabad/avengers-endgame-encore/ET00514163 + +# Watch only some of the configured providers. Unset = every provider you configured. +# PROVIDERS=district,bookmyshow + # ── Behaviour ───────────────────────────────────────────────────────────────── CHECK_INTERVAL_SECONDS=60 # how often to poll; don't go lower without good reason FAILURE_THRESHOLD=6 # consecutive failures before the "monitor broken" alert -TZ_OFFSET_MINUTES=330 # showtimes are rendered in this offset (330 = IST) +TZ_OFFSET_MINUTES=330 # timezone used for showtimes and dates (330 = IST) HEARTBEAT_HOUR=9 # daily "still alive" push at this local hour; -1 disables CURRENCY=₹ # EXPIRY_UTC= # auto: local midnight after TARGET_DATE. Override to extend. @@ -47,6 +60,7 @@ CURRENCY=₹ STATE_PATH=./state.json # Docker image defaults this to /data/state.json PORT=4733 # status endpoint (--loop mode only) -# Routes only the site checks through a SOCKS5 proxy; notifications stay direct. -# Needed on datacenter hosts, whose IPs the site's WAF is likely to block. +# Routes District checks through a SOCKS5 proxy; notifications stay direct. +# Needed on datacenter hosts, whose IPs District's WAF blocks. It does nothing for +# BookMyShow, which blocks on TLS fingerprint rather than IP (impit handles that). # PROXY_URL=socks5://user:pass@proxy.example.net:1080 diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 5cd8afc..ee868e4 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -39,6 +39,26 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + # Build amd64 first and actually start it. The native TLS-impersonation binary + # only fails at runtime, so a plain build would happily publish a broken image. + - uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64 + load: true + tags: ticket-monitoring:smoke + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Smoke test the image + run: | + docker run --rm ticket-monitoring:smoke \ + node -e "import('impit').then(m => { if (!m.Impit) { throw new Error('impit missing'); } console.log('impit loaded'); })" + docker run --rm --env-file smoke.env ticket-monitoring:smoke \ + node src/index.js 2>&1 | tee /tmp/smoke.log + grep -q "\[district\]" /tmp/smoke.log + grep -q "\[bookmyshow\]" /tmp/smoke.log + - uses: docker/build-push-action@v6 with: context: . diff --git a/README.md b/README.md index 910b116..a36dff6 100644 --- a/README.md +++ b/README.md @@ -2,36 +2,41 @@ [![docker](https://github.com/aculix/ticket-monitoring/actions/workflows/docker.yml/badge.svg)](https://github.com/aculix/ticket-monitoring/actions/workflows/docker.yml) -Watches [district.in](https://www.district.in) showtimes and sends a max-priority push to your -phone the second tickets go on sale for the movie, date, format and cinema you care about. +Watches [District](https://www.district.in) and [BookMyShow](https://in.bookmyshow.com) +showtimes and sends a max-priority push to your phone the second tickets go on sale for the +movie, date, format and cinema you care about. ![Example notification: IMAX shows for a target date, with showtimes, seat counts and prices](docs/alert.png) -I built this because IMAX shows kept selling out before I even knew they'd opened. Refreshing the -page a few times a day wasn't working. Now something else checks every 60 seconds and yells at me -when it matters. +I built this because IMAX shows kept selling out before I even knew they'd opened. Refreshing +the page a few times a day wasn't working. Now something else checks every 60 seconds and yells +at me when it matters. ## How it works -Every 60 seconds it calls the same showtimes endpoint the District site uses itself. A date that -isn't on sale yet answers with `204 No Content`. The moment it opens, you get the full session -list back. The monitor filters that list down to your cinema and format, then pushes to +Every 60 seconds it calls the same showtime APIs the two sites use themselves, and filters the +result down to your cinema and format. When something matches, it pushes to [ntfy](https://ntfy.sh), a free open-source notification service with iOS and Android apps. -The part I care most about: silence always means "not open yet". If checks start failing you get a -*Monitor BROKEN* push after six failures in a row, plus an optional daily heartbeat. A monitor -that died at 3am and one that's quietly working should never look the same from the outside. +Both sites can be watched at once, in one process. Each keeps its own dedupe and failure state, +so tickets appearing on one site never suppress the alert for the other, and one site going down +doesn't stop the other from being checked. -Alerts are deduplicated by session id in a small state file, so you hear about each show once. -Shows added later still get through. +The part I care most about: silence always means "not open yet". If checks start failing you get +a *BROKEN* push after six failures in a row, naming the site that's failing, plus an optional +daily heartbeat. A monitor that died at 3am and one that's quietly working should never look the +same from the outside. + +Alerts are deduplicated by session id, so you hear about each show once. Shows added later still +get through. | Notification | Priority | What it means | |---|---|---| -| ` OPEN - BOOK NOW` | max | The one you're waiting for. Tap it to book. | -| ` bookings are OPEN (no yet)` | high | Date opened, your format hasn't shown up yet. | -| `Monitor BROKEN - check manually` | high | Checks are failing. Don't trust the silence. | -| `Monitor recovered` | default | Back to normal. | -| `Still watching ` | min | Daily heartbeat. | +| ` OPEN on - BOOK NOW` | max | The one you're waiting for. Tap it to book. | +| ` is OPEN on (no yet)` | high | Date opened, your format hasn't shown up yet. | +| ` checks BROKEN - check manually` | high | That site's checks are failing. Don't trust the silence. | +| ` checks recovered` | default | Back to normal. | +| `Still watching ` | min | Daily heartbeat, with a line per site. | | `Monitor retired` | default | Target date passed, so it stopped on its own. | ## Requirements @@ -44,8 +49,8 @@ A prebuilt image is published to GitHub Container Registry on every push to `mai `linux/amd64` and `linux/arm64`. It runs as-is on a normal server, an Apple Silicon Mac or a Raspberry Pi, with nothing to clone and nothing to build. -Grab [`.env.example`](.env.example), save it as `.env`, fill it in using "Finding your IDs" below, -then: +Grab [`.env.example`](.env.example), save it as `.env`, fill it in using "Finding your IDs" +below, then: ```bash docker run -d --name ticket-monitoring \ @@ -60,11 +65,11 @@ docker run -d --name ticket-monitoring \ docker logs -f ticket-monitoring ``` -You want to see `tick: closed failures: 0` scrolling past once a minute. +You want to see a line per site once a minute, like `[district] closed | matched: 0 | failures: 0`. -The volume is what stops it re-alerting you about shows it already found, so keep it around across -restarts. Drop the `-p` flag if you don't want the status endpoint reachable. To update, pull the -image again and recreate the container: +The volume is what stops it re-alerting you about shows it already found, so keep it around +across restarts. Drop the `-p` flag if you don't want the status endpoint reachable. To update, +pull the image again and recreate the container: ```bash docker pull ghcr.io/aculix/ticket-monitoring:latest @@ -90,7 +95,7 @@ docker compose logs -f ```bash npm install cp .env.example .env # edit it -npm run check # one check, tells you what it found +npm run check # one check against every configured site npm start # run it for real ``` @@ -100,79 +105,118 @@ support. ## Finding your IDs -Everything movie-specific is sitting in one URL. Open the film's District page for your city: +Configure either site, or both. Skip a site by leaving its main ID unset: no +`DISTRICT_MOVIE_CODE` means District isn't watched, no `BMS_EVENT_CODE` means BookMyShow isn't. + +### District + +Everything is sitting in the movie's page URL for your city: ``` -https://www.district.in/movies/the-odyssey-movie-tickets-in-ahmedabad-MV187151?frmtid=y7EZNLXN5Y&fromdate=2026-08-10 +https://www.district.in/movies/the-odyssey-movie-tickets-in-ahmedabad-MV187151?frmtid=y7EZNLXN5Y&fromdate=2026-09-25 └── CITY_KEY ──┘ └CONTENT_ID┘ └─MOVIE_CODE─┘ ``` -- `CONTENT_ID` is the number after `MV` -- `MOVIE_CODE` is the `frmtid` parameter, which pins down the movie *and* the language -- `CITY_KEY` is the city slug in the path -- `LAT` and `LNG` can be any coordinates inside that city. The API refuses to answer without them, - though results are city-wide either way. -- `BOOKING_URL` is the whole URL with `fromdate` set to your target date +- `DISTRICT_CONTENT_ID` is the number after `MV` +- `DISTRICT_MOVIE_CODE` is the `frmtid` parameter, which pins down the movie *and* the language +- `DISTRICT_CITY_KEY` is the city slug in the path +- `DISTRICT_LAT` and `DISTRICT_LNG` can be any coordinates inside that city. The API refuses to + answer without them, though results are city-wide either way. +- `DISTRICT_BOOKING_URL` is the whole URL with `fromdate` set to your target date -`CINEMA_ID` is the fiddly one, since it isn't in the URL. Easiest approach: leave it empty at -first, which watches every cinema in the city, then run `npm run check` against a date that's -already bookable and read the ids out of the response. Or just leave it empty for good and let the -alert tell you where the shows are. +### BookMyShow -Want any format, not only IMAX? Set both `FORMAT_TAG` and `FORMAT_MATCH` to empty. +From the movie's page URL: + +``` +https://in.bookmyshow.com/movies/ahmedabad/avengers-endgame-encore/ET00514163 + └REGION_SLUG┘ └EVENT_CODE┘ +``` + +- `BMS_EVENT_CODE` is the `ET...` code +- `BMS_REGION_SLUG` is the city slug, and `BMS_REGION_CODE` is the short code BMS uses internally + (`AHD` for Ahmedabad). If you don't know yours, open a showtimes page for your city and look at + the `regionCode` parameter in the network request, or try the obvious abbreviation. +- `BMS_REF_EVENT_CODE` only matters when a URL carries a `refEventCode` that differs from the + event code, which happens for format-specific listings. Leave it unset otherwise. + +### Both + +`DISTRICT_CINEMA_ID` and `BMS_VENUE_CODE` restrict alerts to one cinema. The fiddly part is that +neither is in the URL. Easiest approach: leave them empty at first, which watches every cinema in +the city, run `npm run check`, and read the ids out of the output. Or leave them empty for good +and let the alert tell you where the shows are. + +Want any format, not only IMAX? Clear `DISTRICT_FORMAT_TAG`, `DISTRICT_FORMAT_MATCH` and +`BMS_FORMAT_MATCH`. ## Configuration -Everything is environment variables. `.env` is only a convenient place to put them. +Everything is environment variables. `.env` is only a convenient place to put them. Full list +with comments in [`.env.example`](.env.example). | Variable | Default | Purpose | |---|---|---| -| `MOVIE_CODE` | *required* | `frmtid`, identifies movie and language | -| `CONTENT_ID` | *required* | Movie id, the digits after `MV` | -| `CITY_KEY` | *required* | City slug, e.g. `ahmedabad` | -| `LAT`, `LNG` | *required* | Coordinates somewhere in that city | | `TARGET_DATE` | *required* | The date you're waiting for, `YYYY-MM-DD` | | `NTFY_TOPIC` | *required* | The topic you subscribe to. Treat it like a password. | -| `CINEMA_ID` | *(any)* | Restrict to one cinema | -| `FORMAT_TAG` | `imax_2d` | Session tag that counts as a match | -| `FORMAT_MATCH` | `IMAX` | Screen-format substring that counts as a match | +| `DISTRICT_MOVIE_CODE` | | Set to watch District. Plus `DISTRICT_CONTENT_ID`, `DISTRICT_CITY_KEY`, `DISTRICT_LAT`, `DISTRICT_LNG` | +| `DISTRICT_CINEMA_ID` | *(any)* | Restrict District to one cinema | +| `DISTRICT_FORMAT_TAG` | `imax_2d` | Session tag that counts as a match | +| `DISTRICT_FORMAT_MATCH` | `IMAX` | Screen-format substring that counts as a match | +| `DISTRICT_BOOKING_URL` | | Opens when you tap a District notification | +| `BMS_EVENT_CODE` | | Set to watch BookMyShow. Plus `BMS_REGION_CODE`, `BMS_REGION_SLUG` | +| `BMS_VENUE_CODE` | *(any)* | Restrict BookMyShow to one cinema | +| `BMS_FORMAT_MATCH` | `IMAX` | Screen-format substring that counts as a match | +| `BMS_LANGUAGE` | *(all)* | Restrict to one language's shows | +| `BMS_BOOKING_URL` | | Opens when you tap a BookMyShow notification | +| `PROVIDERS` | *(all configured)* | Allow-list, e.g. `district` to temporarily watch only one | | `FORMAT_LABEL` | `IMAX` | What the format is called in the alert text | -| `BOOKING_URL` | | Opens when you tap the notification | | `NTFY_URL` | `https://ntfy.sh` | Your own ntfy server, if you run one | | `NTFY_TOKEN` | | Only if your server wants auth | | `CHECK_INTERVAL_SECONDS` | `60` | Poll interval | | `FAILURE_THRESHOLD` | `6` | Failures in a row before the BROKEN alert | -| `TZ_OFFSET_MINUTES` | `330` | Timezone for showtimes, 330 is IST | +| `TZ_OFFSET_MINUTES` | `330` | Timezone for showtimes and dates, 330 is IST | | `HEARTBEAT_HOUR` | `9` | Local hour for the daily heartbeat, `-1` turns it off | | `CURRENCY` | `₹` | Price prefix | | `EXPIRY_UTC` | *auto* | When to retire. Defaults to local midnight after `TARGET_DATE`. | | `STATE_PATH` | `./state.json` | Where dedupe state lives, `/data/state.json` in Docker | | `PORT` | `4733` | Status endpoint | -| `PROXY_URL` | | SOCKS5 proxy for the site checks, see below | +| `PROXY_URL` | | SOCKS5 proxy for District checks, see below | + +v1 used flat names (`MOVIE_CODE`, `CINEMA_ID` and friends) for District. Those still work, so an +existing `.env` keeps running unchanged. ## Status endpoint While running continuously it serves JSON on `PORT`, which defaults to 4733: -- `/` gives you state, `lastTick` and `proxied` -- `/?force=1` adds a live check right now, with no notifications and no state changes +- `/` gives you state and `lastTick` per site, plus `proxied` +- `/?force=1` adds a live check of every site right now, with no notifications and no state changes - `/healthz` returns `{"ok":true}` ```bash curl -s localhost:4733/ | jq ``` -Watch `lastTick.at`. If it's more than a couple of minutes old, the loop is stuck. +Watch `lastTick`. If a site's entry is more than a couple of minutes old, the loop is stuck. ## Troubleshooting -### Every check returns HTTP 403 +### District returns HTTP 403 -The site's WAF is blocking your host. This happens reliably on cloud providers. An earlier version -of this ran as a Cloudflare Worker, worked fine for two days, then got blocked permanently, while -a home connection was never touched once. Either run it from a residential connection, or point -`PROXY_URL` at a SOCKS5 proxy. Only the site checks go through the proxy. Notifications stay -direct, so your alerts don't depend on it. +District's WAF is blocking your host. This happens reliably on cloud providers. An earlier +version of this ran as a Cloudflare Worker, worked fine for two days, then got blocked +permanently, while a home connection was never touched once. Either run it from a residential +connection, or point `PROXY_URL` at a SOCKS5 proxy. Only the site checks go through the proxy. +Notifications stay direct, so your alerts don't depend on it. + +### BookMyShow returns HTTP 403 + +BookMyShow blocks on the TLS fingerprint of the client, not on your IP, which is why plain +`curl` and Node's `fetch` both get 403 no matter which headers or cookies you send. A proxy +won't help either. This is handled with [impit](https://github.com/apify/impit), which performs +the request with a real Chrome fingerprint. If you see 403s here, the impersonation has probably +stopped matching, so try updating the image or `npm update impit`. ### ntfy returns HTTP 429 @@ -186,9 +230,9 @@ messages through Do Not Disturb. ### Alerts repeat, or never arrive again -Dedupe lives in the state file at `STATE_PATH`, or in the `data/` volume under Docker. Delete it to -start fresh. Do this whenever you change `TARGET_DATE`, otherwise it still thinks it has already -told you about those shows. +Dedupe lives in the state file at `STATE_PATH`, or in the `data/` volume under Docker. Delete it +to start fresh. Do this whenever you change `TARGET_DATE`, otherwise it still thinks it has +already told you about those shows. ### Nothing happens at all @@ -197,24 +241,31 @@ Run `npm run check` for a single noisy check. Configuration problems are reporte ## Development ```bash -npm test # 16 tests, no network needed +npm test # 24 tests, no network needed ``` -The tests run the alert state machine and session parsing against a real captured API response in -`test/fixtures/`. `src/monitor.js` has the pure logic and the API client, `src/index.js` is the -runner plus state file and status server, `src/config.js` reads the environment. +The tests run the alert state machine and both site parsers against real captured API responses +in `test/fixtures/`. Layout: -If you're forking this for another site, [docs/design-notes.md](docs/design-notes.md) covers what I -learned about the API and why the alert logic is shaped the way it is. +- `src/providers/district.js` and `src/providers/bookmyshow.js` fetch and normalize showtimes. + Each exports `check()` and `extract()`, and nothing else knows which site a session came from. +- `src/monitor.js` decides what to alert about and when to stay quiet +- `src/index.js` is the runner, state file and status server +- `src/config.js` reads the environment +- `src/lib.js` has the shared time helpers + +Adding a third site means writing one file in `src/providers/` and registering it in +`config.js`. [docs/design-notes.md](docs/design-notes.md) covers what I learned about both APIs +and why the alert logic is shaped the way it is. ## Notes -This polls a public endpoint once a minute, which is roughly what a person refreshing the page +This polls public endpoints once a minute, which is roughly what a person refreshing the page would do, to buy their own tickets. It doesn't automate purchases, hold seats or skip any queue. Please keep it that way: don't drop the interval, don't run ten copies, don't use it to scalp. -Not affiliated with District, PVR or IMAX. The API is undocumented and can change whenever they -feel like it. +Not affiliated with District, BookMyShow, PVR or IMAX. Both APIs are undocumented and can change +whenever they feel like it. ## License diff --git a/docs/design-notes.md b/docs/design-notes.md index 31b39c0..71268f3 100644 --- a/docs/design-notes.md +++ b/docs/design-notes.md @@ -3,7 +3,11 @@ Why this is built the way it is, and what broke along the way. Useful if you're forking it, or writing something similar against a different site. -## The API +Two sites are supported. Each lives in `src/providers/` and exposes the same two functions, +`check()` and `extract()`, returning sessions in one normalized shape. Everything above that +layer, including the alert state machine, has no idea which site a session came from. + +## District: the API District's movie pages are server-rendered Next.js, but showtimes load client-side from: @@ -29,9 +33,42 @@ Findings that shaped the design: `204` while closed, the daily heartbeat makes one extra call for *today* purely to report how far ahead bookings currently run. That probe is best-effort and never counts as a failure. +## BookMyShow: the API + +``` +GET /api/movies-data/v5/showtimes-by-event/primary-dynamic + ?etCodes=&dateCode=YYYYMMDD®ionCode=AHD + &isDesktop=true&xLocationShared=false&memberId=&lsId=&subCode= + &appCode=WEB&refEventCode=[&language=hindi] +``` + +Sent with `x-region-code`, `x-region-slug`, `x-latitude`, `x-longitude`, `x-app-code: WEB` and +an `x-bms-id` that, like District's guest token, the web client makes up locally. + +- **Cloudflare blocks on TLS fingerprint, not IP or headers.** curl and Node's `fetch` both get + 403 with a byte-perfect copy of the browser's headers, and no `cf_clearance` cookie is involved, + so there is nothing to copy across. A proxy does not help either, since the block happens during + the TLS handshake. [impit](https://github.com/apify/impit) performs the request with a real + Chrome fingerprint and returns 200. It ships prebuilt binaries for linux x64 and arm64 in both + gnu and musl flavours, which is what keeps the Alpine image and the arm64 build working. +- **There is no 204.** A date that is not on sale answers 200 with a short body containing only + `header`, `additionalData` and `addOnWidgets`. The tell is that `data.showtimeWidgets` is + absent, so "closed" is a property of the payload rather than the status code. +- **A past or invalid date returns 400**, which counts as a failure rather than "closed". That is + deliberate: it means something is wrong with the configuration, and you should hear about it. +- **Sessions are buried in generic UI widgets.** Venue cards sit several layers down inside + `showtimeWidgets`, so the parser walks the tree looking for `type: "venue-card"` instead of + indexing through fixed positions, which would break the moment BMS reorders its layout. +- **Seat detail lives in a bottom sheet.** Each showtime carries a `customGestureCTA` describing + the panel shown on double-tap, and that is the only place per-tier prices appear. BMS reports + availability as words (`AVAILABLE`, `FILLING FAST`, `ALMOST FULL`) and never as exact counts, + unlike District, so the normalized session leaves `avail` and `total` null and alert lines omit + the seat figures for this site. +- **Times are already local**, so unlike District's UTC timestamps they are passed through as-is. + ## Alert state machine -`decide()` in `src/monitor.js` is pure: state in, `{ baseState, alerts }` out. That makes every +`decideProvider()` in `src/monitor.js` is pure: state in, `{ baseState, alerts }` out. That makes every edge case testable without network or clock mocking, and it's where the interesting rules live. - **Publish before persist.** Each alert carries its own `statePatch`, merged only after that @@ -45,9 +82,13 @@ edge case testable without network or clock mocking, and it's where the interest - **State is written only when it actually changed.** Originally this ran on Cloudflare KV with a 1,000 writes/day free limit against 1,440 daily ticks, which forced the discipline; it's still the right behaviour on a filesystem. +- **State is namespaced per site.** Each provider gets its own dedupe map, failure counter and + BROKEN flag under `state.providers.`, so the same session id can alert once per site and a + site going down never marks the other as broken. Retirement and the daily heartbeat are the only + tick-level decisions, and they live in `decideGlobal()`. The guiding rule throughout: **degrade toward duplicate notifications, never toward silence.** -Every failure path — unreadable state, failed push, failed write — errs that way. +Every failure path, whether it is unreadable state, a failed push or a failed write, errs that way. ## What broke in production diff --git a/package-lock.json b/package-lock.json index 8f14003..91db2bd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "fetch-socks": "^1.3.3", + "impit": "^0.14.4", "undici": "^8.10.0" }, "engines": { @@ -24,6 +25,153 @@ "undici": ">=7" } }, + "node_modules/impit": { + "version": "0.14.4", + "resolved": "https://registry.npmjs.org/impit/-/impit-0.14.4.tgz", + "integrity": "sha512-XXAafVQpWIYIchSxT6UufZgVNLewKVg9BJSwQYom7b1VWt/l0m+GPMhc4agK9O7QlAnEiFm1QQcX2RpsmLV6gQ==", + "license": "Apache-2.0", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "impit-darwin-arm64": "0.14.4", + "impit-darwin-x64": "0.14.4", + "impit-linux-arm64-gnu": "0.14.4", + "impit-linux-arm64-musl": "0.14.4", + "impit-linux-x64-gnu": "0.14.4", + "impit-linux-x64-musl": "0.14.4", + "impit-win32-arm64-msvc": "0.14.4", + "impit-win32-x64-msvc": "0.14.4" + } + }, + "node_modules/impit-darwin-arm64": { + "version": "0.14.4", + "resolved": "https://registry.npmjs.org/impit-darwin-arm64/-/impit-darwin-arm64-0.14.4.tgz", + "integrity": "sha512-i9fkPXXCB/1qBTGIlS26Z2krmE0wk/1UUlDUFeE1bMtihoUIe8TbeslSnx6pgaSY5ai9H0seUAgS9D4i66Kscg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/impit-darwin-x64": { + "version": "0.14.4", + "resolved": "https://registry.npmjs.org/impit-darwin-x64/-/impit-darwin-x64-0.14.4.tgz", + "integrity": "sha512-p+W0enCZDCsnSwEWOMImpHq71jmVR7J9CjYvqgiFtQD3W+rQb7ilFLYYRalQ0rmrrH8imLRm2skG6V7YQkPwOg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/impit-linux-arm64-gnu": { + "version": "0.14.4", + "resolved": "https://registry.npmjs.org/impit-linux-arm64-gnu/-/impit-linux-arm64-gnu-0.14.4.tgz", + "integrity": "sha512-9VVb905sNSPh0dF2sDFbbjSz5JqXCoMPxTery77PyqMCEuxybtW+Yv3U1tCM+CFKSuC39wXmueW+FLwOCS+zIw==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/impit-linux-arm64-musl": { + "version": "0.14.4", + "resolved": "https://registry.npmjs.org/impit-linux-arm64-musl/-/impit-linux-arm64-musl-0.14.4.tgz", + "integrity": "sha512-PajwrCPCWPey4wjLSWSVFS/mRQXbh8WVrxXdgo6Ej530dyplxIiqcDh0Gr9fWYPriOC8pkcOpRqQ4O+1mH1xfQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/impit-linux-x64-gnu": { + "version": "0.14.4", + "resolved": "https://registry.npmjs.org/impit-linux-x64-gnu/-/impit-linux-x64-gnu-0.14.4.tgz", + "integrity": "sha512-Ly8CweOZ9Qj6BZNhvglJMo56i7tCF7Nw33OdXLZLZRxnBzxd2kI/LXpFsd1LiMkTDaiuLvqLGo7ZpxVt08LdwA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/impit-linux-x64-musl": { + "version": "0.14.4", + "resolved": "https://registry.npmjs.org/impit-linux-x64-musl/-/impit-linux-x64-musl-0.14.4.tgz", + "integrity": "sha512-dS9wj2phkB+o8OFdWBIFYx23ExH+pcsFMiXmoOwRVdDfUX9reZTmZL4KaiPuhSEBysN/wd46dxB/OzNbnn5hnQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/impit-win32-arm64-msvc": { + "version": "0.14.4", + "resolved": "https://registry.npmjs.org/impit-win32-arm64-msvc/-/impit-win32-arm64-msvc-0.14.4.tgz", + "integrity": "sha512-Nga0z+Hmp/HmxEKX0jv1wy2vaT4J44nE6+Z+CNZvvyVWiodWCG9+FvVn4McivgXix7UerspeyPvc174QBHwXEw==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/impit-win32-x64-msvc": { + "version": "0.14.4", + "resolved": "https://registry.npmjs.org/impit-win32-x64-msvc/-/impit-win32-x64-msvc-0.14.4.tgz", + "integrity": "sha512-v15fn/kcjLEzALB4z+VWTeG1UrtqZQq9n2HNsRUfUgfcgzM1i+KKtKP2RCJ1Fg1YeojQ7B6XGhRtpVVTjh8u+A==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/ip-address": { "version": "10.4.0", "license": "MIT", diff --git a/package.json b/package.json index d7ce113..73c4d6f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ticket-monitoring", - "version": "1.0.0", - "description": "Watches district.in showtimes and sends a phone notification the moment tickets for a specific movie, date, format and cinema go on sale.", + "version": "2.0.0", + "description": "Watches district.in and BookMyShow showtimes and sends a phone notification the moment tickets for a specific movie, date, format and cinema go on sale.", "license": "MIT", "type": "module", "engines": { @@ -14,6 +14,7 @@ }, "dependencies": { "fetch-socks": "^1.3.3", + "impit": "^0.14.4", "undici": "^8.10.0" } } diff --git a/smoke.env b/smoke.env new file mode 100644 index 0000000..532b223 --- /dev/null +++ b/smoke.env @@ -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 diff --git a/src/config.js b/src/config.js index 40af006..0730579 100644 --- a/src/config.js +++ b/src/config.js @@ -1,5 +1,12 @@ -// Configuration is read entirely from environment variables (see .env.example). -// Load a .env file with Node's built-in flag: node --env-file-if-exists=.env src/index.js +// Configuration comes entirely from environment variables (see .env.example). +// Load a .env file with Node's built-in flag: +// node --env-file-if-exists=.env src/index.js +import * as district from "./providers/district.js"; +import * as bookmyshow from "./providers/bookmyshow.js"; + +const MODULES = { district, bookmyshow }; + +const env = (name, fallback = "") => process.env[name] ?? fallback; function required(name) { const v = process.env[name]; @@ -15,6 +22,55 @@ const num = (name, fallback) => { return n; }; +/** Old single-provider variable names, still honoured so v1 .env files keep working. */ +function legacyDistrict() { + if (!process.env.MOVIE_CODE || process.env.DISTRICT_MOVIE_CODE) return null; + return { + movieCode: env("MOVIE_CODE"), + contentId: env("CONTENT_ID"), + cityKey: env("CITY_KEY"), + lat: env("LAT"), + lng: env("LNG"), + venueId: env("CINEMA_ID"), + formatTag: process.env.FORMAT_TAG ?? "imax_2d", + formatMatch: process.env.FORMAT_MATCH ?? "IMAX", + bookingUrl: env("BOOKING_URL"), + }; +} + +function districtConfig() { + const legacy = legacyDistrict(); + if (legacy) return legacy; + if (!process.env.DISTRICT_MOVIE_CODE) return null; + return { + movieCode: required("DISTRICT_MOVIE_CODE"), + contentId: required("DISTRICT_CONTENT_ID"), + cityKey: required("DISTRICT_CITY_KEY"), + lat: required("DISTRICT_LAT"), + lng: required("DISTRICT_LNG"), + venueId: env("DISTRICT_CINEMA_ID"), + formatTag: process.env.DISTRICT_FORMAT_TAG ?? "imax_2d", + formatMatch: process.env.DISTRICT_FORMAT_MATCH ?? "IMAX", + bookingUrl: env("DISTRICT_BOOKING_URL"), + }; +} + +function bookmyshowConfig() { + if (!process.env.BMS_EVENT_CODE) return null; + return { + eventCode: required("BMS_EVENT_CODE"), + refEventCode: env("BMS_REF_EVENT_CODE"), + regionCode: required("BMS_REGION_CODE"), + regionSlug: env("BMS_REGION_SLUG", "").toLowerCase(), + lat: env("BMS_LAT", "0"), + lng: env("BMS_LNG", "0"), + venueId: env("BMS_VENUE_CODE"), + language: env("BMS_LANGUAGE"), + formatMatch: process.env.BMS_FORMAT_MATCH ?? "IMAX", + bookingUrl: env("BMS_BOOKING_URL"), + }; +} + export function loadConfig() { const targetDate = required("TARGET_DATE"); if (!/^\d{4}-\d{2}-\d{2}$/.test(targetDate)) { @@ -26,37 +82,48 @@ export function loadConfig() { const expiryUtc = process.env.EXPIRY_UTC || new Date(Date.parse(`${targetDate}T00:00:00Z`) + 86400000 - tzOffsetMinutes * 60000).toISOString(); + const built = { district: districtConfig(), bookmyshow: bookmyshowConfig() }; + // PROVIDERS is an explicit allow-list; without it, every configured provider runs. + const wanted = env("PROVIDERS").split(",").map((s) => s.trim()).filter(Boolean); + for (const w of wanted) { + if (!MODULES[w]) throw new Error(`Unknown provider "${w}" in PROVIDERS. Valid: ${Object.keys(MODULES).join(", ")}`); + } + const keys = (wanted.length ? wanted : Object.keys(built)).filter((k) => built[k]); + if (!keys.length) { + throw new Error("No provider configured. Set DISTRICT_MOVIE_CODE and/or BMS_EVENT_CODE (see .env.example)"); + } + for (const w of wanted) { + if (!built[w]) throw new Error(`PROVIDERS lists "${w}" but its settings are missing (see .env.example)`); + } + + const providers = keys.map((k) => ({ + key: k, + label: MODULES[k].label, + module: MODULES[k], + ...built[k], + })); + return { - // what to watch - movieCode: required("MOVIE_CODE"), - contentId: required("CONTENT_ID"), - cityKey: required("CITY_KEY"), - lat: required("LAT"), - lng: required("LNG"), targetDate, - cinemaId: process.env.CINEMA_ID || "", // empty = any cinema - formatTag: process.env.FORMAT_TAG ?? "imax_2d", // empty + empty match = any format - formatMatch: process.env.FORMAT_MATCH ?? "IMAX", - formatLabel: process.env.FORMAT_LABEL || "IMAX", - bookingUrl: process.env.BOOKING_URL || "", + providers, + formatLabel: env("FORMAT_LABEL", "IMAX"), + // Used by alerts that are not tied to one provider (heartbeat, retirement). + bookingUrl: providers[0].bookingUrl || "", - // notifications - ntfyUrl: (process.env.NTFY_URL || "https://ntfy.sh").replace(/\/+$/, ""), + ntfyUrl: env("NTFY_URL", "https://ntfy.sh").replace(/\/+$/, ""), ntfyTopic: required("NTFY_TOPIC"), - ntfyToken: process.env.NTFY_TOKEN || "", + ntfyToken: env("NTFY_TOKEN"), - // behaviour intervalMs: num("CHECK_INTERVAL_SECONDS", 60) * 1000, failureThreshold: num("FAILURE_THRESHOLD", 6), tzOffsetMinutes, heartbeatHour: num("HEARTBEAT_HOUR", 9), // -1 disables the daily heartbeat - currency: process.env.CURRENCY || "₹", + currency: env("CURRENCY", "₹"), expiryUtc, - // runtime - statePath: process.env.STATE_PATH || "./state.json", + statePath: env("STATE_PATH", "./state.json"), port: num("PORT", 4733), - proxyUrl: process.env.PROXY_URL || "", + proxyUrl: env("PROXY_URL"), dispatcher: null, // set by attachProxy() when PROXY_URL is present }; } diff --git a/src/index.js b/src/index.js index a89c78d..b04f117 100644 --- a/src/index.js +++ b/src/index.js @@ -1,18 +1,20 @@ #!/usr/bin/env node -// Entry point. Runs one check (default) or loops forever with a status server (--loop). +// Entry point. Runs one check across every configured provider (default), or loops +// forever with a status server (--loop). import { createServer } from "node:http"; import { readFileSync, writeFileSync, renameSync, mkdirSync } from "node:fs"; import { dirname } from "node:path"; import { loadConfig } from "./config.js"; +import { localDate } from "./lib.js"; import { - decide, defaultState, extractSessions, checkDistrict, notify, heartbeatDue, localDate, + decideProvider, decideGlobal, defaultState, providerState, heartbeatDue, notify, } from "./monitor.js"; const ts = () => new Date().toISOString(); -// Last tick's outcome, surfaced by the status server. Persisted state records only +// Last tick per provider, surfaced by the status server. Persisted state records only // transitions, so "is it ticking right now" has to live in memory. -const lastTick = { at: null, kind: null, reason: null }; +const lastTick = {}; function readState(cfg) { try { @@ -30,9 +32,10 @@ function writeState(cfg, state) { } /** - * Routes District requests through a SOCKS5 proxy. Notifications stay direct, so the - * alerting path never depends on the proxy. Needed on datacenter hosts whose IPs the - * site's WAF blocks. + * Routes provider requests through a SOCKS5 proxy. Notifications stay direct, so the + * alerting path never depends on the proxy. District needs this on datacenter hosts whose + * IP the WAF blocks. It does nothing for BookMyShow, which blocks on TLS fingerprint + * rather than IP; impit handles that instead. */ async function attachProxy(cfg) { if (!cfg.proxyUrl) return; @@ -51,49 +54,81 @@ async function attachProxy(cfg) { console.log(ts(), `checks routed via SOCKS5 ${u.hostname}:${u.port}`); } +/** Check one provider and return its alerts plus the next sub-state. */ +async function tickProvider(cfg, p, state, now) { + const pstate = providerState(state, p.key); + + // Best-effort daily probe, only from providers that support it and only on the + // heartbeat tick. Its failure never counts toward the failure streak. + let probeMax = null; + if (p.module.probeShowDates && heartbeatDue(state, now, cfg) && !pstate.brokenAlerted) { + try { + probeMax = await p.module.probeShowDates(cfg, p, localDate(now, cfg.tzOffsetMinutes)); + } catch (err) { + console.error(ts(), `[${p.key}] showDates probe failed (best-effort):`, err.message); + } + } + + let input; + try { + const res = await p.module.check(cfg, p, cfg.targetDate); + input = res.kind === "open" + ? { kind: "open", extract: p.module.extract(res.data, cfg, p), now, probeMax } + : { kind: "closed", now, probeMax }; + } catch (err) { + input = { kind: "error", reason: String(err.message ?? err), now, probeMax }; + } + + const { baseState, alerts } = decideProvider(pstate, input, cfg, p); + lastTick[p.key] = { at: ts(), kind: input.kind, reason: input.reason ?? null }; + const matched = input.extract?.matched?.length ?? 0; + console.log(ts(), `[${p.key}] ${input.kind}${input.reason ? ` (${input.reason})` : ""}`, + `| matched: ${matched} | failures: ${baseState.failures}`); + return { pstate: baseState, alerts, input }; +} + export async function tick(cfg) { const now = new Date(); const state = readState(cfg); if (state.retired) return "retired"; - let input; - if (now.getTime() >= Date.parse(cfg.expiryUtc)) { - input = { kind: "closed", now }; // decide() retires before it looks at kind - } else { - // Best-effort daily probe: showDates only exists in a 200 body, and the target date - // returns an empty 204 while closed. Its failure never counts toward the streak. - let probeMax = null; - if (heartbeatDue(state, now, cfg) && !state.brokenAlerted) { - try { - const probe = await checkDistrict(cfg, localDate(now, cfg.tzOffsetMinutes)); - if (probe.kind === "open") { - const dates = probe.data?.meta?.showDates ?? []; - if (dates.length) probeMax = dates.slice().sort().at(-1); - } - } catch (err) { - console.error(ts(), "showDates probe failed (best-effort):", err.message); - } - } + const merged = structuredClone(state); + merged.providers ??= {}; + const summaries = []; + const expired = now.getTime() >= Date.parse(cfg.expiryUtc); + + const send = async (alert, apply) => { try { - const res = await checkDistrict(cfg, cfg.targetDate); - input = res.kind === "open" - ? { kind: "open", extract: extractSessions(res.data, cfg), now, probeMax } - : { kind: "closed", now, probeMax }; + await notify(cfg, alert); + apply(); + console.log(ts(), "alert sent:", alert.title); } catch (err) { - input = { kind: "error", reason: String(err.message ?? err), now, probeMax }; + // Patch not applied, so the push is retried on the next tick. + console.error(ts(), `notify failed for "${alert.title}" (will retry):`, err.message); + } + }; + + if (!expired) { + for (const p of cfg.providers) { + const { pstate, alerts, input } = await tickProvider(cfg, p, state, now); + merged.providers[p.key] = pstate; + for (const alert of alerts) { + await send(alert, () => { + merged.providers[p.key] = { ...merged.providers[p.key], ...alert.statePatch }; + }); + } + summaries.push({ + label: p.label, + kind: input.kind === "error" ? "checks failing" : merged.providers[p.key].lastGood?.kind, + showDatesMax: merged.providers[p.key].lastGood?.showDatesMax, + }); } } - const { baseState, alerts } = decide(state, input, cfg); - let merged = baseState; - for (const alert of alerts) { - try { - await notify(cfg, alert); - merged = { ...merged, ...alert.statePatch }; - console.log(ts(), "alert sent:", alert.title); - } catch (err) { - console.error(ts(), `notify failed for "${alert.title}" (will retry next tick):`, err.message); - } + const global = decideGlobal(state, cfg, now, summaries); + Object.assign(merged, global.baseState, { providers: merged.providers }); + for (const alert of global.alerts) { + await send(alert, () => Object.assign(merged, alert.statePatch)); } if (JSON.stringify(merged) !== JSON.stringify(state)) { @@ -104,12 +139,7 @@ export async function tick(cfg) { console.error(ts(), "state write failed (may duplicate pushes):", err.message); } } - - lastTick.at = ts(); - lastTick.kind = input.kind; - lastTick.reason = input.reason ?? null; - console.log(ts(), "tick:", input.kind, input.kind === "error" ? `(${input.reason})` : "", "failures:", merged.failures); - return input.kind; + return merged.retired ? "retired" : "ok"; } function startStatusServer(cfg) { @@ -125,6 +155,7 @@ function startStatusServer(cfg) { const out = { now: ts(), targetDate: cfg.targetDate, + providers: cfg.providers.map((p) => p.key), proxied: Boolean(cfg.dispatcher), retired: state.retired, lastTick, @@ -132,13 +163,16 @@ function startStatusServer(cfg) { }; if (url.searchParams.get("force") === "1") { // Side-effect-free: no state writes, no notifications. - try { - const r = await checkDistrict(cfg, cfg.targetDate); - out.live = r.kind === "open" - ? { kind: "open", ...extractSessions(r.data, cfg) } - : { kind: "closed" }; - } catch (err) { - out.live = { kind: "error", reason: String(err.message ?? err) }; + out.live = {}; + for (const p of cfg.providers) { + try { + const r = await p.module.check(cfg, p, cfg.targetDate); + out.live[p.key] = r.kind === "open" + ? { kind: "open", ...p.module.extract(r.data, cfg, p) } + : { kind: "closed" }; + } catch (err) { + out.live[p.key] = { kind: "error", reason: String(err.message ?? err) }; + } } } send(200, out); @@ -153,12 +187,13 @@ await attachProxy(cfg); if (process.argv.includes("--loop")) { startStatusServer(cfg); - console.log(ts(), `watching ${cfg.targetDate} every ${cfg.intervalMs / 1000}s (expires ${cfg.expiryUtc})`); + console.log(ts(), `watching ${cfg.targetDate} on ${cfg.providers.map((p) => p.label).join(" + ")}`, + `every ${cfg.intervalMs / 1000}s (expires ${cfg.expiryUtc})`); for (const sig of ["SIGINT", "SIGTERM"]) process.on(sig, () => process.exit(0)); for (;;) { - const kind = await tick(cfg).catch((err) => { console.error(ts(), "tick crashed:", err); return "crash"; }); - if (kind === "retired") break; - await new Promise((r) => setTimeout(r, cfg.intervalMs)); + const r = await tick(cfg).catch((err) => { console.error(ts(), "tick crashed:", err); return "crash"; }); + if (r === "retired") break; + await new Promise((r2) => setTimeout(r2, cfg.intervalMs)); } } else { await tick(cfg); diff --git a/src/lib.js b/src/lib.js new file mode 100644 index 0000000..4c68205 --- /dev/null +++ b/src/lib.js @@ -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(); +} diff --git a/src/monitor.js b/src/monitor.js index f5a1cb0..7a9c81b 100644 --- a/src/monitor.js +++ b/src/monitor.js @@ -1,146 +1,148 @@ -// Core logic: District API client, session matching, alert state machine, ntfy publisher. -// Everything here is runtime-agnostic and side-effect-free except checkDistrict/notify. +// Provider-agnostic alert logic: what to notify about, once, and when to stay quiet. +// Providers supply normalized sessions; nothing here knows which site they came from. +import { localDate, localMinutes } from "./lib.js"; -export class CheckError extends Error {} - -const UA = "Mozilla/5.0 (Linux; Android 15; Pixel 9) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36"; - -/** - * District's web client mints this token locally on every request — it is not - * server-issued, and any value of this shape is accepted. - */ -export function guestToken() { - let digits = ""; - for (let i = 0; i < 18; i++) digits += Math.floor(Math.random() * 10); - const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789"; - let suffix = ""; - for (let i = 0; i < 11; i++) suffix += alphabet[Math.floor(Math.random() * alphabet.length)]; - return `${Date.now()}_${digits}_${suffix}`; +export function defaultProviderState() { + return { dateOpenAlerted: false, sidsAlerted: {}, failures: 0, brokenAlerted: false, lastGood: null }; } -/** API showTime is UTC without a zone suffix ("2026-08-05T03:30"). */ -export function toLocalTime(showTime, tzOffsetMinutes) { - let s = showTime; - if (/T\d{2}:\d{2}$/.test(s)) s += ":00"; - if (!s.endsWith("Z")) s += "Z"; - const d = new Date(Date.parse(s) + tzOffsetMinutes * 60000); - let h = d.getUTCHours(); - const m = d.getUTCMinutes(); - const ampm = h >= 12 ? "PM" : "AM"; - h = h % 12 || 12; - return `${h}:${String(m).padStart(2, "0")} ${ampm}`; +export function defaultState() { + return { hbDate: "", retired: false, providers: {} }; } -/** YYYY-MM-DD in the configured timezone. */ -export function localDate(now, tzOffsetMinutes) { - return new Date(now.getTime() + tzOffsetMinutes * 60000).toISOString().slice(0, 10); +/** Provider sub-state, defaulted, so older state files keep working. */ +export function providerState(state, key) { + return { ...defaultProviderState(), ...((state.providers ?? {})[key] ?? {}) }; } -function localMinutes(now, tzOffsetMinutes) { - const d = new Date(now.getTime() + tzOffsetMinutes * 60000); - return d.getUTCHours() * 60 + d.getUTCMinutes(); -} - -/** Single source of truth for "should this tick send today's heartbeat". */ export function heartbeatDue(state, now, cfg) { if (cfg.heartbeatHour < 0) return false; return localMinutes(now, cfg.tzOffsetMinutes) >= cfg.heartbeatHour * 60 && state.hbDate !== localDate(now, cfg.tzOffsetMinutes); } -function isMatch(sess, cfg) { - // Deliberately broad: either the format tag or a substring of the screen format, - // so "IMAX" also catches "IMAX 3D" and similar variants. - if (!cfg.formatTag && !cfg.formatMatch) return true; - return (cfg.formatTag && (sess.tags || []).includes(cfg.formatTag)) || - (cfg.formatMatch && String(sess.scrnFmt || "").toUpperCase().includes(cfg.formatMatch.toUpperCase())); -} - -function normalize(sess, cinemaName, cfg) { - return { - sid: String(sess.sid), - cinema: cinemaName, - time: toLocalTime(sess.showTime, cfg.tzOffsetMinutes), - audi: sess.audi || "", - avail: sess.avail, - total: sess.total, - tiers: (sess.areas || []) - .map((a) => `${a.label} ${cfg.currency}${Math.round(a.price)} (${a.sAvail}/${a.sTotal})`) - .join(" · "), - }; +function sessionLine(s) { + const head = [s.time, s.cinema, s.audi || s.format, s.avail != null ? `seats ${s.avail}/${s.total}` : null] + .filter(Boolean).join(" | "); + return s.tiers ? `${head}\n${s.tiers}` : head; } /** - * Split a District response into matching sessions. - * matched — at cfg.cinemaId (or every cinema when cinemaId is unset) - * other — matching format at a different cinema (reported as secondary lines) + * Decide what to alert for ONE provider on one tick. + * input: { kind: "closed"|"open"|"error", extract?, reason?, now, probeMax? } + * Returns { baseState, alerts } for that provider's sub-state. Each alert carries its own + * statePatch, which the caller merges only after that alert's push succeeds, so a failed + * notification retries next tick instead of being recorded as delivered. */ -export function extractSessions(data, cfg) { - const cinemas = [ - ...(data?.pageData?.nearbyCinemas ?? []), - ...(data?.pageData?.farCinemas ?? []), - ]; - const out = { - matched: [], other: [], - cinemaCount: cinemas.length, sessionCount: 0, - showDates: data?.meta?.showDates ?? [], - }; - for (const c of cinemas) { - const name = c?.cinemaInfo?.name || `cinema ${c?.id}`; - for (const s of c?.sessions ?? []) { - out.sessionCount++; - if (!isMatch(s, cfg)) continue; - const wanted = !cfg.cinemaId || String(c.id) === String(cfg.cinemaId); - (wanted ? out.matched : out.other).push(normalize(s, name, cfg)); +export function decideProvider(pstate, input, cfg, p) { + const base = structuredClone(pstate); + const alerts = []; + const now = input.now; + const tag = (a) => ({ ...a, provider: p.key }); + + if (input.probeMax && base.lastGood?.showDatesMax !== input.probeMax) { + base.lastGood = { ...(base.lastGood ?? {}), showDatesMax: input.probeMax, at: now.toISOString() }; + } + + if (input.kind === "error") { + base.failures = Math.min((pstate.failures ?? 0) + 1, cfg.failureThreshold); + if (base.failures >= cfg.failureThreshold && !pstate.brokenAlerted) { + alerts.push(tag({ + title: `${p.label} checks BROKEN - check manually`, + priority: "4", + tags: "warning", + click: p.bookingUrl, + body: `${cfg.failureThreshold} consecutive failures against ${p.label} (last: ${input.reason}). The site may be blocking this host, or be down. Until this recovers, silence does NOT mean "not open yet" - check the page yourself.`, + statePatch: { brokenAlerted: true }, + })); + } + return { baseState: base, alerts }; + } + + base.failures = 0; + if (pstate.brokenAlerted) { + alerts.push(tag({ + title: `${p.label} checks recovered`, + priority: "3", + tags: "white_check_mark", + click: p.bookingUrl, + body: `${p.label} checks are succeeding again. Normal service resumed.`, + statePatch: { brokenAlerted: false }, + })); + } + + const ex = input.kind === "open" ? input.extract : null; + const effectiveKind = ex && ex.sessionCount > 0 ? "open" : "closed"; // open-but-empty = closed + if (base.lastGood?.kind !== effectiveKind) { + base.lastGood = { ...(base.lastGood ?? {}), kind: effectiveKind, at: now.toISOString() }; + } + + if (effectiveKind === "open") { + const fresh = ex.matched.filter((s) => !pstate.sidsAlerted?.[s.sid]); + if (fresh.length > 0) { + const lines = fresh.map(sessionLine); + for (const s of ex.other) lines.push(`Also at ${s.cinema}: ${s.time}`); + const sidsAlerted = { ...pstate.sidsAlerted }; + for (const s of fresh) sidsAlerted[s.sid] = true; + alerts.push(tag({ + title: `${cfg.formatLabel} ${cfg.targetDate} OPEN on ${p.label} - BOOK NOW`, + priority: "5", + tags: "rotating_light,movie_camera", + click: p.bookingUrl, + body: lines.join("\n\n"), + statePatch: { sidsAlerted, dateOpenAlerted: true }, + })); + } else if (!pstate.dateOpenAlerted && ex.matched.length === 0) { + alerts.push(tag({ + title: `${cfg.targetDate} is OPEN on ${p.label} (no ${cfg.formatLabel} yet)`, + priority: "4", + tags: "eyes", + click: p.bookingUrl, + body: `${p.label} is listing ${cfg.targetDate} at ${ex.cinemaCount} cinemas, but nothing matching "${cfg.formatLabel}" so far. You'll get a max-priority push the moment it appears.${ex.showDates.length ? ` Open dates: ${ex.showDates.join(", ")}.` : ""}`, + statePatch: { dateOpenAlerted: true }, + })); } } - return out; -} -function apiUrl(cfg, date) { - const p = new URLSearchParams({ - version: "3", site_id: "1", channel: "mweb", child_site_id: "1", platform: "district", - movieCode: cfg.movieCode, city_key: cfg.cityKey, content_id: cfg.contentId, - date, latitude: cfg.lat, longitude: cfg.lng, cinemaOrderLogic: "3", - }); - return `https://www.district.in/gw/consumer/movies/v5/movie?${p}`; + return { baseState: base, alerts }; } /** - * One API call, classified by HTTP outcome only: - * 204 -> date not open for booking yet - * 200 -> open (open-but-empty is decided later, in decide()) - * Anything else throws CheckError, which counts toward the failure streak. + * Tick-level decisions that are not per-provider: retirement and the daily heartbeat. + * summaries: [{ label, kind, showDatesMax }] describing each provider this tick. */ -export async function checkDistrict(cfg, date) { - let res; - try { - res = await fetch(apiUrl(cfg, date), { - headers: { - accept: "*/*", - api_source: "district", - "x-app-type": "ed_mweb", - "x-guest-token": guestToken(), - "x-request-id": crypto.randomUUID(), - "user-agent": UA, - ...(cfg.bookingUrl ? { referer: cfg.bookingUrl } : {}), - }, - signal: AbortSignal.timeout(15000), - // Node-only: routes this call through a SOCKS5 proxy when one is configured. - ...(cfg.dispatcher ? { dispatcher: cfg.dispatcher } : {}), +export function decideGlobal(state, cfg, now, summaries) { + const base = structuredClone(state); + const alerts = []; + + if (base.retired) return { baseState: base, alerts }; + + if (now.getTime() >= Date.parse(cfg.expiryUtc)) { + alerts.push({ + title: "Monitor retired", + priority: "3", + tags: "checkered_flag", + click: cfg.bookingUrl, + body: `${cfg.targetDate} has passed, so this monitor is now idle. Stop it when convenient.`, + statePatch: { retired: true }, }); - } catch (err) { - throw new CheckError(`fetch failed: ${err.message}`); + return { baseState: base, alerts }; } - if (res.status === 204) return { kind: "closed" }; - if (res.status === 200) { - try { - return { kind: "open", data: await res.json() }; - } catch (err) { - throw new CheckError(`unparseable 200 body: ${err.message}`); - } + + if (heartbeatDue(state, now, cfg)) { + const lines = summaries.map((s) => + `${s.label}: ${s.kind ?? "unknown"}${s.showDatesMax ? ` (open through ${s.showDatesMax})` : ""}`); + alerts.push({ + title: `Still watching ${cfg.targetDate}`, + priority: "1", + tags: "hourglass", + click: cfg.bookingUrl, + body: `Monitor healthy.\n${lines.join("\n")}`, + statePatch: { hbDate: localDate(now, cfg.tzOffsetMinutes) }, + }); } - throw new CheckError(`HTTP ${res.status}`); + + return { baseState: base, alerts }; } export async function notify(cfg, alert) { @@ -161,122 +163,3 @@ export async function notify(cfg, alert) { }); if (!res.ok) throw new Error(`ntfy HTTP ${res.status}`); } - -export function defaultState() { - return { - dateOpenAlerted: false, - sidsAlerted: {}, - failures: 0, - brokenAlerted: false, - hbDate: "", - lastGood: null, - retired: false, - }; -} - -/** - * Pure decision core. - * input: { kind: "closed"|"open"|"error", extract?, reason?, now, probeMax? } - * Returns { baseState, alerts }. Each alert carries its own statePatch, which the - * caller merges ONLY after that alert's push succeeds, so a failed push retries - * next tick instead of being silently marked as delivered. - */ -export function decide(state, input, cfg) { - const base = structuredClone(state); - const alerts = []; - const now = input.now; - - if (base.retired) return { baseState: base, alerts }; - - if (now.getTime() >= Date.parse(cfg.expiryUtc)) { - alerts.push({ - title: "Monitor retired", - priority: "3", - tags: "checkered_flag", - click: cfg.bookingUrl, - body: `${cfg.targetDate} has passed, so this monitor is now idle. Stop it when convenient.`, - statePatch: { retired: true }, - }); - return { baseState: base, alerts }; - } - - // Only write when it actually changed, so a repeating probe cannot churn state. - if (input.probeMax && base.lastGood?.showDatesMax !== input.probeMax) { - base.lastGood = { ...(base.lastGood ?? {}), showDatesMax: input.probeMax, at: now.toISOString() }; - } - - if (input.kind === "error") { - base.failures = Math.min((state.failures ?? 0) + 1, cfg.failureThreshold); - if (base.failures >= cfg.failureThreshold && !state.brokenAlerted) { - alerts.push({ - title: "Monitor BROKEN - check manually", - priority: "4", - tags: "warning", - click: cfg.bookingUrl, - body: `${cfg.failureThreshold} consecutive check failures (last: ${input.reason}). The site may be blocking this host, or be down. Until this recovers, silence does NOT mean "not open yet" - check the page yourself.`, - statePatch: { brokenAlerted: true }, - }); - } - return { baseState: base, alerts }; - } - - // --- success tick --- - base.failures = 0; - if (state.brokenAlerted) { - alerts.push({ - title: "Monitor recovered", - priority: "3", - tags: "white_check_mark", - click: cfg.bookingUrl, - body: "Checks are succeeding again. Normal service resumed.", - statePatch: { brokenAlerted: false }, - }); - } - - const ex = input.kind === "open" ? input.extract : null; - const effectiveKind = ex && ex.sessionCount > 0 ? "open" : "closed"; // open-but-empty = closed - if (base.lastGood?.kind !== effectiveKind) { - base.lastGood = { ...(base.lastGood ?? {}), kind: effectiveKind, at: now.toISOString() }; - } - - if (effectiveKind === "open") { - const fresh = ex.matched.filter((s) => !state.sidsAlerted?.[s.sid]); - if (fresh.length > 0) { - const lines = fresh.map((s) => - `${s.time} | ${s.cinema}${s.audi ? ` | ${s.audi}` : ""} | seats ${s.avail}/${s.total}\n${s.tiers}`); - for (const s of ex.other) lines.push(`Also at ${s.cinema}: ${s.time}`); - const sidsAlerted = { ...state.sidsAlerted }; - for (const s of fresh) sidsAlerted[s.sid] = true; - alerts.push({ - title: `${cfg.formatLabel} ${cfg.targetDate} OPEN - BOOK NOW`, - priority: "5", - tags: "rotating_light,movie_camera", - click: cfg.bookingUrl, - body: lines.join("\n\n"), - statePatch: { sidsAlerted, dateOpenAlerted: true }, - }); - } else if (!state.dateOpenAlerted && ex.matched.length === 0) { - alerts.push({ - title: `${cfg.targetDate} bookings are OPEN (no ${cfg.formatLabel} yet)`, - priority: "4", - tags: "eyes", - click: cfg.bookingUrl, - body: `${ex.cinemaCount} cinemas are listing ${cfg.targetDate}, but nothing matching "${cfg.formatLabel}" so far. You'll get a max-priority push the moment it appears. Open dates: ${ex.showDates.join(", ") || "n/a"}.`, - statePatch: { dateOpenAlerted: true }, - }); - } - } - - if (heartbeatDue(state, now, cfg)) { - alerts.push({ - title: `Still watching ${cfg.targetDate}`, - priority: "1", - tags: "hourglass", - click: cfg.bookingUrl, - body: `Monitor healthy. Bookings open through: ${base.lastGood?.showDatesMax ?? "unavailable"}. Target date is still ${effectiveKind}.`, - statePatch: { hbDate: localDate(now, cfg.tzOffsetMinutes) }, - }); - } - - return { baseState: base, alerts }; -} diff --git a/src/providers/bookmyshow.js b/src/providers/bookmyshow.js new file mode 100644 index 0000000..8b30e37 --- /dev/null +++ b/src/providers/bookmyshow.js @@ -0,0 +1,145 @@ +// BookMyShow (in.bookmyshow.com) provider. +// +// BMS sits behind Cloudflare bot management that rejects Node's fetch and curl with 403 +// regardless of headers or cookies: the block is on the TLS/HTTP2 fingerprint. `impit` +// performs the request with a real Chrome fingerprint, which is why it is a dependency. +// +// Unlike District there is no 204. A date that is not on sale yet answers 200 with a +// short body that simply has no `showtimeWidgets` key, so "closed" is inferred from the +// payload rather than the status code. +import { CheckError } from "../lib.js"; + +export const key = "bookmyshow"; +export const label = "BookMyShow"; + +let Impit = null; +let client = null; + +async function getClient(cfg) { + if (client) return client; + if (!Impit) ({ Impit } = await import("impit")); + client = new Impit({ + browser: "chrome", + ...(cfg.proxyUrl ? { proxyUrl: cfg.proxyUrl } : {}), + }); + return client; +} + +/** BMS dates are YYYYMMDD; the rest of the app speaks YYYY-MM-DD. */ +export const toDateCode = (isoDate) => isoDate.replaceAll("-", ""); + +function apiUrl(p, date) { + const q = new URLSearchParams({ + etCodes: p.eventCode, + dateCode: toDateCode(date), + isDesktop: "true", + regionCode: p.regionCode, + xLocationShared: "false", + memberId: "", lsId: "", subCode: "", + appCode: "WEB", + refEventCode: p.refEventCode || p.eventCode, + ...(p.language ? { language: p.language } : {}), + }); + return `https://in.bookmyshow.com/api/movies-data/v5/showtimes-by-event/primary-dynamic?${q}`; +} + +/** Generated by the BMS web client itself, not issued by the server. */ +const bmsId = () => `1.${Math.floor(Math.random() * 9e8 + 1e8)}.${Date.now()}`; + +export async function check(cfg, p, date) { + const impit = await getClient(cfg); + let res, text; + try { + res = await impit.fetch(apiUrl(p, date), { + headers: { + accept: "application/json, text/plain, */*", + "x-region-code": p.regionCode, + "x-region-slug": p.regionSlug, + "x-latitude": p.lat, + "x-longitude": p.lng, + "x-bms-id": bmsId(), + "x-app-code": "WEB", + "x-platform-code": "WEB", + "x-platform": "WEB", + }, + }); + text = await res.text(); + } catch (err) { + throw new CheckError(`fetch failed: ${err.message}`); + } + if (res.status !== 200) throw new CheckError(`HTTP ${res.status}`); + let data; + try { + data = JSON.parse(text); + } catch (err) { + throw new CheckError(`unparseable body: ${err.message}`); + } + // No showtime widgets at all means the date has not gone on sale. + if (!data?.data?.showtimeWidgets) return { kind: "closed" }; + return { kind: "open", data }; +} + +/** Venue cards sit several widget layers deep, so walk rather than index blindly. */ +function collectVenueCards(node, out = []) { + if (Array.isArray(node)) { + for (const n of node) collectVenueCards(n, out); + } else if (node && typeof node === "object") { + if (node.type === "venue-card") out.push(node); + for (const v of Object.values(node)) collectVenueCards(v, out); + } + return out; +} + +/** Seat tiers live in the double-tap bottom sheet: type, price and an availability word. */ +function tiersOf(showtime, cfg) { + const widgets = showtime?.customGestureCTA?.additionalData?.bottomSheetData?.widgets ?? []; + return widgets + .filter((w) => w?.variableData?.seatType) + .map((w) => { + const v = w.variableData; + const cost = String(v.seatCost || "").replace(/[^\d.]/g, ""); + const price = cost ? `${cfg.currency}${Math.round(Number(cost))}` : ""; + return `${v.seatType} ${price} (${v.seatAvalibility || ""})`.replace(/\s+/g, " ").trim(); + }) + .join(" · "); +} + +function formatOf(showtime) { + return String(showtime.screenAttr || showtime?.additionalData?.attributes || ""); +} + +function isMatch(showtime, p) { + if (!p.formatMatch) return true; + return formatOf(showtime).toUpperCase().includes(p.formatMatch.toUpperCase()); +} + +export function extract(data, cfg, p) { + const cards = collectVenueCards(data?.data?.showtimeWidgets ?? []); + const out = { matched: [], other: [], cinemaCount: cards.length, sessionCount: 0, showDates: [] }; + for (const card of cards) { + const info = card.additionalData ?? {}; + const name = info.venueName || info.venueCode || "unknown venue"; + for (const section of card.showtimesSections ?? []) { + for (const st of section.showtimes ?? []) { + out.sessionCount++; + if (!isMatch(st, p)) continue; + const ad = st.additionalData ?? {}; + const session = { + sid: String(ad.sessionId || `${info.venueCode}-${ad.showTimeCode || st.title}`), + cinema: name, + // BMS already returns local wall-clock time, so no timezone shift is applied. + time: ad.showTime || st.title || "", + audi: "", + format: formatOf(st), + // BMS exposes availability words per tier, never exact seat counts. + avail: null, + total: null, + tiers: tiersOf(st, cfg), + }; + const wanted = !p.venueId || String(info.venueCode) === String(p.venueId); + (wanted ? out.matched : out.other).push(session); + } + } + } + return out; +} diff --git a/src/providers/district.js b/src/providers/district.js new file mode 100644 index 0000000..ff04c21 --- /dev/null +++ b/src/providers/district.js @@ -0,0 +1,112 @@ +// District (district.in) provider. +// The showtimes endpoint answers 204 while a date is not on sale yet, and 200 with the +// full session list once it opens. Plain fetch works, so this provider needs no special +// HTTP client, and it honours a SOCKS5 dispatcher when one is configured. +import { CheckError, toLocalTime } from "../lib.js"; + +export const key = "district"; +export const label = "District"; + +const UA = "Mozilla/5.0 (Linux; Android 15; Pixel 9) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36"; + +/** Generated client-side by District's own web app, not issued by the server. */ +function guestToken() { + let digits = ""; + for (let i = 0; i < 18; i++) digits += Math.floor(Math.random() * 10); + const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789"; + let suffix = ""; + for (let i = 0; i < 11; i++) suffix += alphabet[Math.floor(Math.random() * alphabet.length)]; + return `${Date.now()}_${digits}_${suffix}`; +} + +function apiUrl(p, date) { + const q = new URLSearchParams({ + version: "3", site_id: "1", channel: "mweb", child_site_id: "1", platform: "district", + movieCode: p.movieCode, city_key: p.cityKey, content_id: p.contentId, + date, latitude: p.lat, longitude: p.lng, cinemaOrderLogic: "3", + }); + return `https://www.district.in/gw/consumer/movies/v5/movie?${q}`; +} + +/** @returns {{kind:"closed"}|{kind:"open",data:object}} */ +export async function check(cfg, p, date) { + let res; + try { + res = await fetch(apiUrl(p, date), { + headers: { + accept: "*/*", + api_source: "district", + "x-app-type": "ed_mweb", + "x-guest-token": guestToken(), + "x-request-id": crypto.randomUUID(), + "user-agent": UA, + ...(p.bookingUrl ? { referer: p.bookingUrl } : {}), + }, + signal: AbortSignal.timeout(15000), + ...(cfg.dispatcher ? { dispatcher: cfg.dispatcher } : {}), + }); + } catch (err) { + throw new CheckError(`fetch failed: ${err.message}`); + } + if (res.status === 204) return { kind: "closed" }; + if (res.status === 200) { + try { + return { kind: "open", data: await res.json() }; + } catch (err) { + throw new CheckError(`unparseable 200 body: ${err.message}`); + } + } + throw new CheckError(`HTTP ${res.status}`); +} + +function isMatch(sess, p) { + if (!p.formatTag && !p.formatMatch) return true; + return (p.formatTag && (sess.tags || []).includes(p.formatTag)) || + (p.formatMatch && String(sess.scrnFmt || "").toUpperCase().includes(p.formatMatch.toUpperCase())); +} + +export function extract(data, cfg, p) { + const cinemas = [ + ...(data?.pageData?.nearbyCinemas ?? []), + ...(data?.pageData?.farCinemas ?? []), + ]; + const out = { + matched: [], other: [], + cinemaCount: cinemas.length, sessionCount: 0, + showDates: data?.meta?.showDates ?? [], + }; + for (const c of cinemas) { + const name = c?.cinemaInfo?.name || `cinema ${c?.id}`; + for (const s of c?.sessions ?? []) { + out.sessionCount++; + if (!isMatch(s, p)) continue; + const session = { + sid: String(s.sid), + cinema: name, + time: toLocalTime(s.showTime, cfg.tzOffsetMinutes), + audi: s.audi || "", + format: s.scrnFmt || "", + avail: s.avail, + total: s.total, + tiers: (s.areas || []) + .map((a) => `${a.label} ${cfg.currency}${Math.round(a.price)} (${a.sAvail}/${a.sTotal})`) + .join(" · "), + }; + const wanted = !p.venueId || String(c.id) === String(p.venueId); + (wanted ? out.matched : out.other).push(session); + } + } + return out; +} + +/** + * Optional capability: how far ahead bookings currently run. `meta.showDates` only + * appears in a 200 body, and the target date returns an empty 204 while closed, so this + * asks about today instead. Best effort; the caller ignores failures. + */ +export async function probeShowDates(cfg, p, todayIso) { + const res = await check(cfg, p, todayIso); + if (res.kind !== "open") return null; + const dates = res.data?.meta?.showDates ?? []; + return dates.length ? dates.slice().sort().at(-1) : null; +} diff --git a/test/fixtures/bms-closed-date.json b/test/fixtures/bms-closed-date.json new file mode 100644 index 0000000..6ef18a0 --- /dev/null +++ b/test/fixtures/bms-closed-date.json @@ -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"}}}}} \ No newline at end of file diff --git a/test/fixtures/bms-open-date.json b/test/fixtures/bms-open-date.json new file mode 100644 index 0000000..e89d45f --- /dev/null +++ b/test/fixtures/bms-open-date.json @@ -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"},"subtitle":{"styleId":"filter-subtitle","text":"7:00 PM - 11:59 PM"},"iconUrl":"https://assets-in.bmscdn.com/moviesmaster/movies-showtimes/v4/common/night.png","cta":{"type":"selectFilter","analytics":{"category":"time","event_action":"bottom_filter","event_name":"showtime_actions","event_type":"click","label":"night"},"additionalData":{"filterID":"tf4","filterLevel":"both","categoryId":"time"}}}]}],"ctaData":[{"text":"Reset","style":"secondary","cta":{"type":"clearFilter","analytics":{"category":"reset","event_action":"bottom_filter","event_name":"showtime_actions","event_type":"click"}}},{"text":"Apply Filters","style":"primary","cta":{"type":"applyFilter"}}]},"format-selector":{"widgets":[{"type":"vertical-text-list","data":[{"text":"Toxic: A Fairy Tale for Grown-ups","styleId":"bottomsheet-subtitle"},{"text":"Select language and format","styleId":"bottomsheet-title"}]},{"type":"chip-list","text":"Hindi","data":[{"type":"chip","title":"2D","cta":{"type":"formatSelector","analytics":{"event_code":"ET00379311","event_name":"language_format_selected","event_type":"click","format":"2D","screen_name":"movie_showtimes"},"additionalData":{"eventCode":"ET00379311","eventUrl":"toxic-a-fairy-tale-for-grown-ups-hindi","language":"Hindi","refEventCode":"ET00379311"}}},{"type":"chip","title":"4DX","cta":{"type":"formatSelector","analytics":{"event_code":"ET00513506","event_name":"language_format_selected","event_type":"click","format":"4DX","screen_name":"movie_showtimes"},"additionalData":{"eventCode":"ET00513506","eventUrl":"toxic-a-fairy-tale-for-grown-ups-4dxhindi","language":"Hindi","refEventCode":"ET00513506"}}},{"type":"chip","title":"IMAX 2D","cta":{"type":"formatSelector","analytics":{"event_code":"ET00513458","event_name":"language_format_selected","event_type":"click","format":"IMAX 2D","screen_name":"movie_showtimes"},"additionalData":{"eventCode":"ET00513458","eventUrl":"toxic-a-fairy-tale-for-grown-ups-imax-2dhindi","isDisabled":true,"language":"Hindi","refEventCode":"ET00513458"}}},{"type":"chip","title":"Select all","cta":{"type":"formatSelector","analytics":{"event_name":"language_format_selected","event_type":"click","format":"all_formats","language":"Hindi","screen_name":"movie_showtimes"},"additionalData":{"eventCode":"*","language":"Hindi","refEventCode":"ET00379311"}}}]},{"type":"chip-list","text":"Kannada","data":[{"type":"chip","title":"2D","cta":{"type":"formatSelector","analytics":{"event_code":"ET00378770","event_name":"language_format_selected","event_type":"click","format":"2D","screen_name":"movie_showtimes"},"additionalData":{"eventCode":"ET00378770","eventUrl":"toxic-a-fairy-tale-for-grown-ups","language":"Kannada","refEventCode":"ET00378770"}}}]},{"type":"chip-list","text":"Telugu","data":[{"type":"chip","title":"2D","cta":{"type":"formatSelector","analytics":{"event_code":"ET00379307","event_name":"language_format_selected","event_type":"click","format":"2D","screen_name":"movie_showtimes"},"additionalData":{"eventCode":"ET00379307","eventUrl":"toxic-a-fairy-tale-for-grown-ups-telugu","language":"Telugu","refEventCode":"ET00379307"}}}]}],"analytics":{"event_name":"language_format_viewed","event_type":"screen_view"}},"sortBy":{"backgroundColor":"#ffffff","type":"sort-by","handleColor":"#000000","widgets":[{"cta":null,"data":[{"styleId":"bottomsheet-title","text":"Sort by","type":"text"}],"type":"vertical-text-list"},{"data":[{"cta":{"id":"relevance","type":"sort","additionalData":{"isDefault":true}},"isDefault":true,"subtitle":{"styleId":"sortby-subtitle-style","text":"Best options for you first"},"title":{"styleId":"filter-title","text":"Relevance"}},{"cta":{"id":"popularityRank","type":"sort"},"subtitle":{"styleId":"sortby-subtitle-style","text":"Show most popular first"},"title":{"styleId":"filter-title","text":"Popularity"}},{"cta":{"id":"distance","type":"autoDetectLocation"},"subtitle":{"styleId":"sortby-subtitle-style","text":"Show nearest first"},"title":{"styleId":"filter-title","text":"Distance"}}],"type":"single-selector"}]}},"additionalData":{"dateCode":"20260827","eventCode":"ET00513458","defaultSortId":"relevance","sortingEnabled":true,"showPriceWithoutDecimal":true,"isV5MultiEvent":true,"refEventCode":"ET00513458"},"topStickyWidgets":[{"type":"horizontal-block-list","data":[{"type":"vertical-text-list","id":"20260826","styleId":"date-default","data":[{"text":"WED","styleId":"weekday-title"},{"text":"26","styleId":"date-title"},{"text":"AUG","styleId":"date-subtitle"}],"cta":{"type":"dateSelector","analytics":{"event_action":"date","event_name":"showtime_actions","event_type":"click","label":"2026-08-26"},"additionalData":{"dateCode":"20260826"}}},{"type":"vertical-text-list","id":"20260827","styleId":"date-selected","data":[{"text":"THU","styleId":"weekday-title-selected"},{"text":"27","styleId":"date-title-selected"},{"text":"AUG","styleId":"date-subtitle-selected"}],"cta":{"type":"dateSelector","analytics":{"event_action":"date","event_name":"showtime_actions","event_type":"click","label":"2026-08-27"},"additionalData":{"dateCode":"20260827"}}},{"type":"vertical-text-list","id":"20260828","styleId":"date-default","data":[{"text":"FRI","styleId":"weekday-title"},{"text":"28","styleId":"date-title"},{"text":"AUG","styleId":"date-subtitle"}],"cta":{"type":"dateSelector","analytics":{"event_action":"date","event_name":"showtime_actions","event_type":"click","label":"2026-08-28"},"additionalData":{"dateCode":"20260828"}}},{"type":"vertical-text-list","id":"20260829","styleId":"date-default","data":[{"text":"SAT","styleId":"weekday-title"},{"text":"29","styleId":"date-title"},{"text":"AUG","styleId":"date-subtitle"}],"cta":{"type":"dateSelector","analytics":{"event_action":"date","event_name":"showtime_actions","event_type":"click","label":"2026-08-29"},"additionalData":{"dateCode":"20260829"}}},{"type":"vertical-text-list","id":"20260830","styleId":"date-default","data":[{"text":"SUN","styleId":"weekday-title"},{"text":"30","styleId":"date-title"},{"text":"AUG","styleId":"date-subtitle"}],"cta":{"type":"dateSelector","analytics":{"event_action":"date","event_name":"showtime_actions","event_type":"click","label":"2026-08-30"},"additionalData":{"dateCode":"20260830"}}},{"type":"vertical-text-list","id":"20260831","styleId":"date-disabled","data":[{"text":"MON","styleId":"weekday-title"},{"text":"31","styleId":"date-title-disabled"},{"text":"AUG","styleId":"date-subtitle-disabled"}]},{"type":"vertical-text-list","id":"20260901","styleId":"date-disabled","data":[{"text":"TUE","styleId":"weekday-title"},{"text":"01","styleId":"date-title-disabled"},{"text":"SEP","styleId":"date-subtitle-disabled"}]}]},{"type":"horizontal-text-list","data":[{"styleId":"format-style","leftText":{"data":[{"components":[{"uuid":"format-left-title-1","type":"text","styleId":"format-left-title","text":"Hindi • IMAX 2D"}]}]},"rightText":{"data":[{"components":[{"uuid":"format-change-title-1","type":"text","styleId":"format-change-title","text":"Change"},{"uuid":"format-change-icon-1","type":"image","styleId":"format-change-icon","imageUrl":"https://assets-in.bmscdn.com/moviesmaster/movies-showtimes/v4/common/right-chevron.png"}]}]}}],"cta":{"type":"bottomsheet","id":"format-selector"}},{"type":"horizontal-pill-list","id":"filter-list","stickyWidget":{"type":"vertical-text-list","styleId":"sortby-pill","iconUrl":"https://assets-in.bmscdn.com/moviesmaster/movies-showtimes/v4/common/down-chevron.png","data":[{"text":"Sort by","styleId":"sticky-pill-sortby"}],"cta":{"type":"bottomsheet","id":"sortBy","analytics":{"category":"sort","event_action":"quick_filter","event_name":"showtime_actions","event_type":"click"}},"startIcon":{"url":"https://assets-in.bmscdn.com/moviesmaster/movies-showtimes/v4/common/sortbyStarticon.png"}},"data":[{"type":"chip","title":"Cancellable","cta":{"type":"filter","id":"cancellation","analytics":{"category":"cancel","event_action":"quick_filter","event_name":"showtime_actions","event_type":"click","label":"Cancellable"},"additionalData":{"filterID":"cancellation","filterLevel":"parent","categoryId":"cancellation","selectionRank":1}}},{"type":"chip","title":"₹0 - ₹500","cta":{"type":"filter","analytics":{"category":"price","event_action":"quick_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":"filter","analytics":{"category":"price","event_action":"quick_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":"filter","analytics":{"category":"price","event_action":"quick_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":"filter","analytics":{"category":"price","event_action":"quick_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":"filter","analytics":{"category":"price","event_action":"quick_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":"filter","analytics":{"category":"price","event_action":"quick_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":"filter","analytics":{"category":"price","event_action":"quick_filter","event_name":"showtime_actions","event_type":"click","label":"1001-1200"},"additionalData":{"filterID":"pf93","filterLevel":"both","categoryId":"price"}}},{"type":"chip","title":"Clear filters","styleId":"pill-highlighted","cta":{"type":"clearFilter","analytics":{"category":"reset","event_action":"quick_filter","event_name":"showtime_actions","event_type":"click"}}}]}],"showtimeWidgets":[{"type":"adtech","data":[{"id":"AD_MOVIE_SHOWTIMES_CARD","customFilters":{"eventCode":"ET00513458","showDate":"20260827","genre":["Action","Crime","Period","Thriller"],"movieType":""},"aspectRatio":5}],"filters":["ALL"],"sortInfo":{"distance":{"value":0,"enabled":false},"popularityRank":{"value":0,"enabled":false}}},{"type":"groupList","id":"List_1","data":[{"type":"venueGroup","id":"Venue_GROUP_1","data":[{"type":"venue-card","additionalData":{"venueCode":"PPAM","seatLegendIds":["4","1","88","2"],"seatSelectorFooterText":"default","bestSeatsDialogBox":"default","enableBestSeatCelebration":true,"favouriteActionState":"unfavourite","venueName":"PVR: Palladium Mall, Ahmedabad","isFnBAvailable":true,"isMTicketAvailable":true},"filters":["cancellation","pf5","pf6","pf7","pf8","pf9","pf91","pf93","tf1","tf2","tf4"],"sortInfo":{"distance":{"value":4900000,"enabled":true},"popularityRank":{"value":1,"enabled":true}},"header":{"data":{"styleId":"header-container-style","components":[{"type":"image","data":{"url":"https://assets-in.bmscdn.com/moviesmaster/movies-showtimes/v4/cinema-icon/pvr.png","styleId":"cinema-icon","cta":{"type":"redirection","id":"venueLink","analytics":{"event_action":"venue_info","event_name":"showtime_actions","event_type":"click"},"additionalData":{"venueCode":"PPAM","venueName":"PVR: Palladium Mall, Ahmedabad","redirectionUrl":"https://in.bookmyshow.com/cinemas/ahmedabad/pvr-palladium-mall-ahmedabad/buytickets/PPAM/20260827"}},"elementType":"a","elementAttributes":{"alt":"PVR: Palladium Mall, Ahmedabad","href":"https://in.bookmyshow.com/cinemas/ahmedabad/pvr-palladium-mall-ahmedabad/buytickets/PPAM/20260827"}}},{"styleId":"header-title-container","type":"text","data":{"data":[{"components":[{"text":"PVR: Palladium Mall, Ahmedabad","styleId":"header-subtitle","type":"text"},{"styleId":"cinema-info-image-v2","imageUrl":"https://assets-in.bmscdn.com/moviesmaster/movies-showtimes/v4/common/infoV2.png","type":"image"}],"cta":{"type":"bottomsheet","id":"venueInfo","analytics":{"event_action":"venue_info","event_name":"showtime_actions","event_type":"click"},"additionalData":{"venueCode":"PPAM","venueName":"PVR: Palladium Mall, Ahmedabad"}}}]}},{"styleId":"favourite-icon","type":"action","data":{"id":"actionStates","cta":{"type":"venueFavourite","analytics":{"event_name":"favourite_venue_selected","event_type":"click","venue_code":"PPAM"}}}}]}},"infoList":[{"label":"Cancellation available","textColor":"#666666"}],"showtimesSections":[{"showtimes":[{"title":"08:00 AM","screenAttr":"IMAX","styleId":"green-pill-with-border","cta":{"type":"showTimeRedirect","analytics":{"company_code":"PVR","event_name":"showtime_selected","event_type":"click","format":"IMAX","label":"morning_show","show_session_id":"37929","show_time":"08:00","type":"actual_icon"},"additionalData":{"showCutOffBottomSheet":true,"cta":{"type":"bottomsheet","id":"cutOffBottomSheet"}}},"additionalData":{"sessionId":"37929","availStatus":"3","cutOffDateTime":"202608270845","cutOffDateTimeEpoch":"1787800500","showDateCode":"20260827","showDateTime":"202608270800","showTimeCode":"0800","showTime":"08:00 AM","attributes":"IMAX"},"filters":["pf5","pf6","pf7","pf91","tf1"],"customGestureCTA":{"gestureType":"doubleTap","type":"bottomsheet","additionalData":{"bottomSheetData":{"backgroundColor":"#ffffff","handleColor":"#000000","widgets":[{"layoutId":"format-container","type":"utility","variableData":{"format":"Hindi • IMAX 2D | IMAX"}},{"layoutId":"category-price-header-container","type":"text","variableData":{"title":"Seat category and price"}},{"layoutId":"seat-category-type-almost-full","type":"text","variableData":{"seatAvalibility":"ALMOST FULL","seatCost":"₹ 960.00","seatType":"RECLINER"}},{"layoutId":"seat-category-type-available","type":"text","variableData":{"seatAvalibility":"AVAILABLE","seatCost":"₹ 650.00","seatType":"PICTURE PERFECT"}},{"layoutId":"seat-category-type-filling-fast","type":"text","variableData":{"seatAvalibility":"FILLING FAST","seatCost":"₹ 570.00","seatType":"PRIME PLUS"}},{"layoutId":"seat-category-type-available","type":"text","variableData":{"seatAvalibility":"AVAILABLE","seatCost":"₹ 490.00","seatType":"PRIME"}},{"layoutId":"seat-category-type-available","type":"text","variableData":{"seatAvalibility":"AVAILABLE","seatCost":"₹ 470.00","seatType":"CLASSIC"}}]}},"analytics":{"event_action":"showinfo_modal_viewed","event_name":"showtime_actions","event_type":"click"}},"overlayId":"earlyMorning"},{"title":"11:45 AM","screenAttr":"IMAX","styleId":"green-pill-with-border","cta":{"type":"showTimeRedirect","analytics":{"company_code":"PVR","event_name":"showtime_selected","event_type":"click","format":"IMAX","show_session_id":"37930","show_time":"11:45","type":"actual_icon"},"additionalData":{"showCutOffBottomSheet":true,"cta":{"type":"bottomsheet","id":"cutOffBottomSheet"}}},"additionalData":{"sessionId":"37930","availStatus":"3","cutOffDateTime":"202608271230","cutOffDateTimeEpoch":"1787814000","showDateCode":"20260827","showDateTime":"202608271145","showTimeCode":"1145","showTime":"11:45 AM","attributes":"IMAX"},"filters":["pf5","pf6","pf7","pf91","tf1"],"customGestureCTA":{"gestureType":"doubleTap","type":"bottomsheet","additionalData":{"bottomSheetData":{"backgroundColor":"#ffffff","handleColor":"#000000","widgets":[{"layoutId":"format-container","type":"utility","variableData":{"format":"Hindi • IMAX 2D | IMAX"}},{"layoutId":"category-price-header-container","type":"text","variableData":{"title":"Seat category and price"}},{"layoutId":"seat-category-type-almost-full","type":"text","variableData":{"seatAvalibility":"ALMOST FULL","seatCost":"₹ 960.00","seatType":"RECLINER"}},{"layoutId":"seat-category-type-available","type":"text","variableData":{"seatAvalibility":"AVAILABLE","seatCost":"₹ 650.00","seatType":"PICTURE PERFECT"}},{"layoutId":"seat-category-type-filling-fast","type":"text","variableData":{"seatAvalibility":"FILLING FAST","seatCost":"₹ 570.00","seatType":"PRIME PLUS"}},{"layoutId":"seat-category-type-available","type":"text","variableData":{"seatAvalibility":"AVAILABLE","seatCost":"₹ 490.00","seatType":"PRIME"}},{"layoutId":"seat-category-type-available","type":"text","variableData":{"seatAvalibility":"AVAILABLE","seatCost":"₹ 470.00","seatType":"CLASSIC"}}]}},"analytics":{"event_action":"showinfo_modal_viewed","event_name":"showtime_actions","event_type":"click"}}},{"title":"03:45 PM","screenAttr":"IMAX","styleId":"green-pill-with-border","cta":{"type":"showTimeRedirect","analytics":{"company_code":"PVR","event_name":"showtime_selected","event_type":"click","format":"IMAX","show_session_id":"37931","show_time":"15:45","type":"actual_icon"},"additionalData":{"showCutOffBottomSheet":true,"cta":{"type":"bottomsheet","id":"cutOffBottomSheet"}}},"additionalData":{"sessionId":"37931","availStatus":"3","cutOffDateTime":"202608271630","cutOffDateTimeEpoch":"1787828400","showDateCode":"20260827","showDateTime":"202608271545","showTimeCode":"1545","showTime":"03:45 PM","attributes":"IMAX"},"filters":["pf7","pf8","pf9","pf93","tf2"],"customGestureCTA":{"gestureType":"doubleTap","type":"bottomsheet","additionalData":{"bottomSheetData":{"backgroundColor":"#ffffff","handleColor":"#000000","widgets":[{"layoutId":"format-container","type":"utility","variableData":{"format":"Hindi • IMAX 2D | IMAX"}},{"layoutId":"category-price-header-container","type":"text","variableData":{"title":"Seat category and price"}},{"layoutId":"seat-category-type-almost-full","type":"text","variableData":{"seatAvalibility":"ALMOST FULL","seatCost":"₹ 1200.00","seatType":"RECLINER"}},{"layoutId":"seat-category-type-filling-fast","type":"text","variableData":{"seatAvalibility":"FILLING FAST","seatCost":"₹ 860.00","seatType":"PICTURE PERFECT"}},{"layoutId":"seat-category-type-filling-fast","type":"text","variableData":{"seatAvalibility":"FILLING FAST","seatCost":"₹ 780.00","seatType":"PRIME PLUS"}},{"layoutId":"seat-category-type-available","type":"text","variableData":{"seatAvalibility":"AVAILABLE","seatCost":"₹ 650.00","seatType":"PRIME"}},{"layoutId":"seat-category-type-available","type":"text","variableData":{"seatAvalibility":"AVAILABLE","seatCost":"₹ 630.00","seatType":"CLASSIC"}}]}},"analytics":{"event_action":"showinfo_modal_viewed","event_name":"showtime_actions","event_type":"click"}}},{"title":"07:45 PM","screenAttr":"IMAX","styleId":"orange-pill-with-border","cta":{"type":"showTimeRedirect","analytics":{"company_code":"PVR","event_name":"showtime_selected","event_type":"click","format":"IMAX","metadata":"{\"venue_info\":[\"fast_filling\"]}","show_session_id":"37932","show_time":"19:45","type":"actual_icon"},"additionalData":{"showCutOffBottomSheet":true,"cta":{"type":"bottomsheet","id":"cutOffBottomSheet"}}},"additionalData":{"sessionId":"37932","availStatus":"2","cutOffDateTime":"202608272030","cutOffDateTimeEpoch":"1787842800","showDateCode":"20260827","showDateTime":"202608271945","showTimeCode":"1945","showTime":"07:45 PM","attributes":"IMAX"},"filters":["pf7","pf8","pf9","pf93","tf4"],"customGestureCTA":{"gestureType":"doubleTap","type":"bottomsheet","additionalData":{"bottomSheetData":{"backgroundColor":"#ffffff","handleColor":"#000000","widgets":[{"layoutId":"format-container","type":"utility","variableData":{"format":"Hindi • IMAX 2D | IMAX"}},{"layoutId":"category-price-header-container","type":"text","variableData":{"title":"Seat category and price"}},{"layoutId":"seat-category-type-sold-out","type":"text","variableData":{"seatAvalibility":"SOLD OUT","seatCost":"₹ 1200.00","seatType":"RECLINER"}},{"layoutId":"seat-category-type-almost-full","type":"text","variableData":{"seatAvalibility":"ALMOST FULL","seatCost":"₹ 860.00","seatType":"PICTURE PERFECT"}},{"layoutId":"seat-category-type-almost-full","type":"text","variableData":{"seatAvalibility":"ALMOST FULL","seatCost":"₹ 830.00","seatType":"PRIME PLUS"}},{"layoutId":"seat-category-type-filling-fast","type":"text","variableData":{"seatAvalibility":"FILLING FAST","seatCost":"₹ 750.00","seatType":"PRIME"}},{"layoutId":"seat-category-type-available","type":"text","variableData":{"seatAvalibility":"AVAILABLE","seatCost":"₹ 630.00","seatType":"CLASSIC"}}]}},"analytics":{"event_action":"showinfo_modal_viewed","event_name":"showtime_actions","event_type":"click"}}},{"title":"11:45 PM","screenAttr":"IMAX","styleId":"orange-pill-with-border","cta":{"type":"showTimeRedirect","analytics":{"company_code":"PVR","event_name":"showtime_selected","event_type":"click","format":"IMAX","label":"night_show","metadata":"{\"venue_info\":[\"fast_filling\"]}","show_session_id":"37933","show_time":"23:45","type":"actual_icon"},"additionalData":{"showCutOffBottomSheet":true,"cta":{"type":"bottomsheet","id":"cutOffBottomSheet"}}},"additionalData":{"sessionId":"37933","availStatus":"2","cutOffDateTime":"202608280030","cutOffDateTimeEpoch":"1787857200","showDateCode":"20260827","showDateTime":"202608272345","showTimeCode":"2345","showTime":"11:45 PM","attributes":"IMAX"},"filters":["pf7","pf8","pf9","pf93","tf4"],"customGestureCTA":{"gestureType":"doubleTap","type":"bottomsheet","additionalData":{"bottomSheetData":{"backgroundColor":"#ffffff","handleColor":"#000000","widgets":[{"layoutId":"format-container","type":"utility","variableData":{"format":"Hindi • IMAX 2D | IMAX"}},{"layoutId":"category-price-header-container","type":"text","variableData":{"title":"Seat category and price"}},{"layoutId":"seat-category-type-sold-out","type":"text","variableData":{"seatAvalibility":"SOLD OUT","seatCost":"₹ 1200.00","seatType":"RECLINER"}},{"layoutId":"seat-category-type-almost-full","type":"text","variableData":{"seatAvalibility":"ALMOST FULL","seatCost":"₹ 860.00","seatType":"PICTURE PERFECT"}},{"layoutId":"seat-category-type-almost-full","type":"text","variableData":{"seatAvalibility":"ALMOST FULL","seatCost":"₹ 780.00","seatType":"PRIME PLUS"}},{"layoutId":"seat-category-type-filling-fast","type":"text","variableData":{"seatAvalibility":"FILLING FAST","seatCost":"₹ 750.00","seatType":"PRIME"}},{"layoutId":"seat-category-type-available","type":"text","variableData":{"seatAvalibility":"AVAILABLE","seatCost":"₹ 630.00","seatType":"CLASSIC"}}]}},"analytics":{"event_action":"showinfo_modal_viewed","event_name":"showtime_actions","event_type":"click"}},"overlayId":"lateNight"}],"filters":["cancellation","pf6","pf7","tf1","pf8","pf9","pf93","pf5","tf2","tf4","pf91"],"additionalData":{"eventCode":"ET00513458"}}],"analytics":{"display_position":"1","event_name":"showtime_card_viewed","event_type":"impression","is_favourite":"false","label":"morning_show, night_show","venue_code":"PPAM"},"styleId":"venue-card-style","id":"PPAM"}]}]},{"type":"info","id":"changeLocation","filters":["ALL"],"data":[{"styleId":"widget-title","text":"Unable to find what you are looking for?"}],"action":{"label":"Change Location","backgroundColor":"#FFFFFF","textColor":"#DC354B","borderColor":"#DC354B","cta":{"type":"changeLocation","analytics":{"event_action":"change_location","event_name":"showtime_actions","event_type":"click"}}},"sortInfo":{"distance":{"value":0,"enabled":false},"popularityRank":{"value":5000000,"enabled":true}}}],"coachmarksState":{"showtimeCoachmark":true,"filterShowtimeCoachmark":false,"carouselCoachmarks":{"favCinemas":true,"showtimeDemarcation":true}},"addOnWidgets":{},"availableFilters":["cancellation","pf5","pf6","pf7","pf8","pf9","pf91","pf93","tf1","tf2","tf4"]},"metadata":{"analytics":{"event_code":"ET00513458","event_group":"EG00377461","event_name":"movie_showtimes_viewed","event_type":"screen_view","format":"IMAX 2D","genre":"Action|Crime|Period|Thriller","language":"Hindi","metadata":{"coachmark":"Y"},"product":"movies","screen_name":"movie_showtimes","show_date":"2026-08-27","title":"Toxic: A Fairy Tale for Grown-ups"}}} \ No newline at end of file diff --git a/test/monitor.test.js b/test/monitor.test.js index 8cb241e..1b0ab4d 100644 --- a/test/monitor.test.js +++ b/test/monitor.test.js @@ -1,165 +1,129 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; -import { fileURLToPath } from "node:url"; -import { guestToken, toLocalTime, extractSessions, decide, defaultState } from "../src/monitor.js"; +import { + decideProvider, decideGlobal, defaultProviderState, defaultState, providerState, heartbeatDue, +} from "../src/monitor.js"; -const fixture = JSON.parse(readFileSync( - fileURLToPath(new URL("./fixtures/response-open-date.json", import.meta.url)), "utf8")); - -// Matches the sample in .env.example: IMAX at one specific cinema, IST. const CFG = { - targetDate: "2026-08-10", - expiryUtc: "2026-08-10T18:30:00Z", + targetDate: "2026-09-25", + expiryUtc: "2026-09-25T18:30:00Z", bookingUrl: "https://example.com/book", failureThreshold: 6, tzOffsetMinutes: 330, heartbeatHour: 9, - formatTag: "imax_2d", - formatMatch: "IMAX", formatLabel: "IMAX", - cinemaId: "1032362", currency: "₹", }; +const P = { key: "district", label: "District", bookingUrl: "https://example.com/d" }; +const P2 = { key: "bookmyshow", label: "BookMyShow", bookingUrl: "https://example.com/b" }; -test("guestToken matches District's client-generated shape", () => { - const t = guestToken(); - assert.match(t, /^\d{13}_\d{18}_[a-z0-9]{11}$/); - assert.notEqual(guestToken(), t, "fresh token per call"); -}); - -test("toLocalTime converts API UTC showTime to local clock time", () => { - assert.equal(toLocalTime("2026-08-05T03:30", 330), "9:00 AM"); // verified against the site UI - assert.equal(toLocalTime("2026-08-05T06:30", 330), "12:00 PM"); // noon boundary - assert.equal(toLocalTime("2026-08-05T12:00", 330), "5:30 PM"); - assert.equal(toLocalTime("2026-08-05T18:45", 330), "12:15 AM"); // past midnight, clock time only - assert.equal(toLocalTime("2026-08-05T03:30:00", 330), "9:00 AM"); // seconds already present - assert.equal(toLocalTime("2026-08-05T03:30", 0), "3:30 AM"); // other timezones -}); - -test("extractSessions finds the target cinema's sessions in a real capture", () => { - const ex = extractSessions(fixture, CFG); - assert.equal(ex.cinemaCount, 5); - assert.ok(ex.sessionCount > ex.matched.length); - assert.equal(ex.matched.length, 4); - assert.equal(ex.other.length, 0); - const s = ex.matched[0]; - assert.equal(s.sid, "36874"); - assert.equal(s.cinema, "PVR Palladium Mall, Thaltej, Ahmedabad"); - assert.equal(s.time, "9:00 AM"); - assert.equal(s.audi, "SCREEN 9"); - assert.ok(s.tiers.includes("CLASSIC ₹260")); - assert.ok(s.tiers.includes("RECLINER ₹640")); - assert.equal(ex.showDates.at(-1), "2026-08-09"); -}); - -test("extractSessions: no cinemaId watches every cinema; no format filter matches all", () => { - const anyCinema = extractSessions(fixture, { ...CFG, cinemaId: "" }); - assert.equal(anyCinema.matched.length, 4, "still only IMAX sessions"); - assert.equal(anyCinema.other.length, 0, "nothing is 'other' when no cinema is targeted"); - - const anyFormat = extractSessions(fixture, { ...CFG, cinemaId: "", formatTag: "", formatMatch: "" }); - assert.equal(anyFormat.matched.length, anyFormat.sessionCount, "every session matches"); - - const otherCinema = extractSessions(fixture, { ...CFG, cinemaId: "125" }); - assert.equal(otherCinema.matched.length, 0); - assert.equal(otherCinema.other.length, 4, "IMAX elsewhere becomes a secondary line"); -}); - -test("extractSessions tolerates empty/malformed payloads", () => { - assert.deepEqual(extractSessions({}, CFG), - { matched: [], other: [], cinemaCount: 0, sessionCount: 0, showDates: [] }); -}); - -const T0 = new Date("2026-08-06T02:00:00Z"); // before the 03:30 UTC (09:00 IST) heartbeat window +const T0 = new Date("2026-09-20T02:00:00Z"); // before the 03:30 UTC (09:00 IST) heartbeat window +const HB = new Date("2026-09-20T03:31:00Z"); // inside it const openInput = (extract, now = T0) => ({ kind: "open", extract, now }); + const EX_MATCH = { - matched: [{ sid: "111", cinema: "Test Cinema", time: "9:00 AM", audi: "SCREEN 9", avail: 100, total: 300, tiers: "CLASSIC ₹260 (66/66)" }], + matched: [{ sid: "111", cinema: "Test Cinema", time: "9:00 AM", audi: "SCREEN 9", format: "IMAX", avail: 100, total: 300, tiers: "CLASSIC ₹260 (66/66)" }], other: [], cinemaCount: 3, sessionCount: 7, showDates: [], }; const EX_NO_MATCH = { matched: [], other: [], cinemaCount: 3, sessionCount: 7, showDates: [] }; const EX_EMPTY = { matched: [], other: [], cinemaCount: 0, sessionCount: 0, showDates: [] }; /** Mirrors the runner: merge each alert's patch only after its push succeeds. */ -function apply(state, decision) { +function apply(pstate, decision) { let merged = decision.baseState; for (const a of decision.alerts) merged = { ...merged, ...a.statePatch }; return merged; } test("closed steady state produces no alerts and no state change", () => { - const s1 = apply(defaultState(), decide(defaultState(), { kind: "closed", now: T0 }, CFG)); - const d2 = decide(s1, { kind: "closed", now: new Date(T0.getTime() + 60000) }, CFG); + const s1 = apply(defaultProviderState(), decideProvider(defaultProviderState(), { kind: "closed", now: T0 }, CFG, P)); + const d2 = decideProvider(s1, { kind: "closed", now: new Date(T0.getTime() + 60000) }, CFG, P); assert.equal(d2.alerts.length, 0); assert.equal(JSON.stringify(apply(s1, d2)), JSON.stringify(s1), "no state write when nothing changed"); }); test("a match fires a max-priority alert once, re-firing only for new session ids", () => { - const d1 = decide(defaultState(), openInput(EX_MATCH), CFG); + const d1 = decideProvider(defaultProviderState(), openInput(EX_MATCH), CFG, P); assert.equal(d1.alerts.length, 1); assert.equal(d1.alerts[0].priority, "5"); + assert.equal(d1.alerts[0].provider, "district", "alerts carry their provider"); + assert.ok(d1.alerts[0].title.includes("District"), "the site is named in the title"); assert.ok(d1.alerts[0].statePatch.dateOpenAlerted, "first open response sets dateOpenAlerted"); assert.ok(d1.alerts[0].statePatch.sidsAlerted["111"]); - const s1 = apply(defaultState(), d1); - assert.equal(decide(s1, openInput(EX_MATCH), CFG).alerts.length, 0, "deduped"); + + const s1 = apply(defaultProviderState(), d1); + assert.equal(decideProvider(s1, openInput(EX_MATCH), CFG, P).alerts.length, 0, "deduped"); const withNew = { ...EX_MATCH, matched: [...EX_MATCH.matched, { ...EX_MATCH.matched[0], sid: "222", time: "1:15 PM" }] }; - const d3 = decide(s1, openInput(withNew), CFG); + const d3 = decideProvider(s1, openInput(withNew), CFG, P); assert.equal(d3.alerts.length, 1, "new session id re-alerts"); assert.ok(d3.alerts[0].body.includes("1:15 PM")); assert.ok(!d3.alerts[0].body.includes("9:00 AM"), "already-alerted session not repeated"); }); -test("sessions at other cinemas ride along as secondary lines, untracked", () => { +test("sessions without seat counts still render (BookMyShow shape)", () => { + const bmsLike = { + matched: [{ sid: "37929", cinema: "PVR: Palladium", time: "08:00 AM", audi: "", format: "IMAX", avail: null, total: null, tiers: "CLASSIC ₹470 (AVAILABLE)" }], + other: [], cinemaCount: 1, sessionCount: 5, showDates: [], + }; + const d = decideProvider(defaultProviderState(), openInput(bmsLike), CFG, P2); + const body = d.alerts[0].body; + assert.ok(body.includes("08:00 AM | PVR: Palladium | IMAX"), body); + assert.ok(!body.includes("seats"), "no seat counts when the provider has none"); + assert.ok(d.alerts[0].title.includes("BookMyShow")); +}); + +test("sessions at other venues ride along as secondary lines, untracked", () => { const withOther = { ...EX_MATCH, - other: [{ sid: "999", cinema: "Other Cinema", time: "6:00 PM", audi: "", avail: 50, total: 100, tiers: "CLASSIC ₹300 (50/100)" }], + other: [{ sid: "999", cinema: "Other Cinema", time: "6:00 PM", audi: "", format: "IMAX", avail: null, total: null, tiers: "" }], }; - const d = decide(defaultState(), openInput(withOther), CFG); + const d = decideProvider(defaultProviderState(), openInput(withOther), CFG, P); assert.equal(d.alerts.length, 1, "no separate push for other venues"); assert.ok(d.alerts[0].body.includes("Also at Other Cinema: 6:00 PM")); assert.ok(!d.alerts[0].statePatch.sidsAlerted["999"], "other-venue ids are not dedupe-tracked"); }); -test("date open without a match fires DATE_OPEN once; a later match still fires", () => { - const d1 = decide(defaultState(), openInput(EX_NO_MATCH), CFG); +test("date open without a match fires once; a later match still fires", () => { + const d1 = decideProvider(defaultProviderState(), openInput(EX_NO_MATCH), CFG, P); assert.equal(d1.alerts.length, 1); assert.equal(d1.alerts[0].priority, "4"); - const s1 = apply(defaultState(), d1); - assert.equal(decide(s1, openInput(EX_NO_MATCH), CFG).alerts.length, 0); - const d3 = decide(s1, openInput(EX_MATCH), CFG); + const s1 = apply(defaultProviderState(), d1); + assert.equal(decideProvider(s1, openInput(EX_NO_MATCH), CFG, P).alerts.length, 0); + const d3 = decideProvider(s1, openInput(EX_MATCH), CFG, P); assert.equal(d3.alerts.length, 1); assert.equal(d3.alerts[0].priority, "5"); }); -test("failed match push then sessions vanish -> DATE_OPEN still reachable", () => { - const d1 = decide(defaultState(), openInput(EX_MATCH), CFG); +test("failed match push then sessions vanish -> date-open alert still reachable", () => { + const d1 = decideProvider(defaultProviderState(), openInput(EX_MATCH), CFG, P); const s1 = d1.baseState; // push failed: statePatch not merged - const d2 = decide(s1, openInput(EX_NO_MATCH), CFG); + const d2 = decideProvider(s1, openInput(EX_NO_MATCH), CFG, P); assert.equal(d2.alerts.length, 1); assert.equal(d2.alerts[0].priority, "4", "user still learns the date opened"); }); test("open-but-empty counts as closed", () => { - const d = decide(defaultState(), openInput(EX_EMPTY), CFG); + const d = decideProvider(defaultProviderState(), openInput(EX_EMPTY), CFG, P); assert.equal(d.alerts.length, 0); assert.equal(d.baseState.lastGood.kind, "closed"); }); -test("BROKEN fires at the threshold, once; RECOVERED on the next success", () => { - let s = defaultState(); +test("BROKEN fires at the threshold, once, and names the site; RECOVERED on next success", () => { + let s = defaultProviderState(); for (let i = 1; i <= 5; i++) { - const d = decide(s, { kind: "error", reason: "HTTP 403", now: T0 }, CFG); + const d = decideProvider(s, { kind: "error", reason: "HTTP 403", now: T0 }, CFG, P2); assert.equal(d.alerts.length, 0, `no alert at failure ${i}`); s = apply(s, d); assert.equal(s.failures, i); } - const d6 = decide(s, { kind: "error", reason: "HTTP 403", now: T0 }, CFG); + const d6 = decideProvider(s, { kind: "error", reason: "HTTP 403", now: T0 }, CFG, P2); assert.equal(d6.alerts.length, 1); + assert.ok(d6.alerts[0].title.includes("BookMyShow")); assert.ok(d6.alerts[0].title.includes("BROKEN")); s = apply(s, d6); - assert.equal(decide(s, { kind: "error", reason: "HTTP 403", now: T0 }, CFG).alerts.length, 0, "not repeated"); - const d8 = decide(s, { kind: "closed", now: T0 }, CFG); + assert.equal(decideProvider(s, { kind: "error", reason: "HTTP 403", now: T0 }, CFG, P2).alerts.length, 0, "not repeated"); + const d8 = decideProvider(s, { kind: "closed", now: T0 }, CFG, P2); assert.equal(d8.alerts.length, 1); assert.ok(d8.alerts[0].title.includes("recovered")); s = apply(s, d8); @@ -167,48 +131,62 @@ test("BROKEN fires at the threshold, once; RECOVERED on the next success", () => assert.equal(s.brokenAlerted, false); }); -test("heartbeat fires once per local day, never on error ticks, and can be disabled", () => { - const hbTime = new Date("2026-08-06T03:31:00Z"); // 09:01 IST - const early = new Date("2026-08-06T03:29:00Z"); - assert.equal(decide(defaultState(), { kind: "closed", now: early }, CFG).alerts.length, 0); - - const d1 = decide(defaultState(), { kind: "closed", now: hbTime }, CFG); - assert.equal(d1.alerts.length, 1); - assert.equal(d1.alerts[0].priority, "1"); - assert.equal(d1.alerts[0].statePatch.hbDate, "2026-08-06"); - - const s1 = apply(defaultState(), d1); - assert.equal(decide(s1, { kind: "closed", now: new Date("2026-08-06T09:00:00Z") }, CFG).alerts.length, 0, "once per day"); - assert.equal(decide(defaultState(), { kind: "error", reason: "x", now: hbTime }, CFG).alerts.length, 0, "not on errors"); - assert.equal(decide(defaultState(), { kind: "closed", now: hbTime }, { ...CFG, heartbeatHour: -1 }).alerts.length, 0, "disabled"); +test("providers keep independent dedupe state", () => { + const state = defaultState(); + const dA = decideProvider(providerState(state, "district"), openInput(EX_MATCH), CFG, P); + state.providers.district = apply(providerState(state, "district"), dA); + // BookMyShow has never alerted, so the same session id must still notify there. + const dB = decideProvider(providerState(state, "bookmyshow"), openInput(EX_MATCH), CFG, P2); + assert.equal(dB.alerts.length, 1, "one site alerting must not silence the other"); + assert.ok(dB.alerts[0].title.includes("BookMyShow")); + assert.equal(decideProvider(providerState(state, "district"), openInput(EX_MATCH), CFG, P).alerts.length, 0); }); -test("heartbeat reports the probe-refreshed showDates maximum", () => { - const hbTime = new Date("2026-08-06T03:31:00Z"); - const d = decide(defaultState(), { kind: "closed", now: hbTime, probeMax: "2026-08-11" }, CFG); - assert.equal(d.baseState.lastGood.showDatesMax, "2026-08-11", "probe result rides baseState"); - assert.ok(d.alerts[0].body.includes("2026-08-11")); +test("providerState defaults unknown providers and preserves stored ones", () => { + const state = { ...defaultState(), providers: { district: { failures: 3 } } }; + assert.equal(providerState(state, "district").failures, 3); + assert.equal(providerState(state, "district").brokenAlerted, false, "missing fields are defaulted"); + assert.deepEqual(providerState(state, "bookmyshow"), defaultProviderState()); }); -test("recovery, match and heartbeat can co-fire with disjoint patches", () => { - const hbTime = new Date("2026-08-06T03:31:00Z"); - const s0 = { ...defaultState(), brokenAlerted: true, failures: 6 }; - const d = decide(s0, openInput(EX_MATCH, hbTime), CFG); - assert.deepEqual(d.alerts.map((a) => a.priority), ["3", "5", "1"]); - assert.deepEqual(d.alerts.map((a) => Object.keys(a.statePatch)), - [["brokenAlerted"], ["sidsAlerted", "dateOpenAlerted"], ["hbDate"]]); - const merged = apply(s0, d); - assert.equal(merged.brokenAlerted, false); - assert.equal(merged.failures, 0); - assert.ok(merged.sidsAlerted["111"]); - assert.equal(merged.hbDate, "2026-08-06"); +// ---------------------------------------------------------- global alerts + +test("heartbeat fires once per local day, summarises every provider, and can be disabled", () => { + const summaries = [ + { label: "District", kind: "closed", showDatesMax: "2026-09-20" }, + { label: "BookMyShow", kind: "closed" }, + ]; + assert.equal(decideGlobal(defaultState(), CFG, new Date("2026-09-20T03:29:00Z"), summaries).alerts.length, 0, "before the window"); + + const d = decideGlobal(defaultState(), CFG, HB, summaries); + assert.equal(d.alerts.length, 1); + assert.equal(d.alerts[0].priority, "1"); + assert.ok(d.alerts[0].body.includes("District: closed (open through 2026-09-20)")); + assert.ok(d.alerts[0].body.includes("BookMyShow: closed")); + assert.equal(d.alerts[0].statePatch.hbDate, "2026-09-20"); + + const after = { ...defaultState(), hbDate: "2026-09-20" }; + assert.equal(decideGlobal(after, CFG, new Date("2026-09-20T09:00:00Z"), summaries).alerts.length, 0, "once per day"); + assert.equal(decideGlobal(defaultState(), { ...CFG, heartbeatHour: -1 }, HB, summaries).alerts.length, 0, "disabled"); +}); + +test("heartbeatDue tracks the local day, so it is not fooled by UTC rollover", () => { + assert.equal(heartbeatDue({ hbDate: "" }, HB, CFG), true); + assert.equal(heartbeatDue({ hbDate: "2026-09-20" }, HB, CFG), false); }); test("retirement: one alert at expiry, then a permanent no-op", () => { - const past = new Date("2026-08-10T18:31:00Z"); - const d1 = decide(defaultState(), { kind: "closed", now: past }, CFG); + const past = new Date("2026-09-25T18:31:00Z"); + const d1 = decideGlobal(defaultState(), CFG, past, []); assert.equal(d1.alerts.length, 1); assert.ok(d1.alerts[0].statePatch.retired); - const s1 = apply(defaultState(), d1); - assert.equal(decide(s1, { kind: "closed", now: past }, CFG).alerts.length, 0); + const s1 = { ...defaultState(), ...d1.alerts[0].statePatch }; + assert.equal(decideGlobal(s1, CFG, past, []).alerts.length, 0); +}); + +test("retirement takes precedence over the heartbeat", () => { + const past = new Date("2026-09-26T03:31:00Z"); // after expiry and inside the heartbeat window + const d = decideGlobal(defaultState(), CFG, past, []); + assert.equal(d.alerts.length, 1); + assert.ok(d.alerts[0].title.includes("retired")); }); diff --git a/test/providers.test.js b/test/providers.test.js new file mode 100644 index 0000000..94e67fa --- /dev/null +++ b/test/providers.test.js @@ -0,0 +1,106 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { toLocalTime, localDate } from "../src/lib.js"; +import * as district from "../src/providers/district.js"; +import * as bms from "../src/providers/bookmyshow.js"; + +const load = (f) => JSON.parse(readFileSync(fileURLToPath(new URL(`./fixtures/${f}`, import.meta.url)), "utf8")); +const CFG = { tzOffsetMinutes: 330, currency: "₹" }; + +test("toLocalTime converts API UTC timestamps to local clock time", () => { + assert.equal(toLocalTime("2026-08-05T03:30", 330), "9:00 AM"); // verified against the site UI + assert.equal(toLocalTime("2026-08-05T06:30", 330), "12:00 PM"); // noon boundary + assert.equal(toLocalTime("2026-08-05T12:00", 330), "5:30 PM"); + assert.equal(toLocalTime("2026-08-05T18:45", 330), "12:15 AM"); // past midnight, clock time only + assert.equal(toLocalTime("2026-08-05T03:30:00", 330), "9:00 AM"); // seconds already present + assert.equal(toLocalTime("2026-08-05T03:30", 0), "3:30 AM"); // other timezones +}); + +test("localDate rolls over at local midnight, not UTC midnight", () => { + assert.equal(localDate(new Date("2026-08-05T19:00:00Z"), 330), "2026-08-06"); + assert.equal(localDate(new Date("2026-08-05T17:00:00Z"), 330), "2026-08-05"); +}); + +// ---------------------------------------------------------------- District + +const D_PARAMS = { venueId: "1032362", formatTag: "imax_2d", formatMatch: "IMAX" }; + +test("district: extracts the target cinema's sessions from a real capture", () => { + const ex = district.extract(load("response-open-date.json"), CFG, D_PARAMS); + assert.equal(ex.cinemaCount, 5); + assert.ok(ex.sessionCount > ex.matched.length); + assert.equal(ex.matched.length, 4); + assert.equal(ex.other.length, 0); + const s = ex.matched[0]; + assert.equal(s.sid, "36874"); + assert.equal(s.cinema, "PVR Palladium Mall, Thaltej, Ahmedabad"); + assert.equal(s.time, "9:00 AM"); + assert.equal(s.audi, "SCREEN 9"); + assert.equal(s.avail, 103); // District reports exact seat counts + assert.ok(s.tiers.includes("CLASSIC ₹260")); + assert.ok(s.tiers.includes("RECLINER ₹640")); + assert.equal(ex.showDates.at(-1), "2026-08-09"); +}); + +test("district: no venue watches every cinema, no format filter matches everything", () => { + const data = load("response-open-date.json"); + const anyVenue = district.extract(data, CFG, { ...D_PARAMS, venueId: "" }); + assert.equal(anyVenue.matched.length, 4, "still only IMAX"); + assert.equal(anyVenue.other.length, 0); + + const anyFormat = district.extract(data, CFG, { venueId: "", formatTag: "", formatMatch: "" }); + assert.equal(anyFormat.matched.length, anyFormat.sessionCount); + + const elsewhere = district.extract(data, CFG, { ...D_PARAMS, venueId: "125" }); + assert.equal(elsewhere.matched.length, 0); + assert.equal(elsewhere.other.length, 4, "IMAX at another venue becomes a secondary line"); +}); + +test("district: tolerates empty payloads", () => { + assert.deepEqual(district.extract({}, CFG, D_PARAMS), + { matched: [], other: [], cinemaCount: 0, sessionCount: 0, showDates: [] }); +}); + +// ------------------------------------------------------------- BookMyShow + +const B_PARAMS = { venueId: "PPAM", formatMatch: "IMAX" }; + +test("bookmyshow: date codes drop the dashes", () => { + assert.equal(bms.toDateCode("2026-09-25"), "20260925"); +}); + +test("bookmyshow: extracts venue sessions and per-tier availability from a real capture", () => { + const ex = bms.extract(load("bms-open-date.json"), CFG, B_PARAMS); + assert.equal(ex.cinemaCount, 1); + assert.equal(ex.sessionCount, 5); + assert.equal(ex.matched.length, 5); + const s = ex.matched[0]; + assert.equal(s.sid, "37929"); + assert.equal(s.cinema, "PVR: Palladium Mall, Ahmedabad"); + assert.equal(s.time, "08:00 AM"); // already local, so it is passed through untouched + assert.equal(s.format, "IMAX"); + assert.equal(s.avail, null, "BMS never exposes exact seat counts"); + assert.ok(s.tiers.includes("RECLINER ₹960 (ALMOST FULL)")); + assert.ok(s.tiers.includes("CLASSIC ₹470 (AVAILABLE)")); +}); + +test("bookmyshow: a different venue code moves sessions to the secondary bucket", () => { + const ex = bms.extract(load("bms-open-date.json"), CFG, { ...B_PARAMS, venueId: "XXXX" }); + assert.equal(ex.matched.length, 0); + assert.equal(ex.other.length, 5); +}); + +test("bookmyshow: a non-matching format filter yields nothing", () => { + const ex = bms.extract(load("bms-open-date.json"), CFG, { ...B_PARAMS, formatMatch: "4DX" }); + assert.equal(ex.matched.length, 0); + assert.equal(ex.sessionCount, 5, "sessions are still counted, just not matched"); +}); + +test("bookmyshow: a not-yet-on-sale response has no widgets and extracts to nothing", () => { + const closed = load("bms-closed-date.json"); + assert.equal(closed.data.showtimeWidgets, undefined, "this is how 'not open yet' looks"); + assert.deepEqual(bms.extract(closed, CFG, B_PARAMS), + { matched: [], other: [], cinemaCount: 0, sessionCount: 0, showDates: [] }); +});