Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .beads/interactions.jsonl
Original file line number Diff line number Diff line change
Expand Up @@ -979,3 +979,4 @@
{"id":"int-543d6daae23c869734e3476978a94fb7","kind":"field_change","created_at":"2026-09-18T03:32:32.214406398Z","actor":"luke","issue_id":"teamarr-9yg4","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Replaced the expired fixed date with a window-relative trusted stream date; audited matching tests for comparable calendar coupling."}}
{"id":"int-8c07dda1ddd1cabe86b73bc8acf24d84","kind":"field_change","created_at":"2026-09-18T03:41:46.985536441Z","actor":"luke","issue_id":"teamarr-ifqc","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Fixed colon-form NFL schedule prefix parsing; regression test and exact fixture-match reproduction pass."}}
{"id":"int-ed0574732b9ae1d8b3360da1014ba17a","kind":"field_change","created_at":"2026-09-19T17:28:28.067773275Z","actor":"luke","issue_id":"teamarr-1i0n","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented orientation-aware tie-breaking with regression coverage; PR pending review."}}
{"id":"int-04c3e31b14db500af9b552c627ca0e08","kind":"field_change","created_at":"2026-09-19T18:39:25.953893355Z","actor":"luke","issue_id":"teamarr-ecug","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented per-league feed separation overrides in PR #TBD."}}
2 changes: 2 additions & 0 deletions frontend/src/api/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -660,6 +660,7 @@ export interface SubscriptionLeagueConfig {
channel_group_mode: string | null
matchup_order: string | null // "auto" | "away_first" | "home_first"; null = global setting
included_divisions: string[] | null // #811: null = every division ESPN files under the league
feed_separation_enabled: boolean | null // #862: null = global setting
}

