commit
19ff4e47e6
8 changed files with 411 additions and 0 deletions
@ -0,0 +1,4 @@ |
|||||
|
/venv/ |
||||
|
/rtmp_test.log |
||||
|
__pycache__ |
||||
|
*.egg-info |
||||
@ -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" |
||||
@ -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" |
||||
@ -0,0 +1,6 @@ |
|||||
|
# Copyright 2023 Simon Boyé |
||||
|
from __future__ import annotations |
||||
|
|
||||
|
|
||||
|
NFORTV_RTMP_LOG_FILE = "./rtmp.log" |
||||
|
NFORTV_USERS = {} |
||||
@ -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> |
||||
@ -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); |
||||
|
} |
||||
@ -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 |
||||
|
} |
||||
@ -0,0 +1,8 @@ |
|||||
|
# Copyright 2023 Simon Boyé |
||||
|
from __future__ import annotations |
||||
|
|
||||
|
|
||||
|
NFORTV_RTMP_LOG_FILE = "./rtmp_test.log" |
||||
|
NFORTV_USERS = { |
||||
|
"test": "foobar", |
||||
|
} |
||||
Loading…
Reference in new issue