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