Skip to content

Commit 5ad5f71

Browse files
authored
Add files via upload
Updated functionality and added api key authentication capability
1 parent 9b0e6c9 commit 5ad5f71

3 files changed

Lines changed: 144 additions & 35 deletions

File tree

plugins/stash-scheduler/README.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,8 +81,9 @@ Open **Settings → Plugins → Stash Scheduler** and set your preferences:
8181

8282
| Setting | Description | Default |
8383
|---|---|---|
84+
| **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)* |
8485
| **Scan Frequency** | `hourly`, `daily`, or `weekly` | `daily` |
85-
| **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` |
86+
| **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` |
8687
| **Day of Week** | Day to scan when Frequency is Weekly. Use `mon`, `tue`, `wed`, `thu`, `fri`, `sat`, or `sun`. | `sun` |
8788
| **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` |
8889
| **Run Identify After Scan** | When enabled, runs an Identify task after each scan finishes successfully. | `false` |
@@ -294,6 +295,10 @@ All activity is written to the Stash log. To view it:
294295

295296
| Version | Notes |
296297
|---|---|
298+
| 0.10.0 | Added API Key setting — required when Stash's "Require API key" security option is enabled |
299+
| 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 |
300+
| 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 |
301+
| 0.7.0 | Time of Day now accepts multiple times (comma or space separated) — each fires a separate scheduled scan |
297302
| 0.6.0 | Added "Limit to Paths" setting — scan and identify can now be restricted to specific directories |
298303
| 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) |
299304
| 0.4.0 | Added Check Status task; daemon logs written to file (`/tmp/stash-scheduler-daemon.log`) |

plugins/stash-scheduler/stash-scheduler.yml

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
name: Stash Scheduler
22
description: Schedule automatic library scans with an optional identify pass after each scan.
3-
version: "0.6.0"
3+
version: "0.10.0"
44
url: https://github.com/stashapp/stash
55

66
exec:
@@ -42,6 +42,16 @@ tasks:
4242
mode: check_status
4343

4444
settings:
45+
apiKey:
46+
displayName: API Key
47+
description: >
48+
Stash API key for authenticating background scans. Required when
49+
Settings > Security > "Require API key for local connections" is enabled,
50+
or when accessing Stash over a network. Generate a key under
51+
Settings > Security > API Keys, then paste it here. Leave blank if
52+
Stash does not require authentication.
53+
type: STRING
54+
4555
frequency:
4656
displayName: Scan Frequency
4757
description: >
@@ -53,9 +63,10 @@ settings:
5363
time_of_day:
5464
displayName: Time of Day (HH:MM)
5565
description: >
56-
The time to run the scan in 24-hour HH:MM format. Used for Daily and
57-
Weekly schedules; ignored for Hourly. Examples: 02:00, 14:30, 20:45.
58-
Defaults to 02:00 if not set.
66+
One or more times to run the scan in 24-hour HH:MM format. Used for
67+
Daily and Weekly schedules; ignored for Hourly. Separate multiple times
68+
with commas or spaces. Examples: 02:00 — single daily run. 00:00, 06:00,
69+
09:00, 16:00, 18:00 — five runs per day. Defaults to 02:00 if not set.
5970
type: STRING
6071

6172
day_of_week:

plugins/stash-scheduler/stash_scheduler.py

Lines changed: 123 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ def get_plugin_settings(stash, plugin_id="stash-scheduler"):
142142
"time_of_day": "02:00",
143143
"day_of_week": "sun",
144144
"timezone": "UTC",
145+
"apiKey": "",
145146
"run_identify": False,
146147
"identify_timeout_minutes": 120,
147148
# Comma- or newline-separated list of paths to restrict scan + identify.
@@ -168,19 +169,38 @@ def get_plugin_settings(stash, plugin_id="stash-scheduler"):
168169
return defaults
169170

170171

171-
def _parse_time_of_day(raw, warn):
172-
raw = str(raw).strip()
172+
def _parse_single_time(token, warn):
173+
"""Parse one HH:MM token. Returns (hour, minute) or None on error."""
174+
token = str(token).strip()
173175
try:
174-
parts = raw.split(":")
176+
parts = token.split(":")
175177
if len(parts) != 2:
176178
raise ValueError("expected HH:MM")
177179
hh, mm = int(parts[0]), int(parts[1])
178180
if not (0 <= hh <= 23 and 0 <= mm <= 59):
179181
raise ValueError(f"values out of range: {hh}:{mm:02d}")
180182
return hh, mm
181183
except (ValueError, TypeError) as exc:
182-
warn(f"[Stash Scheduler] Invalid time_of_day {raw!r} ({exc}) — defaulting to 02:00.")
183-
return 2, 0
184+
warn(f"[Stash Scheduler] Invalid time {token!r} ({exc}) — skipping.")
185+
return None
186+
187+
188+
def _parse_times_of_day(raw, warn):
189+
"""Parse comma/space/newline-separated HH:MM times.
190+
Returns a deduplicated list of (hour, minute) tuples, sorted ascending.
191+
Falls back to [(2, 0)] if nothing valid is found."""
192+
import re as _re
193+
tokens = [t for t in _re.split(r"[\s,]+", str(raw).strip()) if t]
194+
results, seen = [], set()
195+
for token in tokens:
196+
parsed = _parse_single_time(token, warn)
197+
if parsed is not None and parsed not in seen:
198+
results.append(parsed)
199+
seen.add(parsed)
200+
if not results:
201+
warn("[Stash Scheduler] No valid times found in time_of_day — defaulting to 02:00.")
202+
return [(2, 0)]
203+
return sorted(results)
184204

185205

186206
def validate_and_coerce_settings(settings, warn):
@@ -194,10 +214,11 @@ def validate_and_coerce_settings(settings, warn):
194214
settings["frequency"] = freq
195215

196216
raw_time = settings.get("time_of_day", "02:00")
197-
hour, minute = _parse_time_of_day(raw_time, warn)
198-
settings["time_of_day"] = f"{hour:02d}:{minute:02d}"
199-
settings["hour"] = hour
200-
settings["minute"] = minute
217+
times = _parse_times_of_day(raw_time, warn)
218+
settings["times_of_day"] = times
219+
settings["time_of_day"] = ", ".join(f"{h:02d}:{m:02d}" for h, m in times)
220+
settings["hour"] = times[0][0]
221+
settings["minute"] = times[0][1]
201222

202223
dow = str(settings.get("day_of_week", "sun")).strip().lower()
203224
if dow not in VALID_DAYS:
@@ -233,6 +254,9 @@ def validate_and_coerce_settings(settings, warn):
233254
tz_raw = "UTC"
234255
settings["timezone"] = tz_raw
235256

257+
# apiKey — strip whitespace; empty string means no key (session auth)
258+
settings["apiKey"] = str(settings.get("apiKey", "") or "").strip()
259+
236260
# scanPaths — parse comma/newline-separated string into a clean list
237261
raw_paths = str(settings.get("scanPaths", "") or "")
238262
import re as _re
@@ -254,6 +278,16 @@ def validate_and_coerce_settings(settings, warn):
254278
# Scan / Identify helpers (used by both plugin tasks and the daemon)
255279
# ---------------------------------------------------------------------------
256280

281+
def _strip_nulls(obj):
282+
"""Recursively remove None/null values from a dict or list.
283+
Required before forwarding a GraphQL query result back as mutation input —
284+
Stash rejects null on required sub-fields."""
285+
if isinstance(obj, dict):
286+
return {k: _strip_nulls(v) for k, v in obj.items() if v is not None}
287+
if isinstance(obj, list):
288+
return [_strip_nulls(i) for i in obj]
289+
return obj
290+
257291
_SCAN_FLAGS = (
258292
"scanGenerateCovers",
259293
"scanGeneratePreviews",
@@ -333,9 +367,12 @@ def trigger_identify(stash_or_log, gql_fn, paths=None):
333367
paths_desc = f" (paths: {', '.join(paths)})" if paths else " (full library)"
334368
_log_info(stash_or_log, f"[Stash Scheduler] Triggering identify task{paths_desc}…")
335369
try:
336-
identify_input = {"sources": identify["sources"]}
370+
# Strip nulls: the query returns the full schema object including null
371+
# sub-fields; forwarding those nulls into the mutation causes Stash to
372+
# reject the request with a schema validation error.
373+
identify_input = {"sources": _strip_nulls(identify["sources"])}
337374
if identify.get("options"):
338-
identify_input["options"] = identify["options"]
375+
identify_input["options"] = _strip_nulls(identify["options"])
339376
if paths:
340377
identify_input["paths"] = paths
341378
result = gql_fn(IDENTIFY_MUTATION, {"input": identify_input})
@@ -528,7 +565,7 @@ def daemon_alive():
528565
return False, None
529566

530567

531-
def tail_log(n=30):
568+
def tail_log(n=100):
532569
"""Return the last n lines of the daemon log file as a string."""
533570
if not os.path.exists(LOG_FILE):
534571
return "(log file not found)"
@@ -572,46 +609,82 @@ def run_daemon():
572609
sys.exit(1)
573610

574611
frequency = settings["frequency"]
575-
hour = settings["hour"]
576-
minute = settings["minute"]
612+
times_of_day = settings["times_of_day"]
577613
day_of_week = settings["day_of_week"]
578614
timezone = settings["timezone"]
579615
run_identify = settings["run_identify"]
580616
identify_timeout = settings["identify_timeout_minutes"]
581617
scan_paths = settings.get("scan_paths") or []
582618

583-
# Build a simple GQL callable for the daemon (not stash.log-based)
584-
def gql(query, variables=None):
585-
return call_gql(stash, query, variables)
586-
587619
def scheduled_job():
620+
"""Fired by APScheduler for each scheduled time slot."""
588621
log.info("Scheduled scan cycle firing.")
622+
# Fresh connection each run — guards against stale sessions after a
623+
# Stash restart.
589624
try:
590-
job_id = trigger_scan(log, gql, settings)
625+
fresh_stash = make_stash(cfg["server_connection"])
626+
def gql(query, variables=None):
627+
return call_gql(fresh_stash, query, variables)
591628
except Exception as exc:
592-
log.error(f"Scan failed: {exc}")
629+
log.error(f"Cannot connect to Stash for scheduled scan: {exc}")
593630
return
594-
if run_identify:
631+
632+
# Reload operational settings live from Stash so changes to
633+
# run_identify, scan_paths, scan flags, etc. take effect immediately
634+
# without needing to restart the daemon.
635+
try:
636+
live_settings = validate_and_coerce_settings(
637+
get_plugin_settings(fresh_stash),
638+
lambda m: log.warning(m),
639+
)
640+
log.info(
641+
f"Settings reloaded — identify: {'yes' if live_settings['run_identify'] else 'no'}, "
642+
f"paths: {', '.join(live_settings.get('scan_paths') or []) or 'full library'}"
643+
)
644+
except Exception as exc:
645+
log.warning(f"Could not reload settings from Stash — using startup settings: {exc}")
646+
live_settings = settings
647+
648+
try:
649+
job_id = trigger_scan(log, gql, live_settings)
650+
except Exception as exc:
651+
log.error(f"Scan trigger failed: {exc}")
652+
return
653+
654+
if live_settings["run_identify"]:
655+
live_timeout = live_settings["identify_timeout_minutes"]
656+
live_paths = live_settings.get("scan_paths") or None
595657
threading.Thread(
596658
target=wait_for_scan_and_identify,
597-
args=(log, gql, job_id, identify_timeout, scan_paths or None),
659+
args=(log, gql, job_id, live_timeout, live_paths),
598660
daemon=True,
599661
).start()
600662

663+
def heartbeat_job():
664+
"""Fires every hour so the log shows the daemon is still alive."""
665+
log.info("[heartbeat] Daemon alive.")
666+
601667
scheduler = BackgroundScheduler(timezone=timezone)
602668
job_kwargs = {"func": scheduled_job, "misfire_grace_time": 3600, "coalesce": True}
603669

670+
times_str = ", ".join(f"{h:02d}:{m:02d}" for h, m in times_of_day)
604671
if frequency == "hourly":
605672
scheduler.add_job(trigger="cron", minute=0, **job_kwargs)
606673
log.info(f"Schedule: every hour at :00 ({timezone})")
607674
elif frequency == "weekly":
608-
scheduler.add_job(
609-
trigger="cron", day_of_week=day_of_week, hour=hour, minute=minute, **job_kwargs
610-
)
611-
log.info(f"Schedule: weekly {day_of_week.upper()} at {hour:02d}:{minute:02d} ({timezone})")
675+
for h, m in times_of_day:
676+
scheduler.add_job(
677+
trigger="cron", day_of_week=day_of_week, hour=h, minute=m, **job_kwargs
678+
)
679+
log.info(f"Schedule: weekly {day_of_week.upper()} at {times_str} ({timezone})")
612680
else:
613-
scheduler.add_job(trigger="cron", hour=hour, minute=minute, **job_kwargs)
614-
log.info(f"Schedule: daily at {hour:02d}:{minute:02d} ({timezone})")
681+
for h, m in times_of_day:
682+
scheduler.add_job(trigger="cron", hour=h, minute=m, **job_kwargs)
683+
log.info(f"Schedule: daily at {times_str} ({timezone})")
684+
685+
# Hourly heartbeat so the log proves the daemon is ticking even between scans
686+
scheduler.add_job(func=heartbeat_job, trigger="cron", minute=0,
687+
misfire_grace_time=3600, coalesce=True)
615688

616689
log.info(f"Identify after scan: {'yes' if run_identify else 'no'}")
617690

@@ -626,7 +699,21 @@ def scheduled_job():
626699
else:
627700
log.info("Scan flags: none (bare scan)")
628701

702+
# Log APScheduler execution errors so silent job failures are visible
703+
from apscheduler.events import EVENT_JOB_ERROR, EVENT_JOB_EXECUTED, EVENT_JOB_MISSED
704+
def _aps_listener(event):
705+
if event.exception:
706+
log.error(f"[APScheduler] Job {event.job_id} raised an exception: {event.exception}")
707+
elif hasattr(event, 'scheduled_run_time') and not hasattr(event, 'retval'):
708+
log.warning(f"[APScheduler] Job {event.job_id} missed its scheduled time.")
709+
scheduler.add_listener(_aps_listener, EVENT_JOB_ERROR | EVENT_JOB_MISSED)
710+
629711
scheduler.start()
712+
713+
# Log next fire times so the log confirms jobs are registered correctly
714+
for job in scheduler.get_jobs():
715+
if job.next_run_time:
716+
log.info(f"Next fire for job '{job.id}': {job.next_run_time}")
630717
log.info("Daemon is running. Waiting for scheduled events…")
631718

632719
stop = threading.Event()
@@ -682,7 +769,13 @@ def gql(query, variables=None):
682769
# ---------------------------------------------------------------------------
683770

684771
def task_start_scheduler(stash, server_connection, settings):
685-
save_config(server_connection, settings)
772+
# Inject the API key into the saved connection dict so every daemon GQL
773+
# request includes it — required when Stash's "Require API key" is enabled.
774+
conn = dict(server_connection)
775+
api_key = settings.get("apiKey", "").strip()
776+
if api_key:
777+
conn["ApiKey"] = api_key
778+
save_config(conn, settings)
686779
kill_existing_daemon()
687780
launch_detached("--daemon")
688781
freq = settings["frequency"]
@@ -727,7 +820,7 @@ def task_run_now(stash, settings, force_identify=False):
727820
def task_check_status(stash):
728821
alive, pid = daemon_alive()
729822
status_line = f"Daemon: RUNNING (PID {pid})" if alive else "Daemon: NOT RUNNING"
730-
recent = tail_log(30)
823+
recent = tail_log(100)
731824
output = f"{status_line}\nLog file: {LOG_FILE}\n\nRecent log ({LOG_FILE}):\n{recent}"
732825
stash.log.info(f"[Stash Scheduler] {status_line}")
733826
print(json.dumps({"output": output}))

0 commit comments

Comments
 (0)