diff --git a/src/plugins/trackservice.py b/src/plugins/trackservice.py index f62a67cb943b92a2e233c073a718e7d361c901c1..80d2a0a7ad2f28365118b499f1af75e01728eacf 100644 --- a/src/plugins/trackservice.py +++ b/src/plugins/trackservice.py @@ -296,7 +296,7 @@ class Playlog: return # Avoid overwrite by multiple calls in a row data = {} - next_timeslot = self.engine.scheduler.get_next_timeslots(1) + next_timeslot = self.engine.scheduler.get_programme().get_next_timeslots(1) if next_timeslot: next_timeslot = next_timeslot[0] else: diff --git a/src/scheduling/programme.py b/src/scheduling/programme.py new file mode 100644 index 0000000000000000000000000000000000000000..c8620342f832c86225edad2e6859d15088428bda --- /dev/null +++ b/src/scheduling/programme.py @@ -0,0 +1,233 @@ + + +# +# Aura Engine (https://gitlab.servus.at/aura/engine) +# +# Copyright (C) 2017-2020 - The Aura Engine Team. + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + + + +import logging + +from datetime import datetime + +from src.base.config import AuraConfig +from src.base.utils import SimpleUtil as SU +from src.core.engine import Engine + +from src.scheduling.calendar import AuraCalendarService +from src.scheduling.models import Timeslot + + + + +class Programme(): + """ + The current programme of the calendar. The programme is consisting of a set of timeslots. + """ + config = None + logger = None + programme = None + last_successful_fetch = None + + + def __init__(self): + """ + Constructor + """ + self.config = AuraConfig.config() + self.logger = logging.getLogger("AuraEngine") + + + + def refresh(self): + """ + Fetch the latest programme from `AuraCalendarService` which stores it to the database. + After that, the programme is in turn loaded from the database and stored in `self.programme`. + """ + + # Fetch programme from API endpoints + self.logger.debug("Trying to fetch new programe from API endpoints...") + acs = AuraCalendarService(self.config) + queue = acs.get_queue() + acs.start() # start fetching thread + response = queue.get() # wait for the end + self.logger.debug("... Programme fetch via API done!") + + # Reset last successful fetch state + lsf = self.last_successful_fetch + self.last_successful_fetch = None + + if response is None: + msg = SU.red("Trying to load programme from Engine Database, because AuraCalendarService returned an empty response.") + self.logger.warning(msg) + elif type(response) is list: + self.programme = response + if self.programme is not None and len(self.programme) > 0: + self.last_successful_fetch = datetime.now() + self.logger.info(SU.green("Finished fetching current programme from API")) + if len(self.programme) == 0: + self.logger.critical("Programme fetched from Steering/Tank has no entries!") + elif response.startswith("fetching_aborted"): + msg = SU.red("Trying to load programme from database only, because fetching was being aborted from AuraCalendarService! Reason: ") + self.logger.warning(msg + response[16:]) + else: + msg = SU.red("Trying to load programme from database only, because of an unknown response from AuraCalendarService: " + response) + self.logger.warning(msg) + + # Always load latest programme from the database + self.last_successful_fetch = lsf + self.load_programme_from_db() + self.logger.info(SU.green("Finished loading current programme from database (%s timeslots)" % str(len(self.programme)))) + for timeslot in self.programme: + self.logger.debug("\tTimeslot %s with Playlist %s" % (str(timeslot), str(timeslot.playlist))) + + + + + def load_programme_from_db(self): + """ + Loads the programme from Engine's database and enables + them via `self.enable_entries(..)`. After that, the + current message queue is printed to the console. + """ + self.programme = Timeslot.select_programme() + + if not self.programme: + self.logger.critical(SU.red("Could not load programme from database. We are in big trouble my friend!")) + return + + + + def get_current_entry(self): + """ + Retrieves the current `PlaylistEntry` which should be played as per programme. + + Returns: + (PlaylistEntry): The track which is (or should) currently being played + """ + now_unix = Engine.engine_time() + + # Load programme if necessary + if not self.programme: + self.load_programme_from_db() + + # Check for current timeslot + current_timeslot = self.get_current_timeslot() + if not current_timeslot: + self.logger.warning(SU.red("There's no active timeslot")) + return None + + # Check for scheduled playlist + current_playlist = current_timeslot.playlist + if not current_playlist: + msg = "There's no playlist assigned to the current timeslot. Most likely a fallback will make things okay again." + self.logger.warning(SU.red(msg)) + return None + + # Iterate over playlist entries and store the current one + current_entry = None + for entry in current_playlist.entries: + if entry.start_unix <= now_unix and now_unix <= entry.end_unix: + current_entry = entry + break + + if not current_entry: + # Nothing playing ... fallback will kick-in + msg = "There's no entry scheduled for playlist '%s' at %s" % (str(current_playlist), SU.fmt_time(now_unix)) + self.logger.warning(SU.red(msg)) + return None + + return current_entry + + + + def get_current_timeslot(self): + """ + Retrieves the timeslot currently to be played. + + Returns: + (Timeslot): The current timeslot + """ + current_timeslot = None + now_unix = Engine.engine_time() + + # Iterate over all timeslots and find the one to be played right now + if self.programme: + for timeslot in self.programme: + if timeslot.start_unix <= now_unix and now_unix < timeslot.end_unix: + current_timeslot = timeslot + break + + return current_timeslot + + + + def get_next_timeslots(self, max_count=0): + """ + Retrieves the timeslots to be played after the current one. + + Args: + max_count (Integer): Maximum of timeslots to return, if `0` all exitsing ones are returned + + Returns: + ([Timeslot]): The next timeslots + """ + now_unix = Engine.engine_time() + next_timeslots = [] + + for timeslot in self.programme: + if timeslot.start_unix > now_unix: + if (len(next_timeslots) < max_count) or max_count == 0: + next_timeslots.append(timeslot) + else: + break + + return self.filter_scheduling_window(next_timeslots) + + + + def filter_scheduling_window(self, timeslots): + """ + Ignore timeslots which are beyond the scheduling window. The end of the scheduling window + is defined by the config option `scheduling_window_end`. This value defines the seconds + minus the actual start time of the timeslot. + """ + now_unix = Engine.engine_time() + len_before = len(timeslots) + window_start = self.config.get("scheduling_window_start") + window_end = self.config.get("scheduling_window_end") + timeslots = list(filter(lambda s: (s.start_unix - window_end) > now_unix and (s.start_unix - window_start) < now_unix, timeslots)) + len_after = len(timeslots) + self.logger.info("For now, skipped %s future timeslot(s) which are out of the scheduling window (-%ss <-> -%ss)" % ((len_before - len_after), window_start, window_end)) + + return timeslots + + + + def is_timeslot_in_window(self, timeslot): + """ + Checks if the timeslot is within the scheduling window. + """ + now_unix = Engine.engine_time() + window_start = self.config.get("scheduling_window_start") + window_end = self.config.get("scheduling_window_end") + + if timeslot.start_unix - window_start < now_unix and \ + timeslot.start_unix - window_end > now_unix: + + return True + return False \ No newline at end of file diff --git a/src/scheduling/scheduler.py b/src/scheduling/scheduler.py index c969d2ec12e23a737a8786ffb787f6adad098201..762324f40c366cfea129d8a991debef6f473cce0 100644 --- a/src/scheduling/scheduler.py +++ b/src/scheduling/scheduler.py @@ -28,17 +28,15 @@ from datetime import datetime, timedelta from src.base.config import AuraConfig from src.base.utils import SimpleUtil as SU -from src.scheduling.models import AuraDatabaseModel, Timeslot, Playlist +from src.scheduling.models import AuraDatabaseModel, Timeslot, Playlist from src.base.exceptions import NoActiveTimeslotException, LoadSourceException from src.core.control import EngineExecutor from src.core.engine import Engine from src.core.channels import ChannelType, TransitionType, EntryPlayState from src.core.resources import ResourceClass, ResourceUtil -from src.scheduling.calendar import AuraCalendarService -from src.scheduling.utils import TimeslotRenderer - - +from src.scheduling.utils import TimeslotRenderer +from src.scheduling.programme import Programme @@ -71,6 +69,7 @@ class TimeslotCommand(EngineExecutor): + class AuraScheduler(threading.Thread): """ Aura Scheduler Class @@ -93,15 +92,12 @@ class AuraScheduler(threading.Thread): logger = None engine = None exit_event = None - is_initialized = None - is_initialized = None - - last_successful_fetch = None timeslot_renderer = None programme = None message_timer = [] fallback = None - + is_initialized = None + is_initialized = None @@ -116,6 +112,7 @@ class AuraScheduler(threading.Thread): """ self.config = AuraConfig.config() self.logger = logging.getLogger("AuraEngine") + self.programme = Programme() self.timeslot_renderer = TimeslotRenderer(self) AuraScheduler.init_database() self.fallback = fallback_manager @@ -151,7 +148,7 @@ class AuraScheduler(threading.Thread): self.logger.info(SU.cyan(f"== start fetching new timeslots (every {seconds_to_wait} seconds) ==")) # Load some stuff from the API in any case - self.fetch_new_programme() + self.programme.refresh() # Queue only when the engine is ready to play if self.is_initialized == True: @@ -204,6 +201,13 @@ class AuraScheduler(threading.Thread): # + def get_programme(self): + """ + Returns the current programme. + """ + return self.programme + + def play_active_entry(self): """ Plays the entry scheduled for the very current moment and forwards to the scheduled position in time. @@ -213,7 +217,7 @@ class AuraScheduler(threading.Thread): (NoActiveTimeslotException): If there's no timeslot in the programme, within the scheduling window """ sleep_offset = 10 - active_timeslot = self.get_active_timeslot() + active_timeslot = self.programme.get_current_timeslot() # Schedule any available fallback playlist if active_timeslot: @@ -225,7 +229,7 @@ class AuraScheduler(threading.Thread): if not active_timeslot.fadeouttimer: self.queue_end_of_timeslot(active_timeslot, True) - active_entry = self.get_active_entry() + active_entry = self.programme.get_current_entry() if not active_entry: raise NoActiveTimeslotException @@ -241,7 +245,7 @@ class AuraScheduler(threading.Thread): if (seconds_to_seek + sleep_offset) > active_entry.duration: self.logger.info("The FFWD [>>] range exceeds the length of the entry. Drink some tea and wait for the sound of the next entry.") else: - # Pre-roll and play active entry + # Preload and play active entry self.engine.player.preload(active_entry) self.engine.player.play(active_entry, TransitionType.FADE) @@ -261,7 +265,7 @@ class AuraScheduler(threading.Thread): elif active_entry.get_content_type() in ResourceClass.STREAM.types \ or active_entry.get_content_type() in ResourceClass.LIVE.types: - # Pre-roll and play active entry + # Preload and play active entry self.engine.player.preload(active_entry) self.engine.player.play(active_entry, TransitionType.FADE) @@ -270,109 +274,6 @@ class AuraScheduler(threading.Thread): - - def get_active_entry(self): - """ - Retrieves the current `PlaylistEntry` which should be played as per programme. - - Returns: - (PlaylistEntry): The track which is (or should) currently being played - """ - now_unix = Engine.engine_time() - - # Load programme if necessary - if not self.programme: - self.load_programme_from_db() - - # Check for current timeslot - current_timeslot = self.get_active_timeslot() - if not current_timeslot: - self.logger.warning(SU.red("There's no active timeslot")) - return None - - # Check for scheduled playlist - current_playlist = current_timeslot.playlist - if not current_playlist: - msg = "There's no playlist assigned to the current timeslot. Most likely a fallback will make things okay again." - self.logger.warning(SU.red(msg)) - return None - - # Iterate over playlist entries and store the current one - current_entry = None - for entry in current_playlist.entries: - if entry.start_unix <= now_unix and now_unix <= entry.end_unix: - current_entry = entry - break - - if not current_entry: - # Nothing playing ... fallback will kick-in - msg = "There's no entry scheduled for playlist '%s' at %s" % (str(current_playlist), SU.fmt_time(now_unix)) - self.logger.warning(SU.red(msg)) - return None - - return current_entry - - - - def get_active_timeslot(self): - """ - Retrieves the timeslot currently to be played. - - Returns: - (Timeslot): The current timeslot - """ - current_timeslot = None - now_unix = Engine.engine_time() - - # Iterate over all timeslots and find the one to be played right now - if self.programme: - for timeslot in self.programme: - if timeslot.start_unix <= now_unix and now_unix < timeslot.end_unix: - current_timeslot = timeslot - break - - return current_timeslot - - - - def get_next_timeslots(self, max_count=0): - """ - Retrieves the timeslots to be played after the current one. - - Args: - max_count (Integer): Maximum of timeslots to return, if `0` all exitsing ones are returned - - Returns: - ([Timeslot]): The next timeslots - """ - now_unix = Engine.engine_time() - next_timeslots = [] - - for timeslot in self.programme: - if timeslot.start_unix > now_unix: - if (len(next_timeslots) < max_count) or max_count == 0: - next_timeslots.append(timeslot) - else: - break - - return next_timeslots - - - def get_active_playlist(self): - """ - Retrieves the currently playing playlist. - - Returns: - (FallbackType, Playlist): The resolved playlist - """ - timeslot = self.get_active_timeslot() - if timeslot: - # return timeslot.playlist - return self.fallback.resolve_playlist(timeslot) - return (None, None) - - - def print_timer_queue(self): """ Prints the current timer queue i.e. playlists in the queue to be played. @@ -400,45 +301,17 @@ class AuraScheduler(threading.Thread): - - -# -# PRIVATE METHODS -# - - - - def filter_scheduling_window(self, timeslots): - """ - Ignore timeslots which are beyond the scheduling window. The end of the scheduling window - is defined by the config option `scheduling_window_end`. This value defines the seconds - minus the actual start time of the timeslot. + def get_active_playlist(self): """ - now_unix = Engine.engine_time() - len_before = len(timeslots) - window_start = self.config.get("scheduling_window_start") - window_end = self.config.get("scheduling_window_end") - timeslots = list(filter(lambda s: (s.start_unix - window_end) > now_unix and (s.start_unix - window_start) < now_unix, timeslots)) - len_after = len(timeslots) - self.logger.info("For now, skipped %s future timeslot(s) which are out of the scheduling window (-%ss <-> -%ss)" % ((len_before - len_after), window_start, window_end)) - - return timeslots - - + Retrieves the currently playing playlist. - def is_timeslot_in_window(self, timeslot): - """ - Checks if the timeslot is within the scheduling window. + Returns: + (FallbackType, Playlist): The resolved playlist """ - now_unix = Engine.engine_time() - window_start = self.config.get("scheduling_window_start") - window_end = self.config.get("scheduling_window_end") - - if timeslot.start_unix - window_start < now_unix and \ - timeslot.start_unix - window_end > now_unix: - - return True - return False + timeslot = self.programme.get_current_timeslot() + if timeslot: + return self.fallback.resolve_playlist(timeslot) + return (None, None) @@ -449,8 +322,7 @@ class AuraScheduler(threading.Thread): """ # Get a clean set of the timeslots within the scheduling window - timeslots = self.get_next_timeslots() - timeslots = self.filter_scheduling_window(timeslots) + timeslots = self.programme.get_next_timeslots() # Queue the timeslots, their playlists and entries if timeslots: @@ -477,14 +349,14 @@ class AuraScheduler(threading.Thread): Queues all entries after the one currently playing upon startup. Don't use this method in any other scenario, as it doesn't respect the scheduling window. """ - current_timeslot = self.get_active_timeslot() + current_timeslot = self.programme.get_current_timeslot() # Queue the (rest of the) currently playing timeslot upon startup if current_timeslot: current_playlist = current_timeslot.playlist if current_playlist: - active_entry = self.get_active_entry() + active_entry = self.programme.get_current_entry() # Finished entries for entry in current_playlist.entries: @@ -645,9 +517,6 @@ class AuraScheduler(threading.Thread): - - - def queue_end_of_timeslot(self, timeslot, fade_out): """ Queues a engine action to stop/fade-out the given timeslot. @@ -688,65 +557,6 @@ class AuraScheduler(threading.Thread): - def fetch_new_programme(self): - """ - Fetch the latest programme from `AuraCalendarService` which stores it to the database. - After that, the programme is in turn loaded from the database and stored in `self.programme`. - """ - - # Fetch programme from API endpoints - self.logger.debug("Trying to fetch new programe from API endpoints...") - acs = AuraCalendarService(self.config) - queue = acs.get_queue() - acs.start() # start fetching thread - response = queue.get() # wait for the end - self.logger.debug("... Programme fetch via API done!") - - # Reset last successful fetch state - lsf = self.last_successful_fetch - self.last_successful_fetch = None - - if response is None: - msg = SU.red("Trying to load programme from Engine Database, because AuraCalendarService returned an empty response.") - self.logger.warning(msg) - elif type(response) is list: - self.programme = response - if self.programme is not None and len(self.programme) > 0: - self.last_successful_fetch = datetime.now() - self.logger.info(SU.green("Finished fetching current programme from API")) - if len(self.programme) == 0: - self.logger.critical("Programme fetched from Steering/Tank has no entries!") - elif response.startswith("fetching_aborted"): - msg = SU.red("Trying to load programme from database only, because fetching was being aborted from AuraCalendarService! Reason: ") - self.logger.warning(msg + response[16:]) - else: - msg = SU.red("Trying to load programme from database only, because of an unknown response from AuraCalendarService: " + response) - self.logger.warning(msg) - - # Always load latest programme from the database - self.last_successful_fetch = lsf - self.load_programme_from_db() - self.logger.info(SU.green("Finished loading current programme from database (%s timeslots)" % str(len(self.programme)))) - for timeslot in self.programme: - self.logger.debug("\tTimeslot %s with Playlist %s" % (str(timeslot), str(timeslot.playlist))) - - - - - def load_programme_from_db(self): - """ - Loads the programme from Engine's database and enables - them via `self.enable_entries(..)`. After that, the - current message queue is printed to the console. - """ - self.programme = Timeslot.select_programme() - - if not self.programme: - self.logger.critical(SU.red("Could not load programme from database. We are in big trouble my friend!")) - return - - - def is_something_planned_at_time(self, given_time): """ Checks for existing timers at the given time. @@ -769,7 +579,7 @@ class AuraScheduler(threading.Thread): param ([]): A timeslot or list of entries Returns: - (CallFunctionTimer, CallFunctionTimer): In case of a "switch" command, the switch and pre-roll timer is returned + (CallFunctionTimer, CallFunctionTimer): In case of a "switch" command, the switch and preload timer is returned (CallFunctionTimer): In all other cases only the timer for the command is returned """ if not fadein and not fadeout and not switcher or fadein and fadeout or fadein and switcher or fadeout and switcher: @@ -782,7 +592,7 @@ class AuraScheduler(threading.Thread): t.start() if switcher: - # Pre-roll function to be called by timer + # Preload function to be called by timer def do_preload(entries): try: if entries[0].get_content_type() in ResourceClass.FILE.types: @@ -792,10 +602,10 @@ class AuraScheduler(threading.Thread): self.logger.info(SU.cyan("=== preload('%s') ===" % ResourceUtil.get_entries_string(entries))) self.engine.player.preload(entries[0]) except LoadSourceException as e: - self.logger.critical(SU.red("Could not pre-roll entries %s" % ResourceUtil.get_entries_string(entries)), e) + self.logger.critical(SU.red("Could not preload entries %s" % ResourceUtil.get_entries_string(entries)), e) if entries[-1].status != EntryPlayState.READY: - self.logger.critical(SU.red("Entries didn't reach 'ready' state during pre-rolling (Entries: %s)" % ResourceUtil.get_entries_string(entries))) + self.logger.critical(SU.red("Entries didn't reach 'ready' state during preloading (Entries: %s)" % ResourceUtil.get_entries_string(entries))) loader_diff = diff - self.config.get("preload_offset") loader = CallFunctionTimer(diff=loader_diff, func=do_preload, param=param, fadein=fadein, fadeout=fadeout, switcher=False, loader=True) @@ -926,6 +736,6 @@ class CallFunctionTimer(threading.Timer): elif self.switcher: return status + " switching to entries '" + ResourceUtil.get_entries_string(self.entries) elif self.loader: - return status + " pre-rolling entries '" + ResourceUtil.get_entries_string(self.entries) + return status + " preloading entries '" + ResourceUtil.get_entries_string(self.entries) else: return "CORRUPTED CallFunctionTimer around! How can that be?" diff --git a/src/scheduling/utils.py b/src/scheduling/utils.py index ad88e62a359e3c379ddcd614f589117ee131c0af..0817a311912c7f41a9c97cef83a3932235d1fd14 100644 --- a/src/scheduling/utils.py +++ b/src/scheduling/utils.py @@ -43,6 +43,7 @@ class TimeslotRenderer: """ logger = None scheduler = None + programme = None def __init__(self, scheduler): @@ -51,7 +52,7 @@ class TimeslotRenderer: """ self.logger = logging.getLogger("AuraEngine") self.scheduler = scheduler - + self.programme = scheduler.get_programme() def get_ascii_timeslots(self): @@ -61,7 +62,7 @@ class TimeslotRenderer: Returns: (String): An ASCII representation of the current and next timeslots """ - active_timeslot = self.scheduler.get_active_timeslot() + active_timeslot = self.programme.get_current_timeslot() s = "\n\n SCHEDULED NOW:" s += "\n┌──────────────────────────────────────────────────────────────────────────────────────────────────────" @@ -86,7 +87,7 @@ class TimeslotRenderer: s += "\n│ └── Playlist %s " % resolved_playlist - active_entry = self.scheduler.get_active_entry() + active_entry = self.programme.get_current_entry() # Finished entries for entry in resolved_playlist.entries: @@ -114,7 +115,7 @@ class TimeslotRenderer: s += "\n SCHEDULED NEXT:" s += "\n┌──────────────────────────────────────────────────────────────────────────────────────────────────────" - next_timeslots = self.scheduler.get_next_timeslots() + next_timeslots = self.programme.get_next_timeslots() if not next_timeslots: s += "\n│ Nothing. " else: