Skip to content

Add channel-scoped excluded aliases - #26

Draft
matrix2669 wants to merge 2 commits into
PiratesIRC:mainfrom
matrix2669:feature/excluded-aliases
Draft

Add channel-scoped excluded aliases#26
matrix2669 wants to merge 2 commits into
PiratesIRC:mainfrom
matrix2669:feature/excluded-aliases

Conversation

@matrix2669

@matrix2669 matrix2669 commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds an optional per-channel excluded_aliases field to lineup JSON files. It prevents a known false-positive stream from attaching to that channel while leaving the stream eligible for other channels.

A rejected high-scoring match can currently return through a positive alias, exact or fuzzy matching, callsign rescue, quality-aware matching, or a channel-number boost. Removing a positive alias alone does not reliably prevent that. This field gives lineup authors a durable way to record the rejected channel/stream pairing.

{
  "name": "Game Show Network",
  "number": 184,
  "aliases": ["GSN"],
  "excluded_aliases": ["Game Show Central"]
}

Game Show Central is a separate service that resembles Game Show Network closely enough to be selected incorrectly. The exclusion blocks only that false-positive pairing.

Behavior

  • Accepts either one string or a list of strings.
  • Uses the same case, spacing, punctuation, provider-prefix, ignored-tag, and quality-tag normalization as positive matching.
  • Applies before positive aliases, callsign rescue, exact, substring, fuzzy, quality-aware bypass, country/region acceptance, and channel-number boosts.
  • Remains channel-scoped; excluding a stream from one channel does not suppress it globally.
  • Treats exclusions as normalized literals. regex:, !, glob characters, and similar syntax have no executable meaning.
  • Leaves existing lineup files unchanged when excluded_aliases is absent.
  • Ignores malformed values from externally supplied lineups with a bounded warning. The committed-lineup validator rejects invalid field types and empty values.

Implementation

  • Normalizes excluded_aliases once when the lineup is loaded.
  • Passes exclusions through both Preview Stream Match and Apply Stream Match.
  • Filters excluded candidates before any positive matching path can select them.
  • Extends the committed matcher golden corpus with exclusion parsing, normalization, precedence, quality-aware bypass, callsign rescue, channel scoping, and literal regex: coverage.
  • Documents the field in the README, lineup format, and user guide.

Validation

  • python3 .github/scripts/validate_plugin.py
  • Python byte compilation for both vendored modules
  • Vendored matching-core parity
  • Vendored notification-client parity
  • Matcher golden gate: 307 outputs match the committed baseline
  • Existing matcher outputs outside the new exclusion corpus remain unchanged
  • git diff --check
  • Live Dispatcharr validation confirmed that excluded streams were not attached to their affected channels, with no LineupARR exceptions or malformed-exclusion warnings during the full sync

Scope and limitations

  • No lineup data is changed.
  • No plugin version bump is included.
  • No regular-expression or glob matching is introduced.
  • EPG matching is unchanged.
  • Exclusions do not remove or disable streams globally.

@PiratesIRC

Copy link
Copy Markdown
Owner

Thanks for this, and for the thorough write-up. The shape is right and the code
is careful, but I hit a blocking problem when I ran your two worked examples, so
I want to put the measurements in front of you before we go further.

The exclusion also removes the channel's own correct streams

Both examples in the pull request return zero matches instead of blocking one
stream.

Your description's example, Game Show Network with Game Show Central excluded:

candidates: Game Show Network, Game Show Central, GSN HD, Game Show Network HD

without excluded_aliases:
  Game Show Network      100  exact
  Game Show Network HD   100  exact
  Game Show Central      100  exact

with excluded_aliases = ["Game Show Central"]:
  (nothing)

The lineup format example, REELZ with US-ReelzChannel excluded:

candidates: REELZ, Reelz Channel, US-ReelzChannel, Reelz HD

without excluded_aliases:  REELZ, Reelz Channel, Reelz HD, US-ReelzChannel
with US-ReelzChannel excluded:  (nothing)

The cause is that _candidate_is_excluded compares normalized forms, and
normalize_name is deliberately lossy:

'Game Show Network'    -> 'Game Show'
'Game Show Central'    -> 'Game Show'
'Game Show Network HD' -> 'Game Show'

'US-ReelzChannel'      -> 'Reelz'
'Reelz Channel'        -> 'Reelz'

So the exclusion key built from Game Show Central is {'game show', 'gameshow'},
and Game Show Network normalizes into that same key.

That needs a design decision rather than a small patch. Two
names that normalize to the same string are exactly the pair a lineup author
wants to separate, because that is why the false positive happened. An exclusion
compared after normalization cannot express that distinction in the case the
feature exists for. Comparing the raw stream name instead, case-folded and
whitespace-collapsed, would make US-ReelzChannel block only that literal name,
at the cost of not catching provider-prefix variants automatically. That
trade-off is yours to pick, and I did not want to choose it for you.

Why the gates did not catch this

The golden corpus records the current behaviour as correct, so it cannot fail on
it. Every blocking case in the baseline is an empty list:

literal_blocks_positive_alias  []
normalized_variant             []
quality_bypass_blocked         []
callsign_rescue_blocked        []

Looking at the pools those use: normalized_variant, quality_bypass_blocked and
callsign_rescue_blocked each pass a single candidate, and
literal_blocks_positive_alias passes ["US-ReelzChannel", "US: Other Network HD"],
where the second name never matches REELZ anyway. In all four an empty result is
indistinguishable from the correct one.

Whatever we settle on for the comparison, the corpus needs at least one case
whose pool holds both a stream that must be blocked and a stream that must
survive, with the two normalizing alike. That is the only shape that can tell
the two behaviours apart.

One scope question

