Compare commits

...

43 commits
v1.2.0 ... main

Author SHA1 Message Date
28748df3c1
feat: hide running Cocoa receiver from the Dock 2025-04-30 12:46:47 +10:00
c2f67c4781
style: apply formatting fixes to AppKit code 2025-04-30 12:46:17 +10:00
413df0979d
Convert MPD_LOGO to the right type for ObjC 2025-03-05 13:17:36 +11:00
7dfd3f85e4
Make Queue.current nullable, since MPD may be stopped 2025-03-05 13:16:48 +11:00
b41339a8c5
Update schemata to accommodate changes to WebSockets 2025-01-23 18:57:11 +11:00
b9039b2ad4
Fix surprise incompatibility with websockets 14 :/ 2025-01-23 18:56:43 +11:00
41f5369b2f
Safe-update dependencies, following semver 2025-01-23 18:22:12 +11:00
e1156b47de
Remove unnecessary dependency on attrs 2025-01-23 17:53:10 +11:00
452867699e
Update Mypy so I can use PEP 695 type param syntax 2024-07-30 10:09:33 +10:00
3ef3112014
Load crossfade settings into Playback.settings too 2024-07-29 11:14:48 +10:00
c29f4b9b27
Add 'heartbeat' to MPD client, so we notice if we disconnect 2024-07-29 10:55:27 +10:00
68609f3d07
Wrap Song in a broader Playback state object with stuff like volume and repeat mode 2024-07-26 09:53:17 +10:00
085bca7974
Declare nextsong index as part of MPD status response 2024-07-26 09:49:45 +10:00
dbd507bccb
Support current songs with no duration, such as streams 2024-07-23 13:38:41 +10:00
012bc0b025
Generate serialisation schema for songs, not validation schema 2024-07-23 13:34:50 +10:00
d9c8e0fe28
Find the current song's URL and pass it on when possible 2024-07-23 13:30:06 +10:00
b8bcdc5a83
Wrap MPD's state into a transfer struct before finalising the Song 2024-07-23 13:12:06 +10:00
1bb2032b9f
Ditch convert_if_exists, just use option_fmap which I prefer 2024-07-23 11:04:18 +10:00
fda799e32e
Fix inheritance of MusicBrainzTags into MPD response types 2024-07-23 10:52:43 +10:00
30e0829ff3
Update MusicBrainz tag shape in song schema 2024-07-23 10:52:10 +10:00
e2268c0c34
Allow websocket server to reuse its port (handle crashes better) 2024-07-23 10:45:06 +10:00
1e6dffcdcc
Support multivalued tags for MusicBrainz IDs too 2024-07-23 10:43:22 +10:00
86761bc420
Don't worry about ormsgpack import error, it's always required now 2024-07-23 10:37:38 +10:00
21b7c28692
Add descriptions to websockets config 2024-07-13 19:28:18 +10:00
ca5086f93a
Fix path to MPD logo in Cocoa receiver (oops) 2024-07-13 18:38:16 +10:00
582a4628b7
Introduce new WebSockets receiver impl
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.
2024-07-13 18:34:53 +10:00
75206a97f1
Add extra for websockets support 2024-07-11 12:17:09 +10:00
04859b8c8b
Adjust receiver protocol to accommodate config 2024-07-11 12:15:34 +10:00
09fe3b3e6c
Expand MusicBrainz support to be much more comprehensive 2024-07-11 12:12:56 +10:00
60116fd616
Make PyObjC a Darwin-only dependency 2024-07-10 23:57:34 +10:00
00ba34bd0b
Refactor Cocoa stuff into a 'receiver'
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.
2024-07-09 12:52:49 +10:00
27d8c37139
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!
2024-07-01 00:10:17 +10:00
3b7ddfa718
fix: handle missing environment variables more robustly 2024-06-28 14:14:24 +10:00
fcf7254e64
Remove deprecated Ruff option from pyproject.toml 2024-06-23 17:32:29 +10:00
1eca56b40e
Update Ruff and PyObjC versions 2024-06-23 17:32:10 +10:00
bc56686fc4
Support multivalued song tags (fixes #1)
python-mpd2 unreliably returns either a single value or a list of
values for commands like currentsong, which is super fun if you're
trying to write type stubs for it that statically describe its
behaviour. Whee.

Anyway, I ended up changing my internal song model to always use lists
for tags like artist and genre which are likely to have multiple values.
There's some finagling involved in massaging python-mpd2's output into
lists every time. However it's much nicer to work with an object that
always has a list of artists, even if it's a list of one or zero
artists, rather than an object that can have a single artist, a list of
multiple artists, or a null. So it's worth it.

The MPNowPlayingInfoCenter in MacOS only works with single string values
for these tags, not lists, so we have to join the artists and such into
a single string for its consumption. I'm using commas for the separator
at the moment, but I may make this a config option later on if there's
interest.
2024-06-23 17:24:37 +10:00
2f70c6f7fa
Prettier-print the generated config schema 2024-06-22 20:12:50 +10:00
3cb5db7528
Add field descriptions to the config schema :) 2024-06-22 20:12:23 +10:00
2def2aece5
Organise 'tools' modules into a subpackage 2024-06-22 18:48:32 +10:00
35703de261
Gitignore __version__.py to avoid spurious diffs 2024-06-22 18:41:49 +10:00
8e982e8a6b
Fixups to placate Ruff linter 2024-06-22 18:39:10 +10:00
dc037a0a4b
Support a TOML configuration file
The new config file currently only configures the same options that were
already available through environment variables. However I have ideas
for additional features that would be much nicer to support using a
structured configuration format like TOML rather than environment
variables, so config files exist now!

The previous environment variables are still supported and will be used
if you don't have a config file. I plan to keep supporting the MPD_HOST
and MPD_PORT environment variables forever since they're shared with
other MPD clients such as mpc, but I may eventually drop the environment
variables specific to mpd-now-playable in a future release.
2024-06-22 18:19:39 +10:00
796e3df87d
Display mpd-now-playable version on launch 2024-06-22 13:17:14 +10:00
60 changed files with 2670 additions and 575 deletions

1
.gitignore vendored
View file

@ -110,6 +110,7 @@ ipython_config.py
.pdm.toml .pdm.toml
.pdm-python .pdm-python
.pdm-build/ .pdm-build/
__version__.py
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/ __pypackages__/

View file

