Commit f90f2f10 authored by David Trattnig's avatar David Trattnig
Browse files

Fallback handling.

parent de452cc0
# Aura Engine
# Playout Daemon for autoradio project
# Copyright (C) 2020 David Trattnig <>
# 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
# 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 <>.
# Meta
__version__ = '0.0.1'
__license__ = "GNU General Public License (GPL) Version 3"
__version_info__ = (0, 0, 1)
__author__ = 'David Trattnig <>'
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.
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):
config (AuraConfig): Holds the engine configuration
self.config = config
self.mailer = AuraMailer(self.config)
self.scheduler = scheduler
self.logger = logger
def get_fallback_for(self, fallbackname):
Retrieves a random fallback audio source for any of the types:
- timeslot/schedule
- show
- station
fallbackname (String): Fallback type
(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
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"
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
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.
(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.
(File): file: the file object.
(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
......@@ -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.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
fallbackname (String): The name of the fallback-type
(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"
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:"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
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
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()"Finished loading current programme from database")"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!")
# 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.enable_entries(planned_entries[0])
# FIXME Still needed?
def enable_entries(self, playlist):
Iterates over all playlist entries and assigs their start time.
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment