BAPSicle/player.py

1030 lines
37 KiB
Python
Raw Normal View History

2020-11-01 02:35:14 +00:00
"""
BAPSicle Server
Next-gen audio playout server for University Radio York playout,
based on WebStudio interface.
Audio Player
Authors:
Matthew Stratford
Michael Grace
Date:
October, November 2020
"""
# This is the player. Reliability is critical here, so we're catching
# literally every exception possible and handling it.
# It is key that whenever the parent server tells us to do something
# that we respond with something, FAIL or OKAY. The server doesn't like to be kept waiting.
2021-04-08 20:15:15 +00:00
# Stop the Pygame Hello message.
import os
os.environ["PYGAME_HIDE_SUPPORT_PROMPT"] = "hide"
2020-10-29 21:23:37 +00:00
from queue import Empty
import multiprocessing
import setproctitle
import copy
import json
import time
2020-12-19 14:57:37 +00:00
from typing import Any, Callable, Dict, List, Optional
from pygame import mixer
2020-10-25 01:23:24 +00:00
from mutagen.mp3 import MP3
from syncer import sync
from threading import Timer
from helpers.myradio_api import MyRadioAPI
from helpers.state_manager import StateManager
from helpers.logging_manager import LoggingManager
2021-04-12 21:59:51 +00:00
from baps_types.plan import PlanItem
from baps_types.marker import Marker
2020-10-23 20:10:32 +00:00
# TODO ENUM
2021-04-05 23:32:58 +00:00
VALID_MESSAGE_SOURCES = ["WEBSOCKET", "UI", "CONTROLLER", "TEST", "ALL"]
TRACKLISTING_DELAYED_S = 20
2021-04-08 19:53:51 +00:00
2021-04-08 21:32:16 +00:00
2021-04-08 19:53:51 +00:00
class Player:
out_q: multiprocessing.Queue
last_msg: str
last_msg_source: str
last_time_update = None
state: StateManager
logger: LoggingManager
api: MyRadioAPI
running: bool = False
stopped_manually: bool = False
2020-10-24 20:31:52 +00:00
tracklist_start_timer: Optional[Timer] = None
tracklist_end_timer: Optional[Timer] = None
2020-10-24 20:31:52 +00:00
__default_state = {
2020-10-30 00:32:34 +00:00
"initialised": False,
2020-11-01 02:35:14 +00:00
"loaded_item": None,
2020-10-24 20:31:52 +00:00
"channel": -1,
"playing": False,
"paused": False,
2020-10-29 22:25:17 +00:00
"loaded": False,
2020-10-24 20:31:52 +00:00
"pos": 0,
"pos_offset": 0,
"pos_true": 0,
2020-10-24 20:31:52 +00:00
"remaining": 0,
"length": 0,
"auto_advance": True,
"repeat": "none", # none, one or all
"play_on_load": False,
2020-11-01 02:35:14 +00:00
"output": None,
2021-04-08 19:53:51 +00:00
"show_plan": [],
"tracklist_mode": "off",
"tracklist_id": None,
2020-10-24 20:31:52 +00:00
}
2021-04-08 19:53:51 +00:00
__rate_limited_params = ["pos", "pos_offset", "pos_true", "remaining"]
2020-10-29 22:25:17 +00:00
@property
2020-10-24 20:31:52 +00:00
def isInit(self):
try:
2020-10-25 01:23:24 +00:00
mixer.music.get_busy()
2021-04-08 21:32:16 +00:00
except Exception:
2020-10-24 20:31:52 +00:00
return False
2020-10-29 22:25:17 +00:00
return True
2020-10-24 20:31:52 +00:00
2020-10-29 21:23:37 +00:00
@property
2021-04-12 21:59:51 +00:00
def isPlaying(self) -> bool:
2020-10-29 22:25:17 +00:00
if self.isInit:
return (not self.isPaused) and bool(mixer.music.get_busy())
2020-10-29 22:25:17 +00:00
return False
2020-10-24 20:31:52 +00:00
@property
2020-12-19 14:57:37 +00:00
def isPaused(self) -> bool:
return self.state.get()["paused"]
2020-10-29 21:23:37 +00:00
@property
def isLoaded(self):
2021-04-12 21:59:51 +00:00
return self._isLoaded()
def _isLoaded(self, short_test: bool = False):
if not self.state.get()["loaded_item"]:
2020-10-29 22:25:17 +00:00
return False
if self.isPlaying:
return True
2021-04-12 21:59:51 +00:00
# If we don't want to do any testing if it's really loaded, fine.
if short_test:
return True
2020-10-30 00:32:34 +00:00
# Because Pygame/SDL is annoying
# We're not playing now, so we can quickly test run
# If that works, we're loaded.
2020-10-29 22:25:17 +00:00
try:
2020-10-30 00:32:34 +00:00
mixer.music.set_volume(0)
mixer.music.play(0)
2021-04-08 21:32:16 +00:00
except Exception:
2020-10-30 00:32:34 +00:00
try:
mixer.music.set_volume(1)
2021-04-08 21:32:16 +00:00
except Exception:
2021-04-08 19:53:51 +00:00
self.logger.log.exception(
"Failed to reset volume after attempting loaded test."
)
2020-10-30 00:32:34 +00:00
pass
2020-10-29 22:25:17 +00:00
return False
finally:
mixer.music.stop()
2020-10-30 00:32:34 +00:00
mixer.music.set_volume(1)
return True
2020-10-29 22:25:17 +00:00
2021-04-12 21:59:51 +00:00
@property
def isCued(self):
# Don't mess with playback, we only care about if it's supposed to be loaded.
if not self._isLoaded(short_test=True):
return False
return (self.state.get()["pos_true"] == self.state.get()["loaded_item"].cue and not self.isPlaying)
2021-04-12 21:59:51 +00:00
2020-10-30 00:32:34 +00:00
@property
def status(self):
2020-11-01 02:35:14 +00:00
state = copy.copy(self.state.state)
# Not the biggest fan of this, but maybe I'll get a better solution for this later
2021-04-08 19:53:51 +00:00
state["loaded_item"] = (
state["loaded_item"].__dict__ if state["loaded_item"] else None
)
state["show_plan"] = [repr.__dict__ for repr in state["show_plan"]]
2020-11-01 02:35:14 +00:00
res = json.dumps(state)
2020-10-30 00:32:34 +00:00
return res
2020-10-29 21:23:37 +00:00
2020-11-15 19:34:13 +00:00
# Audio Playout Related Methods
2020-12-19 14:57:37 +00:00
def play(self, pos: float = 0):
2021-03-15 20:19:45 +00:00
if not self.isLoaded:
return
2021-05-01 22:08:38 +00:00
self.logger.log.info("Playing from pos: " + str(pos))
try:
mixer.music.play(0, pos)
self.state.update("pos_offset", pos)
2021-04-08 21:32:16 +00:00
except Exception:
2020-11-03 22:48:11 +00:00
self.logger.log.exception("Failed to play at pos: " + str(pos))
return False
self.state.update("paused", False)
self._potentially_tracklist()
self.stopped_manually = False
return True
2020-10-24 20:31:52 +00:00
def pause(self):
try:
2021-04-12 21:59:51 +00:00
mixer.music.stop()
2021-04-08 21:32:16 +00:00
except Exception:
2020-11-03 22:48:11 +00:00
self.logger.log.exception("Failed to pause.")
return False
self.stopped_manually = True
self.state.update("paused", True)
return True
2020-10-24 20:31:52 +00:00
def unpause(self):
2020-10-29 22:25:17 +00:00
if not self.isPlaying:
2021-05-02 01:18:00 +00:00
state = self.state.get()
position: float = state["pos_true"]
2020-10-29 22:25:17 +00:00
try:
2020-11-03 22:48:11 +00:00
self.play(position)
2021-04-08 21:32:16 +00:00
except Exception:
2021-04-08 19:53:51 +00:00
self.logger.log.exception(
"Failed to unpause from pos: " + str(position)
)
2020-10-29 22:25:17 +00:00
return False
self.state.update("paused", False)
2021-05-02 01:18:00 +00:00
# Increment Played count
loaded_item = state["loaded_item"]
if loaded_item:
loaded_item.play_count_increment()
self.state.update("loaded_item", loaded_item)
2020-10-29 22:25:17 +00:00
return True
return False
2020-10-24 20:31:52 +00:00
2021-04-12 21:59:51 +00:00
def stop(self, user_initiated: bool = False):
try:
mixer.music.stop()
2021-04-08 21:32:16 +00:00
except Exception:
2020-11-03 22:48:11 +00:00
self.logger.log.exception("Failed to stop playing.")
return False
self.state.update("paused", False)
if user_initiated:
self._potentially_end_tracklist()
self.stopped_manually = True
if not self.state.get()["loaded_item"]:
2021-04-12 21:59:51 +00:00
self.logger.log.warning("Tried to stop without a loaded item.")
return True
# This lets users toggle (using the stop button) between cue point and 0.
if user_initiated and not self.isCued:
# if there's a cue point ant we're not at it, go there.
self.seek(self.state.get()["loaded_item"].cue)
2021-04-12 21:59:51 +00:00
else:
# Otherwise, let's go to 0.
2021-04-24 18:32:43 +00:00
self.seek(0)
2021-04-12 21:59:51 +00:00
return True
2020-10-24 20:31:52 +00:00
2020-12-19 14:57:37 +00:00
def seek(self, pos: float) -> bool:
2021-05-01 22:08:38 +00:00
self.logger.log.info("Seeking to pos:" + str(pos))
2020-10-29 22:25:17 +00:00
if self.isPlaying:
try:
self.play(pos)
2021-04-08 21:32:16 +00:00
except Exception:
2020-11-03 22:48:11 +00:00
self.logger.log.exception("Failed to seek to pos: " + str(pos))
2020-10-29 22:25:17 +00:00
return False
return True
else:
2021-05-01 22:08:38 +00:00
self.logger.log.debug("Not playing during seek, setting pos state for next play.")
2021-04-12 21:59:51 +00:00
self.stopped_manually = True # Don't trigger _ended() on seeking.
2021-04-24 18:32:43 +00:00
if pos > 0:
self.state.update("paused", True)
self._updateState(pos=pos)
return True
2020-10-24 20:31:52 +00:00
def set_auto_advance(self, message: bool) -> bool:
self.state.update("auto_advance", message)
return True
def set_repeat(self, message: str) -> bool:
if message in ["all", "one", "none"]:
self.state.update("repeat", message)
return True
else:
return False
def set_play_on_load(self, message: bool) -> bool:
self.state.update("play_on_load", message)
return True
2020-11-15 19:34:13 +00:00
# Show Plan Related Methods
def get_plan(self, message: int):
plan = sync(self.api.get_showplan(message))
self.clear_channel_plan()
channel = self.state.get()["channel"]
2021-04-25 16:23:41 +00:00
self.logger.log.debug(plan)
if not isinstance(plan, dict):
return False
if str(channel) in plan.keys():
for plan_item in plan[str(channel)]:
try:
self.add_to_plan(plan_item)
except Exception as e:
2021-04-08 19:53:51 +00:00
self.logger.log.critical(
"Failed to add item to show plan: {}".format(e)
)
continue
return True
def _check_ghosts(self, item: PlanItem):
if isinstance(item.timeslotitemid, str) and item.timeslotitemid.startswith("I"):
# Kinda a bodge for the moment, each "Ghost" (item which is not saved in the database showplan yet) needs to have a unique temporary item.
# To do this, we'll start with the channel number the item was originally added to (to stop items somehow simultaneously added to different channels from having the same id)
# And chuck in the unix epoch in ns for good measure.
item.timeslotitemid = "GHOST-{}-{}".format(self.state.get()["channel"], time.time_ns())
return item
# TODO Allow just moving an item inside the channel instead of removing and adding.
2020-12-19 14:57:37 +00:00
def add_to_plan(self, new_item: Dict[str, Any]) -> bool:
2021-03-13 22:32:04 +00:00
new_item_obj = PlanItem(new_item)
new_item_obj = self._check_ghosts(new_item_obj)
plan_copy: List[PlanItem] = copy.copy(self.state.get()["show_plan"])
2021-03-13 22:32:04 +00:00
# Shift any plan items after the new position down one to make space.
for item in plan_copy:
if item.weight >= new_item_obj.weight:
item.weight += 1
2021-04-08 19:53:51 +00:00
plan_copy += [new_item_obj] # Add the new item.
2021-03-13 22:32:04 +00:00
self._fix_and_update_weights(plan_copy)
loaded_item = self.state.get()["loaded_item"]
if loaded_item:
# Right. So this may be confusing.
# So... If the user has just moved the loaded item in the channel (by removing above and readding)
# Then we want to re-associate the loaded_item object reference with the new one.
# The loaded item object before this change is now an ophan, which was kept around while the loaded item was potentially moved to another channel.
if loaded_item.timeslotitemid == new_item_obj.timeslotitemid:
self.state.update("loaded_item", new_item_obj)
# NOPE NOPE NOPE
# THIS IS AN EXAMPLE OF WHAT NOT TO DO!
# ONCE AGAIN, THE LOADED ITEM IS THE SAME OBJECT INSTANCE AS THE ONE IN THE SHOW PLAN (AS LONG AS IT HASN'T BEEN RE/MOVED)
## loaded_item.weight = new_item_obj.weight
# Bump the loaded_item's weight if we just added a new item above it.
##elif loaded_item.weight >= new_item_obj.weight:
## loaded_item.weight += 1
# Else, new weight stays the same.
##else:
## return True
##self.state.update("loaded_item", loaded_item)
2020-11-01 02:35:14 +00:00
return True
def remove_from_plan(self, weight: int) -> bool:
plan_copy: List[PlanItem] = copy.copy(self.state.get()["show_plan"])
found: Optional[PlanItem ] = None
2021-05-17 21:03:38 +00:00
before = []
for item in plan_copy:
before += (item.weight, item.name)
self.logger.log.debug("Weights before removing weight {}:\n{}".format(weight, before))
2021-05-17 21:03:38 +00:00
2020-12-19 14:57:37 +00:00
for i in plan_copy:
if i.weight == weight:
found = i
2020-11-02 23:06:45 +00:00
plan_copy.remove(i)
2021-03-13 22:32:04 +00:00
if found:
self._fix_and_update_weights(plan_copy)
# If we removed the loaded item from this channel, update it's weight
# So we know how/not to autoadvance.
loaded_item = self.state.get()["loaded_item"]
if loaded_item == found:
# Loaded_item is actually the same PlanItem instance as in the show_plan.
# So if it's still in the show plan, we'll have corrected it's weight already.
# If it was removed above, fix_weights won't have done anything
# So we'll want to update the weight.
# We're removing the loaded item from the channel.
#if loaded_item.weight == weight:
loaded_item.weight = -1
# If loaded_item wasn't the same instance, we'd want to do the below.
# We removed an item above it. Shift it up.
#elif loaded_item.weight > weight:
# loaded_item.weight -= 1
# Else, new weight stays the same.
#else:
# return True
self.state.update("loaded_item", loaded_item)
2021-03-13 22:32:04 +00:00
return True
2020-11-02 23:06:45 +00:00
return False
def clear_channel_plan(self) -> bool:
self.state.update("show_plan", [])
return True
def load(self, weight: int):
2020-10-29 22:25:17 +00:00
if not self.isPlaying:
self.unload()
2020-10-29 22:25:17 +00:00
showplan = self.state.get()["show_plan"]
2020-12-19 14:57:37 +00:00
loaded_item: Optional[PlanItem] = None
for i in range(len(showplan)):
if showplan[i].weight == weight:
loaded_item = showplan[i]
2020-11-01 02:35:14 +00:00
break
2021-04-08 21:05:25 +00:00
if loaded_item is None:
2021-04-08 20:15:15 +00:00
self.logger.log.error(
"Failed to find weight: {}".format(weight))
2020-11-01 02:35:14 +00:00
return False
2021-04-07 21:52:33 +00:00
reload = False
2021-04-08 21:05:25 +00:00
if loaded_item.filename == "" or loaded_item.filename is None:
2021-04-08 20:15:15 +00:00
self.logger.log.info(
"Filename is not specified, loading from API.")
2021-04-07 21:52:33 +00:00
reload = True
elif not os.path.exists(loaded_item.filename):
2021-04-08 19:53:51 +00:00
self.logger.log.warn(
"Filename given doesn't exist. Re-loading from API."
)
2021-04-07 21:52:33 +00:00
reload = True
if reload:
loaded_item.filename = sync(self.api.get_filename(item=loaded_item))
2020-12-19 14:57:37 +00:00
if not loaded_item.filename:
return False
self.state.update("loaded_item", loaded_item)
2020-10-29 22:25:17 +00:00
for i in range(len(showplan)):
if showplan[i].weight == weight:
self.state.update("show_plan", index=i, value=loaded_item)
break
2021-04-12 21:59:51 +00:00
# TODO: Update the show plan filenames???
2020-10-29 22:25:17 +00:00
load_attempt = 0
while load_attempt < 5:
load_attempt += 1
try:
self.logger.log.info("Loading file: " +
str(loaded_item.filename))
mixer.music.load(loaded_item.filename)
except Exception:
# We couldn't load that file.
self.logger.log.exception(
"Couldn't load file: " + str(loaded_item.filename)
)
time.sleep(1)
continue # Try loading again.
2020-10-29 22:25:17 +00:00
if not self.isLoaded:
self.logger.log.error("Pygame loaded file without error, but never actually loaded.")
time.sleep(1)
continue # Try loading again.
try:
if ".mp3" in loaded_item.filename:
song = MP3(loaded_item.filename)
self.state.update("length", song.info.length)
else:
self.state.update(
"length", mixer.Sound(
loaded_item.filename).get_length() / 1000
)
except Exception:
self.logger.log.exception(
"Failed to update the length of item.")
time.sleep(1)
continue # Try loading again.
# Everything worked, we made it!
if loaded_item.cue > 0:
self.seek(loaded_item.cue)
2020-10-29 22:25:17 +00:00
else:
self.seek(0)
if self.state.get()["play_on_load"]:
self.unpause()
2021-04-12 21:59:51 +00:00
return True
self.logger.log.error("Failed to load track after numerous retries.")
return False
return False
2020-10-29 22:25:17 +00:00
def unload(self):
if not self.isPlaying:
try:
mixer.music.unload()
self.state.update("paused", False)
2020-11-01 02:35:14 +00:00
self.state.update("loaded_item", None)
2021-04-08 21:32:16 +00:00
except Exception:
2020-11-03 22:48:11 +00:00
self.logger.log.exception("Failed to unload channel.")
2020-10-29 22:25:17 +00:00
return False
self._potentially_end_tracklist()
# If we unloaded successfully, reset the tracklist_id, ready for the next item.
if not self.isLoaded:
self.state.update("tracklist_id", None)
2020-10-29 22:25:17 +00:00
return not self.isLoaded
2020-10-24 20:31:52 +00:00
def quit(self):
2020-11-03 22:48:11 +00:00
try:
mixer.quit()
self.state.update("paused", False)
self.logger.log.info("Quit mixer.")
2021-04-08 21:32:16 +00:00
except Exception:
2020-11-03 22:48:11 +00:00
self.logger.log.exception("Failed to quit mixer.")
2020-10-24 20:31:52 +00:00
2020-12-19 14:57:37 +00:00
def output(self, name: Optional[str] = None):
wasPlaying = self.state.get()["playing"]
oldPos = self.state.get()["pos_true"]
2021-04-07 19:14:39 +00:00
name = None if (not name or name.lower() == "none") else name
2020-10-24 20:31:52 +00:00
self.quit()
2020-10-30 00:32:34 +00:00
self.state.update("output", name)
2020-10-24 20:31:52 +00:00
try:
if name:
2020-11-01 03:27:26 +00:00
mixer.init(44100, -16, 2, 1024, devicename=name)
2020-10-24 20:31:52 +00:00
else:
2020-11-01 03:27:26 +00:00
mixer.init(44100, -16, 2, 1024)
2021-04-08 21:32:16 +00:00
except Exception:
2021-04-08 19:53:51 +00:00
self.logger.log.exception(
"Failed to init mixer with device name: " + str(name)
)
2020-10-30 00:32:34 +00:00
return False
2020-10-24 20:31:52 +00:00
loadedItem = self.state.get()["loaded_item"]
2021-04-08 19:53:51 +00:00
if loadedItem:
2021-05-01 22:08:38 +00:00
self.logger.log.info("Reloading after output change.")
self.load(loadedItem.weight)
2020-11-01 03:19:21 +00:00
if wasPlaying:
2021-05-01 22:08:38 +00:00
self.logger.log.info("Resuming playback after output change.")
self.play(oldPos)
2020-11-01 03:19:21 +00:00
2020-10-30 00:32:34 +00:00
return True
2020-10-24 20:31:52 +00:00
2021-04-17 17:27:36 +00:00
# Timeslotitemid can be a ghost (un-submitted item), so may be "IXXX"
def set_marker(self, timeslotitemid: str, marker_str: str):
2021-04-10 21:56:53 +00:00
set_loaded = False
success = True
try:
marker = Marker(marker_str)
except Exception as e:
self.logger.log.error("Failed to create Marker instance with {} {}: {}".format(timeslotitemid, marker_str, e))
return False
2021-04-17 17:27:36 +00:00
if timeslotitemid == "-1":
2021-04-10 21:56:53 +00:00
set_loaded = True
if not self.isLoaded:
return False
timeslotitemid = self.state.get()["loaded_item"].timeslotitemid
2021-04-26 00:01:58 +00:00
elif self.isLoaded and self.state.get()["loaded_item"].timeslotitemid == timeslotitemid:
set_loaded = True
2021-04-10 21:56:53 +00:00
plan_copy: List[PlanItem] = copy.copy(self.state.get()["show_plan"])
for i in range(len(self.state.get()["show_plan"])):
2021-04-12 21:59:51 +00:00
item = plan_copy[i]
2021-04-10 21:56:53 +00:00
2021-04-17 17:27:36 +00:00
if str(item.timeslotitemid) == str(timeslotitemid):
2021-04-10 21:56:53 +00:00
try:
2021-04-12 21:59:51 +00:00
new_item = item.set_marker(marker)
self.state.update("show_plan", new_item, index=i)
2021-04-10 21:56:53 +00:00
except Exception as e:
self.logger.log.error(
"Failed to set marker on item {}: {} with marker \n{}".format(timeslotitemid, e, marker))
success = False
if set_loaded:
try:
self.state.update("loaded_item", self.state.get()["loaded_item"].set_marker(marker))
2021-04-10 21:56:53 +00:00
except Exception as e:
self.logger.log.error(
"Failed to set marker on loaded_item {}: {} with marker \n{}".format(timeslotitemid, e, marker))
success = False
return success
2021-05-02 01:18:00 +00:00
def reset_played(self, weight: int):
plan: List[PlanItem] = self.state.get()["show_plan"]
if weight == -1:
for item in plan:
item.play_count_reset()
self.state.update("show_plan", plan)
elif len(plan) > weight:
plan[weight].play_count_reset()
self.state.update("show_plan", plan[weight], weight)
else:
return False
return True
2021-04-10 21:56:53 +00:00
# Helper functions
# This essentially allows the tracklist end API call to happen in a separate thread, to avoid hanging playout/loading.
def _potentially_tracklist(self):
mode = self.state.get()["tracklist_mode"]
time: int = -1
if mode == "on":
time = 1 # Let's do it pretty quickly.
elif mode == "delayed":
# Let's do it in a bit, once we're sure it's been playing. (Useful if we've got no idea if it's live or cueing.)
time = TRACKLISTING_DELAYED_S
if time >= 0 and not self.tracklist_start_timer:
self.logger.log.info("Setting timer for tracklisting in {} secs due to Mode: {}".format(time, mode))
self.tracklist_start_timer = Timer(time, self._tracklist_start)
self.tracklist_start_timer.start()
elif self.tracklist_start_timer:
self.logger.log.error("Failed to potentially tracklist, timer already busy.")
# This essentially allows the tracklist end API call to happen in a separate thread, to avoid hanging playout/loading.
def _potentially_end_tracklist(self):
if self.tracklist_start_timer:
self.logger.log.info("A tracklist start timer was running, cancelling.")
self.tracklist_start_timer.cancel()
self.tracklist_start_timer = None
2021-05-02 01:18:00 +00:00
# Decrement Played count on track we didn't play much of.
state = self.state.get()
loaded_item = state["loaded_item"]
if loaded_item and loaded_item.type == "central":
loaded_item.play_count_decrement()
self.state.update("loaded_item", loaded_item)
# Make a copy of the tracklist_id, it will get reset as we load the next item.
tracklist_id = self.state.get()["tracklist_id"]
2021-04-23 20:12:31 +00:00
if not tracklist_id:
self.logger.log.info("No tracklist to end.")
return
self.logger.log.info("Setting timer for ending tracklist_id {}".format(tracklist_id))
if tracklist_id:
self.logger.log.info("Attempting to end tracklist_id {}".format(tracklist_id))
if self.tracklist_end_timer:
self.logger.log.error("Failed to potentially end tracklist, timer already busy.")
return
# This threads it, so it won't hang track loading if it fails.
self.tracklist_end_timer = Timer(1, self._tracklist_end, [tracklist_id])
self.tracklist_end_timer.start()
else:
self.logger.log.warning("Failed to potentially end tracklist, no tracklist started.")
def _tracklist_start(self):
loaded_item = self.state.get()["loaded_item"]
if not loaded_item:
self.logger.log.error("Tried to call _tracklist_start() with no loaded item!")
return
tracklist_id = self.state.get()["tracklist_id"]
if (not tracklist_id):
self.logger.log.info("Tracklisting item: {}".format(loaded_item.name))
tracklist_id = self.api.post_tracklist_start(loaded_item)
if not tracklist_id:
2021-05-01 22:08:38 +00:00
self.logger.log.warning("Failed to tracklist {}".format(loaded_item.name))
else:
self.logger.log.info("Tracklist id: {}".format(tracklist_id))
self.state.update("tracklist_id", tracklist_id)
else:
self.logger.log.info("Not tracklisting item {}, already got tracklistid: {}".format(
loaded_item.name, tracklist_id))
self.tracklist_start_timer = None
def _tracklist_end(self, tracklist_id):
if tracklist_id:
self.logger.log.info("Attempting to end tracklist_id {}".format(tracklist_id))
self.api.post_tracklist_end(tracklist_id)
else:
self.logger.log.error("Tracklist_id to _tracklist_end() missing. Failed to end tracklist.")
self.tracklist_end_timer = None
2021-04-10 21:56:53 +00:00
def _ended(self):
self._potentially_end_tracklist()
state = self.state.get()
loaded_item = state["loaded_item"]
2021-04-12 21:59:51 +00:00
if not loaded_item:
return
# Track has ended
2021-05-01 22:08:38 +00:00
self.logger.log.info("Playback ended of {}, weight {}:".format(loaded_item.name, loaded_item.weight))
# Repeat 1
# TODO ENUM
if state["repeat"] == "one":
self.play()
return
# Auto Advance
if state["auto_advance"]:
# Check for loaded item in show plan.
# If it's been removed, weight will be -1.
# Just stop in this case.
if loaded_item.weight < 0:
self.logger.log.debug("Loaded item is no longer in channel (weight {}), not auto advancing.".format(loaded_item.weight))
else:
self.logger.log.debug("Found current loaded item in this channel show plan. Auto Advancing.")
# If there's another item after this one, load that.
if len(state["show_plan"]) > loaded_item.weight+1:
self.load(loaded_item.weight+1)
return
# Repeat All (Jump to top again)
# TODO ENUM
elif state["repeat"] == "all":
self.load(0) # Jump to the top.
return
# No automations, just stop playing.
self.stop()
self._retAll("STOPPED") # Tell clients that we've stopped playing.
2020-12-19 14:57:37 +00:00
def _updateState(self, pos: Optional[float] = None):
2020-10-30 00:32:34 +00:00
self.state.update("initialised", self.isInit)
if self.isInit:
2021-04-24 18:32:43 +00:00
if pos is not None:
2021-04-24 20:31:35 +00:00
# Seeking sets the position like this when not playing.
2021-04-24 18:32:43 +00:00
self.state.update("pos", pos) # Reset back to 0 if stopped.
self.state.update("pos_offset", 0)
elif self.isPlaying:
2021-04-24 18:32:43 +00:00
# This is the bit that makes the time actually progress during playback.
# Get one last update in, incase we're about to pause/stop it.
2021-04-08 19:53:51 +00:00
self.state.update("pos", max(0, mixer.music.get_pos() / 1000))
# If the state is changing from playing to not playing, and the user didn't stop it, the item must have ended.
2021-04-08 19:53:51 +00:00
if (
self.state.get()["playing"]
2021-04-08 19:53:51 +00:00
and not self.isPlaying
and not self.stopped_manually
):
2021-04-10 21:56:53 +00:00
self._ended()
2020-10-30 00:32:34 +00:00
self.state.update("playing", self.isPlaying)
self.state.update("loaded", self.isLoaded)
2021-04-08 19:53:51 +00:00
self.state.update(
"pos_true",
min(
self.state.get()["length"],
self.state.get()["pos"] + self.state.get()["pos_offset"],
2021-04-08 19:53:51 +00:00
),
)
2021-04-08 19:53:51 +00:00
self.state.update(
"remaining",
max(0, (self.state.get()["length"] -
self.state.get()["pos_true"])),
2021-04-08 19:53:51 +00:00
)
2020-10-30 00:32:34 +00:00
def _ping_times(self):
UPDATES_FREQ_SECS = 0.2
2021-04-08 19:53:51 +00:00
if (
2021-04-08 21:05:25 +00:00
self.last_time_update is None
2021-04-08 19:53:51 +00:00
or self.last_time_update + UPDATES_FREQ_SECS < time.time()
):
self.last_time_update = time.time()
self._retAll("POS:" + str(self.state.get()["pos_true"]))
def _retAll(self, msg):
if self.out_q:
self.out_q.put("ALL:" + msg)
2021-04-08 19:53:51 +00:00
def _retMsg(
self, msg: Any, okay_str: bool = False, custom_prefix: Optional[str] = None
):
# Make sure to add the message source back, so that it can be sent to the correct destination in the main server.
if custom_prefix:
response = custom_prefix
else:
response = "{}:{}:".format(self.last_msg_source, self.last_msg)
2021-04-08 21:05:25 +00:00
if msg is True:
2020-10-30 00:32:34 +00:00
response += "OKAY"
elif isinstance(msg, str):
if okay_str:
response += "OKAY:" + msg
else:
response += "FAIL:" + msg
2020-10-24 20:31:52 +00:00
else:
2020-10-30 00:32:34 +00:00
response += "FAIL"
2021-05-01 22:08:38 +00:00
2020-10-30 00:32:34 +00:00
if self.out_q:
2021-05-01 22:08:38 +00:00
if ("STATUS:" not in response):
# Don't fill logs with status pushes, it's a mess.
self.logger.log.debug(("Sending: {}".format(response)))
2020-10-30 00:32:34 +00:00
self.out_q.put(response)
2021-05-01 22:08:38 +00:00
else:
self.logger.log.exception("Message return Queue is missing!!!! Can't send message.")
2020-10-24 20:31:52 +00:00
def _send_status(self):
# TODO This is hacky
2021-04-08 20:15:15 +00:00
self._retMsg(str(self.status), okay_str=True,
custom_prefix="ALL:STATUS:")
def _fix_and_update_weights(self, plan):
def _sort_weight(e: PlanItem):
return e.weight
before = []
for item in plan:
before += (item.weight, item.name)
self.logger.log.debug("Weights before fixing:\n{}".format(before))
plan.sort(key=_sort_weight) # Sort into weighted order.
sorted = []
for item in plan:
sorted += (item.weight, item.name)
self.logger.log.debug("Weights after sorting:\n{}".format(sorted))
for i in range(len(plan)):
plan[i].weight = i # Recorrect the weights on the channel.
fixed = []
for item in plan:
fixed += (item.weight, item.name)
self.logger.log.debug("Weights after sorting:\n{}".format(fixed))
self.state.update("show_plan", plan)
2021-04-08 19:53:51 +00:00
def __init__(
self, channel: int, in_q: multiprocessing.Queue, out_q: multiprocessing.Queue, server_state: StateManager
2021-04-08 19:53:51 +00:00
):
2020-10-30 00:32:34 +00:00
process_title = "Player: Channel " + str(channel)
setproctitle.setproctitle(process_title)
multiprocessing.current_process().name = process_title
2020-10-24 20:31:52 +00:00
self.running = True
self.out_q = out_q
2020-10-24 20:31:52 +00:00
self.logger = LoggingManager("Player" + str(channel), True)
self.api = MyRadioAPI(self.logger, server_state)
2021-04-08 19:53:51 +00:00
self.state = StateManager(
"Player" + str(channel),
self.logger,
self.__default_state,
self.__rate_limited_params,
)
self.state.add_callback(self._send_status)
2020-10-30 23:59:58 +00:00
self.state.update("channel", channel)
self.state.update("tracklist_mode", server_state.get()["tracklist_mode"])
2020-10-30 23:59:58 +00:00
# Just in case there's any weights somehow messed up, let's fix them.
plan_copy: List[PlanItem] = copy.copy(self.state.get()["show_plan"])
self._fix_and_update_weights(plan_copy)
2020-10-24 20:31:52 +00:00
loaded_state = copy.copy(self.state.state)
if loaded_state["output"]:
2021-04-08 20:15:15 +00:00
self.logger.log.info("Setting output to: " +
str(loaded_state["output"]))
2020-10-24 20:31:52 +00:00
self.output(loaded_state["output"])
else:
self.logger.log.info("Using default output device.")
2020-10-24 20:31:52 +00:00
self.output()
2020-12-19 14:57:37 +00:00
loaded_item = loaded_state["loaded_item"]
if loaded_item:
# No need to load on init, the output switch does this, as it would for regular output switching.
#self.load(loaded_item.weight)
2020-10-24 20:31:52 +00:00
2021-04-12 21:59:51 +00:00
# Load may jump to the cue point, as it would do on a regular load.
# If we were at a different state before, we have to override it now.
if loaded_state["pos_true"] != 0:
2021-04-08 19:53:51 +00:00
self.logger.log.info(
"Seeking to pos_true: " + str(loaded_state["pos_true"])
)
self.seek(loaded_state["pos_true"])
2020-10-24 20:31:52 +00:00
2021-04-08 21:05:25 +00:00
if loaded_state["playing"] is True:
2021-05-01 22:08:38 +00:00
self.logger.log.info("Resuming playback on init.")
2021-04-12 21:59:51 +00:00
self.unpause() # Use un-pause as we don't want to jump to a new position.
2020-10-29 21:23:37 +00:00
else:
2021-05-01 22:08:38 +00:00
self.logger.log.info("No file was previously loaded to resume.")
2020-10-24 20:31:52 +00:00
try:
while self.running:
2021-04-04 22:14:08 +00:00
time.sleep(0.02)
self._updateState()
self._ping_times()
2020-10-29 21:23:37 +00:00
try:
message = in_q.get_nowait()
source = message.split(":")[0]
if source not in VALID_MESSAGE_SOURCES:
self.last_msg_source = ""
self.last_msg = ""
2021-04-08 19:53:51 +00:00
self.logger.log.warn(
2021-04-08 20:15:15 +00:00
"Message from unknown sender source: {}".format(
source)
2021-04-08 19:53:51 +00:00
)
continue
self.last_msg_source = source
self.last_msg = message.split(":", 1)[1]
2021-04-17 17:27:36 +00:00
self.logger.log.debug(
2021-04-08 19:53:51 +00:00
"Recieved message from source {}: {}".format(
self.last_msg_source, self.last_msg
)
)
2020-10-29 21:23:37 +00:00
except Empty:
# The incomming message queue was empty,
# skip message processing
pass
else:
2020-10-30 00:32:34 +00:00
2020-10-29 21:23:37 +00:00
# We got a message.
2020-10-29 22:25:17 +00:00
2020-10-30 00:32:34 +00:00
# Output re-inits the mixer, so we can do this any time.
2021-04-08 19:53:51 +00:00
if self.last_msg.startswith("OUTPUT"):
2020-10-30 00:32:34 +00:00
split = self.last_msg.split(":")
self._retMsg(self.output(split[1]))
elif self.isInit:
2021-04-08 19:53:51 +00:00
message_types: Dict[
str, Callable[..., Any]
] = { # TODO Check Types
"STATUS": lambda: self._retMsg(self.status, True),
# Audio Playout
2021-04-12 21:59:51 +00:00
# Unpause, so we don't jump to 0, we play from the current pos.
"PLAY": lambda: self._retMsg(self.unpause()),
2020-11-15 19:34:13 +00:00
"PAUSE": lambda: self._retMsg(self.pause()),
"PLAYPAUSE": lambda: self._retMsg(self.unpause() if not self.isPlaying else self.pause()), # For the hardware controller.
2020-11-15 19:34:13 +00:00
"UNPAUSE": lambda: self._retMsg(self.unpause()),
2021-04-12 21:59:51 +00:00
"STOP": lambda: self._retMsg(self.stop(user_initiated=True)),
2021-04-08 19:53:51 +00:00
"SEEK": lambda: self._retMsg(
self.seek(float(self.last_msg.split(":")[1]))
),
"AUTOADVANCE": lambda: self._retMsg(
self.set_auto_advance(
(self.last_msg.split(":")[1] == "True")
)
),
"REPEAT": lambda: self._retMsg(
self.set_repeat(self.last_msg.split(":")[1])
),
"PLAYONLOAD": lambda: self._retMsg(
self.set_play_on_load(
(self.last_msg.split(":")[1] == "True")
)
),
# Show Plan Items
2021-04-08 19:53:51 +00:00
"GET_PLAN": lambda: self._retMsg(
self.get_plan(int(self.last_msg.split(":")[1]))
),
"LOAD": lambda: self._retMsg(
self.load(int(self.last_msg.split(":")[1]))
),
2020-11-15 19:34:13 +00:00
"LOADED?": lambda: self._retMsg(self.isLoaded),
"UNLOAD": lambda: self._retMsg(self.unload()),
2021-04-08 19:53:51 +00:00
"ADD": lambda: self._retMsg(
self.add_to_plan(
2021-04-08 20:15:15 +00:00
json.loads(
":".join(self.last_msg.split(":")[1:]))
2021-04-08 19:53:51 +00:00
)
),
"REMOVE": lambda: self._retMsg(
2021-04-08 20:15:15 +00:00
self.remove_from_plan(
int(self.last_msg.split(":")[1]))
2021-04-08 19:53:51 +00:00
),
"CLEAR": lambda: self._retMsg(self.clear_channel_plan()),
2021-04-17 17:27:36 +00:00
"SETMARKER": lambda: self._retMsg(self.set_marker(self.last_msg.split(":")[1], self.last_msg.split(":", 2)[2])),
2021-05-02 01:18:00 +00:00
"RESETPLAYED": lambda: self._retMsg(self.reset_played(int(self.last_msg.split(":")[1])))
2020-11-01 02:37:56 +00:00
}
message_type: str = self.last_msg.split(":")[0]
if message_type in message_types.keys():
message_types[message_type]()
2020-10-30 00:32:34 +00:00
2021-04-08 19:53:51 +00:00
elif self.last_msg == "QUIT":
2021-04-05 23:32:58 +00:00
self._retMsg(True)
2020-10-29 21:23:37 +00:00
self.running = False
2020-10-30 00:32:34 +00:00
continue
else:
self._retMsg("Unknown Command")
else:
2021-04-08 19:53:51 +00:00
if self.last_msg == "STATUS":
2020-10-30 00:32:34 +00:00
self._retMsg(self.status)
else:
self._retMsg(False)
2020-10-29 21:23:37 +00:00
# Catch the player being killed externally.
except KeyboardInterrupt:
self.logger.log.info("Received KeyboardInterupt")
except SystemExit:
self.logger.log.info("Received SystemExit")
except Exception as e:
2021-04-08 20:15:15 +00:00
self.logger.log.exception(
2021-04-08 21:05:47 +00:00
"Received unexpected Exception: {}".format(e))
2020-10-24 20:31:52 +00:00
self.logger.log.info("Quiting player " + str(channel))
2020-10-29 21:23:37 +00:00
self.quit()
2021-04-05 23:32:58 +00:00
self._retAll("QUIT")
del self.logger
os._exit(0)
2020-10-29 21:23:37 +00:00
if __name__ == "__main__":
2021-04-08 19:53:51 +00:00
raise Exception(
"This BAPSicle Player is a subcomponenet, it will not run individually."
)