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
208 changes: 208 additions & 0 deletions plugins/channel-mapparr/group_scope.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
"""Pure, Django-free channel-group scope resolution.

Lives outside plugin.py so the whole include/exclude behaviour table can be
unit-tested with zero mocks. plugin.py supplies the ORM rows and formats the
returns; every rule lives here.

The contract is section 4 of
docs/superpowers/specs/2026-07-26-ignore-groups-design.md.
"""
import re
from dataclasses import dataclass

try:
from .wildcard_match import expand_patterns
except ImportError: # loaded standalone (tests, or a non-package path)
from wildcard_match import expand_patterns

_SPLIT = re.compile(r'[,\n]+')


class GroupScopeError(Exception):
"""The configured scope cannot be honoured; the action must refuse to run.

Fail-closed is right here because the scope is the operator's PRIMARY input,
not a defence-in-depth backstop: "I could not resolve your exclusion" must
never authorize touching the channels it was meant to protect.
"""


@dataclass(frozen=True)
class GroupScope:
"""The resolved include/exclude outcome for one action run.

ignored_names lists every group name matched by the ignore patterns
ANYWHERE, which is a SUPERSET of out_of_scope_names (the subset that was
already outside the include scope, so removing them changed nothing).
A consumer that sums the two counts, or reports len(ignored_names) as
"groups excluded from this run", will over-count.
"""
group_ids: frozenset[int]
include_ungrouped: bool
ignored_names: tuple[str, ...] = ()
out_of_scope_names: tuple[str, ...] = ()
info: str = ""


def parse_tokens(raw):
"""Split a comma/newline separated setting into non-empty stripped tokens.

Empty tokens are dropped BEFORE any emptiness test, so a stray comma reads
as "no list" rather than "a list that matched nothing" - which, under
fail-closed resolution, would hard-error every action.
"""
if not raw:
return []
return [tok.strip() for tok in _SPLIT.split(raw) if tok.strip()]


def is_ignored_name(name, ignore_value):
"""True if a group name the plugin is about to CREATE or write into is ignored.

The scope filters channels out of a scan; this is the other direction -
nothing should create or adopt a group the operator declared untouchable.
"""
return is_ignored_name_tokens(name, parse_tokens(ignore_value))


def is_ignored_name_tokens(name, tokens):
"""Same check as is_ignored_name, but takes ALREADY-PARSED tokens.

Lets a caller that tests many names against one setting (e.g. a per-channel
Organize loop) parse_tokens() once outside the loop instead of re-parsing
the raw setting string on every iteration.
"""
if not name:
return False
if not isinstance(name, str):
return False
if not tokens:
return False
matched, _ = expand_patterns(tokens, [name], ci_plain=True)
return bool(matched)


def build_name_to_ids(rows):
"""Map group name -> SET of ids.

A set, not a scalar: Dispatcharr permits two groups with the same name, and
a scalar map silently drops one of them - leaving it unprotected by an
exclusion that names it.
"""
mapping = {}
for row in rows:
name, gid = row.get('name'), row.get('id')
if name is None or gid is None:
continue
mapping.setdefault(name, set()).add(gid)
return mapping


def resolve_group_scope(include_value, ignore_value, group_name_to_ids, *, include_label):
"""Resolve the include filter, then subtract the exclusion.

Returns a GroupScope whose group_ids is always explicit. Raises
GroupScopeError for every refusal case in the spec's section 4 table.
"""
include_tokens = parse_tokens(include_value)
ignore_tokens = parse_tokens(ignore_value)

# --- include ---------------------------------------------------------
if include_tokens:
# Exact, case-sensitive: unchanged from the pre-existing behaviour.
missing = [t for t in include_tokens if t not in group_name_to_ids]
target = set()
for tok in include_tokens:
target |= group_name_to_ids.get(tok, set())
if not target:
raise GroupScopeError(
f"None of the groups named in '{include_label}' could be found: "
f"{', '.join(missing)}"
)
include_ungrouped = False
else:
target = set()
for ids in group_name_to_ids.values():
target |= ids
include_ungrouped = True

