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
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/USER-GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
9 changes: 8 additions & 1 deletion iptv_checker/plugin.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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)",
Expand Down
52 changes: 51 additions & 1 deletion iptv_checker/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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:
Expand All @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions tests/test_scheduled_check_interruption.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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,
}


Expand Down
2 changes: 1 addition & 1 deletion tests/test_settings_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
114 changes: 114 additions & 0 deletions tests/test_stream_mapparr_trigger.py
Original file line number Diff line number Diff line change
@@ -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