@ -9,8 +9,8 @@ The recommended way to install mpd-now-playable and its dependencies is with [pi
```shell ```shell
pipx install mpd-now-playable pipx install mpd-now-playable
# or, if you'd like to use a separate cache service, one of these: # 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[redis]
pipx install mpd-now-playable[memcached,msgpack] 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. Once pipx is done, the `mpd-now-playable` script should be available on your `$PATH` and ready to use.

559
pdm.lock generated
View file

@ -2,46 +2,49 @@
# It is not intended for manual editing. # It is not intended for manual editing.
[metadata] [metadata]
groups = ["default", "all", "dev"] groups = ["default", "all", "dev", "memcached", "redis", "websockets"]
strategy = ["cross_platform"] strategy = []
lock_version = "4.4.1" lock_version = "4.5.0"
content_hash = "sha256:2b4fba8ca6883f5a1de28a420870d566efa031d62f9057c59095a25a0e51d36e" content_hash = "sha256:b84a0925a81adb7c4ca5a1a947ccb0db6950a18955bd92f08a605ff06cd0c26c"
[[metadata.targets]]
requires_python = ">=3.12"
[[package]] [[package]]
name = "aiocache" name = "aiocache"
version = "0.12.2" version = "0.12.3"
summary = "multi backend asyncio cache" summary = "multi backend asyncio cache"
files = [ files = [
{file = "aiocache-0.12.2-py2.py3-none-any.whl", hash = "sha256:9b6fa30634ab0bfc3ecc44928a91ff07c6ea16d27d55469636b296ebc6eb5918"}, {file = "aiocache-0.12.3-py2.py3-none-any.whl", hash = "sha256:889086fc24710f431937b87ad3720a289f7fc31c4fd8b68e9f918b9bacd8270d"},
{file = "aiocache-0.12.2.tar.gz", hash = "sha256:b41c9a145b050a5dcbae1599f847db6dd445193b1f3bd172d8e0fe0cb9e96684"}, {file = "aiocache-0.12.3.tar.gz", hash = "sha256:f528b27bf4d436b497a1d0d1a8f59a542c153ab1e37c3621713cb376d44c4713"},
] ]
[[package]] [[package]]
name = "aiocache" name = "aiocache"
version = "0.12.2" version = "0.12.3"
extras = ["memcached"] extras = ["memcached"]
summary = "multi backend asyncio cache" summary = "multi backend asyncio cache"
dependencies = [ dependencies = [
"aiocache==0.12.2", "aiocache==0.12.3",
"aiomcache>=0.5.2", "aiomcache>=0.5.2",
] ]
files = [ files = [
{file = "aiocache-0.12.2-py2.py3-none-any.whl", hash = "sha256:9b6fa30634ab0bfc3ecc44928a91ff07c6ea16d27d55469636b296ebc6eb5918"}, {file = "aiocache-0.12.3-py2.py3-none-any.whl", hash = "sha256:889086fc24710f431937b87ad3720a289f7fc31c4fd8b68e9f918b9bacd8270d"},
{file = "aiocache-0.12.2.tar.gz", hash = "sha256:b41c9a145b050a5dcbae1599f847db6dd445193b1f3bd172d8e0fe0cb9e96684"}, {file = "aiocache-0.12.3.tar.gz", hash = "sha256:f528b27bf4d436b497a1d0d1a8f59a542c153ab1e37c3621713cb376d44c4713"},
] ]
[[package]] [[package]]
name = "aiocache" name = "aiocache"
version = "0.12.2" version = "0.12.3"
extras = ["redis"] extras = ["redis"]
summary = "multi backend asyncio cache" summary = "multi backend asyncio cache"
dependencies = [ dependencies = [
"aiocache==0.12.2", "aiocache==0.12.3",
"redis>=4.2.0", "redis>=4.2.0",
] ]
files = [ files = [
{file = "aiocache-0.12.2-py2.py3-none-any.whl", hash = "sha256:9b6fa30634ab0bfc3ecc44928a91ff07c6ea16d27d55469636b296ebc6eb5918"}, {file = "aiocache-0.12.3-py2.py3-none-any.whl", hash = "sha256:889086fc24710f431937b87ad3720a289f7fc31c4fd8b68e9f918b9bacd8270d"},
{file = "aiocache-0.12.2.tar.gz", hash = "sha256:b41c9a145b050a5dcbae1599f847db6dd445193b1f3bd172d8e0fe0cb9e96684"}, {file = "aiocache-0.12.3.tar.gz", hash = "sha256:f528b27bf4d436b497a1d0d1a8f59a542c153ab1e37c3621713cb376d44c4713"},
] ]
[[package]] [[package]]
@ -49,38 +52,143 @@ name = "aiomcache"
version = "0.8.2" version = "0.8.2"
requires_python = ">=3.8" requires_python = ">=3.8"
summary = "Minimal pure python memcached client" summary = "Minimal pure python memcached client"
dependencies = [
"typing-extensions>=4; python_version < \"3.11\"",
]
files = [ files = [
{file = "aiomcache-0.8.2-py3-none-any.whl", hash = "sha256:9d78d6b6e74e775df18b350b1cddfa96bd2f0a44d49ad27fa87759a3469cef5e"}, {file = "aiomcache-0.8.2-py3-none-any.whl", hash = "sha256:9d78d6b6e74e775df18b350b1cddfa96bd2f0a44d49ad27fa87759a3469cef5e"},
{file = "aiomcache-0.8.2.tar.gz", hash = "sha256:43b220d7f499a32a71871c4f457116eb23460fa216e69c1d32b81e3209e51359"}, {file = "aiomcache-0.8.2.tar.gz", hash = "sha256:43b220d7f499a32a71871c4f457116eb23460fa216e69c1d32b81e3209e51359"},
] ]
[[package]] [[package]]
name = "attrs" name = "annotated-types"
version = "23.2.0" version = "0.7.0"
requires_python = ">=3.7" requires_python = ">=3.8"
summary = "Classes Without Boilerplate" summary = "Reusable constraint types to use with typing.Annotated"
dependencies = [
"typing-extensions>=4.0.0; python_version < \"3.9\"",
]
files = [ files = [
{file = "attrs-23.2.0-py3-none-any.whl", hash = "sha256:99b87a485a5820b23b879f04c2305b44b951b502fd64be915879d77a7e8fc6f1"}, {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"},
{file = "attrs-23.2.0.tar.gz", hash = "sha256:935dc3b529c262f6cf76e50877d35a4bd3c1de194fd41f47a2b7ae8f19971f30"}, {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"},
]
[[package]]
name = "boltons"
version = "24.1.0"
requires_python = ">=3.7"
summary = "When they're not builtins, they're boltons."
files = [
{file = "boltons-24.1.0-py3-none-any.whl", hash = "sha256:a1776d47fdc387fb730fba1fe245f405ee184ee0be2fb447dd289773a84aed3b"},
{file = "boltons-24.1.0.tar.gz", hash = "sha256:4a49b7d57ee055b83a458c8682a2a6f199d263a8aa517098bda9bab813554b87"},
]
[[package]]
name = "class-doc"
version = "0.2.6"
requires_python = ">=3.6,<4.0"
summary = "Extract attributes docstrings defined in various ways"
dependencies = [
"more-itertools>=5.0.0",
]
files = [
{file = "class-doc-0.2.6.tar.gz", hash = "sha256:f5e036ed9b7f6de528affdd9f038851910b342d4c1c1252983a55ff080b530e0"},
{file = "class_doc-0.2.6-py3-none-any.whl", hash = "sha256:e6f2cea2dfbe93f76dee25de13d70dc0d2269698e8b849f751d98dc894c52ea5"},
]
[[package]]
name = "idna"
version = "3.7"
requires_python = ">=3.5"
summary = "Internationalized Domain Names in Applications (IDNA)"
files = [
{file = "idna-3.7-py3-none-any.whl", hash = "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0"},
{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"
requires_python = ">=3.8"
summary = "More routines for operating on iterables, beyond itertools"
files = [
{file = "more-itertools-10.3.0.tar.gz", hash = "sha256:e5d93ef411224fbcef366a6e8ddc4c5781bc6359d43412a65dd5964e46111463"},
{file = "more_itertools-10.3.0-py3-none-any.whl", hash = "sha256:ea6a02e24a9161e51faad17a8782b92a0df82c12c1c8886fec7f0c3fa1a1b320"},
]
[[package]]
name = "multidict"
version = "6.0.5"
requires_python = ">=3.7"
summary = "multidict implementation"
files = [
{file = "multidict-6.0.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:51d035609b86722963404f711db441cf7134f1889107fb171a970c9701f92e1e"},
{file = "multidict-6.0.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:cbebcd5bcaf1eaf302617c114aa67569dd3f090dd0ce8ba9e35e9985b41ac35b"},
{file = "multidict-6.0.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2ffc42c922dbfddb4a4c3b438eb056828719f07608af27d163191cb3e3aa6cc5"},
{file = "multidict-6.0.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ceb3b7e6a0135e092de86110c5a74e46bda4bd4fbfeeb3a3bcec79c0f861e450"},
{file = "multidict-6.0.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:79660376075cfd4b2c80f295528aa6beb2058fd289f4c9252f986751a4cd0496"},
{file = "multidict-6.0.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e4428b29611e989719874670fd152b6625500ad6c686d464e99f5aaeeaca175a"},
{file = "multidict-6.0.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d84a5c3a5f7ce6db1f999fb9438f686bc2e09d38143f2d93d8406ed2dd6b9226"},
{file = "multidict-6.0.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:76c0de87358b192de7ea9649beb392f107dcad9ad27276324c24c91774ca5271"},
{file = "multidict-6.0.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:79a6d2ba910adb2cbafc95dad936f8b9386e77c84c35bc0add315b856d7c3abb"},
{file = "multidict-6.0.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:92d16a3e275e38293623ebf639c471d3e03bb20b8ebb845237e0d3664914caef"},
{file = "multidict-6.0.5-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:fb616be3538599e797a2017cccca78e354c767165e8858ab5116813146041a24"},
{file = "multidict-6.0.5-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:14c2976aa9038c2629efa2c148022ed5eb4cb939e15ec7aace7ca932f48f9ba6"},
{file = "multidict-6.0.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:435a0984199d81ca178b9ae2c26ec3d49692d20ee29bc4c11a2a8d4514c67eda"},
{file = "multidict-6.0.5-cp312-cp312-win32.whl", hash = "sha256:9fe7b0653ba3d9d65cbe7698cca585bf0f8c83dbbcc710db9c90f478e175f2d5"},
{file = "multidict-6.0.5-cp312-cp312-win_amd64.whl", hash = "sha256:01265f5e40f5a17f8241d52656ed27192be03bfa8764d88e8220141d1e4b3556"},
{file = "multidict-6.0.5-py3-none-any.whl", hash = "sha256:0d63c74e3d7ab26de115c49bffc92cc77ed23395303d496eae515d4204a625e7"},
{file = "multidict-6.0.5.tar.gz", hash = "sha256:f7e301075edaf50500f0b341543c41194d8df3ae5caf4702f2095f3ca73dd8da"},
] ]
[[package]] [[package]]
name = "mypy" name = "mypy"
version = "1.10.0" version = "1.14.1"
requires_python = ">=3.8" requires_python = ">=3.8"
summary = "Optional static typing for Python" summary = "Optional static typing for Python"
dependencies = [ dependencies = [
"mypy-extensions>=1.0.0", "mypy-extensions>=1.0.0",
"typing-extensions>=4.1.0", "tomli>=1.1.0; python_version < \"3.11\"",
"typing-extensions>=4.6.0",
] ]
files = [ files = [
{file = "mypy-1.10.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:a781f6ad4bab20eef8b65174a57e5203f4be627b46291f4589879bf4e257b97b"}, {file = "mypy-1.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:30ff5ef8519bbc2e18b3b54521ec319513a26f1bba19a7582e7b1f58a6e69f14"},
{file = "mypy-1.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b808e12113505b97d9023b0b5e0c0705a90571c6feefc6f215c1df9381256e30"}, {file = "mypy-1.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cb9f255c18052343c70234907e2e532bc7e55a62565d64536dbc7706a20b78b9"},
{file = "mypy-1.10.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f55583b12156c399dce2df7d16f8a5095291354f1e839c252ec6c0611e86e2e"}, {file = "mypy-1.14.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b4e3413e0bddea671012b063e27591b953d653209e7a4fa5e48759cda77ca11"},
{file = "mypy-1.10.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4cf18f9d0efa1b16478c4c129eabec36148032575391095f73cae2e722fcf9d5"}, {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.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:bc6ac273b23c6b82da3bb25f4136c4fd42665f17f2cd850771cb600bdd2ebeda"}, {file = "mypy-1.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fad79bfe3b65fe6a1efaed97b445c3d37f7be9fdc348bdb2d7cac75579607c89"},
{file = "mypy-1.10.0-py3-none-any.whl", hash = "sha256:f8c083976eb530019175aabadb60921e73b4f45736760826aa1689dda8208aee"}, {file = "mypy-1.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:8fa2220e54d2946e94ab6dbb3ba0a992795bd68b16dc852db33028df2b00191b"},
{file = "mypy-1.10.0.tar.gz", hash = "sha256:3d087fcbec056c4ee34974da493a826ce316947485cef3901f511848e687c131"}, {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]] [[package]]
@ -95,114 +203,237 @@ files = [
[[package]] [[package]]
name = "ormsgpack" name = "ormsgpack"
version = "1.5.0" version = "1.7.0"
requires_python = ">=3.8" requires_python = ">=3.9"
summary = "Fast, correct Python msgpack library supporting dataclasses, datetimes, and numpy" summary = "Fast, correct Python msgpack library supporting dataclasses, datetimes, and numpy"
files = [ 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.7.0-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:77bc2ea387d85cfad045b9bcb8040bae43ad32dafe9363360f732cc19d489bbe"},
{file = "ormsgpack-1.5.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c6d423668e2c3abdbc474562b1c73360ff7326f06cb9532dcb73254b5b63dae4"}, {file = "ormsgpack-1.7.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ec763096d978d35eedcef0af13991a10741717c2e236b26f4c2047b0740ea7b"},
{file = "ormsgpack-1.5.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eeb2dd4ed3e503a8266dcbfbb8d810a36baa34e4bb4229e90e9c213058a06d74"}, {file = "ormsgpack-1.7.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:22418a4d399027a72fb2e6b873559b1886cf2e63323ca7afc17b222c454413b7"},
{file = "ormsgpack-1.5.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f13bd643df1324e8797caba4c5c0168a87524df8424e8413ba29723e89a586a"}, {file = "ormsgpack-1.7.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:97723786755a7df85fcf6e68d7b5359dacea98d5c26b1d9af219a3cc05df4734"},
{file = "ormsgpack-1.5.0-cp312-none-win_amd64.whl", hash = "sha256:e016da381a126478c4bafab0ae19d3a2537f6471341ecced4bb61471e8841cad"}, {file = "ormsgpack-1.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:7e6ada21f5c7a20ff7cf9b061c44e3814352f819947a12022ad8cb52a9f2a809"},
{file = "ormsgpack-1.5.0.tar.gz", hash = "sha256:00c0743ebaa8d21f1c868fbb609c99151ea79e67fec98b51a29077efd91ce348"}, {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.10.5"
requires_python = ">=3.8"
summary = "Data validation using Python type hints"
dependencies = [
"annotated-types>=0.6.0",
"pydantic-core==2.27.2",
"typing-extensions>=4.12.2",
]
files = [
{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.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.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]]
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]] [[package]]
name = "pyobjc-core" name = "pyobjc-core"
version = "10.2" version = "11.0"
requires_python = ">=3.8" requires_python = ">=3.8"
summary = "Python<->ObjC Interoperability Module" summary = "Python<->ObjC Interoperability Module"
files = [ files = [
{file = "pyobjc-core-10.2.tar.gz", hash = "sha256:0153206e15d0e0d7abd53ee8a7fbaf5606602a032e177a028fc8589516a8771c"}, {file = "pyobjc_core-11.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a03061d4955c62ddd7754224a80cdadfdf17b6b5f60df1d9169a3b1b02923f0b"},
{file = "pyobjc_core-10.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:a70546246177c23acb323c9324330e37638f1a0a3d13664abcba3bb75e43012c"}, {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]] [[package]]
name = "pyobjc-framework-avfoundation" name = "pyobjc-framework-avfoundation"
version = "10.2" version = "11.0"
requires_python = ">=3.8" requires_python = ">=3.9"
summary = "Wrappers for the framework AVFoundation on macOS" summary = "Wrappers for the framework AVFoundation on macOS"
dependencies = [ dependencies = [
"pyobjc-core>=10.2", "pyobjc-core>=11.0",
"pyobjc-framework-Cocoa>=10.2", "pyobjc-framework-Cocoa>=11.0",
"pyobjc-framework-CoreAudio>=10.2", "pyobjc-framework-CoreAudio>=11.0",
"pyobjc-framework-CoreMedia>=10.2", "pyobjc-framework-CoreMedia>=11.0",
"pyobjc-framework-Quartz>=10.2", "pyobjc-framework-Quartz>=11.0",
] ]
files = [ files = [
{file = "pyobjc-framework-AVFoundation-10.2.tar.gz", hash = "sha256:4d394014f2477c0c6a596dbb01ef5d92944058d0e0d954ce6121a676ae9395ce"}, {file = "pyobjc_framework_AVFoundation-11.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:6bb6f4be53c0fb42bee3f46cf0bb5396a8fd13f92d47a01f6b77037a1134f26b"},
{file = "pyobjc_framework_AVFoundation-10.2-cp36-abi3-macosx_10_9_universal2.whl", hash = "sha256:5f20c11a8870d7d58f0e4f20f918e45e922520aa5c9dbee61dc59ca4bc4bd26d"}, {file = "pyobjc_framework_AVFoundation-11.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d9d2497acf3e7c5ae4a8175832af249754847b415494422727ac43efe14cc776"},
{file = "pyobjc_framework_AVFoundation-10.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:283355d1f96c184e5f5f479870eb3bf510747307697616737bbc5d224af3abcb"}, {file = "pyobjc_framework_AVFoundation-11.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:da932d77e29e3f4112d0526918a47c978381d00af23133cb06e0a5f76e92a9b6"},
{file = "pyobjc_framework_AVFoundation-10.2-cp36-abi3-macosx_11_0_universal2.whl", hash = "sha256:a63a4e26c088023b0b1cb29d7da2c2246aa8eca2b56767fe1cc36a18c6fb650b"}, {file = "pyobjc_framework_avfoundation-11.0.tar.gz", hash = "sha256:269a592bdaf8a16948d8935f0cf7c8cb9a53e7ea609a963ada0e55f749ddb530"},
] ]
[[package]] [[package]]
name = "pyobjc-framework-cocoa" name = "pyobjc-framework-cocoa"
version = "10.2" version = "11.0"
requires_python = ">=3.8" requires_python = ">=3.9"
summary = "Wrappers for the Cocoa frameworks on macOS" summary = "Wrappers for the Cocoa frameworks on macOS"
dependencies = [ dependencies = [
"pyobjc-core>=10.2", "pyobjc-core>=11.0",
] ]
files = [ files = [
{file = "pyobjc-framework-Cocoa-10.2.tar.gz", hash = "sha256:6383141379636b13855dca1b39c032752862b829f93a49d7ddb35046abfdc035"}, {file = "pyobjc_framework_Cocoa-11.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:280a577b83c68175a28b2b7138d1d2d3111f2b2b66c30e86f81a19c2b02eae71"},
{file = "pyobjc_framework_Cocoa-10.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:18886d5013cd7dc7ecd6e0df5134c767569b5247fc10a5e293c72ee3937b217b"}, {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]] [[package]]
name = "pyobjc-framework-coreaudio" name = "pyobjc-framework-coreaudio"
version = "10.2" version = "11.0"
requires_python = ">=3.8" requires_python = ">=3.9"
summary = "Wrappers for the framework CoreAudio on macOS" summary = "Wrappers for the framework CoreAudio on macOS"
dependencies = [ dependencies = [
"pyobjc-core>=10.2", "pyobjc-core>=11.0",
"pyobjc-framework-Cocoa>=10.2", "pyobjc-framework-Cocoa>=11.0",
] ]
files = [ files = [
{file = "pyobjc-framework-CoreAudio-10.2.tar.gz", hash = "sha256:5e97ae7a65be85aee83aef004b31146c5fbf28325d870362959f7312b303fb67"}, {file = "pyobjc_framework_CoreAudio-11.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d26eac5bc325bf046fc0bfdaa3322ddc828690dab726275f1c4c118bb888cc00"},
{file = "pyobjc_framework_CoreAudio-10.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:32608ce881b5e6a7cb332c2732762fa93829ac495c5344c33e8e8b72a2431b23"}, {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]] [[package]]
name = "pyobjc-framework-coremedia" name = "pyobjc-framework-coremedia"
version = "10.2" version = "11.0"
requires_python = ">=3.8" requires_python = ">=3.9"
summary = "Wrappers for the framework CoreMedia on macOS" summary = "Wrappers for the framework CoreMedia on macOS"
dependencies = [ dependencies = [
"pyobjc-core>=10.2", "pyobjc-core>=11.0",
"pyobjc-framework-Cocoa>=10.2", "pyobjc-framework-Cocoa>=11.0",
] ]
files = [ files = [
{file = "pyobjc-framework-CoreMedia-10.2.tar.gz", hash = "sha256:d726d86636217eaa135e5626d05c7eb0f9b4529ce1ed504e08069fe1e0421483"}, {file = "pyobjc_framework_CoreMedia-11.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:afd8eb59f5ce0730ff15476ad3989aa84ffb8d8d02c9b8b2c9c1248b0541dbff"},
{file = "pyobjc_framework_CoreMedia-10.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:7fa13166a14d384bb6442e5f635310dd075c2a4b3b3bd67ac63b1e2e1fd2d65e"}, {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]] [[package]]
name = "pyobjc-framework-mediaplayer" name = "pyobjc-framework-mediaplayer"
version = "10.2" version = "11.0"
requires_python = ">=3.8" requires_python = ">=3.9"
summary = "Wrappers for the framework MediaPlayer on macOS" summary = "Wrappers for the framework MediaPlayer on macOS"
dependencies = [ dependencies = [
"pyobjc-core>=10.2", "pyobjc-core>=11.0",
"pyobjc-framework-AVFoundation>=10.2", "pyobjc-framework-AVFoundation>=11.0",
] ]
files = [ files = [
{file = "pyobjc-framework-MediaPlayer-10.2.tar.gz", hash = "sha256:4b6d296b084e01fb6e5c782b7b6308077db09f4051f50b0a6c3298ffbd1f1d70"}, {file = "pyobjc_framework_MediaPlayer-11.0-py2.py3-none-any.whl", hash = "sha256:b124b0f18444b69b64142bad2579287d0b1a4a35cb6b14526523a822066d527d"},
{file = "pyobjc_framework_MediaPlayer-10.2-py2.py3-none-any.whl", hash = "sha256:c501ea19380bfbf6b04fbe909fcfe9a78c5ff2a9b58dae87be259066b1ae3521"}, {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]] [[package]]
name = "pyobjc-framework-quartz" name = "pyobjc-framework-quartz"
version = "10.2" version = "11.0"
requires_python = ">=3.8" requires_python = ">=3.9"
summary = "Wrappers for the Quartz frameworks on macOS" summary = "Wrappers for the Quartz frameworks on macOS"
dependencies = [ dependencies = [
"pyobjc-core>=10.2", "pyobjc-core>=11.0",
"pyobjc-framework-Cocoa>=10.2", "pyobjc-framework-Cocoa>=11.0",
] ]
files = [ files = [
{file = "pyobjc-framework-Quartz-10.2.tar.gz", hash = "sha256:9b947e081f5bd6cd01c99ab5d62c36500d2d6e8d3b87421c1cbb7f9c885555eb"}, {file = "pyobjc_framework_Quartz-11.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:cb4a9f2d9d580ea15e25e6b270f47681afb5689cafc9e25712445ce715bcd18e"},
{file = "pyobjc_framework_Quartz-10.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:3e8e33246d966c2bd7f5ee2cf3b431582fa434a6ec2b6dbe580045ebf1f55be5"}, {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]] [[package]]
@ -215,47 +446,165 @@ files = [
{file = "python_mpd2-3.1.1-py2.py3-none-any.whl", hash = "sha256:86bf1100a0b135959d74a9a7a58cf0515bf30bb54eb25ae6fb8e175e50300fc3"}, {file = "python_mpd2-3.1.1-py2.py3-none-any.whl", hash = "sha256:86bf1100a0b135959d74a9a7a58cf0515bf30bb54eb25ae6fb8e175e50300fc3"},
] ]
[[package]]
name = "pytomlpp"
version = "1.0.13"
summary = "A python wrapper for toml++"
files = [
{file = "pytomlpp-1.0.13.tar.gz", hash = "sha256:a0bd639a8f624d1bdf5b3ea94363ca23dbfef38ab7b5b9348881a84afab434ad"},
]
[[package]] [[package]]
name = "redis" name = "redis"
version = "5.0.4" version = "5.0.7"
requires_python = ">=3.7" requires_python = ">=3.7"
summary = "Python client for Redis database and key-value store" 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 = [ files = [
{file = "redis-5.0.4-py3-none-any.whl", hash = "sha256:7adc2835c7a9b5033b7ad8f8918d09b7344188228809c98df07af226d39dec91"}, {file = "redis-5.0.7-py3-none-any.whl", hash = "sha256:0e479e24da960c690be5d9b96d21f7b918a98c0cf49af3b6fafaa0753f93a0db"},
{file = "redis-5.0.4.tar.gz", hash = "sha256:ec31f2ed9675cc54c21ba854cfe0462e6faf1d83c8ce5944709db8a4700b9c61"}, {file = "redis-5.0.7.tar.gz", hash = "sha256:8f611490b93c8109b50adc317b31bfd84fff31def3475b92e7e80bf39f48175b"},
]
[[package]]
name = "rich"
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.11\"",
]
files = [
{file = "rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90"},
{file = "rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098"},
] ]
[[package]] [[package]]
name = "ruff" name = "ruff"
version = "0.4.4" version = "0.9.2"
requires_python = ">=3.7" requires_python = ">=3.7"
summary = "An extremely fast Python linter and code formatter, written in Rust." summary = "An extremely fast Python linter and code formatter, written in Rust."
files = [ files = [
{file = "ruff-0.4.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:29d44ef5bb6a08e235c8249294fa8d431adc1426bfda99ed493119e6f9ea1bf6"}, {file = "ruff-0.9.2-py3-none-linux_armv6l.whl", hash = "sha256:80605a039ba1454d002b32139e4970becf84b5fee3a3c3bf1c2af6f61a784347"},
{file = "ruff-0.4.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c4efe62b5bbb24178c950732ddd40712b878a9b96b1d02b0ff0b08a090cbd891"}, {file = "ruff-0.9.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b9aab82bb20afd5f596527045c01e6ae25a718ff1784cb92947bff1f83068b00"},
{file = "ruff-0.4.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c8e2f1e8fc12d07ab521a9005d68a969e167b589cbcaee354cb61e9d9de9c15"}, {file = "ruff-0.9.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fbd337bac1cfa96be615f6efcd4bc4d077edbc127ef30e2b8ba2a27e18c054d4"},
{file = "ruff-0.4.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:60ed88b636a463214905c002fa3eaab19795679ed55529f91e488db3fe8976ab"}, {file = "ruff-0.9.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82b35259b0cbf8daa22a498018e300b9bb0174c2bbb7bcba593935158a78054d"},
{file = "ruff-0.4.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b90fc5e170fc71c712cc4d9ab0e24ea505c6a9e4ebf346787a67e691dfb72e85"}, {file = "ruff-0.9.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8b6a9701d1e371bf41dca22015c3f89769da7576884d2add7317ec1ec8cb9c3c"},
{file = "ruff-0.4.4-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:8e7e6ebc10ef16dcdc77fd5557ee60647512b400e4a60bdc4849468f076f6eef"}, {file = "ruff-0.9.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9cc53e68b3c5ae41e8faf83a3b89f4a5d7b2cb666dff4b366bb86ed2a85b481f"},
{file = "ruff-0.4.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b9ddb2c494fb79fc208cd15ffe08f32b7682519e067413dbaf5f4b01a6087bcd"}, {file = "ruff-0.9.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:8efd9da7a1ee314b910da155ca7e8953094a7c10d0c0a39bfde3fcfd2a015684"},
{file = "ruff-0.4.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c51c928a14f9f0a871082603e25a1588059b7e08a920f2f9fa7157b5bf08cfe9"}, {file = "ruff-0.9.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3292c5a22ea9a5f9a185e2d131dc7f98f8534a32fb6d2ee7b9944569239c648d"},
{file = "ruff-0.4.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b5eb0a4bfd6400b7d07c09a7725e1a98c3b838be557fee229ac0f84d9aa49c36"}, {file = "ruff-0.9.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1a605fdcf6e8b2d39f9436d343d1f0ff70c365a1e681546de0104bef81ce88df"},
{file = "ruff-0.4.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b1867ee9bf3acc21778dcb293db504692eda5f7a11a6e6cc40890182a9f9e595"}, {file = "ruff-0.9.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c547f7f256aa366834829a08375c297fa63386cbe5f1459efaf174086b564247"},
{file = "ruff-0.4.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1aecced1269481ef2894cc495647392a34b0bf3e28ff53ed95a385b13aa45768"}, {file = "ruff-0.9.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:d18bba3d3353ed916e882521bc3e0af403949dbada344c20c16ea78f47af965e"},
{file = "ruff-0.4.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9da73eb616b3241a307b837f32756dc20a0b07e2bcb694fec73699c93d04a69e"}, {file = "ruff-0.9.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b338edc4610142355ccf6b87bd356729b62bf1bc152a2fad5b0c7dc04af77bfe"},
{file = "ruff-0.4.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:958b4ea5589706a81065e2a776237de2ecc3e763342e5cc8e02a4a4d8a5e6f95"}, {file = "ruff-0.9.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:492a5e44ad9b22a0ea98cf72e40305cbdaf27fac0d927f8bc9e1df316dcc96eb"},
{file = "ruff-0.4.4-py3-none-win32.whl", hash = "sha256:cb53473849f011bca6e754f2cdf47cafc9c4f4ff4570003a0dad0b9b6890e876"}, {file = "ruff-0.9.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:af1e9e9fe7b1f767264d26b1075ac4ad831c7db976911fa362d09b2d0356426a"},
{file = "ruff-0.4.4-py3-none-win_amd64.whl", hash = "sha256:424e5b72597482543b684c11def82669cc6b395aa8cc69acc1858b5ef3e5daae"}, {file = "ruff-0.9.2-py3-none-win32.whl", hash = "sha256:71cbe22e178c5da20e1514e1e01029c73dc09288a8028a5d3446e6bba87a5145"},
{file = "ruff-0.4.4-py3-none-win_arm64.whl", hash = "sha256:39df0537b47d3b597293edbb95baf54ff5b49589eb7ff41926d8243caa995ea6"}, {file = "ruff-0.9.2-py3-none-win_amd64.whl", hash = "sha256:c5e1d6abc798419cf46eed03f54f2e0c3adb1ad4b801119dedf23fcaf69b55b5"},
{file = "ruff-0.4.4.tar.gz", hash = "sha256:f87ea42d5cdebdc6a69761a9d0bc83ae9b3b30d0ad78952005ba6568d6c022af"}, {file = "ruff-0.9.2-py3-none-win_arm64.whl", hash = "sha256:a1b63fa24149918f8b37cef2ee6fff81f24f0d74b6f0bdc37bc3e1f2143e41c6"},
{file = "ruff-0.9.2.tar.gz", hash = "sha256:b5eceb334d55fae5f316f783437392642ae18e16dcf4f1858d55d3c2a0f8f5d0"},
] ]
[[package]] [[package]]
name = "typing-extensions" name = "typing-extensions"
version = "4.11.0" version = "4.12.2"
requires_python = ">=3.8" requires_python = ">=3.8"
summary = "Backported and Experimental Type Hints for Python 3.8+" summary = "Backported and Experimental Type Hints for Python 3.8+"
files = [ files = [
{file = "typing_extensions-4.11.0-py3-none-any.whl", hash = "sha256:c1f94d72897edaf4ce775bb7558d5b79d8126906a14ea5ed1635921406c0387a"}, {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"},
{file = "typing_extensions-4.11.0.tar.gz", hash = "sha256:83f085bd5ca59c80295fc2a82ab5dac679cbe02b9f33f7d83af68e241bea51b0"}, {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"},
]
[[package]]
name = "websockets"
version = "14.2"
requires_python = ">=3.9"
summary = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)"
files = [
{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.2"
requires_python = "<4.0,>=3.10"
summary = "Variables defined by the XDG Base Directory Specification"
files = [
{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.18.3"
requires_python = ">=3.9"
summary = "Yet another URL library"
dependencies = [
"idna>=2.0",
"multidict>=4.0",
"propcache>=0.2.0",
]
files = [
{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"},
] ]

View file

@ -7,9 +7,15 @@ authors = [
] ]
dependencies = [ dependencies = [
"aiocache>=0.12.2", "aiocache>=0.12.2",
"attrs>=23.1.0", "pyobjc-framework-MediaPlayer>=10.0 ; sys_platform == 'darwin'",
"pyobjc-framework-MediaPlayer>=10.0",
"python-mpd2>=3.1.0", "python-mpd2>=3.1.0",
"xdg-base-dirs>=6.0.1",
"pytomlpp>=1.0.13",
"yarl>=1.9.4",
"boltons>=24.0.0",
"pydantic>=2.7.4",
"rich>=13.7.1",
"ormsgpack>=1.5.0",
] ]
readme = "README.md" readme = "README.md"
@ -24,14 +30,16 @@ classifiers = [
] ]
[project.optional-dependencies] [project.optional-dependencies]
redis = ["aiocache[redis]"] redis = [
memcached = ["aiocache[memcached]"] "aiocache[redis]",
msgpack = [
"ormsgpack>=1.5.0",
] ]
all = [ memcached = [
"mpd-now-playable[redis,memcached,msgpack]", "aiocache[memcached]",
] ]
websockets = [
"websockets>=12.0",
]
all = ["mpd-now-playable[redis,memcached,websockets]"]
[project.urls] [project.urls]
Homepage = "https://git.00dani.me/00dani/mpd-now-playable" Homepage = "https://git.00dani.me/00dani/mpd-now-playable"
@ -40,33 +48,16 @@ Issues = "https://git.00dani.me/00dani/mpd-now-playable/issues"
[project.scripts] [project.scripts]
mpd-now-playable = 'mpd_now_playable.cli:main' mpd-now-playable = 'mpd_now_playable.cli:main'
[tool.pdm.scripts]
start = {call = 'mpd_now_playable.cli:main'}
lint = 'ruff check src/mpd_now_playable'
typecheck = 'mypy -p mpd_now_playable'
check = {composite = ['lint', 'typecheck']}
[tool.pdm.version]
source = "scm"
[build-system] [build-system]
requires = ["pdm-backend"] requires = ["pdm-backend"]
build-backend = "pdm.backend" build-backend = "pdm.backend"
[tool.pdm.build]
excludes = ["**/.mypy_cache"]
[tool.pdm.dev-dependencies]
dev = [
"mypy>=1.7.1",
"ruff>=0.1.6",
]
[tool.mypy] [tool.mypy]
mypy_path = 'stubs' mypy_path = 'stubs'
plugins = ['pydantic.mypy', 'mpd_now_playable.tools.schema.plugin']
enable_incomplete_feature = 'NewGenericSyntax'
[tool.ruff] [tool.ruff.lint]
ignore-init-module-imports = true
select = [ select = [
# pycodestyle # pycodestyle
"E4", # import "E4", # import
@ -87,6 +78,7 @@ select = [
] ]
ignore = [ ignore = [
"ANN101", # missing-type-self "ANN101", # missing-type-self
"ANN102", # missing-type-cls
] ]
[tool.ruff.lint.flake8-annotations] [tool.ruff.lint.flake8-annotations]
@ -95,3 +87,26 @@ mypy-init-return = true
[tool.ruff.format] [tool.ruff.format]
# I prefer tabs for accessibility reasons. # I prefer tabs for accessibility reasons.
indent-style = "tab" indent-style = "tab"
[tool.pdm.scripts]
start = {call = 'mpd_now_playable.cli:main'}
lint = 'ruff check src/mpd_now_playable'
typecheck = 'mypy -p mpd_now_playable'
check = {composite = ['lint', 'typecheck'], keep_going = true}
[tool.pdm.version]
source = "scm"
write_to = 'mpd_now_playable/__version__.py'
write_template = "__version__ = '{}'"
[tool.pdm.build]
excludes = ["**/.mypy_cache"]
[tool.pdm.dev-dependencies]
dev = [
"mypy>=1.11.0",
"ruff>=0.1.6",
"class-doc>=0.2.6",
]

119
schemata/config-v1.json Normal file
View file

@ -0,0 +1,119 @@
{
"$defs": {
"CocoaReceiverConfig": {
"properties": {
"kind": {
"const": "cocoa",
"default": "cocoa",
"title": "Kind",
"type": "string"
}
},
"title": "CocoaReceiverConfig",
"type": "object"
},
"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"
},
"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",
"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"
},
"WebsocketsReceiverConfig": {
"properties": {
"host": {
"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.",
"format": "hostname",
"title": "Host",
"type": "string"
},
"kind": {
"const": "websockets",
"default": "websockets",
"title": "Kind",
"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",
"type": "integer"
}
},
"required": [
"port"
],
"title": "WebsocketsReceiverConfig",
"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"
},
"receivers": {
"default": [
{
"kind": "cocoa"
}
],
"items": {
"discriminator": {
"mapping": {
"cocoa": "#/$defs/CocoaReceiverConfig",
"websockets": "#/$defs/WebsocketsReceiverConfig"
},
"propertyName": "kind"
},
"oneOf": [
{
"$ref": "#/$defs/CocoaReceiverConfig"
},
{
"$ref": "#/$defs/WebsocketsReceiverConfig"
}
]
},
"title": "Receivers",
"type": "array"
}
},
"title": "Config",
"type": "object"
}

378
schemata/playback-v1.json Normal file
View file

@ -0,0 +1,378 @@
{
"$defs": {
"HasArtwork": {
"properties": {
"data": {
"format": "binary",
"title": "Data",
"type": "string"
}
},
"required": [
"data"
],
"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": {
"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. If MPD is currently stopped, then there is no current song in the queue, indicated by None.",
"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",
"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"
},
"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",
"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",
"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",
"crossfade",
"mixramp"
],
"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",
"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"
}

209
schemata/song-v1.json Normal file
View file

@ -0,0 +1,209 @@
{
"$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"
}
},
"$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. 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"
}

View file

@ -1,54 +1,52 @@
from __future__ import annotations from __future__ import annotations
from contextlib import suppress from typing import Any, Optional
from typing import Any, Optional, TypeVar
from urllib.parse import parse_qsl, urlparse
from aiocache import Cache
from aiocache.serializers import BaseSerializer, PickleSerializer
T = TypeVar("T")
HAS_ORMSGPACK = False
with suppress(ImportError):
import ormsgpack import ormsgpack
from aiocache import Cache
HAS_ORMSGPACK = True from aiocache.serializers import BaseSerializer
from pydantic.type_adapter import TypeAdapter
from yarl import URL
class OrmsgpackSerializer(BaseSerializer): class OrmsgpackSerializer[T](BaseSerializer):
DEFAULT_ENCODING = None DEFAULT_ENCODING = None
def dumps(self, value: Any) -> bytes: def __init__(self, schema: TypeAdapter[T]):
return ormsgpack.packb(value) super().__init__()
self.schema = schema
def loads(self, value: Optional[bytes]) -> Any: 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: if value is None:
return None return None
return ormsgpack.unpackb(value) data = ormsgpack.unpackb(value)
return self.schema.validate_python(data)
def make_cache(url: str, namespace: str = "") -> Cache[T]: def make_cache[T](schema: TypeAdapter[T], url: URL, namespace: str = "") -> Cache[T]:
parsed_url = urlparse(url) backend = Cache.get_scheme_class(url.scheme)
backend = Cache.get_scheme_class(parsed_url.scheme)
if backend == Cache.MEMORY: if backend == Cache.MEMORY:
return Cache(backend) return Cache(backend)
kwargs: dict[str, Any] = dict(parse_qsl(parsed_url.query))
if parsed_url.path: kwargs: dict[str, Any] = dict(url.query)
kwargs.update(backend.parse_uri_path(parsed_url.path))
if parsed_url.hostname: if url.path:
kwargs["endpoint"] = parsed_url.hostname kwargs.update(backend.parse_uri_path(url.path))
if parsed_url.port: if url.host:
kwargs["port"] = parsed_url.port kwargs["endpoint"] = url.host
if parsed_url.password: if url.port:
kwargs["password"] = parsed_url.password kwargs["port"] = url.port
if url.password:
kwargs["password"] = url.password
namespace = ":".join(s for s in [kwargs.pop("namespace", ""), namespace] if s) 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)

View file

@ -1,32 +1,41 @@
import asyncio import asyncio
from os import environ from collections.abc import Iterable
from corefoundationasyncio import CoreFoundationEventLoop from rich import print
from .cocoa.now_playing import CocoaNowPlaying from .__version__ import __version__
from .config.load import loadConfig
from .config.model import Config
from .mpd.listener import MpdStateListener from .mpd.listener import MpdStateListener
from .song_receiver import (
Receiver,
choose_loop_factory,
construct_receiver,
)
async def listen() -> None: async def listen(
port = int(environ.get("MPD_PORT", "6600")) config: Config, listener: MpdStateListener, receivers: Iterable[Receiver]
host = environ.get("MPD_HOST", "localhost") ) -> None:
password = environ.get("MPD_PASSWORD") await listener.start(config.mpd)
cache = environ.get("MPD_NOW_PLAYABLE_CACHE") await asyncio.gather(*(rec.start(listener) for rec in receivers))
if password is None and "@" in host: await listener.loop(receivers)
password, host = host.split("@", maxsplit=1)
listener = MpdStateListener(cache)
now_playing = CocoaNowPlaying(listener)
await listener.start(host=host, port=port, password=password)
await listener.loop(now_playing)
def make_loop() -> CoreFoundationEventLoop:
return CoreFoundationEventLoop(console_app=True)
def main() -> None: 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)
receivers = tuple(construct_receiver(rec_config) for rec_config in config.receivers)
factory = choose_loop_factory(receivers)
asyncio.run(
listen(config, listener, receivers),
loop_factory=factory.make_loop,
debug=True,
)
if __name__ == "__main__": if __name__ == "__main__":

View file

@ -1,194 +0,0 @@
from collections.abc import Callable, Coroutine
from pathlib import Path
from AppKit import NSCompositingOperationCopy, NSImage, NSMakeRect
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,
MPNowPlayingInfoPropertyElapsedPlaybackTime,
MPNowPlayingInfoPropertyExternalContentIdentifier,
MPNowPlayingInfoPropertyMediaType,
MPNowPlayingInfoPropertyPlaybackQueueCount,
MPNowPlayingInfoPropertyPlaybackQueueIndex,
MPNowPlayingInfoPropertyPlaybackRate,
MPRemoteCommandCenter,
MPRemoteCommandEvent,
MPRemoteCommandHandlerStatus,
MPRemoteCommandHandlerStatusSuccess,
)
from ..async_tools import run_background_task
from ..player import Player
from ..song import PlaybackState, Song
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")
)
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 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] = song.artist
nowplaying_info[MPMediaItemPropertyAlbumTitle] = song.album
nowplaying_info[MPMediaItemPropertyAlbumTrackNumber] = song.track
nowplaying_info[MPMediaItemPropertyDiscNumber] = song.disc
nowplaying_info[MPMediaItemPropertyGenre] = song.genre
nowplaying_info[MPMediaItemPropertyComposer] = song.composer
nowplaying_info[MPMediaItemPropertyPlaybackDuration] = song.duration
# 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)
)
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())
class CocoaNowPlaying:
def __init__(self, player: Player):
self.cmd_center = MPRemoteCommandCenter.sharedCommandCenter()
self.info_center = MPNowPlayingInfoCenter.defaultCenter()
cmds = (
(self.cmd_center.togglePlayPauseCommand(), player.on_play_pause),
(self.cmd_center.playCommand(), player.on_play),
(self.cmd_center.pauseCommand(), player.on_pause),
(self.cmd_center.stopCommand(), player.on_stop),
(self.cmd_center.nextTrackCommand(), player.on_next),
(self.cmd_center.previousTrackCommand(), player.on_prev),
)
for cmd, handler in cmds:
cmd.setEnabled_(True)
cmd.removeTarget_(None)
cmd.addTargetWithHandler_(self._create_handler(handler))
seekCmd = self.cmd_center.changePlaybackPositionCommand()
seekCmd.setEnabled_(True)
seekCmd.removeTarget_(None)
seekCmd.addTargetWithHandler_(self._create_seek_handler(player.on_seek))
unsupported_cmds = (
self.cmd_center.changePlaybackRateCommand(),
self.cmd_center.seekBackwardCommand(),
self.cmd_center.skipBackwardCommand(),
self.cmd_center.seekForwardCommand(),
self.cmd_center.skipForwardCommand(),
)
for cmd in unsupported_cmds:
cmd.setEnabled_(False)
# If MPD is paused when this bridge starts up, we actually want the now
# playing info center to see a playing -> paused transition, so we can
# unpause with remote commands.
self.info_center.setPlaybackState_(MPMusicPlaybackStatePlaying)
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)
def _create_handler(
self, player: Callable[[], Coroutine[None, None, PlaybackState | None]]
) -> Callable[[MPRemoteCommandEvent], MPRemoteCommandHandlerStatus]:
async def invoke_music_player() -> None:
result = await player()
if result:
self.info_center.setPlaybackState_(playback_state_to_cocoa(result))
def handler(event: MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus:
run_background_task(invoke_music_player())
return 0
return handler
def _create_seek_handler(
self, player: Callable[[float], Coroutine[None, None, None]]
) -> Callable[[MPChangePlaybackPositionCommandEvent], MPRemoteCommandHandlerStatus]:
def handler(
event: MPChangePlaybackPositionCommandEvent,
) -> MPRemoteCommandHandlerStatus:
run_background_task(player(event.positionTime()))
return MPRemoteCommandHandlerStatusSuccess
return handler

View file

@ -0,0 +1,50 @@
from collections.abc import Mapping
from os import environ
from boltons.iterutils import remap
from pytomlpp import load
from xdg_base_dirs import xdg_config_home
from .model import Config
__all__ = ("loadConfig",)
# Sadly this is the kind of function that's incredibly easy to type statically
# in something like TypeScript, but apparently impossible to type statically in
# Python. Basically the TypeScript typing looks like this:
# type OmitNulls<T> = {[K in keyof T]?: OmitNulls<NonNullable<T[K]>>};
# function withoutNulls<T>(data: T): OmitNulls<T>;
# But the Python type system (currently?) lacks mapped and index types, so you
# can't recurse over an arbitrary type's structure as OmitNulls<T> does, as
# well as conditional types so you can't easily define a "filtering" utility
# type like NonNullable<T>. 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[K, V](data: Mapping[K, V | None]) -> Mapping[K, V]:
return remap(data, lambda p, k, v: v is not None)
def loadConfigFromFile() -> Config:
path = xdg_config_home() / "mpd-now-playable" / "config.toml"
data = load(path)
print(f"Loaded your configuration from {path}")
return Config.schema.validate_python(data)
def loadConfigFromEnv() -> Config:
port = int(environ.pop("MPD_PORT")) if "MPD_PORT" in environ else None
host = environ.pop("MPD_HOST", None)
password = environ.pop("MPD_PASSWORD", None)
cache = environ.pop("MPD_NOW_PLAYABLE_CACHE", None)
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 Config.schema.validate_python(withoutNones(data))
def loadConfig() -> Config:
try:
return loadConfigFromFile()
except FileNotFoundError:
return loadConfigFromEnv()

View file

@ -0,0 +1,73 @@
from dataclasses import dataclass, field
from typing import Annotated, Literal, Optional, Protocol
from pydantic import Field
from ..tools.schema.define import schema
from ..tools.schema.fields import DirectoryPath, Host, Password, Port, Url
__all__ = (
"Config",
"MpdConfig",
"BaseReceiverConfig",
"CocoaReceiverConfig",
"WebsocketsReceiverConfig",
)
class BaseReceiverConfig(Protocol):
@property
def kind(self) -> str: ...
@dataclass(slots=True)
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)
#: 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] = None
ReceiverConfig = Annotated[
CocoaReceiverConfig | WebsocketsReceiverConfig,
Field(discriminator="kind"),
]
@dataclass(slots=True)
class MpdConfig:
#: The password required to connect to your MPD instance, if you need one.
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")
#: 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.
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")
@dataclass(slots=True)
class Config:
#: A URL describing a cache service for mpd-now-playable to use. Supported
#: protocols are memory://, redis://, and memcached://.
cache: Optional[Url] = None
mpd: MpdConfig = field(default_factory=MpdConfig)
receivers: tuple[ReceiverConfig, ...] = (CocoaReceiverConfig(),)

View file

@ -0,0 +1,5 @@
from ..tools.schema.generate import write
from .model import Config
if __name__ == "__main__":
write(Config)

View file

@ -1,42 +1,45 @@
from __future__ import annotations from __future__ import annotations
from typing import TypedDict from yarl import URL
from ..async_tools import run_background_task
from ..cache import Cache, make_cache from ..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 from .types import CurrentSongResponse, MpdStateHandler
CACHE_TTL = 60 * 60 # seconds = 1 hour CACHE_TTL = 60 * 60 # seconds = 1 hour
class ArtCacheEntry(TypedDict):
data: bytes | None
def calc_album_key(song: CurrentSongResponse) -> str: def calc_album_key(song: CurrentSongResponse) -> str:
artist = song.get("albumartist", song.get("artist", "Unknown Artist")) artist = sorted(
album = song.get("album", "Unknown Album") un_maybe_plural(song.get("albumartist", song.get("artist", "Unknown Artist")))
return ":".join(t.replace(":", "-") for t in (artist, album)) )
album = sorted(un_maybe_plural(song.get("album", "Unknown Album")))
return ":".join(";".join(t).replace(":", "-") for t in (artist, album))
def calc_track_key(song: CurrentSongResponse) -> str: def calc_track_key(song: CurrentSongResponse) -> str:
return song["file"] return song["file"]
MEMORY = URL("memory://")
class MpdArtworkCache: class MpdArtworkCache:
mpd: MpdStateHandler mpd: MpdStateHandler
album_cache: Cache[ArtCacheEntry] album_cache: Cache[Artwork]
track_cache: Cache[ArtCacheEntry] track_cache: Cache[Artwork]
def __init__(self, mpd: MpdStateHandler, cache_url: str = "memory://"): def __init__(self, mpd: MpdStateHandler, cache_url: URL = MEMORY):
self.mpd = mpd self.mpd = mpd
self.album_cache = make_cache(cache_url, "album") self.album_cache = make_cache(ArtworkSchema, cache_url, "album")
self.track_cache = make_cache(cache_url, "track") self.track_cache = make_cache(ArtworkSchema, cache_url, "track")
async def get_cached_artwork(self, song: CurrentSongResponse) -> bytes | None: async def get_cached_artwork(self, song: CurrentSongResponse) -> bytes | None:
art = await self.track_cache.get(calc_track_key(song)) art = await self.track_cache.get(calc_track_key(song))
if art: if art:
return art["data"] return art.data
# If we don't have track artwork cached, go find some. # If we don't have track artwork cached, go find some.
run_background_task(self.cache_artwork(song)) run_background_task(self.cache_artwork(song))
@ -44,12 +47,12 @@ class MpdArtworkCache:
# Even if we don't have cached track art, we can try looking for cached album art. # Even if we don't have cached track art, we can try looking for cached album art.
art = await self.album_cache.get(calc_album_key(song)) art = await self.album_cache.get(calc_album_key(song))
if art: if art:
return art["data"] return art.data
return None return None
async def cache_artwork(self, song: CurrentSongResponse) -> None: async def cache_artwork(self, song: CurrentSongResponse) -> None:
art = ArtCacheEntry(data=await self.mpd.get_art(song["file"])) art = to_artwork(await self.mpd.get_art(song["file"]))
try: try:
await self.album_cache.add(calc_album_key(song), art, ttl=CACHE_TTL) await self.album_cache.add(calc_album_key(song), art, ttl=CACHE_TTL)
except ValueError: except ValueError:

View file

@ -0,0 +1,49 @@
from ...config.model import MpdConfig
from ...playback import Playback
from ...playback.queue import Queue
from ...playback.settings import MixRamp, 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=option_fmap(int, mpd.current.get("pos")),
next=int(mpd.status.get("nextsong", 0)),
length=int(mpd.status["playlistlength"]),
)
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")),
repeat=mpd.status["repeat"] == "1",
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),
)
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),
)

View file

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

View file

@ -1,99 +1,89 @@
import asyncio import asyncio
from pathlib import Path from collections.abc import Iterable
from uuid import UUID
from mpd.asyncio import MPDClient from mpd.asyncio import MPDClient
from mpd.base import CommandError from mpd.base import CommandError
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 ..player import Player
from ..song import PlaybackState, Song, SongListener from ..song_receiver import Receiver
from ..type_tools import convert_if_exists from ..tools.asyncio import run_background_task
from .artwork_cache import MpdArtworkCache from .artwork_cache import MpdArtworkCache
from .types import CurrentSongResponse, StatusResponse from .convert.to_playback import to_playback
from .types import MpdState
def mpd_current_to_song(
status: StatusResponse, current: CurrentSongResponse, art: bytes | None
) -> Song:
return Song(
state=PlaybackState(status["state"]),
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=current.get("artist"),
album=current.get("album"),
album_artist=current.get("albumartist"),
composer=current.get("composer"),
genre=current.get("genre"),
track=convert_if_exists(current.get("track"), int),
disc=convert_if_exists(current.get("disc"), int),
duration=float(status["duration"]),
elapsed=float(status["elapsed"]),
art=art,
)
class MpdStateListener(Player): class MpdStateListener(Player):
config: MpdConfig
client: MPDClient client: MPDClient
listener: SongListener receivers: Iterable[Receiver]
art_cache: MpdArtworkCache art_cache: MpdArtworkCache
idle_count = 0 idle_count = 0
def __init__(self, cache: str | None = None) -> None: def __init__(self, cache: URL | None = None) -> None:
self.client = MPDClient() self.client = MPDClient()
self.art_cache = ( self.art_cache = (
MpdArtworkCache(self, cache) if cache else MpdArtworkCache(self) MpdArtworkCache(self, cache) if cache else MpdArtworkCache(self)
) )
async def start( async def start(self, conf: MpdConfig) -> None:
self, host: str = "localhost", port: int = 6600, password: str | None = None self.config = conf
) -> None: print(f"Connecting to MPD server {conf.host}:{conf.port}...")
print(f"Connecting to MPD server {host}:{port}...") await self.client.connect(conf.host, conf.port)
await self.client.connect(host, port) if conf.password is not None:
if password is not None:
print("Authorising to MPD with your password...") print("Authorising to MPD with your password...")
await self.client.password(password) await self.client.password(conf.password.get_secret_value())
print(f"Connected to MPD v{self.client.mpd_version}") 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: async def refresh(self) -> None:
await self.update_listener(self.listener) await self.update_receivers()
async def loop(self, listener: SongListener) -> None: async def loop(self, receivers: Iterable[Receiver]) -> None:
self.listener = listener self.receivers = receivers
# notify our listener 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_listener(listener) await self.update_receivers()
# then wait for stuff to change in MPD. :) # And then wait for stuff to change in MPD. :)
async for _ in self.client.idle(): 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 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. # 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 starting_idle_count = self.idle_count
status, current = await asyncio.gather( status, current = await asyncio.gather(
self.client.status(), self.client.currentsong() self.client.status(), self.client.currentsong()
) )
state = MpdState(status, current)
if starting_idle_count != self.idle_count: if starting_idle_count != self.idle_count:
return return
if status["state"] == "stop": art = None
print("Nothing playing") if status["state"] != "stop":
listener.update(None)
return
art = await self.art_cache.get_cached_artwork(current) art = await self.art_cache.get_cached_artwork(current)
if starting_idle_count != self.idle_count: if starting_idle_count != self.idle_count:
return return
song = mpd_current_to_song(status, current, art) state = MpdState(status, current, art)
print(song) pb = to_playback(self.config, state)
listener.update(song) rprint(pb)
await self.update(pb)
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: async def get_art(self, file: str) -> bytes | None:
picture = await self.readpicture(file) picture = await self.readpicture(file)

View file

@ -1,5 +1,9 @@
from dataclasses import dataclass
from typing import Literal, NotRequired, Protocol, TypedDict from typing import Literal, NotRequired, Protocol, TypedDict
from ..song.musicbrainz import MusicBrainzTags
from ..tools.types import MaybePlural
class MpdStateHandler(Protocol): class MpdStateHandler(Protocol):
async def get_art(self, file: str) -> bytes | None: ... async def get_art(self, file: str) -> bytes | None: ...
@ -16,8 +20,11 @@ OneshotFlag = Literal[BooleanFlag, "oneshot"]
class StatusResponse(TypedDict): class StatusResponse(TypedDict):
state: Literal["play", "stop", "pause"] state: Literal["play", "stop", "pause"]
# The total duration and elapsed playback of the current song, measured in seconds. Fractional seconds are allowed. # The total duration and elapsed playback of the current song, measured in
duration: str # 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 elapsed: str
# The volume value ranges from 0-100. It may be omitted from # The volume value ranges from 0-100. It may be omitted from
@ -30,6 +37,20 @@ class StatusResponse(TypedDict):
single: OneshotFlag single: OneshotFlag
consume: 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. # Partitions essentially let one MPD server act as multiple music players.
# For most folks, this will just be "default", but mpd-now-playable will # For most folks, this will just be "default", but mpd-now-playable will
# eventually support addressing specific partitions. Eventually. # eventually support addressing specific partitions. Eventually.
@ -38,6 +59,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. # The total number of items in the play queue, which is called the "playlist" throughout the MPD protocol for legacy reasons.
playlistlength: str 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". # The format of decoded audio MPD is producing, expressed as a string in the form "samplerate:bits:channels".
audio: str audio: str
@ -46,25 +71,20 @@ class StatusResponse(TypedDict):
# optional. mpd-now-playable will work better if your music is properly # 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 # tagged, since then it can pass more information on to Now Playing, but it
# should work fine with completely untagged music too. # should work fine with completely untagged music too.
class CurrentSongTags(TypedDict, total=False): class CurrentSongTags(MusicBrainzTags, total=False):
artist: str artist: MaybePlural[str]
albumartist: str albumartist: MaybePlural[str]
artistsort: str artistsort: MaybePlural[str]
albumartistsort: str albumartistsort: MaybePlural[str]
title: str title: str
album: str album: MaybePlural[str]
track: str track: str
date: str date: str
originaldate: str originaldate: str
composer: str composer: MaybePlural[str]
disc: str disc: str
label: str label: str
genre: str genre: MaybePlural[str]
musicbrainz_albumid: str
musicbrainz_albumartistid: str
musicbrainz_releasetrackid: str
musicbrainz_artistid: str
musicbrainz_trackid: str
class CurrentSongResponse(CurrentSongTags): class CurrentSongResponse(CurrentSongTags):
@ -78,3 +98,10 @@ class CurrentSongResponse(CurrentSongTags):
ReadPictureResponse = TypedDict("ReadPictureResponse", {"binary": bytes}) ReadPictureResponse = TypedDict("ReadPictureResponse", {"binary": bytes})
@dataclass
class MpdState:
status: StatusResponse
current: CurrentSongResponse
art: bytes | None = None

View file

@ -0,0 +1,3 @@
from .playback import Playback
__all__ = ("Playback",)

View file

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

View file

@ -0,0 +1,15 @@
from dataclasses import dataclass
@dataclass(slots=True)
class Queue:
#: 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
#: 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

View file

@ -0,0 +1,76 @@
from dataclasses import dataclass
from typing import Annotated, Literal
from annotated_types import Ge
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 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
#: 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
#: 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

View file

@ -0,0 +1,7 @@
from enum import StrEnum
class PlaybackState(StrEnum):
play = "play"
pause = "pause"
stop = "stop"

View file

@ -1,26 +1,19 @@
from typing import Protocol from typing import Protocol
from .song import PlaybackState from .playback.state import PlaybackState
class Player(Protocol): 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: ...
...

View file

@ -0,0 +1,3 @@
__all__ = ("receiver",)
from .now_playing import CocoaNowPlayingReceiver as receiver

View file

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

View file

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

View file

@ -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 = ns_image_to_media_item_artwork(logo_to_ns_image())

View file

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

View file

@ -0,0 +1,113 @@
from collections.abc import Callable, Coroutine
from typing import Literal
from AppKit import NSApplication, NSApplicationActivationPolicyAccessory
from MediaPlayer import (
MPChangePlaybackPositionCommandEvent,
MPMusicPlaybackStatePlaying,
MPNowPlayingInfoCenter,
MPRemoteCommandCenter,
MPRemoteCommandEvent,
MPRemoteCommandHandlerStatus,
MPRemoteCommandHandlerStatusSuccess,
)
from corefoundationasyncio import CoreFoundationEventLoop
from ...config.model import CocoaReceiverConfig
from ...playback import Playback
from ...playback.state import PlaybackState
from ...player import Player
from ...song_receiver import LoopFactory, Receiver
from ...tools.asyncio import run_background_task
from .convert.playback_to_media_item import playback_to_media_item
from .convert.to_state import playback_state_to_cocoa
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, config: CocoaReceiverConfig):
pass
async def start(self, player: Player) -> None:
NSApplication.sharedApplication().setActivationPolicy_(
NSApplicationActivationPolicyAccessory
)
self.cmd_center = MPRemoteCommandCenter.sharedCommandCenter()
self.info_center = MPNowPlayingInfoCenter.defaultCenter()
cmds = (
(self.cmd_center.togglePlayPauseCommand(), player.on_play_pause),
(self.cmd_center.playCommand(), player.on_play),
(self.cmd_center.pauseCommand(), player.on_pause),
(self.cmd_center.stopCommand(), player.on_stop),
(self.cmd_center.nextTrackCommand(), player.on_next),
(self.cmd_center.previousTrackCommand(), player.on_prev),
)
for cmd, handler in cmds:
cmd.setEnabled_(True)
cmd.removeTarget_(None)
cmd.addTargetWithHandler_(self._create_handler(handler))
seekCmd = self.cmd_center.changePlaybackPositionCommand()
seekCmd.setEnabled_(True)
seekCmd.removeTarget_(None)
seekCmd.addTargetWithHandler_(self._create_seek_handler(player.on_seek))
unsupported_cmds = (
self.cmd_center.changePlaybackRateCommand(),
self.cmd_center.seekBackwardCommand(),
self.cmd_center.skipBackwardCommand(),
self.cmd_center.seekForwardCommand(),
self.cmd_center.skipForwardCommand(),
)
for cmd in unsupported_cmds:
cmd.setEnabled_(False)
# If MPD is paused when this bridge starts up, we actually want the now
# playing info center to see a playing -> paused transition, so we can
# unpause with remote commands.
self.info_center.setPlaybackState_(MPMusicPlaybackStatePlaying)
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]]
) -> Callable[[MPRemoteCommandEvent], MPRemoteCommandHandlerStatus]:
async def invoke_music_player() -> None:
result = await player()
if result:
self.info_center.setPlaybackState_(playback_state_to_cocoa(result))
def handler(event: MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus:
run_background_task(invoke_music_player())
return 0
return handler
def _create_seek_handler(
self, player: Callable[[float], Coroutine[None, None, None]]
) -> Callable[[MPChangePlaybackPositionCommandEvent], MPRemoteCommandHandlerStatus]:
def handler(
event: MPChangePlaybackPositionCommandEvent,
) -> MPRemoteCommandHandlerStatus:
run_background_task(player(event.positionTime()))
return MPRemoteCommandHandlerStatusSuccess
return handler

View file

@ -3,29 +3,31 @@ from pathlib import Path
from typing import Final from typing import Final
from uuid import UUID 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. # The maximum size for a BLAKE2b "person" value is sixteen bytes, so we need to be concise.
HASH_PERSON_PREFIX: Final = b"mnp.mac." HASH_PERSON_PREFIX: Final = b"mnp.mac."
TRACKID_HASH_PERSON: Final = HASH_PERSON_PREFIX + b"mb_tid" RECORDING_ID_HASH_PERSON: Final = HASH_PERSON_PREFIX + b"mb_rid"
RELEASETRACKID_HASH_PERSON: Final = HASH_PERSON_PREFIX + b"mb_rtid" TRACK_ID_HASH_PERSON: Final = HASH_PERSON_PREFIX + b"mb_tid"
FILE_HASH_PERSON: Final = HASH_PERSON_PREFIX + b"f" FILE_HASH_PERSON: Final = HASH_PERSON_PREFIX + b"f"
PERSISTENT_ID_BITS: Final = 64 PERSISTENT_ID_BITS: Final = 64
PERSISTENT_ID_BYTES: Final = PERSISTENT_ID_BITS // 8 PERSISTENT_ID_BYTES: Final = PERSISTENT_ID_BITS // 8
def digest_trackid(trackid: UUID) -> bytes: def digest_recording_id(recording_id: UUID) -> bytes:
return blake2b( 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() ).digest()
def digest_releasetrackid(trackid: UUID) -> bytes: def digest_track_id(track_id: UUID) -> bytes:
return blake2b( return blake2b(
trackid.bytes, track_id.bytes,
digest_size=PERSISTENT_ID_BYTES, digest_size=PERSISTENT_ID_BYTES,
person=RELEASETRACKID_HASH_PERSON, person=TRACK_ID_HASH_PERSON,
).digest() ).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, # that from the file URI. BLAKE2 can be customised to different digest sizes,
# making it perfect for this problem. # making it perfect for this problem.
def song_to_persistent_id(song: Song) -> int: def song_to_persistent_id(song: Song) -> int:
if song.musicbrainz_trackid: if song.musicbrainz.recording:
hashed_id = digest_trackid(song.musicbrainz_trackid) hashed_id = digest_recording_id(song.musicbrainz.recording)
elif song.musicbrainz_releasetrackid: elif song.musicbrainz.track:
hashed_id = digest_releasetrackid(song.musicbrainz_releasetrackid) hashed_id = digest_track_id(song.musicbrainz.track)
else: else:
hashed_id = digest_file_uri(song.file) hashed_id = digest_file_uri(song.file)
return int.from_bytes(hashed_id) return int.from_bytes(hashed_id)

View file

@ -0,0 +1,2 @@
__all__ = ('receiver',)
from .receiver import WebsocketsReceiver as receiver

View file

@ -0,0 +1,49 @@
from pathlib import Path
import ormsgpack
from websockets import broadcast
from websockets.asyncio.server import Server, ServerConnection, serve
from yarl import URL
from ...config.model import WebsocketsReceiverConfig
from ...playback import Playback
from ...player import Player
from ...song_receiver import DefaultLoopFactory, Receiver
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
class WebsocketsReceiver(Receiver):
config: WebsocketsReceiverConfig
player: Player
server: Server
last_status: bytes = MSGPACK_NULL
def __init__(self, config: WebsocketsReceiverConfig):
self.config = config
@classmethod
def loop_factory(cls) -> DefaultLoopFactory:
return DefaultLoopFactory()
async def start(self, player: Player) -> None:
self.player = player
self.server = await serve(
self.handle, host=self.config.host, port=self.config.port, reuse_port=True
)
async def handle(self, conn: ServerConnection) -> None:
await conn.send(self.last_status)
await conn.wait_closed()
async def update(self, playback: Playback) -> None:
self.last_status = ormsgpack.packb(playback, default=default)
broadcast(self.server.connections, self.last_status)

View file

@ -1,38 +0,0 @@
from enum import StrEnum
from pathlib import Path
from typing import Protocol
from uuid import UUID
from attrs import define, field
class PlaybackState(StrEnum):
play = "play"
pause = "pause"
stop = "stop"
@define
class Song:
state: PlaybackState
queue_index: int
queue_length: int
file: Path
musicbrainz_trackid: UUID | None
musicbrainz_releasetrackid: UUID | None
title: str | None
artist: str | None
composer: str | None
album: str | None
album_artist: str | None
track: int | None
disc: int | None
genre: str | None
duration: float
elapsed: float
art: bytes | None = field(repr=lambda a: "<has art>" if a else "<no art>")
class SongListener(Protocol):
def update(self, song: Song | None) -> None:
...

View file

@ -0,0 +1,13 @@
from .artwork import Artwork, ArtworkSchema, to_artwork
from .musicbrainz import to_brainz
from .song import Song
from .stopped import Stopped
__all__ = (
"Artwork",
"ArtworkSchema",
"to_artwork",
"to_brainz",
"Song",
"Stopped",
)

View file

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

View file

@ -0,0 +1,107 @@
from dataclasses import dataclass
from functools import partial
from typing import Annotated, TypedDict
from uuid import UUID
from pydantic import Field
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).
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: MaybePlural[str]
#: MusicBrainz Release ID
musicbrainz_albumid: MaybePlural[str]
#: MusicBrainz Release Artist ID
musicbrainz_albumartistid: MaybePlural[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: 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: 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: 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: 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: 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: 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,
#: 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: OptionUUID
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=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")),
)

