Compare commits

..

No commits in common. "main" and "v1.6.0" have entirely different histories.
main ... v1.6.0

11 changed files with 81 additions and 618 deletions

View file

@ -1,39 +0,0 @@
---
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

View file

@ -1,67 +0,0 @@
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

View file

@ -1,24 +1,11 @@
# mpd-now-playable # mpd-now-playable [![PyPI version](https://badge.fury.io/py/mpd-now-playable.svg)](https://badge.fury.io/py/mpd-now-playable)
[![PyPI version](https://badge.fury.io/py/mpd-now-playable.svg)](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 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. 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 ## Installation
The recommended way to install mpd-now-playable and its dependencies is with [pipx](https://pypa.github.io/pipx/): The recommended way to install mpd-now-playable and its dependencies is with [pipx](https://pypa.github.io/pipx/):
```shell ```shell
pipx install mpd-now-playable pipx install mpd-now-playable
# or, if you'd like to use a separate cache service, one of these: # or, if you'd like to use a separate cache service, one of these:
@ -28,27 +15,7 @@ pipx install mpd-now-playable[memcached]
Once pipx is done, the `mpd-now-playable` script should be available on your `$PATH` and ready to use. Once pipx is done, the `mpd-now-playable` script should be available on your `$PATH` and ready to use.
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: 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.
```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 ## Configuration
@ -70,7 +37,6 @@ 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. 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: One simple secure way to set your environment variables is with a small wrapper script like this:
```shell ```shell
#!/bin/sh #!/bin/sh
export MPD_HOSTNAME=my.cool.mpd.host export MPD_HOSTNAME=my.cool.mpd.host
@ -79,15 +45,12 @@ export MPD_PASSWORD=swordfish
export MPD_NOW_PLAYABLE_CACHE='redis://localhost:6379/0?namespace=mpd-now-playable&password=fishsword' export MPD_NOW_PLAYABLE_CACHE='redis://localhost:6379/0?namespace=mpd-now-playable&password=fishsword'
exec mpd-now-playable exec mpd-now-playable
``` ```
Make sure this wrapper script is only readable by you, with something like `chmod 700`! 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 ## 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! I'm very open to contributions to fix any of these things, if you're interested in writing them!

View file

@ -0,0 +1,16 @@
<?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
View file

@ -5,7 +5,7 @@
groups = ["default", "all", "dev", "memcached", "redis", "websockets"] groups = ["default", "all", "dev", "memcached", "redis", "websockets"]
strategy = [] strategy = []
lock_version = "4.5.0" lock_version = "4.5.0"
content_hash = "sha256:551970d3b5c9675e8edd582bf96f4b64476b4a2bb852a124439621a8df2075a4" content_hash = "sha256:b84a0925a81adb7c4ca5a1a947ccb0db6950a18955bd92f08a605ff06cd0c26c"
[[metadata.targets]] [[metadata.targets]]
requires_python = ">=3.12" requires_python = ">=3.12"
@ -96,16 +96,6 @@ files = [
{file = "class_doc-0.2.6-py3-none-any.whl", hash = "sha256:e6f2cea2dfbe93f76dee25de13d70dc0d2269698e8b849f751d98dc894c52ea5"}, {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]] [[package]]
name = "idna" name = "idna"
version = "3.7" version = "3.7"
@ -116,16 +106,6 @@ files = [
{file = "idna-3.7.tar.gz", hash = "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc"}, {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]] [[package]]
name = "markdown-it-py" name = "markdown-it-py"
version = "3.0.0" version = "3.0.0"
@ -240,26 +220,6 @@ files = [
{file = "ormsgpack-1.7.0.tar.gz", hash = "sha256:6b4c98839cb7fc2a212037d2258f3a22857155249eb293d45c45cb974cfba834"}, {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]] [[package]]
name = "propcache" name = "propcache"
version = "0.2.1" version = "0.2.1"
@ -476,25 +436,6 @@ files = [
{file = "pyobjc_framework_quartz-11.0.tar.gz", hash = "sha256:3205bf7795fb9ae34747f701486b3db6dfac71924894d1f372977c4d70c3c619"}, {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]] [[package]]
name = "python-mpd2" name = "python-mpd2"
version = "3.1.1" version = "3.1.1"
@ -545,28 +486,28 @@ files = [
[[package]] [[package]]
name = "ruff" name = "ruff"
version = "0.15.4" version = "0.9.2"
requires_python = ">=3.7" requires_python = ">=3.7"
summary = "An extremely fast Python linter and code formatter, written in Rust." summary = "An extremely fast Python linter and code formatter, written in Rust."
files = [ files = [
{file = "ruff-0.15.4-py3-none-linux_armv6l.whl", hash = "sha256:a1810931c41606c686bae8b5b9a8072adac2f611bb433c0ba476acba17a332e0"}, {file = "ruff-0.9.2-py3-none-linux_armv6l.whl", hash = "sha256:80605a039ba1454d002b32139e4970becf84b5fee3a3c3bf1c2af6f61a784347"},
{file = "ruff-0.15.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5a1632c66672b8b4d3e1d1782859e98d6e0b4e70829530666644286600a33992"}, {file = "ruff-0.9.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b9aab82bb20afd5f596527045c01e6ae25a718ff1784cb92947bff1f83068b00"},
{file = "ruff-0.15.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a4386ba2cd6c0f4ff75252845906acc7c7c8e1ac567b7bc3d373686ac8c222ba"}, {file = "ruff-0.9.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fbd337bac1cfa96be615f6efcd4bc4d077edbc127ef30e2b8ba2a27e18c054d4"},
{file = "ruff-0.15.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2496488bdfd3732747558b6f95ae427ff066d1fcd054daf75f5a50674411e75"}, {file = "ruff-0.9.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82b35259b0cbf8daa22a498018e300b9bb0174c2bbb7bcba593935158a78054d"},
{file = "ruff-0.15.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3f1c4893841ff2d54cbda1b2860fa3260173df5ddd7b95d370186f8a5e66a4ac"}, {file = "ruff-0.9.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8b6a9701d1e371bf41dca22015c3f89769da7576884d2add7317ec1ec8cb9c3c"},
{file = "ruff-0.15.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:820b8766bd65503b6c30aaa6331e8ef3a6e564f7999c844e9a547c40179e440a"}, {file = "ruff-0.9.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9cc53e68b3c5ae41e8faf83a3b89f4a5d7b2cb666dff4b366bb86ed2a85b481f"},
{file = "ruff-0.15.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c9fb74bab47139c1751f900f857fa503987253c3ef89129b24ed375e72873e85"}, {file = "ruff-0.9.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:8efd9da7a1ee314b910da155ca7e8953094a7c10d0c0a39bfde3fcfd2a015684"},
{file = "ruff-0.15.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f80c98765949c518142b3a50a5db89343aa90f2c2bf7799de9986498ae6176db"}, {file = "ruff-0.9.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3292c5a22ea9a5f9a185e2d131dc7f98f8534a32fb6d2ee7b9944569239c648d"},
{file = "ruff-0.15.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:451a2e224151729b3b6c9ffb36aed9091b2996fe4bdbd11f47e27d8f2e8888ec"}, {file = "ruff-0.9.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1a605fdcf6e8b2d39f9436d343d1f0ff70c365a1e681546de0104bef81ce88df"},
{file = "ruff-0.15.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a8f157f2e583c513c4f5f896163a93198297371f34c04220daf40d133fdd4f7f"}, {file = "ruff-0.9.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c547f7f256aa366834829a08375c297fa63386cbe5f1459efaf174086b564247"},
{file = "ruff-0.15.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:917cc68503357021f541e69b35361c99387cdbbf99bd0ea4aa6f28ca99ff5338"}, {file = "ruff-0.9.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:d18bba3d3353ed916e882521bc3e0af403949dbada344c20c16ea78f47af965e"},
{file = "ruff-0.15.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e9737c8161da79fd7cfec19f1e35620375bd8b2a50c3e77fa3d2c16f574105cc"}, {file = "ruff-0.9.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b338edc4610142355ccf6b87bd356729b62bf1bc152a2fad5b0c7dc04af77bfe"},
{file = "ruff-0.15.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:291258c917539e18f6ba40482fe31d6f5ac023994ee11d7bdafd716f2aab8a68"}, {file = "ruff-0.9.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:492a5e44ad9b22a0ea98cf72e40305cbdaf27fac0d927f8bc9e1df316dcc96eb"},
{file = "ruff-0.15.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3f83c45911da6f2cd5936c436cf86b9f09f09165f033a99dcf7477e34041cbc3"}, {file = "ruff-0.9.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:af1e9e9fe7b1f767264d26b1075ac4ad831c7db976911fa362d09b2d0356426a"},
{file = "ruff-0.15.4-py3-none-win32.whl", hash = "sha256:65594a2d557d4ee9f02834fcdf0a28daa8b3b9f6cb2cb93846025a36db47ef22"}, {file = "ruff-0.9.2-py3-none-win32.whl", hash = "sha256:71cbe22e178c5da20e1514e1e01029c73dc09288a8028a5d3446e6bba87a5145"},
{file = "ruff-0.15.4-py3-none-win_amd64.whl", hash = "sha256:04196ad44f0df220c2ece5b0e959c2f37c777375ec744397d21d15b50a75264f"}, {file = "ruff-0.9.2-py3-none-win_amd64.whl", hash = "sha256:c5e1d6abc798419cf46eed03f54f2e0c3adb1ad4b801119dedf23fcaf69b55b5"},
{file = "ruff-0.15.4-py3-none-win_arm64.whl", hash = "sha256:60d5177e8cfc70e51b9c5fad936c634872a74209f934c1e79107d11787ad5453"}, {file = "ruff-0.9.2-py3-none-win_arm64.whl", hash = "sha256:a1b63fa24149918f8b37cef2ee6fff81f24f0d74b6f0bdc37bc3e1f2143e41c6"},
{file = "ruff-0.15.4.tar.gz", hash = "sha256:3412195319e42d634470cc97aa9803d07e9d5c9223b99bcb1518f0c725f26ae1"}, {file = "ruff-0.9.2.tar.gz", hash = "sha256:b5eceb334d55fae5f316f783437392642ae18e16dcf4f1858d55d3c2a0f8f5d0"},
] ]
[[package]] [[package]]

View file

@ -1,7 +1,7 @@
[project] [project]
name = "mpd-now-playable" name = "mpd-now-playable"
dynamic = ["version"] 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 = [ authors = [
{name = "Danielle McLean", email = "dani@00dani.me"}, {name = "Danielle McLean", email = "dani@00dani.me"},
] ]
@ -52,14 +52,6 @@ mpd-now-playable = 'mpd_now_playable.cli:main'
requires = ["pdm-backend"] requires = ["pdm-backend"]
build-backend = "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] [tool.mypy]
mypy_path = 'stubs' mypy_path = 'stubs'
plugins = ['pydantic.mypy', 'mpd_now_playable.tools.schema.plugin'] plugins = ['pydantic.mypy', 'mpd_now_playable.tools.schema.plugin']
@ -85,13 +77,8 @@ select = [
"C90", "C90",
] ]
ignore = [ 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] [tool.ruff.lint.flake8-annotations]
@ -114,3 +101,12 @@ write_template = "__version__ = '{}'"
[tool.pdm.build] [tool.pdm.build]
excludes = ["**/.mypy_cache"] excludes = ["**/.mypy_cache"]
[tool.pdm.dev-dependencies]
dev = [
"mypy>=1.11.0",
"ruff>=0.1.6",
"class-doc>=0.2.6",
]

View file

@ -1,14 +1,12 @@
import argparse
import asyncio import asyncio
import sys import sys
from collections.abc import Iterable from collections.abc import Iterable
from rich import print as rich_print from rich import print
from .__version__ import __version__ from .__version__ import __version__
from .config.load import loadConfig from .config.load import loadConfig
from .config.model import Config from .config.model import Config
from .launchd import DEFAULT_LABEL, install_launchagent, uninstall_launchagent
from .mpd.listener import MpdStateListener from .mpd.listener import MpdStateListener
from .song_receiver import ( from .song_receiver import (
Receiver, Receiver,
@ -26,68 +24,25 @@ async def listen(
def print_help() -> None: def print_help() -> None:
parser = build_parser() print("Usage: mpd-now-playable [OPTIONS]")
parser.print_help() print("")
print("Options:")
print(" -h, --help Show this help message and exit.")
def build_parser() -> argparse.ArgumentParser: print(" -v, --version Show version and exit.")
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: def main() -> None:
parser = build_parser() args = set(sys.argv[1:])
args = parser.parse_args(sys.argv[1:]) if "-h" in args or "--help" in args:
print_help()
if args.version: return
rich_print(f"mpd-now-playable v{__version__}") if "-v" in args or "--version" in args:
print(f"mpd-now-playable v{__version__}")
return return
if args.command == "install-launchagent": print(f"mpd-now-playable v{__version__}")
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() config = loadConfig()
rich_print(config) print(config)
listener = MpdStateListener(config.cache) listener = MpdStateListener(config.cache)
receivers = tuple(construct_receiver(rec_config) for rec_config in config.receivers) receivers = tuple(construct_receiver(rec_config) for rec_config in config.receivers)

View file

@ -1,85 +0,0 @@
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

View file

@ -1,18 +1,18 @@
from enum import IntEnum from enum import Enum
class FailureResponseCode(IntEnum): class FailureResponseCode(Enum):
NOT_LIST = ... NOT_LIST: int
ARG = ... ARG: int
PASSWORD = ... PASSWORD: int
PERMISSION = ... PERMISSION: int
UNKNOWN = ... UNKNOWN: int
NO_EXIST = ... NO_EXIST: int
PLAYLIST_MAX = ... PLAYLIST_MAX: int
SYSTEM = ... SYSTEM: int
PLAYLIST_LOAD = ... PLAYLIST_LOAD: int
UPDATE_ALREADY = ... UPDATE_ALREADY: int
PLAYER_SYNC = ... PLAYER_SYNC: int
EXIST = ... EXIST: int
class MPDError(Exception): ... class MPDError(Exception): ...

View file

@ -1,140 +0,0 @@
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

View file

@ -1,77 +0,0 @@
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)]