Channel Numbering

How Teamarr assigns channel numbers: the global sort, pinned blocks (numbering exceptions, #333), the stability modes that govern renumbering across runs, and the migration that retired manual mode.

Code: teamarr/database/channel_numbers.py (allocators), teamarr/database/numbering_exceptions.py (pinned blocks + lane resolution).

Model: lanes

Every managed channel is numbered inside exactly one lane. A lane is a range of numbers with its own start:

Lane Where it comes from
Pinned block One row in numbering_exceptions (a team, league or sport pin), or several rows sharing a start under one label (a group)
Default “Everything Else” in the UI — the global channel range (channel_range_start / channel_range_end)

The allocator runs the same placement code per lane — compact, gap or strict, sticky locks, daily re-layout — with the lane’s start in place of the global range start. Pinned lanes are placed first, in ascending start order, then the default lane. All lanes share one set of used numbers (plus external Dispatcharr channels), so a block that outgrows its space spills forward and later lanes skip over it with a warning rather than colliding.

With zero pinned blocks there is one lane (the default) and the allocator is byte-for-byte the pre-#333 behaviour. tests/lifecycle/test_numbering_lanes.py holds golden-equivalence tests that assert this for compact, gap and strict.

Resolution: most specific wins

LaneResolver.resolve(sport, league, home_team, away_team, season_type, *, segment, exception_keyword) returns the lane for a channel. Precedence:

  1. Team pin matching the home team
  2. Team pin matching the away team
  3. League pin
  4. Sport pin
  5. Default lane

Within one level a channel can match only one row (one league, one sport, one team per side), so there is no user-facing tie-break; (sort_order, id) remains as a deterministic fallback for legacy rows. Team matching is (sport, team_name) case-insensitive on either side of the event — the same key channel_priority_teams uses — so a team pin follows the club into cup competitions. A team pin covers both the team’s dedicated team channel and every event channel it plays in.

Season condition (#950)

A pin may name a season type (preseason, regular, postseason, offseason). It is then a candidate only for channels of that season, and at its level it outranks the unconditioned pin: “NBA postseason at 900” plus “NBA at 1700” sends playoff games to 900 and everything else to 1700. With no unconditioned pin, the other seasons fall through to the sport pin or the default lane. Scope still decides first — an unconditioned league pin beats a conditioned sport pin.

A channel whose season is unknown (NULL) satisfies no condition — the case for providers that report none. ESPN’s NBA play-in carries its own season slug (play-in-season), which the ESPN provider maps to postseason, so play-in games land in a Postseason block.

The season is stored on the channel (managed_channels.season_type, written at creation and kept current by the settings sync), because re-layout resolves lanes from the stored row and has no event to ask.

Segment and keyword conditions (#1018)

A pin may also carry segments (a list of session / card-segment codes) and a keyword condition. A row with any unsatisfied condition is not a candidate. Among the candidates, scope rank decides first. Inside one rank the winner is the first row by this fixed key, so the result never depends on insertion order or on how many conditions a row has:

  1. keyword specificity, highest first: feed:<feed_key>, then label:<label>, then driver / variant, then any, then main, then no keyword
  2. rows with segments before rows without
  3. rows with a season before rows without
  4. (sort_order, id)

A season-only row therefore resolves exactly as before. For example, a keyword=main block beats a segments=[race] block for the main race channel, and feed:driver:charles-leclerc beats label:Charles Leclerc, which beats driver, for that driver.

Segment codes must come from the known set for the block’s own sport: SESSION_ORDER for racing (fp1, fp2, fp3, sprint_qualifying, sprint, qualifying, race) and the card segments for MMA (early_prelims, prelims, main_card). main_card on an F1 block is rejected, and a sport with no segments rejects any code. A league block takes its sport from the leagues table, and a team block from the team’s team_cache row. GET /numbering-exceptions/segment-codes lists the codes by sport.

keyword values:

Value Satisfied by
main a channel with no exception keyword
any any exception-keyword channel
driver / variant a race feed of that kind in the channel’s league
feed:<feed_key> one race feed, e.g. feed:driver:charles-leclerc
label:<label> one exception keyword, matched case-insensitively

A channel whose segment is NULL satisfies no segments condition.

The segment is stored on the channel row (managed_channels.segment, the lowercase suffix of event_id) because re-layout has no event. The race feed is not stored. LaneResolver.load reads race_feeds and maps (league, feed label) to feed_key, and a channel’s exception_keyword equals its feed label, so the resolver derives the feed from the keyword. A roster refresh that adds a feed takes effect on the next re-grid. Migration v100 backfills segment for existing racing and mma rows from the -<segment> suffix of event_id, for known codes only. It is safe to re-run.

A league block whose only condition is keyword = main also takes that league’s team channels and any channel without a keyword. A racing event with no session data carries no segment, so it matches no segments block.

Example, F1 with four blocks, each a league pin: main channel for race, qualifying, sprint_qualifying, sprint at 1200 (keyword=main); driver feeds at 1300 (keyword=driver); other race feeds at 1400 (keyword=variant); an unconditioned pin at 1500 takes practice. Every row is a candidate at league rank, and the keyword rule picks the lane, so a main race channel lands at 1200 rather than 1500.

Channel group on a block (#950)

A pin may carry an output channel group — a static channel_group_id, or a pattern in channel_group_mode (09 TEAMARR {league}). A channel that resolves to that pin uses it in place of the subscription’s group (global, then per-league override). Create and sync both go through ChannelLifecycleService._pinned_block_group, one LaneResolver per batch, so a channel cannot flip groups between runs.

Resolution is deterministic from fields that never change during an event (sport, league, teams, season type), so a channel’s lane is stable for its lifetime. It changes only when the user edits pinned blocks — which arms a re-layout in sticky modes, exactly as changing the channel range does.

Block semantics

  • start is required. end is optional; when set, a block that fills up overflows into the default lane (with a warning) instead of spilling forward.
  • Feeds and keyword variants of one event stay contiguous — they resolve to the same lane and _group_consecutive places them as one block, so the existing invariant (“gap only between events”) holds inside every lane.
  • A group is just several rows with the same start and label; there is no separate table. “850 — Big events: World Cup, Olympics” is two rows.
  • A start belongs to one block. LaneResolver keys lanes on start alone, so any two rows at one start are one lane whatever their labels. Because users read “Brewers at 550 + MLB at 550” as “Brewers first in the MLB block” (it is not — the merged lane sorts in normal lineup order, which is the 2.14.0 Discord confusion), add_numbering_exception and update_numbering_exception raise StartConflict (→ HTTP 400 with a what-to-do-instead message) unless every existing row at that start shares the new row’s non-empty label (case-insensitive). Toggling enabled alone never re-validates, so rows created before this rule keep working. The reorder endpoint and UI arrows were removed with it — they never changed placement (lanes place in ascending start), only the never-exercised within-level tie-break.

Stability modes inside lanes

channel_stability_mode is global and applies inside every lane:

Mode Inside a lane
compact Re-sorted contiguously from the lane start every run
gap Sticky; new events slot into the lane’s grid (gap_size); locked channels never move except at the daily reset
strict Sticky; new events append past the lane’s frontier

The sticky allocator’s “invalid anchor” rule — a locked channel whose number is outside its range is re-placed — now uses the lane’s range, so moving a block (say Lions from 800 to 900) re-places those channels on the next run.

Global sort and priority-team scope

get_all_channels_sorted produces one ordered list that every lane is then partitioned from. The key is

(all_tier, sport_pri, sport_tier, league_pri, league_tier, event_time, event_id, keyword)

where the three *_tier values (0 = floats, 1 = normal) come from channel_priority_teams.scope — 'all' sets every tier, 'sport' sets sport_tier + league_tier, 'league' only league_tier. A team listed several times keeps its broadest scope. The column was added by reconciliation with DEFAULT 'all', so pre-scope rows keep the old float-above-everything behaviour; new rows default to 'league'. Because lanes are cut after the sort, a priority team moves only relative to channels in its own lane.

The API for the preview (GET /numbering-exceptions/preview) returns lanes sorted by start so the default lane appears where it actually sits when a block starts below or inside the range.

Creation-time allocation

get_next_channel_number(conn, league, sport, home_team, away_team, ...) resolves the lane and returns the first free number from the lane start. Full ordering happens in the end-of-run reassign_all_channels pass, as before.

Migration from manual mode (schema v88)

Before #333, global_channel_mode='manual' numbered each league sequentially from league_channel_starts[league] in global sort order — which is exactly a league-scoped pinned block in compact mode. The v88 migration therefore:

  1. Inserts one numbering_exceptions row (scope='league') per entry in league_channel_starts, preserving each start.
  2. Sets global_channel_mode='auto'.
  3. Sets channel_stability_mode='compact' for manual-mode installs (manual never had stability; compact is the only value that reproduces its numbers).
  4. Leaves league_channel_starts and the 'manual' CHECK value in place, unread, as a rollback aid for one release.

Auto-mode installs are untouched: no rows are inserted and every setting keeps its value.

One deliberate difference: the old manual reassign pass tracked each league’s counter independently and avoided only external numbers, so two leagues whose blocks overlapped could be assigned the same number. Lanes share the used set, so those installs get corrected (non-colliding) numbers after upgrading.

Storage

CREATE TABLE numbering_exceptions (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    scope TEXT NOT NULL CHECK(scope IN ('team', 'league', 'sport')),
    sport TEXT NOT NULL,            -- always set (team + league pins are sport-scoped)
    league_code TEXT,               -- scope='league'
    team_name TEXT,                 -- scope='team' (match key, case-insensitive)
    provider TEXT,                  -- scope='team' (identity, from team_cache)
    provider_team_id TEXT,          -- scope='team'
    start INTEGER NOT NULL,
    "end" INTEGER,                  -- NULL = spill forward
    label TEXT,                     -- group label (rows sharing start + label)
    sort_order INTEGER NOT NULL DEFAULT 0,
    enabled BOOLEAN DEFAULT 1,
    season_type TEXT,               -- NULL = any season (#950)
    segments TEXT,                  -- NULL = any; comma-joined segment codes (#1018)
    keyword TEXT,                   -- NULL = any; main|any|driver|variant|feed:<key>|label:<label>
    channel_group_id INTEGER,       -- output group for the block's channels (#950)
    channel_group_mode TEXT,        -- ...or a pattern, e.g. '09 TEAMARR {league}'
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

API: /api/v1/numbering-exceptions (list — ascending start —, create, update, delete, preview = effective layout of today’s channels). UI: Channels → Numbering → Pinned Blocks.


Back to top

Teamarr - Sports EPG Generator for Dispatcharr

This site uses Just the Docs, a documentation theme for Jekyll.