changeset 2427:cc69faa87c27

tear up and rewrite ascoltami to emit player state into the graph. web ui works but displays nothing but songs
author drewp@bigasterisk.com
date Sat, 25 May 2024 15:44:11 -0700
parents 90792e984249
children 5f4c8705cf73
files src/light9/ascoltami/main.py src/light9/ascoltami/player.py src/light9/ascoltami/playlist.py src/light9/ascoltami/webapp.py src/light9/showconfig.py web/ascoltami/main.ts
diffstat 6 files changed, 363 insertions(+), 287 deletions(-) [+]
line wrap: on
line diff
--- a/src/light9/ascoltami/main.py	Sat May 25 15:41:27 2024 -0700
+++ b/src/light9/ascoltami/main.py	Sat May 25 15:44:11 2024 -0700
@@ -1,36 +1,62 @@
 #!bin/python
 import logging
-import optparse
-import sys
 from typing import cast
 
-import gi
-from rdflib import URIRef
+import louie
+from rdfdb.syncedgraph.syncedgraph import SyncedGraph
+from rdflib import ConjunctiveGraph, Literal, URIRef
+from rdflib.graph import _ContextType
 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
-
 
 from light9 import networking, showconfig
 from light9.ascoltami import webapp
-from light9.ascoltami.player import Player
 from light9.ascoltami.import_gst import Gst
+from light9.ascoltami.player import Player, PlayerState
 from light9.ascoltami.playlist import NoSuchSong, Playlist
-
-reactor = cast(IReactorCore, reactor)
-
+from light9.namespaces import L9
+from light9.newtypes import decimalLiteral
+from light9.run_local import log
 
 class Ascoltami:
 
-    def __init__(self, graph, show):
+    def __init__(self, graph: SyncedGraph, show: URIRef):
         self.graph = graph
-        self.player = Player(onEOS=self.onEOS, autoStopOffset=0)
+        self.player = Player(onStateChange=self.onStateChange, autoStopOffset=0)
         self.show = show
-        self.playlist = Playlist.fromShow(graph, show)
+        self.ctx = cast(_ContextType, (URIRef(self.show + '/ascoltami')))
+
+        self.playerState = PlayerState()
+        self.playlist = Playlist(graph, show)
+
+    def onStateChange(self, s: PlayerState):
+        log.info('louie send')
+        louie.send(webapp.OnStateChange, s=s)
+        g = self.stateAsGraph(s)
+
+        self.graph.patchSubgraph(newGraph=g, context=self.ctx)
+        self.playerState = s
+
+    def stateAsGraph(self, s):
+        g = ConjunctiveGraph()
+        asc = L9['ascoltami']
+        if s.song:
+            try:
+                g.add((asc, L9['song'], self.playlist.songUri(s.song), self.ctx))
+            except KeyError:
+                pass
+        g.add((asc, L9['duration'], decimalLiteral(s.duration), self.ctx))
+        g.add((asc, L9['playing'], Literal(s.playing), self.ctx))
+        if s.wallStartTime is not None:
+            g.add((asc, L9['wallStartTime'], decimalLiteral(s.wallStartTime), self.ctx))
+        if s.pausedSongTime is not None:
+            g.add((asc, L9['pausedSongTime'], decimalLiteral(s.pausedSongTime), self.ctx))
+        g.add((asc, L9['endOfSong'], Literal(s.endOfSong), self.ctx))
+        return g
+
+    def getPlayerState(self) -> PlayerState:
+        return self.playerState
 
     def onEOS(self, song):
         self.player.pause()
@@ -50,31 +76,28 @@
     logging.getLogger('sse_starlette.sse').setLevel(logging.INFO)
     Gst.init(None)
 
-    graph = showconfig.getGraph()
+    graph = SyncedGraph(networking.rdfdb.url, "ascoltami")
     asco = Ascoltami(graph, showconfig.showUri())
 
+    h = webapp.WebHandlers(graph, asco.show, asco.player, asco.playlist, asco.getPlayerState)
     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"]),
