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
113 changes: 92 additions & 21 deletions Stream-Mapparr/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -595,14 +595,15 @@ def _progress_toast_interval_for(total_items):

# Stream ordering tables for a zoned channel (lower = assigned earlier/primary).
# bug-068 routing policy, named so the two tables are greppable side by side:
_WEST_CHANNEL_ZONE_RANK = {'WEST': 0, 'DEFAULT': 1, 'EAST': 2} # West: own > generic > East (never empty)
_WEST_CHANNEL_ZONE_RANK = {'WEST': 0, 'DEFAULT': 2, 'EAST': 2} # West: own > East+generic
_EAST_CHANNEL_ZONE_RANK = {'EAST': 0, 'DEFAULT': 0, 'WEST': 2} # Default/East: East+generic > West


def _zone_affinity_rank(channel_zone, stream_zone):
"""Stream ordering priority for a zoned channel (lower = assigned earlier).
West channel: WEST > generic > EAST (never empty). Default/East channel
(the default feed is East-like; generic counts as East): East+generic > West."""
An unmarked (generic) feed is the East feed in a US lineup, so it ranks
with EAST on both sides (issue 55). West channel: WEST > East+generic.
Default/East channel: East+generic > West."""
table = _WEST_CHANNEL_ZONE_RANK if channel_zone == 'WEST' else _EAST_CHANNEL_ZONE_RANK
return table.get(stream_zone, 1)

Expand Down Expand Up @@ -4015,36 +4016,52 @@ def _channels_to_update_for_group(self, sorted_channels, visible_channel_limit,
covered.add(z)
return channels

def _order_streams_for_zone(self, matched_streams, channel_zone, same_country_ids=None):
"""Stable re-sort of a channel's matched streams by zone affinity — own zone
first, generic next, other zone last — preserving the existing quality/
throughput order within each tier (bug-068).
def _order_streams_for_zone(self, matched_streams, channel_zone, same_country_ids=None,
keep_all_if_empty=True):
"""Stable re-sort of a channel's matched streams by zone affinity, own zone
first, preserving the existing quality/throughput order within each tier
(bug-068). Streams of the other zone are dropped, see below.

bug-158: when the country filter engaged, the country tier is the OUTER key
and zone affinity the inner one. Without this a DEFAULT-zone channel (which
since bug-132 is most of the lineup) ranks an unmarked generic stream at 0
and a proven same-country WEST feed at 2, handing order 0 to the unproven one.

keep_all_if_empty: when dropping leaves nothing, True returns every
matched stream (with a warning) and False returns an empty list, so a
caller that can leave the channel's existing streams alone decides
for itself (issue 55).
"""
if not self.fuzzy_matcher or not matched_streams:
return matched_streams

# Drop the OPPOSITE zone outright rather than ranking it last. A plain
# Drop the other zone outright rather than ranking it last. A plain
# named channel is its East or national feed, and a West feed on it is
# three hours behind: if every East feed fails, Dispatcharr walks down
# the list and plays a different programme rather than showing a glitch.
# An unmarked feed is kept for either zone, so neither side is left with
# nothing when only generic feeds exist.
opposite = 'EAST' if channel_zone == 'WEST' else 'WEST'
kept = [s for s in matched_streams
if self.fuzzy_matcher.extract_zone(s.get('name', '')) != opposite]
# An UNMARKED feed is the East feed in a US lineup (extract_zone says so,
# and the channel side already routes an unmarked channel as East), so a
# West channel keeps only West streams. Issue 55: "INVESTIGATION CHANNEL"
# was dropped from a West channel when written "(EAST)" and kept when
# written without the marker, although it is the same feed.
if channel_zone == 'WEST':
kept = [s for s in matched_streams
if self.fuzzy_matcher.extract_zone(s.get('name', '')) == 'WEST']
else:
kept = [s for s in matched_streams
if self.fuzzy_matcher.extract_zone(s.get('name', '')) != 'WEST']
if not kept:
# Match and Assign REPLACES a channel's whole stream list, so
# dropping everything takes the channel off the air. Keeping the
if not keep_all_if_empty:
return []
# A caller that REPLACES a channel's whole stream list (Sort, and
# Match and Assign for a channel holding no streams) would take the
# channel off the air by applying an empty list. Keeping the
# wrong-zone feed is the lesser harm here, and it is said out loud
# rather than done quietly.
LOGGER.warning(
f"[Stream-Mapparr] Every matched stream for a {channel_zone} channel is "
f"{opposite} zone; keeping them rather than leaving the channel with none")
f"[Stream-Mapparr] No matched stream is marked for this {channel_zone} "
f"channel's zone; keeping the other-zone streams rather than leaving "
f"the channel with none")
kept = list(matched_streams)

