changeset 2310:3d58c1c78f1f

port ascoltami from cyclone to starlette
author drewp@bigasterisk.com
date Wed, 31 May 2023 13:12:06 -0700
parents 1aaa449e89d0
children a10f0f0e4dae
files bin/ascoltami light9/ascoltami/main.py light9/ascoltami/main_test.py light9/ascoltami/webapp.py
diffstat 4 files changed, 161 insertions(+), 170 deletions(-) [+]
line wrap: on
line diff
--- a/bin/ascoltami	Wed May 31 02:22:57 2023 -0700
+++ b/bin/ascoltami	Wed May 31 13:12:06 2023 -0700
@@ -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
--- a/light9/ascoltami/main.py	Wed May 31 02:22:57 2023 -0700
+++ b/light9/ascoltami/main.py	Wed May 31 13:12:06 2023 -0700
@@ -5,24 +5,29 @@
 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 @@
         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()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/light9/ascoltami/main_test.py	Wed May 31 13:12:06 2023 -0700
@@ -0,0 +1,7 @@
+
+from light9.run_local import log
+
+
+def test_import():
+    import light9.ascoltami.main
+    
\ No newline at end of file
--- a/light9/ascoltami/webapp.py	Wed May 31 02:22:57 2023 -0700
+++ b/light9/ascoltami/webapp.py	Wed May 31 13:12:06 2023 -0700
@@ -1,3 +1,4 @@
+import asyncio
 import json
 import logging
 import socket
@@ -5,15 +6,14 @@
 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 @@
     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 @@
     }
 
 
-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: <seconds>} 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: <seconds>} 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")