Changeset - 3d58c1c78f1f
[Not reviewed]
default
0 3 1
drewp@bigasterisk.com - 20 months ago 2023-05-31 20:12:06
drewp@bigasterisk.com
port ascoltami from cyclone to starlette
4 files changed with 161 insertions and 170 deletions:
0 comments (0 inline, 0 general)
bin/ascoltami
Show inline comments
 
#!/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
light9/ascoltami/main.py
Show inline comments
 
@@ -2,67 +2,82 @@
 
import logging
 
import optparse
 
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
 
        self.player = Player(onEOS=self.onEOS, autoStopOffset=0)
 
        self.show = show
 
        self.playlist = Playlist.fromShow(graph, show)
 

	
 
    def onEOS(self, song):
 
        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()
light9/ascoltami/main_test.py
Show inline comments
 
new file 100644
 

	
 
from light9.run_local import log
 

	
 

	
 
def test_import():
 
    import light9.ascoltami.main
 
    
 
\ No newline at end of file
light9/ascoltami/webapp.py
Show inline comments
 
import asyncio
 
import json
 
import logging
 
import socket
 
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
 

	
 

	
 
def songLocation(graph, songUri):
 
@@ -26,26 +26,22 @@ def songLocation(graph, songUri):
 

	
 

	
 
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):
 
    """or None"""
 

	
 
    playingLocation = player.getSong()
 
@@ -71,149 +67,122 @@ def currentState(graph, player):
 
        "t": player.currentTime(),
 
        "state": player.states(),
 
        "next": nextAction,
 
    }
 

	
 

	
 
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")
0 comments (0 inline, 0 general)