01All bugs at a glance
| # | What you see | Where in the code | How bad |
|---|---|---|---|
| 1 | Alert arrives with a seat page link but no free tickets, or no seat details | poller.py, the alert loop inside run_once (lines 262 to 345); _bms_url; colour rules in canvas_parse.py |
High |
| 2 | Login headers must be copied from Chrome by hand, and they expire | bms.py Session.from_env; build_state.py; README |
High |
| 3 | Error 400 on every poll since 5 September, no alert sent, 1,490 crash reports in the log | poller.py main, the catch-all except; DATE_CODE in .env |
High |
| 4 | A burst of "New show opened" alerts after one bad answer from BookMyShow | poller.py lines 255 to 260, where missing shows are forgotten |
Medium |
| 5 | Any seat painted in a colour other than grey counts as free | canvas_parse.py AVAILABLE_COLORS, parse_seat_grid |
Medium |
| 6 | probe.py crashes (KeyError: 'available', wrong type passed to find_adjacent_pairs) |
probe.py lines 40 and 124 |
Medium |
| 7 | Every alert says "Spider-Man: Brand New Day" whatever the movie | poller.py line 331 |
Low |
| 8 | FINAL_HOUR_INTERVAL_SEC and SEAT_CALL_DELAY_SEC are in .env but nothing reads them |
.env, poller.py |
Low |
| 9 | Seat numbers in alerts are column positions, not the numbers printed on the seats | adjacency.py AdjacentGroup.label |
Low |
| 10 | The ntfy topic name is public; anyone who guesses it reads your alerts | .env NTFY_TOPIC, notifier.py |
Low |
02Bug 1: alerts with a link but no tickets
Four different paths in the code produce this, all in or around the alert loop of run_once in poller.py. Fix all four; any one of them alone keeps sending empty alerts.
Cause A: a seat read that finds nothing still alerts. The only "do not send" rule is at line 314: skip when groups were found before and none are new. When the seat reader returns an empty list, either because the show really has no group of GROUP_SIZE free seats or because the page never finished loading (the "Please wait" spinner), the count is zero, the rule does not match, and the generic "Seats opened up" alert goes out with the link and no seat lines. The except at line 308 does the same after any error ("falling back to MVP alert"). This is the most likely source of your blank alerts.
Cause B: "went up" alerts never check the Book button. Lines 247 to 252 queue an alert whenever the availability number rises. Only the "new show" path at line 244 checks r["bookable"], which comes from whether the API says the button opens the seat page or shows a sold-out message. So a show whose number flips while its button still says sold out gets alerted.
Cause C: the availability number is a rough hint. It is the colour BookMyShow paints the showtime (0 sold out, 1 few, 2 filling fast, 3 available). A jump from 0 to 1 can mean one single seat that is booked again by the time you tap. Without the V2 seat read the poller cannot tell.
Cause D: the link can be built with an empty movie code. _bms_url (line 166) takes row["eventCode"] from each theatre block of the answer. When a block has no code, the link becomes /seat-layout//AMBH/113045/20260801, which BookMyShow shows as an empty page.
How to find out which one hit you. Add one log line before notify() and let it run for a day:
log.info("alert sid=%s kind=%s prev=%s curr=%s bookable=%s v2=%s scrape_ok=%s groups=%d new=%d url=%s",
r["sessionId"], kind, prev, curr, r["bookable"], v2_enabled, scrape_ok,
pair_count_total, pair_count_new, url)Then search the log for those lines:
grep "alert sid=" logs/poller.out.logsudo journalctl -u bms-poller --no-pager | grep "alert sid="Select-String -Path logs\poller.out.log -Pattern "alert sid="A line with groups=0 points to cause A, bookable=False to cause B, and url=...seat-layout// to cause D.
The fix. Remember whether the seat read worked, require the Book button, and skip shows that were checked and found empty:
# poller.py, inside `for kind, r, prev, curr in alerts:`
scrape_ok = False
if v2_enabled:
try:
pairs = scrape_pairs_sync(...)
scrape_ok = True
... # the existing group bookkeeping stays
except Exception as e:
log.warning("scrape failed for %s: %s", r["sessionId"], e)
if not r["bookable"]:
log.info("skip %s: not bookable yet", r["sessionId"]); continue
if v2_enabled and scrape_ok and pair_count_total == 0:
log.info("skip %s: no free group of %d seats", r["sessionId"], group_size); continue
if v2_enabled and not scrape_ok and os.environ.get("ALERT_ON_SCRAPE_FAILURE", "0") != "1":
log.info("skip %s: scrape failed, unverified", r["sessionId"]); continue
if v2_enabled and pair_count_total > 0 and pair_count_new == 0:
continue # the existing ruleAnd in _bms_url:
event = row["eventCode"] or os.environ["EVENT_CODE"]
if not (event and row["venueCode"] and row["sessionId"]):
raise ValueError(f"incomplete row, cannot build link: {row}")For cause C when the V2 reader is off: fetch the showtimes once more 10 seconds later and alert only if the rise is still there. With V2 on, the seat read is the proof, so make V2_SCRAPER_ENABLED=1 the default.
Test before shipping. Write test_poller_alerts.py using pytest. Replace bms.fetch_showtimes with a function that returns the saved answer in dump_showtimes.json (270 shows, 53 sold out), replace scrape_pairs_sync with one that returns [], replace notify with one that only counts calls, and check the count is zero. Then make the seat read return two groups and check one call happened with "Row" in the text. pytest's monkeypatch guide shows how to swap functions like this.
03Bug 2: the BookMyShow token is pasted by hand
The pasted token is not needed for polling at all, and for reading seats it can be replaced by a one-time login. This was tested on 11 October 2026:
- The showtimes API answered 200 with every login header left empty. Only the city headers and a normal browser "User-Agent" matter (a bare
curlgets a Cloudflare 403). - The seat page does need a login. With no cookies the "Please wait" spinner never clears. With the login saved on 25 July it still read 7 rows and 51 seats, eleven weeks later.
- The whole login is one cookie named
ud. It holds JSON; itsLSIDfield is bothx-access-tokenandx-lsid, itsMEMBERIDisx-member-id,MOBILEandMEMBEREMAILare the phone and email.x-bms-idis a separate cookiebmsId.x-advertiser-idexists nowhere and an empty value is accepted.
Where it lives. Session.from_env in bms.py (lines 47 to 59) crashes unless all seven BMS_* lines exist in .env. build_state.py turns a DevTools dump (../test.txt) into bms_state.json. main() in poller.py (lines 372 to 384) sends the "recapture from DevTools" push after a 401.
Step 1: stop requiring the token for polling. Make every BMS_* setting optional (os.environ.get(k, "")), send a header only when it is non-empty, and delete the seven lines from .env.example. After this the basic poller works on any machine with only a movie code.
Step 2: replace the DevTools dump with a one-time login window. Add login.py. It opens a visible Chrome with its own profile folder, you log in on BookMyShow with phone and OTP as usual, and the profile stays on disk. Anyone can run it with their own account. That is what "works on any account" means in practice.
# login.py (run once per account, needs a screen)
from pathlib import Path
from playwright.sync_api import sync_playwright
PROFILE = Path.home() / ".bms-poller" / "profile"
with sync_playwright() as p:
ctx = p.chromium.launch_persistent_context(str(PROFILE), headless=False,
args=["--disable-blink-features=AutomationControlled"])
page = ctx.new_page(); page.goto("https://in.bookmyshow.com/explore/home/hyderabad")
input("Log in (phone + OTP) in the window, then press Enter here... ")
ctx.storage_state(path="bms_state.json") # keeps the old file format working too
ctx.close()Step 3: build the headers from the saved login instead of .env. A small session.py reads the cookies from bms_state.json and fills the Session object:
import json, urllib.parse
def session_from_storage(path, region_code, region_slug) -> Session:
cookies = {c["name"]: c["value"] for c in json.load(open(path))["cookies"]}
ud = json.loads(urllib.parse.unquote(cookies["ud"]))
return Session(access_token=ud["LSID"], lsid=ud["LSID"], member_id=ud["MEMBERID"],
bms_id=cookies.get("bmsId", ""), advertiser_id="",
mobile=ud.get("MOBILE", ""), email=ud.get("MEMBEREMAIL", ""),
region_code=region_code, region_slug=region_slug)The seat reader then opens the same profile folder without a window (launch_persistent_context(str(PROFILE), headless=True)) instead of loading a saved file. BookMyShow's own page code keeps the login fresh on every visit.
Step 4: when the login expires, ask for a re-login, not a re-paste. After a 401, or when the seat spinner fails twice in a row, send one push: "BookMyShow login expired, run python login.py". Retry every 5 minutes instead of sleeping an hour. For the Oracle server, which has no screen, run login.py on your own computer and copy the profile folder across. The profile is not tied to one machine. Replace YOUR_SERVER with the server's address:
scp -r ~/.bms-poller/profile ubuntu@YOUR_SERVER:~/.bms-poller/scp -r ~/.bms-poller/profile ubuntu@YOUR_SERVER:~/.bms-poller/scp -r "$HOME\.bms-poller\profile" ubuntu@YOUR_SERVER:~/.bms-poller/Check it worked. python -c "import session; print(session.session_from_storage('bms_state.json','HYD','hyderabad').member_id)" prints your member id. python poller.py with an empty BMS_ACCESS_TOKEN still prints showtimes: ... sessions match filter. python test_v2_flow.py reads seat rows using the profile.
Please note BookMyShow's terms of use limit automated access. Keep the rate at one request a minute and use it only for your own bookings.
04Bugs 3 to 10
Each row has the smallest fix that closes it. Fix bug 3 first: without it, a poller with a past date keeps failing and nobody is told.
| # | What goes wrong | Fix |
|---|---|---|
| 3 | DATE_CODE=20260801 is in the past. BookMyShow answers 400, the catch-all except in main prints a traceback and sleeps 5 minutes, forever. Nobody is told. 1,490 tracebacks since 5 September. |
At start, refuse a DATE_CODE earlier than today. In main, count failures in a row; on the third, send one "poller is failing" push and repeat at most every 6 hours. Later, replace the fixed date with a DAYS_AHEAD setting. |
| 4 | Shows missing from one answer are deleted from state.json (lines 255 to 260). When they come back they look brand new, and every bookable one sends a "New show opened" alert. |
Store a last_seen time per show and delete only after 24 hours. Also, on the very first poll after a start, record everything without alerting. |
| 5 | canvas_parse.py calls a seat free if it is white or anything that is not grey #e5e5e5. A wheelchair seat, a blocked seat, a selected seat, or a new BookMyShow colour scheme makes every seat look free, and the reader reports dozens of groups. |
Treat only white #ffffff as free. Count the colours seen per read and warn yourself when an unknown colour is more than 5 % of seats. Check with python seat_scraper.py --diagnose --headed ... and compare diag/screenshot.png. |
| 6 | probe.py reads r["available"] but the code produces bookable, and it passes raw JSON to find_adjacent_pairs, which wants a SeatGrid. |
Rename to bookable, delete the last loop, keep probe.py as a tool that only saves the raw answer. |
| 7 | Every alert says "Spider-Man: Brand New Day". | Add MOVIE_NAME to .env, or read the title from the showtimes answer. |
| 8 | FINAL_HOUR_INTERVAL_SEC and SEAT_CALL_DELAY_SEC are never read. |
The answer carries cutOffDateTimeEpoch per show, so build the final-hour mode: if any watched show starts within 60 minutes, poll at the shorter interval. Delete SEAT_CALL_DELAY_SEC. |
| 9 | AdjacentGroup.label prints the column position plus one, after aisles are removed, not the number printed on the seat. "Seats #5-6" can really be 7 and 8. |
The drawing log records every text draw with its x and y. Match the number texts in the same row by x to each seat and print those. |
| 10 | ntfy topics are public, and a short or memorable topic name is easy to guess. | Use a 32-character random topic, or an ntfy access token. Version 2 replaces this with per-user push anyway. |
Two smaller things: after a 401 the poller sleeps a whole hour, so a fresh login is ignored for up to 60 minutes (make it 5), and the log file never rotates (1.4 MB of tracebacks in five weeks; add a RotatingFileHandler).
05How to debug, step by step
Reproduce from saved data first, change one thing at a time, and prove the fix with a test before touching the running service. Every bug above can be replayed offline with the files already in the folder.
- Freeze the evidence. Copy
logs/poller.out.log,state.json,notified_pairs.jsonand the alert email or ntfy message into a folderbug-YYYYMMDD/before restarting anything. -
Stop the background service so it does not overwrite the state files while you look:
launchctl unload ~/Library/LaunchAgents/com.sriram.bms-poller.plistsudo systemctl stop bms-pollerStop-ScheduledTask -TaskName "BMS Poller" -
Find the alert in the log. The lines just before
ALERTINGshow theshowtimes: a/b sessions match filter (c bookable)counts for that cycle.4. Replay the showtimes comparison offline. In a Python shell, loadgrep -n "ALERTING\|scrape failed\|skip \|spinner" logs/poller.out.logsudo journalctl -u bms-poller --no-pager | grep -n "ALERTING\|scrape failed\|skip \|spinner"Select-String -Path logs\poller.out.log -Pattern "ALERTING|scrape failed|skip |spinner"dump_showtimes.json, call_pluck_showtimesfrompoller.py, and compare with the frozenstate.json. Same input always gives the same output, and it takes a second. 5. Replay the seat reading offline (with the virtual environment on, the same on every system).python canvas_parse.py diag/canvas_draw_log.jsonprints each row as dots and blocks.python adjacency.py diag/canvas_draw_log.jsonprints the groups. If the picture does not matchdiag/screenshot.png, the bug is in the seat reader, not the poller. 6. Read one show live, with a window.python seat_scraper.py --diagnose --headed --event ET00505091 --venue AMBH --session 113045 --date 20261012opens a real browser window and saves a screenshot, the page and the drawing log intodiag/. 7. Write the failing test first (pip install pytest): one test per cause, using the saved files as input, red before the fix and green after. 8. Run the real loop for one cycle. SetPOLL_INTERVAL_SEC=20, runpython poller.pyin a terminal, watch two cycles, press Ctrl-C. 9. Ship. Commit on a branch, open a pull request, merge, then start the background service again (Getting started, section 8) and watch the log for ten minutes.
Two habits that save repeat work: keep the alert sid=... log line from bug 1 on every alert, and never test against the live site faster than every 20 seconds, because Cloudflare blocks the whole network after a burst.
06What to read for each kind of bug
One page per kind of bug. The BookMyShow-specific facts above come from this session's own tests; no public documentation of their API exists.
| Kind of bug | Read | Why |
|---|---|---|
| False or empty alerts (bugs 1, 4) | Google SRE book: Monitoring | Its rule "alert only on confirmed, actionable conditions" is exactly the missing skip logic. |
| Waiting one extra poll before alerting (bug 1, cause C) | Prometheus alerting: the for clause |
The same idea in a well-known tool. |
| Saving and reusing a login in Playwright (bug 2) | Playwright Python: Authentication | storage_state(path=) saves, new_context(storage_state=) loads. |
| A browser profile that stays on disk (bug 2) | Playwright: launch_persistent_context |
Keeps cookies between runs, so BookMyShow's own page refreshes the login. |
| Reading the headers the site sends itself (bug 2) | Scrapfly: capture requests in Playwright and Playwright network events | page.on("request") shows every header of every call the page makes. |
| Cloudflare 403 on scripted requests | BookMyShow notes on browse.sh | Independent confirmation that theatre pages carry their data in window.__INITIAL_STATE__ and that bare requests are challenged. |
| Silent failure loops (bug 3) | Python logging cookbook: rotating files, tenacity retry library | Rotation stops the growing log; tenacity gives "retry N times, then give up and call this function". |
| Seat colours (bug 5) | MDN: CanvasRenderingContext2D | The drawing functions seat_scraper.py hooks: fillRect, roundRect, fillText. |
| Seat numbers from drawn text (bug 9) | MDN: fillText |
The recorded text entries carry the seat numbers with x and y. |
| Push topic security (bug 10) | ntfy: access control | On the public server the topic name is the only secret. |
| Testing with saved answers (section 5) | pytest: monkeypatch | Swap bms.fetch_showtimes, scrape_pairs_sync and notify with fakes in each test. |
| Cleaning up after a leaked secret | GitHub: removing sensitive data | .env is ignored, but dump_*.json and test.txt contain your login too. |