From f90f2f10afb8bf92fe22701248780e4a53df1aba Mon Sep 17 00:00:00 2001 From: David Trattnig <david.trattnig@o94.at> Date: Tue, 25 Feb 2020 17:21:09 +0100 Subject: [PATCH] Fallback handling. --- modules/scheduling/fallback_manager.py | 229 +++++++++++++++++++++++++ modules/scheduling/scheduler.py | 77 +++------ 2 files changed, 249 insertions(+), 57 deletions(-) create mode 100644 modules/scheduling/fallback_manager.py diff --git a/modules/scheduling/fallback_manager.py b/modules/scheduling/fallback_manager.py new file mode 100644 index 00000000..1d5300ca --- /dev/null +++ b/modules/scheduling/fallback_manager.py @@ -0,0 +1,229 @@ + +# +# Aura Engine +# +# Playout Daemon for autoradio project +# +# +# Copyright (C) 2020 David Trattnig <david.trattnig@subsquare.at> +# +# This file is part of engine. +# +# engine is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# any later version. +# +# engine 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with engine. If not, see <http://www.gnu.org/licenses/>. +# + +# Meta +__version__ = '0.0.1' +__license__ = "GNU General Public License (GPL) Version 3" +__version_info__ = (0, 0, 1) +__author__ = 'David Trattnig <david.trattnig@subsquare.at>' + + +import os, os.path +import logging +import random + +from accessify import private, protected +from modules.base.simpleutil import SimpleUtil +from modules.communication.mail import AuraMailer + + +class FallbackManager: + """ + Handles all types of fallbacks in case there is an outage + for the regular radio programme. + + Attributes: + config (AuraConfig): The engine configuration + logger (AuraLogger): The logger + mail (AuraMailer): Mail service + scheduler (AuraScheduler): The scheduler + fallback_history (Dict): Holds a 24h history of played, local tracks to avoid re-play + last_fallback (Integer): Timestamp, when the last local file fallback was played + is_processing (Boolean): Flag to avoid race-conditions, as Liquidsoap sends plenty of requests at once + """ + + config = None + logger = None + mailer = None + scheduler = None + fallback_history = {} + last_fallback = 0 + is_processing = False + + + def __init__(self, config, logger, scheduler): + """ + Constructor + + Args: + config (AuraConfig): Holds the engine configuration + """ + self.config = config + self.mailer = AuraMailer(self.config) + self.scheduler = scheduler + self.logger = logger + + + # + # PUBLIC METHODS + # + + + def get_fallback_for(self, fallbackname): + """ + Retrieves a random fallback audio source for any of the types: + - timeslot/schedule + - show + - station + + Args: + fallbackname (String): Fallback type + + Returns: + (String): Absolute path to the file + """ + file = "" + media_type = "PLAYLIST" + active_schedule, active_playlist = self.scheduler.get_active_playlist() + + # Block access to avoid race-conditions + if self.is_processing: + return None + else: + self.is_processing = True + + # Get fallback track(s) by fallback-type + if fallbackname == "timeslot": + file = self.get_playlist_items(active_schedule, "schedule_fallback") + + elif fallbackname == "show": + file = self.get_playlist_items(active_schedule, "show_fallback") + + elif fallbackname == "station": + file = self.get_playlist_items(active_schedule, "station_fallback") + + if not file: + media_type = "TRACK" + file = self.get_random_local_track() + + if not file: + self.logger.critical("Got no file for station fallback! Playing default test track, to play anything at all.") + file = "../../testing/content/ernie_mayne_sugar.mp3" + media_type = "DEFAULT TRACK" + else: + file = "" + self.logger.critical("Should set next fallback file for " + fallbackname + ", but this fallback is unknown!") + + # Send admin email to notify about the fallback state + if file: + if not active_playlist: + active_playlist = "n/a" + msg = "AURA ENGINE %s FALLBACK DETECTED!\n\n" % fallbackname + msg += "Expected, active Schedule: %s \n" % active_schedule + msg += "Expected, active Playlist: %s \n\n" % active_playlist + msg += "Providing FALLBACK-%s for %s '%s'\n\n" % (media_type, fallbackname, file) + msg += "Please review the schedules or contact your Aura Engine administrator." + self.mailer.send_admin_mail("CRITICAL - Detected fallback for %s" % fallbackname, msg) + self.logger.critical("Sent admin email:\n──────────────────────────────────────\n" + msg) + + self.is_processing = False + return file + + + + # + # PRIVATE METHODS + # + + + def get_playlist_items(self, schedule, fallback_key): + """ + Retrieves the list of tracks from a playlist defined by `fallback_key`. + """ + playlist_files = "" + + if hasattr(schedule, fallback_key): + playlist = getattr(schedule, fallback_key) + if len(playlist) > 0: + playlist = playlist[0] + if playlist and playlist.entries: + for entry in playlist.entries: + playlist_files += entry.filename + "\n" + + return playlist_files + + + + def get_random_local_track(self): + """ + Retrieves a random audio track from the local station-fallback directory. + + Returns: + (String): Absolute path to an audio file + """ + dir = self.config.fallback_music_folder + files = os.listdir(dir) + audio_files = list(filter(lambda f: self.is_audio_file(dir, f), files)) + + if not dir or not audio_files: + self.logger.error("Folder 'fallback_music_folder = %s' is empty!" % dir) + return None + + # If last played fallback is > 24 hours ago, ignore play history + # This should save used memory if the engine runs for a long time + if self.last_fallback < SimpleUtil.timestamp() - (60*60*24): + self.fallback_history = {} + self.last_fallback = SimpleUtil.timestamp() + + # Retrieve files which haven't been played yet + history = set(self.fallback_history.keys()) + left_audio_files = list(set(audio_files).difference(history)) + + # If nothing left, clear history and start with all files again + if not len(left_audio_files): + self.fallback_history = {} + left_audio_files = audio_files + + # Select random track from directory + i = random.randint(0, len(left_audio_files)-1) + file = os.path.join(dir, left_audio_files[i]) + + # Store track in history, to avoid playing it multiple times + if file: + self.fallback_history[left_audio_files[i]] = SimpleUtil.timestamp() + + return file + + + + def is_audio_file(self, dir, file): + """ + Checks if the passed file is an audio file i.e. has a file-extension + known for audio files. + + Args: + (File): file: the file object. + + Returns: + (Boolean): True, if it's an audio file. + """ + audio_extensions = [".wav", ".flac", ".mp3", ".ogg"] + ext = os.path.splitext(file)[1] + abs_path = os.path.join(dir, file) + + if os.path.isfile(abs_path): + if any(ext in s for s in audio_extensions): + return True + return False \ No newline at end of file diff --git a/modules/scheduling/scheduler.py b/modules/scheduling/scheduler.py index f8544800..4487272b 100644 --- a/modules/scheduling/scheduler.py +++ b/modules/scheduling/scheduler.py @@ -35,7 +35,6 @@ import datetime import decimal import traceback import sqlalchemy - import logging import threading @@ -44,6 +43,7 @@ from operator import attrgetter from modules.base.simpleutil import SimpleUtil from modules.communication.redis.messenger import RedisMessenger from modules.scheduling.calendar import AuraCalendarService +from modules.scheduling.fallback_manager import FallbackManager from libraries.database.broadcasts import Schedule, Playlist, AuraDatabaseModel from libraries.exceptions.exception_logger import ExceptionLogger from libraries.enum.auraenumerations import ScheduleEntryType, TimerType, TerminalColors @@ -90,11 +90,10 @@ class AuraScheduler(ExceptionLogger, threading.Thread): exit_event = None liquidsoapcommunicator = None last_successful_fetch = None - programme = None active_entry = None message_timer = [] - + fallback_manager = None #schedule_entries = None client = None @@ -110,7 +109,7 @@ class AuraScheduler(ExceptionLogger, threading.Thread): self.logger = logging.getLogger("AuraEngine") self.init_error_messages() self.init_database() - + self.fallback_manager = FallbackManager(config, self.logger, self) self.redismessenger = RedisMessenger(config) # init threading @@ -276,35 +275,20 @@ class AuraScheduler(ExceptionLogger, threading.Thread): def get_next_file_for(self, fallbackname): """ - Evaluates the next **fallback files/folders** to be played for a given fallback-type. + Evaluates the next **fallback files/playlists** to be played for a given fallback-type. Valid fallback-types are: * timeslot * show * station + Args: + fallbackname (String): The name of the fallback-type + Returns: (String): Absolute path to the file to be played as a fallback. """ - - file = None - # next_entry = None - - # if not self.active_entry: - # self.get_active_entry() - # next_entry = self.active_entry - # else: - # next_entry = self.get_next_entry() - - if fallbackname == "timeslot": - file = "/home/david/Music/ab.mp3" - elif fallbackname == "show": - file = "/home/david/Code/aura/engine2/testing/content/ernie_mayne_sugar.mp3" - elif fallbackname == "station": - file = "/home/david/Code/aura/engine2/testing/content/ernie_mayne_sugar.mp3" - else: - file = "" - self.logger.critical("Should set next fallback file for " + fallbackname + ", but this fallback is unknown!") + file = self.fallback_manager.get_fallback_for(fallbackname) if file: self.logger.info("Got next file '%s' (type: %s)" % (file, fallbackname)) @@ -336,14 +320,15 @@ class AuraScheduler(ExceptionLogger, threading.Thread): current_playlist = None # Iterate over all shows and playlists and find the one to be played right now - for schedule in self.programme: - if schedule.start_unix < now_unix < schedule.end_unix: - current_schedule = schedule - for playlist in schedule.playlist: - if playlist.start_unix < now_unix < playlist.end_unix: - current_playlist = playlist - break - break + if self.programme: + for schedule in self.programme: + if schedule.start_unix < now_unix < schedule.end_unix: + current_schedule = schedule + for playlist in schedule.playlist: + if playlist.start_unix < now_unix < playlist.end_unix: + current_playlist = playlist + break + break return (current_schedule, current_playlist) @@ -523,9 +508,9 @@ class AuraScheduler(ExceptionLogger, threading.Thread): # Always load latest programme from the database self.last_successful_fetch = lsf self.load_programme_from_db() - self.logger.info("Finished loading current programme from database") + self.logger.info("Finished loading current programme from database (%s schedules)" % str(len(self.programme))) for schedule in self.programme: - self.logger.debug("\tSchedule %s with Playlist %s" % (str(schedule), str(schedule.playlist[0]))) + self.logger.debug("\tSchedule %s with Playlist %s" % (str(schedule), str(schedule.playlist))) @@ -542,31 +527,9 @@ class AuraScheduler(ExceptionLogger, threading.Thread): self.logger.critical("Could not load programme from database. We are in big trouble my friend!") return - # FIXME That's very likely not needed - review! - # planned_entries = [] - - # for schedule in self.programme: - # # playlist to play - # #schedule.playlist = [Playlist.select_playlist_for_schedule(schedule.schedule_start, schedule.playlist_id)] - - - # # show fallback is played when playlist fails - # #schedule.showfallback = Playlist.select_playlist(schedule.show_fallback_id) - # # timeslot fallback is played when show fallback fails - # #schedule.timeslotfallback = Playlist.select_playlist(schedule.timeslot_fallback_id) - # # station fallback is played when timeslot fallback fails - # #schedule.stationfallback = Playlist.select_playlist(schedule.station_fallback_id) - - # for p in schedule.playlist: - # planned_entries.append(p) - - # FIXME Same playlists are repeated over time - test with different schedules/timeslots/playlists - # Therefore only passing the first playlist for now: - # self.logger.warn("ONLY PASSING 1ST PLAYLIST OF PROGRAMME") - # self.enable_entries(planned_entries[0]) - + # FIXME Still needed? def enable_entries(self, playlist): """ Iterates over all playlist entries and assigs their start time. -- GitLab