Import stateserver into tree, MyPy-ify, CI-ify

This commit is contained in:
Marks Polakovs 2020-04-13 18:40:03 +02:00
parent de49a1d6be
commit 45f26d7c75
8 changed files with 469 additions and 42 deletions

3
.gitignore vendored
View file

@ -27,7 +27,8 @@ yarn-error.log*
.mypy_cache/ .mypy_cache/
env/ env/
env.ci/
shittyserver.ini serverconfig.ini
.idea/ .idea/

12
Jenkinsfile vendored
View file

@ -29,9 +29,14 @@ pipeline {
sh 'node_modules/.bin/tsc -p tsconfig.json --noEmit --extendedDiagnostics' sh 'node_modules/.bin/tsc -p tsconfig.json --noEmit --extendedDiagnostics'
} }
} }
stage('MyPy') { stage('MyPy (stateserver)') {
steps { steps {
sh 'env/bin/mypy server.py' sh 'env/bin/mypy stateserver.py'
}
}
stage('MyPy (shittyserver)') {
steps {
sh 'env/bin/mypy shittyserver.py'
} }
} }
} }
@ -69,7 +74,8 @@ pipeline {
stage('Deploy server') { stage('Deploy server') {
steps { steps {
sshagent(credentials: ['ury']) { sshagent(credentials: ['ury']) {
sh 'scp -v -o StrictHostKeyChecking=no server.py liquidsoap@dolby.ury:/opt/webstudioserver/server.py' sh 'scp -v -o StrictHostKeyChecking=no stateserver.py liquidsoap@dolby.ury:/opt/webstudioserver/stateserver.py'
sh 'scp -v -o StrictHostKeyChecking=no shittyserver.py liquidsoap@dolby.ury:/opt/webstudioserver/shittyserver.py'
sh 'scp -v -o StrictHostKeyChecking=no requirements.txt liquidsoap@dolby.ury:/opt/webstudioserver/requirements.txt' sh 'scp -v -o StrictHostKeyChecking=no requirements.txt liquidsoap@dolby.ury:/opt/webstudioserver/requirements.txt'
} }
} }

View file

@ -1,12 +1,59 @@
aiohttp==3.6.2 appdirs==1.4.3
async-timeout==3.0.1 astroid==2.3.3
attrs==19.3.0 attrs==19.3.0
autopep8==1.5
black==19.10b0
certifi==2019.11.28
chardet==3.0.4 chardet==3.0.4
idna==2.9 click==7.1.1
multidict==4.7.5 colorama==0.4.1
mypy==0.770 decorator==4.4.0
distlib==0.3.0
docopt==0.6.2
dokuwiki==1.2.1
entrypoints==0.3
filelock==3.0.12
flake8==3.7.9
frida==12.8.7
frida-tools==6.0.0
greenlet==0.4.15
idna==2.8
isort==4.3.21
jedi==0.15.2
lazy-object-proxy==1.4.3
mccabe==0.6.1
msgpack==1.0.0
mypy-extensions==0.4.3 mypy-extensions==0.4.3
parso==0.6.2
pathspec==0.7.0
pipenv==2018.11.26
pluggy==0.13.1
prompt-toolkit==2.0.10
protonvpn-cli==2.2.1
psutil==5.6.3
pycodestyle==2.5.0
pydocstyle==5.0.2
pyflakes==2.1.1
Pygments==2.5.2
pylint==2.4.4
pynvim==0.4.1
pyte==0.8.0
python-jsonrpc-server==0.3.4
python-language-server==0.31.8
pythondialog==3.5.1
regex==2020.2.20
requests==2.22.0
rope==0.16.0
six==1.12.0
snowballstemmer==2.0.0
thefuck==3.29
toml==0.10.0
typed-ast==1.4.1 typed-ast==1.4.1
typing-extensions==3.7.4.2 typing-extensions==3.7.4.2
websockets==8.1 ujson==1.35
yarl==1.4.2 urllib3==1.25.8
virtualenv==20.0.16
virtualenv-clone==0.5.4
wcwidth==0.1.7
wrapt==1.11.2
yapf==0.29.0

View file

@ -8,12 +8,18 @@ blinker==1.4
certifi==2020.4.5.1 certifi==2020.4.5.1
cffi==1.14.0 cffi==1.14.0
chardet==3.0.4 chardet==3.0.4
click==7.1.1
crc32c==2.0 crc32c==2.0
cryptography==2.8 cryptography==2.8
Flask==1.1.2
Flask-Cors==3.0.8
idna==2.9 idna==2.9
itsdangerous==1.1.0
JACK-Client==0.5.2 JACK-Client==0.5.2
jedi==0.15.2 jedi==0.15.2
Jinja2==2.11.2
jsonpickle==1.3 jsonpickle==1.3
MarkupSafe==1.1.1
multidict==4.7.5 multidict==4.7.5
mypy==0.770 mypy==0.770
mypy-extensions==0.4.3 mypy-extensions==0.4.3
@ -35,4 +41,5 @@ typing-extensions==3.7.4.2
ujson==1.35 ujson==1.35
urllib3==1.25.8 urllib3==1.25.8
websockets==8.1 websockets==8.1
Werkzeug==1.0.1
yarl==1.4.2 yarl==1.4.2

12
serverconfig.ini.example Normal file
View file

@ -0,0 +1,12 @@
[raygun]
key = CHANGEME
enable = False
[shittyserver]
notify_url = https://example.com
websocket_port = 8079
telnet_port = 8078
[stateserver]
myradio_key = CHANGEME
sustainer_autonews = True

View file

@ -1,10 +0,0 @@
[raygun]
key = CHANGEME
enable = False
[mattserver]
notify_url = https://ent9s2r5u77vj.x.pipedream.net
[ports]
websocket = 8079
telnet = 8078

View file

@ -1,27 +1,24 @@
import asyncio import asyncio
import websockets import configparser
import json import json
import uuid
import av # type: ignore
import struct
from aiortc import MediaStreamTrack, RTCPeerConnection, RTCSessionDescription # type: ignore
from aiortc.mediastreams import MediaStreamError # type: ignore
from aiortc.contrib.media import MediaBlackhole, MediaPlayer # type: ignore
import jack as Jack # type: ignore
import os import os
import re import re
from datetime import datetime
from typing import Optional, Any, Type, Dict
from types import TracebackType
import sys import sys
import aiohttp import uuid
from raygun4py import raygunprovider # type: ignore from datetime import datetime
import struct from types import TracebackType
from typing import Optional, Any, Type, Dict
import configparser import aiohttp
import av # type: ignore
import jack as Jack # type: ignore
import websockets
from aiortc import MediaStreamTrack, RTCPeerConnection, RTCSessionDescription # type: ignore
from aiortc.mediastreams import MediaStreamError # type: ignore
from raygun4py import raygunprovider # type: ignore
config = configparser.ConfigParser() config = configparser.ConfigParser()
config.read("shittyserver.ini") config.read("serverconfig.ini")
ENABLE_EXCEPTION_LOGGING = False ENABLE_EXCEPTION_LOGGING = False
@ -112,7 +109,7 @@ async def notify_mattserver_about_sessions() -> None:
data: Dict[str, Dict[str, str]] = {} data: Dict[str, Dict[str, str]] = {}
for sid, sess in active_sessions.items(): for sid, sess in active_sessions.items():
data[sid] = sess.to_dict() data[sid] = sess.to_dict()
async with session.post(config.get("mattserver", "notify_url"), json=data) as response: async with session.post(config.get("shittyserver", "notify_url"), json=data) as response:
print("Mattserver response", response) print("Mattserver response", response)
@ -339,10 +336,10 @@ async def serve(websocket: websockets.WebSocketServerProtocol, path: str) -> Non
start_server = websockets.serve( start_server = websockets.serve(
serve, "localhost", int(config.get("ports", "websocket")) serve, "localhost", int(config.get("shittyserver", "websocket_port"))
) )
print("Shittyserver WS starting on port {}.".format(config.get("ports", "websocket"))) print("Shittyserver WS starting on port {}.".format(config.get("shittyserver", "websocket_port")))
async def telnet_server( async def telnet_server(
@ -417,14 +414,14 @@ async def telnet_server(
async def run_telnet_server() -> None: async def run_telnet_server() -> None:
server = await asyncio.start_server( server = await asyncio.start_server(
telnet_server, "localhost", int(config.get("ports", "telnet")) telnet_server, "localhost", int(config.get("shittyserver", "telnet_port"))
) )
await server.serve_forever() await server.serve_forever()
jack.activate() jack.activate()
print("Shittyserver TELNET starting on port {}".format(config.get("ports", "telnet"))) print("Shittyserver TELNET starting on port {}".format(config.get("shittyserver", "telnet_port")))
asyncio.get_event_loop().run_until_complete(notify_mattserver_about_sessions()) asyncio.get_event_loop().run_until_complete(notify_mattserver_about_sessions())
asyncio.get_event_loop().run_until_complete( asyncio.get_event_loop().run_until_complete(

367
stateserver.py Executable file
View file

@ -0,0 +1,367 @@
#!flask/bin/python
## IMPORTANT ASSUMPTIONS MADE:
# show timestamps start on the hour.
# normal shows in real studios aren't currently a thing!!!
import subprocess
from typing import List, Any, Dict, Optional
from flask import Flask, jsonify, request
from flask_cors import CORS # type: ignore
import requests
import datetime
import random
from telnetlib import Telnet
import configparser
config = configparser.ConfigParser()
config.read("serverconfig.ini")
api_key = config.get("stateserver", "myradio_key")
app = Flask(__name__)
CORS(app) # Enable Cors access-all
SUSTAINER_AUTONEWS = config.get("stateserver", "sustainer_autonews") == "True"
def do_ws_srv_telnet(source: str) -> None:
HOST = "localhost"
print("telnet {} {} SEL {}".format(HOST, config.get("shittyserver", "telnet_port"), source))
tn = Telnet(HOST, int(config.get("shittyserver", "telnet_port")))
tn.write(b"SEL " + str.encode(source) + b"\n")
try:
print(tn.read_until(b"\n").decode("utf-8"))
except EOFError:
pass
else:
tn.close()
def genFail(reason: str, code: int = 400) -> Any:
return jsonify({"status": "FAIL", "reason": reason})
def genPayload(payload: Any) -> Any:
return jsonify({"status": "OK", "payload": payload})
def myradioApiRequest(url: str) -> Any:
res = requests.get('https://ury.org.uk/api/v2/' + url + '?api_key=' + api_key)
if res.ok:
return res.json()["payload"]
else:
raise Exception("err {} {}".format(res.status_code, res.text))
def getNextHourTimestamp() -> int:
current = datetime.datetime.now()
currentPlusHour = current + datetime.timedelta(hours=1)
nextHourStart = currentPlusHour.replace(minute=0, second=0)
nextTimestamp = int(nextHourStart.timestamp())
return nextTimestamp
# sadly we're on python 3.7 so we can't use TypedDict
Connection = Dict[str, Any]
def getConnByID(connID: str) -> Optional[Connection]:
for conn in connections:
if conn["connid"] == connID:
return conn
return None
SOURCE_JUKEBOX = 3 # Set to 8 for testing.
SOURCE_OB = 4
SOURCE_WS = 5
SOURCE_OFFAIR = 8
SOURCES = [SOURCE_JUKEBOX, SOURCE_OB, SOURCE_WS, SOURCE_OFFAIR]
# This array will only hold connections we've validated to be authorised to broadcast.
connections: List[Connection] = []
wsSessions: Dict[str, Dict[str, str]] = {}
lastConnectionIDToRegister = -1
def getCurrentShowConnection() -> Optional[Connection]:
for connection in connections:
if (connection["startTimestamp"] <= datetime.datetime.now().timestamp()) and (
connection["endTimestamp"] >= getNextHourTimestamp()):
return connection
return None
def getNextHourConnection() -> Optional[Connection]:
nextHourTimestamp = getNextHourTimestamp()
isConnectionEnding = False
for connection in connections:
if connection["startTimestamp"] == nextHourTimestamp:
return connection
if connection["endTimestamp"] == nextHourTimestamp:
isConnectionEnding = True
if not isConnectionEnding:
# There isn't a show that starts at the next hour, so we're returning the current connection.
return getCurrentShowConnection()
else:
# The show is ending, return no next show.
return None
def cleanOldConnections() -> None:
global connections
for i in range(len(connections)):
if connections[i]["endTimestamp"] < datetime.datetime.now().timestamp():
connections.pop(i)
def stateDecider() -> Dict[str, Any]:
currentConnection = getCurrentShowConnection()
nextConnection = getNextHourConnection()
print("currentConnection:", currentConnection)
print("nextConnection:", nextConnection)
willRunAutoNews = True
switchAudioAtMin = 2
newSelSource = None
newWSSource = None
if currentConnection != nextConnection:
print("Will be transitioning")
# The show is transitioning this hour.
if currentConnection:
print("There's a current connection.")
# Current show wants to end their show at 2 mins past
if currentConnection["autoNewsEnd"] == False:
print("This show doesn't want to end with news.")
willRunAutoNews = False
switchAudioAtMin = 2 # no real change here
if nextConnection:
print("There's a next connection.")
# next show wants to begin at 0 mins past hour.
if nextConnection["autoNewsBeginning"] == False:
print("The next connection doesn't want news at start.")
willRunAutoNews = False
switchAudioAtMin = 0
newSelSource = nextConnection["sourceid"]
newWSSource = nextConnection["wsid"] # None if show is not a WS.
else:
print("No next show, going back to jukebox.")
# There isn't a next show, go back to sustainer
newSelSource = SOURCE_JUKEBOX
else:
# Show/sustainer is continuing for another hour.
print("Show / Sustainer is continuing this hour.")
if currentConnection:
print("We're currently doing a show, so check if they want middle news.")
willRunAutoNews = currentConnection["autoNewsMiddle"]
newSelSource = currentConnection["sourceid"]
newWSSource = currentConnection["wsid"]
elif SUSTAINER_AUTONEWS:
print("There's no show on currently, so we're going to AutoNEWS on sustainer")
# Jukebox -> NEWS -> Jukebox
newSelSource = SOURCE_JUKEBOX
else:
print("There's no show on currently, but AutoNews on sustainer is disabled, so don't do news")
# Jukebox -> Jukebox
newSelSource = SOURCE_JUKEBOX
switchAudioAtMin = 0
willRunAutoNews = False
nextState = {
"autoNews": willRunAutoNews,
"switchAudioAtMin": switchAudioAtMin,
"selSource": newSelSource,
"wsSource": newWSSource
}
return nextState
@app.route('/api/v1/status', methods=['GET'])
def get_status() -> Any:
print(getNextHourTimestamp())
global connections
cleanOldConnections()
return genPayload(
{
"connections": connections,
"wsSessions": wsSessions
}
)
@app.route('/api/v1/nextTransition', methods=['GET'])
def get_next_transition() -> Any:
cleanOldConnections()
return genPayload(stateDecider())
@app.route('/api/v1/cancelTimeslot', methods=['POST'])
def post_cancelCheck() -> Any:
global connections
content = request.json
if not content:
return genFail("No parameters provided.")
if not isinstance(content["connid"], int):
return genFail("Request missing valid connid.")
# We're gonna cancel their show.
currentShow = getCurrentShowConnection()
if currentShow and currentShow["connid"] == content["connid"]:
# this show is (at least supposed to be) live now.
# kill their show
do_ws_srv_telnet("NUL")
switch_proc = subprocess.Popen(["sel", str(SOURCE_JUKEBOX)])
pass
for i in range(len(connections)):
if connections[i]["connid"] == content["connid"]:
connections.pop(i)
return genPayload("Connection cancelled.")
return genFail("Connection not found.")
@app.route('/api/v1/registerTimeslot', methods=['POST'])
def post_registerCheck() -> Any:
global connections
global lastConnectionIDToRegister
content = request.json
if not content:
return genFail("No parameters provided.")
if not isinstance(content["timeslotid"], int):
return genFail("Request missing valid timeslotid.")
if not isinstance(content["memberid"], int):
return genFail("Request missing valid memberid.")
if not isinstance(content["sourceid"], int):
return genFail("Request missing valid source.")
if not content["sourceid"] in SOURCES:
return genFail("Request missing valid source.")
member = myradioApiRequest("user/" + str(content["memberid"]))
if not member:
return genFail("Could not get member.")
timeslot = myradioApiRequest("timeslot/" + str(content["timeslotid"]))
if not timeslot:
return genFail("Could not get tiemslot.")
found_credit = False
for credit in timeslot["credits"]:
if content["memberid"] == credit["memberid"]:
found_credit = True
break
if not found_credit:
return genFail("You are not authorised to broadcast for this timeslot.")
for conn in connections:
if content["timeslotid"] == conn["timeslotid"]:
# they've already registered, return the existing session
return genPayload(conn)
start_time = datetime.datetime.strptime(timeslot["start_time"], "%d/%m/%Y %H:%M")
duration = timeslot["duration"].split(":")
duration_time = datetime.timedelta(hours=int(duration[0]), minutes=int(duration[1]))
end_time = start_time + duration_time
now_time = datetime.datetime.now()
if start_time - now_time > datetime.timedelta(hours=1):
return genFail("This show too far away, please try again within an hour of starting your show.")
if start_time + duration_time < now_time:
return genFail("This show has already ended.")
random.seed(a=timeslot["timeslot_id"], version=2)
connection = {
"connid": random.randint(0, 100000000), # TODO: this is horrible. I'll sort this later.
"timeslotid": timeslot["timeslot_id"],
"startTimestamp": int(start_time.timestamp()),
"endTimestamp": int(end_time.timestamp()),
"sourceid": content["sourceid"],
'autoNewsBeginning': True,
'autoNewsMiddle': True,
'autoNewsEnd': True,
'wsid': None
}
connections.append(connection)
print(connections)
lastConnectionIDToRegister = connection["connid"]
return genPayload(connection)
@app.route('/api/v1/changeTimeslot', methods=['POST'])
def post_settingsCheck() -> Any:
global connections
content = request.json
if not content:
return genFail("No parameters provided.")
if not isinstance(content["connid"], int):
return genFail("Request missing valid connID.")
if not isinstance(content["beginning"], bool):
return genFail("Request missing valid beginning bool.")
if not isinstance(content["middle"], bool):
return genFail("Request missing valid middle bool.")
if not isinstance(content["end"], bool):
return genFail("Request missing valid end bool.")
if not isinstance(content["sourceid"], int):
return genFail("Request missing valid sourcid.")
for conn in connections:
if conn["connid"] == content["connid"]:
conn["autoNewsBeginning"] = content["beginning"]
conn["autoNewsMiddle"] = content["middle"]
conn["autoNewsEnd"] = content["end"]
conn["sourceid"] = content["sourceid"]
return genPayload(conn)
return genFail("No connection found.")
@app.route('/api/v1/updateWSSessions', methods=['POST'])
def post_wsSessions() -> Any:
global connections
global wsSessions
content = request.json
# if not content:
# return genFail("No parameters provided.")
oldSessions = wsSessions
wsSessions = content
print("New wsSessions:", wsSessions)
wsids_to_remove = []
wsids_to_add = []
for session in oldSessions:
if not oldSessions[session]["connection_id"] in wsSessions:
wsids_to_remove.append(oldSessions[session]["connection_id"])
print("wsSessions which have disappeared:", wsids_to_remove)
for session in wsSessions:
if not wsSessions[session]["connection_id"] in oldSessions:
wsids_to_add.append(wsSessions[session]["connection_id"])
print("wsSessions which have appeared:", wsids_to_add)
for conn in connections:
if conn["connid"] == lastConnectionIDToRegister:
if conn["wsid"] == None and len(wsids_to_add) == 1:
conn["wsid"] = wsids_to_add[0]
# time.sleep(5)
# TODO this doesn't exactly work right
do_ws_srv_telnet(conn["wsid"])
subprocess.Popen(['sel', '5'])
if conn["wsid"] in wsids_to_remove:
conn["wsid"] = None
# TODO Make this actually do a disconnect sequence if this is the current show.
# time.sleep(5)
subprocess.Popen(['sel', str(SOURCE_JUKEBOX)])
do_ws_srv_telnet("NUL")
return genPayload("Thx, K, bye.")
if __name__ == '__main__':
app.run(debug=True, host="0.0.0.0")