+            Route("/config", h.get_config),
+            Route("/time", h.get_time, methods=["GET"]),
+            Route("/time", h.post_time, methods=["POST"]),
+            Route("/time/stream", h.timeStream),
+            Route("/song", h.post_song, methods=["POST"]),
+            Route("/songs", h.get_songs),
+            Route("/seekPlayOrPause", h.post_seekPlayOrPause),
+            Route("/output", h.post_output, methods=["POST"]),
+            Route("/go", h.post_goButton, methods=["POST"]),
         ],
     )
 
     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
 
 
--- a/src/light9/ascoltami/player.py	Sat May 25 15:41:27 2024 -0700
+++ b/src/light9/ascoltami/player.py	Sat May 25 15:44:11 2024 -0700
@@ -1,60 +1,89 @@
 """
-alternate to the mpd music player, for ascoltami
+alternate to the mpd music player, for ascoltami.
+
+This module doesn't know URIRef or graph. It does use 
+GstFileUri (str) for filesystem paths.
 """
 
+import asyncio
 import logging
 import time
 import traceback
 from dataclasses import dataclass
-from pathlib import Path
+from typing import NewType
 
-from rdflib import URIRef
-from twisted.internet import task
 from light9.ascoltami.import_gst import Gst  # type: ignore
 from light9.metrics import metrics
+
 log = logging.getLogger()
 
+GstFileUri = NewType('GstFileUri', str)
+
+
+@dataclass
+class PlayerState:
+    song: GstFileUri | None = None
+    duration: float = 0
+    playing: bool = False  # time is advancing, and song time is now()-wallStartTime
+    # wall time of song-time 0 (this is adjusted when you seek)
+    wallStartTime: float = 0
+    pausedSongTime: float | None = None  # if we're paused, this has the song time
+    endOfSong: bool = False  # True if we're in the stopped state due to EOS
 
 
 class Player:
 
