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 17f74e6..91038b2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,30 +1,19 @@ -Makefile -Makefile.in -Makefile.in.in -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 +*.pyo +build/ +dist/ +*.egg-info/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +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 @@ -34,3 +23,8 @@ debian/tmp/ *.deb *.buildinfo *.changes + +# Python virtualenv (local dev) +.venv/ +__pycache__/ +*.pyc 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 new file mode 100644 index 0000000..44a299a --- /dev/null +++ b/Makefile @@ -0,0 +1,82 @@ +# 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 \ + --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: + @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/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/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 | 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 new file mode 100644 index 0000000..efceab8 --- /dev/null +++ b/babel.cfg @@ -0,0 +1 @@ +[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/mpdris2/__init__.py b/mpdris2/__init__.py new file mode 100644 index 0000000..61fb31c --- /dev/null +++ b/mpdris2/__init__.py @@ -0,0 +1 @@ +__version__ = "0.10.0" 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/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 new file mode 100644 index 0000000..cde98f5 --- /dev/null +++ b/mpdris2/cli.py @@ -0,0 +1,279 @@ +"""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 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 = [ + Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config") + / "mpDris2" / "mpDris2.conf", + Path("/etc/mpDris2/mpDris2.conf"), +] + + +class ConfigError(Exception): + """Raised when the daemon can't start because of invalid / missing config.""" + + +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: + ``[Connection]`` / ``[Library]`` / ``[Bling]`` / ``[Notify]``. + Missing file is not an error — defaults apply. + """ + cfg = configparser.ConfigParser() + paths: list[Path] = [Path(path)] if path else CONFIG_PATHS + for p in paths: + if p.exists(): + cfg.read(p) + logger.info("read %s", p) + return cfg + logger.info("no config file found, using defaults") + 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", + 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) + + 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() + + asyncio.run(_amain()) + + +if __name__ == "__main__": + main() 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/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/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/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/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 new file mode 100644 index 0000000..47be686 --- /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 = [ + # <3.2: mpd_client.fetch_config leans on private attrs. + "python-mpd2>=3.1,<3.2", + "dbus-fast>=2.0", +] +dynamic = ["version"] + +[project.optional-dependencies] +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) 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/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/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 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" 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 new file mode 100644 index 0000000..530683e --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,222 @@ +"""Argparse + config-loading tests. No D-Bus, no MPD, no event loop.""" + +from __future__ import annotations + +import argparse +import configparser +import os +from pathlib import Path + +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: + 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" + + +# --- 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 + # 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) + + +# --- _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 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 + + 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] 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" 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" + )