From 55d82c72c39b169b35c2d8b27a06839fb83db01a Mon Sep 17 00:00:00 2001 From: Danielle McLean Date: Tue, 14 May 2024 13:25:13 +1000 Subject: [PATCH] Support MPD_NOW_PLAYABLE_CACHE setting (Redis or Memcached) --- src/mpd_now_playable/cache.py | 55 +++++++++++++++++++++++ src/mpd_now_playable/cli.py | 3 +- src/mpd_now_playable/mpd/artwork_cache.py | 40 ++++++----------- src/mpd_now_playable/mpd/listener.py | 6 ++- stubs/aiocache/base.pyi | 7 +++ stubs/aiocache/factory.pyi | 29 +++++++++--- stubs/aiocache/serializers.pyi | 14 ++++++ 7 files changed, 118 insertions(+), 36 deletions(-) create mode 100644 src/mpd_now_playable/cache.py create mode 100644 stubs/aiocache/base.pyi create mode 100644 stubs/aiocache/serializers.pyi diff --git a/src/mpd_now_playable/cache.py b/src/mpd_now_playable/cache.py new file mode 100644 index 0000000..05e586d --- /dev/null +++ b/src/mpd_now_playable/cache.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from contextlib import suppress +from typing import Any, Optional, TypeVar +from urllib.parse import parse_qsl, urlparse + +from aiocache import Cache +from aiocache.serializers import BaseSerializer, PickleSerializer + +T = TypeVar("T") + +HAS_ORMSGPACK = False +with suppress(ImportError): + import ormsgpack + + HAS_ORMSGPACK = True + + +class OrmsgpackSerializer(BaseSerializer): + DEFAULT_ENCODING = None + + def dumps(self, value: Any) -> bytes: + return ormsgpack.packb(value) + + def loads(self, value: Optional[bytes]) -> Any: + if value is None: + return None + return ormsgpack.unpackb(value) + + +def make_cache(url: str, namespace: str = "") -> Cache[T]: + parsed_url = urlparse(url) + backend = Cache.get_scheme_class(parsed_url.scheme) + if backend == Cache.MEMORY: + return Cache(backend) + kwargs: dict[str, Any] = dict(parse_qsl(parsed_url.query)) + + if parsed_url.path: + kwargs.update(backend.parse_uri_path(parsed_url.path)) + + if parsed_url.hostname: + kwargs["endpoint"] = parsed_url.hostname + + if parsed_url.port: + kwargs["port"] = parsed_url.port + + if parsed_url.password: + kwargs["password"] = parsed_url.password + + namespace = ":".join(s for s in [kwargs.get("namespace"), namespace] if s) + del kwargs["namespace"] + + serializer = OrmsgpackSerializer if HAS_ORMSGPACK else PickleSerializer + + return Cache(backend, serializer=serializer(), namespace=namespace, **kwargs) diff --git a/src/mpd_now_playable/cli.py b/src/mpd_now_playable/cli.py index 2b822d3..5cd7031 100644 --- a/src/mpd_now_playable/cli.py +++ b/src/mpd_now_playable/cli.py @@ -11,10 +11,11 @@ async def listen() -> None: port = int(environ.get("MPD_PORT", "6600")) host = environ.get("MPD_HOST", "localhost") password = environ.get("MPD_PASSWORD") + cache = environ.get("MPD_NOW_PLAYABLE_CACHE") if password is None and "@" in host: password, host = host.split("@", maxsplit=1) - listener = MpdStateListener() + listener = MpdStateListener(cache) now_playing = CocoaNowPlaying(listener) await listener.start(host=host, port=port, password=password) await listener.loop(now_playing) diff --git a/src/mpd_now_playable/mpd/artwork_cache.py b/src/mpd_now_playable/mpd/artwork_cache.py index 13819b1..c0d4d23 100644 --- a/src/mpd_now_playable/mpd/artwork_cache.py +++ b/src/mpd_now_playable/mpd/artwork_cache.py @@ -1,30 +1,16 @@ -from dataclasses import dataclass +from __future__ import annotations -from aiocache import Cache +from typing import TypedDict from ..async_tools import run_background_task +from ..cache import Cache, make_cache from .types import CurrentSongResponse, MpdStateHandler CACHE_TTL = 60 * 10 # ten minutes -@dataclass(frozen=True) -class HasArt: - data: bytes - - -@dataclass(frozen=True) -class HasNoArt: - data = None - - -ArtCacheEntry = HasArt | HasNoArt - - -def make_cache_entry(art: bytes | None) -> ArtCacheEntry: - if art is None: - return HasNoArt() - return HasArt(art) +class ArtCacheEntry(TypedDict): + data: bytes | None def calc_album_key(song: CurrentSongResponse) -> str: @@ -39,18 +25,18 @@ def calc_track_key(song: CurrentSongResponse) -> str: class MpdArtworkCache: mpd: MpdStateHandler - album_cache: "Cache[ArtCacheEntry]" - track_cache: "Cache[ArtCacheEntry]" + album_cache: Cache[ArtCacheEntry] + track_cache: Cache[ArtCacheEntry] - def __init__(self, mpd: MpdStateHandler): + def __init__(self, mpd: MpdStateHandler, cache_url: str = "memory://"): self.mpd = mpd - self.album_cache = Cache() - self.track_cache = Cache() + self.album_cache = make_cache(cache_url, "album") + self.track_cache = make_cache(cache_url, "track") 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 + return art["data"] # If we don't have track artwork cached, go find some. run_background_task(self.cache_artwork(song)) @@ -58,12 +44,12 @@ class MpdArtworkCache: # 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.data + return art["data"] return None async def cache_artwork(self, song: CurrentSongResponse) -> None: - art = make_cache_entry(await self.mpd.readpicture(song["file"])) + art = ArtCacheEntry(data=await self.mpd.readpicture(song["file"])) try: await self.album_cache.add(calc_album_key(song), art, ttl=CACHE_TTL) except ValueError: diff --git a/src/mpd_now_playable/mpd/listener.py b/src/mpd_now_playable/mpd/listener.py index 81d2174..9559937 100644 --- a/src/mpd_now_playable/mpd/listener.py +++ b/src/mpd_now_playable/mpd/listener.py @@ -44,9 +44,11 @@ class MpdStateListener(Player): art_cache: MpdArtworkCache idle_count = 0 - def __init__(self) -> None: + def __init__(self, cache: str | None = None) -> None: self.client = MPDClient() - self.art_cache = MpdArtworkCache(self) + self.art_cache = ( + MpdArtworkCache(self, cache) if cache else MpdArtworkCache(self) + ) async def start( self, host: str = "localhost", port: int = 6600, password: str | None = None diff --git a/stubs/aiocache/base.pyi b/stubs/aiocache/base.pyi new file mode 100644 index 0000000..8b7578f --- /dev/null +++ b/stubs/aiocache/base.pyi @@ -0,0 +1,7 @@ +from typing import Generic, TypeVar + +T = TypeVar("T") + +class BaseCache(Generic[T]): + @staticmethod + def parse_uri_path(path: str) -> dict[str, str]: ... diff --git a/stubs/aiocache/factory.pyi b/stubs/aiocache/factory.pyi index e1f7226..78cc6a5 100644 --- a/stubs/aiocache/factory.pyi +++ b/stubs/aiocache/factory.pyi @@ -1,8 +1,25 @@ -from typing import Generic, Optional, TypeVar +from typing import ClassVar, Optional, TypeVar -T = TypeVar('T') +from .base import BaseCache +from .serializers import BaseSerializer -class Cache(Generic[T]): - async def add(self, key: str, value: T, ttl: Optional[int]) -> None: ... - async def set(self, key: str, value: T, ttl: Optional[int]) -> None: ... # noqa: A003 - async def get(self, key: str, default: T | None = None) -> T | None: ... +T = TypeVar("T") + +class Cache(BaseCache[T]): + MEMORY: ClassVar[type[BaseCache]] + REDIS: ClassVar[type[BaseCache] | None] + MEMCACHED: ClassVar[type[BaseCache] | None] + + def __new__( + cls, + cache_class: type[BaseCache] = MEMORY, + *, + serializer: Optional[BaseSerializer] = None, + namespace: str = "", + **kwargs, + ) -> Cache[T]: ... + @staticmethod + def get_scheme_class(scheme: str) -> type[BaseCache]: ... + async def add(self, key: str, value: T, ttl: Optional[int]) -> None: ... + async def set(self, key: str, value: T, ttl: Optional[int]) -> None: ... # noqa: A003 + async def get(self, key: str, default: T | None = None) -> T | None: ... diff --git a/stubs/aiocache/serializers.pyi b/stubs/aiocache/serializers.pyi new file mode 100644 index 0000000..577ba11 --- /dev/null +++ b/stubs/aiocache/serializers.pyi @@ -0,0 +1,14 @@ +from abc import ABC, abstractmethod +from typing import Any, Optional + +class BaseSerializer(ABC): + DEFAULT_ENCODING: Optional[str] = "utf-8" + @abstractmethod + def dumps(self, value: Any, /) -> Any: ... + @abstractmethod + def loads(self, value: Any, /) -> Any: ... + +class PickleSerializer(BaseSerializer): + DEFAULT_ENCODING = None + def dumps(self, value: Any, /) -> Any: ... + def loads(self, value: Any, /) -> Any: ...