# HG changeset patch # User drewp@bigasterisk.com # Date 2023-05-31 20:12:06 # Node ID 3d58c1c78f1f94192f727771eeeb063de76ff44a # Parent 1aaa449e89d0c077a53c66c35b0bd36f1e905677 port ascoltami from cyclone to starlette diff --git a/bin/ascoltami b/bin/ascoltami --- a/bin/ascoltami +++ b/bin/ascoltami @@ -1,4 +1,4 @@ #!/bin/zsh pnpm exec vite -c light9/ascoltami/vite.config.ts & -pdm run python light9/ascoltami/main.py +pdm run uvicorn light9.ascoltami.main:app --host 0.0.0.0 --port 8206 --no-access-log wait diff --git a/light9/ascoltami/main.py b/light9/ascoltami/main.py --- a/light9/ascoltami/main.py +++ b/light9/ascoltami/main.py @@ -5,24 +5,29 @@ import sys from typing import cast import gi -from light9.run_local import log from rdflib import URIRef +from starlette.applications import Starlette +from starlette.routing import Route +from starlette_exporter import PrometheusMiddleware, handle_metrics from twisted.internet import reactor from twisted.internet.interfaces import IReactorCore +from light9.run_local import log + gi.require_version('Gst', '1.0') gi.require_version('Gtk', '3.0') -from gi.repository import Gst # type: ignore +from gi.repository import Gst # type: ignore + from light9 import networking, showconfig +from light9.ascoltami import webapp from light9.ascoltami.player import Player from light9.ascoltami.playlist import NoSuchSong, Playlist -from light9.ascoltami.webapp import makeWebApp, songLocation, songUri reactor = cast(IReactorCore, reactor) -class App: +class Ascoltami: def __init__(self, graph, show): self.graph = graph @@ -34,35 +39,45 @@ class App: self.player.pause() self.player.seek(0) - thisSongUri = songUri(graph, URIRef(song)) + thisSongUri = webapp.songUri(self.graph, URIRef(song)) try: nextSong = self.playlist.nextSong(thisSongUri) except NoSuchSong: # we're at the end of the playlist return - self.player.setSong(songLocation(graph, nextSong), play=False) + self.player.setSong(webapp.songLocation(self.graph, nextSong), play=False) -if __name__ == "__main__": +def main(): Gst.init(None) - parser = optparse.OptionParser() - parser.add_option('--show', help='show URI, like http://light9.bigasterisk.com/show/dance2008', default=showconfig.showUri()) - parser.add_option("-v", "--verbose", action="store_true", help="logging.DEBUG") - parser.add_option("--twistedlog", action="store_true", help="twisted logging") - (options, args) = parser.parse_args() - - log.setLevel(logging.DEBUG if options.verbose else logging.INFO) + graph = showconfig.getGraph() + asco = Ascoltami(graph, showconfig.showUri()) - if not options.show: - raise ValueError("missing --show http://...") + app = Starlette( + debug=True, + routes=[ + Route("/config", webapp.get_config), + Route("/time", webapp.get_time, methods=["GET"]), + Route("/time", webapp.post_time, methods=["POST"]), + Route("/time/stream", webapp.timeStream), + Route("/song", webapp.post_song, methods=["POST"]), + Route("/songs", webapp.get_songs), + Route("/seekPlayOrPause", webapp.post_seekPlayOrPause), + Route("/output", webapp.post_output, methods=["POST"]), + Route("/go", webapp.post_goButton, methods=["POST"]), + ], + ) - graph = showconfig.getGraph() - app = App(graph, URIRef(options.show)) - if options.twistedlog: - from twisted.python import log as twlog - twlog.startLogging(sys.stderr) - reactor.listenTCP(networking.musicPlayer.port, makeWebApp(app)) - log.info("listening on %s" % networking.musicPlayer.port) - reactor.run() + app.add_middleware(PrometheusMiddleware) + app.add_route("/metrics", handle_metrics) + + app.state.graph = graph + app.state.show = asco.show + app.state.player = asco.player + + return app + + +app = main() diff --git a/light9/ascoltami/main_test.py b/light9/ascoltami/main_test.py new file mode 100644 --- /dev/null +++ b/light9/ascoltami/main_test.py @@ -0,0 +1,7 @@ + +from light9.run_local import log + + +def test_import(): + import light9.ascoltami.main + \ No newline at end of file diff --git a/light9/ascoltami/webapp.py b/light9/ascoltami/webapp.py --- a/light9/ascoltami/webapp.py +++ b/light9/ascoltami/webapp.py @@ -1,3 +1,4 @@ +import asyncio import json import logging import socket @@ -5,15 +6,14 @@ import subprocess import time from typing import cast -import cyclone.web -import cyclone.websocket -from cycloneerr import PrettyErrorHandler -from light9.metrics import metricsRoute +from rdflib import RDFS, Graph, URIRef +from light9.ascoltami.player import Player +from sse_starlette.sse import EventSourceResponse +from starlette.requests import Request +from starlette.responses import JSONResponse, PlainTextResponse + from light9.namespaces import L9 from light9.showconfig import getSongsFromShow, showUri, songOnDisk -from rdflib import RDFS, Graph, URIRef -from twisted.internet import reactor -from twisted.internet.interfaces import IReactorTime log = logging.getLogger() _songUris = {} # locationUri : song @@ -29,20 +29,16 @@ def songUri(graph, locationUri): return _songUris[locationUri] -class config(cyclone.web.RequestHandler): - - def get(self): - self.set_header("Content-Type", "application/json") - self.write( - json.dumps( - dict( - host=socket.gethostname(), - show=str(showUri()), - times={ - # these are just for the web display. True values are on Player.__init__ - 'intro': 4, - 'post': 0 - }))) +async def get_config(request: Request) -> JSONResponse: + return JSONResponse( + dict( + host=socket.gethostname(), + show=str(showUri()), + times={ + # these are just for the web display. True values are on Player.__init__ + 'intro': 4, + 'post': 0 + })) def playerSongUri(graph, player): @@ -74,146 +70,119 @@ def currentState(graph, player): } -class timeResource(PrettyErrorHandler, cyclone.web.RequestHandler): - - def get(self): - player = self.settings.app.player - graph = self.settings.app.graph - self.set_header("Content-Type", "application/json") - self.write(json.dumps(currentState(graph, player))) - - def post(self): - """ - post a json object with {pause: true} or {resume: true} if you - want those actions. Use {t: } to seek, optionally - with a pause/resume command too. - """ - params = json.loads(self.request.body) - player = self.settings.app.player - if params.get('pause', False): - player.pause() - if params.get('resume', False): - player.resume() - if 't' in params: - player.seek(params['t']) - self.set_header("Content-Type", "text/plain") - self.write("ok") +async def get_time(request: Request) -> JSONResponse: + player = cast(Player, request.app.state.player) + graph = cast(Graph, request.app.state.graph) + return JSONResponse(currentState(graph, player)) -class timeStreamResource(cyclone.websocket.WebSocketHandler): - - def connectionMade(self, *args, **kwargs) -> None: - self.lastSent = None - self.lastSentTime = 0. - self.loop() - - def loop(self): - now = time.time() - msg = currentState(self.settings.app.graph, self.settings.app.player) - if msg != self.lastSent or now > self.lastSentTime + 2: - # self.sendMessage(json.dumps(msg)) - self.lastSent = msg - self.lastSentTime = now - - if self.transport.connected: - cast(IReactorTime, reactor).callLater(.2, self.loop) # type: ignore - - def connectionLost(self, reason): - log.info("bye ws client %r: %s", self, reason) +async def post_time(request: Request) -> PlainTextResponse: + """ + post a json object with {pause: true} or {resume: true} if you + want those actions. Use {t: } to seek, optionally + with a pause/resume command too. + """ + params = await request.json() + player = cast(Player, request.app.state.player) + if params.get('pause', False): + player.pause() + if params.get('resume', False): + player.resume() + if 't' in params: + player.seek(params['t']) + return PlainTextResponse("ok") -class songs(PrettyErrorHandler, cyclone.web.RequestHandler): - - def get(self): - graph = cast(Graph, self.settings.app.graph) - - songs = getSongsFromShow(graph, self.settings.app.show) +async def timeStream(request: Request): + graph = cast(Graph, request.app.state.graph) + player = cast(Player, request.app.state.player) + async def event_generator(): + last_sent = None + last_sent_time = 0.0 - self.set_header("Content-Type", "application/json") - self.write(json.dumps({ - "songs": [ - { # - "uri": s, - "path": graph.value(s, L9['songFilename']), - "label": graph.value(s, RDFS.label) - } for s in songs - ] - })) + while True: + now = time.time() + msg = currentState(graph, player) + if msg != last_sent or now > last_sent_time + 2: + event_data = json.dumps(msg) + yield event_data + last_sent = msg + last_sent_time = now + + await asyncio.sleep(0.2) + + return EventSourceResponse(event_generator()) -class songResource(PrettyErrorHandler, cyclone.web.RequestHandler): +async def get_songs(request: Request) -> JSONResponse: + graph = cast(Graph, request.app.state.graph) + + songs = getSongsFromShow(graph, request.app.state.show) - def post(self): - """post a uri of song to switch to (and start playing)""" - graph = self.settings.app.graph + songs_data = [ + { # + "uri": s, + "path": graph.value(s, L9['songFilename']), + "label": graph.value(s, RDFS.label) + } for s in songs + ] - self.settings.app.player.setSong(songLocation(graph, URIRef(self.request.body.decode('utf8')))) - self.set_header("Content-Type", "text/plain") - self.write("ok") + return JSONResponse({"songs": songs_data}) -class seekPlayOrPause(PrettyErrorHandler, cyclone.web.RequestHandler): - """curveCalc's ctrl-p or a vidref scrub""" - - def post(self): - player = self.settings.app.player +async def post_song(request: Request) -> PlainTextResponse: + """post a uri of song to switch to (and start playing)""" + graph = cast(Graph, request.app.state.graph) + player = cast(Player, request.app.state.player) - data = json.loads(self.request.body) - if 'scrub' in data: - player.pause() - player.seek(data['scrub']) - return - if 'action' in data: - if data['action'] == 'play': - player.resume() - elif data['action'] == 'pause': - player.pause() - else: - raise NotImplementedError - return - if player.isPlaying(): - player.pause() - else: - player.seek(data['t']) - player.resume() + song_uri = URIRef((await request.body()).decode('utf8')) + player.setSong(songLocation(graph, song_uri)) + + return PlainTextResponse("ok") -class output(PrettyErrorHandler, cyclone.web.RequestHandler): +async def post_seekPlayOrPause(request: Request) -> PlainTextResponse: + """curveCalc's ctrl-p or a vidref scrub""" + player = cast(Player, request.app.state.player) - def post(self): - d = json.loads(self.request.body) - subprocess.check_call(["bin/movesinks", str(d['sink'])]) + data = await request.json() + if 'scrub' in data: + player.pause() + player.seek(data['scrub']) + return PlainTextResponse("ok") + if 'action' in data: + if data['action'] == 'play': + player.resume() + elif data['action'] == 'pause': + player.pause() + else: + raise NotImplementedError + return PlainTextResponse("ok") + if player.isPlaying(): + player.pause() + else: + player.seek(data['t']) + player.resume() + + return PlainTextResponse("ok") -class goButton(PrettyErrorHandler, cyclone.web.RequestHandler): - - def post(self): - """ - if music is playing, this silently does nothing. - """ - player = self.settings.app.player - - if player.isAutostopped(): - player.resume() - elif player.isPlaying(): - pass - else: - player.resume() - - self.set_header("Content-Type", "text/plain") - self.write("ok") +async def post_output(request: Request) -> PlainTextResponse: + d = await request.json() + subprocess.check_call(["bin/movesinks", str(d['sink'])]) + return PlainTextResponse("ok") -def makeWebApp(app): - return cyclone.web.Application(handlers=[ - (r"/config", config), - (r"/time", timeResource), - (r"/time/stream", timeStreamResource), - (r"/song", songResource), - (r"/songs", songs), - (r"/seekPlayOrPause", seekPlayOrPause), - (r"/output", output), - (r"/go", goButton), - metricsRoute(), - ], - app=app) +async def post_goButton(request: Request) -> PlainTextResponse: + """ + if music is playing, this silently does nothing. + """ + player = cast(Player, request.app.state.player) + + if player.isAutostopped(): + player.resume() + elif player.isPlaying(): + pass + else: + player.resume() + return PlainTextResponse("ok")