From 582a4628b7d1792df122a57a0c2e8c132f02f1cb Mon Sep 17 00:00:00 2001 From: Danielle McLean Date: Sat, 13 Jul 2024 18:34:53 +1000 Subject: [PATCH] Introduce new WebSockets receiver impl MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When enabled, this new receiver will spin up a local WebSockets server and will send the currently playing song information to any clients that connect. It's designed with Übersicht in mind, since WebSockets is the easiest way to efficiently push events into an Übersicht widget, but I'm sure it'd work for a variety of other purposes too. Currently the socket is only used in one direction, pushing the current song info from server to client, but I'll probably extend it to support sending MPD commands from WebSockets clients as well. --- schemata/config-v1.json | 46 +++- schemata/song-v1.json | 205 ++++++++++++++++++ src/mpd_now_playable/config/model.py | 10 +- .../receivers/websockets/__init__.py | 2 + .../receivers/websockets/receiver.py | 52 +++++ src/mpd_now_playable/song/musicbrainz.py | 19 +- src/mpd_now_playable/song/song.py | 49 +++++ src/mpd_now_playable/tools/schema/generate.py | 6 + 8 files changed, 379 insertions(+), 10 deletions(-) create mode 100644 schemata/song-v1.json create mode 100644 src/mpd_now_playable/receivers/websockets/__init__.py create mode 100644 src/mpd_now_playable/receivers/websockets/receiver.py diff --git a/schemata/config-v1.json b/schemata/config-v1.json index 14600a1..8c0c9e6 100644 --- a/schemata/config-v1.json +++ b/schemata/config-v1.json @@ -42,6 +42,46 @@ }, "title": "MpdConfig", "type": "object" + }, + "WebsocketsReceiverConfig": { + "properties": { + "host": { + "anyOf": [ + { + "format": "hostname", + "type": "string" + }, + { + "items": { + "format": "hostname", + "type": "string" + }, + "type": "array" + } + ], + "title": "Host" + }, + "kind": { + "const": "websockets", + "default": "websockets", + "enum": [ + "websockets" + ], + "title": "Kind", + "type": "string" + }, + "port": { + "maximum": 65535, + "minimum": 1, + "title": "Port", + "type": "integer" + } + }, + "required": [ + "port" + ], + "title": "WebsocketsReceiverConfig", + "type": "object" } }, "$id": "https://cdn.00dani.me/m/schemata/mpd-now-playable/config-v1.json", @@ -65,13 +105,17 @@ "items": { "discriminator": { "mapping": { - "cocoa": "#/$defs/CocoaReceiverConfig" + "cocoa": "#/$defs/CocoaReceiverConfig", + "websockets": "#/$defs/WebsocketsReceiverConfig" }, "propertyName": "kind" }, "oneOf": [ { "$ref": "#/$defs/CocoaReceiverConfig" + }, + { + "$ref": "#/$defs/WebsocketsReceiverConfig" } ] }, diff --git a/schemata/song-v1.json b/schemata/song-v1.json new file mode 100644 index 0000000..6ee74de --- /dev/null +++ b/schemata/song-v1.json @@ -0,0 +1,205 @@ +{ + "$defs": { + "HasArtwork": { + "properties": { + "data": { + "format": "binary", + "title": "Data", + "type": "string" + } + }, + "required": [ + "data" + ], + "title": "HasArtwork", + "type": "object" + }, + "MusicBrainzIds": { + "properties": { + "artist": { + "description": "https://musicbrainz.org/doc/Artist", + "format": "uuid", + "title": "Artist", + "type": "string" + }, + "recording": { + "description": "A MusicBrainz recording represents audio from a specific performance. For example, if the same song was released as a studio recording and as a live performance, those two versions of the song are different recordings. The song itself is considered a \"work\", of which two recordings were made. However, recordings are not always associated with a work in the MusicBrainz database, and Picard won't load work IDs by default (you have to enable \"use track relationships\" in the options), so recording IDs are a much more reliable way to identify a particular song. https://musicbrainz.org/doc/Recording", + "format": "uuid", + "title": "Recording", + "type": "string" + }, + "release": { + "description": "A MusicBrainz release roughly corresponds to an \"album\", and indeed is stored in a tag called MUSICBRAINZ_ALBUMID. The more general name is meant to encompass all the different ways music can be released. https://musicbrainz.org/doc/Release", + "format": "uuid", + "title": "Release", + "type": "string" + }, + "release_artist": { + "description": "Again, the release artist corresponds to an \"album artist\". These MBIDs refer to the same artists in the MusicBrainz database that individual recordings' artist MBIDs do.", + "format": "uuid", + "title": "Release Artist", + "type": "string" + }, + "release_group": { + "description": "A MusicBrainz release group roughly corresponds to \"all the editions of a particular album\". For example, if the same album were released on CD, vinyl records, and as a digital download, then all of those would be different releases but share a release group. Note that MPD's support for this tag is relatively new (July 2023) and doesn't seem especially reliable, so it might be missing here even if your music has been tagged with it. Not sure why. https://musicbrainz.org/doc/Release_Group", + "format": "uuid", + "title": "Release Group", + "type": "string" + }, + "track": { + "description": "A MusicBrainz track represents a specific instance of a recording appearing as part of some release. For example, if the same song appears on both two-CD and four-CD versions of a soundtrack, then it will be considered the same \"recording\" in both cases, but different \"tracks\". https://musicbrainz.org/doc/Track", + "format": "uuid", + "title": "Track", + "type": "string" + }, + "work": { + "description": "A MusicBrainz work represents the idea of a particular song or creation (it doesn't have to be audio). Each work may have multiple recordings (studio versus live, different performers, etc.), with the work ID grouping them together. https://musicbrainz.org/doc/Work", + "format": "uuid", + "title": "Work", + "type": "string" + } + }, + "title": "MusicBrainzIds", + "type": "object" + }, + "NoArtwork": { + "properties": {}, + "title": "NoArtwork", + "type": "object" + }, + "PlaybackState": { + "enum": [ + "play", + "pause", + "stop" + ], + "title": "PlaybackState", + "type": "string" + } + }, + "$id": "https://cdn.00dani.me/m/schemata/mpd-now-playable/song-v1.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "album": { + "description": "The name of the song's containing album, which may be multivalued.", + "items": { + "type": "string" + }, + "title": "Album", + "type": "array" + }, + "album_artist": { + "description": "The album's artists. This is often used to group together songs from a single album that featured different artists.", + "items": { + "type": "string" + }, + "title": "Album Artist", + "type": "array" + }, + "art": { + "anyOf": [ + { + "$ref": "#/$defs/HasArtwork" + }, + { + "$ref": "#/$defs/NoArtwork" + } + ], + "description": "The song's cover art, if it has any - the art will be available as bytes if present, ready to be displayed directly by receivers.", + "title": "Art" + }, + "artist": { + "description": "The song's artists. Will be an empty list if the song has not been tagged with an artist, and may contain multiple values if the song has been tagged with several artists.", + "items": { + "type": "string" + }, + "title": "Artist", + "type": "array" + }, + "composer": { + "description": "The song's composers. Again, this is permitted to be multivalued.", + "items": { + "type": "string" + }, + "title": "Composer", + "type": "array" + }, + "disc": { + "description": "The disc number of the song on its album. As with the track number, this is usually one-based, but it doesn't have to be.", + "title": "Disc", + "type": "integer" + }, + "duration": { + "description": "The song's duration as read from its tags, measured in seconds. Fractional seconds are allowed.", + "title": "Duration", + "type": "number" + }, + "elapsed": { + "description": "How far into the song MPD is, measured in seconds. Fractional seconds are allowed. This is usually going to be less than or equal to the song's duration, but because the duration is tagged as metadata and this value represents the actual elapsed time, it might go higher if the song's duration tag is inaccurate.", + "title": "Elapsed", + "type": "number" + }, + "file": { + "description": "The relative path to the current song inside the music directory. MPD itself uses this path as a stable identifier for the audio file in many places, so you can safely do the same.", + "format": "path", + "title": "File", + "type": "string" + }, + "genre": { + "description": "The song's genre or genres. These are completely arbitrary descriptions and don't follow any particular standard.", + "items": { + "type": "string" + }, + "title": "Genre", + "type": "array" + }, + "musicbrainz": { + "$ref": "#/$defs/MusicBrainzIds", + "description": "The MusicBrainz IDs associated with the song and with its artist and album, which if present are an extremely accurate way to identify a given song. They're not always present, though." + }, + "queue_index": { + "description": "The zero-based index of the current song in MPD's queue.", + "title": "Queue Index", + "type": "integer" + }, + "queue_length": { + "description": "The total length of MPD's queue - the last song in the queue will have the index one less than this, since queue indices are zero-based.", + "title": "Queue Length", + "type": "integer" + }, + "state": { + "$ref": "#/$defs/PlaybackState", + "description": "Whether MPD is currently playing, paused, or stopped. Pretty simple." + }, + "title": { + "description": "The song's title, if it's been tagged with one. Currently only one title is supported, since it doesn't make a lot of sense to tag a single audio file with multiple titles.", + "title": "Title", + "type": "string" + }, + "track": { + "description": "The track number the song has on its album. This is usually one-based, but it's just an arbitrary audio tag so a particular album might start at zero or do something weird with it.", + "title": "Track", + "type": "integer" + } + }, + "required": [ + "state", + "queue_index", + "queue_length", + "file", + "title", + "artist", + "composer", + "album", + "album_artist", + "track", + "disc", + "genre", + "duration", + "elapsed", + "art", + "musicbrainz" + ], + "title": "Song", + "type": "object" +} diff --git a/src/mpd_now_playable/config/model.py b/src/mpd_now_playable/config/model.py index a5b67f2..6935f96 100644 --- a/src/mpd_now_playable/config/model.py +++ b/src/mpd_now_playable/config/model.py @@ -11,6 +11,7 @@ __all__ = ( "MpdConfig", "BaseReceiverConfig", "CocoaReceiverConfig", + "WebsocketsReceiverConfig", ) @@ -24,8 +25,15 @@ class CocoaReceiverConfig(BaseReceiverConfig): kind: Literal["cocoa"] = field(default="cocoa", repr=False) +@dataclass(slots=True, kw_only=True) +class WebsocketsReceiverConfig(BaseReceiverConfig): + kind: Literal["websockets"] = field(default="websockets", repr=False) + port: Port + host: Optional[Host | tuple[Host, ...]] = None + + ReceiverConfig = Annotated[ - CocoaReceiverConfig, + CocoaReceiverConfig | WebsocketsReceiverConfig, Field(discriminator="kind"), ] diff --git a/src/mpd_now_playable/receivers/websockets/__init__.py b/src/mpd_now_playable/receivers/websockets/__init__.py new file mode 100644 index 0000000..0a13e88 --- /dev/null +++ b/src/mpd_now_playable/receivers/websockets/__init__.py @@ -0,0 +1,2 @@ +__all__ = ('receiver',) +from .receiver import WebsocketsReceiver as receiver diff --git a/src/mpd_now_playable/receivers/websockets/receiver.py b/src/mpd_now_playable/receivers/websockets/receiver.py new file mode 100644 index 0000000..0439811 --- /dev/null +++ b/src/mpd_now_playable/receivers/websockets/receiver.py @@ -0,0 +1,52 @@ +from pathlib import Path + +import ormsgpack +from websockets import broadcast +from websockets.server import WebSocketServerProtocol, serve + +from ...config.model import WebsocketsReceiverConfig +from ...player import Player +from ...song import Song +from ...song_receiver import DefaultLoopFactory, Receiver + +MSGPACK_NULL = ormsgpack.packb(None) + + +def default(value: object) -> object: + if isinstance(value, Path): + return str(value) + raise TypeError + + +class WebsocketsReceiver(Receiver): + config: WebsocketsReceiverConfig + player: Player + connections: set[WebSocketServerProtocol] + last_status: bytes = MSGPACK_NULL + + def __init__(self, config: WebsocketsReceiverConfig): + self.config = config + self.connections = set() + + @classmethod + def loop_factory(cls) -> DefaultLoopFactory: + return DefaultLoopFactory() + + async def start(self, player: Player) -> None: + self.player = player + await serve(self.handle, host=self.config.host, port=self.config.port) + + async def handle(self, conn: WebSocketServerProtocol) -> None: + self.connections.add(conn) + await conn.send(self.last_status) + try: + await conn.wait_closed() + finally: + self.connections.remove(conn) + + async def update(self, song: Song | None) -> None: + if song is None: + self.last_status = MSGPACK_NULL + else: + self.last_status = ormsgpack.packb(song, default=default) + broadcast(self.connections, self.last_status) diff --git a/src/mpd_now_playable/song/musicbrainz.py b/src/mpd_now_playable/song/musicbrainz.py index 7478d2c..9ab5278 100644 --- a/src/mpd_now_playable/song/musicbrainz.py +++ b/src/mpd_now_playable/song/musicbrainz.py @@ -1,11 +1,14 @@ from dataclasses import dataclass from functools import partial -from typing import TypedDict +from typing import Annotated, TypedDict from uuid import UUID +from pydantic import Field + from ..tools.types import option_fmap option_uuid = partial(option_fmap, UUID) +OptionUUID = Annotated[UUID | None, Field(default=None)] class MusicBrainzTags(TypedDict, total=False): @@ -44,35 +47,35 @@ class MusicBrainzIds: #: so recording IDs are a much more reliable way to identify a particular #: song. #: https://musicbrainz.org/doc/Recording - recording: UUID | None + recording: OptionUUID #: A MusicBrainz work represents the idea of a particular song or creation #: (it doesn't have to be audio). Each work may have multiple recordings #: (studio versus live, different performers, etc.), with the work ID #: grouping them together. #: https://musicbrainz.org/doc/Work - work: UUID | None + work: OptionUUID #: A MusicBrainz track represents a specific instance of a recording #: appearing as part of some release. For example, if the same song appears #: on both two-CD and four-CD versions of a soundtrack, then it will be #: considered the same "recording" in both cases, but different "tracks". #: https://musicbrainz.org/doc/Track - track: UUID | None + track: OptionUUID #: https://musicbrainz.org/doc/Artist - artist: UUID | None + artist: OptionUUID #: A MusicBrainz release roughly corresponds to an "album", and indeed is #: stored in a tag called MUSICBRAINZ_ALBUMID. The more general name is #: meant to encompass all the different ways music can be released. #: https://musicbrainz.org/doc/Release - release: UUID | None + release: OptionUUID #: Again, the release artist corresponds to an "album artist". These MBIDs #: refer to the same artists in the MusicBrainz database that individual #: recordings' artist MBIDs do. - release_artist: UUID | None + release_artist: OptionUUID #: A MusicBrainz release group roughly corresponds to "all the editions of #: a particular album". For example, if the same album were released on CD, @@ -81,7 +84,7 @@ class MusicBrainzIds: #: for this tag is relatively new (July 2023) and doesn't seem especially #: reliable, so it might be missing here even if your music has been tagged #: with it. Not sure why. https://musicbrainz.org/doc/Release_Group - release_group: UUID | None + release_group: OptionUUID def to_brainz(tags: MusicBrainzTags) -> MusicBrainzIds: diff --git a/src/mpd_now_playable/song/song.py b/src/mpd_now_playable/song/song.py index e30c9ca..716dff0 100644 --- a/src/mpd_now_playable/song/song.py +++ b/src/mpd_now_playable/song/song.py @@ -2,6 +2,7 @@ from dataclasses import dataclass from enum import StrEnum from pathlib import Path +from ..tools.schema.define import schema from .artwork import Artwork from .musicbrainz import MusicBrainzIds @@ -12,21 +13,69 @@ class PlaybackState(StrEnum): stop = "stop" +@schema("https://cdn.00dani.me/m/schemata/mpd-now-playable/song-v1.json") @dataclass(slots=True) class Song: + #: Whether MPD is currently playing, paused, or stopped. Pretty simple. state: PlaybackState + + #: The zero-based index of the current song in MPD's queue. queue_index: int + #: The total length of MPD's queue - the last song in the queue will have + #: the index one less than this, since queue indices are zero-based. queue_length: int + + #: The relative path to the current song inside the music directory. MPD + #: itself uses this path as a stable identifier for the audio file in many + #: places, so you can safely do the same. file: Path + + #: The song's title, if it's been tagged with one. Currently only one title + #: is supported, since it doesn't make a lot of sense to tag a single audio + #: file with multiple titles. title: str | None + + #: The song's artists. Will be an empty list if the song has not been + #: tagged with an artist, and may contain multiple values if the song has + #: been tagged with several artists. artist: list[str] + #: The song's composers. Again, this is permitted to be multivalued. composer: list[str] + #: The name of the song's containing album, which may be multivalued. album: list[str] + #: The album's artists. This is often used to group together songs from a + #: single album that featured different artists. album_artist: list[str] + + #: The track number the song has on its album. This is usually one-based, + #: but it's just an arbitrary audio tag so a particular album might start + #: at zero or do something weird with it. track: int | None + + #: The disc number of the song on its album. As with the track number, this + #: is usually one-based, but it doesn't have to be. disc: int | None + + #: The song's genre or genres. These are completely arbitrary descriptions + #: and don't follow any particular standard. genre: list[str] + + #: The song's duration as read from its tags, measured in seconds. + #: Fractional seconds are allowed. duration: float + + #: How far into the song MPD is, measured in seconds. Fractional seconds + #: are allowed. This is usually going to be less than or equal to the + #: song's duration, but because the duration is tagged as metadata and this + #: value represents the actual elapsed time, it might go higher if the + #: song's duration tag is inaccurate. elapsed: float + + #: The song's cover art, if it has any - the art will be available as bytes + #: if present, ready to be displayed directly by receivers. art: Artwork + + #: The MusicBrainz IDs associated with the song and with its artist and + #: album, which if present are an extremely accurate way to identify a + #: given song. They're not always present, though. musicbrainz: MusicBrainzIds diff --git a/src/mpd_now_playable/tools/schema/generate.py b/src/mpd_now_playable/tools/schema/generate.py index 181a214..ec1b539 100644 --- a/src/mpd_now_playable/tools/schema/generate.py +++ b/src/mpd_now_playable/tools/schema/generate.py @@ -45,3 +45,9 @@ class MyGenerateJsonSchema(GenerateJsonSchema): def nullable_schema(self, schema: s.NullableSchema) -> JsonSchemaValue: return self.generate_inner(schema["schema"]) + +if __name__ == '__main__': + from ...config.model import Config + from ...song import Song + write(Config) + write(Song)