From 00ba34bd0b949228f1e254320db1d5f973250dbb Mon Sep 17 00:00:00 2001 From: Danielle McLean Date: Tue, 9 Jul 2024 12:52:49 +1000 Subject: [PATCH 1/7] Refactor Cocoa stuff into a 'receiver' MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The idea here is that there are other places that might want to know what's playing, besides MPNowPlayingInfoCenter. For example, to expose the now playing info to Übersicht efficiently, it needs to be available from a web browser, ideally using WebSockets. So there could be a receiver that runs a small WebSockets server and sends out now playing info to anyone who connects. Additionally, I hope to write receivers for MPRIS and for the System Media Transport Controls on Windows, making mpd-now-playable equally useful across all platforms. None of this is implemented yet, of course, but I hope to get the WebSockets receiver done pretty soon! I'm going to keep the default behaviour unchanged. Unless you explicitly configure different receivers in config.toml, mpd-now-playable will just behave as an MPNowPlayingInfoCenter integration as it's always done. --- pyproject.toml | 1 + schemata/config-v1.json | 37 +++++++++ src/mpd_now_playable/cli.py | 39 ++++++---- src/mpd_now_playable/config/model.py | 28 ++++++- src/mpd_now_playable/mpd/listener.py | 26 ++++--- .../{cocoa => receivers}/__init__.py | 0 .../receivers/cocoa/__init__.py | 3 + .../{ => receivers}/cocoa/now_playing.py | 28 +++++-- .../{ => receivers}/cocoa/persistent_id.py | 2 +- src/mpd_now_playable/song.py | 6 +- src/mpd_now_playable/song_receiver.py | 76 +++++++++++++++++++ src/mpd_now_playable/tools/types.py | 6 ++ 12 files changed, 214 insertions(+), 38 deletions(-) rename src/mpd_now_playable/{cocoa => receivers}/__init__.py (100%) create mode 100644 src/mpd_now_playable/receivers/cocoa/__init__.py rename src/mpd_now_playable/{ => receivers}/cocoa/now_playing.py (90%) rename src/mpd_now_playable/{ => receivers}/cocoa/persistent_id.py (98%) create mode 100644 src/mpd_now_playable/song_receiver.py diff --git a/pyproject.toml b/pyproject.toml index 64b5b6f..8b8cbfc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -70,6 +70,7 @@ select = [ ] ignore = [ "ANN101", # missing-type-self + "ANN102", # missing-type-cls ] [tool.ruff.lint.flake8-annotations] diff --git a/schemata/config-v1.json b/schemata/config-v1.json index 436ba7b..14600a1 100644 --- a/schemata/config-v1.json +++ b/schemata/config-v1.json @@ -1,5 +1,20 @@ { "$defs": { + "CocoaReceiverConfig": { + "properties": { + "kind": { + "const": "cocoa", + "default": "cocoa", + "enum": [ + "cocoa" + ], + "title": "Kind", + "type": "string" + } + }, + "title": "CocoaReceiverConfig", + "type": "object" + }, "MpdConfig": { "properties": { "host": { @@ -40,6 +55,28 @@ }, "mpd": { "$ref": "#/$defs/MpdConfig" + }, + "receivers": { + "default": [ + { + "kind": "cocoa" + } + ], + "items": { + "discriminator": { + "mapping": { + "cocoa": "#/$defs/CocoaReceiverConfig" + }, + "propertyName": "kind" + }, + "oneOf": [ + { + "$ref": "#/$defs/CocoaReceiverConfig" + } + ] + }, + "title": "Receivers", + "type": "array" } }, "title": "Config", diff --git a/src/mpd_now_playable/cli.py b/src/mpd_now_playable/cli.py index 6f6e44f..0e201e7 100644 --- a/src/mpd_now_playable/cli.py +++ b/src/mpd_now_playable/cli.py @@ -1,30 +1,41 @@ import asyncio +from collections.abc import Iterable -from corefoundationasyncio import CoreFoundationEventLoop from rich import print from .__version__ import __version__ -from .cocoa.now_playing import CocoaNowPlaying from .config.load import loadConfig +from .config.model import Config from .mpd.listener import MpdStateListener +from .song_receiver import ( + Receiver, + choose_loop_factory, + import_receiver, +) -async def listen() -> None: - print(f"mpd-now-playable v{__version__}") - config = loadConfig() - print(config) - listener = MpdStateListener(config.cache) - now_playing = CocoaNowPlaying(listener) +async def listen( + config: Config, listener: MpdStateListener, receiver_types: Iterable[type[Receiver]] +) -> None: await listener.start(config.mpd) - await listener.loop(now_playing) - - -def make_loop() -> CoreFoundationEventLoop: - return CoreFoundationEventLoop(console_app=True) + receivers = (rec(listener, config) for rec in receiver_types) + await listener.loop(receivers) def main() -> None: - asyncio.run(listen(), loop_factory=make_loop, debug=True) + print(f"mpd-now-playable v{__version__}") + config = loadConfig() + print(config) + + listener = MpdStateListener(config.cache) + receiver_types = tuple(import_receiver(rec) for rec in config.receivers) + + factory = choose_loop_factory(receiver_types) + asyncio.run( + listen(config, listener, receiver_types), + loop_factory=factory.make_loop, + debug=True, + ) if __name__ == "__main__": diff --git a/src/mpd_now_playable/config/model.py b/src/mpd_now_playable/config/model.py index 4bd06bf..7e2e6af 100644 --- a/src/mpd_now_playable/config/model.py +++ b/src/mpd_now_playable/config/model.py @@ -1,10 +1,33 @@ from dataclasses import dataclass, field -from typing import Optional +from typing import Annotated, Literal, Optional, Protocol + +from pydantic import Field from ..tools.schema.define import schema from .fields import Host, Password, Port, Url -__all__ = ("MpdConfig", "Config") +__all__ = ( + "Config", + "MpdConfig", + "BaseReceiverConfig", + "CocoaReceiverConfig", +) + + +class BaseReceiverConfig(Protocol): + @property + def kind(self) -> str: ... + + +@dataclass(slots=True) +class CocoaReceiverConfig(BaseReceiverConfig): + kind: Literal["cocoa"] = "cocoa" + + +ReceiverConfig = Annotated[ + CocoaReceiverConfig, + Field(discriminator="kind"), +] @dataclass(slots=True) @@ -27,3 +50,4 @@ class Config: #: protocols are memory://, redis://, and memcached://. cache: Optional[Url] = None mpd: MpdConfig = field(default_factory=MpdConfig) + receivers: tuple[ReceiverConfig, ...] = (CocoaReceiverConfig(),) diff --git a/src/mpd_now_playable/mpd/listener.py b/src/mpd_now_playable/mpd/listener.py index 96e7f30..f787223 100644 --- a/src/mpd_now_playable/mpd/listener.py +++ b/src/mpd_now_playable/mpd/listener.py @@ -1,4 +1,5 @@ import asyncio +from collections.abc import Iterable from pathlib import Path from uuid import UUID @@ -9,7 +10,8 @@ from yarl import URL from ..config.model import MpdConfig from ..player import Player -from ..song import Artwork, PlaybackState, Song, SongListener, to_artwork +from ..song import Artwork, PlaybackState, Song, to_artwork +from ..song_receiver import Receiver from ..tools.types import convert_if_exists, un_maybe_plural from .artwork_cache import MpdArtworkCache from .types import CurrentSongResponse, StatusResponse @@ -43,7 +45,7 @@ def mpd_current_to_song( class MpdStateListener(Player): client: MPDClient - listener: SongListener + receivers: Iterable[Receiver] art_cache: MpdArtworkCache idle_count = 0 @@ -62,18 +64,18 @@ class MpdStateListener(Player): print(f"Connected to MPD v{self.client.mpd_version}") async def refresh(self) -> None: - await self.update_listener(self.listener) + await self.update_receivers() - async def loop(self, listener: SongListener) -> None: - self.listener = listener - # notify our listener of the initial state MPD is in when this script loads up. - await self.update_listener(listener) + async def loop(self, receivers: Iterable[Receiver]) -> None: + self.receivers = receivers + # notify our receivers of the initial state MPD is in when this script loads up. + await self.update_receivers() # then wait for stuff to change in MPD. :) async for _ in self.client.idle(): self.idle_count += 1 - await self.update_listener(listener) + await self.update_receivers() - async def update_listener(self, listener: SongListener) -> None: + async def update_receivers(self) -> None: # If any async calls in here take long enough that we got another MPD idle event, we want to bail out of this older update. starting_idle_count = self.idle_count status, current = await asyncio.gather( @@ -85,7 +87,8 @@ class MpdStateListener(Player): if status["state"] == "stop": print("Nothing playing") - listener.update(None) + for r in self.receivers: + r.update(None) return art = await self.art_cache.get_cached_artwork(current) @@ -94,7 +97,8 @@ class MpdStateListener(Player): song = mpd_current_to_song(status, current, to_artwork(art)) rprint(song) - listener.update(song) + for r in self.receivers: + r.update(song) async def get_art(self, file: str) -> bytes | None: picture = await self.readpicture(file) diff --git a/src/mpd_now_playable/cocoa/__init__.py b/src/mpd_now_playable/receivers/__init__.py similarity index 100% rename from src/mpd_now_playable/cocoa/__init__.py rename to src/mpd_now_playable/receivers/__init__.py diff --git a/src/mpd_now_playable/receivers/cocoa/__init__.py b/src/mpd_now_playable/receivers/cocoa/__init__.py new file mode 100644 index 0000000..a91fd74 --- /dev/null +++ b/src/mpd_now_playable/receivers/cocoa/__init__.py @@ -0,0 +1,3 @@ +__all__ = ("receiver",) + +from .now_playing import CocoaNowPlayingReceiver as receiver diff --git a/src/mpd_now_playable/cocoa/now_playing.py b/src/mpd_now_playable/receivers/cocoa/now_playing.py similarity index 90% rename from src/mpd_now_playable/cocoa/now_playing.py rename to src/mpd_now_playable/receivers/cocoa/now_playing.py index 1121329..158c5a5 100644 --- a/src/mpd_now_playable/cocoa/now_playing.py +++ b/src/mpd_now_playable/receivers/cocoa/now_playing.py @@ -1,7 +1,9 @@ from collections.abc import Callable, Coroutine from pathlib import Path +from typing import Literal from AppKit import NSCompositingOperationCopy, NSImage, NSMakeRect +from corefoundationasyncio import CoreFoundationEventLoop from Foundation import CGSize, NSMutableDictionary from MediaPlayer import ( MPChangePlaybackPositionCommandEvent, @@ -35,9 +37,11 @@ from MediaPlayer import ( MPRemoteCommandHandlerStatusSuccess, ) -from ..player import Player -from ..song import PlaybackState, Song -from ..tools.asyncio import run_background_task +from ...config.model import Config +from ...player import Player +from ...song import PlaybackState, Song +from ...song_receiver import LoopFactory, Receiver +from ...tools.asyncio import run_background_task from .persistent_id import song_to_persistent_id @@ -127,8 +131,22 @@ def nothing_to_media_item() -> NSMutableDictionary: MPD_LOGO = ns_image_to_media_item_artwork(logo_to_ns_image()) -class CocoaNowPlaying: - def __init__(self, player: Player): +class CocoaLoopFactory(LoopFactory[CoreFoundationEventLoop]): + @property + def is_replaceable(self) -> Literal[False]: + return False + + @classmethod + def make_loop(cls) -> CoreFoundationEventLoop: + return CoreFoundationEventLoop(console_app=True) + + +class CocoaNowPlayingReceiver(Receiver): + @classmethod + def loop_factory(cls) -> LoopFactory[CoreFoundationEventLoop]: + return CocoaLoopFactory() + + def __init__(self, player: Player, config: Config): self.cmd_center = MPRemoteCommandCenter.sharedCommandCenter() self.info_center = MPNowPlayingInfoCenter.defaultCenter() diff --git a/src/mpd_now_playable/cocoa/persistent_id.py b/src/mpd_now_playable/receivers/cocoa/persistent_id.py similarity index 98% rename from src/mpd_now_playable/cocoa/persistent_id.py rename to src/mpd_now_playable/receivers/cocoa/persistent_id.py index 7f63ae9..0ce50a1 100644 --- a/src/mpd_now_playable/cocoa/persistent_id.py +++ b/src/mpd_now_playable/receivers/cocoa/persistent_id.py @@ -3,7 +3,7 @@ from pathlib import Path from typing import Final from uuid import UUID -from ..song import Song +from ...song import Song # The maximum size for a BLAKE2b "person" value is sixteen bytes, so we need to be concise. HASH_PERSON_PREFIX: Final = b"mnp.mac." diff --git a/src/mpd_now_playable/song.py b/src/mpd_now_playable/song.py index 940177e..487c9cb 100644 --- a/src/mpd_now_playable/song.py +++ b/src/mpd_now_playable/song.py @@ -1,7 +1,7 @@ from dataclasses import dataclass, field from enum import StrEnum from pathlib import Path -from typing import Literal, Protocol +from typing import Literal from uuid import UUID from pydantic.type_adapter import TypeAdapter @@ -53,7 +53,3 @@ class Song: duration: float elapsed: float art: Artwork - - -class SongListener(Protocol): - def update(self, song: Song | None) -> None: ... diff --git a/src/mpd_now_playable/song_receiver.py b/src/mpd_now_playable/song_receiver.py new file mode 100644 index 0000000..d6f5b8e --- /dev/null +++ b/src/mpd_now_playable/song_receiver.py @@ -0,0 +1,76 @@ +from asyncio import AbstractEventLoop, new_event_loop +from dataclasses import dataclass +from importlib import import_module +from typing import Generic, Iterable, Literal, Protocol, TypeVar, cast + +from .config.model import BaseReceiverConfig, Config +from .player import Player +from .song import Song +from .tools.types import not_none + +T = TypeVar("T", bound=AbstractEventLoop, covariant=True) + + +class LoopFactory(Generic[T], Protocol): + @property + def is_replaceable(self) -> bool: ... + + @classmethod + def make_loop(cls) -> T: ... + + +class Receiver(Protocol): + def __init__(self, player: Player, config: Config) -> None: ... + @classmethod + def loop_factory(cls) -> LoopFactory[AbstractEventLoop]: ... + def update(self, song: Song | None) -> None: ... + + +class ReceiverModule(Protocol): + receiver: type[Receiver] + + +class DefaultLoopFactory(LoopFactory[AbstractEventLoop]): + @property + def is_replaceable(self) -> Literal[True]: + return True + + @classmethod + def make_loop(cls) -> AbstractEventLoop: + return new_event_loop() + + +@dataclass +class IncompatibleReceiverError(Exception): + a: type[Receiver] + b: type[Receiver] + + +def import_receiver(config: BaseReceiverConfig) -> type[Receiver]: + mod = cast( + ReceiverModule, import_module(f"mpd_now_playable.receivers.{config.kind}") + ) + return mod.receiver + + +def choose_loop_factory( + receivers: Iterable[type[Receiver]], +) -> LoopFactory[AbstractEventLoop]: + """Given the desired receivers, determine which asyncio event loop implementation will support all of them. Will raise an IncompatibleReceiverError if no such implementation exists.""" + + chosen_fac: LoopFactory[AbstractEventLoop] = DefaultLoopFactory() + chosen_rec: type[Receiver] | None = None + + for rec in receivers: + fac = rec.loop_factory() + if fac.is_replaceable: + continue + + if chosen_fac.is_replaceable: + chosen_fac = fac + elif type(fac) is not type(chosen_fac): + raise IncompatibleReceiverError(rec, not_none(chosen_rec)) + + chosen_rec = rec + + return chosen_fac diff --git a/src/mpd_now_playable/tools/types.py b/src/mpd_now_playable/tools/types.py index 1d0f5cc..023641d 100644 --- a/src/mpd_now_playable/tools/types.py +++ b/src/mpd_now_playable/tools/types.py @@ -31,6 +31,12 @@ AnyExceptList = ( U = TypeVar("U") +def not_none(value: U | None) -> U: + if value is None: + raise ValueError("None should not be possible here.") + return value + + def convert_if_exists(value: str | None, converter: Callable[[str], U]) -> U | None: if value is None: return None From 60116fd61619c1bcac3fdd190689913f4b7a5918 Mon Sep 17 00:00:00 2001 From: Danielle McLean Date: Wed, 10 Jul 2024 23:57:34 +1000 Subject: [PATCH 2/7] Make PyObjC a Darwin-only dependency --- pdm.lock | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pdm.lock b/pdm.lock index f703af1..cc13694 100644 --- a/pdm.lock +++ b/pdm.lock @@ -5,7 +5,7 @@ groups = ["default", "all", "dev"] strategy = ["cross_platform"] lock_version = "4.4.1" -content_hash = "sha256:5a1cfd2988fd81c4b8743daaf399bbeb9f1f9d78e161901f0bf76c771ec8052f" +content_hash = "sha256:c9845c93dab5638ddd0cbce910010bb8c03a6f42ae0af666e6b759d05540c8a6" [[package]] name = "aiocache" diff --git a/pyproject.toml b/pyproject.toml index 8b8cbfc..fa32f8f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ authors = [ dependencies = [ "aiocache>=0.12.2", "attrs>=23.1.0", - "pyobjc-framework-MediaPlayer>=10.0", + "pyobjc-framework-MediaPlayer>=10.0 ; sys_platform == 'darwin'", "python-mpd2>=3.1.0", "xdg-base-dirs>=6.0.1", "pytomlpp>=1.0.13", From 09fe3b3e6cc475315fc011f0dfd8155c67989dd1 Mon Sep 17 00:00:00 2001 From: Danielle McLean Date: Thu, 11 Jul 2024 12:12:56 +1000 Subject: [PATCH 3/7] Expand MusicBrainz support to be much more comprehensive --- src/mpd_now_playable/mpd/listener.py | 8 +- .../receivers/cocoa/persistent_id.py | 24 ++--- src/mpd_now_playable/song.py | 55 ----------- src/mpd_now_playable/song/__init__.py | 12 +++ src/mpd_now_playable/song/artwork.py | 25 +++++ src/mpd_now_playable/song/musicbrainz.py | 96 +++++++++++++++++++ src/mpd_now_playable/song/song.py | 32 +++++++ src/mpd_now_playable/tools/types.py | 8 ++ 8 files changed, 188 insertions(+), 72 deletions(-) delete mode 100644 src/mpd_now_playable/song.py create mode 100644 src/mpd_now_playable/song/__init__.py create mode 100644 src/mpd_now_playable/song/artwork.py create mode 100644 src/mpd_now_playable/song/musicbrainz.py create mode 100644 src/mpd_now_playable/song/song.py diff --git a/src/mpd_now_playable/mpd/listener.py b/src/mpd_now_playable/mpd/listener.py index f787223..50f256a 100644 --- a/src/mpd_now_playable/mpd/listener.py +++ b/src/mpd_now_playable/mpd/listener.py @@ -1,7 +1,6 @@ import asyncio from collections.abc import Iterable from pathlib import Path -from uuid import UUID from mpd.asyncio import MPDClient from mpd.base import CommandError @@ -10,7 +9,7 @@ from yarl import URL from ..config.model import MpdConfig from ..player import Player -from ..song import Artwork, PlaybackState, Song, to_artwork +from ..song import Artwork, PlaybackState, Song, to_artwork, to_brainz from ..song_receiver import Receiver from ..tools.types import convert_if_exists, un_maybe_plural from .artwork_cache import MpdArtworkCache @@ -25,10 +24,6 @@ def mpd_current_to_song( queue_index=int(current["pos"]), queue_length=int(status["playlistlength"]), file=Path(current["file"]), - musicbrainz_trackid=convert_if_exists(current.get("musicbrainz_trackid"), UUID), - musicbrainz_releasetrackid=convert_if_exists( - current.get("musicbrainz_releasetrackid"), UUID - ), title=current.get("title"), artist=un_maybe_plural(current.get("artist")), album=un_maybe_plural(current.get("album")), @@ -39,6 +34,7 @@ def mpd_current_to_song( disc=convert_if_exists(current.get("disc"), int), duration=float(status["duration"]), elapsed=float(status["elapsed"]), + musicbrainz=to_brainz(current), art=art, ) diff --git a/src/mpd_now_playable/receivers/cocoa/persistent_id.py b/src/mpd_now_playable/receivers/cocoa/persistent_id.py index 0ce50a1..12c992d 100644 --- a/src/mpd_now_playable/receivers/cocoa/persistent_id.py +++ b/src/mpd_now_playable/receivers/cocoa/persistent_id.py @@ -7,25 +7,27 @@ from ...song import Song # The maximum size for a BLAKE2b "person" value is sixteen bytes, so we need to be concise. HASH_PERSON_PREFIX: Final = b"mnp.mac." -TRACKID_HASH_PERSON: Final = HASH_PERSON_PREFIX + b"mb_tid" -RELEASETRACKID_HASH_PERSON: Final = HASH_PERSON_PREFIX + b"mb_rtid" +RECORDING_ID_HASH_PERSON: Final = HASH_PERSON_PREFIX + b"mb_rid" +TRACK_ID_HASH_PERSON: Final = HASH_PERSON_PREFIX + b"mb_tid" FILE_HASH_PERSON: Final = HASH_PERSON_PREFIX + b"f" PERSISTENT_ID_BITS: Final = 64 PERSISTENT_ID_BYTES: Final = PERSISTENT_ID_BITS // 8 -def digest_trackid(trackid: UUID) -> bytes: +def digest_recording_id(recording_id: UUID) -> bytes: return blake2b( - trackid.bytes, digest_size=PERSISTENT_ID_BYTES, person=TRACKID_HASH_PERSON + recording_id.bytes, + digest_size=PERSISTENT_ID_BYTES, + person=RECORDING_ID_HASH_PERSON, ).digest() -def digest_releasetrackid(trackid: UUID) -> bytes: +def digest_track_id(track_id: UUID) -> bytes: return blake2b( - trackid.bytes, + track_id.bytes, digest_size=PERSISTENT_ID_BYTES, - person=RELEASETRACKID_HASH_PERSON, + person=TRACK_ID_HASH_PERSON, ).digest() @@ -41,10 +43,10 @@ def digest_file_uri(file: Path) -> bytes: # that from the file URI. BLAKE2 can be customised to different digest sizes, # making it perfect for this problem. def song_to_persistent_id(song: Song) -> int: - if song.musicbrainz_trackid: - hashed_id = digest_trackid(song.musicbrainz_trackid) - elif song.musicbrainz_releasetrackid: - hashed_id = digest_releasetrackid(song.musicbrainz_releasetrackid) + if song.musicbrainz.recording: + hashed_id = digest_recording_id(song.musicbrainz.recording) + elif song.musicbrainz.track: + hashed_id = digest_track_id(song.musicbrainz.track) else: hashed_id = digest_file_uri(song.file) return int.from_bytes(hashed_id) diff --git a/src/mpd_now_playable/song.py b/src/mpd_now_playable/song.py deleted file mode 100644 index 487c9cb..0000000 --- a/src/mpd_now_playable/song.py +++ /dev/null @@ -1,55 +0,0 @@ -from dataclasses import dataclass, field -from enum import StrEnum -from pathlib import Path -from typing import Literal -from uuid import UUID - -from pydantic.type_adapter import TypeAdapter - - -class PlaybackState(StrEnum): - play = "play" - pause = "pause" - stop = "stop" - - -@dataclass(slots=True) -class HasArtwork: - data: bytes = field(repr=False) - - -@dataclass(slots=True) -class NoArtwork: - def __bool__(self) -> Literal[False]: - return False - - -Artwork = HasArtwork | NoArtwork -ArtworkSchema: TypeAdapter[Artwork] = TypeAdapter(HasArtwork | NoArtwork) - - -def to_artwork(art: bytes | None) -> Artwork: - if art is None: - return NoArtwork() - return HasArtwork(art) - - -@dataclass(slots=True) -class Song: - state: PlaybackState - queue_index: int - queue_length: int - file: Path - musicbrainz_trackid: UUID | None - musicbrainz_releasetrackid: UUID | None - title: str | None - artist: list[str] - composer: list[str] - album: list[str] - album_artist: list[str] - track: int | None - disc: int | None - genre: list[str] - duration: float - elapsed: float - art: Artwork diff --git a/src/mpd_now_playable/song/__init__.py b/src/mpd_now_playable/song/__init__.py new file mode 100644 index 0000000..80e5f4f --- /dev/null +++ b/src/mpd_now_playable/song/__init__.py @@ -0,0 +1,12 @@ +from .artwork import Artwork, ArtworkSchema, to_artwork +from .musicbrainz import to_brainz +from .song import PlaybackState, Song + +__all__ = ( + "Artwork", + "ArtworkSchema", + "to_artwork", + "to_brainz", + "PlaybackState", + "Song", +) diff --git a/src/mpd_now_playable/song/artwork.py b/src/mpd_now_playable/song/artwork.py new file mode 100644 index 0000000..4c4d941 --- /dev/null +++ b/src/mpd_now_playable/song/artwork.py @@ -0,0 +1,25 @@ +from dataclasses import dataclass, field +from typing import Literal + +from pydantic.type_adapter import TypeAdapter + + +@dataclass(slots=True) +class HasArtwork: + data: bytes = field(repr=False) + + +@dataclass(slots=True) +class NoArtwork: + def __bool__(self) -> Literal[False]: + return False + + +Artwork = HasArtwork | NoArtwork +ArtworkSchema: TypeAdapter[Artwork] = TypeAdapter(HasArtwork | NoArtwork) + + +def to_artwork(art: bytes | None) -> Artwork: + if art is None: + return NoArtwork() + return HasArtwork(art) diff --git a/src/mpd_now_playable/song/musicbrainz.py b/src/mpd_now_playable/song/musicbrainz.py new file mode 100644 index 0000000..7478d2c --- /dev/null +++ b/src/mpd_now_playable/song/musicbrainz.py @@ -0,0 +1,96 @@ +from dataclasses import dataclass +from functools import partial +from typing import TypedDict +from uuid import UUID + +from ..tools.types import option_fmap + +option_uuid = partial(option_fmap, UUID) + + +class MusicBrainzTags(TypedDict, total=False): + """ + The MusicBrainz tags mpd-now-playable expects and will load (all optional). + They're named slightly differently than the actual MusicBrainz IDs they + store - use to_brainz to map them across to their canonical form. + https://picard-docs.musicbrainz.org/downloads/MusicBrainz_Picard_Tag_Map.html + """ + + #: MusicBrainz Recording ID + musicbrainz_trackid: str + #: MusicBrainz Track ID + musicbrainz_releasetrackid: str + #: MusicBrainz Artist ID + musicbrainz_artistid: str + #: MusicBrainz Release ID + musicbrainz_albumid: str + #: MusicBrainz Release Artist ID + musicbrainz_albumartistid: str + #: MusicBrainz Release Group ID + musicbrainz_releasegroupid: str + #: MusicBrainz Work ID + musicbrainz_workid: str + + +@dataclass(slots=True) +class MusicBrainzIds: + #: 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 + recording: UUID | None + + #: 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 + + #: 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 + + #: https://musicbrainz.org/doc/Artist + artist: UUID | None + + #: 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 + + #: 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 + + #: 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 + release_group: UUID | None + + +def to_brainz(tags: MusicBrainzTags) -> MusicBrainzIds: + return MusicBrainzIds( + recording=option_uuid(tags.get("musicbrainz_trackid")), + work=option_uuid(tags.get("musicbrainz_workid")), + track=option_uuid(tags.get("musicbrainz_releasetrackid")), + artist=option_uuid(tags.get("musicbrainz_artistid")), + release=option_uuid(tags.get("musicbrainz_albumid")), + release_artist=option_uuid(tags.get("musicbrainz_albumartistid")), + release_group=option_uuid(tags.get("musicbrainz_releasegroupid")), + ) diff --git a/src/mpd_now_playable/song/song.py b/src/mpd_now_playable/song/song.py new file mode 100644 index 0000000..e30c9ca --- /dev/null +++ b/src/mpd_now_playable/song/song.py @@ -0,0 +1,32 @@ +from dataclasses import dataclass +from enum import StrEnum +from pathlib import Path + +from .artwork import Artwork +from .musicbrainz import MusicBrainzIds + + +class PlaybackState(StrEnum): + play = "play" + pause = "pause" + stop = "stop" + + +@dataclass(slots=True) +class Song: + state: PlaybackState + queue_index: int + queue_length: int + file: Path + title: str | None + artist: list[str] + composer: list[str] + album: list[str] + album_artist: list[str] + track: int | None + disc: int | None + genre: list[str] + duration: float + elapsed: float + art: Artwork + musicbrainz: MusicBrainzIds diff --git a/src/mpd_now_playable/tools/types.py b/src/mpd_now_playable/tools/types.py index 023641d..da62c90 100644 --- a/src/mpd_now_playable/tools/types.py +++ b/src/mpd_now_playable/tools/types.py @@ -4,6 +4,7 @@ from typing import Any, TypeAlias, TypeVar __all__ = ( "AnyExceptList", "MaybePlural", + "option_fmap", "convert_if_exists", "un_maybe_plural", ) @@ -29,6 +30,7 @@ AnyExceptList = ( U = TypeVar("U") +V = TypeVar("V") def not_none(value: U | None) -> U: @@ -37,6 +39,12 @@ def not_none(value: U | None) -> U: return value +def option_fmap(f: Callable[[U], V], value: U | None) -> V | None: + if value is None: + return None + return f(value) + + def convert_if_exists(value: str | None, converter: Callable[[str], U]) -> U | None: if value is None: return None From 04859b8c8b6a2d6fd5bc27800d6be6fd7157917a Mon Sep 17 00:00:00 2001 From: Danielle McLean Date: Thu, 11 Jul 2024 12:15:34 +1000 Subject: [PATCH 4/7] Adjust receiver protocol to accommodate config --- src/mpd_now_playable/cli.py | 12 +++++------ src/mpd_now_playable/config/model.py | 2 +- src/mpd_now_playable/mpd/listener.py | 9 ++++---- .../receivers/cocoa/now_playing.py | 9 +++++--- src/mpd_now_playable/song_receiver.py | 21 ++++++++++++------- 5 files changed, 32 insertions(+), 21 deletions(-) diff --git a/src/mpd_now_playable/cli.py b/src/mpd_now_playable/cli.py index 0e201e7..34153c2 100644 --- a/src/mpd_now_playable/cli.py +++ b/src/mpd_now_playable/cli.py @@ -10,15 +10,15 @@ from .mpd.listener import MpdStateListener from .song_receiver import ( Receiver, choose_loop_factory, - import_receiver, + construct_receiver, ) async def listen( - config: Config, listener: MpdStateListener, receiver_types: Iterable[type[Receiver]] + config: Config, listener: MpdStateListener, receivers: Iterable[Receiver] ) -> None: await listener.start(config.mpd) - receivers = (rec(listener, config) for rec in receiver_types) + await asyncio.gather(*(rec.start(listener) for rec in receivers)) await listener.loop(receivers) @@ -28,11 +28,11 @@ def main() -> None: print(config) listener = MpdStateListener(config.cache) - receiver_types = tuple(import_receiver(rec) for rec in config.receivers) + receivers = tuple(construct_receiver(rec_config) for rec_config in config.receivers) + factory = choose_loop_factory(receivers) - factory = choose_loop_factory(receiver_types) asyncio.run( - listen(config, listener, receiver_types), + listen(config, listener, receivers), loop_factory=factory.make_loop, debug=True, ) diff --git a/src/mpd_now_playable/config/model.py b/src/mpd_now_playable/config/model.py index 7e2e6af..a5b67f2 100644 --- a/src/mpd_now_playable/config/model.py +++ b/src/mpd_now_playable/config/model.py @@ -21,7 +21,7 @@ class BaseReceiverConfig(Protocol): @dataclass(slots=True) class CocoaReceiverConfig(BaseReceiverConfig): - kind: Literal["cocoa"] = "cocoa" + kind: Literal["cocoa"] = field(default="cocoa", repr=False) ReceiverConfig = Annotated[ diff --git a/src/mpd_now_playable/mpd/listener.py b/src/mpd_now_playable/mpd/listener.py index 50f256a..bceaaf3 100644 --- a/src/mpd_now_playable/mpd/listener.py +++ b/src/mpd_now_playable/mpd/listener.py @@ -83,8 +83,7 @@ class MpdStateListener(Player): if status["state"] == "stop": print("Nothing playing") - for r in self.receivers: - r.update(None) + await self.update(None) return art = await self.art_cache.get_cached_artwork(current) @@ -93,8 +92,10 @@ class MpdStateListener(Player): song = mpd_current_to_song(status, current, to_artwork(art)) rprint(song) - for r in self.receivers: - r.update(song) + await self.update(song) + + async def update(self, song: Song | None) -> None: + await asyncio.gather(*(r.update(song) for r in self.receivers)) async def get_art(self, file: str) -> bytes | None: picture = await self.readpicture(file) diff --git a/src/mpd_now_playable/receivers/cocoa/now_playing.py b/src/mpd_now_playable/receivers/cocoa/now_playing.py index 158c5a5..60c2b49 100644 --- a/src/mpd_now_playable/receivers/cocoa/now_playing.py +++ b/src/mpd_now_playable/receivers/cocoa/now_playing.py @@ -37,7 +37,7 @@ from MediaPlayer import ( MPRemoteCommandHandlerStatusSuccess, ) -from ...config.model import Config +from ...config.model import CocoaReceiverConfig from ...player import Player from ...song import PlaybackState, Song from ...song_receiver import LoopFactory, Receiver @@ -146,7 +146,10 @@ class CocoaNowPlayingReceiver(Receiver): def loop_factory(cls) -> LoopFactory[CoreFoundationEventLoop]: return CocoaLoopFactory() - def __init__(self, player: Player, config: Config): + def __init__(self, config: CocoaReceiverConfig): + pass + + async def start(self, player: Player) -> None: self.cmd_center = MPRemoteCommandCenter.sharedCommandCenter() self.info_center = MPNowPlayingInfoCenter.defaultCenter() @@ -184,7 +187,7 @@ class CocoaNowPlayingReceiver(Receiver): # unpause with remote commands. self.info_center.setPlaybackState_(MPMusicPlaybackStatePlaying) - def update(self, song: Song | None) -> None: + async def update(self, song: Song | None) -> None: if song: self.info_center.setNowPlayingInfo_(song_to_media_item(song)) self.info_center.setPlaybackState_(playback_state_to_cocoa(song.state)) diff --git a/src/mpd_now_playable/song_receiver.py b/src/mpd_now_playable/song_receiver.py index d6f5b8e..cf18326 100644 --- a/src/mpd_now_playable/song_receiver.py +++ b/src/mpd_now_playable/song_receiver.py @@ -3,7 +3,7 @@ from dataclasses import dataclass from importlib import import_module from typing import Generic, Iterable, Literal, Protocol, TypeVar, cast -from .config.model import BaseReceiverConfig, Config +from .config.model import BaseReceiverConfig from .player import Player from .song import Song from .tools.types import not_none @@ -20,10 +20,12 @@ class LoopFactory(Generic[T], Protocol): class Receiver(Protocol): - def __init__(self, player: Player, config: Config) -> None: ... + def __init__(self, config: BaseReceiverConfig): ... @classmethod def loop_factory(cls) -> LoopFactory[AbstractEventLoop]: ... - def update(self, song: Song | None) -> None: ... + + async def start(self, player: Player) -> None: ... + async def update(self, song: Song | None) -> None: ... class ReceiverModule(Protocol): @@ -42,8 +44,8 @@ class DefaultLoopFactory(LoopFactory[AbstractEventLoop]): @dataclass class IncompatibleReceiverError(Exception): - a: type[Receiver] - b: type[Receiver] + a: Receiver + b: Receiver def import_receiver(config: BaseReceiverConfig) -> type[Receiver]: @@ -53,13 +55,18 @@ def import_receiver(config: BaseReceiverConfig) -> type[Receiver]: return mod.receiver +def construct_receiver(config: BaseReceiverConfig) -> Receiver: + cls = import_receiver(config) + return cls(config) + + def choose_loop_factory( - receivers: Iterable[type[Receiver]], + receivers: Iterable[Receiver], ) -> LoopFactory[AbstractEventLoop]: """Given the desired receivers, determine which asyncio event loop implementation will support all of them. Will raise an IncompatibleReceiverError if no such implementation exists.""" chosen_fac: LoopFactory[AbstractEventLoop] = DefaultLoopFactory() - chosen_rec: type[Receiver] | None = None + chosen_rec: Receiver | None = None for rec in receivers: fac = rec.loop_factory() From 75206a97f11f238f5f0bea68d4fadc04270f4582 Mon Sep 17 00:00:00 2001 From: Danielle McLean Date: Thu, 11 Jul 2024 12:17:09 +1000 Subject: [PATCH 5/7] Add extra for websockets support --- pdm.lock | 187 +++++++++++++++++++++++++++++++------------------ pyproject.toml | 14 +++- 2 files changed, 129 insertions(+), 72 deletions(-) diff --git a/pdm.lock b/pdm.lock index cc13694..fa4b4e9 100644 --- a/pdm.lock +++ b/pdm.lock @@ -5,7 +5,7 @@ groups = ["default", "all", "dev"] strategy = ["cross_platform"] lock_version = "4.4.1" -content_hash = "sha256:c9845c93dab5638ddd0cbce910010bb8c03a6f42ae0af666e6b759d05540c8a6" +content_hash = "sha256:ddedd388cce9ed181dc2f3786240fc14e19ceb4539f0a360eeb2efb28da63ebe" [[package]] name = "aiocache" @@ -167,7 +167,7 @@ files = [ [[package]] name = "mypy" -version = "1.10.0" +version = "1.10.1" requires_python = ">=3.8" summary = "Optional static typing for Python" dependencies = [ @@ -175,13 +175,13 @@ dependencies = [ "typing-extensions>=4.1.0", ] files = [ - {file = "mypy-1.10.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:a781f6ad4bab20eef8b65174a57e5203f4be627b46291f4589879bf4e257b97b"}, - {file = "mypy-1.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b808e12113505b97d9023b0b5e0c0705a90571c6feefc6f215c1df9381256e30"}, - {file = "mypy-1.10.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f55583b12156c399dce2df7d16f8a5095291354f1e839c252ec6c0611e86e2e"}, - {file = "mypy-1.10.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4cf18f9d0efa1b16478c4c129eabec36148032575391095f73cae2e722fcf9d5"}, - {file = "mypy-1.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:bc6ac273b23c6b82da3bb25f4136c4fd42665f17f2cd850771cb600bdd2ebeda"}, - {file = "mypy-1.10.0-py3-none-any.whl", hash = "sha256:f8c083976eb530019175aabadb60921e73b4f45736760826aa1689dda8208aee"}, - {file = "mypy-1.10.0.tar.gz", hash = "sha256:3d087fcbec056c4ee34974da493a826ce316947485cef3901f511848e687c131"}, + {file = "mypy-1.10.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:d8681909f7b44d0b7b86e653ca152d6dff0eb5eb41694e163c6092124f8246d7"}, + {file = "mypy-1.10.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:378c03f53f10bbdd55ca94e46ec3ba255279706a6aacaecac52ad248f98205d3"}, + {file = "mypy-1.10.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bacf8f3a3d7d849f40ca6caea5c055122efe70e81480c8328ad29c55c69e93e"}, + {file = "mypy-1.10.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:701b5f71413f1e9855566a34d6e9d12624e9e0a8818a5704d74d6b0402e66c04"}, + {file = "mypy-1.10.1-cp312-cp312-win_amd64.whl", hash = "sha256:3c4c2992f6ea46ff7fce0072642cfb62af7a2484efe69017ed8b095f7b39ef31"}, + {file = "mypy-1.10.1-py3-none-any.whl", hash = "sha256:71d8ac0b906354ebda8ef1673e5fde785936ac1f29ff6987c7483cfbd5a4235a"}, + {file = "mypy-1.10.1.tar.gz", hash = "sha256:1f8f492d7db9e3593ef42d4f115f04e556130f2819ad33ab84551403e97dd4c0"}, ] [[package]] @@ -210,58 +210,70 @@ files = [ [[package]] name = "pydantic" -version = "2.7.4" +version = "2.8.2" requires_python = ">=3.8" summary = "Data validation using Python type hints" dependencies = [ "annotated-types>=0.4.0", - "pydantic-core==2.18.4", - "typing-extensions>=4.6.1", + "pydantic-core==2.20.1", + "typing-extensions>=4.12.2; python_version >= \"3.13\"", + "typing-extensions>=4.6.1; python_version < \"3.13\"", ] files = [ - {file = "pydantic-2.7.4-py3-none-any.whl", hash = "sha256:ee8538d41ccb9c0a9ad3e0e5f07bf15ed8015b481ced539a1759d8cc89ae90d0"}, - {file = "pydantic-2.7.4.tar.gz", hash = "sha256:0c84efd9548d545f63ac0060c1e4d39bb9b14db8b3c0652338aecc07b5adec52"}, + {file = "pydantic-2.8.2-py3-none-any.whl", hash = "sha256:73ee9fddd406dc318b885c7a2eab8a6472b68b8fb5ba8150949fc3db939f23c8"}, + {file = "pydantic-2.8.2.tar.gz", hash = "sha256:6f62c13d067b0755ad1c21a34bdd06c0c12625a22b0fc09c6b149816604f7c2a"}, ] [[package]] name = "pydantic-core" -version = "2.18.4" +version = "2.20.1" requires_python = ">=3.8" summary = "Core functionality for Pydantic validation and serialization" dependencies = [ "typing-extensions!=4.7.0,>=4.6.0", ] files = [ - {file = "pydantic_core-2.18.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:6f5c4d41b2771c730ea1c34e458e781b18cc668d194958e0112455fff4e402b2"}, - {file = "pydantic_core-2.18.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2fdf2156aa3d017fddf8aea5adfba9f777db1d6022d392b682d2a8329e087cef"}, - {file = "pydantic_core-2.18.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4748321b5078216070b151d5271ef3e7cc905ab170bbfd27d5c83ee3ec436695"}, - {file = "pydantic_core-2.18.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847a35c4d58721c5dc3dba599878ebbdfd96784f3fb8bb2c356e123bdcd73f34"}, - {file = "pydantic_core-2.18.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3c40d4eaad41f78e3bbda31b89edc46a3f3dc6e171bf0ecf097ff7a0ffff7cb1"}, - {file = "pydantic_core-2.18.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:21a5e440dbe315ab9825fcd459b8814bb92b27c974cbc23c3e8baa2b76890077"}, - {file = "pydantic_core-2.18.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:01dd777215e2aa86dfd664daed5957704b769e726626393438f9c87690ce78c3"}, - {file = "pydantic_core-2.18.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4b06beb3b3f1479d32befd1f3079cc47b34fa2da62457cdf6c963393340b56e9"}, - {file = "pydantic_core-2.18.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:564d7922e4b13a16b98772441879fcdcbe82ff50daa622d681dd682175ea918c"}, - {file = "pydantic_core-2.18.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:0eb2a4f660fcd8e2b1c90ad566db2b98d7f3f4717c64fe0a83e0adb39766d5b8"}, - {file = "pydantic_core-2.18.4-cp312-none-win32.whl", hash = "sha256:8b8bab4c97248095ae0c4455b5a1cd1cdd96e4e4769306ab19dda135ea4cdb07"}, - {file = "pydantic_core-2.18.4-cp312-none-win_amd64.whl", hash = "sha256:14601cdb733d741b8958224030e2bfe21a4a881fb3dd6fbb21f071cabd48fa0a"}, - {file = "pydantic_core-2.18.4-cp312-none-win_arm64.whl", hash = "sha256:c1322d7dd74713dcc157a2b7898a564ab091ca6c58302d5c7b4c07296e3fd00f"}, - {file = "pydantic_core-2.18.4-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:574d92eac874f7f4db0ca653514d823a0d22e2354359d0759e3f6a406db5d55d"}, - {file = "pydantic_core-2.18.4-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1f4d26ceb5eb9eed4af91bebeae4b06c3fb28966ca3a8fb765208cf6b51102ab"}, - {file = "pydantic_core-2.18.4-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77450e6d20016ec41f43ca4a6c63e9fdde03f0ae3fe90e7c27bdbeaece8b1ed4"}, - {file = "pydantic_core-2.18.4-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d323a01da91851a4f17bf592faf46149c9169d68430b3146dcba2bb5e5719abc"}, - {file = "pydantic_core-2.18.4-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:43d447dd2ae072a0065389092a231283f62d960030ecd27565672bd40746c507"}, - {file = "pydantic_core-2.18.4-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:578e24f761f3b425834f297b9935e1ce2e30f51400964ce4801002435a1b41ef"}, - {file = "pydantic_core-2.18.4-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:81b5efb2f126454586d0f40c4d834010979cb80785173d1586df845a632e4e6d"}, - {file = "pydantic_core-2.18.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ab86ce7c8f9bea87b9d12c7f0af71102acbf5ecbc66c17796cff45dae54ef9a5"}, - {file = "pydantic_core-2.18.4-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:90afc12421df2b1b4dcc975f814e21bc1754640d502a2fbcc6d41e77af5ec312"}, - {file = "pydantic_core-2.18.4-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:51991a89639a912c17bef4b45c87bd83593aee0437d8102556af4885811d59f5"}, - {file = "pydantic_core-2.18.4-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:293afe532740370aba8c060882f7d26cfd00c94cae32fd2e212a3a6e3b7bc15e"}, - {file = "pydantic_core-2.18.4-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b48ece5bde2e768197a2d0f6e925f9d7e3e826f0ad2271120f8144a9db18d5c8"}, - {file = "pydantic_core-2.18.4-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:eae237477a873ab46e8dd748e515c72c0c804fb380fbe6c85533c7de51f23a8f"}, - {file = "pydantic_core-2.18.4-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:834b5230b5dfc0c1ec37b2fda433b271cbbc0e507560b5d1588e2cc1148cf1ce"}, - {file = "pydantic_core-2.18.4-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:e858ac0a25074ba4bce653f9b5d0a85b7456eaddadc0ce82d3878c22489fa4ee"}, - {file = "pydantic_core-2.18.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2fd41f6eff4c20778d717af1cc50eca52f5afe7805ee530a4fbd0bae284f16e9"}, - {file = "pydantic_core-2.18.4.tar.gz", hash = "sha256:ec3beeada09ff865c344ff3bc2f427f5e6c26401cc6113d77e372c3fdac73864"}, + {file = "pydantic_core-2.20.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:595ba5be69b35777474fa07f80fc260ea71255656191adb22a8c53aba4479231"}, + {file = "pydantic_core-2.20.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a4f55095ad087474999ee28d3398bae183a66be4823f753cd7d67dd0153427c9"}, + {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f9aa05d09ecf4c75157197f27cdc9cfaeb7c5f15021c6373932bf3e124af029f"}, + {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e97fdf088d4b31ff4ba35db26d9cc472ac7ef4a2ff2badeabf8d727b3377fc52"}, + {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bc633a9fe1eb87e250b5c57d389cf28998e4292336926b0b6cdaee353f89a237"}, + {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d573faf8eb7e6b1cbbcb4f5b247c60ca8be39fe2c674495df0eb4318303137fe"}, + {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26dc97754b57d2fd00ac2b24dfa341abffc380b823211994c4efac7f13b9e90e"}, + {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:33499e85e739a4b60c9dac710c20a08dc73cb3240c9a0e22325e671b27b70d24"}, + {file = "pydantic_core-2.20.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:bebb4d6715c814597f85297c332297c6ce81e29436125ca59d1159b07f423eb1"}, + {file = "pydantic_core-2.20.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:516d9227919612425c8ef1c9b869bbbee249bc91912c8aaffb66116c0b447ebd"}, + {file = "pydantic_core-2.20.1-cp312-none-win32.whl", hash = "sha256:469f29f9093c9d834432034d33f5fe45699e664f12a13bf38c04967ce233d688"}, + {file = "pydantic_core-2.20.1-cp312-none-win_amd64.whl", hash = "sha256:035ede2e16da7281041f0e626459bcae33ed998cca6a0a007a5ebb73414ac72d"}, + {file = "pydantic_core-2.20.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:0827505a5c87e8aa285dc31e9ec7f4a17c81a813d45f70b1d9164e03a813a686"}, + {file = "pydantic_core-2.20.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19c0fa39fa154e7e0b7f82f88ef85faa2a4c23cc65aae2f5aea625e3c13c735a"}, + {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa223cd1e36b642092c326d694d8bf59b71ddddc94cdb752bbbb1c5c91d833b"}, + {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c336a6d235522a62fef872c6295a42ecb0c4e1d0f1a3e500fe949415761b8a19"}, + {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7eb6a0587eded33aeefea9f916899d42b1799b7b14b8f8ff2753c0ac1741edac"}, + {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:70c8daf4faca8da5a6d655f9af86faf6ec2e1768f4b8b9d0226c02f3d6209703"}, + {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e9fa4c9bf273ca41f940bceb86922a7667cd5bf90e95dbb157cbb8441008482c"}, + {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:11b71d67b4725e7e2a9f6e9c0ac1239bbc0c48cce3dc59f98635efc57d6dac83"}, + {file = "pydantic_core-2.20.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:270755f15174fb983890c49881e93f8f1b80f0b5e3a3cc1394a255706cabd203"}, + {file = "pydantic_core-2.20.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:c81131869240e3e568916ef4c307f8b99583efaa60a8112ef27a366eefba8ef0"}, + {file = "pydantic_core-2.20.1-cp313-none-win32.whl", hash = "sha256:b91ced227c41aa29c672814f50dbb05ec93536abf8f43cd14ec9521ea09afe4e"}, + {file = "pydantic_core-2.20.1-cp313-none-win_amd64.whl", hash = "sha256:65db0f2eefcaad1a3950f498aabb4875c8890438bc80b19362cf633b87a8ab20"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:a45f84b09ac9c3d35dfcf6a27fd0634d30d183205230a0ebe8373a0e8cfa0906"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d02a72df14dfdbaf228424573a07af10637bd490f0901cee872c4f434a735b94"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d2b27e6af28f07e2f195552b37d7d66b150adbaa39a6d327766ffd695799780f"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:084659fac3c83fd674596612aeff6041a18402f1e1bc19ca39e417d554468482"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:242b8feb3c493ab78be289c034a1f659e8826e2233786e36f2893a950a719bb6"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:38cf1c40a921d05c5edc61a785c0ddb4bed67827069f535d794ce6bcded919fc"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:e0bbdd76ce9aa5d4209d65f2b27fc6e5ef1312ae6c5333c26db3f5ade53a1e99"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:254ec27fdb5b1ee60684f91683be95e5133c994cc54e86a0b0963afa25c8f8a6"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:407653af5617f0757261ae249d3fba09504d7a71ab36ac057c938572d1bc9331"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:c693e916709c2465b02ca0ad7b387c4f8423d1db7b4649c551f27a529181c5ad"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5b5ff4911aea936a47d9376fd3ab17e970cc543d1b68921886e7f64bd28308d1"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:177f55a886d74f1808763976ac4efd29b7ed15c69f4d838bbd74d9d09cf6fa86"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:964faa8a861d2664f0c7ab0c181af0bea66098b1919439815ca8803ef136fc4e"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:4dd484681c15e6b9a977c785a345d3e378d72678fd5f1f3c0509608da24f2ac0"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f6d6cff3538391e8486a431569b77921adfcdef14eb18fbf19b7c0a5294d4e6a"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:a6d511cc297ff0883bc3708b465ff82d7560193169a8b93260f74ecb0a5e08a7"}, + {file = "pydantic_core-2.20.1.tar.gz", hash = "sha256:26ca695eeee5f9f1aeeb211ffc12f10bcb6f71e2989988fda61dabd65db878d4"}, ] [[package]] @@ -410,12 +422,12 @@ files = [ [[package]] name = "redis" -version = "5.0.4" +version = "5.0.7" requires_python = ">=3.7" summary = "Python client for Redis database and key-value store" files = [ - {file = "redis-5.0.4-py3-none-any.whl", hash = "sha256:7adc2835c7a9b5033b7ad8f8918d09b7344188228809c98df07af226d39dec91"}, - {file = "redis-5.0.4.tar.gz", hash = "sha256:ec31f2ed9675cc54c21ba854cfe0462e6faf1d83c8ce5944709db8a4700b9c61"}, + {file = "redis-5.0.7-py3-none-any.whl", hash = "sha256:0e479e24da960c690be5d9b96d21f7b918a98c0cf49af3b6fafaa0753f93a0db"}, + {file = "redis-5.0.7.tar.gz", hash = "sha256:8f611490b93c8109b50adc317b31bfd84fff31def3475b92e7e80bf39f48175b"}, ] [[package]] @@ -434,37 +446,74 @@ files = [ [[package]] name = "ruff" -version = "0.4.10" +version = "0.5.1" requires_python = ">=3.7" summary = "An extremely fast Python linter and code formatter, written in Rust." files = [ - {file = "ruff-0.4.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5c2c4d0859305ac5a16310eec40e4e9a9dec5dcdfbe92697acd99624e8638dac"}, - {file = "ruff-0.4.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a79489607d1495685cdd911a323a35871abfb7a95d4f98fc6f85e799227ac46e"}, - {file = "ruff-0.4.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b1dd1681dfa90a41b8376a61af05cc4dc5ff32c8f14f5fe20dba9ff5deb80cd6"}, - {file = "ruff-0.4.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c75c53bb79d71310dc79fb69eb4902fba804a81f374bc86a9b117a8d077a1784"}, - {file = "ruff-0.4.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:18238c80ee3d9100d3535d8eb15a59c4a0753b45cc55f8bf38f38d6a597b9739"}, - {file = "ruff-0.4.10-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:d8f71885bce242da344989cae08e263de29752f094233f932d4f5cfb4ef36a81"}, - {file = "ruff-0.4.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:330421543bd3222cdfec481e8ff3460e8702ed1e58b494cf9d9e4bf90db52b9d"}, - {file = "ruff-0.4.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e9b6fb3a37b772628415b00c4fc892f97954275394ed611056a4b8a2631365e"}, - {file = "ruff-0.4.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f54c481b39a762d48f64d97351048e842861c6662d63ec599f67d515cb417f6"}, - {file = "ruff-0.4.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:67fe086b433b965c22de0b4259ddfe6fa541c95bf418499bedb9ad5fb8d1c631"}, - {file = "ruff-0.4.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:acfaaab59543382085f9eb51f8e87bac26bf96b164839955f244d07125a982ef"}, - {file = "ruff-0.4.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:3cea07079962b2941244191569cf3a05541477286f5cafea638cd3aa94b56815"}, - {file = "ruff-0.4.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:338a64ef0748f8c3a80d7f05785930f7965d71ca260904a9321d13be24b79695"}, - {file = "ruff-0.4.10-py3-none-win32.whl", hash = "sha256:ffe3cd2f89cb54561c62e5fa20e8f182c0a444934bf430515a4b422f1ab7b7ca"}, - {file = "ruff-0.4.10-py3-none-win_amd64.whl", hash = "sha256:67f67cef43c55ffc8cc59e8e0b97e9e60b4837c8f21e8ab5ffd5d66e196e25f7"}, - {file = "ruff-0.4.10-py3-none-win_arm64.whl", hash = "sha256:dd1fcee327c20addac7916ca4e2653fbbf2e8388d8a6477ce5b4e986b68ae6c0"}, - {file = "ruff-0.4.10.tar.gz", hash = "sha256:3aa4f2bc388a30d346c56524f7cacca85945ba124945fe489952aadb6b5cd804"}, + {file = "ruff-0.5.1-py3-none-linux_armv6l.whl", hash = "sha256:6ecf968fcf94d942d42b700af18ede94b07521bd188aaf2cd7bc898dd8cb63b6"}, + {file = "ruff-0.5.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:204fb0a472f00f2e6280a7c8c7c066e11e20e23a37557d63045bf27a616ba61c"}, + {file = "ruff-0.5.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d235968460e8758d1e1297e1de59a38d94102f60cafb4d5382033c324404ee9d"}, + {file = "ruff-0.5.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38beace10b8d5f9b6bdc91619310af6d63dd2019f3fb2d17a2da26360d7962fa"}, + {file = "ruff-0.5.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5e478d2f09cf06add143cf8c4540ef77b6599191e0c50ed976582f06e588c994"}, + {file = "ruff-0.5.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f0368d765eec8247b8550251c49ebb20554cc4e812f383ff9f5bf0d5d94190b0"}, + {file = "ruff-0.5.1-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:3a9a9a1b582e37669b0138b7c1d9d60b9edac880b80eb2baba6d0e566bdeca4d"}, + {file = "ruff-0.5.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bdd9f723e16003623423affabcc0a807a66552ee6a29f90eddad87a40c750b78"}, + {file = "ruff-0.5.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:be9fd62c1e99539da05fcdc1e90d20f74aec1b7a1613463ed77870057cd6bd96"}, + {file = "ruff-0.5.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e216fc75a80ea1fbd96af94a6233d90190d5b65cc3d5dfacf2bd48c3e067d3e1"}, + {file = "ruff-0.5.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c4c2112e9883a40967827d5c24803525145e7dab315497fae149764979ac7929"}, + {file = "ruff-0.5.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dfaf11c8a116394da3b65cd4b36de30d8552fa45b8119b9ef5ca6638ab964fa3"}, + {file = "ruff-0.5.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d7ceb9b2fe700ee09a0c6b192c5ef03c56eb82a0514218d8ff700f6ade004108"}, + {file = "ruff-0.5.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:bac6288e82f6296f82ed5285f597713acb2a6ae26618ffc6b429c597b392535c"}, + {file = "ruff-0.5.1-py3-none-win32.whl", hash = "sha256:5c441d9c24ec09e1cb190a04535c5379b36b73c4bc20aa180c54812c27d1cca4"}, + {file = "ruff-0.5.1-py3-none-win_amd64.whl", hash = "sha256:b1789bf2cd3d1b5a7d38397cac1398ddf3ad7f73f4de01b1e913e2abc7dfc51d"}, + {file = "ruff-0.5.1-py3-none-win_arm64.whl", hash = "sha256:2875b7596a740cbbd492f32d24be73e545a4ce0a3daf51e4f4e609962bfd3cd2"}, + {file = "ruff-0.5.1.tar.gz", hash = "sha256:3164488aebd89b1745b47fd00604fb4358d774465f20d1fcd907f9c0fc1b0655"}, ] [[package]] name = "typing-extensions" -version = "4.11.0" +version = "4.12.2" requires_python = ">=3.8" summary = "Backported and Experimental Type Hints for Python 3.8+" files = [ - {file = "typing_extensions-4.11.0-py3-none-any.whl", hash = "sha256:c1f94d72897edaf4ce775bb7558d5b79d8126906a14ea5ed1635921406c0387a"}, - {file = "typing_extensions-4.11.0.tar.gz", hash = "sha256:83f085bd5ca59c80295fc2a82ab5dac679cbe02b9f33f7d83af68e241bea51b0"}, + {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, + {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, +] + +[[package]] +name = "websockets" +version = "12.0" +requires_python = ">=3.8" +summary = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" +files = [ + {file = "websockets-12.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0e6e2711d5a8e6e482cacb927a49a3d432345dfe7dea8ace7b5790df5932e4df"}, + {file = "websockets-12.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:dbcf72a37f0b3316e993e13ecf32f10c0e1259c28ffd0a85cee26e8549595fbc"}, + {file = "websockets-12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:12743ab88ab2af1d17dd4acb4645677cb7063ef4db93abffbf164218a5d54c6b"}, + {file = "websockets-12.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b645f491f3c48d3f8a00d1fce07445fab7347fec54a3e65f0725d730d5b99cb"}, + {file = "websockets-12.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9893d1aa45a7f8b3bc4510f6ccf8db8c3b62120917af15e3de247f0780294b92"}, + {file = "websockets-12.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f38a7b376117ef7aff996e737583172bdf535932c9ca021746573bce40165ed"}, + {file = "websockets-12.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:f764ba54e33daf20e167915edc443b6f88956f37fb606449b4a5b10ba42235a5"}, + {file = "websockets-12.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:1e4b3f8ea6a9cfa8be8484c9221ec0257508e3a1ec43c36acdefb2a9c3b00aa2"}, + {file = "websockets-12.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:9fdf06fd06c32205a07e47328ab49c40fc1407cdec801d698a7c41167ea45113"}, + {file = "websockets-12.0-cp312-cp312-win32.whl", hash = "sha256:baa386875b70cbd81798fa9f71be689c1bf484f65fd6fb08d051a0ee4e79924d"}, + {file = "websockets-12.0-cp312-cp312-win_amd64.whl", hash = "sha256:ae0a5da8f35a5be197f328d4727dbcfafa53d1824fac3d96cdd3a642fe09394f"}, + {file = "websockets-12.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:248d8e2446e13c1d4326e0a6a4e9629cb13a11195051a73acf414812700badbd"}, + {file = "websockets-12.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f44069528d45a933997a6fef143030d8ca8042f0dfaad753e2906398290e2870"}, + {file = "websockets-12.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c4e37d36f0d19f0a4413d3e18c0d03d0c268ada2061868c1e6f5ab1a6d575077"}, + {file = "websockets-12.0-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d829f975fc2e527a3ef2f9c8f25e553eb7bc779c6665e8e1d52aa22800bb38b"}, + {file = "websockets-12.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:2c71bd45a777433dd9113847af751aae36e448bc6b8c361a566cb043eda6ec30"}, + {file = "websockets-12.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:0bee75f400895aef54157b36ed6d3b308fcab62e5260703add87f44cee9c82a6"}, + {file = "websockets-12.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:423fc1ed29f7512fceb727e2d2aecb952c46aa34895e9ed96071821309951123"}, + {file = "websockets-12.0-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:27a5e9964ef509016759f2ef3f2c1e13f403725a5e6a1775555994966a66e931"}, + {file = "websockets-12.0-pp38-pypy38_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c3181df4583c4d3994d31fb235dc681d2aaad744fbdbf94c4802485ececdecf2"}, + {file = "websockets-12.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:b067cb952ce8bf40115f6c19f478dc71c5e719b7fbaa511359795dfd9d1a6468"}, + {file = "websockets-12.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:00700340c6c7ab788f176d118775202aadea7602c5cc6be6ae127761c16d6b0b"}, + {file = "websockets-12.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e469d01137942849cff40517c97a30a93ae79917752b34029f0ec72df6b46399"}, + {file = "websockets-12.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffefa1374cd508d633646d51a8e9277763a9b78ae71324183693959cf94635a7"}, + {file = "websockets-12.0-pp39-pypy39_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba0cab91b3956dfa9f512147860783a1829a8d905ee218a9837c18f683239611"}, + {file = "websockets-12.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2cb388a5bfb56df4d9a406783b7f9dbefb888c09b71629351cc6b036e9259370"}, + {file = "websockets-12.0-py3-none-any.whl", hash = "sha256:dc284bbc8d7c78a6c69e0c7325ab46ee5e40bb4d50e494d8131a07ef47500e9e"}, + {file = "websockets-12.0.tar.gz", hash = "sha256:81df9cbcbb6c260de1e007e58c011bfebe2dafc8435107b0537f393dd38c8b1b"}, ] [[package]] diff --git a/pyproject.toml b/pyproject.toml index fa32f8f..b5cb70e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,7 @@ dependencies = [ "boltons>=24.0.0", "pydantic>=2.7.4", "rich>=13.7.1", + "ormsgpack>=1.5.0", ] readme = "README.md" @@ -30,9 +31,16 @@ classifiers = [ ] [project.optional-dependencies] -redis = ["aiocache[redis]", "ormsgpack>=1.5.0"] -memcached = ["aiocache[memcached]", "ormsgpack>=1.5.0"] -all = ["mpd-now-playable[redis,memcached]"] +redis = [ + "aiocache[redis]", +] +memcached = [ + "aiocache[memcached]", +] +websockets = [ + "websockets>=12.0", +] +all = ["mpd-now-playable[redis,memcached,websockets]"] [project.urls] Homepage = "https://git.00dani.me/00dani/mpd-now-playable" From 582a4628b7d1792df122a57a0c2e8c132f02f1cb Mon Sep 17 00:00:00 2001 From: Danielle McLean Date: Sat, 13 Jul 2024 18:34:53 +1000 Subject: [PATCH 6/7] 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) From ca5086f93a0cebb9d03e268552dcee7f6efbe653 Mon Sep 17 00:00:00 2001 From: Danielle McLean Date: Sat, 13 Jul 2024 18:38:16 +1000 Subject: [PATCH 7/7] Fix path to MPD logo in Cocoa receiver (oops) --- src/mpd_now_playable/receivers/cocoa/now_playing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mpd_now_playable/receivers/cocoa/now_playing.py b/src/mpd_now_playable/receivers/cocoa/now_playing.py index 60c2b49..d207b65 100644 --- a/src/mpd_now_playable/receivers/cocoa/now_playing.py +++ b/src/mpd_now_playable/receivers/cocoa/now_playing.py @@ -47,7 +47,7 @@ from .persistent_id import song_to_persistent_id def logo_to_ns_image() -> NSImage: return NSImage.alloc().initByReferencingFile_( - str(Path(__file__).parent.parent / "mpd/logo.svg") + str(Path(__file__).parent.parent.parent / "mpd/logo.svg") )