@@ -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
186206def 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
684771def 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):
727820def 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 } \n Log file: { LOG_FILE } \n \n Recent log ({ LOG_FILE } ):\n { recent } "
732825 stash .log .info (f"[Stash Scheduler] { status_line } " )
733826 print (json .dumps ({"output" : output }))
0 commit comments