Commit 94c38f18 authored by David Trattnig's avatar David Trattnig
Browse files

Default playlists for show and schedule-level. #52

parent e87be140
......@@ -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()
......
......@@ -268,30 +268,30 @@ class EngineEventDispatcher():
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)
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)
thread = Thread(target = func, args = (self, cleaned_channel))
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)
# 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)
# thread = Thread(target = func, args = (self, cleaned_channel))
# thread.start()
def on_fallback_active(self, timeslot, fallback_type):
......
......@@ -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):
......
......@@ -121,12 +121,11 @@ 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")
......
......@@ -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,8 +59,7 @@ 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
......@@ -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)
# Args:
# timeslot (Timeslot)
Returns:
(FallbackType, Playlist)
"""
fallback_type = None
planned_playlist = self.engine.scheduler.programme.get_current_playlist(timeslot)
# Returns:
# (FallbackType, Playlist)
# """
# (playlist_type, planned_playlist) = self.engine.scheduler.programme.get_current_playlist(timeslot)
if planned_playlist:
fallback_type = FallbackType.NONE
else:
(fallback_type, planned_playlist) = self.get_fallback_playlist(timeslot)
# if planned_playlist:
# playlist_type = FallbackType.NONE
# else:
# (playlist_type, planned_playlist) = self.get_fallback_playlist(timeslot)
return (fallback_type, planned_playlist)
# return (playlist_type, planned_playlist)
def get_fallback_playlist(self, timeslot):
"""
Retrieves the playlist to be used in a fallback scenario.
# def get_fallback_playlist(self, timeslot):
# """
# Retrieves the playlist to be used in a fallback scenario.
Args:
timeslot (Timeslot)
# Args:
# timeslot (Timeslot)
Returns:
(Playlist)
"""
playlist = None
fallback_type = FallbackType.STATION
# Returns:
# (Playlist)
# """
# playlist = None
# fallback_type = FallbackType.STATION
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
# 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)
# return (fallback_type, playlist)
def validate_playlist(self, timeslot, playlist_type):
"""
Checks if a playlist is valid for play-out.
# def validate_playlist(self, timeslot, playlist_type):
# """
# Checks if a playlist is valid for play-out.
Following checks are done for all playlists:
# Following checks are done for all playlists:
- has one or more entries
# - has one or more entries
Fallback playlists should either:
# Fallback playlists should either:
- have filesystem entries only
- reference a recording of a previous playout of a show (also filesystem)
# - 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:
# 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
# # 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
# # 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
# return False
class FallbackCommand(EngineExecutor):
"""
Command composition for executing timed scheduling and unscheduling of fallback playlists.
# 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.
"""
# Based on the `timeslot.start_date` and `timeslot.end_date` two `EngineExecutor commands
# are created.
# """
def __init__(self, timeslot, entries):
"""
Constructor
# 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
# 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
......@@ -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):
......
......@@ -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))
......@@ -132,7 +132,7 @@ class ProgrammeService():
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
......@@ -170,13 +170,16 @@ 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)