# --- exclude ---------------------------------------------------------
ignored_names, out_of_scope = (), ()
if ignore_tokens:
if not group_name_to_ids:
raise GroupScopeError(
"'Channel Groups to Ignore' is set, but Dispatcharr has no "
"channel groups to match it against."
)
matched, unmatched = expand_patterns(
ignore_tokens, list(group_name_to_ids), ci_plain=True)
if unmatched:
raise GroupScopeError(
f"These entries in 'Channel Groups to Ignore' match no channel "
f"group: {', '.join(unmatched)}. Check the spelling, or use a "
f"wildcard (a group name containing a comma cannot be written "
f"literally, because the setting splits on commas)."
)
ignored_ids = set()
for name in matched:
ignored_ids |= group_name_to_ids[name]
ignored_names = tuple(matched)
# A real group that the include filter had already excluded: a no-op,
# NOT a typo. Reported, never fatal.
out_of_scope = tuple(
n for n in matched if not (group_name_to_ids[n] & target))
target -= ignored_ids
if not target:
raise GroupScopeError(
f"'Channel Groups to Ignore' excluded every group that "
f"'{include_label}' selected, so there is nothing left to "
f"process. Narrow the exclusion or widen the selection."
)

return GroupScope(
group_ids=frozenset(target),
include_ungrouped=include_ungrouped,
ignored_names=ignored_names,
out_of_scope_names=out_of_scope,
info=_describe(include_tokens, ignored_names, include_label),
)


def split_rows_by_ignore(rows, ignore_value, *, group_key='channel_group'):
"""Partition persisted result rows into (kept, dropped) by group NAME.

The rename/tag actions replay a results file and never fetch channels, so
the exclusion has to be applied here too. Matching on the stored NAME rather
than an id means a stale file is still filtered after the group has been
renamed or deleted.

Deliberately does not refuse on an unmatched token: the results file may
legitimately contain no rows from a named group, and refusing a rename for
a group absent from *this file* would be wrong. The typo case is already
caught at scan time by resolve_group_scope.
"""
rows = list(rows)
tokens = parse_tokens(ignore_value)
if not tokens:
return rows, []

present = sorted({r.get(group_key) for r in rows if r.get(group_key)})
matched, _ = expand_patterns(tokens, present, ci_plain=True)
ignored = set(matched)

kept, dropped = [], []
for row in rows:
(dropped if row.get(group_key) in ignored else kept).append(row)
return kept, dropped


def _describe(include_tokens, ignored_names, include_label):
parts = []
if include_tokens:
parts.append(f"{include_label}: {', '.join(include_tokens)}")
else:
parts.append(f"{include_label}: all groups")
if ignored_names:
parts.append(f"ignoring {len(ignored_names)} group(s): "
f"{', '.join(ignored_names)}")
return "; ".join(parts)
53 changes: 47 additions & 6 deletions plugins/channel-mapparr/matching_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,15 @@ def normalize_name(self, name, user_ignored_tags=None, ignore_quality=True, igno

original_name = name

# Strip zero-width / invisible Unicode format characters (category Cf: ZERO WIDTH
# SPACE U+200B, joiners U+200C/D, word joiner U+2060, BOM U+FEFF, soft hyphen
# U+00AD, bidi marks). Some IPTV providers pad names with these around a
# decorative block glyph (e.g. "UK <ZWSP>|<ZWSP>BBC 1"); they are invisible
# padding that \s does not match and _DECORATOR_CATS does not cover, so they
# would otherwise survive the whole pipeline and poison the match. Removed, not
# spaced, since they are zero-width (a ZWSP inside "BB<ZWSP>C" -> "BBC", not "BB C").
name = ''.join(c for c in name if unicodedata.category(c) != 'Cf')

name = _LEADING_BAR_TAG_RE.sub('', name) # leading "┃CANAL+┃" bouquet tag

# Map emoji-as-letters (⚽ = 'o' in "SP⚽RTS") and strip emoji decoration, before
Expand Down Expand Up @@ -356,8 +365,18 @@ def normalize_name(self, name, user_ignored_tags=None, ignore_quality=True, igno
# "SPoRTS" to "3840P" and break the word-boundary anchor.
for pattern in RESOLUTION_PATTERNS:
name = re.sub(pattern, '', name, flags=re.IGNORECASE)
# Replace with a SPACE, not '' (bug-126). Every QUALITY_PATTERN also
# consumes the whitespace flanking the tag, so deleting the match glues
# the tag's neighbours together whenever a token follows it:
# "SKY NEWS FHD rec" -> "SKY NEWSrec", "CNN [HD] USA" -> "CNNUSA". A
# glued token is also unreachable by a user ignore tag (\brec\b finds no
# boundary inside "NEWSrec"), so the custom-tag escape hatch silently did
# nothing. Tags at the start/end just leave an edge space, which the
# whitespace cleanup at the end of this method strips. The loop still
# terminates: a match always removes >=2 tag chars and adds at most one
# space, so each pass strictly shortens the name.
for pattern in QUALITY_PATTERNS:
name = re.sub(pattern, '', name, flags=re.IGNORECASE)
name = re.sub(pattern, ' ', name, flags=re.IGNORECASE)

# Normalize spacing around numbers
name = re.sub(r'([a-zA-Z])(\d)', r'\1 \2', name)
Expand Down Expand Up @@ -702,9 +721,21 @@ def _trailing_number(name):
'KIND', 'KING', 'KINGS', 'KISS', 'KITE', 'KNEE', 'KNEW', 'KNOW', 'KNOWN',
})

# OTA branding context that immediately follows a station callsign: a channel
# number (optionally prefixed by a broadcast suffix), e.g. "KING 5", "WAVE 3",
# "WOOD TV8", "WHO 13". Used to rescue a denylisted common-word callsign in a
# loose position WITHOUT also rescuing bare program words ("King of the Hill",
# "Doctor Who"), which never carry this trailing number. bug-098.
_OTA_NUMBER_CONTEXT = re.compile(r'^\s+(?:TV|DT|CD|LP|LD)?\s*\d{1,3}\b', re.IGNORECASE)

def _is_callsign_allowed(self, callsign):
"""A candidate callsign is allowed if it is not denylisted, OR the plugin
supplied a known-real callsign set that contains it (DB rescue)."""
supplied a known-real callsign set that contains it (DB rescue).

NOTE: this full rescue is used only at the PARENTHESIZED priorities (1/1b),
where the parentheses are an unambiguous OTA signal. The end-of-name and
loose priorities deliberately do NOT use it for denylisted words -- see
bug-098 hardening in _compute_callsign_with_confidence."""
return (callsign not in self._CALLSIGN_DENYLIST
or (self._known_callsigns is not None and callsign in self._known_callsigns))

Expand Down Expand Up @@ -746,18 +777,28 @@ def _compute_callsign_with_confidence(self, channel_name):
if paren_suffix_match:
return paren_suffix_match.group(1).upper(), True

# Priority 3: Callsigns at the end
# Priority 3: Callsigns at the end. A denylisted common word at the end
# ("WOLF KING", "Doctor Who") is NOT rescued here -- end position alone is
# too weak a signal for a word that is also a real callsign. Non-denylisted
# callsigns (and suffixed forms like "KING-TV") still match. bug-098.
end_match = re.search(r'\b([KW][A-Z]{2,4}(?:-(?:TV|CD|LP|DT|LD))?)\s*(?:\.[a-z]+)?\s*$', channel_name, re.IGNORECASE)
if end_match:
callsign = end_match.group(1).upper()
if self._is_callsign_allowed(callsign):
if callsign not in self._CALLSIGN_DENYLIST:
return callsign, True

# Priority 4: Any word matching callsign pattern (low confidence)
# Priority 4: Any word matching callsign pattern (low confidence). A
# denylisted common word is rescued here ONLY in OTA branding context --
# immediately followed by a channel number ("KING 5", "WAVE 3", "WOOD
# TV8", "WHO 13") -- never as a bare program word ("King of the Hill",
# "Doctor Who", "Will Ferrell"). bug-098.
word_match = re.search(r'\b([KW][A-Z]{2,4}(?:-(?:TV|CD|LP|DT|LD))?)\b', channel_name, re.IGNORECASE)
if word_match:
callsign = word_match.group(1).upper()
if self._is_callsign_allowed(callsign):
if callsign not in self._CALLSIGN_DENYLIST:
return callsign, False
if (self._known_callsigns is not None and callsign in self._known_callsigns
and self._OTA_NUMBER_CONTEXT.match(channel_name[word_match.end():])):
return callsign, False

return None, False
Expand Down
Loading
Loading