Refactor Cocoa stuff into a 'receiver'

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.
This commit is contained in:
Danielle McLean 2024-07-09 12:52:49 +10:00
parent 27d8c37139
commit 00ba34bd0b
Signed by: 00dani
GPG key ID: 6854781A0488421C
12 changed files with 214 additions and 38 deletions

View file

@ -70,6 +70,7 @@ select = [
] ]
ignore = [ ignore = [
"ANN101", # missing-type-self "ANN101", # missing-type-self
"ANN102", # missing-type-cls
] ]
[tool.ruff.lint.flake8-annotations] [tool.ruff.lint.flake8-annotations]

View file

@ -1,5 +1,20 @@
{ {
"$defs": { "$defs": {
"CocoaReceiverConfig": {
"properties": {
"kind": {
"const": "cocoa",
"default": "cocoa",
"enum": [
"cocoa"
],
"title": "Kind",
"type": "string"
}
},
"title": "CocoaReceiverConfig",
"type": "object"
},
"MpdConfig": { "MpdConfig": {
"properties": { "properties": {
"host": { "host": {
@ -40,6 +55,28 @@
}, },
"mpd": { "mpd": {
"$ref": "#/$defs/MpdConfig" "$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", "title": "Config",

View file

@ -1,30 +1,41 @@
import asyncio import asyncio
from collections.abc import Iterable
from corefoundationasyncio import CoreFoundationEventLoop
from rich import print from rich import print
from .__version__ import __version__ from .__version__ import __version__
from .cocoa.now_playing import CocoaNowPlaying
from .config.load import loadConfig from .config.load import loadConfig
from .config.model import Config
from .mpd.listener import MpdStateListener from .mpd.listener import MpdStateListener
from .song_receiver import (
Receiver,
choose_loop_factory,
import_receiver,
)
async def listen() -> None: async def listen(
print(f"mpd-now-playable v{__version__}") config: Config, listener: MpdStateListener, receiver_types: Iterable[type[Receiver]]
config = loadConfig() ) -> None:
print(config)
listener = MpdStateListener(config.cache)
now_playing = CocoaNowPlaying(listener)
await listener.start(config.mpd) await listener.start(config.mpd)
await listener.loop(now_playing) receivers = (rec(listener, config) for rec in receiver_types)
await listener.loop(receivers)
def make_loop() -> CoreFoundationEventLoop:
return CoreFoundationEventLoop(console_app=True)
def main() -> None: 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__": if __name__ == "__main__":

View file

@ -1,10 +1,33 @@
from dataclasses import dataclass, field 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 ..tools.schema.define import schema
from .fields import Host, Password, Port, Url 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) @dataclass(slots=True)
@ -27,3 +50,4 @@ class Config:
#: protocols are memory://, redis://, and memcached://. #: protocols are memory://, redis://, and memcached://.
cache: Optional[Url] = None cache: Optional[Url] = None
mpd: MpdConfig = field(default_factory=MpdConfig) mpd: MpdConfig = field(default_factory=MpdConfig)
receivers: tuple[ReceiverConfig, ...] = (CocoaReceiverConfig(),)

View file

@ -1,4 +1,5 @@
import asyncio import asyncio
from collections.abc import Iterable
from pathlib import Path from pathlib import Path
from uuid import UUID from uuid import UUID
@ -9,7 +10,8 @@ from yarl import URL
from ..config.model import MpdConfig from ..config.model import MpdConfig
from ..player import Player 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 ..tools.types import convert_if_exists, un_maybe_plural
from .artwork_cache import MpdArtworkCache from .artwork_cache import MpdArtworkCache
from .types import CurrentSongResponse, StatusResponse from .types import CurrentSongResponse, StatusResponse
@ -43,7 +45,7 @@ def mpd_current_to_song(
class MpdStateListener(Player): class MpdStateListener(Player):
client: MPDClient client: MPDClient
listener: SongListener receivers: Iterable[Receiver]
art_cache: MpdArtworkCache art_cache: MpdArtworkCache
idle_count = 0 idle_count = 0
@ -62,18 +64,18 @@ class MpdStateListener(Player):
print(f"Connected to MPD v{self.client.mpd_version}") print(f"Connected to MPD v{self.client.mpd_version}")
async def refresh(self) -> None: async def refresh(self) -> None:
await self.update_listener(self.listener) await self.update_receivers()
async def loop(self, listener: SongListener) -> None: async def loop(self, receivers: Iterable[Receiver]) -> None:
self.listener = listener self.receivers = receivers
# notify our listener of the initial state MPD is in when this script loads up. # notify our receivers of the initial state MPD is in when this script loads up.
await self.update_listener(listener) await self.update_receivers()
# then wait for stuff to change in MPD. :) # then wait for stuff to change in MPD. :)
async for _ in self.client.idle(): async for _ in self.client.idle():
self.idle_count += 1 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. # 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 starting_idle_count = self.idle_count
status, current = await asyncio.gather( status, current = await asyncio.gather(
@ -85,7 +87,8 @@ class MpdStateListener(Player):
if status["state"] == "stop": if status["state"] == "stop":
print("Nothing playing") print("Nothing playing")
listener.update(None) for r in self.receivers:
r.update(None)
return return
art = await self.art_cache.get_cached_artwork(current) 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)) song = mpd_current_to_song(status, current, to_artwork(art))
rprint(song) rprint(song)
listener.update(song) for r in self.receivers:
r.update(song)
async def get_art(self, file: str) -> bytes | None: async def get_art(self, file: str) -> bytes | None:
picture = await self.readpicture(file) picture = await self.readpicture(file)

View file

@ -0,0 +1,3 @@
__all__ = ("receiver",)
from .now_playing import CocoaNowPlayingReceiver as receiver

View file

@ -1,7 +1,9 @@
from collections.abc import Callable, Coroutine from collections.abc import Callable, Coroutine
from pathlib import Path from pathlib import Path
from typing import Literal
from AppKit import NSCompositingOperationCopy, NSImage, NSMakeRect from AppKit import NSCompositingOperationCopy, NSImage, NSMakeRect
from corefoundationasyncio import CoreFoundationEventLoop
from Foundation import CGSize, NSMutableDictionary from Foundation import CGSize, NSMutableDictionary
from MediaPlayer import ( from MediaPlayer import (
MPChangePlaybackPositionCommandEvent, MPChangePlaybackPositionCommandEvent,
@ -35,9 +37,11 @@ from MediaPlayer import (
MPRemoteCommandHandlerStatusSuccess, MPRemoteCommandHandlerStatusSuccess,
) )
from ..player import Player from ...config.model import Config
from ..song import PlaybackState, Song from ...player import Player
from ..tools.asyncio import run_background_task 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 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()) MPD_LOGO = ns_image_to_media_item_artwork(logo_to_ns_image())
class CocoaNowPlaying: class CocoaLoopFactory(LoopFactory[CoreFoundationEventLoop]):
def __init__(self, player: Player): @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.cmd_center = MPRemoteCommandCenter.sharedCommandCenter()
self.info_center = MPNowPlayingInfoCenter.defaultCenter() self.info_center = MPNowPlayingInfoCenter.defaultCenter()

View file

@ -3,7 +3,7 @@ from pathlib import Path
from typing import Final from typing import Final
from uuid import UUID 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. # The maximum size for a BLAKE2b "person" value is sixteen bytes, so we need to be concise.
HASH_PERSON_PREFIX: Final = b"mnp.mac." HASH_PERSON_PREFIX: Final = b"mnp.mac."

View file

@ -1,7 +1,7 @@
from dataclasses import dataclass, field from dataclasses import dataclass, field
from enum import StrEnum from enum import StrEnum
from pathlib import Path from pathlib import Path
from typing import Literal, Protocol from typing import Literal
from uuid import UUID from uuid import UUID
from pydantic.type_adapter import TypeAdapter from pydantic.type_adapter import TypeAdapter
@ -53,7 +53,3 @@ class Song:
duration: float duration: float
elapsed: float elapsed: float
art: Artwork art: Artwork
class SongListener(Protocol):
def update(self, song: Song | None) -> None: ...

View file

@ -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

View file

@ -31,6 +31,12 @@ AnyExceptList = (
U = TypeVar("U") 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: def convert_if_exists(value: str | None, converter: Callable[[str], U]) -> U | None:
if value is None: if value is None:
return None return None