Consumer Layer
The consumer layer orchestrates EPG generation, stream matching, channel lifecycle, and Dispatcharr synchronization. It sits between the API routes and the service/provider layers.
Generation Workflow
generation.py provides the single entry point: run_full_generation().
A global lock prevents concurrent runs. The workflow progresses through these phases (names match generation_status progress events):
| Phase | % | Description |
|---|---|---|
init | 3% | M3U refresh, setup |
teams | 5-50% | Process all active team EPGs |
groups | 50-93% | Match streams, create channels, generate EPG |
ordering | 93% | Apply stream-ordering priority rules |
| (channel reassignment) | 94% | Global channel number rebalancing (_sync_global_channels) |
saving | 95% | Merge team + group XMLTV output |
dispatcharr | 96-97% | EPG refresh, channel association, stream audit |
channelsdvr/emby/jellyfin | 97% | Parallel media-server EPG refreshes (_run_media_server_refreshes) |
lifecycle | 98% | Channel lifecycle sync |
reconciliation | 99% | Detect/fix channel drift |
cleanup | 99% | Scheduled deletions, orphan sweeps |
complete | 100% | Done |
Shared state across phases:
- Single
SportsDataServiceinstance keeps the event cache warm across all teams and groups - Shared events cache (
league:datekeyed dict) prevents duplicate API calls across groups - Shared generation counter ensures cache fingerprint coherence
Cancellation: GenerationCancelled exception raised when the cancellation flag is set, checked at phase boundaries.
Event Group Processor
The event_group_processor/ package handles the core matching and channel lifecycle for event groups. processor.py holds the EventGroupProcessor coordinator; the pipeline stages live in sibling modules (stream_fetcher.py, matching.py, team_filter.py, persistence.py, xmltv.py, preview.py, results.py).
Processing Pipeline
1. Load group config (leagues, team filters, M3U account)
2. Fetch streams from Dispatcharr
3. Filter streams (stale, placeholder, regex include/exclude)
4. Fetch events from providers (parallel, cached)
5. Match streams to events (StreamMatcher)
6. Exclude by timing (past/final/before window)
7. Subscription league filtering (per-group overrides)
8. Create/update channels (ChannelLifecycleService)
9. Generate XMLTV (template resolution)
10. Push to Dispatcharr
11. Track stats
Key Methods
| Method | Description |
|---|---|
process_group(group_id) | Full processing for one group — returns match/channel/EPG stats |
process_all_groups(callback) | Parallel processing of all groups with ThreadPoolExecutor |
preview_group(group_id) | Test matching without persisting — returns match details |
Subscription Leagues
_resolve_subscription_leagues() resolves which leagues a group should search:
- Global subscription — default for all groups
- Per-group override — group can specify its own leagues
- Soccer modes:
all(expand all enabled),teams(discover from followed teams),manual(explicit selection)
Team Processor
team_processor.py generates XMLTV programmes for each team’s XMLTV channel (schedule tracking). For teams without managed channels, Dispatcharr association remains manual. Opted-in persistent Team EPG channels are maintained separately by TeamChannelManager.
| Method | Description |
|---|---|
process_team(team_id) | Single team EPG — load config, fetch schedule, generate programmes |
process_all_teams(callback) | Parallel processing with ThreadPoolExecutor (up to ESPN_MAX_WORKERS) |
Managed Team Channels (services/team_channel_manager.py)
TeamChannelManager creates and synchronizes persistent Dispatcharr channels for active teams with managed channels enabled. It runs before the EPG refresh, associates the refreshed Team EPG afterward, and applies team-channel stream ordering during generation.
- Ownership is established only by a
managed_team_channelsrecord. A same-tvg_idDispatcharr channel without that mapping is manual and is left untouched; a conflict is reported rather than adopted. - Matched event streams are persisted in
managed_team_channel_streams, then attached only while their membership window is active. This is independent of event-channel create/delete timing. - Disabling management, deactivating, or deleting a team deletes only a UUID-verified owned remote channel before its mapping is removed. Failed or unverifiable deletion leaves the team change blocked.
- Event-channel reset, expiry, reconciliation, and orphan cleanup deliberately exclude persistent Team EPG channels. Dashboard rows for them are audit-only.
Stream Matching
Classifier (matching/classifier.py)
classify_stream() categorizes streams into:
| Category | Description | Examples |
|---|---|---|
TEAM_VS_TEAM | Contains separator (vs/@/at) | "Cowboys vs Eagles" |
EVENT_CARD | Combat sports pattern | "UFC 315: Main Card" |
FIELD_EVENT | Field/competitor events (racing, tennis, golf) | "NASCAR Cup: Daytona 500" |
PLACEHOLDER | No event info | "ESPN+ 1", "Coming Soon" |
Output includes: extracted team names, detected league/sport hints, card segment (combat sports), and whether custom regex was used.
Matcher (matching/matcher.py)
StreamMatcher matches classified streams to real sporting events.
Match methods (in priority order):
| Method | Description |
|---|---|
cache | Fingerprint cache hit from previous match |
exact | Exact team name match |
alias | Team alias lookup (Detection Library) |
fuzzy | Fuzzy string matching on team names |
league_hint | Detected league hint narrows search space |
epg | Matched via EPG program title (see below); persisted as MatchMethod.EPG |
Caching: Fingerprint-based cache keyed by hash(stream_name, group_id, generation). The generation counter increments per EPG run to bust stale cache entries.
Event prefetch: Before matching a batch, _prefetch_events fetches every searched league across the whole match window (-MATCH_WINDOW_DAYS to +days_ahead) once, instead of per stream. It runs in three passes — plan every (league, date) cell, fill the cells that still need a service call, then assemble in league order. Network-bound fetches go out concurrently (up to ESPN_MAX_WORKERS, default 24 here); cache-only cells run inline because they never touch the network, and TSDB stays inline because its rate limiter sleeps under a lock, so concurrent callers would queue inside that sleep rather than overlap. Results land in shared_events, so later groups in the same run reuse them.
EPG-title matching (matching/epg_matcher.py, matching/epg_index.py)
For static-named linear channels (ESPN, NBA1) the stream name is unmatchable, but the Dispatcharr EPG guide carries the real matchup. When a group opts in, StreamMatcher augments name matching with EPG-title matching:
- Resolve —
matching/epg_resolver.pybridges the streamtvg_id→ programtvg_idnamespace gap via a cascade (curated channelepg_data_id→ direct tvg_id → strict name match → Xtream provider guide,matching/epg_xtream.py). Does not require streams to be pre-built into channels. - Index + match —
EPGProgramIndex(built once per run, scoped to resolvedtvg_ids) fetches programs;build_match_input()pipe-joins programtitle + sub_titleand feeds it through the sameclassify_stream → TeamMatcherpipeline. Studio/talk and replay program categories are skipped. - Fan-out — one linear stream matches many events (one per program); results carry
MatchMethod.EPGand the program’s start/end window for the lifecycle layer. - Reconciliation —
_reconcile_epg(): lineartvg_id+ EPG match → EPG wins (time-windowed), name match discarded; dedicatedtvg_id→ name match kept, EPG only fills when name found nothing.
The persisted MatchMethod is carried onto each managed_channel_streams row (match_method column) so the epg_match stream-ordering rule can prioritize time-shared linear streams. See Program Matching for the user-facing behavior.
Channel Lifecycle
Service (lifecycle/service.py + stage modules)
ChannelLifecycleService manages channel creation, sync, and deletion in Dispatcharr. service.py holds the coordinator (shared state, _safe_update_channel, profile-change batching); the paths live in sibling modules: creator.py (matched-stream driver, duplicate modes, channel creation), syncer.py (settings/profiles/logo sync, EPG association), cleanup.py (scheduled deletions, missing/rotated streams, orphan + disabled-group sweeps), naming.py (name/logo/template resolution shared by create and sync).
Safe update pattern — _safe_update_channel():
- Calls Dispatcharr API
- Checks
OperationResult.successbefore writing to local DB - On failure: DB stays unchanged, drift re-detected on next run (self-healing)
- No retry queue needed
Three parallel context resolution paths (must stay in sync):
| Path | Purpose | File |
|---|---|---|
_create_channel | New channel from matched stream | lifecycle/creator.py |
_sync_channel_settings | Update existing channel | lifecycle/syncer.py |
| EPG Generator | XMLTV channel name/icon | event_epg.py |
All three resolve: name, tvg_id, logo, channel group, profiles, stream profile, channel number, and delete timing from the same event + template context.
Dynamic Resolver (lifecycle/dynamic_resolver.py)
Resolves {sport} and {league} wildcards in channel group and profile names:
- Looks up display names from the database
- Auto-creates groups/profiles in Dispatcharr if they don’t exist
- Caches resolved IDs for fast repeated lookups
Reconciliation (consumers/reconciliation.py)
ChannelReconciler detects and fixes inconsistencies between the local DB and Dispatcharr:
| Issue Type | Description | Action |
|---|---|---|
orphan_teamarr | DB record but no Dispatcharr channel | Delete DB record |
orphan_dispatcharr | Dispatcharr channel but no DB record | Link or ignore |
duplicate | Multiple channels for same event | Merge or keep first |
drift | Settings mismatch (name, streams, profiles) | Update Dispatcharr |
Runs automatically at the end of each generation. Issues have severity levels (critical/warning/info) and auto_fixable flags.
Timing (lifecycle/timing.py)
ChannelLifecycleManager computes create/delete times based on:
- Event start time
- Sport-specific duration
- Pre/post buffer minutes
- Create/delete timing mode (
same_dayorbefore_event/after_event)
Time-windowed stream membership (managed_channel_streams.attach_at/detach_at)
For EPG-matched linear streams, membership in a channel is time-windowed so one linear stream (ESPN, NBA1) rotates across many event channels, attached to each only near game time. This is separate from channel create/delete timing — the channel exists for its whole lifecycle; only the stream swaps in and out.
compute_stream_window()(lifecycle/timing.py) derivesattach_at/detach_atfrom the matched EPG program slot ± the globalepg_stream_pre/post_buffer_minutessettings, clipped to the neighbouring programs on thattvg_id.NULLwindow = full-life membership (dedicated/name-matched streams — unchanged behavior).get_ordered_stream_ids()enforces the window gate; it’s the set pushed to Dispatcharr, and reconciliation drift uses the same window-gated set as “expected”.
Sports Data Service
services/sports_data.py orchestrates provider calls with caching.
Key design:
PersistentTTLCache— in-memory during generation (fast), background flush to SQLite every 2 minutes- Provider selection by priority (ESPN → MLB Stats → HockeyTech → TSDB)
- TTLs: 30 days for final events, 8h for schedules, 30m for live events, 24h for team info
- Date membership is decided at this seam (#590): a requested date is the user-local day, converted once to a UTC window (
utilities/event_dates.py) and applied to everything providers return. Providers never compare calendars themselves, so provider/UTC/venue date mismatches (UFC cards and race weekends spanning midnight, AFL’s UTC+10 schedule, TSDB’s UTC event dates) can’t drop events. Event caches are keyed per user timezone (events_v2:<league>:<date>:<tz>). - The superset the seam filters is built here, not by providers (#601): server-side day-bucketed APIs (ESPN’s scoreboard
?dates=, MLB Stats) file each event under their calendar day and cannot honour a “return ±1 day” contract.get_eventstherefore fetches the provider-day buckets D-1, D, D+1 (provider_day_buckets) and unions them before filtering. Without it, a user far from the API’s home region loses every evening kickoff at every lookahead — the event is filtered out of day D and never fetched under day D+1. Raw buckets get their own timezone-independent cache layer (events_raw:<provider>:<league>:<date>), so a contiguous run of N days costs N+2 provider calls rather than 3N.
| Method | TTL | Description |
|---|---|---|
get_events(league, date) | 8h (30d if all final) | All events for a league on a date |
get_team_schedule(team_id, league) | 8h | Team’s upcoming schedule |
get_team(team_id, league) | 24h | Team metadata |
get_team_stats(team_id, league) | 4h | Record, standings |
get_single_event(event_id, league) | 30m | Live event with scores |
Stream Ordering
services/stream_ordering.py assigns priority to a channel’s streams based on configurable rules. Nine rule types:
| Rule Type | Matches On |
|---|---|
m3u | M3U account name |
group | Source group name |
regex | Stream name pattern (case-insensitive) |
stream_type | Stream type |
team_feed / not_team_feed | Whether the stream is a team feed |
epg_match | Stream was EPG-matched (match_method) |
dispatcharr_group | Dispatcharr channel group |
stats_metric | Stream stats metric (score mode) |
Each rule runs in one of two modes: priority (band assignment) or score (numeric ranking); bands and scores are collapsed into a single ordering (_collapse). No match defaults to priority 999 (sorted to end), though a user rule can override the catch-all band. Ties break by added_at for stable ordering.
Other Consumer Modules
| Module | Purpose |
|---|---|
consumers/cache/ | Unified team/league reverse-lookup cache (queries, refresh) driving event matching, multi-league resolution, soccer league discovery |
consumers/enforcement/ | Post-processing enforcers: KeywordEnforcer, CrossGroupEnforcer, KeywordOrderingEnforcer |
consumers/filler/ | Team and event filler programme generation |
consumers/team_epg.py / consumers/event_epg.py | XMLTV programme generation for team and event channels |
consumers/scheduler.py | Background EPG cron scheduler |
consumers/racing_segments.py / consumers/mma_segments.py | Racing-weekend and fight-card segment expansion |
consumers/channel_lifecycle.py | Lifecycle helpers shared across consumers |
consumers/stream_match_cache.py | Fingerprint match cache persistence |
consumers/generation_status.py | Generation progress state machine |
consumers/event_matcher.py | Event matching helpers |
consumers/matching/ contains 14 modules; beyond those described above: team_matcher.py, racing_matcher.py, tennis_matcher.py, country_resolver.py, normalizer.py, constants.py, result.py, event_matcher.py.
File Locations
| File | Purpose |
|---|---|
consumers/generation.py | Unified generation workflow |
consumers/event_group_processor/ | Event group processing pipeline (coordinator + stage modules) |
consumers/team_processor.py | Team EPG generation |
consumers/matching/classifier.py | Stream classification |
consumers/matching/matcher.py | Stream-to-event matching |
consumers/matching/epg_index.py | Per-run scoped EPG program index (tvg_id → programs) |
consumers/matching/epg_matcher.py | EPG title/category matching helpers |
consumers/lifecycle/ | Channel lifecycle management (service coordinator + creator/syncer/cleanup/naming) |
consumers/lifecycle/dynamic_resolver.py | Wildcard resolution |
consumers/reconciliation.py | Drift detection and repair |
consumers/lifecycle/timing.py | Channel create/delete timing |
services/sports_data.py | Provider orchestration with caching |
services/stream_ordering.py | Channel priority rules |