chronodiagram/webserver.py

626 lines
21 KiB
Python
Raw Permalink Normal View History

2021-11-22 19:54:04 +00:00
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
2022-01-19 09:28:51 +00:00
import svganim.strokes
2021-11-22 19:54:04 +00:00
2022-01-19 09:28:51 +00:00
logger = logging.getLogger("svganim.webserver")
2021-11-22 19:54:04 +00:00
class DateTimeEncoder(json.JSONEncoder):
def default(self, o):
if isinstance(o, datetime.datetime):
2022-01-19 09:28:51 +00:00
return o.isoformat(timespec="milliseconds")
2021-11-22 19:54:04 +00:00
return super().default(self, o)
2022-01-19 09:28:51 +00:00
2021-11-22 19:54:04 +00:00
class StaticFileWithHeaderHandler(tornado.web.StaticFileHandler):
def set_extra_headers(self, path):
"""For subclass to add extra headers to the response"""
2022-01-19 09:28:51 +00:00
if path[-5:] == ".html":
2021-11-22 19:54:04 +00:00
self.set_header("Access-Control-Allow-Origin", "*")
2022-01-19 09:28:51 +00:00
if path[-4:] == ".svg":
2021-11-22 19:54:04 +00:00
self.set_header("Content-Type", "image/svg+xml")
class WebSocketHandler(tornado.websocket.WebSocketHandler):
"""
2022-01-19 09:28:51 +00:00
Websocket from the drawing
2021-11-22 19:54:04 +00:00
"""
2022-01-19 09:28:51 +00:00
2021-11-23 11:30:49 +00:00
# CORS_ORIGINS = ['localhost']
2021-11-22 19:54:04 +00:00
connections = set()
def initialize(self, config):
self.config = config
self.strokes = []
self.hasWritten = False
self.prev_file = None
2022-02-09 07:18:28 +00:00
self.prev_file_duration = 0
2021-11-22 19:54:04 +00:00
self.dimensions = [None, None]
2021-11-23 11:30:49 +00:00
# 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
2021-11-22 19:54:04 +00:00
# the client connected
2022-01-19 09:28:51 +00:00
def open(self, p=None):
2021-11-22 19:54:04 +00:00
self.__class__.connections.add(self)
2022-01-19 09:28:51 +00:00
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=}")
2022-01-19 09:28:51 +00:00
self.write_message(json.dumps({"filename": self.filename}))
2021-11-22 19:54:04 +00:00
def check_filenr(self):
2022-01-19 09:28:51 +00:00
files = glob.glob(os.path.join(self.config.storage, self.prefix + "*"))
2021-11-22 19:54:04 +00:00
return len(files) + 1
2022-01-19 09:28:51 +00:00
def appendEvent(self, row):
if not self.hasWritten and self.prev_file and 'event' in row and row['event'] == 'viewbox':
# ignore canvas movement after
return
# write to an appendable json format. So basically a file that should be wrapped in [] to be json-parsable
2022-01-19 09:28:51 +00:00
with open(
os.path.join(self.config.storage, self.filename +
".json_appendable"), "a"
2022-01-19 09:28:51 +00:00
) as fp:
if not self.hasWritten:
if self.prev_file:
# TODO WIP
with open(
self.prev_file, 'r'
) as fprev:
wrote = False
for line in fprev:
wrote = True
fp.write(line)
if wrote:
fp.write(",\n")
2022-01-19 09:28:51 +00:00
# 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
2022-01-19 09:28:51 +00:00
fp.write(",\n")
# first column is color, rest is points
fp.write(json.dumps(row))
2021-11-22 19:54:04 +00:00
def preloadFile(self, file):
if self.hasWritten:
logger.error("Cannot preload when already written content")
return False
logger.info(f"load {file}")
# TODO, make sure file doesn't load file outside of storage
prev_file = os.path.join(
self.config.storage, file + ".json_appendable")
if not os.path.exists(prev_file):
logger.error(f"Cannot preload non-existent file: {prev_file}")
self.write_message(json.dumps(
{"error": f"Non-existent file: {file}"}))
return False
self.prev_file = prev_file
2022-02-09 07:18:28 +00:00
metadata = self.getFileMetadata(self.prev_file)
self.prev_file_duration = self.getLastTimestampInFile(self.prev_file)
logger.info(
"Previous file set. {self.prev_file} {metadata=} time: {self.prev_file_duration}")
self.write_message(json.dumps(
{"preloaded_svg": f"/drawing/{file}", "dimensions": [metadata[1], metadata[2]], "time": self.prev_file_duration}))
def getFileMetadata(self, filename):
with open(filename, "r") as fp:
first_line = fp.readline().strip()
if first_line.endswith(","):
first_line = first_line[:-1]
metadata = json.loads(first_line)
2022-02-09 07:18:28 +00:00
return metadata
def getLastTimestampInFile(self, filename):
with open(filename, "r") as fp:
for line in fp:
pass # loop until the end
last_line = line.strip()
if last_line.endswith(","):
last_line = last_line[:-1]
data = json.loads(last_line)
if type(data) is list:
raise Exception("Oddly, the file ends with merely metadata")
if data['event'] == 'stroke':
return data['points'][-1][3]
elif data['event'] == 'viewbox':
return data['viewboxes'][-1]['t']
else:
raise Exception("Unknown last event")
2021-11-22 19:54:04 +00:00
# the client sent the message
def on_message(self, message):
logger.info(f"recieve: {message}")
try:
msg = json.loads(message)
2022-01-19 09:28:51 +00:00
if msg["event"] == "stroke":
logger.info("stroke")
2022-02-09 07:18:28 +00:00
for i in range(len(msg['points'])):
msg['points'][i][3] += self.prev_file_duration
self.appendEvent(msg)
2022-01-19 09:28:51 +00:00
elif msg["event"] == "dimensions":
self.dimensions = [int(msg["width"]), int(msg["height"])]
2021-11-22 19:54:04 +00:00
logger.info(f"{self.dimensions=}")
2022-01-19 09:28:51 +00:00
elif msg["event"] == "viewbox":
logger.info("move or resize")
2022-02-09 07:18:28 +00:00
if len(msg['viewboxes']) == 0:
logger.warn("Empty viewbox array")
else:
for i in range(len(msg['viewboxes'])):
msg['viewboxes'][i]['t'] += self.prev_file_duration
self.appendEvent(msg)
elif msg["event"] == "preload":
self.preloadFile(msg["file"])
2021-11-22 19:54:04 +00:00
else:
# self.send({'alert': 'Unknown request: {}'.format(message)})
2022-01-19 09:28:51 +00:00
logger.warn("Unknown request: {}".format(message))
2021-11-22 19:54:04 +00:00
except Exception as e:
# self.send({'alert': 'Invalid request: {}'.format(e)})
logger.exception(e)
# client disconnected
def on_close(self):
self.__class__.rmConnection(self)
2022-01-19 09:28:51 +00:00
2021-11-22 19:54:04 +00:00
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)
2022-01-19 09:28:51 +00:00
2021-11-22 19:54:04 +00:00
@classmethod
def hasConnection(cls, client):
return client in cls.connections
2022-01-19 09:28:51 +00:00
2021-11-22 19:54:04 +00:00
class AudioListingHandler(tornado.web.RequestHandler):
def initialize(self, config):
self.config = config
2022-01-19 09:28:51 +00:00
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:
2022-01-19 09:28:51 +00:00
names = sorted(
[
f"/audio/{name}"
for name in os.listdir(self.audiodir)
if name not in [".gitignore"]
]
)
print(names)
self.write(json.dumps(names))
2022-01-19 09:28:51 +00:00
2021-11-22 19:54:04 +00:00
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)
2022-01-19 09:28:51 +00:00
if filename == "":
2021-12-21 13:31:02 +00:00
files = []
2022-01-19 09:28:51 +00:00
names = [
name
for name in os.listdir(self.config.storage)
if name.endswith("json_appendable")
]
2021-12-21 13:31:02 +00:00
for name in names:
fn = os.path.join(self.config.storage, name)
stat = os.stat(fn)
if stat.st_size == 0:
continue
with open(fn, "r") as fp:
2021-12-21 13:31:02 +00:00
first_line = fp.readline().strip()
2022-01-19 09:28:51 +00:00
if first_line.endswith(","):
2021-12-21 13:31:02 +00:00
first_line = first_line[:-1]
2021-12-21 13:31:02 +00:00
metadata = json.loads(first_line)
2022-01-19 09:28:51 +00:00
files.append(
{
"name": f"/files/{name[:-16]}",
"id": name[:-16],
"ctime": metadata[0],
"mtime": datetime.datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %T"),
2022-01-19 09:28:51 +00:00
"dimensions": [metadata[1], metadata[2]],
"svg": f"/drawing/{name[:-16]}.svg",
2022-01-19 09:28:51 +00:00
}
)
files.sort(key=lambda k: k["mtime"])
2021-12-21 13:31:02 +00:00
self.write(json.dumps(files))
2021-11-22 19:54:04 +00:00
else:
2022-01-19 09:28:51 +00:00
path = os.path.join(
self.config.storage, os.path.basename(
filename) + ".json_appendable"
2022-01-19 09:28:51 +00:00
)
drawing = {"file": filename, "shape": []}
with open(path, "r") as fp:
events = json.loads("[" + fp.read() + "]")
for i, event in enumerate(events):
2021-11-22 19:54:04 +00:00
if i == 0:
# metadata on first line
2022-01-19 09:28:51 +00:00
drawing["time"] = event[0]
drawing["dimensions"] = [event[1], event[2]]
else:
2022-02-09 07:18:28 +00:00
if type(event) is list:
# ignore double metadatas, which appear when continuaing an existing drawing
continue
2022-01-19 09:28:51 +00:00
if event["event"] == "viewbox":
pass
2022-01-19 09:28:51 +00:00
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])])
2022-01-19 09:28:51 +00:00
drawing["shape"].append(
{"color": event["color"],
"points": event["points"]}
2022-01-19 09:28:51 +00:00
)
2021-11-22 19:54:04 +00:00
self.write(json.dumps(drawing))
2022-01-19 09:28:51 +00:00
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]
2021-11-22 19:54:04 +00:00
else:
2022-01-19 09:28:51 +00:00
extension = None
logger.info(f"annotation {annotation_id=}, {extension=}")
if annotation_id not in self.index.annotations:
raise tornado.web.HTTPError(404)
2021-11-22 19:54:04 +00:00
2022-01-19 09:28:51 +00:00
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())
2022-01-19 09:28:51 +00:00
elif extension == "wav":
self.set_header("Content-Type", "audio/wav")
self.write(annotation.getAnimationSlice(
).audio.export(format="wav").read())
2022-01-19 09:28:51 +00:00
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 DrawingHandler(tornado.web.RequestHandler):
"""Get drawing 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, drawing_id):
if drawing_id[-4:] == ".svg":
extension = "svg"
drawing_id = drawing_id[:-4]
elif drawing_id[-4:] == ".mp3":
extension = "mp3"
drawing_id = drawing_id[:-4]
elif drawing_id[-4:] == ".wav":
extension = "wav"
drawing_id = drawing_id[:-4]
else:
extension = None
logger.info(f"drawing {drawing_id=}, {extension=}")
if drawing_id not in self.index.drawings:
self.index.refresh()
# double check
if drawing_id not in self.index.drawings:
raise tornado.web.HTTPError(404)
drawing = self.index.drawings[drawing_id]
if extension == "svg":
self.set_header("Content-Type", "image/svg+xml")
self.write(drawing.get_animation().get_as_svg())
elif extension == "mp3":
self.set_header("Content-Type", "audio/mp3")
self.write(drawing.get_animation(
).audio.export(format="mp3").read())
elif extension == "wav":
self.set_header("Content-Type", "audio/wav")
self.write(drawing.get_animation(
).audio.export(format="wav").read())
else:
self.set_header("Content-Type", "application/json")
self.write(json.dumps({
"id": drawing.id,
"annotations_url": drawing.get_annotations_url(),
"audio": f"/drawing/{drawing.id}.mp3",
"svg": f"/drawing/{drawing.id}.svg",
}))
class AnnotationsHandler(tornado.web.RequestHandler):
def initialize(self, config):
self.config = config
2022-01-19 09:28:51 +00:00
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
2022-01-19 09:28:51 +00:00
2021-12-22 10:52:38 +00:00
def get_filenames(self):
2022-01-19 09:28:51 +00:00
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")
2021-12-22 10:52:38 +00:00
filenames = self.get_filenames()
print(filenames, filename)
if filename not in filenames:
2021-12-22 10:52:38 +00:00
raise tornado.web.HTTPError(404)
2022-01-19 09:28:51 +00:00
meta_file = os.path.join(self.metadir, filename + ".json")
if not os.path.exists(meta_file):
self.set_status(404)
return
2022-01-19 09:28:51 +00:00
with open(meta_file, "r") as fp:
self.write(json.load(fp))
def post(self, filename):
2022-01-19 09:28:51 +00:00
# filename = self.argument("file", None)
2021-12-22 10:52:38 +00:00
filenames = self.get_filenames()
print(filenames, filename)
2022-01-19 09:28:51 +00:00
if filename not in filenames:
2021-12-22 10:52:38 +00:00
raise tornado.web.HTTPError(404)
if not os.path.exists(self.metadir):
os.mkdir(self.metadir)
2022-01-19 09:28:51 +00:00
meta_file = os.path.join(self.metadir, filename + ".json")
with open(meta_file, "w") as fp:
json.dump(self.json_args, fp)
2022-01-19 09:28:51 +00:00
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):
2022-01-19 10:15:55 +00:00
do_refresh = bool(self.get_query_argument('refresh', False))
if do_refresh:
self.logger.info("Reloading Annotation Index")
self.index.refresh()
self.logger.info("\treloaded annotation index")
2022-01-19 09:28:51 +00:00
self.render("templates/index.html", index=self.index)
2021-11-22 19:54:04 +00:00
2021-11-22 19:54:04 +00:00
class Server:
"""
Server for HIT -> plotter events
As well as for the Status interface
"""
2022-01-19 09:28:51 +00:00
2021-11-22 19:54:04 +00:00
loop = None
def __init__(self, config, logger):
self.config = config
self.logger = logger
2022-01-19 09:28:51 +00:00
# self.config['server']['port']
self.web_root = os.path.join("www")
2021-11-22 19:54:04 +00:00
2022-01-19 09:28:51 +00:00
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")
2021-11-22 19:54:04 +00:00
def start(self):
2022-01-19 09:28:51 +00:00
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}),
2022-01-19 09:28:51 +00:00
(
r"/tags/(.+)",
TagAnnotationsHandler,
{"config": self.config, "index": self.index},
),
(
r"/annotation/(.+)",
AnnotationHandler,
{"config": self.config, "index": self.index},
),
(
r"/drawing/(.+)",
DrawingHandler,
{"config": self.config, "index": self.index},
),
2022-01-19 09:28:51 +00:00
(r"/index", IndexHandler,
{"config": self.config, "index": self.index}),
(r"/(.*)", StaticFileWithHeaderHandler,
{"path": self.web_root, 'default_filename': 'index.html'}),
2022-01-19 09:28:51 +00:00
],
debug=True,
autoreload=True,
)
application.listen(self.config.port)
tornado.ioloop.IOLoop.current().start()
2021-11-22 19:54:04 +00:00
if __name__ == "__main__":
argParser = argparse.ArgumentParser(
2022-01-19 09:28:51 +00:00
description="Start up the vector animation server"
)
2021-11-22 19:54:04 +00:00
# argParser.add_argument(
# '--config',
# '-c',
# required=True,
# type=str,
# help='The yaml config file to load'
# )
2022-01-19 09:28:51 +00:00
argParser.add_argument("--port", type=int, default=7890, help="Port")
2021-11-22 19:54:04 +00:00
argParser.add_argument(
2022-01-19 09:28:51 +00:00
"--storage", type=str, default="files", help="directory name for output files"
2021-11-22 19:54:04 +00:00
)
2022-01-19 09:28:51 +00:00
argParser.add_argument("--verbose", "-v", action="count", default=0)
2021-11-22 19:54:04 +00:00
args = argParser.parse_args()
2022-01-19 09:28:51 +00:00
loglevel = (
logging.NOTSET
if args.verbose > 1
else logging.DEBUG
if args.verbose > 0
else logging.INFO
)
2021-11-22 19:54:04 +00:00
coloredlogs.install(
level=loglevel,
2022-01-19 09:28:51 +00:00
# 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",
2021-11-22 19:54:04 +00:00
)
# File logging
2022-01-19 09:28:51 +00:00
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
2021-11-22 19:54:04 +00:00
logFileHandler = logging.handlers.RotatingFileHandler(
2022-01-19 09:28:51 +00:00
"log/draw_log.log", maxBytes=1024 * 512, backupCount=5
2021-11-22 19:54:04 +00:00
)
logFileHandler.setFormatter(formatter)
2022-01-19 09:28:51 +00:00
logger.addHandler(logFileHandler)
2021-12-08 11:40:45 +00:00
logger.info(f"Start server: http://localhost:{args.port}")
2022-01-19 09:28:51 +00:00
2021-11-22 19:54:04 +00:00
server = Server(args, logger)
server.start()