Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 82 additions & 31 deletions Stream-Mapparr/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
128 changes: 128 additions & 0 deletions tests/test_custom_aliases_validation.py
Original file line number Diff line number Diff line change
@@ -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
Loading