Compare commits
3 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
491e7dea1f | ||
|
|
eb5a9d5745 | ||
|
|
b922d34db8 |
11 changed files with 618 additions and 81 deletions
39
.github/runtime/action.yml
vendored
Normal file
39
.github/runtime/action.yml
vendored
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
---
|
||||
name: Install runtime
|
||||
description: Install the required runtime and related base dependencies
|
||||
|
||||
inputs:
|
||||
python:
|
||||
description: "Install Python"
|
||||
default: "true"
|
||||
|
||||
outputs:
|
||||
python:
|
||||
description: "Resolved version"
|
||||
value: ${{ steps.get_version.outputs.python }}
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Determine version
|
||||
id: get_version
|
||||
shell: bash -eo pipefail {0}
|
||||
run: |
|
||||
python="$(grep -E '^requires-python\s*=\s*\"' pyproject.toml | sed -E 's/.*\"[^0-9]*([0-9]+\.[0-9]+).*/\1/' | head -n1)"
|
||||
if [ -z "$python" ]; then
|
||||
exit 1
|
||||
fi
|
||||
echo "python=${python}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Install Python
|
||||
if: ${{ inputs.python == 'true' }}
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ steps.get_version.outputs.python }}
|
||||
|
||||
- name: Show tooling information
|
||||
shell: bash -eo pipefail {0}
|
||||
run: |
|
||||
set -x
|
||||
|
||||
python --version
|
||||
67
.github/workflows/static-checks.yml
vendored
Normal file
67
.github/workflows/static-checks.yml
vendored
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
name: Static Checks
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: static-checks-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
lint-and-typecheck:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install runtime
|
||||
id: runtime
|
||||
uses: ./.github/runtime
|
||||
|
||||
- name: Set up PDM
|
||||
uses: pdm-project/setup-pdm@v4
|
||||
with:
|
||||
python-version: ${{ steps.runtime.outputs.python }}
|
||||
cache: true
|
||||
|
||||
- name: Install dependencies
|
||||
run: pdm sync -dG:all
|
||||
|
||||
- name: Ruff
|
||||
run: pdm run ruff check src
|
||||
|
||||
- name: Mypy
|
||||
run: pdm run mypy -p mpd_now_playable
|
||||
|
||||
tests:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install runtime
|
||||
id: runtime
|
||||
uses: ./.github/runtime
|
||||
|
||||
- name: Set up PDM
|
||||
uses: pdm-project/setup-pdm@v4
|
||||
with:
|
||||
python-version: ${{ steps.runtime.outputs.python }}
|
||||
cache: true
|
||||
|
||||
- name: Install dependencies
|
||||
run: pdm sync -dG:all
|
||||
|
||||
- name: Run tests
|
||||
run: pdm run pytest tests
|
||||
47
README.md
47
README.md
|
|
@ -1,11 +1,24 @@
|
|||
# mpd-now-playable [](https://badge.fury.io/py/mpd-now-playable)
|
||||
# mpd-now-playable
|
||||
|
||||
This little Python program turns your MPD server into a [now playable app](https://developer.apple.com/documentation/mediaplayer/becoming_a_now_playable_app) on MacOS.
|
||||
[](https://badge.fury.io/py/mpd-now-playable)
|
||||
|
||||
This little Python program turns your MPD server into a [now playable app](https://developer.apple.com/documentation/mediaplayer/becoming_a_now_playable_app) on macOS.
|
||||
This enables your keyboard's standard media keys to control MPD, as well as more esoteric music control methods like the buttons on your Bluetooth headphones.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
<!-- toc -->
|
||||
|
||||
- [Installation](#installation)
|
||||
- [Configuration](#configuration)
|
||||
- [Limitations](#limitations)
|
||||
|
||||
<!-- tocstop -->
|
||||
|
||||
## Installation
|
||||
|
||||
The recommended way to install mpd-now-playable and its dependencies is with [pipx](https://pypa.github.io/pipx/):
|
||||
|
||||
```shell
|
||||
pipx install mpd-now-playable
|
||||
# or, if you'd like to use a separate cache service, one of these:
|
||||
|
|
@ -15,7 +28,27 @@ 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.
|
||||
|
||||
Most likely, you'll want mpd-now-playable to stay running in the background as a launchd service. [Here's the service plist I use](https://git.00dani.me/00dani/mpd-now-playable/src/branch/main/me.00dani.mpd-now-playable.plist), but it's hardcoded to my `$HOME` so you'll want to customise it.
|
||||
Most likely, you'll want mpd-now-playable to stay running in the background as a `launchd` service. The CLI can install a per-user LaunchAgent for you:
|
||||
|
||||
```shell
|
||||
mpd-now-playable install-launchagent
|
||||
```
|
||||
|
||||
This writes `~/Library/LaunchAgents/me.00dani.mpd-now-playable.plist`, bootstraps it with `launchctl`, and starts it immediately.
|
||||
|
||||
To replace an existing LaunchAgent plist:
|
||||
|
||||
```shell
|
||||
mpd-now-playable install-launchagent --force
|
||||
```
|
||||
|
||||
To remove it later:
|
||||
|
||||
```shell
|
||||
mpd-now-playable uninstall-launchagent
|
||||
```
|
||||
|
||||
You may override the launchd label in both commands with `--label`.
|
||||
|
||||
## Configuration
|
||||
|
||||
|
|
@ -37,6 +70,7 @@ Additionally, mpd-now-playable caches your album artwork, by default simply in m
|
|||
You may provide a `namespace` query parameter to prefix cache keys if you wish, as well as a `password` query parameter if your service requires a password to access. As with your other environment variables, keep your cache password secure.
|
||||
|
||||
One simple secure way to set your environment variables is with a small wrapper script like this:
|
||||
|
||||
```shell
|
||||
#!/bin/sh
|
||||
export MPD_HOSTNAME=my.cool.mpd.host
|
||||
|
|
@ -45,12 +79,15 @@ export MPD_PASSWORD=swordfish
|
|||
export MPD_NOW_PLAYABLE_CACHE='redis://localhost:6379/0?namespace=mpd-now-playable&password=fishsword'
|
||||
exec mpd-now-playable
|
||||
```
|
||||
|
||||
Make sure this wrapper script is only readable by you, with something like `chmod 700`!
|
||||
|
||||
If you're using launchd, you can point `ProgramArguments` in your plist at that wrapper script instead.
|
||||
|
||||
## Limitations
|
||||
|
||||
mpd-now-playable is currently *very* specific to MacOS. I did my best to keep the generic MPD and extremely Apple parts separate, but it definitely won't work with MPRIS2 or the Windows system media feature.
|
||||
mpd-now-playable is currently _very_ specific to macOS. I did my best to keep the generic MPD and extremely Apple parts separate, but it definitely won't work with MPRIS2 or the Windows system media feature.
|
||||
|
||||
Chances are my MacOS integration code isn't the best, either. This is the first project I've written using PyObjC and it took a lot of fiddling to get working.
|
||||
Chances are my macOS integration code isn't the best, either. This is the first project I've written using PyObjC and it took a lot of fiddling to get working.
|
||||
|
||||
I'm very open to contributions to fix any of these things, if you're interested in writing them!
|
||||
|
|
|
|||
|
|
@ -1,16 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>Label</key>
|
||||
<string>me.00dani.mpd-now-playable</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/Users/dani/.local/bin/mpd-now-playable</string>
|
||||
</array>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
99
pdm.lock
generated
99
pdm.lock
generated
|
|
@ -5,7 +5,7 @@
|
|||
groups = ["default", "all", "dev", "memcached", "redis", "websockets"]
|
||||
strategy = []
|
||||
lock_version = "4.5.0"
|
||||
content_hash = "sha256:b84a0925a81adb7c4ca5a1a947ccb0db6950a18955bd92f08a605ff06cd0c26c"
|
||||
content_hash = "sha256:551970d3b5c9675e8edd582bf96f4b64476b4a2bb852a124439621a8df2075a4"
|
||||
|
||||
[[metadata.targets]]
|
||||
requires_python = ">=3.12"
|
||||
|
|
@ -96,6 +96,16 @@ files = [
|
|||
{file = "class_doc-0.2.6-py3-none-any.whl", hash = "sha256:e6f2cea2dfbe93f76dee25de13d70dc0d2269698e8b849f751d98dc894c52ea5"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
|
||||
summary = "Cross-platform colored terminal text."
|
||||
files = [
|
||||
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
|
||||
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.7"
|
||||
|
|
@ -106,6 +116,16 @@ files = [
|
|||
{file = "idna-3.7.tar.gz", hash = "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.0"
|
||||
requires_python = ">=3.10"
|
||||
summary = "brain-dead simple config-ini parsing"
|
||||
files = [
|
||||
{file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"},
|
||||
{file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markdown-it-py"
|
||||
version = "3.0.0"
|
||||
|
|
@ -220,6 +240,26 @@ files = [
|
|||
{file = "ormsgpack-1.7.0.tar.gz", hash = "sha256:6b4c98839cb7fc2a212037d2258f3a22857155249eb293d45c45cb974cfba834"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "26.0"
|
||||
requires_python = ">=3.8"
|
||||
summary = "Core utilities for Python packages"
|
||||
files = [
|
||||
{file = "packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529"},
|
||||
{file = "packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
requires_python = ">=3.9"
|
||||
summary = "plugin and hook calling mechanisms for python"
|
||||
files = [
|
||||
{file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"},
|
||||
{file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "propcache"
|
||||
version = "0.2.1"
|
||||
|
|
@ -436,6 +476,25 @@ files = [
|
|||
{file = "pyobjc_framework_quartz-11.0.tar.gz", hash = "sha256:3205bf7795fb9ae34747f701486b3db6dfac71924894d1f372977c4d70c3c619"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.0.2"
|
||||
requires_python = ">=3.10"
|
||||
summary = "pytest: simple powerful testing with Python"
|
||||
dependencies = [
|
||||
"colorama>=0.4; sys_platform == \"win32\"",
|
||||
"exceptiongroup>=1; python_version < \"3.11\"",
|
||||
"iniconfig>=1.0.1",
|
||||
"packaging>=22",
|
||||
"pluggy<2,>=1.5",
|
||||
"pygments>=2.7.2",
|
||||
"tomli>=1; python_version < \"3.11\"",
|
||||
]
|
||||
files = [
|
||||
{file = "pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b"},
|
||||
{file = "pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-mpd2"
|
||||
version = "3.1.1"
|
||||
|
|
@ -486,28 +545,28 @@ files = [
|
|||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.9.2"
|
||||
version = "0.15.4"
|
||||
requires_python = ">=3.7"
|
||||
summary = "An extremely fast Python linter and code formatter, written in Rust."
|
||||
files = [
|
||||
{file = "ruff-0.9.2-py3-none-linux_armv6l.whl", hash = "sha256:80605a039ba1454d002b32139e4970becf84b5fee3a3c3bf1c2af6f61a784347"},
|
||||
{file = "ruff-0.9.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b9aab82bb20afd5f596527045c01e6ae25a718ff1784cb92947bff1f83068b00"},
|
||||
{file = "ruff-0.9.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fbd337bac1cfa96be615f6efcd4bc4d077edbc127ef30e2b8ba2a27e18c054d4"},
|
||||
{file = "ruff-0.9.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82b35259b0cbf8daa22a498018e300b9bb0174c2bbb7bcba593935158a78054d"},
|
||||
{file = "ruff-0.9.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8b6a9701d1e371bf41dca22015c3f89769da7576884d2add7317ec1ec8cb9c3c"},
|
||||
{file = "ruff-0.9.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9cc53e68b3c5ae41e8faf83a3b89f4a5d7b2cb666dff4b366bb86ed2a85b481f"},
|
||||
{file = "ruff-0.9.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:8efd9da7a1ee314b910da155ca7e8953094a7c10d0c0a39bfde3fcfd2a015684"},
|
||||
{file = "ruff-0.9.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3292c5a22ea9a5f9a185e2d131dc7f98f8534a32fb6d2ee7b9944569239c648d"},
|
||||
{file = "ruff-0.9.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1a605fdcf6e8b2d39f9436d343d1f0ff70c365a1e681546de0104bef81ce88df"},
|
||||
{file = "ruff-0.9.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c547f7f256aa366834829a08375c297fa63386cbe5f1459efaf174086b564247"},
|
||||
{file = "ruff-0.9.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:d18bba3d3353ed916e882521bc3e0af403949dbada344c20c16ea78f47af965e"},
|
||||
{file = "ruff-0.9.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b338edc4610142355ccf6b87bd356729b62bf1bc152a2fad5b0c7dc04af77bfe"},
|
||||
{file = "ruff-0.9.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:492a5e44ad9b22a0ea98cf72e40305cbdaf27fac0d927f8bc9e1df316dcc96eb"},
|
||||
{file = "ruff-0.9.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:af1e9e9fe7b1f767264d26b1075ac4ad831c7db976911fa362d09b2d0356426a"},
|
||||
{file = "ruff-0.9.2-py3-none-win32.whl", hash = "sha256:71cbe22e178c5da20e1514e1e01029c73dc09288a8028a5d3446e6bba87a5145"},
|
||||
{file = "ruff-0.9.2-py3-none-win_amd64.whl", hash = "sha256:c5e1d6abc798419cf46eed03f54f2e0c3adb1ad4b801119dedf23fcaf69b55b5"},
|
||||
{file = "ruff-0.9.2-py3-none-win_arm64.whl", hash = "sha256:a1b63fa24149918f8b37cef2ee6fff81f24f0d74b6f0bdc37bc3e1f2143e41c6"},
|
||||
{file = "ruff-0.9.2.tar.gz", hash = "sha256:b5eceb334d55fae5f316f783437392642ae18e16dcf4f1858d55d3c2a0f8f5d0"},
|
||||
{file = "ruff-0.15.4-py3-none-linux_armv6l.whl", hash = "sha256:a1810931c41606c686bae8b5b9a8072adac2f611bb433c0ba476acba17a332e0"},
|
||||
{file = "ruff-0.15.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5a1632c66672b8b4d3e1d1782859e98d6e0b4e70829530666644286600a33992"},
|
||||
{file = "ruff-0.15.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a4386ba2cd6c0f4ff75252845906acc7c7c8e1ac567b7bc3d373686ac8c222ba"},
|
||||
{file = "ruff-0.15.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2496488bdfd3732747558b6f95ae427ff066d1fcd054daf75f5a50674411e75"},
|
||||
{file = "ruff-0.15.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3f1c4893841ff2d54cbda1b2860fa3260173df5ddd7b95d370186f8a5e66a4ac"},
|
||||
{file = "ruff-0.15.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:820b8766bd65503b6c30aaa6331e8ef3a6e564f7999c844e9a547c40179e440a"},
|
||||
{file = "ruff-0.15.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c9fb74bab47139c1751f900f857fa503987253c3ef89129b24ed375e72873e85"},
|
||||
{file = "ruff-0.15.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f80c98765949c518142b3a50a5db89343aa90f2c2bf7799de9986498ae6176db"},
|
||||
{file = "ruff-0.15.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:451a2e224151729b3b6c9ffb36aed9091b2996fe4bdbd11f47e27d8f2e8888ec"},
|
||||
{file = "ruff-0.15.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a8f157f2e583c513c4f5f896163a93198297371f34c04220daf40d133fdd4f7f"},
|
||||
{file = "ruff-0.15.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:917cc68503357021f541e69b35361c99387cdbbf99bd0ea4aa6f28ca99ff5338"},
|
||||
{file = "ruff-0.15.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e9737c8161da79fd7cfec19f1e35620375bd8b2a50c3e77fa3d2c16f574105cc"},
|
||||
{file = "ruff-0.15.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:291258c917539e18f6ba40482fe31d6f5ac023994ee11d7bdafd716f2aab8a68"},
|
||||
{file = "ruff-0.15.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3f83c45911da6f2cd5936c436cf86b9f09f09165f033a99dcf7477e34041cbc3"},
|
||||
{file = "ruff-0.15.4-py3-none-win32.whl", hash = "sha256:65594a2d557d4ee9f02834fcdf0a28daa8b3b9f6cb2cb93846025a36db47ef22"},
|
||||
{file = "ruff-0.15.4-py3-none-win_amd64.whl", hash = "sha256:04196ad44f0df220c2ece5b0e959c2f37c777375ec744397d21d15b50a75264f"},
|
||||
{file = "ruff-0.15.4-py3-none-win_arm64.whl", hash = "sha256:60d5177e8cfc70e51b9c5fad936c634872a74209f934c1e79107d11787ad5453"},
|
||||
{file = "ruff-0.15.4.tar.gz", hash = "sha256:3412195319e42d634470cc97aa9803d07e9d5c9223b99bcb1518f0c725f26ae1"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
[project]
|
||||
name = "mpd-now-playable"
|
||||
dynamic = ["version"]
|
||||
description = "Expose your MPD server as a 'now playable' app on MacOS"
|
||||
description = "Expose your MPD server as a 'now playable' app on macOS"
|
||||
authors = [
|
||||
{name = "Danielle McLean", email = "dani@00dani.me"},
|
||||
]
|
||||
|
|
@ -52,6 +52,14 @@ mpd-now-playable = 'mpd_now_playable.cli:main'
|
|||
requires = ["pdm-backend"]
|
||||
build-backend = "pdm.backend"
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest>=8.0",
|
||||
"mypy>=1.11",
|
||||
"ruff>=0.15",
|
||||
"class-doc>=0.2.6",
|
||||
]
|
||||
|
||||
[tool.mypy]
|
||||
mypy_path = 'stubs'
|
||||
plugins = ['pydantic.mypy', 'mpd_now_playable.tools.schema.plugin']
|
||||
|
|
@ -77,8 +85,13 @@ select = [
|
|||
"C90",
|
||||
]
|
||||
ignore = [
|
||||
"ANN101", # missing-type-self
|
||||
"ANN102", # missing-type-cls
|
||||
]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"src/corefoundationasyncio/eventloop.py" = [
|
||||
"ANN",
|
||||
"I001",
|
||||
"S101",
|
||||
]
|
||||
|
||||
[tool.ruff.lint.flake8-annotations]
|
||||
|
|
@ -101,12 +114,3 @@ 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",
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
import argparse
|
||||
import asyncio
|
||||
import sys
|
||||
from collections.abc import Iterable
|
||||
|
||||
from rich import print
|
||||
from rich import print as rich_print
|
||||
|
||||
from .__version__ import __version__
|
||||
from .config.load import loadConfig
|
||||
from .config.model import Config
|
||||
from .launchd import DEFAULT_LABEL, install_launchagent, uninstall_launchagent
|
||||
from .mpd.listener import MpdStateListener
|
||||
from .song_receiver import (
|
||||
Receiver,
|
||||
|
|
@ -24,25 +26,68 @@ async def listen(
|
|||
|
||||
|
||||
def print_help() -> None:
|
||||
print("Usage: mpd-now-playable [OPTIONS]")
|
||||
print("")
|
||||
print("Options:")
|
||||
print(" -h, --help Show this help message and exit.")
|
||||
print(" -v, --version Show version and exit.")
|
||||
parser = build_parser()
|
||||
parser.print_help()
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(prog="mpd-now-playable")
|
||||
parser.add_argument(
|
||||
"-v",
|
||||
"--version",
|
||||
action="store_true",
|
||||
help="Show version and exit.",
|
||||
)
|
||||
|
||||
subparsers = parser.add_subparsers(dest="command")
|
||||
install_cmd = subparsers.add_parser(
|
||||
"install-launchagent",
|
||||
help="Install and start a per-user launchd service.",
|
||||
)
|
||||
install_cmd.add_argument(
|
||||
"--label",
|
||||
default=DEFAULT_LABEL,
|
||||
help=f"launchd label to install (default: {DEFAULT_LABEL}).",
|
||||
)
|
||||
install_cmd.add_argument(
|
||||
"--force",
|
||||
action="store_true",
|
||||
help="Replace an existing plist with the same label.",
|
||||
)
|
||||
|
||||
uninstall_cmd = subparsers.add_parser(
|
||||
"uninstall-launchagent",
|
||||
help="Unload and remove a per-user launchd service.",
|
||||
)
|
||||
uninstall_cmd.add_argument(
|
||||
"--label",
|
||||
default=DEFAULT_LABEL,
|
||||
help=f"launchd label to uninstall (default: {DEFAULT_LABEL}).",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = set(sys.argv[1:])
|
||||
if "-h" in args or "--help" in args:
|
||||
print_help()
|
||||
return
|
||||
if "-v" in args or "--version" in args:
|
||||
print(f"mpd-now-playable v{__version__}")
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(sys.argv[1:])
|
||||
|
||||
if args.version:
|
||||
rich_print(f"mpd-now-playable v{__version__}")
|
||||
return
|
||||
|
||||
print(f"mpd-now-playable v{__version__}")
|
||||
if args.command == "install-launchagent":
|
||||
path = install_launchagent(label=args.label, force=args.force)
|
||||
rich_print(f"Installed LaunchAgent at {path}")
|
||||
return
|
||||
|
||||
if args.command == "uninstall-launchagent":
|
||||
path = uninstall_launchagent(label=args.label)
|
||||
rich_print(f"Uninstalled LaunchAgent at {path}")
|
||||
return
|
||||
|
||||
rich_print(f"mpd-now-playable v{__version__}")
|
||||
config = loadConfig()
|
||||
print(config)
|
||||
rich_print(config)
|
||||
|
||||
listener = MpdStateListener(config.cache)
|
||||
receivers = tuple(construct_receiver(rec_config) for rec_config in config.receivers)
|
||||
|
|
|
|||
85
src/mpd_now_playable/launchd.py
Normal file
85
src/mpd_now_playable/launchd.py
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import os
|
||||
import plistlib
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
DEFAULT_LABEL = "me.00dani.mpd-now-playable"
|
||||
|
||||
|
||||
def _require_macos() -> None:
|
||||
if sys.platform != "darwin":
|
||||
msg = "launchd commands are only supported on macOS"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
|
||||
def _launch_agents_dir() -> Path:
|
||||
return Path.home() / "Library" / "LaunchAgents"
|
||||
|
||||
|
||||
def plist_path_for_label(label: str) -> Path:
|
||||
return _launch_agents_dir() / f"{label}.plist"
|
||||
|
||||
|
||||
def _service_target(label: str) -> str:
|
||||
return f"gui/{os.getuid()}/{label}"
|
||||
|
||||
|
||||
def _launchctl_binary() -> str:
|
||||
# Prefer absolute paths to avoid relying on PATH for privileged process control.
|
||||
for candidate in ("/bin/launchctl", "/usr/bin/launchctl"):
|
||||
if Path(candidate).exists():
|
||||
return candidate
|
||||
return "launchctl"
|
||||
|
||||
|
||||
def _run_launchctl(*args: str, check: bool = True) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run( # noqa: S603 - arguments are fixed command tokens, shell is not used.
|
||||
[_launchctl_binary(), *args],
|
||||
check=check,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
|
||||
def _plist_bytes(label: str) -> bytes:
|
||||
payload = {
|
||||
"Label": label,
|
||||
"ProgramArguments": [sys.executable, "-m", "mpd_now_playable.cli"],
|
||||
"RunAtLoad": True,
|
||||
"KeepAlive": True,
|
||||
}
|
||||
return plistlib.dumps(payload, fmt=plistlib.FMT_XML)
|
||||
|
||||
|
||||
def install_launchagent(label: str = DEFAULT_LABEL, force: bool = False) -> Path:
|
||||
_require_macos()
|
||||
plist_path = plist_path_for_label(label)
|
||||
plist_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if plist_path.exists() and not force:
|
||||
msg = f"{plist_path} already exists; rerun with --force to replace it"
|
||||
raise FileExistsError(msg)
|
||||
|
||||
plist_path.write_bytes(_plist_bytes(label))
|
||||
target = _service_target(label)
|
||||
|
||||
# Ignore errors if the service does not already exist.
|
||||
_run_launchctl("bootout", target, check=False)
|
||||
_run_launchctl("bootstrap", f"gui/{os.getuid()}", str(plist_path))
|
||||
_run_launchctl("kickstart", "-k", target)
|
||||
|
||||
return plist_path
|
||||
|
||||
|
||||
def uninstall_launchagent(label: str = DEFAULT_LABEL) -> Path:
|
||||
_require_macos()
|
||||
plist_path = plist_path_for_label(label)
|
||||
target = _service_target(label)
|
||||
|
||||
# Ignore errors if the service is not loaded.
|
||||
_run_launchctl("bootout", target, check=False)
|
||||
if plist_path.exists():
|
||||
plist_path.unlink()
|
||||
|
||||
return plist_path
|
||||
|
|
@ -1,18 +1,18 @@
|
|||
from enum import Enum
|
||||
from enum import IntEnum
|
||||
|
||||
class FailureResponseCode(Enum):
|
||||
NOT_LIST: int
|
||||
ARG: int
|
||||
PASSWORD: int
|
||||
PERMISSION: int
|
||||
UNKNOWN: int
|
||||
NO_EXIST: int
|
||||
PLAYLIST_MAX: int
|
||||
SYSTEM: int
|
||||
PLAYLIST_LOAD: int
|
||||
UPDATE_ALREADY: int
|
||||
PLAYER_SYNC: int
|
||||
EXIST: int
|
||||
class FailureResponseCode(IntEnum):
|
||||
NOT_LIST = ...
|
||||
ARG = ...
|
||||
PASSWORD = ...
|
||||
PERMISSION = ...
|
||||
UNKNOWN = ...
|
||||
NO_EXIST = ...
|
||||
PLAYLIST_MAX = ...
|
||||
SYSTEM = ...
|
||||
PLAYLIST_LOAD = ...
|
||||
UPDATE_ALREADY = ...
|
||||
PLAYER_SYNC = ...
|
||||
EXIST = ...
|
||||
|
||||
class MPDError(Exception): ...
|
||||
|
||||
|
|
|
|||
140
tests/test_cli.py
Normal file
140
tests/test_cli.py
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
import mpd_now_playable.cli as cli
|
||||
|
||||
|
||||
def test_main_help_prints_usage(monkeypatch, capsys) -> None:
|
||||
monkeypatch.setattr(cli.sys, "argv", ["mpd-now-playable", "--help"])
|
||||
|
||||
def fail_load() -> None:
|
||||
raise AssertionError("loadConfig should not run for --help")
|
||||
|
||||
monkeypatch.setattr(cli, "loadConfig", fail_load)
|
||||
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
cli.main()
|
||||
assert exc.value.code == 0
|
||||
output = capsys.readouterr().out
|
||||
|
||||
assert "usage: mpd-now-playable" in output
|
||||
assert "--version" in output
|
||||
assert "install-launchagent" in output
|
||||
assert "uninstall-launchagent" in output
|
||||
|
||||
|
||||
def test_main_version_prints_version(monkeypatch, capsys) -> None:
|
||||
monkeypatch.setattr(cli.sys, "argv", ["mpd-now-playable", "--version"])
|
||||
|
||||
def fail_load() -> None:
|
||||
raise AssertionError("loadConfig should not run for --version")
|
||||
|
||||
monkeypatch.setattr(cli, "loadConfig", fail_load)
|
||||
|
||||
cli.main()
|
||||
output = capsys.readouterr().out
|
||||
|
||||
assert output.strip() == f"mpd-now-playable v{cli.__version__}"
|
||||
|
||||
|
||||
def test_main_starts_listener_and_receivers(monkeypatch) -> None:
|
||||
receiver_cfg_1 = SimpleNamespace(kind="websockets")
|
||||
receiver_cfg_2 = SimpleNamespace(kind="websockets")
|
||||
config = SimpleNamespace(
|
||||
cache="memory://",
|
||||
mpd=SimpleNamespace(host="127.0.0.1", port=6600),
|
||||
receivers=(receiver_cfg_1, receiver_cfg_2),
|
||||
)
|
||||
|
||||
listener = object()
|
||||
receiver_1 = object()
|
||||
receiver_2 = object()
|
||||
fake_coroutine = object()
|
||||
|
||||
seen = {}
|
||||
|
||||
def fake_load_config() -> SimpleNamespace:
|
||||
return config
|
||||
|
||||
def fake_listener_ctor(cache: str) -> object:
|
||||
seen["cache"] = cache
|
||||
return listener
|
||||
|
||||
def fake_construct_receiver(rcfg: SimpleNamespace) -> object:
|
||||
if rcfg is receiver_cfg_1:
|
||||
return receiver_1
|
||||
if rcfg is receiver_cfg_2:
|
||||
return receiver_2
|
||||
raise AssertionError(f"Unexpected receiver config: {rcfg!r}")
|
||||
|
||||
class FakeLoopFactory:
|
||||
@staticmethod
|
||||
def make_loop() -> object:
|
||||
return object()
|
||||
|
||||
def fake_choose_loop_factory(receivers: tuple[object, ...]) -> type[FakeLoopFactory]:
|
||||
seen["receivers"] = receivers
|
||||
return FakeLoopFactory
|
||||
|
||||
def fake_listen(cfg: SimpleNamespace, lst: object, receivers: tuple[object, ...]) -> object:
|
||||
seen["listen_args"] = (cfg, lst, receivers)
|
||||
return fake_coroutine
|
||||
|
||||
def fake_run(coro: object, *, loop_factory: object, debug: bool) -> None:
|
||||
seen["run"] = (coro, loop_factory, debug)
|
||||
|
||||
monkeypatch.setattr(cli.sys, "argv", ["mpd-now-playable"])
|
||||
monkeypatch.setattr(cli, "loadConfig", fake_load_config)
|
||||
monkeypatch.setattr(cli, "MpdStateListener", fake_listener_ctor)
|
||||
monkeypatch.setattr(cli, "construct_receiver", fake_construct_receiver)
|
||||
monkeypatch.setattr(cli, "choose_loop_factory", fake_choose_loop_factory)
|
||||
monkeypatch.setattr(cli, "listen", fake_listen)
|
||||
monkeypatch.setattr(cli.asyncio, "run", fake_run)
|
||||
|
||||
cli.main()
|
||||
|
||||
assert seen["cache"] == "memory://"
|
||||
assert seen["receivers"] == (receiver_1, receiver_2)
|
||||
assert seen["listen_args"] == (config, listener, (receiver_1, receiver_2))
|
||||
assert seen["run"] == (fake_coroutine, FakeLoopFactory.make_loop, False)
|
||||
|
||||
|
||||
def test_main_install_launchagent(monkeypatch, capsys) -> None:
|
||||
seen = {}
|
||||
|
||||
def fake_install(*, label: str, force: bool) -> str:
|
||||
seen["args"] = (label, force)
|
||||
return "/tmp/example.plist"
|
||||
|
||||
monkeypatch.setattr(
|
||||
cli.sys,
|
||||
"argv",
|
||||
["mpd-now-playable", "install-launchagent", "--label", "com.example.test", "--force"],
|
||||
)
|
||||
monkeypatch.setattr(cli, "install_launchagent", fake_install)
|
||||
|
||||
cli.main()
|
||||
output = capsys.readouterr().out
|
||||
assert seen["args"] == ("com.example.test", True)
|
||||
assert "Installed LaunchAgent at /tmp/example.plist" in output
|
||||
|
||||
|
||||
def test_main_uninstall_launchagent(monkeypatch, capsys) -> None:
|
||||
seen = {}
|
||||
|
||||
def fake_uninstall(*, label: str) -> str:
|
||||
seen["label"] = label
|
||||
return "/tmp/example.plist"
|
||||
|
||||
monkeypatch.setattr(
|
||||
cli.sys,
|
||||
"argv",
|
||||
["mpd-now-playable", "uninstall-launchagent", "--label", "com.example.test"],
|
||||
)
|
||||
monkeypatch.setattr(cli, "uninstall_launchagent", fake_uninstall)
|
||||
|
||||
cli.main()
|
||||
output = capsys.readouterr().out
|
||||
assert seen["label"] == "com.example.test"
|
||||
assert "Uninstalled LaunchAgent at /tmp/example.plist" in output
|
||||
77
tests/test_launchd.py
Normal file
77
tests/test_launchd.py
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import plistlib
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import mpd_now_playable.launchd as launchd
|
||||
|
||||
|
||||
def test_plist_bytes_uses_absolute_python(monkeypatch, tmp_path) -> None:
|
||||
monkeypatch.setattr(launchd.sys, "executable", "/abs/python")
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
|
||||
payload = plistlib.loads(launchd._plist_bytes("com.example.test"))
|
||||
|
||||
assert payload["Label"] == "com.example.test"
|
||||
assert payload["ProgramArguments"] == ["/abs/python", "-m", "mpd_now_playable.cli"]
|
||||
assert payload["RunAtLoad"] is True
|
||||
assert payload["KeepAlive"] is True
|
||||
|
||||
|
||||
def test_install_launchagent_writes_plist_and_bootstraps(monkeypatch, tmp_path) -> None:
|
||||
monkeypatch.setattr(launchd.sys, "platform", "darwin")
|
||||
monkeypatch.setattr(launchd.sys, "executable", "/abs/python")
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
monkeypatch.setattr(launchd.os, "getuid", lambda: 501)
|
||||
|
||||
seen: list[tuple[tuple[str, ...], bool]] = []
|
||||
|
||||
def fake_run_launchctl(*args: str, check: bool = True) -> object:
|
||||
seen.append((args, check))
|
||||
return object()
|
||||
|
||||
monkeypatch.setattr(launchd, "_run_launchctl", fake_run_launchctl)
|
||||
|
||||
plist_path = launchd.install_launchagent(label="com.example.test", force=False)
|
||||
assert plist_path == tmp_path / "Library" / "LaunchAgents" / "com.example.test.plist"
|
||||
assert plist_path.exists()
|
||||
assert seen == [
|
||||
(("bootout", "gui/501/com.example.test"), False),
|
||||
(("bootstrap", "gui/501", str(plist_path)), True),
|
||||
(("kickstart", "-k", "gui/501/com.example.test"), True),
|
||||
]
|
||||
|
||||
|
||||
def test_install_launchagent_refuses_existing_without_force(monkeypatch, tmp_path) -> None:
|
||||
monkeypatch.setattr(launchd.sys, "platform", "darwin")
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
|
||||
plist = tmp_path / "Library" / "LaunchAgents" / "com.example.test.plist"
|
||||
plist.parent.mkdir(parents=True, exist_ok=True)
|
||||
plist.write_text("already here")
|
||||
|
||||
with pytest.raises(FileExistsError):
|
||||
launchd.install_launchagent(label="com.example.test")
|
||||
|
||||
|
||||
def test_uninstall_launchagent_boots_out_and_removes_file(monkeypatch, tmp_path) -> None:
|
||||
monkeypatch.setattr(launchd.sys, "platform", "darwin")
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
monkeypatch.setattr(launchd.os, "getuid", lambda: 501)
|
||||
|
||||
seen: list[tuple[tuple[str, ...], bool]] = []
|
||||
|
||||
def fake_run_launchctl(*args: str, check: bool = True) -> object:
|
||||
seen.append((args, check))
|
||||
return object()
|
||||
|
||||
monkeypatch.setattr(launchd, "_run_launchctl", fake_run_launchctl)
|
||||
|
||||
plist = tmp_path / "Library" / "LaunchAgents" / "com.example.test.plist"
|
||||
plist.parent.mkdir(parents=True, exist_ok=True)
|
||||
plist.write_text("x")
|
||||
|
||||
removed = launchd.uninstall_launchagent(label="com.example.test")
|
||||
assert removed == plist
|
||||
assert not plist.exists()
|
||||
assert seen == [(("bootout", "gui/501/com.example.test"), False)]
|
||||
Loading…
Add table
Add a link
Reference in a new issue