Initial commit

Watches district.in showtimes and sends a max-priority phone notification the
moment tickets for a given movie, date, format and cinema go on sale.
This commit is contained in:
Aculix Technologies committed 2026-08-14 14:31:28 +05:30
commit b70968d259
15 files changed
+1164

No files matched your search

+1
View File
@@ -0,0 +1 @@
{"serverTime":1785852192,"meta":{"entityMetaData":{"entity_ids":"171,125,1020680,1032362,1022291","num_show_total":"1,3,1,4,2","max_price_cinema":"150.0,350.0,200.0,870.0,440.0","min_price_cinema":"150.0,200.0,130.0,260.0,180.0","cinema_distance":"2.918495927212754,4.709702768689504,5.0199044666769534,6.312918866307513,6.456346530469135","screen_format_dict":"{\"1020680\":[\"2D\"],\"1032362\":[\"IMAX 2D\"],\"125\":[\"2D\"],\"1022291\":[\"2D\",\"4DX-2D\"],\"171\":[\"2D\"]}","show_language_dict":"{\"1020680\":[\"default_language\"],\"1032362\":[\"default_language\"],\"125\":[\"default_language\"],\"1022291\":[\"default_language\"],\"171\":[\"default_language\"]}","prev_ordered_cinema":"0,0,0,0,0"},"showDates":["2026-08-04","2026-08-05","2026-08-06","2026-08-07","2026-08-08","2026-08-09"],"amenities":{"12":{"id":12,"name":"WIFI","rank":12,"icon_url":"https://cdn.district.in/movies-assets/images/cinema/Wifi-5646f900-95f6-11ef-b954-a1c7e1e4be25.png"},"24":{"id":24,"name":"Silver Screen","rank":24,"icon_url":"https://cdn.district.in/movies-assets/images/cinema/Screen-fef54df0-95f5-11ef-b954-a1c7e1e4be25.png"},"14":{"id":14,"name":"Gaming Zone","rank":14,"icon_url":"https://cdn.district.in/movies-assets/images/cinema/Gaming-d3353df0-95f6-11ef-a253-33f238ee5f34.png"},"15":{"id":15,"name":"2K","rank":15,"icon_url":"https://cdn.district.in/movies-assets/images/cinema/Screen-fef54df0-95f5-11ef-b954-a1c7e1e4be25.png"},"17":{"id":17,"name":"4DX","rank":17,"icon_url":"https://cdn.district.in/movies-assets/images/cinema/Screen-fef54df0-95f5-11ef-b954-a1c7e1e4be25.png"},"28":{"id":28,"name":"Free Parking","rank":28,"icon_url":"https://cdn.district.in/movies-assets/images/cinema/Parking-c79a3400-95f6-11ef-a253-33f238ee5f34.png"},"18":{"id":18,"name":"Dolby 7.1","rank":18,"icon_url":"https://cdn.district.in/movies-assets/images/cinema/Audio-4acee060-95f6-11ef-b954-a1c7e1e4be25.png"},"29":{"id":29,"name":"Baggage Counter","rank":29,"icon_url":"https://cdn.district.in/movies-assets/images/cinema/Baggage%20counter-f4cbae50-95f5-11ef-a253-33f238ee5f34.png"},"19":{"id":19,"name":"Air Conditioning","rank":19,"icon_url":"https://cdn.district.in/movies-assets/images/cinema/AC-d92791f0-95f5-11ef-b954-a1c7e1e4be25.png"},"1":{"id":1,"name":"Mobile Ticket","rank":2,"icon_url":"https://cdn.district.in/movies-assets/images/cinema/Mobile%20Ticket-bc527a30-95f6-11ef-b954-a1c7e1e4be25.png"},"2":{"id":2,"name":"Parking","rank":1,"icon_url":"https://cdn.district.in/movies-assets/images/cinema/Parking-c79a3400-95f6-11ef-a253-33f238ee5f34.png"},"3":{"id":3,"name":"Food & Beverages","rank":3,"icon_url":"https://cdn.district.in/movies-assets/images/cinema/F%26B-938587f0-95f6-11ef-b954-a1c7e1e4be25.png"},"4":{"id":4,"name":"Wheelchair Friendly","rank":6,"icon_url":"https://cdn.district.in/movies-assets/images/cinema/Wheelchair-73f627a0-95f6-11ef-a253-33f238ee5f34.png"},"5":{"id":5,"name":"Recliners","rank":4,"icon_url":"https://cdn.district.in/movies-assets/images/cinema/Seat-13023560-95f6-11ef-b954-a1c7e1e4be25.png"},"6":{"id":6,"name":"Digital Payments","rank":5,"icon_url":"https://cdn.district.in/movies-assets/images/cinema/Payment-eab8cb00-95f5-11ef-a253-33f238ee5f34.png"},"7":{"id":7,"name":"RGB Laser","rank":7,"icon_url":"https://cdn.district.in/movies-assets/images/cinema/Screen-fef54df0-95f5-11ef-b954-a1c7e1e4be25.png"},"8":{"id":8,"name":"Dolby Atmos","rank":8,"icon_url":"https://cdn.district.in/movies-assets/images/cinema/Audio-4acee060-95f6-11ef-b954-a1c7e1e4be25.png"},"9":{"id":9,"name":"IMAX","rank":9,"icon_url":"https://cdn.district.in/movies-assets/images/cinema/Screen-fef54df0-95f5-11ef-b954-a1c7e1e4be25.png"},"30":{"id":30,"name":"Dolby 4 Way 11.1","rank":30,"icon_url":"https://cdn.district.in/movies-assets/images/cinema/Audio-4acee060-95f6-11ef-b954-a1c7e1e4be25.png"},"20":{"id":20,"name":"Metro Nearby","rank":20,"icon_url":"https://cdn.district.in/movies-assets/images/cinema/Metro-08856cb0-95f6-11ef-b954-a1c7e1e4be25.png"},"31":{"id":31,"name":"Barco 4K Ultra HD","rank":31,"icon_url":"https://cdn.district.in/movies-assets/images/cinema/Screen-fef54df0-95f5-11ef-b954-a1c7e1e4be25.png"},"10":{"id":10,"name":"4K","rank":10,"icon_url":"https://cdn.district.in/movies-assets/images/cinema/Screen-fef54df0-95f5-11ef-b954-a1c7e1e4be25.png"},"21":{"id":21,"name":"Sofa","rank":21,"icon_url":"https://cdn.district.in/movies-assets/images/cinema/Seat-13023560-95f6-11ef-b954-a1c7e1e4be25.png"}},"selectedShowDate":"2026-08-05","pageMeta":null,"movie":{"content_id":187151,"name":"The Odyssey","description":"The story follows Odysseus on his epic journey home after the Trojan War, highlighting his encounters with Polyphemus, the Sirens, and Circe, culminating in a heartfelt reunion with his wife, Penelope.","release_date":"2026-07-17","re_release_date":"","upcoming_movie_text":null,"duration":175,"censor":"A","primary_language":"English","languages":["Hindi","English"],"genre":["Epic","Adventure"],"poster":"https://cdn.district.in/movies-assets/iLine truncated
+214
View File
@@ -0,0 +1,214 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { guestToken, toLocalTime, extractSessions, decide, defaultState } from "../src/monitor.js";
const fixture = JSON.parse(readFileSync(
fileURLToPath(new URL("./fixtures/response-open-date.json", import.meta.url)), "utf8"));
// Matches the sample in .env.example: IMAX at one specific cinema, IST.
const CFG = {
targetDate: "2026-08-10",
expiryUtc: "2026-08-10T18:30:00Z",
bookingUrl: "https://example.com/book",
failureThreshold: 6,
tzOffsetMinutes: 330,
heartbeatHour: 9,
formatTag: "imax_2d",
formatMatch: "IMAX",
formatLabel: "IMAX",
cinemaId: "1032362",
currency: "₹",
};
test("guestToken matches District's client-generated shape", () => {
const t = guestToken();
assert.match(t, /^\d{13}_\d{18}_[a-z0-9]{11}$/);
assert.notEqual(guestToken(), t, "fresh token per call");
});
test("toLocalTime converts API UTC showTime to local clock time", () => {
assert.equal(toLocalTime("2026-08-05T03:30", 330), "9:00 AM"); // verified against the site UI
assert.equal(toLocalTime("2026-08-05T06:30", 330), "12:00 PM"); // noon boundary
assert.equal(toLocalTime("2026-08-05T12:00", 330), "5:30 PM");
assert.equal(toLocalTime("2026-08-05T18:45", 330), "12:15 AM"); // past midnight, clock time only
assert.equal(toLocalTime("2026-08-05T03:30:00", 330), "9:00 AM"); // seconds already present
assert.equal(toLocalTime("2026-08-05T03:30", 0), "3:30 AM"); // other timezones
});
test("extractSessions finds the target cinema's sessions in a real capture", () => {
const ex = extractSessions(fixture, CFG);
assert.equal(ex.cinemaCount, 5);
assert.ok(ex.sessionCount > ex.matched.length);
assert.equal(ex.matched.length, 4);
assert.equal(ex.other.length, 0);
const s = ex.matched[0];
assert.equal(s.sid, "36874");
assert.equal(s.cinema, "PVR Palladium Mall, Thaltej, Ahmedabad");
assert.equal(s.time, "9:00 AM");
assert.equal(s.audi, "SCREEN 9");
assert.ok(s.tiers.includes("CLASSIC ₹260"));
assert.ok(s.tiers.includes("RECLINER ₹640"));
assert.equal(ex.showDates.at(-1), "2026-08-09");
});
test("extractSessions: no cinemaId watches every cinema; no format filter matches all", () => {
const anyCinema = extractSessions(fixture, { ...CFG, cinemaId: "" });
assert.equal(anyCinema.matched.length, 4, "still only IMAX sessions");
assert.equal(anyCinema.other.length, 0, "nothing is 'other' when no cinema is targeted");
const anyFormat = extractSessions(fixture, { ...CFG, cinemaId: "", formatTag: "", formatMatch: "" });
assert.equal(anyFormat.matched.length, anyFormat.sessionCount, "every session matches");
const otherCinema = extractSessions(fixture, { ...CFG, cinemaId: "125" });
assert.equal(otherCinema.matched.length, 0);
assert.equal(otherCinema.other.length, 4, "IMAX elsewhere becomes a secondary line");
});
test("extractSessions tolerates empty/malformed payloads", () => {
assert.deepEqual(extractSessions({}, CFG),
{ matched: [], other: [], cinemaCount: 0, sessionCount: 0, showDates: [] });
});
const T0 = new Date("2026-08-06T02:00:00Z"); // before the 03:30 UTC (09:00 IST) heartbeat window
const openInput = (extract, now = T0) => ({ kind: "open", extract, now });
const EX_MATCH = {
matched: [{ sid: "111", cinema: "Test Cinema", time: "9:00 AM", audi: "SCREEN 9", avail: 100, total: 300, tiers: "CLASSIC ₹260 (66/66)" }],
other: [], cinemaCount: 3, sessionCount: 7, showDates: [],
};
const EX_NO_MATCH = { matched: [], other: [], cinemaCount: 3, sessionCount: 7, showDates: [] };
const EX_EMPTY = { matched: [], other: [], cinemaCount: 0, sessionCount: 0, showDates: [] };
/** Mirrors the runner: merge each alert's patch only after its push succeeds. */
function apply(state, decision) {
let merged = decision.baseState;
for (const a of decision.alerts) merged = { ...merged, ...a.statePatch };
return merged;
}
test("closed steady state produces no alerts and no state change", () => {
const s1 = apply(defaultState(), decide(defaultState(), { kind: "closed", now: T0 }, CFG));
const d2 = decide(s1, { kind: "closed", now: new Date(T0.getTime() + 60000) }, CFG);
assert.equal(d2.alerts.length, 0);
assert.equal(JSON.stringify(apply(s1, d2)), JSON.stringify(s1), "no state write when nothing changed");
});
test("a match fires a max-priority alert once, re-firing only for new session ids", () => {
const d1 = decide(defaultState(), openInput(EX_MATCH), CFG);
assert.equal(d1.alerts.length, 1);
assert.equal(d1.alerts[0].priority, "5");
assert.ok(d1.alerts[0].statePatch.dateOpenAlerted, "first open response sets dateOpenAlerted");
assert.ok(d1.alerts[0].statePatch.sidsAlerted["111"]);
const s1 = apply(defaultState(), d1);
assert.equal(decide(s1, openInput(EX_MATCH), CFG).alerts.length, 0, "deduped");
const withNew = { ...EX_MATCH, matched: [...EX_MATCH.matched, { ...EX_MATCH.matched[0], sid: "222", time: "1:15 PM" }] };
const d3 = decide(s1, openInput(withNew), CFG);
assert.equal(d3.alerts.length, 1, "new session id re-alerts");
assert.ok(d3.alerts[0].body.includes("1:15 PM"));
assert.ok(!d3.alerts[0].body.includes("9:00 AM"), "already-alerted session not repeated");
});
test("sessions at other cinemas ride along as secondary lines, untracked", () => {
const withOther = {
...EX_MATCH,
other: [{ sid: "999", cinema: "Other Cinema", time: "6:00 PM", audi: "", avail: 50, total: 100, tiers: "CLASSIC ₹300 (50/100)" }],
};
const d = decide(defaultState(), openInput(withOther), CFG);
assert.equal(d.alerts.length, 1, "no separate push for other venues");
assert.ok(d.alerts[0].body.includes("Also at Other Cinema: 6:00 PM"));
assert.ok(!d.alerts[0].statePatch.sidsAlerted["999"], "other-venue ids are not dedupe-tracked");
});
test("date open without a match fires DATE_OPEN once; a later match still fires", () => {
const d1 = decide(defaultState(), openInput(EX_NO_MATCH), CFG);
assert.equal(d1.alerts.length, 1);
assert.equal(d1.alerts[0].priority, "4");
const s1 = apply(defaultState(), d1);
assert.equal(decide(s1, openInput(EX_NO_MATCH), CFG).alerts.length, 0);
const d3 = decide(s1, openInput(EX_MATCH), CFG);
assert.equal(d3.alerts.length, 1);
assert.equal(d3.alerts[0].priority, "5");
});
test("failed match push then sessions vanish -> DATE_OPEN still reachable", () => {
const d1 = decide(defaultState(), openInput(EX_MATCH), CFG);
const s1 = d1.baseState; // push failed: statePatch not merged
const d2 = decide(s1, openInput(EX_NO_MATCH), CFG);
assert.equal(d2.alerts.length, 1);
assert.equal(d2.alerts[0].priority, "4", "user still learns the date opened");
});
test("open-but-empty counts as closed", () => {
const d = decide(defaultState(), openInput(EX_EMPTY), CFG);
assert.equal(d.alerts.length, 0);
assert.equal(d.baseState.lastGood.kind, "closed");
});
test("BROKEN fires at the threshold, once; RECOVERED on the next success", () => {
let s = defaultState();
for (let i = 1; i <= 5; i++) {
const d = decide(s, { kind: "error", reason: "HTTP 403", now: T0 }, CFG);
assert.equal(d.alerts.length, 0, `no alert at failure ${i}`);
s = apply(s, d);
assert.equal(s.failures, i);
}
const d6 = decide(s, { kind: "error", reason: "HTTP 403", now: T0 }, CFG);
assert.equal(d6.alerts.length, 1);
assert.ok(d6.alerts[0].title.includes("BROKEN"));
s = apply(s, d6);
assert.equal(decide(s, { kind: "error", reason: "HTTP 403", now: T0 }, CFG).alerts.length, 0, "not repeated");
const d8 = decide(s, { kind: "closed", now: T0 }, CFG);
assert.equal(d8.alerts.length, 1);
assert.ok(d8.alerts[0].title.includes("recovered"));
s = apply(s, d8);
assert.equal(s.failures, 0);
assert.equal(s.brokenAlerted, false);
});
test("heartbeat fires once per local day, never on error ticks, and can be disabled", () => {
const hbTime = new Date("2026-08-06T03:31:00Z"); // 09:01 IST
const early = new Date("2026-08-06T03:29:00Z");
assert.equal(decide(defaultState(), { kind: "closed", now: early }, CFG).alerts.length, 0);
const d1 = decide(defaultState(), { kind: "closed", now: hbTime }, CFG);
assert.equal(d1.alerts.length, 1);
assert.equal(d1.alerts[0].priority, "1");
assert.equal(d1.alerts[0].statePatch.hbDate, "2026-08-06");
const s1 = apply(defaultState(), d1);
assert.equal(decide(s1, { kind: "closed", now: new Date("2026-08-06T09:00:00Z") }, CFG).alerts.length, 0, "once per day");
assert.equal(decide(defaultState(), { kind: "error", reason: "x", now: hbTime }, CFG).alerts.length, 0, "not on errors");
assert.equal(decide(defaultState(), { kind: "closed", now: hbTime }, { ...CFG, heartbeatHour: -1 }).alerts.length, 0, "disabled");
});
test("heartbeat reports the probe-refreshed showDates maximum", () => {
const hbTime = new Date("2026-08-06T03:31:00Z");
const d = decide(defaultState(), { kind: "closed", now: hbTime, probeMax: "2026-08-11" }, CFG);
assert.equal(d.baseState.lastGood.showDatesMax, "2026-08-11", "probe result rides baseState");
assert.ok(d.alerts[0].body.includes("2026-08-11"));
});
test("recovery, match and heartbeat can co-fire with disjoint patches", () => {
const hbTime = new Date("2026-08-06T03:31:00Z");
const s0 = { ...defaultState(), brokenAlerted: true, failures: 6 };
const d = decide(s0, openInput(EX_MATCH, hbTime), CFG);
assert.deepEqual(d.alerts.map((a) => a.priority), ["3", "5", "1"]);
assert.deepEqual(d.alerts.map((a) => Object.keys(a.statePatch)),
[["brokenAlerted"], ["sidsAlerted", "dateOpenAlerted"], ["hbDate"]]);
const merged = apply(s0, d);
assert.equal(merged.brokenAlerted, false);
assert.equal(merged.failures, 0);
assert.ok(merged.sidsAlerted["111"]);
assert.equal(merged.hbDate, "2026-08-06");
});
test("retirement: one alert at expiry, then a permanent no-op", () => {
const past = new Date("2026-08-10T18:31:00Z");
const d1 = decide(defaultState(), { kind: "closed", now: past }, CFG);
assert.equal(d1.alerts.length, 1);
assert.ok(d1.alerts[0].statePatch.retired);
const s1 = apply(defaultState(), d1);
assert.equal(decide(s1, { kind: "closed", now: past }, CFG).alerts.length, 0);
});