-    def __init__(self, autoStopOffset=4, onEOS=None):
+    def __init__(self, autoStopOffset=4, onStateChange=lambda ps: None):
         """autoStopOffset is the number of seconds before the end of
         song before automatically stopping (which is really pausing).
-        onEOS is an optional function to be called when we reach the
-        end of a stream (for example, can be used to advance the song).
-        It is called with one argument which is the URI of the song that
-        just finished."""
+        """
         self.autoStopOffset = autoStopOffset
-        self.playbin = self.pipeline = Gst.ElementFactory.make('playbin', None)
+        self.onStateChange = onStateChange
+        self._playbin = self.pipeline = Gst.ElementFactory.make('playbin', None)
 
-        self._playStartTime = 0
+        self.playerState: PlayerState = PlayerState()
+
         self._lastWatchTime = 0
         self._autoStopTime = 0
-        self._lastSetSongUri = None
-        self._onEOS = onEOS
+        self._lastSetSongUri: GstFileUri | None = None
 
-        task.LoopingCall(self.watchTime).start(.050)
+        # task.LoopingCall(self._watchTime).start(.050)
+        asyncio.create_task(self._watchTime())
 
         #bus = self.pipeline.get_bus()
         # not working- see notes in pollForMessages
-        #self.watchForMessages(bus)
+        #self._watchForMessages(bus)
 
-    def watchTime(self):
-        try:
-            self.pollForMessages()
+    async def _watchTime(self):
+        while True:
+            now = time.time()
+            try:
+                self._pollForMessages()
+                t = self.currentTime()
+                # log.debug("watch %s < %s < %s", self._lastWatchTime, self._autoStopTime, t)
+                if self._lastWatchTime < self._autoStopTime < t:
+                    log.info("autostop")
+                    self.pause()
 
-            t = self.currentTime()
-            log.debug("watch %s < %s < %s", self._lastWatchTime, self._autoStopTime, t)
-            if self._lastWatchTime < self._autoStopTime < t:
-                log.info("autostop")
-                self.pause()
+                ps = PlayerState(
+                    song=self._getSongFileUri(),
+                    duration=round(self.duration(), 2),
+                    wallStartTime=round(now - t, 2) if self.isPlaying() else None,
+                    playing=self.isPlaying(),
+                    pausedSongTime=None if self.isPlaying() else t,
+                    endOfSong=t >= self.duration() - .1,  #todo
+                )
 
-            self._lastWatchTime = t
-        except Exception:
-            traceback.print_exc()
+                if self.playerState != ps:
+                    self.onStateChange(ps)
+                    self.playerState = ps
 
-    def watchForMessages(self, bus):
+                self._lastWatchTime = t
+            except Exception:
+                traceback.print_exc()
+            await asyncio.sleep(0.5)
+
+    def _watchForMessages(self, bus):
         """this would be nicer than pollForMessages but it's not working for
         me. It's like add_signal_watch isn't running."""
         bus.add_signal_watch()
@@ -62,7 +91,7 @@
         def onEos(*args):
             print("onEos", args)
             if self._onEOS is not None:
-                self._onEOS(self.getSong())
+                self._onEOS(self._getSongFileUri())
 
         bus.connect('message::eos', onEos)
 
@@ -74,7 +103,7 @@
 
         bus.connect('message::stream-status', onStreamStatus)
 
-    def pollForMessages(self):
+    def _pollForMessages(self):
         """bus.add_signal_watch seems to be having no effect, but this works"""
         bus = self.pipeline.get_bus()
         mt = Gst.MessageType
@@ -90,26 +119,25 @@
             if msg.type == mt.ERROR:
                 log.error(repr(msg.parse_error()))
             if msg.type == mt.EOS:
-                if self._onEOS is not None:
-                    self._onEOS(self.getSong())
+                log.info("EOS msg: todo")
             if msg.type == mt.STREAM_STATUS:
                 (statusType, _elem) = msg.parse_stream_status()
                 if statusType == Gst.StreamStatusType.ENTER:
                     self.setupAutostop()
 
     def seek(self, t):
-        isSeekable = self.playbin.seek_simple(Gst.Format.TIME, Gst.SeekFlags.FLUSH | Gst.SeekFlags.ACCURATE | Gst.SeekFlags.SKIP, t * Gst.SECOND)
+        isSeekable = self._playbin.seek_simple(Gst.Format.TIME, Gst.SeekFlags.FLUSH | Gst.SeekFlags.ACCURATE | Gst.SeekFlags.SKIP, t * Gst.SECOND)
         if not isSeekable:
             raise ValueError('seek_simple failed')
         self._playStartTime = time.time()
 
-    def setSong(self, songLoc, play=True):
+    def setSong(self, songLoc: GstFileUri, play=True):
         """
         uri like file:///my/proj/light9/show/dance2010/music/07.wav
         """
         log.info("set song to %r" % songLoc)
         self.pipeline.set_state(Gst.State.READY)
-        self.preload(songLoc)
+        self._preload(songLoc)
         self.pipeline.set_property("uri", songLoc)
         self._lastSetSongUri = songLoc
         # todo: don't have any error report yet if the uri can't be read
@@ -117,12 +145,15 @@
             self.pipeline.set_state(Gst.State.PLAYING)
             self._playStartTime = time.time()
 
-    def getSong(self):
-        """Returns the URI of the current song."""
+    def _getSongFileUri(self) -> GstFileUri | None:
+        """Returns the file URI of the current song."""
         # even the 'uri' that I just set isn't readable yet
-        return self.playbin.get_property("uri") or self._lastSetSongUri
+        return GstFileUri(self._playbin.get_property("uri")) or self._lastSetSongUri
 
-    def preload(self, songPath):
+    def getSong(self) -> GstFileUri | None:
+        return self.playerState.song
+
+    def _preload(self, songPath):
         """
         to avoid disk seek stutters, which happened sometimes (in 2007) with the
         non-gst version of this program, we read the whole file to get
@@ -140,13 +171,13 @@
 
     @metrics('current_time').time()
     def currentTime(self):
-        success, cur = self.playbin.query_position(Gst.Format.TIME)
+        success, cur = self._playbin.query_position(Gst.Format.TIME)
         if not success:
             return 0
         return cur / Gst.SECOND
 
     def duration(self):
-        success, dur = self.playbin.query_duration(Gst.Format.TIME)
+        success, dur = self._playbin.query_duration(Gst.Format.TIME)
         if not success:
             return 0
         return dur / Gst.SECOND
@@ -154,7 +185,7 @@
     def states(self):
         """json-friendly object describing the interesting states of
         the player nodes"""
-        success, state, pending = self.playbin.get_state(timeout=0)
+        success, state, pending = self._playbin.get_state(timeout=0)
         return {"current": {"name": state.value_nick}, "pending": {"name": state.value_nick}}
 
     def pause(self):
