Template Engine
The template engine resolves {variable} placeholders in EPG titles, descriptions, and filler content. It supports 260 variables across 20 categories, 33 condition evaluators, suffix rules for multi-game context, and template-type scoping for the variable picker.
Architecture
TemplateResolver
├── VariableRegistry (260 variables, 20 categories)
├── ConditionEvaluator (23 evaluators)
└── ContextBuilder (Event + Team → TemplateContext)
Variable Resolution Pipeline
- Parse
{variable}and{variable.suffix}patterns from template string - Look up each variable in the
VariableRegistry - Check the variable’s
SuffixRulesto determine which game contexts are valid - Call the variable’s extractor function with the appropriate
GameContext - Replace placeholders with resolved values
- Clean up artifacts (empty parentheses, double spaces, trailing punctuation)
Suffix Rules
Each variable declares which game contexts it supports:
| Suffix | Context | Example |
|---|---|---|
{var} (base) | Current/next game | {game_date} → "Mar 15" |
{var.next} | Next scheduled game | {game_date.next} → "Mar 18" |
{var.last} | Last completed game | {game_date.last} → "Mar 12" |
| Rule | Base | .next | .last | Used By |
|---|---|---|---|---|
ALL | Yes | Yes | Yes | Most variables (opponent, game_date, scores) |
BASE_ONLY | Yes | No | No | Team constants (team_name, league, sport) |
BASE_NEXT_ONLY | Yes | Yes | No | Odds (no odds for past games) |
Template Scope
Orthogonal to suffix rules. Each variable also declares which template type(s) it is valid in, mirroring the existing template_type concept ('team' / 'event') used on the templates table and the conditions endpoint. This gates variable picker availability per template.
| Scope | Team picker | Event picker | Used By |
|---|---|---|---|
ALL (default) | Yes | Yes | Positional and game-level variables (home_team, venue, odds_spread, is_playoff, etc.) |
TEAM_ONLY | Yes | No | “Our team” perspective (team_name, opponent, is_home, team_record, win_streak, result, odds_moneyline, etc.) |
EVENT_ONLY | No | Yes | Feed separation (feed_team, feed_team_short, is_home_feed, feed_home_away, etc.) |
The registry exposes filter_by_template_type(template_type) which returns the valid subset. Unknown values and None return all variables (fail-open, matches the conditions endpoint’s behavior). Today the filter only applies to the picker via GET /variables?template_type=…; hand-typed out-of-scope variables still resolve at render time (backward compatibility).
Declare scope on the decorator:
@register_variable(
name="opponent",
category=Category.IDENTITY,
suffix_rules=SuffixRules.ALL,
description="Opponent team name",
scope=TemplateScope.TEAM_ONLY,
)
Variable Categories
| Category | Count | Key Variables |
|---|---|---|
| Identity | 20 | team_name, opponent, league, sport, team_short, matchup_short |
| Combat | 26 | fighter1, fighter2, card_segment, round_number, fight_result_method |
| Conference | 20 | college_conference, pro_division, division_abbrev |
| Records | 18 | team_record, opponent_record, team_wins, team_losses |
| Streaks | 18 | win_streak, loss_streak, streak_detail, streak_emoji |
| Home/Away | 17 | is_home, vs_at, home_team_name, away_team_short |
| Scores | 15 | team_score, opponent_score, final_score, score_differential |
| DateTime | 10 | game_date, game_time, days_until, hours_until |
| Rankings | 9 | team_rank, opponent_rank, is_ranked, rank_text |
| Odds | 7 | odds_spread, odds_over_under, odds_moneyline_team |
| Soccer | 6 | soccer_match_league, soccer_group_name, soccer_match_matchday |
| Statistics | 6 | team_ppg, opponent_ppg, team_wpct |
| Outcome | 5 | result, result_text, result_emoji, final_status |
| Broadcast | 4 | broadcast_simple, network, market |
| Venue | 4 | venue_full, venue_name, venue_city |
| Standings | 4 | playoff_seed, games_back |
| Playoffs | 4 | is_playoff, is_preseason, season_type |
Variables are registered via decorator in teamarr/templates/variables/ (one file per category).
Condition Evaluators
23 evaluators for conditional descriptions. Lower priority number = evaluated first. Priority 100 is the default (always matches).
| Condition | Description | Value Param |
|---|---|---|
always | Legacy: always true | No |
is_home | Team playing at home | No |
is_away | Team playing away | No |
win_streak | On N+ game win streak | Min streak length |
loss_streak | On N+ game loss streak | Min streak length |
is_ranked | Team ranked top 25 | No |
is_ranked_opponent | Opponent ranked top 25 | No |
is_ranked_matchup | Both teams top 25 | No |
is_top_ten_matchup | Both teams top 10 | No |
is_conference_game | Same conference (college) | No |
is_playoff | Playoff game | No |
is_preseason | Preseason game | No |
is_national_broadcast | National TV (ABC, ESPN, NBC, etc.) | No |
has_odds | Betting odds available | No |
opponent_name_contains | Opponent name includes string | Search string |
is_knockout | KO/TKO finish (MMA) | No |
is_submission | Submission finish (MMA) | No |
is_decision | Decision (MMA) | No |
is_finish | Any finish (KO/TKO/sub) | No |
went_distance | Went all rounds (MMA) | No |
Conditional Description Selection
Templates can define multiple descriptions with conditions and priorities:
[
{"condition": "win_streak", "condition_value": "5", "priority": 10,
"template": "{team_name} riding a {win_streak}-game win streak!"},
{"condition": "is_playoff", "priority": 20,
"template": "Playoff {sport}: {team_name} vs {opponent}"},
{"priority": 100,
"template": "{team_name} vs {opponent}"}
]
The selector evaluates conditions by priority (lowest first). If multiple match at the same priority, one is chosen randomly. The selected template is then passed through variable resolution.
Template Context
The context builder assembles a TemplateContext from events and team data:
| Component | Contents | Variables |
|---|---|---|
TeamChannelContext | Team identity (name, league, sport, logo) | BASE_ONLY vars |
GameContext (current) | Current/next event, home/away, opponent, odds | ALL + BASE_NEXT_ONLY vars |
GameContext (.next) | Next scheduled game | .next suffix vars |
GameContext (.last) | Last completed game | .last suffix vars |
TeamStats | Season record, standings, streak | ALL vars |
Three Parallel Resolution Paths
Template resolution happens in three places that must stay in sync:
| Path | Purpose | File |
|---|---|---|
| Channel creation | New channel name, tvg_id, logo | lifecycle/creator.py |
| Channel sync | Update existing channel | lifecycle/syncer.py |
| EPG generation | XMLTV programme content | consumers/event_epg.py |
When adding new template variables, all three paths must be updated.
Art URL Reconstruction (game-thumbs base URL)
Art/icon fields (program_art_url, event_channel_logo_url, and filler art_url) can store relative paths (e.g. /{league_id}/{away_team_pascal}/{home_team_pascal}/cover.png). A single configured base URL (settings.art_base_url, set in EPG → Output → Game Thumbs) is prefixed onto them at resolution time so the deployment-specific host:port lives in one place. See Game Thumbs and the Gracenote-modeled template design.
The reconstruction is centralized so it reaches every consumer identically:
| Piece | Role |
|---|---|
utilities/art_url.py → apply_art_base_url(value, base) | the single join helper — prefixes the base onto relative values; absolute URLs (scheme://…) pass through unchanged; idempotent |
TemplateResolver.resolve_art(template, ctx) | the one art entry point — resolve() then apply_art_base_url(); art_base_url injected via the resolver constructor |
utilities/art_url.py → read_art_base_url(db_factory) | reads the setting once; processors inject it into each resolver |
Every art sink calls resolve_art (or the shared helper): EPG programme <icon> and channel <icon> (event/team EPG + xmltv.py as an idempotent safety net), Dispatcharr channel logos (lifecycle/naming._resolve_logo_url), and fillers. This guarantees the EPG icon and the Dispatcharr channel logo never diverge.
Migrations: v75 deduces the most-frequent art origin from existing templates and relativizes them; v76 normalizes relative paths to a leading slash; create_template/update_template keep new art relative on write.
Sample Data & Live Preview
The variable picker previews each {variable} against sample values. Two sources, with live preferred by default:
Static sample — three shapes. Every league resolves (by sport, via resolve_shape) to one of three generic, fictitious shapes rather than a per-league profile:
| Shape | Sport(s) | Sample identity |
|---|---|---|
team | all team sports (incl. soccer) | Greenwich Mean Time @ Flint Tropics |
combat | boxing, MMA | Little Mac vs Super Macho Man (WVBA) |
racing | motorsport | Ricky Bobby / Lightning McQueen (Piston Cup) |
Each shape is a kitchen-sink: every variable that applies to it is filled (the team shape carries both pro and college fields so either template type previews fully). Identities are invented on purpose, so a sample never looks like a real (and likely wrong-league) event — a regression test guards against any real franchise/RSN leaking in.
Live preview. When live is on, the picker fetches a real recent/upcoming event (get_sample_event, provider-aware, cached) and shows its actual values. A variable the real event can’t fill is surfaced as a gap — left empty and counted — rather than masked with the fictitious sample, so users don’t get a false sense of availability. Gaps are scoped to categories relevant to the event’s shape (a basketball preview doesn’t flag empty combat/racing variables), and the picker shows live coverage (live_populated/live_total). Any failure (no event, provider down) falls back silently to the static sample.
Combat leagues follow the same finished-first cascade (#260): candidates come from a ±7-day UFC scoreboard range (cards are ~weekly, so a today-only scan is empty most of the week), and when no card is in range a deep lookback walks 35-day windows for the last finished card — the only sample that fills fight_result/finish_*. Failing all of that, the static WVBA shape renders, so game-thumbs URL slugs stay testable year-round.
See GET /variables/samples (live, gaps, live_populated, live_total).
Server-side render (POST /templates/preview, #357). The editor’s rendered previews come from the backend, not client-side substitution: the endpoint runs the SAME TemplateResolver (substitution, empty-artifact cleanup, article capitalization) and conditional selector that EPG generation uses, against the same live event context as /variables/samples (shared cache in templates/preview.py), falling back to static samples when no live event is available. The response also carries a condition trace — per row: matched, selected, and a human-readable reason — so the editor can show which conditional-description row fires for the preview event and why. The frontend keeps a client-side substitution (createResolver) only as an instant optimistic layer while the debounced server render is in flight.
File Locations
| File | Purpose |
|---|---|
templates/resolver.py | Variable resolution pipeline |
templates/conditions.py | 33 condition evaluators |
templates/context.py | Context dataclasses (Odds, GameContext, TemplateContext) |
templates/context_builder.py | Build TemplateContext from Event + Team |
templates/variables/ | 20 category modules with 240 variable definitions |
templates/variables/registry.py | VariableRegistry singleton |
templates/sample_data.py | 3-shape fictitious sample values + resolve_shape for UI preview |
templates/preview.py | Live-context builder + cache shared by /variables/samples and /templates/preview |
utilities/art_url.py | Game-thumbs base URL join helper + reader (apply_art_base_url, read_art_base_url) |
utilities/xmltv.py | XMLTV serialization (applies art base as an idempotent safety net) |