From 27d8c37139ddc0f1e03049637737ad441f218574 Mon Sep 17 00:00:00 2001 From: Danielle McLean Date: Mon, 1 Jul 2024 00:10:17 +1000 Subject: [PATCH 01/32] Significantly overhaul configuration management Everything now uses bog-standard Python dataclasses, with Pydantic providing validation and type conversion through separate classes using its type adapter feature. It's also possible to define your classes using Pydantic's own model type directly, making the type adapter unnecessary, but I didn't want to do things that way because no actual validation is needed when constructing a Song instance for example. Having Pydantic do its thing only on-demand was preferable. I tried a number of validation libraries before settling on Pydantic for this. It's not the fastest option out there (msgspec is I think), but it makes adding support for third-party types like yarl.URL really easy, it generates a nice clean JSON Schema which is easy enough to adjust to my requirements through its GenerateJsonSchema hooks, and raw speed isn't all that important anyway since this is a single-user desktop program that reads its configuration file once on startup. Also, MessagePack is now mandatory if you're caching to an external service. It just didn't make a whole lot sense to explicitly install mpd-now-playable's Redis or Memcached support and then use pickling with them. With all this fussing around done, I'm probably finally ready to actually use that configuration file to configure new features! Yay! --- README.md | 4 +- pdm.lock | 123 ++++++++++++++++-- pyproject.toml | 15 +-- .../config => schemata}/config-v1.json | 51 ++++---- src/mpd_now_playable/cache.py | 30 +++-- src/mpd_now_playable/cli.py | 2 + src/mpd_now_playable/cocoa/now_playing.py | 2 +- src/mpd_now_playable/config/fields.py | 53 ++++---- src/mpd_now_playable/config/load.py | 5 +- src/mpd_now_playable/config/model.py | 20 +-- src/mpd_now_playable/config/schema.py | 45 +------ src/mpd_now_playable/mpd/artwork_cache.py | 21 ++- src/mpd_now_playable/mpd/listener.py | 11 +- src/mpd_now_playable/song.py | 30 ++++- src/mpd_now_playable/tools/schema/__init__.py | 0 src/mpd_now_playable/tools/schema/define.py | 22 ++++ src/mpd_now_playable/tools/schema/generate.py | 47 +++++++ src/mpd_now_playable/tools/schema/plugin.py | 43 ++++++ 18 files changed, 355 insertions(+), 169 deletions(-) rename {src/mpd_now_playable/config => schemata}/config-v1.json (73%) create mode 100644 src/mpd_now_playable/tools/schema/__init__.py create mode 100644 src/mpd_now_playable/tools/schema/define.py create mode 100644 src/mpd_now_playable/tools/schema/generate.py create mode 100644 src/mpd_now_playable/tools/schema/plugin.py diff --git a/README.md b/README.md index 60b2df9..8e48af7 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,8 @@ The recommended way to install mpd-now-playable and its dependencies is with [pi ```shell pipx install mpd-now-playable # or, if you'd like to use a separate cache service, one of these: -pipx install mpd-now-playable[redis,msgpack] -pipx install mpd-now-playable[memcached,msgpack] +pipx install mpd-now-playable[redis] +pipx install mpd-now-playable[memcached] ``` Once pipx is done, the `mpd-now-playable` script should be available on your `$PATH` and ready to use. diff --git a/pdm.lock b/pdm.lock index dab350d..f703af1 100644 --- a/pdm.lock +++ b/pdm.lock @@ -5,7 +5,7 @@ groups = ["default", "all", "dev"] strategy = ["cross_platform"] lock_version = "4.4.1" -content_hash = "sha256:73f93e2fcfc5fc5af9acfdc9604b9f27d3eb5d9c6012f600c671523c2f78bf4c" +content_hash = "sha256:5a1cfd2988fd81c4b8743daaf399bbeb9f1f9d78e161901f0bf76c771ec8052f" [[package]] name = "aiocache" @@ -55,19 +55,13 @@ files = [ ] [[package]] -name = "apischema" -version = "0.18.1" -requires_python = ">=3.7" -summary = "JSON (de)serialization, GraphQL and JSON schema generation using Python typing." +name = "annotated-types" +version = "0.7.0" +requires_python = ">=3.8" +summary = "Reusable constraint types to use with typing.Annotated" files = [ - {file = "apischema-0.18.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:220aa56974f765dc100e875c66c688ff57bad3ae48d0aeaee4fb1ec90c5cd0fd"}, - {file = "apischema-0.18.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:653492010d22acdcbe2240f0ceb3ca59de1b6d34640895e03fda15f944e216d8"}, - {file = "apischema-0.18.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:56a8acc8da59cf7a1052b5aec71d2f8ed6137b54aa1492709acb2c47f0547107"}, - {file = "apischema-0.18.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:d466ccc31cbc95b381037ed5b9e82e034f921eb28f8057263aefc2817678036f"}, - {file = "apischema-0.18.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4b005d5a4baba32eccec5bb0fbd616f9fd26b6a5fb4f15e9a8bb53a948adade0"}, - {file = "apischema-0.18.1-cp312-cp312-win32.whl", hash = "sha256:bd06fc6a52d461bd6540409cb25c1d51aae23b22fcd10b1fb002a3f7f1f15d0f"}, - {file = "apischema-0.18.1-cp312-cp312-win_amd64.whl", hash = "sha256:429f13d9e35379bf8187c41a3c05562f8149358128382a90e415c63db528d6a2"}, - {file = "apischema-0.18.1.tar.gz", hash = "sha256:355dc4dea7389f5b25f5326c26f06eebee8107efda7e82db8f09ee122cdf0c98"}, + {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, + {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, ] [[package]] @@ -113,6 +107,29 @@ files = [ {file = "idna-3.7.tar.gz", hash = "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc"}, ] +[[package]] +name = "markdown-it-py" +version = "3.0.0" +requires_python = ">=3.8" +summary = "Python port of markdown-it. Markdown parsing, done right!" +dependencies = [ + "mdurl~=0.1", +] +files = [ + {file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"}, + {file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"}, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +requires_python = ">=3.7" +summary = "Markdown URL utilities" +files = [ + {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, + {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, +] + [[package]] name = "more-itertools" version = "10.3.0" @@ -191,6 +208,72 @@ files = [ {file = "ormsgpack-1.5.0.tar.gz", hash = "sha256:00c0743ebaa8d21f1c868fbb609c99151ea79e67fec98b51a29077efd91ce348"}, ] +[[package]] +name = "pydantic" +version = "2.7.4" +requires_python = ">=3.8" +summary = "Data validation using Python type hints" +dependencies = [ + "annotated-types>=0.4.0", + "pydantic-core==2.18.4", + "typing-extensions>=4.6.1", +] +files = [ + {file = "pydantic-2.7.4-py3-none-any.whl", hash = "sha256:ee8538d41ccb9c0a9ad3e0e5f07bf15ed8015b481ced539a1759d8cc89ae90d0"}, + {file = "pydantic-2.7.4.tar.gz", hash = "sha256:0c84efd9548d545f63ac0060c1e4d39bb9b14db8b3c0652338aecc07b5adec52"}, +] + +[[package]] +name = "pydantic-core" +version = "2.18.4" +requires_python = ">=3.8" +summary = "Core functionality for Pydantic validation and serialization" +dependencies = [ + "typing-extensions!=4.7.0,>=4.6.0", +] +files = [ + {file = "pydantic_core-2.18.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:6f5c4d41b2771c730ea1c34e458e781b18cc668d194958e0112455fff4e402b2"}, + {file = "pydantic_core-2.18.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2fdf2156aa3d017fddf8aea5adfba9f777db1d6022d392b682d2a8329e087cef"}, + {file = "pydantic_core-2.18.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4748321b5078216070b151d5271ef3e7cc905ab170bbfd27d5c83ee3ec436695"}, + {file = "pydantic_core-2.18.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847a35c4d58721c5dc3dba599878ebbdfd96784f3fb8bb2c356e123bdcd73f34"}, + {file = "pydantic_core-2.18.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3c40d4eaad41f78e3bbda31b89edc46a3f3dc6e171bf0ecf097ff7a0ffff7cb1"}, + {file = "pydantic_core-2.18.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:21a5e440dbe315ab9825fcd459b8814bb92b27c974cbc23c3e8baa2b76890077"}, + {file = "pydantic_core-2.18.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:01dd777215e2aa86dfd664daed5957704b769e726626393438f9c87690ce78c3"}, + {file = "pydantic_core-2.18.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4b06beb3b3f1479d32befd1f3079cc47b34fa2da62457cdf6c963393340b56e9"}, + {file = "pydantic_core-2.18.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:564d7922e4b13a16b98772441879fcdcbe82ff50daa622d681dd682175ea918c"}, + {file = "pydantic_core-2.18.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:0eb2a4f660fcd8e2b1c90ad566db2b98d7f3f4717c64fe0a83e0adb39766d5b8"}, + {file = "pydantic_core-2.18.4-cp312-none-win32.whl", hash = "sha256:8b8bab4c97248095ae0c4455b5a1cd1cdd96e4e4769306ab19dda135ea4cdb07"}, + {file = "pydantic_core-2.18.4-cp312-none-win_amd64.whl", hash = "sha256:14601cdb733d741b8958224030e2bfe21a4a881fb3dd6fbb21f071cabd48fa0a"}, + {file = "pydantic_core-2.18.4-cp312-none-win_arm64.whl", hash = "sha256:c1322d7dd74713dcc157a2b7898a564ab091ca6c58302d5c7b4c07296e3fd00f"}, + {file = "pydantic_core-2.18.4-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:574d92eac874f7f4db0ca653514d823a0d22e2354359d0759e3f6a406db5d55d"}, + {file = "pydantic_core-2.18.4-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1f4d26ceb5eb9eed4af91bebeae4b06c3fb28966ca3a8fb765208cf6b51102ab"}, + {file = "pydantic_core-2.18.4-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77450e6d20016ec41f43ca4a6c63e9fdde03f0ae3fe90e7c27bdbeaece8b1ed4"}, + {file = "pydantic_core-2.18.4-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d323a01da91851a4f17bf592faf46149c9169d68430b3146dcba2bb5e5719abc"}, + {file = "pydantic_core-2.18.4-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:43d447dd2ae072a0065389092a231283f62d960030ecd27565672bd40746c507"}, + {file = "pydantic_core-2.18.4-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:578e24f761f3b425834f297b9935e1ce2e30f51400964ce4801002435a1b41ef"}, + {file = "pydantic_core-2.18.4-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:81b5efb2f126454586d0f40c4d834010979cb80785173d1586df845a632e4e6d"}, + {file = "pydantic_core-2.18.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ab86ce7c8f9bea87b9d12c7f0af71102acbf5ecbc66c17796cff45dae54ef9a5"}, + {file = "pydantic_core-2.18.4-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:90afc12421df2b1b4dcc975f814e21bc1754640d502a2fbcc6d41e77af5ec312"}, + {file = "pydantic_core-2.18.4-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:51991a89639a912c17bef4b45c87bd83593aee0437d8102556af4885811d59f5"}, + {file = "pydantic_core-2.18.4-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:293afe532740370aba8c060882f7d26cfd00c94cae32fd2e212a3a6e3b7bc15e"}, + {file = "pydantic_core-2.18.4-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b48ece5bde2e768197a2d0f6e925f9d7e3e826f0ad2271120f8144a9db18d5c8"}, + {file = "pydantic_core-2.18.4-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:eae237477a873ab46e8dd748e515c72c0c804fb380fbe6c85533c7de51f23a8f"}, + {file = "pydantic_core-2.18.4-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:834b5230b5dfc0c1ec37b2fda433b271cbbc0e507560b5d1588e2cc1148cf1ce"}, + {file = "pydantic_core-2.18.4-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:e858ac0a25074ba4bce653f9b5d0a85b7456eaddadc0ce82d3878c22489fa4ee"}, + {file = "pydantic_core-2.18.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2fd41f6eff4c20778d717af1cc50eca52f5afe7805ee530a4fbd0bae284f16e9"}, + {file = "pydantic_core-2.18.4.tar.gz", hash = "sha256:ec3beeada09ff865c344ff3bc2f427f5e6c26401cc6113d77e372c3fdac73864"}, +] + +[[package]] +name = "pygments" +version = "2.18.0" +requires_python = ">=3.8" +summary = "Pygments is a syntax highlighting package written in Python." +files = [ + {file = "pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a"}, + {file = "pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199"}, +] + [[package]] name = "pyobjc-core" version = "10.3.1" @@ -335,6 +418,20 @@ files = [ {file = "redis-5.0.4.tar.gz", hash = "sha256:ec31f2ed9675cc54c21ba854cfe0462e6faf1d83c8ce5944709db8a4700b9c61"}, ] +[[package]] +name = "rich" +version = "13.7.1" +requires_python = ">=3.7.0" +summary = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" +dependencies = [ + "markdown-it-py>=2.2.0", + "pygments<3.0.0,>=2.13.0", +] +files = [ + {file = "rich-13.7.1-py3-none-any.whl", hash = "sha256:4edbae314f59eb482f54e9e30bf00d33350aaa94f4bfcd4e9e3110e64d0d7222"}, + {file = "rich-13.7.1.tar.gz", hash = "sha256:9be308cb1fe2f1f57d67ce99e95af38a1e2bc71ad9813b0e247cf7ffbcc3a432"}, +] + [[package]] name = "ruff" version = "0.4.10" diff --git a/pyproject.toml b/pyproject.toml index 4c7aaf9..64b5b6f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,9 +12,10 @@ dependencies = [ "python-mpd2>=3.1.0", "xdg-base-dirs>=6.0.1", "pytomlpp>=1.0.13", - "apischema>=0.18.1", "yarl>=1.9.4", "boltons>=24.0.0", + "pydantic>=2.7.4", + "rich>=13.7.1", ] readme = "README.md" @@ -29,14 +30,9 @@ classifiers = [ ] [project.optional-dependencies] -redis = ["aiocache[redis]"] -memcached = ["aiocache[memcached]"] -msgpack = [ - "ormsgpack>=1.5.0", -] -all = [ - "mpd-now-playable[redis,memcached,msgpack]", -] +redis = ["aiocache[redis]", "ormsgpack>=1.5.0"] +memcached = ["aiocache[memcached]", "ormsgpack>=1.5.0"] +all = ["mpd-now-playable[redis,memcached]"] [project.urls] Homepage = "https://git.00dani.me/00dani/mpd-now-playable" @@ -51,6 +47,7 @@ build-backend = "pdm.backend" [tool.mypy] mypy_path = 'stubs' +plugins = ['pydantic.mypy', 'mpd_now_playable.tools.schema.plugin'] [tool.ruff.lint] select = [ diff --git a/src/mpd_now_playable/config/config-v1.json b/schemata/config-v1.json similarity index 73% rename from src/mpd_now_playable/config/config-v1.json rename to schemata/config-v1.json index c5d3fc3..436ba7b 100644 --- a/src/mpd_now_playable/config/config-v1.json +++ b/schemata/config-v1.json @@ -1,52 +1,47 @@ { - "$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": { - "allOf": [ - { - "$ref": "#/definitions/URL" - } - ], - "description": "A URL describing a cache service for mpd-now-playable to use. Supported protocols are memory://, redis://, and memcached://." - }, - "mpd": { - "additionalProperties": false, - "default": { - "host": "127.0.0.1", - "port": 6600 - }, + "$defs": { + "MpdConfig": { "properties": { "host": { "default": "127.0.0.1", "description": "The hostname or IP address of your MPD server. If you're running MPD on your local machine, you don't need to configure this.", "format": "hostname", + "title": "Host", "type": "string" }, "password": { "description": "The password required to connect to your MPD instance, if you need one.", - "type": "string" + "format": "password", + "title": "Password", + "type": "string", + "writeOnly": true }, "port": { "default": 6600, "description": "The port on which to connect to MPD. Unless you're managing multiple MPD servers on one machine for some reason, you probably haven't changed this from the default port, 6600.", "maximum": 65535, "minimum": 1, + "title": "Port", "type": "integer" } }, + "title": "MpdConfig", "type": "object" } }, + "$id": "https://cdn.00dani.me/m/schemata/mpd-now-playable/config-v1.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "cache": { + "description": "A URL describing a cache service for mpd-now-playable to use. Supported protocols are memory://, redis://, and memcached://.", + "format": "uri", + "title": "Cache", + "type": "string" + }, + "mpd": { + "$ref": "#/$defs/MpdConfig" + } + }, + "title": "Config", "type": "object" } diff --git a/src/mpd_now_playable/cache.py b/src/mpd_now_playable/cache.py index 0973a5f..6b27f71 100644 --- a/src/mpd_now_playable/cache.py +++ b/src/mpd_now_playable/cache.py @@ -1,37 +1,41 @@ from __future__ import annotations from contextlib import suppress -from typing import Any, Optional, TypeVar +from typing import Any, Generic, Optional, TypeVar from aiocache import Cache -from aiocache.serializers import BaseSerializer, PickleSerializer +from aiocache.serializers import BaseSerializer +from pydantic.type_adapter import TypeAdapter from yarl import URL T = TypeVar("T") -HAS_ORMSGPACK = False with suppress(ImportError): import ormsgpack - HAS_ORMSGPACK = True - -class OrmsgpackSerializer(BaseSerializer): +class OrmsgpackSerializer(BaseSerializer, Generic[T]): DEFAULT_ENCODING = None - def dumps(self, value: object) -> bytes: - return ormsgpack.packb(value) + def __init__(self, schema: TypeAdapter[T]): + super().__init__() + self.schema = schema - def loads(self, value: Optional[bytes]) -> object: + def dumps(self, value: T) -> bytes: + return ormsgpack.packb(self.schema.dump_python(value)) + + def loads(self, value: Optional[bytes]) -> T | None: if value is None: return None - return ormsgpack.unpackb(value) + data = ormsgpack.unpackb(value) + return self.schema.validate_python(data) -def make_cache(url: URL, namespace: str = "") -> Cache[T]: +def make_cache(schema: TypeAdapter[T], 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(url.query) if url.path: @@ -48,6 +52,6 @@ def make_cache(url: URL, namespace: str = "") -> Cache[T]: namespace = ":".join(s for s in [kwargs.pop("namespace", ""), namespace] if s) - serializer = OrmsgpackSerializer if HAS_ORMSGPACK else PickleSerializer + serializer = OrmsgpackSerializer(schema) - return Cache(backend, serializer=serializer(), namespace=namespace, **kwargs) + 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 bd25127..6f6e44f 100644 --- a/src/mpd_now_playable/cli.py +++ b/src/mpd_now_playable/cli.py @@ -1,6 +1,7 @@ import asyncio from corefoundationasyncio import CoreFoundationEventLoop +from rich import print from .__version__ import __version__ from .cocoa.now_playing import CocoaNowPlaying @@ -11,6 +12,7 @@ from .mpd.listener import MpdStateListener async def listen() -> None: print(f"mpd-now-playable v{__version__}") config = loadConfig() + print(config) listener = MpdStateListener(config.cache) now_playing = CocoaNowPlaying(listener) await listener.start(config.mpd) diff --git a/src/mpd_now_playable/cocoa/now_playing.py b/src/mpd_now_playable/cocoa/now_playing.py index 13681d7..1121329 100644 --- a/src/mpd_now_playable/cocoa/now_playing.py +++ b/src/mpd_now_playable/cocoa/now_playing.py @@ -109,7 +109,7 @@ def song_to_media_item(song: Song) -> NSMutableDictionary: if song.art: nowplaying_info[MPMediaItemPropertyArtwork] = ns_image_to_media_item_artwork( - data_to_ns_image(song.art) + data_to_ns_image(song.art.data) ) return nowplaying_info diff --git a/src/mpd_now_playable/config/fields.py b/src/mpd_now_playable/config/fields.py index 37ff8b1..f73a7f9 100644 --- a/src/mpd_now_playable/config/fields.py +++ b/src/mpd_now_playable/config/fields.py @@ -1,28 +1,37 @@ -from dataclasses import field -from typing import NewType, Optional, TypeVar +from typing import Annotated, NewType -from apischema import schema -from apischema.conversions import deserializer -from apischema.metadata import none_as_undefined -from yarl import URL +from annotated_types import Ge, Le +from pydantic import ( + Field, + PlainSerializer, + PlainValidator, + SecretStr, + Strict, + WithJsonSchema, +) +from yarl import URL as Yarl -__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) +__all__ = ("Host", "Password", "Port", "Url") -def optional() -> Optional[T]: - return field(default=None, metadata=none_as_undefined) +def from_yarl(url: Yarl) -> str: + return url.human_repr() -@deserializer -def from_yarl(url: str) -> URL: - return URL(url) +def to_yarl(value: object) -> Yarl: + if isinstance(value, str): + return Yarl(value) + raise NotImplementedError(f"Cannot convert {type(object)} to URL") + + +Host = NewType( + "Host", Annotated[str, Strict(), Field(json_schema_extra={"format": "hostname"})] +) +Password = NewType("Password", Annotated[SecretStr, Strict()]) +Port = NewType("Port", Annotated[int, Strict(), Ge(1), Le(65535)]) +Url = Annotated[ + Yarl, + PlainValidator(to_yarl), + PlainSerializer(from_yarl, return_type=str), + WithJsonSchema({"type": "string", "format": "uri"}), +] diff --git a/src/mpd_now_playable/config/load.py b/src/mpd_now_playable/config/load.py index 9e79c09..a45a0eb 100644 --- a/src/mpd_now_playable/config/load.py +++ b/src/mpd_now_playable/config/load.py @@ -2,7 +2,6 @@ from collections.abc import Mapping from os import environ from typing import TypeVar -from apischema import deserialize from boltons.iterutils import remap from pytomlpp import load from xdg_base_dirs import xdg_config_home @@ -33,7 +32,7 @@ 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) + return Config.schema.validate_python(data) def loadConfigFromEnv() -> Config: @@ -44,7 +43,7 @@ def loadConfigFromEnv() -> Config: 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, withoutNones(data)) + return Config.schema.validate_python(withoutNones(data)) def loadConfig() -> Config: diff --git a/src/mpd_now_playable/config/model.py b/src/mpd_now_playable/config/model.py index 88d197f..4bd06bf 100644 --- a/src/mpd_now_playable/config/model.py +++ b/src/mpd_now_playable/config/model.py @@ -1,18 +1,16 @@ from dataclasses import dataclass, field from typing import Optional -from apischema import alias -from yarl import URL - -from .fields import Host, Port, optional +from ..tools.schema.define import schema +from .fields import Host, Password, Port, Url __all__ = ("MpdConfig", "Config") -@dataclass(frozen=True) +@dataclass(slots=True) class MpdConfig: #: The password required to connect to your MPD instance, if you need one. - password: Optional[str] = optional() + password: Optional[Password] = None #: The hostname or IP address of your MPD server. If you're running MPD #: on your local machine, you don't need to configure this. host: Host = Host("127.0.0.1") @@ -22,14 +20,10 @@ class MpdConfig: port: Port = Port(6600) -@dataclass(frozen=True) +@schema("https://cdn.00dani.me/m/schemata/mpd-now-playable/config-v1.json") +@dataclass(slots=True) class Config: - schema: URL = field( - default=URL("https://cdn.00dani.me/m/schemata/mpd-now-playable/config-v1.json"), - metadata=alias("$schema"), - ) - #: A URL describing a cache service for mpd-now-playable to use. Supported #: protocols are memory://, redis://, and memcached://. - cache: Optional[URL] = optional() + cache: Optional[Url] = None mpd: MpdConfig = field(default_factory=MpdConfig) diff --git a/src/mpd_now_playable/config/schema.py b/src/mpd_now_playable/config/schema.py index 93c0c10..341487b 100644 --- a/src/mpd_now_playable/config/schema.py +++ b/src/mpd_now_playable/config/schema.py @@ -1,46 +1,5 @@ -from json import dump -from pathlib import Path -from pprint import pp -from shutil import get_terminal_size -from typing import Any, Mapping - -from apischema import schema, settings -from apischema.json_schema import JsonSchemaVersion, deserialization_schema -from apischema.schemas import Schema -from class_doc import extract_docs_from_cls_obj - +from ..tools.schema.generate import write from .model import Config - -def field_base_schema(tp: type, name: str, alias: str) -> Schema | None: - desc_lines = extract_docs_from_cls_obj(tp).get(name, []) - if desc_lines: - print((tp, name, alias)) - return schema(description=" ".join(desc_lines)) - return None - - -settings.base_schema.field = field_base_schema - - -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, sort_dicts=True, width=get_terminal_size().columns) - with open(schema_file, "w") as fp: - dump(schema, fp, indent="\t", sort_keys=True) - fp.write("\n") - - if __name__ == "__main__": - write() + write(Config) diff --git a/src/mpd_now_playable/mpd/artwork_cache.py b/src/mpd_now_playable/mpd/artwork_cache.py index 3d9ed1b..0b1ebbe 100644 --- a/src/mpd_now_playable/mpd/artwork_cache.py +++ b/src/mpd_now_playable/mpd/artwork_cache.py @@ -1,10 +1,9 @@ from __future__ import annotations -from typing import TypedDict - from yarl import URL from ..cache import Cache, make_cache +from ..song import Artwork, ArtworkSchema, to_artwork from ..tools.asyncio import run_background_task from ..tools.types import un_maybe_plural from .types import CurrentSongResponse, MpdStateHandler @@ -12,10 +11,6 @@ from .types import CurrentSongResponse, MpdStateHandler CACHE_TTL = 60 * 60 # seconds = 1 hour -class ArtCacheEntry(TypedDict): - data: bytes | None - - def calc_album_key(song: CurrentSongResponse) -> str: artist = sorted( un_maybe_plural(song.get("albumartist", song.get("artist", "Unknown Artist"))) @@ -33,18 +28,18 @@ MEMORY = URL("memory://") class MpdArtworkCache: mpd: MpdStateHandler - album_cache: Cache[ArtCacheEntry] - track_cache: Cache[ArtCacheEntry] + album_cache: Cache[Artwork] + track_cache: Cache[Artwork] 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") + self.album_cache = make_cache(ArtworkSchema, cache_url, "album") + self.track_cache = make_cache(ArtworkSchema, 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)) @@ -52,12 +47,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 = ArtCacheEntry(data=await self.mpd.get_art(song["file"])) + art = to_artwork(await self.mpd.get_art(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 62229b0..96e7f30 100644 --- a/src/mpd_now_playable/mpd/listener.py +++ b/src/mpd_now_playable/mpd/listener.py @@ -4,18 +4,19 @@ from uuid import UUID from mpd.asyncio import MPDClient from mpd.base import CommandError +from rich import print as rprint from yarl import URL from ..config.model import MpdConfig from ..player import Player -from ..song import PlaybackState, Song, SongListener +from ..song import Artwork, PlaybackState, Song, SongListener, to_artwork from ..tools.types import convert_if_exists, un_maybe_plural from .artwork_cache import MpdArtworkCache from .types import CurrentSongResponse, StatusResponse def mpd_current_to_song( - status: StatusResponse, current: CurrentSongResponse, art: bytes | None + status: StatusResponse, current: CurrentSongResponse, art: Artwork ) -> Song: return Song( state=PlaybackState(status["state"]), @@ -57,7 +58,7 @@ class MpdStateListener(Player): 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(conf.password) + await self.client.password(conf.password.get_secret_value()) print(f"Connected to MPD v{self.client.mpd_version}") async def refresh(self) -> None: @@ -91,8 +92,8 @@ class MpdStateListener(Player): if starting_idle_count != self.idle_count: return - song = mpd_current_to_song(status, current, art) - print(song) + song = mpd_current_to_song(status, current, to_artwork(art)) + rprint(song) listener.update(song) async def get_art(self, file: str) -> bytes | None: diff --git a/src/mpd_now_playable/song.py b/src/mpd_now_playable/song.py index 76f17a8..940177e 100644 --- a/src/mpd_now_playable/song.py +++ b/src/mpd_now_playable/song.py @@ -1,9 +1,10 @@ +from dataclasses import dataclass, field from enum import StrEnum from pathlib import Path -from typing import Protocol +from typing import Literal, Protocol from uuid import UUID -from attrs import define, field +from pydantic.type_adapter import TypeAdapter class PlaybackState(StrEnum): @@ -12,7 +13,28 @@ class PlaybackState(StrEnum): stop = "stop" -@define +@dataclass(slots=True) +class HasArtwork: + data: bytes = field(repr=False) + + +@dataclass(slots=True) +class NoArtwork: + def __bool__(self) -> Literal[False]: + return False + + +Artwork = HasArtwork | NoArtwork +ArtworkSchema: TypeAdapter[Artwork] = TypeAdapter(HasArtwork | NoArtwork) + + +def to_artwork(art: bytes | None) -> Artwork: + if art is None: + return NoArtwork() + return HasArtwork(art) + + +@dataclass(slots=True) class Song: state: PlaybackState queue_index: int @@ -30,7 +52,7 @@ class Song: genre: list[str] duration: float elapsed: float - art: bytes | None = field(repr=lambda a: "" if a else "") + art: Artwork class SongListener(Protocol): diff --git a/src/mpd_now_playable/tools/schema/__init__.py b/src/mpd_now_playable/tools/schema/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/mpd_now_playable/tools/schema/define.py b/src/mpd_now_playable/tools/schema/define.py new file mode 100644 index 0000000..f33c907 --- /dev/null +++ b/src/mpd_now_playable/tools/schema/define.py @@ -0,0 +1,22 @@ +from typing import Callable, Protocol, Self, TypeVar + +from pydantic.type_adapter import TypeAdapter +from yarl import URL + +T = TypeVar("T") + + +class ModelWithSchema(Protocol): + @property + def id(self) -> URL: ... + @property + def schema(self) -> TypeAdapter[Self]: ... + + +def schema(schema_id: str) -> Callable[[type[T]], type[T]]: + def decorate(clazz: type[T]) -> type[T]: + type.__setattr__(clazz, "id", URL(schema_id)) + type.__setattr__(clazz, "schema", TypeAdapter(clazz)) + return clazz + + return decorate diff --git a/src/mpd_now_playable/tools/schema/generate.py b/src/mpd_now_playable/tools/schema/generate.py new file mode 100644 index 0000000..181a214 --- /dev/null +++ b/src/mpd_now_playable/tools/schema/generate.py @@ -0,0 +1,47 @@ +from json import dump +from pathlib import Path + +from class_doc import extract_docs_from_cls_obj +from pydantic.json_schema import GenerateJsonSchema, JsonSchemaMode, JsonSchemaValue +from pydantic_core import core_schema as s +from rich.pretty import pprint + +from .define import ModelWithSchema + +__all__ = ("write",) + + +def write(model: ModelWithSchema) -> None: + schema = model.schema.json_schema(schema_generator=MyGenerateJsonSchema) + schema["$id"] = model.id.human_repr() + schema_file = Path(__file__).parents[4] / "schemata" / model.id.name + print(f"Writing this schema to {schema_file}") + pprint(schema) + with open(schema_file, "w") as fp: + dump(schema, fp, indent="\t", sort_keys=True) + fp.write("\n") + + +class MyGenerateJsonSchema(GenerateJsonSchema): + def generate( + self, schema: s.CoreSchema, mode: JsonSchemaMode = "validation" + ) -> dict[str, object]: + json_schema = super().generate(schema, mode=mode) + json_schema["$schema"] = self.schema_dialect + return json_schema + + def default_schema(self, schema: s.WithDefaultSchema) -> JsonSchemaValue: + result = super().default_schema(schema) + if "default" in result and result["default"] is None: + del result["default"] + return result + + def dataclass_schema(self, schema: s.DataclassSchema) -> JsonSchemaValue: + result = super().dataclass_schema(schema) + docs = extract_docs_from_cls_obj(schema["cls"]) + for field, lines in docs.items(): + result["properties"][field]["description"] = " ".join(lines) + return result + + def nullable_schema(self, schema: s.NullableSchema) -> JsonSchemaValue: + return self.generate_inner(schema["schema"]) diff --git a/src/mpd_now_playable/tools/schema/plugin.py b/src/mpd_now_playable/tools/schema/plugin.py new file mode 100644 index 0000000..441348c --- /dev/null +++ b/src/mpd_now_playable/tools/schema/plugin.py @@ -0,0 +1,43 @@ +from typing import Callable + +from mypy.plugin import ClassDefContext, Plugin +from mypy.plugins.common import add_attribute_to_class + + +def add_schema_classvars(ctx: ClassDefContext) -> None: + api = ctx.api + cls = ctx.cls + URL = api.named_type("yarl.URL") + Adapter = api.named_type( + "pydantic.type_adapter.TypeAdapter", [api.named_type(cls.fullname)] + ) + + add_attribute_to_class( + api, + cls, + "id", + URL, + final=True, + is_classvar=True, + ) + add_attribute_to_class( + api, + cls, + "schema", + Adapter, + final=True, + is_classvar=True, + ) + + +class SchemaDecoratorPlugin(Plugin): + def get_class_decorator_hook( + self, fullname: str + ) -> Callable[[ClassDefContext], None] | None: + if fullname != "mpd_now_playable.tools.schema.define.schema": + return None + return add_schema_classvars + + +def plugin(version: str) -> type[SchemaDecoratorPlugin]: + return SchemaDecoratorPlugin From 00ba34bd0b949228f1e254320db1d5f973250dbb Mon Sep 17 00:00:00 2001 From: Danielle McLean Date: Tue, 9 Jul 2024 12:52:49 +1000 Subject: [PATCH 02/32] Refactor Cocoa stuff into a 'receiver' MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The idea here is that there are other places that might want to know what's playing, besides MPNowPlayingInfoCenter. For example, to expose the now playing info to Übersicht efficiently, it needs to be available from a web browser, ideally using WebSockets. So there could be a receiver that runs a small WebSockets server and sends out now playing info to anyone who connects. Additionally, I hope to write receivers for MPRIS and for the System Media Transport Controls on Windows, making mpd-now-playable equally useful across all platforms. None of this is implemented yet, of course, but I hope to get the WebSockets receiver done pretty soon! I'm going to keep the default behaviour unchanged. Unless you explicitly configure different receivers in config.toml, mpd-now-playable will just behave as an MPNowPlayingInfoCenter integration as it's always done. --- pyproject.toml | 1 + schemata/config-v1.json | 37 +++++++++ src/mpd_now_playable/cli.py | 39 ++++++---- src/mpd_now_playable/config/model.py | 28 ++++++- src/mpd_now_playable/mpd/listener.py | 26 ++++--- .../{cocoa => receivers}/__init__.py | 0 .../receivers/cocoa/__init__.py | 3 + .../{ => receivers}/cocoa/now_playing.py | 28 +++++-- .../{ => receivers}/cocoa/persistent_id.py | 2 +- src/mpd_now_playable/song.py | 6 +- src/mpd_now_playable/song_receiver.py | 76 +++++++++++++++++++ src/mpd_now_playable/tools/types.py | 6 ++ 12 files changed, 214 insertions(+), 38 deletions(-) rename src/mpd_now_playable/{cocoa => receivers}/__init__.py (100%) create mode 100644 src/mpd_now_playable/receivers/cocoa/__init__.py rename src/mpd_now_playable/{ => receivers}/cocoa/now_playing.py (90%) rename src/mpd_now_playable/{ => receivers}/cocoa/persistent_id.py (98%) create mode 100644 src/mpd_now_playable/song_receiver.py diff --git a/pyproject.toml b/pyproject.toml index 64b5b6f..8b8cbfc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -70,6 +70,7 @@ select = [ ] ignore = [ "ANN101", # missing-type-self + "ANN102", # missing-type-cls ] [tool.ruff.lint.flake8-annotations] diff --git a/schemata/config-v1.json b/schemata/config-v1.json index 436ba7b..14600a1 100644 --- a/schemata/config-v1.json +++ b/schemata/config-v1.json @@ -1,5 +1,20 @@ { "$defs": { + "CocoaReceiverConfig": { + "properties": { + "kind": { + "const": "cocoa", + "default": "cocoa", + "enum": [ + "cocoa" + ], + "title": "Kind", + "type": "string" + } + }, + "title": "CocoaReceiverConfig", + "type": "object" + }, "MpdConfig": { "properties": { "host": { @@ -40,6 +55,28 @@ }, "mpd": { "$ref": "#/$defs/MpdConfig" + }, + "receivers": { + "default": [ + { + "kind": "cocoa" + } + ], + "items": { + "discriminator": { + "mapping": { + "cocoa": "#/$defs/CocoaReceiverConfig" + }, + "propertyName": "kind" + }, + "oneOf": [ + { + "$ref": "#/$defs/CocoaReceiverConfig" + } + ] + }, + "title": "Receivers", + "type": "array" } }, "title": "Config", diff --git a/src/mpd_now_playable/cli.py b/src/mpd_now_playable/cli.py index 6f6e44f..0e201e7 100644 --- a/src/mpd_now_playable/cli.py +++ b/src/mpd_now_playable/cli.py @@ -1,30 +1,41 @@ import asyncio +from collections.abc import Iterable -from corefoundationasyncio import CoreFoundationEventLoop from rich import print from .__version__ import __version__ -from .cocoa.now_playing import CocoaNowPlaying from .config.load import loadConfig +from .config.model import Config from .mpd.listener import MpdStateListener +from .song_receiver import ( + Receiver, + choose_loop_factory, + import_receiver, +) -async def listen() -> None: - print(f"mpd-now-playable v{__version__}") - config = loadConfig() - print(config) - listener = MpdStateListener(config.cache) - now_playing = CocoaNowPlaying(listener) +async def listen( + config: Config, listener: MpdStateListener, receiver_types: Iterable[type[Receiver]] +) -> None: await listener.start(config.mpd) - await listener.loop(now_playing) - - -def make_loop() -> CoreFoundationEventLoop: - return CoreFoundationEventLoop(console_app=True) + receivers = (rec(listener, config) for rec in receiver_types) + await listener.loop(receivers) def main() -> None: - asyncio.run(listen(), loop_factory=make_loop, debug=True) + print(f"mpd-now-playable v{__version__}") + config = loadConfig() + print(config) + + listener = MpdStateListener(config.cache) + receiver_types = tuple(import_receiver(rec) for rec in config.receivers) + + factory = choose_loop_factory(receiver_types) + asyncio.run( + listen(config, listener, receiver_types), + loop_factory=factory.make_loop, + debug=True, + ) if __name__ == "__main__": diff --git a/src/mpd_now_playable/config/model.py b/src/mpd_now_playable/config/model.py index 4bd06bf..7e2e6af 100644 --- a/src/mpd_now_playable/config/model.py +++ b/src/mpd_now_playable/config/model.py @@ -1,10 +1,33 @@ from dataclasses import dataclass, field -from typing import Optional +from typing import Annotated, Literal, Optional, Protocol + +from pydantic import Field from ..tools.schema.define import schema from .fields import Host, Password, Port, Url -__all__ = ("MpdConfig", "Config") +__all__ = ( + "Config", + "MpdConfig", + "BaseReceiverConfig", + "CocoaReceiverConfig", +) + + +class BaseReceiverConfig(Protocol): + @property + def kind(self) -> str: ... + + +@dataclass(slots=True) +class CocoaReceiverConfig(BaseReceiverConfig): + kind: Literal["cocoa"] = "cocoa" + + +ReceiverConfig = Annotated[ + CocoaReceiverConfig, + Field(discriminator="kind"), +] @dataclass(slots=True) @@ -27,3 +50,4 @@ class Config: #: protocols are memory://, redis://, and memcached://. cache: Optional[Url] = None mpd: MpdConfig = field(default_factory=MpdConfig) + receivers: tuple[ReceiverConfig, ...] = (CocoaReceiverConfig(),) diff --git a/src/mpd_now_playable/mpd/listener.py b/src/mpd_now_playable/mpd/listener.py index 96e7f30..f787223 100644 --- a/src/mpd_now_playable/mpd/listener.py +++ b/src/mpd_now_playable/mpd/listener.py @@ -1,4 +1,5 @@ import asyncio +from collections.abc import Iterable from pathlib import Path from uuid import UUID @@ -9,7 +10,8 @@ from yarl import URL from ..config.model import MpdConfig from ..player import Player -from ..song import Artwork, PlaybackState, Song, SongListener, to_artwork +from ..song import Artwork, PlaybackState, Song, to_artwork +from ..song_receiver import Receiver from ..tools.types import convert_if_exists, un_maybe_plural from .artwork_cache import MpdArtworkCache from .types import CurrentSongResponse, StatusResponse @@ -43,7 +45,7 @@ def mpd_current_to_song( class MpdStateListener(Player): client: MPDClient - listener: SongListener + receivers: Iterable[Receiver] art_cache: MpdArtworkCache idle_count = 0 @@ -62,18 +64,18 @@ class MpdStateListener(Player): print(f"Connected to MPD v{self.client.mpd_version}") async def refresh(self) -> None: - await self.update_listener(self.listener) + await self.update_receivers() - async def loop(self, listener: SongListener) -> None: - self.listener = listener - # notify our listener of the initial state MPD is in when this script loads up. - await self.update_listener(listener) + async def loop(self, receivers: Iterable[Receiver]) -> None: + self.receivers = receivers + # notify our receivers of the initial state MPD is in when this script loads up. + await self.update_receivers() # then wait for stuff to change in MPD. :) async for _ in self.client.idle(): self.idle_count += 1 - await self.update_listener(listener) + await self.update_receivers() - async def update_listener(self, listener: SongListener) -> None: + async def update_receivers(self) -> None: # If any async calls in here take long enough that we got another MPD idle event, we want to bail out of this older update. starting_idle_count = self.idle_count status, current = await asyncio.gather( @@ -85,7 +87,8 @@ class MpdStateListener(Player): if status["state"] == "stop": print("Nothing playing") - listener.update(None) + for r in self.receivers: + r.update(None) return art = await self.art_cache.get_cached_artwork(current) @@ -94,7 +97,8 @@ class MpdStateListener(Player): song = mpd_current_to_song(status, current, to_artwork(art)) rprint(song) - listener.update(song) + for r in self.receivers: + r.update(song) async def get_art(self, file: str) -> bytes | None: picture = await self.readpicture(file) diff --git a/src/mpd_now_playable/cocoa/__init__.py b/src/mpd_now_playable/receivers/__init__.py similarity index 100% rename from src/mpd_now_playable/cocoa/__init__.py rename to src/mpd_now_playable/receivers/__init__.py diff --git a/src/mpd_now_playable/receivers/cocoa/__init__.py b/src/mpd_now_playable/receivers/cocoa/__init__.py new file mode 100644 index 0000000..a91fd74 --- /dev/null +++ b/src/mpd_now_playable/receivers/cocoa/__init__.py @@ -0,0 +1,3 @@ +__all__ = ("receiver",) + +from .now_playing import CocoaNowPlayingReceiver as receiver diff --git a/src/mpd_now_playable/cocoa/now_playing.py b/src/mpd_now_playable/receivers/cocoa/now_playing.py similarity index 90% rename from src/mpd_now_playable/cocoa/now_playing.py rename to src/mpd_now_playable/receivers/cocoa/now_playing.py index 1121329..158c5a5 100644 --- a/src/mpd_now_playable/cocoa/now_playing.py +++ b/src/mpd_now_playable/receivers/cocoa/now_playing.py @@ -1,7 +1,9 @@ from collections.abc import Callable, Coroutine from pathlib import Path +from typing import Literal from AppKit import NSCompositingOperationCopy, NSImage, NSMakeRect +from corefoundationasyncio import CoreFoundationEventLoop from Foundation import CGSize, NSMutableDictionary from MediaPlayer import ( MPChangePlaybackPositionCommandEvent, @@ -35,9 +37,11 @@ from MediaPlayer import ( MPRemoteCommandHandlerStatusSuccess, ) -from ..player import Player -from ..song import PlaybackState, Song -from ..tools.asyncio import run_background_task +from ...config.model import Config +from ...player import Player +from ...song import PlaybackState, Song +from ...song_receiver import LoopFactory, Receiver +from ...tools.asyncio import run_background_task from .persistent_id import song_to_persistent_id @@ -127,8 +131,22 @@ def nothing_to_media_item() -> NSMutableDictionary: MPD_LOGO = ns_image_to_media_item_artwork(logo_to_ns_image()) -class CocoaNowPlaying: - def __init__(self, player: Player): +class CocoaLoopFactory(LoopFactory[CoreFoundationEventLoop]): + @property + def is_replaceable(self) -> Literal[False]: + return False + + @classmethod + def make_loop(cls) -> CoreFoundationEventLoop: + return CoreFoundationEventLoop(console_app=True) + + +class CocoaNowPlayingReceiver(Receiver): + @classmethod + def loop_factory(cls) -> LoopFactory[CoreFoundationEventLoop]: + return CocoaLoopFactory() + + def __init__(self, player: Player, config: Config): self.cmd_center = MPRemoteCommandCenter.sharedCommandCenter() self.info_center = MPNowPlayingInfoCenter.defaultCenter() diff --git a/src/mpd_now_playable/cocoa/persistent_id.py b/src/mpd_now_playable/receivers/cocoa/persistent_id.py similarity index 98% rename from src/mpd_now_playable/cocoa/persistent_id.py rename to src/mpd_now_playable/receivers/cocoa/persistent_id.py index 7f63ae9..0ce50a1 100644 --- a/src/mpd_now_playable/cocoa/persistent_id.py +++ b/src/mpd_now_playable/receivers/cocoa/persistent_id.py @@ -3,7 +3,7 @@ from pathlib import Path from typing import Final from uuid import UUID -from ..song import Song +from ...song import Song # The maximum size for a BLAKE2b "person" value is sixteen bytes, so we need to be concise. HASH_PERSON_PREFIX: Final = b"mnp.mac." diff --git a/src/mpd_now_playable/song.py b/src/mpd_now_playable/song.py index 940177e..487c9cb 100644 --- a/src/mpd_now_playable/song.py +++ b/src/mpd_now_playable/song.py @@ -1,7 +1,7 @@ from dataclasses import dataclass, field from enum import StrEnum from pathlib import Path -from typing import Literal, Protocol +from typing import Literal from uuid import UUID from pydantic.type_adapter import TypeAdapter @@ -53,7 +53,3 @@ class Song: duration: float elapsed: float art: Artwork - - -class SongListener(Protocol): - def update(self, song: Song | None) -> None: ... diff --git a/src/mpd_now_playable/song_receiver.py b/src/mpd_now_playable/song_receiver.py new file mode 100644 index 0000000..d6f5b8e --- /dev/null +++ b/src/mpd_now_playable/song_receiver.py @@ -0,0 +1,76 @@ +from asyncio import AbstractEventLoop, new_event_loop +from dataclasses import dataclass +from importlib import import_module +from typing import Generic, Iterable, Literal, Protocol, TypeVar, cast + +from .config.model import BaseReceiverConfig, Config +from .player import Player +from .song import Song +from .tools.types import not_none + +T = TypeVar("T", bound=AbstractEventLoop, covariant=True) + + +class LoopFactory(Generic[T], Protocol): + @property + def is_replaceable(self) -> bool: ... + + @classmethod + def make_loop(cls) -> T: ... + + +class Receiver(Protocol): + def __init__(self, player: Player, config: Config) -> None: ... + @classmethod + def loop_factory(cls) -> LoopFactory[AbstractEventLoop]: ... + def update(self, song: Song | None) -> None: ... + + +class ReceiverModule(Protocol): + receiver: type[Receiver] + + +class DefaultLoopFactory(LoopFactory[AbstractEventLoop]): + @property + def is_replaceable(self) -> Literal[True]: + return True + + @classmethod + def make_loop(cls) -> AbstractEventLoop: + return new_event_loop() + + +@dataclass +class IncompatibleReceiverError(Exception): + a: type[Receiver] + b: type[Receiver] + + +def import_receiver(config: BaseReceiverConfig) -> type[Receiver]: + mod = cast( + ReceiverModule, import_module(f"mpd_now_playable.receivers.{config.kind}") + ) + return mod.receiver + + +def choose_loop_factory( + receivers: Iterable[type[Receiver]], +) -> LoopFactory[AbstractEventLoop]: + """Given the desired receivers, determine which asyncio event loop implementation will support all of them. Will raise an IncompatibleReceiverError if no such implementation exists.""" + + chosen_fac: LoopFactory[AbstractEventLoop] = DefaultLoopFactory() + chosen_rec: type[Receiver] | None = None + + for rec in receivers: + fac = rec.loop_factory() + if fac.is_replaceable: + continue + + if chosen_fac.is_replaceable: + chosen_fac = fac + elif type(fac) is not type(chosen_fac): + raise IncompatibleReceiverError(rec, not_none(chosen_rec)) + + chosen_rec = rec + + return chosen_fac diff --git a/src/mpd_now_playable/tools/types.py b/src/mpd_now_playable/tools/types.py index 1d0f5cc..023641d 100644 --- a/src/mpd_now_playable/tools/types.py +++ b/src/mpd_now_playable/tools/types.py @@ -31,6 +31,12 @@ AnyExceptList = ( U = TypeVar("U") +def not_none(value: U | None) -> U: + if value is None: + raise ValueError("None should not be possible here.") + return value + + def convert_if_exists(value: str | None, converter: Callable[[str], U]) -> U | None: if value is None: return None From 60116fd61619c1bcac3fdd190689913f4b7a5918 Mon Sep 17 00:00:00 2001 From: Danielle McLean Date: Wed, 10 Jul 2024 23:57:34 +1000 Subject: [PATCH 03/32] Make PyObjC a Darwin-only dependency --- pdm.lock | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pdm.lock b/pdm.lock index f703af1..cc13694 100644 --- a/pdm.lock +++ b/pdm.lock @@ -5,7 +5,7 @@ groups = ["default", "all", "dev"] strategy = ["cross_platform"] lock_version = "4.4.1" -content_hash = "sha256:5a1cfd2988fd81c4b8743daaf399bbeb9f1f9d78e161901f0bf76c771ec8052f" +content_hash = "sha256:c9845c93dab5638ddd0cbce910010bb8c03a6f42ae0af666e6b759d05540c8a6" [[package]] name = "aiocache" diff --git a/pyproject.toml b/pyproject.toml index 8b8cbfc..fa32f8f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ authors = [ dependencies = [ "aiocache>=0.12.2", "attrs>=23.1.0", - "pyobjc-framework-MediaPlayer>=10.0", + "pyobjc-framework-MediaPlayer>=10.0 ; sys_platform == 'darwin'", "python-mpd2>=3.1.0", "xdg-base-dirs>=6.0.1", "pytomlpp>=1.0.13", From 09fe3b3e6cc475315fc011f0dfd8155c67989dd1 Mon Sep 17 00:00:00 2001 From: Danielle McLean Date: Thu, 11 Jul 2024 12:12:56 +1000 Subject: [PATCH 04/32] Expand MusicBrainz support to be much more comprehensive --- src/mpd_now_playable/mpd/listener.py | 8 +- .../receivers/cocoa/persistent_id.py | 24 ++--- src/mpd_now_playable/song.py | 55 ----------- src/mpd_now_playable/song/__init__.py | 12 +++ src/mpd_now_playable/song/artwork.py | 25 +++++ src/mpd_now_playable/song/musicbrainz.py | 96 +++++++++++++++++++ src/mpd_now_playable/song/song.py | 32 +++++++ src/mpd_now_playable/tools/types.py | 8 ++ 8 files changed, 188 insertions(+), 72 deletions(-) delete mode 100644 src/mpd_now_playable/song.py create mode 100644 src/mpd_now_playable/song/__init__.py create mode 100644 src/mpd_now_playable/song/artwork.py create mode 100644 src/mpd_now_playable/song/musicbrainz.py create mode 100644 src/mpd_now_playable/song/song.py diff --git a/src/mpd_now_playable/mpd/listener.py b/src/mpd_now_playable/mpd/listener.py index f787223..50f256a 100644 --- a/src/mpd_now_playable/mpd/listener.py +++ b/src/mpd_now_playable/mpd/listener.py @@ -1,7 +1,6 @@ import asyncio from collections.abc import Iterable from pathlib import Path -from uuid import UUID from mpd.asyncio import MPDClient from mpd.base import CommandError @@ -10,7 +9,7 @@ from yarl import URL from ..config.model import MpdConfig from ..player import Player -from ..song import Artwork, PlaybackState, Song, to_artwork +from ..song import Artwork, PlaybackState, Song, to_artwork, to_brainz from ..song_receiver import Receiver from ..tools.types import convert_if_exists, un_maybe_plural from .artwork_cache import MpdArtworkCache @@ -25,10 +24,6 @@ def mpd_current_to_song( queue_index=int(current["pos"]), queue_length=int(status["playlistlength"]), file=Path(current["file"]), - musicbrainz_trackid=convert_if_exists(current.get("musicbrainz_trackid"), UUID), - musicbrainz_releasetrackid=convert_if_exists( - current.get("musicbrainz_releasetrackid"), UUID - ), title=current.get("title"), artist=un_maybe_plural(current.get("artist")), album=un_maybe_plural(current.get("album")), @@ -39,6 +34,7 @@ def mpd_current_to_song( disc=convert_if_exists(current.get("disc"), int), duration=float(status["duration"]), elapsed=float(status["elapsed"]), + musicbrainz=to_brainz(current), art=art, ) diff --git a/src/mpd_now_playable/receivers/cocoa/persistent_id.py b/src/mpd_now_playable/receivers/cocoa/persistent_id.py index 0ce50a1..12c992d 100644 --- a/src/mpd_now_playable/receivers/cocoa/persistent_id.py +++ b/src/mpd_now_playable/receivers/cocoa/persistent_id.py @@ -7,25 +7,27 @@ from ...song import Song # The maximum size for a BLAKE2b "person" value is sixteen bytes, so we need to be concise. HASH_PERSON_PREFIX: Final = b"mnp.mac." -TRACKID_HASH_PERSON: Final = HASH_PERSON_PREFIX + b"mb_tid" -RELEASETRACKID_HASH_PERSON: Final = HASH_PERSON_PREFIX + b"mb_rtid" +RECORDING_ID_HASH_PERSON: Final = HASH_PERSON_PREFIX + b"mb_rid" +TRACK_ID_HASH_PERSON: Final = HASH_PERSON_PREFIX + b"mb_tid" FILE_HASH_PERSON: Final = HASH_PERSON_PREFIX + b"f" PERSISTENT_ID_BITS: Final = 64 PERSISTENT_ID_BYTES: Final = PERSISTENT_ID_BITS // 8 -def digest_trackid(trackid: UUID) -> bytes: +def digest_recording_id(recording_id: UUID) -> bytes: return blake2b( - trackid.bytes, digest_size=PERSISTENT_ID_BYTES, person=TRACKID_HASH_PERSON + recording_id.bytes, + digest_size=PERSISTENT_ID_BYTES, + person=RECORDING_ID_HASH_PERSON, ).digest() -def digest_releasetrackid(trackid: UUID) -> bytes: +def digest_track_id(track_id: UUID) -> bytes: return blake2b( - trackid.bytes, + track_id.bytes, digest_size=PERSISTENT_ID_BYTES, - person=RELEASETRACKID_HASH_PERSON, + person=TRACK_ID_HASH_PERSON, ).digest() @@ -41,10 +43,10 @@ def digest_file_uri(file: Path) -> bytes: # that from the file URI. BLAKE2 can be customised to different digest sizes, # making it perfect for this problem. def song_to_persistent_id(song: Song) -> int: - if song.musicbrainz_trackid: - hashed_id = digest_trackid(song.musicbrainz_trackid) - elif song.musicbrainz_releasetrackid: - hashed_id = digest_releasetrackid(song.musicbrainz_releasetrackid) + if song.musicbrainz.recording: + hashed_id = digest_recording_id(song.musicbrainz.recording) + elif song.musicbrainz.track: + hashed_id = digest_track_id(song.musicbrainz.track) else: hashed_id = digest_file_uri(song.file) return int.from_bytes(hashed_id) diff --git a/src/mpd_now_playable/song.py b/src/mpd_now_playable/song.py deleted file mode 100644 index 487c9cb..0000000 --- a/src/mpd_now_playable/song.py +++ /dev/null @@ -1,55 +0,0 @@ -from dataclasses import dataclass, field -from enum import StrEnum -from pathlib import Path -from typing import Literal -from uuid import UUID - -from pydantic.type_adapter import TypeAdapter - - -class PlaybackState(StrEnum): - play = "play" - pause = "pause" - stop = "stop" - - -@dataclass(slots=True) -class HasArtwork: - data: bytes = field(repr=False) - - -@dataclass(slots=True) -class NoArtwork: - def __bool__(self) -> Literal[False]: - return False - - -Artwork = HasArtwork | NoArtwork -ArtworkSchema: TypeAdapter[Artwork] = TypeAdapter(HasArtwork | NoArtwork) - - -def to_artwork(art: bytes | None) -> Artwork: - if art is None: - return NoArtwork() - return HasArtwork(art) - - -@dataclass(slots=True) -class Song: - state: PlaybackState - queue_index: int - queue_length: int - file: Path - musicbrainz_trackid: UUID | None - musicbrainz_releasetrackid: UUID | None - title: str | None - artist: list[str] - composer: list[str] - album: list[str] - album_artist: list[str] - track: int | None - disc: int | None - genre: list[str] - duration: float - elapsed: float - art: Artwork diff --git a/src/mpd_now_playable/song/__init__.py b/src/mpd_now_playable/song/__init__.py new file mode 100644 index 0000000..80e5f4f --- /dev/null +++ b/src/mpd_now_playable/song/__init__.py @@ -0,0 +1,12 @@ +from .artwork import Artwork, ArtworkSchema, to_artwork +from .musicbrainz import to_brainz +from .song import PlaybackState, Song + +__all__ = ( + "Artwork", + "ArtworkSchema", + "to_artwork", + "to_brainz", + "PlaybackState", + "Song", +) diff --git a/src/mpd_now_playable/song/artwork.py b/src/mpd_now_playable/song/artwork.py new file mode 100644 index 0000000..4c4d941 --- /dev/null +++ b/src/mpd_now_playable/song/artwork.py @@ -0,0 +1,25 @@ +from dataclasses import dataclass, field +from typing import Literal + +from pydantic.type_adapter import TypeAdapter + + +@dataclass(slots=True) +class HasArtwork: + data: bytes = field(repr=False) + + +@dataclass(slots=True) +class NoArtwork: + def __bool__(self) -> Literal[False]: + return False + + +Artwork = HasArtwork | NoArtwork +ArtworkSchema: TypeAdapter[Artwork] = TypeAdapter(HasArtwork | NoArtwork) + + +def to_artwork(art: bytes | None) -> Artwork: + if art is None: + return NoArtwork() + return HasArtwork(art) diff --git a/src/mpd_now_playable/song/musicbrainz.py b/src/mpd_now_playable/song/musicbrainz.py new file mode 100644 index 0000000..7478d2c --- /dev/null +++ b/src/mpd_now_playable/song/musicbrainz.py @@ -0,0 +1,96 @@ +from dataclasses import dataclass +from functools import partial +from typing import TypedDict +from uuid import UUID + +from ..tools.types import option_fmap + +option_uuid = partial(option_fmap, UUID) + + +class MusicBrainzTags(TypedDict, total=False): + """ + The MusicBrainz tags mpd-now-playable expects and will load (all optional). + They're named slightly differently than the actual MusicBrainz IDs they + store - use to_brainz to map them across to their canonical form. + https://picard-docs.musicbrainz.org/downloads/MusicBrainz_Picard_Tag_Map.html + """ + + #: MusicBrainz Recording ID + musicbrainz_trackid: str + #: MusicBrainz Track ID + musicbrainz_releasetrackid: str + #: MusicBrainz Artist ID + musicbrainz_artistid: str + #: MusicBrainz Release ID + musicbrainz_albumid: str + #: MusicBrainz Release Artist ID + musicbrainz_albumartistid: str + #: MusicBrainz Release Group ID + musicbrainz_releasegroupid: str + #: MusicBrainz Work ID + musicbrainz_workid: str + + +@dataclass(slots=True) +class MusicBrainzIds: + #: A MusicBrainz recording represents audio from a specific performance. + #: For example, if the same song was released as a studio recording and as + #: a live performance, those two versions of the song are different + #: recordings. The song itself is considered a "work", of which two + #: recordings were made. However, recordings are not always associated with + #: a work in the MusicBrainz database, and Picard won't load work IDs by + #: default (you have to enable "use track relationships" in the options), + #: so recording IDs are a much more reliable way to identify a particular + #: song. + #: https://musicbrainz.org/doc/Recording + recording: UUID | None + + #: A MusicBrainz work represents the idea of a particular song or creation + #: (it doesn't have to be audio). Each work may have multiple recordings + #: (studio versus live, different performers, etc.), with the work ID + #: grouping them together. + #: https://musicbrainz.org/doc/Work + work: UUID | None + + #: A MusicBrainz track represents a specific instance of a recording + #: appearing as part of some release. For example, if the same song appears + #: on both two-CD and four-CD versions of a soundtrack, then it will be + #: considered the same "recording" in both cases, but different "tracks". + #: https://musicbrainz.org/doc/Track + track: UUID | None + + #: https://musicbrainz.org/doc/Artist + artist: UUID | None + + #: A MusicBrainz release roughly corresponds to an "album", and indeed is + #: stored in a tag called MUSICBRAINZ_ALBUMID. The more general name is + #: meant to encompass all the different ways music can be released. + #: https://musicbrainz.org/doc/Release + release: UUID | None + + #: Again, the release artist corresponds to an "album artist". These MBIDs + #: refer to the same artists in the MusicBrainz database that individual + #: recordings' artist MBIDs do. + release_artist: UUID | None + + #: A MusicBrainz release group roughly corresponds to "all the editions of + #: a particular album". For example, if the same album were released on CD, + #: vinyl records, and as a digital download, then all of those would be + #: different releases but share a release group. Note that MPD's support + #: for this tag is relatively new (July 2023) and doesn't seem especially + #: reliable, so it might be missing here even if your music has been tagged + #: with it. Not sure why. https://musicbrainz.org/doc/Release_Group + release_group: UUID | None + + +def to_brainz(tags: MusicBrainzTags) -> MusicBrainzIds: + return MusicBrainzIds( + recording=option_uuid(tags.get("musicbrainz_trackid")), + work=option_uuid(tags.get("musicbrainz_workid")), + track=option_uuid(tags.get("musicbrainz_releasetrackid")), + artist=option_uuid(tags.get("musicbrainz_artistid")), + release=option_uuid(tags.get("musicbrainz_albumid")), + release_artist=option_uuid(tags.get("musicbrainz_albumartistid")), + release_group=option_uuid(tags.get("musicbrainz_releasegroupid")), + ) diff --git a/src/mpd_now_playable/song/song.py b/src/mpd_now_playable/song/song.py new file mode 100644 index 0000000..e30c9ca --- /dev/null +++ b/src/mpd_now_playable/song/song.py @@ -0,0 +1,32 @@ +from dataclasses import dataclass +from enum import StrEnum +from pathlib import Path + +from .artwork import Artwork +from .musicbrainz import MusicBrainzIds + + +class PlaybackState(StrEnum): + play = "play" + pause = "pause" + stop = "stop" + + +@dataclass(slots=True) +class Song: + state: PlaybackState + queue_index: int + queue_length: int + file: Path + title: str | None + artist: list[str] + composer: list[str] + album: list[str] + album_artist: list[str] + track: int | None + disc: int | None + genre: list[str] + duration: float + elapsed: float + art: Artwork + musicbrainz: MusicBrainzIds diff --git a/src/mpd_now_playable/tools/types.py b/src/mpd_now_playable/tools/types.py index 023641d..da62c90 100644 --- a/src/mpd_now_playable/tools/types.py +++ b/src/mpd_now_playable/tools/types.py @@ -4,6 +4,7 @@ from typing import Any, TypeAlias, TypeVar __all__ = ( "AnyExceptList", "MaybePlural", + "option_fmap", "convert_if_exists", "un_maybe_plural", ) @@ -29,6 +30,7 @@ AnyExceptList = ( U = TypeVar("U") +V = TypeVar("V") def not_none(value: U | None) -> U: @@ -37,6 +39,12 @@ def not_none(value: U | None) -> U: return value +def option_fmap(f: Callable[[U], V], value: U | None) -> V | None: + if value is None: + return None + return f(value) + + def convert_if_exists(value: str | None, converter: Callable[[str], U]) -> U | None: if value is None: return None From 04859b8c8b6a2d6fd5bc27800d6be6fd7157917a Mon Sep 17 00:00:00 2001 From: Danielle McLean Date: Thu, 11 Jul 2024 12:15:34 +1000 Subject: [PATCH 05/32] Adjust receiver protocol to accommodate config --- src/mpd_now_playable/cli.py | 12 +++++------ src/mpd_now_playable/config/model.py | 2 +- src/mpd_now_playable/mpd/listener.py | 9 ++++---- .../receivers/cocoa/now_playing.py | 9 +++++--- src/mpd_now_playable/song_receiver.py | 21 ++++++++++++------- 5 files changed, 32 insertions(+), 21 deletions(-) diff --git a/src/mpd_now_playable/cli.py b/src/mpd_now_playable/cli.py index 0e201e7..34153c2 100644 --- a/src/mpd_now_playable/cli.py +++ b/src/mpd_now_playable/cli.py @@ -10,15 +10,15 @@ from .mpd.listener import MpdStateListener from .song_receiver import ( Receiver, choose_loop_factory, - import_receiver, + construct_receiver, ) async def listen( - config: Config, listener: MpdStateListener, receiver_types: Iterable[type[Receiver]] + config: Config, listener: MpdStateListener, receivers: Iterable[Receiver] ) -> None: await listener.start(config.mpd) - receivers = (rec(listener, config) for rec in receiver_types) + await asyncio.gather(*(rec.start(listener) for rec in receivers)) await listener.loop(receivers) @@ -28,11 +28,11 @@ def main() -> None: print(config) listener = MpdStateListener(config.cache) - receiver_types = tuple(import_receiver(rec) for rec in config.receivers) + receivers = tuple(construct_receiver(rec_config) for rec_config in config.receivers) + factory = choose_loop_factory(receivers) - factory = choose_loop_factory(receiver_types) asyncio.run( - listen(config, listener, receiver_types), + listen(config, listener, receivers), loop_factory=factory.make_loop, debug=True, ) diff --git a/src/mpd_now_playable/config/model.py b/src/mpd_now_playable/config/model.py index 7e2e6af..a5b67f2 100644 --- a/src/mpd_now_playable/config/model.py +++ b/src/mpd_now_playable/config/model.py @@ -21,7 +21,7 @@ class BaseReceiverConfig(Protocol): @dataclass(slots=True) class CocoaReceiverConfig(BaseReceiverConfig): - kind: Literal["cocoa"] = "cocoa" + kind: Literal["cocoa"] = field(default="cocoa", repr=False) ReceiverConfig = Annotated[ diff --git a/src/mpd_now_playable/mpd/listener.py b/src/mpd_now_playable/mpd/listener.py index 50f256a..bceaaf3 100644 --- a/src/mpd_now_playable/mpd/listener.py +++ b/src/mpd_now_playable/mpd/listener.py @@ -83,8 +83,7 @@ class MpdStateListener(Player): if status["state"] == "stop": print("Nothing playing") - for r in self.receivers: - r.update(None) + await self.update(None) return art = await self.art_cache.get_cached_artwork(current) @@ -93,8 +92,10 @@ class MpdStateListener(Player): song = mpd_current_to_song(status, current, to_artwork(art)) rprint(song) - for r in self.receivers: - r.update(song) + await self.update(song) + + async def update(self, song: Song | None) -> None: + await asyncio.gather(*(r.update(song) for r in self.receivers)) async def get_art(self, file: str) -> bytes | None: picture = await self.readpicture(file) diff --git a/src/mpd_now_playable/receivers/cocoa/now_playing.py b/src/mpd_now_playable/receivers/cocoa/now_playing.py index 158c5a5..60c2b49 100644 --- a/src/mpd_now_playable/receivers/cocoa/now_playing.py +++ b/src/mpd_now_playable/receivers/cocoa/now_playing.py @@ -37,7 +37,7 @@ from MediaPlayer import ( MPRemoteCommandHandlerStatusSuccess, ) -from ...config.model import Config +from ...config.model import CocoaReceiverConfig from ...player import Player from ...song import PlaybackState, Song from ...song_receiver import LoopFactory, Receiver @@ -146,7 +146,10 @@ class CocoaNowPlayingReceiver(Receiver): def loop_factory(cls) -> LoopFactory[CoreFoundationEventLoop]: return CocoaLoopFactory() - def __init__(self, player: Player, config: Config): + def __init__(self, config: CocoaReceiverConfig): + pass + + async def start(self, player: Player) -> None: self.cmd_center = MPRemoteCommandCenter.sharedCommandCenter() self.info_center = MPNowPlayingInfoCenter.defaultCenter() @@ -184,7 +187,7 @@ class CocoaNowPlayingReceiver(Receiver): # unpause with remote commands. self.info_center.setPlaybackState_(MPMusicPlaybackStatePlaying) - def update(self, song: Song | None) -> None: + async def update(self, song: Song | None) -> None: if song: self.info_center.setNowPlayingInfo_(song_to_media_item(song)) self.info_center.setPlaybackState_(playback_state_to_cocoa(song.state)) diff --git a/src/mpd_now_playable/song_receiver.py b/src/mpd_now_playable/song_receiver.py index d6f5b8e..cf18326 100644 --- a/src/mpd_now_playable/song_receiver.py +++ b/src/mpd_now_playable/song_receiver.py @@ -3,7 +3,7 @@ from dataclasses import dataclass from importlib import import_module from typing import Generic, Iterable, Literal, Protocol, TypeVar, cast -from .config.model import BaseReceiverConfig, Config +from .config.model import BaseReceiverConfig from .player import Player from .song import Song from .tools.types import not_none @@ -20,10 +20,12 @@ class LoopFactory(Generic[T], Protocol): class Receiver(Protocol): - def __init__(self, player: Player, config: Config) -> None: ... + def __init__(self, config: BaseReceiverConfig): ... @classmethod def loop_factory(cls) -> LoopFactory[AbstractEventLoop]: ... - def update(self, song: Song | None) -> None: ... + + async def start(self, player: Player) -> None: ... + async def update(self, song: Song | None) -> None: ... class ReceiverModule(Protocol): @@ -42,8 +44,8 @@ class DefaultLoopFactory(LoopFactory[AbstractEventLoop]): @dataclass class IncompatibleReceiverError(Exception): - a: type[Receiver] - b: type[Receiver] + a: Receiver + b: Receiver def import_receiver(config: BaseReceiverConfig) -> type[Receiver]: @@ -53,13 +55,18 @@ def import_receiver(config: BaseReceiverConfig) -> type[Receiver]: return mod.receiver +def construct_receiver(config: BaseReceiverConfig) -> Receiver: + cls = import_receiver(config) + return cls(config) + + def choose_loop_factory( - receivers: Iterable[type[Receiver]], + receivers: Iterable[Receiver], ) -> LoopFactory[AbstractEventLoop]: """Given the desired receivers, determine which asyncio event loop implementation will support all of them. Will raise an IncompatibleReceiverError if no such implementation exists.""" chosen_fac: LoopFactory[AbstractEventLoop] = DefaultLoopFactory() - chosen_rec: type[Receiver] | None = None + chosen_rec: Receiver | None = None for rec in receivers: fac = rec.loop_factory() From 75206a97f11f238f5f0bea68d4fadc04270f4582 Mon Sep 17 00:00:00 2001 From: Danielle McLean Date: Thu, 11 Jul 2024 12:17:09 +1000 Subject: [PATCH 06/32] Add extra for websockets support --- pdm.lock | 187 +++++++++++++++++++++++++++++++------------------ pyproject.toml | 14 +++- 2 files changed, 129 insertions(+), 72 deletions(-) diff --git a/pdm.lock b/pdm.lock index cc13694..fa4b4e9 100644 --- a/pdm.lock +++ b/pdm.lock @@ -5,7 +5,7 @@ groups = ["default", "all", "dev"] strategy = ["cross_platform"] lock_version = "4.4.1" -content_hash = "sha256:c9845c93dab5638ddd0cbce910010bb8c03a6f42ae0af666e6b759d05540c8a6" +content_hash = "sha256:ddedd388cce9ed181dc2f3786240fc14e19ceb4539f0a360eeb2efb28da63ebe" [[package]] name = "aiocache" @@ -167,7 +167,7 @@ files = [ [[package]] name = "mypy" -version = "1.10.0" +version = "1.10.1" requires_python = ">=3.8" summary = "Optional static typing for Python" dependencies = [ @@ -175,13 +175,13 @@ dependencies = [ "typing-extensions>=4.1.0", ] files = [ - {file = "mypy-1.10.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:a781f6ad4bab20eef8b65174a57e5203f4be627b46291f4589879bf4e257b97b"}, - {file = "mypy-1.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b808e12113505b97d9023b0b5e0c0705a90571c6feefc6f215c1df9381256e30"}, - {file = "mypy-1.10.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f55583b12156c399dce2df7d16f8a5095291354f1e839c252ec6c0611e86e2e"}, - {file = "mypy-1.10.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4cf18f9d0efa1b16478c4c129eabec36148032575391095f73cae2e722fcf9d5"}, - {file = "mypy-1.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:bc6ac273b23c6b82da3bb25f4136c4fd42665f17f2cd850771cb600bdd2ebeda"}, - {file = "mypy-1.10.0-py3-none-any.whl", hash = "sha256:f8c083976eb530019175aabadb60921e73b4f45736760826aa1689dda8208aee"}, - {file = "mypy-1.10.0.tar.gz", hash = "sha256:3d087fcbec056c4ee34974da493a826ce316947485cef3901f511848e687c131"}, + {file = "mypy-1.10.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:d8681909f7b44d0b7b86e653ca152d6dff0eb5eb41694e163c6092124f8246d7"}, + {file = "mypy-1.10.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:378c03f53f10bbdd55ca94e46ec3ba255279706a6aacaecac52ad248f98205d3"}, + {file = "mypy-1.10.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bacf8f3a3d7d849f40ca6caea5c055122efe70e81480c8328ad29c55c69e93e"}, + {file = "mypy-1.10.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:701b5f71413f1e9855566a34d6e9d12624e9e0a8818a5704d74d6b0402e66c04"}, + {file = "mypy-1.10.1-cp312-cp312-win_amd64.whl", hash = "sha256:3c4c2992f6ea46ff7fce0072642cfb62af7a2484efe69017ed8b095f7b39ef31"}, + {file = "mypy-1.10.1-py3-none-any.whl", hash = "sha256:71d8ac0b906354ebda8ef1673e5fde785936ac1f29ff6987c7483cfbd5a4235a"}, + {file = "mypy-1.10.1.tar.gz", hash = "sha256:1f8f492d7db9e3593ef42d4f115f04e556130f2819ad33ab84551403e97dd4c0"}, ] [[package]] @@ -210,58 +210,70 @@ files = [ [[package]] name = "pydantic" -version = "2.7.4" +version = "2.8.2" requires_python = ">=3.8" summary = "Data validation using Python type hints" dependencies = [ "annotated-types>=0.4.0", - "pydantic-core==2.18.4", - "typing-extensions>=4.6.1", + "pydantic-core==2.20.1", + "typing-extensions>=4.12.2; python_version >= \"3.13\"", + "typing-extensions>=4.6.1; python_version < \"3.13\"", ] files = [ - {file = "pydantic-2.7.4-py3-none-any.whl", hash = "sha256:ee8538d41ccb9c0a9ad3e0e5f07bf15ed8015b481ced539a1759d8cc89ae90d0"}, - {file = "pydantic-2.7.4.tar.gz", hash = "sha256:0c84efd9548d545f63ac0060c1e4d39bb9b14db8b3c0652338aecc07b5adec52"}, + {file = "pydantic-2.8.2-py3-none-any.whl", hash = "sha256:73ee9fddd406dc318b885c7a2eab8a6472b68b8fb5ba8150949fc3db939f23c8"}, + {file = "pydantic-2.8.2.tar.gz", hash = "sha256:6f62c13d067b0755ad1c21a34bdd06c0c12625a22b0fc09c6b149816604f7c2a"}, ] [[package]] name = "pydantic-core" -version = "2.18.4" +version = "2.20.1" requires_python = ">=3.8" summary = "Core functionality for Pydantic validation and serialization" dependencies = [ "typing-extensions!=4.7.0,>=4.6.0", ] files = [ - {file = "pydantic_core-2.18.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:6f5c4d41b2771c730ea1c34e458e781b18cc668d194958e0112455fff4e402b2"}, - {file = "pydantic_core-2.18.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2fdf2156aa3d017fddf8aea5adfba9f777db1d6022d392b682d2a8329e087cef"}, - {file = "pydantic_core-2.18.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4748321b5078216070b151d5271ef3e7cc905ab170bbfd27d5c83ee3ec436695"}, - {file = "pydantic_core-2.18.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847a35c4d58721c5dc3dba599878ebbdfd96784f3fb8bb2c356e123bdcd73f34"}, - {file = "pydantic_core-2.18.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3c40d4eaad41f78e3bbda31b89edc46a3f3dc6e171bf0ecf097ff7a0ffff7cb1"}, - {file = "pydantic_core-2.18.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:21a5e440dbe315ab9825fcd459b8814bb92b27c974cbc23c3e8baa2b76890077"}, - {file = "pydantic_core-2.18.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:01dd777215e2aa86dfd664daed5957704b769e726626393438f9c87690ce78c3"}, - {file = "pydantic_core-2.18.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4b06beb3b3f1479d32befd1f3079cc47b34fa2da62457cdf6c963393340b56e9"}, - {file = "pydantic_core-2.18.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:564d7922e4b13a16b98772441879fcdcbe82ff50daa622d681dd682175ea918c"}, - {file = "pydantic_core-2.18.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:0eb2a4f660fcd8e2b1c90ad566db2b98d7f3f4717c64fe0a83e0adb39766d5b8"}, - {file = "pydantic_core-2.18.4-cp312-none-win32.whl", hash = "sha256:8b8bab4c97248095ae0c4455b5a1cd1cdd96e4e4769306ab19dda135ea4cdb07"}, - {file = "pydantic_core-2.18.4-cp312-none-win_amd64.whl", hash = "sha256:14601cdb733d741b8958224030e2bfe21a4a881fb3dd6fbb21f071cabd48fa0a"}, - {file = "pydantic_core-2.18.4-cp312-none-win_arm64.whl", hash = "sha256:c1322d7dd74713dcc157a2b7898a564ab091ca6c58302d5c7b4c07296e3fd00f"}, - {file = "pydantic_core-2.18.4-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:574d92eac874f7f4db0ca653514d823a0d22e2354359d0759e3f6a406db5d55d"}, - {file = "pydantic_core-2.18.4-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1f4d26ceb5eb9eed4af91bebeae4b06c3fb28966ca3a8fb765208cf6b51102ab"}, - {file = "pydantic_core-2.18.4-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77450e6d20016ec41f43ca4a6c63e9fdde03f0ae3fe90e7c27bdbeaece8b1ed4"}, - {file = "pydantic_core-2.18.4-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d323a01da91851a4f17bf592faf46149c9169d68430b3146dcba2bb5e5719abc"}, - {file = "pydantic_core-2.18.4-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:43d447dd2ae072a0065389092a231283f62d960030ecd27565672bd40746c507"}, - {file = "pydantic_core-2.18.4-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:578e24f761f3b425834f297b9935e1ce2e30f51400964ce4801002435a1b41ef"}, - {file = "pydantic_core-2.18.4-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:81b5efb2f126454586d0f40c4d834010979cb80785173d1586df845a632e4e6d"}, - {file = "pydantic_core-2.18.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ab86ce7c8f9bea87b9d12c7f0af71102acbf5ecbc66c17796cff45dae54ef9a5"}, - {file = "pydantic_core-2.18.4-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:90afc12421df2b1b4dcc975f814e21bc1754640d502a2fbcc6d41e77af5ec312"}, - {file = "pydantic_core-2.18.4-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:51991a89639a912c17bef4b45c87bd83593aee0437d8102556af4885811d59f5"}, - {file = "pydantic_core-2.18.4-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:293afe532740370aba8c060882f7d26cfd00c94cae32fd2e212a3a6e3b7bc15e"}, - {file = "pydantic_core-2.18.4-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b48ece5bde2e768197a2d0f6e925f9d7e3e826f0ad2271120f8144a9db18d5c8"}, - {file = "pydantic_core-2.18.4-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:eae237477a873ab46e8dd748e515c72c0c804fb380fbe6c85533c7de51f23a8f"}, - {file = "pydantic_core-2.18.4-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:834b5230b5dfc0c1ec37b2fda433b271cbbc0e507560b5d1588e2cc1148cf1ce"}, - {file = "pydantic_core-2.18.4-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:e858ac0a25074ba4bce653f9b5d0a85b7456eaddadc0ce82d3878c22489fa4ee"}, - {file = "pydantic_core-2.18.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2fd41f6eff4c20778d717af1cc50eca52f5afe7805ee530a4fbd0bae284f16e9"}, - {file = "pydantic_core-2.18.4.tar.gz", hash = "sha256:ec3beeada09ff865c344ff3bc2f427f5e6c26401cc6113d77e372c3fdac73864"}, + {file = "pydantic_core-2.20.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:595ba5be69b35777474fa07f80fc260ea71255656191adb22a8c53aba4479231"}, + {file = "pydantic_core-2.20.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a4f55095ad087474999ee28d3398bae183a66be4823f753cd7d67dd0153427c9"}, + {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f9aa05d09ecf4c75157197f27cdc9cfaeb7c5f15021c6373932bf3e124af029f"}, + {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e97fdf088d4b31ff4ba35db26d9cc472ac7ef4a2ff2badeabf8d727b3377fc52"}, + {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bc633a9fe1eb87e250b5c57d389cf28998e4292336926b0b6cdaee353f89a237"}, + {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d573faf8eb7e6b1cbbcb4f5b247c60ca8be39fe2c674495df0eb4318303137fe"}, + {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26dc97754b57d2fd00ac2b24dfa341abffc380b823211994c4efac7f13b9e90e"}, + {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:33499e85e739a4b60c9dac710c20a08dc73cb3240c9a0e22325e671b27b70d24"}, + {file = "pydantic_core-2.20.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:bebb4d6715c814597f85297c332297c6ce81e29436125ca59d1159b07f423eb1"}, + {file = "pydantic_core-2.20.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:516d9227919612425c8ef1c9b869bbbee249bc91912c8aaffb66116c0b447ebd"}, + {file = "pydantic_core-2.20.1-cp312-none-win32.whl", hash = "sha256:469f29f9093c9d834432034d33f5fe45699e664f12a13bf38c04967ce233d688"}, + {file = "pydantic_core-2.20.1-cp312-none-win_amd64.whl", hash = "sha256:035ede2e16da7281041f0e626459bcae33ed998cca6a0a007a5ebb73414ac72d"}, + {file = "pydantic_core-2.20.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:0827505a5c87e8aa285dc31e9ec7f4a17c81a813d45f70b1d9164e03a813a686"}, + {file = "pydantic_core-2.20.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19c0fa39fa154e7e0b7f82f88ef85faa2a4c23cc65aae2f5aea625e3c13c735a"}, + {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa223cd1e36b642092c326d694d8bf59b71ddddc94cdb752bbbb1c5c91d833b"}, + {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c336a6d235522a62fef872c6295a42ecb0c4e1d0f1a3e500fe949415761b8a19"}, + {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7eb6a0587eded33aeefea9f916899d42b1799b7b14b8f8ff2753c0ac1741edac"}, + {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:70c8daf4faca8da5a6d655f9af86faf6ec2e1768f4b8b9d0226c02f3d6209703"}, + {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e9fa4c9bf273ca41f940bceb86922a7667cd5bf90e95dbb157cbb8441008482c"}, + {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:11b71d67b4725e7e2a9f6e9c0ac1239bbc0c48cce3dc59f98635efc57d6dac83"}, + {file = "pydantic_core-2.20.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:270755f15174fb983890c49881e93f8f1b80f0b5e3a3cc1394a255706cabd203"}, + {file = "pydantic_core-2.20.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:c81131869240e3e568916ef4c307f8b99583efaa60a8112ef27a366eefba8ef0"}, + {file = "pydantic_core-2.20.1-cp313-none-win32.whl", hash = "sha256:b91ced227c41aa29c672814f50dbb05ec93536abf8f43cd14ec9521ea09afe4e"}, + {file = "pydantic_core-2.20.1-cp313-none-win_amd64.whl", hash = "sha256:65db0f2eefcaad1a3950f498aabb4875c8890438bc80b19362cf633b87a8ab20"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:a45f84b09ac9c3d35dfcf6a27fd0634d30d183205230a0ebe8373a0e8cfa0906"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d02a72df14dfdbaf228424573a07af10637bd490f0901cee872c4f434a735b94"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d2b27e6af28f07e2f195552b37d7d66b150adbaa39a6d327766ffd695799780f"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:084659fac3c83fd674596612aeff6041a18402f1e1bc19ca39e417d554468482"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:242b8feb3c493ab78be289c034a1f659e8826e2233786e36f2893a950a719bb6"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:38cf1c40a921d05c5edc61a785c0ddb4bed67827069f535d794ce6bcded919fc"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:e0bbdd76ce9aa5d4209d65f2b27fc6e5ef1312ae6c5333c26db3f5ade53a1e99"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:254ec27fdb5b1ee60684f91683be95e5133c994cc54e86a0b0963afa25c8f8a6"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:407653af5617f0757261ae249d3fba09504d7a71ab36ac057c938572d1bc9331"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:c693e916709c2465b02ca0ad7b387c4f8423d1db7b4649c551f27a529181c5ad"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5b5ff4911aea936a47d9376fd3ab17e970cc543d1b68921886e7f64bd28308d1"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:177f55a886d74f1808763976ac4efd29b7ed15c69f4d838bbd74d9d09cf6fa86"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:964faa8a861d2664f0c7ab0c181af0bea66098b1919439815ca8803ef136fc4e"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:4dd484681c15e6b9a977c785a345d3e378d72678fd5f1f3c0509608da24f2ac0"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f6d6cff3538391e8486a431569b77921adfcdef14eb18fbf19b7c0a5294d4e6a"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:a6d511cc297ff0883bc3708b465ff82d7560193169a8b93260f74ecb0a5e08a7"}, + {file = "pydantic_core-2.20.1.tar.gz", hash = "sha256:26ca695eeee5f9f1aeeb211ffc12f10bcb6f71e2989988fda61dabd65db878d4"}, ] [[package]] @@ -410,12 +422,12 @@ files = [ [[package]] name = "redis" -version = "5.0.4" +version = "5.0.7" requires_python = ">=3.7" summary = "Python client for Redis database and key-value store" files = [ - {file = "redis-5.0.4-py3-none-any.whl", hash = "sha256:7adc2835c7a9b5033b7ad8f8918d09b7344188228809c98df07af226d39dec91"}, - {file = "redis-5.0.4.tar.gz", hash = "sha256:ec31f2ed9675cc54c21ba854cfe0462e6faf1d83c8ce5944709db8a4700b9c61"}, + {file = "redis-5.0.7-py3-none-any.whl", hash = "sha256:0e479e24da960c690be5d9b96d21f7b918a98c0cf49af3b6fafaa0753f93a0db"}, + {file = "redis-5.0.7.tar.gz", hash = "sha256:8f611490b93c8109b50adc317b31bfd84fff31def3475b92e7e80bf39f48175b"}, ] [[package]] @@ -434,37 +446,74 @@ files = [ [[package]] name = "ruff" -version = "0.4.10" +version = "0.5.1" requires_python = ">=3.7" summary = "An extremely fast Python linter and code formatter, written in Rust." files = [ - {file = "ruff-0.4.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5c2c4d0859305ac5a16310eec40e4e9a9dec5dcdfbe92697acd99624e8638dac"}, - {file = "ruff-0.4.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a79489607d1495685cdd911a323a35871abfb7a95d4f98fc6f85e799227ac46e"}, - {file = "ruff-0.4.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b1dd1681dfa90a41b8376a61af05cc4dc5ff32c8f14f5fe20dba9ff5deb80cd6"}, - {file = "ruff-0.4.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c75c53bb79d71310dc79fb69eb4902fba804a81f374bc86a9b117a8d077a1784"}, - {file = "ruff-0.4.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:18238c80ee3d9100d3535d8eb15a59c4a0753b45cc55f8bf38f38d6a597b9739"}, - {file = "ruff-0.4.10-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:d8f71885bce242da344989cae08e263de29752f094233f932d4f5cfb4ef36a81"}, - {file = "ruff-0.4.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:330421543bd3222cdfec481e8ff3460e8702ed1e58b494cf9d9e4bf90db52b9d"}, - {file = "ruff-0.4.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e9b6fb3a37b772628415b00c4fc892f97954275394ed611056a4b8a2631365e"}, - {file = "ruff-0.4.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f54c481b39a762d48f64d97351048e842861c6662d63ec599f67d515cb417f6"}, - {file = "ruff-0.4.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:67fe086b433b965c22de0b4259ddfe6fa541c95bf418499bedb9ad5fb8d1c631"}, - {file = "ruff-0.4.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:acfaaab59543382085f9eb51f8e87bac26bf96b164839955f244d07125a982ef"}, - {file = "ruff-0.4.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:3cea07079962b2941244191569cf3a05541477286f5cafea638cd3aa94b56815"}, - {file = "ruff-0.4.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:338a64ef0748f8c3a80d7f05785930f7965d71ca260904a9321d13be24b79695"}, - {file = "ruff-0.4.10-py3-none-win32.whl", hash = "sha256:ffe3cd2f89cb54561c62e5fa20e8f182c0a444934bf430515a4b422f1ab7b7ca"}, - {file = "ruff-0.4.10-py3-none-win_amd64.whl", hash = "sha256:67f67cef43c55ffc8cc59e8e0b97e9e60b4837c8f21e8ab5ffd5d66e196e25f7"}, - {file = "ruff-0.4.10-py3-none-win_arm64.whl", hash = "sha256:dd1fcee327c20addac7916ca4e2653fbbf2e8388d8a6477ce5b4e986b68ae6c0"}, - {file = "ruff-0.4.10.tar.gz", hash = "sha256:3aa4f2bc388a30d346c56524f7cacca85945ba124945fe489952aadb6b5cd804"}, + {file = "ruff-0.5.1-py3-none-linux_armv6l.whl", hash = "sha256:6ecf968fcf94d942d42b700af18ede94b07521bd188aaf2cd7bc898dd8cb63b6"}, + {file = "ruff-0.5.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:204fb0a472f00f2e6280a7c8c7c066e11e20e23a37557d63045bf27a616ba61c"}, + {file = "ruff-0.5.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d235968460e8758d1e1297e1de59a38d94102f60cafb4d5382033c324404ee9d"}, + {file = "ruff-0.5.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38beace10b8d5f9b6bdc91619310af6d63dd2019f3fb2d17a2da26360d7962fa"}, + {file = "ruff-0.5.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5e478d2f09cf06add143cf8c4540ef77b6599191e0c50ed976582f06e588c994"}, + {file = "ruff-0.5.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f0368d765eec8247b8550251c49ebb20554cc4e812f383ff9f5bf0d5d94190b0"}, + {file = "ruff-0.5.1-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:3a9a9a1b582e37669b0138b7c1d9d60b9edac880b80eb2baba6d0e566bdeca4d"}, + {file = "ruff-0.5.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bdd9f723e16003623423affabcc0a807a66552ee6a29f90eddad87a40c750b78"}, + {file = "ruff-0.5.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:be9fd62c1e99539da05fcdc1e90d20f74aec1b7a1613463ed77870057cd6bd96"}, + {file = "ruff-0.5.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e216fc75a80ea1fbd96af94a6233d90190d5b65cc3d5dfacf2bd48c3e067d3e1"}, + {file = "ruff-0.5.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c4c2112e9883a40967827d5c24803525145e7dab315497fae149764979ac7929"}, + {file = "ruff-0.5.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dfaf11c8a116394da3b65cd4b36de30d8552fa45b8119b9ef5ca6638ab964fa3"}, + {file = "ruff-0.5.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d7ceb9b2fe700ee09a0c6b192c5ef03c56eb82a0514218d8ff700f6ade004108"}, + {file = "ruff-0.5.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:bac6288e82f6296f82ed5285f597713acb2a6ae26618ffc6b429c597b392535c"}, + {file = "ruff-0.5.1-py3-none-win32.whl", hash = "sha256:5c441d9c24ec09e1cb190a04535c5379b36b73c4bc20aa180c54812c27d1cca4"}, + {file = "ruff-0.5.1-py3-none-win_amd64.whl", hash = "sha256:b1789bf2cd3d1b5a7d38397cac1398ddf3ad7f73f4de01b1e913e2abc7dfc51d"}, + {file = "ruff-0.5.1-py3-none-win_arm64.whl", hash = "sha256:2875b7596a740cbbd492f32d24be73e545a4ce0a3daf51e4f4e609962bfd3cd2"}, + {file = "ruff-0.5.1.tar.gz", hash = "sha256:3164488aebd89b1745b47fd00604fb4358d774465f20d1fcd907f9c0fc1b0655"}, ] [[package]] name = "typing-extensions" -version = "4.11.0" +version = "4.12.2" requires_python = ">=3.8" summary = "Backported and Experimental Type Hints for Python 3.8+" files = [ - {file = "typing_extensions-4.11.0-py3-none-any.whl", hash = "sha256:c1f94d72897edaf4ce775bb7558d5b79d8126906a14ea5ed1635921406c0387a"}, - {file = "typing_extensions-4.11.0.tar.gz", hash = "sha256:83f085bd5ca59c80295fc2a82ab5dac679cbe02b9f33f7d83af68e241bea51b0"}, + {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, + {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, +] + +[[package]] +name = "websockets" +version = "12.0" +requires_python = ">=3.8" +summary = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" +files = [ + {file = "websockets-12.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0e6e2711d5a8e6e482cacb927a49a3d432345dfe7dea8ace7b5790df5932e4df"}, + {file = "websockets-12.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:dbcf72a37f0b3316e993e13ecf32f10c0e1259c28ffd0a85cee26e8549595fbc"}, + {file = "websockets-12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:12743ab88ab2af1d17dd4acb4645677cb7063ef4db93abffbf164218a5d54c6b"}, + {file = "websockets-12.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b645f491f3c48d3f8a00d1fce07445fab7347fec54a3e65f0725d730d5b99cb"}, + {file = "websockets-12.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9893d1aa45a7f8b3bc4510f6ccf8db8c3b62120917af15e3de247f0780294b92"}, + {file = "websockets-12.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f38a7b376117ef7aff996e737583172bdf535932c9ca021746573bce40165ed"}, + {file = "websockets-12.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:f764ba54e33daf20e167915edc443b6f88956f37fb606449b4a5b10ba42235a5"}, + {file = "websockets-12.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:1e4b3f8ea6a9cfa8be8484c9221ec0257508e3a1ec43c36acdefb2a9c3b00aa2"}, + {file = "websockets-12.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:9fdf06fd06c32205a07e47328ab49c40fc1407cdec801d698a7c41167ea45113"}, + {file = "websockets-12.0-cp312-cp312-win32.whl", hash = "sha256:baa386875b70cbd81798fa9f71be689c1bf484f65fd6fb08d051a0ee4e79924d"}, + {file = "websockets-12.0-cp312-cp312-win_amd64.whl", hash = "sha256:ae0a5da8f35a5be197f328d4727dbcfafa53d1824fac3d96cdd3a642fe09394f"}, + {file = "websockets-12.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:248d8e2446e13c1d4326e0a6a4e9629cb13a11195051a73acf414812700badbd"}, + {file = "websockets-12.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f44069528d45a933997a6fef143030d8ca8042f0dfaad753e2906398290e2870"}, + {file = "websockets-12.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c4e37d36f0d19f0a4413d3e18c0d03d0c268ada2061868c1e6f5ab1a6d575077"}, + {file = "websockets-12.0-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d829f975fc2e527a3ef2f9c8f25e553eb7bc779c6665e8e1d52aa22800bb38b"}, + {file = "websockets-12.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:2c71bd45a777433dd9113847af751aae36e448bc6b8c361a566cb043eda6ec30"}, + {file = "websockets-12.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:0bee75f400895aef54157b36ed6d3b308fcab62e5260703add87f44cee9c82a6"}, + {file = "websockets-12.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:423fc1ed29f7512fceb727e2d2aecb952c46aa34895e9ed96071821309951123"}, + {file = "websockets-12.0-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:27a5e9964ef509016759f2ef3f2c1e13f403725a5e6a1775555994966a66e931"}, + {file = "websockets-12.0-pp38-pypy38_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c3181df4583c4d3994d31fb235dc681d2aaad744fbdbf94c4802485ececdecf2"}, + {file = "websockets-12.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:b067cb952ce8bf40115f6c19f478dc71c5e719b7fbaa511359795dfd9d1a6468"}, + {file = "websockets-12.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:00700340c6c7ab788f176d118775202aadea7602c5cc6be6ae127761c16d6b0b"}, + {file = "websockets-12.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e469d01137942849cff40517c97a30a93ae79917752b34029f0ec72df6b46399"}, + {file = "websockets-12.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffefa1374cd508d633646d51a8e9277763a9b78ae71324183693959cf94635a7"}, + {file = "websockets-12.0-pp39-pypy39_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba0cab91b3956dfa9f512147860783a1829a8d905ee218a9837c18f683239611"}, + {file = "websockets-12.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2cb388a5bfb56df4d9a406783b7f9dbefb888c09b71629351cc6b036e9259370"}, + {file = "websockets-12.0-py3-none-any.whl", hash = "sha256:dc284bbc8d7c78a6c69e0c7325ab46ee5e40bb4d50e494d8131a07ef47500e9e"}, + {file = "websockets-12.0.tar.gz", hash = "sha256:81df9cbcbb6c260de1e007e58c011bfebe2dafc8435107b0537f393dd38c8b1b"}, ] [[package]] diff --git a/pyproject.toml b/pyproject.toml index fa32f8f..b5cb70e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,7 @@ dependencies = [ "boltons>=24.0.0", "pydantic>=2.7.4", "rich>=13.7.1", + "ormsgpack>=1.5.0", ] readme = "README.md" @@ -30,9 +31,16 @@ classifiers = [ ] [project.optional-dependencies] -redis = ["aiocache[redis]", "ormsgpack>=1.5.0"] -memcached = ["aiocache[memcached]", "ormsgpack>=1.5.0"] -all = ["mpd-now-playable[redis,memcached]"] +redis = [ + "aiocache[redis]", +] +memcached = [ + "aiocache[memcached]", +] +websockets = [ + "websockets>=12.0", +] +all = ["mpd-now-playable[redis,memcached,websockets]"] [project.urls] Homepage = "https://git.00dani.me/00dani/mpd-now-playable" From 582a4628b7d1792df122a57a0c2e8c132f02f1cb Mon Sep 17 00:00:00 2001 From: Danielle McLean Date: Sat, 13 Jul 2024 18:34:53 +1000 Subject: [PATCH 07/32] Introduce new WebSockets receiver impl MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When enabled, this new receiver will spin up a local WebSockets server and will send the currently playing song information to any clients that connect. It's designed with Übersicht in mind, since WebSockets is the easiest way to efficiently push events into an Übersicht widget, but I'm sure it'd work for a variety of other purposes too. Currently the socket is only used in one direction, pushing the current song info from server to client, but I'll probably extend it to support sending MPD commands from WebSockets clients as well. --- schemata/config-v1.json | 46 +++- schemata/song-v1.json | 205 ++++++++++++++++++ src/mpd_now_playable/config/model.py | 10 +- .../receivers/websockets/__init__.py | 2 + .../receivers/websockets/receiver.py | 52 +++++ src/mpd_now_playable/song/musicbrainz.py | 19 +- src/mpd_now_playable/song/song.py | 49 +++++ src/mpd_now_playable/tools/schema/generate.py | 6 + 8 files changed, 379 insertions(+), 10 deletions(-) create mode 100644 schemata/song-v1.json create mode 100644 src/mpd_now_playable/receivers/websockets/__init__.py create mode 100644 src/mpd_now_playable/receivers/websockets/receiver.py diff --git a/schemata/config-v1.json b/schemata/config-v1.json index 14600a1..8c0c9e6 100644 --- a/schemata/config-v1.json +++ b/schemata/config-v1.json @@ -42,6 +42,46 @@ }, "title": "MpdConfig", "type": "object" + }, + "WebsocketsReceiverConfig": { + "properties": { + "host": { + "anyOf": [ + { + "format": "hostname", + "type": "string" + }, + { + "items": { + "format": "hostname", + "type": "string" + }, + "type": "array" + } + ], + "title": "Host" + }, + "kind": { + "const": "websockets", + "default": "websockets", + "enum": [ + "websockets" + ], + "title": "Kind", + "type": "string" + }, + "port": { + "maximum": 65535, + "minimum": 1, + "title": "Port", + "type": "integer" + } + }, + "required": [ + "port" + ], + "title": "WebsocketsReceiverConfig", + "type": "object" } }, "$id": "https://cdn.00dani.me/m/schemata/mpd-now-playable/config-v1.json", @@ -65,13 +105,17 @@ "items": { "discriminator": { "mapping": { - "cocoa": "#/$defs/CocoaReceiverConfig" + "cocoa": "#/$defs/CocoaReceiverConfig", + "websockets": "#/$defs/WebsocketsReceiverConfig" }, "propertyName": "kind" }, "oneOf": [ { "$ref": "#/$defs/CocoaReceiverConfig" + }, + { + "$ref": "#/$defs/WebsocketsReceiverConfig" } ] }, diff --git a/schemata/song-v1.json b/schemata/song-v1.json new file mode 100644 index 0000000..6ee74de --- /dev/null +++ b/schemata/song-v1.json @@ -0,0 +1,205 @@ +{ + "$defs": { + "HasArtwork": { + "properties": { + "data": { + "format": "binary", + "title": "Data", + "type": "string" + } + }, + "required": [ + "data" + ], + "title": "HasArtwork", + "type": "object" + }, + "MusicBrainzIds": { + "properties": { + "artist": { + "description": "https://musicbrainz.org/doc/Artist", + "format": "uuid", + "title": "Artist", + "type": "string" + }, + "recording": { + "description": "A MusicBrainz recording represents audio from a specific performance. For example, if the same song was released as a studio recording and as a live performance, those two versions of the song are different recordings. The song itself is considered a \"work\", of which two recordings were made. However, recordings are not always associated with a work in the MusicBrainz database, and Picard won't load work IDs by default (you have to enable \"use track relationships\" in the options), so recording IDs are a much more reliable way to identify a particular song. https://musicbrainz.org/doc/Recording", + "format": "uuid", + "title": "Recording", + "type": "string" + }, + "release": { + "description": "A MusicBrainz release roughly corresponds to an \"album\", and indeed is stored in a tag called MUSICBRAINZ_ALBUMID. The more general name is meant to encompass all the different ways music can be released. https://musicbrainz.org/doc/Release", + "format": "uuid", + "title": "Release", + "type": "string" + }, + "release_artist": { + "description": "Again, the release artist corresponds to an \"album artist\". These MBIDs refer to the same artists in the MusicBrainz database that individual recordings' artist MBIDs do.", + "format": "uuid", + "title": "Release Artist", + "type": "string" + }, + "release_group": { + "description": "A MusicBrainz release group roughly corresponds to \"all the editions of a particular album\". For example, if the same album were released on CD, vinyl records, and as a digital download, then all of those would be different releases but share a release group. Note that MPD's support for this tag is relatively new (July 2023) and doesn't seem especially reliable, so it might be missing here even if your music has been tagged with it. Not sure why. https://musicbrainz.org/doc/Release_Group", + "format": "uuid", + "title": "Release Group", + "type": "string" + }, + "track": { + "description": "A MusicBrainz track represents a specific instance of a recording appearing as part of some release. For example, if the same song appears on both two-CD and four-CD versions of a soundtrack, then it will be considered the same \"recording\" in both cases, but different \"tracks\". https://musicbrainz.org/doc/Track", + "format": "uuid", + "title": "Track", + "type": "string" + }, + "work": { + "description": "A MusicBrainz work represents the idea of a particular song or creation (it doesn't have to be audio). Each work may have multiple recordings (studio versus live, different performers, etc.), with the work ID grouping them together. https://musicbrainz.org/doc/Work", + "format": "uuid", + "title": "Work", + "type": "string" + } + }, + "title": "MusicBrainzIds", + "type": "object" + }, + "NoArtwork": { + "properties": {}, + "title": "NoArtwork", + "type": "object" + }, + "PlaybackState": { + "enum": [ + "play", + "pause", + "stop" + ], + "title": "PlaybackState", + "type": "string" + } + }, + "$id": "https://cdn.00dani.me/m/schemata/mpd-now-playable/song-v1.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "album": { + "description": "The name of the song's containing album, which may be multivalued.", + "items": { + "type": "string" + }, + "title": "Album", + "type": "array" + }, + "album_artist": { + "description": "The album's artists. This is often used to group together songs from a single album that featured different artists.", + "items": { + "type": "string" + }, + "title": "Album Artist", + "type": "array" + }, + "art": { + "anyOf": [ + { + "$ref": "#/$defs/HasArtwork" + }, + { + "$ref": "#/$defs/NoArtwork" + } + ], + "description": "The song's cover art, if it has any - the art will be available as bytes if present, ready to be displayed directly by receivers.", + "title": "Art" + }, + "artist": { + "description": "The song's artists. Will be an empty list if the song has not been tagged with an artist, and may contain multiple values if the song has been tagged with several artists.", + "items": { + "type": "string" + }, + "title": "Artist", + "type": "array" + }, + "composer": { + "description": "The song's composers. Again, this is permitted to be multivalued.", + "items": { + "type": "string" + }, + "title": "Composer", + "type": "array" + }, + "disc": { + "description": "The disc number of the song on its album. As with the track number, this is usually one-based, but it doesn't have to be.", + "title": "Disc", + "type": "integer" + }, + "duration": { + "description": "The song's duration as read from its tags, measured in seconds. Fractional seconds are allowed.", + "title": "Duration", + "type": "number" + }, + "elapsed": { + "description": "How far into the song MPD is, measured in seconds. Fractional seconds are allowed. This is usually going to be less than or equal to the song's duration, but because the duration is tagged as metadata and this value represents the actual elapsed time, it might go higher if the song's duration tag is inaccurate.", + "title": "Elapsed", + "type": "number" + }, + "file": { + "description": "The relative path to the current song inside the music directory. MPD itself uses this path as a stable identifier for the audio file in many places, so you can safely do the same.", + "format": "path", + "title": "File", + "type": "string" + }, + "genre": { + "description": "The song's genre or genres. These are completely arbitrary descriptions and don't follow any particular standard.", + "items": { + "type": "string" + }, + "title": "Genre", + "type": "array" + }, + "musicbrainz": { + "$ref": "#/$defs/MusicBrainzIds", + "description": "The MusicBrainz IDs associated with the song and with its artist and album, which if present are an extremely accurate way to identify a given song. They're not always present, though." + }, + "queue_index": { + "description": "The zero-based index of the current song in MPD's queue.", + "title": "Queue Index", + "type": "integer" + }, + "queue_length": { + "description": "The total length of MPD's queue - the last song in the queue will have the index one less than this, since queue indices are zero-based.", + "title": "Queue Length", + "type": "integer" + }, + "state": { + "$ref": "#/$defs/PlaybackState", + "description": "Whether MPD is currently playing, paused, or stopped. Pretty simple." + }, + "title": { + "description": "The song's title, if it's been tagged with one. Currently only one title is supported, since it doesn't make a lot of sense to tag a single audio file with multiple titles.", + "title": "Title", + "type": "string" + }, + "track": { + "description": "The track number the song has on its album. This is usually one-based, but it's just an arbitrary audio tag so a particular album might start at zero or do something weird with it.", + "title": "Track", + "type": "integer" + } + }, + "required": [ + "state", + "queue_index", + "queue_length", + "file", + "title", + "artist", + "composer", + "album", + "album_artist", + "track", + "disc", + "genre", + "duration", + "elapsed", + "art", + "musicbrainz" + ], + "title": "Song", + "type": "object" +} diff --git a/src/mpd_now_playable/config/model.py b/src/mpd_now_playable/config/model.py index a5b67f2..6935f96 100644 --- a/src/mpd_now_playable/config/model.py +++ b/src/mpd_now_playable/config/model.py @@ -11,6 +11,7 @@ __all__ = ( "MpdConfig", "BaseReceiverConfig", "CocoaReceiverConfig", + "WebsocketsReceiverConfig", ) @@ -24,8 +25,15 @@ class CocoaReceiverConfig(BaseReceiverConfig): kind: Literal["cocoa"] = field(default="cocoa", repr=False) +@dataclass(slots=True, kw_only=True) +class WebsocketsReceiverConfig(BaseReceiverConfig): + kind: Literal["websockets"] = field(default="websockets", repr=False) + port: Port + host: Optional[Host | tuple[Host, ...]] = None + + ReceiverConfig = Annotated[ - CocoaReceiverConfig, + CocoaReceiverConfig | WebsocketsReceiverConfig, Field(discriminator="kind"), ] diff --git a/src/mpd_now_playable/receivers/websockets/__init__.py b/src/mpd_now_playable/receivers/websockets/__init__.py new file mode 100644 index 0000000..0a13e88 --- /dev/null +++ b/src/mpd_now_playable/receivers/websockets/__init__.py @@ -0,0 +1,2 @@ +__all__ = ('receiver',) +from .receiver import WebsocketsReceiver as receiver diff --git a/src/mpd_now_playable/receivers/websockets/receiver.py b/src/mpd_now_playable/receivers/websockets/receiver.py new file mode 100644 index 0000000..0439811 --- /dev/null +++ b/src/mpd_now_playable/receivers/websockets/receiver.py @@ -0,0 +1,52 @@ +from pathlib import Path + +import ormsgpack +from websockets import broadcast +from websockets.server import WebSocketServerProtocol, serve + +from ...config.model import WebsocketsReceiverConfig +from ...player import Player +from ...song import Song +from ...song_receiver import DefaultLoopFactory, Receiver + +MSGPACK_NULL = ormsgpack.packb(None) + + +def default(value: object) -> object: + if isinstance(value, Path): + return str(value) + raise TypeError + + +class WebsocketsReceiver(Receiver): + config: WebsocketsReceiverConfig + player: Player + connections: set[WebSocketServerProtocol] + last_status: bytes = MSGPACK_NULL + + def __init__(self, config: WebsocketsReceiverConfig): + self.config = config + self.connections = set() + + @classmethod + def loop_factory(cls) -> DefaultLoopFactory: + return DefaultLoopFactory() + + async def start(self, player: Player) -> None: + self.player = player + await serve(self.handle, host=self.config.host, port=self.config.port) + + async def handle(self, conn: WebSocketServerProtocol) -> None: + self.connections.add(conn) + await conn.send(self.last_status) + try: + await conn.wait_closed() + finally: + self.connections.remove(conn) + + async def update(self, song: Song | None) -> None: + if song is None: + self.last_status = MSGPACK_NULL + else: + self.last_status = ormsgpack.packb(song, default=default) + broadcast(self.connections, self.last_status) diff --git a/src/mpd_now_playable/song/musicbrainz.py b/src/mpd_now_playable/song/musicbrainz.py index 7478d2c..9ab5278 100644 --- a/src/mpd_now_playable/song/musicbrainz.py +++ b/src/mpd_now_playable/song/musicbrainz.py @@ -1,11 +1,14 @@ from dataclasses import dataclass from functools import partial -from typing import TypedDict +from typing import Annotated, TypedDict from uuid import UUID +from pydantic import Field + from ..tools.types import option_fmap option_uuid = partial(option_fmap, UUID) +OptionUUID = Annotated[UUID | None, Field(default=None)] class MusicBrainzTags(TypedDict, total=False): @@ -44,35 +47,35 @@ class MusicBrainzIds: #: so recording IDs are a much more reliable way to identify a particular #: song. #: https://musicbrainz.org/doc/Recording - recording: UUID | None + recording: OptionUUID #: A MusicBrainz work represents the idea of a particular song or creation #: (it doesn't have to be audio). Each work may have multiple recordings #: (studio versus live, different performers, etc.), with the work ID #: grouping them together. #: https://musicbrainz.org/doc/Work - work: UUID | None + work: OptionUUID #: A MusicBrainz track represents a specific instance of a recording #: appearing as part of some release. For example, if the same song appears #: on both two-CD and four-CD versions of a soundtrack, then it will be #: considered the same "recording" in both cases, but different "tracks". #: https://musicbrainz.org/doc/Track - track: UUID | None + track: OptionUUID #: https://musicbrainz.org/doc/Artist - artist: UUID | None + artist: OptionUUID #: A MusicBrainz release roughly corresponds to an "album", and indeed is #: stored in a tag called MUSICBRAINZ_ALBUMID. The more general name is #: meant to encompass all the different ways music can be released. #: https://musicbrainz.org/doc/Release - release: UUID | None + release: OptionUUID #: Again, the release artist corresponds to an "album artist". These MBIDs #: refer to the same artists in the MusicBrainz database that individual #: recordings' artist MBIDs do. - release_artist: UUID | None + release_artist: OptionUUID #: A MusicBrainz release group roughly corresponds to "all the editions of #: a particular album". For example, if the same album were released on CD, @@ -81,7 +84,7 @@ class MusicBrainzIds: #: for this tag is relatively new (July 2023) and doesn't seem especially #: reliable, so it might be missing here even if your music has been tagged #: with it. Not sure why. https://musicbrainz.org/doc/Release_Group - release_group: UUID | None + release_group: OptionUUID def to_brainz(tags: MusicBrainzTags) -> MusicBrainzIds: diff --git a/src/mpd_now_playable/song/song.py b/src/mpd_now_playable/song/song.py index e30c9ca..716dff0 100644 --- a/src/mpd_now_playable/song/song.py +++ b/src/mpd_now_playable/song/song.py @@ -2,6 +2,7 @@ from dataclasses import dataclass from enum import StrEnum from pathlib import Path +from ..tools.schema.define import schema from .artwork import Artwork from .musicbrainz import MusicBrainzIds @@ -12,21 +13,69 @@ class PlaybackState(StrEnum): stop = "stop" +@schema("https://cdn.00dani.me/m/schemata/mpd-now-playable/song-v1.json") @dataclass(slots=True) class Song: + #: Whether MPD is currently playing, paused, or stopped. Pretty simple. state: PlaybackState + + #: The zero-based index of the current song in MPD's queue. queue_index: int + #: The total length of MPD's queue - the last song in the queue will have + #: the index one less than this, since queue indices are zero-based. queue_length: int + + #: The relative path to the current song inside the music directory. MPD + #: itself uses this path as a stable identifier for the audio file in many + #: places, so you can safely do the same. file: Path + + #: The song's title, if it's been tagged with one. Currently only one title + #: is supported, since it doesn't make a lot of sense to tag a single audio + #: file with multiple titles. title: str | None + + #: The song's artists. Will be an empty list if the song has not been + #: tagged with an artist, and may contain multiple values if the song has + #: been tagged with several artists. artist: list[str] + #: The song's composers. Again, this is permitted to be multivalued. composer: list[str] + #: The name of the song's containing album, which may be multivalued. album: list[str] + #: The album's artists. This is often used to group together songs from a + #: single album that featured different artists. album_artist: list[str] + + #: The track number the song has on its album. This is usually one-based, + #: but it's just an arbitrary audio tag so a particular album might start + #: at zero or do something weird with it. track: int | None + + #: The disc number of the song on its album. As with the track number, this + #: is usually one-based, but it doesn't have to be. disc: int | None + + #: The song's genre or genres. These are completely arbitrary descriptions + #: and don't follow any particular standard. genre: list[str] + + #: The song's duration as read from its tags, measured in seconds. + #: Fractional seconds are allowed. duration: float + + #: How far into the song MPD is, measured in seconds. Fractional seconds + #: are allowed. This is usually going to be less than or equal to the + #: song's duration, but because the duration is tagged as metadata and this + #: value represents the actual elapsed time, it might go higher if the + #: song's duration tag is inaccurate. elapsed: float + + #: The song's cover art, if it has any - the art will be available as bytes + #: if present, ready to be displayed directly by receivers. art: Artwork + + #: The MusicBrainz IDs associated with the song and with its artist and + #: album, which if present are an extremely accurate way to identify a + #: given song. They're not always present, though. musicbrainz: MusicBrainzIds diff --git a/src/mpd_now_playable/tools/schema/generate.py b/src/mpd_now_playable/tools/schema/generate.py index 181a214..ec1b539 100644 --- a/src/mpd_now_playable/tools/schema/generate.py +++ b/src/mpd_now_playable/tools/schema/generate.py @@ -45,3 +45,9 @@ class MyGenerateJsonSchema(GenerateJsonSchema): def nullable_schema(self, schema: s.NullableSchema) -> JsonSchemaValue: return self.generate_inner(schema["schema"]) + +if __name__ == '__main__': + from ...config.model import Config + from ...song import Song + write(Config) + write(Song) From ca5086f93a0cebb9d03e268552dcee7f6efbe653 Mon Sep 17 00:00:00 2001 From: Danielle McLean Date: Sat, 13 Jul 2024 18:38:16 +1000 Subject: [PATCH 08/32] Fix path to MPD logo in Cocoa receiver (oops) --- src/mpd_now_playable/receivers/cocoa/now_playing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mpd_now_playable/receivers/cocoa/now_playing.py b/src/mpd_now_playable/receivers/cocoa/now_playing.py index 60c2b49..d207b65 100644 --- a/src/mpd_now_playable/receivers/cocoa/now_playing.py +++ b/src/mpd_now_playable/receivers/cocoa/now_playing.py @@ -47,7 +47,7 @@ from .persistent_id import song_to_persistent_id def logo_to_ns_image() -> NSImage: return NSImage.alloc().initByReferencingFile_( - str(Path(__file__).parent.parent / "mpd/logo.svg") + str(Path(__file__).parent.parent.parent / "mpd/logo.svg") ) From 21b7c286921101d4c6128f09580318bd08038bb4 Mon Sep 17 00:00:00 2001 From: Danielle McLean Date: Sat, 13 Jul 2024 19:28:18 +1000 Subject: [PATCH 09/32] Add descriptions to websockets config --- schemata/config-v1.json | 2 ++ src/mpd_now_playable/config/model.py | 6 ++++++ 2 files changed, 8 insertions(+) diff --git a/schemata/config-v1.json b/schemata/config-v1.json index 8c0c9e6..a73d6cf 100644 --- a/schemata/config-v1.json +++ b/schemata/config-v1.json @@ -59,6 +59,7 @@ "type": "array" } ], + "description": "The hostname you'd like your WebSockets server to listen on. In most cases the default behaviour, which binds to all network interfaces, will be fine.", "title": "Host" }, "kind": { @@ -71,6 +72,7 @@ "type": "string" }, "port": { + "description": "The TCP port you'd like your WebSockets server to listen on. Should generally be higher than 1024, since mpd-now-playable doesn't normally run with the privilege to bind to low-numbered ports.", "maximum": 65535, "minimum": 1, "title": "Port", diff --git a/src/mpd_now_playable/config/model.py b/src/mpd_now_playable/config/model.py index 6935f96..561d830 100644 --- a/src/mpd_now_playable/config/model.py +++ b/src/mpd_now_playable/config/model.py @@ -28,7 +28,13 @@ class CocoaReceiverConfig(BaseReceiverConfig): @dataclass(slots=True, kw_only=True) class WebsocketsReceiverConfig(BaseReceiverConfig): kind: Literal["websockets"] = field(default="websockets", repr=False) + #: The TCP port you'd like your WebSockets server to listen on. Should + #: generally be higher than 1024, since mpd-now-playable doesn't normally + #: run with the privilege to bind to low-numbered ports. port: Port + #: The hostname you'd like your WebSockets server to listen on. In most + #: cases the default behaviour, which binds to all network interfaces, will + #: be fine. host: Optional[Host | tuple[Host, ...]] = None From 86761bc4207330a167b0e732b6d2cd0bfced33b3 Mon Sep 17 00:00:00 2001 From: Danielle McLean Date: Tue, 23 Jul 2024 10:37:38 +1000 Subject: [PATCH 10/32] Don't worry about ormsgpack import error, it's always required now --- src/mpd_now_playable/cache.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/mpd_now_playable/cache.py b/src/mpd_now_playable/cache.py index 6b27f71..58394e6 100644 --- a/src/mpd_now_playable/cache.py +++ b/src/mpd_now_playable/cache.py @@ -1,8 +1,8 @@ from __future__ import annotations -from contextlib import suppress from typing import Any, Generic, Optional, TypeVar +import ormsgpack from aiocache import Cache from aiocache.serializers import BaseSerializer from pydantic.type_adapter import TypeAdapter @@ -10,9 +10,6 @@ from yarl import URL T = TypeVar("T") -with suppress(ImportError): - import ormsgpack - class OrmsgpackSerializer(BaseSerializer, Generic[T]): DEFAULT_ENCODING = None From 1e6dffcdccd9f24e8d25eaf367de9d4378678346 Mon Sep 17 00:00:00 2001 From: Danielle McLean Date: Tue, 23 Jul 2024 10:43:22 +1000 Subject: [PATCH 11/32] Support multivalued tags for MusicBrainz IDs too --- src/mpd_now_playable/mpd/types.py | 10 +++------ src/mpd_now_playable/song/musicbrainz.py | 28 +++++++++++++++--------- 2 files changed, 21 insertions(+), 17 deletions(-) diff --git a/src/mpd_now_playable/mpd/types.py b/src/mpd_now_playable/mpd/types.py index 8782ddc..800fc7b 100644 --- a/src/mpd_now_playable/mpd/types.py +++ b/src/mpd_now_playable/mpd/types.py @@ -1,5 +1,6 @@ from typing import Literal, NotRequired, Protocol, TypedDict +from ..song.musicbrainz import MusicBrainzTags from ..tools.types import MaybePlural @@ -15,7 +16,7 @@ OneshotFlag = Literal[BooleanFlag, "oneshot"] # This is not the complete status response from MPD, just the parts of it mpd-now-playable uses. -class StatusResponse(TypedDict): +class StatusResponse(TypedDict, MusicBrainzTags): state: Literal["play", "stop", "pause"] # The total duration and elapsed playback of the current song, measured in seconds. Fractional seconds are allowed. @@ -48,7 +49,7 @@ class StatusResponse(TypedDict): # optional. mpd-now-playable will work better if your music is properly # tagged, since then it can pass more information on to Now Playing, but it # should work fine with completely untagged music too. -class CurrentSongTags(TypedDict, total=False): +class CurrentSongTags(TypedDict, MusicBrainzTags, total=False): artist: MaybePlural[str] albumartist: MaybePlural[str] artistsort: MaybePlural[str] @@ -62,11 +63,6 @@ class CurrentSongTags(TypedDict, total=False): disc: str label: str genre: MaybePlural[str] - musicbrainz_albumid: str - musicbrainz_albumartistid: str - musicbrainz_releasetrackid: str - musicbrainz_artistid: str - musicbrainz_trackid: str class CurrentSongResponse(CurrentSongTags): diff --git a/src/mpd_now_playable/song/musicbrainz.py b/src/mpd_now_playable/song/musicbrainz.py index 9ab5278..23ae559 100644 --- a/src/mpd_now_playable/song/musicbrainz.py +++ b/src/mpd_now_playable/song/musicbrainz.py @@ -5,12 +5,16 @@ from uuid import UUID from pydantic import Field -from ..tools.types import option_fmap +from ..tools.types import MaybePlural, option_fmap, un_maybe_plural option_uuid = partial(option_fmap, UUID) OptionUUID = Annotated[UUID | None, Field(default=None)] +def to_uuids(values: MaybePlural[str] | None) -> list[UUID]: + return [UUID(i) for i in un_maybe_plural(values)] + + class MusicBrainzTags(TypedDict, total=False): """ The MusicBrainz tags mpd-now-playable expects and will load (all optional). @@ -24,11 +28,11 @@ class MusicBrainzTags(TypedDict, total=False): #: MusicBrainz Track ID musicbrainz_releasetrackid: str #: MusicBrainz Artist ID - musicbrainz_artistid: str + musicbrainz_artistid: MaybePlural[str] #: MusicBrainz Release ID - musicbrainz_albumid: str + musicbrainz_albumid: MaybePlural[str] #: MusicBrainz Release Artist ID - musicbrainz_albumartistid: str + musicbrainz_albumartistid: MaybePlural[str] #: MusicBrainz Release Group ID musicbrainz_releasegroupid: str #: MusicBrainz Work ID @@ -63,19 +67,23 @@ class MusicBrainzIds: #: https://musicbrainz.org/doc/Track track: OptionUUID + #: A MusicBrainz artist is pretty intuitively the artist who recorded the + #: song. This particular ID refers to the individual recording's artist or + #: artists, which may be distinct from the release artist below when a + #: release contains recordings from many different artists. #: https://musicbrainz.org/doc/Artist - artist: OptionUUID + artist: list[UUID] #: A MusicBrainz release roughly corresponds to an "album", and indeed is #: stored in a tag called MUSICBRAINZ_ALBUMID. The more general name is #: meant to encompass all the different ways music can be released. #: https://musicbrainz.org/doc/Release - release: OptionUUID + release: list[UUID] #: Again, the release artist corresponds to an "album artist". These MBIDs #: refer to the same artists in the MusicBrainz database that individual #: recordings' artist MBIDs do. - release_artist: OptionUUID + release_artist: list[UUID] #: A MusicBrainz release group roughly corresponds to "all the editions of #: a particular album". For example, if the same album were released on CD, @@ -92,8 +100,8 @@ def to_brainz(tags: MusicBrainzTags) -> MusicBrainzIds: recording=option_uuid(tags.get("musicbrainz_trackid")), work=option_uuid(tags.get("musicbrainz_workid")), track=option_uuid(tags.get("musicbrainz_releasetrackid")), - artist=option_uuid(tags.get("musicbrainz_artistid")), - release=option_uuid(tags.get("musicbrainz_albumid")), - release_artist=option_uuid(tags.get("musicbrainz_albumartistid")), + artist=to_uuids(tags.get("musicbrainz_artistid")), + release=to_uuids(tags.get("musicbrainz_albumid")), + release_artist=to_uuids(tags.get("musicbrainz_albumartistid")), release_group=option_uuid(tags.get("musicbrainz_releasegroupid")), ) From e2268c0c34c753b3efeec75130f0e15057e7e5b5 Mon Sep 17 00:00:00 2001 From: Danielle McLean Date: Tue, 23 Jul 2024 10:45:06 +1000 Subject: [PATCH 12/32] Allow websocket server to reuse its port (handle crashes better) --- src/mpd_now_playable/receivers/websockets/receiver.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mpd_now_playable/receivers/websockets/receiver.py b/src/mpd_now_playable/receivers/websockets/receiver.py index 0439811..22c35bb 100644 --- a/src/mpd_now_playable/receivers/websockets/receiver.py +++ b/src/mpd_now_playable/receivers/websockets/receiver.py @@ -34,7 +34,7 @@ class WebsocketsReceiver(Receiver): async def start(self, player: Player) -> None: self.player = player - await serve(self.handle, host=self.config.host, port=self.config.port) + await serve(self.handle, host=self.config.host, port=self.config.port, reuse_port=True) async def handle(self, conn: WebSocketServerProtocol) -> None: self.connections.add(conn) From 30e0829ff39a70c51403aa584249177b6efa8e82 Mon Sep 17 00:00:00 2001 From: Danielle McLean Date: Tue, 23 Jul 2024 10:47:05 +1000 Subject: [PATCH 13/32] Update MusicBrainz tag shape in song schema --- schemata/song-v1.json | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/schemata/song-v1.json b/schemata/song-v1.json index 6ee74de..a16bda4 100644 --- a/schemata/song-v1.json +++ b/schemata/song-v1.json @@ -17,10 +17,13 @@ "MusicBrainzIds": { "properties": { "artist": { - "description": "https://musicbrainz.org/doc/Artist", - "format": "uuid", + "description": "A MusicBrainz artist is pretty intuitively the artist who recorded the song. This particular ID refers to the individual recording's artist or artists, which may be distinct from the release artist below when a release contains recordings from many different artists. https://musicbrainz.org/doc/Artist", + "items": { + "format": "uuid", + "type": "string" + }, "title": "Artist", - "type": "string" + "type": "array" }, "recording": { "description": "A MusicBrainz recording represents audio from a specific performance. For example, if the same song was released as a studio recording and as a live performance, those two versions of the song are different recordings. The song itself is considered a \"work\", of which two recordings were made. However, recordings are not always associated with a work in the MusicBrainz database, and Picard won't load work IDs by default (you have to enable \"use track relationships\" in the options), so recording IDs are a much more reliable way to identify a particular song. https://musicbrainz.org/doc/Recording", @@ -30,15 +33,21 @@ }, "release": { "description": "A MusicBrainz release roughly corresponds to an \"album\", and indeed is stored in a tag called MUSICBRAINZ_ALBUMID. The more general name is meant to encompass all the different ways music can be released. https://musicbrainz.org/doc/Release", - "format": "uuid", + "items": { + "format": "uuid", + "type": "string" + }, "title": "Release", - "type": "string" + "type": "array" }, "release_artist": { "description": "Again, the release artist corresponds to an \"album artist\". These MBIDs refer to the same artists in the MusicBrainz database that individual recordings' artist MBIDs do.", - "format": "uuid", + "items": { + "format": "uuid", + "type": "string" + }, "title": "Release Artist", - "type": "string" + "type": "array" }, "release_group": { "description": "A MusicBrainz release group roughly corresponds to \"all the editions of a particular album\". For example, if the same album were released on CD, vinyl records, and as a digital download, then all of those would be different releases but share a release group. Note that MPD's support for this tag is relatively new (July 2023) and doesn't seem especially reliable, so it might be missing here even if your music has been tagged with it. Not sure why. https://musicbrainz.org/doc/Release_Group", @@ -59,6 +68,11 @@ "type": "string" } }, + "required": [ + "artist", + "release", + "release_artist" + ], "title": "MusicBrainzIds", "type": "object" }, From fda799e32e64a1a7507ec6225a7b9c8b4eaecffb Mon Sep 17 00:00:00 2001 From: Danielle McLean Date: Tue, 23 Jul 2024 10:52:43 +1000 Subject: [PATCH 14/32] Fix inheritance of MusicBrainzTags into MPD response types --- src/mpd_now_playable/mpd/types.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/mpd_now_playable/mpd/types.py b/src/mpd_now_playable/mpd/types.py index 800fc7b..4183e5d 100644 --- a/src/mpd_now_playable/mpd/types.py +++ b/src/mpd_now_playable/mpd/types.py @@ -16,7 +16,7 @@ OneshotFlag = Literal[BooleanFlag, "oneshot"] # This is not the complete status response from MPD, just the parts of it mpd-now-playable uses. -class StatusResponse(TypedDict, MusicBrainzTags): +class StatusResponse(TypedDict): state: Literal["play", "stop", "pause"] # The total duration and elapsed playback of the current song, measured in seconds. Fractional seconds are allowed. @@ -49,7 +49,7 @@ class StatusResponse(TypedDict, MusicBrainzTags): # optional. mpd-now-playable will work better if your music is properly # tagged, since then it can pass more information on to Now Playing, but it # should work fine with completely untagged music too. -class CurrentSongTags(TypedDict, MusicBrainzTags, total=False): +class CurrentSongTags(MusicBrainzTags, total=False): artist: MaybePlural[str] albumartist: MaybePlural[str] artistsort: MaybePlural[str] From 1bb2032b9fd042f77e56c3708a14f279d2e3be70 Mon Sep 17 00:00:00 2001 From: Danielle McLean Date: Tue, 23 Jul 2024 11:03:13 +1000 Subject: [PATCH 15/32] Ditch convert_if_exists, just use option_fmap which I prefer --- src/mpd_now_playable/mpd/listener.py | 6 +++--- src/mpd_now_playable/tools/types.py | 7 ------- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/src/mpd_now_playable/mpd/listener.py b/src/mpd_now_playable/mpd/listener.py index bceaaf3..1743bbb 100644 --- a/src/mpd_now_playable/mpd/listener.py +++ b/src/mpd_now_playable/mpd/listener.py @@ -11,7 +11,7 @@ from ..config.model import MpdConfig from ..player import Player from ..song import Artwork, PlaybackState, Song, to_artwork, to_brainz from ..song_receiver import Receiver -from ..tools.types import convert_if_exists, un_maybe_plural +from ..tools.types import option_fmap, un_maybe_plural from .artwork_cache import MpdArtworkCache from .types import CurrentSongResponse, StatusResponse @@ -30,8 +30,8 @@ def mpd_current_to_song( album_artist=un_maybe_plural(current.get("albumartist")), composer=un_maybe_plural(current.get("composer")), genre=un_maybe_plural(current.get("genre")), - track=convert_if_exists(current.get("track"), int), - disc=convert_if_exists(current.get("disc"), int), + track=option_fmap(int, current.get("track")), + disc=option_fmap(int, current.get("disc")), duration=float(status["duration"]), elapsed=float(status["elapsed"]), musicbrainz=to_brainz(current), diff --git a/src/mpd_now_playable/tools/types.py b/src/mpd_now_playable/tools/types.py index da62c90..deb18ad 100644 --- a/src/mpd_now_playable/tools/types.py +++ b/src/mpd_now_playable/tools/types.py @@ -5,7 +5,6 @@ __all__ = ( "AnyExceptList", "MaybePlural", "option_fmap", - "convert_if_exists", "un_maybe_plural", ) @@ -45,12 +44,6 @@ def option_fmap(f: Callable[[U], V], value: U | None) -> V | None: return f(value) -def convert_if_exists(value: str | None, converter: Callable[[str], U]) -> U | None: - if value is None: - return None - return converter(value) - - T = TypeVar("T", bound=AnyExceptList) MaybePlural: TypeAlias = list[T] | T From b8bcdc5a834facb28155436ff25f0b3cf824e9fe Mon Sep 17 00:00:00 2001 From: Danielle McLean Date: Tue, 23 Jul 2024 13:12:06 +1000 Subject: [PATCH 16/32] Wrap MPD's state into a transfer struct before finalising the Song --- src/mpd_now_playable/mpd/listener.py | 19 +++++++++++-------- src/mpd_now_playable/mpd/types.py | 8 ++++++++ 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/src/mpd_now_playable/mpd/listener.py b/src/mpd_now_playable/mpd/listener.py index 1743bbb..b3c400b 100644 --- a/src/mpd_now_playable/mpd/listener.py +++ b/src/mpd_now_playable/mpd/listener.py @@ -9,16 +9,17 @@ from yarl import URL from ..config.model import MpdConfig from ..player import Player -from ..song import Artwork, PlaybackState, Song, to_artwork, to_brainz +from ..song import PlaybackState, Song, to_artwork, to_brainz from ..song_receiver import Receiver from ..tools.types import option_fmap, un_maybe_plural from .artwork_cache import MpdArtworkCache -from .types import CurrentSongResponse, StatusResponse +from .types import MpdState -def mpd_current_to_song( - status: StatusResponse, current: CurrentSongResponse, art: Artwork -) -> Song: + +def mpd_state_to_song(mpd: MpdState) -> Song: + status = mpd.status + current = mpd.current return Song( state=PlaybackState(status["state"]), queue_index=int(current["pos"]), @@ -34,8 +35,8 @@ def mpd_current_to_song( disc=option_fmap(int, current.get("disc")), duration=float(status["duration"]), elapsed=float(status["elapsed"]), - musicbrainz=to_brainz(current), - art=art, + musicbrainz=to_brainz(mpd.current), + art=to_artwork(mpd.art), ) @@ -77,6 +78,7 @@ class MpdStateListener(Player): status, current = await asyncio.gather( self.client.status(), self.client.currentsong() ) + state = MpdState(status, current) if starting_idle_count != self.idle_count: return @@ -90,7 +92,8 @@ class MpdStateListener(Player): if starting_idle_count != self.idle_count: return - song = mpd_current_to_song(status, current, to_artwork(art)) + state = MpdState(status, current, art) + song = mpd_state_to_song(state) rprint(song) await self.update(song) diff --git a/src/mpd_now_playable/mpd/types.py b/src/mpd_now_playable/mpd/types.py index 4183e5d..98f6612 100644 --- a/src/mpd_now_playable/mpd/types.py +++ b/src/mpd_now_playable/mpd/types.py @@ -1,3 +1,4 @@ +from dataclasses import dataclass from typing import Literal, NotRequired, Protocol, TypedDict from ..song.musicbrainz import MusicBrainzTags @@ -76,3 +77,10 @@ class CurrentSongResponse(CurrentSongTags): ReadPictureResponse = TypedDict("ReadPictureResponse", {"binary": bytes}) + + +@dataclass +class MpdState: + status: StatusResponse + current: CurrentSongResponse + art: bytes | None = None From d9c8e0fe282baea3eaebc39acd4da3c6ecff307f Mon Sep 17 00:00:00 2001 From: Danielle McLean Date: Tue, 23 Jul 2024 13:29:50 +1000 Subject: [PATCH 17/32] Find the current song's URL and pass it on when possible --- schemata/config-v1.json | 6 +++ schemata/song-v1.json | 6 +++ src/mpd_now_playable/config/model.py | 8 ++- src/mpd_now_playable/mpd/listener.py | 54 ++++++++++++------- .../receivers/cocoa/now_playing.py | 4 ++ .../receivers/websockets/receiver.py | 3 ++ src/mpd_now_playable/song/song.py | 10 +++- .../{config => tools/schema}/fields.py | 8 ++- stubs/MediaPlayer/__init__.pyi | 1 + 9 files changed, 79 insertions(+), 21 deletions(-) rename src/mpd_now_playable/{config => tools/schema}/fields.py (77%) diff --git a/schemata/config-v1.json b/schemata/config-v1.json index a73d6cf..68eff9f 100644 --- a/schemata/config-v1.json +++ b/schemata/config-v1.json @@ -24,6 +24,12 @@ "title": "Host", "type": "string" }, + "music_directory": { + "description": "Your music directory, just as it's set up in your mpd.conf. mpd-now-playable uses this setting to figure out an absolute file:// URL for the current song, which MPNowPlayingInfoCenter will use to display cool stuff like audio waveforms. It'll still work fine without setting this, though.", + "format": "directory-path", + "title": "Music Directory", + "type": "string" + }, "password": { "description": "The password required to connect to your MPD instance, if you need one.", "format": "password", diff --git a/schemata/song-v1.json b/schemata/song-v1.json index a16bda4..6ecc4ec 100644 --- a/schemata/song-v1.json +++ b/schemata/song-v1.json @@ -194,6 +194,12 @@ "description": "The track number the song has on its album. This is usually one-based, but it's just an arbitrary audio tag so a particular album might start at zero or do something weird with it.", "title": "Track", "type": "integer" + }, + "url": { + "description": "An absolute URL referring to the current song, if available. If the song's a local file and its absolute path can be determined (mpd-now-playable has been configured with your music directory), then this field will contain a file:// URL. If the song's remote, then MPD itself returns an absolute URL in the first place.", + "format": "uri", + "title": "Url", + "type": "string" } }, "required": [ diff --git a/src/mpd_now_playable/config/model.py b/src/mpd_now_playable/config/model.py index 561d830..0fde2d1 100644 --- a/src/mpd_now_playable/config/model.py +++ b/src/mpd_now_playable/config/model.py @@ -4,7 +4,7 @@ from typing import Annotated, Literal, Optional, Protocol from pydantic import Field from ..tools.schema.define import schema -from .fields import Host, Password, Port, Url +from ..tools.schema.fields import DirectoryPath, Host, Password, Port, Url __all__ = ( "Config", @@ -55,6 +55,12 @@ class MpdConfig: #: servers on one machine for some reason, you probably haven't changed this #: from the default port, 6600. port: Port = Port(6600) + #: Your music directory, just as it's set up in your mpd.conf. + #: mpd-now-playable uses this setting to figure out an absolute file:// URL + #: for the current song, which MPNowPlayingInfoCenter will use to display + #: cool stuff like audio waveforms. It'll still work fine without setting + #: this, though. + music_directory: Optional[DirectoryPath] = None @schema("https://cdn.00dani.me/m/schemata/mpd-now-playable/config-v1.json") diff --git a/src/mpd_now_playable/mpd/listener.py b/src/mpd_now_playable/mpd/listener.py index b3c400b..01a1aaf 100644 --- a/src/mpd_now_playable/mpd/listener.py +++ b/src/mpd_now_playable/mpd/listener.py @@ -16,31 +16,48 @@ from .artwork_cache import MpdArtworkCache from .types import MpdState +def mpd_file_to_uri(config: MpdConfig, file: str) -> URL | None: + url = URL(file) + if url.scheme != "": + # We already got an absolute URL - probably a stream? - so we can just return it. + return url + + if not config.music_directory: + # We have a relative song URI, but we can't make it absolute since no music directory is configured. + return None + + # Prepend the configured music directory, then turn the whole path into a file:// URL. + abs_file = config.music_directory / file + return URL(abs_file.as_uri()) + + +def mpd_state_to_song(config: MpdConfig, mpd: MpdState) -> Song: + file = mpd.current["file"] + url = mpd_file_to_uri(config, file) -def mpd_state_to_song(mpd: MpdState) -> Song: - status = mpd.status - current = mpd.current return Song( - state=PlaybackState(status["state"]), - queue_index=int(current["pos"]), - queue_length=int(status["playlistlength"]), - file=Path(current["file"]), - title=current.get("title"), - artist=un_maybe_plural(current.get("artist")), - album=un_maybe_plural(current.get("album")), - album_artist=un_maybe_plural(current.get("albumartist")), - composer=un_maybe_plural(current.get("composer")), - genre=un_maybe_plural(current.get("genre")), - track=option_fmap(int, current.get("track")), - disc=option_fmap(int, current.get("disc")), - duration=float(status["duration"]), - elapsed=float(status["elapsed"]), + state=PlaybackState(mpd.status["state"]), + queue_index=int(mpd.current["pos"]), + queue_length=int(mpd.status["playlistlength"]), + file=Path(file), + url=url, + title=mpd.current.get("title"), + artist=un_maybe_plural(mpd.current.get("artist")), + album=un_maybe_plural(mpd.current.get("album")), + album_artist=un_maybe_plural(mpd.current.get("albumartist")), + composer=un_maybe_plural(mpd.current.get("composer")), + genre=un_maybe_plural(mpd.current.get("genre")), + track=option_fmap(int, mpd.current.get("track")), + disc=option_fmap(int, mpd.current.get("disc")), + duration=float(mpd.status["duration"]), + elapsed=float(mpd.status["elapsed"]), musicbrainz=to_brainz(mpd.current), art=to_artwork(mpd.art), ) class MpdStateListener(Player): + config: MpdConfig client: MPDClient receivers: Iterable[Receiver] art_cache: MpdArtworkCache @@ -53,6 +70,7 @@ class MpdStateListener(Player): ) async def start(self, conf: MpdConfig) -> None: + self.config = conf print(f"Connecting to MPD server {conf.host}:{conf.port}...") await self.client.connect(conf.host, conf.port) if conf.password is not None: @@ -93,7 +111,7 @@ class MpdStateListener(Player): return state = MpdState(status, current, art) - song = mpd_state_to_song(state) + song = mpd_state_to_song(self.config, state) rprint(song) await self.update(song) diff --git a/src/mpd_now_playable/receivers/cocoa/now_playing.py b/src/mpd_now_playable/receivers/cocoa/now_playing.py index d207b65..c67c733 100644 --- a/src/mpd_now_playable/receivers/cocoa/now_playing.py +++ b/src/mpd_now_playable/receivers/cocoa/now_playing.py @@ -25,6 +25,7 @@ from MediaPlayer import ( MPNowPlayingInfoCenter, MPNowPlayingInfoMediaTypeAudio, MPNowPlayingInfoMediaTypeNone, + MPNowPlayingInfoPropertyAssetURL, MPNowPlayingInfoPropertyElapsedPlaybackTime, MPNowPlayingInfoPropertyExternalContentIdentifier, MPNowPlayingInfoPropertyMediaType, @@ -106,6 +107,9 @@ def song_to_media_item(song: Song) -> NSMutableDictionary: nowplaying_info[MPMediaItemPropertyComposer] = join_plural_field(song.composer) nowplaying_info[MPMediaItemPropertyPlaybackDuration] = song.duration + if song.url is not None: + nowplaying_info[MPNowPlayingInfoPropertyAssetURL] = song.url.human_repr() + # MPD can't play back music at different rates, so we just want to set it # to 1.0 if the song is playing. (Leave it at 0.0 if the song is paused.) if song.state == PlaybackState.play: diff --git a/src/mpd_now_playable/receivers/websockets/receiver.py b/src/mpd_now_playable/receivers/websockets/receiver.py index 22c35bb..5cbce3d 100644 --- a/src/mpd_now_playable/receivers/websockets/receiver.py +++ b/src/mpd_now_playable/receivers/websockets/receiver.py @@ -3,6 +3,7 @@ from pathlib import Path import ormsgpack from websockets import broadcast from websockets.server import WebSocketServerProtocol, serve +from yarl import URL from ...config.model import WebsocketsReceiverConfig from ...player import Player @@ -15,6 +16,8 @@ MSGPACK_NULL = ormsgpack.packb(None) def default(value: object) -> object: if isinstance(value, Path): return str(value) + if isinstance(value, URL): + return value.human_repr() raise TypeError diff --git a/src/mpd_now_playable/song/song.py b/src/mpd_now_playable/song/song.py index 716dff0..3b2bbe1 100644 --- a/src/mpd_now_playable/song/song.py +++ b/src/mpd_now_playable/song/song.py @@ -3,6 +3,7 @@ from enum import StrEnum from pathlib import Path from ..tools.schema.define import schema +from ..tools.schema.fields import Url from .artwork import Artwork from .musicbrainz import MusicBrainzIds @@ -14,7 +15,7 @@ class PlaybackState(StrEnum): @schema("https://cdn.00dani.me/m/schemata/mpd-now-playable/song-v1.json") -@dataclass(slots=True) +@dataclass(slots=True, kw_only=True) class Song: #: Whether MPD is currently playing, paused, or stopped. Pretty simple. state: PlaybackState @@ -30,6 +31,13 @@ class Song: #: places, so you can safely do the same. file: Path + #: An absolute URL referring to the current song, if available. If the + #: song's a local file and its absolute path can be determined + #: (mpd-now-playable has been configured with your music directory), then + #: this field will contain a file:// URL. If the song's remote, then MPD + #: itself returns an absolute URL in the first place. + url: Url | None = None + #: The song's title, if it's been tagged with one. Currently only one title #: is supported, since it doesn't make a lot of sense to tag a single audio #: file with multiple titles. diff --git a/src/mpd_now_playable/config/fields.py b/src/mpd_now_playable/tools/schema/fields.py similarity index 77% rename from src/mpd_now_playable/config/fields.py rename to src/mpd_now_playable/tools/schema/fields.py index f73a7f9..f171e28 100644 --- a/src/mpd_now_playable/config/fields.py +++ b/src/mpd_now_playable/tools/schema/fields.py @@ -1,7 +1,9 @@ +from os.path import expanduser from typing import Annotated, NewType from annotated_types import Ge, Le from pydantic import ( + BeforeValidator, Field, PlainSerializer, PlainValidator, @@ -9,9 +11,12 @@ from pydantic import ( Strict, WithJsonSchema, ) +from pydantic import ( + DirectoryPath as DirectoryType, +) from yarl import URL as Yarl -__all__ = ("Host", "Password", "Port", "Url") +__all__ = ("DirectoryPath", "Host", "Password", "Port", "Url") def from_yarl(url: Yarl) -> str: @@ -24,6 +29,7 @@ def to_yarl(value: object) -> Yarl: raise NotImplementedError(f"Cannot convert {type(object)} to URL") +DirectoryPath = Annotated[DirectoryType, BeforeValidator(expanduser)] Host = NewType( "Host", Annotated[str, Strict(), Field(json_schema_extra={"format": "hostname"})] ) diff --git a/stubs/MediaPlayer/__init__.pyi b/stubs/MediaPlayer/__init__.pyi index 01682fe..86c1226 100644 --- a/stubs/MediaPlayer/__init__.pyi +++ b/stubs/MediaPlayer/__init__.pyi @@ -34,6 +34,7 @@ MPNowPlayingInfoPropertyPlaybackQueueIndex: Final = ( MPNowPlayingInfoPropertyElapsedPlaybackTime: Final = ( "MPNowPlayingInfoPropertyElapsedPlaybackTime" ) +MPNowPlayingInfoPropertyAssetURL: Final = "MPNowPlayingInfoPropertyAssetURL" MPNowPlayingInfoPropertyExternalContentIdentifier: Final = ( "MPNowPlayingInfoPropertyExternalContentIdentifier" ) From 012bc0b02574dfc3e80bdf28a4bf87b0ee850018 Mon Sep 17 00:00:00 2001 From: Danielle McLean Date: Tue, 23 Jul 2024 13:34:50 +1000 Subject: [PATCH 18/32] Generate serialisation schema for songs, not validation schema --- src/mpd_now_playable/tools/schema/generate.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/mpd_now_playable/tools/schema/generate.py b/src/mpd_now_playable/tools/schema/generate.py index ec1b539..a8d71ef 100644 --- a/src/mpd_now_playable/tools/schema/generate.py +++ b/src/mpd_now_playable/tools/schema/generate.py @@ -11,8 +11,8 @@ from .define import ModelWithSchema __all__ = ("write",) -def write(model: ModelWithSchema) -> None: - schema = model.schema.json_schema(schema_generator=MyGenerateJsonSchema) +def write(model: ModelWithSchema, mode: JsonSchemaMode = "validation") -> None: + schema = model.schema.json_schema(schema_generator=MyGenerateJsonSchema, mode=mode) schema["$id"] = model.id.human_repr() schema_file = Path(__file__).parents[4] / "schemata" / model.id.name print(f"Writing this schema to {schema_file}") @@ -46,8 +46,10 @@ class MyGenerateJsonSchema(GenerateJsonSchema): def nullable_schema(self, schema: s.NullableSchema) -> JsonSchemaValue: return self.generate_inner(schema["schema"]) -if __name__ == '__main__': + +if __name__ == "__main__": from ...config.model import Config from ...song import Song + write(Config) - write(Song) + write(Song, mode="serialization") From dbd507bccb46a8396acab48999be51fd074018f2 Mon Sep 17 00:00:00 2001 From: Danielle McLean Date: Tue, 23 Jul 2024 13:38:41 +1000 Subject: [PATCH 19/32] Support current songs with no duration, such as streams --- src/mpd_now_playable/mpd/listener.py | 2 +- src/mpd_now_playable/mpd/types.py | 7 +++++-- src/mpd_now_playable/song/song.py | 5 +++-- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/mpd_now_playable/mpd/listener.py b/src/mpd_now_playable/mpd/listener.py index 01a1aaf..75852f7 100644 --- a/src/mpd_now_playable/mpd/listener.py +++ b/src/mpd_now_playable/mpd/listener.py @@ -49,7 +49,7 @@ def mpd_state_to_song(config: MpdConfig, mpd: MpdState) -> Song: genre=un_maybe_plural(mpd.current.get("genre")), track=option_fmap(int, mpd.current.get("track")), disc=option_fmap(int, mpd.current.get("disc")), - duration=float(mpd.status["duration"]), + duration=option_fmap(float, mpd.status.get("duration")), elapsed=float(mpd.status["elapsed"]), musicbrainz=to_brainz(mpd.current), art=to_artwork(mpd.art), diff --git a/src/mpd_now_playable/mpd/types.py b/src/mpd_now_playable/mpd/types.py index 98f6612..8d9db81 100644 --- a/src/mpd_now_playable/mpd/types.py +++ b/src/mpd_now_playable/mpd/types.py @@ -20,8 +20,11 @@ OneshotFlag = Literal[BooleanFlag, "oneshot"] class StatusResponse(TypedDict): state: Literal["play", "stop", "pause"] - # The total duration and elapsed playback of the current song, measured in seconds. Fractional seconds are allowed. - duration: str + # The total duration and elapsed playback of the current song, measured in + # seconds. Fractional seconds are allowed. The duration field may be + # omitted because MPD cannot determine the duration of certain sources, + # such as Internet radio streams. + duration: NotRequired[str] elapsed: str # The volume value ranges from 0-100. It may be omitted from diff --git a/src/mpd_now_playable/song/song.py b/src/mpd_now_playable/song/song.py index 3b2bbe1..45c4425 100644 --- a/src/mpd_now_playable/song/song.py +++ b/src/mpd_now_playable/song/song.py @@ -69,8 +69,9 @@ class Song: genre: list[str] #: The song's duration as read from its tags, measured in seconds. - #: Fractional seconds are allowed. - duration: float + #: Fractional seconds are allowed. The duration may be unavailable for some + #: sources, such as internet radio streams. + duration: float | None #: How far into the song MPD is, measured in seconds. Fractional seconds #: are allowed. This is usually going to be less than or equal to the From 085bca79742b6c56e51071cb60d83c15a5f35bdb Mon Sep 17 00:00:00 2001 From: Danielle McLean Date: Fri, 26 Jul 2024 09:49:45 +1000 Subject: [PATCH 20/32] Declare nextsong index as part of MPD status response --- src/mpd_now_playable/mpd/types.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/mpd_now_playable/mpd/types.py b/src/mpd_now_playable/mpd/types.py index 8d9db81..a7401c1 100644 --- a/src/mpd_now_playable/mpd/types.py +++ b/src/mpd_now_playable/mpd/types.py @@ -45,6 +45,10 @@ class StatusResponse(TypedDict): # The total number of items in the play queue, which is called the "playlist" throughout the MPD protocol for legacy reasons. playlistlength: str + # The zero-based index of the song that will play when the current song + # ends, taking into account repeat and random playback settings. + nextsong: str + # The format of decoded audio MPD is producing, expressed as a string in the form "samplerate:bits:channels". audio: str From 68609f3d071ba8ea1bc9dfa9fe7aa83c615ee968 Mon Sep 17 00:00:00 2001 From: Danielle McLean Date: Fri, 26 Jul 2024 09:53:17 +1000 Subject: [PATCH 21/32] Wrap Song in a broader Playback state object with stuff like volume and repeat mode --- schemata/playback-v1.json | 355 ++++++++++++++++++ schemata/song-v1.json | 32 +- src/mpd_now_playable/mpd/convert/__init__.py | 0 .../mpd/convert/to_playback.py | 37 ++ src/mpd_now_playable/mpd/convert/to_song.py | 51 +++ src/mpd_now_playable/mpd/listener.py | 69 +--- src/mpd_now_playable/playback/__init__.py | 3 + src/mpd_now_playable/playback/playback.py | 36 ++ src/mpd_now_playable/playback/queue.py | 13 + src/mpd_now_playable/playback/settings.py | 48 +++ src/mpd_now_playable/playback/state.py | 7 + src/mpd_now_playable/player.py | 23 +- .../receivers/cocoa/convert/__init__.py | 0 .../cocoa/convert/playback_to_media_item.py | 33 ++ .../cocoa/convert/song_to_media_item.py | 61 +++ .../receivers/cocoa/convert/to_nsimage.py | 40 ++ .../receivers/cocoa/convert/to_state.py | 19 + .../receivers/cocoa/now_playing.py | 131 +------ .../receivers/websockets/receiver.py | 13 +- src/mpd_now_playable/song/__init__.py | 5 +- src/mpd_now_playable/song/song.py | 19 +- src/mpd_now_playable/song/stopped.py | 9 + src/mpd_now_playable/song_receiver.py | 4 +- src/mpd_now_playable/tools/schema/generate.py | 2 + 24 files changed, 765 insertions(+), 245 deletions(-) create mode 100644 schemata/playback-v1.json create mode 100644 src/mpd_now_playable/mpd/convert/__init__.py create mode 100644 src/mpd_now_playable/mpd/convert/to_playback.py create mode 100644 src/mpd_now_playable/mpd/convert/to_song.py create mode 100644 src/mpd_now_playable/playback/__init__.py create mode 100644 src/mpd_now_playable/playback/playback.py create mode 100644 src/mpd_now_playable/playback/queue.py create mode 100644 src/mpd_now_playable/playback/settings.py create mode 100644 src/mpd_now_playable/playback/state.py create mode 100644 src/mpd_now_playable/receivers/cocoa/convert/__init__.py create mode 100644 src/mpd_now_playable/receivers/cocoa/convert/playback_to_media_item.py create mode 100644 src/mpd_now_playable/receivers/cocoa/convert/song_to_media_item.py create mode 100644 src/mpd_now_playable/receivers/cocoa/convert/to_nsimage.py create mode 100644 src/mpd_now_playable/receivers/cocoa/convert/to_state.py create mode 100644 src/mpd_now_playable/song/stopped.py diff --git a/schemata/playback-v1.json b/schemata/playback-v1.json new file mode 100644 index 0000000..245d92b --- /dev/null +++ b/schemata/playback-v1.json @@ -0,0 +1,355 @@ +{ + "$defs": { + "HasArtwork": { + "properties": { + "data": { + "format": "binary", + "title": "Data", + "type": "string" + } + }, + "required": [ + "data" + ], + "title": "HasArtwork", + "type": "object" + }, + "MusicBrainzIds": { + "properties": { + "artist": { + "description": "A MusicBrainz artist is pretty intuitively the artist who recorded the song. This particular ID refers to the individual recording's artist or artists, which may be distinct from the release artist below when a release contains recordings from many different artists. https://musicbrainz.org/doc/Artist", + "items": { + "format": "uuid", + "type": "string" + }, + "title": "Artist", + "type": "array" + }, + "recording": { + "description": "A MusicBrainz recording represents audio from a specific performance. For example, if the same song was released as a studio recording and as a live performance, those two versions of the song are different recordings. The song itself is considered a \"work\", of which two recordings were made. However, recordings are not always associated with a work in the MusicBrainz database, and Picard won't load work IDs by default (you have to enable \"use track relationships\" in the options), so recording IDs are a much more reliable way to identify a particular song. https://musicbrainz.org/doc/Recording", + "format": "uuid", + "title": "Recording", + "type": "string" + }, + "release": { + "description": "A MusicBrainz release roughly corresponds to an \"album\", and indeed is stored in a tag called MUSICBRAINZ_ALBUMID. The more general name is meant to encompass all the different ways music can be released. https://musicbrainz.org/doc/Release", + "items": { + "format": "uuid", + "type": "string" + }, + "title": "Release", + "type": "array" + }, + "release_artist": { + "description": "Again, the release artist corresponds to an \"album artist\". These MBIDs refer to the same artists in the MusicBrainz database that individual recordings' artist MBIDs do.", + "items": { + "format": "uuid", + "type": "string" + }, + "title": "Release Artist", + "type": "array" + }, + "release_group": { + "description": "A MusicBrainz release group roughly corresponds to \"all the editions of a particular album\". For example, if the same album were released on CD, vinyl records, and as a digital download, then all of those would be different releases but share a release group. Note that MPD's support for this tag is relatively new (July 2023) and doesn't seem especially reliable, so it might be missing here even if your music has been tagged with it. Not sure why. https://musicbrainz.org/doc/Release_Group", + "format": "uuid", + "title": "Release Group", + "type": "string" + }, + "track": { + "description": "A MusicBrainz track represents a specific instance of a recording appearing as part of some release. For example, if the same song appears on both two-CD and four-CD versions of a soundtrack, then it will be considered the same \"recording\" in both cases, but different \"tracks\". https://musicbrainz.org/doc/Track", + "format": "uuid", + "title": "Track", + "type": "string" + }, + "work": { + "description": "A MusicBrainz work represents the idea of a particular song or creation (it doesn't have to be audio). Each work may have multiple recordings (studio versus live, different performers, etc.), with the work ID grouping them together. https://musicbrainz.org/doc/Work", + "format": "uuid", + "title": "Work", + "type": "string" + } + }, + "required": [ + "artist", + "release", + "release_artist" + ], + "title": "MusicBrainzIds", + "type": "object" + }, + "NoArtwork": { + "properties": {}, + "title": "NoArtwork", + "type": "object" + }, + "Queue": { + "properties": { + "current": { + "description": "The zero-based index of the current song in MPD's queue.", + "title": "Current", + "type": "integer" + }, + "length": { + "description": "The total length of MPD's queue - the last song in the queue will have the index one less than this, since queue indices are zero-based.", + "title": "Length", + "type": "integer" + }, + "next": { + "description": "The index of the next song to be played, taking into account random and repeat playback settings.", + "title": "Next", + "type": "integer" + } + }, + "required": [ + "current", + "next", + "length" + ], + "title": "Queue", + "type": "object" + }, + "Settings": { + "properties": { + "consume": { + "anyOf": [ + { + "type": "boolean" + }, + { + "const": "oneshot", + "enum": [ + "oneshot" + ], + "type": "string" + } + ], + "description": "Remove songs from the queue as they're played. This flag can also be set to \"oneshot\", which means the currently playing song will be consumed, and then the flag will automatically be switched off.", + "title": "Consume" + }, + "random": { + "description": "Play the queued songs in random order. This is distinct from shuffling the queue, which randomises the queue's order once when you send the shuffle command and will then play the queue in that new order repeatedly if asked. If MPD is asked to both repeat and randomise, the queue is effectively shuffled each time it loops.", + "title": "Random", + "type": "boolean" + }, + "repeat": { + "description": "Repeat playback of the queued songs. This setting normally means the entire queue will be played on repeat, but its behaviour can be influenced by the other playback mode flags.", + "title": "Repeat", + "type": "boolean" + }, + "single": { + "anyOf": [ + { + "type": "boolean" + }, + { + "const": "oneshot", + "enum": [ + "oneshot" + ], + "type": "string" + } + ], + "description": "Play only a single song. If MPD is asked to repeat, then the current song will be played repeatedly. Otherwise, when the current song ends MPD will simply stop playback. Like the consume flag, the single flag can also be set to \"oneshot\", which will cause the single flag to be switched off after it takes effect once (either the current song will repeat just once, or playback will stop but the single flag will be switched off).", + "title": "Single" + }, + "volume": { + "description": "The playback volume ranging from 0 to 100 - it will only be available if MPD has a volume mixer configured.", + "title": "Volume", + "type": "integer" + } + }, + "required": [ + "volume", + "repeat", + "random", + "single", + "consume" + ], + "title": "Settings", + "type": "object" + }, + "Song": { + "properties": { + "album": { + "description": "The name of the song's containing album, which may be multivalued.", + "items": { + "type": "string" + }, + "title": "Album", + "type": "array" + }, + "album_artist": { + "description": "The album's artists. This is often used to group together songs from a single album that featured different artists.", + "items": { + "type": "string" + }, + "title": "Album Artist", + "type": "array" + }, + "art": { + "anyOf": [ + { + "$ref": "#/$defs/HasArtwork" + }, + { + "$ref": "#/$defs/NoArtwork" + } + ], + "description": "The song's cover art, if it has any - the art will be available as bytes if present, ready to be displayed directly by receivers.", + "title": "Art" + }, + "artist": { + "description": "The song's artists. Will be an empty list if the song has not been tagged with an artist, and may contain multiple values if the song has been tagged with several artists.", + "items": { + "type": "string" + }, + "title": "Artist", + "type": "array" + }, + "composer": { + "description": "The song's composers. Again, this is permitted to be multivalued.", + "items": { + "type": "string" + }, + "title": "Composer", + "type": "array" + }, + "disc": { + "description": "The disc number of the song on its album. As with the track number, this is usually one-based, but it doesn't have to be.", + "title": "Disc", + "type": "integer" + }, + "duration": { + "description": "The song's duration as read from its tags, measured in seconds. Fractional seconds are allowed. The duration may be unavailable for some sources, such as internet radio streams.", + "title": "Duration", + "type": "number" + }, + "elapsed": { + "description": "How far into the song MPD is, measured in seconds. Fractional seconds are allowed. This is usually going to be less than or equal to the song's duration, but because the duration is tagged as metadata and this value represents the actual elapsed time, it might go higher if the song's duration tag is inaccurate.", + "title": "Elapsed", + "type": "number" + }, + "file": { + "description": "The relative path to the current song inside the music directory. MPD itself uses this path as a stable identifier for the audio file in many places, so you can safely do the same.", + "format": "path", + "title": "File", + "type": "string" + }, + "genre": { + "description": "The song's genre or genres. These are completely arbitrary descriptions and don't follow any particular standard.", + "items": { + "type": "string" + }, + "title": "Genre", + "type": "array" + }, + "musicbrainz": { + "$ref": "#/$defs/MusicBrainzIds", + "description": "The MusicBrainz IDs associated with the song and with its artist and album, which if present are an extremely accurate way to identify a given song. They're not always present, though." + }, + "state": { + "description": "Whether MPD is currently playing or paused. Pretty simple.", + "enum": [ + "play", + "pause" + ], + "title": "State", + "type": "string" + }, + "title": { + "description": "The song's title, if it's been tagged with one. Currently only one title is supported, since it doesn't make a lot of sense to tag a single audio file with multiple titles.", + "title": "Title", + "type": "string" + }, + "track": { + "description": "The track number the song has on its album. This is usually one-based, but it's just an arbitrary audio tag so a particular album might start at zero or do something weird with it.", + "title": "Track", + "type": "integer" + }, + "url": { + "description": "An absolute URL referring to the current song, if available. If the song's a local file and its absolute path can be determined (mpd-now-playable has been configured with your music directory), then this field will contain a file:// URL. If the song's remote, then MPD itself returns an absolute URL in the first place.", + "format": "uri", + "title": "Url", + "type": "string" + } + }, + "required": [ + "state", + "file", + "title", + "artist", + "composer", + "album", + "album_artist", + "track", + "disc", + "genre", + "duration", + "elapsed", + "art", + "musicbrainz" + ], + "title": "Song", + "type": "object" + }, + "Stopped": { + "properties": { + "state": { + "const": "stop", + "default": "stop", + "enum": [ + "stop" + ], + "title": "State", + "type": "string" + } + }, + "title": "Stopped", + "type": "object" + } + }, + "$id": "https://static.00dani.me/m/schemata/mpd-now-playable/playback-v1.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "partition": { + "description": "The MPD partition this playback information came from. Essentially, MPD can act as multiple music player servers simultaneously, distinguished by name. For most users, this will always be \"default\".", + "title": "Partition", + "type": "string" + }, + "queue": { + "$ref": "#/$defs/Queue", + "description": "Stats about MPD's song queue, including the current song and next song's indices in it." + }, + "settings": { + "$ref": "#/$defs/Settings", + "description": "Playback settings such as volume and repeat mode." + }, + "song": { + "description": "Information about the current song itself. MPD provides none of this information if its playback is currently stopped, so mpd-now-playable doesn't either and will give you a Stopped instead in that case.", + "discriminator": { + "mapping": { + "pause": "#/$defs/Song", + "play": "#/$defs/Song", + "stop": "#/$defs/Stopped" + }, + "propertyName": "state" + }, + "oneOf": [ + { + "$ref": "#/$defs/Song" + }, + { + "$ref": "#/$defs/Stopped" + } + ], + "title": "Song" + } + }, + "required": [ + "song", + "partition", + "queue", + "settings" + ], + "title": "Playback", + "type": "object" +} diff --git a/schemata/song-v1.json b/schemata/song-v1.json index 6ecc4ec..5c03ca4 100644 --- a/schemata/song-v1.json +++ b/schemata/song-v1.json @@ -80,15 +80,6 @@ "properties": {}, "title": "NoArtwork", "type": "object" - }, - "PlaybackState": { - "enum": [ - "play", - "pause", - "stop" - ], - "title": "PlaybackState", - "type": "string" } }, "$id": "https://cdn.00dani.me/m/schemata/mpd-now-playable/song-v1.json", @@ -144,7 +135,7 @@ "type": "integer" }, "duration": { - "description": "The song's duration as read from its tags, measured in seconds. Fractional seconds are allowed.", + "description": "The song's duration as read from its tags, measured in seconds. Fractional seconds are allowed. The duration may be unavailable for some sources, such as internet radio streams.", "title": "Duration", "type": "number" }, @@ -171,19 +162,14 @@ "$ref": "#/$defs/MusicBrainzIds", "description": "The MusicBrainz IDs associated with the song and with its artist and album, which if present are an extremely accurate way to identify a given song. They're not always present, though." }, - "queue_index": { - "description": "The zero-based index of the current song in MPD's queue.", - "title": "Queue Index", - "type": "integer" - }, - "queue_length": { - "description": "The total length of MPD's queue - the last song in the queue will have the index one less than this, since queue indices are zero-based.", - "title": "Queue Length", - "type": "integer" - }, "state": { - "$ref": "#/$defs/PlaybackState", - "description": "Whether MPD is currently playing, paused, or stopped. Pretty simple." + "description": "Whether MPD is currently playing or paused. Pretty simple.", + "enum": [ + "play", + "pause" + ], + "title": "State", + "type": "string" }, "title": { "description": "The song's title, if it's been tagged with one. Currently only one title is supported, since it doesn't make a lot of sense to tag a single audio file with multiple titles.", @@ -204,8 +190,6 @@ }, "required": [ "state", - "queue_index", - "queue_length", "file", "title", "artist", diff --git a/src/mpd_now_playable/mpd/convert/__init__.py b/src/mpd_now_playable/mpd/convert/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/mpd_now_playable/mpd/convert/to_playback.py b/src/mpd_now_playable/mpd/convert/to_playback.py new file mode 100644 index 0000000..3157829 --- /dev/null +++ b/src/mpd_now_playable/mpd/convert/to_playback.py @@ -0,0 +1,37 @@ +from ...config.model import MpdConfig +from ...playback import Playback +from ...playback.queue import Queue +from ...playback.settings import Settings, to_oneshot +from ...tools.types import option_fmap +from ..types import MpdState +from .to_song import to_song + + +def to_queue(mpd: MpdState) -> Queue: + return Queue( + current=int(mpd.current["pos"]), + next=int(mpd.status["nextsong"]), + length=int(mpd.status["playlistlength"]), + ) + + +def to_settings(mpd: MpdState) -> Settings: + return Settings( + volume=option_fmap(int, mpd.status.get("volume")), + repeat=mpd.status["repeat"] == "1", + random=mpd.status["random"] == "1", + single=to_oneshot(mpd.status["single"]), + consume=to_oneshot(mpd.status["consume"]), + ) + + +def to_playback(config: MpdConfig, mpd: MpdState) -> Playback: + partition = mpd.status["partition"] + queue = to_queue(mpd) + settings = to_settings(mpd) + return Playback( + partition=partition, + queue=queue, + settings=settings, + song=to_song(config, mpd), + ) diff --git a/src/mpd_now_playable/mpd/convert/to_song.py b/src/mpd_now_playable/mpd/convert/to_song.py new file mode 100644 index 0000000..862c7c7 --- /dev/null +++ b/src/mpd_now_playable/mpd/convert/to_song.py @@ -0,0 +1,51 @@ +from pathlib import Path + +from yarl import URL + +from ...config.model import MpdConfig +from ...playback.state import PlaybackState +from ...song import Song, Stopped, to_artwork, to_brainz +from ...tools.types import option_fmap, un_maybe_plural +from ..types import MpdState + + +def file_to_url(config: MpdConfig, file: str) -> URL | None: + url = URL(file) + if url.scheme != "": + # We already got an absolute URL - probably a stream? - so we can just return it. + return url + + if not config.music_directory: + # We have a relative song URI, but we can't make it absolute since no music directory is configured. + return None + + # Prepend the configured music directory, then turn the whole path into a file:// URL. + abs_file = config.music_directory / file + return URL(abs_file.as_uri()) + + +def to_song(config: MpdConfig, mpd: MpdState) -> Song | Stopped: + state = PlaybackState(mpd.status["state"]) + if state == PlaybackState.stop: + return Stopped() + + file = mpd.current["file"] + url = file_to_url(config, file) + + return Song( + state=state, + file=Path(file), + url=url, + title=mpd.current.get("title"), + artist=un_maybe_plural(mpd.current.get("artist")), + album=un_maybe_plural(mpd.current.get("album")), + album_artist=un_maybe_plural(mpd.current.get("albumartist")), + composer=un_maybe_plural(mpd.current.get("composer")), + genre=un_maybe_plural(mpd.current.get("genre")), + track=option_fmap(int, mpd.current.get("track")), + disc=option_fmap(int, mpd.current.get("disc")), + duration=option_fmap(float, mpd.status.get("duration")), + elapsed=float(mpd.status["elapsed"]), + musicbrainz=to_brainz(mpd.current), + art=to_artwork(mpd.art), + ) diff --git a/src/mpd_now_playable/mpd/listener.py b/src/mpd_now_playable/mpd/listener.py index 75852f7..23f774b 100644 --- a/src/mpd_now_playable/mpd/listener.py +++ b/src/mpd_now_playable/mpd/listener.py @@ -1,6 +1,5 @@ import asyncio from collections.abc import Iterable -from pathlib import Path from mpd.asyncio import MPDClient from mpd.base import CommandError @@ -8,54 +7,15 @@ from rich import print as rprint from yarl import URL from ..config.model import MpdConfig +from ..playback import Playback +from ..playback.state import PlaybackState from ..player import Player -from ..song import PlaybackState, Song, to_artwork, to_brainz from ..song_receiver import Receiver -from ..tools.types import option_fmap, un_maybe_plural from .artwork_cache import MpdArtworkCache +from .convert.to_playback import to_playback from .types import MpdState -def mpd_file_to_uri(config: MpdConfig, file: str) -> URL | None: - url = URL(file) - if url.scheme != "": - # We already got an absolute URL - probably a stream? - so we can just return it. - return url - - if not config.music_directory: - # We have a relative song URI, but we can't make it absolute since no music directory is configured. - return None - - # Prepend the configured music directory, then turn the whole path into a file:// URL. - abs_file = config.music_directory / file - return URL(abs_file.as_uri()) - - -def mpd_state_to_song(config: MpdConfig, mpd: MpdState) -> Song: - file = mpd.current["file"] - url = mpd_file_to_uri(config, file) - - return Song( - state=PlaybackState(mpd.status["state"]), - queue_index=int(mpd.current["pos"]), - queue_length=int(mpd.status["playlistlength"]), - file=Path(file), - url=url, - title=mpd.current.get("title"), - artist=un_maybe_plural(mpd.current.get("artist")), - album=un_maybe_plural(mpd.current.get("album")), - album_artist=un_maybe_plural(mpd.current.get("albumartist")), - composer=un_maybe_plural(mpd.current.get("composer")), - genre=un_maybe_plural(mpd.current.get("genre")), - track=option_fmap(int, mpd.current.get("track")), - disc=option_fmap(int, mpd.current.get("disc")), - duration=option_fmap(float, mpd.status.get("duration")), - elapsed=float(mpd.status["elapsed"]), - musicbrainz=to_brainz(mpd.current), - art=to_artwork(mpd.art), - ) - - class MpdStateListener(Player): config: MpdConfig client: MPDClient @@ -101,22 +61,19 @@ class MpdStateListener(Player): if starting_idle_count != self.idle_count: return - if status["state"] == "stop": - print("Nothing playing") - await self.update(None) - return - - art = await self.art_cache.get_cached_artwork(current) - if starting_idle_count != self.idle_count: - return + art = None + if status["state"] != "stop": + art = await self.art_cache.get_cached_artwork(current) + if starting_idle_count != self.idle_count: + return state = MpdState(status, current, art) - song = mpd_state_to_song(self.config, state) - rprint(song) - await self.update(song) + pb = to_playback(self.config, state) + rprint(pb) + await self.update(pb) - async def update(self, song: Song | None) -> None: - await asyncio.gather(*(r.update(song) for r in self.receivers)) + async def update(self, playback: Playback) -> None: + await asyncio.gather(*(r.update(playback) for r in self.receivers)) async def get_art(self, file: str) -> bytes | None: picture = await self.readpicture(file) diff --git a/src/mpd_now_playable/playback/__init__.py b/src/mpd_now_playable/playback/__init__.py new file mode 100644 index 0000000..b67b174 --- /dev/null +++ b/src/mpd_now_playable/playback/__init__.py @@ -0,0 +1,3 @@ +from .playback import Playback + +__all__ = ("Playback",) diff --git a/src/mpd_now_playable/playback/playback.py b/src/mpd_now_playable/playback/playback.py new file mode 100644 index 0000000..e16d274 --- /dev/null +++ b/src/mpd_now_playable/playback/playback.py @@ -0,0 +1,36 @@ +from dataclasses import dataclass + +from pydantic import Field + +from ..song.song import Song +from ..song.stopped import Stopped +from ..tools.schema.define import schema +from .queue import Queue +from .settings import Settings + + +@schema("https://static.00dani.me/m/schemata/mpd-now-playable/playback-v1.json") +@dataclass(slots=True, kw_only=True) +class Playback: + #: The MPD partition this playback information came from. Essentially, MPD + #: can act as multiple music player servers simultaneously, distinguished + #: by name. For most users, this will always be "default". + partition: str + + #: Stats about MPD's song queue, including the current song and next song's + #: indices in it. + queue: Queue + + #: Playback settings such as volume and repeat mode. + settings: Settings + + #: Information about the current song itself. MPD provides none of this + #: information if its playback is currently stopped, so mpd-now-playable + #: doesn't either and will give you a Stopped instead in that case. + song: Song | Stopped = Field(discriminator="state") + + @property + def active_song(self) -> Song | None: + if isinstance(self.song, Song): + return self.song + return None diff --git a/src/mpd_now_playable/playback/queue.py b/src/mpd_now_playable/playback/queue.py new file mode 100644 index 0000000..7c93741 --- /dev/null +++ b/src/mpd_now_playable/playback/queue.py @@ -0,0 +1,13 @@ +from dataclasses import dataclass + + +@dataclass(slots=True) +class Queue: + #: The zero-based index of the current song in MPD's queue. + current: int + #: The index of the next song to be played, taking into account random and + #: repeat playback settings. + next: int + #: The total length of MPD's queue - the last song in the queue will have + #: the index one less than this, since queue indices are zero-based. + length: int diff --git a/src/mpd_now_playable/playback/settings.py b/src/mpd_now_playable/playback/settings.py new file mode 100644 index 0000000..eba3459 --- /dev/null +++ b/src/mpd_now_playable/playback/settings.py @@ -0,0 +1,48 @@ +from dataclasses import dataclass +from typing import Literal + +OneShotFlag = bool | Literal["oneshot"] + + +def to_oneshot(value: str) -> OneShotFlag: + match value: + case "1": + return True + case "0": + return False + case "oneshot": + return "oneshot" + return False + + +@dataclass(slots=True, kw_only=True) +class Settings: + #: The playback volume ranging from 0 to 100 - it will only be available if + #: MPD has a volume mixer configured. + volume: int | None + + #: Repeat playback of the queued songs. This setting normally means the + #: entire queue will be played on repeat, but its behaviour can be + #: influenced by the other playback mode flags. + repeat: bool + + #: Play the queued songs in random order. This is distinct from shuffling + #: the queue, which randomises the queue's order once when you send the + #: shuffle command and will then play the queue in that new order + #: repeatedly if asked. If MPD is asked to both repeat and randomise, the + #: queue is effectively shuffled each time it loops. + random: bool + + #: Play only a single song. If MPD is asked to repeat, then the current + #: song will be played repeatedly. Otherwise, when the current song ends + #: MPD will simply stop playback. Like the consume flag, the single flag + #: can also be set to "oneshot", which will cause the single flag to be + #: switched off after it takes effect once (either the current song will + #: repeat just once, or playback will stop but the single flag will be + #: switched off). + single: OneShotFlag + + #: Remove songs from the queue as they're played. This flag can also be set + #: to "oneshot", which means the currently playing song will be consumed, + #: and then the flag will automatically be switched off. + consume: OneShotFlag diff --git a/src/mpd_now_playable/playback/state.py b/src/mpd_now_playable/playback/state.py new file mode 100644 index 0000000..3d19970 --- /dev/null +++ b/src/mpd_now_playable/playback/state.py @@ -0,0 +1,7 @@ +from enum import StrEnum + + +class PlaybackState(StrEnum): + play = "play" + pause = "pause" + stop = "stop" diff --git a/src/mpd_now_playable/player.py b/src/mpd_now_playable/player.py index fcadfa4..a5d35e3 100644 --- a/src/mpd_now_playable/player.py +++ b/src/mpd_now_playable/player.py @@ -1,26 +1,19 @@ from typing import Protocol -from .song import PlaybackState +from .playback.state import PlaybackState class Player(Protocol): - async def on_play_pause(self) -> PlaybackState: - ... + async def on_play_pause(self) -> PlaybackState: ... - async def on_play(self) -> PlaybackState: - ... + async def on_play(self) -> PlaybackState: ... - async def on_pause(self) -> PlaybackState: - ... + async def on_pause(self) -> PlaybackState: ... - async def on_stop(self) -> PlaybackState: - ... + async def on_stop(self) -> PlaybackState: ... - async def on_next(self) -> None: - ... + async def on_next(self) -> None: ... - async def on_prev(self) -> None: - ... + async def on_prev(self) -> None: ... - async def on_seek(self, position: float) -> None: - ... + async def on_seek(self, position: float) -> None: ... diff --git a/src/mpd_now_playable/receivers/cocoa/convert/__init__.py b/src/mpd_now_playable/receivers/cocoa/convert/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/mpd_now_playable/receivers/cocoa/convert/playback_to_media_item.py b/src/mpd_now_playable/receivers/cocoa/convert/playback_to_media_item.py new file mode 100644 index 0000000..7ac8a2b --- /dev/null +++ b/src/mpd_now_playable/receivers/cocoa/convert/playback_to_media_item.py @@ -0,0 +1,33 @@ +from Foundation import NSMutableDictionary +from MediaPlayer import ( + MPMediaItemPropertyArtwork, + MPMediaItemPropertyTitle, + MPNowPlayingInfoMediaTypeNone, + MPNowPlayingInfoPropertyMediaType, + MPNowPlayingInfoPropertyPlaybackQueueCount, + MPNowPlayingInfoPropertyPlaybackQueueIndex, + MPNowPlayingInfoPropertyPlaybackRate, +) + +from ....playback import Playback +from .song_to_media_item import song_to_media_item +from .to_nsimage import MPD_LOGO + + +def playback_to_media_item(playback: Playback) -> NSMutableDictionary: + nowplaying_info = nothing_to_media_item() + if song := playback.active_song: + nowplaying_info = song_to_media_item(song) + nowplaying_info[MPNowPlayingInfoPropertyPlaybackQueueCount] = playback.queue.length + nowplaying_info[MPNowPlayingInfoPropertyPlaybackQueueIndex] = playback.queue.current + return nowplaying_info + + +def nothing_to_media_item() -> NSMutableDictionary: + nowplaying_info = NSMutableDictionary.dictionary() + nowplaying_info[MPNowPlayingInfoPropertyMediaType] = MPNowPlayingInfoMediaTypeNone + nowplaying_info[MPMediaItemPropertyArtwork] = MPD_LOGO + nowplaying_info[MPMediaItemPropertyTitle] = "MPD (stopped)" + nowplaying_info[MPNowPlayingInfoPropertyPlaybackRate] = 0.0 + + return nowplaying_info diff --git a/src/mpd_now_playable/receivers/cocoa/convert/song_to_media_item.py b/src/mpd_now_playable/receivers/cocoa/convert/song_to_media_item.py new file mode 100644 index 0000000..f2ed28a --- /dev/null +++ b/src/mpd_now_playable/receivers/cocoa/convert/song_to_media_item.py @@ -0,0 +1,61 @@ +from Foundation import NSMutableDictionary +from MediaPlayer import ( + MPMediaItemPropertyAlbumTitle, + MPMediaItemPropertyAlbumTrackNumber, + MPMediaItemPropertyArtist, + MPMediaItemPropertyArtwork, + MPMediaItemPropertyComposer, + MPMediaItemPropertyDiscNumber, + MPMediaItemPropertyGenre, + MPMediaItemPropertyPersistentID, + MPMediaItemPropertyPlaybackDuration, + MPMediaItemPropertyTitle, + MPNowPlayingInfoMediaTypeAudio, + MPNowPlayingInfoPropertyAssetURL, + MPNowPlayingInfoPropertyElapsedPlaybackTime, + MPNowPlayingInfoPropertyExternalContentIdentifier, + MPNowPlayingInfoPropertyMediaType, + MPNowPlayingInfoPropertyPlaybackRate, +) + +from ....playback.state import PlaybackState +from ....song import Song +from ..persistent_id import song_to_persistent_id +from .to_nsimage import data_to_media_item_artwork + + +def join_plural_field(field: list[str]) -> str | None: + if field: + return ", ".join(field) + return None + + +def song_to_media_item(song: Song) -> NSMutableDictionary: + nowplaying_info = NSMutableDictionary.dictionary() + nowplaying_info[MPNowPlayingInfoPropertyMediaType] = MPNowPlayingInfoMediaTypeAudio + nowplaying_info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = song.elapsed + nowplaying_info[MPNowPlayingInfoPropertyExternalContentIdentifier] = str(song.file) + nowplaying_info[MPMediaItemPropertyPersistentID] = song_to_persistent_id(song) + + nowplaying_info[MPMediaItemPropertyTitle] = song.title + nowplaying_info[MPMediaItemPropertyArtist] = join_plural_field(song.artist) + nowplaying_info[MPMediaItemPropertyAlbumTitle] = join_plural_field(song.album) + nowplaying_info[MPMediaItemPropertyAlbumTrackNumber] = song.track + nowplaying_info[MPMediaItemPropertyDiscNumber] = song.disc + nowplaying_info[MPMediaItemPropertyGenre] = join_plural_field(song.genre) + nowplaying_info[MPMediaItemPropertyComposer] = join_plural_field(song.composer) + nowplaying_info[MPMediaItemPropertyPlaybackDuration] = song.duration + + if song.url is not None: + nowplaying_info[MPNowPlayingInfoPropertyAssetURL] = song.url.human_repr() + + # MPD can't play back music at different rates, so we just want to set it + # to 1.0 if the song is playing. (Set it to 0.0 if the song is paused.) + rate = 1.0 if song.state == PlaybackState.play else 0.0 + nowplaying_info[MPNowPlayingInfoPropertyPlaybackRate] = rate + + if song.art: + artwork = data_to_media_item_artwork(song.art.data) + nowplaying_info[MPMediaItemPropertyArtwork] = artwork + + return nowplaying_info diff --git a/src/mpd_now_playable/receivers/cocoa/convert/to_nsimage.py b/src/mpd_now_playable/receivers/cocoa/convert/to_nsimage.py new file mode 100644 index 0000000..213914d --- /dev/null +++ b/src/mpd_now_playable/receivers/cocoa/convert/to_nsimage.py @@ -0,0 +1,40 @@ +from pathlib import Path + +from AppKit import NSCompositingOperationCopy, NSImage, NSMakeRect +from Foundation import CGSize +from MediaPlayer import MPMediaItemArtwork + + +def logo_to_ns_image() -> NSImage: + return NSImage.alloc().initByReferencingFile_( + str(Path(__file__).parents[3] / "mpd/logo.svg") + ) + + +def data_to_ns_image(data: bytes) -> NSImage: + return NSImage.alloc().initWithData_(data) + + +def data_to_media_item_artwork(data: bytes) -> MPMediaItemArtwork: + return ns_image_to_media_item_artwork(data_to_ns_image(data)) + + +def ns_image_to_media_item_artwork(img: NSImage) -> MPMediaItemArtwork: + def resize(size: CGSize) -> NSImage: + new = NSImage.alloc().initWithSize_(size) + new.lockFocus() + img.drawInRect_fromRect_operation_fraction_( + NSMakeRect(0, 0, size.width, size.height), + NSMakeRect(0, 0, img.size().width, img.size().height), + NSCompositingOperationCopy, + 1.0, + ) + new.unlockFocus() + return new + + return MPMediaItemArtwork.alloc().initWithBoundsSize_requestHandler_( + img.size(), resize + ) + + +MPD_LOGO = logo_to_ns_image() diff --git a/src/mpd_now_playable/receivers/cocoa/convert/to_state.py b/src/mpd_now_playable/receivers/cocoa/convert/to_state.py new file mode 100644 index 0000000..7d2bb60 --- /dev/null +++ b/src/mpd_now_playable/receivers/cocoa/convert/to_state.py @@ -0,0 +1,19 @@ +from MediaPlayer import ( + MPMusicPlaybackState, + MPMusicPlaybackStatePaused, + MPMusicPlaybackStatePlaying, + MPMusicPlaybackStateStopped, +) + +from ....playback.state import PlaybackState + +__all__ = ("playback_state_to_cocoa",) + + +def playback_state_to_cocoa(state: PlaybackState) -> MPMusicPlaybackState: + mapping: dict[PlaybackState, MPMusicPlaybackState] = { + PlaybackState.play: MPMusicPlaybackStatePlaying, + PlaybackState.pause: MPMusicPlaybackStatePaused, + PlaybackState.stop: MPMusicPlaybackStateStopped, + } + return mapping[state] diff --git a/src/mpd_now_playable/receivers/cocoa/now_playing.py b/src/mpd_now_playable/receivers/cocoa/now_playing.py index c67c733..602006a 100644 --- a/src/mpd_now_playable/receivers/cocoa/now_playing.py +++ b/src/mpd_now_playable/receivers/cocoa/now_playing.py @@ -1,37 +1,11 @@ from collections.abc import Callable, Coroutine -from pathlib import Path from typing import Literal -from AppKit import NSCompositingOperationCopy, NSImage, NSMakeRect from corefoundationasyncio import CoreFoundationEventLoop -from Foundation import CGSize, NSMutableDictionary from MediaPlayer import ( MPChangePlaybackPositionCommandEvent, - MPMediaItemArtwork, - MPMediaItemPropertyAlbumTitle, - MPMediaItemPropertyAlbumTrackNumber, - MPMediaItemPropertyArtist, - MPMediaItemPropertyArtwork, - MPMediaItemPropertyComposer, - MPMediaItemPropertyDiscNumber, - MPMediaItemPropertyGenre, - MPMediaItemPropertyPersistentID, - MPMediaItemPropertyPlaybackDuration, - MPMediaItemPropertyTitle, - MPMusicPlaybackState, - MPMusicPlaybackStatePaused, MPMusicPlaybackStatePlaying, - MPMusicPlaybackStateStopped, MPNowPlayingInfoCenter, - MPNowPlayingInfoMediaTypeAudio, - MPNowPlayingInfoMediaTypeNone, - MPNowPlayingInfoPropertyAssetURL, - MPNowPlayingInfoPropertyElapsedPlaybackTime, - MPNowPlayingInfoPropertyExternalContentIdentifier, - MPNowPlayingInfoPropertyMediaType, - MPNowPlayingInfoPropertyPlaybackQueueCount, - MPNowPlayingInfoPropertyPlaybackQueueIndex, - MPNowPlayingInfoPropertyPlaybackRate, MPRemoteCommandCenter, MPRemoteCommandEvent, MPRemoteCommandHandlerStatus, @@ -39,100 +13,13 @@ from MediaPlayer import ( ) from ...config.model import CocoaReceiverConfig +from ...playback import Playback +from ...playback.state import PlaybackState from ...player import Player -from ...song import PlaybackState, Song from ...song_receiver import LoopFactory, Receiver from ...tools.asyncio import run_background_task -from .persistent_id import song_to_persistent_id - - -def logo_to_ns_image() -> NSImage: - return NSImage.alloc().initByReferencingFile_( - str(Path(__file__).parent.parent.parent / "mpd/logo.svg") - ) - - -def data_to_ns_image(data: bytes) -> NSImage: - return NSImage.alloc().initWithData_(data) - - -def ns_image_to_media_item_artwork(img: NSImage) -> MPMediaItemArtwork: - def resize(size: CGSize) -> NSImage: - new = NSImage.alloc().initWithSize_(size) - new.lockFocus() - img.drawInRect_fromRect_operation_fraction_( - NSMakeRect(0, 0, size.width, size.height), - NSMakeRect(0, 0, img.size().width, img.size().height), - NSCompositingOperationCopy, - 1.0, - ) - new.unlockFocus() - return new - - return MPMediaItemArtwork.alloc().initWithBoundsSize_requestHandler_( - img.size(), resize - ) - - -def playback_state_to_cocoa(state: PlaybackState) -> MPMusicPlaybackState: - mapping: dict[PlaybackState, MPMusicPlaybackState] = { - PlaybackState.play: MPMusicPlaybackStatePlaying, - PlaybackState.pause: MPMusicPlaybackStatePaused, - PlaybackState.stop: MPMusicPlaybackStateStopped, - } - return mapping[state] - - -def join_plural_field(field: list[str]) -> str | None: - if field: - return ", ".join(field) - return None - - -def song_to_media_item(song: Song) -> NSMutableDictionary: - nowplaying_info = nothing_to_media_item() - nowplaying_info[MPNowPlayingInfoPropertyMediaType] = MPNowPlayingInfoMediaTypeAudio - nowplaying_info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = song.elapsed - nowplaying_info[MPNowPlayingInfoPropertyExternalContentIdentifier] = str(song.file) - nowplaying_info[MPNowPlayingInfoPropertyPlaybackQueueCount] = song.queue_length - nowplaying_info[MPNowPlayingInfoPropertyPlaybackQueueIndex] = song.queue_index - nowplaying_info[MPMediaItemPropertyPersistentID] = song_to_persistent_id(song) - - nowplaying_info[MPMediaItemPropertyTitle] = song.title - nowplaying_info[MPMediaItemPropertyArtist] = join_plural_field(song.artist) - nowplaying_info[MPMediaItemPropertyAlbumTitle] = join_plural_field(song.album) - nowplaying_info[MPMediaItemPropertyAlbumTrackNumber] = song.track - nowplaying_info[MPMediaItemPropertyDiscNumber] = song.disc - nowplaying_info[MPMediaItemPropertyGenre] = join_plural_field(song.genre) - nowplaying_info[MPMediaItemPropertyComposer] = join_plural_field(song.composer) - nowplaying_info[MPMediaItemPropertyPlaybackDuration] = song.duration - - if song.url is not None: - nowplaying_info[MPNowPlayingInfoPropertyAssetURL] = song.url.human_repr() - - # MPD can't play back music at different rates, so we just want to set it - # to 1.0 if the song is playing. (Leave it at 0.0 if the song is paused.) - if song.state == PlaybackState.play: - nowplaying_info[MPNowPlayingInfoPropertyPlaybackRate] = 1.0 - - if song.art: - nowplaying_info[MPMediaItemPropertyArtwork] = ns_image_to_media_item_artwork( - data_to_ns_image(song.art.data) - ) - return nowplaying_info - - -def nothing_to_media_item() -> NSMutableDictionary: - nowplaying_info = NSMutableDictionary.dictionary() - nowplaying_info[MPNowPlayingInfoPropertyMediaType] = MPNowPlayingInfoMediaTypeNone - nowplaying_info[MPMediaItemPropertyArtwork] = MPD_LOGO - nowplaying_info[MPMediaItemPropertyTitle] = "MPD (stopped)" - nowplaying_info[MPNowPlayingInfoPropertyPlaybackRate] = 0.0 - - return nowplaying_info - - -MPD_LOGO = ns_image_to_media_item_artwork(logo_to_ns_image()) +from .convert.playback_to_media_item import playback_to_media_item +from .convert.to_state import playback_state_to_cocoa class CocoaLoopFactory(LoopFactory[CoreFoundationEventLoop]): @@ -191,13 +78,9 @@ class CocoaNowPlayingReceiver(Receiver): # unpause with remote commands. self.info_center.setPlaybackState_(MPMusicPlaybackStatePlaying) - async def update(self, song: Song | None) -> None: - if song: - self.info_center.setNowPlayingInfo_(song_to_media_item(song)) - self.info_center.setPlaybackState_(playback_state_to_cocoa(song.state)) - else: - self.info_center.setNowPlayingInfo_(nothing_to_media_item()) - self.info_center.setPlaybackState_(MPMusicPlaybackStateStopped) + async def update(self, playback: Playback) -> None: + self.info_center.setNowPlayingInfo_(playback_to_media_item(playback)) + self.info_center.setPlaybackState_(playback_state_to_cocoa(playback.song.state)) def _create_handler( self, player: Callable[[], Coroutine[None, None, PlaybackState | None]] diff --git a/src/mpd_now_playable/receivers/websockets/receiver.py b/src/mpd_now_playable/receivers/websockets/receiver.py index 5cbce3d..a125b6b 100644 --- a/src/mpd_now_playable/receivers/websockets/receiver.py +++ b/src/mpd_now_playable/receivers/websockets/receiver.py @@ -6,8 +6,8 @@ from websockets.server import WebSocketServerProtocol, serve from yarl import URL from ...config.model import WebsocketsReceiverConfig +from ...playback import Playback from ...player import Player -from ...song import Song from ...song_receiver import DefaultLoopFactory, Receiver MSGPACK_NULL = ormsgpack.packb(None) @@ -37,7 +37,9 @@ class WebsocketsReceiver(Receiver): async def start(self, player: Player) -> None: self.player = player - await serve(self.handle, host=self.config.host, port=self.config.port, reuse_port=True) + await serve( + self.handle, host=self.config.host, port=self.config.port, reuse_port=True + ) async def handle(self, conn: WebSocketServerProtocol) -> None: self.connections.add(conn) @@ -47,9 +49,6 @@ class WebsocketsReceiver(Receiver): finally: self.connections.remove(conn) - async def update(self, song: Song | None) -> None: - if song is None: - self.last_status = MSGPACK_NULL - else: - self.last_status = ormsgpack.packb(song, default=default) + async def update(self, playback: Playback) -> None: + self.last_status = ormsgpack.packb(playback, default=default) broadcast(self.connections, self.last_status) diff --git a/src/mpd_now_playable/song/__init__.py b/src/mpd_now_playable/song/__init__.py index 80e5f4f..4c478d6 100644 --- a/src/mpd_now_playable/song/__init__.py +++ b/src/mpd_now_playable/song/__init__.py @@ -1,12 +1,13 @@ from .artwork import Artwork, ArtworkSchema, to_artwork from .musicbrainz import to_brainz -from .song import PlaybackState, Song +from .song import Song +from .stopped import Stopped __all__ = ( "Artwork", "ArtworkSchema", "to_artwork", "to_brainz", - "PlaybackState", "Song", + "Stopped", ) diff --git a/src/mpd_now_playable/song/song.py b/src/mpd_now_playable/song/song.py index 45c4425..2246ab6 100644 --- a/src/mpd_now_playable/song/song.py +++ b/src/mpd_now_playable/song/song.py @@ -1,30 +1,19 @@ from dataclasses import dataclass -from enum import StrEnum from pathlib import Path +from typing import Literal +from ..playback.state import PlaybackState from ..tools.schema.define import schema from ..tools.schema.fields import Url from .artwork import Artwork from .musicbrainz import MusicBrainzIds -class PlaybackState(StrEnum): - play = "play" - pause = "pause" - stop = "stop" - - @schema("https://cdn.00dani.me/m/schemata/mpd-now-playable/song-v1.json") @dataclass(slots=True, kw_only=True) class Song: - #: Whether MPD is currently playing, paused, or stopped. Pretty simple. - state: PlaybackState - - #: The zero-based index of the current song in MPD's queue. - queue_index: int - #: The total length of MPD's queue - the last song in the queue will have - #: the index one less than this, since queue indices are zero-based. - queue_length: int + #: Whether MPD is currently playing or paused. Pretty simple. + state: Literal[PlaybackState.play, PlaybackState.pause] #: The relative path to the current song inside the music directory. MPD #: itself uses this path as a stable identifier for the audio file in many diff --git a/src/mpd_now_playable/song/stopped.py b/src/mpd_now_playable/song/stopped.py new file mode 100644 index 0000000..467da05 --- /dev/null +++ b/src/mpd_now_playable/song/stopped.py @@ -0,0 +1,9 @@ +from dataclasses import dataclass, field +from typing import Literal + +from ..playback.state import PlaybackState + + +@dataclass(slots=True, kw_only=True) +class Stopped: + state: Literal[PlaybackState.stop] = field(default=PlaybackState.stop, repr=False) diff --git a/src/mpd_now_playable/song_receiver.py b/src/mpd_now_playable/song_receiver.py index cf18326..f0d06ca 100644 --- a/src/mpd_now_playable/song_receiver.py +++ b/src/mpd_now_playable/song_receiver.py @@ -4,8 +4,8 @@ from importlib import import_module from typing import Generic, Iterable, Literal, Protocol, TypeVar, cast from .config.model import BaseReceiverConfig +from .playback import Playback from .player import Player -from .song import Song from .tools.types import not_none T = TypeVar("T", bound=AbstractEventLoop, covariant=True) @@ -25,7 +25,7 @@ class Receiver(Protocol): def loop_factory(cls) -> LoopFactory[AbstractEventLoop]: ... async def start(self, player: Player) -> None: ... - async def update(self, song: Song | None) -> None: ... + async def update(self, playback: Playback) -> None: ... class ReceiverModule(Protocol): diff --git a/src/mpd_now_playable/tools/schema/generate.py b/src/mpd_now_playable/tools/schema/generate.py index a8d71ef..c52242f 100644 --- a/src/mpd_now_playable/tools/schema/generate.py +++ b/src/mpd_now_playable/tools/schema/generate.py @@ -49,7 +49,9 @@ class MyGenerateJsonSchema(GenerateJsonSchema): if __name__ == "__main__": from ...config.model import Config + from ...playback import Playback from ...song import Song write(Config) + write(Playback, mode="serialization") write(Song, mode="serialization") From c29f4b9b27726c2d2a3a2079ce5b1ab0b5711f6e Mon Sep 17 00:00:00 2001 From: Danielle McLean Date: Mon, 29 Jul 2024 10:55:27 +1000 Subject: [PATCH 22/32] Add 'heartbeat' to MPD client, so we notice if we disconnect --- src/mpd_now_playable/mpd/listener.py | 16 +++++++++++++--- stubs/mpd/asyncio.pyi | 1 + 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/mpd_now_playable/mpd/listener.py b/src/mpd_now_playable/mpd/listener.py index 23f774b..603dfe5 100644 --- a/src/mpd_now_playable/mpd/listener.py +++ b/src/mpd_now_playable/mpd/listener.py @@ -11,6 +11,7 @@ from ..playback import Playback from ..playback.state import PlaybackState from ..player import Player from ..song_receiver import Receiver +from ..tools.asyncio import run_background_task from .artwork_cache import MpdArtworkCache from .convert.to_playback import to_playback from .types import MpdState @@ -37,16 +38,25 @@ class MpdStateListener(Player): print("Authorising to MPD with your password...") await self.client.password(conf.password.get_secret_value()) print(f"Connected to MPD v{self.client.mpd_version}") + run_background_task(self.heartbeat()) + + async def heartbeat(self) -> None: + while True: + await self.client.ping() + await asyncio.sleep(10) async def refresh(self) -> None: await self.update_receivers() async def loop(self, receivers: Iterable[Receiver]) -> None: self.receivers = receivers - # 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() - # then wait for stuff to change in MPD. :) - async for _ in self.client.idle(): + # And then wait for stuff to change in MPD. :) + async for subsystems in self.client.idle(): + # If no subsystems actually changed, we don't need to update the receivers. + if not subsystems: + continue self.idle_count += 1 await self.update_receivers() diff --git a/stubs/mpd/asyncio.pyi b/stubs/mpd/asyncio.pyi index 6968141..1193ba7 100644 --- a/stubs/mpd/asyncio.pyi +++ b/stubs/mpd/asyncio.pyi @@ -9,6 +9,7 @@ class MPDClient(MPDClientBase): def __init__(self) -> None: ... async def connect(self, host: str, port: int = ...) -> None: ... + async def ping(self) -> None: ... async def password(self, password: str) -> None: ... def idle(self, subsystems: Sequence[str] = ...) -> AsyncIterator[Sequence[str]]: ... async def status(self) -> types.StatusResponse: ... From 3ef31120149a3ac21174dd9776aeef044397f798 Mon Sep 17 00:00:00 2001 From: Danielle McLean Date: Mon, 29 Jul 2024 11:14:48 +1000 Subject: [PATCH 23/32] Load crossfade settings into Playback.settings too --- schemata/playback-v1.json | 34 ++++++++++++++++++- .../mpd/convert/to_playback.py | 14 +++++++- src/mpd_now_playable/mpd/types.py | 14 ++++++++ src/mpd_now_playable/playback/settings.py | 30 +++++++++++++++- 4 files changed, 89 insertions(+), 3 deletions(-) diff --git a/schemata/playback-v1.json b/schemata/playback-v1.json index 245d92b..58b7244 100644 --- a/schemata/playback-v1.json +++ b/schemata/playback-v1.json @@ -14,6 +14,26 @@ "title": "HasArtwork", "type": "object" }, + "MixRamp": { + "properties": { + "db": { + "description": "The volume threshold at which MPD will overlap MixRamp-analysed songs, measured in decibels. Can be set to any float, but sensible values are typically negative.", + "title": "Db", + "type": "number" + }, + "delay": { + "description": "A delay time in seconds which will be subtracted from the MixRamp overlap. Must be set to a positive value for MixRamp to work at all - will be zero if it's disabled.", + "title": "Delay", + "type": "number" + } + }, + "required": [ + "db", + "delay" + ], + "title": "MixRamp", + "type": "object" + }, "MusicBrainzIds": { "properties": { "artist": { @@ -125,6 +145,16 @@ "description": "Remove songs from the queue as they're played. This flag can also be set to \"oneshot\", which means the currently playing song will be consumed, and then the flag will automatically be switched off.", "title": "Consume" }, + "crossfade": { + "description": "The number of seconds to overlap songs when cross-fading between the current song and the next. Will be zero when the cross-fading feature is disabled entirely. Curiously, fractional seconds are not supported here, unlike many other places MPD uses seconds.", + "minimum": 0, + "title": "Crossfade", + "type": "integer" + }, + "mixramp": { + "$ref": "#/$defs/MixRamp", + "description": "Settings for MixRamp-powered cross-fading, which analyses your songs' volume levels to choose optimal places for cross-fading. This requires either that the songs have previously been analysed and tagged with MixRamp information, or that MPD's on the fly mixramp_analyzer has been enabled." + }, "random": { "description": "Play the queued songs in random order. This is distinct from shuffling the queue, which randomises the queue's order once when you send the shuffle command and will then play the queue in that new order repeatedly if asked. If MPD is asked to both repeat and randomise, the queue is effectively shuffled each time it loops.", "title": "Random", @@ -162,7 +192,9 @@ "repeat", "random", "single", - "consume" + "consume", + "crossfade", + "mixramp" ], "title": "Settings", "type": "object" diff --git a/src/mpd_now_playable/mpd/convert/to_playback.py b/src/mpd_now_playable/mpd/convert/to_playback.py index 3157829..29b2223 100644 --- a/src/mpd_now_playable/mpd/convert/to_playback.py +++ b/src/mpd_now_playable/mpd/convert/to_playback.py @@ -1,7 +1,7 @@ from ...config.model import MpdConfig from ...playback import Playback from ...playback.queue import Queue -from ...playback.settings import Settings, to_oneshot +from ...playback.settings import MixRamp, Settings, to_oneshot from ...tools.types import option_fmap from ..types import MpdState from .to_song import to_song @@ -15,6 +15,16 @@ def to_queue(mpd: MpdState) -> Queue: ) +def to_mixramp(mpd: MpdState) -> MixRamp: + delay = mpd.status.get("mixrampdelay", 0) + if delay == "nan": + delay = 0 + return MixRamp( + db=float(mpd.status.get("mixrampdb", 0)), + delay=float(delay), + ) + + def to_settings(mpd: MpdState) -> Settings: return Settings( volume=option_fmap(int, mpd.status.get("volume")), @@ -22,6 +32,8 @@ def to_settings(mpd: MpdState) -> Settings: random=mpd.status["random"] == "1", single=to_oneshot(mpd.status["single"]), consume=to_oneshot(mpd.status["consume"]), + crossfade=int(mpd.status.get("xfade", 0)), + mixramp=to_mixramp(mpd), ) diff --git a/src/mpd_now_playable/mpd/types.py b/src/mpd_now_playable/mpd/types.py index a7401c1..c9345d8 100644 --- a/src/mpd_now_playable/mpd/types.py +++ b/src/mpd_now_playable/mpd/types.py @@ -37,6 +37,20 @@ class StatusResponse(TypedDict): single: OneshotFlag consume: OneshotFlag + # The configured crossfade time in seconds. Omitted if crossfading isn't + # enabled. Fractional seconds are *not* allowed for this field. + xfade: NotRequired[str] + + # The volume threshold at which MixRamp-compatible songs will be + # overlapped, measured in decibels. Will usually be negative, and is + # permitted to be fractional. + mixrampdb: NotRequired[str] + + # A number of seconds to subtract from the overlap computed by MixRamp. + # Must be positive for MixRamp to work and is permitted to be fractional. + # Can be set to "nan" to disable MixRamp and use basic crossfading instead. + mixrampdelay: NotRequired[str] + # Partitions essentially let one MPD server act as multiple music players. # For most folks, this will just be "default", but mpd-now-playable will # eventually support addressing specific partitions. Eventually. diff --git a/src/mpd_now_playable/playback/settings.py b/src/mpd_now_playable/playback/settings.py index eba3459..5296c88 100644 --- a/src/mpd_now_playable/playback/settings.py +++ b/src/mpd_now_playable/playback/settings.py @@ -1,5 +1,7 @@ from dataclasses import dataclass -from typing import Literal +from typing import Annotated, Literal + +from annotated_types import Ge OneShotFlag = bool | Literal["oneshot"] @@ -15,6 +17,19 @@ def to_oneshot(value: str) -> OneShotFlag: return False +@dataclass(slots=True, kw_only=True) +class MixRamp: + #: The volume threshold at which MPD will overlap MixRamp-analysed songs, + #: measured in decibels. Can be set to any float, but sensible values are + #: typically negative. + db: float + + #: A delay time in seconds which will be subtracted from the MixRamp + #: overlap. Must be set to a positive value for MixRamp to work at all - + #: will be zero if it's disabled. + delay: float + + @dataclass(slots=True, kw_only=True) class Settings: #: The playback volume ranging from 0 to 100 - it will only be available if @@ -46,3 +61,16 @@ class Settings: #: to "oneshot", which means the currently playing song will be consumed, #: and then the flag will automatically be switched off. consume: OneShotFlag + + #: The number of seconds to overlap songs when cross-fading between the + #: current song and the next. Will be zero when the cross-fading feature is + #: disabled entirely. Curiously, fractional seconds are not supported here, + #: unlike many other places MPD uses seconds. + crossfade: Annotated[int, Ge(0)] + + #: Settings for MixRamp-powered cross-fading, which analyses your songs' + #: volume levels to choose optimal places for cross-fading. This requires + #: either that the songs have previously been analysed and tagged with + #: MixRamp information, or that MPD's on the fly mixramp_analyzer has been + #: enabled. + mixramp: MixRamp From 452867699e6c2a59cb81d44653d671797cd10bbf Mon Sep 17 00:00:00 2001 From: Danielle McLean Date: Tue, 30 Jul 2024 10:09:33 +1000 Subject: [PATCH 24/32] Update Mypy so I can use PEP 695 type param syntax --- pdm.lock | 46 +++++++++++++++------ pyproject.toml | 3 +- src/mpd_now_playable/cache.py | 8 ++-- src/mpd_now_playable/config/load.py | 5 +-- src/mpd_now_playable/song_receiver.py | 6 +-- src/mpd_now_playable/tools/schema/define.py | 6 +-- src/mpd_now_playable/tools/types.py | 15 +++---- stubs/aiocache/base.pyi | 6 +-- stubs/aiocache/factory.pyi | 6 +-- stubs/boltons/iterutils.pyi | 4 +- 10 files changed, 53 insertions(+), 52 deletions(-) diff --git a/pdm.lock b/pdm.lock index fa4b4e9..f863acf 100644 --- a/pdm.lock +++ b/pdm.lock @@ -2,10 +2,13 @@ # It is not intended for manual editing. [metadata] -groups = ["default", "all", "dev"] -strategy = ["cross_platform"] -lock_version = "4.4.1" -content_hash = "sha256:ddedd388cce9ed181dc2f3786240fc14e19ceb4539f0a360eeb2efb28da63ebe" +groups = ["default", "all", "dev", "memcached", "redis", "websockets"] +strategy = [] +lock_version = "4.5.0" +content_hash = "sha256:fdf0ffc09550df3ba54428f2c6ceb534fc68c4e1633299dbcfd8db9d9f325564" + +[[metadata.targets]] +requires_python = ">=3.12" [[package]] name = "aiocache" @@ -49,6 +52,9 @@ name = "aiomcache" version = "0.8.2" requires_python = ">=3.8" summary = "Minimal pure python memcached client" +dependencies = [ + "typing-extensions>=4; python_version < \"3.11\"", +] files = [ {file = "aiomcache-0.8.2-py3-none-any.whl", hash = "sha256:9d78d6b6e74e775df18b350b1cddfa96bd2f0a44d49ad27fa87759a3469cef5e"}, {file = "aiomcache-0.8.2.tar.gz", hash = "sha256:43b220d7f499a32a71871c4f457116eb23460fa216e69c1d32b81e3209e51359"}, @@ -59,6 +65,9 @@ name = "annotated-types" version = "0.7.0" requires_python = ">=3.8" summary = "Reusable constraint types to use with typing.Annotated" +dependencies = [ + "typing-extensions>=4.0.0; python_version < \"3.9\"", +] files = [ {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, @@ -69,6 +78,9 @@ name = "attrs" version = "23.2.0" requires_python = ">=3.7" summary = "Classes Without Boilerplate" +dependencies = [ + "importlib-metadata; python_version < \"3.8\"", +] files = [ {file = "attrs-23.2.0-py3-none-any.whl", hash = "sha256:99b87a485a5820b23b879f04c2305b44b951b502fd64be915879d77a7e8fc6f1"}, {file = "attrs-23.2.0.tar.gz", hash = "sha256:935dc3b529c262f6cf76e50877d35a4bd3c1de194fd41f47a2b7ae8f19971f30"}, @@ -167,21 +179,22 @@ files = [ [[package]] name = "mypy" -version = "1.10.1" +version = "1.11.0" requires_python = ">=3.8" summary = "Optional static typing for Python" dependencies = [ "mypy-extensions>=1.0.0", - "typing-extensions>=4.1.0", + "tomli>=1.1.0; python_version < \"3.11\"", + "typing-extensions>=4.6.0", ] files = [ - {file = "mypy-1.10.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:d8681909f7b44d0b7b86e653ca152d6dff0eb5eb41694e163c6092124f8246d7"}, - {file = "mypy-1.10.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:378c03f53f10bbdd55ca94e46ec3ba255279706a6aacaecac52ad248f98205d3"}, - {file = "mypy-1.10.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bacf8f3a3d7d849f40ca6caea5c055122efe70e81480c8328ad29c55c69e93e"}, - {file = "mypy-1.10.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:701b5f71413f1e9855566a34d6e9d12624e9e0a8818a5704d74d6b0402e66c04"}, - {file = "mypy-1.10.1-cp312-cp312-win_amd64.whl", hash = "sha256:3c4c2992f6ea46ff7fce0072642cfb62af7a2484efe69017ed8b095f7b39ef31"}, - {file = "mypy-1.10.1-py3-none-any.whl", hash = "sha256:71d8ac0b906354ebda8ef1673e5fde785936ac1f29ff6987c7483cfbd5a4235a"}, - {file = "mypy-1.10.1.tar.gz", hash = "sha256:1f8f492d7db9e3593ef42d4f115f04e556130f2819ad33ab84551403e97dd4c0"}, + {file = "mypy-1.11.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:1d44c1e44a8be986b54b09f15f2c1a66368eb43861b4e82573026e04c48a9e20"}, + {file = "mypy-1.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cea3d0fb69637944dd321f41bc896e11d0fb0b0aa531d887a6da70f6e7473aba"}, + {file = "mypy-1.11.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a83ec98ae12d51c252be61521aa5731f5512231d0b738b4cb2498344f0b840cd"}, + {file = "mypy-1.11.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:c7b73a856522417beb78e0fb6d33ef89474e7a622db2653bc1285af36e2e3e3d"}, + {file = "mypy-1.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:f2268d9fcd9686b61ab64f077be7ffbc6fbcdfb4103e5dd0cc5eaab53a8886c2"}, + {file = "mypy-1.11.0-py3-none-any.whl", hash = "sha256:56913ec8c7638b0091ef4da6fcc9136896914a9d60d54670a75880c3e5b99ace"}, + {file = "mypy-1.11.0.tar.gz", hash = "sha256:93743608c7348772fdc717af4aeee1997293a1ad04bc0ea6efa15bf65385c538"}, ] [[package]] @@ -425,6 +438,11 @@ name = "redis" version = "5.0.7" requires_python = ">=3.7" summary = "Python client for Redis database and key-value store" +dependencies = [ + "async-timeout>=4.0.3; python_full_version < \"3.11.3\"", + "importlib-metadata>=1.0; python_version < \"3.8\"", + "typing-extensions; python_version < \"3.8\"", +] files = [ {file = "redis-5.0.7-py3-none-any.whl", hash = "sha256:0e479e24da960c690be5d9b96d21f7b918a98c0cf49af3b6fafaa0753f93a0db"}, {file = "redis-5.0.7.tar.gz", hash = "sha256:8f611490b93c8109b50adc317b31bfd84fff31def3475b92e7e80bf39f48175b"}, @@ -438,6 +456,7 @@ summary = "Render rich text, tables, progress bars, syntax highlighting, markdow dependencies = [ "markdown-it-py>=2.2.0", "pygments<3.0.0,>=2.13.0", + "typing-extensions<5.0,>=4.0.0; python_version < \"3.9\"", ] files = [ {file = "rich-13.7.1-py3-none-any.whl", hash = "sha256:4edbae314f59eb482f54e9e30bf00d33350aaa94f4bfcd4e9e3110e64d0d7222"}, @@ -534,6 +553,7 @@ summary = "Yet another URL library" dependencies = [ "idna>=2.0", "multidict>=4.0", + "typing-extensions>=3.7.4; python_version < \"3.8\"", ] files = [ {file = "yarl-1.9.4-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0d2454f0aef65ea81037759be5ca9947539667eecebca092733b2eb43c965a81"}, diff --git a/pyproject.toml b/pyproject.toml index b5cb70e..b21f79e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,6 +56,7 @@ build-backend = "pdm.backend" [tool.mypy] mypy_path = 'stubs' plugins = ['pydantic.mypy', 'mpd_now_playable.tools.schema.plugin'] +enable_incomplete_feature = 'NewGenericSyntax' [tool.ruff.lint] select = [ @@ -104,7 +105,7 @@ excludes = ["**/.mypy_cache"] [tool.pdm.dev-dependencies] dev = [ - "mypy>=1.7.1", + "mypy>=1.11.0", "ruff>=0.1.6", "class-doc>=0.2.6", ] diff --git a/src/mpd_now_playable/cache.py b/src/mpd_now_playable/cache.py index 58394e6..4fddc14 100644 --- a/src/mpd_now_playable/cache.py +++ b/src/mpd_now_playable/cache.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Any, Generic, Optional, TypeVar +from typing import Any, Optional import ormsgpack from aiocache import Cache @@ -8,10 +8,8 @@ from aiocache.serializers import BaseSerializer from pydantic.type_adapter import TypeAdapter from yarl import URL -T = TypeVar("T") - -class OrmsgpackSerializer(BaseSerializer, Generic[T]): +class OrmsgpackSerializer[T](BaseSerializer): DEFAULT_ENCODING = None def __init__(self, schema: TypeAdapter[T]): @@ -28,7 +26,7 @@ class OrmsgpackSerializer(BaseSerializer, Generic[T]): return self.schema.validate_python(data) -def make_cache(schema: TypeAdapter[T], url: URL, namespace: str = "") -> Cache[T]: +def make_cache[T](schema: TypeAdapter[T], url: URL, namespace: str = "") -> Cache[T]: backend = Cache.get_scheme_class(url.scheme) if backend == Cache.MEMORY: return Cache(backend) diff --git a/src/mpd_now_playable/config/load.py b/src/mpd_now_playable/config/load.py index a45a0eb..ac299d8 100644 --- a/src/mpd_now_playable/config/load.py +++ b/src/mpd_now_playable/config/load.py @@ -1,6 +1,5 @@ from collections.abc import Mapping from os import environ -from typing import TypeVar from boltons.iterutils import remap from pytomlpp import load @@ -9,8 +8,6 @@ from xdg_base_dirs import xdg_config_home from .model import Config __all__ = ("loadConfig",) -K = TypeVar("K") -V = TypeVar("V") # Sadly this is the kind of function that's incredibly easy to type statically @@ -24,7 +21,7 @@ V = TypeVar("V") # type like NonNullable. Python's type system also doesn't infer a # dictionary literal as having a structural type by default in the way # TypeScript does, of course, so that part wouldn't work anyway. -def withoutNones(data: Mapping[K, V | None]) -> Mapping[K, V]: +def withoutNones[K, V](data: Mapping[K, V | None]) -> Mapping[K, V]: return remap(data, lambda p, k, v: v is not None) diff --git a/src/mpd_now_playable/song_receiver.py b/src/mpd_now_playable/song_receiver.py index f0d06ca..d6df301 100644 --- a/src/mpd_now_playable/song_receiver.py +++ b/src/mpd_now_playable/song_receiver.py @@ -1,17 +1,15 @@ from asyncio import AbstractEventLoop, new_event_loop from dataclasses import dataclass from importlib import import_module -from typing import Generic, Iterable, Literal, Protocol, TypeVar, cast +from typing import Iterable, Literal, Protocol, cast from .config.model import BaseReceiverConfig from .playback import Playback from .player import Player from .tools.types import not_none -T = TypeVar("T", bound=AbstractEventLoop, covariant=True) - -class LoopFactory(Generic[T], Protocol): +class LoopFactory[T: AbstractEventLoop](Protocol): @property def is_replaceable(self) -> bool: ... diff --git a/src/mpd_now_playable/tools/schema/define.py b/src/mpd_now_playable/tools/schema/define.py index f33c907..dcf970f 100644 --- a/src/mpd_now_playable/tools/schema/define.py +++ b/src/mpd_now_playable/tools/schema/define.py @@ -1,10 +1,8 @@ -from typing import Callable, Protocol, Self, TypeVar +from typing import Callable, Protocol, Self from pydantic.type_adapter import TypeAdapter from yarl import URL -T = TypeVar("T") - class ModelWithSchema(Protocol): @property @@ -13,7 +11,7 @@ class ModelWithSchema(Protocol): def schema(self) -> TypeAdapter[Self]: ... -def schema(schema_id: str) -> Callable[[type[T]], type[T]]: +def schema[T](schema_id: str) -> Callable[[type[T]], type[T]]: def decorate(clazz: type[T]) -> type[T]: type.__setattr__(clazz, "id", URL(schema_id)) type.__setattr__(clazz, "schema", TypeAdapter(clazz)) diff --git a/src/mpd_now_playable/tools/types.py b/src/mpd_now_playable/tools/types.py index deb18ad..d151c9b 100644 --- a/src/mpd_now_playable/tools/types.py +++ b/src/mpd_now_playable/tools/types.py @@ -1,5 +1,5 @@ from collections.abc import Callable -from typing import Any, TypeAlias, TypeVar +from typing import Any __all__ = ( "AnyExceptList", @@ -28,27 +28,22 @@ AnyExceptList = ( ) -U = TypeVar("U") -V = TypeVar("V") - - -def not_none(value: U | None) -> U: +def not_none[U](value: U | None) -> U: if value is None: raise ValueError("None should not be possible here.") return value -def option_fmap(f: Callable[[U], V], value: U | None) -> V | None: +def option_fmap[U, V](f: Callable[[U], V], value: U | None) -> V | None: if value is None: return None return f(value) -T = TypeVar("T", bound=AnyExceptList) -MaybePlural: TypeAlias = list[T] | T +type MaybePlural[T: AnyExceptList] = list[T] | T -def un_maybe_plural(value: MaybePlural[T] | None) -> list[T]: +def un_maybe_plural[T: AnyExceptList](value: MaybePlural[T] | None) -> list[T]: match value: case None: return [] diff --git a/stubs/aiocache/base.pyi b/stubs/aiocache/base.pyi index 8b7578f..13ba741 100644 --- a/stubs/aiocache/base.pyi +++ b/stubs/aiocache/base.pyi @@ -1,7 +1,3 @@ -from typing import Generic, TypeVar - -T = TypeVar("T") - -class BaseCache(Generic[T]): +class BaseCache[T]: @staticmethod def parse_uri_path(path: str) -> dict[str, str]: ... diff --git a/stubs/aiocache/factory.pyi b/stubs/aiocache/factory.pyi index 78cc6a5..783d761 100644 --- a/stubs/aiocache/factory.pyi +++ b/stubs/aiocache/factory.pyi @@ -1,11 +1,9 @@ -from typing import ClassVar, Optional, TypeVar +from typing import ClassVar, Optional from .base import BaseCache from .serializers import BaseSerializer -T = TypeVar("T") - -class Cache(BaseCache[T]): +class Cache[T](BaseCache[T]): MEMORY: ClassVar[type[BaseCache]] REDIS: ClassVar[type[BaseCache] | None] MEMCACHED: ClassVar[type[BaseCache] | None] diff --git a/stubs/boltons/iterutils.pyi b/stubs/boltons/iterutils.pyi index 2b56e0a..47fc123 100644 --- a/stubs/boltons/iterutils.pyi +++ b/stubs/boltons/iterutils.pyi @@ -1,5 +1,5 @@ from collections.abc import Callable, Mapping -from typing import TypeAlias, TypeVar +from typing import TypeVar # Apparently you need Python 3.13 for type var defaults to work. But since this # is just a stub file, it's okay if they aren't supported at runtime. @@ -8,7 +8,7 @@ KOut = TypeVar("KOut", default=KIn) VIn = TypeVar("VIn") VOut = TypeVar("VOut", default=VIn) -Path: TypeAlias = tuple[KIn, ...] +type Path[KIn] = tuple[KIn, ...] # remap() is Complicated and really difficult to define a type for, so I'm not # surprised the boltons package doesn't try to type it for you. This particular From e1156b47deb0172b403fb97b8188a06e54cfe578 Mon Sep 17 00:00:00 2001 From: Danielle McLean Date: Thu, 23 Jan 2025 17:53:10 +1100 Subject: [PATCH 25/32] Remove unnecessary dependency on attrs --- pdm.lock | 15 +-------------- pyproject.toml | 1 - 2 files changed, 1 insertion(+), 15 deletions(-) diff --git a/pdm.lock b/pdm.lock index f863acf..ebb1a87 100644 --- a/pdm.lock +++ b/pdm.lock @@ -5,7 +5,7 @@ groups = ["default", "all", "dev", "memcached", "redis", "websockets"] strategy = [] lock_version = "4.5.0" -content_hash = "sha256:fdf0ffc09550df3ba54428f2c6ceb534fc68c4e1633299dbcfd8db9d9f325564" +content_hash = "sha256:b84a0925a81adb7c4ca5a1a947ccb0db6950a18955bd92f08a605ff06cd0c26c" [[metadata.targets]] requires_python = ">=3.12" @@ -73,19 +73,6 @@ files = [ {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, ] -[[package]] -name = "attrs" -version = "23.2.0" -requires_python = ">=3.7" -summary = "Classes Without Boilerplate" -dependencies = [ - "importlib-metadata; python_version < \"3.8\"", -] -files = [ - {file = "attrs-23.2.0-py3-none-any.whl", hash = "sha256:99b87a485a5820b23b879f04c2305b44b951b502fd64be915879d77a7e8fc6f1"}, - {file = "attrs-23.2.0.tar.gz", hash = "sha256:935dc3b529c262f6cf76e50877d35a4bd3c1de194fd41f47a2b7ae8f19971f30"}, -] - [[package]] name = "boltons" version = "24.0.0" diff --git a/pyproject.toml b/pyproject.toml index b21f79e..e9f0b9d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,6 @@ authors = [ ] dependencies = [ "aiocache>=0.12.2", - "attrs>=23.1.0", "pyobjc-framework-MediaPlayer>=10.0 ; sys_platform == 'darwin'", "python-mpd2>=3.1.0", "xdg-base-dirs>=6.0.1", From 41f5369b2f932d5466411fbb79ece6956f00d8ff Mon Sep 17 00:00:00 2001 From: Danielle McLean Date: Thu, 23 Jan 2025 18:22:12 +1100 Subject: [PATCH 26/32] Safe-update dependencies, following semver --- pdm.lock | 483 ++++++++++++++++++++++++++++++------------------------- 1 file changed, 265 insertions(+), 218 deletions(-) diff --git a/pdm.lock b/pdm.lock index ebb1a87..61e5b3a 100644 --- a/pdm.lock +++ b/pdm.lock @@ -12,39 +12,39 @@ requires_python = ">=3.12" [[package]] name = "aiocache" -version = "0.12.2" +version = "0.12.3" summary = "multi backend asyncio cache" files = [ - {file = "aiocache-0.12.2-py2.py3-none-any.whl", hash = "sha256:9b6fa30634ab0bfc3ecc44928a91ff07c6ea16d27d55469636b296ebc6eb5918"}, - {file = "aiocache-0.12.2.tar.gz", hash = "sha256:b41c9a145b050a5dcbae1599f847db6dd445193b1f3bd172d8e0fe0cb9e96684"}, + {file = "aiocache-0.12.3-py2.py3-none-any.whl", hash = "sha256:889086fc24710f431937b87ad3720a289f7fc31c4fd8b68e9f918b9bacd8270d"}, + {file = "aiocache-0.12.3.tar.gz", hash = "sha256:f528b27bf4d436b497a1d0d1a8f59a542c153ab1e37c3621713cb376d44c4713"}, ] [[package]] name = "aiocache" -version = "0.12.2" +version = "0.12.3" extras = ["memcached"] summary = "multi backend asyncio cache" dependencies = [ - "aiocache==0.12.2", + "aiocache==0.12.3", "aiomcache>=0.5.2", ] files = [ - {file = "aiocache-0.12.2-py2.py3-none-any.whl", hash = "sha256:9b6fa30634ab0bfc3ecc44928a91ff07c6ea16d27d55469636b296ebc6eb5918"}, - {file = "aiocache-0.12.2.tar.gz", hash = "sha256:b41c9a145b050a5dcbae1599f847db6dd445193b1f3bd172d8e0fe0cb9e96684"}, + {file = "aiocache-0.12.3-py2.py3-none-any.whl", hash = "sha256:889086fc24710f431937b87ad3720a289f7fc31c4fd8b68e9f918b9bacd8270d"}, + {file = "aiocache-0.12.3.tar.gz", hash = "sha256:f528b27bf4d436b497a1d0d1a8f59a542c153ab1e37c3621713cb376d44c4713"}, ] [[package]] name = "aiocache" -version = "0.12.2" +version = "0.12.3" extras = ["redis"] summary = "multi backend asyncio cache" dependencies = [ - "aiocache==0.12.2", + "aiocache==0.12.3", "redis>=4.2.0", ] files = [ - {file = "aiocache-0.12.2-py2.py3-none-any.whl", hash = "sha256:9b6fa30634ab0bfc3ecc44928a91ff07c6ea16d27d55469636b296ebc6eb5918"}, - {file = "aiocache-0.12.2.tar.gz", hash = "sha256:b41c9a145b050a5dcbae1599f847db6dd445193b1f3bd172d8e0fe0cb9e96684"}, + {file = "aiocache-0.12.3-py2.py3-none-any.whl", hash = "sha256:889086fc24710f431937b87ad3720a289f7fc31c4fd8b68e9f918b9bacd8270d"}, + {file = "aiocache-0.12.3.tar.gz", hash = "sha256:f528b27bf4d436b497a1d0d1a8f59a542c153ab1e37c3621713cb376d44c4713"}, ] [[package]] @@ -75,12 +75,12 @@ files = [ [[package]] name = "boltons" -version = "24.0.0" +version = "24.1.0" requires_python = ">=3.7" summary = "When they're not builtins, they're boltons." files = [ - {file = "boltons-24.0.0-py3-none-any.whl", hash = "sha256:9618695a6ec4f50412e7072e5d78910a00b4111d0b9b549e4a3d60bc321e7807"}, - {file = "boltons-24.0.0.tar.gz", hash = "sha256:7153feccaea1ff2e1472f68d4b57fadb796a2ee49d29f638f1c9cd8fb5ebd916"}, + {file = "boltons-24.1.0-py3-none-any.whl", hash = "sha256:a1776d47fdc387fb730fba1fe245f405ee184ee0be2fb447dd289773a84aed3b"}, + {file = "boltons-24.1.0.tar.gz", hash = "sha256:4a49b7d57ee055b83a458c8682a2a6f199d263a8aa517098bda9bab813554b87"}, ] [[package]] @@ -166,7 +166,7 @@ files = [ [[package]] name = "mypy" -version = "1.11.0" +version = "1.14.1" requires_python = ">=3.8" summary = "Optional static typing for Python" dependencies = [ @@ -175,13 +175,20 @@ dependencies = [ "typing-extensions>=4.6.0", ] files = [ - {file = "mypy-1.11.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:1d44c1e44a8be986b54b09f15f2c1a66368eb43861b4e82573026e04c48a9e20"}, - {file = "mypy-1.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cea3d0fb69637944dd321f41bc896e11d0fb0b0aa531d887a6da70f6e7473aba"}, - {file = "mypy-1.11.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a83ec98ae12d51c252be61521aa5731f5512231d0b738b4cb2498344f0b840cd"}, - {file = "mypy-1.11.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:c7b73a856522417beb78e0fb6d33ef89474e7a622db2653bc1285af36e2e3e3d"}, - {file = "mypy-1.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:f2268d9fcd9686b61ab64f077be7ffbc6fbcdfb4103e5dd0cc5eaab53a8886c2"}, - {file = "mypy-1.11.0-py3-none-any.whl", hash = "sha256:56913ec8c7638b0091ef4da6fcc9136896914a9d60d54670a75880c3e5b99ace"}, - {file = "mypy-1.11.0.tar.gz", hash = "sha256:93743608c7348772fdc717af4aeee1997293a1ad04bc0ea6efa15bf65385c538"}, + {file = "mypy-1.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:30ff5ef8519bbc2e18b3b54521ec319513a26f1bba19a7582e7b1f58a6e69f14"}, + {file = "mypy-1.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cb9f255c18052343c70234907e2e532bc7e55a62565d64536dbc7706a20b78b9"}, + {file = "mypy-1.14.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b4e3413e0bddea671012b063e27591b953d653209e7a4fa5e48759cda77ca11"}, + {file = "mypy-1.14.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:553c293b1fbdebb6c3c4030589dab9fafb6dfa768995a453d8a5d3b23784af2e"}, + {file = "mypy-1.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fad79bfe3b65fe6a1efaed97b445c3d37f7be9fdc348bdb2d7cac75579607c89"}, + {file = "mypy-1.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:8fa2220e54d2946e94ab6dbb3ba0a992795bd68b16dc852db33028df2b00191b"}, + {file = "mypy-1.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:92c3ed5afb06c3a8e188cb5da4984cab9ec9a77ba956ee419c68a388b4595255"}, + {file = "mypy-1.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dbec574648b3e25f43d23577309b16534431db4ddc09fda50841f1e34e64ed34"}, + {file = "mypy-1.14.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8c6d94b16d62eb3e947281aa7347d78236688e21081f11de976376cf010eb31a"}, + {file = "mypy-1.14.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d4b19b03fdf54f3c5b2fa474c56b4c13c9dbfb9a2db4370ede7ec11a2c5927d9"}, + {file = "mypy-1.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0c911fde686394753fff899c409fd4e16e9b294c24bfd5e1ea4675deae1ac6fd"}, + {file = "mypy-1.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:8b21525cb51671219f5307be85f7e646a153e5acc656e5cebf64bfa076c50107"}, + {file = "mypy-1.14.1-py3-none-any.whl", hash = "sha256:b66a60cc4073aeb8ae00057f9c1f64d49e90f918fbcef9a977eb121da8b8f1d1"}, + {file = "mypy-1.14.1.tar.gz", hash = "sha256:7ec88144fe9b510e8475ec2f5f251992690fcf89ccb4500b214b4226abcd32d6"}, ] [[package]] @@ -196,84 +203,118 @@ files = [ [[package]] name = "ormsgpack" -version = "1.5.0" -requires_python = ">=3.8" +version = "1.7.0" +requires_python = ">=3.9" summary = "Fast, correct Python msgpack library supporting dataclasses, datetimes, and numpy" files = [ - {file = "ormsgpack-1.5.0-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:a921b0d54b5fb5ba1ea4e87c65caa8992736224f1fc5ce8f46a882e918c8e22d"}, - {file = "ormsgpack-1.5.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c6d423668e2c3abdbc474562b1c73360ff7326f06cb9532dcb73254b5b63dae4"}, - {file = "ormsgpack-1.5.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eeb2dd4ed3e503a8266dcbfbb8d810a36baa34e4bb4229e90e9c213058a06d74"}, - {file = "ormsgpack-1.5.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f13bd643df1324e8797caba4c5c0168a87524df8424e8413ba29723e89a586a"}, - {file = "ormsgpack-1.5.0-cp312-none-win_amd64.whl", hash = "sha256:e016da381a126478c4bafab0ae19d3a2537f6471341ecced4bb61471e8841cad"}, - {file = "ormsgpack-1.5.0.tar.gz", hash = "sha256:00c0743ebaa8d21f1c868fbb609c99151ea79e67fec98b51a29077efd91ce348"}, + {file = "ormsgpack-1.7.0-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:77bc2ea387d85cfad045b9bcb8040bae43ad32dafe9363360f732cc19d489bbe"}, + {file = "ormsgpack-1.7.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ec763096d978d35eedcef0af13991a10741717c2e236b26f4c2047b0740ea7b"}, + {file = "ormsgpack-1.7.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:22418a4d399027a72fb2e6b873559b1886cf2e63323ca7afc17b222c454413b7"}, + {file = "ormsgpack-1.7.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:97723786755a7df85fcf6e68d7b5359dacea98d5c26b1d9af219a3cc05df4734"}, + {file = "ormsgpack-1.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:7e6ada21f5c7a20ff7cf9b061c44e3814352f819947a12022ad8cb52a9f2a809"}, + {file = "ormsgpack-1.7.0-cp313-cp313-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:462089a419dbde654915ccb0b859c0dbe3c178b0ac580018e82befea6ccd73f4"}, + {file = "ormsgpack-1.7.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4b353204e99b56c1d33f1cf4767bd1fe1195596181a1cc789f25aa26c0b50f3d"}, + {file = "ormsgpack-1.7.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a5e12b51a590be47ccef67907905653e679fc2f920854b456edc216690ecc09c"}, + {file = "ormsgpack-1.7.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a6a97937d2cf21496d7689b90a43df83c5062bbe846aaa39197cc9ad73eaa7b"}, + {file = "ormsgpack-1.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:8d301e47565fe0e52a60052e730a9bb7669dfbd2a94643b8be925e3928c64c15"}, + {file = "ormsgpack-1.7.0.tar.gz", hash = "sha256:6b4c98839cb7fc2a212037d2258f3a22857155249eb293d45c45cb974cfba834"}, +] + +[[package]] +name = "propcache" +version = "0.2.1" +requires_python = ">=3.9" +summary = "Accelerated property cache" +files = [ + {file = "propcache-0.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:081a430aa8d5e8876c6909b67bd2d937bfd531b0382d3fdedb82612c618bc41a"}, + {file = "propcache-0.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d2ccec9ac47cf4e04897619c0e0c1a48c54a71bdf045117d3a26f80d38ab1fb0"}, + {file = "propcache-0.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:14d86fe14b7e04fa306e0c43cdbeebe6b2c2156a0c9ce56b815faacc193e320d"}, + {file = "propcache-0.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:049324ee97bb67285b49632132db351b41e77833678432be52bdd0289c0e05e4"}, + {file = "propcache-0.2.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1cd9a1d071158de1cc1c71a26014dcdfa7dd3d5f4f88c298c7f90ad6f27bb46d"}, + {file = "propcache-0.2.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98110aa363f1bb4c073e8dcfaefd3a5cea0f0834c2aab23dda657e4dab2f53b5"}, + {file = "propcache-0.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:647894f5ae99c4cf6bb82a1bb3a796f6e06af3caa3d32e26d2350d0e3e3faf24"}, + {file = "propcache-0.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bfd3223c15bebe26518d58ccf9a39b93948d3dcb3e57a20480dfdd315356baff"}, + {file = "propcache-0.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d71264a80f3fcf512eb4f18f59423fe82d6e346ee97b90625f283df56aee103f"}, + {file = "propcache-0.2.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:e73091191e4280403bde6c9a52a6999d69cdfde498f1fdf629105247599b57ec"}, + {file = "propcache-0.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3935bfa5fede35fb202c4b569bb9c042f337ca4ff7bd540a0aa5e37131659348"}, + {file = "propcache-0.2.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f508b0491767bb1f2b87fdfacaba5f7eddc2f867740ec69ece6d1946d29029a6"}, + {file = "propcache-0.2.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1672137af7c46662a1c2be1e8dc78cb6d224319aaa40271c9257d886be4363a6"}, + {file = "propcache-0.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b74c261802d3d2b85c9df2dfb2fa81b6f90deeef63c2db9f0e029a3cac50b518"}, + {file = "propcache-0.2.1-cp312-cp312-win32.whl", hash = "sha256:d09c333d36c1409d56a9d29b3a1b800a42c76a57a5a8907eacdbce3f18768246"}, + {file = "propcache-0.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:c214999039d4f2a5b2073ac506bba279945233da8c786e490d411dfc30f855c1"}, + {file = "propcache-0.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aca405706e0b0a44cc6bfd41fbe89919a6a56999157f6de7e182a990c36e37bc"}, + {file = "propcache-0.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:12d1083f001ace206fe34b6bdc2cb94be66d57a850866f0b908972f90996b3e9"}, + {file = "propcache-0.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d93f3307ad32a27bda2e88ec81134b823c240aa3abb55821a8da553eed8d9439"}, + {file = "propcache-0.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba278acf14471d36316159c94a802933d10b6a1e117b8554fe0d0d9b75c9d536"}, + {file = "propcache-0.2.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4e6281aedfca15301c41f74d7005e6e3f4ca143584ba696ac69df4f02f40d629"}, + {file = "propcache-0.2.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5b750a8e5a1262434fb1517ddf64b5de58327f1adc3524a5e44c2ca43305eb0b"}, + {file = "propcache-0.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf72af5e0fb40e9babf594308911436c8efde3cb5e75b6f206c34ad18be5c052"}, + {file = "propcache-0.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b2d0a12018b04f4cb820781ec0dffb5f7c7c1d2a5cd22bff7fb055a2cb19ebce"}, + {file = "propcache-0.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e800776a79a5aabdb17dcc2346a7d66d0777e942e4cd251defeb084762ecd17d"}, + {file = "propcache-0.2.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:4160d9283bd382fa6c0c2b5e017acc95bc183570cd70968b9202ad6d8fc48dce"}, + {file = "propcache-0.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:30b43e74f1359353341a7adb783c8f1b1c676367b011709f466f42fda2045e95"}, + {file = "propcache-0.2.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:58791550b27d5488b1bb52bc96328456095d96206a250d28d874fafe11b3dfaf"}, + {file = "propcache-0.2.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:0f022d381747f0dfe27e99d928e31bc51a18b65bb9e481ae0af1380a6725dd1f"}, + {file = "propcache-0.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:297878dc9d0a334358f9b608b56d02e72899f3b8499fc6044133f0d319e2ec30"}, + {file = "propcache-0.2.1-cp313-cp313-win32.whl", hash = "sha256:ddfab44e4489bd79bda09d84c430677fc7f0a4939a73d2bba3073036f487a0a6"}, + {file = "propcache-0.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:556fc6c10989f19a179e4321e5d678db8eb2924131e64652a51fe83e4c3db0e1"}, + {file = "propcache-0.2.1-py3-none-any.whl", hash = "sha256:52277518d6aae65536e9cea52d4e7fd2f7a66f4aa2d30ed3f2fcea620ace3c54"}, + {file = "propcache-0.2.1.tar.gz", hash = "sha256:3f77ce728b19cb537714499928fe800c3dda29e8d9428778fc7c186da4c09a64"}, ] [[package]] name = "pydantic" -version = "2.8.2" +version = "2.10.5" requires_python = ">=3.8" summary = "Data validation using Python type hints" dependencies = [ - "annotated-types>=0.4.0", - "pydantic-core==2.20.1", - "typing-extensions>=4.12.2; python_version >= \"3.13\"", - "typing-extensions>=4.6.1; python_version < \"3.13\"", + "annotated-types>=0.6.0", + "pydantic-core==2.27.2", + "typing-extensions>=4.12.2", ] files = [ - {file = "pydantic-2.8.2-py3-none-any.whl", hash = "sha256:73ee9fddd406dc318b885c7a2eab8a6472b68b8fb5ba8150949fc3db939f23c8"}, - {file = "pydantic-2.8.2.tar.gz", hash = "sha256:6f62c13d067b0755ad1c21a34bdd06c0c12625a22b0fc09c6b149816604f7c2a"}, + {file = "pydantic-2.10.5-py3-none-any.whl", hash = "sha256:4dd4e322dbe55472cb7ca7e73f4b63574eecccf2835ffa2af9021ce113c83c53"}, + {file = "pydantic-2.10.5.tar.gz", hash = "sha256:278b38dbbaec562011d659ee05f63346951b3a248a6f3642e1bc68894ea2b4ff"}, ] [[package]] name = "pydantic-core" -version = "2.20.1" +version = "2.27.2" requires_python = ">=3.8" summary = "Core functionality for Pydantic validation and serialization" dependencies = [ "typing-extensions!=4.7.0,>=4.6.0", ] files = [ - {file = "pydantic_core-2.20.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:595ba5be69b35777474fa07f80fc260ea71255656191adb22a8c53aba4479231"}, - {file = "pydantic_core-2.20.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a4f55095ad087474999ee28d3398bae183a66be4823f753cd7d67dd0153427c9"}, - {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f9aa05d09ecf4c75157197f27cdc9cfaeb7c5f15021c6373932bf3e124af029f"}, - {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e97fdf088d4b31ff4ba35db26d9cc472ac7ef4a2ff2badeabf8d727b3377fc52"}, - {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bc633a9fe1eb87e250b5c57d389cf28998e4292336926b0b6cdaee353f89a237"}, - {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d573faf8eb7e6b1cbbcb4f5b247c60ca8be39fe2c674495df0eb4318303137fe"}, - {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26dc97754b57d2fd00ac2b24dfa341abffc380b823211994c4efac7f13b9e90e"}, - {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:33499e85e739a4b60c9dac710c20a08dc73cb3240c9a0e22325e671b27b70d24"}, - {file = "pydantic_core-2.20.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:bebb4d6715c814597f85297c332297c6ce81e29436125ca59d1159b07f423eb1"}, - {file = "pydantic_core-2.20.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:516d9227919612425c8ef1c9b869bbbee249bc91912c8aaffb66116c0b447ebd"}, - {file = "pydantic_core-2.20.1-cp312-none-win32.whl", hash = "sha256:469f29f9093c9d834432034d33f5fe45699e664f12a13bf38c04967ce233d688"}, - {file = "pydantic_core-2.20.1-cp312-none-win_amd64.whl", hash = "sha256:035ede2e16da7281041f0e626459bcae33ed998cca6a0a007a5ebb73414ac72d"}, - {file = "pydantic_core-2.20.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:0827505a5c87e8aa285dc31e9ec7f4a17c81a813d45f70b1d9164e03a813a686"}, - {file = "pydantic_core-2.20.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19c0fa39fa154e7e0b7f82f88ef85faa2a4c23cc65aae2f5aea625e3c13c735a"}, - {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa223cd1e36b642092c326d694d8bf59b71ddddc94cdb752bbbb1c5c91d833b"}, - {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c336a6d235522a62fef872c6295a42ecb0c4e1d0f1a3e500fe949415761b8a19"}, - {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7eb6a0587eded33aeefea9f916899d42b1799b7b14b8f8ff2753c0ac1741edac"}, - {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:70c8daf4faca8da5a6d655f9af86faf6ec2e1768f4b8b9d0226c02f3d6209703"}, - {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e9fa4c9bf273ca41f940bceb86922a7667cd5bf90e95dbb157cbb8441008482c"}, - {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:11b71d67b4725e7e2a9f6e9c0ac1239bbc0c48cce3dc59f98635efc57d6dac83"}, - {file = "pydantic_core-2.20.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:270755f15174fb983890c49881e93f8f1b80f0b5e3a3cc1394a255706cabd203"}, - {file = "pydantic_core-2.20.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:c81131869240e3e568916ef4c307f8b99583efaa60a8112ef27a366eefba8ef0"}, - {file = "pydantic_core-2.20.1-cp313-none-win32.whl", hash = "sha256:b91ced227c41aa29c672814f50dbb05ec93536abf8f43cd14ec9521ea09afe4e"}, - {file = "pydantic_core-2.20.1-cp313-none-win_amd64.whl", hash = "sha256:65db0f2eefcaad1a3950f498aabb4875c8890438bc80b19362cf633b87a8ab20"}, - {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:a45f84b09ac9c3d35dfcf6a27fd0634d30d183205230a0ebe8373a0e8cfa0906"}, - {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d02a72df14dfdbaf228424573a07af10637bd490f0901cee872c4f434a735b94"}, - {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d2b27e6af28f07e2f195552b37d7d66b150adbaa39a6d327766ffd695799780f"}, - {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:084659fac3c83fd674596612aeff6041a18402f1e1bc19ca39e417d554468482"}, - {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:242b8feb3c493ab78be289c034a1f659e8826e2233786e36f2893a950a719bb6"}, - {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:38cf1c40a921d05c5edc61a785c0ddb4bed67827069f535d794ce6bcded919fc"}, - {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:e0bbdd76ce9aa5d4209d65f2b27fc6e5ef1312ae6c5333c26db3f5ade53a1e99"}, - {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:254ec27fdb5b1ee60684f91683be95e5133c994cc54e86a0b0963afa25c8f8a6"}, - {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:407653af5617f0757261ae249d3fba09504d7a71ab36ac057c938572d1bc9331"}, - {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:c693e916709c2465b02ca0ad7b387c4f8423d1db7b4649c551f27a529181c5ad"}, - {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5b5ff4911aea936a47d9376fd3ab17e970cc543d1b68921886e7f64bd28308d1"}, - {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:177f55a886d74f1808763976ac4efd29b7ed15c69f4d838bbd74d9d09cf6fa86"}, - {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:964faa8a861d2664f0c7ab0c181af0bea66098b1919439815ca8803ef136fc4e"}, - {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:4dd484681c15e6b9a977c785a345d3e378d72678fd5f1f3c0509608da24f2ac0"}, - {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f6d6cff3538391e8486a431569b77921adfcdef14eb18fbf19b7c0a5294d4e6a"}, - {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:a6d511cc297ff0883bc3708b465ff82d7560193169a8b93260f74ecb0a5e08a7"}, - {file = "pydantic_core-2.20.1.tar.gz", hash = "sha256:26ca695eeee5f9f1aeeb211ffc12f10bcb6f71e2989988fda61dabd65db878d4"}, + {file = "pydantic_core-2.27.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9e0c8cfefa0ef83b4da9588448b6d8d2a2bf1a53c3f1ae5fca39eb3061e2f0b0"}, + {file = "pydantic_core-2.27.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:83097677b8e3bd7eaa6775720ec8e0405f1575015a463285a92bfdfe254529ef"}, + {file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:172fce187655fece0c90d90a678424b013f8fbb0ca8b036ac266749c09438cb7"}, + {file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:519f29f5213271eeeeb3093f662ba2fd512b91c5f188f3bb7b27bc5973816934"}, + {file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:05e3a55d124407fffba0dd6b0c0cd056d10e983ceb4e5dbd10dda135c31071d6"}, + {file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9c3ed807c7b91de05e63930188f19e921d1fe90de6b4f5cd43ee7fcc3525cb8c"}, + {file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fb4aadc0b9a0c063206846d603b92030eb6f03069151a625667f982887153e2"}, + {file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28ccb213807e037460326424ceb8b5245acb88f32f3d2777427476e1b32c48c4"}, + {file = "pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:de3cd1899e2c279b140adde9357c4495ed9d47131b4a4eaff9052f23398076b3"}, + {file = "pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:220f892729375e2d736b97d0e51466252ad84c51857d4d15f5e9692f9ef12be4"}, + {file = "pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a0fcd29cd6b4e74fe8ddd2c90330fd8edf2e30cb52acda47f06dd615ae72da57"}, + {file = "pydantic_core-2.27.2-cp312-cp312-win32.whl", hash = "sha256:1e2cb691ed9834cd6a8be61228471d0a503731abfb42f82458ff27be7b2186fc"}, + {file = "pydantic_core-2.27.2-cp312-cp312-win_amd64.whl", hash = "sha256:cc3f1a99a4f4f9dd1de4fe0312c114e740b5ddead65bb4102884b384c15d8bc9"}, + {file = "pydantic_core-2.27.2-cp312-cp312-win_arm64.whl", hash = "sha256:3911ac9284cd8a1792d3cb26a2da18f3ca26c6908cc434a18f730dc0db7bfa3b"}, + {file = "pydantic_core-2.27.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7d14bd329640e63852364c306f4d23eb744e0f8193148d4044dd3dacdaacbd8b"}, + {file = "pydantic_core-2.27.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82f91663004eb8ed30ff478d77c4d1179b3563df6cdb15c0817cd1cdaf34d154"}, + {file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71b24c7d61131bb83df10cc7e687433609963a944ccf45190cfc21e0887b08c9"}, + {file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fa8e459d4954f608fa26116118bb67f56b93b209c39b008277ace29937453dc9"}, + {file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce8918cbebc8da707ba805b7fd0b382816858728ae7fe19a942080c24e5b7cd1"}, + {file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eda3f5c2a021bbc5d976107bb302e0131351c2ba54343f8a496dc8783d3d3a6a"}, + {file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8086fa684c4775c27f03f062cbb9eaa6e17f064307e86b21b9e0abc9c0f02e"}, + {file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8d9b3388db186ba0c099a6d20f0604a44eabdeef1777ddd94786cdae158729e4"}, + {file = "pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7a66efda2387de898c8f38c0cf7f14fca0b51a8ef0b24bfea5849f1b3c95af27"}, + {file = "pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:18a101c168e4e092ab40dbc2503bdc0f62010e95d292b27827871dc85450d7ee"}, + {file = "pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ba5dd002f88b78a4215ed2f8ddbdf85e8513382820ba15ad5ad8955ce0ca19a1"}, + {file = "pydantic_core-2.27.2-cp313-cp313-win32.whl", hash = "sha256:1ebaf1d0481914d004a573394f4be3a7616334be70261007e47c2a6fe7e50130"}, + {file = "pydantic_core-2.27.2-cp313-cp313-win_amd64.whl", hash = "sha256:953101387ecf2f5652883208769a79e48db18c6df442568a0b5ccd8c2723abee"}, + {file = "pydantic_core-2.27.2-cp313-cp313-win_arm64.whl", hash = "sha256:ac4dbfd1691affb8f48c2c13241a2e3b60ff23247cbcf981759c768b6633cf8b"}, + {file = "pydantic_core-2.27.2.tar.gz", hash = "sha256:eb026e5a4c1fee05726072337ff51d1efb6f59090b7da90d30ea58625b1ffb39"}, ] [[package]] @@ -288,106 +329,111 @@ files = [ [[package]] name = "pyobjc-core" -version = "10.3.1" +version = "11.0" requires_python = ">=3.8" summary = "Python<->ObjC Interoperability Module" files = [ - {file = "pyobjc_core-10.3.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:6ff5823d13d0a534cdc17fa4ad47cf5bee4846ce0fd27fc40012e12b46db571b"}, - {file = "pyobjc_core-10.3.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2581e8e68885bcb0e11ec619e81ef28e08ee3fac4de20d8cc83bc5af5bcf4a90"}, - {file = "pyobjc_core-10.3.1.tar.gz", hash = "sha256:b204a80ccc070f9ab3f8af423a3a25a6fd787e228508d00c4c30f8ac538ba720"}, + {file = "pyobjc_core-11.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a03061d4955c62ddd7754224a80cdadfdf17b6b5f60df1d9169a3b1b02923f0b"}, + {file = "pyobjc_core-11.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c338c1deb7ab2e9436d4175d1127da2eeed4a1b564b3d83b9f3ae4844ba97e86"}, + {file = "pyobjc_core-11.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b4e9dc4296110f251a4033ff3f40320b35873ea7f876bd29a1c9705bb5e08c59"}, + {file = "pyobjc_core-11.0.tar.gz", hash = "sha256:63bced211cb8a8fb5c8ff46473603da30e51112861bd02c438fbbbc8578d9a70"}, ] [[package]] name = "pyobjc-framework-avfoundation" -version = "10.3.1" -requires_python = ">=3.8" +version = "11.0" +requires_python = ">=3.9" summary = "Wrappers for the framework AVFoundation on macOS" dependencies = [ - "pyobjc-core>=10.3.1", - "pyobjc-framework-Cocoa>=10.3.1", - "pyobjc-framework-CoreAudio>=10.3.1", - "pyobjc-framework-CoreMedia>=10.3.1", - "pyobjc-framework-Quartz>=10.3.1", + "pyobjc-core>=11.0", + "pyobjc-framework-Cocoa>=11.0", + "pyobjc-framework-CoreAudio>=11.0", + "pyobjc-framework-CoreMedia>=11.0", + "pyobjc-framework-Quartz>=11.0", ] files = [ - {file = "pyobjc_framework_AVFoundation-10.3.1-cp36-abi3-macosx_10_13_universal2.whl", hash = "sha256:0896f6650df35f0229d1fb3aa3fbf632647dd815d4921cb61d9eb7fa26be6237"}, - {file = "pyobjc_framework_AVFoundation-10.3.1-cp36-abi3-macosx_10_9_universal2.whl", hash = "sha256:0cb27cc95288d95df7504adf474596f8855de7fa7798bbc1bbfbdfbbcb940952"}, - {file = "pyobjc_framework_AVFoundation-10.3.1-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ccb606ef0806d952a04db45ae691167678121df1d8d7c2f8cc73745695097033"}, - {file = "pyobjc_framework_AVFoundation-10.3.1-cp36-abi3-macosx_11_0_universal2.whl", hash = "sha256:00889eb915479aa9ea392cdd241e4b635ae0fa3114f043d08cf3e1d1b5a23bd4"}, - {file = "pyobjc_framework_avfoundation-10.3.1.tar.gz", hash = "sha256:2f94bee3a4217b46d9416cad066e4f357bf0f344079c328736114451ae19ae94"}, + {file = "pyobjc_framework_AVFoundation-11.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:6bb6f4be53c0fb42bee3f46cf0bb5396a8fd13f92d47a01f6b77037a1134f26b"}, + {file = "pyobjc_framework_AVFoundation-11.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d9d2497acf3e7c5ae4a8175832af249754847b415494422727ac43efe14cc776"}, + {file = "pyobjc_framework_AVFoundation-11.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:da932d77e29e3f4112d0526918a47c978381d00af23133cb06e0a5f76e92a9b6"}, + {file = "pyobjc_framework_avfoundation-11.0.tar.gz", hash = "sha256:269a592bdaf8a16948d8935f0cf7c8cb9a53e7ea609a963ada0e55f749ddb530"}, ] [[package]] name = "pyobjc-framework-cocoa" -version = "10.3.1" -requires_python = ">=3.8" +version = "11.0" +requires_python = ">=3.9" summary = "Wrappers for the Cocoa frameworks on macOS" dependencies = [ - "pyobjc-core>=10.3.1", + "pyobjc-core>=11.0", ] files = [ - {file = "pyobjc_framework_Cocoa-10.3.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:11b4e0bad4bbb44a4edda128612f03cdeab38644bbf174de0c13129715497296"}, - {file = "pyobjc_framework_Cocoa-10.3.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:de5e62e5ccf2871a94acf3bf79646b20ea893cc9db78afa8d1fe1b0d0f7cbdb0"}, - {file = "pyobjc_framework_cocoa-10.3.1.tar.gz", hash = "sha256:1cf20714daaa986b488fb62d69713049f635c9d41a60c8da97d835710445281a"}, + {file = "pyobjc_framework_Cocoa-11.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:280a577b83c68175a28b2b7138d1d2d3111f2b2b66c30e86f81a19c2b02eae71"}, + {file = "pyobjc_framework_Cocoa-11.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:15b2bd977ed340074f930f1330f03d42912d5882b697d78bd06f8ebe263ef92e"}, + {file = "pyobjc_framework_Cocoa-11.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:5750001db544e67f2b66f02067d8f0da96bb2ef71732bde104f01b8628f9d7ea"}, + {file = "pyobjc_framework_cocoa-11.0.tar.gz", hash = "sha256:00346a8cb81ad7b017b32ff7bf596000f9faa905807b1bd234644ebd47f692c5"}, ] [[package]] name = "pyobjc-framework-coreaudio" -version = "10.3.1" -requires_python = ">=3.8" +version = "11.0" +requires_python = ">=3.9" summary = "Wrappers for the framework CoreAudio on macOS" dependencies = [ - "pyobjc-core>=10.3.1", - "pyobjc-framework-Cocoa>=10.3.1", + "pyobjc-core>=11.0", + "pyobjc-framework-Cocoa>=11.0", ] files = [ - {file = "pyobjc_framework_CoreAudio-10.3.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:e0aeca61a425d846afc92350ffba970e1e503469182f5f0ea436de98cfd00d96"}, - {file = "pyobjc_framework_CoreAudio-10.3.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:21cecd1b023b6960d1071c106345656de45a399196701b07c7e5c076321f25ad"}, - {file = "pyobjc_framework_coreaudio-10.3.1.tar.gz", hash = "sha256:c81c709bf955aea474a4de380b187f3c2e56c864ca7de520b08362b73070c795"}, + {file = "pyobjc_framework_CoreAudio-11.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d26eac5bc325bf046fc0bfdaa3322ddc828690dab726275f1c4c118bb888cc00"}, + {file = "pyobjc_framework_CoreAudio-11.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:272388af86809f7a81250d931e99f650f62878410d4e1cfcd8adf0bbfb0d4581"}, + {file = "pyobjc_framework_CoreAudio-11.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:764873ec0724e42844ed2f0ca95ab4654c5ba59f883799207a3eecd4f5b444df"}, + {file = "pyobjc_framework_coreaudio-11.0.tar.gz", hash = "sha256:38b6b531381119be6998cf704d04c9ea475aaa33f6dd460e0584351475acd0ae"}, ] [[package]] name = "pyobjc-framework-coremedia" -version = "10.3.1" -requires_python = ">=3.8" +version = "11.0" +requires_python = ">=3.9" summary = "Wrappers for the framework CoreMedia on macOS" dependencies = [ - "pyobjc-core>=10.3.1", - "pyobjc-framework-Cocoa>=10.3.1", + "pyobjc-core>=11.0", + "pyobjc-framework-Cocoa>=11.0", ] files = [ - {file = "pyobjc_framework_CoreMedia-10.3.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:c6eaf48f202becab10679e3b5dd62607ddec2739495db45882524592cabf3997"}, - {file = "pyobjc_framework_CoreMedia-10.3.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:98b8ab02e6ec979007b706e05166e16bd61121e47fbc6e449f4b2de2c58f3cb6"}, - {file = "pyobjc_framework_coremedia-10.3.1.tar.gz", hash = "sha256:bc3e0cddf5f546b5d8407d8f46b203f1bd4396ad5dbfdc0d064a560b3fe31221"}, + {file = "pyobjc_framework_CoreMedia-11.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:afd8eb59f5ce0730ff15476ad3989aa84ffb8d8d02c9b8b2c9c1248b0541dbff"}, + {file = "pyobjc_framework_CoreMedia-11.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:88b26ca9a1333ddbe2a6dfa9a8c2d2be712cb717c3e9e1174fed66bf8d7af067"}, + {file = "pyobjc_framework_CoreMedia-11.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:ab18a7fbc5003e0929fc8380f371bb580e6ecd6be26333bf88b4a7f51a9c0789"}, + {file = "pyobjc_framework_coremedia-11.0.tar.gz", hash = "sha256:a414db97ba30b43c9dd96213459d6efb169f9e92ce1ad7a75516a679b181ddfb"}, ] [[package]] name = "pyobjc-framework-mediaplayer" -version = "10.3.1" -requires_python = ">=3.8" +version = "11.0" +requires_python = ">=3.9" summary = "Wrappers for the framework MediaPlayer on macOS" dependencies = [ - "pyobjc-core>=10.3.1", - "pyobjc-framework-AVFoundation>=10.3.1", + "pyobjc-core>=11.0", + "pyobjc-framework-AVFoundation>=11.0", ] files = [ - {file = "pyobjc_framework_MediaPlayer-10.3.1-py2.py3-none-any.whl", hash = "sha256:5b428cc28e57c1778bd431156c3adb948650f7503f266689559d0ece94b34e8a"}, - {file = "pyobjc_framework_mediaplayer-10.3.1.tar.gz", hash = "sha256:97043df5ef89d4fbe217813e8f4ee1e226d8a43dee4eac00fff95e6b8a7772be"}, + {file = "pyobjc_framework_MediaPlayer-11.0-py2.py3-none-any.whl", hash = "sha256:b124b0f18444b69b64142bad2579287d0b1a4a35cb6b14526523a822066d527d"}, + {file = "pyobjc_framework_MediaPlayer-11.0-py3-none-any.whl", hash = "sha256:1a051624b536666feb5fd1a4bb54000ab45dac0c8aea4cd4707cbde1773acf57"}, + {file = "pyobjc_framework_mediaplayer-11.0.tar.gz", hash = "sha256:c61be0ba6c648db6b1d013a52f9afb8901a8d7fbabd983df2175c1b1fbff81e5"}, ] [[package]] name = "pyobjc-framework-quartz" -version = "10.3.1" -requires_python = ">=3.8" +version = "11.0" +requires_python = ">=3.9" summary = "Wrappers for the Quartz frameworks on macOS" dependencies = [ - "pyobjc-core>=10.3.1", - "pyobjc-framework-Cocoa>=10.3.1", + "pyobjc-core>=11.0", + "pyobjc-framework-Cocoa>=11.0", ] files = [ - {file = "pyobjc_framework_Quartz-10.3.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:ca35f92486869a41847a1703bb176aab8a53dbfd8e678d1f4d68d8e6e1581c71"}, - {file = "pyobjc_framework_Quartz-10.3.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:00a0933267e3a46ea4afcc35d117b2efb920f06de797fa66279c52e7057e3590"}, - {file = "pyobjc_framework_quartz-10.3.1.tar.gz", hash = "sha256:b6d7e346d735c9a7f147cd78e6da79eeae416a0b7d3874644c83a23786c6f886"}, + {file = "pyobjc_framework_Quartz-11.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:cb4a9f2d9d580ea15e25e6b270f47681afb5689cafc9e25712445ce715bcd18e"}, + {file = "pyobjc_framework_Quartz-11.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:973b4f9b8ab844574461a038bd5269f425a7368d6e677e3cc81fcc9b27b65498"}, + {file = "pyobjc_framework_Quartz-11.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:66ab58d65348863b8707e63b2ec5cdc54569ee8189d1af90d52f29f5fdf6272c"}, + {file = "pyobjc_framework_quartz-11.0.tar.gz", hash = "sha256:3205bf7795fb9ae34747f701486b3db6dfac71924894d1f372977c4d70c3c619"}, ] [[package]] @@ -405,18 +451,6 @@ name = "pytomlpp" version = "1.0.13" summary = "A python wrapper for toml++" files = [ - {file = "pytomlpp-1.0.13-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:4710c72456c10a90e58084174312abef8f9652b0f91c240c008903c1bd99814d"}, - {file = "pytomlpp-1.0.13-pp37-pypy37_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b59acc12339a992404289ab7294f28ba06c7df3c2562e81d316a0e744ab4103b"}, - {file = "pytomlpp-1.0.13-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:252e31a5e013a74b898784f4ffb8aa8068e136b910ad11f2af1ee8a5700e6e1e"}, - {file = "pytomlpp-1.0.13-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:09e716c0f462d15f2334cecc736957777dd30f8a5bfa5cf8150679da7577d2fd"}, - {file = "pytomlpp-1.0.13-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:19dbded2995370e802105fa6dce54ed60f79e58b4eb35fee7ef33f1fb5958f6c"}, - {file = "pytomlpp-1.0.13-pp38-pypy38_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9f87f6c958309e4c2358b778902c80bd33611d1c392f1abe2c226e3a62909ca4"}, - {file = "pytomlpp-1.0.13-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e285aca948b419301fdda1927723287ef28482752782c44c9ee8c57eae7a1dc8"}, - {file = "pytomlpp-1.0.13-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:aad6ae19c056ea62a43fec82427ad4675b5c773dc255c4bdcf6da659cd7edff6"}, - {file = "pytomlpp-1.0.13-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:0e0b34b7a132856567714342e9a622f7be0b4c9bac561a6252f0f85626c1aa4b"}, - {file = "pytomlpp-1.0.13-pp39-pypy39_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ac06ca7683f5a2737b3888ea1e38d6968abb24fab703bc7ceccbe589d5420e0c"}, - {file = "pytomlpp-1.0.13-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:35225c1d9d674df87b4682f04af97856049351c38822455b78258248d9309363"}, - {file = "pytomlpp-1.0.13-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:dbc9208ac58ea2a9d5ebb77e69d54d146744007f4a704a3f4e56d9881d41ee1c"}, {file = "pytomlpp-1.0.13.tar.gz", hash = "sha256:a0bd639a8f624d1bdf5b3ea94363ca23dbfef38ab7b5b9348881a84afab434ad"}, ] @@ -437,43 +471,43 @@ files = [ [[package]] name = "rich" -version = "13.7.1" -requires_python = ">=3.7.0" +version = "13.9.4" +requires_python = ">=3.8.0" summary = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" dependencies = [ "markdown-it-py>=2.2.0", "pygments<3.0.0,>=2.13.0", - "typing-extensions<5.0,>=4.0.0; python_version < \"3.9\"", + "typing-extensions<5.0,>=4.0.0; python_version < \"3.11\"", ] files = [ - {file = "rich-13.7.1-py3-none-any.whl", hash = "sha256:4edbae314f59eb482f54e9e30bf00d33350aaa94f4bfcd4e9e3110e64d0d7222"}, - {file = "rich-13.7.1.tar.gz", hash = "sha256:9be308cb1fe2f1f57d67ce99e95af38a1e2bc71ad9813b0e247cf7ffbcc3a432"}, + {file = "rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90"}, + {file = "rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098"}, ] [[package]] name = "ruff" -version = "0.5.1" +version = "0.9.2" requires_python = ">=3.7" summary = "An extremely fast Python linter and code formatter, written in Rust." files = [ - {file = "ruff-0.5.1-py3-none-linux_armv6l.whl", hash = "sha256:6ecf968fcf94d942d42b700af18ede94b07521bd188aaf2cd7bc898dd8cb63b6"}, - {file = "ruff-0.5.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:204fb0a472f00f2e6280a7c8c7c066e11e20e23a37557d63045bf27a616ba61c"}, - {file = "ruff-0.5.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d235968460e8758d1e1297e1de59a38d94102f60cafb4d5382033c324404ee9d"}, - {file = "ruff-0.5.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38beace10b8d5f9b6bdc91619310af6d63dd2019f3fb2d17a2da26360d7962fa"}, - {file = "ruff-0.5.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5e478d2f09cf06add143cf8c4540ef77b6599191e0c50ed976582f06e588c994"}, - {file = "ruff-0.5.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f0368d765eec8247b8550251c49ebb20554cc4e812f383ff9f5bf0d5d94190b0"}, - {file = "ruff-0.5.1-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:3a9a9a1b582e37669b0138b7c1d9d60b9edac880b80eb2baba6d0e566bdeca4d"}, - {file = "ruff-0.5.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bdd9f723e16003623423affabcc0a807a66552ee6a29f90eddad87a40c750b78"}, - {file = "ruff-0.5.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:be9fd62c1e99539da05fcdc1e90d20f74aec1b7a1613463ed77870057cd6bd96"}, - {file = "ruff-0.5.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e216fc75a80ea1fbd96af94a6233d90190d5b65cc3d5dfacf2bd48c3e067d3e1"}, - {file = "ruff-0.5.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c4c2112e9883a40967827d5c24803525145e7dab315497fae149764979ac7929"}, - {file = "ruff-0.5.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dfaf11c8a116394da3b65cd4b36de30d8552fa45b8119b9ef5ca6638ab964fa3"}, - {file = "ruff-0.5.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d7ceb9b2fe700ee09a0c6b192c5ef03c56eb82a0514218d8ff700f6ade004108"}, - {file = "ruff-0.5.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:bac6288e82f6296f82ed5285f597713acb2a6ae26618ffc6b429c597b392535c"}, - {file = "ruff-0.5.1-py3-none-win32.whl", hash = "sha256:5c441d9c24ec09e1cb190a04535c5379b36b73c4bc20aa180c54812c27d1cca4"}, - {file = "ruff-0.5.1-py3-none-win_amd64.whl", hash = "sha256:b1789bf2cd3d1b5a7d38397cac1398ddf3ad7f73f4de01b1e913e2abc7dfc51d"}, - {file = "ruff-0.5.1-py3-none-win_arm64.whl", hash = "sha256:2875b7596a740cbbd492f32d24be73e545a4ce0a3daf51e4f4e609962bfd3cd2"}, - {file = "ruff-0.5.1.tar.gz", hash = "sha256:3164488aebd89b1745b47fd00604fb4358d774465f20d1fcd907f9c0fc1b0655"}, + {file = "ruff-0.9.2-py3-none-linux_armv6l.whl", hash = "sha256:80605a039ba1454d002b32139e4970becf84b5fee3a3c3bf1c2af6f61a784347"}, + {file = "ruff-0.9.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b9aab82bb20afd5f596527045c01e6ae25a718ff1784cb92947bff1f83068b00"}, + {file = "ruff-0.9.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fbd337bac1cfa96be615f6efcd4bc4d077edbc127ef30e2b8ba2a27e18c054d4"}, + {file = "ruff-0.9.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82b35259b0cbf8daa22a498018e300b9bb0174c2bbb7bcba593935158a78054d"}, + {file = "ruff-0.9.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8b6a9701d1e371bf41dca22015c3f89769da7576884d2add7317ec1ec8cb9c3c"}, + {file = "ruff-0.9.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9cc53e68b3c5ae41e8faf83a3b89f4a5d7b2cb666dff4b366bb86ed2a85b481f"}, + {file = "ruff-0.9.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:8efd9da7a1ee314b910da155ca7e8953094a7c10d0c0a39bfde3fcfd2a015684"}, + {file = "ruff-0.9.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3292c5a22ea9a5f9a185e2d131dc7f98f8534a32fb6d2ee7b9944569239c648d"}, + {file = "ruff-0.9.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1a605fdcf6e8b2d39f9436d343d1f0ff70c365a1e681546de0104bef81ce88df"}, + {file = "ruff-0.9.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c547f7f256aa366834829a08375c297fa63386cbe5f1459efaf174086b564247"}, + {file = "ruff-0.9.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:d18bba3d3353ed916e882521bc3e0af403949dbada344c20c16ea78f47af965e"}, + {file = "ruff-0.9.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b338edc4610142355ccf6b87bd356729b62bf1bc152a2fad5b0c7dc04af77bfe"}, + {file = "ruff-0.9.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:492a5e44ad9b22a0ea98cf72e40305cbdaf27fac0d927f8bc9e1df316dcc96eb"}, + {file = "ruff-0.9.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:af1e9e9fe7b1f767264d26b1075ac4ad831c7db976911fa362d09b2d0356426a"}, + {file = "ruff-0.9.2-py3-none-win32.whl", hash = "sha256:71cbe22e178c5da20e1514e1e01029c73dc09288a8028a5d3446e6bba87a5145"}, + {file = "ruff-0.9.2-py3-none-win_amd64.whl", hash = "sha256:c5e1d6abc798419cf46eed03f54f2e0c3adb1ad4b801119dedf23fcaf69b55b5"}, + {file = "ruff-0.9.2-py3-none-win_arm64.whl", hash = "sha256:a1b63fa24149918f8b37cef2ee6fff81f24f0d74b6f0bdc37bc3e1f2143e41c6"}, + {file = "ruff-0.9.2.tar.gz", hash = "sha256:b5eceb334d55fae5f316f783437392642ae18e16dcf4f1858d55d3c2a0f8f5d0"}, ] [[package]] @@ -488,76 +522,89 @@ files = [ [[package]] name = "websockets" -version = "12.0" -requires_python = ">=3.8" +version = "14.2" +requires_python = ">=3.9" summary = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" files = [ - {file = "websockets-12.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0e6e2711d5a8e6e482cacb927a49a3d432345dfe7dea8ace7b5790df5932e4df"}, - {file = "websockets-12.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:dbcf72a37f0b3316e993e13ecf32f10c0e1259c28ffd0a85cee26e8549595fbc"}, - {file = "websockets-12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:12743ab88ab2af1d17dd4acb4645677cb7063ef4db93abffbf164218a5d54c6b"}, - {file = "websockets-12.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b645f491f3c48d3f8a00d1fce07445fab7347fec54a3e65f0725d730d5b99cb"}, - {file = "websockets-12.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9893d1aa45a7f8b3bc4510f6ccf8db8c3b62120917af15e3de247f0780294b92"}, - {file = "websockets-12.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f38a7b376117ef7aff996e737583172bdf535932c9ca021746573bce40165ed"}, - {file = "websockets-12.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:f764ba54e33daf20e167915edc443b6f88956f37fb606449b4a5b10ba42235a5"}, - {file = "websockets-12.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:1e4b3f8ea6a9cfa8be8484c9221ec0257508e3a1ec43c36acdefb2a9c3b00aa2"}, - {file = "websockets-12.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:9fdf06fd06c32205a07e47328ab49c40fc1407cdec801d698a7c41167ea45113"}, - {file = "websockets-12.0-cp312-cp312-win32.whl", hash = "sha256:baa386875b70cbd81798fa9f71be689c1bf484f65fd6fb08d051a0ee4e79924d"}, - {file = "websockets-12.0-cp312-cp312-win_amd64.whl", hash = "sha256:ae0a5da8f35a5be197f328d4727dbcfafa53d1824fac3d96cdd3a642fe09394f"}, - {file = "websockets-12.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:248d8e2446e13c1d4326e0a6a4e9629cb13a11195051a73acf414812700badbd"}, - {file = "websockets-12.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f44069528d45a933997a6fef143030d8ca8042f0dfaad753e2906398290e2870"}, - {file = "websockets-12.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c4e37d36f0d19f0a4413d3e18c0d03d0c268ada2061868c1e6f5ab1a6d575077"}, - {file = "websockets-12.0-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d829f975fc2e527a3ef2f9c8f25e553eb7bc779c6665e8e1d52aa22800bb38b"}, - {file = "websockets-12.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:2c71bd45a777433dd9113847af751aae36e448bc6b8c361a566cb043eda6ec30"}, - {file = "websockets-12.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:0bee75f400895aef54157b36ed6d3b308fcab62e5260703add87f44cee9c82a6"}, - {file = "websockets-12.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:423fc1ed29f7512fceb727e2d2aecb952c46aa34895e9ed96071821309951123"}, - {file = "websockets-12.0-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:27a5e9964ef509016759f2ef3f2c1e13f403725a5e6a1775555994966a66e931"}, - {file = "websockets-12.0-pp38-pypy38_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c3181df4583c4d3994d31fb235dc681d2aaad744fbdbf94c4802485ececdecf2"}, - {file = "websockets-12.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:b067cb952ce8bf40115f6c19f478dc71c5e719b7fbaa511359795dfd9d1a6468"}, - {file = "websockets-12.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:00700340c6c7ab788f176d118775202aadea7602c5cc6be6ae127761c16d6b0b"}, - {file = "websockets-12.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e469d01137942849cff40517c97a30a93ae79917752b34029f0ec72df6b46399"}, - {file = "websockets-12.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffefa1374cd508d633646d51a8e9277763a9b78ae71324183693959cf94635a7"}, - {file = "websockets-12.0-pp39-pypy39_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba0cab91b3956dfa9f512147860783a1829a8d905ee218a9837c18f683239611"}, - {file = "websockets-12.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2cb388a5bfb56df4d9a406783b7f9dbefb888c09b71629351cc6b036e9259370"}, - {file = "websockets-12.0-py3-none-any.whl", hash = "sha256:dc284bbc8d7c78a6c69e0c7325ab46ee5e40bb4d50e494d8131a07ef47500e9e"}, - {file = "websockets-12.0.tar.gz", hash = "sha256:81df9cbcbb6c260de1e007e58c011bfebe2dafc8435107b0537f393dd38c8b1b"}, + {file = "websockets-14.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1f20522e624d7ffbdbe259c6b6a65d73c895045f76a93719aa10cd93b3de100c"}, + {file = "websockets-14.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:647b573f7d3ada919fd60e64d533409a79dcf1ea21daeb4542d1d996519ca967"}, + {file = "websockets-14.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6af99a38e49f66be5a64b1e890208ad026cda49355661549c507152113049990"}, + {file = "websockets-14.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:091ab63dfc8cea748cc22c1db2814eadb77ccbf82829bac6b2fbe3401d548eda"}, + {file = "websockets-14.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b374e8953ad477d17e4851cdc66d83fdc2db88d9e73abf755c94510ebddceb95"}, + {file = "websockets-14.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a39d7eceeea35db85b85e1169011bb4321c32e673920ae9c1b6e0978590012a3"}, + {file = "websockets-14.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0a6f3efd47ffd0d12080594f434faf1cd2549b31e54870b8470b28cc1d3817d9"}, + {file = "websockets-14.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:065ce275e7c4ffb42cb738dd6b20726ac26ac9ad0a2a48e33ca632351a737267"}, + {file = "websockets-14.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e9d0e53530ba7b8b5e389c02282f9d2aa47581514bd6049d3a7cffe1385cf5fe"}, + {file = "websockets-14.2-cp312-cp312-win32.whl", hash = "sha256:20e6dd0984d7ca3037afcb4494e48c74ffb51e8013cac71cf607fffe11df7205"}, + {file = "websockets-14.2-cp312-cp312-win_amd64.whl", hash = "sha256:44bba1a956c2c9d268bdcdf234d5e5ff4c9b6dc3e300545cbe99af59dda9dcce"}, + {file = "websockets-14.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6f1372e511c7409a542291bce92d6c83320e02c9cf392223272287ce55bc224e"}, + {file = "websockets-14.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4da98b72009836179bb596a92297b1a61bb5a830c0e483a7d0766d45070a08ad"}, + {file = "websockets-14.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8a86a269759026d2bde227652b87be79f8a734e582debf64c9d302faa1e9f03"}, + {file = "websockets-14.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:86cf1aaeca909bf6815ea714d5c5736c8d6dd3a13770e885aafe062ecbd04f1f"}, + {file = "websockets-14.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9b0f6c3ba3b1240f602ebb3971d45b02cc12bd1845466dd783496b3b05783a5"}, + {file = "websockets-14.2-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:669c3e101c246aa85bc8534e495952e2ca208bd87994650b90a23d745902db9a"}, + {file = "websockets-14.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:eabdb28b972f3729348e632ab08f2a7b616c7e53d5414c12108c29972e655b20"}, + {file = "websockets-14.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2066dc4cbcc19f32c12a5a0e8cc1b7ac734e5b64ac0a325ff8353451c4b15ef2"}, + {file = "websockets-14.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ab95d357cd471df61873dadf66dd05dd4709cae001dd6342edafc8dc6382f307"}, + {file = "websockets-14.2-cp313-cp313-win32.whl", hash = "sha256:a9e72fb63e5f3feacdcf5b4ff53199ec8c18d66e325c34ee4c551ca748623bbc"}, + {file = "websockets-14.2-cp313-cp313-win_amd64.whl", hash = "sha256:b439ea828c4ba99bb3176dc8d9b933392a2413c0f6b149fdcba48393f573377f"}, + {file = "websockets-14.2-py3-none-any.whl", hash = "sha256:7a6ceec4ea84469f15cf15807a747e9efe57e369c384fa86e022b3bea679b79b"}, + {file = "websockets-14.2.tar.gz", hash = "sha256:5059ed9c54945efb321f097084b4c7e52c246f2c869815876a69d1efc4ad6eb5"}, ] [[package]] name = "xdg-base-dirs" -version = "6.0.1" -requires_python = ">=3.10,<4.0" +version = "6.0.2" +requires_python = "<4.0,>=3.10" summary = "Variables defined by the XDG Base Directory Specification" files = [ - {file = "xdg_base_dirs-6.0.1-py3-none-any.whl", hash = "sha256:63f6ebc1721ced2e86c340856e004ef829501a30a37e17079c52cfaf0e1741b9"}, - {file = "xdg_base_dirs-6.0.1.tar.gz", hash = "sha256:b4c8f4ba72d1286018b25eea374ec6fbf4fddda3d4137edf50de95de53e195a6"}, + {file = "xdg_base_dirs-6.0.2-py3-none-any.whl", hash = "sha256:3c01d1b758ed4ace150ac960ac0bd13ce4542b9e2cdf01312dcda5012cfebabe"}, + {file = "xdg_base_dirs-6.0.2.tar.gz", hash = "sha256:950504e14d27cf3c9cb37744680a43bf0ac42efefc4ef4acf98dc736cab2bced"}, ] [[package]] name = "yarl" -version = "1.9.4" -requires_python = ">=3.7" +version = "1.18.3" +requires_python = ">=3.9" summary = "Yet another URL library" dependencies = [ "idna>=2.0", "multidict>=4.0", - "typing-extensions>=3.7.4; python_version < \"3.8\"", + "propcache>=0.2.0", ] files = [ - {file = "yarl-1.9.4-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0d2454f0aef65ea81037759be5ca9947539667eecebca092733b2eb43c965a81"}, - {file = "yarl-1.9.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:44d8ffbb9c06e5a7f529f38f53eda23e50d1ed33c6c869e01481d3fafa6b8142"}, - {file = "yarl-1.9.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:aaaea1e536f98754a6e5c56091baa1b6ce2f2700cc4a00b0d49eca8dea471074"}, - {file = "yarl-1.9.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3777ce5536d17989c91696db1d459574e9a9bd37660ea7ee4d3344579bb6f129"}, - {file = "yarl-1.9.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9fc5fc1eeb029757349ad26bbc5880557389a03fa6ada41703db5e068881e5f2"}, - {file = "yarl-1.9.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea65804b5dc88dacd4a40279af0cdadcfe74b3e5b4c897aa0d81cf86927fee78"}, - {file = "yarl-1.9.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa102d6d280a5455ad6a0f9e6d769989638718e938a6a0a2ff3f4a7ff8c62cc4"}, - {file = "yarl-1.9.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09efe4615ada057ba2d30df871d2f668af661e971dfeedf0c159927d48bbeff0"}, - {file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:008d3e808d03ef28542372d01057fd09168419cdc8f848efe2804f894ae03e51"}, - {file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:6f5cb257bc2ec58f437da2b37a8cd48f666db96d47b8a3115c29f316313654ff"}, - {file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:992f18e0ea248ee03b5a6e8b3b4738850ae7dbb172cc41c966462801cbf62cf7"}, - {file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:0e9d124c191d5b881060a9e5060627694c3bdd1fe24c5eecc8d5d7d0eb6faabc"}, - {file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3986b6f41ad22988e53d5778f91855dc0399b043fc8946d4f2e68af22ee9ff10"}, - {file = "yarl-1.9.4-cp312-cp312-win32.whl", hash = "sha256:4b21516d181cd77ebd06ce160ef8cc2a5e9ad35fb1c5930882baff5ac865eee7"}, - {file = "yarl-1.9.4-cp312-cp312-win_amd64.whl", hash = "sha256:a9bd00dc3bc395a662900f33f74feb3e757429e545d831eef5bb280252631984"}, - {file = "yarl-1.9.4-py3-none-any.whl", hash = "sha256:928cecb0ef9d5a7946eb6ff58417ad2fe9375762382f1bf5c55e61645f2c43ad"}, - {file = "yarl-1.9.4.tar.gz", hash = "sha256:566db86717cf8080b99b58b083b773a908ae40f06681e87e589a976faf8246bf"}, + {file = "yarl-1.18.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1dd4bdd05407ced96fed3d7f25dbbf88d2ffb045a0db60dbc247f5b3c5c25d50"}, + {file = "yarl-1.18.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7c33dd1931a95e5d9a772d0ac5e44cac8957eaf58e3c8da8c1414de7dd27c576"}, + {file = "yarl-1.18.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:25b411eddcfd56a2f0cd6a384e9f4f7aa3efee14b188de13048c25b5e91f1640"}, + {file = "yarl-1.18.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:436c4fc0a4d66b2badc6c5fc5ef4e47bb10e4fd9bf0c79524ac719a01f3607c2"}, + {file = "yarl-1.18.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e35ef8683211db69ffe129a25d5634319a677570ab6b2eba4afa860f54eeaf75"}, + {file = "yarl-1.18.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:84b2deecba4a3f1a398df819151eb72d29bfeb3b69abb145a00ddc8d30094512"}, + {file = "yarl-1.18.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00e5a1fea0fd4f5bfa7440a47eff01d9822a65b4488f7cff83155a0f31a2ecba"}, + {file = "yarl-1.18.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d0e883008013c0e4aef84dcfe2a0b172c4d23c2669412cf5b3371003941f72bb"}, + {file = "yarl-1.18.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5a3f356548e34a70b0172d8890006c37be92995f62d95a07b4a42e90fba54272"}, + {file = "yarl-1.18.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ccd17349166b1bee6e529b4add61727d3f55edb7babbe4069b5764c9587a8cc6"}, + {file = "yarl-1.18.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b958ddd075ddba5b09bb0be8a6d9906d2ce933aee81100db289badbeb966f54e"}, + {file = "yarl-1.18.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c7d79f7d9aabd6011004e33b22bc13056a3e3fb54794d138af57f5ee9d9032cb"}, + {file = "yarl-1.18.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4891ed92157e5430874dad17b15eb1fda57627710756c27422200c52d8a4e393"}, + {file = "yarl-1.18.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ce1af883b94304f493698b00d0f006d56aea98aeb49d75ec7d98cd4a777e9285"}, + {file = "yarl-1.18.3-cp312-cp312-win32.whl", hash = "sha256:f91c4803173928a25e1a55b943c81f55b8872f0018be83e3ad4938adffb77dd2"}, + {file = "yarl-1.18.3-cp312-cp312-win_amd64.whl", hash = "sha256:7e2ee16578af3b52ac2f334c3b1f92262f47e02cc6193c598502bd46f5cd1477"}, + {file = "yarl-1.18.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:90adb47ad432332d4f0bc28f83a5963f426ce9a1a8809f5e584e704b82685dcb"}, + {file = "yarl-1.18.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:913829534200eb0f789d45349e55203a091f45c37a2674678744ae52fae23efa"}, + {file = "yarl-1.18.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ef9f7768395923c3039055c14334ba4d926f3baf7b776c923c93d80195624782"}, + {file = "yarl-1.18.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88a19f62ff30117e706ebc9090b8ecc79aeb77d0b1f5ec10d2d27a12bc9f66d0"}, + {file = "yarl-1.18.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e17c9361d46a4d5addf777c6dd5eab0715a7684c2f11b88c67ac37edfba6c482"}, + {file = "yarl-1.18.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1a74a13a4c857a84a845505fd2d68e54826a2cd01935a96efb1e9d86c728e186"}, + {file = "yarl-1.18.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:41f7ce59d6ee7741af71d82020346af364949314ed3d87553763a2df1829cc58"}, + {file = "yarl-1.18.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f52a265001d830bc425f82ca9eabda94a64a4d753b07d623a9f2863fde532b53"}, + {file = "yarl-1.18.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:82123d0c954dc58db301f5021a01854a85bf1f3bb7d12ae0c01afc414a882ca2"}, + {file = "yarl-1.18.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2ec9bbba33b2d00999af4631a3397d1fd78290c48e2a3e52d8dd72db3a067ac8"}, + {file = "yarl-1.18.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:fbd6748e8ab9b41171bb95c6142faf068f5ef1511935a0aa07025438dd9a9bc1"}, + {file = "yarl-1.18.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:877d209b6aebeb5b16c42cbb377f5f94d9e556626b1bfff66d7b0d115be88d0a"}, + {file = "yarl-1.18.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b464c4ab4bfcb41e3bfd3f1c26600d038376c2de3297760dfe064d2cb7ea8e10"}, + {file = "yarl-1.18.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8d39d351e7faf01483cc7ff7c0213c412e38e5a340238826be7e0e4da450fdc8"}, + {file = "yarl-1.18.3-cp313-cp313-win32.whl", hash = "sha256:61ee62ead9b68b9123ec24bc866cbef297dd266175d53296e2db5e7f797f902d"}, + {file = "yarl-1.18.3-cp313-cp313-win_amd64.whl", hash = "sha256:578e281c393af575879990861823ef19d66e2b1d0098414855dd367e234f5b3c"}, + {file = "yarl-1.18.3-py3-none-any.whl", hash = "sha256:b57f4f58099328dfb26c6a771d09fb20dbbae81d20cfb66141251ea063bd101b"}, + {file = "yarl-1.18.3.tar.gz", hash = "sha256:ac1801c45cbf77b6c99242eeff4fffb5e4e73a800b5c4ad4fc0be5def634d2e1"}, ] From b9039b2ad4185e81a596df8604891af94d478bb1 Mon Sep 17 00:00:00 2001 From: Danielle McLean Date: Thu, 23 Jan 2025 18:56:43 +1100 Subject: [PATCH 27/32] Fix surprise incompatibility with websockets 14 :/ --- src/mpd_now_playable/config/model.py | 2 +- .../receivers/websockets/receiver.py | 17 ++++++----------- 2 files changed, 7 insertions(+), 12 deletions(-) diff --git a/src/mpd_now_playable/config/model.py b/src/mpd_now_playable/config/model.py index 0fde2d1..a112042 100644 --- a/src/mpd_now_playable/config/model.py +++ b/src/mpd_now_playable/config/model.py @@ -35,7 +35,7 @@ class WebsocketsReceiverConfig(BaseReceiverConfig): #: The hostname you'd like your WebSockets server to listen on. In most #: cases the default behaviour, which binds to all network interfaces, will #: be fine. - host: Optional[Host | tuple[Host, ...]] = None + host: Optional[Host] = None ReceiverConfig = Annotated[ diff --git a/src/mpd_now_playable/receivers/websockets/receiver.py b/src/mpd_now_playable/receivers/websockets/receiver.py index a125b6b..d218f35 100644 --- a/src/mpd_now_playable/receivers/websockets/receiver.py +++ b/src/mpd_now_playable/receivers/websockets/receiver.py @@ -2,7 +2,7 @@ from pathlib import Path import ormsgpack from websockets import broadcast -from websockets.server import WebSocketServerProtocol, serve +from websockets.asyncio.server import Server, ServerConnection, serve from yarl import URL from ...config.model import WebsocketsReceiverConfig @@ -24,12 +24,11 @@ def default(value: object) -> object: class WebsocketsReceiver(Receiver): config: WebsocketsReceiverConfig player: Player - connections: set[WebSocketServerProtocol] + server: Server last_status: bytes = MSGPACK_NULL def __init__(self, config: WebsocketsReceiverConfig): self.config = config - self.connections = set() @classmethod def loop_factory(cls) -> DefaultLoopFactory: @@ -37,18 +36,14 @@ class WebsocketsReceiver(Receiver): async def start(self, player: Player) -> None: self.player = player - await serve( + self.server = await serve( self.handle, host=self.config.host, port=self.config.port, reuse_port=True ) - async def handle(self, conn: WebSocketServerProtocol) -> None: - self.connections.add(conn) + async def handle(self, conn: ServerConnection) -> None: await conn.send(self.last_status) - try: - await conn.wait_closed() - finally: - self.connections.remove(conn) + await conn.wait_closed() async def update(self, playback: Playback) -> None: self.last_status = ormsgpack.packb(playback, default=default) - broadcast(self.connections, self.last_status) + broadcast(self.server.connections, self.last_status) From b41339a8c5d548da18142ec4cd9991cff8550a69 Mon Sep 17 00:00:00 2001 From: Danielle McLean Date: Thu, 23 Jan 2025 18:57:11 +1100 Subject: [PATCH 28/32] Update schemata to accommodate changes to WebSockets --- schemata/config-v1.json | 23 +++-------------------- schemata/playback-v1.json | 9 --------- 2 files changed, 3 insertions(+), 29 deletions(-) diff --git a/schemata/config-v1.json b/schemata/config-v1.json index 68eff9f..bf5b358 100644 --- a/schemata/config-v1.json +++ b/schemata/config-v1.json @@ -5,9 +5,6 @@ "kind": { "const": "cocoa", "default": "cocoa", - "enum": [ - "cocoa" - ], "title": "Kind", "type": "string" } @@ -52,28 +49,14 @@ "WebsocketsReceiverConfig": { "properties": { "host": { - "anyOf": [ - { - "format": "hostname", - "type": "string" - }, - { - "items": { - "format": "hostname", - "type": "string" - }, - "type": "array" - } - ], "description": "The hostname you'd like your WebSockets server to listen on. In most cases the default behaviour, which binds to all network interfaces, will be fine.", - "title": "Host" + "format": "hostname", + "title": "Host", + "type": "string" }, "kind": { "const": "websockets", "default": "websockets", - "enum": [ - "websockets" - ], "title": "Kind", "type": "string" }, diff --git a/schemata/playback-v1.json b/schemata/playback-v1.json index 58b7244..29c2300 100644 --- a/schemata/playback-v1.json +++ b/schemata/playback-v1.json @@ -136,9 +136,6 @@ }, { "const": "oneshot", - "enum": [ - "oneshot" - ], "type": "string" } ], @@ -172,9 +169,6 @@ }, { "const": "oneshot", - "enum": [ - "oneshot" - ], "type": "string" } ], @@ -328,9 +322,6 @@ "state": { "const": "stop", "default": "stop", - "enum": [ - "stop" - ], "title": "State", "type": "string" } From 7dfd3f85e471d12a0ab44ded9ed9aba43b470116 Mon Sep 17 00:00:00 2001 From: Danielle McLean Date: Wed, 5 Mar 2025 13:16:48 +1100 Subject: [PATCH 29/32] Make Queue.current nullable, since MPD may be stopped --- schemata/playback-v1.json | 2 +- src/mpd_now_playable/mpd/convert/to_playback.py | 4 ++-- src/mpd_now_playable/playback/queue.py | 6 ++++-- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/schemata/playback-v1.json b/schemata/playback-v1.json index 29c2300..7c97aa4 100644 --- a/schemata/playback-v1.json +++ b/schemata/playback-v1.json @@ -104,7 +104,7 @@ "Queue": { "properties": { "current": { - "description": "The zero-based index of the current song in MPD's queue.", + "description": "The zero-based index of the current song in MPD's queue. If MPD is currently stopped, then there is no current song in the queue, indicated by None.", "title": "Current", "type": "integer" }, diff --git a/src/mpd_now_playable/mpd/convert/to_playback.py b/src/mpd_now_playable/mpd/convert/to_playback.py index 29b2223..77adfa2 100644 --- a/src/mpd_now_playable/mpd/convert/to_playback.py +++ b/src/mpd_now_playable/mpd/convert/to_playback.py @@ -9,8 +9,8 @@ from .to_song import to_song def to_queue(mpd: MpdState) -> Queue: return Queue( - current=int(mpd.current["pos"]), - next=int(mpd.status["nextsong"]), + current=option_fmap(int, mpd.current.get("pos")), + next=int(mpd.status.get("nextsong", 0)), length=int(mpd.status["playlistlength"]), ) diff --git a/src/mpd_now_playable/playback/queue.py b/src/mpd_now_playable/playback/queue.py index 7c93741..df5e76c 100644 --- a/src/mpd_now_playable/playback/queue.py +++ b/src/mpd_now_playable/playback/queue.py @@ -3,8 +3,10 @@ from dataclasses import dataclass @dataclass(slots=True) class Queue: - #: The zero-based index of the current song in MPD's queue. - current: int + #: The zero-based index of the current song in MPD's queue. If MPD is + #: currently stopped, then there is no current song in the queue, indicated + #: by None. + current: int | None #: The index of the next song to be played, taking into account random and #: repeat playback settings. next: int From 413df0979de46466a2eca60b4df4f8f838c559d3 Mon Sep 17 00:00:00 2001 From: Danielle McLean Date: Wed, 5 Mar 2025 13:17:36 +1100 Subject: [PATCH 30/32] Convert MPD_LOGO to the right type for ObjC --- src/mpd_now_playable/receivers/cocoa/convert/to_nsimage.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mpd_now_playable/receivers/cocoa/convert/to_nsimage.py b/src/mpd_now_playable/receivers/cocoa/convert/to_nsimage.py index 213914d..e8544ed 100644 --- a/src/mpd_now_playable/receivers/cocoa/convert/to_nsimage.py +++ b/src/mpd_now_playable/receivers/cocoa/convert/to_nsimage.py @@ -37,4 +37,4 @@ def ns_image_to_media_item_artwork(img: NSImage) -> MPMediaItemArtwork: ) -MPD_LOGO = logo_to_ns_image() +MPD_LOGO = ns_image_to_media_item_artwork(logo_to_ns_image()) From c2f67c47815a5abe1041dd2bcab093533379c58b Mon Sep 17 00:00:00 2001 From: Danielle McLean Date: Wed, 30 Apr 2025 12:46:17 +1000 Subject: [PATCH 31/32] style: apply formatting fixes to AppKit code --- .../receivers/cocoa/now_playing.py | 3 +- stubs/AppKit/__init__.pyi | 38 +++++++++---------- 2 files changed, 21 insertions(+), 20 deletions(-) diff --git a/src/mpd_now_playable/receivers/cocoa/now_playing.py b/src/mpd_now_playable/receivers/cocoa/now_playing.py index 602006a..4e60ff3 100644 --- a/src/mpd_now_playable/receivers/cocoa/now_playing.py +++ b/src/mpd_now_playable/receivers/cocoa/now_playing.py @@ -1,7 +1,6 @@ from collections.abc import Callable, Coroutine from typing import Literal -from corefoundationasyncio import CoreFoundationEventLoop from MediaPlayer import ( MPChangePlaybackPositionCommandEvent, MPMusicPlaybackStatePlaying, @@ -12,6 +11,8 @@ from MediaPlayer import ( MPRemoteCommandHandlerStatusSuccess, ) +from corefoundationasyncio import CoreFoundationEventLoop + from ...config.model import CocoaReceiverConfig from ...playback import Playback from ...playback.state import PlaybackState diff --git a/stubs/AppKit/__init__.pyi b/stubs/AppKit/__init__.pyi index 30512c2..e21476e 100644 --- a/stubs/AppKit/__init__.pyi +++ b/stubs/AppKit/__init__.pyi @@ -8,26 +8,26 @@ NSCompositingOperationCopy: Final = 1 NSCompositingOperation = Literal[0, 1] class NSRect: - pass + pass def NSMakeRect(x: float, y: float, w: float, h: float) -> NSRect: ... class NSImage: - @staticmethod - def alloc() -> type[NSImage]: ... - - @staticmethod - def initByReferencingFile_(file: str) -> NSImage: ... - - @staticmethod - def initWithData_(data: bytes) -> NSImage: ... - - @staticmethod - def initWithSize_(size: CGSize) -> NSImage: ... - - def size(self) -> CGSize: ... - - def lockFocus(self) -> None: ... - def unlockFocus(self) -> None: ... - - def drawInRect_fromRect_operation_fraction_(self, inRect: NSRect, fromRect: NSRect, operation: NSCompositingOperation, fraction: float) -> None: ... + @staticmethod + def alloc() -> type[NSImage]: ... + @staticmethod + def initByReferencingFile_(file: str) -> NSImage: ... + @staticmethod + def initWithData_(data: bytes) -> NSImage: ... + @staticmethod + def initWithSize_(size: CGSize) -> NSImage: ... + def size(self) -> CGSize: ... + def lockFocus(self) -> None: ... + def unlockFocus(self) -> None: ... + def drawInRect_fromRect_operation_fraction_( + self, + inRect: NSRect, + fromRect: NSRect, + operation: NSCompositingOperation, + fraction: float, + ) -> None: ... From 28748df3c16bd7361be6b334840191cbeebbb8ff Mon Sep 17 00:00:00 2001 From: Danielle McLean Date: Wed, 30 Apr 2025 12:46:47 +1000 Subject: [PATCH 32/32] feat: hide running Cocoa receiver from the Dock --- src/mpd_now_playable/receivers/cocoa/now_playing.py | 4 ++++ stubs/AppKit/__init__.pyi | 10 ++++++++++ 2 files changed, 14 insertions(+) diff --git a/src/mpd_now_playable/receivers/cocoa/now_playing.py b/src/mpd_now_playable/receivers/cocoa/now_playing.py index 4e60ff3..83ba472 100644 --- a/src/mpd_now_playable/receivers/cocoa/now_playing.py +++ b/src/mpd_now_playable/receivers/cocoa/now_playing.py @@ -1,6 +1,7 @@ from collections.abc import Callable, Coroutine from typing import Literal +from AppKit import NSApplication, NSApplicationActivationPolicyAccessory from MediaPlayer import ( MPChangePlaybackPositionCommandEvent, MPMusicPlaybackStatePlaying, @@ -42,6 +43,9 @@ class CocoaNowPlayingReceiver(Receiver): pass async def start(self, player: Player) -> None: + NSApplication.sharedApplication().setActivationPolicy_( + NSApplicationActivationPolicyAccessory + ) self.cmd_center = MPRemoteCommandCenter.sharedCommandCenter() self.info_center = MPNowPlayingInfoCenter.defaultCenter() diff --git a/stubs/AppKit/__init__.pyi b/stubs/AppKit/__init__.pyi index e21476e..50b8fcc 100644 --- a/stubs/AppKit/__init__.pyi +++ b/stubs/AppKit/__init__.pyi @@ -2,6 +2,16 @@ from typing import Final, Literal from Foundation import CGSize +NSApplicationActivationPolicyRegular: Final = 0 +NSApplicationActivationPolicyAccessory: Final = 1 +NSApplicationActivationPolicyProhibited: Final = 2 +NSApplicationActivationPolicy = Literal[0, 1, 2] + +class NSApplication: + @staticmethod + def sharedApplication() -> NSApplication: ... + def setActivationPolicy_(self, policy: NSApplicationActivationPolicy) -> bool: ... + # There are many other operations available but we only actually use copy, so we don't need all of them here. NSCompositingOperationClear: Final = 0 NSCompositingOperationCopy: Final = 1