From 6f6a8ef4b52ab8220eef7f74d3a11c8f3188b914 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathieu=20R=C3=A9quillart?= Date: Thu, 14 May 2026 15:05:08 +0200 Subject: [PATCH 1/9] Bootstrap pyproject + asyncio package skeleton MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First step of the autotools → pyproject + asyncio + dbus-fast refactor (see docs/refactor-asyncio-dbus-fast.md). Adds the mpdris2/ package with __init__, __main__, cli and a daemon stub that just awaits SIGTERM, the pyproject.toml + scripts/version.py + slim dev Makefile + babel.cfg, and tests/test_cli.py covering argparse and config loading. The old autotools build stays alongside until PR 4 cuts it over. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 22 +++++++++- Makefile | 77 ++++++++++++++++++++++++++++++++++ babel.cfg | 1 + mpdris2/__init__.py | 1 + mpdris2/__main__.py | 5 +++ mpdris2/cli.py | 100 ++++++++++++++++++++++++++++++++++++++++++++ mpdris2/daemon.py | 32 ++++++++++++++ pyproject.toml | 78 ++++++++++++++++++++++++++++++++++ scripts/version.py | 80 +++++++++++++++++++++++++++++++++++ tests/__init__.py | 0 tests/test_cli.py | 72 +++++++++++++++++++++++++++++++ 11 files changed, 467 insertions(+), 1 deletion(-) create mode 100644 Makefile create mode 100644 babel.cfg create mode 100644 mpdris2/__init__.py create mode 100644 mpdris2/__main__.py create mode 100644 mpdris2/cli.py create mode 100644 mpdris2/daemon.py create mode 100644 pyproject.toml create mode 100755 scripts/version.py create mode 100644 tests/__init__.py create mode 100644 tests/test_cli.py diff --git a/.gitignore b/.gitignore index 17f74e6..006d400 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ -Makefile +# Autotools (legacy — removed by PR 4 in the asyncio refactor; kept here +# transitionally so an accidental `./autogen.sh` doesn't dirty the tree). Makefile.in Makefile.in.in +po/Makefile +src/Makefile compile config.* configure @@ -18,6 +21,18 @@ mpDris2 mpDris2.py mpDris2.service +# Python build artifacts +__pycache__/ +*.pyc +*.pyo +build/ +dist/ +*.egg-info/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +mpdris2/locale/ + # debhelper / dpkg-buildpackage artifacts debian/.debhelper/ debian/autoreconf.after @@ -34,3 +49,8 @@ debian/tmp/ *.deb *.buildinfo *.changes + +# Python virtualenv (local dev) +.venv/ +__pycache__/ +*.pyc diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..9f5d720 --- /dev/null +++ b/Makefile @@ -0,0 +1,77 @@ +# Dev / CI helpers. `mpdris2/__init__.py` is the version source of truth; +# this Makefile drives lint / test / build / deb / i18n and keeps the +# debian/changelog in sync with the Python version, no logic duplicated +# in the workflow YAML. + +PYTHON ?= python3 +VERSION := $(PYTHON) scripts/version.py + +.PHONY: version deb-version check-tag sync-deb \ + lint lint-ruff lint-mypy test build deb clean \ + i18n-extract i18n-compile + +# --- version helpers --------------------------------------------------- + +version: + @$(VERSION) + +deb-version: + @$(VERSION) --debian + +# Fail if the git tag doesn't match __init__.py (vX prefix optional). +# CI invokes this with TAG=$GITHUB_REF_NAME on tag pushes to catch a drift +# between the manual __init__.py bump and the tag. +TAG ?= $(GITHUB_REF_NAME) +check-tag: + @$(VERSION) --check-tag '$(TAG)' + +# Bump debian/changelog to match deb-version. Idempotent — noop if already +# in sync. Needs `devscripts` (dch) and `dpkg-dev` (dpkg-parsechangelog). +sync-deb: + @deb=$$($(VERSION) --debian); \ + cl=$$(dpkg-parsechangelog -S Version); \ + if [ "$$deb" != "$$cl" ]; then \ + dch -b --newversion "$$deb" --distribution unstable \ + --urgency medium "Release $$deb"; \ + fi + +# --- dev workflow ------------------------------------------------------ + +lint: lint-ruff lint-mypy + +lint-ruff: + ruff check mpdris2/ tests/ + +lint-mypy: + mypy mpdris2/ + +test: + pytest -q + +build: + $(PYTHON) -m build + +# Builds the .deb via dpkg-buildpackage. Requires a Debian toolchain +# (debhelper, dh-python, devscripts, etc.) — not available on Fedora; +# use a Debian container for local builds. Note: this target does NOT +# call `sync-deb`; call it first manually for a release build so the +# changelog matches __init__.py. +deb: + dpkg-buildpackage -b -us -uc + +clean: + rm -rf build/ dist/ *.egg-info mpdris2.egg-info mpdris2/locale/ + +# --- i18n -------------------------------------------------------------- + +# Refresh the .pot template from current source. +i18n-extract: + pybabel extract -F babel.cfg -o po/mpdris2.pot mpdris2/ + +# Compile .po files into the package's runtime locale tree. +i18n-compile: + @for po in po/*.po; do \ + lang=$$(basename $$po .po); \ + mkdir -p mpdris2/locale/$$lang/LC_MESSAGES; \ + msgfmt $$po -o mpdris2/locale/$$lang/LC_MESSAGES/mpdris2.mo; \ + done diff --git a/babel.cfg b/babel.cfg new file mode 100644 index 0000000..98c5550 --- /dev/null +++ b/babel.cfg @@ -0,0 +1 @@ +[python: mpdris2/**.py] diff --git a/mpdris2/__init__.py b/mpdris2/__init__.py new file mode 100644 index 0000000..a7b87e5 --- /dev/null +++ b/mpdris2/__init__.py @@ -0,0 +1 @@ +__version__ = "0.10.0b1" diff --git a/mpdris2/__main__.py b/mpdris2/__main__.py new file mode 100644 index 0000000..db508bd --- /dev/null +++ b/mpdris2/__main__.py @@ -0,0 +1,5 @@ +"""Allow ``python -m mpdris2`` to invoke the CLI.""" + +from mpdris2.cli import main + +main() diff --git a/mpdris2/cli.py b/mpdris2/cli.py new file mode 100644 index 0000000..46b914b --- /dev/null +++ b/mpdris2/cli.py @@ -0,0 +1,100 @@ +"""CLI entry point: argparse + config loading + asyncio dispatch. + +Kept separate from the bridge runtime (``mpdris2.bridge``) so the +bootstrap surface (argument parsing, config resolution) is testable in +isolation from the asyncio event loop. +""" + +from __future__ import annotations + +import argparse +import asyncio +import configparser +import contextlib +import logging +import os +import sys + +logger = logging.getLogger("mpdris2") + +CONFIG_PATHS = [ + os.path.join( + os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config"), + "mpDris2", + "mpDris2.conf", + ), + "/etc/mpDris2/mpDris2.conf", +] + + +class ConfigError(Exception): + """Raised when the daemon can't start because of invalid / missing config.""" + + +def read_config(path: str | None = None) -> configparser.ConfigParser: + """Parse the first existing INI file (or ``path`` if given). + + Sections preserved from the original mpDris2 layout: + ``[Connection]`` / ``[Library]`` / ``[Bling]`` / ``[Notify]``. + Missing file is not an error — defaults apply. + """ + cfg = configparser.ConfigParser() + paths = [path] if path else CONFIG_PATHS + for p in paths: + if p and os.path.exists(p): + cfg.read(p) + logger.info("read %s", p) + return cfg + logger.info("no config file found, using defaults") + return cfg + + +def build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser( + prog="mpDris2", + description="MPRIS2 D-Bus bridge for MPD.", + ) + p.add_argument("-v", "--verbose", action="store_true", + help="enable debug logging") + p.add_argument("--config", metavar="PATH", + help="path to an alternative config file") + p.add_argument("--use-journal", action="store_true", + help="log to systemd journal (no timestamps in stderr)") + p.add_argument("--no-reconnect", action="store_true", + help="exit instead of reconnecting if MPD disconnects") + p.add_argument("-H", "--host", metavar="HOST", + help="MPD host (overrides [Connection] host)") + p.add_argument("-p", "--port", metavar="PORT", type=int, + help="MPD port (overrides [Connection] port)") + p.add_argument("--music-dir", metavar="PATH", + help="music library path (overrides [Library] music_dir)") + return p + + +def main() -> None: + args = build_parser().parse_args() + + log_format = ("%(levelname)s: %(name)s - %(message)s" + if args.use_journal + else "%(asctime)s %(levelname)s: %(name)s - %(message)s") + logging.basicConfig( + format=log_format, + level=logging.DEBUG if args.verbose else logging.INFO, + ) + + try: + cfg = read_config(args.config) + except (OSError, configparser.Error) as e: + logger.critical("failed to read config: %s", e) + sys.exit(1) + + # Imported lazily so test_cli.py can exercise main() without dragging + # in dbus-fast / python-mpd2 at import time. + from mpdris2.daemon import run + + with contextlib.suppress(KeyboardInterrupt): + asyncio.run(run(cfg, args)) + + +if __name__ == "__main__": + main() diff --git a/mpdris2/daemon.py b/mpdris2/daemon.py new file mode 100644 index 0000000..d7f7f40 --- /dev/null +++ b/mpdris2/daemon.py @@ -0,0 +1,32 @@ +"""Daemon orchestration — asyncio runtime that wires MPD, D-Bus, cover, +and notifications together. + +PR 1 ships a placeholder ``run`` coroutine that just waits for SIGTERM / +SIGINT so the new entry point can be exercised end-to-end (``pip install +-e .`` then ``mpDris2 -v``) before any of the real wrappers exist. +PR 2 will replace this with the MPD + D-Bus glue. +""" + +from __future__ import annotations + +import argparse +import asyncio +import configparser +import logging +import signal + +logger = logging.getLogger(__name__) + + +async def run(cfg: configparser.ConfigParser, args: argparse.Namespace) -> None: + loop = asyncio.get_running_loop() + logger.info("mpDris2 started (skeleton — D-Bus + MPD wiring pending)") + + stop_event = asyncio.Event() + loop.add_signal_handler(signal.SIGTERM, stop_event.set) + loop.add_signal_handler(signal.SIGINT, stop_event.set) + + try: + await stop_event.wait() + finally: + logger.info("shutting down") diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..42edf7f --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,78 @@ +[build-system] +requires = ["setuptools>=61"] +build-backend = "setuptools.build_meta" + +[project] +name = "mpdris2" +description = "MPRIS2 D-Bus bridge for MPD" +readme = "README.md" +license = {text = "GPL-3.0-or-later"} +authors = [{name = "Mathieu Réquillart", email = "mathieu.requillart@gmail.com"}] +requires-python = ">=3.11" +classifiers = [ + "Programming Language :: Python :: 3", + "Operating System :: POSIX :: Linux", +] +dependencies = [ + "python-mpd2>=3.1", + "dbus-fast>=2.0", +] +dynamic = ["version"] + +[project.optional-dependencies] +cover = ["mutagen>=1.45"] +dev = ["pytest", "pytest-asyncio", "mypy", "ruff", "babel", "build"] + +[project.scripts] +mpDris2 = "mpdris2.cli:main" + +[project.urls] +Homepage = "https://github.com/b0bbywan/mpDris2" + +[tool.setuptools.dynamic] +version = {attr = "mpdris2.__version__"} + +[tool.setuptools.packages.find] +include = ["mpdris2*"] +exclude = ["tests*"] + +[tool.setuptools.package-data] +mpdris2 = ["locale/*/LC_MESSAGES/*.mo"] + +[tool.pytest.ini_options] +# Add the repo root so `pytest -q` (not via `python -m pytest`) can +# still import the mpdris2 package without an editable install. +pythonpath = ["."] + +[tool.ruff] +line-length = 120 +target-version = "py311" + +[tool.ruff.lint] +# Pyflakes (F) for real bugs, pycodestyle (E, W) for style, isort (I) for +# imports, bugbear (B) for common pitfalls, pyupgrade (UP) for modern +# Python idioms, and simplify (SIM) for redundant constructs. +select = ["E", "F", "W", "I", "B", "UP", "SIM"] + +[tool.ruff.lint.per-file-ignores] +# The dbus-fast service interface declares D-Bus type signatures as Python +# annotations ("s", "b", "a{sv}", ...). Ruff parses these as forward +# references and complains; silence F821/F722 for that file only. +"mpdris2/mpris.py" = ["F821", "F722", "UP037"] + +[tool.mypy] +python_version = "3.11" +warn_unused_ignores = true +warn_redundant_casts = true +warn_return_any = true +no_implicit_optional = true +disallow_untyped_defs = true +# Third-party libs (dbus_fast, mpd, mutagen, musicbrainzngs) don't ship +# type stubs; treat their imports as Any rather than failing outright. +ignore_missing_imports = true + +[[tool.mypy.overrides]] +# Same dbus-fast signature-as-annotation issue as with ruff: those "s", +# "b", "a{sv}" strings aren't Python types. Skip mpris.py. +module = "mpdris2.mpris" +ignore_errors = true diff --git a/scripts/version.py b/scripts/version.py new file mode 100755 index 0000000..a5ca137 --- /dev/null +++ b/scripts/version.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +"""Version helpers — ``mpdris2/__init__.py`` is the source of truth. + +Usage: + version.py print PEP 440 version + version.py --debian print Debian-sortable equivalent + version.py --check-tag TAG exit 1 if TAG doesn't match (vX prefix optional) + +Parses __init__.py directly (no import), so the script works without the +package's runtime dependencies installed. +""" +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path + +INIT = Path(__file__).resolve().parent.parent / "mpdris2" / "__init__.py" + + +def read_version() -> str: + m = re.search(r'^__version__\s*=\s*"([^"]+)"', INIT.read_text(), re.M) + if not m: + sys.exit(f"could not parse __version__ from {INIT}") + return m.group(1) + + +def to_debian(v: str) -> str: + """PEP 440 prerelease (rcN/bN/aN) -> Debian-sortable (~rc.N/~beta.N/~alpha.N) + so apt's comparator sorts prereleases below the final release. + """ + v = re.sub(r"rc(\d+)$", r"~rc.\1", v) + v = re.sub(r"b(\d+)$", r"~beta.\1", v) + v = re.sub(r"a(\d+)$", r"~alpha.\1", v) + return v + + +TAG_RE = re.compile(r"^v?(\d+\.\d+\.\d+)(?:-(rc|beta|alpha)\.(\d+))?$") + + +def normalize_tag(tag: str) -> str: + """Validate the canonical tag form and return the matching PEP 440 version. + + Canonical form: ``vX.Y.Z`` or ``vX.Y.Z-{rc,beta,alpha}.N`` (leading ``v`` + optional). The ``-rc.N`` shape is required so apt sorts prereleases below + finals and so ``contains(github.ref_name, '-rc')`` in the release job + still picks them up as prereleases. + """ + m = TAG_RE.match(tag) + if not m: + sys.exit( + f"tag {tag!r} doesn't match the canonical form " + "vX.Y.Z or vX.Y.Z-{rc,beta,alpha}.N" + ) + base, kind, n = m.group(1), m.group(2), m.group(3) + if kind is None: + return base + suffix = {"rc": "rc", "beta": "b", "alpha": "a"}[kind] + return f"{base}{suffix}{n}" + + +def main() -> None: + p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + g = p.add_mutually_exclusive_group() + g.add_argument("--debian", action="store_true", help="print Debian-sortable version") + g.add_argument("--check-tag", metavar="TAG", help="exit 1 if TAG doesn't match __init__.py") + args = p.parse_args() + + v = read_version() + if args.check_tag: + tag = normalize_tag(args.check_tag) + if tag != v: + sys.exit(f"tag {args.check_tag!r} does not match __init__.py version {v!r}") + return + print(to_debian(v) if args.debian else v) + + +if __name__ == "__main__": + main() diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..e88a63c --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,72 @@ +"""Argparse + config-loading tests. No D-Bus, no MPD, no event loop.""" + +from __future__ import annotations + +import os +from pathlib import Path + +from mpdris2.cli import build_parser, read_config + + +def test_parser_defaults() -> None: + args = build_parser().parse_args([]) + assert args.verbose is False + assert args.config is None + assert args.use_journal is False + assert args.no_reconnect is False + assert args.host is None + assert args.port is None + assert args.music_dir is None + + +def test_parser_flags() -> None: + args = build_parser().parse_args([ + "-v", + "--use-journal", + "--no-reconnect", + "-H", "192.0.2.10", + "-p", "6601", + "--music-dir", "/srv/music", + ]) + assert args.verbose is True + assert args.use_journal is True + assert args.no_reconnect is True + assert args.host == "192.0.2.10" + assert args.port == 6601 + assert args.music_dir == "/srv/music" + + +def test_read_config_missing_file_uses_defaults(tmp_path: Path) -> None: + # Point at a path that doesn't exist; parser returns an empty + # ConfigParser instead of raising. + cfg = read_config(str(tmp_path / "absent.conf")) + assert cfg.sections() == [] + + +def test_read_config_parses_ini(tmp_path: Path) -> None: + p = tmp_path / "mpDris2.conf" + p.write_text( + "[Connection]\n" + "host = mpd.example\n" + "port = 6600\n" + "\n" + "[Library]\n" + "music_dir = /srv/music\n" + ) + cfg = read_config(str(p)) + assert cfg.get("Connection", "host") == "mpd.example" + assert cfg.getint("Connection", "port") == 6600 + assert cfg.get("Library", "music_dir") == "/srv/music" + + +def test_read_config_no_argument_falls_back_to_xdg(tmp_path: Path, monkeypatch) -> None: + # Force the XDG path to point inside tmp_path so the lookup + # is hermetic. With no file present the parser still returns an + # empty ConfigParser rather than raising. + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) + # Re-import so the module-level CONFIG_PATHS would pick up XDG — + # but CONFIG_PATHS is computed at import time, so this exercises the + # caller-supplied None branch instead. + cfg = read_config(None) + assert cfg.sections() == [] + os.environ.pop("XDG_CONFIG_HOME", None) From 3313251c2e8beaf75311e8a5ff03af101737df34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathieu=20R=C3=A9quillart?= Date: Thu, 14 May 2026 16:03:28 +0200 Subject: [PATCH 2/9] Port MPRIS interface + asyncio MPD client (PR 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the dbus-python / GLib glue with dbus-fast ServiceInterface classes (MediaPlayer2 + MediaPlayer2Player) and an asyncio mpd.asyncio wrapper with exponential-backoff connect. The daemon's run() coroutine acquires the bus, watches MPD idle events on player/mixer/options/playlist, and translates state changes into PropertiesChanged emissions. Methods (Play/Pause/PlayPause/Stop/Next/Previous/Seek/SetPosition) and R/W properties (Volume / LoopStatus / Shuffle) round-trip to MPD; Metadata stays empty until PR 3 wires translate.py + cover.py. The mmkeys GNOME-SettingsDaemon grab is intentionally dropped — modern desktops route media keys through MPRIS2 directly. Co-Authored-By: Claude Opus 4.7 (1M context) --- mpdris2/daemon.py | 331 +++++++++++++++++++++++++++++++++++++++- mpdris2/mpd_client.py | 129 ++++++++++++++++ mpdris2/mpris.py | 345 ++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 3 +- tests/test_mpris.py | 136 +++++++++++++++++ 5 files changed, 935 insertions(+), 9 deletions(-) create mode 100644 mpdris2/mpd_client.py create mode 100644 mpdris2/mpris.py create mode 100644 tests/test_mpris.py diff --git a/mpdris2/daemon.py b/mpdris2/daemon.py index d7f7f40..d3181c2 100644 --- a/mpdris2/daemon.py +++ b/mpdris2/daemon.py @@ -1,10 +1,12 @@ -"""Daemon orchestration — asyncio runtime that wires MPD, D-Bus, cover, -and notifications together. +"""Daemon orchestration — asyncio runtime that wires MPD and D-Bus. -PR 1 ships a placeholder ``run`` coroutine that just waits for SIGTERM / -SIGINT so the new entry point can be exercised end-to-end (``pip install --e .`` then ``mpDris2 -v``) before any of the real wrappers exist. -PR 2 will replace this with the MPD + D-Bus glue. +Single asyncio event loop. No threads, no GLib. MPRIS callbacks +schedule MPD commands as fire-and-forget tasks; MPD ``idle`` events +drive ``refresh()``, which translates the new status into MPRIS +properties and emits PropertiesChanged via ``MediaPlayer2Player``. + +PR 2 ships the playback-state + transport-control surface (no +Metadata / cover / notify yet — those come in PR 3). """ from __future__ import annotations @@ -12,21 +14,334 @@ import argparse import asyncio import configparser +import contextlib import logging +import os import signal +from collections.abc import Awaitable, Coroutine +from typing import Any + +import mpd +from dbus_fast import BusType +from dbus_fast.aio import MessageBus +from mpd.asyncio import MPDClient + +from mpdris2 import mpd_client +from mpdris2.mpris import ( + BUS_NAME, + ROOT_PATH, + MediaPlayer2, + MediaPlayer2Player, +) logger = logging.getLogger(__name__) +# Subsystems we care about — others (e.g. ``database``, ``update``, +# ``sticker``) don't influence the MPRIS-exposed state. +WATCHED_SUBSYSTEMS = frozenset({"player", "mixer", "options", "playlist"}) + + +def _resolve_endpoint( + cfg: configparser.ConfigParser, args: argparse.Namespace +) -> tuple[str, int, str | None]: + """Pick (host, port, password) from CLI args → config → env → defaults.""" + host = ( + args.host + or cfg.get("Connection", "host", fallback=None) + or os.environ.get("MPD_HOST") + or "localhost" + ) + password: str | None = cfg.get("Connection", "password", fallback=None) or None + if "@" in host: + # ``password@host`` shorthand matches the original mpDris2. + password, host = host.rsplit("@", 1) + + port_str = ( + str(args.port) if args.port else cfg.get("Connection", "port", fallback="") + ) or os.environ.get("MPD_PORT") or "6600" + try: + port = int(port_str) + except ValueError: + logger.warning("invalid MPD port %r; falling back to 6600", port_str) + port = 6600 + return host, port, password + + +def _loop_status_from(repeat: bool, single: bool) -> str: + if repeat and single: + return "Track" + if repeat: + return "Playlist" + return "None" + async def run(cfg: configparser.ConfigParser, args: argparse.Namespace) -> None: loop = asyncio.get_running_loop() - logger.info("mpDris2 started (skeleton — D-Bus + MPD wiring pending)") + host, port, password = _resolve_endpoint(cfg, args) stop_event = asyncio.Event() loop.add_signal_handler(signal.SIGTERM, stop_event.set) loop.add_signal_handler(signal.SIGINT, stop_event.set) + # Strong-ref fire-and-forget tasks so the loop's weak refs don't + # let them be GC'd mid-execution (asyncio docs explicitly warn). + bg_tasks: set[asyncio.Task] = set() + + def schedule(coro: Coroutine[Any, Any, Any]) -> None: + task = loop.create_task(coro) + bg_tasks.add(task) + task.add_done_callback(bg_tasks.discard) + + async def mpd_safe(awaitable: Awaitable) -> Any: + """Run an MPD coroutine; swallow command-level errors that don't + matter for the MPRIS surface (no current song, invalid arg, …) + and log connection drops without raising into the caller.""" + try: + return await awaitable + except mpd.CommandError as e: + logger.debug("MPD command error: %s", e) + except (mpd.ConnectionError, OSError) as e: + logger.warning("MPD lost during command: %s", e) + return None + + # --- State holders rebound on each reconnect ----------------- + client: MPDClient | None = None + caps: dict[str, bool] = {} + last_status: dict = {} + last_song: dict = {} + last_time: float = 0.0 + + # --- MPRIS surface ------------------------------------------------ + def on_play() -> None: + c = client + if c is not None: + schedule(mpd_safe(c.play())) + + def on_pause() -> None: + c = client + if c is not None: + schedule(mpd_safe(c.pause(1))) + + def on_play_pause() -> None: + c = client + if c is None: + return + + async def toggle() -> None: + s = await mpd_safe(c.status()) + if s and s.get("state") == "play": + await mpd_safe(c.pause(1)) + else: + await mpd_safe(c.play()) + + schedule(toggle()) + + def on_stop() -> None: + c = client + if c is not None: + schedule(mpd_safe(c.stop())) + + def on_next() -> None: + c = client + if c is not None: + schedule(mpd_safe(c.next())) + + def on_previous() -> None: + c = client + if c is not None: + schedule(mpd_safe(c.previous())) + + def on_seek(offset_us: int) -> None: + c = client + if c is None: + return + offset_s = offset_us / 1_000_000 + # MPD's seekcur accepts a string with a leading sign for relative + # seeks; bare numbers are absolute. + arg = f"+{offset_s}" if offset_us >= 0 else str(offset_s) + schedule(mpd_safe(c.seekcur(arg))) + + def on_set_position(trackid: str, position_us: int) -> None: + c = client + if c is None: + return + # MPRIS requires the trackid match the currently playing track; + # if it doesn't, the call is a no-op per spec. + cur_id = last_song.get("id") + if cur_id is not None and trackid != f"/org/mpris/MediaPlayer2/Track/{cur_id}": + return + position_s = position_us / 1_000_000 + schedule(mpd_safe(c.seekcur(str(position_s)))) + + def on_volume_set(v: float) -> None: + c = client + if c is not None: + schedule(mpd_safe(c.setvol(int(round(v * 100))))) + + def on_loop_status_set(val: str) -> None: + c = client + if c is None: + return + single_supported = caps.get("single", False) + + async def apply() -> None: + if val == "Playlist": + await mpd_safe(c.repeat(1)) + if single_supported: + await mpd_safe(c.single(0)) + elif val == "Track": + await mpd_safe(c.repeat(1)) + if single_supported: + await mpd_safe(c.single(1)) + else: # "None" + await mpd_safe(c.repeat(0)) + if single_supported: + await mpd_safe(c.single(0)) + + schedule(apply()) + + def on_shuffle_set(v: bool) -> None: + c = client + if c is not None: + schedule(mpd_safe(c.random(1 if v else 0))) + + root = MediaPlayer2() + player = MediaPlayer2Player( + on_play=on_play, + on_pause=on_pause, + on_play_pause=on_play_pause, + on_stop=on_stop, + on_next=on_next, + on_previous=on_previous, + on_seek=on_seek, + on_set_position=on_set_position, + on_volume_set=on_volume_set, + on_loop_status_set=on_loop_status_set, + on_shuffle_set=on_shuffle_set, + ) + + # --- D-Bus export (kept alive across MPD reconnects) ------------- + bus = await MessageBus(bus_type=BusType.SESSION).connect() + bus.export(ROOT_PATH, root) + bus.export(ROOT_PATH, player) + await bus.request_name(BUS_NAME) + logger.info("D-Bus name acquired: %s", BUS_NAME) + + # --- Refresh: MPD status -> MPRIS properties --------------------- + async def refresh() -> None: + nonlocal last_status, last_song, last_time + c = client + if c is None: + return + try: + status = await c.status() + song = await c.currentsong() + except (mpd.ConnectionError, OSError) as e: + logger.warning("MPD lost during refresh: %s", e) + return + + now = loop.time() + old_status = last_status + old_song = last_song + old_time = last_time + last_status = status + last_song = song + last_time = now + + state = status.get("state", "stop") + player.update_playback_status( + {"play": "Playing", "pause": "Paused", "stop": "Stopped"}.get(state, "Stopped") + ) + + repeat = status.get("repeat", "0") == "1" + single = status.get("single", "0") == "1" + player.update_loop_status(_loop_status_from(repeat, single)) + player.update_shuffle(status.get("random", "0") == "1") + + # MPD: volume is 0-100, or -1 when the audio backend can't + # report it (e.g. some ALSA configs). Treat -1 as "leave as-is". + try: + vol_raw = int(status.get("volume", -1)) + except (TypeError, ValueError): + vol_raw = -1 + if vol_raw >= 0: + player.update_volume(vol_raw / 100.0) + + # Position + Seeked detection. Same heuristic as the original + # mpDris2: if the song didn't change and we were playing, the + # elapsed time should advance linearly; a >0.6s deviation means + # someone seeked outside of MPRIS. + try: + new_pos_s = float(status.get("elapsed", 0.0)) + except (TypeError, ValueError): + new_pos_s = 0.0 + player.update_position(int(new_pos_s * 1_000_000)) + + same_song = bool( + old_song + and song + and old_song.get("id") == song.get("id") + ) + if same_song and old_status.get("state") == "play" and state == "play": + expected = float(old_status.get("elapsed", 0.0)) + (now - old_time) + if abs(new_pos_s - expected) > 0.6: + player.emit_seeked(int(new_pos_s * 1_000_000)) + + # CanGoNext: a next song is queued, or we'd loop back to the + # start of the playlist anyway. + has_next = "nextsongid" in status or repeat + player.update_capabilities(can_go_next=has_next) + # CanSeek: until PR 3 wires real metadata, hard-True (matching + # the original mpDris2). MPD will simply reject seekcur when + # there's no current song. + player.update_capabilities(can_seek=True) + + # --- Outer MPD connect / reconnect loop -------------------------- try: - await stop_event.wait() + while not stop_event.is_set(): + try: + new_client = await mpd_client.connect( + host, port, password, retry=not args.no_reconnect + ) + except (mpd.CommandError, mpd.ConnectionError, OSError) as e: + logger.critical("MPD connection failed: %s", e) + break + + client = new_client + try: + cmds = await new_client.commands() + except (mpd.ConnectionError, OSError) as e: + logger.warning("MPD dropped before commands probe: %s", e) + client = None + continue + caps = mpd_client.capabilities(cmds) + logger.info("MPD capabilities: %s", + ",".join(k for k, v in caps.items() if v)) + + await refresh() + + try: + async for subsystems in new_client.idle(): + if stop_event.is_set(): + break + if WATCHED_SUBSYSTEMS.intersection(subsystems): + await refresh() + except (mpd.ConnectionError, OSError) as e: + logger.warning("MPD idle loop ended: %s", e) + finally: + with contextlib.suppress(Exception): + new_client.disconnect() + client = None + + if args.no_reconnect or stop_event.is_set(): + break + # Reset MPRIS state so subscribers see "nothing playing" + # while we reconnect. + player.update_playback_status("Stopped") + player.update_metadata({}) finally: logger.info("shutting down") + with contextlib.suppress(Exception): + await bus.release_name(BUS_NAME) + with contextlib.suppress(Exception): + bus.disconnect() diff --git a/mpdris2/mpd_client.py b/mpdris2/mpd_client.py new file mode 100644 index 0000000..a18177c --- /dev/null +++ b/mpdris2/mpd_client.py @@ -0,0 +1,129 @@ +"""Asyncio MPD client helpers: connect-with-backoff + capability probe. + +Thin functional wrapper around ``mpd.asyncio.MPDClient``. The daemon +keeps a direct reference to the client and ``await``s commands on it; +this module only abstracts the connect/retry policy and the capability +mapping so the daemon code stays focused on state translation. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import logging +from collections.abc import Iterable +from functools import partial + +import mpd +from mpd.asyncio import CommandResult, MPDClient + +logger = logging.getLogger(__name__) + +CONNECT_BACKOFF_MIN = 1.0 +CONNECT_BACKOFF_MAX = 30.0 +CONNECT_BACKOFF_FACTOR = 1.5 +CONNECT_TIMEOUT = 10.0 +CONFIG_PROBE_TIMEOUT = 5.0 + + +def is_unix_socket(host: str) -> bool: + """``/path`` = filesystem socket, ``@name`` = Linux abstract socket.""" + return host.startswith(("/", "@")) + + +async def connect( + host: str, + port: int, + password: str | None = None, + *, + retry: bool = True, +) -> MPDClient: + """Open an MPD connection. With ``retry=True`` loop with exponential + backoff until a connection succeeds (or the caller cancels us). + Authentication failures are *not* retried — they bubble out. + Each attempt is capped at ``CONNECT_TIMEOUT`` seconds so a + silently-dropped TCP SYN doesn't hang the daemon at startup. + """ + backoff = CONNECT_BACKOFF_MIN + while True: + client = MPDClient() + connected = False + try: + async with asyncio.timeout(CONNECT_TIMEOUT): + await client.connect(host, port) + if password: + await client.password(password) + connected = True + endpoint = host if is_unix_socket(host) else f"{host}:{port}" + logger.info("connected to MPD at %s", endpoint) + return client + except mpd.CommandError as e: + logger.error("MPD auth/command error during connect: %s", e) + raise + except (OSError, mpd.ConnectionError, TimeoutError) as e: + if not retry: + raise + logger.warning("MPD connect failed (%s); retry in %.1fs", e, backoff) + await asyncio.sleep(backoff) + backoff = min(backoff * CONNECT_BACKOFF_FACTOR, CONNECT_BACKOFF_MAX) + finally: + if not connected: + with contextlib.suppress(Exception): + client.disconnect() + + +async def fetch_config(client: MPDClient) -> dict[str, str]: + """Send MPD's ``config`` command and parse the response as a dict. + + Works around python-mpd2 3.1.x mapping ``config`` to + ``_parse_item`` (which only handles single-pair responses); + ``config`` actually returns multiple pairs (``music_directory``, + ``playlist_directory``, ``pcre``), so the upstream parser returns + ``None`` and we never see the data. + + We reuse python-mpd2's internal command queue + writer with a + correct dict-parsing callback. Only allowed on local socket + connections (MPD answers "Access denied" on TCP). + """ + def _parse_as_dict(client_: MPDClient, lines: list) -> dict[str, str]: + return dict(client_._parse_pairs(lines)) + + result = CommandResult("config", (), partial(_parse_as_dict, client)) + try: + # ``__command_queue`` is name-mangled inside the mpd.asyncio.MPDClient + # class; access it via the mangled attribute name. + await client._MPDClient__command_queue.put(result) + client._end_idle() + client._write_command("config") + except AttributeError as e: + logger.warning("python-mpd2 private API moved (%s); skipping config probe", e) + return {} + except (mpd.ConnectionError, OSError) as e: + # Connection died between put() and write_command(): the + # CommandResult is orphaned in the queue, but run_loop drops the + # client right after this returns, so it gets GC'd with the rest. + logger.debug("MPD lost during config probe: %s", e) + return {} + + try: + async with asyncio.timeout(CONFIG_PROBE_TIMEOUT): + parsed: dict[str, str] = await result + except (TimeoutError, mpd.ConnectionError, OSError) as e: + logger.debug("config probe gave up: %s", e) + return {} + return parsed + + +def capabilities(commands: Iterable[str]) -> dict[str, bool]: + """Map the result of ``await client.commands()`` to feature flags. + Each MPD command in the table below was added in a specific server + version; checking the per-command list rather than parsing the + version string handles forks (mopidy etc.) gracefully too. + """ + cmds = set(commands) + return { + "idle": "idle" in cmds, # 0.14 + "single": "single" in cmds, # 0.15 + "albumart": "albumart" in cmds, # 0.21 + "readpicture": "readpicture" in cmds, # 0.22 + } diff --git a/mpdris2/mpris.py b/mpdris2/mpris.py new file mode 100644 index 0000000..d9f7737 --- /dev/null +++ b/mpdris2/mpris.py @@ -0,0 +1,345 @@ +"""MPRIS2 D-Bus interface, exposed via dbus-fast. + +Two ServiceInterface subclasses correspond to the two interfaces every +MPRIS2 player must implement on the object path +``/org/mpris/MediaPlayer2``: + +* ``org.mpris.MediaPlayer2`` — identity + capabilities (root) +* ``org.mpris.MediaPlayer2.Player`` — playback state + controls + +Behaviour is driven from the outside: callbacks injected at construction +time handle Play/Pause/Stop/PlayPause/Next/Previous/Seek/SetPosition/ +volume/loop/shuffle, and ``update_*`` push state changes back to +subscribed MPRIS clients via ``emit_properties_changed``. + +This module has no MPD knowledge — see ``mpdris2.bridge`` for the glue. +""" + +from __future__ import annotations + +import logging +from collections.abc import Callable + +from dbus_fast.errors import DBusError +from dbus_fast.service import PropertyAccess, ServiceInterface, dbus_property, method, signal + +NOT_SUPPORTED = "org.freedesktop.DBus.Error.NotSupported" + +logger = logging.getLogger(__name__) + +ROOT_PATH = "/org/mpris/MediaPlayer2" +MEDIA_PLAYER_IFACE = "org.mpris.MediaPlayer2" +BUS_NAME = f"{MEDIA_PLAYER_IFACE}.mpd" +PLAYER_IFACE = f"{MEDIA_PLAYER_IFACE}.Player" + +IDENTITY = "Music Player Daemon" +DESKTOP_ENTRY = "mpdris2" + +VALID_PLAYBACK_STATUS = {"Playing", "Paused", "Stopped"} +VALID_LOOP_STATUS = {"None", "Track", "Playlist"} + + +class MediaPlayer2(ServiceInterface): + """Root MPRIS interface — identity + capabilities.""" + + def __init__(self) -> None: + super().__init__(MEDIA_PLAYER_IFACE) + + @method() + def Raise(self): # noqa: N802 + # MPD has no GUI to bring forward; advertise CanRaise=False and + # answer the method with NotSupported per MPRIS spec hints. + raise DBusError(NOT_SUPPORTED, "Raise is not supported") + + @method() + def Quit(self): # noqa: N802 + raise DBusError(NOT_SUPPORTED, "Quit is not supported") + + @dbus_property(access=PropertyAccess.READ) + def CanQuit(self) -> "b": # noqa: N802 + return False + + @dbus_property(access=PropertyAccess.READ) + def CanRaise(self) -> "b": # noqa: N802 + return False + + @dbus_property(access=PropertyAccess.READ) + def HasTrackList(self) -> "b": # noqa: N802 + return False + + @dbus_property(access=PropertyAccess.READ) + def Identity(self) -> "s": # noqa: N802 + return IDENTITY + + @dbus_property(access=PropertyAccess.READ) + def DesktopEntry(self) -> "s": # noqa: N802 + return DESKTOP_ENTRY + + @dbus_property(access=PropertyAccess.READ) + def SupportedUriSchemes(self) -> "as": # noqa: N802 + # Filled at daemon startup from MPD's ``urlhandlers`` command if + # we ever want to advertise OpenUri. For now MPD's local URI + # scheme isn't MPRIS-portable, so leave empty. + return [] + + @dbus_property(access=PropertyAccess.READ) + def SupportedMimeTypes(self) -> "as": # noqa: N802 + return [] + + +class MediaPlayer2Player(ServiceInterface): + """Player MPRIS interface — playback state, metadata and controls.""" + + def __init__( + self, + on_play: Callable[[], None] | None = None, + on_pause: Callable[[], None] | None = None, + on_play_pause: Callable[[], None] | None = None, + on_stop: Callable[[], None] | None = None, + on_next: Callable[[], None] | None = None, + on_previous: Callable[[], None] | None = None, + on_seek: Callable[[int], None] | None = None, + on_set_position: Callable[[str, int], None] | None = None, + on_volume_set: Callable[[float], None] | None = None, + on_loop_status_set: Callable[[str], None] | None = None, + on_shuffle_set: Callable[[bool], None] | None = None, + ) -> None: + super().__init__(PLAYER_IFACE) + self._playback_status = "Stopped" + self._loop_status = "None" + self._shuffle = False + self._metadata: dict = {} + self._volume = 0.0 + self._position = 0 # microseconds, int64 + # Capabilities — filled in from MPD state on every refresh. + self._can_play = True + self._can_pause = True + self._can_go_next = True + self._can_go_previous = True + self._can_seek = False # flips True once we have mpris:length + self._on_play = on_play + self._on_pause = on_pause + self._on_play_pause = on_play_pause + self._on_stop = on_stop + self._on_next = on_next + self._on_previous = on_previous + self._on_seek = on_seek + self._on_set_position = on_set_position + self._on_volume_set = on_volume_set + self._on_loop_status_set = on_loop_status_set + self._on_shuffle_set = on_shuffle_set + + # --- MPRIS methods ------------------------------------------------ + @method() + def Play(self): # noqa: N802 + if self._on_play: + self._on_play() + + @method() + def Pause(self): # noqa: N802 + if self._on_pause: + self._on_pause() + + @method() + def PlayPause(self): # noqa: N802 + if self._on_play_pause: + self._on_play_pause() + + @method() + def Stop(self): # noqa: N802 + if self._on_stop: + self._on_stop() + + @method() + def Next(self): # noqa: N802 + if self._on_next: + self._on_next() + + @method() + def Previous(self): # noqa: N802 + if self._on_previous: + self._on_previous() + + @method() + def Seek(self, Offset: "x"): # noqa: N802, N803 + if self._on_seek: + self._on_seek(int(Offset)) + + @method() + def SetPosition(self, TrackId: "o", Position: "x"): # noqa: N802, N803 + if self._on_set_position: + self._on_set_position(str(TrackId), int(Position)) + + @method() + def OpenUri(self, Uri: "s"): # noqa: N802, N803, ARG002 + raise DBusError(NOT_SUPPORTED, "OpenUri is not supported") + + @signal() + def Seeked(self, Position: "x") -> "x": # noqa: N802, N803 + return Position + + # --- MPRIS properties -------------------------------------------- + @dbus_property(access=PropertyAccess.READ) + def PlaybackStatus(self) -> "s": # noqa: N802 + return self._playback_status + + @dbus_property() + def LoopStatus(self) -> "s": # noqa: N802 + return self._loop_status + + @LoopStatus.setter # type: ignore[no-redef] + def LoopStatus(self, val: "s") -> None: # noqa: N802 + if val not in VALID_LOOP_STATUS: + raise DBusError("org.freedesktop.DBus.Error.InvalidArgs", + f"LoopStatus {val!r} is not a valid value") + if self._on_loop_status_set: + self._on_loop_status_set(val) + + @dbus_property() + def Shuffle(self) -> "b": # noqa: N802 + return self._shuffle + + @Shuffle.setter # type: ignore[no-redef] + def Shuffle(self, val: "b") -> None: # noqa: N802 + if self._on_shuffle_set: + self._on_shuffle_set(bool(val)) + + @dbus_property(access=PropertyAccess.READ) + def Metadata(self) -> "a{sv}": # noqa: N802 + return self._metadata + + @dbus_property() + def Volume(self) -> "d": # noqa: N802 + return self._volume + + @Volume.setter # type: ignore[no-redef] + def Volume(self, val: "d") -> None: # noqa: N802 + clamped = max(0.0, min(1.0, float(val))) + logger.debug("MPRIS Set Volume: %.3f -> %.3f", self._volume, clamped) + if clamped == self._volume: + if self._on_volume_set: + self._on_volume_set(clamped) + return + self._volume = clamped + # Emit synchronously so every MPRIS subscriber learns about the + # change immediately. The follow-up MPD idle refresh will early- + # return in update_volume() because self._volume already matches. + self.emit_properties_changed({"Volume": clamped}) + if self._on_volume_set: + self._on_volume_set(clamped) + + @dbus_property(access=PropertyAccess.READ) + def Position(self) -> "x": # noqa: N802 + return self._position + + @dbus_property(access=PropertyAccess.READ) + def Rate(self) -> "d": # noqa: N802 + return 1.0 + + @dbus_property(access=PropertyAccess.READ) + def MinimumRate(self) -> "d": # noqa: N802 + return 1.0 + + @dbus_property(access=PropertyAccess.READ) + def MaximumRate(self) -> "d": # noqa: N802 + return 1.0 + + @dbus_property(access=PropertyAccess.READ) + def CanGoNext(self) -> "b": # noqa: N802 + return self._can_go_next + + @dbus_property(access=PropertyAccess.READ) + def CanGoPrevious(self) -> "b": # noqa: N802 + return self._can_go_previous + + @dbus_property(access=PropertyAccess.READ) + def CanPlay(self) -> "b": # noqa: N802 + return self._can_play + + @dbus_property(access=PropertyAccess.READ) + def CanPause(self) -> "b": # noqa: N802 + return self._can_pause + + @dbus_property(access=PropertyAccess.READ) + def CanSeek(self) -> "b": # noqa: N802 + return self._can_seek + + @dbus_property(access=PropertyAccess.READ) + def CanControl(self) -> "b": # noqa: N802 + # Hardcoded True: an MPD bridge is always controllable in principle. + # The per-action Can* flags reflect the playlist state more + # precisely (e.g. CanGoNext = there is a next song). + return True + + # --- External update API ----------------------------------------- + def update_playback_status(self, status: str) -> None: + if status not in VALID_PLAYBACK_STATUS: + logger.warning("ignoring invalid playback status: %s", status) + return + if status == self._playback_status: + return + self._playback_status = status + self.emit_properties_changed({"PlaybackStatus": status}) + + def update_loop_status(self, status: str) -> None: + if status not in VALID_LOOP_STATUS: + logger.warning("ignoring invalid loop status: %s", status) + return + if status == self._loop_status: + return + self._loop_status = status + self.emit_properties_changed({"LoopStatus": status}) + + def update_shuffle(self, shuffle: bool) -> None: + shuffle = bool(shuffle) + if shuffle == self._shuffle: + return + self._shuffle = shuffle + self.emit_properties_changed({"Shuffle": shuffle}) + + def update_metadata(self, metadata: dict) -> None: + # Always replace + emit even if identical — MPRIS clients can + # rely on a Metadata signal after every track change. Cheap. + self._metadata = metadata + self.emit_properties_changed({"Metadata": metadata}) + + def update_volume(self, volume: float) -> None: + clamped = max(0.0, min(1.0, float(volume))) + if clamped == self._volume: + return + self._volume = clamped + self.emit_properties_changed({"Volume": clamped}) + + def update_position(self, position_us: int) -> None: + # Position is not emitted via PropertiesChanged per spec + # (it changes continuously); stored for Get(Position) reads + # and used as a baseline by Seeked emission in the daemon. + self._position = int(position_us) + + def update_capabilities(self, *, can_play: bool | None = None, + can_pause: bool | None = None, + can_go_next: bool | None = None, + can_go_previous: bool | None = None, + can_seek: bool | None = None) -> None: + changed: dict = {} + if can_play is not None and can_play != self._can_play: + self._can_play = can_play + changed["CanPlay"] = can_play + if can_pause is not None and can_pause != self._can_pause: + self._can_pause = can_pause + changed["CanPause"] = can_pause + if can_go_next is not None and can_go_next != self._can_go_next: + self._can_go_next = can_go_next + changed["CanGoNext"] = can_go_next + if can_go_previous is not None and can_go_previous != self._can_go_previous: + self._can_go_previous = can_go_previous + changed["CanGoPrevious"] = can_go_previous + if can_seek is not None and can_seek != self._can_seek: + self._can_seek = can_seek + changed["CanSeek"] = can_seek + if changed: + self.emit_properties_changed(changed) + + def emit_seeked(self, position_us: int) -> None: + self._position = int(position_us) + self.Seeked(int(position_us)) diff --git a/pyproject.toml b/pyproject.toml index 42edf7f..9720a25 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,8 @@ classifiers = [ "Operating System :: POSIX :: Linux", ] dependencies = [ - "python-mpd2>=3.1", + # <3.2: mpd_client.fetch_config leans on private attrs. + "python-mpd2>=3.1,<3.2", "dbus-fast>=2.0", ] dynamic = ["version"] diff --git a/tests/test_mpris.py b/tests/test_mpris.py new file mode 100644 index 0000000..e51fd97 --- /dev/null +++ b/tests/test_mpris.py @@ -0,0 +1,136 @@ +"""ServiceInterface state-machine tests — no real D-Bus. + +dbus-fast lets us instantiate ServiceInterface subclasses without +exporting them on a bus; emit_properties_changed is a no-op until the +object is attached to a connected ``MessageBus``. That's enough to +cover the update_* contract and the backend-callback dispatch. +""" + +from __future__ import annotations + +import pytest +from dbus_fast.errors import DBusError + +from mpdris2.mpris import MediaPlayer2, MediaPlayer2Player + +# dbus-fast `@dbus_property` rewrites the decorated function into a +# regular attribute (the descriptor returns the stored value on read), +# so tests dereference these without calling them: `p.Volume`, not +# `p.Volume()`. `@method`-decorated functions stay callable normally. + + +def test_root_identity() -> None: + root = MediaPlayer2() + assert root.Identity == "Music Player Daemon" + assert root.DesktopEntry == "mpdris2" + assert root.CanQuit is False + assert root.CanRaise is False + assert root.HasTrackList is False + + +def test_player_defaults() -> None: + p = MediaPlayer2Player() + assert p.PlaybackStatus == "Stopped" + assert p.LoopStatus == "None" + assert p.Shuffle is False + assert p.Metadata == {} + assert p.Volume == 0.0 + assert p.Position == 0 + assert p.CanControl is True + assert p.CanSeek is False + + +def test_update_playback_status_valid() -> None: + p = MediaPlayer2Player() + p.update_playback_status("Playing") + assert p.PlaybackStatus == "Playing" + p.update_playback_status("Paused") + assert p.PlaybackStatus == "Paused" + + +def test_update_playback_status_invalid_is_ignored() -> None: + p = MediaPlayer2Player() + p.update_playback_status("BogusValue") + assert p.PlaybackStatus == "Stopped" + + +def test_update_loop_status() -> None: + p = MediaPlayer2Player() + p.update_loop_status("Track") + assert p.LoopStatus == "Track" + p.update_loop_status("Playlist") + assert p.LoopStatus == "Playlist" + p.update_loop_status("Invalid") + assert p.LoopStatus == "Playlist" # unchanged + + +def test_update_volume_clamps() -> None: + p = MediaPlayer2Player() + p.update_volume(1.5) + assert p.Volume == 1.0 + p.update_volume(-0.2) + assert p.Volume == 0.0 + p.update_volume(0.5) + assert p.Volume == 0.5 + + +def test_update_capabilities_changes_only() -> None: + p = MediaPlayer2Player() + p.update_capabilities(can_seek=True) + assert p.CanSeek is True + p.update_capabilities(can_go_next=False, can_seek=True) # can_seek unchanged + assert p.CanGoNext is False + assert p.CanSeek is True + + +def test_backend_callbacks_fire() -> None: + calls: list[str] = [] + p = MediaPlayer2Player( + on_play=lambda: calls.append("play"), + on_pause=lambda: calls.append("pause"), + on_play_pause=lambda: calls.append("toggle"), + on_stop=lambda: calls.append("stop"), + on_next=lambda: calls.append("next"), + on_previous=lambda: calls.append("prev"), + on_seek=lambda us: calls.append(f"seek:{us}"), + on_set_position=lambda tid, us: calls.append(f"setpos:{tid}:{us}"), + on_volume_set=lambda v: calls.append(f"vol:{v}"), + on_loop_status_set=lambda v: calls.append(f"loop:{v}"), + on_shuffle_set=lambda v: calls.append(f"shuffle:{v}"), + ) + p.Play() + p.Pause() + p.PlayPause() + p.Stop() + p.Next() + p.Previous() + p.Seek(1_000_000) + p.SetPosition("/org/mpris/MediaPlayer2/Track/42", 5_000_000) + p.Volume = 0.75 # type: ignore[misc, assignment] + p.LoopStatus = "Track" # type: ignore[misc, assignment] + p.Shuffle = True # type: ignore[misc, assignment] + assert calls == [ + "play", "pause", "toggle", "stop", "next", "prev", + "seek:1000000", + "setpos:/org/mpris/MediaPlayer2/Track/42:5000000", + "vol:0.75", + "loop:Track", + "shuffle:True", + ] + + +def test_volume_setter_emits_synchronously() -> None: + """Volume setter mutates state before invoking the backend callback, + so the synchronous PropertiesChanged emit (a no-op here, no bus) has + the new value in self._volume.""" + seen = [] + p = MediaPlayer2Player(on_volume_set=lambda v: seen.append(p._volume)) + p.Volume = 0.4 # type: ignore[misc, assignment] + assert seen == [0.4] + assert p.Volume == 0.4 + + +def test_invalid_loop_status_setter_raises() -> None: + p = MediaPlayer2Player(on_loop_status_set=lambda v: None) + with pytest.raises(DBusError): + p.LoopStatus = "Garbage" # type: ignore[misc, assignment] From 621684918b8c1bcacbffc71d7d1ed100e84e6a99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathieu=20R=C3=A9quillart?= Date: Fri, 15 May 2026 17:44:27 +0200 Subject: [PATCH 3/9] =?UTF-8?q?Add=20MPD=E2=86=92MPRIS=20tag=20translation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure mapping of MPD currentsong fields onto MPRIS Metadata keys with dbus-fast Variants. DEFAULT_URL_HANDLERS lists the schemes that should pass through unchanged (http, https, …) so streamed tracks keep their scheme intact instead of being prefixed with the music library path. CDDA / CUE tracks frequently carry only ``albumartist``. MPRIS clients overwhelmingly read ``xesam:artist`` for the track-row artist column, so the translator mirrors albumArtist into artist when artist is missing. Co-Authored-By: Claude Opus 4.7 (1M context) --- mpdris2/translate.py | 216 +++++++++++++++++++++++++++++++++ tests/test_translate.py | 256 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 472 insertions(+) create mode 100644 mpdris2/translate.py create mode 100644 tests/test_translate.py diff --git a/mpdris2/translate.py b/mpdris2/translate.py new file mode 100644 index 0000000..46b8c7c --- /dev/null +++ b/mpdris2/translate.py @@ -0,0 +1,216 @@ +"""Pure MPD → MPRIS shape conversions. + +No D-Bus, no asyncio, no I/O — just shape conversion + tag mapping + +``dbus_fast.Variant`` wrapping. Covers both currentsong() → MPRIS +Metadata (``mpd_to_mpris``) and the smaller per-field status() helpers +(``parse_volume``, ``parse_elapsed``, ``playback_status_from``, +``loop_status_from``) the bridge needs on every refresh. + +Keeping these side-effect-free makes them trivial to unit-test and +reusable: cover lookup, for instance, runs separately and adds +``mpris:artUrl`` on top of ``mpd_to_mpris``'s result. +""" + +from __future__ import annotations + +import contextlib +import re +from collections.abc import Iterable +from pathlib import Path + +from dbus_fast import Variant + +# Tags whose MPD value may legitimately be a list (multiple artists, +# multiple genres, …). For single-valued MPD tags we still wrap as a +# list when the MPRIS key is `as`-typed. +_LIST_TAGS = frozenset({"artist", "albumartist", "composer", "genre"}) + +# Default URL schemes recognised as "already a URL"; daemon overrides +# this from MPD's ``urlhandlers`` command at startup when available. +DEFAULT_URL_HANDLERS = ("http://", "https://", "mms://", "cdda://", "file://") + + +def _to_list(val: object) -> list[str]: + if isinstance(val, list): + return [str(x) for x in val] + return [str(val)] + + +def first(val: object) -> str: + if val is None: + return "" + if isinstance(val, list): + return str(val[0]) if val else "" + return str(val) + + +def _parse_leading_int(s: str) -> int | None: + m = re.match(r"^(\d+)", s) + return int(m.group(1)) if m else None + + +# --- status() helpers ----------------------------------------------------- + + +def playback_status_from(state: str) -> str: + """MPD ``state`` -> MPRIS ``PlaybackStatus``. Unknown values map to + ``Stopped`` so a malformed status never makes MPRIS lie.""" + return {"play": "Playing", "pause": "Paused", "stop": "Stopped"}.get(state, "Stopped") + + +def loop_status_from(repeat: bool, single: bool) -> str: + """MPD's two-flag (repeat, single) -> MPRIS ``LoopStatus``. + ``single`` without ``repeat`` doesn't loop, hence ``None``.""" + if repeat and single: + return "Track" + if repeat: + return "Playlist" + return "None" + + +def parse_loop_flags(status: dict) -> tuple[bool, bool]: + """Extract MPD ``(repeat, single)`` flags as booleans. Bridge keeps + ``repeat`` separately for ``CanGoNext`` (repeat ⇒ playlist wraps).""" + return ( + status.get("repeat", "0") == "1", + status.get("single", "0") == "1", + ) + + +def parse_shuffle(status: dict) -> bool: + return bool(status.get("random", "0") == "1") + + +def parse_volume(status: dict) -> float | None: + """Return MPRIS-style volume (0.0..1.0) from MPD status, or None + when MPD reports -1 (audio backend can't report — leave as-is).""" + try: + v = int(status.get("volume", -1)) + except (TypeError, ValueError): + return None + return v / 100.0 if v >= 0 else None + + +def parse_elapsed(status: dict) -> float: + try: + return float(status.get("elapsed", 0.0)) + except (TypeError, ValueError): + return 0.0 + + +def song_url( + song: dict, + music_dir: Path | None = None, + url_handlers: Iterable[str] = DEFAULT_URL_HANDLERS, +) -> str: + """Resolve MPD's ``file`` field into a MPRIS-facing URI. Returns ``""`` + when no file is set. Schemes in ``url_handlers`` are passed through + untouched; relative paths get absolutised against ``music_dir`` + (when set) and turned into ``file://`` URIs.""" + file_uri = first(song.get("file", "")) if song else "" + if not file_uri: + return "" + if any(file_uri.startswith(h) for h in url_handlers) or not music_dir: + return file_uri + return (music_dir / file_uri).as_uri() + + +# --- currentsong() -> Metadata -------------------------------------------- + + +def mpd_to_mpris( + song: dict, + music_dir: Path | None = None, + url_handlers: Iterable[str] = DEFAULT_URL_HANDLERS, +) -> dict[str, Variant]: + """Translate ``song`` (the dict returned by ``MPD.currentsong()``) + to an MPRIS Metadata dict with ``Variant``-wrapped values. + + ``music_dir`` is the local filesystem path used to absolutise + relative MPD paths into a proper ``xesam:url``. ``url_handlers`` + lists URI schemes MPD already returns as-is so we don't prepend + ``music_dir`` to them. + """ + out: dict[str, Variant] = {} + if not song: + return out + + def setv(key: str, sig: str, val: object) -> None: + out[key] = Variant(sig, val) + + # --- string tags -------------------------------------------------- + for mpd_key, mpris_key in (("title", "xesam:title"), + ("album", "xesam:album")): + if mpd_key in song: + setv(mpris_key, "s", first(song[mpd_key])) + + # --- list-valued tags -------------------------------------------- + for mpd_key, mpris_key in (("artist", "xesam:artist"), + ("albumartist", "xesam:albumArtist"), + ("composer", "xesam:composer"), + ("genre", "xesam:genre")): + if mpd_key in song: + setv(mpris_key, "as", _to_list(song[mpd_key])) + + # CDDA / CUE tracks frequently carry only ``albumartist``. MPRIS + # clients overwhelmingly read ``xesam:artist`` for the track-row + # artist column, so mirror albumArtist into artist when artist is + # missing. + if "xesam:artist" not in out and "xesam:albumArtist" in out: + out["xesam:artist"] = out["xesam:albumArtist"] + + # --- identifiers -------------------------------------------------- + if "id" in song: + setv("mpris:trackid", "o", f"/org/mpris/MediaPlayer2/Track/{first(song['id'])}") + + # --- duration ----------------------------------------------------- + # MPD has both ``time`` (seconds, deprecated) and ``duration`` + # (seconds, float, MPD >= 0.20). Prefer ``duration`` when present. + duration_s: float | None = None + if "duration" in song: + with contextlib.suppress(TypeError, ValueError): + duration_s = float(first(song["duration"])) + elif "time" in song: + with contextlib.suppress(TypeError, ValueError): + duration_s = float(first(song["time"])) + if duration_s is not None and duration_s > 0: + setv("mpris:length", "x", int(duration_s * 1_000_000)) + + # --- dates -------------------------------------------------------- + if "date" in song: + date = first(song["date"]) + # MPRIS expects ISO-8601-ish; mpDris2 historically just kept the + # leading year. Anything more elaborate is below the noise floor + # for MPRIS clients. + if len(date) >= 4 and date[:4].isdigit(): + setv("xesam:contentCreated", "s", date[:4]) + + # --- track / disc numbers ---------------------------------------- + if "track" in song: + n = _parse_leading_int(first(song["track"])) + if n is not None: + # Ensure the integer fits in a signed int32 — MPRIS uses ``i``. + if n & 0x80000000: + n -= 0x100000000 + setv("xesam:trackNumber", "i", n) + if "disc" in song: + n = _parse_leading_int(first(song["disc"])) + if n is not None: + setv("xesam:discNumber", "i", n) + + # --- stream-style metadata fallback ------------------------------- + # Some streams (web radio) only set ``name`` and ``title``: derive + # an album/title from ``name`` so MPRIS clients have something to + # display. + if "name" in song: + if "xesam:title" not in out: + setv("xesam:title", "s", first(song["name"])) + elif "xesam:album" not in out: + setv("xesam:album", "s", first(song["name"])) + + # --- url ---------------------------------------------------------- + url = song_url(song, music_dir, url_handlers) + if url: + setv("xesam:url", "s", url) + + return out diff --git a/tests/test_translate.py b/tests/test_translate.py new file mode 100644 index 0000000..d2b6f5b --- /dev/null +++ b/tests/test_translate.py @@ -0,0 +1,256 @@ +"""Pure-function tests for the translate module — no D-Bus, no MPD.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from mpdris2.translate import ( + first, + loop_status_from, + mpd_to_mpris, + parse_elapsed, + parse_loop_flags, + parse_shuffle, + parse_volume, + playback_status_from, + song_url, +) + + +def test_empty_song_returns_empty_dict() -> None: + assert mpd_to_mpris({}) == {} + + +def test_basic_tags() -> None: + m = mpd_to_mpris({ + "title": "Song", + "album": "Album", + "artist": "Artist", + "albumartist": "AA", + "composer": "C", + "genre": "Pop", + "id": "42", + "track": "3", + "disc": "2", + "duration": "245.123", + "date": "2023-06-15", + "file": "Artist/Album/03 - Song.mp3", + }, music_dir=Path("/srv/music")) + assert m["xesam:title"].value == "Song" + assert m["xesam:album"].value == "Album" + assert m["xesam:artist"].value == ["Artist"] + assert m["xesam:albumArtist"].value == ["AA"] + assert m["xesam:composer"].value == ["C"] + assert m["xesam:genre"].value == ["Pop"] + assert m["mpris:trackid"].value == "/org/mpris/MediaPlayer2/Track/42" + assert m["mpris:trackid"].signature == "o" + assert m["xesam:trackNumber"].value == 3 + assert m["xesam:discNumber"].value == 2 + assert m["mpris:length"].value == 245_123_000 # microseconds + assert m["xesam:contentCreated"].value == "2023" + # music_dir prepended for relative paths (URL-encoded by as_uri) + assert m["xesam:url"].value == "file:///srv/music/Artist/Album/03%20-%20Song.mp3" + + +def test_multi_artist_list_preserved() -> None: + m = mpd_to_mpris({"artist": ["A", "B", "C"]}) + assert m["xesam:artist"].value == ["A", "B", "C"] + assert m["xesam:artist"].signature == "as" + + +def test_artist_backfilled_from_albumartist() -> None: + # CDDA / CUE tracks frequently expose only ``albumartist``. + m = mpd_to_mpris({"albumartist": "AA", "title": "T"}) + assert m["xesam:artist"].value == ["AA"] + assert m["xesam:albumArtist"].value == ["AA"] + + +def test_artist_not_overwritten_when_present() -> None: + m = mpd_to_mpris({"artist": "Track Artist", "albumartist": "Album Artist"}) + assert m["xesam:artist"].value == ["Track Artist"] + assert m["xesam:albumArtist"].value == ["Album Artist"] + + +def test_track_with_total_only_keeps_leading_int() -> None: + # "3/12" is a common MPD format meaning track 3 of 12. + m = mpd_to_mpris({"track": "3/12"}) + assert m["xesam:trackNumber"].value == 3 + + +def test_url_with_scheme_left_untouched() -> None: + m = mpd_to_mpris( + {"file": "http://stream.example/live.mp3"}, + music_dir=Path("/srv/music"), + ) + assert m["xesam:url"].value == "http://stream.example/live.mp3" + + +def test_stream_name_fills_missing_title() -> None: + m = mpd_to_mpris({"name": "Radio Example", "file": "http://r/x.mp3"}) + assert m["xesam:title"].value == "Radio Example" + + +def test_stream_name_fills_album_when_title_present() -> None: + m = mpd_to_mpris({ + "name": "Radio Example", + "title": "Song - Artist", + }) + assert m["xesam:title"].value == "Song - Artist" + assert m["xesam:album"].value == "Radio Example" + + +def test_duration_takes_precedence_over_time() -> None: + # MPD ships both; ``duration`` is the float-precision modern one. + m = mpd_to_mpris({"time": "180", "duration": "180.456"}) + assert m["mpris:length"].value == 180_456_000 + + +def test_unparseable_track_dropped_silently() -> None: + m = mpd_to_mpris({"track": "garbage"}) + assert "xesam:trackNumber" not in m + + +def test_no_duration_no_length_key() -> None: + m = mpd_to_mpris({"title": "x"}) + assert "mpris:length" not in m + + +def test_invalid_date_dropped() -> None: + m = mpd_to_mpris({"date": "n/a"}) + assert "xesam:contentCreated" not in m + + +def test_first_handles_none() -> None: + assert first(None) == "" + + +def test_first_handles_empty_list() -> None: + assert first([]) == "" + + +# --- playback_status_from ------------------------------------------------- + +@pytest.mark.parametrize("state,expected", [ + ("play", "Playing"), + ("pause", "Paused"), + ("stop", "Stopped"), + ("", "Stopped"), + ("garbage", "Stopped"), +]) +def test_playback_status_from(state: str, expected: str) -> None: + assert playback_status_from(state) == expected + + +# --- loop_status_from ----------------------------------------------------- + +@pytest.mark.parametrize("repeat,single,expected", [ + (False, False, "None"), + (True, False, "Playlist"), + (True, True, "Track"), + (False, True, "None"), # single without repeat doesn't loop +]) +def test_loop_status_from(repeat: bool, single: bool, expected: str) -> None: + assert loop_status_from(repeat, single) == expected + + +# --- parse_loop_flags ----------------------------------------------------- + +def test_parse_loop_flags_both_off() -> None: + assert parse_loop_flags({}) == (False, False) + + +def test_parse_loop_flags_repeat_only() -> None: + assert parse_loop_flags({"repeat": "1"}) == (True, False) + + +def test_parse_loop_flags_both_on() -> None: + assert parse_loop_flags({"repeat": "1", "single": "1"}) == (True, True) + + +def test_parse_loop_flags_zero_is_false() -> None: + assert parse_loop_flags({"repeat": "0", "single": "0"}) == (False, False) + + +# --- parse_shuffle -------------------------------------------------------- + +def test_parse_shuffle_on() -> None: + assert parse_shuffle({"random": "1"}) is True + + +def test_parse_shuffle_off() -> None: + assert parse_shuffle({"random": "0"}) is False + + +def test_parse_shuffle_missing() -> None: + assert parse_shuffle({}) is False + + +# --- parse_volume --------------------------------------------------------- + +def test_parse_volume_valid() -> None: + assert parse_volume({"volume": "75"}) == 0.75 + + +def test_parse_volume_zero() -> None: + assert parse_volume({"volume": "0"}) == 0.0 + + +def test_parse_volume_missing_means_no_change() -> None: + assert parse_volume({}) is None + + +def test_parse_volume_minus_one_means_unreportable() -> None: + # MPD returns -1 when the audio backend can't report the level. + assert parse_volume({"volume": "-1"}) is None + + +def test_parse_volume_garbage_means_no_change() -> None: + assert parse_volume({"volume": "loud"}) is None + + +# --- parse_elapsed -------------------------------------------------------- + +def test_parse_elapsed_valid() -> None: + assert parse_elapsed({"elapsed": "12.345"}) == 12.345 + + +def test_parse_elapsed_missing() -> None: + assert parse_elapsed({}) == 0.0 + + +def test_parse_elapsed_garbage() -> None: + assert parse_elapsed({"elapsed": "n/a"}) == 0.0 + + +# --- song_url ------------------------------------------------------------- + +def test_song_url_relative_with_music_dir() -> None: + song = {"file": "Artist/Album/Song.flac"} + assert song_url(song, Path("/srv/music"), ["http://"]) == ( + "file:///srv/music/Artist/Album/Song.flac" + ) + + +def test_song_url_http_passes_through() -> None: + song = {"file": "http://stream.example/live.mp3"} + assert song_url(song, Path("/srv/music"), ["http://"]) == ( + "http://stream.example/live.mp3" + ) + + +def test_song_url_no_music_dir_returns_raw() -> None: + song = {"file": "Artist/Song.flac"} + assert song_url(song, None, ["http://"]) == "Artist/Song.flac" + + +def test_song_url_empty_song() -> None: + assert song_url({}, Path("/srv/music"), ["http://"]) == "" + + +def test_song_url_url_encodes_specials() -> None: + song = {"file": "Artist/Album 01/Song #1.flac"} + assert song_url(song, Path("/srv/music"), ["http://"]) == ( + "file:///srv/music/Artist/Album%2001/Song%20%231.flac" + ) From d85d3925b6396b8ac90ff3f37860bec0d2c07752 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathieu=20R=C3=A9quillart?= Date: Fri, 15 May 2026 17:44:43 +0200 Subject: [PATCH 4/9] Add async cover art finder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 5-step pipeline tried in order, authoritative-first: 1. MPD ``readpicture`` — embedded art in the audio file (FLAC PICTURE, ID3 APIC, …). Server-side parsing, works for local and remote MPD. 2. Filesystem regex match in the song's directory — uses the configurable ``cover_regex`` (default catches cover.*, folder.*, album.*, front.*) so non-standard names like ``folder.jpg`` are resolved without any temp-file copy. 3. MPD ``albumart`` — MPD resolves cover.{png,jpg,jxl,webp} server-side from the song's directory. Useful for remote MPD or standard-named covers that step 2 missed. 4. CUE/cdda fallback — when ``song_file`` is virtual (cdda://, http://, …) the audio file itself has no on-disk cover; look next to the loaded .cue playlist instead (FS scan first, MPD ``albumart`` as a remote fallback). 5. XDG cover cache — ``$XDG_CACHE_HOME/mpDris2/{artist}-{album}.jpg`` for the optional MusicBrainz / Cover Art Archive fallback (PR 5). Capability flags from the MPD command list drive whether readpicture or albumart are attempted at all (older MPDs ship neither). Bytes returned by MPD land in /tmp/cover-*.{jpg,png,…} and the URI of that file is exposed as ``mpris:artUrl``; the FS regex step returns the matched file's URI directly (no copy). Co-Authored-By: Claude Opus 4.7 (1M context) --- mpdris2/cover.py | 381 +++++++++++++++++++++++++ pyproject.toml | 1 - tests/test_cover.py | 677 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 1058 insertions(+), 1 deletion(-) create mode 100644 mpdris2/cover.py create mode 100644 tests/test_cover.py diff --git a/mpdris2/cover.py b/mpdris2/cover.py new file mode 100644 index 0000000..bdaf3a0 --- /dev/null +++ b/mpdris2/cover.py @@ -0,0 +1,381 @@ +"""Cover-art resolution: async pipeline. + +Ordered authoritative-first — the picture inside the audio file is +guaranteed to match the track, whereas a ``cover.jpg`` in the song's +directory could be stale or wrong. We accept the extra cost of +parsing/transferring for that guarantee. + +1. MPD ``readpicture`` — embedded picture in the audio file. MPD does + the format-specific parsing server-side, works for both local and + remote MPD. Bytes → tempfile. +2. Filesystem regex match in the song's directory — only when we have + local FS access. Returns the file's URI directly, no copying. The + cheapest step, but ``cover.jpg`` may not match the track exactly. +3. MPD ``albumart`` — MPD resolves ``cover.{png,jpg,jxl,webp}`` from + the song's directory server-side and ships the bytes. Useful for + remote MPD or when step 2's regex missed a standard-named cover. + Bytes → tempfile. +4. CUE/cdda fallback — when ``song_file`` is a virtual reference + (``cdda://Disc/Track01`` or a CUE playlist track) the audio file + itself has no on-disk cover; look next to the loaded ``.cue`` + playlist instead. Tries the local FS regex first (no temp-file + copy, picks up names like ``folder.jpg``), then falls back to MPD + ``albumart``. +5. XDG cover cache (``$XDG_CACHE_HOME/mpDris2/{artist}-{album}.jpg``). + +The optional MusicBrainz / Cover Art Archive fallback (PR 5) will slot +in as a sixth step. + +Requires MPD ≥ 0.22 (for ``readpicture``); the daemon won't error out +on older servers but covers for non-standardly-named files won't work. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import logging +import os +import re +import tempfile +import urllib.parse +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from pathlib import Path +from typing import IO, Any + +from mpdris2.translate import first + +logger = logging.getLogger(__name__) + +# User-side cover cache. Follows the XDG cache spec; PR 5 +# (MusicBrainz fallback) writes its downloads here and step 4 picks +# them up on the next track. +DEFAULT_COVER_CACHE_DIR = Path(os.environ.get("XDG_CACHE_HOME") or Path.home() / ".cache") / "mpDris2" + +DEFAULT_COVER_REGEX = re.compile( + r"^(album|cover|\.?folder|front).*\.(gif|jpe?g|png|webp|bmp)$", + re.I | re.X, +) + +_MIME_BY_MAGIC = ( + (b"\x89PNG\r\n\x1a\n", "image/png"), + (b"\xff\xd8", "image/jpeg"), + (b"GIF8", "image/gif"), + (b"RIFF", "image/webp"), # very rough — WebP starts with RIFF....WEBP + (b"BM", "image/bmp"), +) +_MIME_EXT = { + "image/jpeg": ".jpg", + "image/png": ".png", + "image/gif": ".gif", + "image/webp": ".webp", + "image/bmp": ".bmp", +} + + +def _detect_mime(data: bytes) -> str | None: + """Return the MIME type for ``data`` based on its magic bytes, or + ``None`` when nothing matches — better to skip the cover than to + serve unknown bytes mislabelled as JPEG.""" + for magic, mime in _MIME_BY_MAGIC: + if data.startswith(magic): + return mime + return None + + +_URI_SCHEME_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9+.-]*://") + +# A CUE virtual track surfaces as ``path/to/sheet.cue/trackNNNN`` — the +# audio it represents lives elsewhere (stream URL, raw CD, separate +# audio file referenced by the sheet), so MPD's readpicture/albumart on +# this URI always fails with ``Unrecognized URI``. The shape itself is a +# reliable marker: regular tracks never look like this. +_VIRTUAL_CUE_TRACK_RE = re.compile(r"\.cue/track\d+$", re.I) + + +def _has_uri_scheme(s: str) -> bool: + return bool(_URI_SCHEME_RE.match(s)) + + +def _is_virtual_cue_track(song_file: str) -> bool: + return bool(_VIRTUAL_CUE_TRACK_RE.search(song_file)) + + +async def _fetch_binary( + cmd: Callable[[str], Awaitable[dict[str, Any]]], + path: str, +) -> bytes | None: + """Call an MPD picture-returning command (``readpicture`` / + ``albumart``), swallow command-level errors, return the binary + payload or ``None``.""" + try: + r = await cmd(path) + except Exception as e: + logger.debug("%s %r failed: %r", cmd.__name__, path, e) + return None + if r and "binary" in r: + return bytes(r["binary"]) + return None + + +@dataclass(frozen=True) +class CoverFinderConfig: + """Construction-time settings for ``CoverFinder``. Capability flags + can still be flipped post-init via ``update_capabilities`` once the + daemon has probed MPD's command list.""" + + music_dir: Path | None = None + cover_regex: re.Pattern[str] = DEFAULT_COVER_REGEX + cover_cache_dir: Path = DEFAULT_COVER_CACHE_DIR + can_readpicture: bool = False + can_albumart: bool = False + + +@dataclass(frozen=True) +class SongLookup: + """Everything ``CoverFinder.find`` needs about one song. ``song_uri`` + is the MPRIS-facing URI we cache against; ``song_file`` is the raw + MPD ``file`` field (may be a relative path, a stream URL, or a CUE + virtual track).""" + + client: Any + song_uri: str + song_file: str + mpd_meta: dict + last_loaded_playlist: str = "" + + +class CoverFinder: + """Owns the per-track temp file for embedded covers + the MPD + capability flags (``readpicture`` / ``albumart``).""" + + def __init__(self, config: CoverFinderConfig | None = None) -> None: + config = config or CoverFinderConfig() + self._music_dir = config.music_dir + self._cover_regex = config.cover_regex + self._cache_dir = config.cover_cache_dir + self._can_readpicture = config.can_readpicture + self._can_albumart = config.can_albumart + self._temp_song_uri: str | None = None + self._temp_cover: IO[bytes] | None = None + + def update_capabilities(self, *, can_readpicture: bool, can_albumart: bool) -> None: + self._can_readpicture = can_readpicture + self._can_albumart = can_albumart + + def update_music_dir(self, music_dir: Path | None) -> None: + self._music_dir = music_dir + + # --- local song path resolution ---------------------------------- + def _song_path(self, song_uri: str) -> Path | None: + if song_uri.startswith("file://"): + return Path(urllib.parse.unquote(song_uri.removeprefix("file://"))) + if song_uri.startswith("local:track:") and self._music_dir: + return self._music_dir / urllib.parse.unquote(song_uri.removeprefix("local:track:")) + return None + + # --- public entry point ------------------------------------------ + async def find(self, req: SongLookup) -> str | None: + song_path = self._song_path(req.song_uri) + song_dir = song_path.parent if song_path else None + + # 0. Reuse the existing temp file if we already resolved this track. + if self._temp_cover is not None: + if req.song_uri == self._temp_song_uri: + return Path(self._temp_cover.name).as_uri() + self._discard_temp() + + # readpicture/albumart need a song_file that MPD can resolve to + # actual audio bytes. Skip URI schemes (cdda://, http://, … — + # readpicture stalls the MPD connection on these, commit + # 234d6da) and CUE virtual tracks (``sheet.cue/trackNNNN`` — + # MPD rejects them with ``Unrecognized URI``). Step 4 picks up + # both cases. + can_query_picture = ( + bool(req.song_file) and not _has_uri_scheme(req.song_file) and not _is_virtual_cue_track(req.song_file) + ) + + # 1. MPD readpicture — embedded picture inside the audio file. + if can_query_picture: + data = await self._try_readpicture(req.client, req.song_file) + cover = self._materialise_bytes(req.song_uri, data, req.song_file) + if cover: + return cover + + # 2. Filesystem regex in the song's directory — local FS, direct URI. + cover = await self._scan_song_dir(song_dir) + if cover: + return cover + + # 3. MPD albumart — MPD reads cover.{jpg,png,…} from the song's + # directory. Useful for remote MPD or when step 2's regex + # missed. + if can_query_picture: + data = await self._try_albumart(req.client, req.song_file) + cover = self._materialise_bytes(req.song_uri, data, req.song_file) + if cover: + return cover + + # 4. CUE/cdda fallback — the song_file is a virtual reference + # (``cdda://Disc/Track01``, ``playlist.cue/track0001``) with + # no real on-disk file. Look for a cover next to the loaded + # .cue playlist (FS scan first, then MPD ``albumart``). + if req.mpd_meta: + cover = await self._cue_fallback(req) + if cover: + return cover + + # 5. Downloaded-covers cache (XDG). + cover = self._lookup_downloads_cache(req.mpd_meta) + if cover: + return cover + + return None + + # --- step 1 + 3 helpers: MPD protocol ---------------------------- + async def _try_readpicture(self, client: Any, path: str) -> bytes | None: + if not self._can_readpicture: + return None + return await _fetch_binary(client.readpicture, path) + + async def _try_albumart(self, client: Any, path: str) -> bytes | None: + if not self._can_albumart: + return None + return await _fetch_binary(client.albumart, path) + + async def _cue_fallback(self, req: SongLookup) -> str | None: + """When ``song_file`` is a CUE virtual track (cdda://, http://, + …) the audio file itself has no on-disk cover and MPD's + ``albumart`` on the song path fails. The only useful fallback + is the directory holding the CUE itself — that's where the + cover typically lives. Try a local FS regex scan first (no + temp-file copy, picks up non-standard names like + ``folder.jpg``); on failure ask MPD's ``albumart`` to resolve + ``cover.{png,jpg,jxl,webp}`` server-side.""" + cue_dir = self._resolve_cue_dir(req) + if not cue_dir: + return None + if self._music_dir: + cover = await self._scan_song_dir(self._music_dir / cue_dir) + if cover: + return cover + # MPD's albumart command scans the file's parent directory + # server-side for cover.{png,jpg,jxl,webp} — one call is + # enough, the path-suffix we pass is just a directory hint. + data = await self._try_albumart(req.client, str(cue_dir / "cover")) + return self._materialise_bytes(req.song_uri, data, req.song_file) + + def _resolve_cue_dir(self, req: SongLookup) -> Path | None: + """Directory holding the CUE container, relative to ``music_dir``. + Prefer ``status.lastloadedplaylist`` when MPD set it (i.e. the + playlist was added via ``load``); otherwise infer from + ``song_file`` itself.""" + return self._cue_dir_from_playlist(req.last_loaded_playlist) or self._cue_dir_from_song_file(req.song_file) + + def _cue_dir_from_playlist(self, playlist: str) -> Path | None: + """``status.lastloadedplaylist`` is an absolute path on disk when + the CUE was added via ``load``. Strip the ``music_dir`` prefix + (if any) and return the parent directory.""" + if not playlist: + return None + if self._music_dir: + md_str = str(self._music_dir) + if playlist.startswith(md_str): + playlist = playlist[len(md_str) :] + cue_dir = Path(playlist.lstrip("/")).parent + return cue_dir if str(cue_dir) not in ("", ".") else None + + def _cue_dir_from_song_file(self, song_file: str) -> Path | None: + """A CUE virtual track has the form ``dir/sheet.cue/trackNNNN``. + The grandparent is the cue dir — needed when + ``lastloadedplaylist`` is empty (CUE added via ``add`` rather + than ``load``). No filesystem check: ``_is_virtual_cue_track`` + is a reliable shape marker, so this works without ``music_dir`` + being configured.""" + if not song_file or not _is_virtual_cue_track(song_file): + return None + grand = Path(song_file).parent.parent + return grand if str(grand) not in ("", ".") else None + + def _materialise_bytes( + self, + song_uri: str, + data: bytes | None, + log_origin: str, + ) -> str | None: + """Wrap mime detection + materialise; returns None for empty or + unrecognised data and logs a warning in the latter case.""" + if not data: + return None + mime = _detect_mime(data) + if mime is None: + logger.warning( + "MPD returned %d bytes of unrecognised image data for %r; skipping", + len(data), + log_origin, + ) + return None + return self._materialise(song_uri, data, mime) + + # --- step 2: filesystem regex ----------------------------------- + async def _scan_song_dir(self, song_dir: Path | None) -> str | None: + if not song_dir: + return None + + def _scan() -> str | None: + if not song_dir.is_dir(): + return None + try: + # Sort: iterdir() order is filesystem-dependent. + entries = sorted(song_dir.iterdir(), key=lambda e: e.name) + except OSError as e: + logger.debug("cover: scan %s failed: %s", song_dir, e) + return None + for entry in entries: + if self._cover_regex.match(entry.name): + logger.debug("cover: regex matched %r in %s", entry.name, song_dir) + return entry.as_uri() + return None + + return await asyncio.to_thread(_scan) + + # --- step 5: downloaded-covers cache ---------------------------- + def _lookup_downloads_cache(self, mpd_meta: dict) -> str | None: + artist = first(mpd_meta.get("artist")) + album = first(mpd_meta.get("album")) + if not artist or not album: + return None + + # ``/`` would escape ``_cache_dir`` (e.g. "AC/DC"). + safe_name = f"{artist}-{album}.jpg".replace("/", "_") + path = self._cache_dir / safe_name + return path.as_uri() if path.exists() else None + + # --- internal helpers -------------------------------------------- + def _materialise(self, song_uri: str, data: bytes, mime: str) -> str: + ext = _MIME_EXT.get(mime, ".jpg") + # delete=True cleans up on normal interpreter shutdown via GC, + # and the daemon calls ``_discard_temp`` explicitly on exit. + # Hard kills (SIGKILL, OOM) leak the file until /tmp is purged + # — acceptable since covers are a few KB on tmpfs. Lifetime + # extends past this function (caller holds via + # ``self._temp_cover``), hence the SIM115 silence. + tmp = tempfile.NamedTemporaryFile(prefix="cover-", suffix=ext) # noqa: SIM115 + tmp.write(data) + tmp.flush() + self._temp_cover = tmp + self._temp_song_uri = song_uri + logger.debug("cover: stored embedded image at %r", tmp.name) + return Path(tmp.name).as_uri() + + def close(self) -> None: + """Release the per-track temp cover. Daemon calls this at shutdown.""" + self._discard_temp() + + def _discard_temp(self) -> None: + if self._temp_cover is not None: + with contextlib.suppress(Exception): + self._temp_cover.close() + self._temp_cover = None + self._temp_song_uri = None diff --git a/pyproject.toml b/pyproject.toml index 9720a25..3a7c63b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,6 @@ dependencies = [ dynamic = ["version"] [project.optional-dependencies] -cover = ["mutagen>=1.45"] dev = ["pytest", "pytest-asyncio", "mypy", "ruff", "babel", "build"] [project.scripts] diff --git a/tests/test_cover.py b/tests/test_cover.py new file mode 100644 index 0000000..1a0d6a6 --- /dev/null +++ b/tests/test_cover.py @@ -0,0 +1,677 @@ +"""Unit tests for cover.py — pure helpers, filesystem steps, and the +async ``find`` orchestration with a stubbed MPD client. + +Mutagen extraction (step 2) is not exercised here: it would need a +real media file with embedded art per format. Covered by integration. +""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from mpdris2.cover import ( + CoverFinder, + CoverFinderConfig, + SongLookup, + _detect_mime, + _has_uri_scheme, + _is_virtual_cue_track, +) + +# --- _detect_mime --------------------------------------------------------- + +@pytest.mark.parametrize("data,expected", [ + (b"\x89PNG\r\n\x1a\n...", "image/png"), + (b"\xff\xd8\xff\xe0...", "image/jpeg"), + (b"GIF89a...", "image/gif"), + (b"RIFF\x00\x00\x00\x00WEBP", "image/webp"), + (b"BM\x36\x00\x00\x00...", "image/bmp"), +]) +def test_detect_mime_known_magics(data: bytes, expected: str) -> None: + assert _detect_mime(data) == expected + + +@pytest.mark.parametrize("data", [b"", b"random garbage", b"TIFF\x00"]) +def test_detect_mime_unknown_returns_none(data: bytes) -> None: + assert _detect_mime(data) is None + + +# --- DEFAULT_COVER_REGEX -------------------------------------------------- + +@pytest.mark.parametrize("name", [ + "cover.jpg", "cover.jpeg", "cover.png", "cover.gif", "cover.webp", + "cover.bmp", "Cover.JPG", "album.png", "folder.jpg", ".folder.jpg", + "front.jpeg", +]) +def test_default_cover_regex_matches(name: str) -> None: + from mpdris2.cover import DEFAULT_COVER_REGEX + assert DEFAULT_COVER_REGEX.match(name) + + +@pytest.mark.parametrize("name", [ + "song.flac", "readme.txt", "cover.txt", "back.jpg", "cover.tiff", +]) +def test_default_cover_regex_rejects(name: str) -> None: + from mpdris2.cover import DEFAULT_COVER_REGEX + assert not DEFAULT_COVER_REGEX.match(name) + + +# --- _has_uri_scheme ------------------------------------------------------ + +@pytest.mark.parametrize("s", [ + "http://x", "https://x", "cdda://Disc1", "file:///x", +]) +def test_has_uri_scheme_authority_form(s: str) -> None: + """Only ``scheme://`` (authority-style) URIs trip the check; that's + what callers want — readpicture stalls on those but not on plain + relative MPD paths.""" + assert _has_uri_scheme(s) + + +@pytest.mark.parametrize("s", [ + "Artist/Song.flac", "/abs/path/song.flac", + "", "no_scheme_here", "ftp_no_colon_slash", + # local:track:... is the mopidy convention; lacks "//" so the + # check returns False and step 1 will still try readpicture. + "local:track:Artist/Song.flac", +]) +def test_has_uri_scheme_false(s: str) -> None: + assert not _has_uri_scheme(s) + + +# --- _is_virtual_cue_track ------------------------------------------------ + +@pytest.mark.parametrize("s", [ + "Artist/playlist.cue/track0001", + ".disc-cuer/9c0bf40c/playlist.cue/track0001", + "GrosseRadioReggae/playlist.cue/track0001", + # case-insensitive + "Artist/PLAYLIST.CUE/track0001", + # tail digits aren't fixed-width + "dir/sheet.cue/track1", + "dir/sheet.cue/track99999", +]) +def test_is_virtual_cue_track_true(s: str) -> None: + """Matches MPD's ``sheet.cue/trackNNNN`` virtual-track shape — the + marker we use to bypass readpicture/albumart (they fail on these) + and derive the cue dir from the path.""" + assert _is_virtual_cue_track(s) + + +@pytest.mark.parametrize("s", [ + # plain audio files + "Artist/Album/track.flac", + "Artist/Album/track.mp3", + # the .cue sheet itself, not a virtual track inside it + "Artist/playlist.cue", + # URI schemes — handled by ``_has_uri_scheme`` instead + "cdda:///1", + "http://example.com/stream.mp3", + # embedded-CUE containers (.flac/.ape/.wv): out of scope for now, + # the helper deliberately matches only ``.cue/trackNNNN`` + "Artist/album.flac/track01", + # non-track suffix + "Artist/playlist.cue/cover.jpg", + # empty / no slash + "", + "playlist.cue", +]) +def test_is_virtual_cue_track_false(s: str) -> None: + assert not _is_virtual_cue_track(s) + + +# --- CoverFinder constructor + setters ----------------------------------- + +def test_default_capabilities_off() -> None: + cf = CoverFinder() + assert cf._can_readpicture is False + assert cf._can_albumart is False + + +def test_update_capabilities() -> None: + cf = CoverFinder() + cf.update_capabilities(can_readpicture=True, can_albumart=False) + assert cf._can_readpicture is True + assert cf._can_albumart is False + + +def test_update_music_dir_round_trip() -> None: + cf = CoverFinder() + cf.update_music_dir(Path("/srv/music")) + assert cf._music_dir == Path("/srv/music") + cf.update_music_dir(None) + assert cf._music_dir is None + + +# --- _song_path ---------------------------------------------------------- + +def test_song_path_file_uri() -> None: + cf = CoverFinder() + assert cf._song_path("file:///srv/music/x.flac") == Path("/srv/music/x.flac") + + +def test_song_path_file_uri_url_decoded() -> None: + # ``Path.as_uri()`` URL-encodes spaces / accents — ``_song_path`` must + # reverse it, otherwise ``Path(...).is_dir()`` short-circuits and + # ``_scan_song_dir`` silently misses the cover. + cf = CoverFinder() + p = cf._song_path("file:///srv/music/Some%20Album/Song%20%231.flac") + assert p == Path("/srv/music/Some Album/Song #1.flac") + + +def test_song_path_local_track_with_music_dir() -> None: + cf = CoverFinder(CoverFinderConfig(music_dir=Path("/srv/music"))) + p = cf._song_path("local:track:Artist/Song.flac") + assert p == Path("/srv/music/Artist/Song.flac") + + +def test_song_path_local_track_url_decoded() -> None: + cf = CoverFinder(CoverFinderConfig(music_dir=Path("/srv/music"))) + p = cf._song_path("local:track:Artist/Song%20%231.flac") + assert p == Path("/srv/music/Artist/Song #1.flac") + + +def test_song_path_local_track_without_music_dir() -> None: + cf = CoverFinder() + assert cf._song_path("local:track:Artist/Song.flac") is None + + +def test_song_path_other_scheme() -> None: + cf = CoverFinder(CoverFinderConfig(music_dir=Path("/srv/music"))) + assert cf._song_path("http://stream.example/live.mp3") is None + assert cf._song_path("cdda://Disc/Track01") is None + + +# --- _scan_song_dir ------------------------------------------------------ + +@pytest.mark.asyncio +async def test_scan_song_dir_matches_cover_jpg(tmp_path: Path) -> None: + (tmp_path / "cover.jpg").touch() + (tmp_path / "song.flac").touch() + cf = CoverFinder() + assert await cf._scan_song_dir(tmp_path) == (tmp_path / "cover.jpg").as_uri() + + +@pytest.mark.asyncio +async def test_scan_song_dir_matches_folder_png(tmp_path: Path) -> None: + (tmp_path / "folder.png").touch() + cf = CoverFinder() + assert await cf._scan_song_dir(tmp_path) == (tmp_path / "folder.png").as_uri() + + +@pytest.mark.asyncio +async def test_scan_song_dir_no_match(tmp_path: Path) -> None: + (tmp_path / "readme.txt").touch() + cf = CoverFinder() + assert await cf._scan_song_dir(tmp_path) is None + + +@pytest.mark.asyncio +async def test_scan_song_dir_none() -> None: + cf = CoverFinder() + assert await cf._scan_song_dir(None) is None + + +@pytest.mark.asyncio +async def test_scan_song_dir_nonexistent(tmp_path: Path) -> None: + cf = CoverFinder() + assert await cf._scan_song_dir(tmp_path / "does_not_exist") is None + + +@pytest.mark.asyncio +async def test_scan_song_dir_url_encodes_filename(tmp_path: Path) -> None: + (tmp_path / "cover with space.jpg").touch() + cf = CoverFinder() + result = await cf._scan_song_dir(tmp_path) + assert result is not None + assert "cover%20with%20space.jpg" in result + + +@pytest.mark.asyncio +async def test_scan_song_dir_deterministic_on_multiple_matches( + tmp_path: Path, +) -> None: + # iterdir() ordering is filesystem-dependent; the scan must pick + # the same file on every run regardless of creation order. + for name in ("front.jpg", "album.png", "cover.jpg", "folder.png"): + (tmp_path / name).touch() + cf = CoverFinder() + result = await cf._scan_song_dir(tmp_path) + assert result == (tmp_path / "album.png").as_uri() + + +@pytest.mark.asyncio +async def test_scan_song_dir_swallows_oserror( + tmp_path: Path, monkeypatch, +) -> None: + # TOCTOU: dir vanishes between is_dir() and iterdir(); the scan + # must log+return None rather than bubble up. + def _raise(self) -> None: + raise PermissionError(13, "denied") + monkeypatch.setattr(Path, "iterdir", _raise) + cf = CoverFinder() + assert await cf._scan_song_dir(tmp_path) is None + + +# --- _lookup_downloads_cache --------------------------------------------- + +def test_lookup_downloads_cache_hit(tmp_path: Path) -> None: + (tmp_path / "Artist-Album.jpg").touch() + cf = CoverFinder(CoverFinderConfig(cover_cache_dir=tmp_path)) + result = cf._lookup_downloads_cache({"artist": "Artist", "album": "Album"}) + assert result == (tmp_path / "Artist-Album.jpg").as_uri() + + +def test_lookup_downloads_cache_miss(tmp_path: Path) -> None: + cf = CoverFinder(CoverFinderConfig(cover_cache_dir=tmp_path)) + result = cf._lookup_downloads_cache({"artist": "Artist", "album": "Album"}) + assert result is None + + +def test_lookup_downloads_cache_missing_artist(tmp_path: Path) -> None: + cf = CoverFinder(CoverFinderConfig(cover_cache_dir=tmp_path)) + assert cf._lookup_downloads_cache({"album": "Album"}) is None + + +def test_lookup_downloads_cache_missing_album(tmp_path: Path) -> None: + cf = CoverFinder(CoverFinderConfig(cover_cache_dir=tmp_path)) + assert cf._lookup_downloads_cache({"artist": "Artist"}) is None + + +def test_lookup_downloads_cache_sanitizes_slash(tmp_path: Path) -> None: + # "AC/DC" must not escape the cache dir into ``tmp_path/AC/DC-...``. + (tmp_path / "AC_DC-Back in Black.jpg").touch() + cf = CoverFinder(CoverFinderConfig(cover_cache_dir=tmp_path)) + result = cf._lookup_downloads_cache( + {"artist": "AC/DC", "album": "Back in Black"}, + ) + assert result == (tmp_path / "AC_DC-Back in Black.jpg").as_uri() + + +def test_lookup_downloads_cache_list_artist_uses_first(tmp_path: Path) -> None: + (tmp_path / "A-B.jpg").touch() + cf = CoverFinder(CoverFinderConfig(cover_cache_dir=tmp_path)) + result = cf._lookup_downloads_cache( + {"artist": ["A", "X", "Y"], "album": "B"} + ) + assert result == (tmp_path / "A-B.jpg").as_uri() + + +# --- _materialise + temp reuse via find() -------------------------------- + +def test_materialise_writes_bytes_at_returned_uri() -> None: + cf = CoverFinder() + uri = cf._materialise("file:///srv/music/x.flac", b"PNGDATA", "image/png") + assert uri.startswith("file://") + path = Path(uri[7:]) + assert path.exists() + assert path.suffix == ".png" + assert path.read_bytes() == b"PNGDATA" + cf._discard_temp() + assert not path.exists() + + +def test_materialise_uses_jpg_for_unknown_mime() -> None: + cf = CoverFinder() + uri = cf._materialise("file:///x", b"raw", "image/x-weird") + assert uri.endswith(".jpg") + cf._discard_temp() + + +def test_discard_temp_no_op_when_empty() -> None: + cf = CoverFinder() + cf._discard_temp() # should not raise even when nothing is held + assert cf._temp_cover is None + assert cf._temp_song_uri is None + + +# --- find() orchestration with mocked MPD client ------------------------- + +def _client_with( + readpicture=None, albumart=None, find=None, +) -> MagicMock: + """Build a MagicMock client where the named coros return the given + payloads. Each is AsyncMock so ``await`` works. ``__name__`` is set + on each so the cover-finder code can introspect it (it derives the + matching capability flag from the method name).""" + c = MagicMock() + for name, payload in ( + ("readpicture", readpicture or {}), + ("albumart", albumart or {}), + ): + mock = AsyncMock(return_value=payload) + mock.__name__ = name + setattr(c, name, mock) + c.find = AsyncMock(return_value=find or []) + return c + + +@pytest.mark.asyncio +async def test_find_step1_returns_mpd_readpicture_cover() -> None: + cf = CoverFinder(CoverFinderConfig(can_readpicture=True)) + client = _client_with(readpicture={"binary": b"\xff\xd8JPEGDATA"}) + uri = await cf.find(SongLookup( + client=client, + song_uri="file:///srv/music/Song.flac", + song_file="Song.flac", + mpd_meta={}, + )) + assert uri is not None + assert uri.startswith("file://") + path = Path(uri[7:]) + assert path.read_bytes().startswith(b"\xff\xd8") + cf._discard_temp() + + +@pytest.mark.asyncio +async def test_find_step1_skipped_for_uri_scheme() -> None: + """song_file with a URI scheme (cdda://, http://) must NOT trigger + readpicture — it stalls the MPD connection (commit 234d6da).""" + cf = CoverFinder(CoverFinderConfig(can_readpicture=True)) + client = _client_with(readpicture={"binary": b"\xff\xd8X"}) + await cf.find(SongLookup( + client=client, + song_uri="cdda://Disc1/Track01", + song_file="cdda://Disc1/Track01", + mpd_meta={}, + )) + client.readpicture.assert_not_called() + + +@pytest.mark.asyncio +async def test_find_falls_through_to_step3_filesystem( + tmp_path: Path, +) -> None: + """No MPD readpicture (caps off) — falls through to the FS scan + which finds cover.jpg directly (no tempfile).""" + song_dir = tmp_path / "Artist" / "Album" + song_dir.mkdir(parents=True) + (song_dir / "cover.jpg").touch() + song_path = song_dir / "song.flac" + song_path.touch() + + cf = CoverFinder(CoverFinderConfig( + music_dir=tmp_path, can_readpicture=False, can_albumart=False, + )) + uri = await cf.find(SongLookup( + client=_client_with(), + song_uri=song_path.as_uri(), + song_file=str(song_path.relative_to(tmp_path)), + mpd_meta={}, + )) + assert uri == (song_dir / "cover.jpg").as_uri() + + +@pytest.mark.asyncio +async def test_find_falls_through_to_step4_downloads_cache( + tmp_path: Path, +) -> None: + cache_dir = tmp_path / "cache" + cache_dir.mkdir() + (cache_dir / "Artist-Album.jpg").touch() + music_dir = tmp_path / "music" + music_dir.mkdir() + + cf = CoverFinder(CoverFinderConfig( + music_dir=music_dir, cover_cache_dir=cache_dir, + )) + uri = await cf.find(SongLookup( + client=_client_with(), + song_uri=(music_dir / "Song.flac").as_uri(), + song_file="Song.flac", + mpd_meta={"artist": "Artist", "album": "Album"}, + )) + assert uri == (cache_dir / "Artist-Album.jpg").as_uri() + + +@pytest.mark.asyncio +async def test_find_returns_none_when_nothing_matches( + tmp_path: Path, +) -> None: + cache_dir = tmp_path / "cache" + cache_dir.mkdir() + music_dir = tmp_path / "music" + music_dir.mkdir() + cf = CoverFinder(CoverFinderConfig( + music_dir=music_dir, cover_cache_dir=cache_dir, + )) + uri = await cf.find(SongLookup( + client=_client_with(), + song_uri=(music_dir / "Nope.flac").as_uri(), + song_file="Nope.flac", + mpd_meta={}, + )) + assert uri is None + + +@pytest.mark.asyncio +async def test_find_reuses_temp_for_same_song_uri() -> None: + cf = CoverFinder(CoverFinderConfig(can_readpicture=True)) + client = _client_with(readpicture={"binary": b"\xff\xd8data1"}) + req = SongLookup( + client=client, song_uri="file:///x.flac", song_file="x.flac", mpd_meta={}, + ) + uri1 = await cf.find(req) + # second call shouldn't touch MPD again + client.readpicture.reset_mock() + uri2 = await cf.find(req) + assert uri1 == uri2 + client.readpicture.assert_not_called() + cf._discard_temp() + + +@pytest.mark.asyncio +async def test_find_discards_temp_when_song_uri_changes() -> None: + cf = CoverFinder(CoverFinderConfig(can_readpicture=True)) + client = _client_with(readpicture={"binary": b"\xff\xd8first"}) + uri1 = await cf.find(SongLookup( + client=client, song_uri="file:///a.flac", song_file="a.flac", mpd_meta={}, + )) + # change the cover payload so we can distinguish + client.readpicture = AsyncMock(return_value={"binary": b"\xff\xd8second"}) + client.readpicture.__name__ = "readpicture" + uri2 = await cf.find(SongLookup( + client=client, song_uri="file:///b.flac", song_file="b.flac", mpd_meta={}, + )) + assert uri1 != uri2 + # first file should be gone + assert not Path(uri1[7:]).exists() + cf._discard_temp() + + +@pytest.mark.asyncio +async def test_find_unknown_mime_skips_cover() -> None: + """MPD returned bytes we can't identify — better skip than serve + garbage as JPEG.""" + cf = CoverFinder(CoverFinderConfig(can_readpicture=True)) + client = _client_with(readpicture={"binary": b"\x00\x01\x02\x03not_an_image"}) + uri = await cf.find(SongLookup( + client=client, song_uri="file:///x.flac", song_file="x.flac", mpd_meta={}, + )) + assert uri is None + + +# --- _cue_dir_from_playlist / _cue_dir_from_song_file ------------------ + +def test_cue_dir_from_playlist_strips_music_dir_prefix() -> None: + cf = CoverFinder(CoverFinderConfig(music_dir=Path("/srv/music"))) + assert cf._cue_dir_from_playlist("/srv/music/Artist/album.cue") == Path("Artist") + + +def test_cue_dir_from_playlist_relative_path() -> None: + cf = CoverFinder(CoverFinderConfig(music_dir=Path("/srv/music"))) + assert cf._cue_dir_from_playlist("Artist/album.cue") == Path("Artist") + + +def test_cue_dir_from_playlist_empty_returns_none() -> None: + cf = CoverFinder(CoverFinderConfig()) + assert cf._cue_dir_from_playlist("") is None + + +def test_cue_dir_from_playlist_top_level_returns_none() -> None: + # "album.cue" has no parent dir under music_dir — nothing to scan. + cf = CoverFinder(CoverFinderConfig(music_dir=Path("/srv/music"))) + assert cf._cue_dir_from_playlist("/srv/music/album.cue") is None + + +def test_cue_dir_from_song_file_uses_grandparent() -> None: + cf = CoverFinder(CoverFinderConfig()) + assert cf._cue_dir_from_song_file( + "Artist/playlist.cue/track0001" + ) == Path("Artist") + + +def test_cue_dir_from_song_file_regular_track_returns_none() -> None: + # Regular track ("Artist/Album/track.flac") — not a CUE virtual + # track, leave it for the normal step 1/2/3. + cf = CoverFinder(CoverFinderConfig()) + assert cf._cue_dir_from_song_file("Artist/Album/track.flac") is None + + +def test_cue_dir_from_song_file_uri_scheme_returns_none() -> None: + cf = CoverFinder(CoverFinderConfig()) + assert cf._cue_dir_from_song_file("cdda:///1") is None + + +def test_cue_dir_from_song_file_works_without_music_dir() -> None: + # The ``.cue/trackNNNN`` shape is a reliable marker — no need to + # stat the filesystem, so the fallback works even when the user + # hasn't configured ``music_dir``. + cf = CoverFinder(CoverFinderConfig()) + assert cf._cue_dir_from_song_file( + "GrosseRadioReggae/playlist.cue/track0001" + ) == Path("GrosseRadioReggae") + + +def test_cue_dir_from_song_file_top_level_container_returns_none() -> None: + # "playlist.cue/track0001" — grandparent is "." → nothing to scan. + cf = CoverFinder(CoverFinderConfig()) + assert cf._cue_dir_from_song_file("playlist.cue/track0001") is None + + +# --- _cue_fallback (CUE/cdda fallback) ---------------------------------- + +@pytest.mark.asyncio +async def test_cue_fallback_fs_scan_short_circuits_albumart(tmp_path: Path) -> None: + # CUE on local FS with a regex-matched cover next to it → FS scan + # returns the file URI directly, no MPD albumart round-trip and no + # /tmp copy. + cue_dir = tmp_path / ".disc-cuer/abc" + cue_dir.mkdir(parents=True) + (cue_dir / "folder.jpg").touch() + cf = CoverFinder(CoverFinderConfig( + music_dir=tmp_path, can_albumart=True, + )) + client = _client_with(albumart={"binary": b"\xff\xd8JPEG"}) + uri = await cf._cue_fallback(SongLookup( + client=client, + song_uri="cdda://Disc1/Track01", + song_file="cdda:///1", + mpd_meta={"track": "1"}, + last_loaded_playlist=str(cue_dir / "playlist.cue"), + )) + assert uri == (cue_dir / "folder.jpg").as_uri() + client.albumart.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_cue_fallback_albumart_in_cue_dir() -> None: + cf = CoverFinder(CoverFinderConfig( + music_dir=Path("/srv/music"), can_albumart=True, + )) + client = _client_with(albumart={"binary": b"\xff\xd8JPEG"}) + uri = await cf._cue_fallback(SongLookup( + client=client, + song_uri="cdda://Disc1/Track01", + song_file="cdda:///1", + mpd_meta={"track": "1"}, + last_loaded_playlist="/srv/music/.disc-cuer/abc/playlist.cue", + )) + assert uri is not None + # Exactly one albumart call, in the CUE's parent dir — MPD's + # albumart command resolves cover.{png,jpg,jxl,webp} server-side, + # so the path-suffix we pass is just a directory hint. + client.albumart.assert_awaited_once() + queried = client.albumart.await_args_list[0].args[0] + assert queried.startswith(".disc-cuer/abc/") + assert "playlist.cue/" not in queried + + +@pytest.mark.asyncio +async def test_cue_fallback_returns_none_without_playlist() -> None: + cf = CoverFinder(CoverFinderConfig(can_albumart=True)) + uri = await cf._cue_fallback(SongLookup( + client=_client_with(albumart={"binary": b"x"}), + song_uri="cdda://Disc1/Track01", + song_file="cdda:///1", + mpd_meta={"track": "1"}, + last_loaded_playlist="", + )) + assert uri is None + + +@pytest.mark.asyncio +async def test_cue_fallback_infers_from_song_file_when_playlist_empty( + tmp_path: Path, +) -> None: + # MPD only fills ``lastloadedplaylist`` when the CUE was added via + # ``load`` — adding it through ``add`` leaves the field empty. + # Derive the cue dir from ``song_file`` itself: a virtual track + # ``dir/sheet.cue/trackNNNN`` means the grandparent holds the + # cover. With music_dir set, the FS scan short-circuits albumart. + album_dir = tmp_path / "GrosseRadioReggae" + album_dir.mkdir() + (album_dir / "cover.png").touch() + cf = CoverFinder(CoverFinderConfig(music_dir=tmp_path, can_albumart=True)) + client = _client_with(albumart={"binary": b"\xff\xd8JPEG"}) + uri = await cf._cue_fallback(SongLookup( + client=client, + song_uri=(album_dir / "playlist.cue/track0001").as_uri(), + song_file="GrosseRadioReggae/playlist.cue/track0001", + mpd_meta={"title": "Track 1"}, + last_loaded_playlist="", + )) + assert uri == (album_dir / "cover.png").as_uri() + client.albumart.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_cue_fallback_infers_from_song_file_without_music_dir() -> None: + # Real-world case: user has no music_dir configured and adds a CUE + # via ``add`` (so lastloadedplaylist is empty too). We still want + # the albumart call against the cue dir to fire — that's the only + # way the cover surfaces. + cf = CoverFinder(CoverFinderConfig(can_albumart=True)) + client = _client_with(albumart={"binary": b"\xff\xd8JPEG"}) + uri = await cf._cue_fallback(SongLookup( + client=client, + song_uri="file:///irrelevant", + song_file="GrosseRadioReggae/playlist.cue/track0001", + mpd_meta={"title": "Track 1"}, + last_loaded_playlist="", + )) + assert uri is not None + client.albumart.assert_awaited_once() + queried = client.albumart.await_args_list[0].args[0] + assert queried.startswith("GrosseRadioReggae/") + assert "playlist.cue/" not in queried + + +@pytest.mark.asyncio +async def test_cue_fallback_returns_none_when_no_cover() -> None: + cf = CoverFinder(CoverFinderConfig( + music_dir=Path("/srv/music"), can_albumart=True, + )) + uri = await cf._cue_fallback(SongLookup( + client=_client_with(albumart={}), + song_uri="cdda://Disc1/Track01", + song_file="cdda:///1", + mpd_meta={"track": "1"}, + last_loaded_playlist="/srv/music/.disc-cuer/abc/playlist.cue", + )) + assert uri is None + + From bcac7d2b959a53daa4d44d88998f8b097365dd81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathieu=20R=C3=A9quillart?= Date: Fri, 15 May 2026 17:45:00 +0200 Subject: [PATCH 5/9] Add libnotify bubble helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thin libnotify wrapper built on dbus-fast — no PyGObject dependency. Notifier.notify(title, body, icon) fires (or replaces) a desktop bubble through the freedesktop Notifications interface; the daemon calls it on track change when the [Notify] section enables it. Tuning lives in two frozen dataclasses: * ``NotifierConfig(urgency, timeout)`` — display knobs forwarded to the notification server (urgency 0/1/2, expire_timeout in ms or -1 to defer to the server's default). * ``NotifyTemplates(summary, body, paused_summary, paused_body)`` — optional ``%placeholder%`` format strings consumed by ``format_template`` (placeholders: %album% %title% %id% %time% %timeposition% %date% %track% %disc% %artist% %albumartist% %composer% %genre% %file%). Empty templates fall back to built-in defaults; paused_* falls back to the playing template before the built-in default. ``_format_duration`` mirrors the original ``convert_timestamp`` so %time%/%timeposition% render as ``M:SS`` (or ``H:MM:SS`` past an hour). Unknown placeholders are left untouched rather than raising — friendlier when users typo. Co-Authored-By: Claude Opus 4.7 (1M context) --- mpdris2/notify.py | 213 +++++++++++++++++++++++++++++++++++++++++++ tests/test_notify.py | 156 +++++++++++++++++++++++++++++++ 2 files changed, 369 insertions(+) create mode 100644 mpdris2/notify.py create mode 100644 tests/test_notify.py diff --git a/mpdris2/notify.py b/mpdris2/notify.py new file mode 100644 index 0000000..bc40d59 --- /dev/null +++ b/mpdris2/notify.py @@ -0,0 +1,213 @@ +"""Desktop notifications via dbus-fast. + +Talks to ``org.freedesktop.Notifications`` directly — no PyGObject / +gi.repository.Notify. The wrapper remembers the last notification id +so subsequent calls *replace* the existing bubble instead of stacking +new ones (matches the behaviour of the original libnotify-based +``NotifyWrapper``). +""" + +from __future__ import annotations + +import logging +import re +import urllib.parse +from dataclasses import dataclass +from gettext import gettext as _ +from typing import Any + +from dbus_fast import Message, MessageType, Variant +from dbus_fast.aio import MessageBus + +from mpdris2.mpris import DESKTOP_ENTRY + +logger = logging.getLogger(__name__) + +NOTIFICATIONS_BUS = "org.freedesktop.Notifications" +NOTIFICATIONS_PATH = "/org/freedesktop/Notifications" +NOTIFICATIONS_IFACE = "org.freedesktop.Notifications" + + +@dataclass(frozen=True) +class NotifyTemplates: + """User-supplied format strings for the notification body and + summary, per playback state. Empty string means "use the built-in + default".""" + summary: str = "" + body: str = "" + paused_summary: str = "" + paused_body: str = "" + + +def _format_duration(secs: float) -> str: + """Mirror the original ``convert_timestamp``: ``M:SS`` for tracks + under an hour, ``H:MM:SS`` otherwise.""" + if secs <= 0: + return "0:00" + s = int(secs % 60) + m = int((secs / 60) % 60) + h = int(secs / 3600) + if h == 0: + return f"{m}:{s:02d}" + return f"{h}:{m:02d}:{s:02d}" + + +def _variant_value(v: Any) -> Any: + """Unwrap a ``dbus_fast.Variant`` if needed. ``Any`` so call sites + can ``int()`` / iterate without intermediate casts — MPRIS Metadata + values are deliberately polymorphic.""" + return getattr(v, "value", v) + + +def format_template( + template: str, meta: dict, *, position_us: int = 0, +) -> str: + """Expand ``%placeholder%`` tokens against an MPRIS Metadata dict. + + Mirrors the original mpDris2 placeholder set so existing + configurations keep working. Unknown placeholders are left + untouched (rather than raising) — friendlier when users typo. + """ + length_us = _variant_value(meta.get("mpris:length", 0)) or 0 + trackid = str(_variant_value(meta.get("mpris:trackid", "")) or "") + url = str(_variant_value(meta.get("xesam:url", "")) or "") + artist = _variant_value(meta.get("xesam:artist", [])) or [] + albumartist = _variant_value(meta.get("xesam:albumArtist", [])) or [] + genre = _variant_value(meta.get("xesam:genre", [])) or [] + + values: dict[str, str] = { + "album": str(_variant_value(meta.get("xesam:album", _("Unknown album")))), + "title": str(_variant_value(meta.get("xesam:title", _("Unknown title")))), + "id": trackid.split("/")[-1], + "time": _format_duration(int(length_us) / 1_000_000), + "timeposition": _format_duration(position_us / 1_000_000), + "date": str(_variant_value(meta.get("xesam:contentCreated", ""))), + "track": str(_variant_value(meta.get("xesam:trackNumber", ""))), + "disc": str(_variant_value(meta.get("xesam:discNumber", ""))), + "artist": ", ".join(str(a) for a in artist) or _("Unknown artist"), + "albumartist": ", ".join(str(a) for a in albumartist), + "composer": str(_variant_value(meta.get("xesam:composer", ""))), + "genre": ", ".join(str(g) for g in genre), + "file": url.split("/")[-1], + } + return re.sub( + r"%([a-z]+)%", + lambda m: values.get(m.group(1), m.group(0)), + template, + ) + + +@dataclass(frozen=True) +class NotifierConfig: + """Display tuning for the libnotify bubble. + + ``urgency`` maps to the freedesktop Notifications hint (0 low, + 1 normal, 2 critical). ``timeout`` is in milliseconds; ``-1`` + asks the server to apply its default, ``0`` means "never expire". + """ + urgency: int = 1 + timeout: int = -1 + + +PAUSED_ICON = "media-playback-pause-symbolic" + + +def _icon_path_for(meta: dict) -> str: + """Libnotify wants a filesystem path for the icon, not a file:// URL.""" + value = getattr(meta.get("mpris:artUrl"), "value", "") + if value.startswith("file://"): + return urllib.parse.unquote(value.removeprefix("file://")) + return value + + +def _build_track_notification( + meta: dict, state: str = "play", position_us: int = 0, + templates: NotifyTemplates | None = None, +) -> tuple[str, str, str]: + """Compose (summary, body, icon). When the matching template is + blank, fall back to the built-in default; ``paused_*`` falls back + to ``summary`` / ``body`` before the built-in default.""" + templates = templates or NotifyTemplates() + paused = state == "pause" + summary_tpl = (templates.paused_summary if paused else "") or templates.summary + body_tpl = (templates.paused_body if paused else "") or templates.body + + if summary_tpl: + title = format_template(summary_tpl, meta, position_us=position_us) + else: + title_v = meta.get("xesam:title") + title = str(getattr(title_v, "value", title_v) if title_v else _("Unknown title")) + + if body_tpl: + body = format_template(body_tpl, meta, position_us=position_us) + else: + artists_v = meta.get("xesam:artist") + artists = getattr(artists_v, "value", artists_v) if artists_v else [_("Unknown artist")] + body = _("by %s") % ", ".join(artists or [_("Unknown artist")]) + if paused: + body += f" ({_('Paused')})" + + icon = PAUSED_ICON if paused else _icon_path_for(meta) + return title, body, icon + + +class Notifier: + def __init__( + self, bus: MessageBus, app_name: str = "mpDris2", + config: NotifierConfig | None = None, + templates: NotifyTemplates | None = None, + ) -> None: + self._bus = bus + self._app_name = app_name + self._config = config or NotifierConfig() + self._templates = templates or NotifyTemplates() + self._last_id: int = 0 + + async def notify( + self, summary: str, body: str = "", icon: str = "", + ) -> None: + """Fire (or replace) a notification. Failures are logged at + debug level — no notification daemon is a common, non-fatal + configuration (headless, ssh sessions, …).""" + msg = Message( + destination=NOTIFICATIONS_BUS, + path=NOTIFICATIONS_PATH, + interface=NOTIFICATIONS_IFACE, + member="Notify", + signature="susssasa{sv}i", + body=[ + self._app_name, + self._last_id, # replaces_id (0 = new bubble) + icon, + summary, + body, + [], # actions + { + "urgency": Variant("y", self._config.urgency), + "desktop-entry": Variant("s", DESKTOP_ENTRY), + }, + self._config.timeout, + ], + ) + try: + reply = await self._bus.call(msg) + except Exception as e: + logger.debug("notify call failed: %r", e) + return + if reply is None or reply.message_type != MessageType.METHOD_RETURN: + return + try: + self._last_id = int(reply.body[0]) + except (IndexError, TypeError, ValueError): + self._last_id = 0 + + async def notify_track( + self, meta: dict, state: str = "play", position_us: int = 0, + ) -> None: + """Format an MPRIS metadata dict into a track-change bubble and + fire it. The bridge passes standard MPRIS data; formatting + (templates, paused fallback, icon path) lives here.""" + title, body, icon = _build_track_notification( + meta, state, position_us, self._templates, + ) + await self.notify(title, body, icon) diff --git a/tests/test_notify.py b/tests/test_notify.py new file mode 100644 index 0000000..2f11676 --- /dev/null +++ b/tests/test_notify.py @@ -0,0 +1,156 @@ +"""Unit tests for the pure helpers in notify.py — formatter + duration. + +The Notifier class itself needs a live D-Bus, so it's exercised via +the bridge integration tests, not here. +""" + +from __future__ import annotations + +import pytest +from dbus_fast import Variant + +from mpdris2.notify import ( + NotifyTemplates, + _build_track_notification, + _format_duration, + _icon_path_for, + format_template, +) + + +@pytest.mark.parametrize("secs,expected", [ + (0, "0:00"), + (-3, "0:00"), + (5, "0:05"), + (61, "1:01"), + (60 * 59 + 59, "59:59"), + (3600, "1:00:00"), + (3661, "1:01:01"), +]) +def test_format_duration(secs: float, expected: str) -> None: + assert _format_duration(secs) == expected + + +def test_format_template_basic_placeholders() -> None: + meta = { + "xesam:title": Variant("s", "Song"), + "xesam:album": Variant("s", "Album"), + "xesam:artist": Variant("as", ["Artist A", "Artist B"]), + "xesam:trackNumber": Variant("i", 3), + "mpris:length": Variant("x", 245_000_000), + } + out = format_template( + "%artist% — %title% (#%track% on %album%, %time%)", + meta, + ) + assert out == "Artist A, Artist B — Song (#3 on Album, 4:05)" + + +def test_format_template_unknown_placeholder_kept() -> None: + out = format_template("%title%/%nope%", {"xesam:title": Variant("s", "S")}) + assert out == "S/%nope%" + + +def test_format_template_missing_fields_use_defaults() -> None: + out = format_template("%album%/%artist%/%title%", {}) + assert out == "Unknown album/Unknown artist/Unknown title" + + +def test_format_template_position() -> None: + out = format_template("%timeposition%", {}, position_us=65_000_000) + assert out == "1:05" + + +def test_format_template_id_from_trackid_tail() -> None: + out = format_template( + "%id%", {"mpris:trackid": Variant("o", "/org/mpris/MediaPlayer2/Track/42")}, + ) + assert out == "42" + + +def test_format_template_file_from_url_tail() -> None: + out = format_template( + "%file%", {"xesam:url": Variant("s", "file:///srv/music/Artist/01.flac")}, + ) + assert out == "01.flac" + + +# --- _icon_path_for -------------------------------------------------------- + +def test_icon_path_for_file_uri_strips_scheme() -> None: + meta = {"mpris:artUrl": Variant("s", "file:///tmp/cover.jpg")} + assert _icon_path_for(meta) == "/tmp/cover.jpg" + + +def test_icon_path_for_plain_path_unchanged() -> None: + meta = {"mpris:artUrl": Variant("s", "/tmp/cover.jpg")} + assert _icon_path_for(meta) == "/tmp/cover.jpg" + + +def test_icon_path_for_missing() -> None: + assert _icon_path_for({}) == "" + + +# --- _build_track_notification -------------------------------------------- + +def test_track_notification_full_meta() -> None: + meta = { + "xesam:title": Variant("s", "Song"), + "xesam:artist": Variant("as", ["Artist A", "Artist B"]), + "mpris:artUrl": Variant("s", "file:///tmp/c.jpg"), + } + title, body, icon = _build_track_notification(meta) + assert title == "Song" + assert body == "by Artist A, Artist B" + assert icon == "/tmp/c.jpg" + + +def test_track_notification_missing_fields() -> None: + title, body, icon = _build_track_notification({}) + assert title == "Unknown title" + assert body == "by Unknown artist" + assert icon == "" + + +def test_track_notification_paused_default_appends_marker() -> None: + meta = {"xesam:title": Variant("s", "Song"), + "xesam:artist": Variant("as", ["A"])} + title, body, icon = _build_track_notification(meta, state="pause") + assert title == "Song" + assert body == "by A (Paused)" + assert icon == "media-playback-pause-symbolic" + + +def test_track_notification_summary_template_expands() -> None: + meta = { + "xesam:title": Variant("s", "Song"), + "xesam:album": Variant("s", "Album"), + "xesam:artist": Variant("as", ["A"]), + } + templates = NotifyTemplates(summary="%artist% — %title%", body="from %album%") + title, body, _icon = _build_track_notification(meta, templates=templates) + assert title == "A — Song" + assert body == "from Album" + + +def test_track_notification_paused_template_falls_back_to_playing() -> None: + # No paused_summary → uses the playing template; no paused_body → same. + meta = {"xesam:title": Variant("s", "Song"), + "xesam:artist": Variant("as", ["A"])} + templates = NotifyTemplates(summary="P:%title%", body="B:%artist%") + title, body, icon = _build_track_notification(meta, state="pause", templates=templates) + assert title == "P:Song" + assert body == "B:A" + assert icon == "media-playback-pause-symbolic" + + +def test_track_notification_paused_uses_paused_templates_when_set() -> None: + meta = {"xesam:title": Variant("s", "Song"), + "xesam:artist": Variant("as", ["A"])} + templates = NotifyTemplates( + summary="P:%title%", body="B:%artist%", + paused_summary="zzz", paused_body="snoring", + ) + title, body, _icon = _build_track_notification(meta, state="pause", templates=templates) + assert title == "zzz" + assert body == "snoring" From 2963663126c6d346540819da421f8159c7260515 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathieu=20R=C3=A9quillart?= Date: Fri, 15 May 2026 17:45:19 +0200 Subject: [PATCH 6/9] Wire metadata, cover, and notifications into the daemon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refresh() now translates currentsong into MPRIS Metadata, resolves the artUrl asynchronously via CoverFinder, toggles CanSeek on mpris:length presence, and fires a libnotify bubble on track change. URL handlers are probed from MPD's urlhandlers command at startup so streams keep their scheme intact. The music library path is resolved from CLI / config / XDG and normalised to a file:// URL. [Notify] is preferred over the deprecated [Bling] section for the enable flag. Ported config keys from the historical mpDris2: * ``[Bling] cdprev`` — CD-like Previous: when elapsed >= 3 s, restart the current track via ``seekid`` instead of skipping to the previous one. * ``[Bling] notify_paused`` — also fire the bubble on track change while the player is paused (default off, matching the original). * ``[Notify] urgency`` / ``timeout`` — wired into ``NotifierConfig`` and forwarded to the notification server. * ``[Notify] summary`` / ``body`` / ``paused_summary`` / ``paused_body`` — ``%placeholder%`` templates wired into ``_build_track_notification`` (paused state falls back to the playing template, then the built-in default; the paused default appends "(Paused)" to the body and swaps the icon for ``media-playback-pause-symbolic``). Read with ``raw=True`` so configparser doesn't try to interpolate the literal ``%`` tokens. Closes feature parity with the original daemon minus mmkeys, which is intentionally dropped — modern desktops consume MPRIS directly for multimedia-key handling. Co-Authored-By: Claude Opus 4.7 (1M context) --- mpdris2/bridge.py | 575 +++++++++++++++++++++++++++++++++++++++++++ mpdris2/cli.py | 207 ++++++++++++++-- mpdris2/daemon.py | 347 -------------------------- tests/test_bridge.py | 464 ++++++++++++++++++++++++++++++++++ tests/test_cli.py | 152 +++++++++++- 5 files changed, 1383 insertions(+), 362 deletions(-) create mode 100644 mpdris2/bridge.py delete mode 100644 mpdris2/daemon.py create mode 100644 tests/test_bridge.py diff --git a/mpdris2/bridge.py b/mpdris2/bridge.py new file mode 100644 index 0000000..f659631 --- /dev/null +++ b/mpdris2/bridge.py @@ -0,0 +1,575 @@ +"""MpdMprisBridge — single-event-loop bridge between MPD and MPRIS2. + +The bridge owns the per-connection state (MPD client, capabilities, +last status snapshot) and the long-lived resources (D-Bus connection, +cover finder, notifier). MPRIS callbacks are methods rather than +closures so they're testable in isolation and don't need ``nonlocal``. + +Shutdown is driven by ``CancelledError`` propagating from the +top-level task (``cli._amain`` installs the signal handlers). No +``stop_event`` flag — that pattern doesn't unblock the +``client.idle()`` await, which can hang on a quiet MPD. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import logging +import re +from collections.abc import Awaitable, Callable, Coroutine +from dataclasses import dataclass +from gettext import gettext as _ +from pathlib import Path +from typing import Any + +import mpd +from dbus_fast import Variant +from dbus_fast.aio import MessageBus +from mpd.asyncio import MPDClient + +from mpdris2 import mpd_client +from mpdris2.cover import ( + CoverFinder, + CoverFinderConfig, + SongLookup, +) +from mpdris2.mpris import ( + BUS_NAME, + IDENTITY, + ROOT_PATH, + MediaPlayer2, + MediaPlayer2Player, +) +from mpdris2.notify import Notifier +from mpdris2.translate import ( + DEFAULT_URL_HANDLERS, + loop_status_from, + mpd_to_mpris, + parse_elapsed, + parse_loop_flags, + parse_shuffle, + parse_volume, + playback_status_from, + song_url, +) + +logger = logging.getLogger(__name__) + +# Subsystems we care about — others (e.g. ``database``, ``update``, +# ``sticker``) don't influence the MPRIS-exposed state. +WATCHED_SUBSYSTEMS = frozenset({"player", "mixer", "options", "playlist"}) + + +# --- Configuration resolution helpers ------------------------------------- + + +@dataclass(frozen=True) +class BridgeConfig: + """Pre-resolved runtime config for the bridge. cli.py builds it + from configparser + argparse; the bridge itself never touches + either.""" + + host: str + port: int + password: str | None + is_socket: bool + music_dir: Path | None + cover_regex: re.Pattern[str] + cover_cache_dir: Path + cdprev: bool + notify_paused: bool + no_reconnect: bool + + +# --- Bridge-local helpers ------------------------------------------------- +# Pure MPD→MPRIS shape conversions (``parse_volume``, ``loop_status_from``, +# ``song_url`` …) live in ``mpdris2.translate``. What stays here is either +# stateful (the refresh diff) or a heuristic that's specific to the bridge's +# refresh cadence (external-seek detection). + + +def _is_external_seek(old_status: dict, old_time: float, new_pos_s: float, now: float) -> bool: + """Return True when the elapsed time deviates from what linear + playback since ``old_time`` would predict by more than 0.6s — the + same heuristic the original mpDris2 used to flag MPRIS-external + seeks. Caller is responsible for checking that both sides are in + the ``play`` state.""" + expected = float(old_status.get("elapsed", 0.0)) + (now - old_time) + return abs(new_pos_s - expected) > 0.6 + + +@dataclass(frozen=True) +class _RefreshSnapshot: + """Per-refresh diff between the previous and current MPD status. + Carries the old values (for transition detection) plus the few new + values both ``_apply_current_state`` and ``_emit_notifications`` + consume — so neither helper has to re-derive them.""" + + old_status: dict + old_song: dict + old_time: float + now: float + state: str + new_pos_s: float + same_song: bool + + +# --- The bridge ----------------------------------------------------------- + + +class MpdMprisBridge: + """Single-event-loop bridge between MPD and MPRIS2.""" + + def __init__( + self, + config: BridgeConfig, + *, + bus: MessageBus, + notifier: Notifier | None = None, + ) -> None: + self._loop = asyncio.get_running_loop() + + # Per-connection state — rebound on each MPD reconnect. + self.client: MPDClient | None = None + self.caps: dict[str, bool] = {} + self.last_status: dict = {} + self.last_song: dict = {} + self.last_time: float = 0.0 + + # Pre-resolved configuration (built in cli.py). + self.host = config.host + self.port = config.port + self.password = config.password + self.is_socket = config.is_socket + # ``music_dir`` is mutable on ``self`` because run_loop() may + # learn it later from MPD's ``config`` command on a socket. + self.music_dir = config.music_dir + if self.music_dir: + logger.info("music library: %s", self.music_dir) + + self.url_handlers: list[str] = list(DEFAULT_URL_HANDLERS) + + # Strong-ref fire-and-forget tasks so the loop's weak refs don't + # let them be GC'd mid-execution (asyncio docs explicitly warn). + self.bg_tasks: set[asyncio.Task] = set() + + self.cover_finder = CoverFinder( + CoverFinderConfig( + music_dir=self.music_dir, + cover_regex=config.cover_regex, + cover_cache_dir=config.cover_cache_dir, + ) + ) + self.bus = bus + self.notifier = notifier + self._cdprev = config.cdprev + self._notify_paused = config.notify_paused + self._no_reconnect = config.no_reconnect + # ``True`` once we've held a live MPD connection at least once + # — gates the "Reconnected" / "Disconnected" bubbles so neither + # fires on the very first connect attempt. + self._was_connected = False + + self.player = MediaPlayer2Player( + on_play=self.on_play, + on_pause=self.on_pause, + on_play_pause=self.on_play_pause, + on_stop=self.on_stop, + on_next=self.on_next, + on_previous=self.on_previous, + on_seek=self.on_seek, + on_set_position=self.on_set_position, + on_volume_set=self.on_volume_set, + on_loop_status_set=self.on_loop_status_set, + on_shuffle_set=self.on_shuffle_set, + ) + + # --- Task / error plumbing ------------------------------------------ + + def _on_bg_done(self, task: asyncio.Task) -> None: + self.bg_tasks.discard(task) + if task.cancelled(): + return + # Calling ``exception()`` marks the result as retrieved, so we + # also lose the asyncio "Task exception was never retrieved" + # warning — replace it with a logger error that carries the + # full traceback and our log formatting. + exc = task.exception() + if exc is not None: + logger.error("background task crashed: %r", exc, exc_info=exc) + + def _schedule(self, coro: Coroutine[Any, Any, Any]) -> None: + task = self._loop.create_task(coro) + self.bg_tasks.add(task) + task.add_done_callback(self._on_bg_done) + + async def _mpd_safe(self, awaitable: Awaitable) -> Any: + """Run an MPD coroutine; swallow command-level errors that don't + matter for the MPRIS surface (no current song, invalid arg, …) + and log connection drops without raising into the caller.""" + try: + return await awaitable + except mpd.CommandError as e: + logger.debug("MPD command error: %s", e) + except (mpd.ConnectionError, OSError) as e: + logger.warning("MPD lost during command: %s", e) + return None + + def _fire(self, mpd_call: Callable[[MPDClient], Awaitable]) -> None: + """Schedule a one-shot MPD command from a sync MPRIS callback. + No-op when there's no live connection (during reconnect).""" + c = self.client + if c is not None: + self._schedule(self._mpd_safe(mpd_call(c))) + + # --- MPRIS callbacks ------------------------------------------------ + + def on_play(self) -> None: + self._fire(lambda c: c.play()) + + def on_pause(self) -> None: + self._fire(lambda c: c.pause(1)) + + def on_stop(self) -> None: + self._fire(lambda c: c.stop()) + + def on_next(self) -> None: + self._fire(lambda c: c.next()) + + def on_previous(self) -> None: + self._fire(self._previous_cdaware) + + def on_shuffle_set(self, v: bool) -> None: + self._fire(lambda c: c.random(1 if v else 0)) + + def on_volume_set(self, v: float) -> None: + self._fire(lambda c: c.setvol(int(round(v * 100)))) + + async def _previous_cdaware(self, c: MPDClient) -> None: + """CD-like ``previous``: when ``cdprev`` is enabled and we're + more than 3 s into the current track, seek to the start + instead of skipping to the previous track.""" + if self._cdprev: + status = await c.status() + if float(status.get("elapsed", 0.0)) >= 3 and "songid" in status: + await c.seekid(int(status["songid"]), 0) + return + await c.previous() + + def on_play_pause(self) -> None: + c = self.client + if c is None: + return + + async def toggle() -> None: + s = await self._mpd_safe(c.status()) + if s and s.get("state") == "play": + await self._mpd_safe(c.pause(1)) + else: + await self._mpd_safe(c.play()) + + self._schedule(toggle()) + + def on_seek(self, offset_us: int) -> None: + # MPD's seekcur accepts a string with a leading sign for relative + # seeks; bare numbers are absolute. + offset_s = offset_us / 1_000_000 + arg = f"+{offset_s}" if offset_us >= 0 else str(offset_s) + self._fire(lambda c: c.seekcur(arg)) + + def on_set_position(self, trackid: str, position_us: int) -> None: + # MPRIS requires the trackid match the currently playing track; + # if it doesn't, the call is a no-op per spec. + cur_id = self.last_song.get("id") + if cur_id is not None and trackid != f"/org/mpris/MediaPlayer2/Track/{cur_id}": + return + position_s = position_us / 1_000_000 + self._fire(lambda c: c.seekcur(str(position_s))) + + def on_loop_status_set(self, val: str) -> None: + c = self.client + if c is None: + return + single_supported = self.caps.get("single", False) + + async def apply() -> None: + if val == "Playlist": + await self._mpd_safe(c.repeat(1)) + if single_supported: + await self._mpd_safe(c.single(0)) + elif val == "Track": + await self._mpd_safe(c.repeat(1)) + if single_supported: + await self._mpd_safe(c.single(1)) + else: # "None" + await self._mpd_safe(c.repeat(0)) + if single_supported: + await self._mpd_safe(c.single(0)) + + self._schedule(apply()) + + # --- Metadata + cover ----------------------------------------------- + + async def _build_track_metadata( + self, + song: dict, + status: dict, + ) -> dict[str, Variant]: + """Translate ``song`` into MPRIS Metadata and resolve cover art. + Cover lookup failures are swallowed (logged) — the metadata is + still returned, just without ``mpris:artUrl``.""" + meta = mpd_to_mpris(song, self.music_dir, self.url_handlers) + url = song_url(song, self.music_dir, self.url_handlers) + if not url: + return meta + try: + cover = await self.cover_finder.find( + SongLookup( + client=self.client, + song_uri=url, + song_file=song.get("file", ""), + mpd_meta=song, + last_loaded_playlist=status.get("lastloadedplaylist", ""), + ) + ) + except Exception: + logger.exception("cover lookup failed") + return meta + if cover: + meta["mpris:artUrl"] = Variant("s", cover) + return meta + + # --- Refresh: MPD status -> MPRIS properties ------------------------ + + async def refresh(self) -> None: + c = self.client + if c is None: + return + try: + status = await c.status() + song = await c.currentsong() + except (mpd.ConnectionError, OSError) as e: + logger.warning("MPD lost during refresh: %s", e) + return + + snap = self._snapshot(status, song) + meta = await self._apply_current_state(status, song, snap) + self._emit_notifications(snap, meta) + + def _snapshot(self, status: dict, song: dict) -> _RefreshSnapshot: + """Capture the previous status/song/time, advance ``self.last_*`` + to the new values, and return the deltas + derived values that + both ``_apply_current_state`` and ``_emit_notifications`` need.""" + now = self._loop.time() + snap = _RefreshSnapshot( + old_status=self.last_status, + old_song=self.last_song, + old_time=self.last_time, + now=now, + state=status.get("state", "stop"), + new_pos_s=parse_elapsed(status), + same_song=bool(self.last_song and song and self.last_song.get("id") == song.get("id")), + ) + self.last_status, self.last_song, self.last_time = status, song, now + return snap + + async def _apply_current_state( + self, + status: dict, + song: dict, + snap: _RefreshSnapshot, + ) -> dict[str, Variant]: + """Push the current MPD state onto the MPRIS player interface. + Emits ``Seeked`` when an external seek is detected against the + previous snapshot. Returns the metadata dict (empty when no + song is loaded).""" + self.player.update_playback_status(playback_status_from(snap.state)) + + repeat, single = parse_loop_flags(status) + self.player.update_loop_status(loop_status_from(repeat, single)) + self.player.update_shuffle(parse_shuffle(status)) + + vol = parse_volume(status) + if vol is not None: + self.player.update_volume(vol) + + self.player.update_position(int(snap.new_pos_s * 1_000_000)) + + if ( + snap.same_song + and snap.old_status.get("state") == "play" + and snap.state == "play" + and _is_external_seek( + snap.old_status, + snap.old_time, + snap.new_pos_s, + snap.now, + ) + ): + self.player.emit_seeked(int(snap.new_pos_s * 1_000_000)) + + # CanGoNext: a next song is queued, or we'd loop back to the + # start of the playlist anyway. + self.player.update_capabilities( + can_go_next="nextsongid" in status or repeat, + ) + + if not song: + self.player.update_metadata({}) + self.player.update_capabilities(can_seek=False) + return {} + + meta = await self._build_track_metadata(song, status) + self.player.update_metadata(meta) + self.player.update_capabilities(can_seek="mpris:length" in meta) + return meta + + def _emit_notifications( + self, + snap: _RefreshSnapshot, + meta: dict[str, Variant], + ) -> None: + """Fire libnotify bubbles for state transitions: a one-shot + "Stopped" on play/pause → stop, and a track-change bubble while + playing (or paused when ``[Bling] notify_paused`` is on). + + Both gates require a current song (``meta`` non-empty) — an + empty queue should stay silent.""" + if not self.notifier or not meta: + return + + old_state = snap.old_status.get("state") + if old_state in ("play", "pause") and snap.state == "stop": + self._schedule( + self.notifier.notify( + IDENTITY, + _("Stopped"), + "media-playback-stop-symbolic", + ) + ) + + notify_state = snap.state == "play" or (snap.state == "pause" and self._notify_paused) + if not snap.same_song and notify_state: + self._schedule( + self.notifier.notify_track( + meta, + snap.state, + int(snap.new_pos_s * 1_000_000), + ) + ) + + # --- Lifecycle ------------------------------------------------------ + + async def setup(self) -> None: + """Export MPRIS interfaces on the injected bus and request the + well-known name. The bus + notifier come pre-built from cli.py.""" + self.bus.export(ROOT_PATH, MediaPlayer2()) + self.bus.export(ROOT_PATH, self.player) + await self.bus.request_name(BUS_NAME) + logger.info("D-Bus name acquired: %s", BUS_NAME) + + async def run_loop(self) -> None: + """Outer MPD connect / reconnect loop. Returns when + ``--no-reconnect`` is set or the initial connection is refused; + raises ``CancelledError`` on shutdown signal.""" + while True: + try: + new_client = await mpd_client.connect( + self.host, + self.port, + self.password, + retry=not self._no_reconnect, + ) + except (mpd.CommandError, mpd.ConnectionError, OSError) as e: + logger.critical("MPD connection failed: %s", e) + return + + self.client = new_client + try: + cmds = await new_client.commands() + except (mpd.ConnectionError, OSError) as e: + logger.warning("MPD dropped before commands probe: %s", e) + self.client = None + continue + self.caps = mpd_client.capabilities(cmds) + logger.info("MPD capabilities: %s", ",".join(k for k, v in self.caps.items() if v)) + self.cover_finder.update_capabilities( + can_readpicture=self.caps["readpicture"], + can_albumart=self.caps["albumart"], + ) + + # On a Unix socket MPD allows the ``config`` command; use it + # to auto-pick the music_directory when the user hasn't + # configured one. TCP clients get "Access denied", so we + # only attempt this over a socket. + if self.music_dir is None and self.is_socket: + try: + server_cfg = await mpd_client.fetch_config(new_client) + md = server_cfg.get("music_directory") + except (mpd.CommandError, mpd.ConnectionError, OSError) as e: + logger.debug("MPD config lookup failed: %s", e) + md = None + if md: + self.music_dir = Path(md) + self.cover_finder.update_music_dir(self.music_dir) + logger.info("music library (from MPD): %s", self.music_dir) + + if self.music_dir is None: + logger.warning( + "no music_dir configured; xesam:url will be relative, breaking the MPRIS2 spec", + ) + + try: + self.url_handlers = list(await new_client.urlhandlers()) + except (mpd.CommandError, mpd.ConnectionError, OSError): + self.url_handlers = list(DEFAULT_URL_HANDLERS) + + await self.refresh() + + # Fire the reconnect bubble *after* refresh so MPRIS + # subscribers see the fresh metadata before the popup. + if self._was_connected and self.notifier: + self._schedule(self.notifier.notify(IDENTITY, _("Reconnected"), "")) + self._was_connected = True + + try: + async for subsystems in new_client.idle(): + if WATCHED_SUBSYSTEMS.intersection(subsystems): + await self.refresh() + except (mpd.ConnectionError, OSError) as e: + logger.warning("MPD idle loop ended: %s", e) + # Genuine MPD drop, not an intentional shutdown — emit + # the bubble before tearing down so it appears while + # the bus is still healthy. + if self.notifier: + self._schedule( + self.notifier.notify( + IDENTITY, + _("Disconnected"), + "error", + ) + ) + finally: + with contextlib.suppress(Exception): + new_client.disconnect() + self.client = None + + if self._no_reconnect: + return + # Reset MPRIS state so subscribers see "nothing playing" + # while we reconnect. + self.player.update_playback_status("Stopped") + self.player.update_metadata({}) + + async def close(self) -> None: + """Drain in-flight tasks and release the bus name. The bus + itself is owned by cli.py and disconnected there.""" + logger.info("shutting down") + for t in self.bg_tasks: + t.cancel() + if self.bg_tasks: + await asyncio.gather(*self.bg_tasks, return_exceptions=True) + self.cover_finder.close() + with contextlib.suppress(Exception): + await self.bus.release_name(BUS_NAME) diff --git a/mpdris2/cli.py b/mpdris2/cli.py index 46b914b..cde98f5 100644 --- a/mpdris2/cli.py +++ b/mpdris2/cli.py @@ -11,19 +11,38 @@ import asyncio import configparser import contextlib +import gettext import logging import os +import re +import signal import sys +from pathlib import Path + +from dbus_fast import BusType +from dbus_fast.aio import MessageBus + +from mpdris2.bridge import BridgeConfig, MpdMprisBridge +from mpdris2.cover import DEFAULT_COVER_CACHE_DIR, DEFAULT_COVER_REGEX +from mpdris2.mpd_client import is_unix_socket +from mpdris2.notify import Notifier, NotifierConfig, NotifyTemplates logger = logging.getLogger("mpdris2") +BUS_CONNECT_TIMEOUT = 10.0 + +# Bind the message catalog so ``from gettext import gettext as _`` +# lookups in bridge.py / notify.py hit our installed .mo files. +# Catalogs ship as package data under +# ``mpdris2/locale//LC_MESSAGES/mpdris2.mo``. +_LOCALE_DIR = Path(__file__).resolve().parent / "locale" +gettext.bindtextdomain("mpdris2", str(_LOCALE_DIR)) +gettext.textdomain("mpdris2") + CONFIG_PATHS = [ - os.path.join( - os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config"), - "mpDris2", - "mpDris2.conf", - ), - "/etc/mpDris2/mpDris2.conf", + Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config") + / "mpDris2" / "mpDris2.conf", + Path("/etc/mpDris2/mpDris2.conf"), ] @@ -31,7 +50,7 @@ class ConfigError(Exception): """Raised when the daemon can't start because of invalid / missing config.""" -def read_config(path: str | None = None) -> configparser.ConfigParser: +def read_config(path: str | Path | None = None) -> configparser.ConfigParser: """Parse the first existing INI file (or ``path`` if given). Sections preserved from the original mpDris2 layout: @@ -39,9 +58,9 @@ def read_config(path: str | None = None) -> configparser.ConfigParser: Missing file is not an error — defaults apply. """ cfg = configparser.ConfigParser() - paths = [path] if path else CONFIG_PATHS + paths: list[Path] = [Path(path)] if path else CONFIG_PATHS for p in paths: - if p and os.path.exists(p): + if p.exists(): cfg.read(p) logger.info("read %s", p) return cfg @@ -49,6 +68,131 @@ def read_config(path: str | None = None) -> configparser.ConfigParser: return cfg +def _resolve_notify(cfg: configparser.ConfigParser) -> bool: + # [Notify] preferred, fall back to deprecated [Bling]. + return cfg.getboolean( + "Notify", "notify", + fallback=cfg.getboolean("Bling", "notification", fallback=True), + ) + + +def _resolve_notify_paused(cfg: configparser.ConfigParser) -> bool: + return cfg.getboolean("Bling", "notify_paused", fallback=False) + + +def _resolve_notify_templates(cfg: configparser.ConfigParser) -> NotifyTemplates: + # raw=True so configparser doesn't try to interpolate the literal + # ``%title%`` etc. as variables. + return NotifyTemplates( + summary=cfg.get("Notify", "summary", fallback="", raw=True), + body=cfg.get("Notify", "body", fallback="", raw=True), + paused_summary=cfg.get("Notify", "paused_summary", fallback="", raw=True), + paused_body=cfg.get("Notify", "paused_body", fallback="", raw=True), + ) + + +def _resolve_notifier_config(cfg: configparser.ConfigParser) -> NotifierConfig: + return NotifierConfig( + urgency=cfg.getint("Notify", "urgency", fallback=1), + timeout=cfg.getint("Notify", "timeout", fallback=-1), + ) + + +def _resolve_endpoint( + cfg: configparser.ConfigParser, args: argparse.Namespace +) -> tuple[str, int, str | None]: + """Pick (host, port, password) from CLI args → config → env → defaults.""" + host = ( + args.host + or cfg.get("Connection", "host", fallback=None) + or os.environ.get("MPD_HOST") + or "localhost" + ) + password: str | None = cfg.get("Connection", "password", fallback=None) or None + if "@" in host: + # ``password@host`` shorthand matches the original mpDris2. + password, host = host.rsplit("@", 1) + + port_raw = ( + args.port + or cfg.get("Connection", "port", fallback=None) + or os.environ.get("MPD_PORT") + or 6600 + ) + try: + port = int(port_raw) + except (TypeError, ValueError): + logger.warning("invalid MPD port %r; falling back to 6600", port_raw) + port = 6600 + return host, port, password + + +def _resolve_music_dir( + cfg: configparser.ConfigParser, + args: argparse.Namespace, +) -> Path | None: + """Pick the music library path from CLI / config. Accepts a bare + path or a ``file://`` URI; must resolve to an absolute local path — + non-local URI schemes and relative paths are rejected (cover lookup + needs local FS access, and ``Path.as_uri()`` requires absolute). + + Returns ``None`` when nothing is configured; over a Unix socket the + daemon will then ask MPD for ``music_directory`` on first connect.""" + raw: str | None = ( + args.music_dir + or cfg.get("Library", "music_dir", fallback=None) + or cfg.get("Connection", "music_dir", fallback=None) + ) + if not raw: + return None + path = Path(raw.removeprefix("file://")).expanduser() + if not path.is_absolute(): + logger.warning( + "music_dir %r must be a local absolute path; ignoring", raw, + ) + return None + return path + + +def _resolve_cover_regex(cfg: configparser.ConfigParser) -> re.Pattern[str]: + raw = cfg.get("Library", "cover_regex", fallback=None) + if not raw: + return DEFAULT_COVER_REGEX + try: + return re.compile(raw, re.I | re.X) + except re.error as e: + logger.warning("invalid cover_regex %r: %s; using default", raw, e) + return DEFAULT_COVER_REGEX + + +def _resolve_cover_cache_dir(cfg: configparser.ConfigParser) -> Path: + raw = cfg.get("Library", "cover_cache_dir", fallback=None) + return Path(raw).expanduser() if raw else DEFAULT_COVER_CACHE_DIR + + +def _resolve_cdprev(cfg: configparser.ConfigParser) -> bool: + return cfg.getboolean("Bling", "cdprev", fallback=False) + + +def build_bridge_config( + cfg: configparser.ConfigParser, args: argparse.Namespace, +) -> BridgeConfig: + host, port, password = _resolve_endpoint(cfg, args) + is_socket = is_unix_socket(host) + return BridgeConfig( + host=host, + port=port, + password=password, + is_socket=is_socket, + music_dir=_resolve_music_dir(cfg, args), + cover_regex=_resolve_cover_regex(cfg), + cover_cache_dir=_resolve_cover_cache_dir(cfg), + cdprev=_resolve_cdprev(cfg), + notify_paused=_resolve_notify_paused(cfg), + no_reconnect=args.no_reconnect, + ) + + def build_parser() -> argparse.ArgumentParser: p = argparse.ArgumentParser( prog="mpDris2", @@ -88,12 +232,47 @@ def main() -> None: logger.critical("failed to read config: %s", e) sys.exit(1) - # Imported lazily so test_cli.py can exercise main() without dragging - # in dbus-fast / python-mpd2 at import time. - from mpdris2.daemon import run + bridge_config = build_bridge_config(cfg, args) + + async def _amain() -> None: + # SIGTERM / SIGINT cancel the daemon task; CancelledError + # propagates through all the awaits (notably ``client.idle()``) + # so cleanup runs immediately instead of waiting for the next + # MPD event. + loop = asyncio.get_running_loop() + task = asyncio.current_task() + assert task is not None + loop.add_signal_handler(signal.SIGTERM, task.cancel) + loop.add_signal_handler(signal.SIGINT, task.cancel) + + try: + async with asyncio.timeout(BUS_CONNECT_TIMEOUT): + bus = await MessageBus(bus_type=BusType.SESSION).connect() + except TimeoutError: + logger.critical( + "D-Bus session bus did not respond within %.0fs; aborting", + BUS_CONNECT_TIMEOUT, + ) + raise + + notifier = Notifier( + bus, app_name="mpDris2", + config=_resolve_notifier_config(cfg), + templates=_resolve_notify_templates(cfg), + ) if _resolve_notify(cfg) else None + + bridge = MpdMprisBridge(bridge_config, bus=bus, notifier=notifier) + try: + await bridge.setup() + await bridge.run_loop() + except asyncio.CancelledError: + pass + finally: + await bridge.close() + with contextlib.suppress(Exception): + bus.disconnect() - with contextlib.suppress(KeyboardInterrupt): - asyncio.run(run(cfg, args)) + asyncio.run(_amain()) if __name__ == "__main__": diff --git a/mpdris2/daemon.py b/mpdris2/daemon.py deleted file mode 100644 index d3181c2..0000000 --- a/mpdris2/daemon.py +++ /dev/null @@ -1,347 +0,0 @@ -"""Daemon orchestration — asyncio runtime that wires MPD and D-Bus. - -Single asyncio event loop. No threads, no GLib. MPRIS callbacks -schedule MPD commands as fire-and-forget tasks; MPD ``idle`` events -drive ``refresh()``, which translates the new status into MPRIS -properties and emits PropertiesChanged via ``MediaPlayer2Player``. - -PR 2 ships the playback-state + transport-control surface (no -Metadata / cover / notify yet — those come in PR 3). -""" - -from __future__ import annotations - -import argparse -import asyncio -import configparser -import contextlib -import logging -import os -import signal -from collections.abc import Awaitable, Coroutine -from typing import Any - -import mpd -from dbus_fast import BusType -from dbus_fast.aio import MessageBus -from mpd.asyncio import MPDClient - -from mpdris2 import mpd_client -from mpdris2.mpris import ( - BUS_NAME, - ROOT_PATH, - MediaPlayer2, - MediaPlayer2Player, -) - -logger = logging.getLogger(__name__) - -# Subsystems we care about — others (e.g. ``database``, ``update``, -# ``sticker``) don't influence the MPRIS-exposed state. -WATCHED_SUBSYSTEMS = frozenset({"player", "mixer", "options", "playlist"}) - - -def _resolve_endpoint( - cfg: configparser.ConfigParser, args: argparse.Namespace -) -> tuple[str, int, str | None]: - """Pick (host, port, password) from CLI args → config → env → defaults.""" - host = ( - args.host - or cfg.get("Connection", "host", fallback=None) - or os.environ.get("MPD_HOST") - or "localhost" - ) - password: str | None = cfg.get("Connection", "password", fallback=None) or None - if "@" in host: - # ``password@host`` shorthand matches the original mpDris2. - password, host = host.rsplit("@", 1) - - port_str = ( - str(args.port) if args.port else cfg.get("Connection", "port", fallback="") - ) or os.environ.get("MPD_PORT") or "6600" - try: - port = int(port_str) - except ValueError: - logger.warning("invalid MPD port %r; falling back to 6600", port_str) - port = 6600 - return host, port, password - - -def _loop_status_from(repeat: bool, single: bool) -> str: - if repeat and single: - return "Track" - if repeat: - return "Playlist" - return "None" - - -async def run(cfg: configparser.ConfigParser, args: argparse.Namespace) -> None: - loop = asyncio.get_running_loop() - host, port, password = _resolve_endpoint(cfg, args) - - stop_event = asyncio.Event() - loop.add_signal_handler(signal.SIGTERM, stop_event.set) - loop.add_signal_handler(signal.SIGINT, stop_event.set) - - # Strong-ref fire-and-forget tasks so the loop's weak refs don't - # let them be GC'd mid-execution (asyncio docs explicitly warn). - bg_tasks: set[asyncio.Task] = set() - - def schedule(coro: Coroutine[Any, Any, Any]) -> None: - task = loop.create_task(coro) - bg_tasks.add(task) - task.add_done_callback(bg_tasks.discard) - - async def mpd_safe(awaitable: Awaitable) -> Any: - """Run an MPD coroutine; swallow command-level errors that don't - matter for the MPRIS surface (no current song, invalid arg, …) - and log connection drops without raising into the caller.""" - try: - return await awaitable - except mpd.CommandError as e: - logger.debug("MPD command error: %s", e) - except (mpd.ConnectionError, OSError) as e: - logger.warning("MPD lost during command: %s", e) - return None - - # --- State holders rebound on each reconnect ----------------- - client: MPDClient | None = None - caps: dict[str, bool] = {} - last_status: dict = {} - last_song: dict = {} - last_time: float = 0.0 - - # --- MPRIS surface ------------------------------------------------ - def on_play() -> None: - c = client - if c is not None: - schedule(mpd_safe(c.play())) - - def on_pause() -> None: - c = client - if c is not None: - schedule(mpd_safe(c.pause(1))) - - def on_play_pause() -> None: - c = client - if c is None: - return - - async def toggle() -> None: - s = await mpd_safe(c.status()) - if s and s.get("state") == "play": - await mpd_safe(c.pause(1)) - else: - await mpd_safe(c.play()) - - schedule(toggle()) - - def on_stop() -> None: - c = client - if c is not None: - schedule(mpd_safe(c.stop())) - - def on_next() -> None: - c = client - if c is not None: - schedule(mpd_safe(c.next())) - - def on_previous() -> None: - c = client - if c is not None: - schedule(mpd_safe(c.previous())) - - def on_seek(offset_us: int) -> None: - c = client - if c is None: - return - offset_s = offset_us / 1_000_000 - # MPD's seekcur accepts a string with a leading sign for relative - # seeks; bare numbers are absolute. - arg = f"+{offset_s}" if offset_us >= 0 else str(offset_s) - schedule(mpd_safe(c.seekcur(arg))) - - def on_set_position(trackid: str, position_us: int) -> None: - c = client - if c is None: - return - # MPRIS requires the trackid match the currently playing track; - # if it doesn't, the call is a no-op per spec. - cur_id = last_song.get("id") - if cur_id is not None and trackid != f"/org/mpris/MediaPlayer2/Track/{cur_id}": - return - position_s = position_us / 1_000_000 - schedule(mpd_safe(c.seekcur(str(position_s)))) - - def on_volume_set(v: float) -> None: - c = client - if c is not None: - schedule(mpd_safe(c.setvol(int(round(v * 100))))) - - def on_loop_status_set(val: str) -> None: - c = client - if c is None: - return - single_supported = caps.get("single", False) - - async def apply() -> None: - if val == "Playlist": - await mpd_safe(c.repeat(1)) - if single_supported: - await mpd_safe(c.single(0)) - elif val == "Track": - await mpd_safe(c.repeat(1)) - if single_supported: - await mpd_safe(c.single(1)) - else: # "None" - await mpd_safe(c.repeat(0)) - if single_supported: - await mpd_safe(c.single(0)) - - schedule(apply()) - - def on_shuffle_set(v: bool) -> None: - c = client - if c is not None: - schedule(mpd_safe(c.random(1 if v else 0))) - - root = MediaPlayer2() - player = MediaPlayer2Player( - on_play=on_play, - on_pause=on_pause, - on_play_pause=on_play_pause, - on_stop=on_stop, - on_next=on_next, - on_previous=on_previous, - on_seek=on_seek, - on_set_position=on_set_position, - on_volume_set=on_volume_set, - on_loop_status_set=on_loop_status_set, - on_shuffle_set=on_shuffle_set, - ) - - # --- D-Bus export (kept alive across MPD reconnects) ------------- - bus = await MessageBus(bus_type=BusType.SESSION).connect() - bus.export(ROOT_PATH, root) - bus.export(ROOT_PATH, player) - await bus.request_name(BUS_NAME) - logger.info("D-Bus name acquired: %s", BUS_NAME) - - # --- Refresh: MPD status -> MPRIS properties --------------------- - async def refresh() -> None: - nonlocal last_status, last_song, last_time - c = client - if c is None: - return - try: - status = await c.status() - song = await c.currentsong() - except (mpd.ConnectionError, OSError) as e: - logger.warning("MPD lost during refresh: %s", e) - return - - now = loop.time() - old_status = last_status - old_song = last_song - old_time = last_time - last_status = status - last_song = song - last_time = now - - state = status.get("state", "stop") - player.update_playback_status( - {"play": "Playing", "pause": "Paused", "stop": "Stopped"}.get(state, "Stopped") - ) - - repeat = status.get("repeat", "0") == "1" - single = status.get("single", "0") == "1" - player.update_loop_status(_loop_status_from(repeat, single)) - player.update_shuffle(status.get("random", "0") == "1") - - # MPD: volume is 0-100, or -1 when the audio backend can't - # report it (e.g. some ALSA configs). Treat -1 as "leave as-is". - try: - vol_raw = int(status.get("volume", -1)) - except (TypeError, ValueError): - vol_raw = -1 - if vol_raw >= 0: - player.update_volume(vol_raw / 100.0) - - # Position + Seeked detection. Same heuristic as the original - # mpDris2: if the song didn't change and we were playing, the - # elapsed time should advance linearly; a >0.6s deviation means - # someone seeked outside of MPRIS. - try: - new_pos_s = float(status.get("elapsed", 0.0)) - except (TypeError, ValueError): - new_pos_s = 0.0 - player.update_position(int(new_pos_s * 1_000_000)) - - same_song = bool( - old_song - and song - and old_song.get("id") == song.get("id") - ) - if same_song and old_status.get("state") == "play" and state == "play": - expected = float(old_status.get("elapsed", 0.0)) + (now - old_time) - if abs(new_pos_s - expected) > 0.6: - player.emit_seeked(int(new_pos_s * 1_000_000)) - - # CanGoNext: a next song is queued, or we'd loop back to the - # start of the playlist anyway. - has_next = "nextsongid" in status or repeat - player.update_capabilities(can_go_next=has_next) - # CanSeek: until PR 3 wires real metadata, hard-True (matching - # the original mpDris2). MPD will simply reject seekcur when - # there's no current song. - player.update_capabilities(can_seek=True) - - # --- Outer MPD connect / reconnect loop -------------------------- - try: - while not stop_event.is_set(): - try: - new_client = await mpd_client.connect( - host, port, password, retry=not args.no_reconnect - ) - except (mpd.CommandError, mpd.ConnectionError, OSError) as e: - logger.critical("MPD connection failed: %s", e) - break - - client = new_client - try: - cmds = await new_client.commands() - except (mpd.ConnectionError, OSError) as e: - logger.warning("MPD dropped before commands probe: %s", e) - client = None - continue - caps = mpd_client.capabilities(cmds) - logger.info("MPD capabilities: %s", - ",".join(k for k, v in caps.items() if v)) - - await refresh() - - try: - async for subsystems in new_client.idle(): - if stop_event.is_set(): - break - if WATCHED_SUBSYSTEMS.intersection(subsystems): - await refresh() - except (mpd.ConnectionError, OSError) as e: - logger.warning("MPD idle loop ended: %s", e) - finally: - with contextlib.suppress(Exception): - new_client.disconnect() - client = None - - if args.no_reconnect or stop_event.is_set(): - break - # Reset MPRIS state so subscribers see "nothing playing" - # while we reconnect. - player.update_playback_status("Stopped") - player.update_metadata({}) - finally: - logger.info("shutting down") - with contextlib.suppress(Exception): - await bus.release_name(BUS_NAME) - with contextlib.suppress(Exception): - bus.disconnect() diff --git a/tests/test_bridge.py b/tests/test_bridge.py new file mode 100644 index 0000000..e031350 --- /dev/null +++ b/tests/test_bridge.py @@ -0,0 +1,464 @@ +"""Unit tests for bridge.py pure helpers + the ``_build_track_metadata`` +method — no MPD, no D-Bus. + +``_build_track_metadata`` runs on a partially-initialised +``MpdMprisBridge`` built via ``__new__`` (we skip the heavy ``__init__`` +which needs a running event loop). Only the attributes the method +reads are set on it. +""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from mpdris2.bridge import ( + MpdMprisBridge, + _is_external_seek, + _RefreshSnapshot, +) + + +def _bridge(cover_finder, music_dir=Path("/srv/music"), + url_handlers=("http://",), client=None): + """Minimal bridge stub — only the fields ``_build_track_metadata`` + touches.""" + bridge = MpdMprisBridge.__new__(MpdMprisBridge) + bridge.client = client or MagicMock() + bridge.music_dir = music_dir + bridge.url_handlers = list(url_handlers) + bridge.cover_finder = cover_finder + return bridge + + +# --- _is_external_seek ----------------------------------------------------- + +def test_seek_within_tolerance_is_not_external() -> None: + # 10s ago elapsed=5.0, now=15s wall-clock, observed=15.0 → expected=15.0 + assert not _is_external_seek({"elapsed": "5.0"}, 0.0, 15.0, 10.0) + + +def test_seek_deviation_above_threshold_is_external() -> None: + # 10s elapsed, but actual position jumped to 30s → external seek + assert _is_external_seek({"elapsed": "5.0"}, 0.0, 30.0, 10.0) + + +def test_seek_deviation_at_threshold_is_not_external() -> None: + # Exactly 0.6s deviation is the boundary; spec says > 0.6 only. + assert not _is_external_seek({"elapsed": "5.0"}, 0.0, 15.6, 10.0) + + +def test_seek_deviation_just_above_threshold_is_external() -> None: + assert _is_external_seek({"elapsed": "5.0"}, 0.0, 15.7, 10.0) + + +# --- _build_track_metadata (async) ---------------------------------------- + +@pytest.mark.asyncio +async def test_build_track_metadata_no_song_url_skips_cover() -> None: + """When the song has no file, cover_finder.find must NOT be called.""" + cover_finder = MagicMock() + cover_finder.find = MagicMock(side_effect=AssertionError("should not be called")) + bridge = _bridge(cover_finder) + meta = await bridge._build_track_metadata(song={"title": "x"}, status={}) + assert "xesam:title" in meta + assert "mpris:artUrl" not in meta + + +@pytest.mark.asyncio +async def test_build_track_metadata_cover_attached() -> None: + async def fake_find(*args, **kwargs): + return "file:///cache/cover.jpg" + cover_finder = MagicMock() + cover_finder.find = fake_find + bridge = _bridge(cover_finder) + meta = await bridge._build_track_metadata( + song={"title": "x", "file": "Artist/Song.flac"}, status={}, + ) + assert meta["mpris:artUrl"].value == "file:///cache/cover.jpg" + + +@pytest.mark.asyncio +async def test_build_track_metadata_cover_exception_swallowed(caplog) -> None: + async def boom(*args, **kwargs): + raise RuntimeError("cover lookup broke") + cover_finder = MagicMock() + cover_finder.find = boom + bridge = _bridge(cover_finder) + with caplog.at_level("ERROR"): + meta = await bridge._build_track_metadata( + song={"title": "x", "file": "Artist/Song.flac"}, status={}, + ) + assert "mpris:artUrl" not in meta + assert "xesam:title" in meta + assert any("cover lookup failed" in r.message for r in caplog.records) + + +@pytest.mark.asyncio +async def test_build_track_metadata_cover_none_no_arturl() -> None: + async def empty(*args, **kwargs): + return None + cover_finder = MagicMock() + cover_finder.find = empty + bridge = _bridge(cover_finder) + meta = await bridge._build_track_metadata( + song={"file": "Artist/Song.flac"}, status={}, + ) + assert "mpris:artUrl" not in meta + + +# --- _previous_cdaware ----------------------------------------------------- + +def _mpd_client_with_status(elapsed: float, songid: str = "7"): + client = MagicMock() + client.status = AsyncMock(return_value={"elapsed": str(elapsed), "songid": songid}) + client.previous = AsyncMock() + client.seekid = AsyncMock() + return client + + +def _bridge_with_cdprev(cdprev: bool) -> MpdMprisBridge: + bridge = MpdMprisBridge.__new__(MpdMprisBridge) + bridge._cdprev = cdprev + return bridge + + +@pytest.mark.asyncio +async def test_previous_cdaware_disabled_always_previous() -> None: + bridge = _bridge_with_cdprev(False) + client = _mpd_client_with_status(elapsed=12.0) + await bridge._previous_cdaware(client) + client.previous.assert_awaited_once() + client.seekid.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_previous_cdaware_under_3s_skips_back() -> None: + bridge = _bridge_with_cdprev(True) + client = _mpd_client_with_status(elapsed=1.5) + await bridge._previous_cdaware(client) + client.previous.assert_awaited_once() + client.seekid.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_previous_cdaware_past_3s_seeks_to_start() -> None: + bridge = _bridge_with_cdprev(True) + client = _mpd_client_with_status(elapsed=12.0, songid="42") + await bridge._previous_cdaware(client) + client.seekid.assert_awaited_once_with(42, 0) + client.previous.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_previous_cdaware_at_3s_seeks_to_start() -> None: + # Boundary: the original used ``>= 3``. + bridge = _bridge_with_cdprev(True) + client = _mpd_client_with_status(elapsed=3.0, songid="9") + await bridge._previous_cdaware(client) + client.seekid.assert_awaited_once_with(9, 0) + client.previous.assert_not_awaited() + + +# --- _snapshot ------------------------------------------------------------- + +def _snapshot_bridge( + *, + last_status: dict | None = None, + last_song: dict | None = None, + last_time: float = 0.0, + now: float = 100.0, +) -> MpdMprisBridge: + bridge = MpdMprisBridge.__new__(MpdMprisBridge) + bridge._loop = MagicMock() + bridge._loop.time = MagicMock(return_value=now) + bridge.last_status = last_status if last_status is not None else {} + bridge.last_song = last_song if last_song is not None else {} + bridge.last_time = last_time + return bridge + + +def test_snapshot_captures_old_and_advances_last() -> None: + bridge = _snapshot_bridge( + last_status={"state": "play"}, + last_song={"id": "1"}, + last_time=42.0, + now=100.0, + ) + new_status = {"state": "pause", "elapsed": "12.5"} + new_song = {"id": "2"} + + snap = bridge._snapshot(new_status, new_song) + + assert snap.old_status == {"state": "play"} + assert snap.old_song == {"id": "1"} + assert snap.old_time == 42.0 + assert snap.now == 100.0 + assert snap.state == "pause" + assert snap.new_pos_s == 12.5 + assert snap.same_song is False + # self.last_* advanced to the new values. + assert bridge.last_status is new_status + assert bridge.last_song is new_song + assert bridge.last_time == 100.0 + + +def test_snapshot_same_song_when_ids_match() -> None: + bridge = _snapshot_bridge(last_song={"id": "7"}) + snap = bridge._snapshot({"state": "play"}, {"id": "7"}) + assert snap.same_song is True + + +def test_snapshot_first_refresh_is_not_same_song() -> None: + # No previous song → same_song must be False so track-change + # notifications fire on the very first track. + bridge = _snapshot_bridge() + snap = bridge._snapshot({"state": "play"}, {"id": "1"}) + assert snap.same_song is False + + +def test_snapshot_state_defaults_to_stop_when_missing() -> None: + bridge = _snapshot_bridge() + snap = bridge._snapshot({}, {}) + assert snap.state == "stop" + assert snap.new_pos_s == 0.0 + + +# --- _apply_current_state -------------------------------------------------- + +def _apply_bridge(cover_finder=None, **player_calls) -> MpdMprisBridge: + """Bridge with a mocked player (capture update_* calls) and the + minimal cover/music wiring ``_build_track_metadata`` needs.""" + bridge = MpdMprisBridge.__new__(MpdMprisBridge) + bridge.client = MagicMock() + bridge.music_dir = Path("/srv/music") + bridge.url_handlers = ["http://"] + if cover_finder is None: + cover_finder = MagicMock() + cover_finder.find = AsyncMock(return_value=None) + bridge.cover_finder = cover_finder + bridge.player = MagicMock() + return bridge + + +def _snap( + *, + old_state: str = "stop", state: str = "play", + old_time: float = 0.0, now: float = 10.0, + old_elapsed: float = 0.0, new_pos_s: float = 0.0, + same_song: bool = False, old_song: dict | None = None, +) -> _RefreshSnapshot: + return _RefreshSnapshot( + old_status={"state": old_state, "elapsed": str(old_elapsed)}, + old_song=old_song if old_song is not None else {}, + old_time=old_time, + now=now, + state=state, + new_pos_s=new_pos_s, + same_song=same_song, + ) + + +@pytest.mark.asyncio +async def test_apply_pushes_basic_player_state() -> None: + bridge = _apply_bridge() + status = { + "state": "play", "elapsed": "5.0", + "repeat": "1", "single": "1", "random": "1", "volume": "50", + } + await bridge._apply_current_state( + status, {"id": "1", "title": "x"}, + _snap(state="play", new_pos_s=5.0), + ) + bridge.player.update_playback_status.assert_called_with("Playing") + bridge.player.update_loop_status.assert_called_with("Track") + bridge.player.update_shuffle.assert_called_with(True) + bridge.player.update_volume.assert_called_with(0.5) + bridge.player.update_position.assert_called_with(5_000_000) + + +@pytest.mark.asyncio +async def test_apply_skips_volume_when_unreportable() -> None: + bridge = _apply_bridge() + await bridge._apply_current_state( + {"state": "play", "volume": "-1"}, {"id": "1"}, _snap(), + ) + bridge.player.update_volume.assert_not_called() + + +@pytest.mark.asyncio +async def test_apply_emits_seeked_on_external_seek() -> None: + bridge = _apply_bridge() + # 10s wall-clock elapsed since old_time=0, old elapsed=5 → expected 15s; + # new_pos_s=30s → external seek. + await bridge._apply_current_state( + {"state": "play"}, {"id": "1"}, + _snap(old_state="play", state="play", same_song=True, + old_elapsed=5.0, old_time=0.0, now=10.0, new_pos_s=30.0), + ) + bridge.player.emit_seeked.assert_called_once_with(30_000_000) + + +@pytest.mark.asyncio +async def test_apply_no_seeked_on_natural_progression() -> None: + bridge = _apply_bridge() + await bridge._apply_current_state( + {"state": "play"}, {"id": "1"}, + _snap(old_state="play", state="play", same_song=True, + old_elapsed=5.0, old_time=0.0, now=10.0, new_pos_s=15.0), + ) + bridge.player.emit_seeked.assert_not_called() + + +@pytest.mark.asyncio +async def test_apply_no_seeked_on_song_change() -> None: + bridge = _apply_bridge() + await bridge._apply_current_state( + {"state": "play"}, {"id": "2"}, + _snap(old_state="play", state="play", same_song=False, + new_pos_s=30.0), + ) + bridge.player.emit_seeked.assert_not_called() + + +@pytest.mark.asyncio +async def test_apply_can_go_next_from_nextsongid() -> None: + bridge = _apply_bridge() + await bridge._apply_current_state( + {"state": "play", "nextsongid": "5"}, {"id": "1"}, _snap(), + ) + bridge.player.update_capabilities.assert_any_call(can_go_next=True) + + +@pytest.mark.asyncio +async def test_apply_can_go_next_from_repeat() -> None: + bridge = _apply_bridge() + await bridge._apply_current_state( + {"state": "play", "repeat": "1"}, {"id": "1"}, _snap(), + ) + bridge.player.update_capabilities.assert_any_call(can_go_next=True) + + +@pytest.mark.asyncio +async def test_apply_no_song_clears_metadata_and_returns_empty() -> None: + bridge = _apply_bridge() + meta = await bridge._apply_current_state( + {"state": "stop"}, {}, _snap(state="stop"), + ) + assert meta == {} + bridge.player.update_metadata.assert_called_with({}) + bridge.player.update_capabilities.assert_any_call(can_seek=False) + + +@pytest.mark.asyncio +async def test_apply_song_returns_meta_with_can_seek() -> None: + bridge = _apply_bridge() + meta = await bridge._apply_current_state( + {"state": "play"}, + {"id": "1", "title": "Track", "time": "180"}, + _snap(state="play"), + ) + assert "xesam:title" in meta + bridge.player.update_metadata.assert_called_with(meta) + bridge.player.update_capabilities.assert_any_call(can_seek=True) + + +# --- _emit_notifications --------------------------------------------------- + +def _notif_bridge(*, notifier=None, notify_paused: bool = False) -> MpdMprisBridge: + bridge = MpdMprisBridge.__new__(MpdMprisBridge) + bridge.notifier = notifier + bridge._notify_paused = notify_paused + bridge._schedule = MagicMock() # type: ignore[method-assign] + return bridge + + +def _fake_notifier(): + n = MagicMock() + n.notify = MagicMock(return_value=MagicMock()) + n.notify_track = MagicMock(return_value=MagicMock()) + return n + + +def test_emit_no_notifier_is_noop() -> None: + bridge = _notif_bridge(notifier=None) + bridge._emit_notifications(_snap(state="stop", old_state="play"), {"x": 1}) + # Nothing to assert other than no crash; _schedule was replaced + # with a mock and should not have been called. + bridge._schedule.assert_not_called() # type: ignore[attr-defined] + + +def test_emit_empty_meta_is_noop() -> None: + """Empty queue (no current song) keeps the daemon silent — preserves + the early-return behaviour of the pre-refactor ``refresh``.""" + notifier = _fake_notifier() + bridge = _notif_bridge(notifier=notifier) + bridge._emit_notifications( + _snap(old_state="play", state="stop"), meta={}, + ) + bridge._schedule.assert_not_called() # type: ignore[attr-defined] + notifier.notify.assert_not_called() + notifier.notify_track.assert_not_called() + + +def test_emit_stopped_bubble_on_play_to_stop() -> None: + notifier = _fake_notifier() + bridge = _notif_bridge(notifier=notifier) + bridge._emit_notifications( + _snap(old_state="play", state="stop", same_song=True), {"x": 1}, + ) + notifier.notify.assert_called_once() + # Stopped is the one-shot — track-change must not also fire. + notifier.notify_track.assert_not_called() + + +def test_emit_no_stopped_on_stop_to_stop() -> None: + notifier = _fake_notifier() + bridge = _notif_bridge(notifier=notifier) + bridge._emit_notifications( + _snap(old_state="stop", state="stop"), {"x": 1}, + ) + notifier.notify.assert_not_called() + + +def test_emit_track_change_on_play() -> None: + notifier = _fake_notifier() + bridge = _notif_bridge(notifier=notifier) + bridge._emit_notifications( + _snap(old_state="play", state="play", + same_song=False, new_pos_s=2.5), + {"xesam:title": "x"}, + ) + notifier.notify_track.assert_called_once() + args, _kwargs = notifier.notify_track.call_args + assert args[1] == "play" + assert args[2] == 2_500_000 + + +def test_emit_no_track_change_on_same_song() -> None: + notifier = _fake_notifier() + bridge = _notif_bridge(notifier=notifier) + bridge._emit_notifications( + _snap(old_state="play", state="play", same_song=True), {"x": 1}, + ) + notifier.notify_track.assert_not_called() + + +def test_emit_no_track_change_when_paused_without_flag() -> None: + notifier = _fake_notifier() + bridge = _notif_bridge(notifier=notifier, notify_paused=False) + bridge._emit_notifications( + _snap(old_state="play", state="pause", same_song=False), {"x": 1}, + ) + notifier.notify_track.assert_not_called() + + +def test_emit_track_change_when_paused_with_flag() -> None: + notifier = _fake_notifier() + bridge = _notif_bridge(notifier=notifier, notify_paused=True) + bridge._emit_notifications( + _snap(old_state="play", state="pause", same_song=False), {"x": 1}, + ) + notifier.notify_track.assert_called_once() diff --git a/tests/test_cli.py b/tests/test_cli.py index e88a63c..530683e 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2,10 +2,28 @@ from __future__ import annotations +import argparse +import configparser import os from pathlib import Path -from mpdris2.cli import build_parser, read_config +from mpdris2.cli import ( + _resolve_cdprev, + _resolve_music_dir, + _resolve_notifier_config, + _resolve_notify, + _resolve_notify_paused, + _resolve_notify_templates, + build_parser, + read_config, +) +from mpdris2.notify import NotifyTemplates + + +def _ns(**overrides) -> argparse.Namespace: + base = {"music_dir": None, "host": None, "port": None} + base.update(overrides) + return argparse.Namespace(**base) def test_parser_defaults() -> None: @@ -59,6 +77,69 @@ def test_read_config_parses_ini(tmp_path: Path) -> None: assert cfg.get("Library", "music_dir") == "/srv/music" +# --- notify resolvers ------------------------------------------------------ + +def test_resolve_notify_default_true() -> None: + assert _resolve_notify(configparser.ConfigParser()) is True + + +def test_resolve_notify_explicit_false() -> None: + cfg = configparser.ConfigParser() + cfg.read_string("[Notify]\nnotify = False\n") + assert _resolve_notify(cfg) is False + + +def test_resolve_notify_falls_back_to_bling() -> None: + cfg = configparser.ConfigParser() + cfg.read_string("[Bling]\nnotification = False\n") + assert _resolve_notify(cfg) is False + + +def test_resolve_notify_paused_default_false() -> None: + assert _resolve_notify_paused(configparser.ConfigParser()) is False + + +def test_resolve_notify_paused_explicit_true() -> None: + cfg = configparser.ConfigParser() + cfg.read_string("[Bling]\nnotify_paused = True\n") + assert _resolve_notify_paused(cfg) is True + + +def test_resolve_notify_templates_defaults_blank() -> None: + t = _resolve_notify_templates(configparser.ConfigParser()) + assert t == NotifyTemplates() + + +def test_resolve_notify_templates_explicit() -> None: + cfg = configparser.ConfigParser() + cfg.read_string( + "[Notify]\n" + "summary = %title%\n" + "body = by %artist%\n" + "paused_summary = (paused) %title%\n" + "paused_body = was %artist%\n" + ) + t = _resolve_notify_templates(cfg) + assert t.summary == "%title%" + assert t.body == "by %artist%" + assert t.paused_summary == "(paused) %title%" + assert t.paused_body == "was %artist%" + + +def test_resolve_notifier_config_defaults() -> None: + nc = _resolve_notifier_config(configparser.ConfigParser()) + assert nc.urgency == 1 + assert nc.timeout == -1 + + +def test_resolve_notifier_config_explicit() -> None: + cfg = configparser.ConfigParser() + cfg.read_string("[Notify]\nurgency = 2\ntimeout = 5000\n") + nc = _resolve_notifier_config(cfg) + assert nc.urgency == 2 + assert nc.timeout == 5000 + + def test_read_config_no_argument_falls_back_to_xdg(tmp_path: Path, monkeypatch) -> None: # Force the XDG path to point inside tmp_path so the lookup # is hermetic. With no file present the parser still returns an @@ -70,3 +151,72 @@ def test_read_config_no_argument_falls_back_to_xdg(tmp_path: Path, monkeypatch) cfg = read_config(None) assert cfg.sections() == [] os.environ.pop("XDG_CONFIG_HOME", None) + + +# --- _resolve_music_dir ---------------------------------------------------- + +def test_resolve_music_dir_from_cli(tmp_path: Path) -> None: + args = _ns(music_dir=str(tmp_path)) + cfg = configparser.ConfigParser() + assert _resolve_music_dir(cfg, args) == tmp_path + + +def test_resolve_music_dir_from_file_uri_in_config() -> None: + args = _ns() + cfg = configparser.ConfigParser() + cfg.read_string("[Library]\nmusic_dir = file:///srv/music\n") + assert _resolve_music_dir(cfg, args) == Path("/srv/music") + + +def test_resolve_music_dir_expands_tilde() -> None: + args = _ns() + cfg = configparser.ConfigParser() + cfg.read_string("[Library]\nmusic_dir = ~/Music\n") + result = _resolve_music_dir(cfg, args) + assert result == Path.home() / "Music" + + +def test_resolve_music_dir_non_local_scheme_returns_none(caplog) -> None: + args = _ns() + cfg = configparser.ConfigParser() + cfg.read_string("[Library]\nmusic_dir = http://example.com/music\n") + with caplog.at_level("WARNING"): + assert _resolve_music_dir(cfg, args) is None + assert any("absolute" in r.message for r in caplog.records) + + +def test_resolve_music_dir_relative_path_returns_none(caplog) -> None: + args = _ns() + cfg = configparser.ConfigParser() + cfg.read_string("[Library]\nmusic_dir = Music\n") + with caplog.at_level("WARNING"): + assert _resolve_music_dir(cfg, args) is None + assert any("absolute" in r.message for r in caplog.records) + + +def test_resolve_music_dir_file_uri_with_relative_path_rejected(caplog) -> None: + """``file://relative`` is invalid per RFC 8089 and would crash later + in ``Path.as_uri()`` — reject up front.""" + args = _ns() + cfg = configparser.ConfigParser() + cfg.read_string("[Library]\nmusic_dir = file://Music\n") + with caplog.at_level("WARNING"): + assert _resolve_music_dir(cfg, args) is None + + +def test_resolve_music_dir_unset_returns_none() -> None: + args = _ns() + cfg = configparser.ConfigParser() + assert _resolve_music_dir(cfg, args) is None + + +# --- _resolve_cdprev ------------------------------------------------------- + +def test_resolve_cdprev_default_false() -> None: + assert _resolve_cdprev(configparser.ConfigParser()) is False + + +def test_resolve_cdprev_explicit_true() -> None: + cfg = configparser.ConfigParser() + cfg.read_string("[Bling]\ncdprev = True\n") + assert _resolve_cdprev(cfg) is True From 98a397cd6691ef838fdba4f1a63d277dc6d5cad7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathieu=20R=C3=A9quillart?= Date: Thu, 14 May 2026 16:51:22 +0200 Subject: [PATCH 7/9] Replace autotools with pyproject + pybuild, drop legacy assets (PR 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves packaging to pybuild-plugin-pyproject so debian/rules no longer needs autoreconf; data files (systemd units, D-Bus activation, .desktop, example config, manpage) live under data/ and are installed via debian/mpdris2.install. CI is rebuilt on the snapclientmpris layout: lint (pip install + ruff + mypy + pytest + check-tag) then deb (in a debian:trixie container) then release + odio-apt-repo dispatch. i18n migrates from intltool to babel + msgfmt: po/fr.po and po/nl.po are preserved and re-merged against the new pot; mpdris2/locale/ is built as package_data so gettext finds the catalogs at runtime. cli.py binds the text domain; daemon.py uses ``from gettext import gettext as _`` for the notification strings. src/mpDris2.in.py and all autotools artefacts (configure.ac, autogen.sh, Makefile.am, INSTALL, src/, po/POTFILES.in, po/LINGUAS, debian/*.in) are gone. CLAUDE.md is rewritten to document the new layout. shell.nix swaps dbus-python + pygobject3 for dbus-fast and adds babel/ruff/mypy/ pytest for dev. .gitignore is trimmed to the patterns that still apply. Functional parity with the original daemon is preserved minus mmkeys (intentionally dropped — modern desktops route media keys through MPRIS2 directly). The systemd units keep ConditionUser=!root | !@system and Restart=always RestartSec=5. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/build.yml | 158 +- .gitignore | 26 - CLAUDE.md | 91 + INSTALL | 370 ---- Makefile | 7 +- Makefile.am | 17 - autogen.sh | 9 - babel.cfg | 2 +- configure.ac | 31 - .../dbus-1/org.mpris.MediaPlayer2.mpd.service | 2 +- data/mpdris2.conf | 50 + {src => data}/mpdris2.desktop | 0 .../user/mpDris2.service | 10 +- debian/control | 21 +- debian/mpDris2.1 | 70 + debian/mpDris2.1.in | 61 - debian/mpdris2.install | 14 +- debian/rules | 38 +- po/LINGUAS | 2 - po/POTFILES.in | 1 - po/fr.po | 50 +- po/mpdris2.pot | 52 + po/nl.po | 51 +- pyproject.toml | 4 +- shell.nix | 12 +- src/Makefile.am | 36 - src/mpDris2.conf | 36 - src/mpDris2.in.py | 1733 ----------------- tests/conftest.py | 15 + 29 files changed, 471 insertions(+), 2498 deletions(-) create mode 100644 CLAUDE.md delete mode 100644 INSTALL delete mode 100644 Makefile.am delete mode 100755 autogen.sh delete mode 100644 configure.ac rename src/org.mpris.MediaPlayer2.mpd.service.in => data/dbus-1/org.mpris.MediaPlayer2.mpd.service (68%) create mode 100644 data/mpdris2.conf rename {src => data}/mpdris2.desktop (100%) rename src/mpDris2.service.in => data/user/mpDris2.service (53%) create mode 100644 debian/mpDris2.1 delete mode 100644 debian/mpDris2.1.in delete mode 100644 po/LINGUAS delete mode 100644 po/POTFILES.in create mode 100644 po/mpdris2.pot delete mode 100644 src/Makefile.am delete mode 100644 src/mpDris2.conf delete mode 100755 src/mpDris2.in.py create mode 100644 tests/conftest.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 51f81db..f438baf 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,119 +2,120 @@ name: Build on: push: - branches: [master] tags: ['v*'] pull_request: - branches: [master] + +permissions: + contents: read jobs: - build: + lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 with: - fetch-depth: 0 - - - name: Install dependencies - run: | - sudo apt-get update - sudo apt-get install -y autoconf automake intltool python3 - - - name: Autogen - run: ./autogen.sh --sysconfdir=/etc - - - name: Build - run: make - - - name: Prepare release assets - run: | - mkdir -p release - cp src/mpDris2 release/ - cp src/mpDris2.service release/ - cp src/org.mpris.MediaPlayer2.mpd.service release/ - cp src/mpDris2.conf release/ - cp src/mpdris2.desktop release/ - - - name: Upload artifacts - uses: actions/upload-artifact@v6 - with: - name: mpDris2 - path: release/ + python-version: '3.11' + - name: Install runtime + dev deps + run: pip install -e .[dev,cover] + # Fail fast on a tag that doesn't match mpdris2/__init__.py, + # before the deb job (and the rest of the matrix) burns minutes. + - name: Check tag matches __init__.py + if: startsWith(github.ref, 'refs/tags/v') + run: make check-tag + - name: ruff + if: always() + run: make lint-ruff + - name: mypy + if: always() + run: make lint-mypy + - name: Pytest + if: always() + run: make test deb: runs-on: ubuntu-latest - if: startsWith(github.ref, 'refs/tags/v') + # Build inside the same Debian release we target (trixie) so dh-python, + # debhelper and python3-* versions match what users actually have. + container: + image: debian:trixie steps: - - uses: actions/checkout@v5 + - name: Install build deps + run: | + apt-get update + apt-get install -y --no-install-recommends \ + ca-certificates git make gettext \ + build-essential debhelper dh-python pybuild-plugin-pyproject \ + python3 python3-setuptools python3-babel devscripts + + - uses: actions/checkout@v6 with: fetch-depth: 0 - - name: Install Debian build dependencies - run: | - sudo apt-get update - sudo apt-get install -y \ - build-essential debhelper dh-python \ - autoconf automake gettext intltool \ - python3 devscripts - - - name: Sync changelog with tag version + - name: Sync changelog from __init__.py + if: startsWith(github.ref, 'refs/tags/v') env: DEBFULLNAME: GitHub Actions DEBEMAIL: actions@github.com - run: | - TAG_VERSION="${GITHUB_REF_NAME#v}" - CL_VERSION="$(dpkg-parsechangelog -S Version)" - if [ "${TAG_VERSION}" != "${CL_VERSION}" ]; then - echo "Tag ${TAG_VERSION} differs from changelog ${CL_VERSION} — prepending entry" - dch --newversion "${TAG_VERSION}" --distribution unstable --urgency medium \ - "Release ${TAG_VERSION}" - else - echo "Tag matches changelog (${CL_VERSION})" - fi + run: make sync-deb - name: Build .deb - run: dpkg-buildpackage -b -us -uc + run: make deb - name: Collect .deb run: | - mkdir -p deb - mv ../*.deb deb/ - ls -lh deb/ + mkdir -p dist + mv ../mpdris2_*.deb dist/ + ls -lh dist/ - - name: Upload .deb artifact - uses: actions/upload-artifact@v6 + - uses: actions/upload-artifact@v6 with: name: mpdris2-deb - path: deb/*.deb + path: dist/*.deb + if-no-files-found: error + + sdist: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 + with: + python-version: '3.11' + - name: Install build deps + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends gettext + pip install build + - name: Compile .mo catalogs + run: make i18n-compile + - name: Build sdist + run: python -m build --sdist + - uses: actions/upload-artifact@v6 + with: + name: mpdris2-sdist + path: dist/*.tar.gz + if-no-files-found: error release: - needs: [build, deb] + needs: [lint, deb, sdist] if: startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest permissions: contents: write steps: - - name: Download tarball assets - uses: actions/download-artifact@v6 - with: - name: mpDris2 - path: release - - - name: Download .deb - uses: actions/download-artifact@v6 + - uses: actions/download-artifact@v8 with: name: mpdris2-deb - path: deb - - - name: Create release archive - run: tar czf mpDris2-${GITHUB_REF_NAME#v}.tar.gz -C release . - - - name: Create release - uses: softprops/action-gh-release@v2 + path: dist/ + - uses: actions/download-artifact@v8 + with: + name: mpdris2-sdist + path: dist/ + - uses: softprops/action-gh-release@v3 with: files: | - mpDris2-*.tar.gz - deb/*.deb + dist/*.deb + dist/*.tar.gz generate_release_notes: true prerelease: ${{ contains(github.ref_name, '-rc') || contains(github.ref_name, '-beta') || contains(github.ref_name, '-alpha') }} @@ -123,8 +124,7 @@ jobs: if: startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest steps: - - name: Trigger apt-repo rebuild - uses: peter-evans/repository-dispatch@v3 + - uses: peter-evans/repository-dispatch@v3 with: token: ${{ secrets.APT_REPO_TOKEN }} repository: b0bbywan/odio-apt-repo diff --git a/.gitignore b/.gitignore index 006d400..91038b2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,26 +1,3 @@ -# Autotools (legacy — removed by PR 4 in the asyncio refactor; kept here -# transitionally so an accidental `./autogen.sh` doesn't dirty the tree). -Makefile.in -Makefile.in.in -po/Makefile -src/Makefile -compile -config.* -configure -depcomp -install-sh -missing -aclocal.m4 -autom4te.cache -org.mpris.MediaPlayer2.mpd.service -POTFILES -*.gmo -*.pot -stamp-it -mpDris2 -mpDris2.py -mpDris2.service - # Python build artifacts __pycache__/ *.pyc @@ -35,11 +12,8 @@ mpdris2/locale/ # debhelper / dpkg-buildpackage artifacts debian/.debhelper/ -debian/autoreconf.after -debian/autoreconf.before debian/debhelper-build-stamp debian/files -debian/mpDris2.1 debian/mpdris2/ debian/mpdris2.debhelper.log debian/mpdris2.postinst.debhelper diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..300f1bb --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,91 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +mpDris2 is a Python 3 asyncio daemon that provides MPRIS 2 (Media Player Remote Interfacing Specification) D-Bus interface support for MPD (Music Player Daemon). It monitors a local or remote MPD server and exposes it as an MPRIS2-compliant media player on the session D-Bus, using `python-mpd2` for the MPD protocol and `dbus-fast` for the MPRIS interface. No threads, no GLib. + +## Build System + +`pyproject.toml` (setuptools backend) is the build entry point; `mpdris2/__init__.py` is the version source of truth. + +```bash +# Dev install + tooling +pip install -e '.[dev,cover]' + +# Lint, type-check, tests +make lint +make test + +# Build the Debian package (needs a Debian toolchain — use the +# debian:trixie container that CI uses for parity). +make deb + +# Sync the .deb changelog with __init__.py before tagging a release +make sync-deb +``` + +For Nix users, `shell.nix` provides a development shell. + +## Source Structure + +Flat package at `mpdris2/`: + +| Module | Responsibility | +|--------|----------------| +| `__init__.py` | `__version__` (parsed by `scripts/version.py` and `pyproject.toml`) | +| `__main__.py` | `python -m mpdris2` entry point | +| `cli.py` | argparse, INI config loading, gettext bind, `asyncio.run(run(cfg, args))` | +| `bridge.py` | `MpdMprisBridge` — MPD connect/reconnect, D-Bus export, MPRIS callbacks, idle-driven `refresh()` | +| `mpd_client.py` | `mpd.asyncio.MPDClient` wrapper: connect-with-backoff + capability probe | +| `mpris.py` | `dbus_fast.ServiceInterface` classes: `MediaPlayer2` (root) + `MediaPlayer2Player` | +| `translate.py` | Pure MPD song dict → MPRIS Metadata dict (`Variant`-wrapped) | +| `cover.py` | 5-step async cover pipeline (MPD readpicture → filesystem regex → MPD albumart → CUE/cdda fallback → XDG cache) | +| `notify.py` | Desktop notifications via `org.freedesktop.Notifications` over dbus-fast | +| `locale/` | Compiled `.mo` files (built from `po/*.po`, shipped as package data) | + +Helper scripts: `scripts/version.py` parses `__init__.py` and produces both PEP 440 and Debian-sortable forms. Used by `make sync-deb` and `make check-tag`. + +## Runtime Dependencies + +- Python 3.11+ +- `python-mpd2 >= 3.1` +- `dbus-fast >= 2.0` + +Dev: `pytest`, `pytest-asyncio`, `mypy`, `ruff`, `babel`, `build`. + +## Configuration + +User config at `~/.config/mpDris2/mpDris2.conf` (INI), falling back to `/etc/mpDris2/mpDris2.conf`. Example shipped at `/usr/share/doc/mpdris2/mpdris2.conf`. + +Sections in current use: +- `[Connection]` — `host`, `port`, `password` +- `[Library]` — `music_dir`, `cover_regex` +- `[Notify]` — `notify` (bool) + +CLI overrides config: `-H`/`--host`, `-p`/`--port`, `--music-dir`, `--config`, `--use-journal`, `--no-reconnect`, `-v`/`--verbose`. + +## i18n + +`po/fr.po` + `po/nl.po`. `babel.cfg` controls extraction, `msgfmt` (from `gettext`) compiles `.mo` files. + +```bash +make i18n-extract # refresh po/mpdris2.pot from current source +make i18n-compile # rebuild mpdris2/locale/*/LC_MESSAGES/mpdris2.mo +``` + +The runtime catalog lookup is wired in `cli.py` (`gettext.bindtextdomain` + `gettext.textdomain` against `mpdris2/locale/`). Modules use `from gettext import gettext as _`. + +## Packaging + +- **Debian**: `debian/` uses `pybuild-plugin-pyproject` (no autotools). `debian/rules` calls `msgfmt` before `dh_auto_build` to compile `.mo` files; data files (`data/*.{service,desktop,conf}`) are listed in `debian/mpdris2.install`. +- **Systemd**: user unit `data/user/mpDris2.service` (`Type=dbus`, `BusName=org.mpris.MediaPlayer2.mpd`, `Restart=on-failure`, `ConditionUser=!root` / `ConditionUser=!@system`). Not auto-enabled on install — D-Bus activation kicks it in on the first MPRIS call. +- **D-Bus activation**: `data/dbus-1/org.mpris.MediaPlayer2.mpd.service` → `/usr/share/dbus-1/services/`. +- **CI**: `.github/workflows/build.yml` runs lint + tests on every PR, builds the `.deb` in a `debian:trixie` container on tags, creates a GitHub release, and dispatches to the private `b0bbywan/odio-apt-repo` for APT-repo rebuild. + +## Notes + +- Media keys: the original GNOME `org.gnome.SettingsDaemon.MediaKeys` grab was dropped during the asyncio rewrite. Modern desktops (GNOME ≥ 3.6, KDE, Sway/Hyprland with `playerctld`) route media keys through MPRIS2 directly. +- The `[Bling] mmkeys` config key is no longer honoured (the GNOME MediaKeys grab was dropped in the asyncio rewrite). `[Bling] cdprev` and `[Bling] notify_paused` are still honoured by `bridge.py`. +- The legacy `src/mpDris2.in.py` + autotools build was removed in the same refactor; see `docs/refactor-asyncio-dbus-fast.md` for the migration plan. diff --git a/INSTALL b/INSTALL deleted file mode 100644 index 2099840..0000000 --- a/INSTALL +++ /dev/null @@ -1,370 +0,0 @@ -Installation Instructions -************************* - -Copyright (C) 1994-1996, 1999-2002, 2004-2013 Free Software Foundation, -Inc. - - Copying and distribution of this file, with or without modification, -are permitted in any medium without royalty provided the copyright -notice and this notice are preserved. This file is offered as-is, -without warranty of any kind. - -Basic Installation -================== - - Briefly, the shell command `./configure && make && make install' -should configure, build, and install this package. The following -more-detailed instructions are generic; see the `README' file for -instructions specific to this package. Some packages provide this -`INSTALL' file but do not implement all of the features documented -below. The lack of an optional feature in a given package is not -necessarily a bug. More recommendations for GNU packages can be found -in *note Makefile Conventions: (standards)Makefile Conventions. - - The `configure' shell script attempts to guess correct values for -various system-dependent variables used during compilation. It uses -those values to create a `Makefile' in each directory of the package. -It may also create one or more `.h' files containing system-dependent -definitions. Finally, it creates a shell script `config.status' that -you can run in the future to recreate the current configuration, and a -file `config.log' containing compiler output (useful mainly for -debugging `configure'). - - It can also use an optional file (typically called `config.cache' -and enabled with `--cache-file=config.cache' or simply `-C') that saves -the results of its tests to speed up reconfiguring. Caching is -disabled by default to prevent problems with accidental use of stale -cache files. - - If you need to do unusual things to compile the package, please try -to figure out how `configure' could check whether to do them, and mail -diffs or instructions to the address given in the `README' so they can -be considered for the next release. If you are using the cache, and at -some point `config.cache' contains results you don't want to keep, you -may remove or edit it. - - The file `configure.ac' (or `configure.in') is used to create -`configure' by a program called `autoconf'. You need `configure.ac' if -you want to change it or regenerate `configure' using a newer version -of `autoconf'. - - The simplest way to compile this package is: - - 1. `cd' to the directory containing the package's source code and type - `./configure' to configure the package for your system. - - Running `configure' might take a while. While running, it prints - some messages telling which features it is checking for. - - 2. Type `make' to compile the package. - - 3. Optionally, type `make check' to run any self-tests that come with - the package, generally using the just-built uninstalled binaries. - - 4. Type `make install' to install the programs and any data files and - documentation. When installing into a prefix owned by root, it is - recommended that the package be configured and built as a regular - user, and only the `make install' phase executed with root - privileges. - - 5. Optionally, type `make installcheck' to repeat any self-tests, but - this time using the binaries in their final installed location. - This target does not install anything. Running this target as a - regular user, particularly if the prior `make install' required - root privileges, verifies that the installation completed - correctly. - - 6. You can remove the program binaries and object files from the - source code directory by typing `make clean'. To also remove the - files that `configure' created (so you can compile the package for - a different kind of computer), type `make distclean'. There is - also a `make maintainer-clean' target, but that is intended mainly - for the package's developers. If you use it, you may have to get - all sorts of other programs in order to regenerate files that came - with the distribution. - - 7. Often, you can also type `make uninstall' to remove the installed - files again. In practice, not all packages have tested that - uninstallation works correctly, even though it is required by the - GNU Coding Standards. - - 8. Some packages, particularly those that use Automake, provide `make - distcheck', which can by used by developers to test that all other - targets like `make install' and `make uninstall' work correctly. - This target is generally not run by end users. - -Compilers and Options -===================== - - Some systems require unusual options for compilation or linking that -the `configure' script does not know about. Run `./configure --help' -for details on some of the pertinent environment variables. - - You can give `configure' initial values for configuration parameters -by setting variables in the command line or in the environment. Here -is an example: - - ./configure CC=c99 CFLAGS=-g LIBS=-lposix - - *Note Defining Variables::, for more details. - -Compiling For Multiple Architectures -==================================== - - You can compile the package for more than one kind of computer at the -same time, by placing the object files for each architecture in their -own directory. To do this, you can use GNU `make'. `cd' to the -directory where you want the object files and executables to go and run -the `configure' script. `configure' automatically checks for the -source code in the directory that `configure' is in and in `..'. This -is known as a "VPATH" build. - - With a non-GNU `make', it is safer to compile the package for one -architecture at a time in the source code directory. After you have -installed the package for one architecture, use `make distclean' before -reconfiguring for another architecture. - - On MacOS X 10.5 and later systems, you can create libraries and -executables that work on multiple system types--known as "fat" or -"universal" binaries--by specifying multiple `-arch' options to the -compiler but only a single `-arch' option to the preprocessor. Like -this: - - ./configure CC="gcc -arch i386 -arch x86_64 -arch ppc -arch ppc64" \ - CXX="g++ -arch i386 -arch x86_64 -arch ppc -arch ppc64" \ - CPP="gcc -E" CXXCPP="g++ -E" - - This is not guaranteed to produce working output in all cases, you -may have to build one architecture at a time and combine the results -using the `lipo' tool if you have problems. - -Installation Names -================== - - By default, `make install' installs the package's commands under -`/usr/local/bin', include files under `/usr/local/include', etc. You -can specify an installation prefix other than `/usr/local' by giving -`configure' the option `--prefix=PREFIX', where PREFIX must be an -absolute file name. - - You can specify separate installation prefixes for -architecture-specific files and architecture-independent files. If you -pass the option `--exec-prefix=PREFIX' to `configure', the package uses -PREFIX as the prefix for installing programs and libraries. -Documentation and other data files still use the regular prefix. - - In addition, if you use an unusual directory layout you can give -options like `--bindir=DIR' to specify different values for particular -kinds of files. Run `configure --help' for a list of the directories -you can set and what kinds of files go in them. In general, the -default for these options is expressed in terms of `${prefix}', so that -specifying just `--prefix' will affect all of the other directory -specifications that were not explicitly provided. - - The most portable way to affect installation locations is to pass the -correct locations to `configure'; however, many packages provide one or -both of the following shortcuts of passing variable assignments to the -`make install' command line to change installation locations without -having to reconfigure or recompile. - - The first method involves providing an override variable for each -affected directory. For example, `make install -prefix=/alternate/directory' will choose an alternate location for all -directory configuration variables that were expressed in terms of -`${prefix}'. Any directories that were specified during `configure', -but not in terms of `${prefix}', must each be overridden at install -time for the entire installation to be relocated. The approach of -makefile variable overrides for each directory variable is required by -the GNU Coding Standards, and ideally causes no recompilation. -However, some platforms have known limitations with the semantics of -shared libraries that end up requiring recompilation when using this -method, particularly noticeable in packages that use GNU Libtool. - - The second method involves providing the `DESTDIR' variable. For -example, `make install DESTDIR=/alternate/directory' will prepend -`/alternate/directory' before all installation names. The approach of -`DESTDIR' overrides is not required by the GNU Coding Standards, and -does not work on platforms that have drive letters. On the other hand, -it does better at avoiding recompilation issues, and works well even -when some directory options were not specified in terms of `${prefix}' -at `configure' time. - -Optional Features -================= - - If the package supports it, you can cause programs to be installed -with an extra prefix or suffix on their names by giving `configure' the -option `--program-prefix=PREFIX' or `--program-suffix=SUFFIX'. - - Some packages pay attention to `--enable-FEATURE' options to -`configure', where FEATURE indicates an optional part of the package. -They may also pay attention to `--with-PACKAGE' options, where PACKAGE -is something like `gnu-as' or `x' (for the X Window System). The -`README' should mention any `--enable-' and `--with-' options that the -package recognizes. - - For packages that use the X Window System, `configure' can usually -find the X include and library files automatically, but if it doesn't, -you can use the `configure' options `--x-includes=DIR' and -`--x-libraries=DIR' to specify their locations. - - Some packages offer the ability to configure how verbose the -execution of `make' will be. For these packages, running `./configure ---enable-silent-rules' sets the default to minimal output, which can be -overridden with `make V=1'; while running `./configure ---disable-silent-rules' sets the default to verbose, which can be -overridden with `make V=0'. - -Particular systems -================== - - On HP-UX, the default C compiler is not ANSI C compatible. If GNU -CC is not installed, it is recommended to use the following options in -order to use an ANSI C compiler: - - ./configure CC="cc -Ae -D_XOPEN_SOURCE=500" - -and if that doesn't work, install pre-built binaries of GCC for HP-UX. - - HP-UX `make' updates targets which have the same time stamps as -their prerequisites, which makes it generally unusable when shipped -generated files such as `configure' are involved. Use GNU `make' -instead. - - On OSF/1 a.k.a. Tru64, some versions of the default C compiler cannot -parse its `' header file. The option `-nodtk' can be used as -a workaround. If GNU CC is not installed, it is therefore recommended -to try - - ./configure CC="cc" - -and if that doesn't work, try - - ./configure CC="cc -nodtk" - - On Solaris, don't put `/usr/ucb' early in your `PATH'. This -directory contains several dysfunctional programs; working variants of -these programs are available in `/usr/bin'. So, if you need `/usr/ucb' -in your `PATH', put it _after_ `/usr/bin'. - - On Haiku, software installed for all users goes in `/boot/common', -not `/usr/local'. It is recommended to use the following options: - - ./configure --prefix=/boot/common - -Specifying the System Type -========================== - - There may be some features `configure' cannot figure out -automatically, but needs to determine by the type of machine the package -will run on. Usually, assuming the package is built to be run on the -_same_ architectures, `configure' can figure that out, but if it prints -a message saying it cannot guess the machine type, give it the -`--build=TYPE' option. TYPE can either be a short name for the system -type, such as `sun4', or a canonical name which has the form: - - CPU-COMPANY-SYSTEM - -where SYSTEM can have one of these forms: - - OS - KERNEL-OS - - See the file `config.sub' for the possible values of each field. If -`config.sub' isn't included in this package, then this package doesn't -need to know the machine type. - - If you are _building_ compiler tools for cross-compiling, you should -use the option `--target=TYPE' to select the type of system they will -produce code for. - - If you want to _use_ a cross compiler, that generates code for a -platform different from the build platform, you should specify the -"host" platform (i.e., that on which the generated programs will -eventually be run) with `--host=TYPE'. - -Sharing Defaults -================ - - If you want to set default values for `configure' scripts to share, -you can create a site shell script called `config.site' that gives -default values for variables like `CC', `cache_file', and `prefix'. -`configure' looks for `PREFIX/share/config.site' if it exists, then -`PREFIX/etc/config.site' if it exists. Or, you can set the -`CONFIG_SITE' environment variable to the location of the site script. -A warning: not all `configure' scripts look for a site script. - -Defining Variables -================== - - Variables not defined in a site shell script can be set in the -environment passed to `configure'. However, some packages may run -configure again during the build, and the customized values of these -variables may be lost. In order to avoid this problem, you should set -them in the `configure' command line, using `VAR=value'. For example: - - ./configure CC=/usr/local2/bin/gcc - -causes the specified `gcc' to be used as the C compiler (unless it is -overridden in the site shell script). - -Unfortunately, this technique does not work for `CONFIG_SHELL' due to -an Autoconf limitation. Until the limitation is lifted, you can use -this workaround: - - CONFIG_SHELL=/bin/bash ./configure CONFIG_SHELL=/bin/bash - -`configure' Invocation -====================== - - `configure' recognizes the following options to control how it -operates. - -`--help' -`-h' - Print a summary of all of the options to `configure', and exit. - -`--help=short' -`--help=recursive' - Print a summary of the options unique to this package's - `configure', and exit. The `short' variant lists options used - only in the top level, while the `recursive' variant lists options - also present in any nested packages. - -`--version' -`-V' - Print the version of Autoconf used to generate the `configure' - script, and exit. - -`--cache-file=FILE' - Enable the cache: use and save the results of the tests in FILE, - traditionally `config.cache'. FILE defaults to `/dev/null' to - disable caching. - -`--config-cache' -`-C' - Alias for `--cache-file=config.cache'. - -`--quiet' -`--silent' -`-q' - Do not print messages saying which checks are being made. To - suppress all normal output, redirect it to `/dev/null' (any error - messages will still be shown). - -`--srcdir=DIR' - Look for the package's source code in directory DIR. Usually - `configure' can determine that directory automatically. - -`--prefix=DIR' - Use DIR as the installation prefix. *note Installation Names:: - for more details, including other options available for fine-tuning - the installation locations. - -`--no-create' -`-n' - Run the configure checks, but stop before creating any output - files. - -`configure' also accepts some other, not widely useful, options. Run -`configure --help' for more details. diff --git a/Makefile b/Makefile index 9f5d720..44a299a 100644 --- a/Makefile +++ b/Makefile @@ -66,7 +66,12 @@ clean: # Refresh the .pot template from current source. i18n-extract: - pybabel extract -F babel.cfg -o po/mpdris2.pot mpdris2/ + pybabel extract -F babel.cfg \ + --project=mpDris2 \ + --version="$$($(VERSION))" \ + --copyright-holder="Mathieu Réquillart" \ + --msgid-bugs-address=https://github.com/b0bbywan/mpDris2/issues \ + -o po/mpdris2.pot mpdris2/ # Compile .po files into the package's runtime locale tree. i18n-compile: diff --git a/Makefile.am b/Makefile.am deleted file mode 100644 index b3e1c8c..0000000 --- a/Makefile.am +++ /dev/null @@ -1,17 +0,0 @@ -SUBDIRS = src po -dist_doc_DATA = README COPYING AUTHORS - -EXTRA_DIST = autogen.sh - -dist-hook: - find $(distdir) -name "*~" -exec rm -vf {} + - -sysconfdirwarning: - @if test "${sysconfdir}" != "/etc"; then \ - echo '###'; \ - echo '### $${sysconfdir} evaluates to '${sysconfdir}': this is probably not what you want !'; \ - echo '### You may want to launch ./configure --sysconfdir=/etc.'; \ - echo '###'; \ - fi - -.PHONY = sysconfdirwarning diff --git a/autogen.sh b/autogen.sh deleted file mode 100755 index 43b4459..0000000 --- a/autogen.sh +++ /dev/null @@ -1,9 +0,0 @@ -#!/bin/sh - -autoreconf -f -i || exit - -intltoolize -f || exit - -if [ -z "$NOCONFIGURE" ]; then - ./configure --sysconfdir=/etc "$@" -fi diff --git a/babel.cfg b/babel.cfg index 98c5550..efceab8 100644 --- a/babel.cfg +++ b/babel.cfg @@ -1 +1 @@ -[python: mpdris2/**.py] +[python: **.py] diff --git a/configure.ac b/configure.ac deleted file mode 100644 index 409cb6a..0000000 --- a/configure.ac +++ /dev/null @@ -1,31 +0,0 @@ -AC_INIT([mpDris2], - [0.9.3], - [https://github.com/eonpatapon/mpDris2/issues], - [mpdris2], - [https://github.com/eonpatapon/mpDris2]) -AC_CONFIG_AUX_DIR([build-aux]) -AM_INIT_AUTOMAKE([1.11 tar-ustar foreign]) - -m4_ifdef([AM_SILENT_RULES],[AM_SILENT_RULES([yes])]) - -AM_PATH_PYTHON([3.4],, [:]) - -define([gitversion], esyscmd([sh -c "which git > /dev/null && (git describe | tr -d '\n' || false)"])) -GITVERSION="gitversion" -AC_SUBST(GITVERSION) - -GETTEXT_PACKAGE=mpDris2 -AC_SUBST(GETTEXT_PACKAGE) -AC_DEFINE_UNQUOTED(GETTEXT_PACKAGE, "$GETTEXT_PACKAGE", - [The prefix for our gettext translation domains.]) -IT_PROG_INTLTOOL(0.26) - -AC_CONFIG_FILES([ - Makefile - src/Makefile - po/Makefile.in -]) -AC_OUTPUT - -dnl Warn user sysconfdir is not /etc, if necessary. -make sysconfdirwarning diff --git a/src/org.mpris.MediaPlayer2.mpd.service.in b/data/dbus-1/org.mpris.MediaPlayer2.mpd.service similarity index 68% rename from src/org.mpris.MediaPlayer2.mpd.service.in rename to data/dbus-1/org.mpris.MediaPlayer2.mpd.service index b4ea7f4..21a5acf 100644 --- a/src/org.mpris.MediaPlayer2.mpd.service.in +++ b/data/dbus-1/org.mpris.MediaPlayer2.mpd.service @@ -1,4 +1,4 @@ [D-BUS Service] Name=org.mpris.MediaPlayer2.mpd -Exec=@bindir@/mpDris2 --use-journal +Exec=/usr/bin/mpDris2 --use-journal SystemdService=mpDris2.service diff --git a/data/mpdris2.conf b/data/mpdris2.conf new file mode 100644 index 0000000..59a12ed --- /dev/null +++ b/data/mpdris2.conf @@ -0,0 +1,50 @@ +# Copy this to /etc/mpDris2/mpDris2.conf or +# ~/.config/mpDris2/mpDris2.conf. Default values are shown here, +# commented out. + +[Connection] +# You can also export $MPD_HOST and/or $MPD_PORT to change the server. +#host = localhost +#port = 6600 +#password = + +[Library] +# Required for cover-art resolution when MPD is remote. Auto-detected +# over a local Unix socket connection. +#music_dir = +# Regex matched against filenames in the song's directory (step 2 of +# the cover pipeline). Useful for non-standard names like folder.jpg. +#cover_regex = ^(album|cover|\.?folder|front).*\.(gif|jpe?g|png|webp|bmp)$ +# Where the downloaded-covers cache lives (defaults to +# $XDG_CACHE_HOME/mpDris2/). +#cover_cache_dir = + +[Bling] +# Fire a libnotify bubble on every track change. ``[Notify] notify`` +# is the preferred location; this name is kept as a legacy fallback +# under its historical ``notification`` spelling. +#notification = True +# Also notify when the player is paused (default: only when playing). +#notify_paused = False +# CD-like Previous: if elapsed >= 3 s, restart the current track +# instead of jumping to the previous one. +#cdprev = False + +[Notify] +# Preferred location for the on/off switch above; takes precedence +# over [Bling] notification. +#notify = True +# Urgency forwarded to the notification server: 0 low, 1 normal, +# 2 critical. +#urgency = 1 +# Bubble lifetime in milliseconds — -1 lets the server decide, +# 0 means "never expire". +#timeout = -1 +# Format strings for the bubble. Empty = use the built-in default. +# Placeholders: %album% %title% %id% %time% %timeposition% %date% +# %track% %disc% %artist% %albumartist% %composer% +# %genre% %file% +#summary = +#body = +#paused_summary = +#paused_body = diff --git a/src/mpdris2.desktop b/data/mpdris2.desktop similarity index 100% rename from src/mpdris2.desktop rename to data/mpdris2.desktop diff --git a/src/mpDris2.service.in b/data/user/mpDris2.service similarity index 53% rename from src/mpDris2.service.in rename to data/user/mpDris2.service index ecff5e2..287226e 100644 --- a/src/mpDris2.service.in +++ b/data/user/mpDris2.service @@ -1,15 +1,13 @@ [Unit] Description=mpDris2 - Music Player Daemon D-Bus bridge -After=mpd.service -BindsTo=mpd.service ConditionUser=!root ConditionUser=!@system [Service] -Restart=always -RestartSec=5 -ExecStart=@bindir@/mpDris2 --use-journal --no-reconnect +Type=dbus BusName=org.mpris.MediaPlayer2.mpd +Restart=on-failure +ExecStart=/usr/bin/mpDris2 --use-journal [Install] -WantedBy=mpd.service +WantedBy=default.target diff --git a/debian/control b/debian/control index b40d749..9ccabdb 100644 --- a/debian/control +++ b/debian/control @@ -5,9 +5,10 @@ Maintainer: Mathieu Réquillart Build-Depends: debhelper-compat (= 13), dh-python, - gettext, - intltool, + pybuild-plugin-pyproject, python3, + python3-setuptools, + python3-babel, Rules-Requires-Root: no Standards-Version: 4.7.0 Homepage: https://github.com/b0bbywan/mpDris2 @@ -16,19 +17,14 @@ Vcs-Browser: https://github.com/b0bbywan/mpDris2 Package: mpdris2 Architecture: all -Pre-Depends: - ${misc:Pre-Depends}, +Multi-Arch: foreign Depends: default-dbus-session-bus | dbus-session-bus, python3, - python3-dbus, - python3-gi, python3-mpd, + python3-dbus-fast, ${misc:Depends}, ${python3:Depends}, -Recommends: - gir1.2-notify-0.7, - python3-mutagen, Suggests: mpd, python3-systemd, @@ -37,7 +33,8 @@ Provides: Description: media player interface (MPRIS2) bridge for MPD mpDris2 is an implementation of the MPRIS2 media player interface as a client for MPD, allowing MPRIS2 clients to control MPD and observe its - track changes via a standard D-Bus interface. + track changes via a standard D-Bus interface. The daemon runs entirely + on asyncio: python-mpd2 for the MPD protocol and dbus-fast for the + MPRIS interface. . - It can also respond to multimedia keys if running under GNOME, - and send track-change notifications if gir1.2-notify-0.7 is installed. + The package ships a user systemd unit (not auto-enabled). diff --git a/debian/mpDris2.1 b/debian/mpDris2.1 new file mode 100644 index 0000000..7499a0a --- /dev/null +++ b/debian/mpDris2.1 @@ -0,0 +1,70 @@ +.TH MPDRIS2 1 "May 2026" "" "D-Bus services" +.\" Copyright © 2012-2016 Simon McVittie +.\" Copyright © 2026 Mathieu Réquillart (asyncio / dbus-fast rewrite) +.\" It may be distributed under the same terms as mpDris2 itself. +.SH NAME +mpDris2 \- media player interface (MPRIS2) bridge for MPD +.SH SYNOPSIS +.BR mpDris2 +.RI [ OPTIONS ] +.SH DESCRIPTION +.B mpDris2 +is an implementation of the MPRIS2 media player interface as a +client for MPD, allowing MPRIS2 clients to control MPD and observe its +track changes via a standard D-Bus interface. The daemon runs entirely +on asyncio (python-mpd2 + dbus-fast); track-change notifications are +sent via libnotify when a notification daemon is available. +.PP +It is normally run automatically via D-Bus service activation, or as a +systemd user unit. On systems following the freedesktop.org Desktop +Application Autostart Specification it will be run on login. +.SH CONFIGURATION +\fBmpDris2\fR is normally configured via the file +\fIXDG_CONFIG_HOME\fB/mpDris2/mpDris2.conf\fR (typically +\fB~/.config/mpDris2/mpDris2.conf\fR), with +\fI/etc/mpDris2/mpDris2.conf\fR as a system-wide fallback. An example +configuration ships at \fB/usr/share/doc/mpdris2/mpdris2.conf\fR. +Settings in the configuration file can be overridden by command-line +options and environment variables. +.SH OPTIONS +.TP +\fB-h\fR, \fB--help\fR +Show a help message and exit. +.TP +\fB-v\fR, \fB--verbose\fR +Enable debug logging. +.TP +\fB--use-journal\fR +Log without timestamps (suitable for systemd-journald, which adds them). +.TP +\fB--no-reconnect\fR +Exit on MPD disconnect instead of retrying with backoff. +.TP +\fB--config=\fIPATH\fR +Read an alternative configuration file. +.TP +\fB-H\fR \fIHOST\fR, \fB--host=\fIHOST\fR +MPD host, overriding the configuration file. +.TP +\fB-p\fR \fIPORT\fR, \fB--port=\fIPORT\fR +MPD port, overriding the configuration file. +.TP +\fB--music-dir=\fIPATH\fR +Music library path, overriding the configuration file. +.SH ENVIRONMENT +.TP +\fBXDG_CONFIG_HOME\fR=\fIDIRECTORY\fR +Used to find the configuration file according to the XDG Base +Directory Specification. +.TP +\fBMPD_HOST\fR=\fIHOSTNAME\fR +Set the hostname of the MPD server, overriding the configuration file. +.TP +\fBMPD_PORT\fR=\fIPORT\fR +Set the port number of the MPD server, overriding the configuration file. +.TP +\fBXDG_MUSIC_DIR\fR=\fIDIRECTORY\fR +Used to find the default music directory if not specified in the +configuration file or as a command-line option. +.SH SEE ALSO +.BR mpd (1) diff --git a/debian/mpDris2.1.in b/debian/mpDris2.1.in deleted file mode 100644 index f95f405..0000000 --- a/debian/mpDris2.1.in +++ /dev/null @@ -1,61 +0,0 @@ -.TH MPDRIS2 1 "September 2012" "" "D-Bus services" -\" This man page was written by Simon McVittie for the Debian project, -\" but may be used by others. -\" Copyright © 2012 Simon McVittie -\" It may be distributed under the same terms as mpDris2 itself. -.SH NAME -mpDris2 \- media player interface (MPRIS2) bridge for MPD -.SH SYNOPSIS -.BR mpDris2 -.RI [ OPTIONS ] -.SH DESCRIPTION -.B mpDris2 -is an implementation of the MPRIS2 media player interface as a -client for MPD, allowing MPRIS2 clients to control MPD and observe its -track changes via a standard D-Bus interface. -.PP -It can also respond to multimedia keys if running under GNOME, -and send track-change notifications if python-notify is installed. -.PP -It is normally run automatically via D-Bus service activation. On systems -following the freedesktop.org Desktop Application Autostart Specification, -it will be run automatically on login. -.SH CONFIGURATION -\fBmpDris2\fR is normally configured via the file -\fIXDG_CONFIG_DIRS\fB/mpDris2/mpDris2.conf\fR (typically -\fB~/.config/mpDris2/mpDris2.conf\fR), -whose contents are documented in \fB@docdir@/README\fR. -Settings in this configuration file can be overridden by command-line -options and environment variables. -.SH OPTIONS -.TP -\fB-p\fR \fIPATH\fR, \fB--path=\fIPATH\fR -Set the location of the directory from which mpd reads music, overriding -the configuration file. The default is to use the path specified in the -configuration file, or try some likely directories (\fIXDG_MUSIC_DIR\fR from -the environment or \fIXDG_CONFIG_DIRS\fB/user-dirs.dirs\fR, \fB~/Music\fR, -\fB~/music\fR). -.TP -\fB-h\fR, \fB--help\fR -Show a help message -.TP -\fB-d\fR, \fB--debug\fR -Emit debug logging messages -.SH ENVIRONMENT -.TP -\fBXDG_CONFIG_HOME\fR=\fIDIRECTORY\fR, \fBXDG_CONFIG_DIRS\fR=\fISEARCHPATH\fR -Used to find the configuration file -\fIXDG_CONFIG_DIRS\fB/mpDris2/mpDris2.conf\fR according to the -XDG Base Directory Specification. -.TP -\fBMPD_HOST\fR=\fIHOSTNAME\fR -Set the hostname of the mpd server, overriding the configuration file. -.TP -\fBMPD_PORT\fR=\fIPORT\fR -Set the port number of the mpd server, overriding the configuration file. -.TP -\fBXDG_MUSIC_DIR\fR=\fIDIRECTORY\fR -Used to find the default music directory if not specified in the -configuration file or as a command-line option. -.SH SEE ALSO -.BR mpd (1) diff --git a/debian/mpdris2.install b/debian/mpdris2.install index 9e230c9..178f2cf 100644 --- a/debian/mpdris2.install +++ b/debian/mpdris2.install @@ -1,9 +1,5 @@ -etc/xdg/autostart -usr/bin -usr/lib/systemd/user -usr/share/applications -usr/share/dbus-1/services -usr/share/doc/mpdris2/AUTHORS -usr/share/doc/mpdris2/README -usr/share/doc/mpdris2/mpDris2.conf -usr/share/locale +data/user/mpDris2.service usr/lib/systemd/user/ +data/dbus-1/org.mpris.MediaPlayer2.mpd.service usr/share/dbus-1/services/ +data/mpdris2.desktop etc/xdg/autostart/ +data/mpdris2.desktop usr/share/applications/ +data/mpdris2.conf usr/share/doc/mpdris2/ diff --git a/debian/rules b/debian/rules index 547d9b9..912486c 100755 --- a/debian/rules +++ b/debian/rules @@ -1,31 +1,23 @@ #!/usr/bin/make -f - -# must be before including anything -debian_dir := $(abspath $(dir $(lastword $(MAKEFILE_LIST)))) - -include /usr/share/dpkg/default.mk +export PYBUILD_NAME=mpdris2 %: - dh $@ --with python3 - -override_dh_autoreconf: - NOCONFIGURE=1 dh_autoreconf ./autogen.sh -- - -override_dh_auto_configure: - dh_auto_configure \ - PYTHON=/usr/bin/python3 \ - $(NULL) + dh $@ --with python3 --buildsystem=pybuild +# Compile gettext catalogs into mpdris2/locale/ so setuptools picks +# them up via package_data. Babel ships .py extractors only, so we +# use plain msgfmt here. override_dh_auto_build: + @for po in po/*.po; do \ + lang=$$(basename $$po .po); \ + mkdir -p mpdris2/locale/$$lang/LC_MESSAGES; \ + msgfmt $$po -o mpdris2/locale/$$lang/LC_MESSAGES/mpdris2.mo; \ + done dh_auto_build - sed -e 's!@docdir@!/usr/share/doc/mpdris2!' \ - < debian/mpDris2.1.in \ - > debian/mpDris2.1 -override_dh_auto_install: - dh_auto_install --destdir=debian/tmp - sed -i -e '1s,.*,#!/usr/bin/python3,' debian/tmp/usr/bin/mpDris2 +# Tests run in the CI lint job (pytest + dbus_fast in a venv); the deb +# build env has neither, and shipping pytest into it for this is silly. +override_dh_auto_test: -override_dh_install: - rm -f debian/tmp/usr/share/doc/mpdris2/COPYING - dh_install +override_dh_installsystemduser: + dh_installsystemduser --no-enable diff --git a/po/LINGUAS b/po/LINGUAS deleted file mode 100644 index b25ae57..0000000 --- a/po/LINGUAS +++ /dev/null @@ -1,2 +0,0 @@ -fr -nl diff --git a/po/POTFILES.in b/po/POTFILES.in deleted file mode 100644 index 23c3dc5..0000000 --- a/po/POTFILES.in +++ /dev/null @@ -1 +0,0 @@ -src/mpDris2.in.py diff --git a/po/fr.po b/po/fr.po index 4dafe7e..cf91cf0 100644 --- a/po/fr.po +++ b/po/fr.po @@ -2,38 +2,48 @@ msgid "" msgstr "" "Project-Id-Version: \n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2015-08-08 11:34+0200\n" +"Report-Msgid-Bugs-To: https://github.com/b0bbywan/mpDris2/issues\n" +"POT-Creation-Date: 2026-05-16 19:41+0200\n" "PO-Revision-Date: 2012-02-05 12:20+0100\n" "Last-Translator: Jean-Philippe Braun \n" "Language-Team: Jean-Philippe Braun \n" "Language: fr\n" "MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=utf-8\n" +"Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -#: ../src/mpDris2.py:299 -msgid "Reconnected" -msgstr "Reconnecté" +#: mpdris2/bridge.py:283 mpdris2/notify.py:77 +msgid "Unknown title" +msgstr "Titre inconnu" -#: ../src/mpDris2.py:353 -msgid "Disconnected" -msgstr "Déconnecté" +#: mpdris2/bridge.py:289 mpdris2/bridge.py:290 mpdris2/notify.py:84 +msgid "Unknown artist" +msgstr "Artiste inconnu" + +#: mpdris2/bridge.py:290 +#, python-format +msgid "by %s" +msgstr "par %s" -#: ../src/mpDris2.py:456 ../src/mpDris2.py:1019 ../src/mpDris2.py:1027 +#: mpdris2/bridge.py:292 msgid "Paused" msgstr "En pause" -#: ../src/mpDris2.py:459 ../src/mpDris2.py:1030 ../src/mpDris2.py:1042 -msgid "Playing" -msgstr "En cours" - -#: ../src/mpDris2.py:466 ../src/mpDris2.py:1036 +#: mpdris2/bridge.py:563 msgid "Stopped" msgstr "Stoppé" -#. FIXME: maybe this could be done in a nicer way? -#: ../src/mpDris2.py:669 -#, python-format -msgid "by %s" -msgstr "par %s" +#: mpdris2/bridge.py:621 +msgid "Reconnected" +msgstr "Reconnecté" + +#: mpdris2/bridge.py:665 +msgid "Disconnected" +msgstr "Déconnecté" + +#: mpdris2/notify.py:76 +msgid "Unknown album" +msgstr "Album inconnu" + +#~ msgid "Playing" +#~ msgstr "En cours" diff --git a/po/mpdris2.pot b/po/mpdris2.pot new file mode 100644 index 0000000..7f2c1fe --- /dev/null +++ b/po/mpdris2.pot @@ -0,0 +1,52 @@ +# Translations template for mpDris2. +# Copyright (C) 2026 Mathieu Réquillart +# This file is distributed under the same license as the mpDris2 project. +# FIRST AUTHOR , 2026. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: mpDris2 0.10.0b1\n" +"Report-Msgid-Bugs-To: https://github.com/b0bbywan/mpDris2/issues\n" +"POT-Creation-Date: 2026-05-16 19:53+0200\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.18.0\n" + +#: mpdris2/bridge.py:283 mpdris2/notify.py:77 +msgid "Unknown title" +msgstr "" + +#: mpdris2/bridge.py:289 mpdris2/bridge.py:290 mpdris2/notify.py:84 +msgid "Unknown artist" +msgstr "" + +#: mpdris2/bridge.py:290 +#, python-format +msgid "by %s" +msgstr "" + +#: mpdris2/bridge.py:292 +msgid "Paused" +msgstr "" + +#: mpdris2/bridge.py:563 +msgid "Stopped" +msgstr "" + +#: mpdris2/bridge.py:621 +msgid "Reconnected" +msgstr "" + +#: mpdris2/bridge.py:665 +msgid "Disconnected" +msgstr "" + +#: mpdris2/notify.py:76 +msgid "Unknown album" +msgstr "" + diff --git a/po/nl.po b/po/nl.po index 11dfd27..5db0a39 100644 --- a/po/nl.po +++ b/po/nl.po @@ -2,39 +2,48 @@ msgid "" msgstr "" "Project-Id-Version: \n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2015-10-24 18:22+0200\n" +"Report-Msgid-Bugs-To: https://github.com/b0bbywan/mpDris2/issues\n" +"POT-Creation-Date: 2026-05-16 19:41+0200\n" "PO-Revision-Date: 2015-10-24 18:23+0100\n" "Last-Translator: Daan Sprenkels \n" "Language-Team: Jean-Philippe Braun \n" "Language: nl\n" "MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=utf-8\n" +"Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -#: ../src/mpDris2.in.py:304 -msgid "Reconnected" -msgstr "Verbinding hersteld" +#: mpdris2/bridge.py:283 mpdris2/notify.py:77 +msgid "Unknown title" +msgstr "Onbekende titel" -#: ../src/mpDris2.in.py:358 -msgid "Disconnected" -msgstr "Verbinding verbroken" +#: mpdris2/bridge.py:289 mpdris2/bridge.py:290 mpdris2/notify.py:84 +msgid "Unknown artist" +msgstr "Onbekende artiest" + +#: mpdris2/bridge.py:290 +#, python-format +msgid "by %s" +msgstr "van %s" -#: ../src/mpDris2.in.py:461 ../src/mpDris2.in.py:1024 -#: ../src/mpDris2.in.py:1032 +#: mpdris2/bridge.py:292 msgid "Paused" msgstr "Gepauzeerd" -#: ../src/mpDris2.in.py:464 ../src/mpDris2.in.py:1035 -#: ../src/mpDris2.in.py:1047 -msgid "Playing" -msgstr "Aan het afspelen" - -#: ../src/mpDris2.in.py:471 ../src/mpDris2.in.py:1041 +#: mpdris2/bridge.py:563 msgid "Stopped" msgstr "Gestopt" -#: ../src/mpDris2.in.py:674 -#, python-format -msgid "by %s" -msgstr "van %s" +#: mpdris2/bridge.py:621 +msgid "Reconnected" +msgstr "Verbinding hersteld" + +#: mpdris2/bridge.py:665 +msgid "Disconnected" +msgstr "Verbinding verbroken" + +#: mpdris2/notify.py:76 +msgid "Unknown album" +msgstr "Onbekend album" + +#~ msgid "Playing" +#~ msgstr "Aan het afspelen" diff --git a/pyproject.toml b/pyproject.toml index 3a7c63b..47be686 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,8 +67,8 @@ warn_redundant_casts = true warn_return_any = true no_implicit_optional = true disallow_untyped_defs = true -# Third-party libs (dbus_fast, mpd, mutagen, musicbrainzngs) don't ship -# type stubs; treat their imports as Any rather than failing outright. +# Third-party libs (dbus_fast, mpd) don't ship type stubs; treat their +# imports as Any rather than failing outright. ignore_missing_imports = true [[tool.mypy.overrides]] diff --git a/shell.nix b/shell.nix index c444dc7..63a776d 100644 --- a/shell.nix +++ b/shell.nix @@ -3,6 +3,16 @@ let in pkgs.mkShell { buildInputs = with pkgs; [ - (python3.withPackages (ps: with ps; [mpd2 dbus-python pygobject3 mutagen])) + gettext + (python311.withPackages (ps: with ps; [ + mpd2 + dbus-fast + babel + pytest + pytest-asyncio + mypy + ruff + build + ])) ]; } diff --git a/src/Makefile.am b/src/Makefile.am deleted file mode 100644 index e32cbc4..0000000 --- a/src/Makefile.am +++ /dev/null @@ -1,36 +0,0 @@ -desktopdir = ${datadir}/applications/ -dbusdir = ${datadir}/dbus-1/services/ -autostartdir = ${sysconfdir}/xdg/autostart/ -systemd_userdir = ${prefix}/lib/systemd/user/ - -bin_SCRIPTS = mpDris2 -dist_desktop_DATA = mpdris2.desktop -autostart_DATA = mpdris2.desktop -dist_doc_DATA = mpDris2.conf -nodist_dbus_DATA = org.mpris.MediaPlayer2.mpd.service -nodist_systemd_user_DATA = mpDris2.service - -EXTRA_DIST = \ - org.mpris.MediaPlayer2.mpd.service.in \ - mpDris2.service.in \ - mpDris2.in.py - -CLEANFILES = \ - org.mpris.MediaPlayer2.mpd.service \ - mpDris2.service \ - mpDris2 - -edit = sed -e 's|@bindir[@]|$(bindir)|g' \ - -e 's|@datadir[@]|$(datadir)|g' \ - -e 's|@gitversion[@]|$(GITVERSION)|g' \ - -e 's|@version[@]|$(VERSION)|g' - -mpDris2: mpDris2.in.py Makefile - $(AM_V_GEN) $(edit) $< > $@.tmp && mv $@.tmp $@ - $(AM_V_at) chmod a+x $@ - -org.mpris.MediaPlayer2.mpd.service: org.mpris.MediaPlayer2.mpd.service.in Makefile - $(AM_V_GEN) $(edit) $< > $@.tmp && mv $@.tmp $@ - -mpDris2.service: mpDris2.service.in Makefile - $(AM_V_GEN) $(edit) $< > $@.tmp && mv $@.tmp $@ diff --git a/src/mpDris2.conf b/src/mpDris2.conf deleted file mode 100644 index 1fc21a9..0000000 --- a/src/mpDris2.conf +++ /dev/null @@ -1,36 +0,0 @@ -# Copy this to /etc/mpDris2.conf or ~/.config/mpDris2/mpDris2.conf -# Default values are shown here, commented out. - -[Connection] -# You can also export $MPD_HOST and/or $MPD_PORT to change the server. -#host = localhost -#port = 6600 -#password = - -[Library] -#music_dir = -#cover_regex = ^(album|cover|\.?folder|front).*\.(gif|jpeg|jpg|png)$ - -[Bling] -#mmkeys = True -#notify = True -# Send notifications while paused? -#notify_paused = True -# CD-like previous command: if playback is past 3 seconds, seek to the beginning -#cdprev = True - -[Notify] -# Urgency of the notification: 0 for low, 1 for medium and 2 for high. -#urgency = 0 -# Timeout of the notification in milliseconds. -1 uses the notification's default -# and 0 sets the notification to never timeout. -#timeout = -1 -# Format the notification's summary and body in either playing or paused state. -# Leave blank to use mpDris2's internal defaults. -# Possible values: -# %album%, %title%, %id%, %time%, %timeposition%, %date%, %track%, -# %disc%, %artist%, %albumartist%, %composer%, %genre%, %file% -#summary = -#body = -#paused_summary = -#paused_body = diff --git a/src/mpDris2.in.py b/src/mpDris2.in.py deleted file mode 100755 index f6b67fa..0000000 --- a/src/mpDris2.in.py +++ /dev/null @@ -1,1733 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . -# -# Authors: Jean-Philippe Braun , -# Mantas Mikulėnas -# Based on mpDris from: Erik Karlsson -# Some bits taken from quodlibet mpris plugin by - -import base64 -from configparser import ConfigParser -import dbus -from dbus.mainloop.glib import DBusGMainLoop -import dbus.service -import getopt -import gettext -import gi -from gi.repository import GLib -import logging -import mpd -import os -import re -import shlex -import socket -import sys -import tempfile -import time -import urllib.parse - -try: - import mutagen -except ImportError: - mutagen = None - -try: - gi.require_version('Notify', '0.7') - from gi.repository import Notify -except (ImportError, ValueError): - Notify = None - -_ = gettext.gettext - -__version__ = "@version@" -__git_version__ = "@gitversion@" - -identity = "Music Player Daemon" - -params = { - 'progname': sys.argv[0], - # Connection - 'host': None, - 'port': None, - 'password': None, - 'bus_name': None, - 'reconnect': True, - # Library - 'music_dir': '', - 'cover_regex': None, - # Bling - 'mmkeys': True, - 'notify': (Notify is not None), - "notify_paused": False, - "cdprev": False, - # Notify - "summary": "", - "body": "", - "paused_summary": "", - "paused_body": "", - "urgency": 0, - "timeout": -1, -} - -defaults = { - # Connection - 'host': 'localhost', - 'port': 6600, - 'password': None, - 'bus_name': None, - # Library - 'cover_regex': r'^(album|cover|\.?folder|front).*\.(gif|jpeg|jpg|png)$', -} - -notification = None - -# MPRIS allowed metadata tags -allowed_tags = { - 'mpris:trackid': dbus.ObjectPath, - 'mpris:length': dbus.Int64, - 'mpris:artUrl': str, - 'xesam:album': str, - 'xesam:albumArtist': list, - 'xesam:artist': list, - 'xesam:asText': str, - 'xesam:audioBPM': int, - 'xesam:comment': list, - 'xesam:composer': list, - 'xesam:contentCreated': str, - 'xesam:discNumber': int, - 'xesam:firstUsed': str, - 'xesam:genre': list, - 'xesam:lastUsed': str, - 'xesam:lyricist': str, - 'xesam:title': str, - 'xesam:trackNumber': int, - 'xesam:url': str, - 'xesam:useCount': int, - 'xesam:userRating': float, -} - -# python dbus bindings don't include annotations and properties -MPRIS2_INTROSPECTION = """ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -""" - -# Default url handlers if MPD doesn't support 'urlhandlers' command -urlhandlers = ['http://'] -downloaded_covers = ['~/.covers/%s-%s.jpg'] - - -class MPDWrapper(object): - """ Wrapper of mpd.MPDClient to handle socket - errors and similar - """ - - def __init__(self, params): - self.client = mpd.MPDClient() - - self._dbus = dbus - self._params = params - self._dbus_service = None - self._should_reconnect = params['reconnect'] - - self._can_single = False - self._can_idle = False - self._can_albumart = False - self._can_readpicture = False - - self._errors = 0 - self._poll_id = None - self._watch_id = None - self._idling = False - - self._status = { - 'state': None, - 'volume': None, - 'random': None, - 'repeat': None, - } - self._metadata = {} - self._temp_song_url = None - self._temp_cover = None - self._position = 0 - self._time = 0 - - self._bus = dbus.SessionBus() - if self._params['mmkeys']: - self.setup_mediakeys() - - def run(self): - """ - Try to connect to MPD; retry every 5 seconds on failure. - """ - if self.my_connect(): - GLib.timeout_add_seconds(5, self.my_connect) - return False - else: - return True - - @property - def connected(self): - return self.client._sock is not None - - def my_connect(self): - """ Init MPD connection """ - try: - self._idling = False - self._can_idle = False - self._can_single = False - - self.client.connect(self._params['host'], self._params['port']) - if self._params['password']: - try: - self.client.password(self._params['password']) - except mpd.CommandError as e: - logger.error(e) - sys.exit(1) - - commands = self.commands() - # added in 0.11 - if 'urlhandlers' in commands: - global urlhandlers - urlhandlers = self.urlhandlers() - # added in 0.14 - if 'idle' in commands: - self._can_idle = True - # added in 0.15 - if 'single' in commands: - self._can_single = True - # added in 0.21 - if 'albumart' in commands: - self._can_albumart = True - # added in 0.22 - if 'readpicture' in commands: - self._can_readpicture = True - - if self._errors > 0: - notification.notify(identity, _('Reconnected')) - logger.info('Reconnected to MPD server.') - else: - logger.debug('Connected to MPD server.') - - # Make the socket non blocking to detect deconnections - self.client._sock.settimeout(5.0) - # Export our DBUS service - if not self._dbus_service: - self._dbus_service = MPRISInterface(self._params) - else: - # Add our service to the session bus - #self._dbus_service.add_to_connection(dbus.SessionBus(), - # '/org/mpris/MediaPlayer2') - self._dbus_service.acquire_name() - - # Init internal state to throw events at start - self.init_state() - - # If idle is not available, add periodic status check for sending MPRIS events - # Otherwise the timer will connect the socket if disconnected - # If reconnection is not necessary and idle is supported, this timer isn't enabled. - if not self._poll_id and (not self._can_idle or self._should_reconnect): - interval = 15 if self._can_idle else 1 - self._poll_id = GLib.timeout_add_seconds(interval, - self.timer_callback) - if self._can_idle and not self._watch_id: - self._watch_id = GLib.io_add_watch(self, - GLib.PRIORITY_DEFAULT, - GLib.IO_IN | GLib.IO_HUP, - self.socket_callback) - # Reset error counter - self._errors = 0 - - self.timer_callback() - self.idle_enter() - # Return False to stop trying to connect - return False - except socket.error as e: - self._errors += 1 - if self._errors < 6: - logger.error('Could not connect to MPD: %s' % e) - if self._errors == 6: - logger.info('Continue to connect but going silent') - return True - - def reconnect(self): - logger.warning("Disconnected") - notification.notify(identity, _('Disconnected'), 'error') - - # Release the DBus name and disconnect from bus - if self._dbus_service is not None: - self._dbus_service.release_name() - #self._dbus_service.remove_from_connection() - - # Stop monitoring - if self._poll_id: - GLib.source_remove(self._poll_id) - self._poll_id = None - if self._watch_id: - GLib.source_remove(self._watch_id) - self._watch_id = None - - # Clean mpd client state - try: - self.disconnect() - except: - self.disconnect() - - # Try to reconnect - self.run() - - def disconnect(self): - self._temp_song_url = None - if self._temp_cover: - self._temp_cover.close() - self._temp_cover = None - - self.client.disconnect() - - def init_state(self): - # Get current state - self._status = self.status() - # Invalid some fields to throw events at start - self._status['state'] = 'invalid' - self._status['songid'] = '-1' - self._position = 0 - - def idle_enter(self): - if not self._can_idle: - return False - if not self._idling: - # NOTE: do not use MPDClient.idle(), which waits for an event - self._write_command("idle", []) - self._idling = True - logger.debug("Entered idle") - return True - else: - logger.warning("Nested idle_enter()!") - return False - - def idle_leave(self): - if not self._can_idle: - return False - if self._idling: - # NOTE: don't use noidle() or _execute() to avoid infinite recursion - self._write_command("noidle", []) - self._fetch_object() - self._idling = False - logger.debug("Left idle") - return True - else: - return False - - ## Events - - def timer_callback(self): - try: - was_idle = self.idle_leave() - except (socket.error, mpd.MPDError, socket.timeout): - self.reconnect() - return False - self._update_properties(force=False) - if was_idle: - self.idle_enter() - return True - - def socket_callback(self, fd, event): - logger.debug("Socket event %r on fd %r" % (event, fd)) - - def handle_disconnect(): - if self._should_reconnect: - self.reconnect() - else: - logger.debug("Not reconnecting, quitting main loop") - loop.quit() - return True - - if event & GLib.IO_HUP: - return handle_disconnect() - - elif event & GLib.IO_IN: - if self._idling: - self._idling = False - - try: - data = fd._fetch_objects("changed") - except mpd.base.ConnectionError: - return handle_disconnect() - - logger.debug("Idle events: %r" % data) - updated = False - for item in data: - subsystem = item["changed"] - # subsystem list: - if subsystem in ("player", "mixer", "options", "playlist"): - if not updated: - self._update_properties(force=True) - updated = True - self.idle_enter() - return True - - def mediakey_callback(self, appname, key): - """ GNOME media key handler """ - logger.debug('Got GNOME mmkey "%s" for "%s"' % (key, appname)) - if key == 'Play': - if self._status['state'] == 'play': - self.pause(1) - self.notify_about_state('pause') - else: - self.play() - self.notify_about_state('play') - elif key == 'Next': - self.next() - elif key == 'Previous': - self.previous() - elif key == 'Stop': - self.stop() - self.notify_about_state('stop') - - def last_currentsong(self): - if self._currentsong: - return self._currentsong.copy() - return None - - @property - def metadata(self): - return self._metadata - - def update_metadata(self): - """ - Translate metadata returned by MPD to the MPRIS v2 syntax. - http://www.freedesktop.org/wiki/Specifications/mpris-spec/metadata - """ - - self._metadata = {} - - mpd_meta = self.last_currentsong() - if not mpd_meta: - logger.warning("Attempted to update metadata, but retrieved none") - return - - for tag in ('album', 'title'): - if tag in mpd_meta: - self._metadata['xesam:%s' % tag] = mpd_meta[tag] - - if 'id' in mpd_meta: - self._metadata['mpris:trackid'] = "/org/mpris/MediaPlayer2/Track/%s" % \ - mpd_meta['id'] - - if 'time' in mpd_meta: - self._metadata['mpris:length'] = int(mpd_meta['time']) * 1000000 - - if 'date' in mpd_meta: - self._metadata['xesam:contentCreated'] = mpd_meta['date'][0:4] - - if 'track' in mpd_meta: - # TODO: Is it even *possible* for mpd_meta['track'] to be a list? - if type(mpd_meta['track']) == list and len(mpd_meta['track']) > 0: - track = str(mpd_meta['track'][0]) - else: - track = str(mpd_meta['track']) - - m = re.match('^([0-9]+)', track) - if m: - self._metadata['xesam:trackNumber'] = int(m.group(1)) - # Ensure the integer is signed 32bit - if self._metadata['xesam:trackNumber'] & 0x80000000: - self._metadata['xesam:trackNumber'] += -0x100000000 - else: - self._metadata['xesam:trackNumber'] = 0 - - if 'disc' in mpd_meta: - # TODO: Same as above. When is it a list? - if type(mpd_meta['disc']) == list and len(mpd_meta['disc']) > 0: - disc = str(mpd_meta['disc'][0]) - else: - disc = str(mpd_meta['disc']) - - m = re.match('^([0-9]+)', disc) - if m: - self._metadata['xesam:discNumber'] = int(m.group(1)) - - if 'artist' in mpd_meta: - if type(mpd_meta['artist']) == list: - self._metadata['xesam:artist'] = mpd_meta['artist'] - else: - self._metadata['xesam:artist'] = [mpd_meta['artist']] - - if 'albumartist' in mpd_meta: - if type(mpd_meta['albumartist']) == list: - self._metadata['xesam:albumArtist'] = mpd_meta['albumartist'] - else: - self._metadata['xesam:albumArtist'] = [mpd_meta['albumartist']] - - if 'composer' in mpd_meta: - if type(mpd_meta['composer']) == list: - self._metadata['xesam:composer'] = mpd_meta['composer'] - else: - self._metadata['xesam:composer'] = [mpd_meta['composer']] - - if 'genre' in mpd_meta: - if type(mpd_meta['genre']) == list: - self._metadata['xesam:genre'] = mpd_meta['genre'] - else: - self._metadata['xesam:genre'] = [mpd_meta['genre']] - - # Stream: populate some missings tags with stream's name - if 'name' in mpd_meta: - if 'xesam:title' not in self._metadata: - self._metadata['xesam:title'] = mpd_meta['name'] - elif 'xesam:album' not in self._metadata: - self._metadata['xesam:album'] = mpd_meta['name'] - - if 'file' in mpd_meta: - song_url = mpd_meta['file'] - if not any([song_url.startswith(prefix) for prefix in urlhandlers]): - song_url = os.path.join(self._params['music_dir'], song_url) - self._metadata['xesam:url'] = song_url - cover = self.find_cover(song_url, mpd_meta['file'], mpd_meta) - if cover: - self._metadata['mpris:artUrl'] = cover - - # Cast self._metadata to the correct type, or discard it - for key, value in self._metadata.items(): - try: - self._metadata[key] = allowed_tags[key](value) - except ValueError: - del self._metadata[key] - logger.error("Can't cast value %r to %s" % - (value, allowed_tags[key])) - - def convert_timestamp(self, secs, micros=0): - seconds, minutes, hours = 0, 0, 0 - - if micros > 0: - secs += micros / 1000000 - if secs > 0: - seconds = int(secs % 60) - minutes = int((secs / 60) % 60) - hours = int(secs / 3600) - - if hours == 0: - duration = "{}:{:0>2}".format(minutes, seconds) - else: - duration = "{}:{:0>2}:{:0>2}".format(hours, minutes, seconds) - - return duration - - def format_notification(self, meta, text): - """format '%property%' in a string for it's actual value""" - - format_strings = { - "album": meta.get("xesam:album", "Unknown Album"), - "title": meta.get("xesam:title", "Unknown Title"), - "id": meta.get("mpris:trackid", "").split("/")[-1], - "time": self.convert_timestamp(0, meta.get("mpris:length", 0)), - "timeposition": self.convert_timestamp(self._position, 0), - "date": meta.get("xesam:contentCreated", ""), - "track": meta.get("xesam:trackNumber", ""), - "disc": meta.get("xesam:discNumber", ""), - "artist": ", ".join(meta.get("xesam:artist", ['Unknown Artist'])), - "albumartist": ", ".join(meta.get("xesam:albumArtist", [])), - "composer": meta.get("xesam:composer", ""), - "genre": ", ".join(meta.get("xesam:genre", [])), - "file": meta.get("xesam:url", "").split("/")[-1], - } - return re.sub(r'%([a-z]+)%', r'{\1}', text).format_map(format_strings) - - def notify_about_track(self, meta, state="play"): - uri = meta.get("mpris:artUrl", "sound") - - if self._params["summary"]: - title = self.format_notification(meta, self._params["summary"]) - elif "xesam:title" in meta: - title = meta["xesam:title"] - elif "xesam:url" in meta: - title = meta["xesam:url"].split("/")[-1] - else: - title = "Unknown Title" - - if self._params["body"]: - body = self.format_notification(meta, self._params["body"]) - else: - artist = ", ".join(meta.get("xesam:artist", ["Unknown Artist"])) - body = _("by %s") % artist - - if state == "pause": - if not self._params["notify_paused"]: - return - uri = "media-playback-pause-symbolic" - - if self._params["paused_summary"]: - title = self.format_notification(meta, self._params["paused_summary"]) - - if self._params["paused_body"]: - body = self.format_notification(meta, self._params["paused_body"]) - else: - body += " (Paused)" - notification.notify(title, body, uri) - - def notify_about_state(self, state): - if state == 'stop': - notification.notify(identity, _('Stopped'), 'media-playback-stop-symbolic') - else: - self.notify_about_track(self.metadata, state) - - def find_cover(self, song_url, song_file=None, mpd_meta=None): - if song_url.startswith('file://'): - song_path = song_url[7:] - elif song_url.startswith('local:track:') and self._params['music_dir'].startswith('file://'): - song_path = os.path.join(self._params['music_dir'][7:], urllib.parse.unquote(song_url[12:])) - else: - song_path = None - - song_dir = os.path.dirname(song_path) if song_path else None - - # Try existing temporary file - if self._temp_cover: - if song_url == self._temp_song_url: - logger.debug("find_cover: Reusing old image at %r" % self._temp_cover.name) - return 'file://' + self._temp_cover.name - else: - logger.debug("find_cover: Cleaning up old image at %r" % self._temp_cover.name) - self._temp_song_url = None - self._temp_cover.close() - - # Fetch cover art from MPD (works with remote servers) - if song_file: - cover = self._fetch_cover_from_mpd(song_url, song_file, mpd_meta) - if cover: - return cover - - if song_path is None: - return None - - # Search for embedded cover art - song = None - if mutagen and os.path.exists(song_path): - try: - song = mutagen.File(song_path) - except mutagen.MutagenError as e: - logger.error("Can't extract covers from %r: %r" % (song_path, e)) - if song is not None: - if hasattr(song, "pictures"): - # FLAC - for pic in song.pictures: - if pic.type == mutagen.id3.PictureType.COVER_FRONT: - self._temp_song_url = song_url - return self._create_temp_cover(pic) - if song.tags: - # present but null for some file types - for tag in song.tags.keys(): - if tag.startswith("APIC:"): - for pic in song.tags.getall(tag): - if pic.type == mutagen.id3.PictureType.COVER_FRONT: - self._temp_song_url = song_url - return self._create_temp_cover(pic) - elif tag == "metadata_block_picture": - # OGG - for b64_data in song.get(tag, []): - try: - data = base64.b64decode(b64_data) - except (TypeError, ValueError): - continue - - try: - pic = mutagen.flac.Picture(data) - except mutagen.flac.error: - continue - - if pic.type == mutagen.id3.PictureType.COVER_FRONT: - self._temp_song_url = song_url - return self._create_temp_cover(pic) - elif tag == "covr": - # MP4 - for data in song.get(tag, []): - mimes = {mutagen.mp4.AtomDataType.JPEG: "image/jpeg", - mutagen.mp4.AtomDataType.PNG: "image/png"} - - pic = mutagen.id3.APIC(mime=mimes.get(data.imageformat, ""), data=data) - - self._temp_song_url = song_url - return self._create_temp_cover(pic) - - # Look in song directory for common album cover files - if os.path.exists(song_dir) and os.path.isdir(song_dir): - for f in os.listdir(song_dir): - if self._params['cover_regex'].match(f): - return 'file://' + os.path.join(song_dir, f) - - # Search the shared cover directories - if 'xesam:artist' in self._metadata and 'xesam:album' in self._metadata: - artist = ",".join(self._metadata['xesam:artist']) - album = self._metadata['xesam:album'] - for template in downloaded_covers: - f = os.path.expanduser(template % (artist, album)) - if os.path.exists(f): - return 'file://' + f - - return None - - def _fetch_cover_from_mpd(self, song_url, song_file, mpd_meta=None): - """Fetch cover art from MPD using readpicture or albumart commands. - - Note: This is called from update_metadata during event processing, - when idle mode is already left. We must use self.client directly - instead of self.call() to avoid idle_leave/idle_enter conflicts. - """ - # Skip URIs with schemes (cdda://, http://, etc.) as they cause - # timeouts that corrupt the MPD connection - if not re.match(r'^[a-zA-Z]+://', song_file): - data = self._try_mpd_art(song_file) - else: - data = None - - # Fallback: search MPD database for an alternative path (e.g. CUE - # virtual tracks when currentsong returns a cdda:// URI) - if data is None and mpd_meta: - alt_file = self._find_alt_path(mpd_meta) - if alt_file and alt_file != song_file: - data = self._try_mpd_art(alt_file) - - # Last resort: look for a cover image file in the parent directory - if data is None and alt_file: - data = self._try_mpd_dir_art(alt_file) - - if data is None: - return None - - if data[:8] == b'\x89PNG\r\n\x1a\n': - mime = 'image/png' - elif data[:2] == b'\xff\xd8': - mime = 'image/jpeg' - elif data[:4] == b'GIF8': - mime = 'image/gif' - else: - mime = 'image/jpeg' - - pic = type('Picture', (), {'mime': mime, 'data': data})() - self._temp_song_url = song_url - return self._create_temp_cover(pic) - - def _try_mpd_art(self, path): - """Try readpicture then albumart on a given path, return binary data or None.""" - if self._can_readpicture: - try: - result = self.client.readpicture(path) - if result and 'binary' in result: - return result['binary'] - except Exception as e: - logger.debug("readpicture %r failed: %r" % (path, e)) - - if self._can_albumart: - try: - result = self.client.albumart(path) - if result and 'binary' in result: - return result['binary'] - except Exception as e: - logger.debug("albumart %r failed: %r" % (path, e)) - - return None - - def _find_alt_path(self, mpd_meta): - """Find an alternative file path using lastloadedplaylist from status, - or by searching the MPD database as a fallback.""" - # Use lastloadedplaylist to derive the CUE virtual track path - playlist = self._status.get('lastloadedplaylist', '') - if playlist and 'track' in mpd_meta: - # Strip music_dir prefix to get the relative path - music_dir = self._params['music_dir'] - if music_dir.startswith('file://'): - music_dir = music_dir[7:] - if playlist.startswith(music_dir): - playlist = playlist[len(music_dir):] - playlist = playlist.lstrip('/') - track_num = re.match(r'^(\d+)', str(mpd_meta['track'])) - if track_num: - alt = "%s/track%04d" % (playlist, int(track_num.group(1))) - logger.debug("Trying alt path from lastloadedplaylist: %r" % alt) - return alt - - # Fallback: search MPD database - try: - args = [] - if 'album' in mpd_meta: - args += ['album', mpd_meta['album']] - if 'title' in mpd_meta: - args += ['title', mpd_meta['title']] - if not args: - return None - - results = self.client.find(*args) - if results: - return results[0].get('file') - except Exception as e: - logger.debug("MPD find for alt path failed: %r" % e) - - return None - - def _try_mpd_dir_art(self, track_path): - """Try albumart on common cover filenames in parent directories.""" - cover_names = ['cover.jpg', 'cover.png', 'cover.webp'] - - # Try parent directories (handles CUE virtual paths like - # .disc-cuer/HASH/playlist.cue/track0005) - parent = track_path - for _ in range(3): - parent = os.path.dirname(parent) - if not parent: - break - for name in cover_names: - cover_path = os.path.join(parent, name) - data = self._try_mpd_art(cover_path) - if data: - return data - - return None - - def _create_temp_cover(self, pic): - """ - Create a temporary file containing pic, and return it's location - """ - extension = {'image/jpeg': '.jpg', - 'image/png': '.png', - 'image/gif': '.gif'} - - self._temp_cover = tempfile.NamedTemporaryFile(prefix='cover-', suffix=extension.get(pic.mime, '.jpg')) - self._temp_cover.write(pic.data) - self._temp_cover.flush() - logger.debug("find_cover: Storing embedded image at %r" % self._temp_cover.name) - return 'file://' + self._temp_cover.name - - def last_status(self): - if time.time() - self._time >= 2: - self.timer_callback() - return self._status.copy() - - def _update_properties(self, force=False): - old_status = self._status - old_position = self._position - old_time = self._time - self._currentsong = self.currentsong() - new_status = self.status() - self._time = new_time = int(time.time()) - - if not new_status: - logger.debug("_update_properties: failed to get new status") - return - - self._status = new_status - logger.debug("_update_properties: current song = %r" % self._currentsong) - logger.debug("_update_properties: current status = %r" % self._status) - - if 'elapsed' in new_status: - new_position = float(new_status['elapsed']) - elif 'time' in new_status: - new_position = int(new_status['time'].split(':')[0]) - else: - new_position = 0 - - self._position = new_position - - # "player" subsystem - - if old_status['state'] != new_status['state']: - self._dbus_service.update_property('org.mpris.MediaPlayer2.Player', - 'PlaybackStatus') - - if not force: - old_id = old_status.get('songid', None) - new_id = new_status.get('songid', None) - force = (old_id != new_id) - - if not force: - if new_status['state'] == 'play': - expected_position = old_position + (new_time - old_time) - else: - expected_position = old_position - if abs(new_position - expected_position) > 0.6: - logger.debug("Expected pos %r, actual %r, diff %r" % ( - expected_position, new_position, new_position - expected_position)) - logger.debug("Old position was %r at %r (%r seconds ago)" % ( - old_position, old_time, new_time - old_time)) - self._dbus_service.Seeked(new_position * 1000000) - - else: - # Update current song metadata - old_meta = self._metadata.copy() - self.update_metadata() - new_meta = self._dbus_service.update_property('org.mpris.MediaPlayer2.Player', - 'Metadata') - - if self._params['notify'] and new_status['state'] != 'stop': - if old_meta.get('xesam:artist', None) != new_meta.get('xesam:artist', None) \ - or old_meta.get('xesam:album', None) != new_meta.get('xesam:album', None) \ - or old_meta.get('xesam:title', None) != new_meta.get('xesam:title', None) \ - or old_meta.get('xesam:url', None) != new_meta.get('xesam:url', None): - self.notify_about_track(new_meta, new_status['state']) - - # "mixer" subsystem - if old_status.get('volume') != new_status.get('volume'): - self._dbus_service.update_property('org.mpris.MediaPlayer2.Player', - 'Volume') - - # "options" subsystem - # also triggered if consume, crossfade or ReplayGain are updated - - if old_status['random'] != new_status['random']: - self._dbus_service.update_property('org.mpris.MediaPlayer2.Player', - 'Shuffle') - - if (old_status['repeat'] != new_status['repeat'] - or old_status.get('single', 0) != new_status.get('single', 0)): - self._dbus_service.update_property('org.mpris.MediaPlayer2.Player', - 'LoopStatus') - - if ("nextsongid" in old_status) != ("nextsongid" in new_status): - self._dbus_service.update_property('org.mpris.MediaPlayer2.Player', - 'CanGoNext') - - ## Media keys - - def setup_mediakeys(self): - self.register_mediakeys() - self._dbus_obj = self._bus.get_object("org.freedesktop.DBus", - "/org/freedesktop/DBus") - self._dbus_obj.connect_to_signal("NameOwnerChanged", - self.gsd_name_owner_changed_callback, - arg0="org.gnome.SettingsDaemon") - - def register_mediakeys(self): - try: - try: - gsd_object = self._bus.get_object("org.gnome.SettingsDaemon.MediaKeys", - "/org/gnome/SettingsDaemon/MediaKeys") - except: - # Try older name. - gsd_object = self._bus.get_object("org.gnome.SettingsDaemon", - "/org/gnome/SettingsDaemon/MediaKeys") - gsd_object.GrabMediaPlayerKeys("mpDris2", 0, - dbus_interface="org.gnome.SettingsDaemon.MediaKeys") - except: - logger.warning("Failed to connect to GNOME Settings Daemon. Media keys won't work.") - else: - self._bus.remove_signal_receiver(self.mediakey_callback) - gsd_object.connect_to_signal("MediaPlayerKeyPressed", self.mediakey_callback) - - def gsd_name_owner_changed_callback(self, bus_name, old_owner, new_owner): - if bus_name == "org.gnome.SettingsDaemon" and new_owner != "": - def reregister(): - logger.debug("Re-registering with GNOME Settings Daemon (owner %s)" % new_owner) - self.register_mediakeys() - return False - # Timeout is necessary since g-s-d takes some time to load all plugins. - GLib.timeout_add(600, reregister) - - ## Compatibility functions - - # Fedora 17 still has python-mpd 0.2, which lacks fileno(). - if hasattr(mpd.MPDClient, "fileno"): - def fileno(self): - return self.client.fileno() - else: - def fileno(self): - if not self.connected: - raise mpd.ConnectionError("Not connected") - return self.client._sock.fileno() - - ## Access to python-mpd internal APIs - - # We use _write_command("idle") to manually enter idle mode, as it has no - # immediate response to fetch. - # - # Similarly, we use _write_command("noidle") + _fetch_object() to manually - # leave idle mode (for reasons I don't quite remember). The result of - # _fetch_object() is not used. - - if hasattr(mpd.MPDClient, "_write_command"): - def _write_command(self, *args): - return self.client._write_command(*args) - else: - raise Exception("Could not find the _write_command method in MPDClient") - - if hasattr(mpd.MPDClient, "_parse_objects_direct"): - def _fetch_object(self): - objs = self._fetch_objects() - if not objs: - return {} - return objs[0] - elif hasattr(mpd.MPDClient, "_fetch_object"): - def _fetch_object(self): - return self.client._fetch_object() - else: - raise Exception("Could not find the _fetch_object method in MPDClient") - - # We use _fetch_objects("changed") to receive unprompted idle events on - # socket activity. - - if hasattr(mpd.MPDClient, "_parse_objects_direct"): - def _fetch_objects(self, *args): - return list(self.client._parse_objects_direct(self.client._read_lines(), - *args)) - elif hasattr(mpd.MPDClient, "_fetch_objects"): - def _fetch_objects(self, *args): - return self.client._fetch_objects(*args) - else: - raise Exception("Could not find the _fetch_objects method in MPDClient") - - # Wrapper to catch connection errors when calling mpd client methods. - - def __getattr__(self, attr): - if attr[0] == "_": - raise AttributeError(attr) - return lambda *a, **kw: self.call(attr, *a, **kw) - - def previous(self): - if self._params['cdprev'] and self._position >= 3: - self.seekid(int(self._status['songid']), 0) - else: - self.call("previous") - - def call(self, command, *args): - fn = getattr(self.client, command) - try: - was_idle = self.idle_leave() - logger.debug("Sending command %r (was idle? %r)" % (command, was_idle)) - r = fn(*args) - if was_idle: - self.idle_enter() - return r - except (socket.error, mpd.MPDError, socket.timeout) as ex: - logger.debug("Trying to reconnect, got %r" % ex) - self.reconnect() - return False - - -class NotifyWrapper(object): - - def __init__(self, params): - self._notification = None - self._enabled = True - - if params["notify"]: - self._notification = self._bootstrap_notifications() - if not self._notification: - logger.error("No notification service provider could be found; disabling notifications") - else: - self._enabled = False - - def _bootstrap_notifications(self): - # Check if someone is providing the notification service - bus = dbus.SessionBus() - try: - bus.get_name_owner("org.freedesktop.Notifications") - except dbus.exceptions.DBusException: - return None - - notif = None - - # Bootstrap whatever notifications system we are using - if Notify is not None: - logger.debug("Initializing GObject.Notify") - if Notify.init(identity): - notif = Notify.Notification() - notif.set_hint("desktop-entry", GLib.Variant("s", "mpdris2")) - notif.set_hint("transient", GLib.Variant("b", True)) - notif.connect("closed", self._notification_closed) - else: - logger.error("Failed to init libnotify; disabling notifications") - - return notif - - def _notification_closed(self, data): - # Notification server might consider the old ID invalid - self._notification = self._bootstrap_notifications() - - def notify(self, title, body, uri=''): - if not self._enabled: - return - - # If we did not yet manage to get a notification service, - # try again - if not self._notification: - logger.info('Retrying to acquire a notification service provider...') - self._notification = self._bootstrap_notifications() - if self._notification: - logger.info('Notification service provider acquired!') - - if self._notification: - try: - self._notification.set_timeout(params['timeout']) - self._notification.set_urgency(params['urgency']) - self._notification.update(title, body, uri) - self._notification.show() - except GLib.GError as err: - logger.error("Failed to show notification: %s" % err) - - -class MPRISInterface(dbus.service.Object): - ''' The base object of an MPRIS player ''' - - __path = "/org/mpris/MediaPlayer2" - __introspect_interface = "org.freedesktop.DBus.Introspectable" - __prop_interface = dbus.PROPERTIES_IFACE - - def __init__(self, params): - dbus.service.Object.__init__(self, dbus.SessionBus(), - MPRISInterface.__path) - self._params = params or {} - self._name = self._params["bus_name"] or "org.mpris.MediaPlayer2.mpd" - if not self._name.startswith("org.mpris.MediaPlayer2."): - logger.warn("Configured bus name %r is outside MPRIS2 namespace" % self._name) - - self._bus = dbus.SessionBus() - self._uname = self._bus.get_unique_name() - self._dbus_obj = self._bus.get_object("org.freedesktop.DBus", - "/org/freedesktop/DBus") - self._dbus_obj.connect_to_signal("NameOwnerChanged", - self._name_owner_changed_callback, - arg0=self._name) - - self.acquire_name() - - def _name_owner_changed_callback(self, name, old_owner, new_owner): - if name == self._name and old_owner == self._uname and new_owner != "": - try: - pid = self._dbus_obj.GetConnectionUnixProcessID(new_owner) - except: - pid = None - logger.info("Replaced by %s (PID %s)" % (new_owner, pid or "unknown")) - loop.quit() - - def acquire_name(self): - self._bus_name = dbus.service.BusName(self._name, - bus=self._bus, - allow_replacement=True, - replace_existing=True) - - def release_name(self): - if hasattr(self, "_bus_name"): - del self._bus_name - - __root_interface = "org.mpris.MediaPlayer2" - __root_props = { - "CanQuit": (False, None), - "CanRaise": (False, None), - "DesktopEntry": ("mpdris2", None), - "HasTrackList": (False, None), - "Identity": (identity, None), - "SupportedUriSchemes": (dbus.Array(signature="s"), None), - "SupportedMimeTypes": (dbus.Array(signature="s"), None) - } - - def __get_playback_status(): - status = mpd_wrapper.last_status() - return {'play': 'Playing', 'pause': 'Paused', 'stop': 'Stopped'}[status['state']] - - def __set_loop_status(value): - if value == "Playlist": - mpd_wrapper.repeat(1) - if mpd_wrapper._can_single: - mpd_wrapper.single(0) - elif value == "Track": - if mpd_wrapper._can_single: - mpd_wrapper.repeat(1) - mpd_wrapper.single(1) - elif value == "None": - mpd_wrapper.repeat(0) - if mpd_wrapper._can_single: - mpd_wrapper.single(0) - else: - raise dbus.exceptions.DBusException("Loop mode %r not supported" % - value) - return - - def __get_loop_status(): - status = mpd_wrapper.last_status() - if int(status['repeat']) == 1: - if int(status.get('single', 0)) == 1: - return "Track" - else: - return "Playlist" - else: - return "None" - - def __set_shuffle(value): - mpd_wrapper.random(value) - return - - def __get_shuffle(): - if int(mpd_wrapper.last_status()['random']) == 1: - return True - else: - return False - - def __get_metadata(): - return dbus.Dictionary(mpd_wrapper.metadata, signature='sv') - - def __get_volume(): - vol = float(mpd_wrapper.last_status().get('volume', 0)) - if vol > 0: - return vol / 100.0 - else: - return 0.0 - - def __set_volume(value): - if value >= 0 and value <= 1: - mpd_wrapper.setvol(round(value * 100)) - return - - def __get_position(): - status = mpd_wrapper.last_status() - if 'time' in status: - current, end = status['time'].split(':') - return dbus.Int64((int(current) * 1000000)) - else: - return dbus.Int64(0) - - __player_interface = "org.mpris.MediaPlayer2.Player" - __player_props = { - "PlaybackStatus": (__get_playback_status, None), - "LoopStatus": (__get_loop_status, __set_loop_status), - "Rate": (1.0, None), - "Shuffle": (__get_shuffle, __set_shuffle), - "Metadata": (__get_metadata, None), - "Volume": (__get_volume, __set_volume), - "Position": (__get_position, None), - "MinimumRate": (1.0, None), - "MaximumRate": (1.0, None), - "CanGoNext": (True, None), - "CanGoPrevious": (True, None), - "CanPlay": (True, None), - "CanPause": (True, None), - "CanSeek": (True, None), - "CanControl": (True, None), - } - - __tracklist_interface = "org.mpris.MediaPlayer2.TrackList" - - __prop_mapping = { - __player_interface: __player_props, - __root_interface: __root_props, - } - - @dbus.service.method(__introspect_interface) - def Introspect(self): - return MPRIS2_INTROSPECTION - - @dbus.service.signal(__prop_interface, signature="sa{sv}as") - def PropertiesChanged(self, interface, changed_properties, - invalidated_properties): - pass - - @dbus.service.method(__prop_interface, - in_signature="ss", out_signature="v") - def Get(self, interface, prop): - getter, setter = self.__prop_mapping[interface][prop] - if callable(getter): - return getter() - return getter - - @dbus.service.method(__prop_interface, - in_signature="ssv", out_signature="") - def Set(self, interface, prop, value): - getter, setter = self.__prop_mapping[interface][prop] - if setter is not None: - setter(value) - - @dbus.service.method(__prop_interface, - in_signature="s", out_signature="a{sv}") - def GetAll(self, interface): - read_props = {} - props = self.__prop_mapping[interface] - for key, (getter, setter) in props.items(): - if callable(getter): - getter = getter() - read_props[key] = getter - return read_props - - def update_property(self, interface, prop): - getter, setter = self.__prop_mapping[interface][prop] - if callable(getter): - value = getter() - else: - value = getter - logger.debug('Updated property: %s = %s' % (prop, value)) - self.PropertiesChanged(interface, {prop: value}, []) - return value - - # Root methods - @dbus.service.method(__root_interface, in_signature='', out_signature='') - def Raise(self): - return - - @dbus.service.method(__root_interface, in_signature='', out_signature='') - def Quit(self): - return - - # Player methods - @dbus.service.method(__player_interface, in_signature='', out_signature='') - def Next(self): - mpd_wrapper.next() - return - - @dbus.service.method(__player_interface, in_signature='', out_signature='') - def Previous(self): - mpd_wrapper.previous() - return - - @dbus.service.method(__player_interface, in_signature='', out_signature='') - def Pause(self): - mpd_wrapper.pause(1) - mpd_wrapper.notify_about_state('pause') - return - - @dbus.service.method(__player_interface, in_signature='', out_signature='') - def PlayPause(self): - status = mpd_wrapper.status() - if status['state'] == 'play': - mpd_wrapper.pause(1) - mpd_wrapper.notify_about_state('pause') - else: - mpd_wrapper.play() - mpd_wrapper.notify_about_state('play') - return - - @dbus.service.method(__player_interface, in_signature='', out_signature='') - def Stop(self): - mpd_wrapper.stop() - mpd_wrapper.notify_about_state('stop') - return - - @dbus.service.method(__player_interface, in_signature='', out_signature='') - def Play(self): - mpd_wrapper.play() - mpd_wrapper.notify_about_state('play') - return - - @dbus.service.method(__player_interface, in_signature='x', out_signature='') - def Seek(self, offset): - status = mpd_wrapper.status() - current, end = status['time'].split(':') - current = int(current) - end = int(end) - offset = int(offset) / 1000000 - if current + offset <= end: - position = current + offset - if position < 0: - position = 0 - mpd_wrapper.seekid(int(status['songid']), position) - self.Seeked(position * 1000000) - return - - @dbus.service.method(__player_interface, in_signature='ox', out_signature='') - def SetPosition(self, trackid, position): - song = mpd_wrapper.last_currentsong() - if not song: - logger.error("Failed to retrieve song position, can't seek") - return() - # FIXME: use real dbus objects - if str(trackid) != '/org/mpris/MediaPlayer2/Track/%s' % song['id']: - return - # Convert position to seconds - position = int(position) / 1000000 - if position <= int(song['time']): - mpd_wrapper.seekid(int(song['id']), position) - self.Seeked(position * 1000000) - return - - @dbus.service.signal(__player_interface, signature='x') - def Seeked(self, position): - logger.debug("Seeked to %i" % position) - return float(position) - - @dbus.service.method(__player_interface, in_signature='', out_signature='') - def OpenUri(self): - # TODO - return - -def each_xdg_config(suffix): - """ - Return each location matching XDG_CONFIG_DIRS/suffix in descending - priority order. - """ - config_home = os.environ.get('XDG_CONFIG_HOME', - os.path.expanduser('~/.config')) - config_dirs = os.environ.get('XDG_CONFIG_DIRS', '/etc/xdg').split(':') - return ([os.path.join(config_home, suffix)] + - [os.path.join(d, suffix) for d in config_dirs]) - - -def open_first_xdg_config(suffix): - """ - Try to open each location matching XDG_CONFIG_DIRS/suffix as a file. - Return the first that can be opened successfully, or None. - """ - for filename in each_xdg_config(suffix): - try: - f = open(filename, 'r') - except IOError: - pass - else: - return f - else: - return None - - -def find_music_dir(): - if 'XDG_MUSIC_DIR' in os.environ: - return os.environ['XDG_MUSIC_DIR'] - - conf = open_first_xdg_config('user-dirs.dirs') - if conf is not None: - for line in conf: - if not line.startswith('XDG_MUSIC_DIR='): - continue - # use shlex to handle "shell escaping" - path = shlex.split(line[14:])[0] - if path.startswith('$HOME/'): - return os.path.expanduser('~' + path[5:]) - elif path.startswith('/'): - return path - else: - # other forms are not supported - break - - paths = '~/Music', '~/music' - for path in map(os.path.expanduser, paths): - if os.path.isdir(path): - return path - - return None - - -def usage(params): - print("""\ -Usage: %(progname)s [OPTION]... - - -c, --config=PATH Read a custom configuration file - - -h, --host=ADDR Set the mpd server address - --port=PORT Set the TCP port - --music-dir=PATH Set the music library path - - -d, --debug Run in debug mode - -j, --use-journal Log to systemd journal instead of stderr - -v, --version mpDris2 version - -Environment variables MPD_HOST and MPD_PORT can be used. - -Report bugs to https://github.com/eonpatapon/mpDris2/issues""" % params) - -if __name__ == '__main__': - DBusGMainLoop(set_as_default=True) - - gettext.bindtextdomain('mpDris2', '@datadir@/locale') - gettext.textdomain('mpDris2') - - log_format_stderr = '%(asctime)s %(module)s %(levelname)s: %(message)s' - - log_journal = False - log_level = logging.INFO - config_file = None - music_dir = None - - # Parse command line - try: - (opts, args) = getopt.getopt(sys.argv[1:], 'c:dh:jp:v', - ['help', 'bus-name=', 'config=', - 'debug', 'host=', 'music-dir=', - 'use-journal', 'path=', 'port=', - 'no-reconnect', - 'version']) - except getopt.GetoptError as ex: - (msg, opt) = ex.args - print("%s: %s" % (sys.argv[0], msg), file=sys.stderr) - print(file=sys.stderr) - usage(params) - sys.exit(2) - - for (opt, arg) in opts: - if opt in ['--help']: - usage(params) - sys.exit() - elif opt in ['--bus-name']: - params['bus_name'] = arg - elif opt in ['-c', '--config']: - config_file = arg - elif opt in ['-d', '--debug']: - log_level = logging.DEBUG - elif opt in ['-h', '--host']: - params['host'] = arg - elif opt in ['-j', '--use-journal']: - log_journal = True - elif opt in ['-p', '--path', '--music-dir']: - music_dir = arg - elif opt in ['--port']: - params['port'] = int(arg) - elif opt in ['--no-reconnect']: - params['reconnect'] = False - elif opt in ['-v', '--version']: - v = __version__ - if __git_version__: - v = __git_version__ - print("mpDris2 version %s" % v) - sys.exit() - - if len(args) > 2: - usage(params) - sys.exit() - - logger = logging.getLogger('mpDris2') - logger.propagate = False - logger.setLevel(log_level) - - # Attempt to configure systemd journal logging, if enabled - if log_journal: - try: - from systemd.journal import JournalHandler - log_handler = JournalHandler(SYSLOG_IDENTIFIER='mpDris2') - except ImportError: - log_journal = False - - # Log to stderr if journal logging was not enabled, or if setup failed - if not log_journal: - log_handler = logging.StreamHandler() - log_handler.setFormatter(logging.Formatter(log_format_stderr)) - - logger.addHandler(log_handler) - - # Pick up the server address (argv -> environment -> config) - for arg in args[:2]: - if arg.isdigit(): - params['port'] = arg - else: - params['host'] = arg - - if not params['host']: - if 'MPD_HOST' in os.environ: - params['host'] = os.environ['MPD_HOST'] - if not params['port']: - if 'MPD_PORT' in os.environ: - params['port'] = os.environ['MPD_PORT'] - - # Read configuration - config = ConfigParser() - if config_file: - with open(config_file) as fh: - config.read(config_file) - else: - config.read(['/etc/mpDris2.conf'] + - list(reversed(each_xdg_config('mpDris2/mpDris2.conf')))) - - for p in ['host', 'port', 'password', 'bus_name']: - if not params[p]: - # TODO: switch to get(fallback=…) when possible - if config.has_option('Connection', p): - params[p] = config.get('Connection', p) - else: - params[p] = defaults[p] - - if '@' in params['host']: - params['password'], params['host'] = params['host'].rsplit('@', 1) - - params['host'] = os.path.expanduser(params['host']) - - for p in ["mmkeys", "notify", "notify_paused", "cdprev"]: - if config.has_option("Bling", p): - params[p] = config.getboolean("Bling", p) - - if config.has_option("Notify", "summary"): - params["summary"] = config.get("Notify", "summary", raw=True) - - if config.has_option("Notify", "body"): - params["body"] = config.get("Notify", "body", raw=True) - - if config.has_option("Notify", "paused_summary"): - params["paused_summary"] = config.get("Notify", "paused_summary", raw=True) - - if config.has_option("Notify", "paused_body"): - params["paused_body"] = config.get("Notify", "paused_body", raw=True) - - if config.has_option("Notify", "timeout"): - params["timeout"] = config.getint("Notify", "timeout") - - if config.has_option("Notify", "urgency"): - params["urgency"] = config.getint("Notify", "urgency") - elif config.has_option("Bling", "notify_urgency"): - params["urgency"] = config.getint("Bling", "notify_urgency") - logger.warning("Use of 'notify_urgency' is deprecated. Please use 'urgency' under the 'Notify' section.") - - if not music_dir: - if config.has_option('Library', 'music_dir'): - music_dir = config.get('Library', 'music_dir') - elif config.has_option('Connection', 'music_dir'): - music_dir = config.get('Connection', 'music_dir') - else: - music_dir = find_music_dir() - - if music_dir: - # Ensure that music_dir starts with an URL scheme. - if not re.match('^[0-9A-Za-z+.-]+://', music_dir): - music_dir = 'file://' + music_dir - if music_dir.startswith('file://'): - music_dir = music_dir[:7] + os.path.expanduser(music_dir[7:]) - if not os.path.exists(music_dir[7:]): - logger.error('Music library path %s does not exist!' % music_dir) - # Non-local URLs can still be useful to MPRIS clients, so accept them. - params['music_dir'] = music_dir - logger.info('Using %s as music library path.' % music_dir) - else: - logger.warning('By not supplying a path for the music library ' - 'this program will break the MPRIS specification!') - - if config.has_option('Library', 'cover_regex'): - cover_regex = config.get('Library', 'cover_regex') - else: - cover_regex = defaults['cover_regex'] - params['cover_regex'] = re.compile(cover_regex, re.I | re.X) - - logger.debug('Parameters: %r' % params) - - if mutagen: - logger.info('Using Mutagen to read covers from music files.') - else: - logger.info('Mutagen not available, covers in music files will be ignored.') - - # Set up the main loop - loop = GLib.MainLoop() - - # Wrapper to send notifications - notification = NotifyWrapper(params) - - # Create wrapper to handle connection failures with MPD more gracefully - mpd_wrapper = MPDWrapper(params) - mpd_wrapper.run() - - # Run idle loop - try: - loop.run() - except KeyboardInterrupt: - logger.debug('Caught SIGINT, exiting.') - - # Clean up - if mpd_wrapper.connected: - try: - mpd_wrapper.client.close() - mpd_wrapper.client.disconnect() - logger.debug('Exiting') - except mpd.ConnectionError: - logger.error('Failed to disconnect properly') diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..ffa8b90 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,15 @@ +"""Pytest setup: force the C locale so tests assert against the +untranslated msgids regardless of the developer's ``$LANG``. + +``mpdris2.cli`` binds the ``mpdris2`` textdomain at import time, which +``test_cli.py`` triggers transitively for the rest of the suite. Without +this guard, every ``_("Unknown title")`` in ``bridge.py`` / +``notify.py`` would resolve to the locale's translation and break the +English-string assertions. +""" + +from __future__ import annotations + +import os + +os.environ["LANGUAGE"] = "C" From 00690e79ee46cbdf33626371d35336db33ed9550 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathieu=20R=C3=A9quillart?= Date: Sat, 16 May 2026 16:18:57 +0200 Subject: [PATCH 8/9] Refresh README for the asyncio rewrite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Install: replace autogen/make with the apt.odio.love repository (primary) and a pipx/git fallback; drop the obsolete /usr/local prefix reference and the mutagen requirement (the asyncio package depends only on python-mpd2 and dbus-fast). - Configuration: document every ported key — [Library] cover_regex / cover_cache_dir, [Bling] notify / notify_paused / cdprev, [Notify] urgency / timeout / summary / body / paused_summary / paused_body — with the full %placeholder% set in a comment. - Note that [Bling] mmkeys is intentionally not ported: modern desktops (GNOME, KDE, sway) consume MPRIS directly for multimedia-key handling. - Add a short Architecture section pointing at docs/refactor-asyncio-dbus-fast.md. - Keep the Cover art resolution pipeline table introduced earlier. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 173 +++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 133 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index ccc325b..5a73d37 100644 --- a/README.md +++ b/README.md @@ -4,59 +4,152 @@ mpDris2 provide MPRIS 2 support to mpd (Music Player Daemon). mpDris2 is run in the user session and monitors a local or distant mpd server. -# Installation +# Install -## Stable release +From the Odio APT repository: -Download the latest release at https://github.com/eonpatapon/mpDris2/releases +```sh +curl -fsSL https://apt.odio.love/key.gpg | sudo gpg --dearmor -o /usr/share/keyrings/odio.gpg +echo "deb [signed-by=/usr/share/keyrings/odio.gpg] https://apt.odio.love stable main" \ + | sudo tee /etc/apt/sources.list.d/odio.list +sudo apt update +sudo apt install mpdris2 +``` - tar zvxf mpDris2-X.X.tar.gz - cd mpDris2-X.X - ./autogen.sh --sysconfdir=/etc - make install (as root) +The shipped systemd user unit is `Type=dbus` and a matching D-Bus +service file is installed, so mpDris2 auto-starts on the first MPRIS +call (`playerctl`, a media key, a desktop applet, …). Enable it +explicitly only if you want it running before any client asks: + +```sh +systemctl --user enable --now mpDris2.service +``` ## From git - git clone git://github.com/eonpatapon/mpDris2.git - cd mpDris2 - ./autogen.sh --sysconfdir=/etc - make install (as root) +```sh +git clone https://github.com/b0bbywan/mpDris2.git +cd mpDris2 +pipx install . # or: pip install --user . +``` -Logout/login from your session. -Default prefix is ``/usr/local``. +This installs the `mpDris2` console script into your `$PATH`. Start it +from your desktop's autostart, or via a `systemctl --user` unit. -# Configuration +Tagged releases on GitHub also publish an sdist tarball +(`mpdris2-X.Y.Z.tar.gz`) next to the `.deb`, installable with +`pipx install ./mpdris2-X.Y.Z.tar.gz`. -By default, mpDris2 will try to connect to localhost:6600. +Runtime dependencies: `python-mpd2` and `dbus-fast`. Python 3.11+. + +# Configuration -To set a different host or port copy the example configuration file -``/usr/[local]/share/doc/mpdris2/mpDris2.conf`` to ``~/.config/mpDris2/mpDris2.conf``. +By default mpDris2 connects to `localhost:6600`. Environment variables +`$MPD_HOST` and `$MPD_PORT` are honoured. To change anything else, copy +the example file shipped at `/usr/share/doc/mpdris2/mpDris2.conf` to +`~/.config/mpDris2/mpDris2.conf` and edit. -Use the configuration to enable notifications and multimedia keys support (on -the GNOME desktop). +Cover-art resolution needs `music_dir` to be set (or auto-detected over +a local Unix socket connection to MPD). See [Cover art](#cover-art) +below for the full pipeline. -You need also to set the ``music_dir`` option and have the Python ``mutagen`` -module installed for mpDris2 to export covers paths in the MPRIS metadata. +Restart mpDris2 (`pkill -HUP mpDris2`, or just restart your session) to +pick up config changes. -Restart your session or mpDris2 after changing mpDris2.conf. +> **Note:** the `[Bling] mmkeys` option from the historical mpDris2 is +> no longer supported. Modern desktops (GNOME, KDE, sway with +> `mpris-ctrl`, …) read MPRIS directly for multimedia-key handling, so +> mpDris2 doesn't need to grab the keys itself anymore. ## Sample configuration - [Connection] - host = 192.168.1.5 - port = 6600 - music_dir = /media/music/ - - [Bling] - notify = False - notify_paused = True - mmkeys = True - cdprev = True - - [Notify] - urgency = 0 - timeout = -1 - summary = - body = - paused_summary = - paused_body = +```ini +[Connection] +# Override host / port (or set $MPD_HOST / $MPD_PORT in the environment). +host = 192.168.1.5 +port = 6600 +password = + +[Library] +# Required for cover-art resolution when MPD is remote (auto-detected +# over a local Unix socket connection). +music_dir = /media/music/ +# Override the default cover-file regex; useful for non-standard names. +#cover_regex = ^(album|cover|\.?folder|front).*\.(gif|jpe?g|png|webp|bmp)$ +# Where the downloaded-covers cache lives (defaults to $XDG_CACHE_HOME/mpDris2/). +#cover_cache_dir = + +[Bling] +# Send desktop notifications on track change. +notify = True +# Also notify when the player is paused (default: only when playing). +notify_paused = False +# CD-like Previous: if elapsed >= 3 s, restart the current track instead +# of jumping to the previous one. +cdprev = False + +[Notify] +# Urgency: 0 low, 1 normal, 2 critical. +urgency = 1 +# Bubble lifetime in ms — -1 lets the notification server decide. +timeout = -1 +# Templates for the bubble. Empty = built-in default. +# Placeholders: %album% %title% %id% %time% %timeposition% %date% %track% +# %disc% %artist% %albumartist% %composer% %genre% %file% +summary = +body = +paused_summary = +paused_body = +``` + +With `notify = True`, mpDris2 also raises a brief bubble when playback +stops, and when the MPD connection drops or comes back. + +# Architecture + +mpDris2 is an asyncio + dbus-fast rewrite of the original PyGObject / +dbus-python implementation: a single asyncio event loop replaces the +GLib MainLoop + thread pool, `dbus-fast` replaces `dbus-python`, and +`mpd.asyncio` from `python-mpd2` replaces the blocking client. + +# Development + +A top-level `Makefile` wraps the day-to-day commands so local dev and +CI stay in sync (the GitHub workflow calls the same targets): + +```sh +make lint # ruff + mypy +make test # pytest +make build # python -m build (sdist + wheel) +make deb # dpkg-buildpackage -b -us -uc (Debian toolchain) +make clean # drop build/, dist/, *.egg-info +make version # print the Python version (from __init__.py) +make sync-deb # bump debian/changelog to match __init__.py +make i18n-extract # refresh po/mpdris2.pot from source +make i18n-compile # compile po/*.po into the runtime locale tree +``` + +`mpdris2/__init__.py` is the single source of truth for the version; +`make sync-deb` and `make check-tag TAG=…` keep `debian/changelog` +and the git tag aligned with it. + +# Build a .deb + +Build-deps (per `debian/control`): `debhelper-compat (= 13)`, +`dh-python`, `python3`, `python3-setuptools`. Then `make deb` on +Debian trixie or a derivative produces the `.deb`. The runtime deps +(`python3-mpd`, `python3-dbus-fast`) are resolved by APT at install +time, not at build time. + +# Cover art + +mpDris2 resolves `mpris:artUrl` through a fixed pipeline. The first +step that yields a usable image wins; later steps are skipped. + +| # | Step | Source | Exposed `mpris:artUrl` | Wins when… | +|---|------|--------|------------------------|-----------| +| 1 | MPD `readpicture` | Embedded picture in the audio file (FLAC `PICTURE`, ID3 `APIC`, …) | `file:///tmp/cover-*.{jpg,png,…}` | The track carries embedded art | +| 2 | FS regex scan | `cover_regex` match in the song's directory (default matches `cover.*`, `folder.*`, `album.*`, `front.*`) | `file://` URI of the matched file (RFC-3986 percent-encoded) | A non-standardly-named cover sits next to the audio file (local FS only) | +| 3 | MPD `albumart` | `cover.{png,jpg,jxl,webp}` in the song's directory (resolved server-side by MPD) | `file:///tmp/cover-*.{jpg,png,…}` | Remote MPD, or step 2 missed (standard name only) | +| 4 | CUE/cdda fallback | `cover_regex` match next to the loaded `.cue` playlist (FS scan), falling back to MPD `albumart` (which server-side resolves `cover.{png,jpg,jxl,webp}`) when music_dir isn't locally accessible | `file://` URI of the matched file (local FS) or `file:///tmp/cover-*` (remote MPD) | The song is a CUE virtual track (cdda://, http://, …) and the CUE's own directory holds a cover | +| 5 | XDG cover cache | `$XDG_CACHE_HOME/mpDris2/{artist}-{album}.{jpg,png,…}` | `file://` URI of the cached file | Earlier steps failed and a previous run (or the optional MusicBrainz fallback) populated the cache | From 791de7ee52ede41334945dcc158dfa58caaa08fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathieu=20R=C3=A9quillart?= Date: Tue, 19 May 2026 18:35:34 +0200 Subject: [PATCH 9/9] bump version --- mpdris2/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mpdris2/__init__.py b/mpdris2/__init__.py index a7b87e5..61fb31c 100644 --- a/mpdris2/__init__.py +++ b/mpdris2/__init__.py @@ -1 +1 @@ -__version__ = "0.10.0b1" +__version__ = "0.10.0"