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