Development¶
Prerequisites¶
- Python 3.13
uv(https://docs.astral.sh/uv/) for dependency managementffmpegandffprobeon$PATH(for the convert pipeline + transcoding)
Setup¶
uv sync creates the .venv, installs runtime deps (FastAPI, PyAV, sounddevice, mutagen, Pillow, pyatv, zeroconf, watchdog, ...) and dev deps (ruff, mypy, pyright, pytest, mkdocs).
Common commands¶
make lint # ruff format + check + mypy + pyright
make test # pytest -q
make coverage # pytest with coverage report
make docs-serve # mkdocs live-reload at http://127.0.0.1:8000
make docs-build # build static site to ./site
make app # build the client -> desktop/app/dist/
make app-dev # serve the client with hot reload
make app-gate # its format, lint, types and unit tests
Both make lint and make test must pass before commit, and make app-gate when the client changed.
The client¶
desktop/app is the client: the one maneki serve serves at / and the one the Tauri and Electron shells load. It is on the shared UI template this family of apps uses (TypeScript, Tailwind, shadcn on Base UI, oxlint and oxfmt, vitest): see dirigent's docs/ui-conventions.md, which is the design system it is built to.
It is hash-routed with relative asset URLs, because the desktop shells load the bundle from disk.
Project layout¶
src/maneki/audio/
__init__.py __main__.py
cli/ typer entry; one file per subcommand
__init__.py app = typer.Typer(...) + side-effect imports
convert.py cover.py cover_pick.py inspect.py
library.py retag.py serve.py
_scan.py shared scan-progress wrapper
convert.py ffmpeg encode / remux / copy
cover.py cover-source candidates + pick_best + normalise
discover.py walk input → list[AlbumDir] (with multi-disc merge)
library/ Artist→Album→Track index of the converted output
__init__.py models.py scan.py audit.py fix.py
metadata/ tag read / write
__init__.py models.py album.py read.py write.py overrides.py
naming.py filesystem-safe folder + filename builders
pipeline/ orchestrator — discover → cover → convert → tag → swap
__init__.py run.py album.py track.py report.py progress.py
filenames.py disc.py dedupe.py footprint.py acoustid.py
radio.py curated radio-station list (NRK defaults + user TOML merge)
serve/ Subsonic-compatible HTTP server
__init__.py app.py auth.py config.py
ids.py index.py payloads.py covers.py xml.py
discovery.py watcher.py
endpoints/ __init__.py system.py browsing.py media.py
search.py scan.py extras.py
enrich/ __init__.py _http.py musicbrainz.py
coverart.py musichoarders.py acoustid.py
tests/ pytest suite
docs/ this site
pyproject.toml Makefile mkdocs.yml
input/.gitkeep output/.gitkeep
The major modules went through a 7-wave refactor early on — every package above (cli/, library/, metadata/, pipeline/, serve/) used to be a single file. Each split is committed separately; check git log --oneline | grep "Refactor" for the history.
Code style¶
- Python 3.13,
from __future__ import annotationseverywhere, explicit type annotations. - Line length 120. ruff-formatted.
- Docstrings: one-line module docstring at the top of every file. One-line docstring on every public class / function / method. Triple quotes always.
- Pydantic for data classes —
BaseModelalways, never@dataclass. For an immutable,model_config = ConfigDict(frozen=True). - Async/await where the framework demands it (FastAPI endpoints, the video scan and watcher); synchronous everywhere else.
- No emojis in code, comments, commit messages, PR titles, docs. Plain text only —
[x]not✓,WARNING:not warning glyph. Codified inCLAUDE.md.
Ruff config (in pyproject.toml): E/W/F/I/D, google docstrings, py313, line-length 120.
Mypy: strict-ish. Pyright: strict with the same report* softeners ruff has.
Testing¶
make test # full suite, quiet
uv run pytest -xvs tests/test_specific.py # one file, verbose
uv run pytest -k "test_name_substring" # match by name
Test patterns:
- Convert pipeline:
silent_flac_templatefixture inconftest.pyproduces a 0.2s silent FLAC via ffmpeg; tests build synthetic libraries from copies. - Library / metadata:
_make_trackhelper writes mutagen tags onto a copy of the silent flac. - Serve API: synthetic in-memory
LibraryIndexinjected viaapp.state.cache._reindex(...)— no disk walk. FastAPITestClientfor HTTP round-trips. - Video HLS: pure functions (
plan_segments,build_manifest) covered; subprocess-spawning code paths smoke-tested withmonkeypatchagainst fakesubprocess.run. - mDNS: real Zeroconf register/unregister smoke (skips on environments without IPv4 multicast).
Coverage runs via make coverage; CI thresholds set in pyproject.toml.
Adding a new subcommand¶
Top-level commands live on the root app Typer instance; audio-library ones live on the library_app subapp in audio/cli/ (so the user types maneki audio library <verb>).
- Create
src/maneki/audio/cli/<name>.py:# Top-level (e.g. another sibling of convert / inspect / serve): from maneki.audio.cli import app @app.command(name="my-cmd") def my_cmd(...): """One-line docstring.""" ... # Library subcommand (e.g. another sibling of cover / retag / cover-pick): from maneki.audio.cli.library import library_app @library_app.command(name="my-cmd") def my_cmd(...): """One-line docstring.""" ... - Add a side-effect import in
cli/__init__.py.libraryMUST be imported before any module that registers onlibrary_app:
Typer handles the rest — maneki my-cmd --help (or maneki audio library my-cmd --help) Just Works.
Adding a new Subsonic endpoint¶
- Pick the right router in
src/maneki/audio/serve/endpoints/(browsing, search, media, scan, system, extras). - Add the endpoint:
- Add a test in
tests/test_serve_<group>.py:
The _params(...) helper auto-includes f=json. The middleware re-serialises to XML when f != "json" — endpoints just return dicts.
Commit style¶
Conventional commits: feat:, fix:, chore:, docs:, test:, refactor:. Don't include AI attribution in commit messages.
The repo's commit history is reasonably granular — each "Refactor X (N lines)" commit, each Subsonic-API phase, each fix from a code review.