View file

@ -0,0 +1,79 @@
from dataclasses import dataclass
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
@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 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
#: 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.
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. 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
#: 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

View file

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

View file

@ -0,0 +1,81 @@
from asyncio import AbstractEventLoop, new_event_loop
from dataclasses import dataclass
from importlib import import_module
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
class LoopFactory[T: AbstractEventLoop](Protocol):
@property
def is_replaceable(self) -> bool: ...
@classmethod
def make_loop(cls) -> T: ...
class Receiver(Protocol):
def __init__(self, config: BaseReceiverConfig): ...
@classmethod
def loop_factory(cls) -> LoopFactory[AbstractEventLoop]: ...
async def start(self, player: Player) -> None: ...
async def update(self, playback: Playback) -> 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: Receiver
b: Receiver
def import_receiver(config: BaseReceiverConfig) -> type[Receiver]:
mod = cast(
ReceiverModule, import_module(f"mpd_now_playable.receivers.{config.kind}")
)
return mod.receiver
def construct_receiver(config: BaseReceiverConfig) -> Receiver:
cls = import_receiver(config)
return cls(config)
def choose_loop_factory(
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: 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

View file

View file

@ -0,0 +1,20 @@
from typing import Callable, Protocol, Self
from pydantic.type_adapter import TypeAdapter
from yarl import URL
class ModelWithSchema(Protocol):
@property
def id(self) -> URL: ...
@property
def schema(self) -> TypeAdapter[Self]: ...
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))
return clazz
return decorate

