# # Aura Engine API (https://gitlab.servus.at/aura/engine-api) # # Copyright (C) 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 datetime import json from flask_marshmallow import Marshmallow from flask_sqlalchemy import SQLAlchemy from marshmallow import post_dump from sqlalchemy import Boolean, Column, DateTime, Integer, String from sqlalchemy.event import listen db = SQLAlchemy() ma = Marshmallow() class PlayLog(db.Model): """ Table holding play-log entries. """ __tablename__ = "playlog" # Primary Key track_start = Column(DateTime, primary_key=True) # Columns track_artist = Column(String(256)) track_album = Column(String(256)) track_title = Column(String(256)) track_duration = Column(Integer) track_type = Column(Integer) track_num = Column(Integer) playlist_id = Column(Integer) timeslot_id = Column(Integer) show_id = Column(Integer) show_name = Column(String(256)) log_source = Column( Integer ) # The play-out source which this log is coming from (e.g. engine1, engine2) is_synced = Column(Boolean) # Only relevant for main nodes, in a multi-node setup def __init__(self, data): """ Initializes a trackservice entry """ self.track_start = data.track_start self.track_artist = data.track_artist self.track_album = data.track_album self.track_title = data.track_title self.track_duration = data.track_duration self.track_type = data.track_type self.track_num = data.track_num self.playlist_id = data.playlist_id self.timeslot_id = data.timeslot_id self.show_id = data.show_id self.show_name = data.show_name self.log_source = data.log_source self.is_synced = False if not self.track_duration: self.track_duration = 0 def save(self): db.session.add(self) db.session.commit() @staticmethod def get(start_time): """ Selects the playlog identified by start time. """ db.session.commit() track = ( db.session.query(PlayLog) .filter(PlayLog.track_start <= str(start_time)) .order_by(PlayLog.track_start.desc()) .first() ) return track @staticmethod def select_recent(): """ Selects the most recent played track. Equals to the current track if it's still playing. """ db.session.commit() now = datetime.datetime.now() track = ( db.session.query(PlayLog) .order_by(PlayLog.track_start.desc()) .filter(PlayLog.track_start <= str(now)) .first() ) return track @staticmethod def select_current(): """ Selects the currently playing track. """ db.session.commit() now = datetime.datetime.now() track = PlayLog.select_recent() if track: # Station fallback may not provide a duration. Take this one, it's better than nothing. if track.track_duration == 0 or not track.track_duration: return track # Preferably only get playlogs which are known for still being on air if track.track_start + datetime.timedelta(0, track.track_duration) > now: return track return None @staticmethod def select_for_timeslot(timeslot_id): """ Selects all playlogs which appear to be in the current timeslot. If playlogs without a valid timeslot are queried (-1), then only the last 50 total playlogs are respected. """ db.session.commit() before12h = datetime.datetime.now() - datetime.timedelta(hours=12) playlogs = None # Invalid Timeslot ID if timeslot_id == -1: playlogs = [] result = ( db.session.query(PlayLog) .order_by(PlayLog.track_start.desc()) .filter(PlayLog.track_start >= str(before12h)) .limit(50) ) for playlog in result.all(): if playlog.timeslot_id != -1: break playlogs.append(playlog) # Valid Timeslot ID else: result = ( db.session.query(PlayLog) .order_by(PlayLog.track_start.desc()) .filter(PlayLog.timeslot_id == timeslot_id) ) playlogs = result.all() return playlogs @staticmethod def paginate(page, page_size, from_time=None, to_time=None, skip_synced=False): """ Returns a list of entries for a given page and an start time (optional). """ if not from_time: from_time = datetime.datetime.utcnow().date() if not to_time: to_time = datetime.datetime.now() def q(page=0, page_size=None): query = db.session.query(PlayLog).order_by(PlayLog.track_start.desc()) if isinstance(from_time, datetime.datetime): query = query.filter(PlayLog.track_start >= from_time.isoformat(" ", "seconds")) if isinstance(to_time, datetime.datetime): query = query.filter(PlayLog.track_start <= to_time.isoformat(" ", "seconds")) if skip_synced: query = query.filter(PlayLog.is_synced.is_(False)) listen(query, "before_compile", apply_limit(page, page_size), retval=True) print("Paginate Query: " + str(query)) return query def apply_limit(page, page_size): def wrapped(query): if page_size: query = query.limit(page_size) if page: query = query.offset(page * page_size) return query return wrapped return q(page, page_size) @staticmethod def select_last_hours(n): """ Selects the tracks playing in the past (`n`) hours. """ db.session.commit() last_hours = datetime.datetime.today() - datetime.timedelta(hours=n) tracks = ( db.session.query(PlayLog) .filter(PlayLog.track_start >= str(last_hours)) .order_by(PlayLog.track_start.desc()) .all() ) return tracks @staticmethod def select_by_day(day): """ Select the track-service items for a day. """ db.session.commit() day_plus_one = day + datetime.timedelta(days=1) tracks = ( db.session.query(PlayLog) .filter(PlayLog.track_start >= str(day), PlayLog.track_start < str(day_plus_one)) .order_by(PlayLog.track_start.desc()) .all() ) return tracks @staticmethod def select_by_range(from_day, to_day): """ Selects the track-service items for a day range. """ db.session.commit() tracks = ( db.session.query(PlayLog) .filter(PlayLog.track_start >= str(from_day), PlayLog.track_start < str(to_day)) .order_by(PlayLog.track_start.desc()) .all() ) return tracks def __str__(self): return "Track [track_start: %s, track_title: %s]" % ( str(self.track_start), str(self.track_title), ) class PlayLogSchema(ma.SQLAlchemyAutoSchema): """ Schema for playlog entries. """ class Meta: model = PlayLog sqla_session = db.session SKIP_VALUES = set([None]) @post_dump def remove_skip_values(self, data, many=False): return {key: value for key, value in data.items() if value not in self.SKIP_VALUES} class TrackSchema(ma.SQLAlchemySchema): """ Schema for trackservice entries. """ class Meta: model = PlayLog sqla_session = db.session fields = ( "track_start", "track_artist", "track_album", "track_title", "track_duration", "track_type", "track_num", "playlist_id", "timeslot_id", "show_id", "show_name", ) class ActivityLog(db.Model): """ Table holding a log of play-out source activity and their sync states. Only used in "SYNC" deployment mode. """ __tablename__ = "activity_log" # Primary Key log_time = Column(DateTime, primary_key=True) # Columns source_number = Column(Integer) is_synced = Column(Boolean) def __init__(self, source_number): """ Initializes an activity entry """ self.log_time = datetime.datetime.now() self.source_number = source_number self.is_synced = False @staticmethod def is_empty(): """ Checks if the tables is empty. """ db.session.commit() return not db.session.query(ActivityLog).one_or_none() @staticmethod def get_active_source(): """ Retrieves the currently active source. """ db.session.commit() source = db.session.query(ActivityLog).order_by(ActivityLog.log_time.desc()).first() return source def save(self): db.session.add(self) db.session.commit() class HealthHistory(db.Model): """ Table holding an history of health information for sources. """ __tablename__ = "health_history" # Primary Key log_time = Column(DateTime, primary_key=True) # Columns log_source = Column(Integer) # The source the history entry relates to is_healthy = Column(Boolean) # Indicates if source is "healthy enough" to be used for play-out is_synced = Column(Boolean) # Only relevant for main nodes, in a multi-node setup health_info = Column(String(4096)) # Stringified JSON object or other, if needed def __init__(self, source_number, log_time, is_healthy, health_info): """ Initializes an health entry. """ self.log_time = log_time self.log_source = source_number self.is_healthy = is_healthy self.is_synced = False self.health_info = health_info @staticmethod def get_latest_entry(source_number): """ Retrieves the most recent health history entry for the given source number. """ return ( db.session.query(HealthHistory) .filter(HealthHistory.log_source == source_number) .order_by(HealthHistory.log_time.desc()) .first() ) def save(self): db.session.add(self) db.session.commit() class HealthHistorySchema(ma.SQLAlchemyAutoSchema): """ Schema for health history entries. """ class Meta: model = HealthHistory sqla_session = db.session class ClockInfo(db.Model): """ Table holding information for the current and next show to be displayed by the studio clock. Important: This table doesn't hold the history of information for efficiency. It only stores one record for each possible source. For example in a redundant deployment it holds two records, for engine1 and engine2. The stringified objects allow easy, future extension of the properties, without the need to change the database model. """ __tablename__ = "clock_info" # Primary Key log_source = Column( Integer, primary_key=True ) # The source this entry was updated from ("1" for engine1, "2" for engine2) # Columns log_time = Column(DateTime) current_track = None # Populated live from within `get_info(..)` planned_playlist = Column( String(4096) ) # Stringified "#/components/schemas/Playlist" OpenAPI JSON object current_timeslot = Column( String(2048) ) # Stringified "#/components/schemas/Timeslot" OpenAPI JSON object upcoming_timeslots = Column( String(2048) ) # Stringified "#/components/schemas/Timeslot" OpenAPI JSON object def __init__(self): """ Initializes an clock info entry. """ def set_info(self, source_number, planned_playlist, current_timeslot, upcoming_timeslots): """ Set the values for a clock info entry. """ self.log_time = datetime.datetime.now() self.log_source = source_number if planned_playlist: self.planned_playlist = json.dumps(planned_playlist.to_dict(), default=str) else: self.planned_playlist = None if current_timeslot: self.current_timeslot = json.dumps(current_timeslot.to_dict(), default=str) else: self.current_timeslot = None if upcoming_timeslots: upcoming_list = [] for upcoming in upcoming_timeslots: upcoming_list.append(upcoming.to_dict()) self.upcoming_timeslots = json.dumps(upcoming_list, default=str) else: self.upcoming_timeslots = None @staticmethod def get(source_number): """ Retrieves the clock info for the given source number. """ return db.session.query(ClockInfo).filter(ClockInfo.log_source == source_number).first() @staticmethod def get_info(source_number): """ Retrieves the clock info for the given source number. """ track_schema = TrackSchema() info = dict() data = db.session.query(ClockInfo).filter(ClockInfo.log_source == source_number).first() current_track = PlayLog.select_current() playlogs = None # Construct the clock `info` object if data: info["log_source"] = data.log_source info["log_time"] = data.log_time # Get the track currently playing if current_track: info["current_track"] = track_schema.dump(current_track) # Append the missing planned playlist items to the ones played if data.planned_playlist: info["planned_playlist"] = json.loads(data.planned_playlist) else: info["planned_playlist"] = {} # Get the current timeslot if data.current_timeslot: info["current_timeslot"] = json.loads(data.current_timeslot) # Get the most recently played track (because right now nothing might be playing) most_recent_track = PlayLog.select_recent() # Is the most recent track part of the current timeslot? if most_recent_track.timeslot_id == info["current_timeslot"]["timeslot_id"]: # Get the actual playlogs of the current timeslot, until now playlog_schema = PlayLogSchema(many=True) playlogs = PlayLog.select_for_timeslot(most_recent_track.timeslot_id) playlogs.sort(key=lambda track: track.track_start, reverse=False) info["current_playlogs"] = playlog_schema.dump(playlogs) if info["current_playlogs"] is None: info["current_playlogs"] = {} # Invalid timeslots (e.g. in fallback scenarios) get a virtual start date of # the first fallback track. if info["current_timeslot"]["timeslot_id"] == -1: if playlogs and playlogs[0]: info["current_timeslot"]["timeslot_start"] = playlogs[0].track_start # Get the next timeslot if data.upcoming_timeslots: info["upcoming_timeslots"] = json.loads(data.upcoming_timeslots) else: info["upcoming_timeslots"] = {} return info def save(self): db.session.add(self) db.session.commit() def update(self): db.session.merge(self) db.session.commit() class ClockInfoSchema(ma.SQLAlchemySchema): """ Schema for trackservice entries. """ class Meta: model = ClockInfo sqla_session = db.session fields = ( "log_source", "log_time", "current_track", "planned_playlist", "current_playlogs", "current_timeslot", "upcoming_timeslots", )