Files
ticket-monitoring/README.md
T
Aculix Technologies 00e6852863 ci: publish multi-arch image to GHCR; move status port to 4733
GitHub Actions builds linux/amd64 and linux/arm64 on every push to main and
pushes a single :latest tag to ghcr.io, so the container can be run without
cloning or building. Tests gate the publish step.
2026-08-14 14:36:18 +05:30

9.3 KiB

ticket-monitoring

docker

Watches 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.

Example notification: IMAX shows for a target date, with showtimes, seat counts and prices

I built this because IMAX shows kept selling out before I even knew they'd opened. Refreshing the page a few times a day wasn't working. Now something else checks every 60 seconds and yells at me when it matters.

How it works

Every 60 seconds it calls the same showtimes endpoint the District site uses itself. A date that isn't on sale yet answers with 204 No Content. The moment it opens, you get the full session list back. The monitor filters that list down to your cinema and format, then pushes to ntfy, a free open-source notification service with iOS and Android apps.

The part I care most about: silence always means "not open yet". If checks start failing you get a Monitor BROKEN push after six failures in a row, plus an optional daily heartbeat. A monitor that died at 3am and one that's quietly working should never look the same from the outside.

Alerts are deduplicated by session id in a small state file, so you hear about each show once. Shows added later still get through.

Notification Priority What it means
<FORMAT> <DATE> OPEN - BOOK NOW max The one you're waiting for. Tap it to book.
<DATE> bookings are OPEN (no <FORMAT> yet) high Date opened, your format hasn't shown up yet.
Monitor BROKEN - check manually high Checks are failing. Don't trust the silence.
Monitor recovered default Back to normal.
Still watching <DATE> min Daily heartbeat.
Monitor retired default Target date passed, so it stopped on its own.

Requirements

The ntfy app on your phone, and either Docker or Node.js 22+.

Quickest start: run the published image

A prebuilt image is published to GitHub Container Registry on every push to main, for both 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, save it as .env, fill it in using "Finding your IDs" below, then:

docker run -d --name ticket-monitoring \
  --restart unless-stopped \
  --env-file .env \
  -p 4733:4733 \
  -v ticket-monitoring-data:/data \
  ghcr.io/aculix/ticket-monitoring:latest
docker logs -f ticket-monitoring

You want to see tick: closed failures: 0 scrolling past once a minute.

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:

docker pull ghcr.io/aculix/ticket-monitoring:latest
docker rm -f ticket-monitoring   # then re-run the docker run command above

Only :latest is published, so that's always the current build of main.

Quick start with Docker Compose

Handy if you'd rather keep the config in a file you can edit in place:

git clone https://github.com/aculix/ticket-monitoring.git
cd ticket-monitoring
cp .env.example .env      # edit it, see "Finding your IDs" below
docker compose up -d      # add --build to build locally instead of pulling
docker compose logs -f

Quick start without Docker

npm install
cp .env.example .env      # edit it
npm run check             # one check, tells you what it found
npm start                 # run it for real

Run npm run check first. It's the fastest way to find out you got an ID wrong, and much less annoying than discovering it three hours into a run. Needs Node 22+ for the built-in --env-file support.

Finding your IDs

Everything movie-specific is sitting in one URL. Open the film's District page for your city:

https://www.district.in/movies/the-odyssey-movie-tickets-in-ahmedabad-MV187151?frmtid=y7EZNLXN5Y&fromdate=2026-08-10
                                                          └── CITY_KEY ──┘ └CONTENT_ID┘        └─MOVIE_CODE─┘
  • CONTENT_ID is the number after MV
  • MOVIE_CODE is the frmtid parameter, which pins down the movie and the language
  • CITY_KEY is the city slug in the path
  • LAT and LNG can be any coordinates inside that city. The API refuses to answer without them, though results are city-wide either way.
  • BOOKING_URL is the whole URL with fromdate set to your target date

CINEMA_ID is the fiddly one, since it isn't in the URL. Easiest approach: leave it empty at first, which watches every cinema in the city, then run npm run check against a date that's already bookable and read the ids out of the response. Or just leave it empty for good and let the alert tell you where the shows are.

Want any format, not only IMAX? Set both FORMAT_TAG and FORMAT_MATCH to empty.

Configuration

Everything is environment variables. .env is only a convenient place to put them.

Variable Default Purpose
MOVIE_CODE required frmtid, identifies movie and language
CONTENT_ID required Movie id, the digits after MV
CITY_KEY required City slug, e.g. ahmedabad
LAT, LNG required Coordinates somewhere in that city
TARGET_DATE required The date you're waiting for, YYYY-MM-DD
NTFY_TOPIC required The topic you subscribe to. Treat it like a password.
CINEMA_ID (any) Restrict to one cinema
FORMAT_TAG imax_2d Session tag that counts as a match
FORMAT_MATCH IMAX Screen-format substring that counts as a match
FORMAT_LABEL IMAX What the format is called in the alert text
BOOKING_URL Opens when you tap the notification
NTFY_URL https://ntfy.sh Your own ntfy server, if you run one
NTFY_TOKEN Only if your server wants auth
CHECK_INTERVAL_SECONDS 60 Poll interval
FAILURE_THRESHOLD 6 Failures in a row before the BROKEN alert
TZ_OFFSET_MINUTES 330 Timezone for showtimes, 330 is IST
HEARTBEAT_HOUR 9 Local hour for the daily heartbeat, -1 turns it off
CURRENCY ₹ Price prefix
EXPIRY_UTC auto When to retire. Defaults to local midnight after TARGET_DATE.
STATE_PATH ./state.json Where dedupe state lives, /data/state.json in Docker
PORT 4733 Status endpoint
PROXY_URL SOCKS5 proxy for the site checks, see below

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
  • /healthz returns {"ok":true}
curl -s localhost:4733/ | jq

Watch lastTick.at. If it's more than a couple of minutes old, the loop is stuck.

Troubleshooting

Every check returns HTTP 403

The site's WAF is blocking your host. This happens reliably on cloud providers. An earlier version of this ran as a Cloudflare Worker, worked fine for two days, then got blocked permanently, while a home connection was never touched once. Either run it from a residential connection, or point PROXY_URL at a SOCKS5 proxy. Only the site checks go through the proxy. Notifications stay direct, so your alerts don't depend on it.

ntfy returns HTTP 429

Public ntfy.sh rate-limits by IP, and shared cloud egress IPs are usually spent already by other people. Self-host ntfy and set NTFY_URL, or run from home.

Notifications arrive, but silently

That's your phone, not the monitor. Open the subscription in the ntfy app and let max-priority messages through Do Not Disturb.

Alerts repeat, or never arrive again

Dedupe lives in the state file at STATE_PATH, or in the data/ volume under Docker. Delete it to start fresh. Do this whenever you change TARGET_DATE, otherwise it still thinks it has already told you about those shows.

Nothing happens at all

Run npm run check for a single noisy check. Configuration problems are reported explicitly.

Development

npm test        # 16 tests, no network needed

The tests run the alert state machine and session parsing against a real captured API response in test/fixtures/. src/monitor.js has the pure logic and the API client, src/index.js is the runner plus state file and status server, src/config.js reads the environment.

If you're forking this for another site, docs/design-notes.md covers what I learned about the API and why the alert logic is shaped the way it is.

Notes

This polls a public endpoint once a minute, which is roughly what a person refreshing the page would do, to buy their own tickets. It doesn't automate purchases, hold seats or skip any queue. Please keep it that way: don't drop the interval, don't run ten copies, don't use it to scalp.

Not affiliated with District, PVR or IMAX. The API is undocumented and can change whenever they feel like it.

License

MIT, see LICENSE.