|
|
@ -34,36 +34,47 @@ import sabnzbd.cfg as cfg |
|
|
|
from sabnzbd.constants import LOW_PRIORITY, NORMAL_PRIORITY, HIGH_PRIORITY |
|
|
|
|
|
|
|
|
|
|
|
__SCHED: Optional[kronos.ThreadedScheduler] = None # Global pointer to Scheduler instance |
|
|
|
|
|
|
|
SCHEDULE_GUARD_FLAG = False |
|
|
|
PP_PAUSE_EVENT = False |
|
|
|
|
|
|
|
|
|
|
|
def schedule_guard(): |
|
|
|
""" Set flag for scheduler restart """ |
|
|
|
global SCHEDULE_GUARD_FLAG |
|
|
|
SCHEDULE_GUARD_FLAG = True |
|
|
|
|
|
|
|
|
|
|
|
def pp_pause(): |
|
|
|
sabnzbd.PostProcessor.paused = True |
|
|
|
|
|
|
|
|
|
|
|
def pp_resume(): |
|
|
|
sabnzbd.PostProcessor.paused = False |
|
|
|
|
|
|
|
|
|
|
|
def pp_pause_event(): |
|
|
|
return PP_PAUSE_EVENT |
|
|
|
class Scheduler: |
|
|
|
def __init__(self): |
|
|
|
self.scheduler = kronos.ThreadedScheduler() |
|
|
|
self.pause_end: Optional[float] = None # Moment when pause will end |
|
|
|
self.restart_scheduler = False |
|
|
|
self.pp_pause_event = False |
|
|
|
self.load_schedules() |
|
|
|
|
|
|
|
def start(self): |
|
|
|
""" Start the scheduler """ |
|
|
|
self.scheduler.start() |
|
|
|
|
|
|
|
def stop(self): |
|
|
|
""" Stop the scheduler, destroy instance """ |
|
|
|
logging.debug("Stopping scheduler") |
|
|
|
self.scheduler.stop() |
|
|
|
|
|
|
|
def init(): |
|
|
|
""" Create the scheduler and set all required events """ |
|
|
|
global __SCHED |
|
|
|
def restart(self, plan_restart=True): |
|
|
|
""" Stop and start scheduler """ |
|
|
|
if plan_restart: |
|
|
|
self.restart_scheduler = True |
|
|
|
elif self.restart_scheduler: |
|
|
|
logging.debug("Restarting scheduler") |
|
|
|
self.restart_scheduler = False |
|
|
|
self.scheduler.stop() |
|
|
|
self.scheduler.start() |
|
|
|
self.analyse(sabnzbd.Downloader.paused) |
|
|
|
self.load_schedules() |
|
|
|
|
|
|
|
def abort(self): |
|
|
|
"""Emergency stop, just set the running attribute false so we don't |
|
|
|
have to wait the full scheduler-check cycle before it really stops""" |
|
|
|
self.scheduler.running = False |
|
|
|
|
|
|
|
def is_alive(self): |
|
|
|
""" Thread-like check if we are doing fine """ |
|
|
|
if self.scheduler.thread: |
|
|
|
return self.scheduler.thread.is_alive() |
|
|
|
return False |
|
|
|
|
|
|
|
reset_guardian() |
|
|
|
__SCHED = kronos.ThreadedScheduler() |
|
|
|
def load_schedules(self): |
|
|
|
rss_planned = False |
|
|
|
|
|
|
|
for schedule in cfg.schedules(): |
|
|
@ -95,7 +106,7 @@ def init(): |
|
|
|
d = list(range(1, 8)) |
|
|
|
|
|
|
|
if action_name == "resume": |
|
|
|
action = scheduled_resume |
|
|
|
action = self.scheduled_resume |
|
|
|
arguments = [] |
|
|
|
elif action_name == "pause": |
|
|
|
action = sabnzbd.Downloader.pause |
|
|
@ -163,24 +174,19 @@ def init(): |
|
|
|
continue |
|
|
|
|
|
|
|
if enabled == "1": |
|
|
|
logging.debug("Scheduling %s(%s) on days %s at %02d:%02d", action_name, arguments, d, h, m) |
|
|
|
__SCHED.add_daytime_task(action, action_name, d, None, (h, m), kronos.method.sequential, arguments, None) |
|
|
|
logging.info("Scheduling %s(%s) on days %s at %02d:%02d", action_name, arguments, d, h, m) |
|
|
|
self.scheduler.add_daytime_task(action, action_name, d, None, (h, m), args=arguments) |
|
|
|
else: |
|
|
|
logging.debug("Skipping %s(%s) on days %s at %02d:%02d", action_name, arguments, d, h, m) |
|
|
|
|
|
|
|
# Set Guardian interval to 30 seconds |
|
|
|
__SCHED.add_interval_task(sched_guardian, "Guardian", 15, 30, kronos.method.sequential, None, None) |
|
|
|
|
|
|
|
# Set RSS check interval |
|
|
|
if not rss_planned: |
|
|
|
interval = cfg.rss_rate() |
|
|
|
delay = random.randint(0, interval - 1) |
|
|
|
logging.debug("Scheduling RSS interval task every %s min (delay=%s)", interval, delay) |
|
|
|
logging.info("Scheduling RSS interval task every %s min (delay=%s)", interval, delay) |
|
|
|
sabnzbd.RSSReader.next_run = time.time() + delay * 60 |
|
|
|
__SCHED.add_interval_task( |
|
|
|
sabnzbd.RSSReader.run, "RSS", delay * 60, interval * 60, kronos.method.sequential, None, None |
|
|
|
) |
|
|
|
__SCHED.add_single_task(sabnzbd.RSSReader.run, "RSS", 15, kronos.method.sequential, None, None) |
|
|
|
self.scheduler.add_interval_task(sabnzbd.RSSReader.run, "RSS", delay * 60, interval * 60) |
|
|
|
self.scheduler.add_single_task(sabnzbd.RSSReader.run, "RSS", 15) |
|
|
|
|
|
|
|
if cfg.version_check(): |
|
|
|
# Check for new release, once per week on random time |
|
|
@ -188,144 +194,31 @@ def init(): |
|
|
|
h = random.randint(0, 23) |
|
|
|
d = (random.randint(1, 7),) |
|
|
|
|
|
|
|
logging.debug("Scheduling VersionCheck on day %s at %s:%s", d[0], h, m) |
|
|
|
__SCHED.add_daytime_task( |
|
|
|
sabnzbd.misc.check_latest_version, "VerCheck", d, None, (h, m), kronos.method.sequential, [], None |
|
|
|
) |
|
|
|
logging.info("Scheduling VersionCheck on day %s at %s:%s", d[0], h, m) |
|
|
|
self.scheduler.add_daytime_task(sabnzbd.misc.check_latest_version, "VerCheck", d, None, (h, m)) |
|
|
|
|
|
|
|
action, hour, minute = sabnzbd.BPSMeter.get_quota() |
|
|
|
if action: |
|
|
|
logging.info("Setting schedule for quota check daily at %s:%s", hour, minute) |
|
|
|
__SCHED.add_daytime_task( |
|
|
|
action, "quota_reset", list(range(1, 8)), None, (hour, minute), kronos.method.sequential, [], None |
|
|
|
) |
|
|
|
self.scheduler.add_daytime_task(action, "quota_reset", list(range(1, 8)), None, (hour, minute)) |
|
|
|
|
|
|
|
if sabnzbd.misc.int_conv(cfg.history_retention()) > 0: |
|
|
|
logging.info("Setting schedule for midnight auto history-purge") |
|
|
|
__SCHED.add_daytime_task( |
|
|
|
sabnzbd.database.midnight_history_purge, |
|
|
|
"midnight_history_purge", |
|
|
|
list(range(1, 8)), |
|
|
|
None, |
|
|
|
(0, 0), |
|
|
|
kronos.method.sequential, |
|
|
|
[], |
|
|
|
None, |
|
|
|
self.scheduler.add_daytime_task( |
|
|
|
sabnzbd.database.midnight_history_purge, "midnight_history_purge", list(range(1, 8)), None, (0, 0) |
|
|
|
) |
|
|
|
|
|
|
|
logging.info("Setting schedule for midnight BPS reset") |
|
|
|
__SCHED.add_daytime_task( |
|
|
|
sabnzbd.BPSMeter.midnight, |
|
|
|
"midnight_bps", |
|
|
|
list(range(1, 8)), |
|
|
|
None, |
|
|
|
(0, 0), |
|
|
|
kronos.method.sequential, |
|
|
|
[], |
|
|
|
None, |
|
|
|
) |
|
|
|
self.scheduler.add_daytime_task(sabnzbd.BPSMeter.midnight, "midnight_bps", list(range(1, 8)), None, (0, 0)) |
|
|
|
|
|
|
|
# Subscribe to special schedule changes |
|
|
|
cfg.rss_rate.callback(schedule_guard) |
|
|
|
|
|
|
|
|
|
|
|
def start(): |
|
|
|
""" Start the scheduler """ |
|
|
|
global __SCHED |
|
|
|
if __SCHED: |
|
|
|
logging.debug("Starting scheduler") |
|
|
|
__SCHED.start() |
|
|
|
cfg.rss_rate.callback(self.scheduler_restart_guard) |
|
|
|
|
|
|
|
|
|
|
|
def restart(force=False): |
|
|
|
""" Stop and start scheduler """ |
|
|
|
global SCHEDULE_GUARD_FLAG |
|
|
|
|
|
|
|
if force: |
|
|
|
SCHEDULE_GUARD_FLAG = True |
|
|
|
else: |
|
|
|
if SCHEDULE_GUARD_FLAG: |
|
|
|
SCHEDULE_GUARD_FLAG = False |
|
|
|
stop() |
|
|
|
|
|
|
|
analyse(sabnzbd.Downloader.paused) |
|
|
|
|
|
|
|
init() |
|
|
|
start() |
|
|
|
|
|
|
|
|
|
|
|
def stop(): |
|
|
|
""" Stop the scheduler, destroy instance """ |
|
|
|
global __SCHED |
|
|
|
if __SCHED: |
|
|
|
logging.debug("Stopping scheduler") |
|
|
|
try: |
|
|
|
__SCHED.stop() |
|
|
|
except IndexError: |
|
|
|
pass |
|
|
|
del __SCHED |
|
|
|
__SCHED = None |
|
|
|
|
|
|
|
|
|
|
|
def abort(): |
|
|
|
""" Emergency stop, just set the running attribute false """ |
|
|
|
global __SCHED |
|
|
|
if __SCHED: |
|
|
|
logging.debug("Terminating scheduler") |
|
|
|
__SCHED.running = False |
|
|
|
|
|
|
|
|
|
|
|
def sort_schedules(all_events, now=None): |
|
|
|
"""Sort the schedules, based on order of happening from now |
|
|
|
`all_events=True`: Return an event for each active day |
|
|
|
`all_events=False`: Return only first occurring event of the week |
|
|
|
`now` : for testing: simulated localtime() |
|
|
|
""" |
|
|
|
|
|
|
|
day_min = 24 * 60 |
|
|
|
week_min = 7 * day_min |
|
|
|
events = [] |
|
|
|
|
|
|
|
now = now or time.localtime() |
|
|
|
now_hm = now[3] * 60 + now[4] |
|
|
|
now = now[6] * day_min + now_hm |
|
|
|
|
|
|
|
for schedule in cfg.schedules(): |
|
|
|
parms = None |
|
|
|
try: |
|
|
|
# Note: the last parameter can have spaces (category name)! |
|
|
|
enabled, m, h, dd, action, parms = schedule.split(None, 5) |
|
|
|
except: |
|
|
|
try: |
|
|
|
enabled, m, h, dd, action = schedule.split(None, 4) |
|
|
|
except: |
|
|
|
continue # Bad schedule, ignore |
|
|
|
action = action.strip() |
|
|
|
if dd == "*": |
|
|
|
dd = "1234567" |
|
|
|
if not dd.isdigit(): |
|
|
|
continue # Bad schedule, ignore |
|
|
|
for d in dd: |
|
|
|
then = (int(d) - 1) * day_min + int(h) * 60 + int(m) |
|
|
|
dif = then - now |
|
|
|
if all_events and dif < 0: |
|
|
|
# Expired event will occur again after a week |
|
|
|
dif = dif + week_min |
|
|
|
|
|
|
|
events.append((dif, action, parms, schedule, enabled)) |
|
|
|
if not all_events: |
|
|
|
break |
|
|
|
|
|
|
|
events.sort(key=lambda x: x[0]) |
|
|
|
return events |
|
|
|
|
|
|
|
|
|
|
|
def analyse(was_paused=False, priority=None): |
|
|
|
def analyse(self, was_paused=False, priority=None): |
|
|
|
"""Determine what pause/resume state we would have now. |
|
|
|
'priority': evaluate only effect for given priority, return True for paused |
|
|
|
""" |
|
|
|
global PP_PAUSE_EVENT |
|
|
|
PP_PAUSE_EVENT = False |
|
|
|
self.pp_pause_event = False |
|
|
|
paused = None |
|
|
|
paused_all = False |
|
|
|
pause_post = False |
|
|
@ -351,16 +244,16 @@ def analyse(was_paused=False, priority=None): |
|
|
|
paused = True |
|
|
|
elif action == "pause_all": |
|
|
|
paused_all = True |
|
|
|
PP_PAUSE_EVENT = True |
|
|
|
self.pp_pause_event = True |
|
|
|
elif action == "resume": |
|
|
|
paused = False |
|
|
|
paused_all = False |
|
|
|
elif action == "pause_post": |
|
|
|
pause_post = True |
|
|
|
PP_PAUSE_EVENT = True |
|
|
|
self.pp_pause_event = True |
|
|
|
elif action == "resume_post": |
|
|
|
pause_post = False |
|
|
|
PP_PAUSE_EVENT = True |
|
|
|
self.pp_pause_event = True |
|
|
|
elif action == "speedlimit" and value is not None: |
|
|
|
speedlimit = ev[2] |
|
|
|
elif action == "pause_all_low": |
|
|
@ -425,51 +318,43 @@ def analyse(was_paused=False, priority=None): |
|
|
|
pass |
|
|
|
config.save_config() |
|
|
|
|
|
|
|
def scheduler_restart_guard(self): |
|
|
|
""" Set flag for scheduler restart """ |
|
|
|
self.restart_scheduler = True |
|
|
|
|
|
|
|
# Support for single shot pause (=delayed resume) |
|
|
|
__PAUSE_END = None # Moment when pause will end |
|
|
|
|
|
|
|
|
|
|
|
def scheduled_resume(): |
|
|
|
def scheduled_resume(self): |
|
|
|
""" Scheduled resume, only when no oneshot resume is active """ |
|
|
|
global __PAUSE_END |
|
|
|
if __PAUSE_END is None: |
|
|
|
if self.pause_end is None: |
|
|
|
sabnzbd.unpause_all() |
|
|
|
|
|
|
|
|
|
|
|
def __oneshot_resume(when): |
|
|
|
def __oneshot_resume(self, when): |
|
|
|
"""Called by delayed resume schedule |
|
|
|
Only resumes if call comes at the planned time |
|
|
|
""" |
|
|
|
global __PAUSE_END |
|
|
|
if __PAUSE_END is not None and (when > __PAUSE_END - 5) and (when < __PAUSE_END + 55): |
|
|
|
__PAUSE_END = None |
|
|
|
if self.pause_end is not None and (when > self.pause_end - 5) and (when < self.pause_end + 55): |
|
|
|
self.pause_end = None |
|
|
|
logging.debug("Resume after pause-interval") |
|
|
|
sabnzbd.unpause_all() |
|
|
|
else: |
|
|
|
logging.debug("Ignoring cancelled resume") |
|
|
|
|
|
|
|
|
|
|
|
def plan_resume(interval): |
|
|
|
def plan_resume(self, interval): |
|
|
|
""" Set a scheduled resume after the interval """ |
|
|
|
global __SCHED, __PAUSE_END |
|
|
|
if interval > 0: |
|
|
|
__PAUSE_END = time.time() + (interval * 60) |
|
|
|
logging.debug("Schedule resume at %s", __PAUSE_END) |
|
|
|
__SCHED.add_single_task(__oneshot_resume, "", interval * 60, kronos.method.sequential, [__PAUSE_END], None) |
|
|
|
self.pause_end = time.time() + (interval * 60) |
|
|
|
logging.debug("Schedule resume at %s", self.pause_end) |
|
|
|
self.scheduler.add_single_task(self.__oneshot_resume, "", interval * 60, args=[self.pause_end]) |
|
|
|
sabnzbd.Downloader.pause() |
|
|
|
else: |
|
|
|
__PAUSE_END = None |
|
|
|
self.pause_end = None |
|
|
|
sabnzbd.unpause_all() |
|
|
|
|
|
|
|
|
|
|
|
def pause_int(): |
|
|
|
def pause_int(self) -> str: |
|
|
|
""" Return minutes:seconds until pause ends """ |
|
|
|
global __PAUSE_END |
|
|
|
if __PAUSE_END is None: |
|
|
|
if self.pause_end is None: |
|
|
|
return "0" |
|
|
|
else: |
|
|
|
val = __PAUSE_END - time.time() |
|
|
|
val = self.pause_end - time.time() |
|
|
|
if val < 0: |
|
|
|
sign = "-" |
|
|
|
val = abs(val) |
|
|
@ -479,50 +364,70 @@ def pause_int(): |
|
|
|
sec = int(val - mins * 60) |
|
|
|
return "%s%d:%02d" % (sign, mins, sec) |
|
|
|
|
|
|
|
|
|
|
|
def pause_check(): |
|
|
|
def pause_check(self): |
|
|
|
""" Unpause when time left is negative, compensate for missed schedule """ |
|
|
|
global __PAUSE_END |
|
|
|
if __PAUSE_END is not None and (__PAUSE_END - time.time()) < 0: |
|
|
|
__PAUSE_END = None |
|
|
|
if self.pause_end is not None and (self.pause_end - time.time()) < 0: |
|
|
|
self.pause_end = None |
|
|
|
logging.debug("Force resume, negative timer") |
|
|
|
sabnzbd.unpause_all() |
|
|
|
|
|
|
|
|
|
|
|
def plan_server(action, parms, interval): |
|
|
|
def plan_server(self, action, parms, interval): |
|
|
|
""" Plan to re-activate server after 'interval' minutes """ |
|
|
|
__SCHED.add_single_task(action, "", interval * 60, kronos.method.sequential, parms, None) |
|
|
|
self.scheduler.add_single_task(action, "", interval * 60, args=parms) |
|
|
|
|
|
|
|
|
|
|
|
def force_rss(): |
|
|
|
def force_rss(self): |
|
|
|
""" Add a one-time RSS scan, one second from now """ |
|
|
|
__SCHED.add_single_task(sabnzbd.RSSReader.run, "RSS", 1, kronos.method.sequential, None, None) |
|
|
|
self.scheduler.add_single_task(sabnzbd.RSSReader.run, "RSS", 1) |
|
|
|
|
|
|
|
|
|
|
|
# Scheduler Guarding system |
|
|
|
# Each check sets the guardian flag False |
|
|
|
# Each successful scheduled check sets the flag |
|
|
|
# If 4 consecutive checks fail, the scheduler is assumed to have crashed |
|
|
|
def pp_pause(): |
|
|
|
sabnzbd.PostProcessor.paused = True |
|
|
|
|
|
|
|
|
|
|
|
def pp_resume(): |
|
|
|
sabnzbd.PostProcessor.paused = False |
|
|
|
|
|
|
|
__SCHED_GUARDIAN = False |
|
|
|
__SCHED_GUARDIAN_CNT = 0 |
|
|
|
|
|
|
|
def sort_schedules(all_events, now=None): |
|
|
|
"""Sort the schedules, based on order of happening from now |
|
|
|
`all_events=True`: Return an event for each active day |
|
|
|
`all_events=False`: Return only first occurring event of the week |
|
|
|
`now` : for testing: simulated localtime() |
|
|
|
""" |
|
|
|
|
|
|
|
def reset_guardian(): |
|
|
|
global __SCHED_GUARDIAN, __SCHED_GUARDIAN_CNT |
|
|
|
__SCHED_GUARDIAN = False |
|
|
|
__SCHED_GUARDIAN_CNT = 0 |
|
|
|
day_min = 24 * 60 |
|
|
|
week_min = 7 * day_min |
|
|
|
events = [] |
|
|
|
|
|
|
|
now = now or time.localtime() |
|
|
|
now_hm = now[3] * 60 + now[4] |
|
|
|
now = now[6] * day_min + now_hm |
|
|
|
|
|
|
|
def sched_guardian(): |
|
|
|
global __SCHED_GUARDIAN, __SCHED_GUARDIAN_CNT |
|
|
|
__SCHED_GUARDIAN = True |
|
|
|
for schedule in cfg.schedules(): |
|
|
|
parms = None |
|
|
|
try: |
|
|
|
# Note: the last parameter can have spaces (category name)! |
|
|
|
enabled, m, h, dd, action, parms = schedule.split(None, 5) |
|
|
|
except: |
|
|
|
try: |
|
|
|
enabled, m, h, dd, action = schedule.split(None, 4) |
|
|
|
except: |
|
|
|
continue # Bad schedule, ignore |
|
|
|
action = action.strip() |
|
|
|
if dd == "*": |
|
|
|
dd = "1234567" |
|
|
|
if not dd.isdigit(): |
|
|
|
continue # Bad schedule, ignore |
|
|
|
for d in dd: |
|
|
|
then = (int(d) - 1) * day_min + int(h) * 60 + int(m) |
|
|
|
dif = then - now |
|
|
|
if all_events and dif < 0: |
|
|
|
# Expired event will occur again after a week |
|
|
|
dif = dif + week_min |
|
|
|
|
|
|
|
events.append((dif, action, parms, schedule, enabled)) |
|
|
|
if not all_events: |
|
|
|
break |
|
|
|
|
|
|
|
def sched_check(): |
|
|
|
global __SCHED_GUARDIAN, __SCHED_GUARDIAN_CNT |
|
|
|
if not __SCHED_GUARDIAN: |
|
|
|
__SCHED_GUARDIAN_CNT += 1 |
|
|
|
return __SCHED_GUARDIAN_CNT < 4 |
|
|
|
reset_guardian() |
|
|
|
return True |
|
|
|
events.sort(key=lambda x: x[0]) |
|
|
|
return events |
|
|
|