- Python 97.8%
- Dockerfile 2.2%
The README and the source both named the exact block and the date it was booked for, which is a real seat in a real room on a real evening under my own name. HELD_BLOCK and HELD_OFFSET now come from the environment, where the ntfy token already lives, and default to a neutral label. The status note stays: the tickets are bought, so maintenance is not promised. |
||
|---|---|---|
| .env.example | ||
| .gitignore | ||
| docker-compose.yml | ||
| Dockerfile | ||
| LICENSE | ||
| odyssey-seat-watcher.service | ||
| odyssey_watch.py | ||
| README.md | ||
odyssey-seat-watcher
Watches IMAX 70mm seat maps for Christopher Nolan's The Odyssey and sends an ntfy alert when seats worth having open up.
Configured for Regal Mall of Georgia (Buford, GA), the only 70mm film projector in the state.
Retarget it by editing the constants at the top of odyssey_watch.py.
Fork of LKalra2094/odyssey-seat-watcher, which alerts via Telegram and is configured for AMC Metreon.
Status
I got my tickets, caught from a cancellation this thing found, so the job it was built for is done. It is still running in upgrade mode until the 70mm run at this venue ends on 16 September 2026, but after that I have no particular reason to keep maintaining it. Issues and pull requests may go unanswered. Fork it freely; the Retargeting it section below is written for exactly that.
Why
70mm showings sell out immediately. The only way in is a cancellation, and cancellations get taken within the hour. Email waitlists are too slow.
How it works
Two unauthenticated JSON calls per showtime against Fandango's internal API:
/napi/theaterMovieShowtimes/<theaterId>?date=YYYY-MM-DD showtimes + hash codes
/napi/seatMap/<showtimeHashCode> every seat, live status
Python standard library only. No login, no cookies, no API key, no browser.
Fandango, not the chain's own site. Regal's regmovies.com/api/getShowtimes sits behind a
Cloudflare managed challenge and returns 403 with cf-mitigated: challenge to any plain HTTP
client, from any IP. Fandango sells Regal tickets and serves the same seat maps unchallenged.
The Referer header is required. Without it you get:
{"error": "FORBIDDEN", "errorMessage": "Session expired or invalid token"}
which looks like an auth problem but is not. There is no session to expire. Send
Referer: https://www.fandango.com/<theater-slug>/theater-page and it returns 200.
What counts as a good seat
The auditorium is 248 seats in only 9 rows (A to I) and about 31 seats wide, so it is wide and shallow. Row number alone is a poor filter: the seats that free up in the back rows are almost always against the walls. Consensus on r/imax for this room is that the back four rows (F to I) are the money zone and F is the closest you would want to be.
Seats are scored on the mean distance from the centreline across the whole party, in seat widths. Averaging puts a contiguous run and a scatter on the same footing, and it stops a set qualifying by pairing three centre seats with one against the wall, because the outlier drags the average back out.
Offsets come from the seat map's own x coordinates, not from |seat_number - 16|. The rows are not
numbered on a shared grid: row G's midpoint falls between seats 15 and 16, and row I fits 27 seats
across the same width because the wheelchair bays are wider than a seat. Seat arithmetic is wrong by
half a seat in G and by more than a seat in I. geometry() takes the centreline from the widest
row's midpoint, which lands at x=800.0 here; the screen apex in the map's own backgroundSvg is at
x=802.0, so the room really is symmetric about that line.
A run of four cannot straddle the centreline evenly, so the best any of them manages is a 1.0 average: F14-F17, G14-G17, H14-H17 and I14-I17 all tie there. Two pairs can do better, because each pair hugs the middle of its own row. G15-G16 with H15-H16 averages 0.5.
Adjacency is inferred from seat numbers. Every row here is fully contiguous with no aisle gaps, so consecutive numbers are physically adjacent. Verify that before retargeting.
Wheelchair and companion spaces are excluded by seat type, not by position. In this auditorium
they sit at I19 to I27, which is the middle of the back row, so a position-only filter would surface
seats you cannot book.
Upgrade mode
Once something is booked the question stops being "are these seats good" and becomes "are these
better than the ones I hold". HELD_OFFSET is what the booked block averages, and MIN_GAIN is how
much closer a candidate has to be before it is worth the swap. HELD_BLOCK and HELD_OFFSET are
read from the environment, so the seats you actually hold need not be committed anywhere;
HELD_BLOCK is only a label printed in the alert, and HELD_OFFSET is the number that decides. Fandango has no change-my-seat
function, so upgrading means buying the new seats and refunding the old ones. There is no point
being woken for a fraction of a seat.
Seats still do not have to be adjacent. The alert fires on the best PARTY_SIZE seats in the
showtime however they are arranged; contiguity changes how the alert reads, not whether it fires.
Two pairs in adjacent rows at the centreline beat four together against a wall, and that is a call
worth making yourself. When a whole contiguous run also clears the bar, the alert names it as well:
Mall of Georgia 70mm - beats my seats priority 5 H14-H17 (4 together, 1.0 off centre)
Mall of Georgia 70mm - beats my seats priority 5 G15-G16, H15-H16 (split 2+2, 0.5 off centre)
Mall of Georgia 70mm - beats my seats priority 4 H11-H14 (4 together, 3.5 off centre)
Set HELD_OFFSET to the width of the room and MIN_GAIN to 0 to turn it back into a plain vacancy
watcher, where anything in UPGRADE_ROWS qualifies.
Setup
git clone https://github.com/Motaphe/odyssey-seat-watcher.git
cd odyssey-seat-watcher
python3 odyssey_watch.py discover # what is playing, plus the auditorium layout
python3 odyssey_watch.py check # one pass, explains every showtime
python3 odyssey_watch.py test # confirm the ping arrives
python3 odyssey_watch.py watch # run continuously
Config comes from watch_config.json or from the environment, which wins:
NTFY_URL=https://ntfy.example.com/your-topic
NTFY_TOKEN=tk_... # omit for an unauthenticated topic
HELD_BLOCK=G14-G17 # label for the block you hold, once you have one
HELD_OFFSET=1.0 # what it averages off centre; the bar to beat
Or with Docker:
cp .env.example .env # put your NTFY_URL in it
docker compose up -d
docker compose logs -f
The image installs tzdata, which slim Python images omit. Without it ZoneInfo("America/New_York")
raises ZoneInfoNotFoundError. Alert state is kept on the ./state volume so a restart does not
re-alert on seats you have already been told about.
check prints its reasoning for every showtime, which is the fastest way to confirm the filters do
what you meant:
Mon Aug 24 10:55PM UPGRADE -> H14-H17 (4 together, 1.0 off centre)
Wed Aug 26 8:20PM UPGRADE -> G15-G16, H15-H16 (split 2+2, 0.5 off centre); all together
instead: H14-H17 (1.0 off)
Sat Aug 22 11:10AM sold out
Sun Aug 23 7:00PM 5 free, nothing beating my seats (A1, A2, A3, A4, B1)
Tue Aug 25 2:00PM skip (outside my hours)
Retargeting it
Run discover first. It prints every movie the theater is showing with its exact format strings,
then the auditorium layout for whatever your current MOVIE_MATCH and FORMAT_MATCH select:
python3 odyssey_watch.py discover --theater AANKX --days 7
The Odyssey (2026)
112 showtimes IMAX, IMAX 70MM
31 showtimes (no filmFormat - standard)
Auditorium for The Odyssey (2026) / IMAX 70MM:
auditorium 21 chain REGL 248 seats, rows A-I
centreline x=625.4, nearest seat H16 (row H is the widest). Offsets are
derived from this at runtime, so there is no centre seat to configure.
row I: 27 seats companion/wheelchair at 19-27
Then set the constants:
THEATER is the AAxxx code in a Fandango theater URL. Find your cinema on fandango.com and
read it out of the address bar: fandango.com/regal-mall-of-georgia-aankx/theater-page gives
AANKX. Set THEATER_PAGE to that same slug, since it is sent as the Referer.
MOVIE_MATCH is a lowercase substring of the title. Check the discover output for collisions
before picking something short.
FORMAT_MATCH must be an exact filterName from discover, not a guess. IMAX 70MM and
IMAX are different strings and select different showings. If a format prints as
(no filmFormat - standard) there is nothing to match on, so leave FORMAT_MATCH empty and filter
by title alone.
LOCAL_TZ is the theater's timezone, not yours.
PARTY_SIZE is how many tickets you need. 1 gives single-seat alerts.
UPGRADE_ROWS describes the room, and is the only seating constant you have to set: there is no
centre seat to configure, because geometry() derives the centreline from the seat map at runtime
and gets it right for every row without being told. Row preference is taste. This config prefers the
back four because the room is wide and shallow, which is not true of a tall stadium house.
HELD_BLOCK, HELD_OFFSET, MIN_GAIN are the upgrade bar, described above. The first two are
environment variables rather than constants, so your own seats stay out of the source. check
prints the bar it is using on startup, so you can see which mode you are in.
Watch for two things in the discover row list. An AISLE GAP means consecutive seat numbers
are not physically adjacent, so contiguous runs would silently be wrong. And accessible seating in
the middle of a row (here companion/wheelchair at 19-27) means position alone cannot tell you what
is bookable, which is why seats are filtered on type as well.
EARLIEST / LATEST are per-weekday hour windows, keyed Monday=0. None means no bound:
EARLIEST = {0: 17, 1: 17, 2: 17, 3: 17, 4: 15, 5: None, 6: None}
LATEST = {0: 23, 1: 23, 2: 23, 3: 23, 4: None, 5: None, 6: None}
That reads as: weeknights from 5pm to 11pm, Friday from 3pm, weekends any time. Note that the window is keyed on the calendar day the showtime starts, so a 12:15am Thursday screening is really Wednesday night and gets filtered as a weekday morning.
HORIZON_DAYS should cover the full published run. Check how far ahead your theater posts; this
one publishes about 26 days out, and a 14-day horizon was missing roughly 47 showtimes.
Nothing here is specific to 70mm or to this film. Point it at any Fandango-listed theater, format and title. IMAX 70mm is just the case where it matters, since only about 30 screens worldwide can project it and they usually run one such release at a time.
Configuration
Constants at the top of odyssey_watch.py:
| Name | Purpose |
|---|---|
THEATER |
Fandango theater id, the AAxxx code in Fandango URLs |
FORMAT_MATCH |
exact filmFormat filterName, IMAX 70MM here |
LOCAL_TZ |
theater's timezone |
PARTY_SIZE |
seats needed, adjacent or not |
HELD_BLOCK |
env var, the block already booked; a label only, named in the alert |
HELD_OFFSET |
env var, its mean distance from the centreline, in seats; the bar to beat |
MIN_GAIN |
how much closer to centre a candidate must be to be worth a swap |
UPGRADE_ROWS |
rows at least as good as the one held; everything else is ignored |
EARLIEST / LATEST |
per-weekday showtime windows |
HORIZON_DAYS |
how far ahead to look |
STATE_DIR |
env var, where seen.json lives (used by the container) |
Running as a service
odyssey-seat-watcher.service is a systemd unit that restarts on failure and starts at boot. It
reads secrets from /etc/odyssey-seat-watcher.env so the token stays out of the unit and the repo.
sudo cp odyssey-seat-watcher.service /etc/systemd/system/
sudo systemctl enable --now odyssey-seat-watcher
journalctl -u odyssey-seat-watcher -f
Design notes
It reports when it is alive. A watcher that fails quietly is worse than none, because "nothing found" and "broken" look identical from outside. Every 15 minutes it logs:
alive - 64 showtimes in my window, 64 seat maps checked this pass, 0 openings alerted so far
Openings are recorded only after the alert is delivered. notify() returns a success flag. If
ntfy is unreachable the opening is left unrecorded and retried, rather than being marked as reported
and lost.
Alerts are edge triggered. State is keyed on the whole set of seats the alert proposed, so one seat changing inside a set you have already been told about is not news. A set that opens, gets taken and opens again alerts a second time. State for showtimes that drop off the schedule is pruned.
The showtime list is cached for 15 minutes. Seat availability changes by the minute, showtimes do not. Refetching every date endpoint on each tick is a lot of traffic for no new information. An empty result is never cached, so a transient outage retries instead of pinning an empty schedule.
Polling is tiered. Seat maps are rechecked every 90s within 24 hours of the showtime, every 5 minutes within 3 days, every 15 minutes beyond. Requests are throttled to roughly one per second.
Sold-out handling differs by chain. Regal returns HTTP 410 PerformanceSoldOut where AMC
returns an all-reserved seat map. Treating 410 as a fetch error makes the log almost entirely false
errors at a venue that is mostly sold out. geometry() returns None for the resulting empty map
rather than deriving a centreline from zero seats, and does not cache that.
ntfy header encoding. HTTP header values are latin-1. Non-ASCII characters in a title raise
'latin-1' codec can't encode character, so titles are RFC 2047 encoded.
Differences from upstream
- ntfy instead of Telegram
- centre-offset scoring instead of a minimum row, measured in map coordinates rather than seat numbers, which are not on a shared grid across rows
- upgrade mode: once seats are booked, alerts only on a set that beats them
- party-size matching, ranked by contiguity rather than gated on it
- Regal 410 sold-out handling
- showtime list caching
- env-only configuration, so no config file is needed under systemd
- openings recorded only after successful delivery
- edge-triggered alerts and state pruning
License
MIT, as upstream. See LICENSE.