ids = same_country_ids or set()
Expand All @@ -4056,7 +4073,8 @@ def _order_streams_for_zone(self, matched_streams, channel_zone, same_country_id
),
)

def _streams_for_channel(self, streams, channel_id, zone_routed, same_country_ids=None):
def _streams_for_channel(self, streams, channel_id, zone_routed, same_country_ids=None,
keep_all_if_empty=True):
"""A single channel's stream list, country-partitioned and zone-reordered
as applicable. Returns the input list unchanged when neither applies.
Shared by Match & Assign, Sort, and Preview so all three agree on the
Expand All @@ -4076,14 +4094,43 @@ def _streams_for_channel(self, streams, channel_id, zone_routed, same_country_id
Preview's zone-routed and non-zone-routed channels alike.
"""
if channel_id in zone_routed:
return self._order_streams_for_zone(streams, zone_routed[channel_id], same_country_ids)
return self._order_streams_for_zone(streams, zone_routed[channel_id], same_country_ids,
keep_all_if_empty=keep_all_if_empty)
if same_country_ids:
return (
[s for s in streams if id(s) in same_country_ids]
+ [s for s in streams if id(s) not in same_country_ids]
)
return streams

@staticmethod
def _channel_has_streams(channel_id):
"""Does the channel hold at least one stream row right now?"""
return ChannelStream.objects.filter(channel_id=channel_id).exists()

def _streams_to_assign(self, matched_streams, channel_id, zone_routed, same_country_ids=None):
"""The streams Match and Assign (and Preview) give one channel, as
(streams, keep_existing).

Issue 55: when zone filtering leaves a channel NO stream of its own zone
(a West channel whose provider carries only the unmarked East feed), the
wrong-zone streams are assigned ONLY to a channel holding no streams at
all, where the choice is a wrong-zone feed or nothing. A channel that
already holds streams is left untouched, in both Overwrite modes:
appending would add the wrong feed, and replacing with an empty list
would take the channel off the air (the bug-063 rule, Overwrite only
replaces when there is something to replace with). keep_existing True
means "write nothing for this channel".
"""
streams = self._streams_for_channel(matched_streams, channel_id, zone_routed,
same_country_ids, keep_all_if_empty=False)
if streams or not matched_streams:
return streams, False
if self._channel_has_streams(channel_id):
return [], True
return self._streams_for_channel(matched_streams, channel_id, zone_routed,
same_country_ids), False

def _same_country_ids_for(self, channel, streams, channels_data, logger,
restrict_matching_to_country,
stream_country_memo=None, channel_info_cache=None):
Expand Down Expand Up @@ -7864,12 +7911,27 @@ def preview_changes_action(self, settings, logger, context=None):

