diff --git a/plugins/stash-scheduler/README.md b/plugins/stash-scheduler/README.md index 088f58d1..2bda856d 100644 --- a/plugins/stash-scheduler/README.md +++ b/plugins/stash-scheduler/README.md @@ -1,7 +1,5 @@ # Stash Scheduler Plugin -https://discourse.stashapp.cc/t/stash-scheduler/7059 - A plugin for [Stash](https://github.com/stashapp/stash) that automatically runs library scans on a schedule (hourly, daily, or weekly), with an optional identify pass after each scan. --- @@ -83,8 +81,9 @@ Open **Settings → Plugins → Stash Scheduler** and set your preferences: | Setting | Description | Default | |---|---|---| +| **API Key** | Stash API key for authenticating background scans. Required when "Require API key" is enabled in Stash's Security settings. Generate one under Settings > Security > API Keys. Leave blank if Stash does not require authentication. | *(none)* | | **Scan Frequency** | `hourly`, `daily`, or `weekly` | `daily` | -| **Time of Day (HH:MM)** | Time to run the scan in 24-hour `HH:MM` format. Used by Daily and Weekly; ignored for Hourly. | `02:00` | +| **Time of Day (HH:MM)** | One or more times to run the scan, in 24-hour `HH:MM` format. Separate multiple times with commas or spaces, e.g. `00:00, 06:00, 18:00`. Used by Daily and Weekly; ignored for Hourly. | `02:00` | | **Day of Week** | Day to scan when Frequency is Weekly. Use `mon`, `tue`, `wed`, `thu`, `fri`, `sat`, or `sun`. | `sun` | | **Timezone** | IANA timezone name for interpreting Time of Day and Day of Week. Examples: `America/New_York`, `Europe/London`, `Asia/Tokyo`. Leave blank for UTC. | `UTC` | | **Run Identify After Scan** | When enabled, runs an Identify task after each scan finishes successfully. | `false` | @@ -296,6 +295,10 @@ All activity is written to the Stash log. To view it: | Version | Notes | |---|---| +| 0.10.0 | Added API Key setting — required when Stash's "Require API key" security option is enabled | +| 0.9.0 | Daemon now reloads settings from Stash on every scheduled run — changes to identify, paths, and scan flags take effect without restarting the scheduler | +| 0.8.0 | Fixed identify not running after scan (null fields in GraphQL input); fixed daemon losing Stash connection after restart; added APScheduler error logging, hourly heartbeat, and next-fire-time logging; Check Status now shows 100 log lines | +| 0.7.0 | Time of Day now accepts multiple times (comma or space separated) — each fires a separate scheduled scan | | 0.6.0 | Added "Limit to Paths" setting — scan and identify can now be restricted to specific directories | | 0.5.0 | Fixed identify-after-scan (null jobQueue crash); added 9 scan generation flag settings (covers, previews, sprites, phashes, thumbnails, clip previews, force rescan) | | 0.4.0 | Added Check Status task; daemon logs written to file (`/tmp/stash-scheduler-daemon.log`) | diff --git a/plugins/stash-scheduler/stash-scheduler.yml b/plugins/stash-scheduler/stash-scheduler.yml index 6578d05b..6bd97487 100644 --- a/plugins/stash-scheduler/stash-scheduler.yml +++ b/plugins/stash-scheduler/stash-scheduler.yml @@ -1,7 +1,7 @@ name: Stash Scheduler description: Schedule automatic library scans with an optional identify pass after each scan. -version: "0.6.0" -url: https://discourse.stashapp.cc/t/stash-scheduler/7059 +version: "0.10.0" +url: https://github.com/stashapp/stash exec: - python3 @@ -42,6 +42,16 @@ tasks: mode: check_status settings: + apiKey: + displayName: API Key + description: > + Stash API key for authenticating background scans. Required when + Settings > Security > "Require API key for local connections" is enabled, + or when accessing Stash over a network. Generate a key under + Settings > Security > API Keys, then paste it here. Leave blank if + Stash does not require authentication. + type: STRING + frequency: displayName: Scan Frequency description: > @@ -53,9 +63,10 @@ settings: time_of_day: displayName: Time of Day (HH:MM) description: > - The time to run the scan in 24-hour HH:MM format. Used for Daily and - Weekly schedules; ignored for Hourly. Examples: 02:00, 14:30, 20:45. - Defaults to 02:00 if not set. + One or more times to run the scan in 24-hour HH:MM format. Used for + Daily and Weekly schedules; ignored for Hourly. Separate multiple times + with commas or spaces. Examples: 02:00 — single daily run. 00:00, 06:00, + 09:00, 16:00, 18:00 — five runs per day. Defaults to 02:00 if not set. type: STRING day_of_week: diff --git a/plugins/stash-scheduler/stash_scheduler.py b/plugins/stash-scheduler/stash_scheduler.py index 9535eea4..bfc8a63b 100644 --- a/plugins/stash-scheduler/stash_scheduler.py +++ b/plugins/stash-scheduler/stash_scheduler.py @@ -142,6 +142,7 @@ def get_plugin_settings(stash, plugin_id="stash-scheduler"): "time_of_day": "02:00", "day_of_week": "sun", "timezone": "UTC", + "apiKey": "", "run_identify": False, "identify_timeout_minutes": 120, # Comma- or newline-separated list of paths to restrict scan + identify. @@ -168,10 +169,11 @@ def get_plugin_settings(stash, plugin_id="stash-scheduler"): return defaults -def _parse_time_of_day(raw, warn): - raw = str(raw).strip() +def _parse_single_time(token, warn): + """Parse one HH:MM token. Returns (hour, minute) or None on error.""" + token = str(token).strip() try: - parts = raw.split(":") + parts = token.split(":") if len(parts) != 2: raise ValueError("expected HH:MM") hh, mm = int(parts[0]), int(parts[1]) @@ -179,8 +181,26 @@ def _parse_time_of_day(raw, warn): raise ValueError(f"values out of range: {hh}:{mm:02d}") return hh, mm except (ValueError, TypeError) as exc: - warn(f"[Stash Scheduler] Invalid time_of_day {raw!r} ({exc}) — defaulting to 02:00.") - return 2, 0 + warn(f"[Stash Scheduler] Invalid time {token!r} ({exc}) — skipping.") + return None + + +def _parse_times_of_day(raw, warn): + """Parse comma/space/newline-separated HH:MM times. + Returns a deduplicated list of (hour, minute) tuples, sorted ascending. + Falls back to [(2, 0)] if nothing valid is found.""" + import re as _re + tokens = [t for t in _re.split(r"[\s,]+", str(raw).strip()) if t] + results, seen = [], set() + for token in tokens: + parsed = _parse_single_time(token, warn) + if parsed is not None and parsed not in seen: + results.append(parsed) + seen.add(parsed) + if not results: + warn("[Stash Scheduler] No valid times found in time_of_day — defaulting to 02:00.") + return [(2, 0)] + return sorted(results) def validate_and_coerce_settings(settings, warn): @@ -194,10 +214,11 @@ def validate_and_coerce_settings(settings, warn): settings["frequency"] = freq raw_time = settings.get("time_of_day", "02:00") - hour, minute = _parse_time_of_day(raw_time, warn) - settings["time_of_day"] = f"{hour:02d}:{minute:02d}" - settings["hour"] = hour - settings["minute"] = minute + times = _parse_times_of_day(raw_time, warn) + settings["times_of_day"] = times + settings["time_of_day"] = ", ".join(f"{h:02d}:{m:02d}" for h, m in times) + settings["hour"] = times[0][0] + settings["minute"] = times[0][1] dow = str(settings.get("day_of_week", "sun")).strip().lower() if dow not in VALID_DAYS: @@ -233,6 +254,9 @@ def validate_and_coerce_settings(settings, warn): tz_raw = "UTC" settings["timezone"] = tz_raw + # apiKey — strip whitespace; empty string means no key (session auth) + settings["apiKey"] = str(settings.get("apiKey", "") or "").strip() + # scanPaths — parse comma/newline-separated string into a clean list raw_paths = str(settings.get("scanPaths", "") or "") import re as _re @@ -254,6 +278,16 @@ def validate_and_coerce_settings(settings, warn): # Scan / Identify helpers (used by both plugin tasks and the daemon) # --------------------------------------------------------------------------- +def _strip_nulls(obj): + """Recursively remove None/null values from a dict or list. + Required before forwarding a GraphQL query result back as mutation input — + Stash rejects null on required sub-fields.""" + if isinstance(obj, dict): + return {k: _strip_nulls(v) for k, v in obj.items() if v is not None} + if isinstance(obj, list): + return [_strip_nulls(i) for i in obj] + return obj + _SCAN_FLAGS = ( "scanGenerateCovers", "scanGeneratePreviews", @@ -333,9 +367,12 @@ def trigger_identify(stash_or_log, gql_fn, paths=None): paths_desc = f" (paths: {', '.join(paths)})" if paths else " (full library)" _log_info(stash_or_log, f"[Stash Scheduler] Triggering identify task{paths_desc}…") try: - identify_input = {"sources": identify["sources"]} + # Strip nulls: the query returns the full schema object including null + # sub-fields; forwarding those nulls into the mutation causes Stash to + # reject the request with a schema validation error. + identify_input = {"sources": _strip_nulls(identify["sources"])} if identify.get("options"): - identify_input["options"] = identify["options"] + identify_input["options"] = _strip_nulls(identify["options"]) if paths: identify_input["paths"] = paths result = gql_fn(IDENTIFY_MUTATION, {"input": identify_input}) @@ -528,7 +565,7 @@ def daemon_alive(): return False, None -def tail_log(n=30): +def tail_log(n=100): """Return the last n lines of the daemon log file as a string.""" if not os.path.exists(LOG_FILE): return "(log file not found)" @@ -572,46 +609,82 @@ def run_daemon(): sys.exit(1) frequency = settings["frequency"] - hour = settings["hour"] - minute = settings["minute"] + times_of_day = settings["times_of_day"] day_of_week = settings["day_of_week"] timezone = settings["timezone"] run_identify = settings["run_identify"] identify_timeout = settings["identify_timeout_minutes"] scan_paths = settings.get("scan_paths") or [] - # Build a simple GQL callable for the daemon (not stash.log-based) - def gql(query, variables=None): - return call_gql(stash, query, variables) - def scheduled_job(): + """Fired by APScheduler for each scheduled time slot.""" log.info("Scheduled scan cycle firing.") + # Fresh connection each run — guards against stale sessions after a + # Stash restart. try: - job_id = trigger_scan(log, gql, settings) + fresh_stash = make_stash(cfg["server_connection"]) + def gql(query, variables=None): + return call_gql(fresh_stash, query, variables) except Exception as exc: - log.error(f"Scan failed: {exc}") + log.error(f"Cannot connect to Stash for scheduled scan: {exc}") return - if run_identify: + + # Reload operational settings live from Stash so changes to + # run_identify, scan_paths, scan flags, etc. take effect immediately + # without needing to restart the daemon. + try: + live_settings = validate_and_coerce_settings( + get_plugin_settings(fresh_stash), + lambda m: log.warning(m), + ) + log.info( + f"Settings reloaded — identify: {'yes' if live_settings['run_identify'] else 'no'}, " + f"paths: {', '.join(live_settings.get('scan_paths') or []) or 'full library'}" + ) + except Exception as exc: + log.warning(f"Could not reload settings from Stash — using startup settings: {exc}") + live_settings = settings + + try: + job_id = trigger_scan(log, gql, live_settings) + except Exception as exc: + log.error(f"Scan trigger failed: {exc}") + return + + if live_settings["run_identify"]: + live_timeout = live_settings["identify_timeout_minutes"] + live_paths = live_settings.get("scan_paths") or None threading.Thread( target=wait_for_scan_and_identify, - args=(log, gql, job_id, identify_timeout, scan_paths or None), + args=(log, gql, job_id, live_timeout, live_paths), daemon=True, ).start() + def heartbeat_job(): + """Fires every hour so the log shows the daemon is still alive.""" + log.info("[heartbeat] Daemon alive.") + scheduler = BackgroundScheduler(timezone=timezone) job_kwargs = {"func": scheduled_job, "misfire_grace_time": 3600, "coalesce": True} + times_str = ", ".join(f"{h:02d}:{m:02d}" for h, m in times_of_day) if frequency == "hourly": scheduler.add_job(trigger="cron", minute=0, **job_kwargs) log.info(f"Schedule: every hour at :00 ({timezone})") elif frequency == "weekly": - scheduler.add_job( - trigger="cron", day_of_week=day_of_week, hour=hour, minute=minute, **job_kwargs - ) - log.info(f"Schedule: weekly {day_of_week.upper()} at {hour:02d}:{minute:02d} ({timezone})") + for h, m in times_of_day: + scheduler.add_job( + trigger="cron", day_of_week=day_of_week, hour=h, minute=m, **job_kwargs + ) + log.info(f"Schedule: weekly {day_of_week.upper()} at {times_str} ({timezone})") else: - scheduler.add_job(trigger="cron", hour=hour, minute=minute, **job_kwargs) - log.info(f"Schedule: daily at {hour:02d}:{minute:02d} ({timezone})") + for h, m in times_of_day: + scheduler.add_job(trigger="cron", hour=h, minute=m, **job_kwargs) + log.info(f"Schedule: daily at {times_str} ({timezone})") + + # Hourly heartbeat so the log proves the daemon is ticking even between scans + scheduler.add_job(func=heartbeat_job, trigger="cron", minute=0, + misfire_grace_time=3600, coalesce=True) log.info(f"Identify after scan: {'yes' if run_identify else 'no'}") @@ -626,7 +699,21 @@ def scheduled_job(): else: log.info("Scan flags: none (bare scan)") + # Log APScheduler execution errors so silent job failures are visible + from apscheduler.events import EVENT_JOB_ERROR, EVENT_JOB_EXECUTED, EVENT_JOB_MISSED + def _aps_listener(event): + if event.exception: + log.error(f"[APScheduler] Job {event.job_id} raised an exception: {event.exception}") + elif hasattr(event, 'scheduled_run_time') and not hasattr(event, 'retval'): + log.warning(f"[APScheduler] Job {event.job_id} missed its scheduled time.") + scheduler.add_listener(_aps_listener, EVENT_JOB_ERROR | EVENT_JOB_MISSED) + scheduler.start() + + # Log next fire times so the log confirms jobs are registered correctly + for job in scheduler.get_jobs(): + if job.next_run_time: + log.info(f"Next fire for job '{job.id}': {job.next_run_time}") log.info("Daemon is running. Waiting for scheduled events…") stop = threading.Event() @@ -682,7 +769,13 @@ def gql(query, variables=None): # --------------------------------------------------------------------------- def task_start_scheduler(stash, server_connection, settings): - save_config(server_connection, settings) + # Inject the API key into the saved connection dict so every daemon GQL + # request includes it — required when Stash's "Require API key" is enabled. + conn = dict(server_connection) + api_key = settings.get("apiKey", "").strip() + if api_key: + conn["ApiKey"] = api_key + save_config(conn, settings) kill_existing_daemon() launch_detached("--daemon") freq = settings["frequency"] @@ -727,7 +820,7 @@ def task_run_now(stash, settings, force_identify=False): def task_check_status(stash): alive, pid = daemon_alive() status_line = f"Daemon: RUNNING (PID {pid})" if alive else "Daemon: NOT RUNNING" - recent = tail_log(30) + recent = tail_log(100) output = f"{status_line}\nLog file: {LOG_FILE}\n\nRecent log ({LOG_FILE}):\n{recent}" stash.log.info(f"[Stash Scheduler] {status_line}") print(json.dumps({"output": output}))