From 364190c6ff3042d747234a845ae118c5519e838b Mon Sep 17 00:00:00 2001 From: PiratesIRC <98669745+PiratesIRC@users.noreply.github.com> Date: Sun, 13 Sep 2026 12:55:14 -0500 Subject: [PATCH] feat(scheduler): run the scheduled steps when IPTV Checker finishes a scan New setting Run After IPTV Checker Scan and a hidden action on_iptv_checker_scan that the IPTV Checker plugin calls in process through PluginManager.run_action after a scheduled scan that finished its whole list. Dispatcharr's connect event bus drops any event name outside its fixed list, so a custom event could not be used. The timer loop's load, Sort and Match and Assign steps move into one shared _run_scheduled_sequence so the two paths cannot drift. The wait for a running checker and the scheduled-run timestamp stay on the timer path only. The CSV header names the trigger in its Execution Mode line. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01WJpo1EzMNmJiSiNpbwBi6G --- CHANGELOG.md | 29 ++++ README.md | 13 ++ Stream-Mapparr/__init__.py | 2 +- Stream-Mapparr/plugin.json | 7 +- Stream-Mapparr/plugin.py | 209 +++++++++++++++++++---------- tests/test_action_buttons.py | 16 ++- tests/test_action_order.py | 21 +-- tests/test_iptv_checker_trigger.py | 208 ++++++++++++++++++++++++++++ tests/test_notify_wiring.py | 14 +- 9 files changed, 426 insertions(+), 93 deletions(-) create mode 100644 tests/test_iptv_checker_trigger.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f410563..85217a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,34 @@ # Stream-Mapparr CHANGELOG +## 1.26.2561754 (2026-09-13) + +### Added + +- **Run After IPTV Checker Scan, a new setting in the IPTV Checker Integration + section.** When the IPTV Checker plugin finishes a scheduled scan that reached + the end of its channel list, it now calls this plugin directly and the + scheduled steps run at once: Sort Alternate Streams and Match and Assign, + whichever the schedule has ticked. Until now the only way to sort against a + fresh scan was to guess when the scan would end and set the fixed time after + it. IPTV Checker must have its own Trigger Stream-Mapparr After Scheduled + Check setting on. A run already holding the operation lock is left alone and + nothing is queued; the next scan tries again. + + Dispatcharr's event system was not usable for this, because 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. The new hidden action `on_iptv_checker_scan` receives it. + +### Changed + +- The fixed-time scheduler and the new trigger run one shared sequence, so the + two cannot drift apart. The wait for a running IPTV Checker and the + scheduled-run timestamp behind Validate Settings belong to the fixed-time + path only: a triggered run starts because the scan just finished, and the + timestamp means the timer fired. +- The CSV header's `Execution Mode` line reads `Scheduled (after IPTV Checker + scan)` for a triggered run, so a report says who started it. + ## 1.26.2491549 (2026-09-06) ### Added diff --git a/README.md b/README.md index 36dcd07..cb6fa1d 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,9 @@ database. This plugin modifies channel and stream assignments. slot rather than once per worker - **Auto-match after M3U refresh** (opt-in, Dispatcharr v0.27+): run Match and Assign as soon as a refresh completes. Requires a Channel Profile +- **Run after an IPTV Checker scan** (opt-in): run the scheduled steps the + moment the IPTV Checker plugin finishes a scheduled scan, so sorting sees the + fresh results instead of waiting for the next fixed time - **Rate limiting**: None, Low, Medium or High - **Operation lock**: prevents concurrent tasks, auto-expiring after 10 minutes - **Dry run mode**: preview results with a CSV export and no changes @@ -189,6 +192,7 @@ the operation lock prevents concurrent runs and auto-expires after 10 minutes. | **Custom Ignore Tags** | string | (none) | Tags to strip before matching, for example `[Dead], (Backup)` | | **Wait for IPTV Checker Completion** | boolean | False | Hold a scheduled run until IPTV Checker has finished, so matching sees fresh stats | | **IPTV Checker Max Wait (hours)** | number | 2 | How long to wait before running anyway | +| **Run After IPTV Checker Scan** | boolean | False | When IPTV Checker finishes a scheduled scan, run the scheduled steps (Sort Alternate Streams and Match and Assign, whichever are ticked). IPTV Checker needs its own `Trigger Stream-Mapparr` setting on | | **Enable CSV Export** | boolean | True | Write a CSV on a scheduled Match and Assign run. A dry run always writes one | | **Delete CSV Exports Older Than (Days)** | number | 0 | Remove this plugin's older reports after each new one is written. 0, the default, keeps everything. Only files this plugin wrote are ever considered, because that directory is shared with other plugins. The newest report and the one just written always survive | | **Tag Handling** | select | Strip All | Strip All, Keep Regional, or Keep All | @@ -263,6 +267,15 @@ runs are coalesced under a lock and a follow-up pass catches any account that finishes mid-match. A multi-account refresh therefore produces one effective match rather than one per account. +**After an IPTV Checker scan**: **Run After IPTV Checker Scan** runs the same +steps the fixed times run (Sort Alternate Streams and Match and Assign, +whichever are ticked) when the IPTV Checker plugin finishes a scheduled scan +that reached the end of its channel list. IPTV Checker calls this plugin +directly, so its own **Trigger Stream-Mapparr After Scheduled Check** setting +must be on as well. A run already in progress is left alone and the next scan +tries again. The CSV header for such a run reads `Scheduled (after IPTV +Checker scan)`. + ## CSV Reports Preview and scheduled exports are saved to `/data/exports/` and include: diff --git a/Stream-Mapparr/__init__.py b/Stream-Mapparr/__init__.py index 798681c..cb08636 100644 --- a/Stream-Mapparr/__init__.py +++ b/Stream-Mapparr/__init__.py @@ -5,5 +5,5 @@ from .plugin import Plugin -__version__ = "1.26.2491549" +__version__ = "1.26.2561754" __all__ = ["Plugin"] \ No newline at end of file diff --git a/Stream-Mapparr/plugin.json b/Stream-Mapparr/plugin.json index 1894882..ee72c50 100644 --- a/Stream-Mapparr/plugin.json +++ b/Stream-Mapparr/plugin.json @@ -1,6 +1,6 @@ { "name": "Stream-Mapparr", - "version": "1.26.2491549", + "version": "1.26.2561754", "description": "Automatically add matching streams to channels based on name similarity and quality precedence. Supports unlimited stream matching, channel visibility management, and CSV export cleanup.", "author": "PiratesIRC", "license": "MIT", @@ -18,6 +18,11 @@ "m3u_refresh" ] }, + { + "id": "on_iptv_checker_scan", + "label": "Run after IPTV Checker scan", + "description": "Called by the IPTV Checker plugin when a scheduled scan finishes. Runs the scheduled steps when 'Run After IPTV Checker Scan' is enabled in settings." + }, { "id": "validate_settings", "label": "✅ Validate Settings", diff --git a/Stream-Mapparr/plugin.py b/Stream-Mapparr/plugin.py index 2ac4d6e..8ee8a8c 100644 --- a/Stream-Mapparr/plugin.py +++ b/Stream-Mapparr/plugin.py @@ -303,7 +303,7 @@ class PluginConfig: """ # === PLUGIN METADATA === - PLUGIN_VERSION = "1.26.2491549" + PLUGIN_VERSION = "1.26.2561754" FUZZY_MATCHER_MIN_VERSION = "25.358.0200" # Requires custom ignore tags Unicode fix # Match sensitivity presets (maps select value to threshold number) @@ -492,6 +492,8 @@ class PluginConfig: DEFAULT_FILTER_DEAD_STREAMS = False # Filter streams with 0x0 resolution (requires IPTV Checker) DEFAULT_WAIT_FOR_IPTV_CHECKER = False # Wait for IPTV Checker to complete before scheduled runs DEFAULT_IPTV_CHECKER_MAX_WAIT_HOURS = 6 # Maximum hours to wait for IPTV Checker + DEFAULT_RUN_AFTER_IPTV_CHECKER_SCAN = False # Run the scheduled sequence when IPTV Checker finishes a scheduled scan + IPTV_CHECKER_TRIGGER_LABEL = "IPTV Checker scan" # Names the trigger in logs and the CSV header IPTV_CHECKER_PROGRESS_FILE = "/data/iptv_checker_progress.json" # IPTV Checker progress file IPTV_CHECKER_CHECK_INTERVAL = 60 # Check IPTV Checker status every 60 seconds @@ -1279,6 +1281,13 @@ def fields(self): "default": PluginConfig.DEFAULT_IPTV_CHECKER_MAX_WAIT_HOURS, "help_text": "Maximum hours to wait for IPTV Checker to complete. If IPTV Checker is still running after this time, Stream-Mapparr will proceed anyway. Default: 6 hours.", }, + { + "id": "run_after_iptv_checker_scan", + "label": "\U0001f501 Run After IPTV Checker Scan", + "type": "boolean", + "default": PluginConfig.DEFAULT_RUN_AFTER_IPTV_CHECKER_SCAN, + "help_text": "When IPTV Checker finishes a scheduled scan, run the same steps the schedule below runs (Sort Alternate Streams and Match and Assign, whichever are ticked). IPTV Checker must have its 'Trigger Stream-Mapparr' setting on. A run already in progress is left alone and the next scan tries again.", + }, { "id": "_section_scheduling", "label": "⏰ Scheduling", @@ -1617,6 +1626,11 @@ def fields(self): "description": "Runs Match & Assign automatically after each M3U refresh when 'Auto-match after M3U refresh' is enabled in settings.", "events": ["m3u_refresh"], }, + { + "id": "on_iptv_checker_scan", + "label": "Run after IPTV Checker scan", + "description": "Called by the IPTV Checker plugin when a scheduled scan finishes. Runs the scheduled steps when 'Run After IPTV Checker Scan' is enabled in settings.", + }, ] # Use config values for quality tag ordering @@ -2315,72 +2329,7 @@ def scheduler_loop(): continue # skip THIS slot only; still evaluate other scheduled times LOGGER.info(f"[Stream-Mapparr] Scheduled scan triggered at {now.strftime('%Y-%m-%d %H:%M %Z')}") try: - # Step 0: Wait for IPTV Checker if enabled - wait_result = self._wait_for_iptv_checker_completion(settings, LOGGER) - if not wait_result: - LOGGER.warning("[Stream-Mapparr] IPTV Checker wait timed out, proceeding anyway") - - # Step 1: Load/Process Channels - LOGGER.info("[Stream-Mapparr] Step 1/2: Loading and processing channels...") - load_result = self.load_process_channels_action(settings, LOGGER) - - if load_result.get("status") == "success": - LOGGER.info(f"[Stream-Mapparr] {load_result.get('message', 'Channels loaded successfully')}") - - # Get scheduled task settings - do_sort = settings.get('scheduled_sort_streams', False) - if isinstance(do_sort, str): - do_sort = do_sort.lower() in ('true', 'yes', '1') - - do_match = settings.get('scheduled_match_streams', True) - if isinstance(do_match, str): - do_match = do_match.lower() in ('true', 'yes', '1') - - step = 2 - total_steps = (2 if do_sort else 0) + (1 if do_match else 0) + 1 - - # Step 2: Sort Streams (if enabled) - if do_sort: - LOGGER.info(f"[Stream-Mapparr] Step {step}/{total_steps}: Sorting alternate streams...") - sort_result = self.sort_streams_action(settings, LOGGER, is_scheduled=True) - - if sort_result.get("status") == "success": - LOGGER.info(f"[Stream-Mapparr] {sort_result.get('message', 'Streams sorted successfully')}") - else: - LOGGER.error(f"[Stream-Mapparr] Failed to sort streams: {sort_result.get('message', 'Unknown error')}") - - step += 1 - - # Step 3: Match & Assign Streams (if enabled) - if do_match: - LOGGER.info(f"[Stream-Mapparr] Step {step}/{total_steps}: Matching and assigning streams...") - add_result = self.add_streams_to_channels_action(settings, LOGGER, is_scheduled=True) - - if add_result.get("status") == "success": - LOGGER.info(f"[Stream-Mapparr] {add_result.get('message', 'Streams added successfully')}") - else: - LOGGER.error(f"[Stream-Mapparr] Failed to add streams: {add_result.get('message', 'Unknown error')}") - - # Record that the SCHEDULED path completed a - # run. Placed here, after both sub-steps and - # outside either branch, because do_sort and - # do_match are independent: a Sort-only - # schedule is a healthy schedule and must not - # report "never recorded" forever. Never - # written by an action or the button, which - # is the whole point of the signal. - try: - bridge = self._notify_bridge() - bridge.write_scheduled_run_ts( - bridge.SCHEDULED_RUN_FILE, time.time()) - except Exception as stamp_err: - LOGGER.debug(f"[Stream-Mapparr] Could not record the " - f"scheduled run timestamp: {stamp_err}") - - LOGGER.info("[Stream-Mapparr] Scheduled run completed successfully") - else: - LOGGER.error(f"[Stream-Mapparr] Failed to load channels: {load_result.get('message', 'Unknown error')}") - LOGGER.error("[Stream-Mapparr] Scheduled run aborted - cannot proceed without channel data") + self._run_scheduled_sequence(settings, LOGGER) except Exception as e: LOGGER.error(f"[Stream-Mapparr] Error in scheduled scan: {e}") @@ -6704,6 +6653,8 @@ def run(self, action, settings, context=None): if action == "on_m3u_refresh": return self.on_m3u_refresh_action(settings, logger, context) + if action == "on_iptv_checker_scan": + return self.on_iptv_checker_scan_action(settings, logger, context) # If settings is empty but context has settings, use context settings if context and isinstance(context, dict) and not settings: @@ -7468,7 +7419,7 @@ def _prune_csv_exports(self, retention_days, protect=None): LOGGER.warning(f"[Stream-Mapparr] Could not delete old CSV export {name}: {exc}") return removed - def _generate_csv_header_comment(self, settings, processed_data, action_name="Unknown", is_scheduled=False, total_visible_channels=None, total_matched_streams=None, low_match_channels=None, threshold_data=None): + def _generate_csv_header_comment(self, settings, processed_data, action_name="Unknown", is_scheduled=False, total_visible_channels=None, total_matched_streams=None, low_match_channels=None, threshold_data=None, trigger=None): """Generate CSV comment header with plugin version and settings info.""" # Debug: Log all settings keys to see what's available LOGGER.debug(f"[Stream-Mapparr] CSV generation - All settings keys: {list(settings.keys())}") @@ -7479,6 +7430,13 @@ def _generate_csv_header_comment(self, settings, processed_data, action_name="Un selected_m3us = processed_data.get('selected_m3us', []) current_threshold = self._resolve_match_threshold(settings) + # A triggered run is scheduled work started by another plugin; say so, + # because a report that calls itself Scheduled when the timer did not + # fire is the same lie this header told before 2026-09-05. + execution_mode = 'Scheduled' if is_scheduled else 'Manual' + if is_scheduled and trigger: + execution_mode = f"Scheduled (after {trigger})" + # Build header with all settings except login credentials header_lines = [ f"# Stream-Mapparr Export v{self.version}", @@ -7497,7 +7455,7 @@ def _generate_csv_header_comment(self, settings, processed_data, action_name="Un "#", "# === Action Performed ===", f"# Action: {action_name}", - f"# Execution Mode: {'Scheduled' if is_scheduled else 'Manual'}", + f"# Execution Mode: {execution_mode}", f"# Dry Run Mode: {_yes_no(settings.get('dry_run_mode'))} (Yes means nothing was written to the database)", "#", ] @@ -8122,7 +8080,114 @@ def on_m3u_refresh_action(self, settings_arg, logger, context): return result - def add_streams_to_channels_action(self, settings, logger, is_scheduled=False, context=None): + def _run_scheduled_sequence(self, settings, logger, trigger=None): + """Load channels, then Sort and Match and Assign as the schedule toggles say. + + Shared by the timer loop and the IPTV Checker trigger so the two paths + cannot drift. `trigger` is None for the timer and a label otherwise; it + is threaded as a parameter, never stored on the instance (bug-139). + The wait for IPTV Checker and the scheduled-run timestamp belong to the + timer path only: a triggered run starts because the scan just finished, + and the timestamp means "the timer fired". + """ + if trigger is None: + if not self._wait_for_iptv_checker_completion(settings, logger): + logger.warning("[Stream-Mapparr] IPTV Checker wait timed out, proceeding anyway") + else: + logger.info(f"[Stream-Mapparr] Scheduled steps triggered by {trigger}") + + logger.info("[Stream-Mapparr] Step 1/2: Loading and processing channels...") + load_result = self.load_process_channels_action(settings, logger) + if load_result.get("status") != "success": + logger.error(f"[Stream-Mapparr] Failed to load channels: {load_result.get('message', 'Unknown error')}") + logger.error("[Stream-Mapparr] Scheduled run aborted - cannot proceed without channel data") + return load_result + logger.info(f"[Stream-Mapparr] {load_result.get('message', 'Channels loaded successfully')}") + + do_sort = self._get_bool_setting(settings, 'scheduled_sort_streams', False) + do_match = self._get_bool_setting(settings, 'scheduled_match_streams', True) + + step = 2 + total_steps = (2 if do_sort else 0) + (1 if do_match else 0) + 1 + + if do_sort: + logger.info(f"[Stream-Mapparr] Step {step}/{total_steps}: Sorting alternate streams...") + sort_result = self.sort_streams_action(settings, logger, is_scheduled=True, trigger=trigger) + if sort_result.get("status") == "success": + logger.info(f"[Stream-Mapparr] {sort_result.get('message', 'Streams sorted successfully')}") + else: + logger.error(f"[Stream-Mapparr] Failed to sort streams: {sort_result.get('message', 'Unknown error')}") + step += 1 + + if do_match: + logger.info(f"[Stream-Mapparr] Step {step}/{total_steps}: Matching and assigning streams...") + add_result = self.add_streams_to_channels_action(settings, logger, is_scheduled=True, trigger=trigger) + if add_result.get("status") == "success": + logger.info(f"[Stream-Mapparr] {add_result.get('message', 'Streams added successfully')}") + else: + logger.error(f"[Stream-Mapparr] Failed to add streams: {add_result.get('message', 'Unknown error')}") + + if trigger is None: + # Record that the SCHEDULED path completed a run. Placed after both + # sub-steps and outside either branch, because do_sort and do_match + # are independent: a Sort-only schedule is a healthy schedule and + # must not report "never recorded" forever. Never written by an + # action, the button or a triggered run, which is the whole point + # of the signal. + try: + bridge = self._notify_bridge() + bridge.write_scheduled_run_ts(bridge.SCHEDULED_RUN_FILE, time.time()) + except Exception as stamp_err: + logger.debug(f"[Stream-Mapparr] Could not record the scheduled run timestamp: {stamp_err}") + + logger.info("[Stream-Mapparr] Scheduled run completed successfully") + return {"status": "success"} + + def _should_run_after_iptv_checker_scan(self, settings): + """Pure gate for the IPTV Checker trigger: the setting alone, no ORM.""" + return self._get_bool_setting(settings, "run_after_iptv_checker_scan", + PluginConfig.DEFAULT_RUN_AFTER_IPTV_CHECKER_SCAN) + + def on_iptv_checker_scan_action(self, settings_arg, logger, context): + """Handler the IPTV Checker plugin calls when a scheduled scan finishes. + + Reached through PluginManager.run_action, so the 2nd argument is the + event dict and the real settings are in context["settings"], as for the + M3U refresh handler. Runs the scheduled steps SYNCHRONOUSLY in the + caller's thread. A run already holding the operation lock is left alone + and nothing is queued: the next scan fires again. Returns None when + nothing ran so the caller gets no toast noise. + """ + real_settings = context.get("settings", {}) if isinstance(context, dict) else {} + if not self._should_run_after_iptv_checker_scan(real_settings): + return None + + payload = settings_arg.get("payload") if isinstance(settings_arg, dict) else {} + payload = payload or {} + checked = payload.get("streams_checked") + trigger = PluginConfig.IPTV_CHECKER_TRIGGER_LABEL + + # run() resets this in _execute_with_progress, which this direct path bypasses. + self._op_total_items = None + + is_locked, _info = self._check_operation_lock(logger) + if is_locked: + logger.info(f"[Stream-Mapparr] [{trigger}] a manual or scheduled operation is running; skipping this run") + return None + if not self._acquire_operation_lock("sort_streams", logger): + return None + try: + logger.info(f"[Stream-Mapparr] [{trigger}] scan finished (streams checked: {checked}); running the scheduled steps") + return self._run_scheduled_sequence(real_settings, logger, trigger=trigger) + except Exception as exc: + logger.error(f"[Stream-Mapparr] [{trigger}] run failed: {exc}") + import traceback + logger.error(f"[Stream-Mapparr] Traceback: {traceback.format_exc()}") + return None + finally: + self._release_operation_lock(logger) + + def add_streams_to_channels_action(self, settings, logger, is_scheduled=False, context=None, trigger=None): """Add matching streams to channels and replace existing stream assignments.""" # Check dry run mode dry_run = settings.get('dry_run_mode', False) @@ -8452,6 +8517,7 @@ def add_streams_to_channels_action(self, settings, logger, is_scheduled=False, c header_comment = self._generate_csv_header_comment(settings, processed_data, action_name="Match & Assign Streams", is_scheduled=is_scheduled, + trigger=trigger, total_visible_channels=channels_updated, total_matched_streams=total_streams_added, low_match_channels=low_match_channels, @@ -8895,7 +8961,7 @@ def match_us_ota_only_action(self, settings, logger, context=None): "match_us_ota_only", success_count, ota_streams_assigned, dry_run=dry_run) - def sort_streams_action(self, settings, logger, context=None, is_scheduled=False): + def sort_streams_action(self, settings, logger, context=None, is_scheduled=False, trigger=None): """Sort existing alternate streams by quality for all channels""" try: # Check dry run mode @@ -9203,6 +9269,7 @@ def sort_streams_action(self, settings, logger, context=None, is_scheduled=False processed_data_for_header, action_name="Sort Alternate Streams", is_scheduled=is_scheduled, + trigger=trigger, total_visible_channels=sorted_count, total_matched_streams=0 ) diff --git a/tests/test_action_buttons.py b/tests/test_action_buttons.py index dca8992..025bec9 100644 --- a/tests/test_action_buttons.py +++ b/tests/test_action_buttons.py @@ -41,9 +41,10 @@ os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "Stream-Mapparr", "plugin.json") -# The event handler Dispatcharr invokes after an M3U refresh. It is not a button -# and must never grow one. -EVENT_HANDLER = "on_m3u_refresh" +# The handlers other code invokes: Dispatcharr after an M3U refresh, and the +# IPTV Checker plugin after a scheduled scan. Neither is a button and neither +# must ever grow one. +EVENT_HANDLERS = ("on_m3u_refresh", "on_iptv_checker_scan") EXPECTED_COLOURS = { # red: can remove streams or take channels off air @@ -83,7 +84,7 @@ def _manifest_actions(): def _pressable(actions): - return [a for a in actions if a.get("id") != EVENT_HANDLER] + return [a for a in actions if a.get("id") not in EVENT_HANDLERS] # --------------------------------------------------------------------------- # @@ -95,9 +96,10 @@ def test_every_pressable_action_has_a_button_colour(plugin_module): assert missing == [], f"actions with no button_color: {missing}" -def test_the_event_handler_has_no_button_colour(plugin_module): - """It is invoked by Dispatcharr, never pressed, so it must not look pressable.""" - handler = next(a for a in _actions(plugin_module) if a["id"] == EVENT_HANDLER) +@pytest.mark.parametrize("handler_id", EVENT_HANDLERS) +def test_the_event_handler_has_no_button_colour(plugin_module, handler_id): + """It is invoked by other code, never pressed, so it must not look pressable.""" + handler = next(a for a in _actions(plugin_module) if a["id"] == handler_id) assert "button_color" not in handler diff --git a/tests/test_action_order.py b/tests/test_action_order.py index 949fd27..fbec573 100644 --- a/tests/test_action_order.py +++ b/tests/test_action_order.py @@ -12,6 +12,7 @@ sorting before probing sorts against nothing. The previous order listed Sort before Probe. """ +import pytest def _actions(plugin_module): @@ -50,6 +51,7 @@ def _ids(plugin_module): "report_a_bug", # Not a button: Dispatcharr invokes this after an M3U source refreshes "on_m3u_refresh", + "on_iptv_checker_scan", ] @@ -79,11 +81,12 @@ def test_match_and_assign_comes_before_both(plugin_module): assert ids.index("add_streams_to_channels") < ids.index("probe_throughput") -def test_the_event_handler_is_last_because_it_is_not_a_button(plugin_module): - """Dispatcharr invokes it after an M3U source refreshes. It must stay - registered and must keep a label or the action normaliser drops it, but - nobody should press it.""" - assert _ids(plugin_module)[-1] == "on_m3u_refresh" +def test_the_event_handlers_are_last_because_they_are_not_buttons(plugin_module): + """Dispatcharr invokes one after an M3U source refreshes and the IPTV + Checker plugin invokes the other after a scheduled scan. Both must stay + registered and must keep a label or the action normaliser drops them, but + nobody should press either.""" + assert _ids(plugin_module)[-2:] == ["on_m3u_refresh", "on_iptv_checker_scan"] def test_every_PRESSABLE_action_has_a_short_button_label(plugin_module): @@ -97,13 +100,15 @@ def test_every_PRESSABLE_action_has_a_short_button_label(plugin_module): while reordering these buttons is exactly what that test caught. """ missing = [a["id"] for a in _actions(plugin_module) - if a["id"] != "on_m3u_refresh" and not a.get("button_label")] + if a["id"] not in ("on_m3u_refresh", "on_iptv_checker_scan") + and not a.get("button_label")] assert missing == [], f"actions with no button_label: {missing}" -def test_the_event_handler_still_has_NO_button_label(plugin_module): +@pytest.mark.parametrize("handler_id", ["on_m3u_refresh", "on_iptv_checker_scan"]) +def test_the_event_handler_still_has_NO_button_label(plugin_module, handler_id): """Pins the hiding mechanism from the other direction.""" - handler = next(a for a in _actions(plugin_module) if a["id"] == "on_m3u_refresh") + handler = next(a for a in _actions(plugin_module) if a["id"] == handler_id) assert "button_label" not in handler diff --git a/tests/test_iptv_checker_trigger.py b/tests/test_iptv_checker_trigger.py new file mode 100644 index 0000000..a183ee4 --- /dev/null +++ b/tests/test_iptv_checker_trigger.py @@ -0,0 +1,208 @@ +"""The post-scan trigger: iptv_checker calls on_iptv_checker_scan after a scheduled scan. + +Dispatcharr's connect event bus refuses event names outside its fixed list, so +the sibling plugin calls PluginManager.run_action directly. This file pins the +receiving side: the gate, the lock-held skip, the shared scheduled sequence and +the CSV header wording. +""" +import logging +import types + +log = logging.getLogger("t") + +TRIGGER = "IPTV Checker scan" + + +def _plugin(plugin_module): + p = plugin_module.Plugin.__new__(plugin_module.Plugin) + p.version = "test" + return p + + +# --------------------------------------------------------------------------- # +# Interface +# --------------------------------------------------------------------------- # +def test_the_setting_sits_inside_the_iptv_checker_section(plugin_module): + ids = [f.get("id") for f in _plugin(plugin_module).fields] + start = ids.index("_section_iptv_checker") + end = ids.index("_section_scheduling") + assert "run_after_iptv_checker_scan" in ids[start:end] + + +def test_the_setting_defaults_off(plugin_module): + field = next(f for f in _plugin(plugin_module).fields + if f.get("id") == "run_after_iptv_checker_scan") + assert field["type"] == "boolean" + assert field["default"] is False + + +def test_the_handler_action_is_served_without_a_button_or_an_event(plugin_module): + action = next(a for a in _plugin(plugin_module).actions + if a["id"] == "on_iptv_checker_scan") + assert action.get("label") + assert "button_color" not in action + assert "events" not in action + + +def test_the_gate_reads_the_setting(plugin_module): + p = _plugin(plugin_module) + assert p._should_run_after_iptv_checker_scan({"run_after_iptv_checker_scan": True}) is True + assert p._should_run_after_iptv_checker_scan({"run_after_iptv_checker_scan": "true"}) is True + assert p._should_run_after_iptv_checker_scan({"run_after_iptv_checker_scan": False}) is False + assert p._should_run_after_iptv_checker_scan({}) is False + + +# --------------------------------------------------------------------------- # +# The handler +# --------------------------------------------------------------------------- # +def _wire(monkeypatch, p, locked=False): + calls = [] + monkeypatch.setattr(p, "_check_operation_lock", lambda logger: (locked, {})) + monkeypatch.setattr(p, "_acquire_operation_lock", lambda name, logger: True) + monkeypatch.setattr(p, "_release_operation_lock", lambda logger: calls.append("released")) + monkeypatch.setattr( + p, "_run_scheduled_sequence", + lambda settings, logger, trigger=None: + calls.append(("run", settings, trigger)) or {"status": "success"}) + return calls + + +def test_the_handler_does_nothing_when_the_setting_is_off(plugin_module, monkeypatch): + p = _plugin(plugin_module) + calls = _wire(monkeypatch, p) + ctx = {"settings": {"run_after_iptv_checker_scan": False}} + assert p.on_iptv_checker_scan_action({"event": "x", "payload": {}}, log, ctx) is None + assert calls == [] + + +def test_the_handler_reads_settings_from_the_context_not_the_params(plugin_module, monkeypatch): + """run_action passes the event dict as the 2nd argument, the real settings in context.""" + p = _plugin(plugin_module) + calls = _wire(monkeypatch, p) + real = {"run_after_iptv_checker_scan": True, "scheduled_sort_streams": True} + p.on_iptv_checker_scan_action({"event": "x", "payload": {"streams_checked": 3}}, log, + {"settings": real}) + assert calls == [("run", real, TRIGGER), "released"] + + +def test_the_handler_skips_when_another_operation_holds_the_lock(plugin_module, monkeypatch): + p = _plugin(plugin_module) + calls = _wire(monkeypatch, p, locked=True) + ctx = {"settings": {"run_after_iptv_checker_scan": True}} + assert p.on_iptv_checker_scan_action({}, log, ctx) is None + assert calls == [] + + +def test_the_handler_releases_the_lock_when_the_sequence_raises(plugin_module, monkeypatch): + p = _plugin(plugin_module) + calls = _wire(monkeypatch, p) + + def boom(settings, logger, trigger=None): + raise RuntimeError("sequence failed") + monkeypatch.setattr(p, "_run_scheduled_sequence", boom) + ctx = {"settings": {"run_after_iptv_checker_scan": True}} + result = p.on_iptv_checker_scan_action({}, log, ctx) + assert result is None + assert calls == ["released"] + + +def test_run_dispatches_the_handler_before_the_generic_path(plugin_module, monkeypatch): + p = _plugin(plugin_module) + seen = [] + monkeypatch.setattr(p, "on_iptv_checker_scan_action", + lambda s, l, c: seen.append((s, c)) or {"status": "success"}) + params = {"event": "iptv_checker_scan_complete", "payload": {}} + ctx = {"settings": {"run_after_iptv_checker_scan": True}} + assert p.run("on_iptv_checker_scan", params, ctx) == {"status": "success"} + assert seen == [(params, ctx)] + + +# --------------------------------------------------------------------------- # +# The shared scheduled sequence +# --------------------------------------------------------------------------- # +def _wire_sequence(monkeypatch, p, load_ok=True): + calls = [] + monkeypatch.setattr(p, "_wait_for_iptv_checker_completion", + lambda settings, logger: calls.append("wait") or True) + monkeypatch.setattr( + p, "load_process_channels_action", + lambda settings, logger, context=None: calls.append("load") or + {"status": "success" if load_ok else "error", "message": "m"}) + monkeypatch.setattr( + p, "sort_streams_action", + lambda settings, logger, context=None, is_scheduled=False, trigger=None: + calls.append(("sort", is_scheduled, trigger)) or {"status": "success"}) + monkeypatch.setattr( + p, "add_streams_to_channels_action", + lambda settings, logger, is_scheduled=False, context=None, trigger=None: + calls.append(("match", is_scheduled, trigger)) or {"status": "success"}) + stamps = [] + bridge = types.SimpleNamespace(SCHEDULED_RUN_FILE="/x", + write_scheduled_run_ts=lambda path, ts: stamps.append(path)) + monkeypatch.setattr(p, "_notify_bridge", lambda: bridge) + return calls, stamps + + +def test_the_sequence_honours_the_schedule_toggles(plugin_module, monkeypatch): + p = _plugin(plugin_module) + calls, _ = _wire_sequence(monkeypatch, p) + settings = {"scheduled_sort_streams": True, "scheduled_match_streams": False} + result = p._run_scheduled_sequence(settings, log, trigger=TRIGGER) + assert result["status"] == "success" + assert calls == ["load", ("sort", True, TRIGGER)] + + +def test_the_sequence_runs_match_when_that_toggle_is_on(plugin_module, monkeypatch): + p = _plugin(plugin_module) + calls, _ = _wire_sequence(monkeypatch, p) + settings = {"scheduled_sort_streams": "false", "scheduled_match_streams": "true"} + p._run_scheduled_sequence(settings, log) + assert calls == ["wait", "load", ("match", True, None)] + + +def test_the_timer_path_waits_for_the_checker_and_the_triggered_path_does_not(plugin_module, monkeypatch): + p = _plugin(plugin_module) + calls, _ = _wire_sequence(monkeypatch, p) + p._run_scheduled_sequence({}, log) + assert calls[0] == "wait" + calls.clear() + p._run_scheduled_sequence({}, log, trigger=TRIGGER) + assert "wait" not in calls + + +def test_only_the_timer_path_writes_the_scheduled_run_timestamp(plugin_module, monkeypatch): + """The timestamp means 'the timer fired'; a scan-triggered run must not fake it.""" + p = _plugin(plugin_module) + _, stamps = _wire_sequence(monkeypatch, p) + p._run_scheduled_sequence({"scheduled_sort_streams": True}, log) + assert stamps == ["/x"] + p._run_scheduled_sequence({"scheduled_sort_streams": True}, log, trigger=TRIGGER) + assert stamps == ["/x"] + + +def test_the_sequence_stops_when_channels_fail_to_load(plugin_module, monkeypatch): + p = _plugin(plugin_module) + calls, stamps = _wire_sequence(monkeypatch, p, load_ok=False) + result = p._run_scheduled_sequence({"scheduled_sort_streams": True}, log) + assert result["status"] == "error" + assert calls == ["wait", "load"] + assert stamps == [] + + +# --------------------------------------------------------------------------- # +# The CSV header says who started the run +# --------------------------------------------------------------------------- # +def _mode_line(plugin_module, **kw): + p = _plugin(plugin_module) + header = p._generate_csv_header_comment({}, {}, action_name="Sort", **kw) + return next(line for line in header.splitlines() if line.startswith("# Execution Mode:")) + + +def test_a_triggered_run_names_its_trigger_in_the_header(plugin_module): + line = _mode_line(plugin_module, is_scheduled=True, trigger=TRIGGER) + assert "Scheduled" in line and TRIGGER in line + + +def test_a_timer_run_does_not_mention_a_trigger(plugin_module): + line = _mode_line(plugin_module, is_scheduled=True) + assert "Scheduled" in line and "after" not in line diff --git a/tests/test_notify_wiring.py b/tests/test_notify_wiring.py index 9877ec0..33bb0f0 100644 --- a/tests/test_notify_wiring.py +++ b/tests/test_notify_wiring.py @@ -13,13 +13,17 @@ def test_the_scheduler_stamps_the_run_not_the_match_substep(plugin_module): toggles. An operator who schedules Sort only has a healthy schedule, and it must not report "never recorded" forever. - The scheduler is a nested function named scheduler_loop defined inside - _start_background_scheduler_locked, not a method, so reading the enclosing - method is the only way to see it. + Since 2026-09-13 the loop delegates to _run_scheduled_sequence, shared + with the IPTV Checker trigger, so the stamp lives there and is written only + on the timer path (trigger is None). """ loop = inspect.getsource(plugin_module.Plugin._start_background_scheduler_locked) - assert "write_scheduled_run_ts" in loop, ( - "the scheduler loop must record that a scheduled run completed" + assert "_run_scheduled_sequence" in loop, ( + "the scheduler loop must run the shared scheduled sequence" + ) + sequence = inspect.getsource(plugin_module.Plugin._run_scheduled_sequence) + assert "write_scheduled_run_ts" in sequence, ( + "the shared sequence must record that a scheduled run completed" ) match_action = inspect.getsource(plugin_module.Plugin.add_streams_to_channels_action) assert "write_scheduled_run_ts" not in match_action, (