Scheduling Domain
Manages time-based programming for live channels — program slots, EPG queries, and (planned) blackout and recurrence rules. Emits facts onto the NATS bus when slots become active; it is a producer, not a coupling hub.
Handles:
- Schedule slot CRUD — create, read, update, delete program entries per channel
- Overlap validation — 409 on conflicting slots for the same channel
- EPG grid query — all channels with slots for a given date (single call for the UI)
- Single channel EPG — slots for one channel on a given date
- Now/next — currently airing program and the next one per channel
- Blackout rules — suppress slots for specific regions or subscriber tiers (planned)
- Recurrence rules — iCal-style RRULE patterns, auto-generate slot instances (planned)
- NATS event publishing on slot activation (planned —
SCHEDULING_NATS_ENABLED)
Domain Structure
domain/scheduling/
├── config.go package scheduling — Config struct + ConfigFromEnv()
│ reads: SCHEDULING_NATS_ENABLED
├── models.go package scheduling — ScheduleSlot struct
│ table: schedule_slots
├── helper.go package scheduling — ApplySlotFilters, DayWindow, ParseChannelIDs
├── validation.go package scheduling — ParseID, ParseChannelID
│
├── handlers/
│ ├── handler.go package handlers — Handler struct + NewHandler(db, cfg)
│ ├── handler_schedule.go package handlers — ListSlots, GetSlotByID, CreateSlot,
│ │ UpdateSlot, DeleteSlot
│ │ overlap validation on create + update
│ ├── handler_epg.go package handlers — GetEPGGrid, GetChannelEPG, GetNowNext
│ │ joins channels table via raw SQL
│ │ (no playback model import)
│ ├── handler_blackout.go package handlers — stub, planned
│ └── handler_recurrence.go package handlers — stub, planned
│
└── routes/
├── wire.go package routes — Setup() — only entry point main.go calls
└── routes.go package routes — registerRoutes() — internal
Data Model
erDiagram
Channel {
int id
int sid
string name
}
ScheduleSlot {
int id
int channelId
string title
string description
datetime startTime
datetime endTime
string genre
string posterUrl
}
Channel ||--o{ ScheduleSlot : "has program slots"
ScheduleSlot.channelId is a loose integer FK to channels.id — the scheduling domain never imports or preloads the Channel struct. The EPG grid query joins the channels table in raw SQL to get sid and name for the channel header row. No circular imports.
All times are stored as UTC. Frontend converts to the viewer's local timezone.
Architecture
CoreAPI (Fiber)
│
└── /scheduling
├── /slots → program slot CRUD
│ POST / create — validates channel exists, checks overlap (409)
│ GET / list — filterable by channelId, genre, search, date
│ GET /:id get one
│ PUT /:id update — overlap check excludes current slot
│ DELETE /:id soft delete
│
└── /epg
GET / EPG grid — all channels, one day
GET /:channelId single channel, one day
GET /:channelId/now now + next program on this channel
PostgreSQL
└── schedule_slots ← AutoMigrate from ScheduleSlot model
└── channels ← joined via raw SQL in EPG handlers (read-only from scheduling)
EPG Grid Response
The grid endpoint is the single call that drives the EPG UI. One call per date navigation — no per-channel calls needed.
{
"date": "2026-07-03",
"channels": [
{
"channelId": 1,
"sid": 2000,
"name": "Sports HD",
"slots": [
{
"id": 1,
"channelId": 1,
"title": "Morning Highlights",
"description": "",
"startTime": "2026-07-03T01:00:00Z",
"endTime": "2026-07-03T03:00:00Z",
"genre": "Sports",
"posterUrl": ""
}
]
}
]
}
Channels with no slots for the day are included with an empty slots array — the UI renders them as empty rows in the grid.
If no channels exist at all, returns { "channels": [], "hint": "no channels found — create channels first" }.
Overlap Validation
Two program slots cannot overlap on the same channel. The check is:
existing slot overlaps new slot if:
existing.start_time < new.end_time AND existing.end_time > new.start_time
Returns 409 Conflict with "slot overlaps with an existing program on this channel".
On update, the current slot is excluded from the check — updating a slot's title or genre without changing times will never trigger a 409.
Connection to Playback Domain
The scheduling domain knows about channels by ID only. It does not import domain/playback models. The EPG grid handler reads id, sid, and name from the channels table via a raw SQL query — this is the only cross-domain data access, and it is read-only.
The OTT client calls both domains independently:
GET /api/v1/playback/channels → channel list with stream URLs (what to play)
GET /api/v1/scheduling/epg?date=today → program schedule (what to show in the guide)
No coupling — the stream plays regardless of whether EPG data exists.
Environment Variables
| Variable | Default | Description |
|---|---|---|
SCHEDULING_NATS_ENABLED |
false |
Publish slot activation events to NATS (planned — not yet implemented) |
main.go additions
// imports
schedulingmodels "mashup.castis.io/playtelly/CoreAPI/domain/scheduling"
schedulingroutes "mashup.castis.io/playtelly/CoreAPI/domain/scheduling/routes"
// RegisterModels
&schedulingmodels.ScheduleSlot{}
// Routes
schedulingroutes.Setup(v1, db.GetDB())
Changelog
See changelog.md