01What to install
The poller is plain Python and runs on macOS, Linux and Windows. Every command box in these guides has a tab for each system. Your computer is picked automatically, and you can change it once in the sidebar to switch every box at the same time.
You type commands into a terminal: the Terminal app on macOS, a terminal window on Linux, or PowerShell on Windows.
| Tool | Why | Check it worked |
|---|---|---|
| A package manager | Installs everything else with one command: Homebrew on macOS, apt on Ubuntu or Debian, winget on Windows | brew --version, apt --version or winget --version |
| Python 3.12 or 3.13 | Runs the poller | python3 --version (on Windows: python --version) |
| git | Saves the history of your code (git) | git --version |
| GitHub CLI | Log in to GitHub, make keys, open pull requests from the terminal | gh --version |
| VS Code | The editor, with the Python and Claude Code extensions | code --version |
| Node.js 20 or newer | Only for the version 2 website and the mobile app, not for the poller | node --version |
Install them all:
# Homebrew, the macOS package manager (skip if "brew --version" already works)
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
# The tools
brew install python@3.13 git gh node
brew install --cask visual-studio-code# Ubuntu or Debian. On Fedora, use "sudo dnf install" with the same names.
sudo apt update
sudo apt install -y python3 python3-venv python3-pip git gh curl
sudo snap install code --classic # VS Code
# Node.js 22 (only for version 2 and the app)
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt install -y nodejs# winget comes with Windows 10 and 11. Run these in PowerShell:
winget install -e --id Python.Python.3.13
winget install -e --id Git.Git
winget install -e --id GitHub.cli
winget install -e --id Microsoft.VisualStudioCode
winget install -e --id OpenJS.NodeJS.LTS # only for version 2 and the app
# Close PowerShell and open it again so the new commands are found.Playwright, the browser the poller uses to read seat maps, is installed inside the project in section 3. If gh is not found on an older Linux, follow GitHub's Linux instructions.
Note. use a normal Python release, not a pre-release ("rc" or "beta"), so Playwright's downloads match. If your .venv folder was built with a pre-release, delete it and create it again.
02Git and GitHub setup
The code lives in a private GitHub repository. In the commands below, replace YOUR-GITHUB-NAME with the account that owns it. These steps recreate the setup on any machine. If git is new to you, read GitHub's Hello World first (20 minutes).
-
Tell git who you are (once per machine):
bashgit config --global user.name "Your Name" git config --global user.email "you@example.com" git config --global init.defaultBranch main -
Log in to GitHub (the same on every system). The tool creates an SSH key for you, so you never type a password again:
bashgh auth login # choose: GitHub.com, SSH, generate a new key, log in with browser gh auth status # should say "Logged in to github.com" ssh -T git@github.com -
Download (clone) the code and create the private settings file:
git clone git@github.com:YOUR-GITHUB-NAME/bms-poller.git cd bms-poller cp .env.example .env # then fill in your values; .env is never uploadedgit clone git@github.com:YOUR-GITHUB-NAME/bms-poller.git cd bms-poller cp .env.example .env # then fill in your values; .env is never uploadedgit clone git@github.com:YOUR-GITHUB-NAME/bms-poller.git cd bms-poller Copy-Item .env.example .env # then fill in your values; .env is never uploaded -
Make every change on its own branch, then open a pull request:
bashgit checkout main && git pull git checkout -b fix/blank-links # a new branch named after the change # edit files, run, test git add -A && git commit -m "Skip alert when scraper finds zero groups" git push -u origin fix/blank-links gh pr create --fill # opens a pull request; merge it on GitHub -
Never upload secrets. The
.gitignorefile already keeps out.env,state.json,notified_pairs.json,bms_state.json,logs/,diag/anddump_*.json. If a token ever ends up in a commit, treat it as stolen: log out of BookMyShow in that browser, log in again, and remove the commit from history with git filter-repo (GitHub's guide).
03Install and run the poller
The first run takes about ten minutes. The slow part is copying your BookMyShow login from Chrome, which this version still needs. The bug guide shows how to replace that with a one-time login.
-
Go to the project folder, create the virtual environment, turn it on, and install the packages:
cd ~/Desktop/"Book myshow room"/bms-poller # or wherever you cloned it python3 -m venv .venv source .venv/bin/activate pip install -r requirements.txt playwright install chromium # only needed when V2_SCRAPER_ENABLED=1cd ~/bms-poller # or wherever you cloned it python3 -m venv .venv source .venv/bin/activate pip install -r requirements.txt playwright install --with-deps chromium # also installs the system libraries Chromium needscd $HOME\bms-poller # or wherever you cloned it python -m venv .venv .venv\Scripts\Activate.ps1 # If PowerShell says running scripts is disabled, run this once, then the line above again: # Set-ExecutionPolicy -Scope CurrentUser RemoteSigned pip install -r requirements.txt playwright install chromium # only needed when V2_SCRAPER_ENABLED=1While the virtual environment is on, your prompt starts with
(.venv). In a new terminal window, run thecdline and the activate line again before using the poller. From here on,pythonmeans the virtual environment's Python on every system. -
Copy
.env.exampleto.envand set the target:EVENT_CODE(theET000…code in the movie's BookMyShow web address),DATE_CODEas year-month-day like20261012,REGION_CODE(HYD) andREGION_SLUG(hyderabad). -
Copy your login from Chrome. Open in.bookmyshow.com while logged in, press F12 (Windows, Linux) or Option-Command-I (macOS) to open DevTools, choose the Network tab, type
api/in the filter box, click any request, and copy these request headers into.env:x-access-token,x-lsid,x-member-id,x-bms-id,x-advertiser-id, plus your mobile number and email. -
Optional, for alerts that name the exact seats: in the DevTools Console tab on a BookMyShow page run
jsJSON.stringify({cookies: document.cookie, ls: {...localStorage}, ss: {...sessionStorage}})Save the output as a file named
test.txtin the folder that containsbms-poller(one level up), runpython build_state.pyto createbms_state.json, then setV2_SCRAPER_ENABLED=1andGROUP_SIZE=2(or 3, 4) in.env. -
Set up the two alert channels:
NTFY_TOPIC(install the ntfy app on your phone and subscribe to the same topic name) and a 16-character Gmail app password inSMTP_APP_PASSWORD. -
Run it (the same on every system, with the virtual environment on):
bashpython poller.pyA healthy start prints
monitoring only venues matching: [...]andloaded state: N prior sessions. Then every cycle prints something likeshowtimes: 12/270 sessions match filter (9 bookable), followed by eitherno changes this cycleorALERTING on N changes, thensleeping 60s. -
Stop with Ctrl-C (Control-C on macOS too). The poller remembers what it saw in
state.json, so restarting does not send the same alerts again.
Note. the README mentions probe.py as a first check, but it no longer matches the code and crashes. Run poller.py directly. The fix is bug 6 in the bug guide.
04Settings: the .env file
Everything the poller does is controlled by the .env file. It is read once at start, so after changing it, restart the poller.
| Setting | Used by | What it controls |
|---|---|---|
EVENT_CODE |
poller.py |
The movie, for example ET00505091. One movie per running poller. |
DATE_CODE |
poller.py |
Show date as YYYYMMDD. A date in the past makes BookMyShow answer error 400 on every poll. |
REGION_CODE, REGION_SLUG |
bms.py |
City: HYD and hyderabad. |
BMS_ACCESS_TOKEN, BMS_LSID, BMS_MEMBER_ID, BMS_ID, BMS_ADVERTISER_ID, BMS_MOBILE, BMS_EMAIL |
bms.py |
Your login, copied from DevTools. All seven must exist or the program crashes at start with KeyError. (They are not actually needed for polling; see bug 2.) |
MONITORED_VENUES |
poller.py |
Theatre names to watch, comma-separated, any case, partial match allowed: AMB Cinemas: Gachibowli, Allu Cinemas: Kokapet. Empty means every theatre in the city. |
V2_SCRAPER_ENABLED |
poller.py |
1 makes the poller open the seat map after a change and count free seats. Needs bms_state.json. |
GROUP_SIZE |
poller.py |
How many seats side by side count as a group (at least 2). |
NTFY_TOPIC |
notifier.py |
The ntfy topic for phone push. Anyone who guesses the name can read your alerts, so use a long random one. |
SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_APP_PASSWORD, EMAIL_RECIPIENTS |
notifier.py |
Gmail sending. Recipients are comma-separated. |
POLL_INTERVAL_SEC, POLL_JITTER_SEC |
poller.py |
Seconds between polls, plus a random few seconds so requests do not land at exact minutes. Minimum 5. |
BACKOFF_START_SEC, BACKOFF_MAX_SEC |
poller.py |
After a 403 or 429 (blocked, too many requests) the wait doubles from start up to max. |
FINAL_HOUR_INTERVAL_SEC, SEAT_CALL_DELAY_SEC |
nothing | Present in .env but no code reads them. Safe to delete (bug 8). |
The movie title in the alert text is typed into the code as "Spider-Man: Brand New Day". There is no MOVIE_NAME setting yet (bug 7).
05The big picture (architecture)
One Python program, five files of code, three memory files, and three outside services.
Dashed boxes are outside the program. Nothing here is a server: there is no database, no web page and no login screen, which is why the login headers must be pasted by hand.
| File | Job |
|---|---|
poller.py |
The loop: fetch, compare, decide, alert, save, sleep. |
bms.py |
Builds the request to BookMyShow's showtimes API with the right headers. |
seat_scraper.py |
Opens the seat page in a headless browser and records every drawing command on the canvas. |
canvas_parse.py |
Turns the drawing commands into a grid of seats with a colour each. |
adjacency.py |
Finds runs of free seats next to each other. |
notifier.py |
Sends the push message and the email. |
build_state.py |
One-off: turns the DevTools dump into bms_state.json. |
06What happens every minute (flow)
In words: each cycle is one request for every show of the movie in the city. The poller only opens a seat map when a show's availability number went up or a bookable show appeared for the first time. With the V2 scraper on, a show whose free groups were all announced before is skipped.
The red parts are the two gaps covered in the bug guide: a seat read that finds zero groups still sends an alert (that is the blank-alert bug), and an error 400 is printed to the log but never reported to you.
07Alerts and the memory files
Every alert goes out on two channels at the same time from notifier.py. Three JSON files next to the code remember what has been seen, so a restart does not repeat alerts.
Channels. notify() runs send_ntfy and send_email in parallel. ntfy gets a message with the topic, title, text and priority 5 (urgent). Email goes through Gmail with the app password. If one channel fails, the other still goes out, and the failure is written to the log.
What an alert contains. Movie name (fixed in the code), theatre and its code, show time and show id, date, the change (for example "sold out → few seats"), the seat groups if the V2 scraper found any, and a direct link to the seat page: https://in.bookmyshow.com/movies/{region}/seat-layout/{event}/{venue}/{session}/{date}.
| File | What is inside | Why | Written when |
|---|---|---|---|
state.json |
For each show id, the last availability number seen | So the next poll can notice an increase | End of every successful poll |
notified_pairs.json |
For each show id, the seat groups already announced, like "B#4-5" |
So the same seats are not announced twice | After an alert with V2 on |
bms_state.json |
The saved browser login (cookies and local storage) | Lets the headless browser open the seat page as you | By hand, with build_state.py |
Availability numbers as the poller reads them: 0 sold out, 1 few seats, 2 filling fast, 3 available. The API also says whether the "Book" button works (cta.type is showTimeRedirect) or shows a sold-out message (toast). In the saved sample both agreed for all 270 shows.
Shows that disappear from one answer are removed from state.json right away. That keeps the file small, but it is also why "New show opened" alerts sometimes repeat (bug 4).
08Running in the background
Running python poller.py stops when you close the terminal. To keep it running, hand it to your system's background service manager. macOS uses launchd, Linux uses systemd, and Windows uses Task Scheduler. The original setup runs on a Mac with launchd and on an Oracle cloud server with systemd.
macOS: launchd. The template is deploy/com.sriram.bms-poller.plist. It points at the venv's Python and poller.py, and writes output to logs/poller.out.log and logs/poller.err.log. Open it first and change the four folder paths inside it to where your bms-poller folder is.
cp deploy/com.sriram.bms-poller.plist ~/Library/LaunchAgents/
launchctl load ~/Library/LaunchAgents/com.sriram.bms-poller.plist # start now and at every login
launchctl list | grep bms # shows process id and last exit code
launchctl unload ~/Library/LaunchAgents/com.sriram.bms-poller.plist # stop
tail -f logs/poller.out.log # watch the log liveAfter editing .env, unload and load again; launchd does not notice the change by itself. Guide: launchd.info.
Linux: systemd. This is how the Oracle cloud server runs it. Copy the whole folder, including .env and bms_state.json, to /home/ubuntu/bms-poller, then run deploy/setup_vm.sh once. It installs Python, builds the venv, installs the service and starts it. On your own Linux PC, first change User= and the paths in deploy/bms-poller.service to your user name and folder.
sudo systemctl status bms-poller
sudo journalctl -u bms-poller -f # live log
sudo systemctl stop bms-poller # stop
nano ~/bms-poller/.env && sudo systemctl restart bms-pollerThe service restarts 30 seconds after any crash. On a server without a screen, Playwright also needs playwright install --with-deps chromium. Guides: systemd basics, Oracle Cloud free tier.
Windows: Task Scheduler. Run these once in PowerShell, from the project folder. They create a task that starts the poller when you log in and writes its output to logs\poller.out.log. If the task fails, Task Scheduler retries it every minute. If you ever find the poller stopped, Start-ScheduledTask starts it again.
$dir = (Get-Location).Path
New-Item -ItemType Directory -Force "$dir\logs" | Out-Null
$action = New-ScheduledTaskAction -Execute "cmd.exe" -WorkingDirectory $dir `
-Argument "/c .venv\Scripts\python.exe poller.py >> logs\poller.out.log 2>&1"
$trigger = New-ScheduledTaskTrigger -AtLogOn
$settings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit ([TimeSpan]::Zero) `
-RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1)
Register-ScheduledTask -TaskName "BMS Poller" -Action $action -Trigger $trigger -Settings $settings
Start-ScheduledTask -TaskName "BMS Poller"Everyday commands:
Get-ScheduledTask -TaskName "BMS Poller" | Get-ScheduledTaskInfo # last run time and result
Stop-ScheduledTask -TaskName "BMS Poller" # stop
Start-ScheduledTask -TaskName "BMS Poller" # start again, for example after editing .env
Unregister-ScheduledTask -TaskName "BMS Poller" -Confirm:$false # remove the task completely
Get-Content logs\poller.out.log -Wait -Tail 50 # watch the log liveA small black window stays open while the poller runs. Minimise it; closing it stops the poller. You can also see and edit the task in the Task Scheduler app. Guide: Register-ScheduledTask.
09When something goes wrong
Start by reading the last 50 lines of the log file. Every problem below has its own tell-tale line.
tail -n 50 logs/poller.out.logsudo journalctl -u bms-poller -n 50 --no-pager # when it runs as a service
# When you run "python poller.py" by hand, the log is printed in that terminal.Get-Content logs\poller.out.log -Tail 50| Line in the log | Meaning | Fix |
|---|---|---|
400 Client Error: Bad Request ... showtimes-by-event every 5 minutes |
DATE_CODE is in the past, or EVENT_CODE is wrong. This is the most common reason the poller goes quiet. |
Put a future date in .env, restart. The code should tell you about this instead of looping quietly (bug 3). |
auth error: 401 plus a "BMS token expired" push |
BookMyShow rejected the login headers | Copy fresh headers from DevTools into .env, restart |
rate limited (403 ...) or (429 ...) |
Cloudflare or BookMyShow is blocking too many requests | Wait, the poller slows down by itself. Raise POLL_INTERVAL_SEC if it keeps happening. |
no showtime rows plucked — schema may have changed |
BookMyShow changed the shape of its answer | Save the new answer with probe.py (after fixing it) and update _pluck_showtimes in poller.py |
venue filter matched 0 rows |
The theatre name in MONITORED_VENUES is spelled differently from BookMyShow's |
Copy the exact venueName from a log line |
seat spinner did not clear or scrape failed |
The headless browser could not load the seat page (old bms_state.json, Cloudflare, slow network) |
Rebuild bms_state.json; run python seat_scraper.py --diagnose --headed ... to watch it happen in a real window |
notify: email failed |
Gmail rejected the login | Make a new app password; check 2-step verification is on |
KeyError: 'BMS_...' at start |
A required .env line is missing |
Compare with .env.example |
Two quick checks. The first proves the install is fine. The second replays the last recorded seat map and prints the seat groups the code sees.
.venv/bin/python -c "import bms, poller"
.venv/bin/python adjacency.py diag/canvas_draw_log.json.venv/bin/python -c "import bms, poller"
.venv/bin/python adjacency.py diag/canvas_draw_log.json.venv\Scripts\python -c "import bms, poller"
.venv\Scripts\python adjacency.py diag\canvas_draw_log.json