diff --git a/README.md b/README.md index 8e48af7..d07cfc7 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,24 @@ -# mpd-now-playable [![PyPI version](https://badge.fury.io/py/mpd-now-playable.svg)](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. +[![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 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 + + + +- [Installation](#installation) +- [Configuration](#configuration) +- [Limitations](#limitations) + + + ## 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! diff --git a/me.00dani.mpd-now-playable.plist b/me.00dani.mpd-now-playable.plist deleted file mode 100644 index d75dde7..0000000 --- a/me.00dani.mpd-now-playable.plist +++ /dev/null @@ -1,16 +0,0 @@ - - - - - KeepAlive - - Label - me.00dani.mpd-now-playable - ProgramArguments - - /Users/dani/.local/bin/mpd-now-playable - - RunAtLoad - - - diff --git a/src/mpd_now_playable/cli.py b/src/mpd_now_playable/cli.py index a1f23ea..05ca535 100644 --- a/src/mpd_now_playable/cli.py +++ b/src/mpd_now_playable/cli.py @@ -1,3 +1,4 @@ +import argparse import asyncio import sys from collections.abc import Iterable @@ -7,6 +8,7 @@ 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,20 +26,63 @@ 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() + parser = build_parser() + args = parser.parse_args(sys.argv[1:]) + + if args.version: + rich_print(f"mpd-now-playable v{__version__}") return - if "-v" in args or "--version" in args: - 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__}") diff --git a/src/mpd_now_playable/launchd.py b/src/mpd_now_playable/launchd.py new file mode 100644 index 0000000..71d7995 --- /dev/null +++ b/src/mpd_now_playable/launchd.py @@ -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 diff --git a/tests/test_cli.py b/tests/test_cli.py index a7a4bb3..a82811f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,5 +1,7 @@ from types import SimpleNamespace +import pytest + import mpd_now_playable.cli as cli @@ -11,12 +13,15 @@ def test_main_help_prints_usage(monkeypatch, capsys) -> None: monkeypatch.setattr(cli, "loadConfig", fail_load) - cli.main() + with pytest.raises(SystemExit) as exc: + cli.main() + assert exc.value.code == 0 output = capsys.readouterr().out - assert "Usage: mpd-now-playable [OPTIONS]" in output - assert "-h, --help" in output - assert "-v, --version" in output + 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: @@ -93,3 +98,43 @@ def test_main_starts_listener_and_receivers(monkeypatch) -> None: 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 diff --git a/tests/test_launchd.py b/tests/test_launchd.py new file mode 100644 index 0000000..388b434 --- /dev/null +++ b/tests/test_launchd.py @@ -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)]