Initial commit.
This commit is contained in:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
/venv/
|
||||
/rtmp_test.log
|
||||
__pycache__
|
||||
*.egg-info
|
||||
36
pyproject.toml
Normal file
36
pyproject.toml
Normal file
@@ -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"
|
||||
75
src/nfortv/__init__.py
Normal file
75
src/nfortv/__init__.py
Normal file
@@ -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"
|
||||
6
src/nfortv/config.py
Normal file
6
src/nfortv/config.py
Normal file
@@ -0,0 +1,6 @@
|
||||
# Copyright 2023 Simon Boyé
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
NFORTV_RTMP_LOG_FILE = "./rtmp.log"
|
||||
NFORTV_USERS = {}
|
||||
20
static/index.html
Normal file
20
static/index.html
Normal file
@@ -0,0 +1,20 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<!-- <meta http-equiv="X-UA-Compatible" content="IE=edge"> -->
|
||||
<title>NFOR TV</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" type="text/css" media="screen" href="main.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>NFOR TV 📺</h1>
|
||||
</header>
|
||||
<main>
|
||||
<h2>Streams:</h2>
|
||||
<div id="stat"></div>
|
||||
<script src="main.js" type="module"></script>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
63
static/main.css
Normal file
63
static/main.css
Normal file
@@ -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);
|
||||
}
|
||||
199
static/main.js
Normal file
199
static/main.js
Normal file
@@ -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
|
||||
}
|
||||
8
test_config.py
Normal file
8
test_config.py
Normal file
@@ -0,0 +1,8 @@
|
||||
# Copyright 2023 Simon Boyé
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
NFORTV_RTMP_LOG_FILE = "./rtmp_test.log"
|
||||
NFORTV_USERS = {
|
||||
"test": "foobar",
|
||||
}
|
||||
Reference in New Issue
Block a user