From 3411a5a34d8f3cd784e8d01606cae9f572dda3f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6tz?= Date: Sat, 28 Feb 2026 15:42:49 -0500 Subject: [PATCH 1/7] cli: exit early for --help/--version without starting app The CLI previously always started full app initialization (config load, listener setup, receiver construction, and debug output) even when invoked with `-h`, `--help`, `-v`, or `--version`. That made basic introspection noisy and buried the requested output in startup logs. Add early argument checks in `main()` so help/version requests are handled immediately and the process exits without starting the app. Introduce a small `print_help()` helper for consistent usage output. This improves terminal UX and makes debugging/invocation checks much clearer by keeping `-h` and `-v` output focused and predictable. --- src/mpd_now_playable/cli.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/mpd_now_playable/cli.py b/src/mpd_now_playable/cli.py index 34153c2..d4f9caa 100644 --- a/src/mpd_now_playable/cli.py +++ b/src/mpd_now_playable/cli.py @@ -1,4 +1,5 @@ import asyncio +import sys from collections.abc import Iterable from rich import print @@ -22,7 +23,23 @@ async def listen( 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: + 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__}") config = loadConfig() print(config) From 14717b4866f09d9e4870a760dc217f1f32bd736b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6tz?= Date: Sat, 28 Feb 2026 16:50:15 -0500 Subject: [PATCH 2/7] Fix CF high memory usage: clean up timers and file descriptors Resolved resident memory growth during long-running idle sessions. Fixes: https://github.com/00dani/mpd-now-playable/issues/3 The CoreFoundation asyncio bridge was leaking native resources over time. This change explicitly invalidates one-shot CFRunLoop timers after execution, invalidates timers on cancellation, and invalidates CFFileDescriptor objects when unregistering the final callback source. --- src/corefoundationasyncio/eventloop.py | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/src/corefoundationasyncio/eventloop.py b/src/corefoundationasyncio/eventloop.py index c375699..23cd1ab 100644 --- a/src/corefoundationasyncio/eventloop.py +++ b/src/corefoundationasyncio/eventloop.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # 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. -# There's an open PR to fix this: httpsV//github.com/alberthier/corefoundationasyncio/pull/3 +# There's an open PR to fix this: https://github.com/alberthier/corefoundationasyncio/pull/3 import asyncio @@ -10,8 +10,8 @@ import threading from CoreFoundation import ( CFRunLoopGetCurrent, - CFRunLoopTimerCreate, CFRunLoopAddTimer, CFRunLoopRemoveTimer, CFAbsoluteTimeGetCurrent, - CFFileDescriptorCreate, CFFileDescriptorIsValid, CFFileDescriptorEnableCallBacks, CFFileDescriptorDisableCallBacks, + CFRunLoopTimerCreate, CFRunLoopAddTimer, CFRunLoopRemoveTimer, CFRunLoopTimerInvalidate, CFAbsoluteTimeGetCurrent, + CFFileDescriptorCreate, CFFileDescriptorIsValid, CFFileDescriptorEnableCallBacks, CFFileDescriptorDisableCallBacks, CFFileDescriptorInvalidate, CFFileDescriptorCreateRunLoopSource, CFRunLoopAddSource, CFRunLoopRemoveSource, kCFAllocatorDefault, kCFRunLoopDefaultMode, kCFRunLoopCommonModes, kCFFileDescriptorReadCallBack, kCFFileDescriptorWriteCallBack @@ -106,8 +106,15 @@ class CoreFoundationEventLoop(asyncio.SelectorEventLoop): if handle.cancelled(): return def ontimeout(cf_timer, info): - if not handle.cancelled(): - handle._run() + try: + if not handle.cancelled(): + 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() cf_timer = CFRunLoopTimerCreate(kCFAllocatorDefault, when, 0, 0, 0, ontimeout, None) CFRunLoopAddTimer(self._runloop, cf_timer, kCFRunLoopCommonModes) @@ -116,7 +123,10 @@ class CoreFoundationEventLoop(asyncio.SelectorEventLoop): handle._scheduled = True def _timer_handle_cancelled(self, handle): - CFRunLoopRemoveTimer(self._runloop, handle.cf_runloop_timer, kCFRunLoopCommonModes) + if handle.cf_runloop_timer is not None: + CFRunLoopRemoveTimer(self._runloop, handle.cf_runloop_timer, kCFRunLoopCommonModes) + CFRunLoopTimerInvalidate(handle.cf_runloop_timer) + handle.cf_runloop_timer = None def time(self): return CFAbsoluteTimeGetCurrent() @@ -161,6 +171,7 @@ class CoreFoundationEventLoop(asyncio.SelectorEventLoop): CFFileDescriptorDisableCallBacks(entry.cf_fd, event) else: CFRunLoopRemoveSource(self._runloop, entry.cf_source, kCFRunLoopDefaultMode) + CFFileDescriptorInvalidate(entry.cf_fd) return True def _add_reader(self, fd, callback, *args): From 897cb383ebcb6714d10a72caaaaf4410c9e92691 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6tz?= Date: Sat, 28 Feb 2026 18:59:17 -0500 Subject: [PATCH 3/7] Avoids wakeups from noisy unrelated subsystems --- src/mpd_now_playable/mpd/listener.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/mpd_now_playable/mpd/listener.py b/src/mpd_now_playable/mpd/listener.py index 603dfe5..aa44d5d 100644 --- a/src/mpd_now_playable/mpd/listener.py +++ b/src/mpd_now_playable/mpd/listener.py @@ -18,6 +18,10 @@ from .types import MpdState 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 client: MPDClient receivers: Iterable[Receiver] @@ -53,7 +57,7 @@ class MpdStateListener(Player): # Notify our receivers of the initial state MPD is in when this script loads up. await self.update_receivers() # And then wait for stuff to change in MPD. :) - async for subsystems in self.client.idle(): + async for subsystems in self.client.idle(self.WATCHED_SUBSYSTEMS): # If no subsystems actually changed, we don't need to update the receivers. if not subsystems: continue From 9b910cd991d53079fb841625f0998793616cdc0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6tz?= Date: Sat, 28 Feb 2026 19:01:56 -0500 Subject: [PATCH 4/7] Remove concurrent heartbeat pings on the same MPD client - idle() and periodic ping() on one connection can cause churn/wakeups - Normalized mixrampdb == "nan" to 0 --- src/mpd_now_playable/mpd/convert/to_playback.py | 5 ++++- src/mpd_now_playable/mpd/listener.py | 7 ------- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/src/mpd_now_playable/mpd/convert/to_playback.py b/src/mpd_now_playable/mpd/convert/to_playback.py index 77adfa2..b68405f 100644 --- a/src/mpd_now_playable/mpd/convert/to_playback.py +++ b/src/mpd_now_playable/mpd/convert/to_playback.py @@ -17,10 +17,13 @@ def to_queue(mpd: MpdState) -> Queue: def to_mixramp(mpd: MpdState) -> MixRamp: delay = mpd.status.get("mixrampdelay", 0) + db = mpd.status.get("mixrampdb", 0) if delay == "nan": delay = 0 + if db == "nan": + db = 0 return MixRamp( - db=float(mpd.status.get("mixrampdb", 0)), + db=float(db), delay=float(delay), ) diff --git a/src/mpd_now_playable/mpd/listener.py b/src/mpd_now_playable/mpd/listener.py index aa44d5d..ccdcdb8 100644 --- a/src/mpd_now_playable/mpd/listener.py +++ b/src/mpd_now_playable/mpd/listener.py @@ -11,7 +11,6 @@ from ..playback import Playback from ..playback.state import PlaybackState from ..player import Player from ..song_receiver import Receiver -from ..tools.asyncio import run_background_task from .artwork_cache import MpdArtworkCache from .convert.to_playback import to_playback from .types import MpdState @@ -42,12 +41,6 @@ class MpdStateListener(Player): print("Authorising to MPD with your password...") await self.client.password(conf.password.get_secret_value()) print(f"Connected to MPD v{self.client.mpd_version}") - run_background_task(self.heartbeat()) - - async def heartbeat(self) -> None: - while True: - await self.client.ping() - await asyncio.sleep(10) async def refresh(self) -> None: await self.update_receivers() From fa82f45ef97c07c1c64bde8f873e7a7d9555afd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6tz?= Date: Sat, 28 Feb 2026 19:14:40 -0500 Subject: [PATCH 5/7] Avoid repeated artwork miss fetches and refresh churn Lowers CPU usage: - Only call refresh() when real artwork is found - Treating NoArtwork as a valid cached result (art is not None check). - Adding in-flight dedupe (pending_tracks) so the same track doesn't spawn parallel fetches. --- src/mpd_now_playable/mpd/artwork_cache.py | 35 ++++++++++++++++------- 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/src/mpd_now_playable/mpd/artwork_cache.py b/src/mpd_now_playable/mpd/artwork_cache.py index 0b1ebbe..7948b50 100644 --- a/src/mpd_now_playable/mpd/artwork_cache.py +++ b/src/mpd_now_playable/mpd/artwork_cache.py @@ -30,19 +30,28 @@ class MpdArtworkCache: mpd: MpdStateHandler album_cache: Cache[Artwork] track_cache: Cache[Artwork] + pending_tracks: set[str] def __init__(self, mpd: MpdStateHandler, cache_url: URL = MEMORY): self.mpd = mpd self.album_cache = make_cache(ArtworkSchema, cache_url, "album") self.track_cache = make_cache(ArtworkSchema, cache_url, "track") + self.pending_tracks = set() async def get_cached_artwork(self, song: CurrentSongResponse) -> bytes | None: - art = await self.track_cache.get(calc_track_key(song)) - if art: - return art.data + track_key = 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: + return art.data + return None # If we don't have track artwork cached, go find some. - run_background_task(self.cache_artwork(song)) + if track_key not in self.pending_tracks: + self.pending_tracks.add(track_key) + 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)) @@ -52,10 +61,16 @@ class MpdArtworkCache: return None async def cache_artwork(self, song: CurrentSongResponse) -> None: - art = to_artwork(await self.mpd.get_art(song["file"])) + track_key = calc_track_key(song) 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() + art = to_artwork(await self.mpd.get_art(song["file"])) + try: + await self.album_cache.add(calc_album_key(song), art, ttl=CACHE_TTL) + except ValueError: + pass + await self.track_cache.set(track_key, art, ttl=CACHE_TTL) + # Refresh receivers only when we discovered actual artwork. + if art: + await self.mpd.refresh() + finally: + self.pending_tracks.discard(track_key) From 5120b938ef1f050cb20336829a221e55b67c9fc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6tz?= Date: Sat, 28 Feb 2026 16:50:44 -0500 Subject: [PATCH 6/7] Optimize idle wake-ups --- src/mpd_now_playable/cli.py | 2 +- src/mpd_now_playable/mpd/listener.py | 10 ++++++++-- .../receivers/cocoa/convert/playback_to_media_item.py | 3 ++- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/mpd_now_playable/cli.py b/src/mpd_now_playable/cli.py index 34153c2..5bb5395 100644 --- a/src/mpd_now_playable/cli.py +++ b/src/mpd_now_playable/cli.py @@ -34,7 +34,7 @@ def main() -> None: asyncio.run( listen(config, listener, receivers), loop_factory=factory.make_loop, - debug=True, + debug=False, ) diff --git a/src/mpd_now_playable/mpd/listener.py b/src/mpd_now_playable/mpd/listener.py index ccdcdb8..ae162bd 100644 --- a/src/mpd_now_playable/mpd/listener.py +++ b/src/mpd_now_playable/mpd/listener.py @@ -3,7 +3,6 @@ from collections.abc import Iterable from mpd.asyncio import MPDClient from mpd.base import CommandError -from rich import print as rprint from yarl import URL from ..config.model import MpdConfig @@ -26,12 +25,14 @@ class MpdStateListener(Player): receivers: Iterable[Receiver] art_cache: MpdArtworkCache idle_count = 0 + last_playback: Playback | None def __init__(self, cache: URL | None = None) -> None: self.client = MPDClient() self.art_cache = ( MpdArtworkCache(self, cache) if cache else MpdArtworkCache(self) ) + self.last_playback = None async def start(self, conf: MpdConfig) -> None: self.config = conf @@ -53,6 +54,9 @@ class MpdStateListener(Player): async for subsystems in self.client.idle(self.WATCHED_SUBSYSTEMS): # If no subsystems actually changed, we don't need to update the receivers. 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 self.idle_count += 1 await self.update_receivers() @@ -76,7 +80,9 @@ class MpdStateListener(Player): state = MpdState(status, current, art) pb = to_playback(self.config, state) - rprint(pb) + if pb == self.last_playback: + return + self.last_playback = pb await self.update(pb) async def update(self, playback: Playback) -> None: diff --git a/src/mpd_now_playable/receivers/cocoa/convert/playback_to_media_item.py b/src/mpd_now_playable/receivers/cocoa/convert/playback_to_media_item.py index 7ac8a2b..822108b 100644 --- a/src/mpd_now_playable/receivers/cocoa/convert/playback_to_media_item.py +++ b/src/mpd_now_playable/receivers/cocoa/convert/playback_to_media_item.py @@ -19,7 +19,8 @@ def playback_to_media_item(playback: Playback) -> NSMutableDictionary: if song := playback.active_song: nowplaying_info = song_to_media_item(song) nowplaying_info[MPNowPlayingInfoPropertyPlaybackQueueCount] = playback.queue.length - nowplaying_info[MPNowPlayingInfoPropertyPlaybackQueueIndex] = playback.queue.current + if playback.queue.current is not None: + nowplaying_info[MPNowPlayingInfoPropertyPlaybackQueueIndex] = playback.queue.current return nowplaying_info From 092f731b578484b4de1062051d3105779ad49a79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6tz?= Date: Sat, 28 Feb 2026 20:53:55 -0500 Subject: [PATCH 7/7] Revert heartbeat removal --- src/mpd_now_playable/mpd/listener.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/mpd_now_playable/mpd/listener.py b/src/mpd_now_playable/mpd/listener.py index ae162bd..a742b69 100644 --- a/src/mpd_now_playable/mpd/listener.py +++ b/src/mpd_now_playable/mpd/listener.py @@ -10,6 +10,7 @@ from ..playback import Playback from ..playback.state import PlaybackState from ..player import Player from ..song_receiver import Receiver +from ..tools.asyncio import run_background_task from .artwork_cache import MpdArtworkCache from .convert.to_playback import to_playback from .types import MpdState @@ -42,6 +43,12 @@ class MpdStateListener(Player): print("Authorising to MPD with your password...") await self.client.password(conf.password.get_secret_value()) print(f"Connected to MPD v{self.client.mpd_version}") + run_background_task(self.heartbeat()) + + async def heartbeat(self) -> None: + while True: + await self.client.ping() + await asyncio.sleep(10) async def refresh(self) -> None: await self.update_receivers()