from sanic import Sanic from sanic.exceptions import NotFound, SanicException from sanic.response import html, file, redirect from sanic.response import json as resp_json from sanic_cors import CORS from jinja2 import Environment, FileSystemLoader from jinja2.utils import select_autoescape from urllib.parse import unquote from typing import Any, Optional, List from setproctitle import setproctitle from multiprocessing.queues import Queue from multiprocessing.process import current_process from queue import Empty from time import sleep import json import os import sys from helpers.os_environment import ( isLinux, resolve_external_file_path, resolve_local_file_path, ) from helpers.logging_manager import LoggingManager from helpers.device_manager import DeviceManager from helpers.state_manager import StateManager from helpers.the_terminator import Terminator from helpers.normalisation import get_normalised_filename_if_available from helpers.myradio_api import MyRadioAPI from helpers.alert_manager import AlertManager import package from baps_types.happytime import happytime env = Environment( loader=FileSystemLoader("%s/ui-templates/" % os.path.dirname(__file__)), autoescape=select_autoescape(), ) LOG_FILEPATH = resolve_external_file_path("logs") LOG_FILENAME = LOG_FILEPATH + "/WebServer.log" # From Sanic's default, but set to log to file. os.makedirs(LOG_FILEPATH, exist_ok=True) LOGGING_CONFIG = dict( version=1, disable_existing_loggers=False, loggers={ "sanic.root": {"level": "INFO", "handlers": ["file"]}, "sanic.error": { "level": "INFO", "handlers": ["error_file"], "propagate": True, "qualname": "sanic.error", }, "sanic.access": { "level": "INFO", "handlers": ["access_file"], "propagate": True, "qualname": "sanic.access", }, }, handlers={ "file": { "class": "logging.FileHandler", "formatter": "generic", "filename": LOG_FILENAME, }, "error_file": { "class": "logging.FileHandler", "formatter": "generic", "filename": LOG_FILENAME, }, "access_file": { "class": "logging.FileHandler", "formatter": "access", "filename": LOG_FILENAME, }, }, formatters={ "generic": { "format": "%(asctime)s | [%(process)d] [%(levelname)s] %(message)s", "class": "logging.Formatter", }, "access": { "format": "%(asctime)s | (%(name)s)[%(levelname)s][%(host)s]: " + "%(request)s %(message)s %(status)d %(byte)d", "class": "logging.Formatter", }, }, ) # https://sanic.dev/en/guide/running/manager.html#overcoming-a-coderuntimeerrorcode Sanic.START_METHOD_SET = True app = Sanic("BAPSicle-WebServer", log_config=LOGGING_CONFIG) def render_template(file, data, status=200): template = env.get_template(file) html_content = template.render(data=data) return html(html_content, status=status) def _filter_happytime(date): return happytime(date) env.filters["happytime"] = _filter_happytime logger: LoggingManager server_state: StateManager api: MyRadioAPI alerts: AlertManager player_to_q: List[Queue] = [] player_from_q: Queue # General UI Endpoints @app.exception(NotFound) def page_not_found(request, e: Any): data = {"ui_page": "404", "ui_title": "404", "code": 404, "title": "Page Not Found", "message": "Looks like you fell off the tip of the iceberg."} return render_template("error.html", data=data, status=404) # Future use. def error_page(code=500, ui_title="500", title="Something went very wrong!", message="Looks like the server fell over. Try viewing the WebServer logs for more details."): data = {"ui_page": ui_title, "ui_title": ui_title, "code": code, "title": title, "message": message} return render_template("error.html", data=data, status=500) @app.route("/") def ui_index(request): config = server_state.get() data = { "ui_page": "index", "ui_title": "", "alert_count": len(alerts.alerts_current), "server_version": config["server_version"], "server_build": config["server_build"], "server_name": config["server_name"], "server_beta": config["server_beta"], "server_branch": config["server_branch"], } return render_template("index.html", data=data) @app.route("/status") def ui_status(request): channel_states = [] for i in range(server_state.get()["num_channels"]): channel_states.append(status(i)) data = {"channels": channel_states, "ui_page": "status", "ui_title": "Status"} return render_template("status.html", data=data) @app.route("/alerts") def ui_alerts(request): data = { "alerts_current": alerts.alerts_current, "alerts_previous": alerts.alerts_previous, "ui_page": "alerts", "ui_title": "Alerts" } return render_template("alerts.html", data=data) @app.route("/config/player") def ui_config_player(request): channel_states = [] for i in range(server_state.get()["num_channels"]): channel_states.append(status(i)) outputs = None if isLinux(): outputs = DeviceManager.getAudioDevices() else: outputs = DeviceManager.getAudioOutputs() data = { "channels": channel_states, "outputs": outputs, "sdl_direct": isLinux(), "ui_page": "config", "ui_title": "Player Config", } return render_template("config_player.html", data=data) @app.route("/config/server") def ui_config_server(request): data = { "ui_page": "server", "ui_title": "Server Config", "state": server_state.get(), "ser_ports": DeviceManager.getSerialPorts(), "tracklist_modes": ["off", "on", "delayed", "fader-live"], "normalisation_modes": ["off", "on"], } return render_template("config_server.html", data=data) @app.route("/config/server/update", methods=["POST"]) def ui_config_server_update(request): # TODO Validation! server_state.update("server_name", request.form.get("name")) server_state.update("host", request.form.get("host")) server_state.update("port", int(request.form.get("port"))) server_state.update("num_channels", int(request.form.get("channels"))) server_state.update("ws_port", int(request.form.get("ws_port"))) serial_port = request.form.get("serial_port") server_state.update("serial_port", None if serial_port == "None" else serial_port) # Because we're not showing the api key once it's set. if "myradio_api_key" in request.form and request.form.get("myradio_api_key") != "": server_state.update("myradio_api_key", request.form.get("myradio_api_key")) server_state.update("myradio_base_url", request.form.get("myradio_base_url")) server_state.update("myradio_api_url", request.form.get("myradio_api_url")) server_state.update( "myradio_api_tracklist_source", request.form.get( "myradio_api_tracklist_source") ) server_state.update("tracklist_mode", request.form.get("tracklist_mode")) server_state.update("normalisation_mode", request.form.get("normalisation_mode")) return redirect("/restart") @app.route("/logs") def ui_logs_list(request): files = os.listdir(resolve_external_file_path("/logs")) log_files = [] for file_name in files: if file_name.endswith(".log"): log_files.append(file_name.rstrip(".log")) log_files.sort() data = {"ui_page": "logs", "ui_title": "Logs", "logs": log_files} return render_template("loglist.html", data=data) @app.route("/logs/") def ui_logs_render(request, path): page = request.args.get("page") if not page: return redirect(f"/logs/{path}?page=1") page = int(page) assert page >= 1 try: log_file = open(resolve_external_file_path("/logs/{}.log").format(path)) except FileNotFoundError: raise SanicException("Not Found",404) data = { "logs": log_file.read().splitlines()[ -300 * page:(-300 * (page - 1) if page > 1 else None) ][::-1], "ui_page": "logs", "ui_title": "Logs - {}".format(path), "page": page, } log_file.close() return render_template("log.html", data=data) # Player Audio Control Endpoints # Just useful for messing arround without presenter / websockets. @app.route("/player//") def player_simple(request, channel: int, command: str): simple_endpoints = ["play", "pause", "unpause", "stop", "unload", "clear"] if command in simple_endpoints: player_to_q[channel].put("UI:" + command.upper()) return redirect("/status") raise SanicException("Not Found",404) @app.route("/player//seek/") def player_seek(request, channel: int, pos: float): player_to_q[channel].put("UI:SEEK:" + str(pos)) return redirect("/status") @app.route("/player//load/") def player_load(request, channel: int, channel_weight: int): player_to_q[channel].put("UI:LOAD:" + str(channel_weight)) return redirect("/status") @app.route("/player//remove/") def player_remove(request, channel: int, channel_weight: int): player_to_q[channel].put("UI:REMOVE:" + str(channel_weight)) return redirect("/status") @app.route("/player//output/") def player_output(request, channel: int, name: Optional[str]): player_to_q[channel].put("UI:OUTPUT:" + unquote(str(name))) return redirect("/config/player") @app.route("/player//autoadvance/") def player_autoadvance(request, channel: int, state: int): player_to_q[channel].put("UI:AUTOADVANCE:" + str(state)) return redirect("/status") @app.route("/player//repeat/") def player_repeat(request, channel: int, state: str): player_to_q[channel].put("UI:REPEAT:" + state.upper()) return redirect("/status") @app.route("/player//playonload/") def player_playonload(request, channel: int, state: int): player_to_q[channel].put("UI:PLAYONLOAD:" + str(state)) return redirect("/status") @app.route("/player//status") def player_status_json(request, channel: int): return resp_json(status(channel)) @app.route("/player/all/stop") def player_all_stop(request): for channel in player_to_q: channel.put("UI:STOP") return redirect("/status") # Show Plan Functions @app.route("/plan/load/") def plan_load(request, timeslotid: int): for channel in player_to_q: channel.put("UI:GETPLAN:" + str(timeslotid)) return redirect("/status") @app.route("/plan/clear") def plan_clear(request): for channel in player_to_q: channel.put("UI:CLEAR") return redirect("/status") # API Proxy Endpoints @app.route("/plan/list") async def api_list_showplans(request): return resp_json(await api.get_showplans()) @app.route("/library/search/track") async def api_search_library(request): return resp_json( await api.get_track_search( request.args.get("title"), request.args.get("artist") ) ) @app.route("/library/playlists/") async def api_get_playlists(request, type: str): if type not in ["music", "aux"]: raise SanicException("Bad Request",400) if type == "music": return resp_json(await api.get_playlist_music()) else: return resp_json(await api.get_playlist_aux()) @app.route("/library/playlist//") async def api_get_playlist(request, type: str, library_id: str): if type not in ["music", "aux"]: raise SanicException("Bad Request",400) if type == "music": return resp_json(await api.get_playlist_music_items(library_id)) else: return resp_json(await api.get_playlist_aux_items(library_id)) # JSON Outputs @app.route("/status-json") def json_status(request): channel_states = [] for i in range(server_state.get()["num_channels"]): channel_states.append(status(i)) return resp_json({"server": server_state.get(), "channels": channel_states}) # Get audio for UI to generate waveforms. @app.route("/audiofile//") async def audio_file(request, type: str, id: int): if type not in ["managed", "track"]: raise SanicException("Bad Request",400) filename = resolve_external_file_path( "music-tmp/{}-{}.mp3".format(type, id)) # Swap with a normalised version if it's ready, else returns original. filename = get_normalised_filename_if_available(filename) # Send file or 404 try: response = await file(filename) except FileNotFoundError: raise SanicException("Not Found: "+filename,404) return response # Static Files app.static( "/favicon.ico", resolve_local_file_path("ui-static/favicon.ico"), name="ui-favicon" ) app.static("/static", resolve_local_file_path("ui-static"), name="ui-static") dist_directory = resolve_local_file_path("presenter-build") app.static("/presenter", dist_directory) app.static( "/presenter/", resolve_local_file_path("presenter-build/index.html"), strict_slashes=True, name="presenter-index", ) # Helper Functions def status(channel: int): while not player_from_q.empty(): # Just waste any previous status responses. player_from_q.get() player_to_q[channel].put("UI:STATUS") retries = 0 while retries < 40: try: message = player_from_q.get_nowait() split = message.split(":", 1) channel = int(split[0]) response = split[1] if response.startswith("UI:STATUS:"): response = response.split(":", 2)[2] # TODO: Handle OKAY / FAIL response = response[response.index(":") + 1:] try: response = json.loads(response) except Exception as e: raise e return response except Empty: pass retries += 1 sleep(0.02) # WebServer Start / Stop Functions @app.route("/quit") def quit(request): server_state.update("running_state", "quitting") data = { "ui_page": "message", "ui_title": "Quitting BAPSicle", "title": "See you later!", "ui_menu": False, "message": "BAPSicle is going back into winter hibernation, see you again soon!", } return render_template("message.html", data) @app.route("/restart") def restart(request): if request.args.get("confirm", '') != "true": for i in range(server_state.get()["num_channels"]): state = status(i) if state["playing"]: return render_template("restart-confirm.html", data=None) server_state.update("running_state", "restarting") data = { "ui_page": "message", "ui_title": "Restarting BAPSicle", "title": "Please Wait...", "ui_menu": False, "message": "Just putting BAPSicle back in the freezer for a moment!", "redirect_to": "/", "redirect_wait_ms": 10000, } return render_template("message.html", data) # Don't use reloader, it causes Nested Processes! def WebServer(player_to: List[Queue], player_from: Queue, state: StateManager): global player_to_q, player_from_q, server_state, api, app, alerts player_to_q = player_to player_from_q = player_from server_state = state logger = LoggingManager("WebServer") api = MyRadioAPI(logger, state) alerts = AlertManager() process_title = "BAPSicle - Web Server" setproctitle(process_title) current_process().name = process_title CORS(app, supports_credentials=True) # Allow ALL CORS!!! terminate = Terminator() while not terminate.terminate: try: app.run( host=server_state.get()["host"], port=server_state.get()["port"], auto_reload=False, single_process=True, debug=not package.BETA, access_log=not package.BETA, ) except Exception as e: logger.log.exception(e) sys.exit(1)