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):