# coding=utf-8 import os import pretty from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.triggers.interval import IntervalTrigger from apscheduler.triggers.cron import CronTrigger from apscheduler.triggers.date import DateTrigger from apscheduler.events import EVENT_JOB_SUBMITTED, EVENT_JOB_EXECUTED, EVENT_JOB_ERROR from apscheduler.jobstores.base import JobLookupError from datetime import datetime, timedelta from calendar import day_name from random import randrange from tzlocal import get_localzone from tzlocal.utils import ZoneInfoNotFoundError from dateutil import tz import logging from app.announcements import get_announcements_to_file from sonarr.sync.series import update_series from sonarr.sync.episodes import update_all_episodes from radarr.sync.movies import update_movies, update_all_movies from subtitles.wanted import wanted_search_missing_subtitles_series, wanted_search_missing_subtitles_movies from subtitles.upgrade import upgrade_subtitles from utilities.cache import cache_maintenance from utilities.health import check_health from utilities.backup import backup_to_zip from .config import settings from .get_args import args from .event_handler import event_stream if not args.no_update: from .check_update import check_if_new_update, check_releases else: from .check_update import check_releases from dateutil.relativedelta import relativedelta NO_INTERVAL = "None" NEVER_DATE = "Never" ONE_YEAR_IN_SECONDS = 60 * 60 * 24 * 365 def a_long_time_from_now(job): # currently defined as more than a year from now delta = job.next_run_time - datetime.now(job.next_run_time.tzinfo) return delta.total_seconds() > ONE_YEAR_IN_SECONDS def in_a_century(): century = datetime.now() + relativedelta(years=100) return century.year class Scheduler: def __init__(self): self.__running_tasks = [] try: self.timezone = get_localzone() except ZoneInfoNotFoundError as e: logging.error(f"BAZARR cannot use specified timezone: {e}") self.timezone = tz.gettz("UTC") self.aps_scheduler = BackgroundScheduler({'apscheduler.timezone': self.timezone}) # task listener def task_listener_add(event): if event.job_id not in self.__running_tasks: self.__running_tasks.append(event.job_id) event_stream(type='task') def task_listener_remove(event): if event.job_id in self.__running_tasks: self.__running_tasks.remove(event.job_id) event_stream(type='task') self.aps_scheduler.add_listener(task_listener_add, EVENT_JOB_SUBMITTED) self.aps_scheduler.add_listener(task_listener_remove, EVENT_JOB_EXECUTED | EVENT_JOB_ERROR) # configure all tasks self.__cache_cleanup_task() self.__check_health_task() self.update_configurable_tasks() self.aps_scheduler.start() def update_configurable_tasks(self): self.__sonarr_update_task() self.__radarr_update_task() self.__sonarr_full_update_task() self.__radarr_full_update_task() self.__update_bazarr_task() self.__search_wanted_subtitles_task() self.__upgrade_subtitles_task() self.__randomize_interval_task() self.__automatic_backup() if args.no_tasks: self.__no_task() def add_job(self, job, name=None, max_instances=1, coalesce=True, args=None, kwargs=None): self.aps_scheduler.add_job( job, DateTrigger(run_date=datetime.now()), name=name, id=name, max_instances=max_instances, coalesce=coalesce, args=args, kwargs=kwargs) def execute_job_now(self, taskid): self.aps_scheduler.modify_job(taskid, next_run_time=datetime.now()) def get_running_tasks(self): return self.__running_tasks def get_task_list(self): def get_time_from_interval(td_object): seconds = int(td_object.total_seconds()) periods = [ ('year', 60 * 60 * 24 * 365), ('month', 60 * 60 * 24 * 30), ('day', 60 * 60 * 24), ('hour', 60 * 60), ('minute', 60), ('second', 1) ] if seconds > ONE_YEAR_IN_SECONDS: # more than a year is None return NO_INTERVAL strings = [] for period_name, period_seconds in periods: if seconds > period_seconds: period_value, seconds = divmod(seconds, period_seconds) has_s = 's' if period_value > 1 else '' strings.append("%s %s%s" % (period_value, period_name, has_s)) return ", ".join(strings) def get_time_from_cron(cron): year = str(cron[0]) day = str(cron[4]) hour = str(cron[5]) if day == "*": text = "every day" else: text = f"every {day_name[int(day)]}" if hour != "*": text += f" at {hour}:00" return text task_list = [] for job in self.aps_scheduler.get_jobs(): next_run = NEVER_DATE if job.next_run_time: if a_long_time_from_now(job): # Never for IntervalTrigger jobs next_run = NEVER_DATE else: next_run = pretty.date(job.next_run_time.replace(tzinfo=None)) if isinstance(job.trigger, CronTrigger): if a_long_time_from_now(job): # Never for CronTrigger jobs next_run = NEVER_DATE else: if job.next_run_time: next_run = pretty.date(job.next_run_time.replace(tzinfo=None)) if job.id in self.__running_tasks: running = True else: running = False if isinstance(job.trigger, IntervalTrigger): interval = get_time_from_interval(job.trigger.__getstate__()['interval']) if interval != NO_INTERVAL: interval = f"every {interval}" # else: # interval = "100 Year Interval" task_list.append({'name': job.name, 'interval': interval, 'next_run_in': next_run, 'next_run_time': next_run, 'job_id': job.id, 'job_running': running}) elif isinstance(job.trigger, CronTrigger): if a_long_time_from_now(job): interval = NO_INTERVAL else: interval = get_time_from_cron(job.trigger.fields) task_list.append({'name': job.name, 'interval': interval, 'next_run_in': next_run, 'next_run_time': next_run, 'job_id': job.id, 'job_running': running}) return task_list def __sonarr_update_task(self): if settings.general.use_sonarr: self.aps_scheduler.add_job( update_series, IntervalTrigger(minutes=int(settings.sonarr.series_sync)), max_instances=1, coalesce=True, misfire_grace_time=15, id='update_series', name='Sync with Sonarr', replace_existing=True) def __radarr_update_task(self): if settings.general.use_radarr: self.aps_scheduler.add_job( update_movies, IntervalTrigger(minutes=int(settings.radarr.movies_sync)), max_instances=1, coalesce=True, misfire_grace_time=15, id='update_movies', name='Sync with Radarr', replace_existing=True) def __cache_cleanup_task(self): self.aps_scheduler.add_job(cache_maintenance, IntervalTrigger(hours=24), max_instances=1, coalesce=True, misfire_grace_time=15, id='cache_cleanup', name='Cache Maintenance') def __check_health_task(self): self.aps_scheduler.add_job(check_health, IntervalTrigger(hours=6), max_instances=1, coalesce=True, misfire_grace_time=15, id='check_health', name='Check Health') def __automatic_backup(self): backup = settings.backup.frequency if backup == "Daily": trigger = CronTrigger(hour=settings.backup.hour) elif backup == "Weekly": trigger = CronTrigger(day_of_week=settings.backup.day, hour=settings.backup.hour) elif backup == "Manually": trigger = CronTrigger(year=in_a_century()) self.aps_scheduler.add_job(backup_to_zip, trigger, max_instances=1, coalesce=True, misfire_grace_time=15, id='backup', name='Backup Database and Configuration File', replace_existing=True) def __sonarr_full_update_task(self): if settings.general.use_sonarr: full_update = settings.sonarr.full_update if full_update == "Daily": self.aps_scheduler.add_job( update_all_episodes, CronTrigger(hour=settings.sonarr.full_update_hour), max_instances=1, coalesce=True, misfire_grace_time=15, id='update_all_episodes', name='Index All Episode Subtitles from Disk', replace_existing=True) elif full_update == "Weekly": self.aps_scheduler.add_job( update_all_episodes, CronTrigger(day_of_week=settings.sonarr.full_update_day, hour=settings.sonarr.full_update_hour), max_instances=1, coalesce=True, misfire_grace_time=15, id='update_all_episodes', name='Index All Episode Subtitles from Disk', replace_existing=True) elif full_update == "Manually": self.aps_scheduler.add_job( update_all_episodes, CronTrigger(year=in_a_century()), max_instances=1, coalesce=True, misfire_grace_time=15, id='update_all_episodes', name='Index All Episode Subtitles from Disk', replace_existing=True) def __radarr_full_update_task(self): if settings.general.use_radarr: full_update = settings.radarr.full_update if full_update == "Daily": self.aps_scheduler.add_job( update_all_movies, CronTrigger(hour=settings.radarr.full_update_hour), max_instances=1, coalesce=True, misfire_grace_time=15, id='update_all_movies', name='Index All Movie Subtitles from Disk', replace_existing=True) elif full_update == "Weekly": self.aps_scheduler.add_job( update_all_movies, CronTrigger(day_of_week=settings.radarr.full_update_day, hour=settings.radarr.full_update_hour), max_instances=1, coalesce=True, misfire_grace_time=15, id='update_all_movies', name='Index All Movie Subtitles from Disk', replace_existing=True) elif full_update == "Manually": self.aps_scheduler.add_job( update_all_movies, CronTrigger(year=in_a_century()), max_instances=1, coalesce=True, misfire_grace_time=15, id='update_all_movies', name='Index All Movie Subtitles from Disk', replace_existing=True) def __update_bazarr_task(self): if not args.no_update and os.environ["BAZARR_VERSION"] != '': task_name = 'Update Bazarr' if settings.general.auto_update: self.aps_scheduler.add_job( check_if_new_update, IntervalTrigger(hours=6), max_instances=1, coalesce=True, misfire_grace_time=15, id='update_bazarr', name=task_name, replace_existing=True) else: self.aps_scheduler.add_job( check_if_new_update, CronTrigger(year=in_a_century()), hour=4, id='update_bazarr', name=task_name, replace_existing=True) self.aps_scheduler.add_job( check_releases, IntervalTrigger(hours=3), max_instances=1, coalesce=True, misfire_grace_time=15, id='update_release', name='Update Release Info', replace_existing=True) else: self.aps_scheduler.add_job( check_releases, IntervalTrigger(hours=3), max_instances=1, coalesce=True, misfire_grace_time=15, id='update_release', name='Update Release Info', replace_existing=True) self.aps_scheduler.add_job( get_announcements_to_file, IntervalTrigger(hours=6), max_instances=1, coalesce=True, misfire_grace_time=15, id='update_announcements', name='Update Announcements File', replace_existing=True) def __search_wanted_subtitles_task(self): if settings.general.use_sonarr: self.aps_scheduler.add_job( wanted_search_missing_subtitles_series, IntervalTrigger(hours=int(settings.general.wanted_search_frequency)), max_instances=1, coalesce=True, misfire_grace_time=15, id='wanted_search_missing_subtitles_series', replace_existing=True, name='Search for Missing Series Subtitles') if settings.general.use_radarr: self.aps_scheduler.add_job( wanted_search_missing_subtitles_movies, IntervalTrigger(hours=int(settings.general.wanted_search_frequency_movie)), max_instances=1, coalesce=True, misfire_grace_time=15, id='wanted_search_missing_subtitles_movies', name='Search for Missing Movies Subtitles', replace_existing=True) def __upgrade_subtitles_task(self): if settings.general.upgrade_subs and \ (settings.general.use_sonarr or settings.general.use_radarr): self.aps_scheduler.add_job( upgrade_subtitles, IntervalTrigger(hours=int(settings.general.upgrade_frequency)), max_instances=1, coalesce=True, misfire_grace_time=15, id='upgrade_subtitles', name='Upgrade Previously Downloaded Subtitles', replace_existing=True) else: try: self.aps_scheduler.remove_job(job_id='upgrade_subtitles') except JobLookupError: pass def __randomize_interval_task(self): for job in self.aps_scheduler.get_jobs(): if isinstance(job.trigger, IntervalTrigger): # do not randomize the Never jobs if job.trigger.interval.total_seconds() > ONE_YEAR_IN_SECONDS: continue self.aps_scheduler.modify_job(job.id, next_run_time=datetime.now(tz=self.timezone) + timedelta(seconds=randrange( job.trigger.interval.total_seconds() * 0.75, job.trigger.interval.total_seconds()))) def __no_task(self): for job in self.aps_scheduler.get_jobs(): self.aps_scheduler.modify_job(job.id, next_run_time=None) scheduler = Scheduler() # Force the execution of the sync process with Sonarr and Radarr after migration to v0.9.1 if 'BAZARR_AUDIO_PROFILES_MIGRATION' in os.environ: if settings.general.use_sonarr: scheduler.aps_scheduler.modify_job('update_series', next_run_time=datetime.now()) scheduler.aps_scheduler.modify_job('sync_episodes', next_run_time=datetime.now()) if settings.general.use_radarr: scheduler.aps_scheduler.modify_job('update_movies', next_run_time=datetime.now()) del os.environ['BAZARR_AUDIO_PROFILES_MIGRATION']