Support a TOML configuration file
The new config file currently only configures the same options that were already available through environment variables. However I have ideas for additional features that would be much nicer to support using a structured configuration format like TOML rather than environment variables, so config files exist now! The previous environment variables are still supported and will be used if you don't have a config file. I plan to keep supporting the MPD_HOST and MPD_PORT environment variables forever since they're shared with other MPD clients such as mpc, but I may eventually drop the environment variables specific to mpd-now-playable in a future release.
This commit is contained in:
parent
796e3df87d
commit
dc037a0a4b
12 changed files with 305 additions and 33 deletions
|
@ -2,10 +2,10 @@ 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
|
||||
from yarl import URL
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
@ -28,24 +28,23 @@ class OrmsgpackSerializer(BaseSerializer):
|
|||
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)
|
||||
def make_cache(url: URL, namespace: str = "") -> Cache[T]:
|
||||
backend = Cache.get_scheme_class(url.scheme)
|
||||
if backend == Cache.MEMORY:
|
||||
return Cache(backend)
|
||||
kwargs: dict[str, Any] = dict(parse_qsl(parsed_url.query))
|
||||
kwargs: dict[str, Any] = dict(url.query)
|
||||
|
||||
if parsed_url.path:
|
||||
kwargs.update(backend.parse_uri_path(parsed_url.path))
|
||||
if url.path:
|
||||
kwargs.update(backend.parse_uri_path(url.path))
|
||||
|
||||
if parsed_url.hostname:
|
||||
kwargs["endpoint"] = parsed_url.hostname
|
||||
if url.host:
|
||||
kwargs["endpoint"] = url.host
|
||||
|
||||
if parsed_url.port:
|
||||
kwargs["port"] = parsed_url.port
|
||||
if url.port:
|
||||
kwargs["port"] = url.port
|
||||
|
||||
if parsed_url.password:
|
||||
kwargs["password"] = parsed_url.password
|
||||
if url.password:
|
||||
kwargs["password"] = url.password
|
||||
|
||||
namespace = ":".join(s for s in [kwargs.pop("namespace", ""), namespace] if s)
|
||||
|
||||
|
|
|
@ -1,25 +1,19 @@
|
|||
import asyncio
|
||||
from os import environ
|
||||
|
||||
from corefoundationasyncio import CoreFoundationEventLoop
|
||||
|
||||
from .__version__ import __version__
|
||||
from .cocoa.now_playing import CocoaNowPlaying
|
||||
from .config.load import loadConfig
|
||||
from .mpd.listener import MpdStateListener
|
||||
|
||||
|
||||
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)
|
||||
|
||||
print(f"mpd-now-playable v{__version__}")
|
||||
listener = MpdStateListener(cache)
|
||||
config = loadConfig()
|
||||
listener = MpdStateListener(config.cache)
|
||||
now_playing = CocoaNowPlaying(listener)
|
||||
await listener.start(host=host, port=port, password=password)
|
||||
await listener.start(config.mpd)
|
||||
await listener.loop(now_playing)
|
||||
|
||||
|
||||
|
|
0
src/mpd_now_playable/config/__init__.py
Normal file
0
src/mpd_now_playable/config/__init__.py
Normal file
44
src/mpd_now_playable/config/config-v1.json
Normal file
44
src/mpd_now_playable/config/config-v1.json
Normal file
|
@ -0,0 +1,44 @@
|
|||
{
|
||||
"$id": "https://cdn.00dani.me/m/schemata/mpd-now-playable/config-v1.json",
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"additionalProperties": false,
|
||||
"definitions": {
|
||||
"URL": {
|
||||
"format": "uri",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"$schema": {
|
||||
"$ref": "#/definitions/URL"
|
||||
},
|
||||
"cache": {
|
||||
"$ref": "#/definitions/URL"
|
||||
},
|
||||
"mpd": {
|
||||
"additionalProperties": false,
|
||||
"default": {
|
||||
"host": "127.0.0.1",
|
||||
"port": 6600
|
||||
},
|
||||
"properties": {
|
||||
"host": {
|
||||
"default": "127.0.0.1",
|
||||
"format": "hostname",
|
||||
"type": "string"
|
||||
},
|
||||
"password": {
|
||||
"type": "string"
|
||||
},
|
||||
"port": {
|
||||
"default": 6600,
|
||||
"maximum": 65535,
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
28
src/mpd_now_playable/config/fields.py
Normal file
28
src/mpd_now_playable/config/fields.py
Normal file
|
@ -0,0 +1,28 @@
|
|||
from dataclasses import field
|
||||
from typing import NewType, Optional, TypeVar
|
||||
|
||||
from apischema import schema
|
||||
from apischema.conversions import deserializer
|
||||
from apischema.metadata import none_as_undefined
|
||||
from yarl import URL
|
||||
|
||||
__all__ = ("Host", "Port", "optional")
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
Host = NewType("Host", str)
|
||||
schema(format="hostname")(Host)
|
||||
|
||||
Port = NewType("Port", int)
|
||||
schema(min=1, max=65535)(Port)
|
||||
|
||||
schema(format="uri")(URL)
|
||||
|
||||
|
||||
def optional() -> Optional[T]:
|
||||
return field(default=None, metadata=none_as_undefined)
|
||||
|
||||
|
||||
@deserializer
|
||||
def from_yarl(url: str) -> URL:
|
||||
return URL(url)
|
34
src/mpd_now_playable/config/load.py
Normal file
34
src/mpd_now_playable/config/load.py
Normal file
|
@ -0,0 +1,34 @@
|
|||
from os import environ
|
||||
|
||||
from apischema import deserialize
|
||||
from pytomlpp import load
|
||||
from xdg_base_dirs import xdg_config_home
|
||||
|
||||
from .model import Config
|
||||
|
||||
__all__ = "loadConfig"
|
||||
|
||||
|
||||
def loadConfigFromFile() -> Config:
|
||||
path = xdg_config_home() / "mpd-now-playable" / "config.toml"
|
||||
data = load(path)
|
||||
print(f"Loaded your configuration from {path}")
|
||||
return deserialize(Config, data)
|
||||
|
||||
|
||||
def loadConfigFromEnv() -> Config:
|
||||
port = int(environ["MPD_PORT"]) if "MPD_PORT" in environ else None
|
||||
host = environ.get("MPD_HOST")
|
||||
password = environ.get("MPD_PASSWORD")
|
||||
cache = environ.get("MPD_NOW_PLAYABLE_CACHE")
|
||||
if password is None and host is not None and "@" in host:
|
||||
password, host = host.split("@", maxsplit=1)
|
||||
data = {"cache": cache, "mpd": {"port": port, "host": host, "password": password}}
|
||||
return deserialize(Config, data)
|
||||
|
||||
|
||||
def loadConfig() -> Config:
|
||||
try:
|
||||
return loadConfigFromFile()
|
||||
except FileNotFoundError:
|
||||
return loadConfigFromEnv()
|
26
src/mpd_now_playable/config/model.py
Normal file
26
src/mpd_now_playable/config/model.py
Normal file
|
@ -0,0 +1,26 @@
|
|||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
from apischema import alias
|
||||
from yarl import URL
|
||||
|
||||
from .fields import Host, Port, optional
|
||||
|
||||
__all__ = ("MpdConfig", "Config")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MpdConfig:
|
||||
password: Optional[str] = optional()
|
||||
host: Host = Host("127.0.0.1")
|
||||
port: Port = Port(6600)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Config:
|
||||
schema: URL = field(
|
||||
default=URL("https://cdn.00dani.me/m/schemata/mpd-now-playable/config-v1.json"),
|
||||
metadata=alias("$schema"),
|
||||
)
|
||||
cache: Optional[URL] = optional()
|
||||
mpd: MpdConfig = field(default_factory=MpdConfig)
|
28
src/mpd_now_playable/config/schema.py
Normal file
28
src/mpd_now_playable/config/schema.py
Normal file
|
@ -0,0 +1,28 @@
|
|||
from json import dump
|
||||
from pathlib import Path
|
||||
from pprint import pp
|
||||
from typing import Any, Mapping
|
||||
|
||||
from apischema.json_schema import JsonSchemaVersion, deserialization_schema
|
||||
|
||||
from .model import Config
|
||||
|
||||
|
||||
def generate() -> Mapping[str, Any]:
|
||||
return deserialization_schema(Config, version=JsonSchemaVersion.DRAFT_7)
|
||||
|
||||
|
||||
def write() -> None:
|
||||
schema = dict(generate())
|
||||
schema["$id"] = Config.schema.human_repr()
|
||||
|
||||
schema_file = Path(__file__).parent / Config.schema.name
|
||||
print(f"Writing this schema to {schema_file}")
|
||||
pp(schema)
|
||||
with open(schema_file, "w") as fp:
|
||||
dump(schema, fp, indent="\t", sort_keys=True)
|
||||
fp.write("\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
write()
|
|
@ -2,6 +2,8 @@ from __future__ import annotations
|
|||
|
||||
from typing import TypedDict
|
||||
|
||||
from yarl import URL
|
||||
|
||||
from ..async_tools import run_background_task
|
||||
from ..cache import Cache, make_cache
|
||||
from .types import CurrentSongResponse, MpdStateHandler
|
||||
|
@ -23,12 +25,15 @@ def calc_track_key(song: CurrentSongResponse) -> str:
|
|||
return song["file"]
|
||||
|
||||
|
||||
MEMORY = URL("memory://")
|
||||
|
||||
|
||||
class MpdArtworkCache:
|
||||
mpd: MpdStateHandler
|
||||
album_cache: Cache[ArtCacheEntry]
|
||||
track_cache: Cache[ArtCacheEntry]
|
||||
|
||||
def __init__(self, mpd: MpdStateHandler, cache_url: str = "memory://"):
|
||||
def __init__(self, mpd: MpdStateHandler, cache_url: URL = MEMORY):
|
||||
self.mpd = mpd
|
||||
self.album_cache = make_cache(cache_url, "album")
|
||||
self.track_cache = make_cache(cache_url, "track")
|
||||
|
|
|
@ -4,7 +4,9 @@ from uuid import UUID
|
|||
|
||||
from mpd.asyncio import MPDClient
|
||||
from mpd.base import CommandError
|
||||
from yarl import URL
|
||||
|
||||
from ..config.model import MpdConfig
|
||||
from ..player import Player
|
||||
from ..song import PlaybackState, Song, SongListener
|
||||
from ..type_tools import convert_if_exists
|
||||
|
@ -44,20 +46,18 @@ class MpdStateListener(Player):
|
|||
art_cache: MpdArtworkCache
|
||||
idle_count = 0
|
||||
|
||||
def __init__(self, cache: str | None = None) -> None:
|
||||
def __init__(self, cache: URL | None = None) -> None:
|
||||
self.client = MPDClient()
|
||||
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
|
||||
) -> None:
|
||||
print(f"Connecting to MPD server {host}:{port}...")
|
||||
await self.client.connect(host, port)
|
||||
if password is not None:
|
||||
async def start(self, conf: MpdConfig) -> None:
|
||||
print(f"Connecting to MPD server {conf.host}:{conf.port}...")
|
||||
await self.client.connect(conf.host, conf.port)
|
||||
if conf.password is not None:
|
||||
print("Authorising to MPD with your password...")
|
||||
await self.client.password(password)
|
||||
await self.client.password(conf.password)
|
||||
print(f"Connected to MPD v{self.client.mpd_version}")
|
||||
|
||||
async def refresh(self) -> None:
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue