Compare commits

...

7 commits

Author SHA1 Message Date
Danielle McLean ca5086f93a
Fix path to MPD logo in Cocoa receiver (oops) 2024-07-13 18:38:16 +10:00
Danielle McLean 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
Danielle McLean 75206a97f1
Add extra for websockets support 2024-07-11 12:17:09 +10:00
Danielle McLean 04859b8c8b
Adjust receiver protocol to accommodate config 2024-07-11 12:15:34 +10:00
Danielle McLean 09fe3b3e6c
Expand MusicBrainz support to be much more comprehensive 2024-07-11 12:12:56 +10:00
Danielle McLean 60116fd616
Make PyObjC a Darwin-only dependency 2024-07-10 23:57:34 +10:00
Danielle McLean 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
21 changed files with 912 additions and 183 deletions

187
pdm.lock
View file

@ -5,7 +5,7 @@
groups = ["default", "all", "dev"]
strategy = ["cross_platform"]
lock_version = "4.4.1"
content_hash = "sha256:5a1cfd2988fd81c4b8743daaf399bbeb9f1f9d78e161901f0bf76c771ec8052f"
content_hash = "sha256:ddedd388cce9ed181dc2f3786240fc14e19ceb4539f0a360eeb2efb28da63ebe"
[[package]]
name = "aiocache"
@ -167,7 +167,7 @@ files = [
[[package]]
name = "mypy"
version = "1.10.0"
version = "1.10.1"
requires_python = ">=3.8"
summary = "Optional static typing for Python"
dependencies = [
@ -175,13 +175,13 @@ dependencies = [
"typing-extensions>=4.1.0",
]
files = [
{file = "mypy-1.10.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:a781f6ad4bab20eef8b65174a57e5203f4be627b46291f4589879bf4e257b97b"},
{file = "mypy-1.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b808e12113505b97d9023b0b5e0c0705a90571c6feefc6f215c1df9381256e30"},
{file = "mypy-1.10.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f55583b12156c399dce2df7d16f8a5095291354f1e839c252ec6c0611e86e2e"},
{file = "mypy-1.10.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4cf18f9d0efa1b16478c4c129eabec36148032575391095f73cae2e722fcf9d5"},
{file = "mypy-1.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:bc6ac273b23c6b82da3bb25f4136c4fd42665f17f2cd850771cb600bdd2ebeda"},
{file = "mypy-1.10.0-py3-none-any.whl", hash = "sha256:f8c083976eb530019175aabadb60921e73b4f45736760826aa1689dda8208aee"},
{file = "mypy-1.10.0.tar.gz", hash = "sha256:3d087fcbec056c4ee34974da493a826ce316947485cef3901f511848e687c131"},
{file = "mypy-1.10.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:d8681909f7b44d0b7b86e653ca152d6dff0eb5eb41694e163c6092124f8246d7"},
{file = "mypy-1.10.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:378c03f53f10bbdd55ca94e46ec3ba255279706a6aacaecac52ad248f98205d3"},
{file = "mypy-1.10.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bacf8f3a3d7d849f40ca6caea5c055122efe70e81480c8328ad29c55c69e93e"},
{file = "mypy-1.10.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:701b5f71413f1e9855566a34d6e9d12624e9e0a8818a5704d74d6b0402e66c04"},
{file = "mypy-1.10.1-cp312-cp312-win_amd64.whl", hash = "sha256:3c4c2992f6ea46ff7fce0072642cfb62af7a2484efe69017ed8b095f7b39ef31"},
{file = "mypy-1.10.1-py3-none-any.whl", hash = "sha256:71d8ac0b906354ebda8ef1673e5fde785936ac1f29ff6987c7483cfbd5a4235a"},
{file = "mypy-1.10.1.tar.gz", hash = "sha256:1f8f492d7db9e3593ef42d4f115f04e556130f2819ad33ab84551403e97dd4c0"},
]
[[package]]
@ -210,58 +210,70 @@ files = [
[[package]]
name = "pydantic"
version = "2.7.4"
version = "2.8.2"
requires_python = ">=3.8"
summary = "Data validation using Python type hints"
dependencies = [
"annotated-types>=0.4.0",
"pydantic-core==2.18.4",
"typing-extensions>=4.6.1",
"pydantic-core==2.20.1",
"typing-extensions>=4.12.2; python_version >= \"3.13\"",
"typing-extensions>=4.6.1; python_version < \"3.13\"",
]
files = [
{file = "pydantic-2.7.4-py3-none-any.whl", hash = "sha256:ee8538d41ccb9c0a9ad3e0e5f07bf15ed8015b481ced539a1759d8cc89ae90d0"},
{file = "pydantic-2.7.4.tar.gz", hash = "sha256:0c84efd9548d545f63ac0060c1e4d39bb9b14db8b3c0652338aecc07b5adec52"},
{file = "pydantic-2.8.2-py3-none-any.whl", hash = "sha256:73ee9fddd406dc318b885c7a2eab8a6472b68b8fb5ba8150949fc3db939f23c8"},
{file = "pydantic-2.8.2.tar.gz", hash = "sha256:6f62c13d067b0755ad1c21a34bdd06c0c12625a22b0fc09c6b149816604f7c2a"},
]
[[package]]
name = "pydantic-core"
version = "2.18.4"
version = "2.20.1"
requires_python = ">=3.8"
summary = "Core functionality for Pydantic validation and serialization"
dependencies = [
"typing-extensions!=4.7.0,>=4.6.0",
]
files = [
{file = "pydantic_core-2.18.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:6f5c4d41b2771c730ea1c34e458e781b18cc668d194958e0112455fff4e402b2"},
{file = "pydantic_core-2.18.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2fdf2156aa3d017fddf8aea5adfba9f777db1d6022d392b682d2a8329e087cef"},
{file = "pydantic_core-2.18.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4748321b5078216070b151d5271ef3e7cc905ab170bbfd27d5c83ee3ec436695"},
{file = "pydantic_core-2.18.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847a35c4d58721c5dc3dba599878ebbdfd96784f3fb8bb2c356e123bdcd73f34"},
{file = "pydantic_core-2.18.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3c40d4eaad41f78e3bbda31b89edc46a3f3dc6e171bf0ecf097ff7a0ffff7cb1"},
{file = "pydantic_core-2.18.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:21a5e440dbe315ab9825fcd459b8814bb92b27c974cbc23c3e8baa2b76890077"},
{file = "pydantic_core-2.18.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:01dd777215e2aa86dfd664daed5957704b769e726626393438f9c87690ce78c3"},
{file = "pydantic_core-2.18.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4b06beb3b3f1479d32befd1f3079cc47b34fa2da62457cdf6c963393340b56e9"},
{file = "pydantic_core-2.18.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:564d7922e4b13a16b98772441879fcdcbe82ff50daa622d681dd682175ea918c"},
{file = "pydantic_core-2.18.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:0eb2a4f660fcd8e2b1c90ad566db2b98d7f3f4717c64fe0a83e0adb39766d5b8"},
{file = "pydantic_core-2.18.4-cp312-none-win32.whl", hash = "sha256:8b8bab4c97248095ae0c4455b5a1cd1cdd96e4e4769306ab19dda135ea4cdb07"},
{file = "pydantic_core-2.18.4-cp312-none-win_amd64.whl", hash = "sha256:14601cdb733d741b8958224030e2bfe21a4a881fb3dd6fbb21f071cabd48fa0a"},
{file = "pydantic_core-2.18.4-cp312-none-win_arm64.whl", hash = "sha256:c1322d7dd74713dcc157a2b7898a564ab091ca6c58302d5c7b4c07296e3fd00f"},
{file = "pydantic_core-2.18.4-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:574d92eac874f7f4db0ca653514d823a0d22e2354359d0759e3f6a406db5d55d"},
{file = "pydantic_core-2.18.4-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1f4d26ceb5eb9eed4af91bebeae4b06c3fb28966ca3a8fb765208cf6b51102ab"},
{file = "pydantic_core-2.18.4-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77450e6d20016ec41f43ca4a6c63e9fdde03f0ae3fe90e7c27bdbeaece8b1ed4"},
{file = "pydantic_core-2.18.4-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d323a01da91851a4f17bf592faf46149c9169d68430b3146dcba2bb5e5719abc"},
{file = "pydantic_core-2.18.4-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:43d447dd2ae072a0065389092a231283f62d960030ecd27565672bd40746c507"},
{file = "pydantic_core-2.18.4-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:578e24f761f3b425834f297b9935e1ce2e30f51400964ce4801002435a1b41ef"},
{file = "pydantic_core-2.18.4-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:81b5efb2f126454586d0f40c4d834010979cb80785173d1586df845a632e4e6d"},
{file = "pydantic_core-2.18.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ab86ce7c8f9bea87b9d12c7f0af71102acbf5ecbc66c17796cff45dae54ef9a5"},
{file = "pydantic_core-2.18.4-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:90afc12421df2b1b4dcc975f814e21bc1754640d502a2fbcc6d41e77af5ec312"},
{file = "pydantic_core-2.18.4-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:51991a89639a912c17bef4b45c87bd83593aee0437d8102556af4885811d59f5"},
{file = "pydantic_core-2.18.4-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:293afe532740370aba8c060882f7d26cfd00c94cae32fd2e212a3a6e3b7bc15e"},
{file = "pydantic_core-2.18.4-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b48ece5bde2e768197a2d0f6e925f9d7e3e826f0ad2271120f8144a9db18d5c8"},
{file = "pydantic_core-2.18.4-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:eae237477a873ab46e8dd748e515c72c0c804fb380fbe6c85533c7de51f23a8f"},
{file = "pydantic_core-2.18.4-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:834b5230b5dfc0c1ec37b2fda433b271cbbc0e507560b5d1588e2cc1148cf1ce"},
{file = "pydantic_core-2.18.4-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:e858ac0a25074ba4bce653f9b5d0a85b7456eaddadc0ce82d3878c22489fa4ee"},
{file = "pydantic_core-2.18.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2fd41f6eff4c20778d717af1cc50eca52f5afe7805ee530a4fbd0bae284f16e9"},
{file = "pydantic_core-2.18.4.tar.gz", hash = "sha256:ec3beeada09ff865c344ff3bc2f427f5e6c26401cc6113d77e372c3fdac73864"},
{file = "pydantic_core-2.20.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:595ba5be69b35777474fa07f80fc260ea71255656191adb22a8c53aba4479231"},
{file = "pydantic_core-2.20.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a4f55095ad087474999ee28d3398bae183a66be4823f753cd7d67dd0153427c9"},
{file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f9aa05d09ecf4c75157197f27cdc9cfaeb7c5f15021c6373932bf3e124af029f"},
{file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e97fdf088d4b31ff4ba35db26d9cc472ac7ef4a2ff2badeabf8d727b3377fc52"},
{file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bc633a9fe1eb87e250b5c57d389cf28998e4292336926b0b6cdaee353f89a237"},
{file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d573faf8eb7e6b1cbbcb4f5b247c60ca8be39fe2c674495df0eb4318303137fe"},
{file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26dc97754b57d2fd00ac2b24dfa341abffc380b823211994c4efac7f13b9e90e"},
{file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:33499e85e739a4b60c9dac710c20a08dc73cb3240c9a0e22325e671b27b70d24"},
{file = "pydantic_core-2.20.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:bebb4d6715c814597f85297c332297c6ce81e29436125ca59d1159b07f423eb1"},
{file = "pydantic_core-2.20.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:516d9227919612425c8ef1c9b869bbbee249bc91912c8aaffb66116c0b447ebd"},
{file = "pydantic_core-2.20.1-cp312-none-win32.whl", hash = "sha256:469f29f9093c9d834432034d33f5fe45699e664f12a13bf38c04967ce233d688"},
{file = "pydantic_core-2.20.1-cp312-none-win_amd64.whl", hash = "sha256:035ede2e16da7281041f0e626459bcae33ed998cca6a0a007a5ebb73414ac72d"},
{file = "pydantic_core-2.20.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:0827505a5c87e8aa285dc31e9ec7f4a17c81a813d45f70b1d9164e03a813a686"},
{file = "pydantic_core-2.20.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19c0fa39fa154e7e0b7f82f88ef85faa2a4c23cc65aae2f5aea625e3c13c735a"},
{file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa223cd1e36b642092c326d694d8bf59b71ddddc94cdb752bbbb1c5c91d833b"},
{file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c336a6d235522a62fef872c6295a42ecb0c4e1d0f1a3e500fe949415761b8a19"},
{file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7eb6a0587eded33aeefea9f916899d42b1799b7b14b8f8ff2753c0ac1741edac"},
{file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:70c8daf4faca8da5a6d655f9af86faf6ec2e1768f4b8b9d0226c02f3d6209703"},
{file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e9fa4c9bf273ca41f940bceb86922a7667cd5bf90e95dbb157cbb8441008482c"},
{file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:11b71d67b4725e7e2a9f6e9c0ac1239bbc0c48cce3dc59f98635efc57d6dac83"},
{file = "pydantic_core-2.20.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:270755f15174fb983890c49881e93f8f1b80f0b5e3a3cc1394a255706cabd203"},
{file = "pydantic_core-2.20.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:c81131869240e3e568916ef4c307f8b99583efaa60a8112ef27a366eefba8ef0"},
{file = "pydantic_core-2.20.1-cp313-none-win32.whl", hash = "sha256:b91ced227c41aa29c672814f50dbb05ec93536abf8f43cd14ec9521ea09afe4e"},
{file = "pydantic_core-2.20.1-cp313-none-win_amd64.whl", hash = "sha256:65db0f2eefcaad1a3950f498aabb4875c8890438bc80b19362cf633b87a8ab20"},
{file = "pydantic_core-2.20.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:a45f84b09ac9c3d35dfcf6a27fd0634d30d183205230a0ebe8373a0e8cfa0906"},
{file = "pydantic_core-2.20.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d02a72df14dfdbaf228424573a07af10637bd490f0901cee872c4f434a735b94"},
{file = "pydantic_core-2.20.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d2b27e6af28f07e2f195552b37d7d66b150adbaa39a6d327766ffd695799780f"},
{file = "pydantic_core-2.20.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:084659fac3c83fd674596612aeff6041a18402f1e1bc19ca39e417d554468482"},
{file = "pydantic_core-2.20.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:242b8feb3c493ab78be289c034a1f659e8826e2233786e36f2893a950a719bb6"},
{file = "pydantic_core-2.20.1-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:38cf1c40a921d05c5edc61a785c0ddb4bed67827069f535d794ce6bcded919fc"},
{file = "pydantic_core-2.20.1-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:e0bbdd76ce9aa5d4209d65f2b27fc6e5ef1312ae6c5333c26db3f5ade53a1e99"},
{file = "pydantic_core-2.20.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:254ec27fdb5b1ee60684f91683be95e5133c994cc54e86a0b0963afa25c8f8a6"},
{file = "pydantic_core-2.20.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:407653af5617f0757261ae249d3fba09504d7a71ab36ac057c938572d1bc9331"},
{file = "pydantic_core-2.20.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:c693e916709c2465b02ca0ad7b387c4f8423d1db7b4649c551f27a529181c5ad"},
{file = "pydantic_core-2.20.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5b5ff4911aea936a47d9376fd3ab17e970cc543d1b68921886e7f64bd28308d1"},
{file = "pydantic_core-2.20.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:177f55a886d74f1808763976ac4efd29b7ed15c69f4d838bbd74d9d09cf6fa86"},
{file = "pydantic_core-2.20.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:964faa8a861d2664f0c7ab0c181af0bea66098b1919439815ca8803ef136fc4e"},
{file = "pydantic_core-2.20.1-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:4dd484681c15e6b9a977c785a345d3e378d72678fd5f1f3c0509608da24f2ac0"},
{file = "pydantic_core-2.20.1-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f6d6cff3538391e8486a431569b77921adfcdef14eb18fbf19b7c0a5294d4e6a"},
{file = "pydantic_core-2.20.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:a6d511cc297ff0883bc3708b465ff82d7560193169a8b93260f74ecb0a5e08a7"},
{file = "pydantic_core-2.20.1.tar.gz", hash = "sha256:26ca695eeee5f9f1aeeb211ffc12f10bcb6f71e2989988fda61dabd65db878d4"},
]
[[package]]
@ -410,12 +422,12 @@ files = [
[[package]]
name = "redis"
version = "5.0.4"
version = "5.0.7"
requires_python = ">=3.7"
summary = "Python client for Redis database and key-value store"
files = [
{file = "redis-5.0.4-py3-none-any.whl", hash = "sha256:7adc2835c7a9b5033b7ad8f8918d09b7344188228809c98df07af226d39dec91"},
{file = "redis-5.0.4.tar.gz", hash = "sha256:ec31f2ed9675cc54c21ba854cfe0462e6faf1d83c8ce5944709db8a4700b9c61"},
{file = "redis-5.0.7-py3-none-any.whl", hash = "sha256:0e479e24da960c690be5d9b96d21f7b918a98c0cf49af3b6fafaa0753f93a0db"},
{file = "redis-5.0.7.tar.gz", hash = "sha256:8f611490b93c8109b50adc317b31bfd84fff31def3475b92e7e80bf39f48175b"},
]
[[package]]
@ -434,37 +446,74 @@ files = [
[[package]]
name = "ruff"
version = "0.4.10"
version = "0.5.1"
requires_python = ">=3.7"
summary = "An extremely fast Python linter and code formatter, written in Rust."
files = [
{file = "ruff-0.4.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5c2c4d0859305ac5a16310eec40e4e9a9dec5dcdfbe92697acd99624e8638dac"},
{file = "ruff-0.4.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a79489607d1495685cdd911a323a35871abfb7a95d4f98fc6f85e799227ac46e"},
{file = "ruff-0.4.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b1dd1681dfa90a41b8376a61af05cc4dc5ff32c8f14f5fe20dba9ff5deb80cd6"},
{file = "ruff-0.4.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c75c53bb79d71310dc79fb69eb4902fba804a81f374bc86a9b117a8d077a1784"},
{file = "ruff-0.4.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:18238c80ee3d9100d3535d8eb15a59c4a0753b45cc55f8bf38f38d6a597b9739"},
{file = "ruff-0.4.10-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:d8f71885bce242da344989cae08e263de29752f094233f932d4f5cfb4ef36a81"},
{file = "ruff-0.4.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:330421543bd3222cdfec481e8ff3460e8702ed1e58b494cf9d9e4bf90db52b9d"},
{file = "ruff-0.4.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e9b6fb3a37b772628415b00c4fc892f97954275394ed611056a4b8a2631365e"},
{file = "ruff-0.4.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f54c481b39a762d48f64d97351048e842861c6662d63ec599f67d515cb417f6"},
{file = "ruff-0.4.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:67fe086b433b965c22de0b4259ddfe6fa541c95bf418499bedb9ad5fb8d1c631"},
{file = "ruff-0.4.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:acfaaab59543382085f9eb51f8e87bac26bf96b164839955f244d07125a982ef"},
{file = "ruff-0.4.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:3cea07079962b2941244191569cf3a05541477286f5cafea638cd3aa94b56815"},
{file = "ruff-0.4.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:338a64ef0748f8c3a80d7f05785930f7965d71ca260904a9321d13be24b79695"},
{file = "ruff-0.4.10-py3-none-win32.whl", hash = "sha256:ffe3cd2f89cb54561c62e5fa20e8f182c0a444934bf430515a4b422f1ab7b7ca"},
{file = "ruff-0.4.10-py3-none-win_amd64.whl", hash = "sha256:67f67cef43c55ffc8cc59e8e0b97e9e60b4837c8f21e8ab5ffd5d66e196e25f7"},
{file = "ruff-0.4.10-py3-none-win_arm64.whl", hash = "sha256:dd1fcee327c20addac7916ca4e2653fbbf2e8388d8a6477ce5b4e986b68ae6c0"},
{file = "ruff-0.4.10.tar.gz", hash = "sha256:3aa4f2bc388a30d346c56524f7cacca85945ba124945fe489952aadb6b5cd804"},
{file = "ruff-0.5.1-py3-none-linux_armv6l.whl", hash = "sha256:6ecf968fcf94d942d42b700af18ede94b07521bd188aaf2cd7bc898dd8cb63b6"},
{file = "ruff-0.5.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:204fb0a472f00f2e6280a7c8c7c066e11e20e23a37557d63045bf27a616ba61c"},
{file = "ruff-0.5.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d235968460e8758d1e1297e1de59a38d94102f60cafb4d5382033c324404ee9d"},
{file = "ruff-0.5.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38beace10b8d5f9b6bdc91619310af6d63dd2019f3fb2d17a2da26360d7962fa"},
{file = "ruff-0.5.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5e478d2f09cf06add143cf8c4540ef77b6599191e0c50ed976582f06e588c994"},
{file = "ruff-0.5.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f0368d765eec8247b8550251c49ebb20554cc4e812f383ff9f5bf0d5d94190b0"},
{file = "ruff-0.5.1-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:3a9a9a1b582e37669b0138b7c1d9d60b9edac880b80eb2baba6d0e566bdeca4d"},
{file = "ruff-0.5.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bdd9f723e16003623423affabcc0a807a66552ee6a29f90eddad87a40c750b78"},
{file = "ruff-0.5.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:be9fd62c1e99539da05fcdc1e90d20f74aec1b7a1613463ed77870057cd6bd96"},
{file = "ruff-0.5.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e216fc75a80ea1fbd96af94a6233d90190d5b65cc3d5dfacf2bd48c3e067d3e1"},
{file = "ruff-0.5.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c4c2112e9883a40967827d5c24803525145e7dab315497fae149764979ac7929"},
{file = "ruff-0.5.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dfaf11c8a116394da3b65cd4b36de30d8552fa45b8119b9ef5ca6638ab964fa3"},
{file = "ruff-0.5.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d7ceb9b2fe700ee09a0c6b192c5ef03c56eb82a0514218d8ff700f6ade004108"},
{file = "ruff-0.5.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:bac6288e82f6296f82ed5285f597713acb2a6ae26618ffc6b429c597b392535c"},
{file = "ruff-0.5.1-py3-none-win32.whl", hash = "sha256:5c441d9c24ec09e1cb190a04535c5379b36b73c4bc20aa180c54812c27d1cca4"},
{file = "ruff-0.5.1-py3-none-win_amd64.whl", hash = "sha256:b1789bf2cd3d1b5a7d38397cac1398ddf3ad7f73f4de01b1e913e2abc7dfc51d"},
{file = "ruff-0.5.1-py3-none-win_arm64.whl", hash = "sha256:2875b7596a740cbbd492f32d24be73e545a4ce0a3daf51e4f4e609962bfd3cd2"},
{file = "ruff-0.5.1.tar.gz", hash = "sha256:3164488aebd89b1745b47fd00604fb4358d774465f20d1fcd907f9c0fc1b0655"},
]
[[package]]
name = "typing-extensions"
version = "4.11.0"
version = "4.12.2"
requires_python = ">=3.8"
summary = "Backported and Experimental Type Hints for Python 3.8+"
files = [
{file = "typing_extensions-4.11.0-py3-none-any.whl", hash = "sha256:c1f94d72897edaf4ce775bb7558d5b79d8126906a14ea5ed1635921406c0387a"},
{file = "typing_extensions-4.11.0.tar.gz", hash = "sha256:83f085bd5ca59c80295fc2a82ab5dac679cbe02b9f33f7d83af68e241bea51b0"},
{file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"},
{file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"},
]
[[package]]
name = "websockets"
version = "12.0"
requires_python = ">=3.8"
summary = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)"
files = [
{file = "websockets-12.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0e6e2711d5a8e6e482cacb927a49a3d432345dfe7dea8ace7b5790df5932e4df"},
{file = "websockets-12.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:dbcf72a37f0b3316e993e13ecf32f10c0e1259c28ffd0a85cee26e8549595fbc"},
{file = "websockets-12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:12743ab88ab2af1d17dd4acb4645677cb7063ef4db93abffbf164218a5d54c6b"},
{file = "websockets-12.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b645f491f3c48d3f8a00d1fce07445fab7347fec54a3e65f0725d730d5b99cb"},
{file = "websockets-12.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9893d1aa45a7f8b3bc4510f6ccf8db8c3b62120917af15e3de247f0780294b92"},
{file = "websockets-12.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f38a7b376117ef7aff996e737583172bdf535932c9ca021746573bce40165ed"},
{file = "websockets-12.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:f764ba54e33daf20e167915edc443b6f88956f37fb606449b4a5b10ba42235a5"},
{file = "websockets-12.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:1e4b3f8ea6a9cfa8be8484c9221ec0257508e3a1ec43c36acdefb2a9c3b00aa2"},
{file = "websockets-12.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:9fdf06fd06c32205a07e47328ab49c40fc1407cdec801d698a7c41167ea45113"},
{file = "websockets-12.0-cp312-cp312-win32.whl", hash = "sha256:baa386875b70cbd81798fa9f71be689c1bf484f65fd6fb08d051a0ee4e79924d"},
{file = "websockets-12.0-cp312-cp312-win_amd64.whl", hash = "sha256:ae0a5da8f35a5be197f328d4727dbcfafa53d1824fac3d96cdd3a642fe09394f"},
{file = "websockets-12.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:248d8e2446e13c1d4326e0a6a4e9629cb13a11195051a73acf414812700badbd"},
{file = "websockets-12.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f44069528d45a933997a6fef143030d8ca8042f0dfaad753e2906398290e2870"},
{file = "websockets-12.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c4e37d36f0d19f0a4413d3e18c0d03d0c268ada2061868c1e6f5ab1a6d575077"},
{file = "websockets-12.0-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d829f975fc2e527a3ef2f9c8f25e553eb7bc779c6665e8e1d52aa22800bb38b"},
{file = "websockets-12.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:2c71bd45a777433dd9113847af751aae36e448bc6b8c361a566cb043eda6ec30"},
{file = "websockets-12.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:0bee75f400895aef54157b36ed6d3b308fcab62e5260703add87f44cee9c82a6"},
{file = "websockets-12.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:423fc1ed29f7512fceb727e2d2aecb952c46aa34895e9ed96071821309951123"},
{file = "websockets-12.0-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:27a5e9964ef509016759f2ef3f2c1e13f403725a5e6a1775555994966a66e931"},
{file = "websockets-12.0-pp38-pypy38_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c3181df4583c4d3994d31fb235dc681d2aaad744fbdbf94c4802485ececdecf2"},
{file = "websockets-12.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:b067cb952ce8bf40115f6c19f478dc71c5e719b7fbaa511359795dfd9d1a6468"},
{file = "websockets-12.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:00700340c6c7ab788f176d118775202aadea7602c5cc6be6ae127761c16d6b0b"},
{file = "websockets-12.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e469d01137942849cff40517c97a30a93ae79917752b34029f0ec72df6b46399"},
{file = "websockets-12.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffefa1374cd508d633646d51a8e9277763a9b78ae71324183693959cf94635a7"},
{file = "websockets-12.0-pp39-pypy39_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba0cab91b3956dfa9f512147860783a1829a8d905ee218a9837c18f683239611"},
{file = "websockets-12.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2cb388a5bfb56df4d9a406783b7f9dbefb888c09b71629351cc6b036e9259370"},
{file = "websockets-12.0-py3-none-any.whl", hash = "sha256:dc284bbc8d7c78a6c69e0c7325ab46ee5e40bb4d50e494d8131a07ef47500e9e"},
{file = "websockets-12.0.tar.gz", hash = "sha256:81df9cbcbb6c260de1e007e58c011bfebe2dafc8435107b0537f393dd38c8b1b"},
]
[[package]]

View file

@ -8,7 +8,7 @@ authors = [
dependencies = [
"aiocache>=0.12.2",
"attrs>=23.1.0",
"pyobjc-framework-MediaPlayer>=10.0",
"pyobjc-framework-MediaPlayer>=10.0 ; sys_platform == 'darwin'",
"python-mpd2>=3.1.0",
"xdg-base-dirs>=6.0.1",
"pytomlpp>=1.0.13",
@ -16,6 +16,7 @@ dependencies = [
"boltons>=24.0.0",
"pydantic>=2.7.4",
"rich>=13.7.1",
"ormsgpack>=1.5.0",
]
readme = "README.md"
@ -30,9 +31,16 @@ classifiers = [
]
[project.optional-dependencies]
redis = ["aiocache[redis]", "ormsgpack>=1.5.0"]
memcached = ["aiocache[memcached]", "ormsgpack>=1.5.0"]
all = ["mpd-now-playable[redis,memcached]"]
redis = [
"aiocache[redis]",
]
memcached = [
"aiocache[memcached]",
]
websockets = [
"websockets>=12.0",
]
all = ["mpd-now-playable[redis,memcached,websockets]"]
[project.urls]
Homepage = "https://git.00dani.me/00dani/mpd-now-playable"
@ -70,6 +78,7 @@ select = [
]
ignore = [
"ANN101", # missing-type-self
"ANN102", # missing-type-cls
]
[tool.ruff.lint.flake8-annotations]

View file

@ -1,5 +1,20 @@
{
"$defs": {
"CocoaReceiverConfig": {
"properties": {
"kind": {
"const": "cocoa",
"default": "cocoa",
"enum": [
"cocoa"
],
"title": "Kind",
"type": "string"
}
},
"title": "CocoaReceiverConfig",
"type": "object"
},
"MpdConfig": {
"properties": {
"host": {
@ -27,6 +42,46 @@
},
"title": "MpdConfig",
"type": "object"
},
"WebsocketsReceiverConfig": {
"properties": {
"host": {
"anyOf": [
{
"format": "hostname",
"type": "string"
},
{
"items": {
"format": "hostname",
"type": "string"
},
"type": "array"
}
],
"title": "Host"
},
"kind": {
"const": "websockets",
"default": "websockets",
"enum": [
"websockets"
],
"title": "Kind",
"type": "string"
},
"port": {
"maximum": 65535,
"minimum": 1,
"title": "Port",
"type": "integer"
}
},
"required": [
"port"
],
"title": "WebsocketsReceiverConfig",
"type": "object"
}
},
"$id": "https://cdn.00dani.me/m/schemata/mpd-now-playable/config-v1.json",
@ -40,6 +95,32 @@
},
"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",

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

@ -0,0 +1,205 @@
{
"$defs": {
"HasArtwork": {
"properties": {
"data": {
"format": "binary",
"title": "Data",
"type": "string"
}
},
"required": [
"data"
],
"title": "HasArtwork",
"type": "object"
},
"MusicBrainzIds": {
"properties": {
"artist": {
"description": "https://musicbrainz.org/doc/Artist",
"format": "uuid",
"title": "Artist",
"type": "string"
},
"recording": {
"description": "A MusicBrainz recording represents audio from a specific performance. For example, if the same song was released as a studio recording and as a live performance, those two versions of the song are different recordings. The song itself is considered a \"work\", of which two recordings were made. However, recordings are not always associated with a work in the MusicBrainz database, and Picard won't load work IDs by default (you have to enable \"use track relationships\" in the options), so recording IDs are a much more reliable way to identify a particular song. https://musicbrainz.org/doc/Recording",
"format": "uuid",
"title": "Recording",
"type": "string"
},
"release": {
"description": "A MusicBrainz release roughly corresponds to an \"album\", and indeed is stored in a tag called MUSICBRAINZ_ALBUMID. The more general name is meant to encompass all the different ways music can be released. https://musicbrainz.org/doc/Release",
"format": "uuid",
"title": "Release",
"type": "string"
},
"release_artist": {
"description": "Again, the release artist corresponds to an \"album artist\". These MBIDs refer to the same artists in the MusicBrainz database that individual recordings' artist MBIDs do.",
"format": "uuid",
"title": "Release Artist",
"type": "string"
},
"release_group": {
"description": "A MusicBrainz release group roughly corresponds to \"all the editions of a particular album\". For example, if the same album were released on CD, vinyl records, and as a digital download, then all of those would be different releases but share a release group. Note that MPD's support for this tag is relatively new (July 2023) and doesn't seem especially reliable, so it might be missing here even if your music has been tagged with it. Not sure why. https://musicbrainz.org/doc/Release_Group",
"format": "uuid",
"title": "Release Group",
"type": "string"
},
"track": {
"description": "A MusicBrainz track represents a specific instance of a recording appearing as part of some release. For example, if the same song appears on both two-CD and four-CD versions of a soundtrack, then it will be considered the same \"recording\" in both cases, but different \"tracks\". https://musicbrainz.org/doc/Track",
"format": "uuid",
"title": "Track",
"type": "string"
},
"work": {
"description": "A MusicBrainz work represents the idea of a particular song or creation (it doesn't have to be audio). Each work may have multiple recordings (studio versus live, different performers, etc.), with the work ID grouping them together. https://musicbrainz.org/doc/Work",
"format": "uuid",
"title": "Work",
"type": "string"
}
},
"title": "MusicBrainzIds",
"type": "object"
},
"NoArtwork": {
"properties": {},
"title": "NoArtwork",
"type": "object"
},
"PlaybackState": {
"enum": [
"play",
"pause",
"stop"
],
"title": "PlaybackState",
"type": "string"
}
},
"$id": "https://cdn.00dani.me/m/schemata/mpd-now-playable/song-v1.json",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
"album": {
"description": "The name of the song's containing album, which may be multivalued.",
"items": {
"type": "string"
},
"title": "Album",
"type": "array"
},
"album_artist": {
"description": "The album's artists. This is often used to group together songs from a single album that featured different artists.",
"items": {
"type": "string"
},
"title": "Album Artist",
"type": "array"
},
"art": {
"anyOf": [
{
"$ref": "#/$defs/HasArtwork"
},
{
"$ref": "#/$defs/NoArtwork"
}
],
"description": "The song's cover art, if it has any - the art will be available as bytes if present, ready to be displayed directly by receivers.",
"title": "Art"
},
"artist": {
"description": "The song's artists. Will be an empty list if the song has not been tagged with an artist, and may contain multiple values if the song has been tagged with several artists.",
"items": {
"type": "string"
},
"title": "Artist",
"type": "array"
},
"composer": {
"description": "The song's composers. Again, this is permitted to be multivalued.",
"items": {
"type": "string"
},
"title": "Composer",
"type": "array"
},
"disc": {
"description": "The disc number of the song on its album. As with the track number, this is usually one-based, but it doesn't have to be.",
"title": "Disc",
"type": "integer"
},
"duration": {
"description": "The song's duration as read from its tags, measured in seconds. Fractional seconds are allowed.",
"title": "Duration",
"type": "number"
},
"elapsed": {
"description": "How far into the song MPD is, measured in seconds. Fractional seconds are allowed. This is usually going to be less than or equal to the song's duration, but because the duration is tagged as metadata and this value represents the actual elapsed time, it might go higher if the song's duration tag is inaccurate.",
"title": "Elapsed",
"type": "number"
},
"file": {
"description": "The relative path to the current song inside the music directory. MPD itself uses this path as a stable identifier for the audio file in many places, so you can safely do the same.",
"format": "path",
"title": "File",
"type": "string"
},
"genre": {
"description": "The song's genre or genres. These are completely arbitrary descriptions and don't follow any particular standard.",
"items": {
"type": "string"
},
"title": "Genre",
"type": "array"
},
"musicbrainz": {
"$ref": "#/$defs/MusicBrainzIds",
"description": "The MusicBrainz IDs associated with the song and with its artist and album, which if present are an extremely accurate way to identify a given song. They're not always present, though."
},
"queue_index": {
"description": "The zero-based index of the current song in MPD's queue.",
"title": "Queue Index",
"type": "integer"
},
"queue_length": {
"description": "The total length of MPD's queue - the last song in the queue will have the index one less than this, since queue indices are zero-based.",
"title": "Queue Length",
"type": "integer"
},
"state": {
"$ref": "#/$defs/PlaybackState",
"description": "Whether MPD is currently playing, paused, or stopped. Pretty simple."
},
"title": {
"description": "The song's title, if it's been tagged with one. Currently only one title is supported, since it doesn't make a lot of sense to tag a single audio file with multiple titles.",
"title": "Title",
"type": "string"
},
"track": {
"description": "The track number the song has on its album. This is usually one-based, but it's just an arbitrary audio tag so a particular album might start at zero or do something weird with it.",
"title": "Track",
"type": "integer"
}
},
"required": [
"state",
"queue_index",
"queue_length",
"file",
"title",
"artist",
"composer",
"album",
"album_artist",
"track",
"disc",
"genre",
"duration",
"elapsed",
"art",
"musicbrainz"
],
"title": "Song",
"type": "object"
}

View file

@ -1,30 +1,41 @@
import asyncio
from collections.abc import Iterable
from corefoundationasyncio import CoreFoundationEventLoop
from rich import print
from .__version__ import __version__
from .cocoa.now_playing import CocoaNowPlaying
from .config.load import loadConfig
from .config.model import Config
from .mpd.listener import MpdStateListener
from .song_receiver import (
Receiver,
choose_loop_factory,
construct_receiver,
)
async def listen() -> None:
print(f"mpd-now-playable v{__version__}")
config = loadConfig()
print(config)
listener = MpdStateListener(config.cache)
now_playing = CocoaNowPlaying(listener)
async def listen(
config: Config, listener: MpdStateListener, receivers: Iterable[Receiver]
) -> None:
await listener.start(config.mpd)
await listener.loop(now_playing)
def make_loop() -> CoreFoundationEventLoop:
return CoreFoundationEventLoop(console_app=True)
await asyncio.gather(*(rec.start(listener) for rec in receivers))
await listener.loop(receivers)
def main() -> None:
asyncio.run(listen(), loop_factory=make_loop, debug=True)
print(f"mpd-now-playable v{__version__}")
config = loadConfig()
print(config)
listener = MpdStateListener(config.cache)
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__":

View file

@ -1,10 +1,41 @@
from dataclasses import dataclass, field
from typing import Optional
from typing import Annotated, Literal, Optional, Protocol
from pydantic import Field
from ..tools.schema.define import schema
from .fields import Host, Password, Port, Url
__all__ = ("MpdConfig", "Config")
__all__ = (
"Config",
"MpdConfig",
"BaseReceiverConfig",
"CocoaReceiverConfig",
"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)
port: Port
host: Optional[Host | tuple[Host, ...]] = None
ReceiverConfig = Annotated[
CocoaReceiverConfig | WebsocketsReceiverConfig,
Field(discriminator="kind"),
]
@dataclass(slots=True)
@ -27,3 +58,4 @@ class Config:
#: protocols are memory://, redis://, and memcached://.
cache: Optional[Url] = None
mpd: MpdConfig = field(default_factory=MpdConfig)
receivers: tuple[ReceiverConfig, ...] = (CocoaReceiverConfig(),)

View file

@ -1,6 +1,6 @@
import asyncio
from collections.abc import Iterable
from pathlib import Path
from uuid import UUID
from mpd.asyncio import MPDClient
from mpd.base import CommandError
@ -9,7 +9,8 @@ from yarl import URL
from ..config.model import MpdConfig
from ..player import Player
from ..song import Artwork, PlaybackState, Song, SongListener, to_artwork
from ..song import Artwork, PlaybackState, Song, to_artwork, to_brainz
from ..song_receiver import Receiver
from ..tools.types import convert_if_exists, un_maybe_plural
from .artwork_cache import MpdArtworkCache
from .types import CurrentSongResponse, StatusResponse
@ -23,10 +24,6 @@ def mpd_current_to_song(
queue_index=int(current["pos"]),
queue_length=int(status["playlistlength"]),
file=Path(current["file"]),
musicbrainz_trackid=convert_if_exists(current.get("musicbrainz_trackid"), UUID),
musicbrainz_releasetrackid=convert_if_exists(
current.get("musicbrainz_releasetrackid"), UUID
),
title=current.get("title"),
artist=un_maybe_plural(current.get("artist")),
album=un_maybe_plural(current.get("album")),
@ -37,13 +34,14 @@ def mpd_current_to_song(
disc=convert_if_exists(current.get("disc"), int),
duration=float(status["duration"]),
elapsed=float(status["elapsed"]),
musicbrainz=to_brainz(current),
art=art,
)
class MpdStateListener(Player):
client: MPDClient
listener: SongListener
receivers: Iterable[Receiver]
art_cache: MpdArtworkCache
idle_count = 0
@ -62,18 +60,18 @@ class MpdStateListener(Player):
print(f"Connected to MPD v{self.client.mpd_version}")
async def refresh(self) -> None:
await self.update_listener(self.listener)
await self.update_receivers()
async def loop(self, listener: SongListener) -> None:
self.listener = listener
# notify our listener of the initial state MPD is in when this script loads up.
await self.update_listener(listener)
async def loop(self, receivers: Iterable[Receiver]) -> None:
self.receivers = receivers
# notify our receivers of the initial state MPD is in when this script loads up.
await self.update_receivers()
# then wait for stuff to change in MPD. :)
async for _ in self.client.idle():
self.idle_count += 1
await self.update_listener(listener)
await self.update_receivers()
async def update_listener(self, listener: SongListener) -> None:
async def update_receivers(self) -> None:
# If any async calls in here take long enough that we got another MPD idle event, we want to bail out of this older update.
starting_idle_count = self.idle_count
status, current = await asyncio.gather(
@ -85,7 +83,7 @@ class MpdStateListener(Player):
if status["state"] == "stop":
print("Nothing playing")
listener.update(None)
await self.update(None)
return
art = await self.art_cache.get_cached_artwork(current)
@ -94,7 +92,10 @@ class MpdStateListener(Player):
song = mpd_current_to_song(status, current, to_artwork(art))
rprint(song)
listener.update(song)
await self.update(song)
async def update(self, song: Song | None) -> None:
await asyncio.gather(*(r.update(song) for r in self.receivers))
async def get_art(self, file: str) -> bytes | None:
picture = await self.readpicture(file)

View file

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

View file

@ -1,7 +1,9 @@
from collections.abc import Callable, Coroutine
from pathlib import Path
from typing import Literal
from AppKit import NSCompositingOperationCopy, NSImage, NSMakeRect
from corefoundationasyncio import CoreFoundationEventLoop
from Foundation import CGSize, NSMutableDictionary
from MediaPlayer import (
MPChangePlaybackPositionCommandEvent,
@ -35,15 +37,17 @@ from MediaPlayer import (
MPRemoteCommandHandlerStatusSuccess,
)
from ..player import Player
from ..song import PlaybackState, Song
from ..tools.asyncio import run_background_task
from ...config.model import CocoaReceiverConfig
from ...player import Player
from ...song import PlaybackState, Song
from ...song_receiver import LoopFactory, Receiver
from ...tools.asyncio import run_background_task
from .persistent_id import song_to_persistent_id
def logo_to_ns_image() -> NSImage:
return NSImage.alloc().initByReferencingFile_(
str(Path(__file__).parent.parent / "mpd/logo.svg")
str(Path(__file__).parent.parent.parent / "mpd/logo.svg")
)
@ -127,8 +131,25 @@ def nothing_to_media_item() -> NSMutableDictionary:
MPD_LOGO = ns_image_to_media_item_artwork(logo_to_ns_image())
class CocoaNowPlaying:
def __init__(self, player: Player):
class CocoaLoopFactory(LoopFactory[CoreFoundationEventLoop]):
@property
def is_replaceable(self) -> Literal[False]:
return False
@classmethod
def make_loop(cls) -> CoreFoundationEventLoop:
return CoreFoundationEventLoop(console_app=True)
class CocoaNowPlayingReceiver(Receiver):
@classmethod
def loop_factory(cls) -> LoopFactory[CoreFoundationEventLoop]:
return CocoaLoopFactory()
def __init__(self, config: CocoaReceiverConfig):
pass
async def start(self, player: Player) -> None:
self.cmd_center = MPRemoteCommandCenter.sharedCommandCenter()
self.info_center = MPNowPlayingInfoCenter.defaultCenter()
@ -166,7 +187,7 @@ class CocoaNowPlaying:
# unpause with remote commands.
self.info_center.setPlaybackState_(MPMusicPlaybackStatePlaying)
def update(self, song: Song | None) -> None:
async def update(self, song: Song | None) -> None:
if song:
self.info_center.setNowPlayingInfo_(song_to_media_item(song))
self.info_center.setPlaybackState_(playback_state_to_cocoa(song.state))

View file

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

View file

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

View file

@ -0,0 +1,52 @@
from pathlib import Path
import ormsgpack
from websockets import broadcast
from websockets.server import WebSocketServerProtocol, serve
from ...config.model import WebsocketsReceiverConfig
from ...player import Player
from ...song import Song
from ...song_receiver import DefaultLoopFactory, Receiver
MSGPACK_NULL = ormsgpack.packb(None)
def default(value: object) -> object:
if isinstance(value, Path):
return str(value)
raise TypeError
class WebsocketsReceiver(Receiver):
config: WebsocketsReceiverConfig
player: Player
connections: set[WebSocketServerProtocol]
last_status: bytes = MSGPACK_NULL
def __init__(self, config: WebsocketsReceiverConfig):
self.config = config
self.connections = set()
@classmethod
def loop_factory(cls) -> DefaultLoopFactory:
return DefaultLoopFactory()
async def start(self, player: Player) -> None:
self.player = player
await serve(self.handle, host=self.config.host, port=self.config.port)
async def handle(self, conn: WebSocketServerProtocol) -> None:
self.connections.add(conn)
await conn.send(self.last_status)
try:
await conn.wait_closed()
finally:
self.connections.remove(conn)
async def update(self, song: Song | None) -> None:
if song is None:
self.last_status = MSGPACK_NULL
else:
self.last_status = ormsgpack.packb(song, default=default)
broadcast(self.connections, self.last_status)

View file

@ -1,59 +0,0 @@
from dataclasses import dataclass, field
from enum import StrEnum
from pathlib import Path
from typing import Literal, Protocol
from uuid import UUID
from pydantic.type_adapter import TypeAdapter
class PlaybackState(StrEnum):
play = "play"
pause = "pause"
stop = "stop"
@dataclass(slots=True)
class HasArtwork:
data: bytes = field(repr=False)
@dataclass(slots=True)
class NoArtwork:
def __bool__(self) -> Literal[False]:
return False
Artwork = HasArtwork | NoArtwork
ArtworkSchema: TypeAdapter[Artwork] = TypeAdapter(HasArtwork | NoArtwork)
def to_artwork(art: bytes | None) -> Artwork:
if art is None:
return NoArtwork()
return HasArtwork(art)
@dataclass(slots=True)
class Song:
state: PlaybackState
queue_index: int
queue_length: int
file: Path
musicbrainz_trackid: UUID | None
musicbrainz_releasetrackid: UUID | None
title: str | None
artist: list[str]
composer: list[str]
album: list[str]
album_artist: list[str]
track: int | None
disc: int | None
genre: list[str]
duration: float
elapsed: float
art: Artwork
class SongListener(Protocol):
def update(self, song: Song | None) -> None: ...

View file

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

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,99 @@
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 option_fmap
option_uuid = partial(option_fmap, UUID)
OptionUUID = Annotated[UUID | None, Field(default=None)]
class MusicBrainzTags(TypedDict, total=False):
"""
The MusicBrainz tags mpd-now-playable expects and will load (all optional).
They're named slightly differently than the actual MusicBrainz IDs they
store - use to_brainz to map them across to their canonical form.
https://picard-docs.musicbrainz.org/downloads/MusicBrainz_Picard_Tag_Map.html
"""
#: MusicBrainz Recording ID
musicbrainz_trackid: str
#: MusicBrainz Track ID
musicbrainz_releasetrackid: str
#: MusicBrainz Artist ID
musicbrainz_artistid: str
#: MusicBrainz Release ID
musicbrainz_albumid: str
#: MusicBrainz Release Artist ID
musicbrainz_albumartistid: str
#: MusicBrainz Release Group ID
musicbrainz_releasegroupid: str
#: MusicBrainz Work ID
musicbrainz_workid: str
@dataclass(slots=True)
class MusicBrainzIds:
#: A MusicBrainz recording represents audio from a specific performance.
#: For example, if the same song was released as a studio recording and as
#: a live performance, those two versions of the song are different
#: recordings. The song itself is considered a "work", of which two
#: recordings were made. However, recordings are not always associated with
#: a work in the MusicBrainz database, and Picard won't load work IDs by
#: default (you have to enable "use track relationships" in the options),
#: so recording IDs are a much more reliable way to identify a particular
#: song.
#: https://musicbrainz.org/doc/Recording
recording: 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
#: https://musicbrainz.org/doc/Artist
artist: OptionUUID
#: A MusicBrainz release roughly corresponds to an "album", and indeed is
#: stored in a tag called MUSICBRAINZ_ALBUMID. The more general name is
#: meant to encompass all the different ways music can be released.
#: https://musicbrainz.org/doc/Release
release: OptionUUID
#: Again, the release artist corresponds to an "album artist". These MBIDs
#: refer to the same artists in the MusicBrainz database that individual
#: recordings' artist MBIDs do.
release_artist: OptionUUID
#: A MusicBrainz release group roughly corresponds to "all the editions of
#: a particular album". For example, if the same album were released on CD,
#: 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=option_uuid(tags.get("musicbrainz_artistid")),
release=option_uuid(tags.get("musicbrainz_albumid")),
release_artist=option_uuid(tags.get("musicbrainz_albumartistid")),
release_group=option_uuid(tags.get("musicbrainz_releasegroupid")),
)

View file

@ -0,0 +1,81 @@
from dataclasses import dataclass
from enum import StrEnum
from pathlib import Path
from ..tools.schema.define import schema
from .artwork import Artwork
from .musicbrainz import MusicBrainzIds
class PlaybackState(StrEnum):
play = "play"
pause = "pause"
stop = "stop"
@schema("https://cdn.00dani.me/m/schemata/mpd-now-playable/song-v1.json")
@dataclass(slots=True)
class Song:
#: Whether MPD is currently playing, paused, or stopped. Pretty simple.
state: PlaybackState
#: The zero-based index of the current song in MPD's queue.
queue_index: int
#: The total length of MPD's queue - the last song in the queue will have
#: the index one less than this, since queue indices are zero-based.
queue_length: int
#: The relative path to the current song inside the music directory. MPD
#: itself uses this path as a stable identifier for the audio file in many
#: places, so you can safely do the same.
file: Path
#: The song's title, if it's been tagged with one. Currently only one title
#: is supported, since it doesn't make a lot of sense to tag a single audio
#: file with multiple titles.
title: str | None
#: The song's artists. Will be an empty list if the song has not been
#: tagged with an artist, and may contain multiple values if the song has
#: been tagged with several artists.
artist: list[str]
#: The song's composers. Again, this is permitted to be multivalued.
composer: list[str]
#: The name of the song's containing album, which may be multivalued.
album: list[str]
#: The album's artists. This is often used to group together songs from a
#: single album that featured different artists.
album_artist: list[str]
#: The track number the song has on its album. This is usually one-based,
#: but it's just an arbitrary audio tag so a particular album might start
#: at zero or do something weird with it.
track: int | None
#: The disc number of the song on its album. As with the track number, this
#: is usually one-based, but it doesn't have to be.
disc: int | None
#: The song's genre or genres. These are completely arbitrary descriptions
#: and don't follow any particular standard.
genre: list[str]
#: The song's duration as read from its tags, measured in seconds.
#: Fractional seconds are allowed.
duration: float
#: How far into the song MPD is, measured in seconds. Fractional seconds
#: are allowed. This is usually going to be less than or equal to the
#: song's duration, but because the duration is tagged as metadata and this
#: value represents the actual elapsed time, it might go higher if the
#: song's duration tag is inaccurate.
elapsed: float
#: The song's cover art, if it has any - the art will be available as bytes
#: if present, ready to be displayed directly by receivers.
art: Artwork
#: The MusicBrainz IDs associated with the song and with its artist and
#: album, which if present are an extremely accurate way to identify a
#: given song. They're not always present, though.
musicbrainz: MusicBrainzIds

View file

@ -0,0 +1,83 @@
from asyncio import AbstractEventLoop, new_event_loop
from dataclasses import dataclass
from importlib import import_module
from typing import Generic, Iterable, Literal, Protocol, TypeVar, cast
from .config.model import BaseReceiverConfig
from .player import Player
from .song import Song
from .tools.types import not_none
T = TypeVar("T", bound=AbstractEventLoop, covariant=True)
class LoopFactory(Generic[T], Protocol):
@property
def is_replaceable(self) -> bool: ...
@classmethod
def make_loop(cls) -> T: ...
class Receiver(Protocol):
def __init__(self, config: BaseReceiverConfig): ...
@classmethod
def loop_factory(cls) -> LoopFactory[AbstractEventLoop]: ...
async def start(self, player: Player) -> None: ...
async def update(self, song: Song | None) -> None: ...
class ReceiverModule(Protocol):
receiver: type[Receiver]
class DefaultLoopFactory(LoopFactory[AbstractEventLoop]):
@property
def is_replaceable(self) -> Literal[True]:
return True
@classmethod
def make_loop(cls) -> AbstractEventLoop:
return new_event_loop()
@dataclass
class IncompatibleReceiverError(Exception):
a: 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

@ -45,3 +45,9 @@ class MyGenerateJsonSchema(GenerateJsonSchema):
def nullable_schema(self, schema: s.NullableSchema) -> JsonSchemaValue:
return self.generate_inner(schema["schema"])
if __name__ == '__main__':
from ...config.model import Config
from ...song import Song
write(Config)
write(Song)

View file

@ -4,6 +4,7 @@ from typing import Any, TypeAlias, TypeVar
__all__ = (
"AnyExceptList",
"MaybePlural",
"option_fmap",
"convert_if_exists",
"un_maybe_plural",
)
@ -29,6 +30,19 @@ AnyExceptList = (
U = TypeVar("U")
V = TypeVar("V")
def not_none(value: U | None) -> U:
if value is None:
raise ValueError("None should not be possible here.")
return value
def option_fmap(f: Callable[[U], V], value: U | None) -> V | None:
if value is None:
return None
return f(value)
def convert_if_exists(value: str | None, converter: Callable[[str], U]) -> U | None: