From a1db2a157aaaadae6f8f275075b9572fd9cc29b0 Mon Sep 17 00:00:00 2001 From: jo Date: Sat, 4 Mar 2023 21:50:12 +0100 Subject: [PATCH] feat(playout): replace schedule event dicts with objects --- legacy/application/models/Schedule.php | 42 +- playout/libretime_playout/player/events.py | 87 ++- playout/libretime_playout/player/fetch.py | 42 +- playout/libretime_playout/player/file.py | 89 ++- .../libretime_playout/player/liquidsoap.py | 136 ++-- .../player/liquidsoap_gateway.py | 53 +- playout/libretime_playout/player/push.py | 17 +- playout/libretime_playout/player/queue.py | 6 +- playout/libretime_playout/player/schedule.py | 168 ++--- .../tests/player/liquidsoap_gateway_test.py | 62 +- playout/tests/player/schedule_test.py | 611 +++++++++--------- 11 files changed, 646 insertions(+), 667 deletions(-) diff --git a/legacy/application/models/Schedule.php b/legacy/application/models/Schedule.php index 62110428a..6a334f47c 100644 --- a/legacy/application/models/Schedule.php +++ b/legacy/application/models/Schedule.php @@ -891,11 +891,9 @@ SQL; $schedule_item = [ 'id' => $media_id, 'type' => 'file', - 'metadata' => [ - 'track_title' => $fileMetadata['track_title'], - 'artist_name' => $fileMetadata['artist_name'], - 'mime' => $fileMetadata['mime'], - ], + 'track_title' => $fileMetadata['track_title'], + 'artist_name' => $fileMetadata['artist_name'], + 'mime' => $fileMetadata['mime'], 'row_id' => $item['id'], 'uri' => $uri, 'fade_in' => Application_Model_Schedule::WallTimeToMillisecs($item['fade_in']), @@ -926,25 +924,26 @@ SQL; $stream_buffer_start = self::AirtimeTimeToPypoTime($buffer_start->format(DEFAULT_TIMESTAMP_FORMAT)); - $schedule_item = [ + $schedule_common = [ + 'row_id' => $item['id'], + 'id' => $media_id, + 'uri' => $uri, + 'show_name' => $item['show_name'], + ]; + + $schedule_item = array_merge($schedule_common, [ 'start' => $stream_buffer_start, 'end' => $stream_buffer_start, - 'uri' => $uri, - 'row_id' => $item['id'], 'type' => 'stream_buffer_start', - ]; + ]); self::appendScheduleItem($data, $start, $schedule_item); - $schedule_item = [ - 'id' => $media_id, - 'type' => 'stream_output_start', - 'row_id' => $item['id'], - 'uri' => $uri, + $schedule_item = array_merge($schedule_common, [ 'start' => $start, 'end' => $end, - 'show_name' => $item['show_name'], - ]; + 'type' => 'stream_output_start', + ]); self::appendScheduleItem($data, $start, $schedule_item); // since a stream never ends we have to insert an additional "kick stream" event. The "start" @@ -954,21 +953,18 @@ SQL; $stream_end = self::AirtimeTimeToPypoTime($dt->format(DEFAULT_TIMESTAMP_FORMAT)); - $schedule_item = [ + $schedule_item = array_merge($schedule_common, [ 'start' => $stream_end, 'end' => $stream_end, - 'uri' => $uri, 'type' => 'stream_buffer_end', - 'row_id' => $item['id'], - ]; + ]); self::appendScheduleItem($data, $stream_end, $schedule_item); - $schedule_item = [ + $schedule_item = array_merge($schedule_common, [ 'start' => $stream_end, 'end' => $stream_end, - 'uri' => $uri, 'type' => 'stream_output_end', - ]; + ]); self::appendScheduleItem($data, $stream_end, $schedule_item); } diff --git a/playout/libretime_playout/player/events.py b/playout/libretime_playout/player/events.py index b601b10d2..3d08cf238 100644 --- a/playout/libretime_playout/player/events.py +++ b/playout/libretime_playout/player/events.py @@ -1,22 +1,35 @@ from datetime import datetime from enum import Enum -from typing import Dict, Literal, Optional, TypedDict, Union +from pathlib import Path +from typing import TYPE_CHECKING, Dict, Literal, Optional, Union -from typing_extensions import NotRequired +from dateutil.parser import isoparse +from pydantic import BaseModel, Field, parse_obj_as, validator +from typing_extensions import Annotated + +from ..config import CACHE_DIR +from ..utils import mime_guess_extension + +if TYPE_CHECKING: + from pydantic.typing import AnyClassMethod EVENT_KEY_FORMAT = "%Y-%m-%d-%H-%M-%S" def event_key_to_datetime(value: Union[str, datetime]) -> datetime: - if isinstance(value, datetime): - return value - return datetime.strptime(value, EVENT_KEY_FORMAT) + if isinstance(value, str): + value = datetime.strptime(value, EVENT_KEY_FORMAT) + return value def datetime_to_event_key(value: Union[str, datetime]) -> str: - if isinstance(value, str): - return value - return value.strftime(EVENT_KEY_FORMAT) + if isinstance(value, datetime): + value = value.strftime(EVENT_KEY_FORMAT) + return value + + +def event_isoparse(value: str) -> datetime: + return isoparse(value).replace(tzinfo=None).replace(microsecond=0) class EventKind(str, Enum): @@ -28,16 +41,24 @@ class EventKind(str, Enum): WEB_STREAM_OUTPUT_END = "stream_output_end" -class BaseEvent(TypedDict): - # TODO: Only use datetime - start: Union[str, datetime] - end: Union[str, datetime] +def event_datetime_validator(prop: str) -> "AnyClassMethod": + return validator(prop, pre=True, allow_reuse=True)(event_key_to_datetime) -class FileEventMetadata(TypedDict): - track_title: str - artist_name: str - mime: str +class BaseEvent(BaseModel): + start: datetime + end: datetime + + _start_validator = event_datetime_validator("start") + _end_validator = event_datetime_validator("end") + + @property + def start_key(self) -> str: + return datetime_to_event_key(self.start) + + @property + def end_key(self) -> str: + return datetime_to_event_key(self.end) class FileEvent(BaseEvent): @@ -45,7 +66,7 @@ class FileEvent(BaseEvent): # Schedule row_id: int - uri: Optional[str] + uri: Optional[str] = None id: int # Show data @@ -57,16 +78,22 @@ class FileEvent(BaseEvent): cue_in: float cue_out: float - # TODO: Flatten this metadata dict - metadata: FileEventMetadata + track_title: Optional[str] = None + artist_name: Optional[str] = None - replay_gain: float + mime: str + replay_gain: Optional[float] = None filesize: int - # Runtime - dst: NotRequired[str] - file_ready: NotRequired[bool] - file_ext: NotRequired[str] + file_ready: bool = False + + @property + def file_ext(self) -> str: + return mime_guess_extension(self.mime) + + @property + def local_filepath(self) -> Path: + return CACHE_DIR / f"{self.id}{self.file_ext}" class WebStreamEvent(BaseEvent): @@ -83,7 +110,7 @@ class WebStreamEvent(BaseEvent): id: int # Show data - show_name: NotRequired[str] + show_name: str class ActionEventKind(str, Enum): @@ -97,7 +124,15 @@ class ActionEvent(BaseEvent): event_type: str -AnyEvent = Union[FileEvent, WebStreamEvent, ActionEvent] +AnyEvent = Annotated[ + Union[FileEvent, WebStreamEvent, ActionEvent], + Field(discriminator="type"), +] + + +def parse_any_event(value: dict) -> AnyEvent: + return parse_obj_as(AnyEvent, value) # type: ignore + FileEvents = Dict[str, FileEvent] Events = Dict[str, AnyEvent] diff --git a/playout/libretime_playout/player/fetch.py b/playout/libretime_playout/player/fetch.py index 12cfca71f..adf2bf78f 100644 --- a/playout/libretime_playout/player/fetch.py +++ b/playout/libretime_playout/player/fetch.py @@ -16,16 +16,14 @@ from ..config import CACHE_DIR, POLL_INTERVAL, Config from ..liquidsoap.client import LiquidsoapClient from ..liquidsoap.models import Info, MessageFormatKind, StreamPreferences, StreamState from ..timeout import ls_timeout -from .events import EventKind, Events, FileEvent, FileEvents, event_key_to_datetime +from .events import Events, FileEvent, FileEvents from .liquidsoap import PypoLiquidsoap -from .schedule import get_schedule +from .schedule import get_schedule, receive_schedule logger = logging.getLogger(__name__) here = Path(__file__).parent -from ..utils import mime_guess_extension - # pylint: disable=too-many-instance-attributes class PypoFetch(Thread): @@ -73,7 +71,7 @@ class PypoFetch(Thread): logger.debug("handling event %s: %s", command, message) if command == "update_schedule": - self.schedule_data = message["schedule"]["media"] + self.schedule_data = receive_schedule(message["schedule"]["media"]) self.process_schedule(self.schedule_data) elif command == "reset_liquidsoap_bootstrap": self.set_bootstrap_variables() @@ -209,15 +207,8 @@ class PypoFetch(Thread): try: for key in events: item = events[key] - if item["type"] == EventKind.FILE: - file_ext = self.sanity_check_media_item(item) - dst = os.path.join(self.cache_dir, f'{item["id"]}{file_ext}') - item["dst"] = dst - item["file_ready"] = False + if isinstance(item, FileEvent): file_events[key] = item - - item["start"] = event_key_to_datetime(item["start"]) - item["end"] = event_key_to_datetime(item["end"]) all_events[key] = item self.media_prepare_queue.put(copy.copy(file_events)) @@ -234,25 +225,6 @@ class PypoFetch(Thread): except Exception as exception: # pylint: disable=broad-exception-caught logger.exception(exception) - # do basic validation of file parameters. Useful for debugging - # purposes - def sanity_check_media_item(self, event: FileEvent): - start = event_key_to_datetime(event["start"]) - end = event_key_to_datetime(event["end"]) - - file_ext = mime_guess_extension(event["metadata"]["mime"]) - event["file_ext"] = file_ext - - length1 = (end - start).total_seconds() - length2 = event["cue_out"] - event["cue_in"] - - if abs(length2 - length1) > 1: - logger.error("end - start length: %s", length1) - logger.error("cue_out - cue_in length: %s", length2) - logger.error("Two lengths are not equal!!!") - - return file_ext - def is_file_opened(self, path: str) -> bool: result = run(["lsof", "--", path], stdout=PIPE, stderr=DEVNULL, check=False) return bool(result.stdout) @@ -269,10 +241,8 @@ class PypoFetch(Thread): for key in events: item = events[key] - if item["type"] == EventKind.FILE: - if "file_ext" not in item: - item["file_ext"] = mime_guess_extension(item["metadata"]["mime"]) - scheduled_file_set.add(f'{item["id"]}{item["file_ext"]}') + if isinstance(item, FileEvent): + scheduled_file_set.add(item.local_filepath.name) expired_files = cached_file_set - scheduled_file_set diff --git a/playout/libretime_playout/player/file.py b/playout/libretime_playout/player/file.py index 82fe75fca..9608267e5 100644 --- a/playout/libretime_playout/player/file.py +++ b/playout/libretime_playout/player/file.py @@ -1,7 +1,6 @@ import hashlib import logging import os -import stat import time from queue import Empty, Queue from threading import Thread @@ -36,64 +35,50 @@ class PypoFile(Thread): """ Copy file_event from local library directory to local cache directory. """ - file_id = file_event["id"] - dst = file_event["dst"] + if file_event.local_filepath.is_file(): + logger.debug( + "found file %s in cache %s", + file_event.id, + file_event.local_filepath, + ) + file_event.file_ready = True + return - dst_exists = True + logger.info( + "copying file %s to cache %s", + file_event.id, + file_event.local_filepath, + ) try: - dst_size = os.path.getsize(dst) - if dst_size == 0: - dst_exists = False - except Exception: # pylint: disable=broad-exception-caught - dst_exists = False + with file_event.local_filepath.open("wb") as file_fd: + try: + response = self.api_client.download_file(file_event.id, stream=True) + for chunk in response.iter_content(chunk_size=2048): + file_fd.write(chunk) - do_copy = False - if dst_exists: - # TODO: Check if the locally cached variant of the file is sane. - # This used to be a filesize check that didn't end up working. - # Once we have watched folders updated files from them might - # become an issue here... This needs proper cache management. - # https://github.com/libretime/libretime/issues/756#issuecomment-477853018 - # https://github.com/libretime/libretime/pull/845 - logger.debug("found file %s in cache %s, skipping copy...", file_id, dst) - else: - do_copy = True + except requests.exceptions.HTTPError as exception: + raise RuntimeError( + f"could not download file {file_event.id}" + ) from exception - file_event["file_ready"] = not do_copy + # make file world readable and owner writable + file_event.local_filepath.chmod(0o644) - if do_copy: - logger.info("copying file %s to cache %s", file_id, dst) - try: - with open(dst, "wb") as handle: - logger.info(file_event) - try: - response = self.api_client.download_file(file_id, stream=True) - for chunk in response.iter_content(chunk_size=2048): - handle.write(chunk) - - except requests.exceptions.HTTPError as exception: - raise RuntimeError( - f"could not download file {file_event['id']}" - ) from exception - - # make file world readable and owner writable - os.chmod(dst, stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IROTH) - - if file_event["filesize"] == 0: - file_size = self.report_file_size_and_md5_to_api( - dst, file_event["id"] - ) - file_event["filesize"] = file_size - - file_event["file_ready"] = True - except Exception as exception: # pylint: disable=broad-exception-caught - logger.exception( - "could not copy file %s to %s: %s", - file_id, - dst, - exception, + if file_event.filesize == 0: + file_event.filesize = self.report_file_size_and_md5_to_api( + str(file_event.local_filepath), + file_event.id, ) + file_event.file_ready = True + except Exception as exception: # pylint: disable=broad-exception-caught + logger.exception( + "could not copy file %s to %s: %s", + file_event.id, + file_event.local_filepath, + exception, + ) + def report_file_size_and_md5_to_api(self, file_path: str, file_id: int) -> int: try: file_size = os.path.getsize(file_path) diff --git a/playout/libretime_playout/player/liquidsoap.py b/playout/libretime_playout/player/liquidsoap.py index 7a923bf35..aa073bf9f 100644 --- a/playout/libretime_playout/player/liquidsoap.py +++ b/playout/libretime_playout/player/liquidsoap.py @@ -5,14 +5,7 @@ from typing import Dict, List, Optional, Set from ..liquidsoap.client import LiquidsoapClient from ..utils import seconds_between -from .events import ( - ActionEvent, - AnyEvent, - EventKind, - FileEvent, - WebStreamEvent, - event_key_to_datetime, -) +from .events import ActionEvent, AnyEvent, EventKind, FileEvent, WebStreamEvent from .liquidsoap_gateway import TelnetLiquidsoap logger = logging.getLogger(__name__) @@ -33,65 +26,66 @@ class PypoLiquidsoap: list(self.liq_queue_tracker.keys()), ) - def play(self, media_item: AnyEvent) -> None: - if media_item["type"] == EventKind.FILE: - self.handle_file_type(media_item) - elif media_item["type"] == EventKind.ACTION: - self.handle_event_type(media_item) - elif media_item["type"] == EventKind.WEB_STREAM_BUFFER_START: - self.telnet_liquidsoap.start_web_stream_buffer(media_item) - elif media_item["type"] == EventKind.WEB_STREAM_OUTPUT_START: - if ( - media_item["row_id"] - != self.telnet_liquidsoap.current_prebuffering_stream_id - ): - # this is called if the stream wasn't scheduled sufficiently ahead of - # time so that the prebuffering stage could take effect. Let's do the - # prebuffering now. - self.telnet_liquidsoap.start_web_stream_buffer(media_item) - self.telnet_liquidsoap.start_web_stream() - elif media_item["type"] == EventKind.WEB_STREAM_BUFFER_END: - self.telnet_liquidsoap.stop_web_stream_buffer() - elif media_item["type"] == EventKind.WEB_STREAM_OUTPUT_END: - self.telnet_liquidsoap.stop_web_stream_output() + def play(self, event: AnyEvent) -> None: + if isinstance(event, FileEvent): + self.handle_file_type(event) + elif isinstance(event, ActionEvent): + self.handle_event_type(event) + elif isinstance(event, WebStreamEvent): + self.handle_web_stream_type(event) else: - raise UnknownMediaItemType(str(media_item)) + raise UnknownEvent(str(event)) - def handle_file_type(self, media_item: FileEvent) -> None: + def handle_file_type(self, file_event: FileEvent) -> None: """ Wait 200 seconds (2000 iterations) for file to become ready, otherwise give up on it. """ iter_num = 0 - while not media_item.get("file_ready", False) and iter_num < 2000: + while not file_event.file_ready and iter_num < 2000: time.sleep(0.1) iter_num += 1 - if media_item.get("file_ready", False): + if file_event.file_ready: available_queue = self.find_available_queue() try: - self.telnet_liquidsoap.queue_push(available_queue, media_item) - self.liq_queue_tracker[available_queue] = media_item + self.telnet_liquidsoap.queue_push(available_queue, file_event) + self.liq_queue_tracker[available_queue] = file_event except Exception as exception: logger.exception(exception) raise exception else: logger.warning( "File %s did not become ready in less than 5 seconds. Skipping...", - media_item["dst"], + file_event.local_filepath, ) - def handle_event_type(self, media_item: ActionEvent) -> None: - if media_item["event_type"] == "kick_out": + def handle_web_stream_type(self, event: WebStreamEvent) -> None: + if event.type == EventKind.WEB_STREAM_BUFFER_START: + self.telnet_liquidsoap.start_web_stream_buffer(event) + elif event.type == EventKind.WEB_STREAM_OUTPUT_START: + if event.row_id != self.telnet_liquidsoap.current_prebuffering_stream_id: + # this is called if the stream wasn't scheduled sufficiently ahead of + # time so that the prebuffering stage could take effect. Let's do the + # prebuffering now. + self.telnet_liquidsoap.start_web_stream_buffer(event) + self.telnet_liquidsoap.start_web_stream() + elif event.type == EventKind.WEB_STREAM_BUFFER_END: + self.telnet_liquidsoap.stop_web_stream_buffer() + elif event.type == EventKind.WEB_STREAM_OUTPUT_END: + self.telnet_liquidsoap.stop_web_stream_output() + + def handle_event_type(self, event: ActionEvent) -> None: + if event.event_type == "kick_out": self.telnet_liquidsoap.disconnect_source("live_dj") - elif media_item["event_type"] == "switch_off": + elif event.event_type == "switch_off": self.telnet_liquidsoap.switch_source("live_dj", "off") def is_media_item_finished(self, media_item: Optional[AnyEvent]) -> bool: if media_item is None: return True - return datetime.utcnow() > event_key_to_datetime(media_item["end"]) + return datetime.utcnow() > media_item.end def find_available_queue(self) -> int: available_queue = None @@ -131,25 +125,25 @@ class PypoLiquidsoap: try: scheduled_now_files: List[FileEvent] = [ - x for x in scheduled_now if x["type"] == EventKind.FILE + x for x in scheduled_now if x.type == EventKind.FILE # type: ignore ] scheduled_now_webstream: List[WebStreamEvent] = [ - x + x # type: ignore for x in scheduled_now - if x["type"] == EventKind.WEB_STREAM_OUTPUT_START + if x.type == EventKind.WEB_STREAM_OUTPUT_START ] - schedule_ids: Set[int] = {x["row_id"] for x in scheduled_now_files} + schedule_ids: Set[int] = {x.row_id for x in scheduled_now_files} - row_id_map = {} + row_id_map: Dict[int, FileEvent] = {} liq_queue_ids: Set[int] = set() for queue_item in self.liq_queue_tracker.values(): if queue_item is not None and not self.is_media_item_finished( queue_item ): - liq_queue_ids.add(queue_item["row_id"]) - row_id_map[queue_item["row_id"]] = queue_item + liq_queue_ids.add(queue_item.row_id) + row_id_map[queue_item.row_id] = queue_item to_be_removed: Set[int] = set() to_be_added: Set[int] = set() @@ -159,21 +153,18 @@ class PypoLiquidsoap: # have different attributes. Ff replay gain changes, it shouldn't change the # amplification of the currently playing song for item in scheduled_now_files: - if item["row_id"] in row_id_map: - queue_item = row_id_map[item["row_id"]] - assert queue_item is not None + if item.row_id in row_id_map: + queue_item = row_id_map[item.row_id] - correct = ( - queue_item["start"] == item["start"] - and queue_item["end"] == item["end"] - and queue_item["row_id"] == item["row_id"] - ) - - if not correct: + if not ( + queue_item.start == item.start + and queue_item.end == item.end + and queue_item.row_id == item.row_id + ): # need to re-add logger.info("Track %s found to have new attr.", item) - to_be_removed.add(item["row_id"]) - to_be_added.add(item["row_id"]) + to_be_removed.add(item.row_id) + to_be_added.add(item.row_id) to_be_removed.update(liq_queue_ids - schedule_ids) to_be_added.update(schedule_ids - liq_queue_ids) @@ -183,17 +174,14 @@ class PypoLiquidsoap: # remove files from Liquidsoap's queue for queue_id, queue_item in self.liq_queue_tracker.items(): - if ( - queue_item is not None - and queue_item.get("row_id") in to_be_removed - ): + if queue_item is not None and queue_item.row_id in to_be_removed: self.stop(queue_id) if to_be_added: logger.info("Need to add items to Liquidsoap *now*: %s", to_be_added) for item in scheduled_now_files: - if item["row_id"] in to_be_added: + if item.row_id in to_be_added: self.modify_cue_point(item) self.play(item) @@ -204,7 +192,7 @@ class PypoLiquidsoap: logger.debug("scheduled now webstream: %s", scheduled_now_webstream) if scheduled_now_webstream: - if int(current_stream_id) != int(scheduled_now_webstream[0]["row_id"]): + if int(current_stream_id) != int(scheduled_now_webstream[0].row_id): self.play(scheduled_now_webstream[0]) elif current_stream_id != "-1": # something is playing and it shouldn't be. @@ -217,31 +205,25 @@ class PypoLiquidsoap: self.telnet_liquidsoap.queue_remove(queue_id) self.liq_queue_tracker[queue_id] = None - def is_file(self, event: AnyEvent) -> bool: - return event["type"] == EventKind.FILE - def clear_queue_tracker(self) -> None: for queue_id in self.liq_queue_tracker: self.liq_queue_tracker[queue_id] = None - def modify_cue_point(self, link: FileEvent) -> None: - assert self.is_file(link) + def modify_cue_point(self, file_event: FileEvent) -> None: + assert file_event.type == EventKind.FILE - lateness = seconds_between( - event_key_to_datetime(link["start"]), - datetime.utcnow(), - ) + lateness = seconds_between(file_event.start, datetime.utcnow()) if lateness > 0: logger.debug("media item was supposed to start %ss ago", lateness) - cue_in_orig = timedelta(seconds=float(link["cue_in"])) - link["cue_in"] = cue_in_orig.total_seconds() + lateness + cue_in_orig = timedelta(seconds=file_event.cue_in) + file_event.cue_in = cue_in_orig.total_seconds() + lateness def clear_all_queues(self) -> None: self.telnet_liquidsoap.queue_clear_all() -class UnknownMediaItemType(Exception): +class UnknownEvent(Exception): pass diff --git a/playout/libretime_playout/player/liquidsoap_gateway.py b/playout/libretime_playout/player/liquidsoap_gateway.py index 31bfe1f79..addf7d40a 100644 --- a/playout/libretime_playout/player/liquidsoap_gateway.py +++ b/playout/libretime_playout/player/liquidsoap_gateway.py @@ -1,9 +1,9 @@ import logging -from typing import List +from typing import List, Optional from ..liquidsoap.client import LiquidsoapClient from ..timeout import ls_timeout -from .events import FileEvent +from .events import FileEvent, WebStreamEvent logger = logging.getLogger(__name__) @@ -12,36 +12,35 @@ def create_liquidsoap_annotation(file_event: FileEvent) -> str: # We need liq_start_next value in the annotate. That is the value that controls # overlap duration of crossfade. annotations = { - "media_id": file_event["id"], + "media_id": file_event.id, + "schedule_table_id": file_event.row_id, "liq_start_next": "0", - "liq_fade_in": float(file_event["fade_in"]) / 1000, - "liq_fade_out": float(file_event["fade_out"]) / 1000, - "liq_cue_in": float(file_event["cue_in"]), - "liq_cue_out": float(file_event["cue_out"]), - "schedule_table_id": file_event["row_id"], - "replay_gain": f"{file_event['replay_gain']} dB", + "liq_fade_in": file_event.fade_in / 1000, + "liq_fade_out": file_event.fade_out / 1000, + "liq_cue_in": file_event.cue_in, + "liq_cue_out": file_event.cue_out, } + if file_event.replay_gain is not None: + annotations["replay_gain"] = f"{file_event.replay_gain} dB" + # Override the the artist/title that Liquidsoap extracts from a file's metadata with # the metadata we get from Airtime. (You can modify metadata in Airtime's library, # which doesn't get saved back to the file.) - if "metadata" in file_event: - if "artist_name" in file_event["metadata"]: - artist_name = file_event["metadata"]["artist_name"] - if artist_name: - annotations["artist"] = artist_name.replace('"', '\\"') + if file_event.artist_name: + annotations["artist"] = file_event.artist_name.replace('"', '\\"') - if "track_title" in file_event["metadata"]: - track_title = file_event["metadata"]["track_title"] - if track_title: - annotations["title"] = track_title.replace('"', '\\"') + if file_event.track_title: + annotations["title"] = file_event.track_title.replace('"', '\\"') annotations_str = ",".join(f'{key}="{value}"' for key, value in annotations.items()) - return "annotate:" + annotations_str + ":" + file_event["dst"] + return "annotate:" + annotations_str + ":" + str(file_event.local_filepath) class TelnetLiquidsoap: + current_prebuffering_stream_id: Optional[int] = None + def __init__( self, liq_client: LiquidsoapClient, @@ -49,7 +48,6 @@ class TelnetLiquidsoap: ): self.liq_client = liq_client self.queues = queues - self.current_prebuffering_stream_id = None @ls_timeout def queue_clear_all(self): @@ -66,10 +64,10 @@ class TelnetLiquidsoap: logger.exception(exception) @ls_timeout - def queue_push(self, queue_id: int, media_item: FileEvent): + def queue_push(self, queue_id: int, file_event: FileEvent): try: - annotation = create_liquidsoap_annotation(media_item) - self.liq_client.queue_push(queue_id, annotation, media_item["show_name"]) + annotation = create_liquidsoap_annotation(file_event) + self.liq_client.queue_push(queue_id, annotation, file_event.show_name) except (ConnectionError, TimeoutError) as exception: logger.exception(exception) @@ -96,13 +94,10 @@ class TelnetLiquidsoap: logger.exception(exception) @ls_timeout - def start_web_stream_buffer(self, media_item): + def start_web_stream_buffer(self, event: WebStreamEvent): try: - self.liq_client.web_stream_start_buffer( - media_item["row_id"], - media_item["uri"], - ) - self.current_prebuffering_stream_id = media_item["row_id"] + self.liq_client.web_stream_start_buffer(event.row_id, event.uri) + self.current_prebuffering_stream_id = event.row_id except (ConnectionError, TimeoutError) as exception: logger.exception(exception) diff --git a/playout/libretime_playout/player/push.py b/playout/libretime_playout/player/push.py index 99a44afed..8a6d4a4a8 100644 --- a/playout/libretime_playout/player/push.py +++ b/playout/libretime_playout/player/push.py @@ -7,21 +7,13 @@ from threading import Thread from typing import List, Tuple from ..config import PUSH_INTERVAL, Config -from .events import AnyEvent, EventKind, Events, event_key_to_datetime +from .events import AnyEvent, Events, FileEvent from .liquidsoap import PypoLiquidsoap from .queue import PypoLiqQueue logger = logging.getLogger(__name__) -def is_stream(media_item: AnyEvent) -> bool: - return media_item["type"] == "stream_output_start" - - -def is_file(media_item: AnyEvent) -> bool: - return media_item["type"] == "file" - - class PypoPush(Thread): name = "push" daemon = True @@ -81,14 +73,11 @@ class PypoPush(Thread): item = events[key] # Ignore track that already ended - if ( - item["type"] == EventKind.FILE - and event_key_to_datetime(item["end"]) < now - ): + if isinstance(item, FileEvent) and item.end < now: logger.debug("ignoring ended media_item: %s", item) continue - diff_sec = (now - event_key_to_datetime(item["start"])).total_seconds() + diff_sec = (now - item.start).total_seconds() if diff_sec >= 0: logger.debug("adding media_item to present: %s", item) diff --git a/playout/libretime_playout/player/queue.py b/playout/libretime_playout/player/queue.py index 70d314f9b..9405c1281 100644 --- a/playout/libretime_playout/player/queue.py +++ b/playout/libretime_playout/player/queue.py @@ -6,7 +6,7 @@ from threading import Thread from typing import Any, Dict from ..utils import seconds_between -from .events import AnyEvent, event_key_to_datetime +from .events import AnyEvent from .liquidsoap import PypoLiquidsoap logger = logging.getLogger(__name__) @@ -49,7 +49,7 @@ class PypoLiqQueue(Thread): if len(schedule_deque): time_until_next_play = seconds_between( datetime.utcnow(), - event_key_to_datetime(schedule_deque[0]["start"]), + schedule_deque[0].start, ) else: time_until_next_play = None @@ -66,7 +66,7 @@ class PypoLiqQueue(Thread): if len(keys): time_until_next_play = seconds_between( datetime.utcnow(), - media_schedule[keys[0]]["start"], + media_schedule[keys[0]].start, ) else: diff --git a/playout/libretime_playout/player/schedule.py b/playout/libretime_playout/player/schedule.py index 0dafefbf2..42bf0a5cb 100644 --- a/playout/libretime_playout/player/schedule.py +++ b/playout/libretime_playout/player/schedule.py @@ -2,7 +2,6 @@ from datetime import datetime, time, timedelta from operator import itemgetter from typing import Dict -from dateutil.parser import isoparse from libretime_api_client.v2 import ApiClient from libretime_shared.datetime import time_in_milliseconds, time_in_seconds @@ -15,6 +14,8 @@ from .events import ( FileEvent, WebStreamEvent, datetime_to_event_key, + event_isoparse, + parse_any_event, ) @@ -54,15 +55,15 @@ def get_schedule(api_client: ApiClient) -> Events: events: Dict[str, AnyEvent] = {} for item in sorted(schedule, key=itemgetter("starts_at")): - item["starts_at"] = isoparse(item["starts_at"]) - item["ends_at"] = isoparse(item["ends_at"]) + item["starts_at"] = event_isoparse(item["starts_at"]) + item["ends_at"] = event_isoparse(item["ends_at"]) show_instance = api_client.get_show_instance(item["instance"]).json() show = api_client.get_show(show_instance["show"]).json() if show["live_enabled"]: - show_instance["starts_at"] = isoparse(show_instance["starts_at"]) - show_instance["ends_at"] = isoparse(show_instance["ends_at"]) + show_instance["starts_at"] = event_isoparse(show_instance["starts_at"]) + show_instance["ends_at"] = event_isoparse(show_instance["ends_at"]) generate_live_events( events, show_instance, @@ -87,26 +88,28 @@ def generate_live_events( ): transition = timedelta(seconds=input_fade_transition) - switch_off_event_key = datetime_to_event_key(show_instance["ends_at"] - transition) - kick_out_event_key = datetime_to_event_key(show_instance["ends_at"]) + switch_off = show_instance["ends_at"] - transition + kick_out = show_instance["ends_at"] + switch_off_event_key = datetime_to_event_key(switch_off) + kick_out_event_key = datetime_to_event_key(kick_out) # If enabled, fade the input source out - if switch_off_event_key != kick_out_event_key: - switch_off_event: ActionEvent = { - "type": EventKind.ACTION, - "event_type": "switch_off", - "start": switch_off_event_key, - "end": switch_off_event_key, - } + if switch_off != kick_out: + switch_off_event = ActionEvent( + type=EventKind.ACTION, + event_type="switch_off", + start=switch_off, + end=switch_off, + ) insert_event(events, switch_off_event_key, switch_off_event) # Then kick the source out - kick_out_event: ActionEvent = { - "type": EventKind.ACTION, - "event_type": "kick_out", - "start": kick_out_event_key, - "end": kick_out_event_key, - } + kick_out_event = ActionEvent( + type=EventKind.ACTION, + event_type="kick_out", + start=kick_out, + end=kick_out, + ) insert_event(events, kick_out_event_key, kick_out_event) @@ -119,32 +122,28 @@ def generate_file_events( """ Generate events for a scheduled file. """ - schedule_start_event_key = datetime_to_event_key(schedule["starts_at"]) - schedule_end_event_key = datetime_to_event_key(schedule["ends_at"]) - - event: FileEvent = { - "type": EventKind.FILE, - "row_id": schedule["id"], - "start": schedule_start_event_key, - "end": schedule_end_event_key, - "uri": file["url"], - "id": file["id"], + event = FileEvent( + type=EventKind.FILE, + row_id=schedule["id"], + start=schedule["starts_at"], + end=schedule["ends_at"], + uri=file["url"], + id=file["id"], # Show data - "show_name": show["name"], + show_name=show["name"], # Extra data - "fade_in": time_in_milliseconds(time.fromisoformat(schedule["fade_in"])), - "fade_out": time_in_milliseconds(time.fromisoformat(schedule["fade_out"])), - "cue_in": time_in_seconds(time.fromisoformat(schedule["cue_in"])), - "cue_out": time_in_seconds(time.fromisoformat(schedule["cue_out"])), - "metadata": { - "track_title": file["track_title"], - "artist_name": file["artist_name"], - "mime": file["mime"], - }, - "replay_gain": file["replay_gain"], - "filesize": file["size"], - } - insert_event(events, schedule_start_event_key, event) + fade_in=time_in_milliseconds(time.fromisoformat(schedule["fade_in"])), + fade_out=time_in_milliseconds(time.fromisoformat(schedule["fade_out"])), + cue_in=time_in_seconds(time.fromisoformat(schedule["cue_in"])), + cue_out=time_in_seconds(time.fromisoformat(schedule["cue_out"])), + # File data + track_title=file.get("track_title"), + artist_name=file.get("artist_name"), + mime=file["mime"], + replay_gain=file["replay_gain"], + filesize=file["size"], + ) + insert_event(events, event.start_key, event) def generate_webstream_events( @@ -159,46 +158,61 @@ def generate_webstream_events( schedule_start_event_key = datetime_to_event_key(schedule["starts_at"]) schedule_end_event_key = datetime_to_event_key(schedule["ends_at"]) - stream_buffer_start_event: WebStreamEvent = { - "type": EventKind.WEB_STREAM_BUFFER_START, - "row_id": schedule["id"], - "start": datetime_to_event_key(schedule["starts_at"] - timedelta(seconds=5)), - "end": datetime_to_event_key(schedule["starts_at"] - timedelta(seconds=5)), - "uri": webstream["url"], - "id": webstream["id"], - } + stream_buffer_start_event = WebStreamEvent( + type=EventKind.WEB_STREAM_BUFFER_START, + row_id=schedule["id"], + start=schedule["starts_at"] - timedelta(seconds=5), + end=schedule["starts_at"] - timedelta(seconds=5), + uri=webstream["url"], + id=webstream["id"], + # Show data + show_name=show["name"], + ) insert_event(events, schedule_start_event_key, stream_buffer_start_event) - stream_output_start_event: WebStreamEvent = { - "type": EventKind.WEB_STREAM_OUTPUT_START, - "row_id": schedule["id"], - "start": schedule_start_event_key, - "end": schedule_end_event_key, - "uri": webstream["url"], - "id": webstream["id"], + stream_output_start_event = WebStreamEvent( + type=EventKind.WEB_STREAM_OUTPUT_START, + row_id=schedule["id"], + start=schedule["starts_at"], + end=schedule["ends_at"], + uri=webstream["url"], + id=webstream["id"], # Show data - "show_name": show["name"], - } + show_name=show["name"], + ) insert_event(events, schedule_start_event_key, stream_output_start_event) # NOTE: stream_*_end were previously triggered 1 second before # the schedule end. - stream_buffer_end_event: WebStreamEvent = { - "type": EventKind.WEB_STREAM_BUFFER_END, - "row_id": schedule["id"], - "start": schedule_end_event_key, - "end": schedule_end_event_key, - "uri": webstream["url"], - "id": webstream["id"], - } + stream_buffer_end_event = WebStreamEvent( + type=EventKind.WEB_STREAM_BUFFER_END, + row_id=schedule["id"], + start=schedule["ends_at"], + end=schedule["ends_at"], + uri=webstream["url"], + id=webstream["id"], + # Show data + show_name=show["name"], + ) insert_event(events, schedule_end_event_key, stream_buffer_end_event) - stream_output_end_event: WebStreamEvent = { - "type": EventKind.WEB_STREAM_OUTPUT_END, - "row_id": schedule["id"], - "start": schedule_end_event_key, - "end": schedule_end_event_key, - "uri": webstream["url"], - "id": webstream["id"], - } + stream_output_end_event = WebStreamEvent( + type=EventKind.WEB_STREAM_OUTPUT_END, + row_id=schedule["id"], + start=schedule["ends_at"], + end=schedule["ends_at"], + uri=webstream["url"], + id=webstream["id"], + # Show data + show_name=show["name"], + ) insert_event(events, schedule_end_event_key, stream_output_end_event) + + +def receive_schedule(schedule: Dict[str, dict]) -> Events: + events: Dict[str, AnyEvent] = {} + + for event_key, event in schedule.items(): + events[event_key] = parse_any_event(event) + + return events diff --git a/playout/tests/player/liquidsoap_gateway_test.py b/playout/tests/player/liquidsoap_gateway_test.py index 58ad41a18..ea5cf5843 100644 --- a/playout/tests/player/liquidsoap_gateway_test.py +++ b/playout/tests/player/liquidsoap_gateway_test.py @@ -1,33 +1,45 @@ +from datetime import datetime +from pathlib import Path +from unittest import mock + +from dateutil.tz import tzutc + from libretime_playout.player.events import EventKind, FileEvent from libretime_playout.player.liquidsoap_gateway import create_liquidsoap_annotation +@mock.patch("libretime_playout.player.events.CACHE_DIR", Path("/fake")) def test_create_liquidsoap_annotation(): - file_event: FileEvent = { - "type": EventKind.FILE, - "row_id": 1, - "start": "2022-09-05-11-00-00", - "end": "2022-09-05-11-05-02", - "uri": None, - "id": 2, - "show_name": "Show 1", - "fade_in": 500.0, - "fade_out": 500.0, - "cue_in": 13.7008, - "cue_out": 315.845, - "metadata": { - "track_title": 'My Friend the "Forest"', - "artist_name": "Nils Frahm", - "mime": "audio/flac", - }, - "replay_gain": "11.46", - "filesize": 10000, - "dst": "fake/path.flac", - } + file_event = FileEvent( + type=EventKind.FILE, + row_id=1, + start=datetime(2022, 9, 5, 11, tzinfo=tzutc()), + end=datetime(2022, 9, 5, 11, 5, 2, tzinfo=tzutc()), + uri=None, + id=2, + show_name="Show 1", + fade_in=500.0, + fade_out=500.0, + cue_in=13.7008, + cue_out=315.845, + track_title='My Friend the "Forest"', + artist_name="Nils Frahm", + mime="audio/flac", + replay_gain=11.46, + filesize=10000, + ) assert create_liquidsoap_annotation(file_event) == ( - """annotate:media_id="2",liq_start_next="0",liq_fade_in="0.5",""" - """liq_fade_out="0.5",liq_cue_in="13.7008",liq_cue_out="315.845",""" - """schedule_table_id="1",replay_gain="11.46 dB",artist="Nils Frahm",""" - """title="My Friend the \\"Forest\\"":fake/path.flac""" + "annotate:" + 'media_id="2",' + 'schedule_table_id="1",' + 'liq_start_next="0",' + 'liq_fade_in="0.5",' + 'liq_fade_out="0.5",' + 'liq_cue_in="13.7008",' + 'liq_cue_out="315.845",' + 'replay_gain="11.46 dB",' + 'artist="Nils Frahm",' + 'title="My Friend the \\"Forest\\""' + ":/fake/2.flac" ) diff --git a/playout/tests/player/schedule_test.py b/playout/tests/player/schedule_test.py index 10217b5cf..ba1e1d4c5 100644 --- a/playout/tests/player/schedule_test.py +++ b/playout/tests/player/schedule_test.py @@ -1,11 +1,16 @@ import random -from datetime import timedelta +from datetime import datetime import pytest -from dateutil.parser import isoparse from libretime_api_client.v2 import ApiClient -from libretime_playout.player.events import EventKind +from libretime_playout.player.events import ( + ActionEvent, + EventKind, + FileEvent, + WebStreamEvent, + event_isoparse, +) from libretime_playout.player.schedule import ( generate_file_events, generate_live_events, @@ -268,110 +273,112 @@ SCHEDULE = [ def test_generate_live_events(): show_instance_3 = SHOW_INSTANCE_3.copy() - show_instance_3["starts_at"] = isoparse(show_instance_3["starts_at"]) - show_instance_3["ends_at"] = isoparse(show_instance_3["ends_at"]) + show_instance_3["starts_at"] = event_isoparse(show_instance_3["starts_at"]) + show_instance_3["ends_at"] = event_isoparse(show_instance_3["ends_at"]) result = {} generate_live_events(result, show_instance_3, 0.0) assert result == { - "2022-09-05-13-00-00": { - "type": EventKind.ACTION, - "event_type": "kick_out", - "start": "2022-09-05-13-00-00", - "end": "2022-09-05-13-00-00", - } + "2022-09-05-13-00-00": ActionEvent( + start=datetime(2022, 9, 5, 13, 0), + end=datetime(2022, 9, 5, 13, 0), + type=EventKind.ACTION, + event_type="kick_out", + ), } result = {} generate_live_events(result, show_instance_3, 2.0) assert result == { - "2022-09-05-12-59-58": { - "type": EventKind.ACTION, - "event_type": "switch_off", - "start": "2022-09-05-12-59-58", - "end": "2022-09-05-12-59-58", - }, - "2022-09-05-13-00-00": { - "type": EventKind.ACTION, - "event_type": "kick_out", - "start": "2022-09-05-13-00-00", - "end": "2022-09-05-13-00-00", - }, + "2022-09-05-12-59-58": ActionEvent( + start=datetime(2022, 9, 5, 12, 59, 58), + end=datetime(2022, 9, 5, 12, 59, 58), + type=EventKind.ACTION, + event_type="switch_off", + ), + "2022-09-05-13-00-00": ActionEvent( + start=datetime(2022, 9, 5, 13, 0), + end=datetime(2022, 9, 5, 13, 0), + type=EventKind.ACTION, + event_type="kick_out", + ), } def test_generate_file_events(): schedule_1 = SCHEDULE_1.copy() - schedule_1["starts_at"] = isoparse(schedule_1["starts_at"]) - schedule_1["ends_at"] = isoparse(schedule_1["ends_at"]) + schedule_1["starts_at"] = event_isoparse(schedule_1["starts_at"]) + schedule_1["ends_at"] = event_isoparse(schedule_1["ends_at"]) result = {} generate_file_events(result, schedule_1, FILE_2, SHOW_1) assert result == { - "2022-09-05-11-00-00": { - "type": EventKind.FILE, - "row_id": 1, - "start": "2022-09-05-11-00-00", - "end": "2022-09-05-11-05-02", - "uri": None, - "id": 2, - "show_name": "Show 1", - "fade_in": 500.0, - "fade_out": 500.0, - "cue_in": 13.7008, - "cue_out": 315.845, - "metadata": { - "track_title": "My Friend the Forest", - "artist_name": "Nils Frahm", - "mime": "audio/flac", - }, - "replay_gain": "11.46", - "filesize": 10000, - } + "2022-09-05-11-00-00": FileEvent( + start=datetime(2022, 9, 5, 11, 0), + end=datetime(2022, 9, 5, 11, 5, 2), + type=EventKind.FILE, + row_id=1, + uri=None, + id=2, + show_name="Show 1", + fade_in=500.0, + fade_out=500.0, + cue_in=13.7008, + cue_out=315.845, + track_title="My Friend the Forest", + artist_name="Nils Frahm", + mime="audio/flac", + replay_gain=11.46, + filesize=10000, + file_ready=False, + ) } def test_generate_webstream_events(): schedule_5 = SCHEDULE_5.copy() - schedule_5["starts_at"] = isoparse(schedule_5["starts_at"]) - schedule_5["ends_at"] = isoparse(schedule_5["ends_at"]) + schedule_5["starts_at"] = event_isoparse(schedule_5["starts_at"]) + schedule_5["ends_at"] = event_isoparse(schedule_5["ends_at"]) result = {} generate_webstream_events(result, schedule_5, WEBSTREAM_1, SHOW_3) assert result == { - "2022-09-05-12-10-00": { - "type": EventKind.WEB_STREAM_BUFFER_START, - "row_id": 5, - "start": "2022-09-05-12-09-55", - "end": "2022-09-05-12-09-55", - "uri": "http://stream.radio.org/main.ogg", - "id": 1, - }, - "2022-09-05-12-10-00_0": { - "type": EventKind.WEB_STREAM_OUTPUT_START, - "row_id": 5, - "start": "2022-09-05-12-10-00", - "end": "2022-09-05-12-40-00", - "uri": "http://stream.radio.org/main.ogg", - "id": 1, - "show_name": "Show 3", - }, - "2022-09-05-12-40-00": { - "type": EventKind.WEB_STREAM_BUFFER_END, - "row_id": 5, - "start": "2022-09-05-12-40-00", - "end": "2022-09-05-12-40-00", - "uri": "http://stream.radio.org/main.ogg", - "id": 1, - }, - "2022-09-05-12-40-00_0": { - "type": EventKind.WEB_STREAM_OUTPUT_END, - "row_id": 5, - "start": "2022-09-05-12-40-00", - "end": "2022-09-05-12-40-00", - "uri": "http://stream.radio.org/main.ogg", - "id": 1, - }, + "2022-09-05-12-10-00": WebStreamEvent( + start=datetime(2022, 9, 5, 12, 9, 55), + end=datetime(2022, 9, 5, 12, 9, 55), + type=EventKind.WEB_STREAM_BUFFER_START, + row_id=5, + uri="http://stream.radio.org/main.ogg", + id=1, + show_name="Show 3", + ), + "2022-09-05-12-10-00_0": WebStreamEvent( + start=datetime(2022, 9, 5, 12, 10), + end=datetime(2022, 9, 5, 12, 40), + type=EventKind.WEB_STREAM_OUTPUT_START, + row_id=5, + uri="http://stream.radio.org/main.ogg", + id=1, + show_name="Show 3", + ), + "2022-09-05-12-40-00": WebStreamEvent( + start=datetime(2022, 9, 5, 12, 40), + end=datetime(2022, 9, 5, 12, 40), + type=EventKind.WEB_STREAM_BUFFER_END, + row_id=5, + uri="http://stream.radio.org/main.ogg", + id=1, + show_name="Show 3", + ), + "2022-09-05-12-40-00_0": WebStreamEvent( + start=datetime(2022, 9, 5, 12, 40), + end=datetime(2022, 9, 5, 12, 40), + type=EventKind.WEB_STREAM_OUTPUT_END, + row_id=5, + uri="http://stream.radio.org/main.ogg", + id=1, + show_name="Show 3", + ), } @@ -412,229 +419,223 @@ def test_get_schedule(schedule, requests_mock, api_client: ApiClient): requests_mock.get(f"{base_url}/api/v2/webstreams/1", json=WEBSTREAM_1) assert get_schedule(api_client) == { - "2022-09-05-11-00-00": { - "type": EventKind.FILE, - "row_id": 1, - "start": "2022-09-05-11-00-00", - "end": "2022-09-05-11-05-02", - "uri": None, - "id": 2, - "show_name": "Show 1", - "fade_in": 500.0, - "fade_out": 500.0, - "cue_in": 13.7008, - "cue_out": 315.845, - "metadata": { - "track_title": "My Friend the Forest", - "artist_name": "Nils Frahm", - "mime": "audio/flac", - }, - "replay_gain": "11.46", - "filesize": 10000, - }, - "2022-09-05-11-05-02": { - "type": EventKind.FILE, - "row_id": 2, - "start": "2022-09-05-11-05-02", - "end": "2022-09-05-11-10-00", - "uri": None, - "id": 4, - "show_name": "Show 1", - "fade_in": 500.0, - "fade_out": 500.0, - "cue_in": 0.0, - "cue_out": 297.8558, - "metadata": { - "track_title": "#2", - "artist_name": "Nils Frahm", - "mime": "audio/flac", - }, - "replay_gain": "-1.65", - "filesize": 10000, - }, - "2022-09-05-11-10-00": { - "type": EventKind.FILE, - "row_id": 3, - "start": "2022-09-05-11-10-00", - "end": "2022-09-05-12-08-59", - "uri": None, - "id": 5, - "show_name": "Show 2", - "fade_in": 500.0, - "fade_out": 500.0, - "cue_in": 0.0, - "cue_out": 3539.13, - "metadata": { - "track_title": "Democracy Now! 2022-09-05 Monday", - "artist_name": "Democracy Now! Audio", - "mime": "audio/mp3", - }, - "replay_gain": "-1.39", - "filesize": 10000, - }, - "2022-09-05-12-08-59": { - "type": EventKind.FILE, - "row_id": 4, - "start": "2022-09-05-12-08-59", - "end": "2022-09-05-12-10-00", - "uri": None, - "id": 4, - "show_name": "Show 2", - "fade_in": 500.0, - "fade_out": 500.0, - "cue_in": 0.0, - "cue_out": 61.0, - "metadata": { - "track_title": "#2", - "artist_name": "Nils Frahm", - "mime": "audio/flac", - }, - "replay_gain": "-1.65", - "filesize": 10000, - }, - "2022-09-05-12-10-00": { - "type": EventKind.WEB_STREAM_BUFFER_START, - "row_id": 5, - "start": "2022-09-05-12-09-55", - "end": "2022-09-05-12-09-55", - "uri": "http://stream.radio.org/main.ogg", - "id": 1, - }, - "2022-09-05-12-10-00_0": { - "type": EventKind.WEB_STREAM_OUTPUT_START, - "row_id": 5, - "start": "2022-09-05-12-10-00", - "end": "2022-09-05-12-40-00", - "uri": "http://stream.radio.org/main.ogg", - "id": 1, - "show_name": "Show 3", - }, - "2022-09-05-12-40-00": { - "type": EventKind.WEB_STREAM_BUFFER_END, - "row_id": 5, - "start": "2022-09-05-12-40-00", - "end": "2022-09-05-12-40-00", - "uri": "http://stream.radio.org/main.ogg", - "id": 1, - }, - "2022-09-05-12-40-00_0": { - "type": EventKind.WEB_STREAM_OUTPUT_END, - "row_id": 5, - "start": "2022-09-05-12-40-00", - "end": "2022-09-05-12-40-00", - "uri": "http://stream.radio.org/main.ogg", - "id": 1, - }, - "2022-09-05-12-40-00_1": { - "type": EventKind.FILE, - "row_id": 6, - "start": "2022-09-05-12-40-00", - "end": "2022-09-05-12-53-23", - "uri": None, - "id": 3, - "show_name": "Show 3", - "fade_in": 500.0, - "fade_out": 500.0, - "cue_in": 55.1211, - "cue_out": 858.4, - "metadata": { - "track_title": "All Melody", - "artist_name": "Nils Frahm", - "mime": "audio/flac", - }, - "replay_gain": "-2.13", - "filesize": 10000, - }, - "2022-09-05-12-53-23": { - "type": EventKind.FILE, - "row_id": 7, - "start": "2022-09-05-12-53-23", - "end": "2022-09-05-12-58-25", - "uri": None, - "id": 2, - "show_name": "Show 3", - "fade_in": 500.0, - "fade_out": 500.0, - "cue_in": 13.7008, - "cue_out": 315.845, - "metadata": { - "track_title": "My Friend the Forest", - "artist_name": "Nils Frahm", - "mime": "audio/flac", - }, - "replay_gain": "11.46", - "filesize": 10000, - }, - "2022-09-05-12-58-25": { - "type": EventKind.FILE, - "row_id": 8, - "start": "2022-09-05-12-58-25", - "end": "2022-09-05-13-00-00", - "uri": None, - "id": 1, - "show_name": "Show 3", - "fade_in": 500.0, - "fade_out": 500.0, - "cue_in": 8.25245, - "cue_out": 95.0, - "metadata": { - "track_title": "The Dane", - "artist_name": "Nils Frahm", - "mime": "audio/flac", - }, - "replay_gain": "4.52", - "filesize": 10000, - }, - "2022-09-05-12-59-58": { - "type": EventKind.ACTION, - "event_type": "switch_off", - "start": "2022-09-05-12-59-58", - "end": "2022-09-05-12-59-58", - }, - "2022-09-05-13-00-00": { - "type": EventKind.ACTION, - "event_type": "kick_out", - "start": "2022-09-05-13-00-00", - "end": "2022-09-05-13-00-00", - }, - "2022-09-05-13-00-00_0": { - "type": EventKind.FILE, - "row_id": 9, - "start": "2022-09-05-13-00-00", - "end": "2022-09-05-13-05-02", - "uri": None, - "id": 2, - "show_name": "Show 4", - "fade_in": 500.0, - "fade_out": 500.0, - "cue_in": 13.7008, - "cue_out": 315.845, - "metadata": { - "track_title": "My Friend the Forest", - "artist_name": "Nils Frahm", - "mime": "audio/flac", - }, - "replay_gain": "11.46", - "filesize": 10000, - }, - "2022-09-05-13-05-02": { - "type": EventKind.FILE, - "row_id": 10, - "start": "2022-09-05-13-05-02", - "end": "2022-09-05-13-10-00", - "uri": None, - "id": 4, - "show_name": "Show 4", - "fade_in": 500.0, - "fade_out": 500.0, - "cue_in": 0.0, - "cue_out": 297.8558, - "metadata": { - "track_title": "#2", - "artist_name": "Nils Frahm", - "mime": "audio/flac", - }, - "replay_gain": "-1.65", - "filesize": 10000, - }, + "2022-09-05-11-00-00": FileEvent( + start=datetime(2022, 9, 5, 11, 0), + end=datetime(2022, 9, 5, 11, 5, 2), + type=EventKind.FILE, + row_id=1, + uri=None, + id=2, + show_name="Show 1", + fade_in=500.0, + fade_out=500.0, + cue_in=13.7008, + cue_out=315.845, + track_title="My Friend the Forest", + artist_name="Nils Frahm", + mime="audio/flac", + replay_gain=11.46, + filesize=10000, + file_ready=False, + ), + "2022-09-05-11-05-02": FileEvent( + start=datetime(2022, 9, 5, 11, 5, 2), + end=datetime(2022, 9, 5, 11, 10), + type=EventKind.FILE, + row_id=2, + uri=None, + id=4, + show_name="Show 1", + fade_in=500.0, + fade_out=500.0, + cue_in=0.0, + cue_out=297.8558, + track_title="#2", + artist_name="Nils Frahm", + mime="audio/flac", + replay_gain=-1.65, + filesize=10000, + file_ready=False, + ), + "2022-09-05-11-10-00": FileEvent( + start=datetime(2022, 9, 5, 11, 10), + end=datetime(2022, 9, 5, 12, 8, 59), + type=EventKind.FILE, + row_id=3, + uri=None, + id=5, + show_name="Show 2", + fade_in=500.0, + fade_out=500.0, + cue_in=0.0, + cue_out=3539.13, + track_title="Democracy Now! 2022-09-05 Monday", + artist_name="Democracy Now! Audio", + mime="audio/mp3", + replay_gain=-1.39, + filesize=10000, + file_ready=False, + ), + "2022-09-05-12-08-59": FileEvent( + start=datetime(2022, 9, 5, 12, 8, 59), + end=datetime(2022, 9, 5, 12, 10), + type=EventKind.FILE, + row_id=4, + uri=None, + id=4, + show_name="Show 2", + fade_in=500.0, + fade_out=500.0, + cue_in=0.0, + cue_out=61.0, + track_title="#2", + artist_name="Nils Frahm", + mime="audio/flac", + replay_gain=-1.65, + filesize=10000, + file_ready=False, + ), + "2022-09-05-12-10-00": WebStreamEvent( + start=datetime(2022, 9, 5, 12, 9, 55), + end=datetime(2022, 9, 5, 12, 9, 55), + type=EventKind.WEB_STREAM_BUFFER_START, + row_id=5, + uri="http://stream.radio.org/main.ogg", + id=1, + show_name="Show 3", + ), + "2022-09-05-12-10-00_0": WebStreamEvent( + start=datetime(2022, 9, 5, 12, 10), + end=datetime(2022, 9, 5, 12, 40), + type=EventKind.WEB_STREAM_OUTPUT_START, + row_id=5, + uri="http://stream.radio.org/main.ogg", + id=1, + show_name="Show 3", + ), + "2022-09-05-12-40-00": WebStreamEvent( + start=datetime(2022, 9, 5, 12, 40), + end=datetime(2022, 9, 5, 12, 40), + type=EventKind.WEB_STREAM_BUFFER_END, + row_id=5, + uri="http://stream.radio.org/main.ogg", + id=1, + show_name="Show 3", + ), + "2022-09-05-12-40-00_0": WebStreamEvent( + start=datetime(2022, 9, 5, 12, 40), + end=datetime(2022, 9, 5, 12, 40), + type=EventKind.WEB_STREAM_OUTPUT_END, + row_id=5, + uri="http://stream.radio.org/main.ogg", + id=1, + show_name="Show 3", + ), + "2022-09-05-12-40-00_1": FileEvent( + start=datetime(2022, 9, 5, 12, 40), + end=datetime(2022, 9, 5, 12, 53, 23), + type=EventKind.FILE, + row_id=6, + uri=None, + id=3, + show_name="Show 3", + fade_in=500.0, + fade_out=500.0, + cue_in=55.1211, + cue_out=858.4, + track_title="All Melody", + artist_name="Nils Frahm", + mime="audio/flac", + replay_gain=-2.13, + filesize=10000, + file_ready=False, + ), + "2022-09-05-12-53-23": FileEvent( + start=datetime(2022, 9, 5, 12, 53, 23), + end=datetime(2022, 9, 5, 12, 58, 25), + type=EventKind.FILE, + row_id=7, + uri=None, + id=2, + show_name="Show 3", + fade_in=500.0, + fade_out=500.0, + cue_in=13.7008, + cue_out=315.845, + track_title="My Friend the Forest", + artist_name="Nils Frahm", + mime="audio/flac", + replay_gain=11.46, + filesize=10000, + file_ready=False, + ), + "2022-09-05-12-58-25": FileEvent( + start=datetime(2022, 9, 5, 12, 58, 25), + end=datetime(2022, 9, 5, 13, 0), + type=EventKind.FILE, + row_id=8, + uri=None, + id=1, + show_name="Show 3", + fade_in=500.0, + fade_out=500.0, + cue_in=8.25245, + cue_out=95.0, + track_title="The Dane", + artist_name="Nils Frahm", + mime="audio/flac", + replay_gain=4.52, + filesize=10000, + file_ready=False, + ), + "2022-09-05-12-59-58": ActionEvent( + start=datetime(2022, 9, 5, 12, 59, 58), + end=datetime(2022, 9, 5, 12, 59, 58), + type=EventKind.ACTION, + event_type="switch_off", + ), + "2022-09-05-13-00-00": ActionEvent( + start=datetime(2022, 9, 5, 13, 0), + end=datetime(2022, 9, 5, 13, 0), + type=EventKind.ACTION, + event_type="kick_out", + ), + "2022-09-05-13-00-00_0": FileEvent( + start=datetime(2022, 9, 5, 13, 0), + end=datetime(2022, 9, 5, 13, 5, 2), + type=EventKind.FILE, + row_id=9, + uri=None, + id=2, + show_name="Show 4", + fade_in=500.0, + fade_out=500.0, + cue_in=13.7008, + cue_out=315.845, + track_title="My Friend the Forest", + artist_name="Nils Frahm", + mime="audio/flac", + replay_gain=11.46, + filesize=10000, + file_ready=False, + ), + "2022-09-05-13-05-02": FileEvent( + start=datetime(2022, 9, 5, 13, 5, 2), + end=datetime(2022, 9, 5, 13, 10), + type=EventKind.FILE, + row_id=10, + uri=None, + id=4, + show_name="Show 4", + fade_in=500.0, + fade_out=500.0, + cue_in=0.0, + cue_out=297.8558, + track_title="#2", + artist_name="Nils Frahm", + mime="audio/flac", + replay_gain=-1.65, + filesize=10000, + file_ready=False, + ), }