chronodiagram/webserver.py

460 lines
15 KiB
Python

import json
import logging
import os
import tornado.ioloop
import tornado.web
import tornado.websocket
from urllib.parse import urlparse
import uuid
import datetime
import html
import argparse
import coloredlogs
import glob
import svganim.strokes
logger = logging.getLogger("svganim.webserver")
class DateTimeEncoder(json.JSONEncoder):
def default(self, o):
if isinstance(o, datetime.datetime):
return o.isoformat(timespec="milliseconds")
return super().default(self, o)
class StaticFileWithHeaderHandler(tornado.web.StaticFileHandler):
def set_extra_headers(self, path):
"""For subclass to add extra headers to the response"""
if path[-5:] == ".html":
self.set_header("Access-Control-Allow-Origin", "*")
if path[-4:] == ".svg":
self.set_header("Content-Type", "image/svg+xml")
class WebSocketHandler(tornado.websocket.WebSocketHandler):
"""
Websocket from the drawing
"""
# CORS_ORIGINS = ['localhost']
connections = set()
def initialize(self, config):
self.config = config
self.strokes = []
self.hasWritten = False
self.dimensions = [None, None]
# def check_origin(self, origin):
# parsed_origin = urlparse(origin)
# # parsed_origin.netloc.lower() gives localhost:3333
# valid = any([parsed_origin.hostname.endswith(origin) for origin in self.CORS_ORIGINS])
# return valid
# the client connected
def open(self, p=None):
self.__class__.connections.add(self)
self.prefix = datetime.datetime.now().strftime("%Y-%m-%d-")
self.filename = (
self.prefix + str(self.check_filenr()) + "-" + uuid.uuid4().hex[:6]
)
logger.info(f"{self.filename=}")
self.write_message(json.dumps({"filename": self.filename}))
def check_filenr(self):
files = glob.glob(os.path.join(self.config.storage, self.prefix + "*"))
return len(files) + 1
def appendEvent(self, row):
# write to an appendable json format. So basically a file that should be wrapped in [] to be json-parsable
with open(
os.path.join(self.config.storage, self.filename + ".json_appendable"), "a"
) as fp:
if not self.hasWritten:
# metadata to first row, but only on demand
fp.write(
json.dumps(
[
datetime.datetime.now().strftime("%Y-%m-%d %T"),
self.dimensions[0],
self.dimensions[1],
]
)
)
# writer.writerow()
self.hasWritten = True
fp.write(",\n")
# first column is color, rest is points
fp.write(json.dumps(row))
# the client sent the message
def on_message(self, message):
logger.info(f"recieve: {message}")
try:
msg = json.loads(message)
if msg["event"] == "stroke":
logger.info("stroke")
self.appendEvent(msg)
elif msg["event"] == "dimensions":
self.dimensions = [int(msg["width"]), int(msg["height"])]
logger.info(f"{self.dimensions=}")
elif msg["event"] == "viewbox":
logger.info("move or resize")
self.appendEvent(msg)
else:
# self.send({'alert': 'Unknown request: {}'.format(message)})
logger.warn("Unknown request: {}".format(message))
except Exception as e:
# self.send({'alert': 'Invalid request: {}'.format(e)})
logger.exception(e)
# client disconnected
def on_close(self):
self.__class__.rmConnection(self)
logger.info(f"Client disconnected: {self.request.remote_ip}")
@classmethod
def rmConnection(cls, client):
if client not in cls.connections:
return
cls.connections.remove(client)
@classmethod
def hasConnection(cls, client):
return client in cls.connections
class AudioListingHandler(tornado.web.RequestHandler):
def initialize(self, config):
self.config = config
self.audiodir = os.path.join(self.config.storage, "audio")
def get(self):
# filename = self.get_argument("file", None)
self.set_header("Content-Type", "application/json")
if not os.path.exists(self.audiodir):
names = []
else:
names = sorted(
[
f"/audio/{name}"
for name in os.listdir(self.audiodir)
if name not in [".gitignore"]
]
)
print(names)
self.write(json.dumps(names))
class AnimationHandler(tornado.web.RequestHandler):
def initialize(self, config):
self.config = config
def get(self, filename):
self.set_header("Content-Type", "application/json")
# filename = self.get_argument("file", None)
if filename == "":
files = []
names = [
name
for name in os.listdir(self.config.storage)
if name.endswith("json_appendable")
]
for name in names:
with open(os.path.join(self.config.storage, name), "r") as fp:
first_line = fp.readline().strip()
if first_line.endswith(","):
first_line = first_line[:-1]
print(first_line)
metadata = json.loads(first_line)
files.append(
{
"name": f"/files/{name[:-16]}",
"time": metadata[0],
"dimensions": [metadata[1], metadata[2]],
}
)
files.sort(key=lambda k: k["time"])
self.write(json.dumps(files))
else:
path = os.path.join(
self.config.storage, os.path.basename(filename) + ".json_appendable"
)
drawing = {"file": filename, "shape": []}
with open(path, "r") as fp:
events = json.loads("[" + fp.read() + "]")
for i, event in enumerate(events):
if i == 0:
# metadata on first line
drawing["time"] = event[0]
drawing["dimensions"] = [event[1], event[2]]
else:
if event["event"] == "viewbox":
pass
if event["event"] == "stroke":
# points = []
# for i in range(int(len(stroke) / 4)):
# p = stroke[i*4:i*4+4]
# points.append([float(p[0]), float(p[1]), int(p[2]), float(p[3])])
drawing["shape"].append(
{"color": event["color"], "points": event["points"]}
)
self.write(json.dumps(drawing))
class TagHandler(tornado.web.RequestHandler):
"""List all tags"""
def initialize(self, config, index: svganim.strokes.AnnotationIndex):
self.config = config
self.index = index
self.metadir = os.path.join(self.config.storage, "metadata")
def get(self):
self.set_header("Content-Type", "application/json")
tags = self.index.tags.keys()
self.write(json.dumps(list(tags)))
class TagAnnotationsHandler(tornado.web.RequestHandler):
"""List all annotations for given tag"""
def initialize(self, config, index: svganim.strokes.AnnotationIndex):
self.config = config
self.index = index
self.metadir = os.path.join(self.config.storage, "metadata")
def get(self, tag):
if tag not in self.index.tags:
raise tornado.web.HTTPError(404)
self.set_header("Content-Type", "application/json")
annotations = self.index.tags[tag]
self.write(json.dumps(list([a.id for a in annotations])))
class AnnotationHandler(tornado.web.RequestHandler):
"""Get annotation as svg"""
def initialize(self, config, index: svganim.strokes.AnnotationIndex):
self.config = config
self.index = index
self.metadir = os.path.join(self.config.storage, "metadata")
def get(self, annotation_id):
if annotation_id[-4:] == ".svg":
extension = "svg"
annotation_id = annotation_id[:-4]
elif annotation_id[-4:] == ".mp3":
extension = "mp3"
annotation_id = annotation_id[:-4]
elif annotation_id[-4:] == ".wav":
extension = "wav"
annotation_id = annotation_id[:-4]
else:
extension = None
logger.info(f"annotation {annotation_id=}, {extension=}")
if annotation_id not in self.index.annotations:
raise tornado.web.HTTPError(404)
annotation = self.index.annotations[annotation_id]
if extension == "svg":
self.set_header("Content-Type", "image/svg+xml")
self.write(annotation.get_as_svg())
elif extension == "mp3":
self.set_header("Content-Type", "audio/mp3")
self.write(annotation.getAnimationSlice().audio.export(format="mp3").read())
elif extension == "wav":
self.set_header("Content-Type", "audio/wav")
self.write(annotation.getAnimationSlice().audio.export(format="wav").read())
else:
self.set_header("Content-Type", "application/json")
self.write(json.dumps({
"id": annotation.id,
"tag": annotation.tag,
"audio": f"/annotation/{annotation.id}.mp3",
}))
class AnnotationsHandler(tornado.web.RequestHandler):
def initialize(self, config):
self.config = config
self.metadir = os.path.join(self.config.storage, "metadata")
def prepare(self):
if self.request.headers.get("Content-Type", "").startswith("application/json"):
self.json_args = json.loads(self.request.body)
else:
self.json_args = None
def get_filenames(self):
return [
name[:-16]
for name in os.listdir(self.config.storage)
if name.endswith("json_appendable")
]
def get(self, filename):
self.set_header("Content-Type", "application/json")
filenames = self.get_filenames()
print(filenames, filename)
if filename not in filenames:
raise tornado.web.HTTPError(404)
meta_file = os.path.join(self.metadir, filename + ".json")
if not os.path.exists(meta_file):
self.set_status(404)
return
with open(meta_file, "r") as fp:
self.write(json.load(fp))
def post(self, filename):
# filename = self.argument("file", None)
filenames = self.get_filenames()
print(filenames, filename)
if filename not in filenames:
raise tornado.web.HTTPError(404)
if not os.path.exists(self.metadir):
os.mkdir(self.metadir)
meta_file = os.path.join(self.metadir, filename + ".json")
with open(meta_file, "w") as fp:
json.dump(self.json_args, fp)
class IndexHandler(tornado.web.RequestHandler):
"""Get annotation as svg"""
def initialize(self, config, index: svganim.strokes.AnnotationIndex):
self.config = config
self.index = index
def get(self):
self.render("templates/index.html", index=self.index)
class Server:
"""
Server for HIT -> plotter events
As well as for the Status interface
"""
loop = None
def __init__(self, config, logger):
self.config = config
self.logger = logger
# self.config['server']['port']
self.web_root = os.path.join("www")
self.index = svganim.strokes.AnnotationIndex(
"annotation_index.shelve", "files", "files/metadata"
)
self.logger.info("Loading Annotation Index")
self.index.refresh()
self.logger.info("\tloaded annotation index")
def start(self):
application = tornado.web.Application(
[
(
r"/ws(.*)",
WebSocketHandler,
{
"config": self.config,
},
),
(r"/files/(.*)", AnimationHandler, {"config": self.config}),
(
r"/audio/(.+)",
tornado.web.StaticFileHandler,
{"path": os.path.join(self.config.storage, "audio")},
),
(r"/audio", AudioListingHandler, {"config": self.config}),
(r"/annotations/(.+)", AnnotationsHandler, {"config": self.config}),
(r"/tags", TagHandler, {"config": self.config, "index": self.index}),
(
r"/tags/(.+)",
TagAnnotationsHandler,
{"config": self.config, "index": self.index},
),
(
r"/annotation/(.+)",
AnnotationHandler,
{"config": self.config, "index": self.index},
),
(r"/(.+)", StaticFileWithHeaderHandler, {"path": self.web_root}),
(r"/", IndexHandler, {"config": self.config, "index": self.index}),
],
debug=True,
autoreload=True,
)
application.listen(self.config.port)
tornado.ioloop.IOLoop.current().start()
if __name__ == "__main__":
argParser = argparse.ArgumentParser(
description="Start up the vector animation server"
)
# argParser.add_argument(
# '--config',
# '-c',
# required=True,
# type=str,
# help='The yaml config file to load'
# )
argParser.add_argument("--port", type=int, default=7890, help="Port")
argParser.add_argument(
"--storage", type=str, default="files", help="directory name for output files"
)
argParser.add_argument("--verbose", "-v", action="count", default=0)
args = argParser.parse_args()
loglevel = (
logging.NOTSET
if args.verbose > 1
else logging.DEBUG
if args.verbose > 0
else logging.INFO
)
coloredlogs.install(
level=loglevel,
# default: "%(asctime)s %(hostname)s %(name)s[%(process)d] %(levelname)s %(message)s"
fmt="%(asctime)s %(hostname)s %(name)s[%(process)d,%(threadName)s] %(levelname)s %(message)s",
)
# File logging
formatter = logging.Formatter(
fmt="%(asctime)s %(module)s:%(lineno)d %(levelname)8s | %(message)s",
datefmt="%Y/%m/%d %H:%M:%S",
) # %I:%M:%S %p AM|PM format
logFileHandler = logging.handlers.RotatingFileHandler(
"log/draw_log.log", maxBytes=1024 * 512, backupCount=5
)
logFileHandler.setFormatter(formatter)
logger.addHandler(logFileHandler)
logger.info(f"Start server: http://localhost:{args.port}")
server = Server(args, logger)
server.start()