Guide 2 of 4 · Bug guide

Bug guide: where to look and how to fix

Ten problems were found by reading the code and the 1.4 MB log file. The two you reported, alerts with no tickets and the hand-pasted login, are bugs 1 and 2. Bug 3 is why a poller can go silent for weeks without anyone noticing. Each bug below says where it lives, how to prove it, and the smallest fix.

  • 11 October 2026
  • 12 min read
  • For whoever fixes the poller
B4–5
A real alert names seats like these. A blank alert names none: that is bug 1.

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

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:

python
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.log

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:

python
# 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 rule

And in _bms_url:

python
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 curl gets 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; its LSID field is both x-access-token and x-lsid, its MEMBERID is x-member-id, MOBILE and MEMBEREMAIL are the phone and email. x-bms-id is a separate cookie bmsId. x-advertiser-id exists 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.

python
# 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:

python
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/

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.

  1. Freeze the evidence. Copy logs/poller.out.log, state.json, notified_pairs.json and the alert email or ntfy message into a folder bug-YYYYMMDD/ before restarting anything.
  2. Stop the background service so it does not overwrite the state files while you look:

    launchctl unload ~/Library/LaunchAgents/com.sriram.bms-poller.plist
  3. Find the alert in the log. The lines just before ALERTING show the showtimes: a/b sessions match filter (c bookable) counts for that cycle.

    grep -n "ALERTING\|scrape failed\|skip \|spinner" logs/poller.out.log
    4. Replay the showtimes comparison offline. In a Python shell, load dump_showtimes.json, call _pluck_showtimes from poller.py, and compare with the frozen state.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.json prints each row as dots and blocks. python adjacency.py diag/canvas_draw_log.json prints the groups. If the picture does not match diag/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 20261012 opens a real browser window and saves a screenshot, the page and the drawing log into diag/. 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. Set POLL_INTERVAL_SEC=20, run python poller.py in 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.