View file

@ -0,0 +1,43 @@
from os.path import expanduser
from typing import Annotated, NewType
from annotated_types import Ge, Le
from pydantic import (
BeforeValidator,
Field,
PlainSerializer,
PlainValidator,
SecretStr,
Strict,
WithJsonSchema,
)
from pydantic import (
DirectoryPath as DirectoryType,
)
from yarl import URL as Yarl
__all__ = ("DirectoryPath", "Host", "Password", "Port", "Url")
def from_yarl(url: Yarl) -> str:
return url.human_repr()
def to_yarl(value: object) -> Yarl:
if isinstance(value, str):
return Yarl(value)
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"})]
)
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"}),
]

View file

@ -0,0 +1,57 @@
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, 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}")
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"])
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")

View file

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

View file

@ -0,0 +1,53 @@
from collections.abc import Callable
from typing import Any
__all__ = (
"AnyExceptList",
"MaybePlural",
"option_fmap",
"un_maybe_plural",
)
# Accept as many types as possible that are not lists. Yes, having to identify
# them manually like this is kind of a pain! TypeScript can express this
# restriction using a conditional type, and with another conditional type it
# can correctly type a version of un_maybe_plural that *does* accept lists, but
# Python's type system isn't quite that bonkers powerful. Yet?
AnyExceptList = (
int
| float
| complex
| bool
| str
| bytes
| set[Any]
| dict[Any, Any]
| tuple[Any, ...]
| Callable[[Any], Any]
| type
)
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[U, V](f: Callable[[U], V], value: U | None) -> V | None:
if value is None:
return None
return f(value)
type MaybePlural[T: AnyExceptList] = list[T] | T
def un_maybe_plural[T: AnyExceptList](value: MaybePlural[T] | None) -> list[T]:
match value:
case None:
return []
case list(values):
return values[:]
case item:
return [item]

