01Goals
| Today (version 1) | Version 2 |
|---|---|
One movie, one date, one city per running program, set in .env |
Many "watches" per user. A watch is a theatre plus a movie plus a date plus a seat-group size, created in the browser. |
| Theatre filter is a typed name | Theatre picked from a list for the city, then only the movies showing at that theatre |
| Seven login headers copied from Chrome, pasted again when they expire | No account needed for polling. One login per user for seat checking, stored safely and refreshed by itself. |
| Alerts can be empty or wrong (bug 1) | Every alert is checked against the seat map before sending, and names the exact seats |
| One ntfy topic and one Gmail account for everyone | Push to each user's own phone or browser; email optional |
| Memory in three JSON files next to the code | A database with users, watches, poll results and sent alerts |
| Runs as a background service on one machine | Runs as two Docker containers on the Oracle server, started with one command |
| Failed quietly for five weeks | A health page and an alert to you when polling stops working |
Keep what already works. The showtimes comparison in poller.py, the seat map recorder in seat_scraper.py and the readers in canvas_parse.py and adjacency.py move into version 2 unchanged, apart from the fixes in the bug guide.
02The big picture
Two programs share one database. The API serves the pickers and the forms and never opens a browser. The worker polls BookMyShow with no login, opens the seat page with the owning user's cookies only when a show's status rises, and sends the alert. The website and the app are thin and share one generated API client. Dashed boxes are outside your server.
03Backend technology
Stay in Python and add FastAPI for the API plus a separate worker program for polling. That keeps every line of scraping code you have, and FastAPI writes the API documentation page that the website and app are built against.
| Layer | Use | Why this one | Instead of |
|---|---|---|---|
| Language | Python 3.12 or newer | bms.py, seat_scraper.py, canvas_parse.py, adjacency.py are reused as they are |
Node.js: would mean rewriting the seat map reader |
| API | FastAPI + Uvicorn (the program that serves it) | Fast, checks incoming data with Pydantic, and gives you a free /docs page to try every call |
Flask (no built-in docs), Django (bigger than needed) |
| Worker | A second Python program using APScheduler (a scheduler) | Runs one poll job per watch on its own timer. The headless browser lives only here, so the API never waits on it | Celery + Redis: right once you have hundreds of watches, too much now |
| Database | PostgreSQL on the server, SQLite on your Mac, through SQLModel | Users, watches, results and alerts need real tables; SQLModel lets one class describe both the table and the API shape | Keep JSON files: breaks as soon as two watches run at once |
| Table changes | Alembic | Change tables later without losing data | Hand-written SQL |
| Web requests | httpx | Same feel as requests, works inside the worker without blocking |
Keep requests: fine but blocks |
| Browser | Playwright Chromium with a saved profile per user | Already proven against the seat map | Selenium: slower |
| Packaging | Docker with docker compose: api, worker, postgres |
One command to deploy on the Oracle server; Playwright's official image includes Chromium | Separate background services, as now: harder to recreate |
| Settings | pydantic-settings | Checks settings at start, so a past date or a missing value fails loudly (fixes bug 3) | python-dotenv alone |
| Tests | pytest with the saved JSON files as inputs | The dump_*.json files in the repo become test inputs |
Manual runs |
Suggested folder layout:
bms-poller/
app/
api/ FastAPI routes: auth, theatres, movies, watches, alerts, health
core/ settings, database connection, security
models/ tables: User, BmsSession, Watch, PollSnapshot, Alert, PushDevice
bms/ bms.py, venue_pages.py (new), seat_scraper.py, canvas_parse.py, adjacency.py
worker/ scheduler.py, jobs/poll_watch.py, jobs/verify_seats.py, notify.py
web/ the React website (section 4)
mobile/ the Expo app (mobile app guide)
docker-compose.yml, Dockerfile.api, Dockerfile.worker, alembic/Your own computer for version 2. You build and test on macOS, Linux or Windows, and the server runs Linux. Besides the tools from Getting started, section 1, install Docker, which runs the database and the two programs the same way everywhere:
brew install --cask docker # Docker Desktop; open it once from Applications
docker --versioncurl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER # use Docker without sudo; log out and in again
docker --versionwsl --install # Docker needs WSL 2; restart Windows when asked
winget install -e --id Docker.DockerDesktop
docker --versionAfter that, the commands to run version 2 are identical on all three systems, for example docker compose up -d to start everything and docker compose logs -f worker to watch the worker.
04Website: pick the theatre first, then the movie
Build the website in React with Vite and TypeScript. It is fed by two new backend calls that read BookMyShow's own theatre pages. Both pages load without any login and carry their data inside the page, so the picker needs no account and no token. Checked on 11 October 2026 for Hyderabad: 97 theatres, 15 movies at AMB Gachibowli.
Where the picker data comes from. The backend downloads these two pages, pulls the JSON out of window.__INITIAL_STATE__ inside each, and keeps the result for a while (a cache):
| Step | Page the backend reads | Where the data sits in the page | Fields to keep |
|---|---|---|---|
| Theatres in a city | https://in.bookmyshow.com/{city}/cinemas (keep 24 hours) |
fetchVenuesListingApi.queries[...].data.venues[] |
VenueCode, VenueName, VenueAddress, VenueLatitude, VenueLongitude, arrDates, availableEventFormats |
| Movies and shows at one theatre on one date | https://in.bookmyshow.com/cinemas/{city}/{any-text}/buytickets/{VenueCode}/{YYYYMMDD} (keep 5 minutes) |
venueShowtimesFunctionalApi.queries["getShowtimesByVenue-{VenueCode}-{date}"].data.showDetailsTransformed.Event[] |
per Event: EventTitle, EventGroup; per ChildEvents[]: EventCode, EventName, EventLanguage, EventDimension; per ShowTimes[]: SessionId, ShowTime, ScreenName, AvailStatus, MinPrice, MaxPrice, CutOffDateTime, Attributes |
The text between {city}/ and /buytickets does not matter; BookMyShow redirects based on VenueCode, so let the download follow redirects. Once a watch exists, the worker polls with the version 1 showtimes call (one request per movie covers every theatre) and keeps only the exact venueCode, replacing today's name matching.
The pages and the order of clicks.
- Sign in with an email link or Google (section 5), so watches and phones belong to a person.
- Theatre at
/theatres: city chooser (Hyderabad first), searchable list, distance if the user allows location, a star to favourite. Backend call:GET /api/theatres?city=hyderabad. - Movie and date at
/theatres/AMBH: a row of dates fromarrDates, then movie cards grouped byEventTitlewith small labels for language and format. Each card lists its shows with time, screen, price range and the current availability colour. Backend call:GET /api/theatres/AMBH/showtimes?date=20261012. - Create watch at
/watch/new: pick one or more shows (or "any show that day"), seat-group size 2 to 6, optional price range fromMinPriceandMaxPrice, channels (push, email), optional stop time. Backend call:POST /api/watches. - Dashboard at
/: one card per watch with last poll time, per-show status, confirmed seat groups, pause and delete; alert history below. Backend calls:GET /api/watches,GET /api/alerts. - Connect BookMyShow at
/settings/bms: the one-time login that turns on seat checking (section 6).
Libraries. Vite + React 19 + TypeScript. TanStack Query for loading and caching the lists that depend on each other. React Router for pages. Tailwind CSS with shadcn/ui ready-made parts (Combobox for theatre search, Tabs for dates, Card, Badge, Switch). openapi-typescript to generate the API client from FastAPI's description, so the website and the app share one. react-hook-form + zod for the watch form.
Why theatre first. A theatre page lists only the movies playing there, so the user can never pick a movie that is not at their cinema, and the backend needs one cached page per theatre per day instead of one call per movie. Keep the current movie-first path as a second entry (/movies/{EventCode}, using the version 1 call) for people who want any theatre.
05The pieces in the middle
Six things sit between the website and the scraping code: sign-in, the scheduler, a shared speed limit for BookMyShow, the database, the senders, and hosting on the Oracle server you already have.
| Piece | Choice | Notes |
|---|---|---|
| Sign-in | fastapi-users with email + password and Sign in with Google, handing out short-lived sign-in tokens (15 minutes) plus a refresh token | The same tokens work for web and mobile. Hosted alternatives with less code: Supabase Auth or Clerk. |
| Scheduler | APScheduler in the worker; one job per active watch, every 60 seconds give or take 15, never two runs of the same job at once | Jobs are loaded from the watch table at start and whenever the API says a watch changed (a 30-second re-scan is fine). |
| BookMyShow speed limit | One shared limiter for the whole worker: at most 1 request per second. Watches on the same movie, date and city share one showtimes request per cycle | Cloudflare counts by server address; the whole server shares one. |
| Cache | cachetools in the API: theatre list 24 hours, theatre showtimes 5 minutes | Redis only if you ever run more than one API program. |
| Database | PostgreSQL 16 through SQLModel; tables below | SQLite file on your Mac with the same table classes. |
| Push and email | Browser push with pywebpush; phone push with exponent-server-sdk-python; email with Resend (3,000 free a month) or keep Gmail | An alert row is saved before sending and marked per channel, so a crash cannot send twice. |
| Front door and padlock | Caddy as the reverse proxy in front of the API and the built website; free HTTPS certificates by itself | Needs a hostname: a free DuckDNS name or a cheap domain. Browser push and the mobile app both require HTTPS. |
| Hosting | The Oracle Always Free ARM server (Ubuntu, already set up), docker compose with api, worker, postgres, caddy |
Worker image: mcr.microsoft.com/playwright/python (has Chromium). Alternatives: Fly.io, Railway. |
| Health | GET /health shows the last good poll per watch; the worker pings healthchecks.io after every cycle, and you get an email when the pings stop |
This is the structural fix for the five silent weeks. |
| Logs | structlog JSON lines; read with docker compose logs -f worker |
Docker rotates them. |
Tables.
| Table | Main columns |
|---|---|
user |
id, email, name, created_at |
bms_session |
user_id, cookies (encrypted JSON), member_id, captured_at, last_ok_at, status (ok or expired) |
watch |
id, user_id, city, venue_code, event_code, event_name, date_code, session_ids (list, or empty for any), group_size, price_min, price_max, channels, active, stop_at, created_at |
poll_snapshot |
watch_id, polled_at, session_id, avail_status, bookable, groups_found (JSON) |
alert |
id, watch_id, session_id, kind, groups (JSON), url, created_at, push_sent_at, email_sent_at |
push_device |
user_id, kind (webpush or expo), token or subscription JSON, created_at, last_ok_at |
theatre_cache, showtimes_cache |
key, payload JSON, fetched_at (only if the in-memory cache is not enough) |
Secrets on the server. DATABASE_URL, JWT_SECRET, GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET, VAPID_PUBLIC and VAPID_PRIVATE (browser push keys), RESEND_API_KEY, and FERNET_KEY (the encryption key for stored cookies). All in /opt/bms/.env with permissions 600, never in git.
06Logins without tokens: works on any account
Split the two jobs. Polling runs with no BookMyShow account at all. Seat checking uses a login the user does once inside your app, which the backend stores encrypted and keeps fresh. Nobody copies headers again.
What the tests on 11 October 2026 showed (details in the bug guide, bug 2): the showtimes API and the theatre pages answer without a login; the seat page needs one; the whole login is the ud cookie (its LSID field is the token) plus the bmsId device cookie; a login saved on 25 July still worked eleven weeks later.
Polling, no login. The worker keeps one shared web client with the city headers and a normal browser name. No BMS_* settings exist in version 2.
Connecting a BookMyShow account, three ways, best first.
- Mobile app: log in inside a web view. The app shows
https://in.bookmyshow.comin a web view, the user logs in with phone and OTP as usual, the app reads the cookies for.bookmyshow.comand sendsudandbmsIdtoPOST /api/bms/session. Nothing on the login page is automated, so it keeps working when BookMyShow changes its login screen. - Website: the backend relays the OTP. The user types their phone number. The backend opens the BookMyShow login in a headless browser, types the number, BookMyShow texts the OTP to the user, the website asks for the OTP, the backend types it in, and on success saves the login. Keep the half-finished page alive for 5 minutes with a
login_attemptrow. Research first whether a headless login gets a captcha (section 8, question 1). - Fallback: paste a dump. Keep today's DevTools snippet as an "advanced" option on the settings page, for the day the two above break.
Storing and using the login.
- Store only the cookies for
.bookmyshow.com, encrypted with Fernet, inbms_sessiontogether withmember_id(fromud.MEMBERID) andcaptured_at. - The worker checks a watch with a browser context created from that user's cookies, or a saved profile folder per user under
/var/lib/bms/profiles/{user_id}. After every successful seat read it saves the login back, so cookies BookMyShow rotates are kept. - Any header a future API call needs comes from the cookie:
x-access-tokenandx-lsidfromud.LSID,x-member-idfromud.MEMBERID,x-bms-idfrombmsId.
When a login expires. Every seat check is also a login check. If the seat spinner never clears or the page goes to the login screen, mark the session expired, send one push "Reconnect BookMyShow" that opens the connect screen, and keep polling without seat checks, with the alert text marked "unverified" (or pause, as a user setting). Check again at most every 5 minutes. No hour-long sleep.
Consent and control. The connect screen says exactly what is stored (login cookies, used only to read seat maps for your watches), offers a Disconnect button that deletes the row, and never stores the OTP or the phone number beyond the login attempt. BookMyShow's terms limit automated access; keep one request a second across the whole server and use the data only for the user's own bookings.
07Feature list, in order
Priority 1 is the smallest set that removes both reported bugs and the hand-pasted token. Priority 2 makes it nice for more than one person. Priority 3 is polish.
| Priority | Feature | Notes |
|---|---|---|
| 1 | Theatre-first picker: city, theatre, date, movie, shows | Section 4; replaces EVENT_CODE, DATE_CODE, MONITORED_VENUES |
| 1 | Watches with seat-group size 2 to 6, one or more shows, or any show that day | Replaces GROUP_SIZE |
| 1 | Checked alerts only: the seat map is read before any alert, and the alert lists the seats | Fixes bug 1, causes A to D; includes the alert log line |
| 1 | Polling without login, per-user BookMyShow connect, automatic refresh and reconnect prompt | Section 6; removes every BMS_* setting |
| 1 | Browser push and email; alert history | push_device, alert tables |
| 1 | Health page and "still alive" ping; alert to you when polling fails | Fixes bug 3 |
| 1 | Pause, resume, delete a watch; stop by itself after the show's cut-off time (CutOffDateTime) |
Also stops polling past dates, the cause of the 400 loop |
| 2 | Sign in with email link or Google; several users on one server | fastapi-users |
| 2 | Price filter using MinPrice and MaxPrice, and category from CategoryRange |
From the theatre page data |
| 2 | Final-hour mode: poll every 20 seconds when a watched show starts within 60 minutes | The idea behind the unused FINAL_HOUR_INTERVAL_SEC |
| 2 | Seat numbers as printed on the map, not column positions | Bug 9 |
| 2 | One tap from the alert to the BookMyShow seat page | Deep link bmsalerts://alert/{id} in the mobile app |
| 2 | Quiet hours and a per-watch limit (for example at most one alert per show per 10 minutes) | Avoids a flood on big releases |
| 3 | Favourite theatres and recent movies on the home screen | |
| 3 | Cities beyond Hyderabad (the city list is the regions key in the same page data) |
|
| 3 | Group watches: several people get the same alert for a planned outing | A share link for a watch |
| 3 | Telegram or WhatsApp as an extra channel | The Telegram Bot API is free and simple |
| 3 | A picture of the seat map in the alert | Draw the parsed grid as a PNG with Pillow |
Left out on purpose: automatic booking or payment. It would need card details, break BookMyShow's terms outright, and the user still has to choose. The alert plus a link is the product.
08What to research before building
Ten open questions, each answerable in an hour or less with what is already in the repo. The first three decide the shape of the login code, so do them first. Write each answer as a short note in docs/research/NN-question.md with the date and the exact command, so a future change on BookMyShow's side can be re-tested the same way.
| # | Question | How to find out | Reading |
|---|---|---|---|
| 1 | Does a headless Playwright login (phone + OTP) on BookMyShow get a captcha or a block? | Run login.py from the bug guide once with a window and once with headless=True; watch for a Cloudflare challenge page. This decides whether way 2 in section 6 is possible or only the mobile web view. |
Playwright: launch_persistent_context |
| 2 | What is the smallest set of cookies the seat page accepts? | Make a browser context with only ud and bmsId from bms_state.json; run test_v2_flow.py. If it works, the app sends two cookies instead of a dump. |
Playwright: cookies |
| 3 | Is there a JSON seat map that avoids reading the drawing? | On a seat page, DevTools, Network tab: look for the request carrying the encrypted strData (the doTrans.aspx call named in seat_scraper.py) and for any seatlayout JSON. dump_seatlayout.json and dump_venue_sessions.json in the repo are earlier attempts. A JSON path would save 5 seconds and a browser per check. |
Chrome DevTools: Network |
| 4 | How long does a BookMyShow login last? | Record last_ok_at per session in version 2; the July login still worked after 11 weeks. |
|
| 5 | How fast earns a Cloudflare 403? | Keep the limiter at 1 request per second and log every 403 with the request count in the previous minute. | Cloudflare: rate limiting |
| 6 | Exact meaning of AvailStatus 1, 2, 3 and of BestAvailableSeats on the theatre page |
Compare the page's legend text with the values across a day; VenueLegends in the same data names the icons. |
|
| 7 | Browser push on iPhones | Needs iOS 16.4 or newer, the site added to the home screen, and the VAPID key; test on your own iPhone before promising it. | web.dev: Web Push for iOS |
| 8 | Playwright Chromium on the ARM Oracle server | playwright install --with-deps chromium on Ubuntu arm64; run test_v2_flow.py there. |
Playwright: system requirements |
| 9 | Expo push versus talking to Google and Apple directly | Expo's service is enough for one app; direct FCM only if you need silent background pushes. | Expo: push notifications overview |
| 10 | Terms of use and personal use | Read BookMyShow's terms; keep the service private (invite only), no resale, no booking automation. | BookMyShow terms |
09Tutorials for each technology
One main tutorial per tool, in the order you will meet them. Each is the official guide unless marked.
If you prefer one longer course over separate docs: the Full Stack FastAPI Template is an official example project with FastAPI, SQLModel, React, TypeScript and Docker Compose wired together, close to the stack above.
10Build order
Eight steps, each leaving something that runs. The first two fix the live poller and need no new technology, so start there this week.
- Fix version 1 in place (bug guide, bugs 1, 2, 3): skip on an empty seat read, optional tokens,
login.py, date check and self-alert. Set a currentDATE_CODE, restart the Mac service, confirm one checked alert end to end. - Make a library. Move
bms.py,seat_scraper.py,canvas_parse.py,adjacency.pyintoapp/bms/with tests from the saved files. Addvenue_pages.pythat reads the theatre list and theatre showtimes pages (section 4). Nothing user-facing changes yet. - API skeleton. FastAPI with
/health,/api/theatres,/api/theatres/{code}/showtimes, Postgres in Docker Compose, first Alembic migration. Browse the data in the generated/docspage. - Watches and the worker.
Watch,PollSnapshot,Alerttables. The worker loads watches, polls with the shared limiter, checks seats with one shared profile (yours), and writes alerts. Keep ntfy and Gmail as the only channels for now. - Website, single user. Theatre, movie, watch form, dashboard against the API, no sign-in yet (one fixed test user). Deploy behind Caddy on the Oracle server with HTTPS. This is already more useful than version 1.
- Accounts and logins. fastapi-users, per-user BookMyShow connect (settings page with the paste fallback first, then the OTP relay if research question 1 says yes), encrypted
bms_session, reconnect push. - Browser push and email per user, alert history, health pings, final-hour mode.
- Mobile app from the mobile app guide, reusing the generated client. The web view login becomes the preferred way to connect BookMyShow.
Rough effort for one person in the evenings: steps 1 and 2 one week, 3 to 5 two to three weeks, 6 and 7 two weeks, 8 three weeks for a first Android build. Keep a CHANGELOG.md and tag a release after each step so you can always roll the server back.