export interface LeagueDivision {
Expand Down Expand Up @@ -689,6 +690,7 @@ export async function upsertLeagueConfig(
channel_group_mode?: string | null
matchup_order?: string | null
included_divisions?: string[] | null
feed_separation_enabled?: boolean | null
}
): Promise<SubscriptionLeagueConfig> {
return api.put(`/league-configs/${encodeURIComponent(leagueCode)}`, data)
Expand Down
43 changes: 41 additions & 2 deletions frontend/src/components/PerLeagueChannelConfig.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,13 +55,15 @@ function LeagueConfigRow({
channel_group_mode?: string | null
matchup_order?: string | null
included_divisions?: string[] | null
feed_separation_enabled?: boolean | null
}) => Promise<void>
onClear: () => Promise<void>
}) {
const [localProfileIds, setLocalProfileIds] = useState<(number | string)[]>([])
const [localGroupId, setLocalGroupId] = useState<number | null>(null)
const [localGroupMode, setLocalGroupMode] = useState<string | null>(null)
const [localMatchupOrder, setLocalMatchupOrder] = useState<string | null>(null)
const [localFeedSeparationEnabled, setLocalFeedSeparationEnabled] = useState<boolean | null>(null)
// #811: the divisions still ingested. Held as the checked set (null config =
// all of them), so the UI never has to special-case "no override".
const [localDivisions, setLocalDivisions] = useState<string[]>([])
Expand All @@ -84,12 +86,14 @@ function LeagueConfigRow({
setLocalGroupId(config.channel_group_id)
setLocalGroupMode(config.channel_group_mode)
setLocalMatchupOrder(config.matchup_order ?? null)
setLocalFeedSeparationEnabled(config.feed_separation_enabled)
setLocalDivisions(config.included_divisions ?? divisions.map((d) => d.key))
} else if (isExpanded && !config) {
setLocalProfileIds([])
setLocalGroupId(null)
setLocalGroupMode(null)
setLocalMatchupOrder(null)
setLocalFeedSeparationEnabled(null)
setLocalDivisions(divisions.map((d) => d.key))
}
}
Expand Down Expand Up @@ -134,6 +138,7 @@ function LeagueConfigRow({
channel_group_id: localGroupId,
channel_group_mode: localGroupMode,
matchup_order: localMatchupOrder,
feed_separation_enabled: localFeedSeparationEnabled,
// All of them is stored as "no override" so the selection still means
// everything if ESPN's slate gains a division later.
included_divisions:
Expand Down Expand Up @@ -178,6 +183,13 @@ function LeagueConfigRow({
{matchupOrderSummary}
</span>
</td>
<td className="px-3 py-1.5">
<span className={cn("text-xs", !hasOverride && "text-muted-foreground")}>
{config?.feed_separation_enabled === null || config?.feed_separation_enabled === undefined
? "Default"
: config.feed_separation_enabled ? "Enabled" : "Disabled"}
</span>
</td>
<td className="px-3 py-1.5 text-right">
{hasOverride && (
<Button
Expand All @@ -197,7 +209,7 @@ function LeagueConfigRow({
</tr>
{isExpanded && (
<tr>
<td colSpan={8} className="px-4 py-3 bg-muted/20 border-t-0">
<td colSpan={9} className="px-4 py-3 bg-muted/20 border-t-0">
<div className="space-y-4 max-w-2xl">
{/* Channel Profiles */}
<div>
Expand Down Expand Up @@ -327,6 +339,32 @@ function LeagueConfigRow({
</div>
)}

{/* Feed separation (#862) */}
<div>
<Label className="text-sm font-medium">Feed Separation</Label>
<p className="text-xs text-muted-foreground mb-2">
Choose whether HOME/AWAY feeds create separate channels for this league.
Default inherits the global setting, which remains the master toggle.
</p>
<Select
value={
localFeedSeparationEnabled === null
? ""
: localFeedSeparationEnabled ? "enabled" : "disabled"
}
onChange={(e) =>
setLocalFeedSeparationEnabled(
e.target.value === "" ? null : e.target.value === "enabled"
)
}
className="w-64"
>
<option value="">Default (inherit)</option>
<option value="enabled">Enabled</option>
<option value="disabled">Disabled</option>
</Select>
</div>

{/* Matchup Order (#692) */}
<div>
<Label className="text-sm font-medium">Matchup Order</Label>
Expand Down Expand Up @@ -486,6 +524,7 @@ export function PerLeagueChannelConfig() {
<th className="px-3 py-2 text-left font-medium">Channel Group</th>
<th className="px-3 py-2 text-left font-medium">Group Mode</th>
<th className="px-3 py-2 text-left font-medium">Matchup Order</th>
<th className="px-3 py-2 text-left font-medium">Feed Separation</th>
<th className="px-3 py-2 text-right font-medium w-16"></th>
</tr>
</thead>
Expand Down Expand Up @@ -537,7 +576,7 @@ export function PerLeagueChannelConfig() {
})}
{filteredLeagues.length === 0 && (
<tr>
<td colSpan={7} className="px-3 py-4 text-center text-muted-foreground">
<td colSpan={9} className="px-3 py-4 text-center text-muted-foreground">
{leagueSearch ? "No leagues match your search" : "No subscribed leagues"}
</td>
</tr>
Expand Down
5 changes: 5 additions & 0 deletions teamarr/api/routes/subscription.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ class LeagueConfigResponse(BaseModel):
channel_group_mode: str | None = None
matchup_order: str | None = None # #692: None = global setting
included_divisions: list[str] | None = None # #811: None = every division
feed_separation_enabled: bool | None = None # #862: None = global setting


class LeagueConfigUpdate(BaseModel):
Expand All @@ -105,6 +106,7 @@ class LeagueConfigUpdate(BaseModel):
channel_group_mode: str | None = None
matchup_order: str | None = None # #692: 'auto' | 'away_first' | 'home_first'
included_divisions: list[str] | None = None # #811: None = every division
feed_separation_enabled: bool | None = None # #862: None = global setting


class LeagueDivision(BaseModel):
Expand Down Expand Up @@ -376,6 +378,7 @@ def list_league_configs():
channel_group_mode=c.channel_group_mode,
matchup_order=c.matchup_order,
included_divisions=c.included_divisions,
feed_separation_enabled=c.feed_separation_enabled,
)
for c in configs
],
Expand Down Expand Up @@ -405,6 +408,7 @@ def upsert_league_config_endpoint(league_code: str, request: LeagueConfigUpdate)
channel_group_mode=request.channel_group_mode,
matchup_order=request.matchup_order,
included_divisions=included_divisions,
feed_separation_enabled=request.feed_separation_enabled,
)
set_league_matchup_order(league_code, config.matchup_order) # render-time cache (#692)

Expand All @@ -415,6 +419,7 @@ def upsert_league_config_endpoint(league_code: str, request: LeagueConfigUpdate)
channel_group_mode=config.channel_group_mode,
matchup_order=config.matchup_order,
included_divisions=config.included_divisions,
feed_separation_enabled=config.feed_separation_enabled,
)


Expand Down
17 changes: 14 additions & 3 deletions teamarr/consumers/event_group_processor/matching.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,12 @@
logger = logging.getLogger(__name__)


def _separation_applies(event: Any, separation_sports: list[str] | None) -> bool:
"""Whether feed separation splits channels for this event's sport (#732).
def _separation_applies(
event: Any,
separation_sports: list[str] | None,
disabled_leagues: set[str] | None = None,
) -> bool:
"""Whether feed separation splits channels for this event (#732, #862).

An empty or missing list means every sport — separation was global before
#732, so that is both the upgrade default and the "no opinion" answer.
Expand All @@ -42,6 +46,8 @@ def _separation_applies(event: Any, separation_sports: list[str] | None) -> bool
has nothing to say, and the master toggle stands. Excluding here would
silently un-split channels whenever a provider omitted the field.
"""
if getattr(event, "league", None) in (disabled_leagues or set()):
return False
if not separation_sports:
return True
sport = getattr(event, "sport", None)
Expand Down Expand Up @@ -536,6 +542,7 @@ def _resolve_feed_teams(
detect_team_names: bool,
separation_enabled: bool,
separation_sports: list[str] | None = None,
disabled_leagues: set[str] | None = None,
) -> list[dict]:
"""Resolve feed hints to actual teams (Phase 2 feed separation).

Expand Down Expand Up @@ -571,6 +578,8 @@ def _resolve_feed_teams(
None means every sport — the pre-#732 behavior, and what
existing installs upgrade to. Narrows the master toggle only;
it can never turn separation on where the toggle is off.
disabled_leagues: League codes whose per-league override disables
separation (#862).
"""
for entry in matched_streams:
event = entry.get("event")
Expand Down Expand Up @@ -610,7 +619,9 @@ def _resolve_feed_teams(
source = "team_name_detect"
break

splits = separation_enabled and _separation_applies(event, separation_sports)
splits = separation_enabled and _separation_applies(
event, separation_sports, disabled_leagues
)

entry["stream_feed_team"] = feed_team
entry["feed_team"] = feed_team if splits else None
Expand Down
14 changes: 13 additions & 1 deletion teamarr/consumers/event_group_processor/processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -710,11 +710,19 @@ def _mark(phase: str) -> None:
# identification persists per-stream for team_feed ordering rules;
# feed_separation.enabled gates only channel splitting.
feed_settings = get_feed_separation_settings(conn)
from teamarr.database.subscription import get_league_configs

feed_separation_disabled_leagues = {
config.league_code
for config in get_league_configs(conn)
if config.feed_separation_enabled is False
}
matched_streams = self._resolve_feed_teams(
matched_streams,
feed_settings.detect_team_names,
feed_settings.enabled,
feed_settings.sports,
feed_separation_disabled_leagues,
)

# Sort channels: sport → league → time → event_id (fixed order since v59)
Expand Down Expand Up @@ -771,7 +779,11 @@ def _effective_event_id(m) -> str | None:
eid
for m in matched_streams
if (eid := _effective_event_id(m))
and _separation_applies(m.get("event"), feed_settings.sports)
and _separation_applies(
m.get("event"),
feed_settings.sports,
feed_separation_disabled_leagues,
)
}
if feed_settings.enabled
else set()
Expand Down
2 changes: 2 additions & 0 deletions teamarr/database/migrations/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
_migrate_v87_cfl_team_selections,
_migrate_v92_retire_tsdb_tier,
_migrate_v96_hockeytech_bellmedia,
_migrate_v97_league_feed_separation,
_recover_schema_version_from_v65_backup_if_needed,
_run_migrations,
_table_exists,
Expand Down Expand Up @@ -93,6 +94,7 @@
"_migrate_v87_cfl_team_selections",
"_migrate_v92_retire_tsdb_tier",
"_migrate_v96_hockeytech_bellmedia",
"_migrate_v97_league_feed_separation",
"_recover_schema_version_from_v65_backup_if_needed",
"_rename_league_id_column_if_needed",
"_run_migrations",
Expand Down
7 changes: 7 additions & 0 deletions teamarr/database/migrations/versioned.py
Original file line number Diff line number Diff line change
Expand Up @@ -2761,6 +2761,13 @@ def _migrate_v92_retire_tsdb_tier(conn: sqlite3.Connection) -> None:
logger.info("[MIGRATE] v92: cleared tsdb_tier on %d league row(s)", cleared)


def _migrate_v97_league_feed_separation(conn: sqlite3.Connection) -> None:
"""Add the nullable per-league feed separation override (#862)."""
_add_column_if_not_exists(
conn, "subscription_league_config", "feed_separation_enabled", "BOOLEAN DEFAULT NULL"
)


def _migrate_v95_team_channel_defaults(conn: sqlite3.Connection) -> None:
"""Seed the managed-channel name/logo defaults on existing team templates.

Expand Down
4 changes: 3 additions & 1 deletion teamarr/database/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -814,7 +814,9 @@ CREATE TABLE IF NOT EXISTS subscription_league_config (
-- NCAA divisions this league still ingests (#811): NULL = every division
-- ESPN files under the league, otherwise a JSON list of division keys
-- (see COLLEGE_SCOREBOARD_DIVISIONS). A dropped division is never fetched.
included_divisions JSON DEFAULT NULL
included_divisions JSON DEFAULT NULL,
-- Feed separation override (#862): NULL = use global setting
feed_separation_enabled BOOLEAN DEFAULT NULL
);


Expand Down
17 changes: 14 additions & 3 deletions teamarr/database/subscription.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ class SubscriptionLeagueConfig:
# #811: NCAA divisions this league ingests. None = every division ESPN
# files under it (the default); otherwise the kept division keys.
included_divisions: list[str] | None = None
# #862: None inherits the global feed separation setting.
feed_separation_enabled: bool | None = None


@dataclass
Expand Down Expand Up @@ -499,19 +501,21 @@ def upsert_league_config(
channel_group_mode: str | None = None,
matchup_order: str | None = None,
included_divisions: list[str] | None = None,
feed_separation_enabled: bool | None = None,
) -> SubscriptionLeagueConfig:
"""Create or update per-league config. Returns the saved config."""
conn.execute(
"""INSERT INTO subscription_league_config
(league_code, channel_profile_ids, channel_group_id,
channel_group_mode, matchup_order, included_divisions)
VALUES (?, ?, ?, ?, ?, ?)
channel_group_mode, matchup_order, included_divisions, feed_separation_enabled)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(league_code) DO UPDATE SET
channel_profile_ids = excluded.channel_profile_ids,
channel_group_id = excluded.channel_group_id,
channel_group_mode = excluded.channel_group_mode,
matchup_order = excluded.matchup_order,
included_divisions = excluded.included_divisions
included_divisions = excluded.included_divisions,
feed_separation_enabled = excluded.feed_separation_enabled
""",
(
league_code,
Expand All @@ -522,6 +526,7 @@ def upsert_league_config(
channel_group_mode,
matchup_order,
json.dumps(included_divisions) if included_divisions is not None else None,
feed_separation_enabled,
),
)
logger.info("[LEAGUE_CONFIG] Upserted config for %s", league_code)
Expand Down Expand Up @@ -564,4 +569,10 @@ def _build_league_config(row) -> SubscriptionLeagueConfig:
channel_group_mode=row["channel_group_mode"],
matchup_order=row["matchup_order"] if "matchup_order" in row.keys() else None,
included_divisions=divisions,
feed_separation_enabled=(
bool(row["feed_separation_enabled"])
if "feed_separation_enabled" in row.keys()
and row["feed_separation_enabled"] is not None
else None
),
)
19 changes: 19 additions & 0 deletions tests/migrations/test_migration_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,7 @@ def test_relaxes_check_constraint_allowing_custom_pattern(self, db):
"INSERT INTO subscription_league_config (league_code, channel_group_mode) "
"VALUES ('eng.1', '{sport} | {league}')"
)

row = db.execute(
"SELECT channel_group_mode FROM subscription_league_config WHERE league_code='eng.1'"
).fetchone()
Expand Down Expand Up @@ -309,6 +310,24 @@ def test_idempotent(self, db):
assert "default_channel_group_id" in cols


# =============================================================================
# v97: per-league feed separation override
# =============================================================================


class TestV97LeagueFeedSeparation:
def test_adds_nullable_feed_separation_override(self, db):
from teamarr.database.migrations import (
_migrate_v61_subscription_league_config,
_migrate_v97_league_feed_separation,
)

_migrate_v61_subscription_league_config(db)
_migrate_v97_league_feed_separation(db)
columns = {row[1] for row in db.execute("PRAGMA table_info(subscription_league_config)")}
assert "feed_separation_enabled" in columns


# =============================================================================
# v66: TSDB tiered provider model
# =============================================================================
Expand Down
7 changes: 7 additions & 0 deletions tests/test_feed_separation.py
Original file line number Diff line number Diff line change
Expand Up @@ -1413,6 +1413,13 @@ def test_unlisted_sport_does_not_apply(self):

assert _separation_applies(self._event("racing"), ["baseball"]) is False

def test_disabled_league_does_not_apply(self):
from teamarr.consumers.event_group_processor.matching import _separation_applies

event = self._event("baseball")
event.league = "mlb"
assert _separation_applies(event, [], {"mlb"}) is False

def test_event_without_a_sport_is_never_excluded(self):
# The list can only speak about sports; not knowing the sport means it
# has nothing to say, so the master toggle stands. Excluding here would
Expand Down
Loading
Loading