diff --git a/Lineuparr/plugin.py b/Lineuparr/plugin.py index f00e44e..6fed465 100644 --- a/Lineuparr/plugin.py +++ b/Lineuparr/plugin.py @@ -5,6 +5,7 @@ """ import copy +import hashlib import logging import json import csv @@ -13,6 +14,7 @@ import time import fnmatch import threading +import uuid from datetime import datetime, timedelta from glob import glob @@ -216,6 +218,22 @@ class PluginConfig: OPERATION_LOCK_FILE = "/data/lineuparr_operation.lock" OPERATION_LOCK_TIMEOUT_MINUTES = 10 + # Automatic Full Sync (issue #31). A separate file from STATE_FILE: that + # one is overwritten wholesale by several unrelated steps (e.g. the stream + # match step inside a Full Sync itself), so a scheduled-run marker written + # there would be clobbered before anyone saw it. + SCHEDULE_STATE_FILE = "/data/lineuparr_schedule_state.json" + # django-celery-beat PeriodicTask row name (must be unique) and the Celery + # task name it points at. Namespaced so they cannot collide with another + # plugin's schedule. + SCHEDULE_TASK_NAME = "Lineuparr: Automatic Full Sync" + SCHEDULED_TASK_CELERY_NAME = "lineuparr.scheduled_full_sync" + # A hash of whatever _reconcile_schedule last actually wrote to the + # PeriodicTask row, so it can skip that write (and the query it takes) + # on an action dispatch where nothing schedule-relevant changed, instead + # of hitting the database on every single plugin action. + SCHEDULE_RECONCILE_HASH_FILE = "/data/lineuparr_schedule_reconcile_hash.json" + # Simplified category mapping: simplified name -> list of original categories to merge SIMPLIFIED_CATEGORIES = { "News & Info": ["News", "Discovery", "Crime"], @@ -363,6 +381,11 @@ def wait(self): class Plugin: name = "Lineuparr" version = PluginConfig.PLUGIN_VERSION + # Class-level fallbacks for the lock reentrancy state __init__ sets below, + # so a test (or anything else) building a Plugin via Plugin.__new__(Plugin) + # - bypassing __init__ - still finds these attributes defined. + _lock_owner = None + _lock_depth = 0 def __init__(self): self._thread = None @@ -374,6 +397,14 @@ def __init__(self): # sub-steps don't each fire a channel-list refresh; the parent # operation fires exactly one refresh when it finishes. self._suppress_refresh = False + # Operation lock reentrancy: set while this instance holds + # OPERATION_LOCK_FILE, so its own nested calls (a scheduled Full + # Sync's groups/channels/stream/EPG/logo steps, all on the one + # Plugin() instance that started it) extend the same lock instead + # of finding it already held and rejecting themselves. See + # _acquire_lock. + self._lock_owner = None + self._lock_depth = 0 def _try_start_thread(self, target, args): """Atomically check if a thread is running and start a new one. @@ -396,6 +427,11 @@ def _run_then_report(*call_args): @property def fields(self): """Dynamically generate field definitions with current options.""" + # Dispatcharr's own configured time zone, offered as this field's + # default so a fresh install schedules in the box's actual zone + # without the operator needing to already know its IANA name. + detected_timezone = self._detected_timezone() + # Discover lineup files plugin_dir = os.path.dirname(__file__) lineup_options = [] @@ -661,6 +697,77 @@ def fields(self): ], "help_text": "A notification carries one attachment, so choosing both sends two emails per run.", }, + # --- Section: Automation --- + { + "id": "_sec_automation", + "type": "info", + "label": "Automation", + "help_text": "Optionally run Full Sync on a schedule instead of only when you click it. Off by default. A scheduled run reuses the exact settings saved here at the time it fires, and takes a snapshot of them the next time any Lineuparr action runs after you save - including this settings page's own Validate Settings check - so save your settings and then run any action once to pick up a change. If another Lineuparr operation is already in progress when the scheduled time arrives, the scheduled run is skipped rather than overlapping it.", + }, + { + "id": "auto_full_sync", + "label": "Automatic Full Sync", + "type": "select", + "default": "off", + "options": [ + {"value": "off", "label": "Off"}, + {"value": "daily", "label": "Daily"}, + {"value": "weekly", "label": "Weekly"}, + {"value": "monthly", "label": "Monthly"}, + {"value": "custom", "label": "Custom (cron expression)"}, + ], + "help_text": "Run Full Sync automatically on this schedule. Uses the same process as the manual Full Sync button (groups, channels, stream matching, EPG matching, logo assignment). Monthly uses Sync Day Of Month below; Custom ignores Sync Time/Sync Day/Sync Day Of Month entirely and uses the Custom Cron Expression instead.", + }, + { + "id": "auto_full_sync_time", + "label": "Sync Time", + "type": "string", + "default": "03:00", + "placeholder": "HH:MM (24-hour)", + "help_text": "Time of day the scheduled Full Sync runs, 24-hour HH:MM, in the Time Zone set below. Ignored while Automatic Full Sync is Off.", + }, + { + "id": "auto_full_sync_timezone", + "label": "Time Zone", + "type": "string", + "default": detected_timezone, + "placeholder": "e.g. America/New_York, Europe/London", + "help_text": f"IANA time zone name Sync Time is interpreted in. Pre-filled with {detected_timezone}, detected from Dispatcharr's own configured time zone - change it if it is wrong, or if you want the schedule on a different zone than the server itself. Falls back to UTC if left blank or set to something not recognized, which is not necessarily the server's own zone, so an unrecognized value here can run the sync at the wrong hour.", + }, + { + "id": "auto_full_sync_day", + "label": "Sync Day", + "type": "select", + "default": "monday", + "options": [ + {"value": "monday", "label": "Monday"}, + {"value": "tuesday", "label": "Tuesday"}, + {"value": "wednesday", "label": "Wednesday"}, + {"value": "thursday", "label": "Thursday"}, + {"value": "friday", "label": "Friday"}, + {"value": "saturday", "label": "Saturday"}, + {"value": "sunday", "label": "Sunday"}, + ], + "help_text": "Day of the week the scheduled Full Sync runs. Only used when Automatic Full Sync is set to Weekly.", + }, + { + "id": "auto_full_sync_day_of_month", + "label": "Sync Day Of Month", + "type": "number", + "default": 1, + "min": 1, + "max": 31, + "step": 1, + "help_text": "Day of the month the scheduled Full Sync runs, using Sync Time above. Only used when Automatic Full Sync is set to Monthly. A month shorter than this day (e.g. 31 in April) is simply skipped that month, same as standard cron behavior.", + }, + { + "id": "auto_full_sync_cron", + "label": "Custom Cron Expression", + "type": "string", + "default": "", + "placeholder": "minute hour day-of-month month day-of-week, e.g. 0 3 * * 0", + "help_text": "Standard 5-field cron expression (minute, hour, day of month, month, day of week), evaluated in the Time Zone setting above. Only used when Automatic Full Sync is set to Custom - Sync Time, Sync Day and Sync Day Of Month are all ignored in that mode. Leave the other three fields alone and use this for anything the presets above cannot express, such as twice a day or a specific week of the month.", + }, # --- Section: Advanced --- { "id": "_sec_advanced", @@ -706,6 +813,14 @@ def run(self, action, params, context): logger = context.get("logger", LOGGER) settings = context.get("settings", {}) + # Keep the Automatic Full Sync schedule in sync with current settings + # on every action, not just this one - see _reconcile_schedule for why. + # Never allowed to block or fail the action actually requested. + try: + self._reconcile_schedule(settings, logger) + except Exception as e: + logger.warning(f"{LOG_PREFIX} Automatic Full Sync schedule reconciliation failed: {e}") + try: action_map = { "validate_settings": self._validate_settings, @@ -774,6 +889,11 @@ def _plugin_status(self, settings, logger): the user can check progress on demand without reading the logs.""" progress = load_progress(PluginConfig.PROGRESS_FILE) message = build_status_message(progress) + + schedule_row = self._schedule_status_row(settings) + if schedule_row["Value"] != "Off": + message += f"\n\n{schedule_row['Setting']} ({schedule_row['Value']}): {schedule_row['Status']}" + logger.info(f"{LOG_PREFIX} Status requested: {message.splitlines()[0]}") return {"status": "ok", "message": message} @@ -1684,12 +1804,33 @@ def get_quality_score(stream): return sorted(streams, key=get_quality_score) def _acquire_lock(self, logger): - """Acquire operation lock. Returns True if acquired.""" + """Acquire the operation lock. Returns True if acquired. + + Reentrant per Plugin instance via an owner token stored in the lock + file: once this instance holds the lock, a further call on the same + instance recognizes its own token and extends the lock (bumping + _lock_depth) instead of finding it already held and rejecting + itself. This is what lets run_scheduled_full_sync hold the lock for + the whole of _do_full_sync while that method's own groups, channels, + stream match, EPG match and logo assignment steps each call + _acquire_lock/_release_lock around their own work exactly like a + manual click does - on the one Plugin() instance the scheduled task + created for this run. A manual action runs on a different Plugin + instance (Dispatcharr's own loader owns that one), so it is a + genuinely different owner and is rejected normally while a run is in + progress, same as before. + """ lock_file = PluginConfig.OPERATION_LOCK_FILE + owner = self._lock_owner try: if os.path.exists(lock_file): with open(lock_file, 'r') as f: lock_data = json.load(f) + if owner and lock_data.get('owner') == owner: + self._lock_depth += 1 + self._write_lock_file(lock_file, owner) + return True + lock_time = datetime.fromisoformat(lock_data.get('timestamp', '')) elapsed = (datetime.now() - lock_time).total_seconds() / 60 if elapsed < PluginConfig.OPERATION_LOCK_TIMEOUT_MINUTES: @@ -1697,19 +1838,37 @@ def _acquire_lock(self, logger): return False logger.warning(f"{LOG_PREFIX} Stale lock detected ({elapsed:.0f}m old), overriding") - os.makedirs(os.path.dirname(lock_file), exist_ok=True) - tmp = lock_file + ".tmp" - with open(tmp, 'w') as f: - json.dump({"timestamp": datetime.now().isoformat(), "action": "lineuparr"}, f) - os.replace(tmp, lock_file) + owner = owner or uuid.uuid4().hex + self._lock_owner = owner + self._lock_depth = 1 + self._write_lock_file(lock_file, owner) return True except Exception as e: logger.error(f"{LOG_PREFIX} Failed to acquire lock: {e}") return False + @staticmethod + def _write_lock_file(lock_file, owner): + os.makedirs(os.path.dirname(lock_file), exist_ok=True) + tmp = lock_file + ".tmp" + with open(tmp, 'w') as f: + json.dump({"timestamp": datetime.now().isoformat(), "action": "lineuparr", "owner": owner}, f) + os.replace(tmp, lock_file) + def _release_lock(self, logger): - """Release operation lock.""" + """Release the operation lock, respecting reentrant depth. + + An inner release (a Full Sync sub-step finishing while + run_scheduled_full_sync's own outer acquire is still holding the + lock for this same run) only decrements _lock_depth; the file is + only removed once the outermost acquire's matching release runs. + """ try: + if self._lock_depth > 1: + self._lock_depth -= 1 + return + self._lock_depth = 0 + self._lock_owner = None if os.path.exists(PluginConfig.OPERATION_LOCK_FILE): os.remove(PluginConfig.OPERATION_LOCK_FILE) except Exception as e: @@ -2291,6 +2450,310 @@ def _save_state(self, state_data, logger): except Exception as e: logger.error(f"{LOG_PREFIX} Failed to save state: {e}") + def _save_schedule_state(self, state_data, logger): + """Save the last Automatic Full Sync outcome atomically, for Validate + Settings / Plugin Status to show. A separate file from STATE_FILE: + that one is overwritten wholesale by several unrelated steps (the + stream-match step inside a Full Sync included), so a scheduled-run + marker written there would be clobbered before anyone saw it.""" + try: + os.makedirs(os.path.dirname(PluginConfig.SCHEDULE_STATE_FILE), exist_ok=True) + tmp = PluginConfig.SCHEDULE_STATE_FILE + ".tmp" + with open(tmp, 'w') as f: + json.dump(state_data, f, indent=2) + os.replace(tmp, PluginConfig.SCHEDULE_STATE_FILE) + except Exception as e: + logger.error(f"{LOG_PREFIX} Failed to save schedule state: {e}") + + @staticmethod + def _load_schedule_state(): + """Last recorded Automatic Full Sync outcome, or None if it has + never run (or the file cannot be read).""" + try: + with open(PluginConfig.SCHEDULE_STATE_FILE, 'r') as f: + return json.load(f) + except (FileNotFoundError, json.JSONDecodeError, OSError): + return None + + # Weekly Sync Day setting value -> the 3-letter form CrontabSchedule's + # day_of_week expects. + _SCHEDULE_DAY_TO_CRON = { + "monday": "mon", "tuesday": "tue", "wednesday": "wed", "thursday": "thu", + "friday": "fri", "saturday": "sat", "sunday": "sun", + } + + @staticmethod + def _detected_timezone(): + """Dispatcharr's own configured time zone, as a best-effort guess for + the Time Zone field's default - so a fresh install of this plugin + schedules in the box's actual zone without the operator having to + already know its IANA name. Never authoritative: whatever is actually + saved in settings always wins once the operator touches the field. + + Reads core.models.CoreSettings.get_system_time_zone() first - the + same source progress_status.py's _display_tz() already uses - + rather than Django's settings.TIME_ZONE. Dispatcharr pins that + Django setting (and the container's $TZ) to UTC regardless of the + admin-configured zone, so checking it first always returned "UTC": + a valid IANA name, so the function returned right there without + ever reaching CoreSettings or DISPATCHARR_TIME_ZONE. Measured live + on a container whose system time zone was America/Chicago - + Django's TIME_ZONE still read "UTC", silently scheduling the + default 03:00 sync at 22:00 Central instead.""" + try: + from core.models import CoreSettings + tz = CoreSettings.get_system_time_zone() + if tz: + import zoneinfo + zoneinfo.ZoneInfo(tz) # raises if not a real IANA name + return tz + except Exception: + pass + + tz = os.environ.get("DISPATCHARR_TIME_ZONE") + if tz: + try: + import zoneinfo + zoneinfo.ZoneInfo(tz) + return tz + except Exception: + pass + + return "UTC" + + def _reconcile_schedule(self, settings, logger): + """Create, update or remove the Automatic Full Sync periodic task so + it matches the current settings. + + Called at the top of every action dispatch (see run()), not only + when settings are saved: Dispatcharr's plugin API gives a plugin no + "settings changed" hook, so the only way to notice a change is the + next time any of the plugin's own code runs at all. This is also why + the scheduled task's settings snapshot lives in the PeriodicTask row + itself (its `kwargs`) rather than being read live - a Celery task run + by beat has no access to "whatever is currently saved in the settings + UI", only what was written here. That snapshot self-refreshes the + same way, on the next action, with no separate "Apply Schedule" + button needed. + + Failures here (django-celery-beat not installed, an invalid time + string) are logged and swallowed: they must never break the actual + action the user asked for. + """ + mode = (settings.get("auto_full_sync") or "off").strip().lower() + + try: + from django_celery_beat.models import PeriodicTask, CrontabSchedule + except ImportError: + if mode != "off": + logger.warning( + f"{LOG_PREFIX} Automatic Full Sync is set to '{mode}' but " + f"django-celery-beat is not installed, so it cannot run." + ) + return + + if mode == "off": + off_hash = self._reconcile_hash("off") + if self._load_reconcile_hash() == off_hash: + return # already off as of the last reconcile - nothing to query + deleted, _ = PeriodicTask.objects.filter(name=PluginConfig.SCHEDULE_TASK_NAME).delete() + if deleted: + logger.info(f"{LOG_PREFIX} Automatic Full Sync turned off; removed its schedule.") + self._save_reconcile_hash(off_hash) + return + + if mode not in ("daily", "weekly", "monthly", "custom"): + logger.warning(f"{LOG_PREFIX} Automatic Full Sync: unrecognized mode '{mode}' - schedule not updated.") + return + + # Explicit, not inferred from the server/container: relying on + # whatever django-celery-beat's CrontabSchedule defaults to (which is + # not guaranteed to be the box's local time - it can be UTC) silently + # runs the sync at the wrong hour, which is exactly what happened + # testing this against a real install with DISPATCHARR_TIME_ZONE set + # to America/New_York: the schedule fired on UTC time instead. + tz_str = (settings.get("auto_full_sync_timezone") or "").strip() or self._detected_timezone() + try: + import zoneinfo + zoneinfo.ZoneInfo(tz_str) + except (zoneinfo.ZoneInfoNotFoundError, KeyError, ValueError): + logger.warning( + f"{LOG_PREFIX} Automatic Full Sync: unrecognized Time Zone '{tz_str}' " + f"- schedule not updated. Use an IANA name such as 'America/New_York'." + ) + return + + if mode == "custom": + cron_str = (settings.get("auto_full_sync_cron") or "").strip() + fields = cron_str.split() + if len(fields) != 5: + logger.warning( + f"{LOG_PREFIX} Automatic Full Sync: Custom Cron Expression '{cron_str}' must have exactly " + f"5 fields (minute hour day-of-month month day-of-week) - schedule not updated." + ) + return + minute_f, hour_f, dom_f, month_f, dow_f = fields + try: + # celery's own crontab parser validates each field's syntax + # and range without needing a live app/broker - use it as a + # dry-run check before writing anything to the database. + from celery.schedules import crontab as _crontab_validator + _crontab_validator(minute=minute_f, hour=hour_f, day_of_month=dom_f, + month_of_year=month_f, day_of_week=dow_f) + except Exception as e: + logger.warning( + f"{LOG_PREFIX} Automatic Full Sync: invalid Custom Cron Expression " + f"'{cron_str}': {e} - schedule not updated." + ) + return + minute, hour, day_of_month, month_of_year, day_of_week = minute_f, hour_f, dom_f, month_f, dow_f + when = f"custom '{cron_str}' {tz_str}" + else: + time_str = (settings.get("auto_full_sync_time") or "03:00").strip() + try: + hour_str, minute_str = time_str.split(":", 1) + hour, minute = int(hour_str), int(minute_str) + if not (0 <= hour <= 23 and 0 <= minute <= 59): + raise ValueError + except ValueError: + logger.warning( + f"{LOG_PREFIX} Automatic Full Sync: invalid Sync Time '{time_str}' " + f"(expected 24-hour HH:MM) - schedule not updated." + ) + return + minute, hour = str(minute), str(hour) + month_of_year = "*" + + if mode == "weekly": + day = (settings.get("auto_full_sync_day") or "monday").strip().lower() + day_of_week = self._SCHEDULE_DAY_TO_CRON.get(day) + if not day_of_week: + logger.warning(f"{LOG_PREFIX} Automatic Full Sync: invalid Sync Day '{day}' - schedule not updated.") + return + day_of_month = "*" + when = f"weekly at {time_str} {tz_str} on {day}" + elif mode == "monthly": + dom_raw = settings.get("auto_full_sync_day_of_month", 1) + try: + dom_int = int(dom_raw) + if not (1 <= dom_int <= 31): + raise ValueError + except (TypeError, ValueError): + logger.warning( + f"{LOG_PREFIX} Automatic Full Sync: invalid Sync Day Of Month '{dom_raw}' " + f"(expected 1-31) - schedule not updated." + ) + return + day_of_week = "*" + day_of_month = str(dom_int) + when = f"monthly at {time_str} {tz_str} on day {dom_int}" + else: # daily + day_of_week = "*" + day_of_month = "*" + when = f"daily at {time_str} {tz_str}" + + # The DB write below (and the query it takes) is only needed when it + # would actually change something. kwargs carries the full settings + # snapshot, so the hash has to cover settings too, not just the + # crontab fields - otherwise an unrelated settings change (e.g. a + # lineup file swap) would never refresh what the scheduled run + # itself sees next time it fires. + reconcile_hash = self._reconcile_hash({ + "mode": mode, "minute": minute, "hour": hour, "day_of_week": day_of_week, + "day_of_month": day_of_month, "month_of_year": month_of_year, "tz": tz_str, + "settings": settings, + }) + if self._load_reconcile_hash() == reconcile_hash: + return # nothing that would change the PeriodicTask row - skip the query + + schedule, _ = CrontabSchedule.objects.get_or_create( + minute=str(minute), hour=str(hour), day_of_week=str(day_of_week), + day_of_month=str(day_of_month), month_of_year=str(month_of_year), timezone=tz_str, + ) + task, created = PeriodicTask.objects.update_or_create( + name=PluginConfig.SCHEDULE_TASK_NAME, + defaults={ + "crontab": schedule, + "interval": None, + "task": PluginConfig.SCHEDULED_TASK_CELERY_NAME, + "queue": "dvr", + "enabled": True, + "kwargs": json.dumps({"settings": dict(settings)}), + "description": "Lineuparr Automatic Full Sync", + }, + ) + if created: + logger.info(f"{LOG_PREFIX} Automatic Full Sync scheduled: {when}") + self._save_reconcile_hash(reconcile_hash) + + @staticmethod + def _reconcile_hash(value): + """Stable hash of whatever _reconcile_schedule is about to write (or + delete), so a later call with an identical value can tell nothing + changed without touching the database.""" + return hashlib.sha256( + json.dumps(value, sort_keys=True, default=str).encode() + ).hexdigest() + + @staticmethod + def _load_reconcile_hash(): + try: + with open(PluginConfig.SCHEDULE_RECONCILE_HASH_FILE, 'r') as f: + return json.load(f).get('hash') + except Exception: + return None + + @staticmethod + def _save_reconcile_hash(value): + try: + hash_file = PluginConfig.SCHEDULE_RECONCILE_HASH_FILE + os.makedirs(os.path.dirname(hash_file), exist_ok=True) + tmp = hash_file + ".tmp" + with open(tmp, 'w') as f: + json.dump({"hash": value}, f) + os.replace(tmp, hash_file) + except Exception: + pass # a failure to persist this just means the next call reconciles again, not a correctness issue + + def _schedule_status_row(self, settings): + """One results-table row summarizing Automatic Full Sync, for + Validate Settings and Plugin Status. Never raises - a broken + schedule read must not stop either report from finishing.""" + mode = (settings.get("auto_full_sync") or "off").strip().lower() + if mode == "off": + return {"Setting": "Automatic Full Sync", "Value": "Off", "Status": "OK"} + + tz_display = settings.get('auto_full_sync_timezone') or self._detected_timezone() + if mode == "custom": + when = f"custom '{settings.get('auto_full_sync_cron', '')}' {tz_display}" + else: + when = f"{mode} at {settings.get('auto_full_sync_time', '03:00')} {tz_display}" + if mode == "weekly": + when += f" on {settings.get('auto_full_sync_day', 'monday')}" + elif mode == "monthly": + when += f" on day {settings.get('auto_full_sync_day_of_month', 1)}" + + try: + last = self._load_schedule_state() + except Exception: + last = None + + if not last: + return {"Setting": "Automatic Full Sync", "Value": when, "Status": "OK (has not run yet)"} + + status = last.get("status", "unknown") + ran_at = last.get("ran_at", "unknown time") + # Capped: this row gets appended to the Plugin Status toast message, + # which Dispatcharr's own UI cuts at roughly 280 characters, so an + # uncapped exception message here could push the "Automatic Full + # Sync" line itself out of what is shown. + detail = last.get("message", "")[:80] + return { + "Setting": "Automatic Full Sync", + "Value": when, + "Status": f"Last run {ran_at}: {status}" + (f" - {detail}" if detail else ""), + } + def _trigger_frontend_refresh(self, logger): """Tell the Dispatcharr UI to refetch the channel list. @@ -2508,6 +2971,9 @@ def _validate_settings(self, settings, logger): results.append({"Setting": "Database", "Value": "", "Status": f"ERROR: {e}"}) errors += 1 + # Automatic Full Sync + results.append(self._schedule_status_row(settings)) + warn_details = [r["Setting"] + ": " + r["Status"] for r in results if "WARNING" in r.get("Status", "")] status = "ok" if errors == 0 else "error" if errors == 0: @@ -2868,46 +3334,57 @@ def _migrate_single_group_name(self, prefix, dry_run, logger): logger.info(f"{LOG_PREFIX} Renamed group '{old_name}' to '{new_name}' (ID: {old_group.id})") def _sync_groups(self, settings, logger): - """Create/update ChannelGroups from lineup categories.""" - dry_run = settings.get("dry_run_mode", False) - lineup = self._load_lineup(settings, logger) - prefix = self._get_group_prefix(settings, lineup) - rate_limiter = SmartRateLimiter(settings.get("rate_limiting", PluginConfig.DEFAULT_RATE_LIMITING)) + """Create/update ChannelGroups from lineup categories. - if PluginConfig.SINGLE_GROUP_CATEGORY in lineup["categories"]: - self._migrate_single_group_name(prefix, dry_run, logger) + Takes the operation lock like the other destructive actions + (_do_sync_channels, the three _do_apply_* steps): without it, this + step of a scheduled Full Sync could run at the same time as a + manual action touching the same ChannelGroup rows. + """ + if not self._acquire_lock(logger): + return {"status": "error", "error": "Another operation is in progress. Try again later."} + try: + dry_run = settings.get("dry_run_mode", False) + lineup = self._load_lineup(settings, logger) + prefix = self._get_group_prefix(settings, lineup) + rate_limiter = SmartRateLimiter(settings.get("rate_limiting", PluginConfig.DEFAULT_RATE_LIMITING)) - created = 0 - existed = 0 + if PluginConfig.SINGLE_GROUP_CATEGORY in lineup["categories"]: + self._migrate_single_group_name(prefix, dry_run, logger) - for category in lineup["categories"]: - group_name = self._make_group_name(prefix, category) + created = 0 + existed = 0 - if dry_run: - exists = ChannelGroup.objects.filter(name=group_name).exists() - if exists: - existed += 1 - else: - created += 1 - logger.info(f"{LOG_PREFIX} [DRY RUN] Would create group: '{group_name}'") - else: - group, was_created = ChannelGroup.objects.get_or_create(name=group_name) - if was_created: - created += 1 - logger.info(f"{LOG_PREFIX} Created group: '{group_name}' (ID: {group.id})") + for category in lineup["categories"]: + group_name = self._make_group_name(prefix, category) + + if dry_run: + exists = ChannelGroup.objects.filter(name=group_name).exists() + if exists: + existed += 1 + else: + created += 1 + logger.info(f"{LOG_PREFIX} [DRY RUN] Would create group: '{group_name}'") else: - existed += 1 + group, was_created = ChannelGroup.objects.get_or_create(name=group_name) + if was_created: + created += 1 + logger.info(f"{LOG_PREFIX} Created group: '{group_name}' (ID: {group.id})") + else: + existed += 1 - rate_limiter.wait() + rate_limiter.wait() - if not dry_run: - self._trigger_frontend_refresh(logger) + if not dry_run: + self._trigger_frontend_refresh(logger) - prefix_str = f" (dry run)" if dry_run else "" - return { - "status": "ok", - "message": f"Groups synced{prefix_str}: {created} created, {existed} already existed.", - } + prefix_str = f" (dry run)" if dry_run else "" + return { + "status": "ok", + "message": f"Groups synced{prefix_str}: {created} created, {existed} already existed.", + } + finally: + self._release_lock(logger) def _sync_channels(self, settings, logger): """Create channels from lineup and assign to groups. @@ -2953,117 +3430,128 @@ def _do_sync_channels_bg(self, settings, logger): }) def _do_sync_channels(self, settings, logger): - """Core sync channels logic.""" - dry_run = settings.get("dry_run_mode", False) - lineup = self._load_lineup(settings, logger) - prefix = self._get_group_prefix(settings, lineup) - rate_limiter = SmartRateLimiter(settings.get("rate_limiting", PluginConfig.DEFAULT_RATE_LIMITING)) - assigner = self._init_assigner_state(settings) + """Core sync channels logic. - # Sync Channels builds its own group lookup below rather than going - # through _sync_groups, so it needs this same migration call: without - # it, a lineup using Category Detail = "none" whose only prior sync - # was ever via this button (there is no Sync Groups button in the - # UI) would never see its pre-fix ": All" group renamed, and - # get_or_create just below would create a second "" group - # instead, orphaning the channels already on the old one. - if PluginConfig.SINGLE_GROUP_CATEGORY in lineup["categories"]: - self._migrate_single_group_name(prefix, dry_run, logger) - - # Ensure groups exist - existing_groups = {g['name']: g['id'] for g in ChannelGroup.objects.all().values('id', 'name')} - for category in lineup["categories"]: - group_name = self._make_group_name(prefix, category) - if group_name not in existing_groups: - if dry_run: - logger.info(f"{LOG_PREFIX} [DRY RUN] Group '{group_name}' does not exist, would need sync_groups first") - return {"status": "error", "error": f"Group '{group_name}' does not exist. Run 'Sync Groups' first."} - else: - group, _ = ChannelGroup.objects.get_or_create(name=group_name) - existing_groups[group_name] = group.id - logger.info(f"{LOG_PREFIX} Auto-created group: '{group_name}'") + Takes the operation lock like _sync_groups and the three + _do_apply_* steps: without it, this step of a scheduled Full Sync + could run at the same time as a manual action touching the same + Channel/ChannelGroup rows. + """ + if not self._acquire_lock(logger): + return {"status": "error", "error": "Another operation is in progress. Try again later."} + try: + dry_run = settings.get("dry_run_mode", False) + lineup = self._load_lineup(settings, logger) + prefix = self._get_group_prefix(settings, lineup) + rate_limiter = SmartRateLimiter(settings.get("rate_limiting", PluginConfig.DEFAULT_RATE_LIMITING)) + assigner = self._init_assigner_state(settings) - created = 0 - updated = 0 - unchanged = 0 - failed = 0 - synced_channel_ids = [] + # Sync Channels builds its own group lookup below rather than going + # through _sync_groups, so it needs this same migration call: without + # it, a lineup using Category Detail = "none" whose only prior sync + # was ever via this button (there is no Sync Groups button in the + # UI) would never see its pre-fix ": All" group renamed, and + # get_or_create just below would create a second "" group + # instead, orphaning the channels already on the old one. + if PluginConfig.SINGLE_GROUP_CATEGORY in lineup["categories"]: + self._migrate_single_group_name(prefix, dry_run, logger) + + # Ensure groups exist + existing_groups = {g['name']: g['id'] for g in ChannelGroup.objects.all().values('id', 'name')} + for category in lineup["categories"]: + group_name = self._make_group_name(prefix, category) + if group_name not in existing_groups: + if dry_run: + logger.info(f"{LOG_PREFIX} [DRY RUN] Group '{group_name}' does not exist, would need sync_groups first") + return {"status": "error", "error": f"Group '{group_name}' does not exist. Run 'Sync Groups' first."} + else: + group, _ = ChannelGroup.objects.get_or_create(name=group_name) + existing_groups[group_name] = group.id + logger.info(f"{LOG_PREFIX} Auto-created group: '{group_name}'") - total_channels = sum(len(v) for v in lineup["categories"].values()) - progress = ProgressTracker(total_channels, "sync_channels", logger) + created = 0 + updated = 0 + unchanged = 0 + failed = 0 + synced_channel_ids = [] - for category, channels in lineup["categories"].items(): - group_name = self._make_group_name(prefix, category) - group_id = existing_groups[group_name] + total_channels = sum(len(v) for v in lineup["categories"].values()) + progress = ProgressTracker(total_channels, "sync_channels", logger) - for entry in channels: - ch_name = entry["name"] - ch_number = self._get_channel_number(settings, entry, assigner) + for category, channels in lineup["categories"].items(): + group_name = self._make_group_name(prefix, category) + group_id = existing_groups[group_name] - try: - if dry_run: - existing = Channel.objects.filter(name=ch_name, channel_group_id=group_id).values('id', 'channel_number').first() - if existing: - synced_channel_ids.append(existing['id']) - if ch_number is not None and existing['channel_number'] != ch_number: - updated += 1 + for entry in channels: + ch_name = entry["name"] + ch_number = self._get_channel_number(settings, entry, assigner) + + try: + if dry_run: + existing = Channel.objects.filter(name=ch_name, channel_group_id=group_id).values('id', 'channel_number').first() + if existing: + synced_channel_ids.append(existing['id']) + if ch_number is not None and existing['channel_number'] != ch_number: + updated += 1 + else: + unchanged += 1 else: - unchanged += 1 - else: - created += 1 - else: - defaults = {"channel_number": ch_number} if ch_number is not None else {} - ch, was_created = Channel.objects.get_or_create( - name=ch_name, - channel_group_id=group_id, - defaults=defaults - ) - synced_channel_ids.append(ch.id) - if was_created: - created += 1 - logger.debug(f"{LOG_PREFIX} Created channel: '{ch_name}' #{ch_number} in '{group_name}'") + created += 1 else: - # Update channel number if different - if ch_number is not None and ch.channel_number != ch_number: - ch.channel_number = ch_number - ch.save(update_fields=['channel_number']) - updated += 1 + defaults = {"channel_number": ch_number} if ch_number is not None else {} + ch, was_created = Channel.objects.get_or_create( + name=ch_name, + channel_group_id=group_id, + defaults=defaults + ) + synced_channel_ids.append(ch.id) + if was_created: + created += 1 + logger.debug(f"{LOG_PREFIX} Created channel: '{ch_name}' #{ch_number} in '{group_name}'") else: - unchanged += 1 - - except Exception as e: - logger.error(f"{LOG_PREFIX} Failed to sync channel '{ch_name}': {e}") - failed += 1 + # Update channel number if different + if ch_number is not None and ch.channel_number != ch_number: + ch.channel_number = ch_number + ch.save(update_fields=['channel_number']) + updated += 1 + else: + unchanged += 1 + + except Exception as e: + logger.error(f"{LOG_PREFIX} Failed to sync channel '{ch_name}': {e}") + failed += 1 - rate_limiter.wait() - progress.update() + rate_limiter.wait() + progress.update() - progress.finish() + progress.finish() - # Enable synced channels in configured profiles - if not dry_run and synced_channel_ids: - self._enable_channels_in_profiles(synced_channel_ids, settings, logger) + # Enable synced channels in configured profiles + if not dry_run and synced_channel_ids: + self._enable_channels_in_profiles(synced_channel_ids, settings, logger) - if not dry_run: - # Record what this run actually created, for the lifetime tally the - # public badge sums. Guarded by dry_run because the preview branch - # increments the same counter for channels it WOULD have made, so - # recording unconditionally would inflate the badge on every Preview. - self._record_channels_created(created, "sync_channels", logger) - self._trigger_frontend_refresh(logger) + if not dry_run: + # Record what this run actually created, for the lifetime tally the + # public badge sums. Guarded by dry_run because the preview branch + # increments the same counter for channels it WOULD have made, so + # recording unconditionally would inflate the badge on every Preview. + self._record_channels_created(created, "sync_channels", logger) + self._trigger_frontend_refresh(logger) - prefix_str = " (dry run)" if dry_run else "" - msg = f"Channels synced{prefix_str}: {created} created, {updated} updated, {unchanged} unchanged" - if failed: - msg += f", {failed} failed" - total = created + updated + unchanged + failed - if failed == 0 or total == 0: - status = "ok" - elif failed == total: - status = "error" - else: - status = "warning" - return action_result(status, msg) + prefix_str = " (dry run)" if dry_run else "" + msg = f"Channels synced{prefix_str}: {created} created, {updated} updated, {unchanged} unchanged" + if failed: + msg += f", {failed} failed" + total = created + updated + unchanged + failed + if failed == 0 or total == 0: + status = "ok" + elif failed == total: + status = "error" + else: + status = "warning" + return action_result(status, msg) + finally: + self._release_lock(logger) def _apply_stream_match(self, settings, logger): """Attach matched streams to channels with quality ordering. @@ -4231,3 +4719,88 @@ def _clear_csv_exports(self, settings, logger): logger.error(f"{LOG_PREFIX} Failed to delete {f}: {e}") return {"status": "ok", "message": f"Removed {removed} CSV export file(s)."} + + def run_scheduled_full_sync(self, settings, logger): + """The actual work behind the Automatic Full Sync schedule - kept as + a plain method (not inline in the Celery task below) so it can be + unit tested without Celery installed. + + Acquires the operation lock itself and holds it for the whole of + _do_full_sync, as the owner for this run. _do_full_sync's own + groups, channels, stream match, EPG match and logo assignment steps + each call _acquire_lock/_release_lock around their own work exactly + like a manual click does, on this same Plugin() instance, so they + recognize the lock as already held by this run and extend it rather + than rejecting themselves - see _acquire_lock. Earlier, this method + only peeked at the lock without holding it, which let a manual + action start partway through a scheduled run and touch the same + groups/channels rows; it also meant the lock went stale and let a + second scheduled run start while the first was still going, if any + one sub-step ran past OPERATION_LOCK_TIMEOUT_MINUTES. + + Skips, without raising, if a different Plugin instance (a manual + action) already holds the lock - a Celery retry would just collide + with the still-running operation again - per the issue's "skip + rather than overlap" requirement. Records the outcome for Validate + Settings / Plugin Status to show, via _save_schedule_state. + """ + settings = settings or {} + ran_at = datetime.now().isoformat() + + if not self._acquire_lock(logger): + logger.warning(f"{LOG_PREFIX} Automatic Full Sync skipped: another Lineuparr operation is already running.") + self._save_schedule_state( + {"ran_at": ran_at, "status": "skipped", "message": "another operation was already running"}, + logger, + ) + return {"status": "skipped"} + + try: + self._do_full_sync(settings, logger) + self._save_schedule_state({"ran_at": ran_at, "status": "ok", "message": ""}, logger) + return {"status": "ok"} + except Exception as e: + logger.exception(f"{LOG_PREFIX} Automatic Full Sync failed: {e}") + self._save_schedule_state({"ran_at": ran_at, "status": "error", "message": str(e)}, logger) + return {"status": "error", "error": str(e)} + finally: + self._release_lock(logger) + + +# -------------------------------------------------------------------------- +# Automatic Full Sync (issue #31): the Celery entry point django-celery-beat +# fires on its schedule. Registered at module import time, the same way +# every Dispatcharr plugin that schedules its own work does it (Dispatcharr +# has no plugin scheduling API of its own) - a module-level @shared_task, +# not a Plugin method, because Celery has to find and register it once at +# import, before any Plugin instance exists. Deliberately a thin wrapper: +# the actual work is Plugin.run_scheduled_full_sync, above, so it stays +# unit-testable in an environment without Celery installed. +# +# Routed to the "dvr" queue - not by this decorator, which names no queue, +# but by the "queue": "dvr" field _reconcile_schedule sets on the +# PeriodicTask row itself. That row is what actually matters: a task left on +# Dispatcharr's default "celery" queue is silently never executed, even +# though it shows up as registered and Beat's own run counter still +# increments - that counter only records that a message was sent, not that +# a worker picked it up. +try: + from celery import shared_task as _lineuparr_shared_task + + @_lineuparr_shared_task(name=PluginConfig.SCHEDULED_TASK_CELERY_NAME) + def _lineuparr_scheduled_full_sync(settings=None): + import logging + # Use Dispatcharr's own plugin-action logger, the same one its + # action dispatcher hands every manual click via context["logger"], + # so a scheduled run's log lines look and route identically to a + # manual Full Sync's - same prefix, same place in the container log - + # rather than appearing under a separate, easy-to-miss logger name. + logger = logging.getLogger("apps.plugins.loader") + return Plugin().run_scheduled_full_sync(settings, logger) +except Exception as _lineuparr_celery_register_err: + # Celery may not be importable in every environment this module is + # loaded in (e.g. a management command, or these tests). Logged to + # stderr, not raised, so importing plugin.py never fails because of it - + # if scheduling silently never fires, this is the line that explains why. + import sys as _sys + print(f"[Lineuparr] Celery task registration failed: {_lineuparr_celery_register_err}", file=_sys.stderr) diff --git a/tests/test_group_prefix_only.py b/tests/test_group_prefix_only.py index 9c56c10..3e0405a 100644 --- a/tests/test_group_prefix_only.py +++ b/tests/test_group_prefix_only.py @@ -212,8 +212,9 @@ class TestSyncChannelsAlsoMigrates: """ def test_sync_channels_renames_the_old_group_instead_of_duplicating_it( - self, plugin, logger, monkeypatch + self, plugin, logger, monkeypatch, tmp_path ): + monkeypatch.setattr(PluginConfig, "OPERATION_LOCK_FILE", str(tmp_path / "op.lock")) old = _FakeGroup("Spectrum: All", id=7) fake = _FakeChannelGroup([old]) monkeypatch.setattr(plugin_module, "ChannelGroup", fake) diff --git a/tests/test_scheduled_full_sync.py b/tests/test_scheduled_full_sync.py new file mode 100644 index 0000000..461283d --- /dev/null +++ b/tests/test_scheduled_full_sync.py @@ -0,0 +1,707 @@ +"""Automatic Full Sync (GitHub issue #31): an optional django-celery-beat +schedule that reuses the exact manual Full Sync path. + +Covers the three constraints the maintainer's issue reply set before +accepting a PR, plus three more raised in code review of this PR's first +version: + + 1. The periodic task must be routed to the "dvr" Celery queue, not the + default "celery" queue - _reconcile_schedule sets queue="dvr" on the + PeriodicTask row itself. + 2. A scheduled run must take the operation lock itself, as the owner for + the whole run, and skip (not overlap) if a different Plugin instance + already holds it - run_scheduled_full_sync. Its own groups, channels, + stream match, EPG match and logo assignment steps reacquire that same + lock and succeed (reentrant, via an owner token) rather than + rejecting themselves - _acquire_lock / TestAcquireLockReentrancy. + 3. Off by default, and the last scheduled run's outcome (including a + skip or a failure) must be visible via Validate Settings / Plugin + Status - _schedule_status_row / _load_schedule_state. + 4. The default Time Zone must come from Dispatcharr's own + CoreSettings.get_system_time_zone(), not Django's settings.TIME_ZONE + (which Dispatcharr pins to UTC regardless of the admin-configured + zone) - _detected_timezone / TestDetectedTimezone. + 5. _reconcile_schedule, called on every action dispatch, must skip its + database query when nothing relevant to the schedule changed since + the last call - TestReconcileSkipsTheQueryWhenUnchanged. + +django-celery-beat is not a dependency of this plugin's own test suite (it +is Dispatcharr's), so these tests fake its two models rather than importing +them, the same way tests/test_group_prefix_only.py fakes ChannelGroup. +""" +import json +import logging +import sys +import types +from datetime import datetime, timedelta + +import pytest + +from Lineuparr.plugin import Plugin, PluginConfig + + +@pytest.fixture +def plugin(): + return Plugin.__new__(Plugin) + + +@pytest.fixture +def logger(): + return logging.getLogger("test") + + +# --- Fakes for django_celery_beat.models ----------------------------------- + +class _FakeCrontabSchedule: + _rows = [] + + def __init__(self, **kwargs): + self.__dict__.update(kwargs) + + class objects: + @staticmethod + def get_or_create(**kwargs): + for row in _FakeCrontabSchedule._rows: + if row.__dict__ == kwargs: + return row, False + row = _FakeCrontabSchedule(**kwargs) + _FakeCrontabSchedule._rows.append(row) + return row, True + + +class _FakePeriodicTask: + _rows = {} # name -> _FakePeriodicTask + + def __init__(self, name, **kwargs): + self.name = name + for k, v in kwargs.items(): + setattr(self, k, v) + + class objects: + @staticmethod + def update_or_create(name, defaults): + existing = _FakePeriodicTask._rows.get(name) + created = existing is None + if existing is None: + existing = _FakePeriodicTask(name, **defaults) + _FakePeriodicTask._rows[name] = existing + else: + for k, v in defaults.items(): + setattr(existing, k, v) + return existing, created + + @staticmethod + def filter(name=None): + match = _FakePeriodicTask._rows.get(name) + return _FakeQuerySet([match] if match else []) + + +class _FakeQuerySet: + def __init__(self, items): + self._items = items + + def delete(self): + count = len(self._items) + for item in self._items: + _FakePeriodicTask._rows.pop(item.name, None) + return count, {} + + def first(self): + return self._items[0] if self._items else None + + +@pytest.fixture(autouse=True) +def _reset_fakes(): + _FakeCrontabSchedule._rows = [] + _FakePeriodicTask._rows = {} + yield + _FakeCrontabSchedule._rows = [] + _FakePeriodicTask._rows = {} + + +@pytest.fixture +def fake_celery_beat(monkeypatch): + """Install a fake django_celery_beat.models module so + `from django_celery_beat.models import PeriodicTask, CrontabSchedule` + resolves inside _reconcile_schedule without the real package installed. + + Also installs a fake celery.schedules.crontab, used only to validate a + Custom Cron Expression's syntax before writing it to the database. Mimics + celery's own range checks (minute 0-59, hour 0-23, day_of_month 1-31, + month_of_year 1-12) closely enough to exercise the validation wiring, + without depending on the real celery package. + """ + fake_models = types.ModuleType("django_celery_beat.models") + fake_models.PeriodicTask = _FakePeriodicTask + fake_models.CrontabSchedule = _FakeCrontabSchedule + fake_pkg = types.ModuleType("django_celery_beat") + fake_pkg.models = fake_models + monkeypatch.setitem(sys.modules, "django_celery_beat", fake_pkg) + monkeypatch.setitem(sys.modules, "django_celery_beat.models", fake_models) + + def _fake_crontab_validator(minute, hour, day_of_month, month_of_year, day_of_week): + def _check(field, low, high): + if field == "*": + return + for part in field.split(","): + if not part.lstrip("-").isdigit() or not (low <= int(part) <= high): + raise ValueError(f"invalid field {field!r} (expected {low}-{high} or *)") + _check(minute, 0, 59) + _check(hour, 0, 23) + _check(day_of_month, 1, 31) + _check(month_of_year, 1, 12) + _check(day_of_week, 0, 6) + + fake_schedules = types.ModuleType("celery.schedules") + fake_schedules.crontab = _fake_crontab_validator + fake_celery_pkg = types.ModuleType("celery") + fake_celery_pkg.schedules = fake_schedules + monkeypatch.setitem(sys.modules, "celery", fake_celery_pkg) + monkeypatch.setitem(sys.modules, "celery.schedules", fake_schedules) + + return fake_models + + +class TestReconcileScheduleQueueRouting: + """Requirement 1: every periodic task this plugin creates must be + routed to the 'dvr' queue.""" + + def test_daily_schedule_is_routed_to_the_dvr_queue(self, plugin, logger, fake_celery_beat): + plugin._reconcile_schedule( + {"auto_full_sync": "daily", "auto_full_sync_time": "03:30"}, logger + ) + task = _FakePeriodicTask._rows[PluginConfig.SCHEDULE_TASK_NAME] + assert task.queue == "dvr" + assert task.task == PluginConfig.SCHEDULED_TASK_CELERY_NAME + assert task.enabled is True + + def test_weekly_schedule_sets_the_chosen_day(self, plugin, logger, fake_celery_beat): + plugin._reconcile_schedule( + {"auto_full_sync": "weekly", "auto_full_sync_time": "04:15", "auto_full_sync_day": "friday"}, + logger, + ) + task = _FakePeriodicTask._rows[PluginConfig.SCHEDULE_TASK_NAME] + assert task.crontab.day_of_week == "fri" + assert task.crontab.hour == "4" + assert task.crontab.minute == "15" + + def test_settings_snapshot_is_stored_on_the_task(self, plugin, logger, fake_celery_beat): + settings = {"auto_full_sync": "daily", "auto_full_sync_time": "03:00", "lineup_file": "US_Foo.json"} + plugin._reconcile_schedule(settings, logger) + task = _FakePeriodicTask._rows[PluginConfig.SCHEDULE_TASK_NAME] + stored = json.loads(task.kwargs) + assert stored["settings"]["lineup_file"] == "US_Foo.json" + + +class TestReconcileScheduleMonthly: + def test_monthly_sets_the_chosen_day_of_month(self, plugin, logger, fake_celery_beat): + plugin._reconcile_schedule( + {"auto_full_sync": "monthly", "auto_full_sync_time": "02:00", "auto_full_sync_day_of_month": 15}, + logger, + ) + task = _FakePeriodicTask._rows[PluginConfig.SCHEDULE_TASK_NAME] + assert task.crontab.day_of_month == "15" + assert task.crontab.day_of_week == "*" + assert task.crontab.hour == "2" + assert task.crontab.minute == "0" + assert task.queue == "dvr" + + def test_monthly_defaults_to_day_1_when_missing(self, plugin, logger, fake_celery_beat): + plugin._reconcile_schedule({"auto_full_sync": "monthly", "auto_full_sync_time": "02:00"}, logger) + task = _FakePeriodicTask._rows[PluginConfig.SCHEDULE_TASK_NAME] + assert task.crontab.day_of_month == "1" + + @pytest.mark.parametrize("bad_day", [0, 32, -1, "not-a-number"]) + def test_invalid_day_of_month_does_not_create_a_schedule(self, plugin, logger, fake_celery_beat, bad_day): + plugin._reconcile_schedule( + {"auto_full_sync": "monthly", "auto_full_sync_time": "02:00", "auto_full_sync_day_of_month": bad_day}, + logger, + ) + assert PluginConfig.SCHEDULE_TASK_NAME not in _FakePeriodicTask._rows + + +class TestReconcileScheduleCustom: + def test_valid_custom_cron_creates_the_schedule_with_exact_fields(self, plugin, logger, fake_celery_beat): + plugin._reconcile_schedule( + {"auto_full_sync": "custom", "auto_full_sync_cron": "15 4 1 * 0"}, + logger, + ) + task = _FakePeriodicTask._rows[PluginConfig.SCHEDULE_TASK_NAME] + assert task.crontab.minute == "15" + assert task.crontab.hour == "4" + assert task.crontab.day_of_month == "1" + assert task.crontab.month_of_year == "*" + assert task.crontab.day_of_week == "0" + assert task.queue == "dvr" + + def test_custom_ignores_sync_time_and_day_settings(self, plugin, logger, fake_celery_beat): + # Sync Time/Sync Day are for daily/weekly/monthly only - custom must + # use only the cron expression, even if the other fields are also set + # (e.g. left over from switching modes in the UI). + plugin._reconcile_schedule( + { + "auto_full_sync": "custom", + "auto_full_sync_cron": "0 3 * * *", + "auto_full_sync_time": "23:59", + "auto_full_sync_day": "sunday", + "auto_full_sync_day_of_month": 31, + }, + logger, + ) + task = _FakePeriodicTask._rows[PluginConfig.SCHEDULE_TASK_NAME] + assert task.crontab.hour == "3" + assert task.crontab.minute == "0" + + @pytest.mark.parametrize("bad_cron", [ + "0 3 * *", # only 4 fields + "0 3 * * * *", # 6 fields + "", # blank + "99 3 * * *", # minute out of range + "0 25 * * *", # hour out of range + ]) + def test_invalid_custom_cron_does_not_create_a_schedule(self, plugin, logger, fake_celery_beat, bad_cron): + plugin._reconcile_schedule({"auto_full_sync": "custom", "auto_full_sync_cron": bad_cron}, logger) + assert PluginConfig.SCHEDULE_TASK_NAME not in _FakePeriodicTask._rows + + +class TestReconcileScheduleTimeZone: + """Regression: tested live against a real install with + DISPATCHARR_TIME_ZONE=America/New_York - a schedule created without an + explicit timezone fired on UTC instead of the box's local time, silently + missing the intended run. The Time Zone setting must be explicit and + passed straight through, never inferred from the server.""" + + def test_explicit_timezone_is_passed_to_the_crontab(self, plugin, logger, fake_celery_beat): + plugin._reconcile_schedule( + {"auto_full_sync": "daily", "auto_full_sync_time": "03:00", "auto_full_sync_timezone": "America/New_York"}, + logger, + ) + task = _FakePeriodicTask._rows[PluginConfig.SCHEDULE_TASK_NAME] + assert task.crontab.timezone == "America/New_York" + + def test_missing_timezone_defaults_to_utc(self, plugin, logger, fake_celery_beat): + plugin._reconcile_schedule({"auto_full_sync": "daily", "auto_full_sync_time": "03:00"}, logger) + task = _FakePeriodicTask._rows[PluginConfig.SCHEDULE_TASK_NAME] + assert task.crontab.timezone == "UTC" + + def test_unrecognized_timezone_does_not_create_a_schedule(self, plugin, logger, fake_celery_beat): + plugin._reconcile_schedule( + {"auto_full_sync": "daily", "auto_full_sync_time": "03:00", "auto_full_sync_timezone": "Not/A_Real_Zone"}, + logger, + ) + assert PluginConfig.SCHEDULE_TASK_NAME not in _FakePeriodicTask._rows + + def test_blank_timezone_uses_the_detected_default(self, plugin, logger, fake_celery_beat, monkeypatch): + monkeypatch.setattr(plugin, "_detected_timezone", lambda: "Europe/London") + plugin._reconcile_schedule({"auto_full_sync": "daily", "auto_full_sync_time": "03:00"}, logger) + task = _FakePeriodicTask._rows[PluginConfig.SCHEDULE_TASK_NAME] + assert task.crontab.timezone == "Europe/London" + + +@pytest.fixture +def fake_core_settings(monkeypatch): + """Install a fake core.models.CoreSettings so + `from core.models import CoreSettings` resolves inside _detected_timezone + without a real Dispatcharr/Django environment, the same way + fake_celery_beat fakes django_celery_beat.models. + + Returns the setter used by each test to control what + get_system_time_zone() reports. + """ + fake_module = types.ModuleType("core.models") + + class _FakeCoreSettings: + _zone = None + + @staticmethod + def get_system_time_zone(): + return _FakeCoreSettings._zone + + fake_module.CoreSettings = _FakeCoreSettings + monkeypatch.setitem(sys.modules, "core.models", fake_module) + + def _set(zone): + _FakeCoreSettings._zone = zone + + yield _set + _FakeCoreSettings._zone = None + + +class TestDetectedTimezone: + """_detected_timezone offers Dispatcharr's own configured zone as the + Time Zone field's default, so a fresh install doesn't have to already + know its IANA name. Never authoritative once a setting is actually saved.""" + + def test_falls_back_to_utc_with_nothing_configured(self, plugin, monkeypatch): + monkeypatch.delenv("DISPATCHARR_TIME_ZONE", raising=False) + assert plugin._detected_timezone() == "UTC" + + def test_reads_the_dispatcharr_time_zone_env_var(self, plugin, monkeypatch): + monkeypatch.setenv("DISPATCHARR_TIME_ZONE", "America/Chicago") + assert plugin._detected_timezone() == "America/Chicago" + + def test_an_invalid_env_var_value_falls_back_to_utc(self, plugin, monkeypatch): + monkeypatch.setenv("DISPATCHARR_TIME_ZONE", "Not/A_Real_Zone") + assert plugin._detected_timezone() == "UTC" + + def test_the_settings_field_defaults_to_the_detected_zone(self, monkeypatch): + monkeypatch.setenv("DISPATCHARR_TIME_ZONE", "America/Chicago") + fields = Plugin.__new__(Plugin).fields + tz_field = next(f for f in fields if f.get("id") == "auto_full_sync_timezone") + assert tz_field["default"] == "America/Chicago" + + def test_prefers_core_settings_system_time_zone_over_the_env_var(self, plugin, monkeypatch, fake_core_settings): + """Regression from code review: Django's settings.TIME_ZONE is + pinned to UTC by Dispatcharr regardless of the admin-configured + zone, so checking it first always returned "UTC" - a valid IANA + name - without ever reaching CoreSettings or + DISPATCHARR_TIME_ZONE. Measured live on a container whose system + time zone was America/Chicago. CoreSettings.get_system_time_zone() + is Dispatcharr's own setting and must win even when a different + value is sitting in the env var too.""" + fake_core_settings("America/Chicago") + monkeypatch.setenv("DISPATCHARR_TIME_ZONE", "Europe/London") + assert plugin._detected_timezone() == "America/Chicago" + + def test_falls_back_to_the_env_var_when_core_settings_has_no_zone(self, plugin, monkeypatch, fake_core_settings): + fake_core_settings(None) + monkeypatch.setenv("DISPATCHARR_TIME_ZONE", "America/Chicago") + assert plugin._detected_timezone() == "America/Chicago" + + +class TestReconcileScheduleOffByDefault: + def test_off_creates_nothing(self, plugin, logger, fake_celery_beat): + plugin._reconcile_schedule({"auto_full_sync": "off"}, logger) + assert PluginConfig.SCHEDULE_TASK_NAME not in _FakePeriodicTask._rows + + def test_missing_setting_defaults_to_off(self, plugin, logger, fake_celery_beat): + plugin._reconcile_schedule({}, logger) + assert PluginConfig.SCHEDULE_TASK_NAME not in _FakePeriodicTask._rows + + def test_turning_off_removes_an_existing_schedule(self, plugin, logger, fake_celery_beat): + plugin._reconcile_schedule({"auto_full_sync": "daily", "auto_full_sync_time": "03:00"}, logger) + assert PluginConfig.SCHEDULE_TASK_NAME in _FakePeriodicTask._rows + + plugin._reconcile_schedule({"auto_full_sync": "off"}, logger) + assert PluginConfig.SCHEDULE_TASK_NAME not in _FakePeriodicTask._rows + + def test_django_celery_beat_not_installed_does_not_raise(self, plugin, logger): + # No fake_celery_beat fixture here - the import genuinely fails. + plugin._reconcile_schedule({"auto_full_sync": "daily", "auto_full_sync_time": "03:00"}, logger) + + +class TestReconcileScheduleValidation: + @pytest.mark.parametrize("bad_time", ["not-a-time", "25:00", "12:60", "12"]) + def test_invalid_time_does_not_create_a_schedule(self, plugin, logger, fake_celery_beat, bad_time): + plugin._reconcile_schedule({"auto_full_sync": "daily", "auto_full_sync_time": bad_time}, logger) + assert PluginConfig.SCHEDULE_TASK_NAME not in _FakePeriodicTask._rows + + def test_empty_time_falls_back_to_the_default(self, plugin, logger, fake_celery_beat): + # Consistent with how every other setting in this plugin treats "": + # settings.get(x) or default, not an error. + plugin._reconcile_schedule({"auto_full_sync": "daily", "auto_full_sync_time": ""}, logger) + task = _FakePeriodicTask._rows[PluginConfig.SCHEDULE_TASK_NAME] + assert task.crontab.hour == "3" + assert task.crontab.minute == "0" + + def test_invalid_day_does_not_create_a_schedule(self, plugin, logger, fake_celery_beat): + plugin._reconcile_schedule( + {"auto_full_sync": "weekly", "auto_full_sync_time": "03:00", "auto_full_sync_day": "someday"}, + logger, + ) + assert PluginConfig.SCHEDULE_TASK_NAME not in _FakePeriodicTask._rows + + def test_invalid_mode_does_not_create_a_schedule(self, plugin, logger, fake_celery_beat): + plugin._reconcile_schedule({"auto_full_sync": "hourly"}, logger) + assert PluginConfig.SCHEDULE_TASK_NAME not in _FakePeriodicTask._rows + + +class TestReconcileSkipsTheQueryWhenUnchanged: + """Requirement 3 from code review: _reconcile_schedule runs on every + action dispatch (there is no settings-changed hook), which meant a + DELETE or an update_or_create on every single button press, including + read-only ones like Plugin Status. It now compares a hash of what it is + about to write against a hash of what it wrote last time, stored in + SCHEDULE_RECONCILE_HASH_FILE, and skips the database call entirely when + nothing relevant changed. + """ + + @staticmethod + def _count_calls(monkeypatch, obj, method_name): + calls = [] + original = getattr(obj, method_name) + + def _wrapped(*args, **kwargs): + calls.append(True) + return original(*args, **kwargs) + monkeypatch.setattr(obj, method_name, _wrapped) + return calls + + def test_an_unchanged_daily_schedule_only_writes_once(self, plugin, logger, fake_celery_beat, tmp_path, monkeypatch): + monkeypatch.setattr(PluginConfig, "SCHEDULE_RECONCILE_HASH_FILE", str(tmp_path / "hash.json")) + calls = self._count_calls(monkeypatch, _FakePeriodicTask.objects, "update_or_create") + settings = {"auto_full_sync": "daily", "auto_full_sync_time": "03:00"} + + plugin._reconcile_schedule(settings, logger) + plugin._reconcile_schedule(dict(settings), logger) + plugin._reconcile_schedule(dict(settings), logger) + + assert len(calls) == 1 + + def test_a_changed_setting_writes_again(self, plugin, logger, fake_celery_beat, tmp_path, monkeypatch): + monkeypatch.setattr(PluginConfig, "SCHEDULE_RECONCILE_HASH_FILE", str(tmp_path / "hash.json")) + calls = self._count_calls(monkeypatch, _FakePeriodicTask.objects, "update_or_create") + + plugin._reconcile_schedule({"auto_full_sync": "daily", "auto_full_sync_time": "03:00"}, logger) + plugin._reconcile_schedule({"auto_full_sync": "daily", "auto_full_sync_time": "04:00"}, logger) + + assert len(calls) == 2 + + def test_an_unrelated_setting_change_still_refreshes_the_kwargs_snapshot(self, plugin, logger, fake_celery_beat, tmp_path, monkeypatch): + """The settings snapshot stored in the PeriodicTask row's kwargs is + what the scheduled task actually runs with, so a change to a + setting outside the schedule fields (e.g. the selected lineup file) + must still refresh it, not be skipped as "unchanged".""" + monkeypatch.setattr(PluginConfig, "SCHEDULE_RECONCILE_HASH_FILE", str(tmp_path / "hash.json")) + calls = self._count_calls(monkeypatch, _FakePeriodicTask.objects, "update_or_create") + + plugin._reconcile_schedule({"auto_full_sync": "daily", "auto_full_sync_time": "03:00", "lineup_file": "US_Foo.json"}, logger) + plugin._reconcile_schedule({"auto_full_sync": "daily", "auto_full_sync_time": "03:00", "lineup_file": "US_Bar.json"}, logger) + + assert len(calls) == 2 + task = _FakePeriodicTask._rows[PluginConfig.SCHEDULE_TASK_NAME] + assert json.loads(task.kwargs)["settings"]["lineup_file"] == "US_Bar.json" + + def test_repeatedly_off_only_queries_once(self, plugin, logger, fake_celery_beat, tmp_path, monkeypatch): + monkeypatch.setattr(PluginConfig, "SCHEDULE_RECONCILE_HASH_FILE", str(tmp_path / "hash.json")) + # Establish a schedule first so "off" actually has something to delete. + plugin._reconcile_schedule({"auto_full_sync": "daily", "auto_full_sync_time": "03:00"}, logger) + + calls = self._count_calls(monkeypatch, _FakePeriodicTask.objects, "filter") + plugin._reconcile_schedule({"auto_full_sync": "off"}, logger) + plugin._reconcile_schedule({"auto_full_sync": "off"}, logger) + plugin._reconcile_schedule({"auto_full_sync": "off"}, logger) + + assert len(calls) == 1 + assert PluginConfig.SCHEDULE_TASK_NAME not in _FakePeriodicTask._rows + + +# --- run_scheduled_full_sync: locking + status recording -------------------- + +class TestRunScheduledFullSyncLocking: + """Requirement 2: skip rather than overlap when another run is in + progress. Regression from code review on this PR: an earlier version of + run_scheduled_full_sync only peeked at the lock (_is_operation_locked) + instead of holding it, so a manual action - running on Dispatcharr's + own Plugin instance, separate from the one the scheduled task creates - + could start partway through a scheduled run and touch the same + groups/channels rows. It now acquires the lock itself as the owner for + the whole run; _do_full_sync's own groups, channels, stream match, EPG + match and logo assignment steps each reacquire the same lock on this + same Plugin instance and succeed (reentrant, via the owner token + _acquire_lock stores in the lock file) instead of finding it already + held and rejecting themselves - the self-deadlock measured when an + even earlier version wrapped _do_full_sync in its own acquire without + that reentrancy: exactly three "Operation locked" warnings from one + single invocation, and no sync ever completed. + """ + + def test_skips_and_records_it_when_a_different_instance_holds_the_lock(self, plugin, logger, tmp_path, monkeypatch): + """A manual action runs on Dispatcharr's own Plugin instance, never + the one the scheduled task creates - simulate that with a second, + separate Plugin instance holding the lock.""" + monkeypatch.setattr(PluginConfig, "OPERATION_LOCK_FILE", str(tmp_path / "op.lock")) + monkeypatch.setattr(PluginConfig, "SCHEDULE_STATE_FILE", str(tmp_path / "sched.json")) + other = Plugin.__new__(Plugin) + assert other._acquire_lock(logger) is True # a different Plugin instance holding it + + do_full_sync_called = [] + monkeypatch.setattr(plugin, "_do_full_sync", lambda settings, logger: do_full_sync_called.append(True)) + + result = plugin.run_scheduled_full_sync({}, logger) + + assert result == {"status": "skipped"} + assert not do_full_sync_called # never even attempted + state = plugin._load_schedule_state() + assert state["status"] == "skipped" + + def test_holds_the_lock_across_do_full_syncs_own_reacquires(self, plugin, logger, tmp_path, monkeypatch): + """The regression test: _do_full_sync's own sub-steps reacquire the + same lock, on this same Plugin instance, and succeed - rather than + finding it already held by this same run and rejecting themselves. + The lock file exists throughout, proving the outer acquire from + run_scheduled_full_sync is the one actually holding it, and an + inner release never removes it early.""" + lock_path = tmp_path / "op.lock" + monkeypatch.setattr(PluginConfig, "OPERATION_LOCK_FILE", str(lock_path)) + monkeypatch.setattr(PluginConfig, "SCHEDULE_STATE_FILE", str(tmp_path / "sched.json")) + + acquired_count = [] + + def _fake_do_full_sync(settings, logger): + # Mimics what _do_full_sync's real sub-steps do: acquire and + # release the lock themselves, more than once, on this same + # Plugin instance. + for _ in range(5): + assert plugin._acquire_lock(logger) is True, ( + "a sub-step on the same instance must reuse the outer run's " + "lock, not reject itself" + ) + assert lock_path.exists() + acquired_count.append(True) + plugin._release_lock(logger) + assert lock_path.exists(), "an inner release must not remove the lock while the outer run still holds it" + + monkeypatch.setattr(plugin, "_do_full_sync", _fake_do_full_sync) + + result = plugin.run_scheduled_full_sync({}, logger) + + assert result == {"status": "ok"} + assert len(acquired_count) == 5 + assert not lock_path.exists() # released for good once the outer run finished + state = plugin._load_schedule_state() + assert state["status"] == "ok" + + def test_records_failure_and_still_releases_the_lock(self, plugin, logger, tmp_path, monkeypatch): + lock_path = tmp_path / "op.lock" + monkeypatch.setattr(PluginConfig, "OPERATION_LOCK_FILE", str(lock_path)) + monkeypatch.setattr(PluginConfig, "SCHEDULE_STATE_FILE", str(tmp_path / "sched.json")) + + def _boom(settings, logger): + raise RuntimeError("stream matching blew up") + monkeypatch.setattr(plugin, "_do_full_sync", _boom) + + result = plugin.run_scheduled_full_sync({}, logger) + + assert result["status"] == "error" + assert "stream matching blew up" in result["error"] + assert not lock_path.exists() # released even on failure + state = plugin._load_schedule_state() + assert state["status"] == "error" + + +class TestAcquireLockReentrancy: + """_acquire_lock's owner-token mechanism, isolated from + run_scheduled_full_sync: the behavior the three _do_apply_* steps, + _sync_groups and _do_sync_channels all now depend on.""" + + def test_a_second_call_on_the_same_instance_is_reentrant(self, plugin, logger, tmp_path, monkeypatch): + monkeypatch.setattr(PluginConfig, "OPERATION_LOCK_FILE", str(tmp_path / "op.lock")) + assert plugin._acquire_lock(logger) is True + assert plugin._acquire_lock(logger) is True # same instance - extends, does not reject + + def test_a_different_instance_is_rejected_while_held(self, plugin, logger, tmp_path, monkeypatch): + monkeypatch.setattr(PluginConfig, "OPERATION_LOCK_FILE", str(tmp_path / "op.lock")) + assert plugin._acquire_lock(logger) is True + other = Plugin.__new__(Plugin) + assert other._acquire_lock(logger) is False + + def test_an_inner_release_does_not_unlock_for_an_outer_holder(self, plugin, logger, tmp_path, monkeypatch): + lock_path = tmp_path / "op.lock" + monkeypatch.setattr(PluginConfig, "OPERATION_LOCK_FILE", str(lock_path)) + assert plugin._acquire_lock(logger) is True # outer + assert plugin._acquire_lock(logger) is True # inner (reentrant) + plugin._release_lock(logger) # inner's release + assert lock_path.exists(), "the outer acquire still holds it" + plugin._release_lock(logger) # outer's release + assert not lock_path.exists() + + def test_reentrant_acquire_refreshes_a_near_stale_timestamp(self, plugin, logger, tmp_path, monkeypatch): + """The staleness-related case from code review: with the lock held + only by short-lived sub-steps, one running past + OPERATION_LOCK_TIMEOUT_MINUTES let a second scheduled run start + while the first was still going. A reentrant call from the same + owner now refreshes the timestamp instead of being evaluated + against it, so a long-running step no longer makes its own run's + lock look stale to a genuinely different instance.""" + lock_path = tmp_path / "op.lock" + monkeypatch.setattr(PluginConfig, "OPERATION_LOCK_FILE", str(lock_path)) + assert plugin._acquire_lock(logger) is True + + old_time = (datetime.now() - timedelta(minutes=15)).isoformat() + data = json.loads(lock_path.read_text()) + data["timestamp"] = old_time + lock_path.write_text(json.dumps(data)) + + assert plugin._acquire_lock(logger) is True # same owner - refreshes, ignores staleness + + other = Plugin.__new__(Plugin) + assert other._acquire_lock(logger) is False # still genuinely held, from a different instance's view + + def test_a_genuinely_stale_lock_from_a_different_instance_is_overridden(self, plugin, logger, tmp_path, monkeypatch): + lock_path = tmp_path / "op.lock" + monkeypatch.setattr(PluginConfig, "OPERATION_LOCK_FILE", str(lock_path)) + old_time = (datetime.now() - timedelta(minutes=20)).isoformat() + lock_path.write_text(json.dumps({"timestamp": old_time, "action": "lineuparr", "owner": "some-other-owner"})) + + assert plugin._acquire_lock(logger) is True + + +# --- Status visibility ------------------------------------------------------- + +class TestScheduleStatusRow: + """Requirement 3: off by default, and status visible via Validate + Settings / Plugin Status.""" + + def test_off_reports_off(self, plugin): + row = plugin._schedule_status_row({"auto_full_sync": "off"}) + assert row["Value"] == "Off" + assert row["Status"] == "OK" + + def test_default_with_no_setting_at_all_is_off(self, plugin): + row = plugin._schedule_status_row({}) + assert row["Value"] == "Off" + + def test_scheduled_but_never_run_says_so(self, plugin, tmp_path, monkeypatch): + monkeypatch.setattr(PluginConfig, "SCHEDULE_STATE_FILE", str(tmp_path / "does_not_exist.json")) + row = plugin._schedule_status_row({"auto_full_sync": "daily", "auto_full_sync_time": "03:00"}) + assert "daily" in row["Value"] + assert "has not run yet" in row["Status"] + + def test_shows_the_last_recorded_outcome(self, plugin, logger, tmp_path, monkeypatch): + monkeypatch.setattr(PluginConfig, "SCHEDULE_STATE_FILE", str(tmp_path / "sched.json")) + plugin._save_schedule_state({"ran_at": "2026-09-29T03:00:00", "status": "ok", "message": ""}, logger) + + row = plugin._schedule_status_row({"auto_full_sync": "daily", "auto_full_sync_time": "03:00"}) + + assert "2026-09-29T03:00:00" in row["Status"] + assert "ok" in row["Status"] + + def test_plugin_status_includes_the_schedule_when_not_off(self, plugin, logger, tmp_path, monkeypatch): + monkeypatch.setattr(PluginConfig, "PROGRESS_FILE", str(tmp_path / "progress.json")) + monkeypatch.setattr(PluginConfig, "SCHEDULE_STATE_FILE", str(tmp_path / "sched.json")) + result = plugin._plugin_status({"auto_full_sync": "daily", "auto_full_sync_time": "03:00"}, logger) + assert "Automatic Full Sync" in result["message"] + + def test_plugin_status_omits_the_schedule_when_off(self, plugin, logger, tmp_path, monkeypatch): + monkeypatch.setattr(PluginConfig, "PROGRESS_FILE", str(tmp_path / "progress.json")) + monkeypatch.setattr(PluginConfig, "SCHEDULE_STATE_FILE", str(tmp_path / "sched.json")) + result = plugin._plugin_status({"auto_full_sync": "off"}, logger) + assert "Automatic Full Sync" not in result["message"] + + +class TestReconcileCalledOnEveryAction: + """There is no settings-changed hook in Dispatcharr's plugin API, so + reconciliation has to piggyback on every action dispatch instead.""" + + def test_run_calls_reconcile_schedule_before_dispatching(self, monkeypatch): + plugin = Plugin.__new__(Plugin) + calls = [] + monkeypatch.setattr(plugin, "_reconcile_schedule", lambda settings, logger: calls.append(settings)) + monkeypatch.setattr(plugin, "_plugin_status", lambda settings, logger: {"status": "ok", "message": "x"}) + + plugin.run("plugin_status", {}, {"logger": logging.getLogger("test"), "settings": {"auto_full_sync": "daily"}}) + + assert calls == [{"auto_full_sync": "daily"}] + + def test_a_broken_reconcile_does_not_block_the_requested_action(self, monkeypatch): + plugin = Plugin.__new__(Plugin) + + def _boom(settings, logger): + raise RuntimeError("schedule DB unreachable") + monkeypatch.setattr(plugin, "_reconcile_schedule", _boom) + monkeypatch.setattr(plugin, "_plugin_status", lambda settings, logger: {"status": "ok", "message": "fine"}) + + result = plugin.run("plugin_status", {}, {"logger": logging.getLogger("test"), "settings": {}}) + + assert result == {"status": "ok", "message": "fine"} diff --git a/tests/test_settings_sections.py b/tests/test_settings_sections.py index 7dc3829..3a76b54 100644 --- a/tests/test_settings_sections.py +++ b/tests/test_settings_sections.py @@ -33,6 +33,7 @@ ("_sec_groups", "group_prefix"), ("_sec_matching", "match_sensitivity"), ("_sec_notify", "notify_enabled"), + ("_sec_automation", "auto_full_sync"), ("_sec_advanced", "rate_limiting"), ]