--- a/src/light9/ascoltami/playlist.py	Sat May 25 15:41:27 2024 -0700
+++ b/src/light9/ascoltami/playlist.py	Sat May 25 15:44:11 2024 -0700
@@ -1,5 +1,13 @@
+import logging
+
+from rdfdb.syncedgraph.syncedgraph import SyncedGraph
+from rdflib import RDFS, URIRef
+
+from light9.ascoltami.player import GstFileUri
+from light9.namespaces import L9
 from light9.showconfig import songOnDisk
-from light9.namespaces import L9
+
+log = logging.getLogger('list')
 
 
 class NoSuchSong(ValueError):
@@ -8,48 +16,65 @@
 
 
 class Playlist:
+    """
+    maintains a view of the playlist in the rdf graph. Also produces GstFileUri strs
+    for Player
+    """
+    _songs: list[URIRef]
+    _labels: dict[URIRef, str]
+    _nextSong: dict[URIRef, URIRef]
+    _songUri: dict[GstFileUri, URIRef]
+    _fileUri: dict[URIRef, GstFileUri]
 
-    def __init__(self, graph, playlistUri):
+    def __init__(self, graph: SyncedGraph, show: URIRef):
         self.graph = graph
-        self.playlistUri = playlistUri
-        self.songs = list(graph.items(playlistUri))
+        self.show = show
+        self.graph.addHandler(self._readSongs)
 
-    def nextSong(self, currentSong):
+    def _readSongs(self):
+        self._songs = []
+        self._labels = {}
+        self._songUri = {}
+        self._fileUri = {}
+        playlistUri = self.graph.value(self.show, L9['playList'], default=None)
+        if not playlistUri:
+            # expected at startup that we'd have no data
+            return
+        prevSong = None
+        for s in self.graph.items(playlistUri):
+            f = GstFileUri('file://' + str(songOnDisk(self.graph, s)))
+            self._songs.append(s)
+            self._labels[s] = self.graph.value(s, RDFS.label, default="").toPython()
+            self._songUri[f] = s
+            self._fileUri[s] = f
+            if prevSong is not None:
+                self._nextSong[prevSong] = s
+        log.info(f'read {len(self._songs)} songs')
+
+    def nextSong(self, currentSong: URIRef) -> URIRef:
         """Returns the next song in the playlist or raises NoSuchSong if 
         we are at the end of the playlist."""
         try:
-            currentIndex = self.songs.index(currentSong)
-        except IndexError:
-            raise ValueError("%r is not in the current playlist (%r)." %
-                             (currentSong, self.playlistUri))
+            return self._nextSong[currentSong]
+        except KeyError:
+            raise NoSuchSong()
 
-        try:
-            nextSong = self.songs[currentIndex + 1]
-        except IndexError:
-            raise NoSuchSong("%r is the last item in the playlist." %
-                             currentSong)
+    def fileUri(self, song: URIRef) -> GstFileUri:
+        return self._fileUri[song]
 
-        return nextSong
+    def songUri(self, fileUri: GstFileUri) -> URIRef:
+        return self._songUri[fileUri]
+
+    def label(self, song: URIRef) -> str:
+        return self._labels[song]
 
     def allSongs(self):
         """Returns a list of all song URIs in order."""
-        return self.songs
-
-    def allSongPaths(self):
-        """Returns a list of the filesystem paths to all songs in order."""
-        paths = []
-        for song in self.songs:
-            paths.append(songOnDisk(song))
-        return paths
+        return self._songs
 
-    def songPath(self, uri):
-        """filesystem path to a song"""
-        raise NotImplementedError("see showconfig.songOnDisk")
-        # maybe that function should be moved to this method
-
-    @classmethod
-    def fromShow(cls, graph, show):
-        playlistUri = graph.value(show, L9['playList'])
-        if not playlistUri:
-            raise ValueError("%r has no l9:playList" % show)
-        return cls(graph, playlistUri)
+    # def allSongPaths(self) -> list[Path]:
+    #     """Returns a list of the filesystem paths to all songs in order."""
+    #     paths = []
+    #     for row in self._songs:
+    #         paths.append(row.songPath)
+    #     return paths
--- a/src/light9/ascoltami/webapp.py	Sat May 25 15:41:27 2024 -0700
+++ b/src/light9/ascoltami/webapp.py	Sat May 25 15:44:11 2024 -0700
@@ -1,189 +1,187 @@
+"""
+this module shouldn't be necessary for playback to work
+"""
 import asyncio
 import json
 import logging
 import socket
 import subprocess
