UFC auto-schedule
UFC auto-schedule
Turns the operator Stop into a standby: the cockpit wakes itself up ~15 minutes before a UFC card’s first segment, follows the card, and stands itself back down once every bout is decided. Along the way it posts countdown / go-live / wrap-up embeds to Discord.
Motivation: before this, the NVENC transcode ran 24/7 (~39 W) even hours after a card ended, because the only way to stop it was a manual Stop that stayed down until a human pressed Start.
Where the code lives
The feature is a self-contained package, obbyschedule/, which never imports app.py — the
cockpit injects its primitives as callbacks, so the dependency runs one way only.
| Module | Contents |
|---|---|
models.py |
Frozen dataclasses + StrEnums. ScheduleSettings.from_config does all coercion/bounding. No I/O. |
espn.py |
EspnScheduleProvider. Pure @staticmethod parsers (parse_calendar, parse_event) so tests run on recorded JSON. |
notify.py |
DiscordNotifier (retries, 429 handling, never raises) + EmbedBuilder (dict in, dict out). |
state.py |
ScheduleState + ScheduleStateStore — atomic JSON at schedule.state_path. |
scheduler.py |
UfcScheduler. decide() and due_milestones() are pure and synchronous; tick()/run() are the async shell. |
protocols.py |
ScheduleProvider / Notifier structural interfaces, so stubs are type-legal. |
Data source
https://site.api.espn.com/apis/site/v2/sports/mma/ufc/scoreboard — public, unauthenticated.
leagues[0].calendar→ the season’s cards (label,startDate,endDate).events[].competitions[]→ one row per bout, each with its owndateandstatus.type.completed. Distinct dates are the broadcast segments (early prelims / prelims / main card), and the completion flags are how the card’s end is detected.
Gotcha: the calendar start is not always the first bout
For the 2026-07-25 Abu Dhabi card the calendar reads 16:00Z (main card) while the prelims open at
13:00Z. Countdowns therefore anchor on first_card_start from the event detail, and the detail
is loaded max(warn_minutes) + 60m + CALENDAR_SKEW(12h) ≈ 37h ahead so the 24-hour warning is never
missed. next_wakeup() keeps that wide window cheap by sleeping until the next milestone rather than
polling every two minutes for a day and a half.
Gotcha: a scratched bout never completes
STATUS_CANCELED / STATUS_POSTPONED bouts are excluded from the “is everything done?” test, and a
completed main event also ends the card. Otherwise one cancelled prelim would hold the stream up
forever.
Policy
UfcScheduler.decide() — evaluated fresh every tick (level-triggered, so a restart self-heals):
- START once
now >= first_card_start - lead_minutes(default 15), if the card is not finished, not suppressed, and nothing is already running. Clears the operator Stop and kicks a private-IPTV refresh — the pre-roll is the one window where the spare provider connection is free. - STOP once every bout is decided and
end_grace_minutes(default 20) has elapsed since the card was first observed final, or whenmax_runtime_hours(default 8) trips as a failsafe if ESPN stalls. The failsafe is checked first so a stuck grace stamp cannot defeat it. - Re-arm if the encode goes down mid-card (a crash the watchdog could not recover, or a scraper stand-down). The scheduler is level-triggered, so it re-issues START rather than sitting idle until the card ends.
- Only ever stands down a stream it owns (
started_by_scheduler).
Adoption — why it isn’t “only what I started”
The obvious rule, never touch a stream you did not start, quietly makes the whole feature a no-op in the case that actually matters. Production had the encode up for six days straight; if nobody remembers to press Stop first, the scheduler never owns it, the stand-down never fires, and the card ends with ffmpeg still burning — the exact 24/7 behaviour this replaces.
So should_adopt() takes ownership of an already-running stream, but only once the card is genuinely
in its window (PRE_ROLL or LIVE). It will not adopt a card the operator vetoed with Stop, one
already stood down, a finished card, or anything at all while auto-schedule is off. That keeps the
operator’s explicit “not this card” intact while still guaranteeing the card ends in standby.
Operator override
Pressing Stop during a card records stream.stop_reason = "manual" and stamps
suppressed_event_id with the card being tracked. That means “not this event” — the scheduler
stays armed for the next one. A manual Start or Restart lifts the suppression.
schedule_start_stream / schedule_stop_stream in app.py both take PROCESS_LOCK, which is what
serialises the scheduler against a human pressing Stop: whoever takes the lock first wins, and the
loser observes the other’s effect.
Ownership continuity
If ESPN changes an event’s id mid-card, ScheduleState.track() carries scheduler ownership onto
the new id. Dropping it would orphan the encode, since the stand-down branch only fires for
scheduler-owned events.
Notifications
Milestones per card, each fired at most once (deduped in the persisted ledger):
- Coming up — posted when a card is first tracked (~37h out), announcing what’s next with a static per-timezone time table for every segment. See below.
- Countdowns at 24h / 12h / 6h / 2h / 30m before the first bout (
notify.warn_minutes). - Card start for each segment.
- Event end, including the main-event winner from
competitors[].winner.
“Coming up” and the timezone table
Discord’s <t:…:R> markup only localises for the person reading the message in Discord. That is
useless the moment someone quotes the post, screenshots it, or reads it through a bridge — and this
audience is spread across North America, Europe and APAC. So the “Coming up” embed carries a static,
aligned table per card segment:
Los Angeles Sat 1 Aug, 10:00 AM PDT
New York Sat 1 Aug, 1:00 PM EDT
London Sat 1 Aug, 6:00 PM BST
Sydney Sun 2 Aug, 3:00 AM AEST
Zones come from notify.timezones, which accepts bare IANA names or {zone, label} pairs. An
unknown zone is dropped, never rendered: load_zone() falls back to UTC, so a typo would
otherwise print a confidently wrong time under the right city name.
Operators can re-post it any time from the cockpit’s Post “Coming up” button, or
POST /api/schedule {"coming_up": true} — that path force-fetches the next card’s detail even when
the loop would not have loaded it yet, and bypasses the once-per-event ledger.
Card times use Discord’s dynamic timestamp markup (<t:epoch:f> / <t:epoch:R>) so each reader sees
them in their own timezone. Every embed links to https://fight.nswfiles.com/.
A milestone more than notify.max_late_minutes (20) past due is dropped, not fired — after a
redeploy or an outage the channel should get silence, not a burst of stale countdowns. sweep_stale()
marks those as fired so they are not re-evaluated forever.
Config
Lives under the top-level schedule: key.
schedule:
enabled: true
espn_scoreboard_url: https://site.api.espn.com/apis/site/v2/sports/mma/ufc/scoreboard
include_pattern: '^UFC\s+(\d+|Fight Night)'
exclude_pattern: 'Contender Series|Road to UFC'
lead_minutes: 15
end_grace_minutes: 20
max_runtime_hours: 8
calendar_refresh_seconds: 3600
live_poll_seconds: 120
display_timezone: Canada/Pacific
state_path: /etc/obbystreams/schedule_state.json
notify:
enabled: true
discord_webhook_url: '<secret>'
watcher_url: https://fight.nswfiles.com/
warn_minutes: [1440, 720, 360, 120, 30]
notify_card_start: true
notify_event_end: true
max_late_minutes: 20
normalize_configgotcha. It rebuilds the config fromDEFAULT_CONFIGon every save and only copies whitelisted sections.scheduleis copied through explicitly innormalize_config; without that line an operator’s settings would be silently erased by the next unrelatedsave_config(). There is a regression test for exactly this (test_schedule_section_survives_an_unrelated_save).
The webhook URL is a bearer credential and is masked to "***" by public_config(), so it never
leaves the box via /api/config or /api/status.
API
GET /api/schedule(guarded) →{enabled, phase, event, next_event, countdown_seconds, cards, …}POST /api/schedule(guarded) →{"enabled": bool}to toggle,{"test_notification": true}to post one test embed.- The same snapshot is embedded in
/api/statusunderschedule, so the SPA needs no extra polling.
Cockpit UI
- The header banner becomes state-aware:
⏸ STANDBY — auto-starts for … in 3h 12mwhile auto-schedule is on, versus the existing red⏹ STOPPED (manual)when it is off. - An Auto-schedule switch sits in the header actions.
- A
SchedulePanellists the tracked card, each segment’s start time, and bout progress, plus a Test Discord button. - Pure helpers live in
frontend/src/lib/schedule.tsso the countdown/banner logic is vitest-testable without a DOM.
Verifying
cd /opt/obbystreams
uv run ruff check . && uv run ty check && uv run pytest -q && npm test && npm run build
# Walk a whole event timeline offline — no network, no Discord, no ffmpeg:
uv run python tools/schedule_replay.py --step 10
# Against the real ESPN calendar (only meaningful for a card that hasn't happened yet):
uv run python tools/schedule_replay.py --live
Tests: tests/test_schedule_parsing.py (ESPN shapes), tests/test_schedule_policy.py (the state
machine on a fake clock), tests/test_schedule_notifications.py (milestones + embeds),
tests/test_schedule_loop.py (the async shell with stubs). Fixtures in tests/fixtures/espn_*.json
are trimmed captures of the real 2026-07-25 card plus pre / mid / cancelled variants.
Note the test harness never enters the ASGI lifespan, so SCHEDULER is None there — the endpoints
degrade to an “scheduler not running” snapshot rather than erroring.
Deploying
Code changes require restarting obbystreams.service, which interrupts the live encode. Do it
in a non-event window and check /api/status for viewers first.