View file

@ -1,11 +0,0 @@
from typing import Callable, TypeVar
__all__ = ("convert_if_exists",)
T = TypeVar("T")
def convert_if_exists(value: str | None, converter: Callable[[str], T]) -> T | None:
if value is None:
return None
return converter(value)

View file

@ -2,6 +2,16 @@ from typing import Final, Literal
from Foundation import CGSize 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. # There are many other operations available but we only actually use copy, so we don't need all of them here.
NSCompositingOperationClear: Final = 0 NSCompositingOperationClear: Final = 0
NSCompositingOperationCopy: Final = 1 NSCompositingOperationCopy: Final = 1
@ -15,19 +25,19 @@ def NSMakeRect(x: float, y: float, w: float, h: float) -> NSRect: ...
class NSImage: class NSImage:
@staticmethod @staticmethod
def alloc() -> type[NSImage]: ... def alloc() -> type[NSImage]: ...
@staticmethod @staticmethod
def initByReferencingFile_(file: str) -> NSImage: ... def initByReferencingFile_(file: str) -> NSImage: ...
@staticmethod @staticmethod
def initWithData_(data: bytes) -> NSImage: ... def initWithData_(data: bytes) -> NSImage: ...
@staticmethod @staticmethod
def initWithSize_(size: CGSize) -> NSImage: ... def initWithSize_(size: CGSize) -> NSImage: ...
def size(self) -> CGSize: ... def size(self) -> CGSize: ...
def lockFocus(self) -> None: ... def lockFocus(self) -> None: ...
def unlockFocus(self) -> None: ... def unlockFocus(self) -> None: ...
def drawInRect_fromRect_operation_fraction_(
def drawInRect_fromRect_operation_fraction_(self, inRect: NSRect, fromRect: NSRect, operation: NSCompositingOperation, fraction: float) -> None: ... self,
inRect: NSRect,
fromRect: NSRect,
operation: NSCompositingOperation,
fraction: float,
) -> None: ...

