From 520ad5829a26c72daabe0fe98147480a4ef3cfa2 Mon Sep 17 00:00:00 2001 From: PiratesIRC <98669745+PiratesIRC@users.noreply.github.com> Date: Fri, 25 Sep 2026 06:55:29 -0500 Subject: [PATCH] feat(m3u): a * entry in M3U Sources stands for every other source A user wanted one free provider first and any other provider when it has nothing, without naming every provider. Naming only the free provider made Match and Assign use ONLY that provider, because in Match and Assign and Preview the list filters as well as orders. With * in the list no source is dropped. Named sources rank in the order written and every unnamed source takes the position of the *: "Free Provider, *" ranks Free Provider first and everything else after it, including sources added later; "*, Backup" ranks Backup last. Without a * nothing changes. - _resolve_m3u_priorities is now the one reader of the setting, used by the load path (Match and Assign, Preview) and by Sort Alternate Streams. _apply_m3u_priorities filters and stamps _m3u_priority; _m3u_priority_for gives one source's priority. - A repeated entry now counts once at its first position (before, a repeat moved the source to its last position). Names that match no account are logged on the load path, and the Sort priority log no longer numbers them. - Help text, README settings row and the CSV header describe the wildcard. Tests: tests/test_m3u_source_wildcard.py (16). Ignoring the wildcard, ranking it last, taking a repeat at its last position, and Sort ignoring the wildcard each fail at least one test. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 2 +- Stream-Mapparr/plugin.py | 127 +++++++++++++++------ tests/test_m3u_source_wildcard.py | 176 ++++++++++++++++++++++++++++++ 3 files changed, 271 insertions(+), 34 deletions(-) create mode 100644 tests/test_m3u_source_wildcard.py diff --git a/README.md b/README.md index 1b23a4a..a668659 100644 --- a/README.md +++ b/README.md @@ -185,7 +185,7 @@ the operation lock prevents concurrent runs and auto-expires after 10 minutes. | **Channel Groups Mode** | select | Only the groups listed | Whether the list names the groups to process or the groups to skip. Choosing to skip means a group you create later is processed automatically. Empty list means all groups either way | | **Stream Groups** | string | (all) | Stream groups to draw candidate streams from, comma-separated | | **Stream Groups Mode** | select | Only the groups listed | The same choice for stream groups, resolved separately from the channel-group list | -| **M3U Sources** | string | (all) | M3U sources to use, comma-separated. Order sets priority | +| **M3U Sources** | string | (all) | M3U sources to use, comma-separated. Order sets priority. Only listed sources are used unless the list contains `*`, which stands for every other source: `Free Provider, *` puts Free Provider first and keeps all the rest | | **Custom Aliases** | string | (none) | JSON object of extra `"channel": ["alias", ...]` mappings. Channel names and aliases are both matched case-insensitively, and whitespace around a channel name is ignored | | **Stream Name Regex Rules** | string | (none) | JSON list of `[find, replace]` pairs applied to stream names before matching. See [Regex pre-processing](docs/regex-preprocessing.md) | | **Prioritize Quality** | boolean | False | Sort by quality first, then by M3U source priority | diff --git a/Stream-Mapparr/plugin.py b/Stream-Mapparr/plugin.py index ee56bed..2f232f5 100644 --- a/Stream-Mapparr/plugin.py +++ b/Stream-Mapparr/plugin.py @@ -971,7 +971,7 @@ def fields(self): "type": "string", "default": PluginConfig.DEFAULT_SELECTED_M3US, "placeholder": "IPTV Provider 1, Local M3U, Sports", - "help_text": "Specific M3U sources to use when matching, or leave empty for all M3U sources. Multiple M3U sources can be specified separated by commas. Order matters: streams from earlier M3U sources are prioritized over later ones when sorting by quality.", + "help_text": "Specific M3U sources to use when matching, or leave empty for all M3U sources. Multiple M3U sources can be specified separated by commas. Order matters: streams from earlier M3U sources are prioritized over later ones when sorting by quality. Without a * entry, Match and Assign uses only the sources listed. Add * to stand for every other source, so nothing is left out: \"Free Provider, *\" puts Free Provider first and every other source after it, including sources added later.", }, { "id": "_section_name_preprocessing", @@ -2994,6 +2994,81 @@ def _resolve_prioritize_quality(self, settings): value = value.lower() in ('true', 'yes', '1') return bool(value) + # An M3U Sources entry meaning "every source not named in this list". + M3U_WILDCARD = "*" + # Priority for a stream whose source is neither named nor covered by a + # wildcard. Sort keeps such streams and ranks them last. + M3U_UNLISTED_PRIORITY = 999 + + @classmethod + def _resolve_m3u_priorities(cls, selected_m3us_str, m3u_name_to_id): + """Resolve the M3U Sources list into priorities. The one reader of it. + + Returns a dict: + names entries that took a position, in order, the wildcard + included and unknown names left out + priority_map {account id: priority} for each named source, 0 first + other_priority the priority of every source NOT named, which is the + position of the wildcard, or None when there is none + missing names that match no M3U account + + Requested 2026-09-24: a user wanted one free provider first and any + other provider after it without naming them all. Without a wildcard the + list filters Match and Assign and Preview, so naming only the free + provider dropped every other one. A wildcard keeps every source and + places the unnamed ones where it stands: "Free, *" ranks Free first, + "*, Backup" ranks Backup last. A repeated entry counts once, at its + first position. + """ + names, priority_map, missing = [], {}, [] + other_priority = None + for token in str(selected_m3us_str or "").split(","): + token = token.strip() + if not token: + continue + if token == cls.M3U_WILDCARD: + if other_priority is None: + other_priority = len(names) + names.append(token) + continue + if token not in m3u_name_to_id: + missing.append(token) + continue + account_id = m3u_name_to_id[token] + if account_id in priority_map: + continue + priority_map[account_id] = len(names) + names.append(token) + return {"names": names, "priority_map": priority_map, + "other_priority": other_priority, "missing": missing} + + @classmethod + def _m3u_priority_for(cls, account_id, priorities): + """Priority of one source. Unnamed sources take the wildcard position, + or M3U_UNLISTED_PRIORITY when the list has no wildcard.""" + if account_id in priorities["priority_map"]: + return priorities["priority_map"][account_id] + if priorities["other_priority"] is not None: + return priorities["other_priority"] + return cls.M3U_UNLISTED_PRIORITY + + @classmethod + def _apply_m3u_priorities(cls, streams, priorities): + """Stamp _m3u_priority on each stream the list admits and return those. + + Without a wildcard only named sources are admitted, which is the + long-standing filter. With one, every stream is admitted. + """ + admitted = [] + for stream in streams: + account_id = stream.get("m3u_account") + if (priorities["other_priority"] is None + and account_id not in priorities["priority_map"]): + continue + stream["_m3u_priority"] = cls._m3u_priority_for(account_id, priorities) + admitted.append(stream) + return admitted + def _resolve_allow_same_name_streams(self, settings): """Resolve the opt-in 'allow_same_name_streams' toggle (bug-140).""" value = settings.get('allow_same_name_streams', PluginConfig.DEFAULT_ALLOW_SAME_NAME_STREAMS) @@ -7213,27 +7288,20 @@ def load_process_channels_action(self, settings, logger, context=None): # Filter streams by selected M3U sources and add priority metadata if selected_m3us_str: - selected_m3us = [m.strip() for m in selected_m3us_str.split(',') if m.strip()] - valid_m3u_ids = [m3u_name_to_id[name] for name in selected_m3us if name in m3u_name_to_id] - if not valid_m3u_ids: + m3u_priorities = self._resolve_m3u_priorities(selected_m3us_str, m3u_name_to_id) + selected_m3us = m3u_priorities["names"] + if m3u_priorities["missing"]: + logger.warning(f"[Stream-Mapparr] M3U sources not found, ignored: {', '.join(m3u_priorities['missing'])}") + if not m3u_priorities["priority_map"] and m3u_priorities["other_priority"] is None: logger.warning("[Stream-Mapparr] None of the specified M3U sources were found. Using all streams.") selected_m3us = [] # Add default priority to all streams (no prioritization) for stream in all_streams_data: - stream['_m3u_priority'] = 999 # Low priority for unspecified M3Us + stream['_m3u_priority'] = self.M3U_UNLISTED_PRIORITY else: - # Create M3U ID to priority mapping (0 = highest priority) - m3u_priority_map = {m3u_id: idx for idx, m3u_id in enumerate(valid_m3u_ids)} - - # Filter streams by m3u_account (which is the M3U account ID) and add priority - filtered_streams = [] - for s in all_streams_data: - m3u_id = s.get('m3u_account') - if m3u_id in valid_m3u_ids: - # Add priority metadata based on order in selected_m3us list - s['_m3u_priority'] = m3u_priority_map[m3u_id] - filtered_streams.append(s) - + # Without a wildcard this filters to the named sources; with + # one it keeps every source and only sets the priority. + filtered_streams = self._apply_m3u_priorities(all_streams_data, m3u_priorities) logger.info(f"[Stream-Mapparr] Filtered streams from {len(all_streams_data)} to {len(filtered_streams)} based on M3U sources: {', '.join(selected_m3us)}") logger.info(f"[Stream-Mapparr] M3U priority order: {', '.join([f'{name} (priority {idx})' for idx, name in enumerate(selected_m3us)])}") all_streams_data = filtered_streams @@ -7527,7 +7595,7 @@ def _generate_csv_header_comment(self, settings, processed_data, action_name="Un f"# Profile Name(s): {profile_name}", f"# Selected Channel Groups: {', '.join(selected_groups) if selected_groups else '(all groups)'}", f"# Selected Stream Groups: {', '.join(selected_stream_groups) if selected_stream_groups else '(all stream groups)'}", - f"# Selected M3U Sources: {', '.join(selected_m3us) if selected_m3us else '(all M3U sources)'}", + f"# Selected M3U Sources: {', '.join('* (every other source)' if m == self.M3U_WILDCARD else m for m in selected_m3us) if selected_m3us else '(all M3U sources)'}", "#", "# === Matching Settings ===", f"# Name Match Threshold: {current_threshold} out of 100, set by Match Sensitivity (higher is stricter; a stream must score at least this to count as a match)", @@ -9168,21 +9236,18 @@ def sort_streams_action(self, settings, logger, context=None, is_scheduled=False zone_routed = self._zone_routed_map(channels_in_profile, _ig_tags, _ig_q, _ig_r, _ig_g, _ig_m) # Build M3U priority map if M3U sources are specified - selected_m3us_str = settings.get('selected_m3us', '').strip() - m3u_priority_map = {} + selected_m3us_str = (settings.get('selected_m3us') or '').strip() + # Sort never drops a stream: a source the list does not cover ranks + # last (M3U_UNLISTED_PRIORITY), or at the wildcard's position. + m3u_priorities = self._resolve_m3u_priorities('', {}) if selected_m3us_str: # Fetch M3U sources via ORM try: all_m3us = self._get_all_m3u_accounts(logger) m3u_name_to_id = {m['name']: m['id'] for m in all_m3us if 'name' in m and 'id' in m} - - selected_m3us = [m.strip() for m in selected_m3us_str.split(',') if m.strip()] - valid_m3u_ids = [m3u_name_to_id[name] for name in selected_m3us if name in m3u_name_to_id] - - if valid_m3u_ids: - # Create M3U ID to priority mapping (0 = highest priority) - m3u_priority_map = {m3u_id: idx for idx, m3u_id in enumerate(valid_m3u_ids)} - logger.info(f"[Stream-Mapparr] M3U priority order: {', '.join([f'{name} (priority {idx})' for idx, name in enumerate(selected_m3us)])}") + m3u_priorities = self._resolve_m3u_priorities(selected_m3us_str, m3u_name_to_id) + if m3u_priorities["names"]: + logger.info(f"[Stream-Mapparr] M3U priority order: {', '.join([f'{name} (priority {idx})' for idx, name in enumerate(m3u_priorities['names'])])}") except Exception as e: logger.warning(f"[Stream-Mapparr] Could not fetch M3U sources for prioritization: {e}") @@ -9202,11 +9267,7 @@ def sort_streams_action(self, settings, logger, context=None, is_scheduled=False # Get M3U priority for this stream m3u_account_id = stream.m3u_account_id - if m3u_account_id and m3u_account_id in m3u_priority_map: - m3u_priority = m3u_priority_map[m3u_account_id] - else: - # Stream not from a prioritized M3U source - m3u_priority = 999 + m3u_priority = self._m3u_priority_for(m3u_account_id, m3u_priorities) # bug-158 review I2: without this, _stream_country_code # here sees only the raw name (no group signal), which diff --git a/tests/test_m3u_source_wildcard.py b/tests/test_m3u_source_wildcard.py new file mode 100644 index 0000000..8369941 --- /dev/null +++ b/tests/test_m3u_source_wildcard.py @@ -0,0 +1,176 @@ +"""A `*` entry in M3U Sources means "every source not named here". + +Requested by a user on 2026-09-24: they wanted streams from a free provider +first and any other provider when the free one has nothing, without naming every +other provider. Typing only the free provider made Match and Assign use ONLY +that provider, because in Match and Assign and Preview the list is a filter as +well as a priority order: streams from an unlisted source are dropped before +matching. Sort Alternate Streams already ranked unlisted sources last instead of +dropping them. + +With `*` in the list, no source is dropped. Named sources rank in the order +written, and every unnamed source takes the position of the `*`. So +`Free Provider, *` means the free provider first and everything else after it, +and `*, Backup` means everything else first and Backup last. A source added +later is covered without editing the setting. + +Without a `*` nothing changes: the list still filters Match and Assign and +Preview, and Sort still ranks unlisted sources last (priority 999). + +Match and Assign, Preview and Sort all read the list through +_resolve_m3u_priorities, so they cannot disagree about the order. +""" +import inspect + + +NAME_TO_ID = {"Free Provider": 1, "Paid A": 2, "Paid B": 3, "Backup": 4} + + +def _resolve(plugin_module, raw, name_to_id=NAME_TO_ID): + return plugin_module.Plugin._resolve_m3u_priorities(raw, name_to_id) + + +def _streams(): + return [ + {"id": 10, "m3u_account": 1}, + {"id": 20, "m3u_account": 2}, + {"id": 30, "m3u_account": 3}, + {"id": 40, "m3u_account": 4}, + {"id": 50, "m3u_account": None}, + ] + + +def _apply(plugin_module, raw): + priorities = _resolve(plugin_module, raw) + kept = plugin_module.Plugin._apply_m3u_priorities(_streams(), priorities) + return {s["id"]: s["_m3u_priority"] for s in kept} + + +# --------------------------------------------------------------------------- # +# Resolving the list +# --------------------------------------------------------------------------- # +def test_a_name_then_wildcard_ranks_the_name_first_and_everything_else_next(plugin_module): + r = _resolve(plugin_module, "Free Provider, *") + assert r["priority_map"] == {1: 0} + assert r["other_priority"] == 1 + assert r["names"] == ["Free Provider", "*"] + assert r["missing"] == [] + + +def test_a_name_after_the_wildcard_ranks_below_every_other_source(plugin_module): + r = _resolve(plugin_module, "Free Provider, *, Backup") + assert r["priority_map"] == {1: 0, 4: 2} + assert r["other_priority"] == 1 + + +def test_without_a_wildcard_there_is_no_other_priority(plugin_module): + r = _resolve(plugin_module, "Paid B, Free Provider") + assert r["priority_map"] == {3: 0, 1: 1} + assert r["other_priority"] is None + + +def test_an_unknown_name_is_reported_and_takes_no_position(plugin_module): + r = _resolve(plugin_module, "Nope, Free Provider, *") + assert r["missing"] == ["Nope"] + assert r["priority_map"] == {1: 0} + assert r["other_priority"] == 1 + assert r["names"] == ["Free Provider", "*"] + + +def test_repeated_entries_count_once_at_their_first_position(plugin_module): + r = _resolve(plugin_module, "Free Provider, *, Free Provider, *, Backup") + assert r["priority_map"] == {1: 0, 4: 2} + assert r["other_priority"] == 1 + assert r["names"] == ["Free Provider", "*", "Backup"] + + +def test_an_empty_box_resolves_to_nothing(plugin_module): + for raw in ("", " ", " , ,", None): + r = _resolve(plugin_module, raw) + assert r["priority_map"] == {} and r["other_priority"] is None + assert r["names"] == [] and r["missing"] == [] + + +# --------------------------------------------------------------------------- # +# Applying it to streams (the Match and Assign and Preview load path) +# --------------------------------------------------------------------------- # +def test_the_reported_case_keeps_every_source_with_the_free_one_first(plugin_module): + assert _apply(plugin_module, "Free Provider, *") == {10: 0, 20: 1, 30: 1, 40: 1, 50: 1} + + +def test_without_a_wildcard_unlisted_sources_are_still_dropped(plugin_module): + """Unchanged behaviour: the list filters when it has no `*`.""" + assert _apply(plugin_module, "Free Provider") == {10: 0} + assert _apply(plugin_module, "Paid B, Free Provider") == {30: 0, 10: 1} + + +def test_a_lone_wildcard_keeps_everything_at_one_priority(plugin_module): + assert _apply(plugin_module, "*") == {10: 0, 20: 0, 30: 0, 40: 0, 50: 0} + + +def test_wildcard_then_backup_puts_backup_last(plugin_module): + assert _apply(plugin_module, "*, Backup") == {10: 0, 20: 0, 30: 0, 40: 1, 50: 0} + + +def test_a_source_added_later_is_covered_without_editing_the_list(plugin_module): + name_to_id = dict(NAME_TO_ID, **{"New Source": 9}) + priorities = _resolve(plugin_module, "Free Provider, *", name_to_id) + kept = plugin_module.Plugin._apply_m3u_priorities( + [{"id": 90, "m3u_account": 9}, {"id": 10, "m3u_account": 1}], priorities) + assert {s["id"]: s["_m3u_priority"] for s in kept} == {90: 1, 10: 0} + + +def test_the_sort_ordering_ranks_the_free_source_ahead(plugin_module): + """End to end through the real quality sort, source before quality.""" + inst = plugin_module.Plugin.__new__(plugin_module.Plugin) + inst._prioritize_quality = False + priorities = _resolve(plugin_module, "Free Provider, *") + streams = plugin_module.Plugin._apply_m3u_priorities( + [{"id": 20, "m3u_account": 2, "name": "X", "stats": {"width": 1920, "height": 1080, "source_fps": 60}}, + {"id": 10, "m3u_account": 1, "name": "X", "stats": {"width": 1280, "height": 720, "source_fps": 30}}], + priorities) + ordered = inst._sort_streams_by_quality(streams) + assert [s["id"] for s in ordered] == [10, 20] + + +# --------------------------------------------------------------------------- # +# Wiring and the operator-facing text +# --------------------------------------------------------------------------- # +def test_every_reader_of_the_setting_goes_through_the_resolver(plugin_module): + load = inspect.getsource(plugin_module.Plugin.load_process_channels_action) + sort = inspect.getsource(plugin_module.Plugin.sort_streams_action) + for src in (load, sort): + assert "_resolve_m3u_priorities(" in src + assert "enumerate(valid_m3u_ids)" not in src + assert "_apply_m3u_priorities(" in load + + +def test_the_sort_path_uses_the_wildcard_position_for_unnamed_sources(plugin_module): + sort = inspect.getsource(plugin_module.Plugin.sort_streams_action) + assert "_m3u_priority_for(" in sort + p = plugin_module.Plugin + with_wild = _resolve(plugin_module, "Free Provider, *, Backup") + without = _resolve(plugin_module, "Free Provider") + assert p._m3u_priority_for(1, with_wild) == 0 + assert p._m3u_priority_for(2, with_wild) == 1 + assert p._m3u_priority_for(4, with_wild) == 2 + assert p._m3u_priority_for(None, with_wild) == 1 + assert p._m3u_priority_for(2, without) == 999 + + +def test_help_text_documents_the_wildcard(plugin_module): + inst = plugin_module.Plugin.__new__(plugin_module.Plugin) + inst.version = "test" + fields = {f["id"]: f for f in inst.fields} + help_text = fields["selected_m3us"]["help_text"] + assert "*" in help_text + assert "every other" in help_text.lower() + + +def test_the_csv_header_spells_out_the_wildcard(plugin_module): + inst = plugin_module.Plugin.__new__(plugin_module.Plugin) + inst.version = "test" + header = inst._generate_csv_header_comment( + {}, {"selected_m3us": ["Free Provider", "*"]}) + line = [ln for ln in header.splitlines() if ln.startswith("# Selected M3U Sources:")][0] + assert line == "# Selected M3U Sources: Free Provider, * (every other source)"