From 19ff4e47e68a6edfe9f6393a6f89b14882a9d5fc Mon Sep 17 00:00:00 2001 From: Draklaw Date: Sun, 3 Dec 2023 13:49:58 +0100 Subject: [PATCH] Initial commit. --- .gitignore | 4 + pyproject.toml | 36 ++++++++ src/nfortv/__init__.py | 75 ++++++++++++++++ src/nfortv/config.py | 6 ++ static/index.html | 20 +++++ static/main.css | 63 +++++++++++++ static/main.js | 199 +++++++++++++++++++++++++++++++++++++++++ test_config.py | 8 ++ 8 files changed, 411 insertions(+) create mode 100644 .gitignore create mode 100644 pyproject.toml create mode 100644 src/nfortv/__init__.py create mode 100644 src/nfortv/config.py create mode 100644 static/index.html create mode 100644 static/main.css create mode 100644 static/main.js create mode 100644 test_config.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5b16402 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/venv/ +/rtmp_test.log +__pycache__ +*.egg-info diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..ac3ae93 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,36 @@ +[project] +name = "nfortv" +authors = [ + {name="Simon Boyé", email="sim.boye@gmail.com"}, +] +description = "NFOR streaming service." +readme = "README.md" +# Might relax this in the future, but requires testing +requires-python = ">=3.11" +classifiers = [ + # TODO +] +# dynamic = ["version"] +version = "0.0.1" +dependencies = [ + "flask", +] + +# [project.optional-dependencies] +# test = [ +# "pytest", +# ] + +# [project.urls] +# "Homepage" = "https://git.draklia.net/draklaw/pybsv" +# "Bug Tracker" = "https://git.draklia.net/draklaw/pybsv/issues" + +# [project.scripts] +# bsv = "bsv.main:main" + +[build-system] +requires = ["setuptools", "setuptools-scm"] +build-backend = "setuptools.build_meta" + +# [tool.setuptools_scm] +# version_file = "src/bsv/_version.py" diff --git a/src/nfortv/__init__.py b/src/nfortv/__init__.py new file mode 100644 index 0000000..e985a71 --- /dev/null +++ b/src/nfortv/__init__.py @@ -0,0 +1,75 @@ +# Copyright 2023 Simon Boyé +from __future__ import annotations + +import logging +import os +import tomllib + +from flask import Flask, current_app, request +from werkzeug.exceptions import Unauthorized + + +app = Flask( + __name__, + static_folder = None +) +app.config.from_envvar("NFORTV_CONFIG") + +@app.after_request +def add_csp_header(request): + request.headers["Content-Security-Policy"] = "default-src 'self' http://localhost" + request.headers["Access-Control-Allow-Origin"] = "*" + return request + + +RTMP_LOG_FILE = app.config["NFORTV_RTMP_LOG_FILE"] +rtmp_logger = logging.getLogger("rtmp") +rtmp_logger.setLevel(logging.INFO) +rtmp_logger.addHandler(logging.FileHandler(RTMP_LOG_FILE)) +rtmp_logger.handlers[0].setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s")) + +USERS = app.config["NFORTV_USERS"] + + +@app.route("/publish", methods=["POST"]) +def publish(): + app = request.form["app"] + name = request.form["name"] + addr = request.form["addr"] + + password = USERS.get(name) + if request.form.get("p") != password: + rtmp_logger.warning(f"{addr}: {app}/{name}: Publish: Invalid credential") + raise Unauthorized() + + rtmp_logger.info(f"{addr}: {app}/{name}: Publish") + return "success" + +@app.route("/publish_done", methods=["POST"]) +def publish_done(): + app = request.form["app"] + name = request.form["name"] + addr = request.form["addr"] + rtmp_logger.info(f"{addr}: {app}/{name}: Publish done") + return "success" + +@app.route("/play", methods=["POST"]) +def play(): + app = request.form["app"] + name = request.form["name"] + addr = request.form["addr"] + + if name not in USERS: + rtmp_logger.warning(f"{addr}: {app}/{name}: Play: Invalid stream name") + raise Unauthorized() + + rtmp_logger.info(f"{addr}: {app}/{name}: Play") + return "success" + +@app.route("/play_done", methods=["POST"]) +def play_done(): + app = request.form["app"] + name = request.form["name"] + addr = request.form["addr"] + rtmp_logger.info(f"{addr}: {app}/{name}: Play done") + return "success" diff --git a/src/nfortv/config.py b/src/nfortv/config.py new file mode 100644 index 0000000..1a4b9b0 --- /dev/null +++ b/src/nfortv/config.py @@ -0,0 +1,6 @@ +# Copyright 2023 Simon Boyé +from __future__ import annotations + + +NFORTV_RTMP_LOG_FILE = "./rtmp.log" +NFORTV_USERS = {} diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..09adee1 --- /dev/null +++ b/static/index.html @@ -0,0 +1,20 @@ + + + + + + NFOR TV + + + + +
+