View file

@ -34,6 +34,7 @@ MPNowPlayingInfoPropertyPlaybackQueueIndex: Final = (
MPNowPlayingInfoPropertyElapsedPlaybackTime: Final = ( MPNowPlayingInfoPropertyElapsedPlaybackTime: Final = (
"MPNowPlayingInfoPropertyElapsedPlaybackTime" "MPNowPlayingInfoPropertyElapsedPlaybackTime"
) )
MPNowPlayingInfoPropertyAssetURL: Final = "MPNowPlayingInfoPropertyAssetURL"
MPNowPlayingInfoPropertyExternalContentIdentifier: Final = ( MPNowPlayingInfoPropertyExternalContentIdentifier: Final = (
"MPNowPlayingInfoPropertyExternalContentIdentifier" "MPNowPlayingInfoPropertyExternalContentIdentifier"
) )

View file

@ -1,7 +1,3 @@
from typing import Generic, TypeVar class BaseCache[T]:
T = TypeVar("T")
class BaseCache(Generic[T]):
@staticmethod @staticmethod
def parse_uri_path(path: str) -> dict[str, str]: ... def parse_uri_path(path: str) -> dict[str, str]: ...

View file

@ -1,11 +1,9 @@
from typing import ClassVar, Optional, TypeVar from typing import ClassVar, Optional
from .base import BaseCache from .base import BaseCache
from .serializers import BaseSerializer from .serializers import BaseSerializer
T = TypeVar("T") class Cache[T](BaseCache[T]):
class Cache(BaseCache[T]):
MEMORY: ClassVar[type[BaseCache]] MEMORY: ClassVar[type[BaseCache]]
REDIS: ClassVar[type[BaseCache] | None] REDIS: ClassVar[type[BaseCache] | None]
MEMCACHED: ClassVar[type[BaseCache] | None] MEMCACHED: ClassVar[type[BaseCache] | None]

View file

@ -0,0 +1,23 @@
from collections.abc import Callable, Mapping
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.
KIn = TypeVar("KIn")
KOut = TypeVar("KOut", default=KIn)
VIn = TypeVar("VIn")
VOut = TypeVar("VOut", default=VIn)
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
# type declaration works fine for my use of the function, but it's actually
# vastly more flexible than that - it'll accept any iterable as the root, not
# just mappings, and you can provide "enter" and "exit" callables to support
# arbitrary data structures.
def remap(
root: Mapping[KIn, VIn],
visit: Callable[[Path[KIn], KIn, VIn], tuple[KOut, VOut] | bool],
reraise_visit: bool = False,
) -> Mapping[KOut, VOut]: ...

View file

@ -0,0 +1 @@
def extract_docs_from_cls_obj(cls: type) -> dict[str, list[str]]: ...

View file

@ -9,6 +9,7 @@ class MPDClient(MPDClientBase):
def __init__(self) -> None: ... def __init__(self) -> None: ...
async def connect(self, host: str, port: int = ...) -> None: ... async def connect(self, host: str, port: int = ...) -> None: ...
async def ping(self) -> None: ...
async def password(self, password: str) -> None: ... async def password(self, password: str) -> None: ...
def idle(self, subsystems: Sequence[str] = ...) -> AsyncIterator[Sequence[str]]: ... def idle(self, subsystems: Sequence[str] = ...) -> AsyncIterator[Sequence[str]]: ...
async def status(self) -> types.StatusResponse: ... async def status(self) -> types.StatusResponse: ...