From 00ba34bd0b949228f1e254320db1d5f973250dbb Mon Sep 17 00:00:00 2001 From: Danielle McLean Date: Tue, 9 Jul 2024 12:52:49 +1000 Subject: [PATCH] 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