+from dataclasses import dataclass, field
 import time
-from typing import cast
+from typing import Callable
+from typing import Literal as Lit
 
-from rdflib import RDFS, Graph, URIRef
+import louie
+from rdfdb.syncedgraph.syncedgraph import SyncedGraph
+from rdflib import URIRef
 from sse_starlette.sse import EventSourceResponse
 from starlette.requests import Request
 from starlette.responses import JSONResponse, PlainTextResponse
 
-from light9.ascoltami.player import Player
-from light9.namespaces import L9
-from light9.showconfig import getSongsFromShow, showUri, songOnDisk
-
-log = logging.getLogger()
-_songUris = {}  # locationUri : song
-
-
-def songLocation(graph, songUri):
-    loc = URIRef("file://%s" % songOnDisk(songUri))
-    _songUris[loc] = songUri
-    return loc
-
-
-def songUri(graph, locationUri):
-    return _songUris[locationUri]
-
+from light9.ascoltami.player import Player, PlayerState
+from light9.ascoltami.playlist import Playlist
+from light9.showconfig import showUri
 
-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()
-    if playingLocation:
-        return songUri(graph, URIRef(playingLocation))
-    else:
-        return None
+log = logging.getLogger("web")
 
 
-def currentState(graph, player):
-    if player.isAutostopped():
-        nextAction = 'finish'
-    elif player.isPlaying():
-        nextAction = 'disabled'
-    else:
-        nextAction = 'play'
-
-    return {
-        "song": playerSongUri(graph, player),
-        "started": player.playStartTime,
-        "duration": player.duration(),
-        "playing": player.isPlaying(),
-        "t": player.currentTime(),
-        "state": player.states(),
-        "next": nextAction,
-    }
+class OnStateChange:
+    pass
 
 
-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))
-
-
-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")
+@dataclass
+class PlayerState2(PlayerState):
+    song2: URIRef | None = None
+    nextAction: Lit["finish"] | Lit['disabled'] | Lit['play'] = 'disabled'
 
 
-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
+@dataclass
+class WebHandlers:
+    graph: SyncedGraph
+    show: URIRef
+    player: Player
+    playlist: Playlist
+    getPlayerState: Callable[[], PlayerState]
 
-        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
+    # _keep:list=field(default_factory=list)
+    async def get_config(self, 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
+                }))
 
-            await asyncio.sleep(0.1)
-
-    return EventSourceResponse(event_generator())
-
+    def currentState(self, player: Player, playlist: Playlist) -> PlayerState2:
+        if player.isAutostopped():
+            nextAction = 'finish'
+        elif player.isPlaying():
+            nextAction = 'disabled'
+        else:
+            nextAction = 'play'
 
-async def get_songs(request: Request) -> JSONResponse:
-    graph = cast(Graph, request.app.state.graph)
+        ps = self.getPlayerState()
+        return PlayerState2(
+            song2=playlist.songUri((ps.song)) if ps.song else None,
+            duration=ps.duration,
+            playing=ps.playing,
+            # state= player.states(),
+            nextAction=nextAction,
+        )
 
-    songs = getSongsFromShow(graph, request.app.state.show)
+    async def get_time(self, request: Request) -> JSONResponse:
+        return JSONResponse({'t': self.player.currentTime()})
 
-    songs_data = [
-        {  #
-            "uri": s,
-            "path": graph.value(s, L9['songFilename']),
-            "label": graph.value(s, RDFS.label)
-        } for s in songs
-    ]
+    async def post_time(self, 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()
+        if params.get('pause', False):
+            self.player.pause()
+        if params.get('resume', False):
+            self.player.resume()
+        if 't' in params:
+            self.player.seek(params['t'])
+        return PlainTextResponse("ok")
 
-    return JSONResponse({"songs": songs_data})
-
+    async def timeStream(self, request: Request):
 
-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)
+        async def event_generator():
+            last_sent = None
+            last_sent_time = 0.0
 
