Support MPD_NOW_PLAYABLE_CACHE setting (Redis or Memcached)
This commit is contained in:
parent
c7773bf324
commit
55d82c72c3
7 changed files with 118 additions and 36 deletions
55
src/mpd_now_playable/cache.py
Normal file
55
src/mpd_now_playable/cache.py
Normal 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)
|
|
@ -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)
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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
7
stubs/aiocache/base.pyi
Normal 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]: ...
|
|
@ -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")
|
||||||
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
|
class Cache(BaseCache[T]):
|
||||||
async def get(self, key: str, default: T | None = None) -> T | None: ...
|
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: ...
|
||||||
|
|
14
stubs/aiocache/serializers.pyi
Normal file
14
stubs/aiocache/serializers.pyi
Normal 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: ...
|
Loading…
Reference in a new issue