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