-    song_uri = URIRef((await request.body()).decode('utf8'))
-    player.setSong(songLocation(graph, song_uri))
+            def onStateChange(s: PlayerState2):
+                log.info('ws heanndlerr gets state')
+
+            louie.connect(onStateChange, OnStateChange, weak=False)
 
-    return PlainTextResponse("ok")
-
+            try:
+                while True:
+                    now = time.time()
+                    msg = self.currentState(self.player, self.playlist)
+                    if msg != last_sent or now > last_sent_time + 2:
+                        event_data = json.dumps({
+                            # obsolete- watch the graph for these
+                            'duration': msg.duration,
+                            'playing': msg.playing,
+                            'song': self.playlist.songUri(msg.song) if msg.song else None,
+                            'state': {},
+                        })
+                        yield event_data
+                        last_sent = msg
+                        last_sent_time = now
 
-async def post_seekPlayOrPause(request: Request) -> PlainTextResponse:
-    """curveCalc's ctrl-p or a vidref scrub"""
-    player = cast(Player, request.app.state.player)
+                    await asyncio.sleep(0.1)
+            finally:
+                log.info(f'bye listnner {event_generator}')
+                louie.disconnect(onStateChange, OnStateChange, weak=False)
 
-    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 EventSourceResponse(event_generator())
+
+    async def get_songs(self, request: Request) -> JSONResponse:
+
+        songs_data = [
+            {  #
+                "uri": s,
+                "path": self.playlist.fileUri(s),
+                "label": self.playlist.label(s),
+            } for s in self.playlist.allSongs()
+        ]
+
+        return JSONResponse({"songs": songs_data})
+
+    async def post_song(self, request: Request) -> PlainTextResponse:
+        """post a uri of song to switch to (and start playing)"""
+
+        song_uri = URIRef((await request.body()).decode('utf8'))
+        self.player.setSong(self.playlist.fileUri(song_uri))
+
         return PlainTextResponse("ok")
-    if player.isPlaying():
-        player.pause()
-    else:
-        player.seek(data['t'])
-        player.resume()
 
-    return PlainTextResponse("ok")
-
-
-async def post_output(request: Request) -> PlainTextResponse:
-    d = await request.json()
-    subprocess.check_call(["bin/movesinks", str(d['sink'])])
-    return PlainTextResponse("ok")
+    async def post_seekPlayOrPause(self, request: Request) -> PlainTextResponse:
+        """curveCalc's ctrl-p or a vidref scrub"""
 
-
-async def post_goButton(request: Request) -> PlainTextResponse:
-    """
-    if music is playing, this silently does nothing.
-    """
-    player = cast(Player, request.app.state.player)
+        data = await request.json()
+        if 'scrub' in data:
+            self.player.pause()
+            self.player.seek(data['scrub'])
+            return PlainTextResponse("ok")
+        if 'action' in data:
+            if data['action'] == 'play':
+                self.player.resume()
+            elif data['action'] == 'pause':
+                self.player.pause()
+            else:
+                raise NotImplementedError
+            return PlainTextResponse("ok")
+        if self.player.isPlaying():
+            self.player.pause()
+        else:
+            self.player.seek(data['t'])
+            self.player.resume()
 
-    if player.isAutostopped():
-        player.resume()
-    elif player.isPlaying():
-        pass
-    else:
-        player.resume()
-    return PlainTextResponse("ok")
+        return PlainTextResponse("ok")
+
+    async def post_output(self, request: Request) -> PlainTextResponse:
+        d = await request.json()
+        subprocess.check_call(["bin/movesinks", str(d['sink'])])
+        return PlainTextResponse("ok")
+
+    async def post_goButton(self, request: Request) -> PlainTextResponse:
+        """
+        if music is playing, this silently does nothing.
+        """
+
+        if self.player.isAutostopped():
+            self.player.resume()
+        elif self.player.isPlaying():
+            pass
+        else:
+            self.player.resume()
+        return PlainTextResponse("ok")
--- a/src/light9/showconfig.py	Sat May 25 15:41:27 2024 -0700
+++ b/src/light9/showconfig.py	Sat May 25 15:44:11 2024 -0700
@@ -1,28 +1,31 @@
-import logging, warnings
+import logging
+import os
+import warnings
+from os import getenv
+from pathlib import Path
+from typing import cast
+
+from rdfdb.syncedgraph.syncedgraph import SyncedGraph
+from rdflib import Graph, Literal, URIRef
 from twisted.python.filepath import FilePath
