Support MPD_NOW_PLAYABLE_CACHE setting (Redis or Memcached)

This commit is contained in:
Danielle McLean 2024-05-14 13:25:13 +10:00
parent c7773bf324
commit 55d82c72c3
Signed by: 00dani
GPG key ID: 6854781A0488421C
7 changed files with 118 additions and 36 deletions

View file

@ -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)

View file

@ -11,10 +11,11 @@ async def listen() -> None:
port = int(environ.get("MPD_PORT", "6600")) port = int(environ.get("MPD_PORT", "6600"))
host = environ.get("MPD_HOST", "localhost") host = environ.get("MPD_HOST", "localhost")
password = environ.get("MPD_PASSWORD") password = environ.get("MPD_PASSWORD")
cache = environ.get("MPD_NOW_PLAYABLE_CACHE")
if password is None and "@" in host: if password is None and "@" in host:
password, host = host.split("@", maxsplit=1) password, host = host.split("@", maxsplit=1)
listener = MpdStateListener() listener = MpdStateListener(cache)
now_playing = CocoaNowPlaying(listener) now_playing = CocoaNowPlaying(listener)
await listener.start(host=host, port=port, password=password) await listener.start(host=host, port=port, password=password)
await listener.loop(now_playing) await listener.loop(now_playing)

View file

@ -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 ..async_tools import run_background_task
from ..cache import Cache, make_cache
from .types import CurrentSongResponse, MpdStateHandler from .types import CurrentSongResponse, MpdStateHandler
CACHE_TTL = 60 * 10 # ten minutes CACHE_TTL = 60 * 10 # ten minutes
@dataclass(frozen=True) class ArtCacheEntry(TypedDict):
class HasArt: data: bytes | None
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)
def calc_album_key(song: CurrentSongResponse) -> str: def calc_album_key(song: CurrentSongResponse) -> str:
@ -39,18 +25,18 @@ def calc_track_key(song: CurrentSongResponse) -> str:
class MpdArtworkCache: class MpdArtworkCache:
mpd: MpdStateHandler mpd: MpdStateHandler
album_cache: "Cache[ArtCacheEntry]" album_cache: Cache[ArtCacheEntry]
track_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.mpd = mpd
self.album_cache = Cache() self.album_cache = make_cache(cache_url, "album")
self.track_cache = Cache() self.track_cache = make_cache(cache_url, "track")
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)) art = await self.track_cache.get(calc_track_key(song))
if art: if art:
return art.data return art["data"]
# If we don't have track artwork cached, go find some. # If we don't have track artwork cached, go find some.
run_background_task(self.cache_artwork(song)) 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. # 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)) art = await self.album_cache.get(calc_album_key(song))
if art: if art:
return art.data return art["data"]
return None return None
async def cache_artwork(self, song: CurrentSongResponse) -> 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: 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:

View file

@ -44,9 +44,11 @@ class MpdStateListener(Player):
art_cache: MpdArtworkCache art_cache: MpdArtworkCache
idle_count = 0 idle_count = 0
def __init__(self) -> None: def __init__(self, cache: str | None = None) -> None:
self.client = MPDClient() self.client = MPDClient()
self.art_cache = MpdArtworkCache(self) self.art_cache = (
MpdArtworkCache(self, cache) if cache else MpdArtworkCache(self)
)
async def start( async def start(
self, host: str = "localhost", port: int = 6600, password: str | None = None self, host: str = "localhost", port: int = 6600, password: str | None = None

7
stubs/aiocache/base.pyi Normal file
View file

@ -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]: ...

View file

@ -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]): 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 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 set(self, key: str, value: T, ttl: Optional[int]) -> None: ... # noqa: A003
async def get(self, key: str, default: T | None = None) -> T | None: ... async def get(self, key: str, default: T | None = None) -> T | None: ...

View file

@ -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: ...