There are three match_all_streams call sites. Preview Stream Match and Apply
Stream Match both pass excluded_aliases; the third, in _do_apply_epg_match,
does not. Your documentation consistently says "stream", so I read this as
deliberate and I think it is the right call. It would be worth one explicit
sentence in the lineup format page saying exclusions do not affect guide
matching, because someone who has just used one to block a stream will
reasonably wonder about the guide entry with the same name.

What I checked and found correct

  • Filtering candidate_names cannot misalign candidate_countries, because
    that is a dict keyed by stream name rather than a list parallel to the names.
  • Malformed values are ignored without raising: an integer, a dict, None, and a
    list containing None and an empty string all behave.
  • A bare string works as well as a list.
  • The validate_plugin.py addition rejects a non-string or empty entry in a
    committed lineup.
  • Merged into current main there is no textual conflict. On the merged tree the
    full local suite passes, validate_plugin.py passes, both vendored parity
    gates hold their pinned hashes, and the matcher golden gate reports 307
    outputs, matching your number.

Note that main has moved since you branched: there are three commits from today
touching Lineuparr/plugin.py, README.md and docs/USER-GUIDE.md. The merge
is clean, so this is only a heads-up rather than a request to rebase.

Happy to take another look once you have decided how the comparison should work.

@matrix2669
matrix2669 force-pushed the feature/excluded-aliases branch from c766a6b to db4ebec Compare September 6, 2026 00:51
@matrix2669
matrix2669 marked this pull request as draft September 6, 2026 00:53
@matrix2669

Copy link
Copy Markdown
Contributor Author

Thanks for highlighting the normalization issue. I’m updating excluded_aliases to compare against full stream names before positive-match normalization, using case-insensitive matching with whitespace trimmed and collapsed. Exclusions will not use fuzzy matching.
Plain entries will require a full-name match. An explicit * wildcard will support patterns such as Game Show Central, with * available for a literal asterisk.
Exclusions remain channel-specific and do not affect EPG matching. I’m updating the documentation and adding regression tests that verify both excluded streams and legitimate matches that must remain eligible.
I’ve converted the PR to a draft while I complete testing. Once validation is complete, I’ll summarize the results before resubmitting it for review.

@PiratesIRC

Copy link
Copy Markdown
Owner

Thank you for turning this around so quickly. I have run the new commit and
both of the examples that failed last time now behave correctly. Below is
everything I measured, so you can see what I checked and where I think the
remaining gaps are. None of them are blocking.

The two worked examples

Loading Lineuparr/fuzzy_matcher.py directly from your branch and calling
match_all_streams:

Game Show Network, excluding "Game Show Central"
  Game Show Network      100  exact
  Game Show Network HD   100  exact
  (Game Show Central removed)

REELZ, excluding "US-ReelzChannel"
  REELZ           100  exact
  Reelz Channel   100  exact
  Reelz HD        100  exact
  (US-ReelzChannel removed)

Both returned an empty list before. Comparing the raw name was the right call.

Gates on the merged tree

Your branch merges onto current main with no textual conflict. On the merged
tree:

  • the full local suite passes, 1040 passed and 1 skipped, the skip being a
    pre-existing one that needs POSIX permission bits
  • validate_plugin.py passes
  • the matcher golden gate passes, reporting 307 outputs
  • both vendored parity gates hold their pinned hashes
  • no added line introduces a non-ASCII character or a carriage return

The new guard is not vacuous, and I checked that specifically

My last comment said the corpus could not tell correct blocking apart from
over-blocking, so I wanted to be sure the replacement can. I mutated the merged
tree seven times and ran both the golden gate and the local suite each time:

Mutation Result
substring containment instead of the anchored match caught
never exclude anything caught
drop case folding on the candidate name caught
drop whitespace collapsing on the candidate name caught
ignore the backslash escape, so \* acts as a wildcard caught
drop the trailing anchor from wildcard matching caught
reword one comment, as a control survived

Every one of the six real mutations was caught by check_exclusion_regressions
in check_matcher_golden.py, with an assertion naming the case. The control
survived, which is what tells me the six are real kills rather than a gate that
fails on any edit.

Worth knowing if you were wondering why none of your work went into tests/:
that directory is gitignored in this repository, so the gate scripts are the
only place a contributor can add a guard. All six kills came from the gate; the
1040-test suite killed none of them. That is expected here, not a criticism.

Validator and runtime agree on what is a valid pattern

I checked sixteen values against both the validate_plugin.py rule and
parse_excluded_aliases, including *, **, " * * ", \*, \, * a, an
empty string and a whitespace-only string. They agree on all sixteen. That
matters more than it looks: a disagreement would let a lineup pass validation
carrying an exclusion the runtime silently discards, which is invisible to the
person who wrote it.

Two small things, neither blocking

literal_blocks_positive_alias and callsign_rescue_blocked in the baseline
still record an empty list, so those two recorded entries still cannot
distinguish correct blocking from over-blocking on their own. It no longer
matters, because check_exclusion_regressions covers both shapes with pools
that hold a survivor, including the My9 New York callsign case. You may prefer
to give those two corpus entries a survivor as well, so the recorded baseline
reads correctly on its own, but the coverage is already there.

check_exclusion_regressions reaches _exclusion_parts and _matches_exclusion
through _candidate_is_excluded.__func__.__globals__, and uses bare assert
statements, which are removed under python -O. Neither affects a normal CI
run and your comment explains the first one. I mention them only so they are
not a surprise later.

Documentation

The lineup format page now says exclusions do not affect guide matching, which
was the one sentence I asked for, and it warns that patterns carry no implicit
word boundaries. The Game Show Centralization example is a good one to have
written down.

I am happy with this. Mark it ready when your own testing is finished and I
will merge it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants