Context
Dispatcharr's EPG grid endpoint now supports caller-selected time windows. The server-side implementation lives in apps/epg/api_grid.py on dev; the web guide consumes it in Dispatcharr/Dispatcharr#1645 (frontend-only — sliding window, 12h chunk prefetch, merge by program id).
Current state in StreamClient
We already use /api/epg/grid/ for the fast first paint (DispatcherClient.getFastListings, DispatcherClient.swift:1126), but phase 2 of the load uses a different, weaker source:
getAllListings → GET /api/epg/programs/?page_size=50000, paging up to 50 pages (DispatcherClient.swift:1157), then filtering client-side by tvg_id / epg_data_id.
EPGCache.startBackgroundFullLoad (EPGCache.swift:153) merges that into the fast window.
EPGCache.ensureDay(_:) (EPGCache.swift:298) falls back to the same full fetch when the background load failed or was cancelled.
LiveProgramFetcher.swift:349 uses the same endpoint for its own refreshes.
New grid API contract
GET /api/epg/grid/:
start / end — ISO 8601 datetime (or bare date → midnight; naive treated as UTC). Either may be omitted: start defaults to now−1h, end defaults to start+24h. Takes precedence over days/prev_days.
days (1–365, clamped) / prev_days (0–30, clamped) — relative offsets from now.
channel_profile_id — int, or all/empty for the caller's default union.
- No params → the historical now−1h → now+24h window, so our existing call is unchanged.
- Max span 395 days.
end <= start, bad ints, or unparseable datetimes → non-streaming 400 {"error": ...}.
- Response keeps the
{"data":[...]} envelope, so EPGGridResponse decodes as-is.
Why this is more than a bandwidth optimization
The grid endpoint and /api/epg/programs/ are not equivalent data sources, and we're using the weaker one for the full load:
- Dummy programmes are missing past +24h. Grid generates programmes on demand for channels with a dummy EPG source or no EPG at all (
_iter_dummy_for_channels). Those rows do not exist in ProgramData, so our phase-2 merge can never fetch them — channels without real EPG go blank as soon as the user scrolls past the fast window. User-visible bug.
- EPG overrides are ignored. Grid resolves each channel's effective EPG assignment (
effective_epg_data_obj, override wins). We match raw rows ourselves via tvgIdToChannelIds.
- We download invisible channels. Grid applies
hidden_from_output, user_level, adult-content hide, and the assigned-profile union, and drops orphan EPG rows. /api/epg/programs/ returns everything in the DB and we filter client-side.
- Payload size. Up to 50 pages × 50k programs per cold load, vs. bounded windows.
Scope
Out of scope / follow-up
The per-channel GET /api/epg/epgdata/{id}/ fan-out (DispatcherClient.swift:923 and :1030) — one request per channel with an epg_data_id, 20 concurrent, run on both getChannels() and getChannelSummary() just to resolve tvg_id mismatches. Worth asking upstream to inline that mapping in the channel serializer or expose a bulk epgdata list. Nothing in #1645 touches it.
The XMLTV path (/output/epg, DispatcherClient.swift:2022) is unaffected — it is only reached when JWT auth fails and we fall back to Xtream-Codes (:610), where no authenticated API is available.
Context
Dispatcharr's EPG grid endpoint now supports caller-selected time windows. The server-side implementation lives in
apps/epg/api_grid.pyondev; the web guide consumes it in Dispatcharr/Dispatcharr#1645 (frontend-only — sliding window, 12h chunk prefetch, merge by program id).Current state in StreamClient
We already use
/api/epg/grid/for the fast first paint (DispatcherClient.getFastListings,DispatcherClient.swift:1126), but phase 2 of the load uses a different, weaker source:getAllListings→GET /api/epg/programs/?page_size=50000, paging up to 50 pages (DispatcherClient.swift:1157), then filtering client-side bytvg_id/epg_data_id.EPGCache.startBackgroundFullLoad(EPGCache.swift:153) merges that into the fast window.EPGCache.ensureDay(_:)(EPGCache.swift:298) falls back to the same full fetch when the background load failed or was cancelled.LiveProgramFetcher.swift:349uses the same endpoint for its own refreshes.New grid API contract
GET /api/epg/grid/:start/end— ISO 8601 datetime (or bare date → midnight; naive treated as UTC). Either may be omitted:startdefaults to now−1h,enddefaults tostart+24h. Takes precedence overdays/prev_days.days(1–365, clamped) /prev_days(0–30, clamped) — relative offsets from now.channel_profile_id— int, orall/empty for the caller's default union.end <= start, bad ints, or unparseable datetimes → non-streaming400 {"error": ...}.{"data":[...]}envelope, soEPGGridResponsedecodes as-is.Why this is more than a bandwidth optimization
The grid endpoint and
/api/epg/programs/are not equivalent data sources, and we're using the weaker one for the full load:_iter_dummy_for_channels). Those rows do not exist inProgramData, so our phase-2 merge can never fetch them — channels without real EPG go blank as soon as the user scrolls past the fast window. User-visible bug.effective_epg_data_obj, override wins). We match raw rows ourselves viatvgIdToChannelIds.hidden_from_output,user_level, adult-content hide, and the assigned-profile union, and drops orphan EPG rows./api/epg/programs/returns everything in the DB and we filter client-side.Scope
start/end(andchannel_profile_id) to the grid call; keep the no-param call as the compatibility path for older servers. Needs a support-detection strategy — an older server ignores unknown params silently and returns 25h, so we can't assume the window we asked for is the window we got.getAllListingsfrom/api/epg/programs/?page_size=50000to windowed grid calls, passing the profile the guide is currently showing.EPGCache.ensureDay(_:)fetch just that day's window instead of refetching the entire EPG.startBackgroundFullLoadalready merges by program id. Caveat: dummy-programme ids come fromabs(raw.hashValue)(DispatcharrProgram.swift:52), which is process-seeded — fine for in-session dedup, don't build persistence on it./api/epg/programs/to a legacy fallback; same forLiveProgramFetcher.swift:349.StreamingHttpResponse(chunked, noContent-Length), so an exception mid-stream yields a truncated body under a200. Decode failure must degrade to "keep what we have," not a hard error.DispatcherPVR/dispatcharr-api-spec.yaml— line 2822 still documents/api/epg/grid/withparameters: []and the hardcoded 24h description.Out of scope / follow-up
The per-channel
GET /api/epg/epgdata/{id}/fan-out (DispatcherClient.swift:923and:1030) — one request per channel with anepg_data_id, 20 concurrent, run on bothgetChannels()andgetChannelSummary()just to resolve tvg_id mismatches. Worth asking upstream to inline that mapping in the channel serializer or expose a bulkepgdatalist. Nothing in #1645 touches it.The XMLTV path (
/output/epg,DispatcherClient.swift:2022) is unaffected — it is only reached when JWT auth fails and we fall back to Xtream-Codes (:610), where no authenticated API is available.