Merge pull request #6 from goetzc/fix/high-cpu-usage

Fix high CPU usage: optimize artwork fetches and idle wake-ups
This commit is contained in:
Danielle McLean 2026-03-01 12:57:28 +11:00
commit 49a75f8118
Signed by: 00dani
GPG key ID: 6854781A0488421C
5 changed files with 45 additions and 16 deletions

View file

@ -51,7 +51,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=True, debug=False,
) )

View file

@ -30,18 +30,27 @@ 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:
art = await self.track_cache.get(calc_track_key(song)) 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: 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.
@ -52,10 +61,16 @@ 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(calc_track_key(song), art, ttl=CACHE_TTL) 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() await self.mpd.refresh()
finally:
self.pending_tracks.discard(track_key)

View file

@ -17,10 +17,13 @@ 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(mpd.status.get("mixrampdb", 0)), db=float(db),
delay=float(delay), delay=float(delay),
) )

View file

@ -3,7 +3,6 @@ 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
@ -18,17 +17,23 @@ 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
@ -53,9 +58,12 @@ 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(): async for subsystems in self.client.idle(self.WATCHED_SUBSYSTEMS):
# 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()
@ -79,7 +87,9 @@ 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)
rprint(pb) if pb == self.last_playback:
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,6 +19,7 @@ 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