diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c8e586..cca8771 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,29 @@ Newest first. Versions are calver Major.YY.DDDHHMM (UTC). +## 1.26.2561754 (2026-09-13) + +One addition. + +### A finished scheduled check can hand off to Stream-Mapparr + +A new setting, **Trigger Stream-Mapparr After Scheduled Check**, in the Auto-run +After Scheduled Checks section, off by default. When it is on, a scheduled check +that reached the end of its channel list calls the Stream-Mapparr plugin as its +last step, after every rename, move and delete, and Stream-Mapparr then runs +its own scheduled steps against the fresh results. A window that closed part +way through the list, or a session stopped from outside, does not call it. + +Stream-Mapparr must be installed and enabled with its own Run After IPTV +Checker Scan setting on. When it is not installed the step logs one line and +does nothing; when it is disabled, or the call fails for any reason, the +failure is logged and the check still reports completion, because the scan and +its post-actions were already done. + +Dispatcharr's event system could not carry this, since it drops any event name +outside its fixed list, so the hand-off is an in-process call through the +plugin manager, the same call Dispatcharr makes for its own M3U refresh event. + ## 1.26.2481600 (2026-09-05) Five changes. Two of them stop the plugin acting on stale or incomplete diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md index 5e61270..e21b03f 100644 --- a/docs/USER-GUIDE.md +++ b/docs/USER-GUIDE.md @@ -419,6 +419,7 @@ none of the actions below, because its verdicts are incomplete. | Setting | What it does | |---|---| | Email Report After Scheduled Check | Build the HTML report and queue it for delivery | +| Trigger Stream-Mapparr After Scheduled Check | Call the Stream-Mapparr plugin as the last step, so it sorts and matches against the fresh results. Only after a check that finished the whole list, and only if Stream-Mapparr has its own Run After IPTV Checker Scan setting on | | Delete CSV Exports Older Than (Days) | Housekeeping for `/data/exports/`. 0 keeps every file, which is the default. Only files this plugin wrote are ever deleted, because that directory is shared with other plugins | | Restore Recovered Channels | Un-tag and move back channels that recovered. Runs first | | Rename Dead / Slow / Blank | Apply the matching rename format | diff --git a/iptv_checker/plugin.json b/iptv_checker/plugin.json index dc1f490..76c8ed4 100644 --- a/iptv_checker/plugin.json +++ b/iptv_checker/plugin.json @@ -1,6 +1,6 @@ { "name": "IPTV Checker", - "version": "1.26.2481600", + "version": "1.26.2561754", "description": "A Dispatcharr Plugin that goes through a playlist to check IPTV channels", "author": "PiratesIRC", "logo": "logo.png", @@ -344,6 +344,13 @@ "default": false, "help_text": "When ON, each scheduled check builds the HTML report and queues it for delivery by email through Newsflasharr. Runs beside the CSV export and on the same terms, so a window that closes part way through the channel list still leaves a report. Requires the Newsflasharr plugin, its SMTP settings, and a routing rule matching source iptv_checker and event usage_report with channel smtp. Without that rule the report is delivered somewhere else, so the plugin checks for it first and refuses rather than reporting a success you cannot verify." }, + { + "id": "scheduler_trigger_stream_mapparr", + "label": "\ud83d\udd01 Trigger Stream-Mapparr After Scheduled Check", + "type": "boolean", + "default": false, + "help_text": "When ON, a scheduled check that reached the end of its channel list hands off to the Stream-Mapparr plugin, which then runs its own scheduled steps (Sort Alternate Streams and Match and Assign, whichever it has ticked) against the fresh results. Stream-Mapparr must be installed and enabled and must have its 'Run After IPTV Checker Scan' setting on; otherwise this logs a line and does nothing. Runs after the rename, move and delete steps, and not at all for a window that closed part way through the list." + }, { "id": "csv_retention_days", "label": "\ud83e\uddf9 Delete CSV Exports Older Than (Days)", diff --git a/iptv_checker/plugin.py b/iptv_checker/plugin.py index 5c354e2..c39a6cc 100644 --- a/iptv_checker/plugin.py +++ b/iptv_checker/plugin.py @@ -113,6 +113,12 @@ class PluginConfig: # path, so nothing the operator must read may live there. REPORT_DIR = "/config/iptv_checker" RESULTS_FILE = "/data/iptv_checker_results.json" + # The plugin a finished scheduled scan is handed to, in process, through + # PluginManager.run_action. Dispatcharr's connect event bus refuses event + # names outside its fixed list, so this is the only route. + STREAM_MAPPARR_PLUGIN_KEY = "stream-mapparr" + STREAM_MAPPARR_ACTION_ID = "on_iptv_checker_scan" + STREAM_MAPPARR_EVENT_NAME = "iptv_checker_scan_complete" LOADED_CHANNELS_FILE = "/data/iptv_checker_loaded_channels.json" PROGRESS_FILE = "/data/iptv_checker_progress.json" PENDING_RESUME_FILE = "/data/iptv_checker_pending_resume.json" @@ -434,7 +440,7 @@ class Plugin: # Explicitly set the plugin key key = "iptv_checker" - version = "1.26.2481600" + version = "1.26.2561754" # Fields and actions are defined in plugin.json (single source of truth) def __init__(self): @@ -2019,6 +2025,13 @@ def _execute_scheduled_check(self, settings, preserved_window_end=None, preserve else: LOGGER.warning(f"⏰ SCHEDULED: {delete_result.get('message')}") + # Step 10: Hand the finished scan to Stream-Mapparr if enabled. Sits + # behind the mid-list gate above on purpose: a window that closed + # part way must not sort against half a scan. + if _as_bool(settings.get('scheduler_trigger_stream_mapparr'), False): + LOGGER.info("⏰ SCHEDULED: Triggering Stream-Mapparr...") + self._trigger_stream_mapparr(settings, scheduled_logger) + LOGGER.info("⏰ SCHEDULED: Check sequence completed successfully") except Exception as e: @@ -2030,6 +2043,43 @@ def _execute_scheduled_check(self, settings, preserved_window_end=None, preserve if is_window and not scan_unfinished: self._clear_window_state() + def _trigger_stream_mapparr(self, settings, logger): + """Call the Stream-Mapparr plugin's post-scan handler, in process. + + Runs synchronously in this thread, so the scheduled sequence waits for + Stream-Mapparr to finish before it reports completion. Every failure is + logged and swallowed: the scan and its post-actions are already done, + and a sibling plugin's fault must not turn this session into an error. + Returns True only when the call was made and returned. + """ + key = PluginConfig.STREAM_MAPPARR_PLUGIN_KEY + action_id = PluginConfig.STREAM_MAPPARR_ACTION_ID + progress = getattr(self, "check_progress", None) or {} + payload = { + "source": "iptv_checker", + "version": self.version, + "finished_at": datetime.now().isoformat(timespec="seconds"), + "streams_checked": progress.get("total") if isinstance(progress, dict) else None, + } + try: + from apps.plugins.loader import PluginManager + pm = PluginManager.get() + if pm.get_plugin(key) is None: + logger.info(f"Stream-Mapparr trigger: plugin '{key}' is not installed, nothing to call") + return False + logger.info(f"Stream-Mapparr trigger: calling {key}/{action_id}") + result = pm.run_action(key, action_id, + {"event": PluginConfig.STREAM_MAPPARR_EVENT_NAME, "payload": payload}) + status = result.get("status") if isinstance(result, dict) else None + logger.info(f"Stream-Mapparr trigger: {key}/{action_id} returned status {status!r}") + return True + except PermissionError as e: + logger.warning(f"Stream-Mapparr trigger: {e}. Enable the Stream-Mapparr plugin or switch this setting off.") + return False + except Exception as e: + logger.error(f"Stream-Mapparr trigger failed: {e}", exc_info=True) + return False + def _get_latest_version(self, owner="PiratesIRC", repo="Dispatcharr-IPTV-Checker-Plugin"): """ Fetches the latest release tag from GitHub using only Python's standard library. diff --git a/tests/test_scheduled_check_interruption.py b/tests/test_scheduled_check_interruption.py index 997cc17..c677c3e 100644 --- a/tests/test_scheduled_check_interruption.py +++ b/tests/test_scheduled_check_interruption.py @@ -78,6 +78,7 @@ def test_results_mtime_returns_the_modification_time_when_present(plugin, tmp_pa "move_black_screen_channels_action", "delete_dead_channels_action", "_build_and_deliver_report", + "_trigger_stream_mapparr", ) _ALL_PHASE_GATES = { @@ -91,6 +92,7 @@ def test_results_mtime_returns_the_modification_time_when_present(plugin, tmp_pa "scheduler_move_low_framerate_channels": True, "scheduler_move_black_screen_channels": True, "scheduler_delete_dead_channels": True, + "scheduler_trigger_stream_mapparr": True, } diff --git a/tests/test_settings_schema.py b/tests/test_settings_schema.py index f121eb5..033643e 100644 --- a/tests/test_settings_schema.py +++ b/tests/test_settings_schema.py @@ -32,7 +32,7 @@ '_section_scheduling', 'scheduled_times', 'schedule_window_enabled', 'schedule_end_mode', 'schedule_duration_hours', 'schedule_end_time', '_section_auto_run', '_section_auto_rename_move', '_section_auto_delete', 'csv_retention_days', - 'scheduler_email_report', + 'scheduler_email_report', 'scheduler_trigger_stream_mapparr', 'scheduler_restore_channels', 'scheduler_rename_dead_channels', 'scheduler_rename_black_screen_channels', 'scheduler_rename_low_framerate_channels', 'scheduler_add_video_format_suffix', 'scheduler_move_dead_channels', diff --git a/tests/test_stream_mapparr_trigger.py b/tests/test_stream_mapparr_trigger.py new file mode 100644 index 0000000..3a3db00 --- /dev/null +++ b/tests/test_stream_mapparr_trigger.py @@ -0,0 +1,114 @@ +"""The post-scan hand-off to Stream-Mapparr. + +Dispatcharr's connect event bus refuses event names outside its fixed list, so +a scheduled scan that finished the whole list calls the Stream-Mapparr plugin +in process through PluginManager.run_action, the same call Dispatcharr makes +for its own m3u_refresh event. The wiring into _execute_scheduled_check is +covered by tests/test_scheduled_check_interruption.py, which lists the method +among the post-scan phases; this file pins the method itself. +""" +import io +import json +import logging +import os +import sys +import types + +import pytest + +log = logging.getLogger("t") + +_MANIFEST = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "iptv_checker", "plugin.json") + + +@pytest.fixture(scope="module") +def plugin_mod_manifest(): + with io.open(_MANIFEST, encoding="utf-8") as handle: + return json.load(handle) + + +class _FakeManager: + def __init__(self, registered=True, raises=None): + self.registered = registered + self.raises = raises + self.calls = [] + + def get_plugin(self, key): + return object() if self.registered else None + + def run_action(self, key, action_id, params=None): + self.calls.append((key, action_id, params)) + if self.raises is not None: + raise self.raises + return {"status": "success"} + + +@pytest.fixture +def loader(monkeypatch): + """Install a fake apps.plugins.loader so the lazy import inside the method resolves.""" + manager = _FakeManager() + module = types.ModuleType("apps.plugins.loader") + module.PluginManager = types.SimpleNamespace(get=lambda: manager) + monkeypatch.setitem(sys.modules, "apps.plugins.loader", module) + return manager + + +def test_the_setting_sits_inside_the_auto_run_section(plugin_mod_manifest): + ids = [f["id"] for f in plugin_mod_manifest["fields"]] + start = ids.index("_section_auto_run") + end = ids.index("_section_auto_rename_move") + assert "scheduler_trigger_stream_mapparr" in ids[start:end] + + +def test_the_setting_defaults_off(plugin_mod_manifest): + field = next(f for f in plugin_mod_manifest["fields"] + if f["id"] == "scheduler_trigger_stream_mapparr") + assert field["type"] == "boolean" + assert field["default"] is False + + +def test_it_calls_the_stream_mapparr_handler_with_a_small_payload(plugin, loader): + plugin.check_progress = {"current": 7, "total": 7, "status": "idle"} + assert plugin._trigger_stream_mapparr({}, log) is True + assert len(loader.calls) == 1 + key, action_id, params = loader.calls[0] + assert key == "stream-mapparr" + assert action_id == "on_iptv_checker_scan" + assert params["event"] == "iptv_checker_scan_complete" + payload = params["payload"] + assert payload["source"] == "iptv_checker" + assert payload["streams_checked"] == 7 + assert payload["version"] == plugin.version + assert "finished_at" in payload + # Nothing that names a provider or a stream may travel in the payload. + assert set(payload) == {"source", "version", "finished_at", "streams_checked"} + + +def test_it_skips_quietly_when_stream_mapparr_is_not_installed(plugin, loader): + loader.registered = False + assert plugin._trigger_stream_mapparr({}, log) is False + assert loader.calls == [] + + +def test_a_disabled_stream_mapparr_is_reported_not_raised(plugin, loader): + loader.raises = PermissionError("Plugin 'stream-mapparr' is disabled") + assert plugin._trigger_stream_mapparr({}, log) is False + + +def test_any_other_failure_is_logged_and_swallowed(plugin, loader): + loader.raises = RuntimeError("boom") + assert plugin._trigger_stream_mapparr({}, log) is False + + +def test_a_missing_plugin_manager_module_is_logged_and_swallowed(plugin, monkeypatch): + monkeypatch.delitem(sys.modules, "apps.plugins.loader", raising=False) + monkeypatch.delitem(sys.modules, "apps.plugins", raising=False) + assert plugin._trigger_stream_mapparr({}, log) is False + + +def test_the_streams_checked_count_survives_a_missing_progress_dict(plugin, loader): + if hasattr(plugin, "check_progress"): + del plugin.check_progress + assert plugin._trigger_stream_mapparr({}, log) is True + assert loader.calls[0][2]["payload"]["streams_checked"] is None