From 709f07cdeaccdec450c32701e569efcd19972ec2 Mon Sep 17 00:00:00 2001 From: PiratesIRC <98669745+PiratesIRC@users.noreply.github.com> Date: Sat, 19 Sep 2026 10:00:55 -0500 Subject: [PATCH] fix(zones): a West channel no longer receives the unmarked East feed (#55) extract_zone reads an unmarked stream name as DEFAULT, which its own docstring calls the East feed, and 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 feed was dropped when the provider wrote "(EAST)" and kept when it did not. Measured on the maintainer's box: STARZ (W) and Showtime (W) each held 6 unmarked rows beside their West feeds. - A West channel keeps West streams only; unmarked ranks with EAST. - When no West stream matched, Match and Assign (and Preview) leave a channel that already holds streams untouched in both Overwrite modes, and give the other-zone streams only to a channel holding none. - Sort keeps the keep-everything fallback, since it replaces the list. Fixes #55 Co-Authored-By: Claude Opus 5 (1M context) --- Stream-Mapparr/plugin.py | 113 ++++++++++++++++---- tests/test_issue_55_west_unmarked.py | 151 +++++++++++++++++++++++++++ tests/test_plugin_helpers.py | 9 +- tests/test_zone_exclusion.py | 17 ++- 4 files changed, 256 insertions(+), 34 deletions(-) create mode 100644 tests/test_issue_55_west_unmarked.py diff --git a/Stream-Mapparr/plugin.py b/Stream-Mapparr/plugin.py index 88de673..ddde648 100644 --- a/Stream-Mapparr/plugin.py +++ b/Stream-Mapparr/plugin.py @@ -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) @@ -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() @@ -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 @@ -4076,7 +4094,8 @@ 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] @@ -4084,6 +4103,34 @@ def _streams_for_channel(self, streams, channel_id, zone_routed, same_country_id ) 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): @@ -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( @@ -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: diff --git a/tests/test_issue_55_west_unmarked.py b/tests/test_issue_55_west_unmarked.py new file mode 100644 index 0000000..ba28fcc --- /dev/null +++ b/tests/test_issue_55_west_unmarked.py @@ -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 diff --git a/tests/test_plugin_helpers.py b/tests/test_plugin_helpers.py index 519b86a..cf64a60 100644 --- a/tests/test_plugin_helpers.py +++ b/tests/test_plugin_helpers.py @@ -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): @@ -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): diff --git a/tests/test_zone_exclusion.py b/tests/test_zone_exclusion.py index 1be4830..c581f56 100644 --- a/tests/test_zone_exclusion.py +++ b/tests/test_zone_exclusion.py @@ -83,26 +83,25 @@ def test_an_east_channel_drops_west_streams(plugin_module): # A West channel is treated symmetrically # --------------------------------------------------------------------------- # -def test_a_west_channel_drops_east_streams_but_keeps_unmarked(plugin_module): +def test_a_west_channel_drops_east_and_unmarked_streams(plugin_module): """An East feed on a West channel is wrong in the same way, three hours the - other direction. Unmarked feeds are kept for both, so neither is left with - nothing when only generic feeds exist.""" + other direction. Issue 55: an unmarked feed IS the East feed, so a West + channel keeps West streams only.""" p = _plugin(plugin_module) streams = [_s("STARZ WEST HD"), _s("US: STARZ HD"), _s("US: STARZ EAST HD")] out = [s["name"] for s in p._order_streams_for_zone(streams, "WEST")] - assert "US: STARZ EAST HD" not in out - assert out == ["STARZ WEST HD", "US: STARZ HD"] + assert out == ["STARZ WEST HD"] # --------------------------------------------------------------------------- # # Order within what survives is unchanged # --------------------------------------------------------------------------- # -def test_own_zone_still_ranks_above_unmarked(plugin_module): +def test_own_zone_and_unmarked_keep_their_order_on_an_east_channel(plugin_module): p = _plugin(plugin_module) - streams = [_s("US: STARZ HD"), _s("STARZ WEST HD")] - assert [s["name"] for s in p._order_streams_for_zone(streams, "WEST")] == [ - "STARZ WEST HD", "US: STARZ HD"] + streams = [_s("US: HBO HD"), _s("HBO EAST HD")] + assert [s["name"] for s in p._order_streams_for_zone(streams, "EAST")] == [ + "US: HBO HD", "HBO EAST HD"] def test_the_country_partition_still_outranks_zone(plugin_module):