From c83acbdcfe9be9046e74e3604b149342d92f32d8 Mon Sep 17 00:00:00 2001 From: Trysdyn Black Date: Fri, 20 Dec 2024 14:45:49 -0800 Subject: [PATCH] Reinitialize repo to remove private data 10,000 hours mucking with `git filter-repo` and no reasonable use-case found. On the plus side, anyone looking at this and curious what I nuked isn't missing much. This lived in a monorepo up until about a week ago. --- README.md | 49 ++++++- admission.py | 124 +++++++++++++++++ assets/errorlogo.gif | Bin 0 -> 43145 bytes assets/player.js | 219 +++++++++++++++++++++++++++++++ assets/webhook_avatars/README | 3 + config.py | 56 ++++++++ example/management.service | 12 ++ example/ome_management_auth.conf | 3 + example/ome_webhook.conf | 7 + main.py | 59 +++++++++ management.py | 94 +++++++++++++ ovenapi.py | 113 ++++++++++++++++ pyproject.toml | 0 status.py | 42 ++++++ template/management.mako | 143 ++++++++++++++++++++ template/message.mako | 16 +++ template/single.mako | 54 ++++++++ template/webcall.mako | 86 ++++++++++++ viewer.py | 54 ++++++++ 19 files changed, 1133 insertions(+), 1 deletion(-) create mode 100644 admission.py create mode 100644 assets/errorlogo.gif create mode 100644 assets/player.js create mode 100644 assets/webhook_avatars/README create mode 100644 config.py create mode 100644 example/management.service create mode 100644 example/ome_management_auth.conf create mode 100644 example/ome_webhook.conf create mode 100644 main.py create mode 100644 management.py create mode 100644 ovenapi.py create mode 100644 pyproject.toml create mode 100644 status.py create mode 100644 template/management.mako create mode 100644 template/message.mako create mode 100644 template/single.mako create mode 100644 template/webcall.mako create mode 100644 viewer.py diff --git a/README.md b/README.md index 77d0f45..6152e6e 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,50 @@ # ovenemprex -OvenMediaEngine player and management middleware \ No newline at end of file +OvenMediaEngine management middleware + + +# Reqirements + +This project tries to be pretty lean. Requirements should be roughly... + +- OvenMediaEngine 0.10.30 or greater +- Python 3.8 or greater +- python-cherrypy +- python-requests + + +# Setup + +1. Install and configure Ovenmediaengine. The following components are required: + 1. WebRTC publishing + 2. The API enabled with a user/password set + 3. Some number of applications +2. Extract or clone this repository somewhere +3. Configure your HTTP daemon/proxy/etc to proxy HTTPS to `http://localhost:8080` +4. Set up environment variables to your liking. The OvenMediaEngine API key and password are mandatory; see Configuration below +5. Start the management engine with either `python3 main.py` or a systemd unit as noted in `examples/` + + +# Usage + +By default this provides a few things: + +- `https:///` will provide a "Discord like" interface to every stream live in the current app +- `https:////` will display only that stream +- `https:////` will, if configured, display a management interface to allow basic stream management + + +# Configuration + +All configuration is done with environment variables. If using systemd you can configure systemd unit overrides. If you're using your own management script you can set your environment variables any way you wish. + +Check out the config files in the `examples/` dir to see available configuration arguments. + + +# Customization + +There's only a couple supported methods of customization at this time: + +1. `assets/webhook_avatars` can provide for a way to assign stream keys an avatar that the webhook will use when announcing that key has gone live +2. `assets/errorlogo.gif` can be replaced to replace the throbber on any interface waiting for a stream to start +3. Anything in `templates/` can be edited as desired but will likely be reverted in a future update diff --git a/admission.py b/admission.py new file mode 100644 index 0000000..d493145 --- /dev/null +++ b/admission.py @@ -0,0 +1,124 @@ +import time +from pathlib import Path + +import cherrypy +import requests + +import config +import ovenapi + + +def check_webhook_throttle() -> bool: + now = time.time() + + # Clean up notification list to recent notifications + while config.NOTIFICATIONS and now - config.NOTIFICATIONS[0] > 60: + config.NOTIFICATIONS.pop(0) + + config.NOTIFICATIONS.append(now) + + return not len(config.NOTIFICATIONS) > config.NOTIFICATION_THROTTLE + + +def webhook_online(stream) -> None: + if not config.is_webhook_ready(): + return + + data = {"username": f"{config.WEBHOOK_NAME} Online", "content": config.WEBHOOK_ONLINE} + + if config.is_avatar_ready(): + target_av = f"{stream[1]}/{stream[2]}.png" + avatar = target_av if Path(config.WEBHOOK_AVATAR_PATH, target_av).is_file() else "default.png" + data["avatar_url"] = f"{config.WEBHOOK_AVATAR_URL}/{avatar}" + + requests.post(config.WEBHOOK_URL, timeout=10, json=data, headers=config.WEBHOOK_HEADERS) + + +def webhook_offline() -> None: + if not config.is_webhook_ready(): + return + + data = {"username": f"{config.WEBHOOK_NAME} Offline", "content": config.WEBHOOK_OFFLINE} + + if config.WEBHOOK_AVATAR_PATH and config.WEBHOOK_AVATAR_URL: + data["avatar_url"] = f"{config.WEBHOOK_AVATAR_URL}/offline.png" + + requests.post(config.WEBHOOK_URL, timeout=10, json=data, headers=config.WEBHOOK_HEADERS) + + +def check_authorized(host, app, stream, source) -> bool: + # Are we globally disabled? + if config.DISABLED: + return False + # IP Banned? + if source in config.BLOCKED_IPS: + return False + # Nothing in the Oven API maps a domain to "default" vhost + # So here we fudge checking default vhost for all apps/streams + if f"default:{app}:{stream}" in config.DISABLED_KEYS: + return False + # Finally check the provided vhost app/stream + return f"{host}:{app}:{stream}" not in config.DISABLED_KEYS + + +@cherrypy.tools.register("on_end_request") +def handle_notify() -> None: + # If we don't have API creds we can't do this, abort + if not (config.API_USER and config.API_PASS): + return + + # Get stream list from API + # Unfortunately Oven doesn't reflect the new stream fast enough so we have to wait :( + time.sleep(1) + stream_list = ovenapi.OvenAPI(config.API_USER, config.API_PASS).get_stream_list() + + # If we haven't gone empty->active or active->empty we need to do nothing + if bool(stream_list) != bool(config.LAST_STREAM_LIST): + if not check_webhook_throttle(): + cherrypy.log("Webhook throttle limit hit, ignoring") + return + + # Dispatch the appropriate webhook + webhook_online(stream_list[0]) if stream_list else webhook_offline() + + # Save our stream list into a durable value + config.LAST_STREAM_LIST = stream_list.copy() + + +class Admission: + # /admission to control/trigger sessions + @cherrypy.expose + @cherrypy.tools.json_in() + @cherrypy.tools.json_out() + @cherrypy.tools.handle_notify() + def default(self) -> dict: + # Fast fail if we have no json payload + try: + input_json = cherrypy.request.json + except AttributeError: + cherrypy.response.status = 400 + return {} + + # If this is a viewer, allow it with no processing + # This should never happen since we won't enable webhooks for viewing + if input_json["request"]["direction"] == "outgoing": + return {"allowed": True} + + # Figure out scheme, host, app and stream name for recording who is live + _, _, host, app, path = input_json["request"]["url"].split("/")[:5] + stream = path.split("?")[0] + + # If we are closing, return a fast 200 + if input_json["request"]["status"] == "closing": + return {} + + # Get client IP for ACL checking + ip = input_json["client"]["real_ip"] + + # Check if stream is authorized + if not check_authorized(host, app, stream, ip): + cherrypy.log(f"Unauthorized stream key: {app}/{stream}") + return {"allowed": False} + + # Compile and dispatch our response + return {"allowed": True} diff --git a/assets/errorlogo.gif b/assets/errorlogo.gif new file mode 100644 index 0000000000000000000000000000000000000000..10b62d226dcfe187af702531e0290f0d872e3da6 GIT binary patch literal 43145 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 QdUD!w-et_3rc4$82b1*3j{pDw literal 0 HcmV?d00001 diff --git a/assets/player.js b/assets/player.js new file mode 100644 index 0000000..8c1620f --- /dev/null +++ b/assets/player.js @@ -0,0 +1,219 @@ +// Get our initial stream list and create streams +xhr = new XMLHttpRequest(); +xhr.onreadystatechange = function() { + if (this.readyState == 4 && this.status == 200) { + processStreamList(JSON.parse(this.responseText)); + } +} + +disabledPlayers = []; + +// Auto-resize frames in a webcall interface +function webcallFrameResize() { + // Figure out how many Frames are visible + div_count = 0; + + document.querySelectorAll(".frame").forEach( + function(element) { + div_count += 1; + } + ) + + // If none are visible, show placeholder text and bail + if (div_count < 1) { + document.querySelector("#placeholder").style.display = "block"; + return; + } + + // Hide placeholder if any players are visible + document.querySelector("#placeholder").style.display = "none"; + + // Get player frame aspect ratio for fitting purposes + const player_ar = 16 / 9; + + // Try arrangements until the best fit is found + // Take the first column count that doesn't overflow height + cols = 0 + for (let i = 1; i <= div_count; i++) { + const frame_width = window.innerWidth / i; + const frame_height = frame_width / player_ar; + + if (frame_height * Math.ceil(div_count / i) <= window.innerHeight) { + cols = i; + break; + } + } + + // Set frames to the appropriate width + if (cols) { + w = `${Math.floor(100 / cols)}%`; + } else { + w = `${Math.floor(window.innerHeight * player_ar)}px`; + } + + document.querySelectorAll(".frame").forEach( + function(element) { + element.style.width = w; + } + ) +} + +function createPlayer(stream, muted, volume) { + // Create frame + var outer_div = document.createElement("div"); + outer_div.classList.add("frame"); + outer_div.id = `frame_${stream}`; + + // If we're putting name frames around players, make them + // Also provide close buttons to nuke this player + if (named_frames) { + var tab_div = document.createElement("div"); + tab_div.classList.add("frame_tabs"); + outer_div.appendChild(tab_div); + + var name_div = document.createElement("div"); + name_div.classList.add("frame_name"); + name_div.innerHTML = ` ${stream} `; + tab_div.appendChild(name_div); + + var button_div = document.createElement("div"); + button_div.classList.add("frame_buttons"); + button_div.innerHTML = ``; + tab_div.appendChild(button_div); + } + + // Put the player div in the container + var player_div = document.createElement("div"); + player_div.classList.add("player"); + player_div.id = stream; + outer_div.appendChild(player_div); + + // Create a throbber for dead streams + // We had this in the player div before, but it blinks every time the player reconnects + // This hides under a backgroundless player so it's only visible when nothing's up + throbber_div = document.createElement("div"); + throbber_div.id = "throbber"; + outer_div.appendChild(throbber_div); + + // Put container in document + document.body.appendChild(outer_div); + + // Initialize OvenPlayer + // We want as little interface stuff as possible, but we need to keep + // volume controls around so that can't be hidden. + player = OvenPlayer.create(stream, { + currentProtocolOnly: true, + showBigPlayButton: false, + aspect: "16:9", + autoStart: true, + mute: muted, + volume: volume, + sources: [ + { + label: stream, + type: 'webrtc', + file: `wss://${domain}:3334/${app_name}/${stream}` + } + ] + }); + + // Set player up to auto-restart if it stops + // If a streamer goes offline, the player will be reaped + player.stateManager = manageState; + player.on("stateChanged", player.stateManager); + + // Run a resize + if (named_frames) { + webcallFrameResize(); + } +} + +function manageState(data) { + // This serves one purpose: keep attempting to start a live player if it stops + if (data.newstate == "error") { + setTimeout(this.setCurrentSource, 3000, 0); + } +} + +function closePlayer(containerId) { + // Close the player + destroyPlayerById(containerId); + + // Add this ID to the closed player list so we don't reopen it + if (!disabledPlayers.includes(containerId)) { + disabledPlayers.push(containerId); + } +} + +function destroyPlayerById(containerId) { + // Tear down player + player = OvenPlayer.getPlayerByContainerId(containerId); + player.remove(); + + // Delete frame + document.getElementById(`frame_${containerId}`).remove(); + + // Run our resize + if (named_frames) { + webcallFrameResize(); + } +} + +function processStreamList(streams) { + // Remove any closed player from the list + disabledPlayers.forEach((i, index) => { + var removeIndex = streams.indexOf(i); + if (removeIndex !== -1) { + streams.splice(removeIndex, 1); + } + }) + + // Create any player in the list that doesn't have one + streams.forEach((i, index) => { + if (OvenPlayer.getPlayerByContainerId(i) == null) { + createPlayer(i, true, 100); + } + }) + + // Destroy any player not in the list + var players = OvenPlayer.getPlayerList(); + players.forEach((p, index) => { + if (!streams.includes(p.getContainerId())) { + destroyPlayerById(p.getContainerId()); + } + }) +} + +function requestStreamList() { + xhr.open("GET", `https://${domain}/status/default/${app_name}/`) + xhr.send() +} + +// Set up each embed +function EmprexSetup() { + // Pre-populate our disabled list if we have a param for it + window.location.search.substr(1).split("&").forEach((s, index) => { + tmp = s.split("="); + if (tmp[0] == "disabled") { + disabledPlayers = decodeURIComponent(tmp[1]).split(","); + } + }) + + // Placeholder if nothing is live + placeholder = document.createElement("div"); + placeholder.id = "placeholder"; + placeholder.innerHTML = "
Waiting for a stream to start..."; + document.body.appendChild(placeholder); + + // Also resize elements to fill the frame + // We try to debounce this so we're not editing the DOM 100x a second on resize + resize_timeout = false; + addEventListener("resize", function() { + clearTimeout(resize_timeout); + resize_timeout = setTimeout(webcallFrameResize, 250); + }) + + // Update streams every 5 seconds, and immediately + requestStreamList(); + setInterval(requestStreamList, 5000); +} diff --git a/assets/webhook_avatars/README b/assets/webhook_avatars/README new file mode 100644 index 0000000..f169440 --- /dev/null +++ b/assets/webhook_avatars/README @@ -0,0 +1,3 @@ +Put webhook avatars here in the path format of {app}/{stream key}.png + +If configured, webhooks will set the webhook icon to the matching image when sending online announcements diff --git a/config.py b/config.py new file mode 100644 index 0000000..0f423f3 --- /dev/null +++ b/config.py @@ -0,0 +1,56 @@ +"""Config directives for application.""" + +import json +import os +from pathlib import Path + +API_USER = os.getenv("OVENMONITOR_API_USER", "") +API_PASS = os.getenv("OVENMONITOR_API_PASSWORD", "") + +WEBHOOK_URL = os.getenv("OVENMONITOR_WEBHOOK_URL", "") +WEBHOOK_ONLINE = os.getenv("OVENMONITOR_WEBHOOK_ONLINE", "") +WEBHOOK_OFFLINE = os.getenv("OVENMONITOR_WEBHOOK_OFFLINE", "") +WEBHOOK_NAME = os.getenv("OVENMONITOR_WEBHOOK_NAME", "") +WEBHOOK_AVATAR_PATH = os.getenv("OVENMONITOR_WEBHOOK_AVATARPATH", "") +WEBHOOK_AVATAR_URL = os.getenv("OVENMONITOR_WEBHOOK_AVATARURL", "") +WEBHOOK_HEADERS = {"Content-Type": "application/json"} + +# Notifications/min to halt notifying on +NOTIFICATION_THROTTLE = 4 + +LAST_STREAM_LIST = [] +NOTIFICATIONS = [] +DISABLED_KEYS = [] +BLOCKED_IPS = [] +DISABLED = False + + +def load() -> None: + f = Path(Path.home(), Path(".ome_state.json")) + if not f.is_file(): + return + + global DISABLED_KEYS, BLOCKED_IPS, DISABLED + data = json.loads(f.read_text(encoding="utf-8")) + DISABLED_KEYS = data["disabled_keys"] + BLOCKED_IPS = data["blocked_ips"] + DISABLED = data["is_disabled"] + + +def save() -> None: + f = Path(Path.home(), Path(".ome_state.json")) + data = {"disabled_keys": DISABLED_KEYS, "blocked_ips": BLOCKED_IPS, "is_disabled": DISABLED} + + f.write_text(json.dumps(data), encoding="utf-8") + + +def is_api_ready() -> bool: + return bool(API_USER and API_PASS) + + +def is_webhook_ready() -> bool: + return bool(WEBHOOK_URL) + + +def is_avatar_ready() -> bool: + return bool(is_webhook_ready() and WEBHOOK_AVATAR_PATH and WEBHOOK_AVATAR_URL) diff --git a/example/management.service b/example/management.service new file mode 100644 index 0000000..e578318 --- /dev/null +++ b/example/management.service @@ -0,0 +1,12 @@ +[Unit] +Description=Ovenmediaengine Management Script + +[Service] +User=caddy +Group=caddy +Restart=on-failure +WorkingDirectory=/var/lib/caddy +ExecStart=/usr/bin/python /var/lib/caddy/ome-management/main.py + +[Install] +WantedBy=multi-user.target diff --git a/example/ome_management_auth.conf b/example/ome_management_auth.conf new file mode 100644 index 0000000..768e920 --- /dev/null +++ b/example/ome_management_auth.conf @@ -0,0 +1,3 @@ +[Service] +Environment="OVENMONITOR_API_USER=apiuser" +Environment="OVENMONITOR_API_PASSWORD=apipassword" diff --git a/example/ome_webhook.conf b/example/ome_webhook.conf new file mode 100644 index 0000000..c87e1ef --- /dev/null +++ b/example/ome_webhook.conf @@ -0,0 +1,7 @@ +[Service] +Environment="OVENMONITOR_WEBHOOK_URL=FULL WEBHOOK URL" +Environment="OVENMONITOR_WEBHOOK_ONLINE=TEXT WHEN STREAM GOES ONLINE" +Environment="OVENMONITOR_WEBHOOK_OFFLINE=TEXT WHEN STREAM GOES OFFLINE" +Environment="OVENMONITOR_WEBHOOK_NAME=NAME TO ASSIGN TO WEBHOOK BOT" +Environment="OVENMONITOR_WEBHOOK_AVATARPATH=/srv/http/example.com/assets/webhook_avatars" +Environment="OVENMONITOR_WEBHOOK_AVATARURL=https://example.com/assets/webhook_avatars" diff --git a/main.py b/main.py new file mode 100644 index 0000000..2b5a256 --- /dev/null +++ b/main.py @@ -0,0 +1,59 @@ +""" +Management script to let trusted users control OME. + +This should listen on localhost and have Caddy proxy with auth. +""" + +from pathlib import Path + +import cherrypy +from cherrypy.process.plugins import SignalHandler + +import admission +import config +import management +import ovenapi +import status +import viewer + +cherrypy.config.update({ + "server.socket_host": "127.0.0.1", + "environment": "production", + "tools.proxy.on": True, +}) + + +class Noop: + pass + + +def on_exit() -> None: + config.save() + cherrypy.engine.exit() + + +if __name__ == "__main__": + # Establish our config save-out signals + signalhandler = SignalHandler(cherrypy.engine) + signalhandler.handlers["SIGTERM"] = on_exit + signalhandler.handlers["SIGHUP"] = on_exit + signalhandler.handlers["SIGQUIT"] = on_exit + signalhandler.handlers["SIGINT"] = on_exit + signalhandler.subscribe() + + # Load config values + config.load() + + # If we have API access, use it to pull our stream list + if config.is_api_ready(): + config.LAST_STREAM_LIST = ovenapi.OvenAPI(config.API_USER, config.API_PASS).get_stream_list() + + runpath = Path(Path(__file__).parent.resolve(), "assets") + + cherrypy.tree.mount(admission.Admission(), "/admission") + cherrypy.tree.mount(management.Management(), "/management") + cherrypy.tree.mount(status.Status(), "/status") + cherrypy.tree.mount(Noop(), "/assets", config={"/": {"tools.staticdir.on": True, "tools.staticdir.dir": runpath}}) + cherrypy.tree.mount(viewer.Viewer(), "/") + cherrypy.engine.start() + cherrypy.engine.block() diff --git a/management.py b/management.py new file mode 100644 index 0000000..c7d637b --- /dev/null +++ b/management.py @@ -0,0 +1,94 @@ +import subprocess +from pathlib import Path + +import cherrypy +from mako.template import Template + +import config +import ovenapi + + +@cherrypy.tools.register("on_end_request") +def restart_server() -> None: + subprocess.call(["sudo", "/usr/bin/systemctl", "restart", "ovenmediaengine"]) + + +class Management: + def __init__(self): + self.page_template = Path("template/management.mako").read_text(encoding="utf-8") + self.redirect_template = Path("template/message.mako").read_text(encoding="utf-8") + self.api = ovenapi.OvenAPI(config.API_USER, config.API_PASS) + + def __message_and_redirect(self, message: str) -> bytes | str: + return Template(self.redirect_template).render(message=message) + + @cherrypy.expose + @cherrypy.tools.restart_server() + def restart(self) -> bytes | str: + # Blank our stream list because we're about to DC everyone + config.LAST_STREAM_LIST = [] + + # Compile and dispatch our response + return self.__message_and_redirect("Restart command dispatched") + + @cherrypy.expose + def disconnect(self, target): + vhost, app, stream = target.split(":") + self.api.disconnect_key(vhost, app, stream) + return self.__message_and_redirect(f"Disconnected {target}") + + @cherrypy.expose + def ban(self, target): + vhost, app, stream = target.split(":") + ip = self.api.get_stream_ip(vhost, app, stream) + if ip: + config.BLOCKED_IPS.append(ip) + self.disconnect(target) + return self.__message_and_redirect(f"Banned {ip}") + return self.__message_and_redirect("No stream found at that location or other error") + + @cherrypy.expose + def unban(self, target): + if target in config.BLOCKED_IPS: + config.BLOCKED_IPS.remove(target) + return self.__message_and_redirect(f"Unbanned {target}") + return self.__message_and_redirect(f"{target} not in ban list") + + @cherrypy.expose + def disable(self, target): + config.DISABLED_KEYS.append(target) + self.disconnect(target) + return self.__message_and_redirect(f"Disabled key {target}") + + @cherrypy.expose + def enable(self, target): + if target in config.DISABLED_KEYS: + config.DISABLED_KEYS.remove(target) + return self.__message_and_redirect(f"Re-enabled {target}") + return self.__message_and_redirect(f"{target} not in disabled key list") + + @cherrypy.expose + def stop(self): + config.DISABLED = True + self.api.disconnect_all() + return self.__message_and_redirect("Server disabled") + + @cherrypy.expose + def start(self): + config.DISABLED = False + return self.__message_and_redirect("Server re-enabled") + + @cherrypy.expose + def default(self) -> bytes | str: + if not (config.API_USER and config.API_PASS): + cherrypy.response.status = 503 + return "Remote management is disabled on this node." + + data = self.api.get_all_stream_info() + + return Template(self.page_template).render( + DISABLED=config.DISABLED, + BLOCKED_IPS=config.BLOCKED_IPS, + DISABLED_KEYS=config.DISABLED_KEYS, + data=data, + ) diff --git a/ovenapi.py b/ovenapi.py new file mode 100644 index 0000000..f77829d --- /dev/null +++ b/ovenapi.py @@ -0,0 +1,113 @@ +import requests + + +class OvenAPI: + def __init__(self, username: str, password: str, api_path: str = "http://localhost:8081/v1") -> None: + self.opener = requests.Session() + self.opener.auth = (username, password) + self.api_path = api_path + + def __get_api_data(self, rel_path: str, timeout: int = 3) -> dict: + abs_path = f"{self.api_path}/{rel_path.strip('/')}" + + return self.opener.get(abs_path, timeout=timeout).json() + + def get_vhosts(self) -> list: + return self.__get_api_data("/vhosts").get("response", []) + + def get_vhost_info(self, vhost: str) -> dict: + return self.__get_api_data(f"/vhosts/{vhost}").get("response", {}) + + def get_vhost_apps(self, vhost: str) -> list: + return self.__get_api_data(f"/vhosts/{vhost}/apps").get("response", []) + + def get_vhost_stats(self, vhost: str) -> dict: + return self.__get_api_data(f"/stats/current/vhosts/{vhost}").get("response", {}) + + def get_app_info(self, vhost: str, app: str) -> dict: + return self.__get_api_data(f"/vhosts/{vhost}/apps/{app}").get("response", {}) + + def get_app_streams(self, vhost: str, app: str) -> list: + return self.__get_api_data(f"/vhosts/{vhost}/apps/{app}/streams").get("response", []) + + def get_stream_info(self, vhost: str, app: str, stream: str) -> dict: + return self.__get_api_data(f"/vhosts/{vhost}/apps/{app}/streams/{stream}").get("response", {}) + + def get_stream_list(self) -> list[tuple]: + streams = set() + + for vhost in self.get_vhosts(): + for app in self.get_vhost_apps(vhost): + for stream in self.get_app_streams(vhost, app): + streams.add((vhost, app, stream)) + + return list(streams) + + def get_all_stream_info(self) -> dict: + data = {"vhosts": {}} + + for vhost in self.get_vhosts(): + this_vhost = {"apps": {}} + for app in self.get_vhost_apps(vhost): + this_app = {"streams": {}} + for stream in self.get_app_streams(vhost, app): + resp = self.get_stream_info(vhost, app, stream) + + # Simple data: streamer IP, type, start time + this_stream = { + "ip_address": resp["input"]["sourceUrl"].split("://")[1].split(":")[0], + "type": resp["input"]["sourceType"].lower(), + "created": resp["input"]["createdTime"], + } + + # Video data: FPS, bitrate + fps_advertised = 0 + bitrate_advertised = 0 + fps_actual = 0 + bitrate_actual = 0 + has_bframes = False + for track in resp["input"]["tracks"]: + track_type = track.get("type", "none").lower() + this_track = track.get(track_type, {}) + + bitrate_advertised += int(this_track.get("bitrate", 0)) + bitrate_actual += int(this_track.get("bitrateLatest", 0)) + fps_advertised = max(this_track.get("framerate", 0), fps_advertised) + fps_actual = max(this_track.get("framerateLatest", 0), fps_actual) + has_bframes = any([has_bframes, this_track.get("hasBframes", False)]) + + this_stream["fps_advertised"] = fps_advertised + this_stream["fps_actual"] = fps_actual + this_stream["bitrate_advertised"] = bitrate_advertised + this_stream["bitrate_actual"] = bitrate_actual + this_stream["has_bframes"] = has_bframes + + # Stats: We need a different endpoint for this + stats = self.__get_api_data(f"/stats/current/vhosts/{vhost}/apps/{app}/streams/{stream}").get( + "response", {} + ) + this_stream["viewers"] = sum(stats.get("connections", {}).values()) + + # Save this out to the main dict + this_app["streams"][stream] = this_stream + this_vhost["apps"][app] = this_app + data["vhosts"][vhost] = this_vhost + + return data + + def app_exists(self, app_name: str) -> bool: + return app_name in self.__get_api_data("/vhosts/default/apps").get("response", {}) + + def disconnect_all(self) -> None: + for stream in self.get_stream_list(): + self.disconnect_key(stream[0], stream[1], stream[2]) + + def disconnect_key(self, vhost: str, app: str, stream: str) -> None: + self.opener.delete(f"{self.api_path}/vhosts/{vhost}/apps/{app}/streams/{stream}") + + def get_stream_ip(self, vhost: str, app: str, stream: str) -> str | None: + try: + resp = self.get_stream_info(vhost, app, stream) + return resp["response"]["input"]["sourceUrl"].split("://")[1].split(":")[0] + except Exception: + return None diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..e69de29 diff --git a/status.py b/status.py new file mode 100644 index 0000000..5245424 --- /dev/null +++ b/status.py @@ -0,0 +1,42 @@ +import cherrypy + +import config +import ovenapi + + +class Status: + def _cp_dispatch(self, vpath): + if len(vpath): + cherrypy.request.params["vhost"] = vpath.pop(0) + if len(vpath): + cherrypy.request.params["app"] = vpath.pop(0) + if len(vpath): + cherrypy.request.params["stream"] = vpath.pop(0) + + return self + + def __init__(self): + self.api = ovenapi.OvenAPI(config.API_USER, config.API_PASS) + + @cherrypy.expose + @cherrypy.tools.json_out() + def index(self, **params: dict): + vhost = str(params.get("vhost")) + app = str(params.get("app")) + # App status + if "app" in params: + streams = [] + + # Use config.LAST_STREAM_LIST here because this is a cache of the last + # stream list the last time the list of streams changed. It should be + # accurate. + for s_vhost, s_app, s_stream in config.LAST_STREAM_LIST: + if s_vhost == vhost and s_app == app: + streams.append(s_stream) + return streams + + if "vhost" not in params: + vhost = "default" + + # Vhost status + return self.api.get_vhost_stats(vhost) diff --git a/template/management.mako b/template/management.mako new file mode 100644 index 0000000..1966f3f --- /dev/null +++ b/template/management.mako @@ -0,0 +1,143 @@ +<%! + import datetime + from dateutil import tz + + def relative_time(timestamp): + seconds = (datetime.datetime.now(tz.UTC) - datetime.datetime.fromisoformat(timestamp)).seconds + + days = hours = minutes = 0 + if seconds > 86400: + days, seconds = divmod(seconds, 86400) + if seconds > 3600: + hours, seconds = divmod(seconds, 3600) + if seconds > 60: + minutes, seconds = divmod(seconds, 60) + + return f"{days}d, {hours:02d}:{minutes:02d}:{seconds:02d}" +%> + +<%def name="blockdata(disabled, blocked_ips, blocked_keys)"> + % if disabled: +

The server is currently disabled!

+ % endif + + % if blocked_ips: +
Blocked IPs +
    + % for ip in blocked_ips: +
  • ${ip}
  • + % endfor +
+
+ % endif + + % if blocked_keys: +
Disabled Keys +
    + % for streamkey in blocked_keys: +
  • ${streamkey}
  • + % endfor +
+
+ % endif + + % if disabled or blocked_ips or blocked_keys: +
+ % endif + + +<%def name="stream_buttons(vhost, app, stream)"> + + + + + + + + + + + + + + + +
+ ${blockdata(DISABLED, BLOCKED_IPS, DISABLED_KEYS)} + + % for vhost_name, vhost_data in data["vhosts"].items(): +
${vhost_name} vhost + + % for vhost_k, vhost_v in vhost_data.items(): + % if vhost_k != "apps": + + % endif + % endfor +
${vhost_k}${vhost_v}
+ + % for app_name, app_data in vhost_data.get("apps", {}).items(): +
${app_name} + + % for app_k, app_v in app_data.items(): + % if app_k != "streams": + + % endif + % endfor +
${app_k}${app_v}
+ + % for stream_name, stream_data in app_data.get("streams", {}).items(): +
${app_name}/${stream_name} + + % for stream_k, stream_v in stream_data.items(): + <% + if stream_k == "has_bframes" and stream_v: + classname = 'class="alert"' + else: + classname = '' + + if stream_k == "fps_actual": + stream_v = f"{stream_v:.1f}" + elif stream_k.startswith("bitrate_"): + stream_v = f"{stream_v / 1000:.0f}kbps" + elif stream_k == "created": + stream_v = f"{relative_time(stream_v)} ago" + %> + + % endfor +
${stream_k}${stream_v}
+ ${stream_buttons(vhost_name, app_name, stream_name)} +
+ % endfor +
+ % endfor +
+ % endfor + + diff --git a/template/message.mako b/template/message.mako new file mode 100644 index 0000000..b1da7bc --- /dev/null +++ b/template/message.mako @@ -0,0 +1,16 @@ + + + + + + + + +${message}. Returning in 5 seconds. + + diff --git a/template/single.mako b/template/single.mako new file mode 100644 index 0000000..73d9203 --- /dev/null +++ b/template/single.mako @@ -0,0 +1,54 @@ + + + + + + + Single Feed Player - ${app_name}: ${stream_name} + + + + + + + + diff --git a/template/webcall.mako b/template/webcall.mako new file mode 100644 index 0000000..1095ed7 --- /dev/null +++ b/template/webcall.mako @@ -0,0 +1,86 @@ + + + + + + + Webcall Player - ${app_name} + + + + + + + + diff --git a/viewer.py b/viewer.py new file mode 100644 index 0000000..10e8fe9 --- /dev/null +++ b/viewer.py @@ -0,0 +1,54 @@ +from pathlib import Path + +import cherrypy +from mako.template import Template + +import config +import ovenapi + + +class Viewer: + def __init__(self): + self.webcall_template = Path("template/webcall.mako").read_text(encoding="utf-8") + self.single_template = Path("template/single.mako").read_text(encoding="utf-8") + self.api = ovenapi.OvenAPI(config.API_USER, config.API_PASS) + + def app_is_okay(self, app_name): + # If we can't access the API, we can't check if an app exists, just okay it + if not config.is_api_ready(): + return True + + return self.api.app_exists(app_name) + + def _cp_dispatch(self, vpath): + # Extract an app and a page, a la match1/slot1 + if len(vpath): + cherrypy.request.params["app"] = vpath.pop(0) + if len(vpath): + cherrypy.request.params["page"] = vpath.pop(0) + + # This should leave `/` as our path, triggering index() + return self + + @cherrypy.expose + def index(self, **params: dict) -> bytes | str: + if "app" in params and isinstance(params["app"], str): + # Check if the app even exists. If not, fast 404 + if not self.app_is_okay(params["app"]): + cherrypy.response.status = 404 + return "App not found" + + # Get domain for templates + domain = cherrypy.request.base.split("/")[-1].split(":")[0] + + # Any subpath is presumed to be a single player interface for app/stream + if "page" in params: + return Template(self.single_template).render( + domain=domain, app_name=params["app"], stream_name=params["page"] + ) + + # No stream key = pass webcall interface + return Template(self.webcall_template).render(domain=domain, app_name=params["app"]) + + # If we have no subpath at all, return a blank page + return ""