From 94c38f18b2f0145d12546b745f1eeffb67069b6a Mon Sep 17 00:00:00 2001 From: David Trattnig <david.trattnig@o94.at> Date: Thu, 29 Jul 2021 15:30:24 +0200 Subject: [PATCH] Default playlists for show and schedule-level. #52 --- src/engine.py | 70 +++++----- src/events.py | 90 ++++++------- src/plugins/trackservice.py | 23 ++-- src/scheduling/api.py | 45 +++---- src/scheduling/fallback.py | 257 ++++++++++++++++++------------------ src/scheduling/models.py | 57 ++++---- src/scheduling/programme.py | 71 +++++----- src/scheduling/scheduler.py | 34 +++-- src/scheduling/utils.py | 12 +- 9 files changed, 348 insertions(+), 311 deletions(-) diff --git a/src/engine.py b/src/engine.py index b0f23aac..11a07222 100644 --- a/src/engine.py +++ b/src/engine.py @@ -374,41 +374,41 @@ class Player: - def start_fallback_playlist(self, entries): - """ - Sets any scheduled fallback playlist and performs a fade-in. - - Args: - entries ([Entry]): The playlist entries - """ - self.preload_group(entries, ChannelType.FALLBACK_QUEUE) - self.play(entries[0], TransitionType.FADE) - self.event_dispatcher.on_fallback_updated(entries) - - - - def stop_fallback_playlist(self): - """ - Performs a fade-out and clears any scheduled fallback playlist. - """ - dirty_channel = self.channel_router.get_active(ChannelType.FALLBACK_QUEUE) - - self.logger.info(f"Fading out channel '{dirty_channel}'") - self.connector.enable_transaction() - self.mixer_fallback.fade_out(dirty_channel) - self.connector.disable_transaction() - - def clean_up(): - # Wait a little, if there is some long fade-out. Note, this also means, - # this channel should not be used for at least some seconds (including clearing time). - time.sleep(2) - self.connector.enable_transaction() - self.mixer_fallback.channel_activate(dirty_channel.value, False) - res = self.queue_clear(dirty_channel) - self.logger.info("Clear Fallback Queue Response: " + res) - self.connector.disable_transaction() - self.event_dispatcher.on_fallback_cleaned(dirty_channel) - Thread(target=clean_up).start() + # def start_fallback_playlist(self, entries): + # """ + # Sets any scheduled fallback playlist and performs a fade-in. + + # Args: + # entries ([Entry]): The playlist entries + # """ + # self.preload_group(entries, ChannelType.FALLBACK_QUEUE) + # self.play(entries[0], TransitionType.FADE) + # self.event_dispatcher.on_fallback_updated(entries) + + + + # def stop_fallback_playlist(self): + # """ + # Performs a fade-out and clears any scheduled fallback playlist. + # """ + # dirty_channel = self.channel_router.get_active(ChannelType.FALLBACK_QUEUE) + + # self.logger.info(f"Fading out channel '{dirty_channel}'") + # self.connector.enable_transaction() + # self.mixer_fallback.fade_out(dirty_channel) + # self.connector.disable_transaction() + + # def clean_up(): + # # Wait a little, if there is some long fade-out. Note, this also means, + # # this channel should not be used for at least some seconds (including clearing time). + # time.sleep(2) + # self.connector.enable_transaction() + # self.mixer_fallback.channel_activate(dirty_channel.value, False) + # res = self.queue_clear(dirty_channel) + # self.logger.info("Clear Fallback Queue Response: " + res) + # self.connector.disable_transaction() + # self.event_dispatcher.on_fallback_cleaned(dirty_channel) + # Thread(target=clean_up).start() diff --git a/src/events.py b/src/events.py index 74aca24b..c73ea27e 100644 --- a/src/events.py +++ b/src/events.py @@ -89,7 +89,7 @@ class EngineEventDispatcher(): self.logger = logging.getLogger("AuraEngine") self.config = AuraConfig.config() self.engine = engine - + binding = self.attach(AuraMailer) binding.subscribe("on_critical") binding.subscribe("on_sick") @@ -183,33 +183,33 @@ class EngineEventDispatcher(): """ Called when the engine has finished booting and is ready to play. """ - def func(self, param): + def func(self, param): self.logger.debug("on_ready(..)") self.scheduler.on_ready() self.call_event("on_ready", param) thread = Thread(target = func, args = (self, None)) - thread.start() + thread.start() def on_timeslot_start(self, timeslot): """ Called when a timeslot starts. """ - def func(self, timeslot): + def func(self, timeslot): self.logger.debug("on_timeslot_start(..)") self.fallback_manager.on_timeslot_start(timeslot) self.call_event("on_timeslot_start", timeslot) thread = Thread(target = func, args = (self, timeslot)) - thread.start() + thread.start() def on_timeslot_end(self, timeslot): """ Called when a timeslot ends. """ - def func(self, timeslot): + def func(self, timeslot): self.logger.debug("on_timeslot_end(..)") self.fallback_manager.on_timeslot_end(timeslot) self.call_event("on_timeslot_end", timeslot) @@ -227,7 +227,7 @@ class EngineEventDispatcher(): Args: source (String): The `PlaylistEntry` object """ - def func(self, entry): + def func(self, entry): self.logger.debug("on_play(..)") # Assign timestamp indicating start play time. Use the actual playtime when possible. entry.entry_start_actual = datetime.datetime.now() @@ -235,63 +235,63 @@ class EngineEventDispatcher(): self.call_event("on_play", entry) thread = Thread(target = func, args = (self, entry)) - thread.start() + thread.start() def on_metadata(self, data): """ - Event called by the soundsystem implementation (i.e. Liquidsoap) when some entry is actually playing. - This does not include live or stream sources, since they ain't have metadata and are triggered from + Event called by the soundsystem implementation (i.e. Liquidsoap) when some entry is actually playing. + This does not include live or stream sources, since they ain't have metadata and are triggered from engine core (see `on_play(..)`). Args: data (dict): A collection of metadata related to the current track """ - def func(self, data): + def func(self, data): self.logger.debug("on_metadata(..)") self.fallback_manager.on_metadata(data) self.call_event("on_metadata", data) thread = Thread(target = func, args = (self, data)) - thread.start() + thread.start() def on_stop(self, entry): """ The entry on the assigned channel has been stopped playing. """ - def func(self, entry): + def func(self, entry): self.logger.debug("on_stop(..)") self.call_event("on_stop", entry) - + thread = Thread(target = func, args = (self, entry)) - thread.start() + thread.start() - def on_fallback_updated(self, playlist_uri): - """ - Called when the scheduled fallback playlist has been updated. - This event does not indicate that the fallback is actually playing. - """ - def func(self, playlist_uri): - self.logger.debug("on_fallback_updated(..)") - self.call_event("on_fallback_updated", playlist_uri) + # def on_fallback_updated(self, playlist_uri): + # """ + # Called when the scheduled fallback playlist has been updated. + # This event does not indicate that the fallback is actually playing. + # """ + # def func(self, playlist_uri): + # self.logger.debug("on_fallback_updated(..)") + # self.call_event("on_fallback_updated", playlist_uri) - thread = Thread(target = func, args = (self, playlist_uri)) - thread.start() + # thread = Thread(target = func, args = (self, playlist_uri)) + # thread.start() - def on_fallback_cleaned(self, cleaned_channel): - """ - Called when the scheduled fallback queue has been cleaned up. - This event does not indicate that some fallback is actually playing. - """ - def func(self, cleaned_channel): - self.logger.debug("on_fallback_cleaned(..)") - self.call_event("on_fallback_cleaned", cleaned_channel) + # def on_fallback_cleaned(self, cleaned_channel): + # """ + # Called when the scheduled fallback queue has been cleaned up. + # This event does not indicate that some fallback is actually playing. + # """ + # def func(self, cleaned_channel): + # self.logger.debug("on_fallback_cleaned(..)") + # self.call_event("on_fallback_cleaned", cleaned_channel) - thread = Thread(target = func, args = (self, cleaned_channel)) - thread.start() + # thread = Thread(target = func, args = (self, cleaned_channel)) + # thread.start() def on_fallback_active(self, timeslot, fallback_type): @@ -299,57 +299,57 @@ class EngineEventDispatcher(): Called when a fallback is activated for the given timeslot, since no default playlist is available. """ - def func(self, timeslot, fallback_type): + def func(self, timeslot, fallback_type): self.logger.debug("on_fallback_active(..)") self.call_event("on_fallback_active", timeslot, fallback_type) thread = Thread(target = func, args = (self, timeslot, fallback_type)) - thread.start() + thread.start() def on_queue(self, entries): """ One or more entries have been queued and are currently pre-loaded. """ - def func(self, entries): + def func(self, entries): self.logger.debug("on_queue(..)") self.call_event("on_queue", entries) thread = Thread(target = func, args = (self, entries)) - thread.start() + thread.start() def on_sick(self, data): """ Called when the engine is in some unhealthy state. """ - def func(self, data): + def func(self, data): self.logger.debug("on_sick(..)") self.call_event("on_sick", data) thread = Thread(target = func, args = (self, data)) - thread.start() + thread.start() def on_resurrect(self, data): """ Called when the engine turned healthy again after being sick. """ - def func(self, data): + def func(self, data): self.logger.debug("on_resurrect(..)") self.call_event("on_resurrect", data) thread = Thread(target = func, args = (self, data)) - thread.start() + thread.start() def on_critical(self, subject, message, data=None): """ Callend when some critical event occurs """ - def func(self, subject, message, data): + def func(self, subject, message, data): self.logger.debug("on_critical(..)") self.call_event("on_critical", (subject, message, data)) thread = Thread(target = func, args = (self, subject, message, data)) - thread.start() \ No newline at end of file + thread.start() \ No newline at end of file diff --git a/src/plugins/trackservice.py b/src/plugins/trackservice.py index 28d6d7f3..44d41504 100644 --- a/src/plugins/trackservice.py +++ b/src/plugins/trackservice.py @@ -22,7 +22,6 @@ import logging import requests from collections import deque -from datetime import datetime, timedelta from src.base.config import AuraConfig from src.base.utils import SimpleUtil as SU @@ -181,7 +180,7 @@ class TrackServiceHandler(): """ planned_playlist = None if self.engine.scheduler: - (fallback_type, planned_playlist) = self.engine.scheduler.get_active_playlist() + (playlist_type, planned_playlist) = self.engine.scheduler.get_active_playlist() (past_timeslot, current_timeslot, next_timeslot) = self.playlog.get_timeslots() data = dict() @@ -349,21 +348,29 @@ class Playlog: data ({}): The dictionary holding the (virtual) timeslot timeslot (Timeslot): The actual timeslot object to retrieve fallback info from """ - fallback_type = None + playlist_type = None playlist = None if timeslot: - fallback_type, playlist = self.engine.scheduler.fallback.resolve_playlist(timeslot) + playlist_type, playlist = self.engine.scheduler.resolve_playlist(timeslot) if playlist: data["playlist_id"] = playlist.playlist_id else: data["playlist_id"] = -1 - if fallback_type: - data["fallback_type"] = fallback_type.id - else: - data["fallback_type"] = FallbackType.STATION.id + #FIXME "fallback_type" should be a more generic "playout_state"? (compare meta#42) + #FIXME Add field for "playlist_type", which now differs from playout-state + #FIXME Remove dependency to "scheduler" and "scheduler.fallback" module + data["fallback_type"] = 0 + if self.engine.scheduler: + playout_state = self.engine.scheduler.fallback.get_playout_state() + data["fallback_type"] = playout_state.id + + # if playlist_type: + # data["fallback_type"] = playlist_type.id + # else: + # data["fallback_type"] = FallbackType.STATION.id def get_timeslots(self): diff --git a/src/scheduling/api.py b/src/scheduling/api.py index f8831408..c7e97657 100644 --- a/src/scheduling/api.py +++ b/src/scheduling/api.py @@ -37,7 +37,7 @@ class ApiFetcher(threading.Thread): """ config = None logging = None - queue = None + queue = None has_already_fetched = False fetched_timeslot_data = None stop_event = None @@ -62,7 +62,7 @@ class ApiFetcher(threading.Thread): self.tank_secret = self.config.get("api_tank_secret") self.queue = queue.Queue() self.stop_event = threading.Event() - threading.Thread.__init__(self) + threading.Thread.__init__(self) @@ -121,14 +121,13 @@ class ApiFetcher(threading.Thread): for timeslot in self.fetched_timeslot_data: - # FIXME Workaround until https://gitlab.servus.at/aura/steering/-/issues/54 is implemented - if "schedule_fallback_id" in timeslot: - timeslot["default_schedule_playlist_id"] = timeslot["schedule_fallback_id"] + if "schedule_default_playlist_id" in timeslot: + timeslot["default_schedule_playlist_id"] = timeslot["schedule_default_playlist_id"] timeslot["schedule_fallback_id"] = None - if "show_fallback_id" in timeslot: - timeslot["default_show_playlist_id"] = timeslot["show_fallback_id"] + if "show_default_playlist_id" in timeslot: + timeslot["default_show_playlist_id"] = timeslot["show_default_playlist_id"] timeslot["show_fallback_id"] = None - + self.logger.debug("Fetching playlists from TANK") self.fetch_playlists() @@ -163,15 +162,15 @@ class ApiFetcher(threading.Thread): """ timeslots = None headers = { "content-type": "application/json" } - + try: - self.logger.debug("Fetch timeslots from Steering API...") + self.logger.debug("Fetch timeslots from Steering API...") response = requests.get(self.steering_calendar_url, data=None, headers=headers) if not response.status_code == 200: self.logger.critical(SU.red("HTTP Status: %s | Timeslots could not be fetched! Response: %s" % \ (str(response.status_code), response.text))) - return None - timeslots = response.json() + return None + timeslots = response.json() except Exception as e: self.logger.critical(SU.red("Error while requesting timeslots from Steering!"), e) @@ -198,8 +197,8 @@ class ApiFetcher(threading.Thread): # Get IDs of specific, default and fallback playlists playlist_id = self.get_playlist_id(timeslot, "playlist_id") default_schedule_playlist_id = self.get_playlist_id(timeslot, "default_schedule_playlist_id") - default_show_playlist_id = self.get_playlist_id(timeslot, "default_show_playlist_id") - schedule_fallback_id = self.get_playlist_id(timeslot, "schedule_fallback_id") + default_show_playlist_id = self.get_playlist_id(timeslot, "default_show_playlist_id") + schedule_fallback_id = self.get_playlist_id(timeslot, "schedule_fallback_id") show_fallback_id = self.get_playlist_id(timeslot, "show_fallback_id") station_fallback_id = self.get_playlist_id(timeslot, "station_fallback_id") @@ -208,7 +207,7 @@ class ApiFetcher(threading.Thread): timeslot["playlist"] = self.fetch_playlist(playlist_id, fetched_entries) timeslot["default_schedule_playlist"] = self.fetch_playlist(default_schedule_playlist_id, fetched_entries) timeslot["default_show_playlist"] = self.fetch_playlist(default_show_playlist_id, fetched_entries) - timeslot["schedule_fallback"] = self.fetch_playlist(schedule_fallback_id, fetched_entries) + timeslot["schedule_fallback"] = self.fetch_playlist(schedule_fallback_id, fetched_entries) timeslot["show_fallback"] = self.fetch_playlist(show_fallback_id, fetched_entries) timeslot["station_fallback"] = self.fetch_playlist(station_fallback_id, fetched_entries) @@ -232,9 +231,9 @@ class ApiFetcher(threading.Thread): return None playlist = None - url = self.tank_playlist_url.replace("${ID}", playlist_id) + url = self.tank_playlist_url.replace("${ID}", playlist_id) headers = { - "Authorization": "Bearer %s:%s" % (self.tank_session, self.tank_secret), + "Authorization": "Bearer %s:%s" % (self.tank_session, self.tank_secret), "content-type": "application/json" } @@ -243,22 +242,22 @@ class ApiFetcher(threading.Thread): if playlist["id"] == playlist_id: self.logger.debug("Playlist #%s already fetched" % playlist_id) return playlist - + try: - self.logger.debug("Fetch playlist from Tank API...") + self.logger.debug("Fetch playlist from Tank API...") response = requests.get(url, data=None, headers=headers) if not response.status_code == 200: self.logger.critical(SU.red("HTTP Status: %s | Playlist #%s could not be fetched or is not available! Response: %s" % \ (str(response.status_code), str(playlist_id), response.text))) - return None - playlist = response.json() + return None + playlist = response.json() except Exception as e: self.logger.critical(SU.red("Error while requesting playlist #%s from Tank" % str(playlist_id)), e) return None fetched_playlists.append(playlist) return playlist - + def get_playlist_id(self, timeslot, id_name): @@ -279,7 +278,7 @@ class ApiFetcher(threading.Thread): if not playlist_id or playlist_id == "None": self.logger.debug("No value defined for '%s' in timeslot '#%s'" % (id_name, timeslot["id"])) return None - + return playlist_id diff --git a/src/scheduling/fallback.py b/src/scheduling/fallback.py index b2425091..6a6133aa 100644 --- a/src/scheduling/fallback.py +++ b/src/scheduling/fallback.py @@ -36,14 +36,12 @@ class FallbackType(Enum): """ Types of fallbacks. - NONE: No fallback active, default playout - SCHEDULE: The first played when some default playlist fails - SHOW: The second played when the timeslot fallback fails - STATION: The last played when everything else fails + NONE: No fallback active, default playout as planned + STATION: The station fallback is played when everything else fails """ NONE = { "id": 0, "name": "default", "channels": [ Channel.QUEUE_A, Channel.QUEUE_B ] } - SCHEDULE = { "id": 1, "name": "schedule", "channels": [ Channel.FALLBACK_QUEUE_A, Channel.FALLBACK_QUEUE_B ] } - SHOW = { "id": 2, "name": "show", "channels": [ Channel.FALLBACK_QUEUE_A, Channel.FALLBACK_QUEUE_B ] } + # SCHEDULE = { "id": 1, "name": "schedule", "channels": [ Channel.FALLBACK_QUEUE_A, Channel.FALLBACK_QUEUE_B ] } + # SHOW = { "id": 2, "name": "show", "channels": [ Channel.FALLBACK_QUEUE_A, Channel.FALLBACK_QUEUE_B ] } STATION = { "id": 3, "name": "station", "channels": [ Channel.FALLBACK_STATION_FOLDER, Channel.FALLBACK_STATION_PLAYLIST ] } @property @@ -61,14 +59,13 @@ class FallbackType(Enum): class FallbackManager: """ - Handles all types of fallbacks in case there is an outage or missing schedules - for the radio programme. - """ + Manages if engine is in normal or fallback play-state. + """ config = None logger = None engine = None state = None - + def __init__(self, engine): """ @@ -80,9 +77,9 @@ class FallbackManager: self.config = AuraConfig.config() self.logger = logging.getLogger("AuraEngine") self.engine = engine - self.state = { + self.state = { "fallback_type": FallbackType.NONE, - "previous_fallback_type": None, + "previous_fallback_type": None, "timeslot": None } @@ -101,7 +98,7 @@ class FallbackManager: def on_timeslot_end(self, timeslot): """ - The timeslot has ended and the state is updated. The method ensures that any intermediate state + The timeslot has ended and the state is updated. The method ensures that any intermediate state update doesn't get overwritten. """ if self.state["timeslot"] == timeslot: @@ -110,11 +107,11 @@ class FallbackManager: def on_play(self, entry): """ - Event Handler which is called by the engine when some entry is actually playing. + Event Handler which is called by the engine when some entry is actually playing. Args: source (String): The `PlaylistEntry` object - """ + """ content_class = ResourceUtil.get_content_class(entry.get_content_type()) if content_class == ResourceClass.FILE: # Files are handled by "on_metadata" called via Liquidsoap @@ -125,8 +122,8 @@ class FallbackManager: def on_metadata(self, data): """ - Event called by the soundsystem implementation (i.e. Liquidsoap) when some entry is actually playing. - This does not include live or stream sources, since they ain't have metadata and are triggered from + Event called by the soundsystem implementation (i.e. Liquidsoap) when some entry is actually playing. + This does not include live or stream sources, since they ain't have metadata and are triggered from engine core (see `on_play(..)`). Args: @@ -151,6 +148,13 @@ class FallbackManager: # + def get_playout_state(self): + """ + Returns the current playout state, like normal or fallback. + """ + return self.state["fallback_type"] + + def update_fallback_state(self, channel): """ Update the current and previously active fallback state. @@ -168,151 +172,150 @@ class FallbackManager: """ Retrieves the matching fallback type for the given source. """ - if source in [str(i) for i in FallbackType.SCHEDULE.channels]: - return FallbackType.SCHEDULE - if source in [str(i) for i in FallbackType.SHOW.channels]: - return FallbackType.SHOW + # if source in [str(i) for i in FallbackType.SCHEDULE.channels]: + # return FallbackType.SCHEDULE + # if source in [str(i) for i in FallbackType.SHOW.channels]: + # return FallbackType.SHOW if source in [str(i) for i in FallbackType.STATION.channels]: return FallbackType.STATION return FallbackType.NONE - def queue_fallback_playlist(self, timeslot): - """ - Evaluates the scheduled fallback and queues it using a timed thread. - """ - (fallback_type, playlist) = self.get_fallback_playlist(timeslot) + # def queue_fallback_playlist(self, timeslot): + # """ + # Evaluates the scheduled fallback and queues it using a timed thread. + # """ + # (fallback_type, playlist) = self.get_fallback_playlist(timeslot) - if playlist: - self.logger.info(f"Resolved {fallback_type.value} fallback") - return FallbackCommand(timeslot, playlist.entries) - else: - msg = f"There is no timeslot- or show-fallback defined for timeslot#{timeslot.timeslot_id}. " - msg += f"The station fallback will be used automatically." - self.logger.info(msg) + # if playlist: + # self.logger.info(f"Resolved {fallback_type.value} fallback") + # return FallbackCommand(timeslot, playlist.entries) + # else: + # msg = f"There is no fallback playlist on timeslot- or show-level defined for timeslot#{timeslot.timeslot_id}. " + # msg += f"The station fallback will be used automatically." + # self.logger.info(msg) - def resolve_playlist(self, timeslot): - """ - Retrieves the currently planned (fallback) playlist. If a normal playlist is available, - this one is returned. In case of station fallback no playlist is returned. + # def resolve_playlist(self, timeslot): + # """ + # Retrieves the currently planned, default or fallback playlist. If a normal playlist is available, + # this one is returned. In case of a station fallback to be triggered, no playlist is returned. - Args: - timeslot (Timeslot) - - Returns: - (FallbackType, Playlist) - """ - fallback_type = None - planned_playlist = self.engine.scheduler.programme.get_current_playlist(timeslot) + # Args: + # timeslot (Timeslot) - if planned_playlist: - fallback_type = FallbackType.NONE - else: - (fallback_type, planned_playlist) = self.get_fallback_playlist(timeslot) + # Returns: + # (FallbackType, Playlist) + # """ + # (playlist_type, planned_playlist) = self.engine.scheduler.programme.get_current_playlist(timeslot) - return (fallback_type, planned_playlist) + # if planned_playlist: + # playlist_type = FallbackType.NONE + # else: + # (playlist_type, planned_playlist) = self.get_fallback_playlist(timeslot) + # return (playlist_type, planned_playlist) - def get_fallback_playlist(self, timeslot): - """ - Retrieves the playlist to be used in a fallback scenario. - Args: - timeslot (Timeslot) + # def get_fallback_playlist(self, timeslot): + # """ + # Retrieves the playlist to be used in a fallback scenario. - Returns: - (Playlist) - """ - playlist = None - fallback_type = FallbackType.STATION + # Args: + # timeslot (Timeslot) - if self.validate_playlist(timeslot, "schedule_fallback"): - playlist = timeslot.schedule_fallback - fallback_type = FallbackType.SCHEDULE - elif self.validate_playlist(timeslot, "show_fallback"): - playlist = timeslot.show_fallback - fallback_type = FallbackType.SHOW + # Returns: + # (Playlist) + # """ + # playlist = None + # fallback_type = FallbackType.STATION - return (fallback_type, playlist) + # if self.validate_playlist(timeslot, "schedule_fallback"): + # playlist = timeslot.schedule_fallback + # fallback_type = FallbackType.SCHEDULE + # elif self.validate_playlist(timeslot, "show_fallback"): + # playlist = timeslot.show_fallback + # fallback_type = FallbackType.SHOW + # return (fallback_type, playlist) - def validate_playlist(self, timeslot, playlist_type): - """ - Checks if a playlist is valid for play-out. - Following checks are done for all playlists: + # def validate_playlist(self, timeslot, playlist_type): + # """ + # Checks if a playlist is valid for play-out. - - has one or more entries + # Following checks are done for all playlists: - Fallback playlists should either: - - - have filesystem entries only - - reference a recording of a previous playout of a show (also filesystem) - - Otherwise, if a fallback playlist contains Live or Stream entries, - the exact playout behaviour can hardly be predicted. - """ - playlist = getattr(timeslot, playlist_type) - if playlist \ - and playlist.entries \ - and len(playlist.entries) > 0: + # - has one or more entries - # Default playlist - if playlist_type == "playlist": - return True + # Fallback playlists should either: - # Fallback playlist - elif playlist.entries: - is_fs_only = True - for entry in playlist.entries: - if entry.get_content_type() not in ResourceClass.FILE.types: - self.logger.error(SU.red("Fallback playlist of type '%s' contains not only file-system entries! \ - Skipping fallback level..." % playlist_type)) - is_fs_only = False - break - return is_fs_only + # - have filesystem entries only + # - reference a recording of a previous playout of a show (also filesystem) - return False + # Otherwise, if a fallback playlist contains Live or Stream entries, + # the exact playout behaviour can hardly be predicted. + # """ + # playlist = getattr(timeslot, playlist_type) + # if playlist \ + # and playlist.entries \ + # and len(playlist.entries) > 0: + # # Default playlist + # if playlist_type == "playlist": + # return True + # # Fallback playlist + # elif playlist.entries: + # is_fs_only = True + # for entry in playlist.entries: + # if entry.get_content_type() not in ResourceClass.FILE.types: + # self.logger.error(SU.red("Fallback playlist of type '%s' contains not only file-system entries! \ + # Skipping fallback level..." % playlist_type)) + # is_fs_only = False + # break + # return is_fs_only + # return False -class FallbackCommand(EngineExecutor): - """ - Command composition for executing timed scheduling and unscheduling of fallback playlists. - Based on the `timeslot.start_date` and `timeslot.end_date` two `EngineExecutor commands - are created. - """ - def __init__(self, timeslot, entries): - """ - Constructor +# class FallbackCommand(EngineExecutor): +# """ +# Command composition for executing timed scheduling and unscheduling of fallback playlists. - Args: - timeslot (Timeslot): The timeslot any fallback entries should be scheduled for - entries (List): List of entries to be scheduled as fallback - """ - from src.engine import Engine - - def do_play(entries): - self.logger.info(SU.cyan(f"=== start_fallback_playlist('{entries}') ===")) - Engine.get_instance().player.start_fallback_playlist(entries) - - def do_stop(): - self.logger.info(SU.cyan("=== stop_fallback_playlist() ===")) - Engine.get_instance().player.stop_fallback_playlist() - - # Start fade-out 50% before the end of the timeslot for a more smooth transition - end_time_offset = int(float(AuraConfig.config().get("fade_out_time")) / 2 * 1000 * -1) - end_time = SU.timestamp(timeslot.timeslot_end + timedelta(milliseconds=end_time_offset)) - self.logger.info(f"Starting fade-out of scheduled fallback with an offset of {end_time_offset} milliseconds at {end_time}") - super().__init__("FALLBACK", None, timeslot.start_unix, do_play, entries) - EngineExecutor("FALLBACK", self, end_time, do_stop, None) \ No newline at end of file +# Based on the `timeslot.start_date` and `timeslot.end_date` two `EngineExecutor commands +# are created. +# """ + + +# def __init__(self, timeslot, entries): +# """ +# Constructor + +# Args: +# timeslot (Timeslot): The timeslot any fallback entries should be scheduled for +# entries (List): List of entries to be scheduled as fallback +# """ +# from src.engine import Engine + +# def do_play(entries): +# self.logger.info(SU.cyan(f"=== start_fallback_playlist('{entries}') ===")) +# Engine.get_instance().player.start_fallback_playlist(entries) + +# def do_stop(): +# self.logger.info(SU.cyan("=== stop_fallback_playlist() ===")) +# Engine.get_instance().player.stop_fallback_playlist() + +# # Start fade-out 50% before the end of the timeslot for a more smooth transition +# end_time_offset = int(float(AuraConfig.config().get("fade_out_time")) / 2 * 1000 * -1) +# end_time = SU.timestamp(timeslot.timeslot_end + timedelta(milliseconds=end_time_offset)) +# self.logger.info(f"Starting fade-out of scheduled fallback with an offset of {end_time_offset} milliseconds at {end_time}") +# super().__init__("FALLBACK", None, timeslot.start_unix, do_play, entries) +# EngineExecutor("FALLBACK", self, end_time, do_stop, None) \ No newline at end of file diff --git a/src/scheduling/models.py b/src/scheduling/models.py index f6a15987..f5a6b487 100644 --- a/src/scheduling/models.py +++ b/src/scheduling/models.py @@ -331,6 +331,11 @@ class Playlist(DB.Model, AuraDatabaseModel): """ __tablename__ = 'playlist' + # Static Playlist Types + TYPE_TIMESLOT = { "id": 0, "name": "timeslot" } + TYPE_SCHEDULE = { "id": 1, "name": "schedule" } + TYPE_SHOW = { "id": 2, "name": "show" } + # Primary and Foreign Key artificial_id = Column(Integer, primary_key=True) timeslot_start = Column(DateTime, ForeignKey("timeslot.timeslot_start")) @@ -345,19 +350,19 @@ class Playlist(DB.Model, AuraDatabaseModel): entry_count = Column(Integer) - @staticmethod - def select_all(): - """ - Fetches all entries - """ - all_entries = DB.session.query(Playlist).filter(Playlist.fallback_type == 0).all() + # @staticmethod + # def select_all(): + # """ + # Fetches all entries + # """ + # all_entries = DB.session.query(Playlist).filter(Playlist.fallback_type == 0).all() - cnt = 0 - for entry in all_entries: - entry.programme_index = cnt - cnt = cnt + 1 + # cnt = 0 + # for entry in all_entries: + # entry.programme_index = cnt + # cnt = cnt + 1 - return all_entries + # return all_entries @staticmethod @@ -441,21 +446,21 @@ class Playlist(DB.Model, AuraDatabaseModel): return total - def as_dict(self): - """ - Returns the playlist as a dictionary for serialization. - """ - entries = [] - for e in self.entries: - entries.append(e.as_dict()) - - playlist = { - "playlist_id": self.playlist_id, - "fallback_type": self.fallback_type, - "entry_count": self.entry_count, - "entries": entries - } - return playlist + # def as_dict(self): + # """ + # Returns the playlist as a dictionary for serialization. + # """ + # entries = [] + # for e in self.entries: + # entries.append(e.as_dict()) + + # playlist = { + # "playlist_id": self.playlist_id, + # "fallback_type": self.fallback_type, + # "entry_count": self.entry_count, + # "entries": entries + # } + # return playlist def __str__(self): diff --git a/src/scheduling/programme.py b/src/scheduling/programme.py index 195c901a..3c29ca13 100644 --- a/src/scheduling/programme.py +++ b/src/scheduling/programme.py @@ -51,7 +51,7 @@ class ProgrammeService(): Constructor """ self.config = AuraConfig.config() - self.logger = logging.getLogger("AuraEngine") + self.logger = logging.getLogger("AuraEngine") self.programme_store = ProgrammeStore() @@ -87,7 +87,7 @@ class ProgrammeService(): msg = SU.red("Load programme from DB, because of an unknown response from ApiFetcher: " + response) self.logger.warning(msg) - # Load latest programme from the database + # Load latest programme from the database if not self.timeslots: self.timeslots = self.programme_store.load_timeslots() self.logger.info(SU.green("Finished loading current programme from database (%s timeslots)" % str(len(self.timeslots)))) @@ -99,7 +99,7 @@ class ProgrammeService(): def get_current_entry(self): """ - Retrieves the current `PlaylistEntry` which should be played as per programme. + Retrieves the current `PlaylistEntry` which should be played as per programme. Returns: (PlaylistEntry): The track which is (or should) currently being played @@ -117,7 +117,7 @@ class ProgrammeService(): return None # Check for scheduled playlist - current_playlist = self.get_current_playlist(current_timeslot) + playlist_type, current_playlist = self.get_current_playlist(current_timeslot) if not current_playlist: msg = "There's no (default) playlist assigned to the current timeslot. Most likely a fallback will make things okay again." self.logger.warning(SU.red(msg)) @@ -129,21 +129,21 @@ class ProgrammeService(): 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)) + msg = f"There's no entry scheduled for '{playlist_type.get('name')}' playlist '{str(current_playlist)}' at {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. - + Retrieves the timeslot currently to be played. + Returns: (Timeslot): The current timeslot """ @@ -156,7 +156,7 @@ class ProgrammeService(): if timeslot.start_unix <= now_unix and now_unix < timeslot.end_unix: current_timeslot = timeslot break - + return current_timeslot @@ -169,14 +169,17 @@ class ProgrammeService(): Returns: (FallbackType, Playlist): The currently assigned playlist - """ + """ + playlist_type = Playlist.TYPE_TIMESLOT playlist = timeslot.playlist if not playlist: + playlist_type = Playlist.TYPE_SCHEDULE playlist = timeslot.default_schedule_playlist if not playlist: + playlist_type = Playlist.TYPE_SHOW playlist = timeslot.default_show_playlist - return playlist + return (playlist_type, playlist) @@ -202,7 +205,7 @@ class ProgrammeService(): next_timeslots.append(timeslot) else: break - + return next_timeslots @@ -235,9 +238,9 @@ class ProgrammeService(): class ProgrammeStore(): - """ + """ The `ProgrammeStore` service retrieves all current schedules and related - playlists including audio files from the configured API endpoints and stores + playlists including audio files from the configured API endpoints and stores it in the local database. To perform the API queries it utilizes the ApiFetcher class. @@ -277,7 +280,7 @@ class ProgrammeStore(): # Check if existing timeslots have been deleted self.update_deleted_timeslots(fetched_timeslots) - # Process fetched timeslots + # Process fetched timeslots for timeslot in fetched_timeslots: # Check timeslot for validity @@ -303,7 +306,7 @@ class ProgrammeStore(): if timeslot_db.show_fallback_id: self.store_playlist(timeslot_db, timeslot_db.show_fallback_id, timeslot["show_fallback"]) if timeslot_db.station_fallback_id: - self.store_playlist(timeslot_db, timeslot_db.station_fallback_id, timeslot["station_fallback"]) + self.store_playlist(timeslot_db, timeslot_db.station_fallback_id, timeslot["station_fallback"]) return timeslots @@ -312,10 +315,12 @@ class ProgrammeStore(): def update_deleted_timeslots(self, fetched_timeslots): """ Checks if some timeslot has been deleted remotely, so delete it locally too. - - Attention: This method has no effect if only a single timeslot got deleted, - because this could simply indicate a issue with the API/Steering, since that - means no data got retrieved. + + Attention: This method has no effect if only a single existing timeslot got + deleted, i.e. zero timeslots got returned, because this could simply indicate + an issue with the API/Steering, since that means no data got retrieved. This + should not be a problem in real life scenarios though, as there's practically + always something in the timetable. Args: fetched_timeslots ([dict]): List of timeslot dictionaries from the API @@ -332,10 +337,10 @@ class ProgrammeStore(): # Filter the local timeslot from the fetched ones existing_remotely = list(filter(lambda new_timeslot: \ new_timeslot["timeslot_id"] == local_timeslot.timeslot_id, fetched_timeslots)) - + if not existing_remotely: # Only allow deletion of timeslots which are deleted before the start of the scheduling window - if (local_timeslot.start_unix - scheduling_window_start) > now_unix: + if (local_timeslot.start_unix - scheduling_window_start) > now_unix: self.logger.info("Timeslot #%s has been deleted remotely, hence also delete it locally too [%s]" % \ (local_timeslot.timeslot_id, str(local_timeslot))) local_timeslot.delete(commit=True) @@ -379,7 +384,7 @@ class ProgrammeStore(): # Optional API properties if "default_schedule_playlist_id" in timeslot: timeslot_db.default_schedule_playlist_id = timeslot["default_schedule_playlist_id"] - if "default_show_playlist_id" in timeslot: + if "default_show_playlist_id" in timeslot: timeslot_db.default_show_playlist_id = timeslot["default_show_playlist_id"] if "schedule_fallback_id" in timeslot: timeslot_db.schedule_fallback_id = timeslot["schedule_fallback_id"] @@ -400,7 +405,7 @@ class ProgrammeStore(): if not playlist_id or not fetched_playlist: self.logger.debug(f"Playlist ID#{playlist_id} is not available!") return - + playlist_db = Playlist.select_playlist_for_timeslot(timeslot_db.timeslot_start, playlist_id) havetoadd = False @@ -418,7 +423,7 @@ class ProgrammeStore(): playlist_db.entry_count = 0 playlist_db.store(havetoadd, commit=True) - + if playlist_db.entry_count > 0: self.store_playlist_entries(timeslot_db, playlist_db, fetched_playlist) @@ -439,8 +444,8 @@ class ProgrammeStore(): # In the future this is to be replaced by generic music pool feature. entries = self.m3u_processor.spread(entries) - self.expand_entry_duration(timeslot_db, entries) - self.delete_orphaned_entries(playlist_db, entries) + self.expand_entry_duration(timeslot_db, entries) + self.delete_orphaned_entries(playlist_db, entries) for entry in entries: entry_db = PlaylistEntry.select_playlistentry_for_playlist(playlist_db.artificial_id, entry_num) @@ -479,10 +484,10 @@ class ProgrammeStore(): existing_last_idx = PlaylistEntry.count_entries(playlist_db.artificial_id)-1 if existing_last_idx < new_last_idx: - return + return for entry_num in range(new_last_idx, existing_last_idx+1, 1): - PlaylistEntry.delete_entry(playlist_db.artificial_id, entry_num) + PlaylistEntry.delete_entry(playlist_db.artificial_id, entry_num) self.logger.info(SU.yellow("Deleted playlist entry %s:%s" % (playlist_db.artificial_id, entry_num))) entry_num += 1 @@ -496,7 +501,7 @@ class ProgrammeStore(): """ total_seconds = (timeslot_db.timeslot_end - timeslot_db.timeslot_start).total_seconds() total_duration = SU.seconds_to_nano(total_seconds) - actual_duration = 0 + actual_duration = 0 missing_duration = [] idx = 0 @@ -506,7 +511,7 @@ class ProgrammeStore(): else: actual_duration += entry["duration"] idx += 1 - + if len(missing_duration) == 1: entries[missing_duration[0]]["duration"] = total_duration - actual_duration self.logger.info(f"Expanded duration of playlist entry #{missing_duration[0]}") @@ -536,7 +541,7 @@ class ProgrammeStore(): metadata_db.artist = metadata["artist"] else: metadata_db.artist = "" - + if "album" in metadata: metadata_db.album = metadata["album"] else: diff --git a/src/scheduling/scheduler.py b/src/scheduling/scheduler.py index b701ca7b..62cbfd5d 100644 --- a/src/scheduling/scheduler.py +++ b/src/scheduling/scheduler.py @@ -25,7 +25,7 @@ import time from src.base.config import AuraConfig from src.base.utils import SimpleUtil as SU -from src.scheduling.models import AuraDatabaseModel +from src.scheduling.models import AuraDatabaseModel, Playlist from src.base.exceptions import NoActiveTimeslotException, LoadSourceException from src.control import EngineExecutor from src.engine import Engine @@ -184,7 +184,7 @@ class AuraScheduler(threading.Thread): if active_timeslot: # Create command timer to indicate the start of the timeslot TimeslotCommand(self.engine, active_timeslot) - self.fallback.queue_fallback_playlist(active_timeslot) + # self.fallback.queue_fallback_playlist(active_timeslot) active_entry = self.programme.get_current_entry() if not active_entry: @@ -231,18 +231,36 @@ class AuraScheduler(threading.Thread): def get_active_playlist(self): """ - Retrieves the currently playing playlist. + Retrieves the currently playing playlist. If there is no specific playlist for this timeslots, + then any "default playlist" available on the timeslot or show level, is returned. Returns: - (FallbackType, Playlist): The resolved playlist + (Dict, Playlist): A dictionary holding the playlist type and the resolved playlist """ timeslot = self.programme.get_current_timeslot() if timeslot: - return self.fallback.resolve_playlist(timeslot) + return self.resolve_playlist(timeslot) return (None, None) + def resolve_playlist(self, timeslot): + """ + Retrieves the planned or default playlist for the given timeslot. + + Args: + timeslot (Timeslot) + + Returns: + (Dict, Playlist): A dictionary holding the playlist type and the resolved playlist + """ + playlist_type, playlist = self.programme.get_current_playlist(timeslot) + # if not playlist: + # (playlist_type, playlist) = self.fallback.resolve_playlist(timeslot) + return (playlist_type, playlist) + + + def queue_programme(self): """ Queues the current programme (playlists as per timeslot) by creating @@ -259,9 +277,9 @@ class AuraScheduler(threading.Thread): # Create command timer to indicate the start of the timeslot TimeslotCommand(self.engine, next_timeslot) # Schedule any available fallback playlist - self.fallback.queue_fallback_playlist(next_timeslot) + #self.fallback.queue_fallback_playlist(next_timeslot) - playlist = self.programme.get_current_playlist(next_timeslot) + playlist_type, playlist = self.programme.get_current_playlist(next_timeslot) if playlist: self.queue_playlist_entries(next_timeslot, playlist.entries, False, True) @@ -278,7 +296,7 @@ class AuraScheduler(threading.Thread): # Queue the (rest of the) currently playing timeslot upon startup if current_timeslot: - current_playlist = self.programme.get_current_playlist(current_timeslot) + playlist_type, current_playlist = self.programme.get_current_playlist(current_timeslot) if current_playlist: active_entry = self.programme.get_current_entry() diff --git a/src/scheduling/utils.py b/src/scheduling/utils.py index 80c8c1e0..ed94f7e0 100644 --- a/src/scheduling/utils.py +++ b/src/scheduling/utils.py @@ -201,19 +201,19 @@ class TimeslotRenderer: if active_timeslot.playlist: planned_playlist = active_timeslot.playlist - (fallback_type, resolved_playlist) = self.scheduler.fallback.resolve_playlist(active_timeslot) + (playlist_type, resolved_playlist) = self.scheduler.resolve_playlist(active_timeslot) s += "\n│ Playing timeslot %s " % active_timeslot if planned_playlist: if resolved_playlist and resolved_playlist.playlist_id != planned_playlist.playlist_id: s += "\n│ └── Playlist %s " % planned_playlist s += "\n│ " - s += SU.red("↑↑↑ That's the originally planned playlist.") + ("Instead playing the fallback playlist below ↓↓↓") + s += SU.red("↑↑↑ That's the originally planned playlist.") + ("Instead playing the default playlist below ↓↓↓") if resolved_playlist: if not planned_playlist: - s += "\n│ " - s += SU.red("No playlist assigned to timeslot. Instead playing the `%s` playlist below ↓↓↓" % SU.cyan(str(fallback_type))) + s += "\n│ " + s += SU.red(f"No playlist assigned to timeslot. Instead playing the '{playlist_type.get('name')}' playlist below ↓↓↓") s += "\n│ └── Playlist %s " % resolved_playlist @@ -250,11 +250,11 @@ class TimeslotRenderer: s += "\n│ Nothing. " else: for timeslot in next_timeslots: - (fallback_type, resolved_playlist) = self.scheduler.fallback.resolve_playlist(timeslot) + (playlist_type, resolved_playlist) = self.scheduler.resolve_playlist(timeslot) if resolved_playlist: s += "\n│ Queued timeslot %s " % timeslot - s += "\n│ └── Playlist %s (Type: %s)" % (resolved_playlist, SU.cyan(str(fallback_type))) + s += "\n│ └── Playlist %s (Type: %s)" % (resolved_playlist, SU.cyan(str(playlist_type))) if resolved_playlist.end_unix > timeslot.end_unix: s += "\n│ %s! " % \ (SU.red("↑↑↑ Playlist #%s ends after timeslot #%s!" % (resolved_playlist.playlist_id, timeslot.timeslot_id))) -- GitLab