-from os import path, getenv
-from rdflib import Graph
-from rdflib import URIRef, Literal
+
 from .namespaces import L9
-from typing import List, cast
+
 log = logging.getLogger('showconfig')
 
 _config = None  # graph
 
 
 def getGraph() -> Graph:
-    warnings.warn(
-        "code that's using showconfig.getGraph should be "
-        "converted to use the sync graph",
-        stacklevel=2)
+    warnings.warn("code that's using showconfig.getGraph should be "
+                  "converted to use the sync graph", stacklevel=2)
     global _config
     if _config is None:
         graph = Graph()
         # note that logging is probably not configured the first time
         # we're in here
         warnings.warn("reading n3 files around %r" % root())
-        for f in FilePath(root()).globChildren("*.n3") + FilePath(
-                root()).globChildren("build/*.n3"):
+        for f in FilePath(root()).globChildren("*.n3") + FilePath(root()).globChildren("build/*.n3"):
             graph.parse(location=f.path, format='n3')
         _config = graph
     return _config
@@ -31,8 +34,7 @@
 def root() -> bytes:
     r = getenv("LIGHT9_SHOW")
     if r is None:
-        raise OSError(
-            "LIGHT9_SHOW env variable has not been set to the show root")
+        raise OSError("LIGHT9_SHOW env variable has not been set to the show root")
     return r.encode('ascii')
 
 
@@ -43,13 +45,12 @@
     """Return the show URI associated with $LIGHT9_SHOW."""
     global _showUri
     if _showUri is None:
-        _showUri = URIRef(open(path.join(root(), b'URI')).read().strip())
+        _showUri = URIRef(open(os.path.join(root(), b'URI')).read().strip())
     return _showUri
 
 
-def songOnDisk(song: URIRef) -> bytes:
+def songOnDisk(graph: SyncedGraph, song: URIRef) -> Path:
     """given a song URI, where's the on-disk file that mpd would read?"""
-    graph = getGraph()
     root = graph.value(showUri(), L9['musicRoot'])
     if not root:
         raise ValueError("%s has no :musicRoot" % showUri())
@@ -58,10 +59,7 @@
     if not name:
         raise ValueError("Song %r has no :songFilename" % song)
 
-    return path.abspath(
-        path.join(
-            cast(Literal, root).toPython(),
-            cast(Literal, name).toPython()))
+    return (Path(cast(Literal, root).toPython()) / cast(Literal, name).toPython()).absolute()
 
 
 def songFilenameFromURI(uri: URIRef) -> bytes:
@@ -74,7 +72,7 @@
     return str(uri).split('/')[-1].encode('ascii')
 
 
-def getSongsFromShow(graph: Graph, show: URIRef) -> List[URIRef]:
+def getSongsFromShow(graph: Graph, show: URIRef) -> list[URIRef]:
     playList = graph.value(show, L9['playList'])
     if not playList:
         raise ValueError("%r has no l9:playList" % show)
@@ -82,16 +80,16 @@
     # serious bug here.
     songs = list(graph.items(playList))
 
-    return songs
+    return cast(list[URIRef], songs)
 
 
 def curvesDir():
-    return path.join(root(), b"curves")
+    return os.path.join(root(), b"curves")
 
 
 def subFile(subname):
-    return path.join(root(), b"subs", subname)
+    return os.path.join(root(), b"subs", subname)
 
 
 def subsDir():
-    return path.join(root(), b'subs')
+    return os.path.join(root(), b'subs')
--- a/web/ascoltami/main.ts	Sat May 25 15:41:27 2024 -0700
+++ b/web/ascoltami/main.ts	Sat May 25 15:44:11 2024 -0700
@@ -2,6 +2,7 @@
   return document.getElementById(id)!;
 }
 
+// obsolete: watch the graph instead
 export interface TimingUpdate {
   // GET /ascoltami/time response
   duration: number;
@@ -18,7 +19,7 @@
   // let lastPlaying = false;
 
   
-  const events = new EventSource("../service/ascoltami/time/stream");
+  const events = new EventSource("/service/ascoltami/time/stream");
   events.addEventListener("message", (m)=>{
     const update = JSON.parse(m.data) as TimingUpdate
     updateCurrent(update)