for channel in channels_to_update:
match_count = len(matched_streams)
streams_for_channel = self._streams_for_channel(
streams_for_channel, keep_existing = self._streams_to_assign(
matched_streams, channel['id'], zone_routed,
self._same_country_ids_for(channel, matched_streams, channels_data,
logger, restrict_matching_to_country,
stream_country_memo=stream_country_memo,
channel_info_cache=channel_info_cache))
if keep_existing:
# Issue 55: Match and Assign leaves this channel alone,
# so the preview says so instead of listing streams.
all_matches.append({
"channel_id": channel['id'],
"channel_name": channel['name'],
"threshold": current_threshold,
"matched_streams": 0,
"stream_names": [f"(no matched stream is marked for the "
f"{zone_routed.get(channel['id'])} zone; "
f"existing streams kept)"],
"will_update": False,
"is_current": True
})
continue

# Get detailed threshold analysis
threshold_matches = self._get_matches_at_thresholds(
Expand Down Expand Up @@ -8368,12 +8430,21 @@ def add_streams_to_channels_action(self, settings, logger, is_scheduled=False, c
# bug-068: zone-route this channel's streams (West feeds ->
# "STARZ Encore (W)"); non-routed channels keep quality order.
# bug-158: country tier outranks zone affinity within that reorder.
streams_for_channel = self._streams_for_channel(
streams_for_channel, keep_existing = self._streams_to_assign(
matched_streams, channel_id, zone_routed,
self._same_country_ids_for(channel, matched_streams, channels_data,
logger, restrict_matching_to_country,
stream_country_memo=stream_country_memo,
channel_info_cache=channel_info_cache))
if keep_existing:
# Issue 55: no matched stream belongs to this channel's
# zone and the channel already holds streams, so nothing
# is added and nothing is removed.
logger.info(
f"[Stream-Mapparr] No matched stream is marked for the "
f"{zone_routed.get(channel_id)} zone of '{channel['name']}' "
f"(ID: {channel_id}); leaving its existing streams untouched.")
continue

try:
if matched_streams:
Expand Down
151 changes: 151 additions & 0 deletions tests/test_issue_55_west_unmarked.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
"""Issue 55: an unmarked stream was attached to a WEST channel.

extract_zone returns DEFAULT for a stream name with no zone marker, and its
docstring says DEFAULT is the East feed in a US lineup. The channel side
already routes an unmarked channel as East. The stream side dropped only a
stream literally marked EAST from a West channel, so the same East feed was
dropped when the provider wrote "(EAST)" and kept when it did not. Measured on
the maintainer's box 2026-09-19: "STARZ (W)" held 6 unmarked rows and
"Showtime (W)" held 6, beside their real West feeds.

The rule now: a West channel keeps West streams only. When that leaves nothing,
Match and Assign leaves a channel that already holds streams untouched, and
gives the other-zone streams only to a channel holding none. Sort, which always
replaces the list, keeps the old keep-everything fallback.
"""

import inspect


class _Matcher:
@staticmethod
def extract_zone(name):
upper = (name or "").upper()
if "WEST" in upper or "PACIFIC" in upper or "(W)" in upper:
return "WEST"
if "EAST" in upper or "(E)" in upper:
return "EAST"
return "DEFAULT"


def _plugin(plugin_module, has_streams=None):
p = plugin_module.Plugin.__new__(plugin_module.Plugin)
p.fuzzy_matcher = _Matcher()
if has_streams is not None:
p._channel_has_streams = lambda channel_id: has_streams
return p


def _s(name):
return {"name": name}


def _names(streams):
return [s["name"] for s in streams]


def _no_read(channel_id):
raise AssertionError("the channel's rows were read when no decision needed them")


# The reporter's example: the same feed written two ways, plus the West feed.
UNMARKED = "INVESTIGATION CHANNEL"
MARKED_EAST = "INVESTIGATION CHANNEL (EAST)"
WEST = "INVESTIGATION CHANNEL WEST"


def test_the_reporters_case_the_unmarked_feed_leaves_the_west_channel(plugin_module):
p = _plugin(plugin_module)
out = p._order_streams_for_zone([_s(UNMARKED), _s(MARKED_EAST), _s(WEST)], "WEST")
assert _names(out) == [WEST]


def test_the_unmarked_feed_stays_on_the_plain_channel(plugin_module):
p = _plugin(plugin_module)
out = p._order_streams_for_zone([_s(UNMARKED), _s(MARKED_EAST), _s(WEST)], "DEFAULT")
assert _names(out) == [UNMARKED, MARKED_EAST]


def test_the_east_channel_is_unchanged(plugin_module):
p = _plugin(plugin_module)
out = p._order_streams_for_zone([_s(UNMARKED), _s(WEST), _s(MARKED_EAST)], "EAST")
assert _names(out) == [UNMARKED, MARKED_EAST]


def test_without_a_west_stream_the_caller_can_ask_for_nothing(plugin_module):
p = _plugin(plugin_module)
out = p._order_streams_for_zone([_s(UNMARKED), _s(MARKED_EAST)], "WEST",
keep_all_if_empty=False)
assert out == []


def test_without_a_west_stream_the_default_still_keeps_everything(plugin_module):
"""Sort replaces a channel's rows with this list, so an empty list would
take the channel off the air. The default keeps the other-zone streams in
their input (quality) order, since unmarked and East now rank equal."""
p = _plugin(plugin_module)
out = p._order_streams_for_zone([_s(MARKED_EAST), _s(UNMARKED)], "WEST")
assert _names(out) == [MARKED_EAST, UNMARKED]


def test_a_west_channel_that_is_not_zone_routed_is_untouched(plugin_module):
"""A lone marked channel is never zone-routed (brand words such as Key
West), so the new rule cannot reach it."""
p = _plugin(plugin_module)
streams = [_s(UNMARKED), _s(WEST)]
assert p._streams_for_channel(streams, 7, {}) is streams


# _streams_to_assign: what Match and Assign and Preview do with an empty result

def test_a_west_stream_present_is_assigned_without_a_database_read(plugin_module):
p = _plugin(plugin_module)
p._channel_has_streams = _no_read
out, keep = p._streams_to_assign([_s(UNMARKED), _s(WEST)], 5, {5: "WEST"})
assert (_names(out), keep) == ([WEST], False)


def test_no_west_stream_and_existing_streams_means_leave_it_alone(plugin_module):
p = _plugin(plugin_module, has_streams=True)
out, keep = p._streams_to_assign([_s(UNMARKED), _s(MARKED_EAST)], 5, {5: "WEST"})
assert (out, keep) == ([], True)


def test_no_west_stream_and_an_empty_channel_gets_the_other_zone(plugin_module):
"""The choice there is the East feed or no feed; the 2026-08-02 rule
chose the East feed, and it is logged."""
p = _plugin(plugin_module, has_streams=False)
out, keep = p._streams_to_assign([_s(UNMARKED), _s(MARKED_EAST)], 5, {5: "WEST"})
assert (_names(out), keep) == ([UNMARKED, MARKED_EAST], False)


def test_nothing_matched_is_not_a_zone_decision(plugin_module):
p = _plugin(plugin_module)
p._channel_has_streams = _no_read
assert p._streams_to_assign([], 5, {5: "WEST"}) == ([], False)


# Wiring: which action uses which path

def test_match_and_assign_skips_the_channel_before_writing(plugin_module):
src = inspect.getsource(plugin_module.Plugin.add_streams_to_channels_action)
assert "self._streams_to_assign(" in src
assert "self._streams_for_channel(" not in src
between = src.split("if keep_existing:", 1)[1].split("try:", 1)[0]
assert "continue" in between
assert "ChannelStream" not in between


def test_preview_uses_the_same_decision(plugin_module):
src = inspect.getsource(plugin_module.Plugin.preview_changes_action)
assert "self._streams_to_assign(" in src
assert "self._streams_for_channel(" not in src
assert "if keep_existing:" in src


def test_sort_keeps_the_keep_everything_fallback(plugin_module):
"""Sort deletes and recreates a channel's rows from this list, so it must
never receive an empty one for a channel that has streams."""
src = inspect.getsource(plugin_module.Plugin.sort_streams_action)
assert "self._streams_for_channel(" in src
assert "keep_all_if_empty=False" not in src
9 changes: 5 additions & 4 deletions tests/test_plugin_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,8 @@ def test_rearm_with_changed_schedule_restarts_thread(plugin_module):
# --------------------------------------------------------------------------- #
def test_zone_affinity_rank_west_channel_prefers_west(plugin_module):
f = plugin_module._zone_affinity_rank
assert f('WEST', 'WEST') < f('WEST', 'DEFAULT') < f('WEST', 'EAST')
# Issue 55: an unmarked feed is the East feed, so it ranks with EAST.
assert f('WEST', 'WEST') < f('WEST', 'DEFAULT') == f('WEST', 'EAST')


def test_zone_affinity_rank_default_channel_is_east_like(plugin_module):
Expand Down Expand Up @@ -284,9 +285,9 @@ def test_order_streams_for_zone_west_channel_promotes_west(plugin_module, matche
]
ordered = p._order_streams_for_zone(streams, 'WEST')
assert ordered[0]['id'] == 12 # WEST promoted to primary
# 2026-08-02: the opposite zone is now DROPPED, not demoted, so the EAST feed
# is gone entirely and the unmarked one is the only fallback left.
assert [s['id'] for s in ordered] == [12, 11]
# 2026-08-02: the opposite zone is DROPPED, not demoted. Issue 55: the
# unmarked feed is the East feed, so it is dropped from a West channel too.
assert [s['id'] for s in ordered] == [12]


def test_channels_to_update_includes_each_zone_sibling(plugin_module):
Expand Down
Loading
Loading