Compare commits

..

No commits in common. "49a75f811874df852786c430d989b73a987f7d3c" and "28748df3c16bd7361be6b334840191cbeebbb8ff" have entirely different histories.

6 changed files with 22 additions and 79 deletions

View file

@ -1,7 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# Copied from https://github.com/alberthier/corefoundationasyncio/blob/5061b9b7daa8bcd40d54d58432d84dcc0a339ca6/corefoundationasyncio.py # Copied from https://github.com/alberthier/corefoundationasyncio/blob/5061b9b7daa8bcd40d54d58432d84dcc0a339ca6/corefoundationasyncio.py
# This module is copied, rather than simply installed, because the PyPI version of this module depends on *all* of PyObjC even though it only needs pyobjc-framework-Cocoa. # This module is copied, rather than simply installed, because the PyPI version of this module depends on *all* of PyObjC even though it only needs pyobjc-framework-Cocoa.
# There's an open PR to fix this: https://github.com/alberthier/corefoundationasyncio/pull/3 # There's an open PR to fix this: httpsV//github.com/alberthier/corefoundationasyncio/pull/3
import asyncio import asyncio
@ -10,8 +10,8 @@ import threading
from CoreFoundation import ( from CoreFoundation import (
CFRunLoopGetCurrent, CFRunLoopGetCurrent,
CFRunLoopTimerCreate, CFRunLoopAddTimer, CFRunLoopRemoveTimer, CFRunLoopTimerInvalidate, CFAbsoluteTimeGetCurrent, CFRunLoopTimerCreate, CFRunLoopAddTimer, CFRunLoopRemoveTimer, CFAbsoluteTimeGetCurrent,
CFFileDescriptorCreate, CFFileDescriptorIsValid, CFFileDescriptorEnableCallBacks, CFFileDescriptorDisableCallBacks, CFFileDescriptorInvalidate, CFFileDescriptorCreate, CFFileDescriptorIsValid, CFFileDescriptorEnableCallBacks, CFFileDescriptorDisableCallBacks,
CFFileDescriptorCreateRunLoopSource, CFRunLoopAddSource, CFRunLoopRemoveSource, CFFileDescriptorCreateRunLoopSource, CFRunLoopAddSource, CFRunLoopRemoveSource,
kCFAllocatorDefault, kCFRunLoopDefaultMode, kCFRunLoopCommonModes, kCFAllocatorDefault, kCFRunLoopDefaultMode, kCFRunLoopCommonModes,
kCFFileDescriptorReadCallBack, kCFFileDescriptorWriteCallBack kCFFileDescriptorReadCallBack, kCFFileDescriptorWriteCallBack
@ -106,15 +106,8 @@ class CoreFoundationEventLoop(asyncio.SelectorEventLoop):
if handle.cancelled(): if handle.cancelled():
return return
def ontimeout(cf_timer, info): def ontimeout(cf_timer, info):
try:
if not handle.cancelled(): if not handle.cancelled():
handle._run() handle._run()
finally:
# Explicitly invalidate/remove one-shot timers. Relying on
# implicit cleanup can leak CoreFoundation timer objects under
# long-running loads.
CFRunLoopRemoveTimer(self._runloop, cf_timer, kCFRunLoopCommonModes)
CFRunLoopTimerInvalidate(cf_timer)
when = handle.when() if is_timer else self.time() when = handle.when() if is_timer else self.time()
cf_timer = CFRunLoopTimerCreate(kCFAllocatorDefault, when, 0, 0, 0, ontimeout, None) cf_timer = CFRunLoopTimerCreate(kCFAllocatorDefault, when, 0, 0, 0, ontimeout, None)
CFRunLoopAddTimer(self._runloop, cf_timer, kCFRunLoopCommonModes) CFRunLoopAddTimer(self._runloop, cf_timer, kCFRunLoopCommonModes)
@ -123,10 +116,7 @@ class CoreFoundationEventLoop(asyncio.SelectorEventLoop):
handle._scheduled = True handle._scheduled = True
def _timer_handle_cancelled(self, handle): def _timer_handle_cancelled(self, handle):
if handle.cf_runloop_timer is not None:
CFRunLoopRemoveTimer(self._runloop, handle.cf_runloop_timer, kCFRunLoopCommonModes) CFRunLoopRemoveTimer(self._runloop, handle.cf_runloop_timer, kCFRunLoopCommonModes)
CFRunLoopTimerInvalidate(handle.cf_runloop_timer)
handle.cf_runloop_timer = None
def time(self): def time(self):
return CFAbsoluteTimeGetCurrent() return CFAbsoluteTimeGetCurrent()
@ -171,7 +161,6 @@ class CoreFoundationEventLoop(asyncio.SelectorEventLoop):
CFFileDescriptorDisableCallBacks(entry.cf_fd, event) CFFileDescriptorDisableCallBacks(entry.cf_fd, event)
else: else:
CFRunLoopRemoveSource(self._runloop, entry.cf_source, kCFRunLoopDefaultMode) CFRunLoopRemoveSource(self._runloop, entry.cf_source, kCFRunLoopDefaultMode)
CFFileDescriptorInvalidate(entry.cf_fd)
return True return True
def _add_reader(self, fd, callback, *args): def _add_reader(self, fd, callback, *args):

View file

@ -1,5 +1,4 @@
import asyncio import asyncio
import sys
from collections.abc import Iterable from collections.abc import Iterable
from rich import print from rich import print
@ -23,23 +22,7 @@ async def listen(
await listener.loop(receivers) await listener.loop(receivers)
def print_help() -> None:
print("Usage: mpd-now-playable [OPTIONS]")
print("")
print("Options:")
print(" -h, --help Show this help message and exit.")
print(" -v, --version Show version and exit.")
def main() -> None: def main() -> None:
args = set(sys.argv[1:])
if "-h" in args or "--help" in args:
print_help()
return
if "-v" in args or "--version" in args:
print(f"mpd-now-playable v{__version__}")
return
print(f"mpd-now-playable v{__version__}") print(f"mpd-now-playable v{__version__}")
config = loadConfig() config = loadConfig()
print(config) print(config)
@ -51,7 +34,7 @@ def main() -> None:
asyncio.run( asyncio.run(
listen(config, listener, receivers), listen(config, listener, receivers),
loop_factory=factory.make_loop, loop_factory=factory.make_loop,
debug=False, debug=True,
) )

View file

@ -30,27 +30,18 @@ class MpdArtworkCache:
mpd: MpdStateHandler mpd: MpdStateHandler
album_cache: Cache[Artwork] album_cache: Cache[Artwork]
track_cache: Cache[Artwork] track_cache: Cache[Artwork]
pending_tracks: set[str]
def __init__(self, mpd: MpdStateHandler, cache_url: URL = MEMORY): def __init__(self, mpd: MpdStateHandler, cache_url: URL = MEMORY):
self.mpd = mpd self.mpd = mpd
self.album_cache = make_cache(ArtworkSchema, cache_url, "album") self.album_cache = make_cache(ArtworkSchema, cache_url, "album")
self.track_cache = make_cache(ArtworkSchema, cache_url, "track") self.track_cache = make_cache(ArtworkSchema, cache_url, "track")
self.pending_tracks = set()
async def get_cached_artwork(self, song: CurrentSongResponse) -> bytes | None: async def get_cached_artwork(self, song: CurrentSongResponse) -> bytes | None:
track_key = calc_track_key(song) art = await self.track_cache.get(calc_track_key(song))
art = await self.track_cache.get(track_key)
if art is not None:
# NoArtwork is a valid cached value too: returning None here avoids
# repeatedly re-querying MPD for files that have no embedded art.
if art: if art:
return art.data return art.data
return None
# If we don't have track artwork cached, go find some. # If we don't have track artwork cached, go find some.
if track_key not in self.pending_tracks:
self.pending_tracks.add(track_key)
run_background_task(self.cache_artwork(song)) run_background_task(self.cache_artwork(song))
# Even if we don't have cached track art, we can try looking for cached album art. # Even if we don't have cached track art, we can try looking for cached album art.
@ -61,16 +52,10 @@ class MpdArtworkCache:
return None return None
async def cache_artwork(self, song: CurrentSongResponse) -> None: async def cache_artwork(self, song: CurrentSongResponse) -> None:
track_key = calc_track_key(song)
try:
art = to_artwork(await self.mpd.get_art(song["file"])) art = to_artwork(await self.mpd.get_art(song["file"]))
try: try:
await self.album_cache.add(calc_album_key(song), art, ttl=CACHE_TTL) await self.album_cache.add(calc_album_key(song), art, ttl=CACHE_TTL)
except ValueError: except ValueError:
pass pass
await self.track_cache.set(track_key, art, ttl=CACHE_TTL) await self.track_cache.set(calc_track_key(song), art, ttl=CACHE_TTL)
# Refresh receivers only when we discovered actual artwork.
if art:
await self.mpd.refresh() await self.mpd.refresh()
finally:
self.pending_tracks.discard(track_key)

View file

@ -17,13 +17,10 @@ def to_queue(mpd: MpdState) -> Queue:
def to_mixramp(mpd: MpdState) -> MixRamp: def to_mixramp(mpd: MpdState) -> MixRamp:
delay = mpd.status.get("mixrampdelay", 0) delay = mpd.status.get("mixrampdelay", 0)
db = mpd.status.get("mixrampdb", 0)
if delay == "nan": if delay == "nan":
delay = 0 delay = 0
if db == "nan":
db = 0
return MixRamp( return MixRamp(
db=float(db), db=float(mpd.status.get("mixrampdb", 0)),
delay=float(delay), delay=float(delay),
) )

View file

@ -3,6 +3,7 @@ from collections.abc import Iterable
from mpd.asyncio import MPDClient from mpd.asyncio import MPDClient
from mpd.base import CommandError from mpd.base import CommandError
from rich import print as rprint
from yarl import URL from yarl import URL
from ..config.model import MpdConfig from ..config.model import MpdConfig
@ -17,23 +18,17 @@ from .types import MpdState
class MpdStateListener(Player): class MpdStateListener(Player):
# Subsystems relevant to now-playing metadata and remote controls.
# Listening to all MPD subsystems can cause noisy wakeups (e.g. database
# updates), which drives unnecessary status/currentsong polling.
WATCHED_SUBSYSTEMS = ("player", "mixer", "options", "playlist", "partition")
config: MpdConfig config: MpdConfig
client: MPDClient client: MPDClient
receivers: Iterable[Receiver] receivers: Iterable[Receiver]
art_cache: MpdArtworkCache art_cache: MpdArtworkCache
idle_count = 0 idle_count = 0
last_playback: Playback | None
def __init__(self, cache: URL | None = None) -> None: def __init__(self, cache: URL | None = None) -> None:
self.client = MPDClient() self.client = MPDClient()
self.art_cache = ( self.art_cache = (
MpdArtworkCache(self, cache) if cache else MpdArtworkCache(self) MpdArtworkCache(self, cache) if cache else MpdArtworkCache(self)
) )
self.last_playback = None
async def start(self, conf: MpdConfig) -> None: async def start(self, conf: MpdConfig) -> None:
self.config = conf self.config = conf
@ -58,12 +53,9 @@ class MpdStateListener(Player):
# Notify our receivers 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_receivers() await self.update_receivers()
# And then wait for stuff to change in MPD. :) # And then wait for stuff to change in MPD. :)
async for subsystems in self.client.idle(self.WATCHED_SUBSYSTEMS): async for subsystems in self.client.idle():
# If no subsystems actually changed, we don't need to update the receivers. # If no subsystems actually changed, we don't need to update the receivers.
if not subsystems: if not subsystems:
# MPD/python-mpd2 can occasionally wake idle() without reporting a
# changed subsystem; avoid a hot loop if that happens repeatedly.
await asyncio.sleep(0.1)
continue continue
self.idle_count += 1 self.idle_count += 1
await self.update_receivers() await self.update_receivers()
@ -87,9 +79,7 @@ class MpdStateListener(Player):
state = MpdState(status, current, art) state = MpdState(status, current, art)
pb = to_playback(self.config, state) pb = to_playback(self.config, state)
if pb == self.last_playback: rprint(pb)
return
self.last_playback = pb
await self.update(pb) await self.update(pb)
async def update(self, playback: Playback) -> None: async def update(self, playback: Playback) -> None:

View file

@ -19,7 +19,6 @@ def playback_to_media_item(playback: Playback) -> NSMutableDictionary:
if song := playback.active_song: if song := playback.active_song:
nowplaying_info = song_to_media_item(song) nowplaying_info = song_to_media_item(song)
nowplaying_info[MPNowPlayingInfoPropertyPlaybackQueueCount] = playback.queue.length nowplaying_info[MPNowPlayingInfoPropertyPlaybackQueueCount] = playback.queue.length
if playback.queue.current is not None:
nowplaying_info[MPNowPlayingInfoPropertyPlaybackQueueIndex] = playback.queue.current nowplaying_info[MPNowPlayingInfoPropertyPlaybackQueueIndex] = playback.queue.current
return nowplaying_info return nowplaying_info