Skip to content

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


API Routes