fix: District settings read per field, so DISTRICT_* always wins

A leftover v1 MOVIE_CODE used to switch the whole District block to the old names,
silently discarding DISTRICT_CONTENT_ID even when it was set deliberately. The
monitor then watched the wrong film and still reported 'closed', which is the
failure mode this project exists to avoid.

Each field now prefers the DISTRICT_* value and falls back to its v1 name, an
explicitly emptied value is respected, and any v1 name still in use is warned
about at startup. District is enabled by DISTRICT_CONTENT_ID rather than the
movie code, which is optional: District answers without it, and a film with no
showtimes yet has none.
This commit is contained in:
Aculix Technologies committed 2026-09-08 15:14:51 +05:30
1 parent 6607a469a2
commit ef23f25ee5
4 files changed
+88 -33

No files matched your search

+5 -3
View File
@@ -18,11 +18,13 @@ NTFY_URL=https://ntfy.sh
# NTFY_TOKEN=tk_... # only if your server (or topic) requires auth # NTFY_TOKEN=tk_... # only if your server (or topic) requires auth
# ── District (district.in) ──────────────────────────────────────────────────── # ── District (district.in) ────────────────────────────────────────────────────
# Leave DISTRICT_MOVIE_CODE unset to skip District entirely. # Leave DISTRICT_CONTENT_ID unset to skip District entirely.
# Read these off the movie's page URL, e.g. # Read these off the movie's page URL, e.g.
# district.in/movies/the-odyssey-movie-tickets-in-ahmedabad-MV187151?frmtid=y7EZNLXN5Y # district.in/movies/the-odyssey-movie-tickets-in-ahmedabad-MV187151?frmtid=y7EZNLXN5Y
DISTRICT_MOVIE_CODE=y7EZNLXN5Y # ?frmtid= — identifies movie + language DISTRICT_CONTENT_ID=187151 # digits after MV; this is what enables District
DISTRICT_CONTENT_ID=187151 # digits after MV DISTRICT_MOVIE_CODE=y7EZNLXN5Y # optional ?frmtid=, pins one language/format variant.
# Leave empty for a film with no showtimes yet, since
# District only publishes the code once shows exist.
DISTRICT_CITY_KEY=ahmedabad # city slug in the path DISTRICT_CITY_KEY=ahmedabad # city slug in the path
DISTRICT_LAT=23.033938956712916 # any coordinates in that city; the API needs them DISTRICT_LAT=23.033938956712916 # any coordinates in that city; the API needs them
DISTRICT_LNG=72.57696301365426 DISTRICT_LNG=72.57696301365426
+8 -4
View File
@@ -106,7 +106,7 @@ support.
## Finding your IDs ## Finding your IDs
Configure either site, or both. Skip a site by leaving its main ID unset: no Configure either site, or both. Skip a site by leaving its main ID unset: no
`DISTRICT_MOVIE_CODE` means District isn't watched, no `BMS_EVENT_CODE` means BookMyShow isn't. `DISTRICT_CONTENT_ID` means District isn't watched, no `BMS_EVENT_CODE` means BookMyShow isn't.
### District ### District
@@ -118,7 +118,9 @@ https://www.district.in/movies/the-odyssey-movie-tickets-in-ahmedabad-MV187151?f
``` ```
- `DISTRICT_CONTENT_ID` is the number after `MV` - `DISTRICT_CONTENT_ID` is the number after `MV`
- `DISTRICT_MOVIE_CODE` is the `frmtid` parameter, which pins down the movie *and* the language - `DISTRICT_MOVIE_CODE` is the `frmtid` parameter, which pins down the movie *and* the language.
It's optional, and a film that hasn't been given showtimes yet doesn't have one, so leave it
empty when you're waiting on an unreleased title.
- `DISTRICT_CITY_KEY` is the city slug in the path - `DISTRICT_CITY_KEY` is the city slug in the path
- `DISTRICT_LAT` and `DISTRICT_LNG` can be any coordinates inside that city. The API refuses to - `DISTRICT_LAT` and `DISTRICT_LNG` can be any coordinates inside that city. The API refuses to
answer without them, though results are city-wide either way. answer without them, though results are city-wide either way.
@@ -159,7 +161,8 @@ with comments in [`.env.example`](.env.example).
|---|---|---| |---|---|---|
| `TARGET_DATE` | *required* | The date you're waiting for, `YYYY-MM-DD` | | `TARGET_DATE` | *required* | The date you're waiting for, `YYYY-MM-DD` |
| `NTFY_TOPIC` | *required* | The topic you subscribe to. Treat it like a password. | | `NTFY_TOPIC` | *required* | The topic you subscribe to. Treat it like a password. |
| `DISTRICT_MOVIE_CODE` | | Set to watch District. Plus `DISTRICT_CONTENT_ID`, `DISTRICT_CITY_KEY`, `DISTRICT_LAT`, `DISTRICT_LNG` | | `DISTRICT_CONTENT_ID` | | Set to watch District. Plus `DISTRICT_CITY_KEY`, `DISTRICT_LAT`, `DISTRICT_LNG` |
| `DISTRICT_MOVIE_CODE` | *(any variant)* | Optional `frmtid` to pin one language/format |
| `DISTRICT_CINEMA_ID` | *(any)* | Restrict District to one cinema | | `DISTRICT_CINEMA_ID` | *(any)* | Restrict District to one cinema |
| `DISTRICT_FORMAT_TAG` | `imax_2d` | Session tag that counts as a match | | `DISTRICT_FORMAT_TAG` | `imax_2d` | Session tag that counts as a match |
| `DISTRICT_FORMAT_MATCH` | `IMAX` | Screen-format substring that counts as a match | | `DISTRICT_FORMAT_MATCH` | `IMAX` | Screen-format substring that counts as a match |
@@ -184,7 +187,8 @@ with comments in [`.env.example`](.env.example).
| `PROXY_URL` | | SOCKS5 proxy for both sites' checks, see below | | `PROXY_URL` | | SOCKS5 proxy for both sites' checks, see below |
v1 used flat names (`MOVIE_CODE`, `CINEMA_ID` and friends) for District. Those still work, so an v1 used flat names (`MOVIE_CODE`, `CINEMA_ID` and friends) for District. Those still work, so an
existing `.env` keeps running unchanged. existing `.env` keeps running unchanged. They're read per field, so a `DISTRICT_*` value you set
deliberately always wins over a leftover flat one, and the log warns when any flat name is in use.
## Status endpoint ## Status endpoint
+44 -26
View File
@@ -22,36 +22,50 @@ const num = (name, fallback) => {
return n; return n;
}; };
/** Old single-provider variable names, still honoured so v1 .env files keep working. */ /**
function legacyDistrict() { * District settings. v1 used flat names (MOVIE_CODE, CINEMA_ID and so on) and those are
if (!process.env.MOVIE_CODE || process.env.DISTRICT_MOVIE_CODE) return null; * still honoured, but per field, so a stale leftover cannot override a DISTRICT_* value
return { * that was set deliberately. Enabled by the content id, since the movie code is optional.
movieCode: env("MOVIE_CODE"), */
contentId: env("CONTENT_ID"), const LEGACY = {
cityKey: env("CITY_KEY"), DISTRICT_MOVIE_CODE: "MOVIE_CODE",
lat: env("LAT"), DISTRICT_CONTENT_ID: "CONTENT_ID",
lng: env("LNG"), DISTRICT_CITY_KEY: "CITY_KEY",
venueId: env("CINEMA_ID"), DISTRICT_LAT: "LAT",
formatTag: process.env.FORMAT_TAG ?? "imax_2d", DISTRICT_LNG: "LNG",
formatMatch: process.env.FORMAT_MATCH ?? "IMAX", DISTRICT_CINEMA_ID: "CINEMA_ID",
bookingUrl: env("BOOKING_URL"), DISTRICT_FORMAT_TAG: "FORMAT_TAG",
DISTRICT_FORMAT_MATCH: "FORMAT_MATCH",
DISTRICT_BOOKING_URL: "BOOKING_URL",
}; };
const pick = (name) => process.env[name] ?? process.env[LEGACY[name]];
export function legacyNamesInUse() {
return Object.entries(LEGACY)
.filter(([nu, old]) => process.env[nu] === undefined && process.env[old] !== undefined)
.map(([, old]) => old);
} }
function districtConfig() { function districtConfig() {
const legacy = legacyDistrict(); const contentId = pick("DISTRICT_CONTENT_ID");
if (legacy) return legacy; if (!contentId) return null; // District not configured
if (!process.env.DISTRICT_MOVIE_CODE) return null; const need = (name) => {
const v = pick(name);
if (!v) throw new Error(`${name} is required when District is configured (see .env.example)`);
return v;
};
return { return {
movieCode: required("DISTRICT_MOVIE_CODE"), // Optional: District answers without it, and an unreleased film has no format code yet.
contentId: required("DISTRICT_CONTENT_ID"), movieCode: pick("DISTRICT_MOVIE_CODE") ?? "",
cityKey: required("DISTRICT_CITY_KEY"), contentId,
lat: required("DISTRICT_LAT"), cityKey: need("DISTRICT_CITY_KEY"),
lng: required("DISTRICT_LNG"), lat: need("DISTRICT_LAT"),
venueId: env("DISTRICT_CINEMA_ID"), lng: need("DISTRICT_LNG"),
formatTag: process.env.DISTRICT_FORMAT_TAG ?? "imax_2d", venueId: pick("DISTRICT_CINEMA_ID") ?? "",
formatMatch: process.env.DISTRICT_FORMAT_MATCH ?? "IMAX", formatTag: pick("DISTRICT_FORMAT_TAG") ?? "imax_2d",
bookingUrl: env("DISTRICT_BOOKING_URL"), formatMatch: pick("DISTRICT_FORMAT_MATCH") ?? "IMAX",
bookingUrl: pick("DISTRICT_BOOKING_URL") ?? "",
}; };
} }
@@ -83,6 +97,10 @@ export function loadConfig() {
new Date(Date.parse(`${targetDate}T00:00:00Z`) + 86400000 - tzOffsetMinutes * 60000).toISOString(); new Date(Date.parse(`${targetDate}T00:00:00Z`) + 86400000 - tzOffsetMinutes * 60000).toISOString();
const built = { district: districtConfig(), bookmyshow: bookmyshowConfig() }; const built = { district: districtConfig(), bookmyshow: bookmyshowConfig() };
const stale = legacyNamesInUse();
if (stale.length) {
console.warn(`warning: using v1 variable names ${stale.join(", ")}. Rename them to DISTRICT_* to avoid confusion.`);
}
// PROVIDERS is an explicit allow-list; without it, every configured provider runs. // PROVIDERS is an explicit allow-list; without it, every configured provider runs.
const wanted = env("PROVIDERS").split(",").map((s) => s.trim()).filter(Boolean); const wanted = env("PROVIDERS").split(",").map((s) => s.trim()).filter(Boolean);
for (const w of wanted) { for (const w of wanted) {
@@ -90,7 +108,7 @@ export function loadConfig() {
} }
const keys = (wanted.length ? wanted : Object.keys(built)).filter((k) => built[k]); const keys = (wanted.length ? wanted : Object.keys(built)).filter((k) => built[k]);
if (!keys.length) { if (!keys.length) {
throw new Error("No provider configured. Set DISTRICT_MOVIE_CODE and/or BMS_EVENT_CODE (see .env.example)"); throw new Error("No provider configured. Set DISTRICT_CONTENT_ID and/or BMS_EVENT_CODE (see .env.example)");
} }
for (const w of wanted) { for (const w of wanted) {
if (!built[w]) throw new Error(`PROVIDERS lists "${w}" but its settings are missing (see .env.example)`); if (!built[w]) throw new Error(`PROVIDERS lists "${w}" but its settings are missing (see .env.example)`);
+31
View File
@@ -132,3 +132,34 @@ test("district: 400 code 32 is a waiting state, other 400s are failures", async
globalThis.fetch = realFetch; globalThis.fetch = realFetch;
} }
}); });
// A leftover v1 name silently overriding a DISTRICT_* value that was set on purpose is
// the worst kind of config bug: the monitor watches the wrong film and still says "closed".
test("config: DISTRICT_* wins per field over leftover v1 names", async () => {
const { loadConfig, legacyNamesInUse } = await import("../src/config.js");
const saved = { ...process.env };
try {
Object.assign(process.env, {
TARGET_DATE: "2026-09-25", NTFY_TOPIC: "t",
MOVIE_CODE: "OLDCODE", CONTENT_ID: "187151", CITY_KEY: "ahmedabad",
LAT: "23.03", LNG: "72.57", CINEMA_ID: "1032362",
DISTRICT_CONTENT_ID: "227928", DISTRICT_CINEMA_ID: "",
BMS_EVENT_CODE: "ET1", BMS_REGION_CODE: "AHD",
});
const d = loadConfig().providers.find((p) => p.key === "district");
assert.equal(d.contentId, "227928", "the explicitly set new name must win");
assert.equal(d.venueId, "", "an explicitly emptied new name must not fall back to the old one");
assert.equal(d.movieCode, "OLDCODE", "fields with no new value still inherit");
assert.ok(legacyNamesInUse().includes("MOVIE_CODE"), "and the leftovers are reported");
delete process.env.DISTRICT_CONTENT_ID;
delete process.env.CONTENT_ID;
const only = loadConfig().providers;
assert.equal(only.find((p) => p.key === "district"), undefined,
"no content id means District is simply not watched");
assert.equal(only.length, 1, "and the other site carries on alone");
} finally {
for (const k of Object.keys(process.env)) if (!(k in saved)) delete process.env[k];
Object.assign(process.env, saved);
}
});