From a673f2ef9040cf7123464fbd3472f5e12802283c Mon Sep 17 00:00:00 2001 From: Danielle McLean Date: Mon, 27 Nov 2023 15:49:33 +1100 Subject: [PATCH] Initial commit of source - working, but needs stubs for Cocoa --- src/mpd_now_playable/__init__.py | 0 src/mpd_now_playable/async_tools.py | 19 + src/mpd_now_playable/cli.py | 30 + src/mpd_now_playable/cocoa.py | 152 ++++ src/mpd_now_playable/mpd/__init__.py | 0 src/mpd_now_playable/mpd/artwork_cache.py | 49 ++ src/mpd_now_playable/mpd/listener.py | 117 +++ src/mpd_now_playable/mpd/logo.svg | 858 ++++++++++++++++++++++ src/mpd_now_playable/mpd/types.py | 64 ++ src/mpd_now_playable/player.py | 23 + src/mpd_now_playable/py.typed | 0 src/mpd_now_playable/song.py | 27 + 12 files changed, 1339 insertions(+) create mode 100644 src/mpd_now_playable/__init__.py create mode 100644 src/mpd_now_playable/async_tools.py create mode 100644 src/mpd_now_playable/cli.py create mode 100644 src/mpd_now_playable/cocoa.py create mode 100644 src/mpd_now_playable/mpd/__init__.py create mode 100644 src/mpd_now_playable/mpd/artwork_cache.py create mode 100644 src/mpd_now_playable/mpd/listener.py create mode 100644 src/mpd_now_playable/mpd/logo.svg create mode 100644 src/mpd_now_playable/mpd/types.py create mode 100644 src/mpd_now_playable/player.py create mode 100644 src/mpd_now_playable/py.typed create mode 100644 src/mpd_now_playable/song.py diff --git a/src/mpd_now_playable/__init__.py b/src/mpd_now_playable/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/mpd_now_playable/async_tools.py b/src/mpd_now_playable/async_tools.py new file mode 100644 index 0000000..312b584 --- /dev/null +++ b/src/mpd_now_playable/async_tools.py @@ -0,0 +1,19 @@ +import asyncio +from collections.abc import Coroutine +from contextvars import Context +from typing import Optional + +__all__ = ("run_background_task",) + +background_tasks: set[asyncio.Task[None]] = set() + + +def run_background_task( + coro: Coroutine[None, None, None], + *, + name: Optional[str] = None, + context: Optional[Context] = None, +) -> None: + task = asyncio.create_task(coro, name=name, context=context) + background_tasks.add(task) + task.add_done_callback(background_tasks.discard) diff --git a/src/mpd_now_playable/cli.py b/src/mpd_now_playable/cli.py new file mode 100644 index 0000000..3afa6f1 --- /dev/null +++ b/src/mpd_now_playable/cli.py @@ -0,0 +1,30 @@ +import asyncio +from os import environ + +from corefoundationasyncio import CoreFoundationEventLoop + +from .cocoa import CocoaNowPlaying +from .mpd.listener import MpdStateListener + + +async def listen() -> None: + listener = MpdStateListener() + now_playing = CocoaNowPlaying(listener) + await listener.start( + hostname=environ.get("MPD_HOSTNAME", "localhost"), + port=int(environ.get("MPD_PORT", "6600")), + password=environ.get("MPD_PASSWORD"), + ) + await listener.loop(now_playing) + + +def make_loop() -> CoreFoundationEventLoop: + return CoreFoundationEventLoop(console_app=True) + + +def main() -> None: + asyncio.run(listen(), loop_factory=make_loop, debug=True) + + +if __name__ == "__main__": + main() diff --git a/src/mpd_now_playable/cocoa.py b/src/mpd_now_playable/cocoa.py new file mode 100644 index 0000000..d20c13f --- /dev/null +++ b/src/mpd_now_playable/cocoa.py @@ -0,0 +1,152 @@ +from collections.abc import Callable, Coroutine +from pathlib import Path + +from AppKit import NSCompositingOperationCopy, NSImage, NSMakeRect +from Foundation import CGSize, NSMutableDictionary +from MediaPlayer import ( + MPMediaItemArtwork, + MPMediaItemPropertyAlbumTitle, + MPMediaItemPropertyArtist, + MPMediaItemPropertyArtwork, + MPMediaItemPropertyPlaybackDuration, + MPMediaItemPropertyTitle, + MPMusicPlaybackState, + MPMusicPlaybackStatePaused, + MPMusicPlaybackStatePlaying, + MPMusicPlaybackStateStopped, + MPNowPlayingInfoCenter, + MPNowPlayingInfoMediaTypeAudio, + MPNowPlayingInfoMediaTypeNone, + MPNowPlayingInfoPropertyElapsedPlaybackTime, + MPNowPlayingInfoPropertyMediaType, + MPRemoteCommandCenter, + MPRemoteCommandEvent, + MPRemoteCommandHandlerStatus, +) + +from .async_tools import run_background_task +from .player import Player +from .song import PlaybackState, Song + + +def logo_to_ns_image() -> NSImage: + return NSImage.alloc().initByReferencingFile_( + str(Path(__file__).parent / "mpd/logo.svg") + ) + + +def data_to_ns_image(data: bytes) -> NSImage: + return NSImage.alloc().initWithData_(data) + + +def ns_image_to_media_item_artwork(img: NSImage) -> MPMediaItemArtwork: + def resize(size: CGSize) -> NSImage: + new = NSImage.alloc().initWithSize_(size) + new.lockFocus() + img.drawInRect_fromRect_operation_fraction_( + NSMakeRect(0, 0, size.width, size.height), + NSMakeRect(0, 0, img.size().width, img.size().height), + NSCompositingOperationCopy, + 1.0, + ) + new.unlockFocus() + return new + + return MPMediaItemArtwork.alloc().initWithBoundsSize_requestHandler_( + img.size(), resize + ) + + +def playback_state_to_cocoa(state: PlaybackState) -> MPMusicPlaybackState: + return { + PlaybackState.play: MPMusicPlaybackStatePlaying, + PlaybackState.pause: MPMusicPlaybackStatePaused, + PlaybackState.stop: MPMusicPlaybackStateStopped, + }[state] + + +def song_to_media_item(song: Song) -> NSMutableDictionary: + nowplaying_info = nothing_to_media_item() + nowplaying_info[MPNowPlayingInfoPropertyMediaType] = MPNowPlayingInfoMediaTypeAudio + nowplaying_info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = song.elapsed + + nowplaying_info[MPMediaItemPropertyTitle] = song.title + nowplaying_info[MPMediaItemPropertyArtist] = song.artist + nowplaying_info[MPMediaItemPropertyAlbumTitle] = song.album + nowplaying_info[MPMediaItemPropertyPlaybackDuration] = song.duration + + if song.art: + nowplaying_info[MPMediaItemPropertyArtwork] = ns_image_to_media_item_artwork( + data_to_ns_image(song.art) + ) + return nowplaying_info + + +def nothing_to_media_item() -> NSMutableDictionary: + nowplaying_info = NSMutableDictionary.dictionary() + nowplaying_info[MPNowPlayingInfoPropertyMediaType] = MPNowPlayingInfoMediaTypeNone + nowplaying_info[MPMediaItemPropertyArtwork] = MPD_LOGO + nowplaying_info[MPMediaItemPropertyTitle] = "MPD (stopped)" + + return nowplaying_info + + +MPD_LOGO = ns_image_to_media_item_artwork(logo_to_ns_image()) + + +class CocoaNowPlaying: + def __init__(self, player: Player): + self.cmd_center = MPRemoteCommandCenter.sharedCommandCenter() + self.info_center = MPNowPlayingInfoCenter.defaultCenter() + + cmds = ( + (self.cmd_center.togglePlayPauseCommand(), player.on_play_pause), + (self.cmd_center.playCommand(), player.on_play), + (self.cmd_center.pauseCommand(), player.on_pause), + (self.cmd_center.stopCommand(), player.on_stop), + (self.cmd_center.nextTrackCommand(), player.on_next), + (self.cmd_center.previousTrackCommand(), player.on_prev), + ) + + for cmd, handler in cmds: + cmd.setEnabled_(True) + cmd.removeTarget_(None) + cmd.addTargetWithHandler_(self._create_handler(handler)) + + unsupported_cmds = ( + self.cmd_center.changePlaybackRateCommand(), + self.cmd_center.seekBackwardCommand(), + self.cmd_center.skipBackwardCommand(), + self.cmd_center.seekForwardCommand(), + self.cmd_center.skipForwardCommand(), + self.cmd_center.changePlaybackPositionCommand(), + ) + for cmd in unsupported_cmds: + cmd.setEnabled_(False) + + # If MPD is paused when this bridge starts up, we actually want the now + # playing info center to see a playing -> paused transition, so we can + # unpause with remote commands. + self.info_center.setPlaybackState_(MPMusicPlaybackStatePlaying) + + 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)) + else: + self.info_center.setNowPlayingInfo_(nothing_to_media_item()) + self.info_center.setPlaybackState_(MPMusicPlaybackStateStopped) + + def _create_handler( + self, player: Callable[[], Coroutine[None, None, PlaybackState | None]] + ) -> Callable[[MPRemoteCommandEvent], None]: + async def invoke_music_player() -> None: + result = await player() + if result: + self.info_center.setPlaybackState_(playback_state_to_cocoa(result)) + + def handler(event: MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus: + run_background_task(invoke_music_player()) + return 0 + + return handler diff --git a/src/mpd_now_playable/mpd/__init__.py b/src/mpd_now_playable/mpd/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/mpd_now_playable/mpd/artwork_cache.py b/src/mpd_now_playable/mpd/artwork_cache.py new file mode 100644 index 0000000..febc600 --- /dev/null +++ b/src/mpd_now_playable/mpd/artwork_cache.py @@ -0,0 +1,49 @@ +from aiocache import Cache + +from ..async_tools import run_background_task +from .types import CurrentSongResponse, MpdStateHandler + +CACHE_TTL = 60 * 10 # ten minutes + + +def calc_album_key(song: CurrentSongResponse) -> str: + return f'{song["albumartist"]}:-:-:{song["album"]}' + + +def calc_track_key(song: CurrentSongResponse) -> str: + return song["file"] + + +class MpdArtworkCache: + mpd: MpdStateHandler + album_cache: 'Cache[bytes | None]' + track_cache: 'Cache[bytes | None]' + + def __init__(self, mpd: MpdStateHandler): + self.mpd = mpd + self.album_cache = Cache() + self.track_cache = Cache() + + async def get_cached_artwork(self, song: CurrentSongResponse) -> bytes | None: + art = await self.track_cache.get(calc_track_key(song)) + if art: + return art + + # If we don't have track artwork cached, go find some. + run_background_task(self.cache_artwork(song)) + + # Even if we don't have cached track art, we can try looking for cached album art. + art = await self.album_cache.get(calc_album_key(song)) + if art: + return art + + return None + + async def cache_artwork(self, song: CurrentSongResponse) -> None: + art = await self.mpd.readpicture(song["file"]) + try: + await self.album_cache.add(calc_album_key(song), art, ttl=CACHE_TTL) + except ValueError: + pass + await self.track_cache.set(calc_track_key(song), art, ttl=CACHE_TTL) + await self.mpd.refresh() diff --git a/src/mpd_now_playable/mpd/listener.py b/src/mpd_now_playable/mpd/listener.py new file mode 100644 index 0000000..704d9ed --- /dev/null +++ b/src/mpd_now_playable/mpd/listener.py @@ -0,0 +1,117 @@ +import asyncio + +from mpd.asyncio import MPDClient +from mpd.base import CommandError + +from ..player import Player +from ..song import PlaybackState, Song, SongListener +from .artwork_cache import MpdArtworkCache +from .types import CurrentSongResponse, StatusResponse + + +def mpd_current_to_song( + status: StatusResponse, current: CurrentSongResponse, art: bytes | None +) -> Song: + return Song( + state=PlaybackState(status["state"]), + title=current["title"], + artist=current["artist"], + album=current["album"], + album_artist=current["albumartist"], + duration=float(status["duration"]), + elapsed=float(status["elapsed"]), + art=art, + ) + + +class MpdStateListener(Player): + client: MPDClient + listener: SongListener + art_cache: MpdArtworkCache + idle_count = 0 + + def __init__(self) -> None: + self.client = MPDClient() + self.art_cache = MpdArtworkCache(self) + + async def start( + self, hostname: str = "localhost", port: int = 6600, password: str | None = None + ) -> None: + print(f"Connecting to MPD server {hostname}:{port}...") + await self.client.connect(hostname, port) + if password is not None: + print("Authorising to MPD with your password...") + await self.client.password(password) + print(f"Connected to MPD v{self.client.mpd_version}") + + async def refresh(self) -> None: + await self.update_listener(self.listener) + + 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) + # then wait for stuff to change in MPD. :) + async for _ in self.client.idle(): + self.idle_count += 1 + await self.update_listener(listener) + + async def update_listener(self, listener: SongListener) -> 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( + self.client.status(), self.client.currentsong() + ) + + if starting_idle_count != self.idle_count: + return + + if status["state"] == "stop": + print("Nothing playing") + listener.update(None) + return + + art = await self.art_cache.get_cached_artwork(current) + if starting_idle_count != self.idle_count: + return + + song = mpd_current_to_song(status, current, art) + print(song) + listener.update(song) + + async def readpicture(self, file: str) -> bytes | None: + try: + readpic = await self.client.readpicture(file) + return readpic["binary"] + except CommandError: + return None + + async def on_play_pause(self) -> PlaybackState: + # python-mpd2 has direct support for toggling the play/pause state by + # calling MPDClient.pause(None), but it doesn't tell you the final + # state, and we also want to support playing from stopped, so we need + # to handle this ourselves. + status = await self.client.status() + return await { + "play": self.on_pause, + "pause": self.on_play, + "stop": self.on_play, + }[status["state"]]() + + async def on_play(self) -> PlaybackState: + await self.client.play() + return PlaybackState.play + + async def on_pause(self) -> PlaybackState: + await self.client.pause(1) + return PlaybackState.pause + + async def on_stop(self) -> PlaybackState: + await self.client.stop() + return PlaybackState.stop + + async def on_next(self) -> None: + await self.client.next() + + async def on_prev(self) -> None: + await self.client.previous() diff --git a/src/mpd_now_playable/mpd/logo.svg b/src/mpd_now_playable/mpd/logo.svg new file mode 100644 index 0000000..e281c2c --- /dev/null +++ b/src/mpd_now_playable/mpd/logo.svg @@ -0,0 +1,858 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + diff --git a/src/mpd_now_playable/mpd/types.py b/src/mpd_now_playable/mpd/types.py new file mode 100644 index 0000000..8ede8aa --- /dev/null +++ b/src/mpd_now_playable/mpd/types.py @@ -0,0 +1,64 @@ +from typing import Protocol, TypedDict + + +class MpdStateHandler(Protocol): + async def readpicture(self, file: str) -> bytes | None: + ... + + async def refresh(self) -> None: + ... + + +class StatusResponse(TypedDict): + volume: str + repeat: str + random: str + single: str + consume: str + partition: str + playlist: str + playlistlength: str + mixrampdb: str + state: str + song: str + songid: str + time: str + elapsed: str + bitrate: str + duration: str + audio: str + nextsong: str + nextsongid: str + + +CurrentSongResponse = TypedDict( + "CurrentSongResponse", + { + "file": str, + "last-modified": str, + "format": str, + "artist": str, + "albumartist": str, + "artistsort": str, + "albumartistsort": str, + "title": str, + "album": str, + "track": str, + "date": str, + "originaldate": str, + "composer": str, + "disc": str, + "label": str, + "musicbrainz_albumid": str, + "musicbrainz_albumartistid": str, + "musicbrainz_releasetrackid": str, + "musicbrainz_artistid": str, + "musicbrainz_trackid": str, + "time": str, + "duration": str, + "pos": str, + "id": str, + }, +) + +ReadPictureResponse = TypedDict("ReadPictureResponse", {"binary": bytes}) diff --git a/src/mpd_now_playable/player.py b/src/mpd_now_playable/player.py new file mode 100644 index 0000000..4f47e6d --- /dev/null +++ b/src/mpd_now_playable/player.py @@ -0,0 +1,23 @@ +from typing import Protocol + +from .song import PlaybackState + + +class Player(Protocol): + async def on_play_pause(self) -> PlaybackState: + ... + + async def on_play(self) -> PlaybackState: + ... + + async def on_pause(self) -> PlaybackState: + ... + + async def on_stop(self) -> PlaybackState: + ... + + async def on_next(self) -> None: + ... + + async def on_prev(self) -> None: + ... diff --git a/src/mpd_now_playable/py.typed b/src/mpd_now_playable/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/mpd_now_playable/song.py b/src/mpd_now_playable/song.py new file mode 100644 index 0000000..81944c5 --- /dev/null +++ b/src/mpd_now_playable/song.py @@ -0,0 +1,27 @@ +from enum import StrEnum +from typing import Protocol + +from attrs import define, field + + +class PlaybackState(StrEnum): + play = "play" + pause = "pause" + stop = "stop" + + +@define +class Song: + state: PlaybackState + title: str + artist: str + album: str + album_artist: str + duration: float + elapsed: float + art: bytes | None = field(repr=lambda a: "" if a else "") + + +class SongListener(Protocol): + def update(self, song: Song | None) -> None: + ...