NFOR TV 📺

+
+
+

Streams:

+
+ +
+ + \ No newline at end of file diff --git a/static/main.css b/static/main.css new file mode 100644 index 0000000..6c48de1 --- /dev/null +++ b/static/main.css @@ -0,0 +1,63 @@ +/* Copyright 2023 Simon Boyé */ + +html { + font-family: Verdana, Geneva, Tahoma, sans-serif; +} + +body { + margin: 0; +} + +header { + color: #f0f0f0; + background-color: rgb(126, 34, 187); + /* border: 1px solid black; */ + padding: 1em; +} + +header h1 { + margin: 0; +} + +main { + margin: 1em; +} + +.placeholder { + color: #808080; +} + +.streams { + list-style-type: none; +} + +.stream_status { + display: inline-block; + min-width: 1ex; + min-height: 1ex; + margin-left: calc(-1ex - 0.5em); + margin-right: 0.5em; + border: 1px solid #808080; + border-radius: 0.5ex; + background-color: #c0c0c0; +} + +.stream_status.online { + border-color: #36a036; + background-color: #58dc58; +} + +.stream_name { + color: rgb(126, 34, 187); + font-size: 120%; + font-weight: bold; +} + +a { + color: inherit; + text-decoration: none; +} + +a:hover { + color: rgb(156, 29, 241); +} \ No newline at end of file diff --git a/static/main.js b/static/main.js new file mode 100644 index 0000000..6f33805 --- /dev/null +++ b/static/main.js @@ -0,0 +1,199 @@ +// Copyright 2023 Simon Boyé + + +const STAT_URL = "http://109.18.229.148:8080/stat" +const RTMP_URL = "rtmp://109.18.229.148" +const APP_NAME = "nfortv" + +const KNOWN_STREAMS = [ + "alia", + "draklaw", + "draklia", + "drfred", +] + + +const mount_point = document.getElementById("stat") +render_stat(mount_point) + + +async function render_stat(mount_point) { + const g = create_element + + const stat = parse_stat(await fetch_stat()) + console.log(stat) + + const streams = stat.applications[APP_NAME].streams + console.log(streams) + + for (let stream_name of KNOWN_STREAMS) { + if (streams[stream_name] === undefined) { + streams[stream_name] = { + name: stream_name, + time: 0, + bw_in: 0, + bytes_in: 0, + bw_out: 0, + bytes_out: 0, + bw_audio: 0, + bw_video: 0, + audio: null, + video: null, + publisher: false, + viewers: [], + } + } + } + + const elem = Object.keys(streams).length? + g("ul", {"class": "streams"}, + ...Object.entries(stat.applications[APP_NAME].streams).sort().map(([name, app]) => + g("li", {}, + g("div", + { + "class": app.publisher? "stream_status online": "stream_status offline", + title: app.publisher? "Online": "Offline", + }, + ), + g("span", {}, + g("span", {"class": "stream_name"}, + g("a", {href: `${RTMP_URL}/${APP_NAME}/${name}`}, name), + ), + " ", + g("span", {"class": "stream_info"}, + `[${app.viewers.length} viewers]`, + ), + ), + // g("video", { + // src: `${RTMP_URL}/${APP_NAME}/${name}`, + // controls: true, + // }) + ), + ), + ): + g("p", {"class": "placeholder"}, "No streams are playing currently.") + + if (mount_point.childNodes.length == 0) { + mount_point.appendChild(elem) + } + else { + mount_point.replaceChild(elem, mount_point.lastChild) + } +} + + +function parse_stat(xml) { + return { + nginx_version: xml.querySelector("rtmp>nginx_version").firstChild.data, + nginx_rtmp_version: xml.querySelector("rtmp>nginx_rtmp_version").firstChild.data, + built: xml.querySelector("rtmp>built").firstChild.data, + pid: +xml.querySelector("rtmp>pid").firstChild.data, + uptime: +xml.querySelector("rtmp>uptime").firstChild.data, + naccepted: +xml.querySelector("rtmp>naccepted").firstChild.data, + bw_in: +xml.querySelector("rtmp>bw_in").firstChild.data, + bytes_in: +xml.querySelector("rtmp>bytes_in").firstChild.data, + bw_out: +xml.querySelector("rtmp>bw_out").firstChild.data, + bytes_out: +xml.querySelector("rtmp>bytes_out").firstChild.data, + applications: Object.fromEntries(Array.prototype.map.call(xml.querySelectorAll("rtmp>server>application") || [], parse_stat_application)), + } +} + +function parse_stat_application(xml) { + return [ + xml.querySelector("application>name").firstChild.data, + { + // nclients: +xml.querySelector("application>live>nclients").firstChild.data, + streams: Object.fromEntries(Array.prototype.map.call(xml.querySelectorAll("application>live>stream") || [], parse_stat_stream)) + } + ] +} + +function parse_stat_stream(xml) { + const name = xml.querySelector("stream>name").firstChild.data + const clients = Array.prototype.map.call(xml.querySelectorAll("stream>client") || [], parse_stat_client) + const publishers = clients.filter(client => client.is_publishing) + const audio = xml.querySelector("stream>meta>audio")? + { + codec: xml.querySelector("stream>meta>audio>codec").firstChild.data, + profile: xml.querySelector("stream>meta>audio>profile").firstChild.data, + channels: +xml.querySelector("stream>meta>audio>channels").firstChild.data, + sample_rate: +xml.querySelector("stream>meta>audio>sample_rate").firstChild.data, + }: + null + const video = xml.querySelector("stream>meta>video")? + { + width: +xml.querySelector("stream>meta>video>width").firstChild.data, + height: +xml.querySelector("stream>meta>video>height").firstChild.data, + frame_rate: +xml.querySelector("stream>meta>video>frame_rate").firstChild.data, + codec: xml.querySelector("stream>meta>video>codec").firstChild.data, + profile: xml.querySelector("stream>meta>video>profile").firstChild.data, + compat: xml.querySelector("stream>meta>video>compat").firstChild.data, + level: xml.querySelector("stream>meta>video>level").firstChild.data, + }: + null + return [ + name, + { + name: name, + time: +xml.querySelector("stream>time").firstChild.data, + bw_in: +xml.querySelector("stream>bw_in").firstChild.data, + bytes_in: +xml.querySelector("stream>bytes_in").firstChild.data, + bw_out: +xml.querySelector("stream>bw_out").firstChild.data, + bytes_out: +xml.querySelector("stream>bytes_out").firstChild.data, + bw_audio: +xml.querySelector("stream>bw_audio").firstChild.data, + bw_video: +xml.querySelector("stream>bw_video").firstChild.data, + audio: audio, + video: video, + publisher: publishers.length? publishers[0]: null, + viewers: clients.filter(client => !client.is_publishing), + } + ] +} + +function parse_stat_client(xml) { + return { + name: xml.querySelector("client>id").firstChild.data, + address: xml.querySelector("client>address").firstChild.data, + time: xml.querySelector("client>time").firstChild.data, + flashver: xml.querySelector("client>flashver").firstChild.data, + // swfurl: xml.querySelector("client>swfurl").firstChild.data, + dropped: xml.querySelector("client>dropped").firstChild.data, + avsync: xml.querySelector("client>avsync").firstChild.data, + timestamp: xml.querySelector("client>timestamp").firstChild.data, + is_publishing: !!xml.querySelector("client>publishing"), + } +} + + +async function fetch_stat() { + const response = await fetch(STAT_URL) + if (!response.ok) { + throw new Error(`Failed to fetch ${response.url}: ${response.status} ${response.statusText}`) + } + const xml = await response.text() + return new DOMParser().parseFromString(xml, "text/xml") +} + + +function create_element(tag, attrs={}, ...children) { + const elem = document.createElement(tag) + + for (let [key, value] of Object.entries(attrs)) { + elem.setAttribute(key, value) + } + + for (let child of children) { + if (is_string(child)) { + child = document.createTextNode(child) + } + + elem.appendChild(child) + } + + return elem +} + + +function is_string(obj) { + return typeof obj == "string" || obj instanceof String +} diff --git a/test_config.py b/test_config.py new file mode 100644 index 0000000..0a2fd76 --- /dev/null +++ b/test_config.py @@ -0,0 +1,8 @@ +# Copyright 2023 Simon Boyé +from __future__ import annotations + + +NFORTV_RTMP_LOG_FILE = "./rtmp_test.log" +NFORTV_USERS = { + "test": "foobar", +}