Browse Source

Initial commit.

master
Draklaw 2 years ago
commit
19ff4e47e6
  1. 4
      .gitignore
  2. 36
      pyproject.toml
  3. 75
      src/nfortv/__init__.py
  4. 6
      src/nfortv/config.py
  5. 20
      static/index.html
  6. 63
      static/main.css
  7. 199
      static/main.js
  8. 8
      test_config.py

4
.gitignore

@ -0,0 +1,4 @@
/venv/
/rtmp_test.log
__pycache__
*.egg-info

36
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"

75
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"

6
src/nfortv/config.py

@ -0,0 +1,6 @@
# Copyright 2023 Simon Boyé
from __future__ import annotations
NFORTV_RTMP_LOG_FILE = "./rtmp.log"
NFORTV_USERS = {}

20
static/index.html

@ -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

@ -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

@ -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

@ -0,0 +1,8 @@
# Copyright 2023 Simon Boyé
from __future__ import annotations
NFORTV_RTMP_LOG_FILE = "./rtmp_test.log"
NFORTV_USERS = {
"test": "foobar",
}
Loading…
Cancel
Save