diff --git a/Stream-Mapparr/plugin.py b/Stream-Mapparr/plugin.py index ee56bed..e75dc0a 100644 --- a/Stream-Mapparr/plugin.py +++ b/Stream-Mapparr/plugin.py @@ -984,8 +984,8 @@ def fields(self): "label": "🔤 Custom Aliases (JSON)", "type": "text", "default": PluginConfig.DEFAULT_CUSTOM_ALIASES, - "placeholder": '{"My Channel": ["Provider Stream Name", "Alt Name"]}', - "help_text": "JSON object mapping a channel name to extra stream-name aliases (a bare string is accepted as a single alias). Streams whose name exactly matches an alias are force-matched to that channel. Leave blank to use built-in aliases only.", + "placeholder": '{"Channel A": ["Stream Name", "Alt Name"], "Channel B": ["Stream Name"]}', + "help_text": 'One JSON object mapping each channel name to extra stream-name aliases (a bare string is accepted as a single alias). Put every channel in the same object, for example {"Rai 1": ["RAI 1 Full HD"], "Rai 2": ["Rai due"]}, not a list of separate objects. Streams whose name exactly matches an alias are force-matched to that channel. Validate Settings reports entries that cannot be used. Leave blank to use built-in aliases only.', }, { "id": "stream_name_regex_rules", @@ -3723,38 +3723,85 @@ def _build_alias_map(self, settings, country): custom_str = (settings.get("custom_aliases") or "").strip() if custom_str: - try: - custom = json.loads(custom_str) - except (json.JSONDecodeError, ValueError) as e: - LOGGER.warning(f"[Stream-Mapparr] Failed to parse custom_aliases JSON: {e}") - custom = None - if isinstance(custom, dict): - merged = 0 - for k, v in custom.items(): - if isinstance(v, str): - aliases = [v] - elif isinstance(v, list): - aliases = v - else: - LOGGER.warning( - f"[Stream-Mapparr] custom_aliases: ignoring '{k}' - " - f"value must be a string or list") - continue - clean = [a.strip() for a in aliases if isinstance(a, str) and a.strip()] - if not clean: - continue - if k in alias_map: - alias_map[k] = list(dict.fromkeys(alias_map[k] + clean)) - else: - alias_map[k] = clean - merged += 1 - LOGGER.info(f"[Stream-Mapparr] Merged {merged} custom alias entries") - elif custom is not None: - LOGGER.warning( - "[Stream-Mapparr] custom_aliases must be a JSON object - ignored") + usable, problem, skipped = self._parse_custom_aliases(custom_str) + if problem: + LOGGER.warning(f"[Stream-Mapparr] custom_aliases ignored: {problem}") + for k, reason in skipped: + LOGGER.warning(f"[Stream-Mapparr] custom_aliases: ignoring '{k}' - {reason}") + for k, clean in usable.items(): + if k in alias_map: + alias_map[k] = list(dict.fromkeys(alias_map[k] + clean)) + else: + alias_map[k] = clean + if not problem: + LOGGER.info(f"[Stream-Mapparr] Merged {len(usable)} custom alias entries") return alias_map + @staticmethod + def _parse_custom_aliases(custom_str): + """Parse the Custom Aliases box. Returns (usable, problem, skipped). + + usable maps each channel name to its cleaned alias list. problem is None, + or one sentence saying why NOTHING in the box can be used. skipped lists + (channel name, reason) for entries dropped from an otherwise valid object. + + _build_alias_map and Validate Settings both read the box through this one + function, so the count Validate Settings reports is the count the matcher + merges. A user on 2026-09-25 wrote a list of one-key objects instead of + one object; the matcher dropped all of it and only the container log said + so. + """ + try: + custom = json.loads(custom_str) + except (json.JSONDecodeError, ValueError) as e: + detail = (f"{e.msg} at line {e.lineno} column {e.colno}" + if isinstance(e, json.JSONDecodeError) else str(e)) + return {}, f"not valid JSON ({detail})", [] + if isinstance(custom, list): + return {}, ("the box holds a list. Put every channel in one object, " + 'like {"Channel A": ["alias"], "Channel B": ["alias"]}'), [] + if not isinstance(custom, dict): + return {}, "the box must hold a JSON object", [] + + usable, skipped = {}, [] + for k, v in custom.items(): + if isinstance(v, str): + aliases = [v] + elif isinstance(v, list): + aliases = v + else: + skipped.append((k, "value must be a string or list")) + continue + clean = [a.strip() for a in aliases if isinstance(a, str) and a.strip()] + if not clean: + skipped.append((k, "no alias listed")) + continue + usable[k] = clean + return usable, None, skipped + + def _validate_custom_aliases_setting(self, settings): + """Check-list lines for _validate_plugin_settings. Empty setting -> no lines. + + Every problem is a warning, never an error: the matcher carries on with + the built-in aliases when the box is unusable, and an error here would + make Match and Assign refuse to load channels over a formatting mistake. + """ + raw = ((settings or {}).get("custom_aliases") or "").strip() + if not raw: + return [] + usable, problem, skipped = self._parse_custom_aliases(raw) + if problem: + return [f"⚠ Custom Aliases: none are used, {problem}"] + if not skipped: + return [f"✅ Custom Aliases ({len(usable)} channel(s))"] + shown = [f"'{k}' ({reason})" for k, reason in skipped[:3]] + more = len(skipped) - len(shown) + tail = f", and {more} more" if more else "" + total = len(usable) + len(skipped) + return [f"⚠ Custom Aliases: {len(usable)} of {total} used, ignored " + f"{', '.join(shown)}{tail}"] + def _collect_alias_streams(self, channel_name, working_streams, ignore_tags, ignore_quality, ignore_regional, ignore_geographic, ignore_misc): """Return stream dicts whose name exact-normalizes to an alias variant of @@ -7040,6 +7087,10 @@ def _validate_plugin_settings(self, settings, logger): if any(line.startswith("❌") for line in rules_lines): has_errors = True + # 7. Custom aliases (if configured). Warnings only, never has_errors. + logger.debug("[Stream-Mapparr] Validating custom aliases...") + validation_results.extend(self._validate_custom_aliases_setting(settings)) + return has_errors, validation_results except Exception as e: diff --git a/tests/test_custom_aliases_validation.py b/tests/test_custom_aliases_validation.py new file mode 100644 index 0000000..f5445c9 --- /dev/null +++ b/tests/test_custom_aliases_validation.py @@ -0,0 +1,128 @@ +"""Validate Settings says so when the Custom Aliases box cannot be used. + +Reported by a user on 2026-09-25: aliases "work only if I use aliases for one +channel". They had written three separate objects inside a list: + + [{"Rai 1 FHD": [...]}, {"Super Tennis FHD": [...]}, {"Rai 2 FHD": [...]}] + +The setting must be ONE object with a key per channel. _build_alias_map +discards anything that is not an object, so every one of the three entries was +dropped, and the only trace was one warning line in the container log. A single +entry is usually typed without the brackets, which is already the right shape, +so it looked as though one channel worked and three did not. + +Validate Settings now reports each way the box can be unusable as a WARNING, not +an error. The matcher carries on with the built-in aliases when the box is +unusable, so refusing to run would turn a formatting mistake into an outage. + +The validator and the matcher read the box through ONE parser, +_parse_custom_aliases, so the two cannot disagree about which entries count. +""" +import inspect +import json + + +def _plugin(plugin_module): + return plugin_module.Plugin.__new__(plugin_module.Plugin) + + +def _lines(plugin_module, raw): + return _plugin(plugin_module)._validate_custom_aliases_setting({"custom_aliases": raw}) + + +# --------------------------------------------------------------------------- # +# Nothing to say +# --------------------------------------------------------------------------- # +def test_an_empty_box_produces_no_line(plugin_module): + assert _lines(plugin_module, "") == [] + assert _lines(plugin_module, " ") == [] + assert _plugin(plugin_module)._validate_custom_aliases_setting({}) == [] + + +def test_a_valid_object_reports_how_many_channels_it_covers(plugin_module): + lines = _lines(plugin_module, json.dumps({"A": ["a1"], "B": "b1", "C": ["c1", "c2"]})) + assert len(lines) == 1 + assert lines[0].startswith("✅") + assert "3" in lines[0] + + +# --------------------------------------------------------------------------- # +# The reported case and the other whole-box failures +# --------------------------------------------------------------------------- # +def test_the_reported_list_of_objects_is_a_warning_naming_the_fix(plugin_module): + raw = ('[{"Rai 1 FHD":["Rai 1 FHD", "RAI 1 Full HD"]},' + '{"Super Tennis FHD":["Super Tennis FHD", "Super Tennis FULL HD"]},' + '{"Rai 2 FHD":["Rai 2 FHD", "Rai due FHD"]}]') + lines = _lines(plugin_module, raw) + assert len(lines) == 1 + line = lines[0] + assert line.startswith("⚠") + assert "list" in line.lower() + assert "none" in line.lower() + assert "one object" in line.lower() + + +def test_invalid_json_is_a_warning_with_the_position(plugin_module): + lines = _lines(plugin_module, '{"A": ["a1"],}') + assert len(lines) == 1 + assert lines[0].startswith("⚠") + assert "json" in lines[0].lower() + assert "line 1" in lines[0] + + +def test_a_bare_string_or_number_is_a_warning(plugin_module): + for raw in ('"just a string"', "42", "null"): + lines = _lines(plugin_module, raw) + assert len(lines) == 1, raw + assert lines[0].startswith("⚠"), raw + assert "object" in lines[0].lower(), raw + + +# --------------------------------------------------------------------------- # +# Some entries used, some ignored +# --------------------------------------------------------------------------- # +def test_entries_the_matcher_skips_are_named(plugin_module): + raw = json.dumps({"Good": ["g1"], "Numeric": 5, "Blank": ["", " "], "Nested": {"x": 1}}) + lines = _lines(plugin_module, raw) + assert len(lines) == 1 + line = lines[0] + assert line.startswith("⚠") + assert "1 of 4" in line + for name in ("Numeric", "Blank", "Nested"): + assert name in line + assert "Good" not in line + + +def test_a_long_list_of_skipped_entries_is_capped(plugin_module): + raw = json.dumps({f"Channel {i}": 5 for i in range(10)}) + line = _lines(plugin_module, raw)[0] + assert "Channel 0" in line + assert "Channel 9" not in line + assert "7 more" in line + + +# --------------------------------------------------------------------------- # +# Validator and matcher agree, and the check is wired in +# --------------------------------------------------------------------------- # +def test_the_count_the_validator_reports_is_what_the_matcher_merges(plugin_module): + p = _plugin(plugin_module) + raw = json.dumps({"Zzz Only Custom 1": ["z1"], "Zzz Only Custom 2": 7, + "Zzz Only Custom 3": ["z3"]}) + alias_map = p._build_alias_map({"custom_aliases": raw}, None) + merged = [k for k in alias_map if k.startswith("Zzz Only Custom")] + assert sorted(merged) == ["Zzz Only Custom 1", "Zzz Only Custom 3"] + assert "2 of 3" in _lines(plugin_module, raw)[0] + + +def test_validate_plugin_settings_runs_the_alias_check(plugin_module): + src = inspect.getsource(plugin_module.Plugin._validate_plugin_settings) + assert "_validate_custom_aliases_setting(settings)" in src + + +def test_a_problem_is_a_warning_not_an_error(plugin_module): + """An error would make Match and Assign refuse to load channels, while the + matcher itself carries on without the custom aliases. The check must not + stop a run the matcher would have completed.""" + for raw in ("[{}]", "{bad", "42", json.dumps({"A": 5})): + for line in _lines(plugin_module, raw): + assert not line.startswith("❌"), raw