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:
parent
27d8c37139
commit
00ba34bd0b
12 changed files with 214 additions and 38 deletions
|
@ -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]
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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__":
|
||||||
|
|
|
@ -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(),)
|
||||||
|
|
|
@ -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)
|
||||||
|
|
3
src/mpd_now_playable/receivers/cocoa/__init__.py
Normal file
3
src/mpd_now_playable/receivers/cocoa/__init__.py
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
__all__ = ("receiver",)
|
||||||
|
|
||||||
|
from .now_playing import CocoaNowPlayingReceiver as receiver
|
|
@ -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()
|
||||||
|
|
|
@ -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."
|
|
@ -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: ...
|
|
||||||
|
|
76
src/mpd_now_playable/song_receiver.py
Normal file
76
src/mpd_now_playable/song_receiver.py
Normal 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
|
|
@ -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
|
||||||
|
|
Loading…
Reference in a new issue